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
package/src/main/health.cjs
CHANGED
|
@@ -23,7 +23,7 @@ const { computeStallSummary, computeDepHistorySatisfaction, FAILURE_STREAK_ESCAL
|
|
|
23
23
|
const { findStarvedProjects, findUnresolvableDepRoots, DEFAULT_PROJECT_CWD, DEP_HISTORY_FAIL_OPEN } = require('./lib/schedulerBatch.cjs');
|
|
24
24
|
const { auditLogPath, readTail } = require('./lib/auditLog.cjs');
|
|
25
25
|
const { resolveBuildIdentity } = require('./lib/buildIdentity.cjs');
|
|
26
|
-
const {
|
|
26
|
+
const { computeReport, isRetentionEnabled, liveKeysFromJobs } = require('./lib/runLogRetention.cjs');
|
|
27
27
|
const { allProjectCwds } = require('./lib/activeSessions.cjs');
|
|
28
28
|
const { scanEpicTranscripts } = require('./lib/epicTranscriptDiagnostic.cjs');
|
|
29
29
|
|
|
@@ -39,6 +39,8 @@ const PROJECT_ROOT = path.resolve(__dirname, '../..');
|
|
|
39
39
|
// outage goes unnoticed for hours (the 2026-07-14 incident sat stalled for
|
|
40
40
|
// 16.5h before anything asserted on it).
|
|
41
41
|
const TICK_STALL_MULTIPLIER = 3;
|
|
42
|
+
// A utilization hold whose reset is further out than this is not a routine 5h pause.
|
|
43
|
+
const LONG_HOLD_MS = 5 * 60 * 60_000;
|
|
42
44
|
const TICK_STALL_THRESHOLD_MS = TICK_STALL_MULTIPLIER * POLL_INTERVAL_MS;
|
|
43
45
|
// A heartbeat line older than this is treated as "the app isn't running /
|
|
44
46
|
// we can't observe live utilization" rather than "utilization is low" —
|
|
@@ -139,7 +141,19 @@ function evaluateTickLiveness(queueState, heartbeat, now, runningCount) {
|
|
|
139
141
|
};
|
|
140
142
|
}
|
|
141
143
|
if (typeof heartbeat.utilization === 'number' && heartbeat.utilization >= config.utilizationThreshold) {
|
|
142
|
-
|
|
144
|
+
// A hold on the binding window is benign only if it self-heals soon
|
|
145
|
+
// (reset within 5h). A weekly hold, or an unknown reset horizon, can
|
|
146
|
+
// last days — surface it rather than reading GREEN.
|
|
147
|
+
const resetMs = heartbeat.nextReset ? Date.parse(heartbeat.nextReset) : NaN;
|
|
148
|
+
const longHold = !Number.isFinite(resetMs) || resetMs - now > LONG_HOLD_MS;
|
|
149
|
+
return {
|
|
150
|
+
stalled: false,
|
|
151
|
+
reason: 'utilization-at-threshold',
|
|
152
|
+
utilization: heartbeat.utilization,
|
|
153
|
+
window: heartbeat.utilizationWindow ?? null,
|
|
154
|
+
nextReset: heartbeat.nextReset ?? null,
|
|
155
|
+
...(longHold ? { longHold: true } : {}),
|
|
156
|
+
};
|
|
143
157
|
}
|
|
144
158
|
}
|
|
145
159
|
|
|
@@ -613,6 +627,50 @@ function evaluateBuildFreshness({ running = null, installedOnDisk = null, repoHe
|
|
|
613
627
|
return { ok: true, running, installedOnDisk, repoHead, restartNeeded, publishNeeded, ...(notes.length ? { note: notes.join('; ') } : {}) };
|
|
614
628
|
}
|
|
615
629
|
|
|
630
|
+
/**
|
|
631
|
+
* evaluateCredentialHealth({ credState, now }) → { component, issues }
|
|
632
|
+
*
|
|
633
|
+
* Pure over its inputs — `credState` is the shape lib/credentials.cjs's
|
|
634
|
+
* refreshIfNeeded()/readCredentials() return ({ kind, message?, expiredAt? }),
|
|
635
|
+
* injected so this is testable without touching the Keychain. Surfaces the ONE
|
|
636
|
+
* failure that silently voids the whole scheduler on a user's machine: the
|
|
637
|
+
* OAuth token is expired (or missing) so every headless `claude -p` job fails
|
|
638
|
+
* to authenticate. The scheduler already pauses on a live 'auth' poll
|
|
639
|
+
* (scheduler.cjs setPaused('auth')), but nothing told the OPERATOR why the
|
|
640
|
+
* queue went quiet — `npm run health` read GREEN while no job could run. This
|
|
641
|
+
* names the cause and the fix (`claude login`).
|
|
642
|
+
*
|
|
643
|
+
* NON-CRITICAL by design: reported in components.credentials + issues so
|
|
644
|
+
* /local-project-health shows it, but it does not flip status.ok — health must
|
|
645
|
+
* not hard-fail a repo checkout just because THIS machine's token lapsed, and
|
|
646
|
+
* an expired token is an operator action (re-login), not a code defect. Maps:
|
|
647
|
+
* kind 'auth' → not ok, actionable `claude login` message (the real block)
|
|
648
|
+
* kind 'config' → not ok, credentials unreadable/absent
|
|
649
|
+
* kind 'ok'/'unsupported' → ok (token usable, or too-fresh-to-refresh)
|
|
650
|
+
*/
|
|
651
|
+
function evaluateCredentialHealth({ credState, now = Date.now() } = {}) {
|
|
652
|
+
const issues = [];
|
|
653
|
+
if (!credState || typeof credState !== 'object') {
|
|
654
|
+
return { component: { ok: true, state: 'unknown' }, issues };
|
|
655
|
+
}
|
|
656
|
+
const { kind } = credState;
|
|
657
|
+
if (kind === 'auth') {
|
|
658
|
+
const expiredAt = typeof credState.expiredAt === 'number' ? credState.expiredAt : null;
|
|
659
|
+
const agoMin = expiredAt ? Math.max(0, Math.round((now - expiredAt) / 60_000)) : null;
|
|
660
|
+
const when = expiredAt ? new Date(expiredAt).toISOString() : 'unknown';
|
|
661
|
+
const msg = `Claude credentials expired${agoMin !== null ? ` (${agoMin}m ago, at ${when})` : ''} — headless \`claude -p\` scheduler jobs cannot authenticate. Run \`claude login\` in a terminal to refresh.`;
|
|
662
|
+
issues.push(msg);
|
|
663
|
+
return { component: { ok: false, state: 'expired', expiredAt, message: msg }, issues };
|
|
664
|
+
}
|
|
665
|
+
if (kind === 'config') {
|
|
666
|
+
const msg = `Claude credentials unreadable: ${credState.message ?? 'unknown'} — scheduler jobs cannot authenticate. Run \`claude login\`.`;
|
|
667
|
+
issues.push(msg);
|
|
668
|
+
return { component: { ok: false, state: 'unreadable', message: msg }, issues };
|
|
669
|
+
}
|
|
670
|
+
// 'ok' (fresh) or 'unsupported' (auto-refresh unavailable but token still valid)
|
|
671
|
+
return { component: { ok: true, state: kind === 'unsupported' ? 'valid-no-refresh' : 'valid' }, issues };
|
|
672
|
+
}
|
|
673
|
+
|
|
616
674
|
/**
|
|
617
675
|
* evaluateStarveEscalationHealth(jobs, now, thresholdMs, escalationReasons)
|
|
618
676
|
* → { ok, projects?, message? }
|
|
@@ -860,6 +918,16 @@ async function check(opts = {}) {
|
|
|
860
918
|
})(),
|
|
861
919
|
loadGateThreshold: loadGateThreshold(),
|
|
862
920
|
};
|
|
921
|
+
if (liveness.longHold) {
|
|
922
|
+
status.components.scheduler_queue.longHold = {
|
|
923
|
+
window: liveness.window, utilization: liveness.utilization, nextReset: liveness.nextReset,
|
|
924
|
+
};
|
|
925
|
+
status.issues.push(
|
|
926
|
+
`Queue held on the ${liveness.window ?? 'binding'} usage window at ${liveness.utilization}% (≥ threshold); `
|
|
927
|
+
+ (liveness.nextReset ? `resets ${liveness.nextReset}` : 'reset time unknown')
|
|
928
|
+
+ ' — hold may last days, not a normal short pause'
|
|
929
|
+
);
|
|
930
|
+
}
|
|
863
931
|
if (liveness.stalled) {
|
|
864
932
|
const ageMin = Math.round(liveness.tickAgeMs / 60_000);
|
|
865
933
|
status.components.scheduler_queue.stalledJob = liveness.oldestPendingSlug;
|
|
@@ -1114,15 +1182,16 @@ async function check(opts = {}) {
|
|
|
1114
1182
|
// `config`, same home as every other scheduler machine setting.
|
|
1115
1183
|
try {
|
|
1116
1184
|
const retentionCfg = queueState?.config?.schedulerRunLogRetention;
|
|
1117
|
-
const
|
|
1185
|
+
const runsDir = schedulerPaths.runsDir();
|
|
1186
|
+
const liveKeys = liveKeysFromJobs(queueState?.jobs || [], { runsDir });
|
|
1118
1187
|
const report = computeReport(
|
|
1119
|
-
|
|
1188
|
+
runsDir,
|
|
1120
1189
|
retentionCfg?.policy || {},
|
|
1121
1190
|
{ liveKeys }
|
|
1122
1191
|
);
|
|
1123
1192
|
status.components.run_log_retention = {
|
|
1124
1193
|
ok: true,
|
|
1125
|
-
path:
|
|
1194
|
+
path: runsDir,
|
|
1126
1195
|
totalBytes: report.usage.totalBytes,
|
|
1127
1196
|
dirCount: report.usage.dirCount,
|
|
1128
1197
|
runCount: report.usage.runCount,
|
|
@@ -1181,6 +1250,22 @@ async function check(opts = {}) {
|
|
|
1181
1250
|
status.components.build = { ok: true, error: e.message };
|
|
1182
1251
|
}
|
|
1183
1252
|
|
|
1253
|
+
// 6.8. Credential health (informational-but-loud): is the Claude OAuth token
|
|
1254
|
+
// usable? An expired/absent token silently voids every headless `claude -p`
|
|
1255
|
+
// job — the queue "runs" but nothing authenticates. refreshIfNeeded() reads
|
|
1256
|
+
// the Keychain/file and (post futile-refresh fix) never spawns a doomed CLI
|
|
1257
|
+
// fallback for an expired-no-refresh-token creds, so this is a cheap read.
|
|
1258
|
+
// Non-critical: names the cause + `claude login` fix without flipping ok.
|
|
1259
|
+
try {
|
|
1260
|
+
const { refreshIfNeeded } = require('./lib/credentials.cjs');
|
|
1261
|
+
const credState = await refreshIfNeeded();
|
|
1262
|
+
const { component, issues } = evaluateCredentialHealth({ credState, now: Date.now() });
|
|
1263
|
+
status.components.credentials = component;
|
|
1264
|
+
for (const i of issues) status.issues.push(i);
|
|
1265
|
+
} catch (e) {
|
|
1266
|
+
status.components.credentials = { ok: true, state: 'unknown', error: e.message };
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1184
1269
|
// 7. Summary scoring: ok if all critical components pass.
|
|
1185
1270
|
// Critical: nodejs, config dir, typescript, build artifact, test infrastructure.
|
|
1186
1271
|
// Non-fatal: scheduler/transcripts dirs may not exist on fresh install.
|
|
@@ -1207,6 +1292,7 @@ module.exports = {
|
|
|
1207
1292
|
readFreshHeartbeat,
|
|
1208
1293
|
evaluatePrdMigrationHealth,
|
|
1209
1294
|
evaluateDelegationChainHealth,
|
|
1295
|
+
evaluateCredentialHealth,
|
|
1210
1296
|
computeEpicIndexDrift,
|
|
1211
1297
|
evaluateEpicIndexHealth,
|
|
1212
1298
|
computeProjectProblemCounts,
|
package/src/main/index.cjs
CHANGED
|
@@ -1027,7 +1027,7 @@ ipcMain.handle('exchanges:list', validated(schemas.exchangesList, async (payload
|
|
|
1027
1027
|
}
|
|
1028
1028
|
}));
|
|
1029
1029
|
|
|
1030
|
-
// OTEL telemetry export (opt-in via ~/.
|
|
1030
|
+
// OTEL telemetry export (opt-in via ~/.claude/session-manager/otel.json).
|
|
1031
1031
|
ipcMain.handle('otel:get-config', async () => otelSettings.load());
|
|
1032
1032
|
ipcMain.handle('otel:set-config', async (_e, cfg) => {
|
|
1033
1033
|
if (!otelSettings.isValid(cfg)) {
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -632,10 +632,14 @@ const memoryStale = z.object({
|
|
|
632
632
|
// the encoded cwd slug (memoryAggregate.cjs reads directly from
|
|
633
633
|
// ~/.claude/projects/<workspace>/memory/), same regex as the other memory:*
|
|
634
634
|
// handlers. `refresh: true` is the cost gate that fires the single claude -p
|
|
635
|
-
// clustering pass; falsy returns the cached result.
|
|
635
|
+
// clustering pass; falsy returns the cached result. `cwd` (PRD 1389,
|
|
636
|
+
// optional — same shape as memory:stale's) addresses the per-project cache
|
|
637
|
+
// at <cwd>/session-manager-operations/memory-clusters/clusters.json;
|
|
638
|
+
// absent/invalid cwd just disables caching for that call.
|
|
636
639
|
const memoryAggregate = z.object({
|
|
637
640
|
workspace: z.string().regex(MEMORY_WORKSPACE_RE),
|
|
638
641
|
refresh: z.boolean().optional(),
|
|
642
|
+
cwd: z.string().max(4096).optional(),
|
|
639
643
|
}).strict();
|
|
640
644
|
|
|
641
645
|
// ──────────────────────────────────────────── Project Brief (PRD 837)
|
|
@@ -35,7 +35,7 @@ async function mkHome() {
|
|
|
35
35
|
process.env.HOME = dir;
|
|
36
36
|
// Explicit opt-in: overrides the test-environment no-op guard so this
|
|
37
37
|
// suite's real telemetryClient calls actually persist into an isolated dir.
|
|
38
|
-
process.env.SM_TELEMETRY_SPOOL = path.join(dir, '.
|
|
38
|
+
process.env.SM_TELEMETRY_SPOOL = path.join(dir, '.claude', 'session-manager');
|
|
39
39
|
return dir;
|
|
40
40
|
}
|
|
41
41
|
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* credentials-futile-refresh.test.cjs — the 2026-09-23 credential-refresh
|
|
3
|
+
* storm. On this machine the OAuth creds were expired with NO refresh token
|
|
4
|
+
* (expiredAtMs 1775277875159). Every 15 s billing poll called refreshIfNeeded,
|
|
5
|
+
* which tried the OAuth grant (instant 'unsupported' — no refresh token),
|
|
6
|
+
* then spawned `claude --version` as a CLI fallback (which shares the same
|
|
7
|
+
* missing token, so it can't help either), then logged auth_failed_expired —
|
|
8
|
+
* the identical triple, forever, spawning a doomed child process each time.
|
|
9
|
+
*
|
|
10
|
+
* The fix: a futile-refresh short-circuit. When the token is expired AND has
|
|
11
|
+
* no refresh token, refreshIfNeeded re-reads once (to catch an out-of-band
|
|
12
|
+
* `claude login`) and otherwise returns the auth verdict WITHOUT the OAuth
|
|
13
|
+
* call or the CLI-fallback spawn, logging at most once per cooldown window.
|
|
14
|
+
*
|
|
15
|
+
* Run: timeout 120 npx vitest run src/main/lib/__tests__/credentials-futile-refresh.test.cjs
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
import { test, expect, beforeEach, afterAll } from 'vitest';
|
|
21
|
+
const fs = require('node:fs');
|
|
22
|
+
const os = require('node:os');
|
|
23
|
+
const path = require('node:path');
|
|
24
|
+
|
|
25
|
+
// credentials.cjs freezes CREDS_PATH and REFRESH_LOG_PATH from os.homedir() at
|
|
26
|
+
// require time. os.homedir() honors $HOME on POSIX, so point HOME at a temp
|
|
27
|
+
// dir BEFORE the require (not in beforeEach — that runs too late) so both the
|
|
28
|
+
// creds file we write and the log the code writes resolve under our sandbox.
|
|
29
|
+
const originalHome = process.env.HOME;
|
|
30
|
+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-cred-futile-'));
|
|
31
|
+
process.env.HOME = tmpHome;
|
|
32
|
+
|
|
33
|
+
const credentials = require('../credentials.cjs');
|
|
34
|
+
const { isRefreshFutile, refreshIfNeeded, __resetFutileRefreshThrottle } = credentials;
|
|
35
|
+
|
|
36
|
+
const CREDS_FILE = path.join(tmpHome, '.claude', '.credentials.json');
|
|
37
|
+
const LOG_FILE = path.join(tmpHome, '.claude', 'session-manager', 'credential-refresh.log');
|
|
38
|
+
|
|
39
|
+
function writeCreds(oauth) {
|
|
40
|
+
fs.mkdirSync(path.dirname(CREDS_FILE), { recursive: true });
|
|
41
|
+
fs.writeFileSync(CREDS_FILE, JSON.stringify({ claudeAiOauth: oauth }), 'utf8');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readLogLines() {
|
|
45
|
+
try {
|
|
46
|
+
return fs.readFileSync(LOG_FILE, 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
47
|
+
} catch {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
// Clean slate per test: no stale creds file, no carried-over log or throttle.
|
|
54
|
+
try { fs.unlinkSync(CREDS_FILE); } catch { /* absent */ }
|
|
55
|
+
try { fs.unlinkSync(LOG_FILE); } catch { /* absent */ }
|
|
56
|
+
__resetFutileRefreshThrottle();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
afterAll(() => {
|
|
60
|
+
process.env.HOME = originalHome;
|
|
61
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('isRefreshFutile: expired + no refresh token is futile; anything else is not', () => {
|
|
65
|
+
const past = Date.now() - 60_000;
|
|
66
|
+
const future = Date.now() + 60 * 60_000;
|
|
67
|
+
expect(isRefreshFutile({ accessToken: 'a', expiresAt: past })).toBe(true);
|
|
68
|
+
// Has a refresh token → OAuth grant is worth attempting, not futile.
|
|
69
|
+
expect(isRefreshFutile({ accessToken: 'a', expiresAt: past, refreshToken: 'r' })).toBe(false);
|
|
70
|
+
// Not expired → not futile even without a refresh token.
|
|
71
|
+
expect(isRefreshFutile({ accessToken: 'a', expiresAt: future })).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('a futile refresh returns an auth verdict without spawning the CLI fallback, and logs the no-refresh-token event once', async () => {
|
|
75
|
+
writeCreds({ accessToken: 'expired', expiresAt: Date.now() - 60_000 }); // expired, no refreshToken
|
|
76
|
+
|
|
77
|
+
const res = await refreshIfNeeded();
|
|
78
|
+
|
|
79
|
+
expect(res.kind).toBe('auth');
|
|
80
|
+
expect(res.message).toMatch(/no refresh token/i);
|
|
81
|
+
expect(typeof res.expiredAt).toBe('number');
|
|
82
|
+
|
|
83
|
+
const events = readLogLines().map((e) => e.event);
|
|
84
|
+
// The distinctive no-refresh-token event fires...
|
|
85
|
+
expect(events).toContain('auth_failed_expired_no_refresh_token');
|
|
86
|
+
// ...and the storm's spawn/oauth triple never runs.
|
|
87
|
+
expect(events).not.toContain('cli_fallback_ok');
|
|
88
|
+
expect(events).not.toContain('cli_fallback_failed');
|
|
89
|
+
expect(events).not.toContain('oauth_refresh_unsupported');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('repeated polls in the cooldown window do NOT re-log — the every-15s storm is suppressed', async () => {
|
|
93
|
+
writeCreds({ accessToken: 'expired', expiresAt: Date.now() - 60_000 });
|
|
94
|
+
|
|
95
|
+
// Simulate several poll cycles back-to-back (as pollLoop would, every 15 s).
|
|
96
|
+
for (let i = 0; i < 5; i++) {
|
|
97
|
+
const res = await refreshIfNeeded();
|
|
98
|
+
expect(res.kind).toBe('auth');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const futileLogs = readLogLines().filter((e) => e.event === 'auth_failed_expired_no_refresh_token');
|
|
102
|
+
expect(futileLogs.length).toBe(1); // once, not five times
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('an out-of-band `claude login` (fresh creds on disk) is picked up on the next call', async () => {
|
|
106
|
+
writeCreds({ accessToken: 'expired', expiresAt: Date.now() - 60_000 });
|
|
107
|
+
expect((await refreshIfNeeded()).kind).toBe('auth');
|
|
108
|
+
|
|
109
|
+
// User runs `claude` in a terminal → fresh creds land on disk.
|
|
110
|
+
writeCreds({ accessToken: 'fresh', expiresAt: Date.now() + 60 * 60_000 });
|
|
111
|
+
|
|
112
|
+
const res = await refreshIfNeeded();
|
|
113
|
+
expect(res.kind).toBe('ok');
|
|
114
|
+
expect(res.creds.accessToken).toBe('fresh');
|
|
115
|
+
});
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* epicWorktreeProjectConfig.test.cjs — PRD 1035: the per-project "disable
|
|
3
|
-
* Epic worktree isolation" UI toggle
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* epicWorktreeProjectConfig.test.cjs — PRD 1035/1390: the per-project "disable
|
|
3
|
+
* Epic worktree isolation" UI toggle, now stored at
|
|
4
|
+
* `<cwd>/session-manager-operations/prompt-sessions/epic-worktree-config.json`
|
|
5
|
+
* instead of a single machine-wide map. Uses real mkdtemp'd project cwds (the
|
|
6
|
+
* same convention as epicStatusMirror.test.cjs) since the file location is
|
|
7
|
+
* now derived from `cwd` via opsPath(), not from a single overridable path.
|
|
8
|
+
* SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH still points the LEGACY global map (the
|
|
9
|
+
* one-shot migration source) at a throwaway tmpdir file, so tests never touch
|
|
10
|
+
* the real ~/.claude/session-manager/epic-worktree-project-config.json.
|
|
7
11
|
*
|
|
8
12
|
* Run: timeout 120 npx vitest run src/main/lib/__tests__/epicWorktreeProjectConfig.test.cjs
|
|
9
13
|
*/
|
|
@@ -16,60 +20,79 @@ const os = require('node:os');
|
|
|
16
20
|
const path = require('node:path');
|
|
17
21
|
|
|
18
22
|
let tmpRoot;
|
|
19
|
-
let
|
|
23
|
+
let legacyConfigFile;
|
|
20
24
|
let originalOverride;
|
|
25
|
+
const projectDirs = [];
|
|
21
26
|
|
|
22
27
|
beforeEach(() => {
|
|
23
28
|
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-epic-worktree-project-config-'));
|
|
24
|
-
|
|
29
|
+
legacyConfigFile = path.join(tmpRoot, 'legacy-config.json');
|
|
25
30
|
originalOverride = process.env.SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH;
|
|
26
|
-
process.env.SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH =
|
|
31
|
+
process.env.SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH = legacyConfigFile;
|
|
27
32
|
});
|
|
28
33
|
|
|
29
34
|
afterEach(() => {
|
|
30
35
|
if (originalOverride === undefined) delete process.env.SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH;
|
|
31
36
|
else process.env.SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH = originalOverride;
|
|
32
37
|
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
38
|
+
while (projectDirs.length) fs.rmSync(projectDirs.pop(), { recursive: true, force: true });
|
|
33
39
|
});
|
|
34
40
|
|
|
35
41
|
function freshModule() {
|
|
36
42
|
return require('../epicWorktreeProjectConfig.cjs');
|
|
37
43
|
}
|
|
38
44
|
|
|
45
|
+
function mkProjectCwd(name) {
|
|
46
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), `sm-epic-worktree-project-${name}-`));
|
|
47
|
+
projectDirs.push(dir);
|
|
48
|
+
return dir;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function perProjectFile(m, cwd) {
|
|
52
|
+
return m.perProjectConfigPath(cwd);
|
|
53
|
+
}
|
|
54
|
+
|
|
39
55
|
test('defaults to not disabled for a project never touched', () => {
|
|
40
56
|
const m = freshModule();
|
|
41
|
-
|
|
57
|
+
const cwd = mkProjectCwd('a');
|
|
58
|
+
expect(m.isEpicWorktreeDisabledForProject(cwd)).toBe(false);
|
|
42
59
|
});
|
|
43
60
|
|
|
44
|
-
test('setEpicWorktreeDisabledForProject(true) persists across a fresh read', () => {
|
|
61
|
+
test('setEpicWorktreeDisabledForProject(true) persists across a fresh read, in the per-project ops file', () => {
|
|
45
62
|
const m = freshModule();
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
expect(
|
|
49
|
-
const
|
|
50
|
-
expect(
|
|
63
|
+
const cwd = mkProjectCwd('a');
|
|
64
|
+
m.setEpicWorktreeDisabledForProject(cwd, true);
|
|
65
|
+
expect(m.isEpicWorktreeDisabledForProject(cwd)).toBe(true);
|
|
66
|
+
const file = perProjectFile(m, cwd);
|
|
67
|
+
expect(fs.existsSync(file)).toBe(true);
|
|
68
|
+
expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toEqual({ disabled: true });
|
|
51
69
|
});
|
|
52
70
|
|
|
53
71
|
test('is scoped per cwd — a different project is unaffected', () => {
|
|
54
72
|
const m = freshModule();
|
|
55
|
-
|
|
56
|
-
|
|
73
|
+
const cwdA = mkProjectCwd('a');
|
|
74
|
+
const cwdB = mkProjectCwd('b');
|
|
75
|
+
m.setEpicWorktreeDisabledForProject(cwdA, true);
|
|
76
|
+
expect(m.isEpicWorktreeDisabledForProject(cwdB)).toBe(false);
|
|
57
77
|
});
|
|
58
78
|
|
|
59
|
-
test('setEpicWorktreeDisabledForProject(false)
|
|
79
|
+
test('setEpicWorktreeDisabledForProject(false) writes disabled:false rather than deleting the file', () => {
|
|
60
80
|
const m = freshModule();
|
|
61
|
-
|
|
62
|
-
m.setEpicWorktreeDisabledForProject(
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
81
|
+
const cwd = mkProjectCwd('a');
|
|
82
|
+
m.setEpicWorktreeDisabledForProject(cwd, true);
|
|
83
|
+
m.setEpicWorktreeDisabledForProject(cwd, false);
|
|
84
|
+
expect(m.isEpicWorktreeDisabledForProject(cwd)).toBe(false);
|
|
85
|
+
const file = perProjectFile(m, cwd);
|
|
86
|
+
expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toEqual({ disabled: false });
|
|
66
87
|
});
|
|
67
88
|
|
|
68
|
-
test('a missing or corrupt config file reads as not-disabled rather than throwing', () => {
|
|
89
|
+
test('a missing or corrupt per-project config file reads as not-disabled rather than throwing', () => {
|
|
69
90
|
const m = freshModule();
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
91
|
+
const cwd = mkProjectCwd('a');
|
|
92
|
+
const file = perProjectFile(m, cwd);
|
|
93
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
94
|
+
fs.writeFileSync(file, '{ not valid json');
|
|
95
|
+
expect(m.isEpicWorktreeDisabledForProject(cwd)).toBe(false);
|
|
73
96
|
});
|
|
74
97
|
|
|
75
98
|
test('isEpicWorktreeDisabledForProject rejects a non-string cwd instead of throwing', () => {
|
|
@@ -77,3 +100,38 @@ test('isEpicWorktreeDisabledForProject rejects a non-string cwd instead of throw
|
|
|
77
100
|
expect(m.isEpicWorktreeDisabledForProject(null)).toBe(false);
|
|
78
101
|
expect(m.isEpicWorktreeDisabledForProject(undefined)).toBe(false);
|
|
79
102
|
});
|
|
103
|
+
|
|
104
|
+
test('one-shot migration: a legacy global entry seeds the new per-project file on first read', () => {
|
|
105
|
+
const m = freshModule();
|
|
106
|
+
const cwd = mkProjectCwd('a');
|
|
107
|
+
fs.writeFileSync(legacyConfigFile, JSON.stringify({ [cwd]: true }));
|
|
108
|
+
|
|
109
|
+
expect(m.isEpicWorktreeDisabledForProject(cwd)).toBe(true);
|
|
110
|
+
const file = perProjectFile(m, cwd);
|
|
111
|
+
expect(fs.existsSync(file)).toBe(true);
|
|
112
|
+
expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toEqual({ disabled: true });
|
|
113
|
+
|
|
114
|
+
// The legacy file itself is left in place, untouched.
|
|
115
|
+
expect(JSON.parse(fs.readFileSync(legacyConfigFile, 'utf8'))).toEqual({ [cwd]: true });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('migration leaves the legacy map intact for OTHER, still-unmigrated projects', () => {
|
|
119
|
+
const m = freshModule();
|
|
120
|
+
const cwdA = mkProjectCwd('a');
|
|
121
|
+
const cwdB = mkProjectCwd('b');
|
|
122
|
+
fs.writeFileSync(legacyConfigFile, JSON.stringify({ [cwdA]: true, [cwdB]: true }));
|
|
123
|
+
|
|
124
|
+
// Touching cwdA migrates only cwdA's own per-project file.
|
|
125
|
+
expect(m.isEpicWorktreeDisabledForProject(cwdA)).toBe(true);
|
|
126
|
+
expect(fs.existsSync(perProjectFile(m, cwdB))).toBe(false);
|
|
127
|
+
|
|
128
|
+
// cwdB still resolves correctly straight from the untouched legacy map.
|
|
129
|
+
expect(m.isEpicWorktreeDisabledForProject(cwdB)).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test('a project with no legacy entry migrates to disabled:false without throwing', () => {
|
|
133
|
+
const m = freshModule();
|
|
134
|
+
const cwd = mkProjectCwd('a');
|
|
135
|
+
fs.writeFileSync(legacyConfigFile, JSON.stringify({ '/some/other/project': true }));
|
|
136
|
+
expect(m.isEpicWorktreeDisabledForProject(cwd)).toBe(false);
|
|
137
|
+
});
|
|
@@ -847,34 +847,28 @@ test('[epic] SM_EPIC_WORKTREE_DISABLE=1 restores in-place behaviour independentl
|
|
|
847
847
|
|
|
848
848
|
test('[epic] the per-project UI toggle (epicWorktreeProjectConfig.cjs) disables isolation for just that cwd', async () => {
|
|
849
849
|
const projectConfig = require('../epicWorktreeProjectConfig.cjs');
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
try {
|
|
853
|
-
projectConfig.setEpicWorktreeDisabledForProject(repoCwd, true);
|
|
854
|
-
expect(projectConfig.isEpicWorktreeDisabledForProject(repoCwd)).toBe(true);
|
|
850
|
+
projectConfig.setEpicWorktreeDisabledForProject(repoCwd, true);
|
|
851
|
+
expect(projectConfig.isEpicWorktreeDisabledForProject(repoCwd)).toBe(true);
|
|
855
852
|
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
if (originalOverride === undefined) delete process.env.SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH;
|
|
876
|
-
else process.env.SM_EPIC_WORKTREE_PROJECT_CONFIG_PATH = originalOverride;
|
|
877
|
-
}
|
|
853
|
+
const result = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId: 'toggle-x' });
|
|
854
|
+
expect(result.ok).toBe(false);
|
|
855
|
+
expect(result.reason).toMatch(/isolation toggle/);
|
|
856
|
+
|
|
857
|
+
// A different, untouched project cwd is unaffected — the toggle is
|
|
858
|
+
// per-project (its own <cwd>/session-manager-operations/prompt-sessions/
|
|
859
|
+
// epic-worktree-config.json), not machine-wide like the env var.
|
|
860
|
+
const otherCwd = path.join(tmpRoot, 'other-repo');
|
|
861
|
+
initRepo(otherCwd);
|
|
862
|
+
const otherResult = await gitWorktree.createEpicWorktree({ cwd: otherCwd, epicId: 'toggle-y' });
|
|
863
|
+
expect(otherResult.ok).toBe(true);
|
|
864
|
+
await gitWorktree.cleanupEpicWorktree({ cwd: otherCwd, dir: otherResult.dir, branch: otherResult.branch });
|
|
865
|
+
|
|
866
|
+
// Turning it back off restores isolation for the original project.
|
|
867
|
+
projectConfig.setEpicWorktreeDisabledForProject(repoCwd, false);
|
|
868
|
+
expect(projectConfig.isEpicWorktreeDisabledForProject(repoCwd)).toBe(false);
|
|
869
|
+
const restored = await gitWorktree.createEpicWorktree({ cwd: repoCwd, epicId: 'toggle-z' });
|
|
870
|
+
expect(restored.ok).toBe(true);
|
|
871
|
+
await gitWorktree.cleanupEpicWorktree({ cwd: repoCwd, dir: restored.dir, branch: restored.branch });
|
|
878
872
|
});
|
|
879
873
|
|
|
880
874
|
test('[epic] reconcileWorktreesOnBoot removes a leaked epic worktree with no liveness predicate given', async () => {
|
|
@@ -112,14 +112,21 @@ test('writeGuardShims: rewriting the pointer to a DIFFERENT app root lets an alr
|
|
|
112
112
|
const shimBytesBefore = fs.readFileSync(shimFile, 'utf8');
|
|
113
113
|
|
|
114
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
|
|
116
|
-
//
|
|
115
|
+
// its own copy of scripts/hooks/guard-prd-writes.cjs — the file guardShims
|
|
116
|
+
// reads through the pointer at invocation time — plus the sibling policy
|
|
117
|
+
// module it `require()`s at load time (guard-prd-writes-policy.cjs, PRD
|
|
118
|
+
// 1415); a real upgrade ships both since both are listed in package.json's
|
|
119
|
+
// "files".
|
|
117
120
|
const secondAppRoot = await mkTmp('sm-guard-shims-upgraded-app-');
|
|
118
|
-
await fsp.mkdir(path.join(secondAppRoot, 'scripts', 'hooks'), { recursive: true });
|
|
121
|
+
await fsp.mkdir(path.join(secondAppRoot, 'scripts', 'hooks', 'lib'), { recursive: true });
|
|
119
122
|
await fsp.copyFile(
|
|
120
123
|
path.join(APP_ROOT, 'scripts', 'hooks', 'guard-prd-writes.cjs'),
|
|
121
124
|
path.join(secondAppRoot, 'scripts', 'hooks', 'guard-prd-writes.cjs'),
|
|
122
125
|
);
|
|
126
|
+
await fsp.copyFile(
|
|
127
|
+
path.join(APP_ROOT, 'scripts', 'hooks', 'lib', 'guard-prd-writes-policy.cjs'),
|
|
128
|
+
path.join(secondAppRoot, 'scripts', 'hooks', 'lib', 'guard-prd-writes-policy.cjs'),
|
|
129
|
+
);
|
|
123
130
|
|
|
124
131
|
const r = await writeGuardShims({ homeDir, appRoot: secondAppRoot });
|
|
125
132
|
expect(r.ok).toBe(true);
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// batteries. These tests pin the pure decision, the audit rate-limit and the
|
|
10
10
|
// escalation, with loadavg/cores/clock all injected.
|
|
11
11
|
const assert = require('node:assert/strict');
|
|
12
|
-
const { isLoadGated, createLoadGate, AUDIT_INTERVAL_MS } = require('../loadGate.cjs');
|
|
12
|
+
const { isLoadGated, createLoadGate, topCpuConsumers, AUDIT_INTERVAL_MS } = require('../loadGate.cjs');
|
|
13
13
|
const { loadGateThreshold, LOAD_GATE_PER_CORE } = require('../schedulerConfig.cjs');
|
|
14
14
|
|
|
15
15
|
const ORIGINAL_ENV = process.env.SM_LOAD_GATE_PER_CORE;
|
|
@@ -65,6 +65,49 @@ test('SM_LOAD_GATE_PER_CORE is honored and clamped to [0.25, 4]; 0 disables; gar
|
|
|
65
65
|
assert.equal(loadGateThreshold(), 0.85);
|
|
66
66
|
});
|
|
67
67
|
|
|
68
|
+
// ─── topCpuConsumers: platform-correct ps invocation ───────────────────────
|
|
69
|
+
//
|
|
70
|
+
// The audit line the scheduler warn-logs when the load gate escalates. Purely
|
|
71
|
+
// diagnostic (nothing branches on it) but it was blank on macOS: the code only
|
|
72
|
+
// ran the GNU `ps --sort` form, which BSD ps rejects with "illegal option",
|
|
73
|
+
// so every Mac logged `top CPU: n/a` exactly when the operator needed to know
|
|
74
|
+
// what was saturating the box.
|
|
75
|
+
|
|
76
|
+
test('Linux uses the GNU ps form (-eo … --sort=-pcpu) and skips the header row', () => {
|
|
77
|
+
const calls = [];
|
|
78
|
+
const fakeExec = (bin, args) => {
|
|
79
|
+
calls.push({ bin, args });
|
|
80
|
+
return 'PID %CPU COMMAND\n 1 90.0 node\n 2 10.0 tsc\n';
|
|
81
|
+
};
|
|
82
|
+
const rows = topCpuConsumers(2, { execImpl: fakeExec, platform: 'linux' });
|
|
83
|
+
assert.deepEqual(calls[0], { bin: 'ps', args: ['-eo', 'pid,pcpu,comm', '--sort=-pcpu'] });
|
|
84
|
+
assert.deepEqual(rows, ['1 90.0 node', '2 10.0 tsc']);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('macOS uses the BSD ps form (-Aco … -r), NOT the GNU --sort that BSD ps rejects', () => {
|
|
88
|
+
const calls = [];
|
|
89
|
+
const fakeExec = (bin, args) => {
|
|
90
|
+
calls.push({ bin, args });
|
|
91
|
+
return ' PID %CPU COMM\n 847 94.4 suggestd\n 443 15.1 WindowServer\n';
|
|
92
|
+
};
|
|
93
|
+
const rows = topCpuConsumers(3, { execImpl: fakeExec, platform: 'darwin' });
|
|
94
|
+
assert.deepEqual(calls[0].args, ['-Aco', 'pid,pcpu,comm', '-r']);
|
|
95
|
+
assert.ok(!calls[0].args.some((a) => String(a).includes('--sort')), 'must not pass the GNU --sort to BSD ps');
|
|
96
|
+
assert.deepEqual(rows, ['847 94.4 suggestd', '443 15.1 WindowServer']);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('an unsupported platform returns [] without ever spawning ps', () => {
|
|
100
|
+
let spawned = false;
|
|
101
|
+
const rows = topCpuConsumers(3, { execImpl: () => { spawned = true; return ''; }, platform: 'win32' });
|
|
102
|
+
assert.deepEqual(rows, []);
|
|
103
|
+
assert.equal(spawned, false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('a ps failure (throw) degrades to [] rather than propagating — the audit line is best-effort', () => {
|
|
107
|
+
const rows = topCpuConsumers(3, { execImpl: () => { throw new Error('boom'); }, platform: 'darwin' });
|
|
108
|
+
assert.deepEqual(rows, []);
|
|
109
|
+
});
|
|
110
|
+
|
|
68
111
|
// ─── createLoadGate: decision, 1-minute-only, bypass ───────────────────────
|
|
69
112
|
|
|
70
113
|
function gateWith({ l1 = 12.95, l5 = 0, l15 = 0, cores = 14, threshold = 0.85, clock }) {
|