claude-code-session-manager 0.94.0 → 0.96.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/{DataModel-B0LDnnSL.js → DataModel-T0D00BpK.js} +1 -1
- package/dist/assets/History-DuAovTr7.js +2 -0
- package/dist/assets/{Hooks-CwLnp0Z_.js → Hooks-CgP9jsAN.js} +3 -3
- package/dist/assets/{HostBilko-CwEHEKYk.js → HostBilko-DHfz3Mxa.js} +1 -1
- package/dist/assets/{Library-BU9np05E.js → Library--poVP-d1.js} +1 -1
- package/dist/assets/{MarkdownEditor-B74ogK2f.js → MarkdownEditor-C56-Pc4c.js} +1 -1
- package/dist/assets/{McpServers-BLM3ftzP.js → McpServers-D-ykP4Vi.js} +2 -2
- package/dist/assets/Memory-BrkJjRL8.js +8 -0
- package/dist/assets/{Permissions-3pGLJmxQ.js → Permissions-BMpAaxJu.js} +3 -3
- package/dist/assets/Plugins-OuEe5S_T.js +2 -0
- package/dist/assets/{ProvenanceBadge-BnjBQiV9.js → ProvenanceBadge-4eNbDYe8.js} +1 -1
- package/dist/assets/{SaveBar-Zq2NfcPf.js → SaveBar-BQ8y9Lkv.js} +1 -1
- package/dist/assets/Scheduler-DcMT5sT_.js +16 -0
- package/dist/assets/{ScopeSwitcher-Cnojv22D.js → ScopeSwitcher-XdxtVKP-.js} +1 -1
- package/dist/assets/Settings-FdaPUq2t.js +3 -0
- package/dist/assets/{SkillReferenceGraph-BRDiE6Hv.js → SkillReferenceGraph-CJzvvc0_.js} +1 -1
- package/dist/assets/Skills-Wvm9OE1e.js +3 -0
- package/dist/assets/{SystemPrompt-tFr19Od6.js → SystemPrompt--LYw_69j.js} +1 -1
- package/dist/assets/TagLibrary-Be6HbMMx.js +1 -0
- package/dist/assets/{TiptapBody-DGzSg3BO.js → TiptapBody-DzFlWMgO.js} +1 -1
- package/dist/assets/{Toggle-DzoROibf.js → Toggle-BxtAFE1i.js} +1 -1
- package/dist/assets/index-XkrLB67D.js +3079 -0
- package/dist/assets/{settingsSchema-BYKVe1WM.js → settingsSchema-BIvDZ1Cj.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +10 -2
- package/plugins/CLAUDE.md +3 -3
- package/plugins/session-manager-dev/skills/develop/SKILL.md +83 -67
- package/plugins/session-manager-dev/skills/develop/standards.md +5 -11
- package/plugins/session-manager-dev/skills/requesting-code-review/SKILL.md +5 -15
- package/scripts/README.md +4 -0
- package/scripts/hooks/guard-destructive-git.cjs +9 -374
- package/scripts/hooks/guard-inline-implementation.cjs +9 -128
- package/scripts/hooks/guard-prd-writes.cjs +9 -74
- package/scripts/hooks/guard-self-schedule.cjs +9 -61
- package/scripts/hooks/lib/guard-destructive-git-policy.cjs +410 -0
- package/scripts/hooks/lib/guard-inline-implementation-policy.cjs +159 -0
- package/scripts/hooks/lib/guard-prd-writes-policy.cjs +100 -0
- package/scripts/hooks/lib/guard-self-schedule-policy.cjs +89 -0
- package/scripts/scheduler-mcp-server.cjs +8 -1
- package/src/main/__tests__/configWriteBoundaryOwners.test.cjs +59 -0
- package/src/main/__tests__/exchanges.test.cjs +41 -96
- package/src/main/__tests__/exchangesPromptId.test.cjs +16 -25
- package/src/main/__tests__/files-reject-credentials.test.cjs +1 -1
- package/src/main/__tests__/health-credentials.test.cjs +81 -0
- package/src/main/__tests__/health-tick-liveness.test.cjs +12 -0
- package/src/main/__tests__/memoryAggregate.test.cjs +40 -1
- package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +1 -1
- package/src/main/__tests__/planValidator.test.cjs +71 -0
- package/src/main/__tests__/prdCreate.test.cjs +3 -1
- package/src/main/__tests__/prdSizing.test.cjs +106 -0
- package/src/main/__tests__/queue-starvation-dispatch-driver.test.cjs +46 -0
- package/src/main/__tests__/reconcileFlatPrdSweep.test.cjs +10 -0
- package/src/main/__tests__/scheduler-looks-done.test.cjs +40 -0
- package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +81 -0
- package/src/main/__tests__/scheduler-rate-limit-pause.test.cjs +15 -0
- package/src/main/__tests__/scheduler-shard-quarantine.test.cjs +6 -1
- package/src/main/__tests__/scheduler-utilization-hold.test.cjs +89 -0
- package/src/main/__tests__/schedulerStateSidecarRestore.test.cjs +110 -0
- package/src/main/__tests__/seedAgentPersonas.test.cjs +19 -1
- package/src/main/__tests__/seedValidatorPersona.test.cjs +116 -0
- package/src/main/__tests__/telemetryClient.test.cjs +2 -2
- package/src/main/__tests__/telemetryContract.test.cjs +2 -2
- package/src/main/__tests__/usageSingleFlight.test.cjs +10 -0
- package/src/main/__tests__/validationSentinels.test.cjs +84 -0
- package/src/main/build-info.json +4 -4
- package/src/main/config.cjs +26 -14
- package/src/main/crashDiagnostics.cjs +9 -0
- package/src/main/exchanges.cjs +26 -21
- package/src/main/health.cjs +91 -5
- package/src/main/index.cjs +1 -1
- package/src/main/ipcSchemas.cjs +5 -1
- package/src/main/lib/__tests__/crashTelemetry.test.cjs +1 -1
- package/src/main/lib/__tests__/credentials-futile-refresh.test.cjs +115 -0
- package/src/main/lib/__tests__/epicWorktreeProjectConfig.test.cjs +85 -27
- package/src/main/lib/__tests__/gitWorktree.test.cjs +21 -27
- package/src/main/lib/__tests__/guardShims.test.cjs +10 -3
- package/src/main/lib/__tests__/loadGate.test.cjs +44 -1
- package/src/main/lib/__tests__/loopDelay.test.cjs +68 -0
- package/src/main/lib/__tests__/opsOwnership.test.cjs +14 -0
- package/src/main/lib/__tests__/telemetryBacklog.test.cjs +4 -4
- package/src/main/lib/__tests__/telemetryConsent.test.cjs +2 -2
- package/src/main/lib/__tests__/telemetryCountersMetadataColumn.test.cjs +1 -1
- package/src/main/lib/__tests__/usageCircuit.test.cjs +86 -17
- package/src/main/lib/agentModelResolve.cjs +1 -0
- package/src/main/lib/credentials.cjs +62 -0
- package/src/main/lib/cwdClassify.cjs +15 -0
- package/src/main/lib/definitionOfDone.cjs +9 -13
- package/src/main/lib/effectiveModelInfo.cjs +2 -9
- package/src/main/lib/epicWorktreeProjectConfig.cjs +75 -31
- package/src/main/lib/loadGate.cjs +29 -5
- package/src/main/lib/loopDelay.cjs +44 -0
- package/src/main/lib/opsOwnership.cjs +23 -0
- package/src/main/lib/planValidator.cjs +28 -0
- package/src/main/lib/prdCreate.cjs +8 -4
- package/src/main/lib/prdSizing.cjs +83 -0
- package/src/main/lib/runLogRetention.cjs +2 -8
- package/src/main/lib/telemetryBacklog.cjs +9 -3
- package/src/main/lib/telemetryClient.cjs +12 -3
- package/src/main/lib/telemetrySettings.cjs +8 -2
- package/src/main/lib/usageCircuit.cjs +40 -12
- package/src/main/lib/validationSentinels.cjs +29 -0
- package/src/main/memoryAggregate.cjs +36 -11
- package/src/main/otelSettings.cjs +8 -2
- package/src/main/scheduler.cjs +264 -34
- package/src/main/seedAgentPersonas.cjs +1 -1
- package/src/main/sessionsStore.cjs +8 -2
- package/src/main/supervisor.cjs +3 -3
- package/src/main/templates/PRD_AUTHORING.md +9 -9
- package/src/main/usage.cjs +9 -1
- package/src/main/voiceSettings.cjs +8 -2
- package/src/preload/api.d.ts +20 -5
- package/src/preload/index.cjs +5 -5
- package/src/seed/agents/dev-lead.md +24 -15
- package/src/seed/agents/validator.md +33 -0
- package/dist/assets/History-C685Kytt.js +0 -2
- package/dist/assets/Memory-DAP1t8bA.js +0 -8
- package/dist/assets/Plugins-Q1KoLEzn.js +0 -2
- package/dist/assets/Scheduler-D488ebKm.js +0 -16
- package/dist/assets/Settings-D5Lhj7ga.js +0 -3
- package/dist/assets/Skills-BQDqp-EN.js +0 -3
- package/dist/assets/TagLibrary-BjyrnaRE.js +0 -1
- package/dist/assets/index-CCl4tz-u.js +0 -3079
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* loopDelay.test.cjs — event-loop delay probe used by crashDiagnostics's heartbeat.
|
|
3
|
+
*
|
|
4
|
+
* Run: timeout 120 npx vitest run src/main/lib/__tests__/loopDelay.test.cjs
|
|
5
|
+
*/
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
import { test, expect } from 'vitest';
|
|
9
|
+
|
|
10
|
+
const { createLoopDelayMonitor, stallVerdict, LOOP_STALL_WARN_MS } = require('../loopDelay.cjs');
|
|
11
|
+
|
|
12
|
+
// Histogram stub: values in ns, like perf_hooks.
|
|
13
|
+
function stubHistogram(state) {
|
|
14
|
+
return () => ({
|
|
15
|
+
enabled: false,
|
|
16
|
+
enable() { this.enabled = true; },
|
|
17
|
+
disable() { this.enabled = false; },
|
|
18
|
+
percentile(p) { return p === 50 ? state.p50 : state.p99; },
|
|
19
|
+
get max() { return state.max; },
|
|
20
|
+
reset() { state.resets += 1; state.p50 = 0; state.p99 = 0; state.max = 0; },
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test('normal beat: converts ns to ms (1 decimal) and does not flag a stall', () => {
|
|
25
|
+
const state = { p50: 21_040_000, p99: 34_960_000, max: 40_000_000, resets: 0 };
|
|
26
|
+
const m = createLoopDelayMonitor(stubHistogram(state));
|
|
27
|
+
m.enable();
|
|
28
|
+
const snap = m.snapshot();
|
|
29
|
+
expect(snap).toEqual({ loopDelayP50Ms: 21, loopDelayP99Ms: 35, loopDelayMaxMs: 40 });
|
|
30
|
+
expect(stallVerdict(snap)).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('stall beat: max above threshold yields warn "main loop stall"', () => {
|
|
34
|
+
const state = { p50: 20_000_000, p99: 300_000_000, max: 412_300_000, resets: 0 };
|
|
35
|
+
const snap = createLoopDelayMonitor(stubHistogram(state)).snapshot();
|
|
36
|
+
expect(snap.loopDelayMaxMs).toBe(412.3);
|
|
37
|
+
expect(stallVerdict(snap)).toEqual({ level: 'warn', message: 'main loop stall' });
|
|
38
|
+
expect(stallVerdict({ loopDelayMaxMs: LOOP_STALL_WARN_MS })).toBeNull(); // strictly greater
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('reset between beats: a stall does not bleed into the next interval', () => {
|
|
42
|
+
const state = { p50: 20_000_000, p99: 300_000_000, max: 500_000_000, resets: 0 };
|
|
43
|
+
const m = createLoopDelayMonitor(stubHistogram(state));
|
|
44
|
+
expect(stallVerdict(m.snapshot())).not.toBeNull();
|
|
45
|
+
expect(state.resets).toBe(1);
|
|
46
|
+
const second = m.snapshot();
|
|
47
|
+
expect(second.loopDelayMaxMs).toBe(0);
|
|
48
|
+
expect(stallVerdict(second)).toBeNull();
|
|
49
|
+
expect(state.resets).toBe(2);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('passes a 20 ms resolution to the histogram factory and disable() stops it', () => {
|
|
53
|
+
let opts; let h;
|
|
54
|
+
const m = createLoopDelayMonitor((o) => { opts = o; h = stubHistogram({ resets: 0 })(); return h; });
|
|
55
|
+
m.enable();
|
|
56
|
+
expect(opts).toEqual({ resolution: 20 });
|
|
57
|
+
expect(h.enabled).toBe(true);
|
|
58
|
+
m.disable();
|
|
59
|
+
expect(h.enabled).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('real perf_hooks histogram produces finite numbers', () => {
|
|
63
|
+
const m = createLoopDelayMonitor();
|
|
64
|
+
m.enable();
|
|
65
|
+
const snap = m.snapshot();
|
|
66
|
+
m.disable();
|
|
67
|
+
for (const v of Object.values(snap)) expect(Number.isFinite(v)).toBe(true);
|
|
68
|
+
});
|
|
@@ -73,6 +73,20 @@ test('assertOpsWrite throws for a refused write and is silent for an allowed one
|
|
|
73
73
|
assert.doesNotThrow(() => assertOpsWrite(`${P}/project-brief/brief.json`, 'project-home'));
|
|
74
74
|
});
|
|
75
75
|
|
|
76
|
+
test('assertOpsWrite fails closed (not a raw crash) on a relative path that looks like it is inside an ops root', () => {
|
|
77
|
+
// A caller that skips opsPath/resolveProjectRoot (which already throw on a
|
|
78
|
+
// relative cwd) and builds its own path string by hand must still get a
|
|
79
|
+
// graceful, catchable refusal here — this is the last line of defense
|
|
80
|
+
// (PRD 1082). Before this guard, classifyCwd's absolute-path check (added
|
|
81
|
+
// for the worktree-relative-cwd hazard) left innermostOpsRoot null for a
|
|
82
|
+
// relative path, and path.dirname(null) threw an untagged TypeError
|
|
83
|
+
// instead of the intended `unknownCwd`-tagged Error.
|
|
84
|
+
assert.throws(
|
|
85
|
+
() => assertOpsWrite('session-manager-operations/scheduler/state/queue.json', 'scheduler'),
|
|
86
|
+
(e) => e.unknownCwd === true && /absolute/.test(e.message),
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
76
90
|
test('every declared owner is a non-empty string (table sanity)', () => {
|
|
77
91
|
for (const [ns, owner] of Object.entries(OWNERS)) {
|
|
78
92
|
assert.equal(typeof owner, 'string', `${ns} owner must be a string`);
|
|
@@ -56,7 +56,7 @@ function freshTelemetry(home) {
|
|
|
56
56
|
process.env.HOME = home;
|
|
57
57
|
// Explicit opt-in: overrides the test-environment no-op guard so this
|
|
58
58
|
// suite's real telemetryClient calls actually persist into an isolated dir.
|
|
59
|
-
process.env.SM_TELEMETRY_SPOOL = path.join(home, '.
|
|
59
|
+
process.env.SM_TELEMETRY_SPOOL = path.join(home, '.claude', 'session-manager');
|
|
60
60
|
for (const p of ['../telemetryClient.cjs', '../../config.cjs', '../telemetrySettings.cjs', '../machineProfile.cjs']) {
|
|
61
61
|
const resolved = require.resolve(p);
|
|
62
62
|
delete require.cache[resolved];
|
|
@@ -133,7 +133,7 @@ function makeDeps({ home, telemetryClient, config, tabCwds }) {
|
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
function watermarksFileFor(home) {
|
|
136
|
-
return path.join(home, '.
|
|
136
|
+
return path.join(home, '.claude', 'session-manager', 'telemetry-watermarks.json');
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
async function readWatermarks(home) {
|
|
@@ -569,7 +569,7 @@ test('bootDrain always drains with reason "boot", and drains a second time with
|
|
|
569
569
|
|
|
570
570
|
test('drainBacklog resolves (never rejects) when readdir fails, telemetryClient throws, and the watermarks file is corrupt', async () => {
|
|
571
571
|
const home = await mkHome();
|
|
572
|
-
await fsp.mkdir(path.join(home, '.
|
|
572
|
+
await fsp.mkdir(path.join(home, '.claude', 'session-manager'), { recursive: true });
|
|
573
573
|
await fsp.writeFile(watermarksFileFor(home), 'not valid { json', 'utf8');
|
|
574
574
|
const project = await mkProject('contained');
|
|
575
575
|
writeErrorsFile(project, recentDateStr(), [errLine(1)]);
|
|
@@ -584,7 +584,7 @@ test('drainBacklog resolves (never rejects) when readdir fails, telemetryClient
|
|
|
584
584
|
logLine: async () => { throw new Error('boom client'); },
|
|
585
585
|
flush: async () => { throw new Error('boom flush'); },
|
|
586
586
|
isPending: async () => { throw new Error('boom pending'); },
|
|
587
|
-
sentPath: () => path.join(home, '.
|
|
587
|
+
sentPath: () => path.join(home, '.claude', 'session-manager', 'telemetry-sent.json'),
|
|
588
588
|
};
|
|
589
589
|
const deps = {
|
|
590
590
|
sessionsStore: { load: async () => ({ tabs: [{ cwd: project }] }) },
|
|
@@ -40,7 +40,7 @@ function freshModules(home) {
|
|
|
40
40
|
process.env.HOME = home;
|
|
41
41
|
// Explicit opt-in: overrides the test-environment no-op guard so this
|
|
42
42
|
// suite's real telemetryClient calls actually persist into an isolated dir.
|
|
43
|
-
process.env.SM_TELEMETRY_SPOOL = path.join(home, '.
|
|
43
|
+
process.env.SM_TELEMETRY_SPOOL = path.join(home, '.claude', 'session-manager');
|
|
44
44
|
for (const p of ['../telemetryConsent.cjs', '../telemetryClient.cjs', '../telemetrySettings.cjs', '../machineProfile.cjs', '../../config.cjs']) {
|
|
45
45
|
const resolved = require.resolve(p);
|
|
46
46
|
delete require.cache[resolved];
|
|
@@ -105,7 +105,7 @@ test('turning off does NOT clear telemetry-sent.json or the watermarks — re-en
|
|
|
105
105
|
expect(JSON.parse(sentPathContent).ids.length).toBeGreaterThan(0);
|
|
106
106
|
|
|
107
107
|
// Also seed a watermarks file to prove opt-out leaves it untouched.
|
|
108
|
-
const watermarksDir = path.join(home, '.
|
|
108
|
+
const watermarksDir = path.join(home, '.claude', 'session-manager');
|
|
109
109
|
await fsp.mkdir(watermarksDir, { recursive: true });
|
|
110
110
|
const watermarksPath = path.join(watermarksDir, 'telemetry-watermarks.json');
|
|
111
111
|
const watermarksBefore = { 'abc123/errors-2026-01-01.jsonl': { bytesEnqueued: 100, bytesConfirmed: 100 } };
|
|
@@ -37,7 +37,7 @@ async function mkHome() {
|
|
|
37
37
|
process.env.HOME = dir;
|
|
38
38
|
// Explicit opt-in: overrides the test-environment no-op guard so this
|
|
39
39
|
// suite's real telemetryClient calls actually persist into an isolated dir.
|
|
40
|
-
process.env.SM_TELEMETRY_SPOOL = path.join(dir, '.
|
|
40
|
+
process.env.SM_TELEMETRY_SPOOL = path.join(dir, '.claude', 'session-manager');
|
|
41
41
|
return dir;
|
|
42
42
|
}
|
|
43
43
|
|
|
@@ -101,18 +101,74 @@ test('isResetFresh: past -> false, future -> true, null -> false', () => {
|
|
|
101
101
|
assert.strictEqual(isResetFresh('not-a-date', now), false);
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
-
|
|
104
|
+
const REAL_LIMITS = [
|
|
105
|
+
{ kind: 'session', group: 'session', percent: 6, severity: 'normal', resets_at: '2026-09-22T00:40:00.280514+00:00', scope: null, is_active: false },
|
|
106
|
+
{ kind: 'weekly_all', group: 'weekly', percent: 64, severity: 'normal', resets_at: '2026-09-24T17:00:00.280532+00:00', scope: null, is_active: true },
|
|
107
|
+
{ kind: 'weekly_scoped', group: 'weekly', percent: 18, severity: 'normal', resets_at: '2026-09-24T17:00:00.280763+00:00', scope: { model: { id: null, display_name: 'Fable' }, surface: null }, is_active: false },
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
test('bindingWindow: verbatim real payload selects weekly_all 64', () => {
|
|
105
111
|
const payload = {
|
|
106
|
-
five_hour: { utilization:
|
|
112
|
+
five_hour: { utilization: 6, resets_at: '2026-09-22T00:40:00.280514+00:00' },
|
|
113
|
+
seven_day: { utilization: 64, resets_at: '2026-09-24T17:00:00.280532+00:00' },
|
|
114
|
+
limits: REAL_LIMITS,
|
|
115
|
+
};
|
|
116
|
+
assert.deepStrictEqual(bindingWindow(payload), {
|
|
117
|
+
name: 'weekly_all',
|
|
118
|
+
utilization: 64,
|
|
119
|
+
resets_at: '2026-09-24T17:00:00.280532+00:00',
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('bindingWindow: max percent wins over is_active (session 95 beats weekly_all 64)', () => {
|
|
124
|
+
const win = bindingWindow({
|
|
107
125
|
limits: [
|
|
108
|
-
{
|
|
109
|
-
{
|
|
126
|
+
{ kind: 'session', percent: 95, resets_at: '2026-09-22T00:40:00Z', scope: null, is_active: false },
|
|
127
|
+
{ kind: 'weekly_all', percent: 64, resets_at: '2026-09-24T17:00:00Z', scope: null, is_active: true },
|
|
110
128
|
],
|
|
111
|
-
};
|
|
112
|
-
|
|
129
|
+
});
|
|
130
|
+
assert.strictEqual(win.name, 'session');
|
|
131
|
+
assert.strictEqual(win.utilization, 95);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('bindingWindow: is_active breaks a percent tie', () => {
|
|
135
|
+
const win = bindingWindow({
|
|
136
|
+
limits: [
|
|
137
|
+
{ kind: 'session', percent: 50, resets_at: null, scope: null, is_active: false },
|
|
138
|
+
{ kind: 'weekly_all', percent: 50, resets_at: null, scope: null, is_active: true },
|
|
139
|
+
],
|
|
140
|
+
});
|
|
113
141
|
assert.strictEqual(win.name, 'weekly_all');
|
|
114
|
-
|
|
115
|
-
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('bindingWindow: a high weekly_scoped entry is never selected', () => {
|
|
145
|
+
const win = bindingWindow({
|
|
146
|
+
limits: [
|
|
147
|
+
{ kind: 'session', percent: 6, resets_at: null, scope: null, is_active: false },
|
|
148
|
+
{ kind: 'weekly_all', percent: 10, resets_at: null, scope: null, is_active: true },
|
|
149
|
+
{ kind: 'weekly_scoped', percent: 99, resets_at: null, scope: { model: { display_name: 'Fable' } }, is_active: false },
|
|
150
|
+
],
|
|
151
|
+
});
|
|
152
|
+
assert.strictEqual(win.name, 'weekly_all');
|
|
153
|
+
assert.strictEqual(win.utilization, 10);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('bindingWindow: percent over 100 is returned raw, not clamped', () => {
|
|
157
|
+
const win = bindingWindow({ limits: [{ kind: 'session', percent: 112, resets_at: null, scope: null }] });
|
|
158
|
+
assert.strictEqual(win.utilization, 112);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('bindingWindow: null resets_at backfills from the matching flat window', () => {
|
|
162
|
+
const win = bindingWindow({
|
|
163
|
+
five_hour: { utilization: 40, resets_at: '2026-09-22T00:40:00Z' },
|
|
164
|
+
seven_day: { utilization: 5, resets_at: '2026-09-24T17:00:00Z' },
|
|
165
|
+
limits: [
|
|
166
|
+
{ kind: 'session', percent: 40, resets_at: null, scope: null },
|
|
167
|
+
{ kind: 'weekly_all', percent: 5, resets_at: null, scope: null },
|
|
168
|
+
],
|
|
169
|
+
});
|
|
170
|
+
assert.strictEqual(win.name, 'session');
|
|
171
|
+
assert.strictEqual(win.resets_at, '2026-09-22T00:40:00Z');
|
|
116
172
|
});
|
|
117
173
|
|
|
118
174
|
test('bindingWindow: limits[] absent falls back to five_hour', () => {
|
|
@@ -122,12 +178,12 @@ test('bindingWindow: limits[] absent falls back to five_hour', () => {
|
|
|
122
178
|
assert.strictEqual(win.utilization, 42);
|
|
123
179
|
});
|
|
124
180
|
|
|
125
|
-
test('bindingWindow: limits[]
|
|
181
|
+
test('bindingWindow: limits[] with only scoped / non-finite entries falls back to five_hour', () => {
|
|
126
182
|
const payload = {
|
|
127
183
|
five_hour: { utilization: 12, resets_at: null },
|
|
128
184
|
limits: [
|
|
129
|
-
{
|
|
130
|
-
{
|
|
185
|
+
{ kind: 'weekly_scoped', percent: 81, resets_at: null, scope: { model: { display_name: 'Fable' } } },
|
|
186
|
+
{ kind: 'session', resets_at: null, scope: null },
|
|
131
187
|
],
|
|
132
188
|
};
|
|
133
189
|
const win = bindingWindow(payload);
|
|
@@ -135,19 +191,32 @@ test('bindingWindow: limits[] present but nothing is_active also falls back to f
|
|
|
135
191
|
assert.strictEqual(win.utilization, 12);
|
|
136
192
|
});
|
|
137
193
|
|
|
138
|
-
test('degradedBudget:
|
|
194
|
+
test('degradedBudget: a last-good binding window that genuinely read 0% carries forward as 0, not 100', () => {
|
|
139
195
|
const payload = { five_hour: { utilization: 0, resets_at: null } };
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
196
|
+
assert.strictEqual(degradedBudget(payload, {}).utilization, 0);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('degradedBudget: no payload ever received (null/absent) still yields 100, never a blind 0', () => {
|
|
200
|
+
assert.strictEqual(degradedBudget(null, {}).utilization, 100);
|
|
201
|
+
assert.strictEqual(degradedBudget(undefined, {}).utilization, 100);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('degradedBudget: a degraded cycle after a real-shape poll carries forward the real percent (64), not 100', () => {
|
|
205
|
+
const payload = {
|
|
206
|
+
limits: [
|
|
207
|
+
{ kind: 'session', percent: 6, resets_at: null, scope: null, is_active: false },
|
|
208
|
+
{ kind: 'weekly_all', percent: 64, resets_at: '2026-09-24T17:00:00Z', scope: null, is_active: true },
|
|
209
|
+
],
|
|
210
|
+
};
|
|
211
|
+
assert.strictEqual(degradedBudget(payload, {}).utilization, 64);
|
|
143
212
|
});
|
|
144
213
|
|
|
145
214
|
test('degradedBudget: carries forward the real last-good BINDING (weekly_all) utilization, e.g. 81%, never 0', () => {
|
|
146
215
|
const payload = {
|
|
147
216
|
five_hour: { utilization: 0, resets_at: null },
|
|
148
217
|
limits: [
|
|
149
|
-
{
|
|
150
|
-
{
|
|
218
|
+
{ kind: 'session', is_active: false, percent: 0, resets_at: null, scope: null },
|
|
219
|
+
{ kind: 'weekly_all', is_active: true, percent: 81, resets_at: '2026-09-19T00:00:00Z', scope: null },
|
|
151
220
|
],
|
|
152
221
|
};
|
|
153
222
|
const { utilization } = degradedBudget(payload, {});
|
|
@@ -120,6 +120,38 @@ function isExpiringSoon(creds, withinMs = 5 * 60_000) {
|
|
|
120
120
|
return ms !== null && ms - Date.now() < withinMs;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
/**
|
|
124
|
+
* isRefreshFutile(creds) → boolean
|
|
125
|
+
*
|
|
126
|
+
* True when the token is already expired AND there is no refresh token to
|
|
127
|
+
* present. In that state every refresh avenue is a dead end: tryOAuthRefresh
|
|
128
|
+
* short-circuits to 'unsupported' (it needs `creds.refreshToken`), and the
|
|
129
|
+
* `claude --version` CLI fallback performs its own silent OAuth refresh from
|
|
130
|
+
* that SAME missing refresh token, so it cannot help either. The only recovery
|
|
131
|
+
* is an out-of-band `claude login` — which refreshIfNeeded picks up on its very
|
|
132
|
+
* next call via the isExpiringSoon early-return, since fresh creds never reach
|
|
133
|
+
* this path. Left ungated, this state made pollLoop spawn a `claude --version`
|
|
134
|
+
* process and write the oauth_refresh_unsupported → cli_fallback_ok →
|
|
135
|
+
* auth_failed_expired triple on EVERY 15 s poll, forever (observed 2026-09-23:
|
|
136
|
+
* expiredAtMs 1775277875159, identical triple every ~15 min across the log).
|
|
137
|
+
*/
|
|
138
|
+
function isRefreshFutile(creds) {
|
|
139
|
+
return isExpired(creds) && !creds.refreshToken;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Throttle for the futile-refresh state above: once seen, don't respawn the
|
|
143
|
+
// CLI fallback or re-log the auth triple on every poll. Re-probe at most once
|
|
144
|
+
// per this window (an external `claude login` is still caught immediately by
|
|
145
|
+
// refreshIfNeeded's isExpiringSoon early-return, and by the cheap re-read
|
|
146
|
+
// below — neither needs the storm). Mirrors loadGate.cjs's AUDIT_INTERVAL_MS.
|
|
147
|
+
const FUTILE_REFRESH_COOLDOWN_MS = 15 * 60_000;
|
|
148
|
+
let lastFutileRefreshLoggedAt = null;
|
|
149
|
+
|
|
150
|
+
/** Test-only: reset the futile-refresh throttle so each test starts clean. */
|
|
151
|
+
function __resetFutileRefreshThrottle() {
|
|
152
|
+
lastFutileRefreshLoggedAt = null;
|
|
153
|
+
}
|
|
154
|
+
|
|
123
155
|
async function writeCredentials(rawData, freshOauth, source = 'file') {
|
|
124
156
|
const next = { ...rawData, claudeAiOauth: { ...rawData.claudeAiOauth, ...freshOauth } };
|
|
125
157
|
if (source === 'keychain') {
|
|
@@ -222,6 +254,33 @@ async function refreshIfNeeded(forceRefresh = false) {
|
|
|
222
254
|
|
|
223
255
|
const alreadyExpired = isExpired(creds);
|
|
224
256
|
|
|
257
|
+
// Futile-refresh short-circuit: token already expired AND no refresh token
|
|
258
|
+
// to present. Every downstream avenue (OAuth grant, `claude --version` CLI
|
|
259
|
+
// fallback) is a guaranteed dead end from the same missing token, so running
|
|
260
|
+
// them on every 15 s poll only spawns a doomed child process and re-logs the
|
|
261
|
+
// same triple forever (2026-09-23 storm). Re-read once — cheap, and the ONE
|
|
262
|
+
// real recovery (`claude login`) lands here — then, if still futile, return
|
|
263
|
+
// the auth verdict WITHOUT the OAuth call or the CLI-fallback spawn, logging
|
|
264
|
+
// at most once per FUTILE_REFRESH_COOLDOWN_MS instead of every poll.
|
|
265
|
+
if (isRefreshFutile(creds)) {
|
|
266
|
+
const recheckCr = await readCredentials();
|
|
267
|
+
if (recheckCr.kind === 'ok' && !isRefreshFutile(recheckCr.creds) && !isExpired(recheckCr.creds)) {
|
|
268
|
+
appendRefreshLog({ event: 'externally_refreshed_ok', recheckExpiresAt: recheckCr.creds.expiresAt ?? null });
|
|
269
|
+
lastFutileRefreshLoggedAt = null;
|
|
270
|
+
return { kind: 'ok', creds: recheckCr.creds };
|
|
271
|
+
}
|
|
272
|
+
const nowMs = Date.now();
|
|
273
|
+
if (lastFutileRefreshLoggedAt === null || nowMs - lastFutileRefreshLoggedAt >= FUTILE_REFRESH_COOLDOWN_MS) {
|
|
274
|
+
lastFutileRefreshLoggedAt = nowMs;
|
|
275
|
+
appendRefreshLog({ event: 'auth_failed_expired_no_refresh_token', expiredAtMs: expiresAtMs(creds) });
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
kind: 'auth',
|
|
279
|
+
message: 'Credentials expired and cannot be auto-refreshed (no refresh token). Run `claude` in a terminal to log in.',
|
|
280
|
+
expiredAt: expiresAtMs(creds),
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
225
284
|
// Stretch: try OAuth refresh endpoint first.
|
|
226
285
|
const oauthResult = await tryOAuthRefresh(creds);
|
|
227
286
|
appendRefreshLog({ event: `oauth_refresh_${oauthResult.kind}`, message: oauthResult.message ?? null });
|
|
@@ -277,8 +336,11 @@ module.exports = {
|
|
|
277
336
|
expiresAtMs,
|
|
278
337
|
isExpired,
|
|
279
338
|
isExpiringSoon,
|
|
339
|
+
isRefreshFutile,
|
|
280
340
|
refreshIfNeeded,
|
|
281
341
|
parseCredsRaw,
|
|
342
|
+
__resetFutileRefreshThrottle,
|
|
343
|
+
FUTILE_REFRESH_COOLDOWN_MS,
|
|
282
344
|
KEYCHAIN_SERVICE,
|
|
283
345
|
CREDS_PATH,
|
|
284
346
|
};
|
|
@@ -152,6 +152,21 @@ function classifyCwd(cwd) {
|
|
|
152
152
|
if (!cwd || typeof cwd !== 'string') {
|
|
153
153
|
return { kind: 'unknown', projectRoot: null, outermostOpsRoot: null, innermostOpsRoot: null, reason: 'cwd is not a non-empty string' };
|
|
154
154
|
}
|
|
155
|
+
// Must run BEFORE inspectGit: nearestGitEntry walks ancestors via
|
|
156
|
+
// fs.statSync, which silently resolves a relative `dir` against
|
|
157
|
+
// process.cwd() — so a relative cwd that happens to be a real subpath of
|
|
158
|
+
// WHATEVER PROCESS IS RUNNING THIS CODE (not the transcript's actual
|
|
159
|
+
// project) can walk up into a real `.git`, including a linked worktree's
|
|
160
|
+
// `.git` FILE, and return that worktree's real main-tree root as if it
|
|
161
|
+
// were the relative fragment's own project. The 'worktree' branch below
|
|
162
|
+
// returns early on g.shape alone with no absolute check of its own, so
|
|
163
|
+
// this guard cannot be deferred to after inspectGit runs (confirmed via
|
|
164
|
+
// opsRootAbsoluteCwd.test.cjs: reproduces in every git worktree, not just
|
|
165
|
+
// job worktrees — process.cwd() there is a worktree whose .git FILE
|
|
166
|
+
// resolves back to the real main tree root).
|
|
167
|
+
if (!path.isAbsolute(cwd)) {
|
|
168
|
+
return { kind: 'unknown', projectRoot: null, outermostOpsRoot: null, innermostOpsRoot: null, reason: 'cwd is not an absolute path' };
|
|
169
|
+
}
|
|
155
170
|
const parts = cwd.split(path.sep);
|
|
156
171
|
const first = parts.indexOf(OPS_DIRNAME);
|
|
157
172
|
const last = parts.lastIndexOf(OPS_DIRNAME);
|
|
@@ -14,6 +14,7 @@ const path = require('node:path');
|
|
|
14
14
|
const { spawn, spawnSync } = require('node:child_process');
|
|
15
15
|
const { splitFrontmatter } = require('./prdFrontmatter.cjs');
|
|
16
16
|
const { resolvePrdWriteDir } = require('./prdLocations.cjs');
|
|
17
|
+
const schedulerPaths = require('./schedulerPaths.cjs');
|
|
17
18
|
|
|
18
19
|
// Regex identifying meta/dod slugs that must NOT influence the batchKey.
|
|
19
20
|
// This is the load-bearing loop-avoidance filter: when the gate job itself
|
|
@@ -21,11 +22,6 @@ const { resolvePrdWriteDir } = require('./prdLocations.cjs');
|
|
|
21
22
|
// a no-op (idempotent) instead of re-firing forever.
|
|
22
23
|
const DOD_SLUG_RE = /(^|-)dod(-|$)|definition-of-done/i;
|
|
23
24
|
|
|
24
|
-
const RUNS_DIR = path.join(
|
|
25
|
-
os.homedir(),
|
|
26
|
-
'.claude', 'session-manager', 'scheduled-plans', 'runs'
|
|
27
|
-
);
|
|
28
|
-
|
|
29
25
|
/**
|
|
30
26
|
* Compute a stable short hash for a completed job-set.
|
|
31
27
|
*
|
|
@@ -62,7 +58,7 @@ function batchKey(jobs) {
|
|
|
62
58
|
function reportPathFor(key) {
|
|
63
59
|
if (!/^[0-9a-f]+$/.test(key)) throw new Error(`invalid batchKey: ${key}`);
|
|
64
60
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
65
|
-
return path.join(
|
|
61
|
+
return path.join(schedulerPaths.runsDir(), ts, `definition-of-done-${key}.md`);
|
|
66
62
|
}
|
|
67
63
|
|
|
68
64
|
/**
|
|
@@ -70,10 +66,10 @@ function reportPathFor(key) {
|
|
|
70
66
|
* run subdirectory. Scans runs/<ts>/ (shallow, one level).
|
|
71
67
|
*
|
|
72
68
|
* @param {string} key Output of batchKey()
|
|
73
|
-
* @param {string} [runsDir] Override for testing; defaults to
|
|
69
|
+
* @param {string} [runsDir] Override for testing; defaults to schedulerPaths.runsDir()
|
|
74
70
|
* @returns {boolean}
|
|
75
71
|
*/
|
|
76
|
-
function reportExists(key, runsDir =
|
|
72
|
+
function reportExists(key, runsDir = schedulerPaths.runsDir()) {
|
|
77
73
|
if (!/^[0-9a-f]+$/.test(key)) throw new Error(`invalid batchKey: ${key}`);
|
|
78
74
|
let entries;
|
|
79
75
|
try {
|
|
@@ -604,7 +600,7 @@ const STATUS_EMOJI = { pass: '✅', fail: '❌', unverifiable: '⚠️' };
|
|
|
604
600
|
function writeReport(key, { acResults = [], riskFlags = [], runsDir } = {}) {
|
|
605
601
|
if (!/^[0-9a-f]+$/.test(key)) throw new Error(`invalid batchKey: ${key}`);
|
|
606
602
|
|
|
607
|
-
const resolvedRunsDir = runsDir ??
|
|
603
|
+
const resolvedRunsDir = runsDir ?? schedulerPaths.runsDir();
|
|
608
604
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
609
605
|
const dir = path.join(resolvedRunsDir, ts);
|
|
610
606
|
const reportPath = path.join(dir, `definition-of-done-${key}.md`);
|
|
@@ -781,10 +777,10 @@ const WATERMARK_FILENAME = '.dod-watermark.json';
|
|
|
781
777
|
* sidecar means "beginning of time" (process everything), matching the
|
|
782
778
|
* pre-watermark unbounded behavior on first-ever drain.
|
|
783
779
|
*
|
|
784
|
-
* @param {string} [runsDir] Override for testing; defaults to
|
|
780
|
+
* @param {string} [runsDir] Override for testing; defaults to schedulerPaths.runsDir()
|
|
785
781
|
* @returns {string|null} ISO8601 timestamp, or null if unset/unreadable.
|
|
786
782
|
*/
|
|
787
|
-
function readWatermark(runsDir =
|
|
783
|
+
function readWatermark(runsDir = schedulerPaths.runsDir()) {
|
|
788
784
|
try {
|
|
789
785
|
const raw = fs.readFileSync(path.join(runsDir, WATERMARK_FILENAME), 'utf8');
|
|
790
786
|
const parsed = JSON.parse(raw);
|
|
@@ -799,9 +795,9 @@ function readWatermark(runsDir = RUNS_DIR) {
|
|
|
799
795
|
* jobs that completed after it.
|
|
800
796
|
*
|
|
801
797
|
* @param {string} lastFinishedAt ISO8601 timestamp.
|
|
802
|
-
* @param {string} [runsDir] Override for testing; defaults to
|
|
798
|
+
* @param {string} [runsDir] Override for testing; defaults to schedulerPaths.runsDir()
|
|
803
799
|
*/
|
|
804
|
-
function writeWatermark(lastFinishedAt, runsDir =
|
|
800
|
+
function writeWatermark(lastFinishedAt, runsDir = schedulerPaths.runsDir()) {
|
|
805
801
|
fs.mkdirSync(runsDir, { recursive: true });
|
|
806
802
|
_writeFileAtomic(
|
|
807
803
|
path.join(runsDir, WATERMARK_FILENAME),
|
|
@@ -28,6 +28,7 @@ const path = require('node:path');
|
|
|
28
28
|
const os = require('node:os');
|
|
29
29
|
const { splitFrontmatter } = require('./prdFrontmatter.cjs');
|
|
30
30
|
const { resolveEpicTranscriptPath } = require('./epicTranscriptPath.cjs');
|
|
31
|
+
const schedulerPaths = require('./schedulerPaths.cjs');
|
|
31
32
|
|
|
32
33
|
// Deliberately stays a FIXED alias set (reviewed against the catalog-driven raw picker): it answers
|
|
33
34
|
// "alias vs concrete id", not "which options to offer", so live catalog ids must not enter it.
|
|
@@ -51,13 +52,6 @@ const MAX_RUN_LOG_HEAD_BYTES = 64 * 1024;
|
|
|
51
52
|
// pass over a transcript that can reach tens of MB. O(min(size, cap)).
|
|
52
53
|
const MAX_TRANSCRIPT_TAIL_BYTES = 8 * 1024 * 1024;
|
|
53
54
|
|
|
54
|
-
// Mirrors scheduler.cjs's own ROOT/RUNS_DIR constants (same convention as
|
|
55
|
-
// queueStore.cjs's LEGACY_QUEUE_PATH, which duplicates this literal rather
|
|
56
|
-
// than requiring scheduler.cjs's ~530KB module graph for one path).
|
|
57
|
-
function runsDir(homeDir) {
|
|
58
|
-
return path.join(homeDir, '.claude', 'session-manager', 'scheduled-plans', 'runs');
|
|
59
|
-
}
|
|
60
|
-
|
|
61
55
|
function isConcreteModelId(modelAlias) {
|
|
62
56
|
if (!modelAlias) return false;
|
|
63
57
|
// 'opus[1m]' is an alias with a context suffix; 'claude-opus-4-7[1m]' keeps its id.
|
|
@@ -232,10 +226,9 @@ function findLatestSchedulerModel(cwd, agentType, deps) {
|
|
|
232
226
|
const matches = jobs.filter((j) => j && j.agentType === agentType && j.runId && j.slug);
|
|
233
227
|
matches.sort((a, b) => jobTimestampMs(b) - jobTimestampMs(a));
|
|
234
228
|
|
|
235
|
-
const homeDir = deps.homeDir || os.homedir();
|
|
236
229
|
const re = /\[scheduler\] agentType=\S+ persona=.*? model=(\S+)/g;
|
|
237
230
|
for (const job of matches) {
|
|
238
|
-
const logPath = path.join(runsDir(
|
|
231
|
+
const logPath = path.join(schedulerPaths.runsDir(), job.runId, `${job.slug}.log`);
|
|
239
232
|
const head = readHead(logPath, MAX_RUN_LOG_HEAD_BYTES, deps);
|
|
240
233
|
if (!head) continue;
|
|
241
234
|
let match;
|