claude-code-session-manager 0.95.0 → 0.97.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-DZK9QZpV.js → DataModel-D93FoaOm.js} +1 -1
- package/dist/assets/History-CXxxqNP1.js +2 -0
- package/dist/assets/{Hooks-CmK6l0uf.js → Hooks-CzeYFQjq.js} +3 -3
- package/dist/assets/{HostBilko-D8TIx-Bx.js → HostBilko-nESrTRIg.js} +1 -1
- package/dist/assets/{Library-DTxpOjx8.js → Library-D2zPkwOC.js} +1 -1
- package/dist/assets/{MarkdownEditor-BPJyrevq.js → MarkdownEditor-DbinP78A.js} +1 -1
- package/dist/assets/{McpServers-B_rvIYDs.js → McpServers-Bu_sVGLr.js} +2 -2
- package/dist/assets/Memory-BgiI6LZ_.js +8 -0
- package/dist/assets/{Permissions-DhMMp27n.js → Permissions-C1MMzZRm.js} +3 -3
- package/dist/assets/Plugins-Dhu6wAZi.js +2 -0
- package/dist/assets/{ProvenanceBadge-ptlItdCK.js → ProvenanceBadge-C3AcWKMG.js} +1 -1
- package/dist/assets/{SaveBar-CSbNSl2W.js → SaveBar-plwYm0iO.js} +1 -1
- package/dist/assets/Scheduler-BVYTeh39.js +16 -0
- package/dist/assets/{ScopeSwitcher-BJof-GWH.js → ScopeSwitcher-DFy0vv_o.js} +1 -1
- package/dist/assets/Settings-DIhGgdfs.js +3 -0
- package/dist/assets/{SkillReferenceGraph-CGl4B89-.js → SkillReferenceGraph-DsI7hRPB.js} +1 -1
- package/dist/assets/Skills-3E748UfB.js +3 -0
- package/dist/assets/{SystemPrompt-DKj-q39J.js → SystemPrompt-BqmfUJOd.js} +1 -1
- package/dist/assets/TagLibrary-CPz6fO8Q.js +1 -0
- package/dist/assets/{TiptapBody-BbcJL9az.js → TiptapBody-D9EoBQ1P.js} +1 -1
- package/dist/assets/{Toggle-DAslvnnv.js → Toggle-BHAmZNDn.js} +1 -1
- package/dist/assets/index-tn5JIVPj.js +3079 -0
- package/dist/assets/{settingsSchema-pzp0TgvN.js → settingsSchema-Crqy_aNz.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +8 -2
- package/plugins/CLAUDE.md +3 -3
- package/plugins/session-manager-dev/skills/develop/SKILL.md +90 -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 +1 -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__/ipcSchemas-dependsOn.test.cjs +39 -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 +125 -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-shard-quarantine.test.cjs +6 -1
- 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/exchanges.cjs +26 -21
- package/src/main/health.cjs +66 -4
- package/src/main/index.cjs +1 -1
- package/src/main/ipcSchemas.cjs +15 -7
- 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__/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/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/opsOwnership.cjs +23 -0
- package/src/main/lib/planValidator.cjs +80 -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/validationSentinels.cjs +29 -0
- package/src/main/memoryAggregate.cjs +36 -11
- package/src/main/otelSettings.cjs +8 -2
- package/src/main/scheduler.cjs +215 -31
- 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/voiceSettings.cjs +8 -2
- package/src/preload/api.d.ts +3 -3
- 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-BDyJUGtR.js +0 -2
- package/dist/assets/Memory-B-9GlHqe.js +0 -8
- package/dist/assets/Plugins-DKfAlmMY.js +0 -2
- package/dist/assets/Scheduler--rn1QBYc.js +0 -16
- package/dist/assets/Settings-Dsx6XZP5.js +0 -3
- package/dist/assets/Skills-D522Zfol.js +0 -3
- package/dist/assets/TagLibrary-BH7CG7-6.js +0 -1
- package/dist/assets/index-Yf8e7VCB.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
|
|
|
@@ -627,6 +627,50 @@ function evaluateBuildFreshness({ running = null, installedOnDisk = null, repoHe
|
|
|
627
627
|
return { ok: true, running, installedOnDisk, repoHead, restartNeeded, publishNeeded, ...(notes.length ? { note: notes.join('; ') } : {}) };
|
|
628
628
|
}
|
|
629
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
|
+
|
|
630
674
|
/**
|
|
631
675
|
* evaluateStarveEscalationHealth(jobs, now, thresholdMs, escalationReasons)
|
|
632
676
|
* → { ok, projects?, message? }
|
|
@@ -1138,15 +1182,16 @@ async function check(opts = {}) {
|
|
|
1138
1182
|
// `config`, same home as every other scheduler machine setting.
|
|
1139
1183
|
try {
|
|
1140
1184
|
const retentionCfg = queueState?.config?.schedulerRunLogRetention;
|
|
1141
|
-
const
|
|
1185
|
+
const runsDir = schedulerPaths.runsDir();
|
|
1186
|
+
const liveKeys = liveKeysFromJobs(queueState?.jobs || [], { runsDir });
|
|
1142
1187
|
const report = computeReport(
|
|
1143
|
-
|
|
1188
|
+
runsDir,
|
|
1144
1189
|
retentionCfg?.policy || {},
|
|
1145
1190
|
{ liveKeys }
|
|
1146
1191
|
);
|
|
1147
1192
|
status.components.run_log_retention = {
|
|
1148
1193
|
ok: true,
|
|
1149
|
-
path:
|
|
1194
|
+
path: runsDir,
|
|
1150
1195
|
totalBytes: report.usage.totalBytes,
|
|
1151
1196
|
dirCount: report.usage.dirCount,
|
|
1152
1197
|
runCount: report.usage.runCount,
|
|
@@ -1205,6 +1250,22 @@ async function check(opts = {}) {
|
|
|
1205
1250
|
status.components.build = { ok: true, error: e.message };
|
|
1206
1251
|
}
|
|
1207
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
|
+
|
|
1208
1269
|
// 7. Summary scoring: ok if all critical components pass.
|
|
1209
1270
|
// Critical: nodejs, config dir, typescript, build artifact, test infrastructure.
|
|
1210
1271
|
// Non-fatal: scheduler/transcripts dirs may not exist on fresh install.
|
|
@@ -1231,6 +1292,7 @@ module.exports = {
|
|
|
1231
1292
|
readFreshHeartbeat,
|
|
1232
1293
|
evaluatePrdMigrationHealth,
|
|
1233
1294
|
evaluateDelegationChainHealth,
|
|
1295
|
+
evaluateCredentialHealth,
|
|
1234
1296
|
computeEpicIndexDrift,
|
|
1235
1297
|
evaluateEpicIndexHealth,
|
|
1236
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
|
@@ -322,10 +322,14 @@ const PrdAgentTypeSchema = z.string().regex(PERSONA_NAME_RE, 'agent name must be
|
|
|
322
322
|
// Shared shape for a `dependsOn` slug entry — schedulerCreatePrd and
|
|
323
323
|
// adminUpdatePrd (scheduler_update_prd, PRD 1124) must accept the exact same
|
|
324
324
|
// slug shape or an update could write a dependsOn value create would have
|
|
325
|
-
// rejected. Array-level constraints (`.max(
|
|
326
|
-
// call site since update also allows an explicit empty array to
|
|
327
|
-
// dependency, which create has no reason to accept.
|
|
325
|
+
// rejected. Array-level constraints (`.max(MAX_DEPENDS_ON)`, `.optional()`)
|
|
326
|
+
// stay at each call site since update also allows an explicit empty array to
|
|
327
|
+
// CLEAR the dependency, which create has no reason to accept.
|
|
328
328
|
const DepSlugSchema = z.string().min(1).max(160).regex(/^[A-Za-z0-9][\w.-]*$/);
|
|
329
|
+
// Array-level cap shared by every dependsOn field below — a validate PRD in a
|
|
330
|
+
// large /develop plan depends on every other slug in the plan, so this must
|
|
331
|
+
// stay well above the largest plan size the PRD authoring flow allows.
|
|
332
|
+
const MAX_DEPENDS_ON = 100;
|
|
329
333
|
// Wave-authoring decision (scheduler wave-disposition PRD), shared between
|
|
330
334
|
// schedulerCreatePrd (authoring time) and adminPrdFrontmatterPatch (the
|
|
331
335
|
// after-the-fact change path) so both can never disagree on what a valid
|
|
@@ -350,7 +354,7 @@ const schedulerCreatePrd = z.object({
|
|
|
350
354
|
parallelGroup: z.number().int().min(1).max(999999).optional(),
|
|
351
355
|
// Explicit ordering (PRD 832): slugs that must complete before this PRD
|
|
352
356
|
// becomes eligible. Written to frontmatter as `dependsOn: [a, b]`.
|
|
353
|
-
dependsOn: z.array(DepSlugSchema).max(
|
|
357
|
+
dependsOn: z.array(DepSlugSchema).max(MAX_DEPENDS_ON).optional(),
|
|
354
358
|
// Explicit wave-authoring decision (scheduler wave-disposition PRD) — only
|
|
355
359
|
// meaningful (and only stamped) when this PRD joins an Epic that already
|
|
356
360
|
// has incomplete PRDs and lands as a wave root (no dependsOn of its own);
|
|
@@ -417,7 +421,7 @@ const scheduleSetPrdDisposition = z.object({
|
|
|
417
421
|
slug: z.string().regex(SCHEDULE_SLUG_RE),
|
|
418
422
|
cwd: z.string().min(1).max(4096).optional(),
|
|
419
423
|
disposition: PrdDispositionSchema,
|
|
420
|
-
dependsOn: z.array(DepSlugSchema).max(
|
|
424
|
+
dependsOn: z.array(DepSlugSchema).max(MAX_DEPENDS_ON).optional(),
|
|
421
425
|
});
|
|
422
426
|
|
|
423
427
|
// Bulk archive: slug list, capped to limit unbounded retag/archive payloads.
|
|
@@ -501,7 +505,7 @@ const adminPrdFrontmatterPatch = z.object({
|
|
|
501
505
|
// non-empty array is validated write-time against the SAME resolver
|
|
502
506
|
// (depSlugResolve.cjs) scheduler_create_prd uses, so update and create
|
|
503
507
|
// can never disagree about what a dependsOn entry resolves to.
|
|
504
|
-
dependsOn: z.array(DepSlugSchema).max(
|
|
508
|
+
dependsOn: z.array(DepSlugSchema).max(MAX_DEPENDS_ON).optional(),
|
|
505
509
|
// Patchable after creation too — the Scheduler UI's "change disposition"
|
|
506
510
|
// action (promote a wave to its own head, or attach it behind another
|
|
507
511
|
// chain) rewrites this alongside dependsOn via the same
|
|
@@ -632,10 +636,14 @@ const memoryStale = z.object({
|
|
|
632
636
|
// the encoded cwd slug (memoryAggregate.cjs reads directly from
|
|
633
637
|
// ~/.claude/projects/<workspace>/memory/), same regex as the other memory:*
|
|
634
638
|
// handlers. `refresh: true` is the cost gate that fires the single claude -p
|
|
635
|
-
// clustering pass; falsy returns the cached result.
|
|
639
|
+
// clustering pass; falsy returns the cached result. `cwd` (PRD 1389,
|
|
640
|
+
// optional — same shape as memory:stale's) addresses the per-project cache
|
|
641
|
+
// at <cwd>/session-manager-operations/memory-clusters/clusters.json;
|
|
642
|
+
// absent/invalid cwd just disables caching for that call.
|
|
636
643
|
const memoryAggregate = z.object({
|
|
637
644
|
workspace: z.string().regex(MEMORY_WORKSPACE_RE),
|
|
638
645
|
refresh: z.boolean().optional(),
|
|
646
|
+
cwd: z.string().max(4096).optional(),
|
|
639
647
|
}).strict();
|
|
640
648
|
|
|
641
649
|
// ──────────────────────────────────────────── 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 }) {
|
|
@@ -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`);
|