claude-code-session-manager 0.87.0 → 0.88.1
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/README.md +26 -53
- package/dist/assets/{AgentLibrary-DyLWzZDf.js → AgentLibrary-kQF8_wAw.js} +1 -1
- package/dist/assets/{DataModel--mISIJ6h.js → DataModel-CY_mMnsr.js} +1 -1
- package/dist/assets/{History-C2ahUXTg.js → History-Sji2brb0.js} +1 -1
- package/dist/assets/{Hooks-BiC6oyR2.js → Hooks-CtqpO-p5.js} +1 -1
- package/dist/assets/{HostBilko-BPleEOld.js → HostBilko-DN0ydOCr.js} +1 -1
- package/dist/assets/{Library-Dc8Qst1R.js → Library-CoiLZTKh.js} +1 -1
- package/dist/assets/{ListDetail-DIXh-OLX.js → ListDetail-CmqiQ_io.js} +1 -1
- package/dist/assets/{MarkdownEditor-C90bkLXK.js → MarkdownEditor-HPoNeKhT.js} +1 -1
- package/dist/assets/{McpServers-DqcbLOLZ.js → McpServers-Bj1X3_NT.js} +1 -1
- package/dist/assets/{Memory-CW62MXlh.js → Memory-1PKjsM3b.js} +1 -1
- package/dist/assets/{Panel-Bw1FhRuF.js → Panel-I1s6ghPo.js} +1 -1
- package/dist/assets/{Permissions-BcUC-5y8.js → Permissions-BhiiZjHQ.js} +1 -1
- package/dist/assets/{Plugins-BnKx9flD.js → Plugins-gv3_fkPX.js} +2 -2
- package/dist/assets/{ProvenanceBadge-Bw5vNVPT.js → ProvenanceBadge-BGcgBd8V.js} +1 -1
- package/dist/assets/{SaveBar-CWr0O_w-.js → SaveBar-BGr9eZ4g.js} +1 -1
- package/dist/assets/{Scheduler-DYdLuUqq.js → Scheduler-Bdo_awPq.js} +7 -7
- package/dist/assets/{ScopeSwitcher-CrBLbg8s.js → ScopeSwitcher-ukl0qWaA.js} +1 -1
- package/dist/assets/{Settings-DluB-vN1.js → Settings-DJkFj22G.js} +1 -1
- package/dist/assets/{SkillReferenceGraph-CHLSseay.js → SkillReferenceGraph-0t1Qblp6.js} +1 -1
- package/dist/assets/{Skills-gNdo_HNK.js → Skills-CeVdBGE-.js} +1 -1
- package/dist/assets/{SystemPrompt-Cru05-Ia.js → SystemPrompt-CfKvNHi3.js} +1 -1
- package/dist/assets/{TagLibrary-DNHY0xou.js → TagLibrary-DAzNaUPd.js} +1 -1
- package/dist/assets/{TiptapBody-I4lmbCgP.js → TiptapBody--S_rGXoA.js} +1 -1
- package/dist/assets/{Toggle-bWMHjmRh.js → Toggle-Dszm0xnp.js} +1 -1
- package/dist/assets/{index-DV3PorRY.css → index-BHTX4OTc.css} +1 -1
- package/dist/assets/{index-fc_JjdxL.js → index-Dp4Rc--q.js} +356 -356
- package/dist/assets/{settingsSchema-BfhtZnGD.js → settingsSchema-BLbssMYo.js} +1 -1
- package/dist/assets/{whisperWorker-Dbia1OpC.js → whisperWorker-C7ZGQwKg.js} +7 -7
- package/dist/index.html +2 -2
- package/dist/vad/ort-wasm-simd-threaded.asyncify.mjs +106 -110
- package/dist/vad/ort-wasm-simd-threaded.asyncify.wasm +0 -0
- package/dist/vad/ort-wasm-simd-threaded.jsep.mjs +98 -98
- package/dist/vad/ort-wasm-simd-threaded.jsep.wasm +0 -0
- package/dist/vad/ort-wasm-simd-threaded.jspi.mjs +99 -102
- package/dist/vad/ort-wasm-simd-threaded.jspi.wasm +0 -0
- package/dist/vad/ort-wasm-simd-threaded.mjs +46 -46
- package/dist/vad/ort-wasm-simd-threaded.wasm +0 -0
- package/package.json +10 -13
- package/scripts/README.md +59 -0
- package/scripts/audit-ops-hygiene.cjs +350 -0
- package/scripts/hooks/guard-destructive-git.cjs +10 -34
- package/scripts/hooks/guard-inline-implementation.cjs +14 -8
- package/scripts/hooks/guard-prd-writes.cjs +7 -36
- package/scripts/hooks/guard-self-schedule.cjs +175 -0
- package/scripts/ops-sweep.cjs +355 -0
- package/scripts/scheduler-mcp-server.cjs +28 -71
- package/src/main/__tests__/bilkoHost-integration.test.cjs +3 -3
- package/src/main/__tests__/chat-cancel-terminal.test.cjs +6 -9
- package/src/main/__tests__/chat-exit-close-race.test.cjs +3 -3
- package/src/main/__tests__/chat-mcp-consent-notice.test.cjs +4 -5
- package/src/main/__tests__/chat-queue.test.cjs +2 -2
- package/src/main/__tests__/chat-stop-signal.test.cjs +2 -2
- package/src/main/__tests__/dep-orphan-archive-health.test.cjs +77 -0
- package/src/main/__tests__/dod-batchkey.test.cjs +2 -2
- package/src/main/__tests__/dod-drain-hook.test.cjs +2 -2
- package/src/main/__tests__/dod-report.test.cjs +2 -2
- package/src/main/__tests__/dod-reverify.test.cjs +2 -2
- package/src/main/__tests__/epicMint.test.cjs +2 -2
- package/src/main/__tests__/exchanges.test.cjs +2 -2
- package/src/main/__tests__/extractJson.test.cjs +2 -2
- package/src/main/__tests__/files-reject-credentials.test.cjs +1 -1
- package/src/main/__tests__/fixtures/1218-fo-01-move-scripts-lib-into-src-main-lib.log +556 -0
- package/src/main/__tests__/health-build-freshness.test.cjs +39 -0
- package/src/main/__tests__/health-delegation-chain.test.cjs +15 -1
- package/src/main/__tests__/health-queue-dispatch.test.cjs +58 -7
- package/src/main/__tests__/health-tick-liveness.test.cjs +27 -3
- package/src/main/__tests__/health-usage-poller.test.cjs +70 -23
- package/src/main/__tests__/historyRollup.test.cjs +2 -2
- package/src/main/__tests__/kg-augment.test.cjs +2 -2
- package/src/main/__tests__/mcpStatus.test.cjs +2 -2
- package/src/main/__tests__/memoryAggregate.test.cjs +1 -1
- package/src/main/__tests__/memoryStale.test.cjs +1 -1
- package/src/main/__tests__/opsErrorLogTelemetryTap.test.cjs +25 -1
- package/src/main/__tests__/pollLoop-dispatch-on-failure.test.cjs +33 -3
- package/src/main/__tests__/prd-group-allocator.test.cjs +2 -2
- package/src/main/__tests__/prdAdminRouteParity.test.cjs +2 -0
- package/src/main/__tests__/prdAdminRoutes.test.cjs +14 -2
- package/src/main/__tests__/prdAuthoringSeed.test.cjs +39 -0
- package/src/main/__tests__/prdLocationsArchived.test.cjs +20 -13
- package/src/main/__tests__/proc-role-env.test.cjs +125 -0
- package/src/main/__tests__/procname-claude-spawn-sites.test.cjs +304 -0
- package/src/main/__tests__/procname-sm-processes.test.cjs +127 -0
- package/src/main/__tests__/projectHomeAdminRoutes.test.cjs +81 -401
- package/src/main/__tests__/projectPages.test.cjs +63 -149
- package/src/main/__tests__/queue-health-verdict.test.cjs +9 -9
- package/src/main/__tests__/queue-starvation-dispatch-driver.test.cjs +117 -20
- package/src/main/__tests__/queueHistory.test.cjs +2 -2
- package/src/main/__tests__/rateLimitPollerStreak.test.cjs +38 -3
- package/src/main/__tests__/runVerify-landed-commit-outranks.test.cjs +181 -0
- package/src/main/__tests__/runVerify.test.cjs +5 -5
- package/src/main/__tests__/scheduleJobStatusDrift.test.cjs +3 -3
- package/src/main/__tests__/scheduleJobTransitions.test.cjs +2 -2
- package/src/main/__tests__/scheduler-adopted-run-supervision.test.cjs +143 -0
- package/src/main/__tests__/scheduler-autofix-select.test.cjs +2 -2
- package/src/main/__tests__/scheduler-autopromote.test.cjs +2 -2
- package/src/main/__tests__/scheduler-bash-timeout-env.test.cjs +3 -5
- package/src/main/__tests__/scheduler-boot-orphans.test.cjs +78 -97
- package/src/main/__tests__/scheduler-default-eligible-heal.test.cjs +161 -0
- package/src/main/__tests__/scheduler-dispatch-loop.test.cjs +58 -0
- package/src/main/__tests__/scheduler-epic-digest.test.cjs +3 -5
- package/src/main/__tests__/scheduler-force-tick-outcome.test.cjs +2 -2
- package/src/main/__tests__/scheduler-gate-shadow.test.cjs +119 -0
- package/src/main/__tests__/scheduler-guard-verdict-autoresolve.test.cjs +46 -0
- package/src/main/__tests__/scheduler-heartbeat-payload.test.cjs +80 -0
- package/src/main/__tests__/scheduler-inplace-salvage.test.cjs +25 -18
- package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +4 -4
- package/src/main/__tests__/scheduler-launch-failure.test.cjs +3 -5
- package/src/main/__tests__/scheduler-looks-done.test.cjs +26 -7
- package/src/main/__tests__/scheduler-manual-pause.test.cjs +118 -0
- package/src/main/__tests__/scheduler-meta-code-sha.test.cjs +26 -3
- package/src/main/__tests__/scheduler-prd-missing-skip.test.cjs +17 -2
- package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +3 -5
- package/src/main/__tests__/scheduler-quiet-machine-lease.test.cjs +41 -6
- package/src/main/__tests__/scheduler-rate-limit-pause.test.cjs +62 -6
- package/src/main/__tests__/scheduler-rate-limit-spin-guard.test.cjs +3 -5
- package/src/main/__tests__/scheduler-reap-dead-running-jobs.test.cjs +23 -4
- package/src/main/__tests__/scheduler-reconcile-cwd-preserve.test.cjs +100 -0
- package/src/main/__tests__/scheduler-reconcile-invalid-repair.test.cjs +3 -3
- package/src/main/__tests__/scheduler-reconcile-quarantine.test.cjs +4 -4
- package/src/main/__tests__/scheduler-shard-quarantine.test.cjs +110 -0
- package/src/main/__tests__/scheduler-shared-tree-guard.test.cjs +81 -1
- package/src/main/__tests__/scheduler-stall-per-project.test.cjs +14 -0
- package/src/main/__tests__/scheduler-starve-escalation.test.cjs +4 -4
- package/src/main/__tests__/scheduler-stranded-autofix-park.test.cjs +245 -0
- package/src/main/__tests__/scheduler-supervisor-record.test.cjs +81 -0
- package/src/main/__tests__/scheduler-tick-cancel-token.test.cjs +2 -2
- package/src/main/__tests__/scheduler-tick-wedge.test.cjs +172 -0
- package/src/main/__tests__/scheduler-transient-failure.test.cjs +2 -2
- package/src/main/__tests__/scheduler-worktree-cap-defer.test.cjs +37 -11
- package/src/main/__tests__/scheduler-worktree-exec-cwd.test.cjs +3 -5
- package/src/main/__tests__/usageSingleFlight.test.cjs +157 -0
- package/src/main/__tests__/workTypeLibrary.test.cjs +1 -1
- package/src/main/bilkoHost.cjs +16 -7
- package/src/main/build-info.json +8 -0
- package/src/main/chatRunner.cjs +4 -2
- package/src/main/config.cjs +2 -3
- package/src/main/docEdit.cjs +4 -2
- package/src/main/health.cjs +276 -52
- package/src/main/heapSnapshot.cjs +2 -2
- package/src/main/historyAggregator.cjs +1 -1
- package/src/main/index.cjs +61 -10
- package/src/main/ipcSchemas.cjs +6 -17
- package/src/main/lib/__tests__/auditLog.test.cjs +38 -0
- package/src/main/lib/__tests__/buildIdentity.test.cjs +121 -0
- package/src/main/lib/__tests__/cwdClassify.test.cjs +111 -0
- package/src/main/lib/__tests__/definitionOfDoneSequence.test.cjs +95 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +1 -1
- package/src/main/lib/__tests__/dispatchLoop.test.cjs +63 -0
- package/src/main/lib/__tests__/gateFixtures.json +20 -0
- package/src/main/lib/__tests__/gitWorktree.test.cjs +8 -1
- package/src/main/lib/__tests__/instanceLock.test.cjs +93 -7
- package/src/main/lib/__tests__/jobSupervisorRecord.test.cjs +78 -0
- package/src/main/lib/__tests__/jobWorktreeBootLive.test.cjs +11 -0
- package/src/main/lib/__tests__/localAdminHttp.test.cjs +1 -1
- package/src/main/lib/__tests__/mcpToolCatalog.test.cjs +6 -1
- package/src/main/lib/__tests__/procIdentity.test.cjs +119 -0
- package/src/main/lib/__tests__/procName.test.cjs +92 -0
- package/src/main/lib/__tests__/queueStoreMachineStateRecovery.test.cjs +67 -0
- package/src/main/lib/__tests__/schedulerMcpServerHelp.test.cjs +6 -7
- package/src/main/lib/__tests__/schedulerMcpServerProjectHome.test.cjs +37 -204
- package/src/main/lib/__tests__/schedulerPaths.test.cjs +226 -0
- package/src/main/lib/__tests__/schedulerPathsWorktree.test.cjs +94 -0
- package/src/main/lib/__tests__/schedulerRuntimeState.test.cjs +56 -0
- package/src/main/lib/__tests__/sessionSlots.test.cjs +45 -0
- package/src/main/lib/__tests__/telemetryBacklog.test.cjs +1 -1
- package/src/main/lib/__tests__/upgradeDrain.test.cjs +130 -0
- package/src/main/lib/__tests__/usageCircuit.test.cjs +61 -0
- package/src/main/lib/__tests__/watchdog-helpers.test.cjs +63 -0
- package/src/main/lib/__tests__/watchdog-relaunch.test.cjs +73 -0
- package/src/main/lib/activeIndexRebuild.cjs +1 -3
- package/src/main/lib/activeSessions.cjs +20 -88
- package/src/main/lib/adoptedRunSupervisor.cjs +136 -0
- package/src/main/lib/agentModelResolve.cjs +33 -1
- package/src/main/lib/agentPersonaSchema.cjs +2 -2
- package/src/main/lib/auditLog.cjs +30 -5
- package/src/main/lib/buildIdentity.cjs +113 -0
- package/src/main/lib/classifyPromptTicket.cjs +3 -2
- package/src/main/lib/claudeBin.cjs +37 -2
- package/src/main/lib/cleanEnv.cjs +27 -1
- package/src/main/lib/credentials.cjs +4 -2
- package/src/main/lib/cwdClassify.cjs +185 -0
- package/src/main/lib/definitionOfDone.cjs +296 -52
- package/src/main/lib/dispatchLoop.cjs +40 -0
- package/src/main/lib/effectiveModelInfo.cjs +9 -10
- package/src/main/lib/ephemeralCwd.cjs +7 -28
- package/src/main/lib/gitWorktree.cjs +95 -8
- package/src/main/lib/guardShims.cjs +3 -3
- package/src/main/lib/historyRollup.cjs +6 -7
- package/src/main/lib/instanceLock.cjs +32 -5
- package/src/main/lib/jobSupervisorRecord.cjs +147 -0
- package/src/main/lib/jobWorktreeBootLive.cjs +9 -5
- package/src/main/lib/localAdminHttp.cjs +9 -27
- package/src/main/lib/mcpToolCatalog.cjs +29 -77
- package/src/main/lib/opsOwnership.cjs +27 -19
- package/src/main/lib/prdAuthoringSeed.cjs +36 -0
- package/src/main/lib/prdLocations.cjs +48 -1
- package/src/main/lib/procIdentity.cjs +126 -0
- package/src/main/lib/procName.cjs +98 -0
- package/src/main/lib/projectHomeAdminRoutes.cjs +56 -327
- package/src/main/lib/queueHistory.cjs +8 -7
- package/src/main/lib/queueStore.cjs +57 -37
- package/src/main/lib/quietMachineLease.cjs +19 -2
- package/src/main/lib/reservationExpiry.cjs +30 -0
- package/src/main/lib/runClaudeP.cjs +4 -2
- package/src/main/lib/runLogRetention.cjs +3 -2
- package/src/main/lib/scheduleJobSchema.cjs +2 -2
- package/src/main/lib/scheduleJobTransitions.cjs +29 -2
- package/src/main/lib/schedulerBatch.cjs +27 -2
- package/src/main/lib/schedulerPaths.cjs +175 -0
- package/src/main/lib/schedulerRuntimeState.cjs +59 -0
- package/src/main/lib/sessionSlots.cjs +47 -9
- package/src/main/lib/smProcNames.cjs +51 -0
- package/src/main/lib/upgradeDrain.cjs +188 -0
- package/src/main/lib/usageCircuit.cjs +53 -6
- package/src/main/lib/watchdogHelpers.cjs +70 -37
- package/src/main/lib/withTimeout.cjs +33 -0
- package/src/main/mcpStatus.cjs +4 -2
- package/src/main/pluginInstall.cjs +6 -2
- package/src/main/projectPages.cjs +41 -145
- package/src/main/pty.cjs +8 -1
- package/src/main/queueOps.cjs +10 -10
- package/src/main/runVerify.cjs +69 -3
- package/src/main/scheduler.cjs +1607 -383
- package/src/main/seedAgentPersonas.cjs +1 -1
- package/src/main/seedDevPlugin.cjs +1 -1
- package/src/main/seedSchedulerMcp.cjs +7 -6
- package/src/main/seedStatus.cjs +1 -1
- package/src/main/supervisor.cjs +5 -3
- package/src/main/usage.cjs +126 -62
- package/src/preload/api.d.ts +18 -64
- package/src/preload/index.cjs +2 -4
- package/src/seed/agents/project-home-builder.md +31 -48
- package/screenshots/.gitkeep +0 -0
- package/screenshots/README-screenshots.md +0 -13
- package/src/main/lib/projectPageSummarySchema.cjs +0 -181
- package/src/main/teams.cjs +0 -95
- package/src/main/templates/project-pages-catalog.json +0 -741
- package/src/main/templates/project-pages-default-home.html +0 -123
- package/src/main/templates/project-pages-pipeline.md +0 -417
- package/src/seed/prompts/code-review/ac-coverage-check.md +0 -8
- package/src/seed/prompts/code-review/correctness-only.md +0 -8
- package/src/seed/prompts/code-review/full-spectrum-high.md +0 -8
- package/src/seed/prompts/code-review/hallucination-check.md +0 -8
- package/src/seed/prompts/code-review/public-api-compat.md +0 -8
- package/src/seed/prompts/code-review/readability-naming.md +0 -8
- package/src/seed/prompts/debugging/bug-as-failing-test.md +0 -8
- package/src/seed/prompts/debugging/git-bisect-regression.md +0 -8
- package/src/seed/prompts/debugging/instrument-intermittent-bug.md +0 -8
- package/src/seed/prompts/debugging/localize-pipeline-failure.md +0 -8
- package/src/seed/prompts/debugging/reproduce-then-diagnose.md +0 -8
- package/src/seed/prompts/documentation/adr-from-change.md +0 -8
- package/src/seed/prompts/documentation/module-readme.md +0 -8
- package/src/seed/prompts/documentation/onboarding-plan.md +0 -8
- package/src/seed/prompts/documentation/refresh-claude-md.md +0 -8
- package/src/seed/prompts/documentation/tsdoc-public-exports.md +0 -8
- package/src/seed/prompts/git-pr/conventional-commit.md +0 -8
- package/src/seed/prompts/git-pr/draft-pr-title-body.md +0 -8
- package/src/seed/prompts/git-pr/pre-commit-safety-sweep.md +0 -8
- package/src/seed/prompts/git-pr/release-notes-block.md +0 -8
- package/src/seed/prompts/git-pr/split-large-pr.md +0 -8
- package/src/seed/prompts/performance/bundle-startup-audit.md +0 -8
- package/src/seed/prompts/performance/complexity-audit.md +0 -8
- package/src/seed/prompts/performance/cpu-profile-hot-path.md +0 -8
- package/src/seed/prompts/performance/db-query-plan-review.md +0 -8
- package/src/seed/prompts/performance/memory-leak-hunt.md +0 -8
- package/src/seed/prompts/qa/api-contract-tests.md +0 -8
- package/src/seed/prompts/qa/e2e-critical-path.md +0 -8
- package/src/seed/prompts/qa/failing-test-for-bug.md +0 -8
- package/src/seed/prompts/qa/find-missing-test-coverage.md +0 -8
- package/src/seed/prompts/qa/stabilize-flaky-test.md +0 -8
- package/src/seed/prompts/qa/tdd-red-first.md +0 -8
- package/src/seed/prompts/qa/visual-regression-review.md +0 -8
- package/src/seed/prompts/qa/wcag-axe-scan.md +0 -8
- package/src/seed/prompts/refactoring/dead-code-sweep.md +0 -8
- package/src/seed/prompts/refactoring/extract-duplicated-pattern.md +0 -8
- package/src/seed/prompts/refactoring/modernize-legacy-file.md +0 -8
- package/src/seed/prompts/refactoring/reduce-cyclomatic-complexity.md +0 -8
- package/src/seed/prompts/refactoring/tighten-module-boundaries.md +0 -8
- package/src/seed/prompts/security/authz-audit.md +0 -8
- package/src/seed/prompts/security/crypto-correctness.md +0 -8
- package/src/seed/prompts/security/cwe-top-25-hunt.md +0 -8
- package/src/seed/prompts/security/dependency-audit.md +0 -8
- package/src/seed/prompts/security/ipc-boundary-hardening.md +0 -8
- package/src/seed/prompts/security/owasp-top-10-staged-diff.md +0 -8
- package/src/seed/prompts/security/secret-credential-scan.md +0 -8
- package/web/README.md +0 -41
- package/web/project-pages/logic/dist/logic.cjs +0 -4709
- package/web/project-pages/render.cjs +0 -70
- package/web/project-pages/renderer/dist/renderer.cjs +0 -18900
- package/web/project-pages/validate-summary.cjs +0 -62
|
@@ -5,6 +5,13 @@
|
|
|
5
5
|
* resume timer when both the log and the billing endpoint come up empty
|
|
6
6
|
* (the live failure mode: the usage endpoint itself 429s).
|
|
7
7
|
*
|
|
8
|
+
* Also (usage-meter circuit PRD): neither function may arm a PAST reset —
|
|
9
|
+
* resolveRateLimitPauseReset/computeEffectiveResumeAt now reject a stale ISO
|
|
10
|
+
* via usageCircuit.isResetFresh and fall back to the bounded 30-MINUTE pause
|
|
11
|
+
* instead. A stale reset used to survive straight into computeResumeDelay's
|
|
12
|
+
* `Math.max(30_000, <negative>)`, arming a 30-SECOND nap that spun the queue
|
|
13
|
+
* right back into the same still-active rate limit.
|
|
14
|
+
*
|
|
8
15
|
* These test the PURE decision functions scheduler.cjs's setPaused() and the
|
|
9
16
|
* spawnJob() rateLimited branch delegate to — never the real setPaused/mutate
|
|
10
17
|
* path, which writes the machine's live scheduler queue.json at a fixed
|
|
@@ -21,9 +28,11 @@ const assert = require('node:assert/strict');
|
|
|
21
28
|
const path = require('node:path');
|
|
22
29
|
const {
|
|
23
30
|
resolveRateLimitPauseReset,
|
|
31
|
+
billingResetForPause,
|
|
24
32
|
computeEffectiveResumeAt,
|
|
25
33
|
computeResumeDelay,
|
|
26
34
|
} = require('../scheduler.cjs');
|
|
35
|
+
const billing = require('../usage.cjs');
|
|
27
36
|
|
|
28
37
|
// Repo-tracked, byte-for-byte copy of the real 2026-09-05 starry-night-ships
|
|
29
38
|
// incident log (see src/main/lib/__tests__/rateLimitWindow.test.cjs for the
|
|
@@ -32,37 +41,84 @@ const {
|
|
|
32
41
|
const FIXTURE = path.join(__dirname, '..', 'lib', '__tests__', 'fixtures', '204-mercury-steam-horse.log.txt');
|
|
33
42
|
const FIVE_HOUR_ISO = new Date(1788643800 * 1000).toISOString(); // what refreshNextReset() would have returned
|
|
34
43
|
const SEVEN_DAY_ISO = new Date(1789059600 * 1000).toISOString(); // the actually-binding window
|
|
44
|
+
// Fixed "now" contemporaneous with the fixture's own timestamps (BEFORE both
|
|
45
|
+
// resets above), so freshness checks read the fixture the way it looked at
|
|
46
|
+
// incident time — not relative to whenever this suite happens to run.
|
|
47
|
+
const FIXTURE_NOW = Date.parse('2026-09-05T16:32:00.000Z');
|
|
35
48
|
|
|
36
49
|
test('resolveRateLimitPauseReset: prefers the log-derived seven-day reset over the billing five_hour reset', () => {
|
|
37
|
-
const resetIso = resolveRateLimitPauseReset(FIXTURE, FIVE_HOUR_ISO);
|
|
50
|
+
const resetIso = resolveRateLimitPauseReset(FIXTURE, FIVE_HOUR_ISO, FIXTURE_NOW);
|
|
38
51
|
assert.strictEqual(resetIso, SEVEN_DAY_ISO);
|
|
39
52
|
assert.notStrictEqual(resetIso, FIVE_HOUR_ISO);
|
|
40
53
|
});
|
|
41
54
|
|
|
42
55
|
test('resolveRateLimitPauseReset: falls back to the billing reset when the log yields none', () => {
|
|
43
|
-
const resetIso = resolveRateLimitPauseReset('/nonexistent/path/job.log', FIVE_HOUR_ISO);
|
|
56
|
+
const resetIso = resolveRateLimitPauseReset('/nonexistent/path/job.log', FIVE_HOUR_ISO, FIXTURE_NOW);
|
|
44
57
|
assert.strictEqual(resetIso, FIVE_HOUR_ISO);
|
|
45
58
|
});
|
|
46
59
|
|
|
47
60
|
test('resolveRateLimitPauseReset: null when BOTH the log and the billing endpoint are unavailable', () => {
|
|
48
|
-
assert.strictEqual(resolveRateLimitPauseReset('/nonexistent/path/job.log', null), null);
|
|
61
|
+
assert.strictEqual(resolveRateLimitPauseReset('/nonexistent/path/job.log', null, FIXTURE_NOW), null);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('resolveRateLimitPauseReset: a PAST log-derived reset is rejected, not returned stale', () => {
|
|
65
|
+
// "now" is AFTER both fixture resets have already elapsed.
|
|
66
|
+
const longAfter = Date.parse('2026-09-15T00:00:00.000Z');
|
|
67
|
+
const resetIso = resolveRateLimitPauseReset(FIXTURE, FIVE_HOUR_ISO, longAfter);
|
|
68
|
+
assert.strictEqual(resetIso, null);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('resolveRateLimitPauseReset: log yields nothing and the billing reset has already passed -> null, not the stale value', () => {
|
|
72
|
+
const longAfter = Date.parse('2026-09-15T00:00:00.000Z');
|
|
73
|
+
const resetIso = resolveRateLimitPauseReset('/nonexistent/path/job.log', FIVE_HOUR_ISO, longAfter);
|
|
74
|
+
assert.strictEqual(resetIso, null);
|
|
49
75
|
});
|
|
50
76
|
|
|
51
77
|
test('computeEffectiveResumeAt: rate_limit with no resumeAt gets the same bounded 30-minute fallback as network', () => {
|
|
52
|
-
const now =
|
|
78
|
+
const now = FIXTURE_NOW;
|
|
53
79
|
const resumeAt = computeEffectiveResumeAt('rate_limit', null, now);
|
|
54
80
|
assert.strictEqual(resumeAt, new Date(now + 30 * 60_000).toISOString());
|
|
55
81
|
});
|
|
56
82
|
|
|
57
|
-
test('computeEffectiveResumeAt:
|
|
58
|
-
const resumeAt = computeEffectiveResumeAt('rate_limit', SEVEN_DAY_ISO,
|
|
83
|
+
test('computeEffectiveResumeAt: a FRESH explicit resumeAt (the log-derived reset) is never overridden', () => {
|
|
84
|
+
const resumeAt = computeEffectiveResumeAt('rate_limit', SEVEN_DAY_ISO, FIXTURE_NOW);
|
|
59
85
|
assert.strictEqual(resumeAt, SEVEN_DAY_ISO);
|
|
60
86
|
});
|
|
61
87
|
|
|
88
|
+
test('computeEffectiveResumeAt: a PAST resumeAt gets the 30-minute fallback, not a ~30-second nap', () => {
|
|
89
|
+
// now is well AFTER SEVEN_DAY_ISO has already elapsed — the exact shape of
|
|
90
|
+
// the bug: Math.max(30_000, <negative>) used to arm a 30-second resume.
|
|
91
|
+
const longAfter = Date.parse('2026-09-15T00:00:00.000Z');
|
|
92
|
+
const resumeAt = computeEffectiveResumeAt('rate_limit', SEVEN_DAY_ISO, longAfter);
|
|
93
|
+
assert.strictEqual(resumeAt, new Date(longAfter + 30 * 60_000).toISOString());
|
|
94
|
+
assert.notStrictEqual(resumeAt, SEVEN_DAY_ISO);
|
|
95
|
+
const { delayMs } = computeResumeDelay(resumeAt, longAfter);
|
|
96
|
+
assert.ok(delayMs >= 30 * 60_000, `expected >= 30 minutes, got ${delayMs}ms`);
|
|
97
|
+
});
|
|
98
|
+
|
|
62
99
|
test('computeEffectiveResumeAt: other reasons (e.g. auth) get no fallback — stays indefinite', () => {
|
|
63
100
|
assert.strictEqual(computeEffectiveResumeAt('auth', null, Date.now()), null);
|
|
64
101
|
});
|
|
65
102
|
|
|
103
|
+
test('billingResetForPause: skips refreshNextReset() (no billing.fetchUsage call) while the shared circuit is OPEN', async () => {
|
|
104
|
+
const originalFetchUsage = billing.fetchUsage;
|
|
105
|
+
let fetchCalls = 0;
|
|
106
|
+
billing.fetchUsage = async () => { fetchCalls++; return { kind: 'ok', data: { usage: {} } }; };
|
|
107
|
+
try {
|
|
108
|
+
// Force the shared circuit open (3 consecutive failures), same threshold usageCircuit.cjs uses.
|
|
109
|
+
billing.usageCircuit.recordFailure('a');
|
|
110
|
+
billing.usageCircuit.recordFailure('a');
|
|
111
|
+
billing.usageCircuit.recordFailure('a');
|
|
112
|
+
assert.strictEqual(billing.usageCircuit.state(), 'open');
|
|
113
|
+
|
|
114
|
+
await billingResetForPause();
|
|
115
|
+
assert.strictEqual(fetchCalls, 0, 'refreshNextReset() must not call billing.fetchUsage() while the circuit is open');
|
|
116
|
+
} finally {
|
|
117
|
+
billing.usageCircuit.recordSuccess({}); // close the shared circuit back up for later tests/files
|
|
118
|
+
billing.fetchUsage = originalFetchUsage;
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
66
122
|
test('computeResumeDelay: a seven-day-out reset (~5 days from now) is armed, not treated as too-far', () => {
|
|
67
123
|
const now = Date.parse('2026-09-05T16:32:00.000Z');
|
|
68
124
|
// ~5 days out — comfortably under the 0x7fffffff (~24.8 day) setTimeout cap.
|
|
@@ -27,6 +27,7 @@ import { test, expect, beforeAll, afterAll, afterEach } from 'vitest';
|
|
|
27
27
|
const fs = require('node:fs');
|
|
28
28
|
const os = require('node:os');
|
|
29
29
|
const path = require('node:path');
|
|
30
|
+
const claudeStub = require('../../../tests/helpers/claudeStub.cjs');
|
|
30
31
|
const { execFileSync } = require('node:child_process');
|
|
31
32
|
|
|
32
33
|
let tmpHome;
|
|
@@ -92,15 +93,12 @@ function writeProjectQueue(cwd, jobs) {
|
|
|
92
93
|
// non-zero almost instantly, and bumps a counter file each invocation so the
|
|
93
94
|
// test can assert exactly how many times it was actually spawned.
|
|
94
95
|
function writeAlways429ClaudeStub(counterPath) {
|
|
95
|
-
|
|
96
|
-
const body = `
|
|
96
|
+
return claudeStub.writeClaudeStub({ body: `
|
|
97
97
|
const fs = require('fs');
|
|
98
98
|
fs.appendFileSync(${JSON.stringify(counterPath)}, 'x');
|
|
99
99
|
process.stdout.write(JSON.stringify({ type: 'result', subtype: 'error', is_error: true, api_error_status: 429 }) + '\\n');
|
|
100
100
|
process.exit(1);
|
|
101
|
-
|
|
102
|
-
fs.writeFileSync(stubPath, `#!${process.execPath}\n${body}\n`, { mode: 0o755 });
|
|
103
|
-
return stubPath;
|
|
101
|
+
` });
|
|
104
102
|
}
|
|
105
103
|
|
|
106
104
|
test('a manual Resume immediately followed by a permanently-429ing job never produces a runaway dispatch loop', async () => {
|
|
@@ -38,7 +38,7 @@ const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'reap-dead-running-jobs-te
|
|
|
38
38
|
process.env.HOME = tmpHome;
|
|
39
39
|
|
|
40
40
|
const { reapDeadRunningJobs, PIDLESS_SPAWN_GRACE_MS } = require('../scheduler.cjs');
|
|
41
|
-
const {
|
|
41
|
+
const { auditLogPath } = require('../lib/auditLog.cjs');
|
|
42
42
|
// queueStore's cwd discovery is cached for 30s (queueStore.cjs's CACHE_MS) —
|
|
43
43
|
// a project registered by THIS test after an earlier test already populated
|
|
44
44
|
// that cache (and didn't itself bust it, e.g. because it found nothing
|
|
@@ -68,10 +68,29 @@ function registerActiveProject(cwd) {
|
|
|
68
68
|
fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
// Every real scheduler row is born from a real PRD .md file — reconcile()'s
|
|
72
|
+
// auto-archive-drop path (a terminal job whose slug has no on-disk PRD
|
|
73
|
+
// anywhere is treated as "archived on purpose": dropped from queue.json and
|
|
74
|
+
// backfilled to history.jsonl, see scheduler-reconcile-history-backfill.
|
|
75
|
+
// test.cjs) fires the very next time broadcast({flush:true}) reconciles —
|
|
76
|
+
// which reapDeadRunningJobs() itself does on every reap. Without a matching
|
|
77
|
+
// PRD source, a row this test just reaped to a terminal status vanishes from
|
|
78
|
+
// queue.json before the assertions below ever read it back. Epic-scoped (not
|
|
79
|
+
// the legacy flat prds/ dir) so it's never swept by consolidateFlatPrds
|
|
80
|
+
// either.
|
|
81
|
+
function writeFixturePrd(cwd, slug) {
|
|
82
|
+
const prdsDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'epics', 'test-fixture-epic', 'prds');
|
|
83
|
+
fs.mkdirSync(prdsDir, { recursive: true });
|
|
84
|
+
fs.writeFileSync(path.join(prdsDir, `${slug}.md`), 'Test fixture PRD.', 'utf8');
|
|
85
|
+
}
|
|
86
|
+
|
|
71
87
|
function writeProjectQueue(cwd, jobs) {
|
|
72
88
|
const stateDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'state');
|
|
73
89
|
fs.mkdirSync(stateDir, { recursive: true });
|
|
74
90
|
fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs }, null, 2));
|
|
91
|
+
for (const job of jobs) {
|
|
92
|
+
if (job && job.slug) writeFixturePrd(job.cwd || cwd, job.slug);
|
|
93
|
+
}
|
|
75
94
|
return path.join(stateDir, 'queue.json');
|
|
76
95
|
}
|
|
77
96
|
|
|
@@ -131,7 +150,7 @@ test('reapDeadRunningJobs reaps a pidless row older than PIDLESS_SPAWN_GRACE_MS
|
|
|
131
150
|
// Empty run dir: created, but never written to (the exact 2026-09-01 repro).
|
|
132
151
|
fs.mkdirSync(path.join(tmpHome, '.claude', 'session-manager', 'scheduled-plans', 'runs', 'run-pidless-zombie'), { recursive: true });
|
|
133
152
|
|
|
134
|
-
const auditSizeBefore = fs.existsSync(
|
|
153
|
+
const auditSizeBefore = fs.existsSync(auditLogPath()) ? fs.statSync(auditLogPath()).size : 0;
|
|
135
154
|
|
|
136
155
|
await reapDeadRunningJobs();
|
|
137
156
|
|
|
@@ -142,7 +161,7 @@ test('reapDeadRunningJobs reaps a pidless row older than PIDLESS_SPAWN_GRACE_MS
|
|
|
142
161
|
assert.equal(jobs[0].runtime, undefined);
|
|
143
162
|
assert.equal(jobs[0].gateOutcome, 'never_ran', 'a pidless reap means the gate never had a chance to run');
|
|
144
163
|
|
|
145
|
-
const auditText = fs.readFileSync(
|
|
164
|
+
const auditText = fs.readFileSync(auditLogPath(), 'utf8').slice(auditSizeBefore);
|
|
146
165
|
const auditLines = auditText.trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
147
166
|
const pidlessEvent = auditLines.find((e) => e.kind === 'job_reaped_pidless' && e.slug === 'pidless-zombie');
|
|
148
167
|
assert.ok(pidlessEvent, 'reaping a pidless row must leave an audit trace');
|
|
@@ -184,7 +203,7 @@ test('reapDeadRunningJobs: a pidless row past grace with a landedCommit already
|
|
|
184
203
|
// multi-byte characters (e.g. an em dash in a reaper reason string), and
|
|
185
204
|
// slicing a utf8-decoded JS string (UTF-16 code units) at a byte offset
|
|
186
205
|
// then silently corrupts the boundary. The slug is unique to this test.
|
|
187
|
-
const auditLines = fs.readFileSync(
|
|
206
|
+
const auditLines = fs.readFileSync(auditLogPath(), 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
188
207
|
const pidlessEvent = auditLines.find((e) => e.kind === 'job_reaped_pidless' && e.slug === 'shipped-but-pidless');
|
|
189
208
|
assert.ok(pidlessEvent, 'reaping this row must still leave an audit trace');
|
|
190
209
|
assert.equal(pidlessEvent.landedCommit, landedSha, 'the audit event must carry the landed-commit evidence so the decision is reconstructable from the audit log alone');
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-reconcile-cwd-preserve.test.cjs — regression test for the
|
|
3
|
+
* data-loss bug found while chasing PRD 1218's test:unit gate: reconcile()
|
|
4
|
+
* rebuilt a queue row's `cwd` unconditionally from its PRD's own
|
|
5
|
+
* frontmatter (`cwd: p.cwd`) at all three of its row-construction sites. A
|
|
6
|
+
* PRD file with no `cwd:` frontmatter key — every hand-written or legacy PRD,
|
|
7
|
+
* and every fixture in this test suite before this fix — parses to
|
|
8
|
+
* `p.cwd === undefined`, which nulled the row's real, already-correct `cwd`.
|
|
9
|
+
* queueStore.writeSplit's `job.cwd || defaultCwd` bucketing then relocated
|
|
10
|
+
* the row into schedulerBatch.cjs's DEFAULT_PROJECT_CWD shard
|
|
11
|
+
* (`~/Projects/session-manager`) — the WRONG project — while the row's own
|
|
12
|
+
* project's shard was rewritten without it. A PRD's frontmatter `cwd` must
|
|
13
|
+
* REFINE a row's existing `cwd`, never erase one.
|
|
14
|
+
*
|
|
15
|
+
* Covers the normal refresh path (an existing row matched to its PRD) and
|
|
16
|
+
* the fresh-discovery path (a PRD with no prior row, falling back to the
|
|
17
|
+
* project root it was actually found under via
|
|
18
|
+
* prdLocations.deriveProjectCwdFromPrdPath rather than nulling to
|
|
19
|
+
* DEFAULT_PROJECT_CWD).
|
|
20
|
+
*
|
|
21
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-reconcile-cwd-preserve.test.cjs
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
import { test, expect, beforeAll, afterAll } from 'vitest';
|
|
27
|
+
const fs = require('node:fs');
|
|
28
|
+
const os = require('node:os');
|
|
29
|
+
const path = require('node:path');
|
|
30
|
+
|
|
31
|
+
let tmpHome;
|
|
32
|
+
let originalHome;
|
|
33
|
+
let reconcile;
|
|
34
|
+
let DEFAULT_PROJECT_CWD;
|
|
35
|
+
|
|
36
|
+
beforeAll(() => {
|
|
37
|
+
originalHome = process.env.HOME;
|
|
38
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-reconcile-cwd-preserve-'));
|
|
39
|
+
process.env.HOME = tmpHome;
|
|
40
|
+
({ reconcile } = require('../scheduler.cjs'));
|
|
41
|
+
({ DEFAULT_PROJECT_CWD } = require('../lib/schedulerBatch.cjs'));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterAll(() => {
|
|
45
|
+
process.env.HOME = originalHome;
|
|
46
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// allProjectCwds()/activeProjectCwds() (queueStore.cjs's stateCwds) discover
|
|
50
|
+
// project cwds by scanning ~/.claude/projects/*/*.jsonl for a `cwd` field —
|
|
51
|
+
// same pattern as scheduler-reap-dead-running-jobs.test.cjs and friends.
|
|
52
|
+
function registerActiveProject(cwd) {
|
|
53
|
+
const projectsDir = path.join(tmpHome, '.claude', 'projects');
|
|
54
|
+
const slugDir = path.join(projectsDir, `fake-project-slug-${path.basename(cwd)}`);
|
|
55
|
+
fs.mkdirSync(slugDir, { recursive: true });
|
|
56
|
+
fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
test('reconcile() never nulls an existing row\'s cwd because its PRD has no cwd: frontmatter', async () => {
|
|
60
|
+
const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-reconcile-cwd-preserve-project-'));
|
|
61
|
+
registerActiveProject(projectCwd);
|
|
62
|
+
|
|
63
|
+
const slug = `1218-cwd-preserve-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
|
|
64
|
+
const prdsDir = path.join(projectCwd, 'session-manager-operations', 'scheduler', 'epics', 'test-fixture-epic', 'prds');
|
|
65
|
+
fs.mkdirSync(prdsDir, { recursive: true });
|
|
66
|
+
// Deliberately NO `cwd:` frontmatter key — the exact shape of every
|
|
67
|
+
// hand-written or legacy PRD that triggered the bug.
|
|
68
|
+
fs.writeFileSync(path.join(prdsDir, `${slug}.md`), '# Goal\n\nNo cwd frontmatter here.\n', 'utf8');
|
|
69
|
+
|
|
70
|
+
const job = { slug, title: 'x', cwd: projectCwd, status: 'pending' };
|
|
71
|
+
const state = { jobs: [job] };
|
|
72
|
+
|
|
73
|
+
await reconcile(state);
|
|
74
|
+
|
|
75
|
+
const row = state.jobs.find((j) => j.slug === slug);
|
|
76
|
+
expect(row).toBeDefined();
|
|
77
|
+
expect(row.cwd).toBe(projectCwd);
|
|
78
|
+
expect(row.cwd).not.toBe(DEFAULT_PROJECT_CWD);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('reconcile() gives a freshly-discovered, frontmatter-less PRD the project root it was actually found under, never DEFAULT_PROJECT_CWD', async () => {
|
|
82
|
+
const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-reconcile-cwd-preserve-fresh-'));
|
|
83
|
+
registerActiveProject(projectCwd);
|
|
84
|
+
|
|
85
|
+
const slug = `1218-cwd-preserve-fresh-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
|
|
86
|
+
const prdsDir = path.join(projectCwd, 'session-manager-operations', 'scheduler', 'epics', 'test-fixture-epic', 'prds');
|
|
87
|
+
fs.mkdirSync(prdsDir, { recursive: true });
|
|
88
|
+
fs.writeFileSync(path.join(prdsDir, `${slug}.md`), '# Goal\n\nNo cwd frontmatter here either.\n', 'utf8');
|
|
89
|
+
|
|
90
|
+
// No prior row at all — this PRD is discovered fresh by reconcile()'s own
|
|
91
|
+
// onDisk scan, not handed in via `state.jobs`.
|
|
92
|
+
const state = { jobs: [] };
|
|
93
|
+
|
|
94
|
+
await reconcile(state);
|
|
95
|
+
|
|
96
|
+
const row = state.jobs.find((j) => j.slug === slug);
|
|
97
|
+
expect(row).toBeDefined();
|
|
98
|
+
expect(row.cwd).toBe(projectCwd);
|
|
99
|
+
expect(row.cwd).not.toBe(DEFAULT_PROJECT_CWD);
|
|
100
|
+
});
|
|
@@ -127,8 +127,8 @@ test('reconcile() repairs a queue row with an invalid status back to pending, lo
|
|
|
127
127
|
// require time, so spying on the module's export property after the
|
|
128
128
|
// fact wouldn't intercept scheduler.cjs's already-bound reference —
|
|
129
129
|
// assert against the real (tmpHome-isolated) audit log file instead.
|
|
130
|
-
const {
|
|
131
|
-
expect(
|
|
130
|
+
const { auditLogPath } = require('../lib/auditLog.cjs');
|
|
131
|
+
expect(auditLogPath().startsWith(tmpHome)).toBe(true);
|
|
132
132
|
|
|
133
133
|
queueStore.bustCwdCache();
|
|
134
134
|
await scheduler.reconcile(state);
|
|
@@ -142,7 +142,7 @@ test('reconcile() repairs a queue row with an invalid status back to pending, lo
|
|
|
142
142
|
);
|
|
143
143
|
expect(warnedRepair).toBe(true);
|
|
144
144
|
|
|
145
|
-
const auditLines = fs.readFileSync(
|
|
145
|
+
const auditLines = fs.readFileSync(auditLogPath(), 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
146
146
|
const auditedRepair = auditLines.some(
|
|
147
147
|
(rec) => rec.kind === 'scheduler_row_repaired' && rec.slug === badRow.slug && rec.oldStatus === 'queued',
|
|
148
148
|
);
|
|
@@ -80,7 +80,7 @@ test('reconcile() quarantines a newly-discovered PRD with no createdVia provenan
|
|
|
80
80
|
fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs: [] }, null, 2), 'utf8');
|
|
81
81
|
|
|
82
82
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
83
|
-
const {
|
|
83
|
+
const { auditLogPath } = require('../lib/auditLog.cjs');
|
|
84
84
|
|
|
85
85
|
queueStore.bustCwdCache();
|
|
86
86
|
const state = { jobs: [], invalidJobs: [], paused: null };
|
|
@@ -95,7 +95,7 @@ test('reconcile() quarantines a newly-discovered PRD with no createdVia provenan
|
|
|
95
95
|
);
|
|
96
96
|
expect(warned).toBe(true);
|
|
97
97
|
|
|
98
|
-
const auditLines = fs.readFileSync(
|
|
98
|
+
const auditLines = fs.readFileSync(auditLogPath(), 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
99
99
|
expect(auditLines.some((rec) => rec.kind === 'prd_quarantined' && rec.slug === '9001-hand-written')).toBe(true);
|
|
100
100
|
} finally {
|
|
101
101
|
fs.rmSync(cwd, { recursive: true, force: true });
|
|
@@ -230,7 +230,7 @@ test('reconcile() adopts a quarantined row to pending once its PRD file is stamp
|
|
|
230
230
|
fm.issuedAt = '2026-08-07T01:00:00.000Z';
|
|
231
231
|
fs.writeFileSync(prdPath, serializePrdFile(fm, body), 'utf8');
|
|
232
232
|
|
|
233
|
-
const {
|
|
233
|
+
const { auditLogPath } = require('../lib/auditLog.cjs');
|
|
234
234
|
queueStore.bustCwdCache();
|
|
235
235
|
const state = { jobs: [quarantinedRow], invalidJobs: [], paused: null };
|
|
236
236
|
await scheduler.reconcile(state);
|
|
@@ -239,7 +239,7 @@ test('reconcile() adopts a quarantined row to pending once its PRD file is stamp
|
|
|
239
239
|
expect(row).toBeDefined();
|
|
240
240
|
expect(row.status).toBe('pending');
|
|
241
241
|
|
|
242
|
-
const auditLines = fs.readFileSync(
|
|
242
|
+
const auditLines = fs.readFileSync(auditLogPath(), 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
243
243
|
expect(auditLines.some((rec) => rec.kind === 'scheduler_prd_adopted' && rec.slug === '9004-adopt-me')).toBe(true);
|
|
244
244
|
} finally {
|
|
245
245
|
fs.rmSync(cwd, { recursive: true, force: true });
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scheduler-shard-quarantine.test.cjs — a torn per-project queue shard
|
|
3
|
+
* quarantines THAT project only. tickQueue keeps dispatching the healthy
|
|
4
|
+
* project, never scans/mints rows for the torn project's PRDs, never touches
|
|
5
|
+
* the torn file, and health names the quarantined cwd (non-GREEN).
|
|
6
|
+
*
|
|
7
|
+
* Everything runs under a temp HOME; the stub `claude` writes a marker into
|
|
8
|
+
* its own cwd so the test can prove where a spawn did (not) happen.
|
|
9
|
+
*
|
|
10
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/scheduler-shard-quarantine.test.cjs
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
import { test, expect, beforeAll, afterAll } from 'vitest';
|
|
16
|
+
const fs = require('node:fs');
|
|
17
|
+
const os = require('node:os');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
const claudeStub = require('../../../tests/helpers/claudeStub.cjs');
|
|
20
|
+
|
|
21
|
+
let tmpHome;
|
|
22
|
+
let originalHome;
|
|
23
|
+
let originalWt;
|
|
24
|
+
let scheduler;
|
|
25
|
+
|
|
26
|
+
function registerActiveProject(cwd) {
|
|
27
|
+
const slugDir = path.join(tmpHome, '.claude', 'projects', `fake-project-slug-${path.basename(cwd)}`);
|
|
28
|
+
fs.mkdirSync(slugDir, { recursive: true });
|
|
29
|
+
fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function makeProject(name) {
|
|
33
|
+
const cwd = path.join(tmpHome, 'Projects', name);
|
|
34
|
+
const stateDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'state');
|
|
35
|
+
const prdsDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'epics', 'fixture-epic', 'prds');
|
|
36
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
37
|
+
fs.mkdirSync(prdsDir, { recursive: true });
|
|
38
|
+
registerActiveProject(cwd);
|
|
39
|
+
return { cwd, prdsDir, queueFile: path.join(stateDir, 'queue.json') };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function writeClaudeStub() {
|
|
43
|
+
return claudeStub.writeClaudeStub({ body: `
|
|
44
|
+
require('fs').writeFileSync(require('path').join(process.cwd(), 'ran-here.marker'), 'yes');
|
|
45
|
+
process.stdout.write(JSON.stringify({ type: 'result', subtype: 'success', result: 'ok\\nSCHEDULER_VERDICT: PASS' }) + '\\n');
|
|
46
|
+
process.exit(0);
|
|
47
|
+
` });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
beforeAll(() => {
|
|
51
|
+
originalHome = process.env.HOME;
|
|
52
|
+
originalWt = process.env.SM_JOB_WORKTREE_DISABLE;
|
|
53
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-shard-quarantine-'));
|
|
54
|
+
process.env.HOME = tmpHome;
|
|
55
|
+
process.env.SM_JOB_WORKTREE_DISABLE = '1';
|
|
56
|
+
process.env.SM_AUTOFIX_DISABLE = '1';
|
|
57
|
+
fs.mkdirSync(path.join(tmpHome, '.claude', 'session-manager'), { recursive: true });
|
|
58
|
+
scheduler = require('../scheduler.cjs');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
afterAll(() => {
|
|
62
|
+
process.env.HOME = originalHome;
|
|
63
|
+
if (originalWt === undefined) delete process.env.SM_JOB_WORKTREE_DISABLE;
|
|
64
|
+
else process.env.SM_JOB_WORKTREE_DISABLE = originalWt;
|
|
65
|
+
delete process.env.SM_AUTOFIX_DISABLE;
|
|
66
|
+
delete process.env.SM_CLAUDE_BIN;
|
|
67
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('tickQueue dispatches the healthy project while the torn project is skipped; health names it', async () => {
|
|
71
|
+
const good = makeProject('good-proj');
|
|
72
|
+
const torn = makeProject('torn-proj');
|
|
73
|
+
const goodSlug = `9101-good-${process.pid}`;
|
|
74
|
+
const tornSlug = `9102-torn-${process.pid}`;
|
|
75
|
+
fs.writeFileSync(path.join(good.prdsDir, `${goodSlug}.md`), 'Healthy PRD.', 'utf8');
|
|
76
|
+
fs.writeFileSync(path.join(torn.prdsDir, `${tornSlug}.md`), 'PRD in the torn project.', 'utf8');
|
|
77
|
+
fs.writeFileSync(good.queueFile, JSON.stringify({
|
|
78
|
+
jobs: [{ slug: goodSlug, title: 'good', status: 'pending', cwd: good.cwd, dependsOn: [], createdAt: new Date().toISOString() }],
|
|
79
|
+
}));
|
|
80
|
+
// A running row was in flight when the shard tore — must never become pending.
|
|
81
|
+
const TORN = '{"jobs":[{"slug":"' + tornSlug + '","status":"running"}]}{"jobs":[{"slug":"';
|
|
82
|
+
fs.writeFileSync(torn.queueFile, TORN);
|
|
83
|
+
const before = fs.readFileSync(torn.queueFile);
|
|
84
|
+
process.env.SM_CLAUDE_BIN = writeClaudeStub();
|
|
85
|
+
|
|
86
|
+
const result = await scheduler.tickQueue({ bypassLoadGate: true });
|
|
87
|
+
expect(result.reason).not.toBe('unreadable');
|
|
88
|
+
expect(result.fired).toBe(true);
|
|
89
|
+
// flagUnreadable (via tickQueue's readQueue) snapshots the torn shard beside itself.
|
|
90
|
+
const snaps = fs.readdirSync(path.dirname(torn.queueFile)).filter((f) => f.startsWith('queue.json.corrupt-'));
|
|
91
|
+
expect(snaps).toHaveLength(1);
|
|
92
|
+
|
|
93
|
+
await new Promise((resolve, reject) => {
|
|
94
|
+
const t0 = Date.now();
|
|
95
|
+
const poll = () => {
|
|
96
|
+
if (fs.existsSync(path.join(good.cwd, 'ran-here.marker'))) return resolve();
|
|
97
|
+
if (Date.now() - t0 > 20_000) return reject(new Error('healthy project never dispatched'));
|
|
98
|
+
setTimeout(poll, 100);
|
|
99
|
+
};
|
|
100
|
+
poll();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(fs.existsSync(path.join(torn.cwd, 'ran-here.marker'))).toBe(false);
|
|
104
|
+
expect(fs.readFileSync(torn.queueFile).equals(before)).toBe(true);
|
|
105
|
+
|
|
106
|
+
const health = await require('../health.cjs').check();
|
|
107
|
+
expect(health.components.scheduler_queue.ok).toBe(false);
|
|
108
|
+
expect(health.components.scheduler_queue.quarantinedCwds).toEqual([torn.cwd]);
|
|
109
|
+
expect(health.issues.some((i) => i.includes(torn.cwd))).toBe(true);
|
|
110
|
+
}, 120_000); // health.check() alone can take tens of seconds
|
|
@@ -285,7 +285,13 @@ test('checkSharedTreeGuard end-to-end: the real 2026-09-12 incident shape — un
|
|
|
285
285
|
vi.spyOn(scheduler, 'gitHead').mockResolvedValue('sha-after');
|
|
286
286
|
vi.spyOn(scheduler, 'pathsChangedSince').mockResolvedValue(['.gitignore']);
|
|
287
287
|
vi.spyOn(scheduler, 'uncommittedChanges').mockResolvedValue([]); // the log path no longer shows up at all
|
|
288
|
-
|
|
288
|
+
// checkSharedTreeGuard's existence check runs concurrently via
|
|
289
|
+
// fs.promises.access (fsp === fs.promises — same object) rather than
|
|
290
|
+
// fs.existsSync, so a large untracked baseline doesn't block the event
|
|
291
|
+
// loop with hundreds of synchronous stats back-to-back.
|
|
292
|
+
vi.spyOn(fs.promises, 'access').mockImplementation((p) => (
|
|
293
|
+
String(p).includes('errors-2026-09-12.jsonl') ? Promise.resolve() : Promise.reject(new Error('ENOENT'))
|
|
294
|
+
));
|
|
289
295
|
|
|
290
296
|
const result = await checkSharedTreeGuard({
|
|
291
297
|
cwd: '/repo',
|
|
@@ -298,3 +304,77 @@ test('checkSharedTreeGuard end-to-end: the real 2026-09-12 incident shape — un
|
|
|
298
304
|
expect(result.reverted).toBeUndefined();
|
|
299
305
|
expect(result.nowIgnored).toEqual([logPath]);
|
|
300
306
|
});
|
|
307
|
+
|
|
308
|
+
// --- landedCommit ground truth (2026-09-18, 1229-fo-03 false positive) ---
|
|
309
|
+
// Real temp git fixture, never the live repo. Baseline-dirty path `cfg.txt`
|
|
310
|
+
// goes clean with no commit covering it (the shape that makes the baseline
|
|
311
|
+
// diff say "reverted"); only the landedCommit ancestry decides the verdict.
|
|
312
|
+
const { execFileSync } = require('node:child_process');
|
|
313
|
+
const os = require('node:os');
|
|
314
|
+
const nodePath = require('node:path');
|
|
315
|
+
|
|
316
|
+
function gitIn(dir, ...args) {
|
|
317
|
+
return execFileSync('git', args, { cwd: dir, encoding: 'utf8' }).trim();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function fixtureRepo() {
|
|
321
|
+
const dir = fs.mkdtempSync(nodePath.join(os.tmpdir(), 'shared-tree-landed-'));
|
|
322
|
+
gitIn(dir, 'init', '-q');
|
|
323
|
+
gitIn(dir, 'config', 'user.email', 't@example.com');
|
|
324
|
+
gitIn(dir, 'config', 'user.name', 'T');
|
|
325
|
+
fs.writeFileSync(nodePath.join(dir, 'cfg.txt'), 'base\n');
|
|
326
|
+
gitIn(dir, 'add', '-A');
|
|
327
|
+
gitIn(dir, 'commit', '-q', '-m', 'base');
|
|
328
|
+
const base = gitIn(dir, 'rev-parse', 'HEAD');
|
|
329
|
+
fs.writeFileSync(nodePath.join(dir, 'cfg.txt'), 'human wip\n'); // baseline-dirty
|
|
330
|
+
return { dir, base };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function commitPath(dir, rel, msg) {
|
|
334
|
+
fs.writeFileSync(nodePath.join(dir, rel), `${msg}\n`);
|
|
335
|
+
gitIn(dir, 'add', rel);
|
|
336
|
+
gitIn(dir, 'commit', '-q', '-m', msg);
|
|
337
|
+
return gitIn(dir, 'rev-parse', 'HEAD');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
test('checkSharedTreeGuard: landedCommit still an ancestor of HEAD plus an unrelated later commit is NOT reverted', async () => {
|
|
341
|
+
const { dir, base } = fixtureRepo();
|
|
342
|
+
const landed = commitPath(dir, 'job.txt', 'job work');
|
|
343
|
+
gitIn(dir, 'checkout', '--', 'cfg.txt'); // baseline-dirty path goes clean, no commit covers it
|
|
344
|
+
commitPath(dir, 'later.txt', 'unrelated human commit');
|
|
345
|
+
|
|
346
|
+
const result = await checkSharedTreeGuard({
|
|
347
|
+
cwd: dir, stashBaseline: [], dirtyBaseline: [{ code: ' M', path: 'cfg.txt' }],
|
|
348
|
+
headBefore: base, slug: 'job-a', landedCommit: landed,
|
|
349
|
+
});
|
|
350
|
+
expect(result).toBeNull();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test('checkSharedTreeGuard: landedCommit absent from history still parks as reverted', async () => {
|
|
354
|
+
const { dir, base } = fixtureRepo();
|
|
355
|
+
const landed = commitPath(dir, 'job.txt', 'job work');
|
|
356
|
+
gitIn(dir, 'reset', '-q', '--hard', base); // job commit discarded
|
|
357
|
+
commitPath(dir, 'later.txt', 'unrelated human commit');
|
|
358
|
+
|
|
359
|
+
const result = await checkSharedTreeGuard({
|
|
360
|
+
cwd: dir, stashBaseline: [], dirtyBaseline: [{ code: ' M', path: 'cfg.txt' }],
|
|
361
|
+
headBefore: base, slug: 'job-b', landedCommit: landed,
|
|
362
|
+
});
|
|
363
|
+
expect(result).toEqual({ reverted: ['cfg.txt'] });
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test('checkSharedTreeGuard: no landedCommit at all still parks as reverted', async () => {
|
|
367
|
+
const { dir, base } = fixtureRepo();
|
|
368
|
+
gitIn(dir, 'checkout', '--', 'cfg.txt');
|
|
369
|
+
const result = await checkSharedTreeGuard({
|
|
370
|
+
cwd: dir, stashBaseline: [], dirtyBaseline: [{ code: ' M', path: 'cfg.txt' }],
|
|
371
|
+
headBefore: base, slug: 'job-c', landedCommit: null,
|
|
372
|
+
});
|
|
373
|
+
expect(result).toEqual({ reverted: ['cfg.txt'] });
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test('landedCommitIsAncestorOfHead: empty/unknown sha is false, never throws', async () => {
|
|
377
|
+
const { dir } = fixtureRepo();
|
|
378
|
+
expect(await scheduler.landedCommitIsAncestorOfHead(dir, '')).toBe(false);
|
|
379
|
+
expect(await scheduler.landedCommitIsAncestorOfHead(dir, 'deadbeefdeadbeef')).toBe(false);
|
|
380
|
+
});
|
|
@@ -110,3 +110,17 @@ test('findStaleQuarantinedJobs: fires past threshold, not before', () => {
|
|
|
110
110
|
assert.deepStrictEqual(slugs, ['exactly-at-threshold', 'stale']);
|
|
111
111
|
assert.strictEqual(stale.find((s) => s.slug === 'stale').cwd, '/p1');
|
|
112
112
|
});
|
|
113
|
+
|
|
114
|
+
test('computeStallSummary: a fully drained project (all completed) is idle, not stalled', () => {
|
|
115
|
+
const state = {
|
|
116
|
+
paused: null,
|
|
117
|
+
jobs: [
|
|
118
|
+
{ slug: 'a', cwd: '/p1', status: 'completed' },
|
|
119
|
+
{ slug: 'b', cwd: '/p2', status: 'running' },
|
|
120
|
+
],
|
|
121
|
+
invalidJobs: [],
|
|
122
|
+
};
|
|
123
|
+
const summary = computeStallSummary(state);
|
|
124
|
+
assert.strictEqual(summary.byProject['/p1'].stalled, false);
|
|
125
|
+
assert.strictEqual(computeStallSummary({ paused: null, jobs: [{ slug: 'a', cwd: '/p1', status: 'completed' }], invalidJobs: [] }).stalled, false);
|
|
126
|
+
});
|
|
@@ -39,7 +39,7 @@ const {
|
|
|
39
39
|
STARVE_ESCALATION_MS,
|
|
40
40
|
attachWindow,
|
|
41
41
|
} = require('../scheduler.cjs');
|
|
42
|
-
const {
|
|
42
|
+
const { auditLogPath } = require('../lib/auditLog.cjs');
|
|
43
43
|
|
|
44
44
|
afterAll(() => {
|
|
45
45
|
process.env.HOME = originalHome;
|
|
@@ -52,7 +52,7 @@ const CWD_B = '/home/bilko/Projects/session-manager';
|
|
|
52
52
|
function readAuditKind(kind) {
|
|
53
53
|
let lines;
|
|
54
54
|
try {
|
|
55
|
-
lines = fs.readFileSync(
|
|
55
|
+
lines = fs.readFileSync(auditLogPath(), 'utf8').split('\n').filter(Boolean);
|
|
56
56
|
} catch {
|
|
57
57
|
return [];
|
|
58
58
|
}
|
|
@@ -75,8 +75,8 @@ function fakeWindow() {
|
|
|
75
75
|
beforeEach(() => {
|
|
76
76
|
// Fresh scratch audit log per test so escalation counts never bleed across
|
|
77
77
|
// tests in this file.
|
|
78
|
-
fs.mkdirSync(path.dirname(
|
|
79
|
-
fs.writeFileSync(
|
|
78
|
+
fs.mkdirSync(path.dirname(auditLogPath()), { recursive: true });
|
|
79
|
+
fs.writeFileSync(auditLogPath(), '');
|
|
80
80
|
attachWindow(null);
|
|
81
81
|
});
|
|
82
82
|
|