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
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* projectPages.test.cjs —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* read as having no output at all — and the shipped-default fallback added
|
|
6
|
-
* by the "project-home-hosted-html-spec" PRD: a project with no generated
|
|
7
|
-
* output gets the build-time default home.html (isDefault: true) instead of
|
|
8
|
-
* the bare `{ output: null }` empty-state signal.
|
|
2
|
+
* projectPages.test.cjs — projectPages.cjs's read-only surface: get() of the
|
|
3
|
+
* single project-pages/home.html ({html|null, mtimeMs}) plus the refcounted
|
|
4
|
+
* watcher that pushes `project-pages:changed`.
|
|
9
5
|
*
|
|
10
6
|
* Run: timeout 120 npx vitest run src/main/__tests__/projectPages.test.cjs
|
|
11
7
|
*/
|
|
@@ -20,8 +16,6 @@ const path = require('node:path');
|
|
|
20
16
|
const config = require('../config.cjs');
|
|
21
17
|
const {
|
|
22
18
|
get,
|
|
23
|
-
DEFAULT_HOME_PATH,
|
|
24
|
-
readDefaultHomeHtml,
|
|
25
19
|
attachWindow,
|
|
26
20
|
watchOutput,
|
|
27
21
|
unwatchOutput,
|
|
@@ -31,6 +25,7 @@ const {
|
|
|
31
25
|
|
|
32
26
|
const tmpDirs = [];
|
|
33
27
|
afterEach(async () => {
|
|
28
|
+
closeAllOutputWatchers();
|
|
34
29
|
while (tmpDirs.length) {
|
|
35
30
|
const d = tmpDirs.pop();
|
|
36
31
|
await fsp.rm(d, { recursive: true, force: true });
|
|
@@ -44,179 +39,98 @@ async function mkTmpCwd() {
|
|
|
44
39
|
return dir;
|
|
45
40
|
}
|
|
46
41
|
|
|
47
|
-
function
|
|
48
|
-
return path.join(cwd, 'session-manager-operations', 'project-pages'
|
|
42
|
+
function pagesDir(cwd) {
|
|
43
|
+
return path.join(cwd, 'session-manager-operations', 'project-pages');
|
|
49
44
|
}
|
|
50
45
|
|
|
51
|
-
function
|
|
52
|
-
|
|
53
|
-
fs.
|
|
54
|
-
fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify(manifest));
|
|
55
|
-
for (const [lens, html] of Object.entries(lenses)) {
|
|
56
|
-
fs.writeFileSync(path.join(dir, `${lens}.html`), html);
|
|
57
|
-
}
|
|
46
|
+
function writeHome(cwd, html) {
|
|
47
|
+
fs.mkdirSync(pagesDir(cwd), { recursive: true });
|
|
48
|
+
fs.writeFileSync(path.join(pagesDir(cwd), 'home.html'), html);
|
|
58
49
|
}
|
|
59
50
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
feature: 'FEATURE',
|
|
71
|
-
architecture: 'ARCHITECTURE',
|
|
72
|
-
brief: 'BRIEF',
|
|
73
|
-
});
|
|
74
|
-
const result = await get({ cwd });
|
|
75
|
-
expect(result.output).not.toBeNull();
|
|
76
|
-
expect(result.output.home).toBe('HOME');
|
|
77
|
-
expect(result.output.brief).toBe('BRIEF');
|
|
78
|
-
expect(result.output.generatedAt).toBe('2026-08-02T00:00:00.000Z');
|
|
79
|
-
expect(result.output.isDefault).toBe(false);
|
|
80
|
-
});
|
|
51
|
+
function makeFakeWindow(sent) {
|
|
52
|
+
return {
|
|
53
|
+
isDestroyed: () => false,
|
|
54
|
+
webContents: {
|
|
55
|
+
isDestroyed: () => false,
|
|
56
|
+
isCrashed: () => false,
|
|
57
|
+
send: (channel, payload) => sent.push({ channel, payload }),
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
81
61
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
writeOutput(cwd, {
|
|
89
|
-
home: 'HOME',
|
|
90
|
-
marketing: 'MARKETING',
|
|
91
|
-
feature: 'FEATURE',
|
|
92
|
-
architecture: 'ARCHITECTURE',
|
|
93
|
-
});
|
|
94
|
-
const result = await get({ cwd });
|
|
95
|
-
expect(result.output).not.toBeNull();
|
|
96
|
-
expect(result.output.home).toBe('HOME');
|
|
97
|
-
expect(result.output.marketing).toBe('MARKETING');
|
|
98
|
-
expect(result.output.feature).toBe('FEATURE');
|
|
99
|
-
expect(result.output.architecture).toBe('ARCHITECTURE');
|
|
100
|
-
expect(result.output.brief).toBeUndefined();
|
|
101
|
-
expect(result.output.isDefault).toBe(false);
|
|
102
|
-
});
|
|
62
|
+
async function waitFor(predicate, ms = 5000) {
|
|
63
|
+
const deadline = Date.now() + ms;
|
|
64
|
+
while (!predicate() && Date.now() < deadline) {
|
|
65
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
103
68
|
|
|
104
|
-
|
|
105
|
-
test('(b) no manifest at all: get() returns the shipped default with isDefault true', async () => {
|
|
69
|
+
test('get() returns the home.html text and its mtimeMs', async () => {
|
|
106
70
|
const cwd = await mkTmpCwd();
|
|
71
|
+
writeHome(cwd, '<html>HOME</html>');
|
|
107
72
|
const result = await get({ cwd });
|
|
108
|
-
expect(result.
|
|
109
|
-
expect(result.
|
|
110
|
-
expect(result.
|
|
111
|
-
expect(result.output.home).toBe(readDefaultHomeHtml());
|
|
112
|
-
expect(result.output.marketing).toBeUndefined();
|
|
113
|
-
expect(result.output.feature).toBeUndefined();
|
|
114
|
-
expect(result.output.architecture).toBeUndefined();
|
|
73
|
+
expect(result.html).toBe('<html>HOME</html>');
|
|
74
|
+
expect(typeof result.mtimeMs).toBe('number');
|
|
75
|
+
expect(Object.keys(result).sort()).toEqual(['html', 'mtimeMs']);
|
|
115
76
|
});
|
|
116
77
|
|
|
117
|
-
test('
|
|
78
|
+
test('get() with no home.html returns {html: null, mtimeMs: null} — no shipped default', async () => {
|
|
118
79
|
const cwd = await mkTmpCwd();
|
|
119
|
-
|
|
120
|
-
home: 'HOME',
|
|
121
|
-
marketing: 'MARKETING',
|
|
122
|
-
feature: 'FEATURE',
|
|
123
|
-
// architecture.html deliberately missing
|
|
124
|
-
brief: 'BRIEF',
|
|
125
|
-
});
|
|
126
|
-
const result = await get({ cwd });
|
|
127
|
-
expect(result.output).not.toBeNull();
|
|
128
|
-
expect(result.output.isDefault).toBe(true);
|
|
129
|
-
expect(result.output.home).toBe(readDefaultHomeHtml());
|
|
80
|
+
expect(await get({ cwd })).toEqual({ html: null, mtimeMs: null });
|
|
130
81
|
});
|
|
131
82
|
|
|
132
|
-
test('
|
|
83
|
+
test('get() ignores legacy manifest/lens files — only home.html counts', async () => {
|
|
133
84
|
const cwd = await mkTmpCwd();
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
);
|
|
139
|
-
const result = await get({ cwd });
|
|
140
|
-
expect(result.output).not.toBeNull();
|
|
141
|
-
expect(result.output.isDefault).toBe(true);
|
|
85
|
+
const out = path.join(pagesDir(cwd), 'output');
|
|
86
|
+
fs.mkdirSync(out, { recursive: true });
|
|
87
|
+
fs.writeFileSync(path.join(out, 'home.html'), 'OLD');
|
|
88
|
+
fs.writeFileSync(path.join(out, 'manifest.json'), '{"generatedAt":"2026-08-02T00:00:00.000Z"}');
|
|
89
|
+
expect(await get({ cwd })).toEqual({ html: null, mtimeMs: null });
|
|
142
90
|
});
|
|
143
91
|
|
|
144
|
-
|
|
145
|
-
test('(c) default fallback content is identical regardless of which project cwd requests it', async () => {
|
|
146
|
-
const cwdA = await mkTmpCwd();
|
|
147
|
-
const cwdB = await mkTmpCwd();
|
|
148
|
-
const resultA = await get({ cwd: cwdA });
|
|
149
|
-
const resultB = await get({ cwd: cwdB });
|
|
150
|
-
expect(resultA.output.isDefault).toBe(true);
|
|
151
|
-
expect(resultB.output.isDefault).toBe(true);
|
|
152
|
-
expect(resultA.output.home).toBe(resultB.output.home);
|
|
153
|
-
expect(resultA.output.home).toBe(readDefaultHomeHtml());
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
test('the shipped default is honest: no fabricated project-specific content, and prompts Generate My Project Home', () => {
|
|
157
|
-
const html = readDefaultHomeHtml();
|
|
158
|
-
expect(html).toContain('Generate My Project Home');
|
|
159
|
-
expect(html.toLowerCase()).not.toContain('lorem ipsum');
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
// ── ephemeral cwd: get() must fall back, never throw/reject ────────────────
|
|
163
|
-
test('get() with an ephemeral cwd (os.tmpdir() itself) returns the shipped default instead of throwing', async () => {
|
|
92
|
+
test('get() with an ephemeral cwd (os.tmpdir() itself) returns null instead of throwing', async () => {
|
|
164
93
|
config.addAllowedRoot(os.tmpdir());
|
|
165
|
-
|
|
166
|
-
expect(result.output).not.toBeNull();
|
|
167
|
-
expect(result.output.isDefault).toBe(true);
|
|
168
|
-
});
|
|
169
|
-
|
|
170
|
-
// ── watcher ──────────────────────────────────────────────────────────────
|
|
171
|
-
afterEach(() => {
|
|
172
|
-
closeAllOutputWatchers();
|
|
94
|
+
expect(await get({ cwd: os.tmpdir() })).toEqual({ html: null, mtimeMs: null });
|
|
173
95
|
});
|
|
174
96
|
|
|
175
97
|
test('watchOutput() on an ephemeral cwd refuses instead of crashing', async () => {
|
|
176
98
|
config.addAllowedRoot(os.tmpdir());
|
|
177
|
-
|
|
178
|
-
expect(result).toEqual({ ok: false, reason: 'ephemeral' });
|
|
99
|
+
expect(await watchOutput(os.tmpdir())).toEqual({ ok: false, reason: 'ephemeral' });
|
|
179
100
|
expect(_outputWatchers.size).toBe(0);
|
|
180
101
|
});
|
|
181
102
|
|
|
182
|
-
test('watchOutput()
|
|
103
|
+
test('watchOutput() is refcounted: two watches share one watcher, closed at the last unwatch', async () => {
|
|
104
|
+
const cwd = await mkTmpCwd();
|
|
105
|
+
expect(await watchOutput(cwd)).toEqual({ ok: true });
|
|
106
|
+
expect(await watchOutput(cwd)).toEqual({ ok: true });
|
|
107
|
+
expect(_outputWatchers.size).toBe(1);
|
|
108
|
+
unwatchOutput(cwd);
|
|
109
|
+
expect(_outputWatchers.size).toBe(1);
|
|
110
|
+
unwatchOutput(cwd);
|
|
111
|
+
expect(_outputWatchers.size).toBe(0);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('watchOutput() pushes {cwd, html, mtimeMs} on project-pages:changed when home.html lands, ignoring sibling files', async () => {
|
|
183
115
|
const cwd = await mkTmpCwd();
|
|
184
116
|
const sent = [];
|
|
185
|
-
|
|
186
|
-
isDestroyed: () => false,
|
|
187
|
-
webContents: {
|
|
188
|
-
isDestroyed: () => false,
|
|
189
|
-
isCrashed: () => false,
|
|
190
|
-
send: (channel, payload) => sent.push({ channel, payload }),
|
|
191
|
-
},
|
|
192
|
-
};
|
|
193
|
-
attachWindow(fakeWindow);
|
|
117
|
+
attachWindow(makeFakeWindow(sent));
|
|
194
118
|
|
|
195
|
-
|
|
196
|
-
expect(watchResult).toEqual({ ok: true });
|
|
119
|
+
expect(await watchOutput(cwd)).toEqual({ ok: true });
|
|
197
120
|
expect(_outputWatchers.size).toBe(1);
|
|
198
121
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
feature: 'FEATURE',
|
|
203
|
-
architecture: 'ARCHITECTURE',
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
// Bounded poll for the debounced (awaitWriteFinish) push instead of a bare
|
|
207
|
-
// sleep — chokidar's stability window is 50ms, so this gives it generous
|
|
208
|
-
// headroom without hanging the suite on a broken watcher.
|
|
209
|
-
const deadline = Date.now() + 5000;
|
|
210
|
-
while (sent.length === 0 && Date.now() < deadline) {
|
|
211
|
-
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
212
|
-
}
|
|
122
|
+
fs.writeFileSync(path.join(pagesDir(cwd), 'summary.json'), '{}');
|
|
123
|
+
writeHome(cwd, '<html>HOME</html>');
|
|
124
|
+
await waitFor(() => sent.length > 0);
|
|
213
125
|
|
|
214
126
|
expect(sent.length).toBeGreaterThan(0);
|
|
127
|
+
for (const msg of sent) expect(msg.channel).toBe('project-pages:changed');
|
|
215
128
|
const last = sent[sent.length - 1];
|
|
216
|
-
expect(last.channel).toBe('project-pages:changed');
|
|
217
129
|
expect(last.payload.cwd).toBe(fs.realpathSync(cwd));
|
|
218
|
-
expect(last.payload.
|
|
219
|
-
expect(last.payload.
|
|
130
|
+
expect(last.payload.html).toBe('<html>HOME</html>');
|
|
131
|
+
expect(typeof last.payload.mtimeMs).toBe('number');
|
|
132
|
+
// summary.json alone must not have produced a push carrying no html
|
|
133
|
+
expect(sent.every((m) => m.payload.html === '<html>HOME</html>')).toBe(true);
|
|
220
134
|
|
|
221
135
|
unwatchOutput(cwd);
|
|
222
136
|
expect(_outputWatchers.size).toBe(0);
|
|
@@ -42,7 +42,7 @@ test('saturated: jobs running, zero free slots', () => {
|
|
|
42
42
|
];
|
|
43
43
|
const v = classifyQueueHealth({
|
|
44
44
|
jobs, paused: null, launchBlocks: {}, runningSet: new Set(['1-a']),
|
|
45
|
-
freeSlots: 0, totalSlots: 4,
|
|
45
|
+
freeSlots: 0, totalSlots: 4, lastRunAtMs: NOW, now: NOW, cwd: CWD,
|
|
46
46
|
});
|
|
47
47
|
assert.equal(v.kind, 'saturated');
|
|
48
48
|
assert.equal(v.runningCount, 1);
|
|
@@ -62,7 +62,7 @@ test('blocked: every pending row terminates in a failed dependency', () => {
|
|
|
62
62
|
];
|
|
63
63
|
const v = classifyQueueHealth({
|
|
64
64
|
jobs, paused: null, launchBlocks: {}, runningSet: new Set(),
|
|
65
|
-
freeSlots: 4, totalSlots: 4,
|
|
65
|
+
freeSlots: 4, totalSlots: 4, lastRunAtMs: NOW - 5_000, now: NOW, cwd: CWD,
|
|
66
66
|
});
|
|
67
67
|
assert.equal(v.kind, 'blocked');
|
|
68
68
|
assert.equal(v.pending, 1);
|
|
@@ -77,7 +77,7 @@ test('idle: nothing pending', () => {
|
|
|
77
77
|
];
|
|
78
78
|
const v = classifyQueueHealth({
|
|
79
79
|
jobs, paused: null, launchBlocks: {}, runningSet: new Set(),
|
|
80
|
-
freeSlots: 4, totalSlots: 4,
|
|
80
|
+
freeSlots: 4, totalSlots: 4, lastRunAtMs: NOW, now: NOW, cwd: CWD,
|
|
81
81
|
});
|
|
82
82
|
assert.equal(v.kind, 'idle');
|
|
83
83
|
assert.equal(v.pending, 0);
|
|
@@ -90,7 +90,7 @@ test('stalled: dispatchable work, nothing running, driver idle past threshold',
|
|
|
90
90
|
];
|
|
91
91
|
const v = classifyQueueHealth({
|
|
92
92
|
jobs, paused: null, launchBlocks: {}, runningSet: new Set(),
|
|
93
|
-
freeSlots: 4, totalSlots: 4,
|
|
93
|
+
freeSlots: 4, totalSlots: 4, lastRunAtMs: NOW - LONG, now: NOW, cwd: CWD,
|
|
94
94
|
});
|
|
95
95
|
assert.equal(v.kind, 'stalled');
|
|
96
96
|
assert.equal(v.dispatchable, 1);
|
|
@@ -103,7 +103,7 @@ test('running: dispatchable work, nothing running yet, but driver has not been i
|
|
|
103
103
|
];
|
|
104
104
|
const v = classifyQueueHealth({
|
|
105
105
|
jobs, paused: null, launchBlocks: {}, runningSet: new Set(),
|
|
106
|
-
freeSlots: 4, totalSlots: 4,
|
|
106
|
+
freeSlots: 4, totalSlots: 4, lastRunAtMs: NOW - 5_000, now: NOW, cwd: CWD,
|
|
107
107
|
});
|
|
108
108
|
assert.equal(v.kind, 'running');
|
|
109
109
|
});
|
|
@@ -114,7 +114,7 @@ test('paused: a decision, not a stall — takes priority over everything else',
|
|
|
114
114
|
];
|
|
115
115
|
const v = classifyQueueHealth({
|
|
116
116
|
jobs, paused: { reason: 'manual', since: NOW }, launchBlocks: {}, runningSet: new Set(),
|
|
117
|
-
freeSlots: 0, totalSlots: 4,
|
|
117
|
+
freeSlots: 0, totalSlots: 4, lastRunAtMs: NOW - LONG, now: NOW, cwd: CWD,
|
|
118
118
|
});
|
|
119
119
|
assert.equal(v.kind, 'paused');
|
|
120
120
|
assert.equal(v.reason, 'manual');
|
|
@@ -131,7 +131,7 @@ test('launch-blocked: an open circuit breaker for a persona a pending row actual
|
|
|
131
131
|
};
|
|
132
132
|
const v = classifyQueueHealth({
|
|
133
133
|
jobs, paused: null, launchBlocks, runningSet: new Set(),
|
|
134
|
-
freeSlots: 4, totalSlots: 4,
|
|
134
|
+
freeSlots: 4, totalSlots: 4, lastRunAtMs: NOW - LONG, now: NOW, cwd: CWD,
|
|
135
135
|
});
|
|
136
136
|
assert.equal(v.kind, 'launch-blocked');
|
|
137
137
|
assert.equal(v.agentType, 'dev-lead');
|
|
@@ -148,7 +148,7 @@ test('launch-blocked is scoped: a breaker for a persona nothing pending here use
|
|
|
148
148
|
};
|
|
149
149
|
const v = classifyQueueHealth({
|
|
150
150
|
jobs, paused: null, launchBlocks, runningSet: new Set(),
|
|
151
|
-
freeSlots: 4, totalSlots: 4,
|
|
151
|
+
freeSlots: 4, totalSlots: 4, lastRunAtMs: NOW - 5_000, now: NOW, cwd: CWD,
|
|
152
152
|
});
|
|
153
153
|
assert.notEqual(v.kind, 'launch-blocked');
|
|
154
154
|
});
|
|
@@ -163,7 +163,7 @@ test('needsReviewCount and cwd scoping: another project\'s rows never leak into
|
|
|
163
163
|
];
|
|
164
164
|
const v = classifyQueueHealth({
|
|
165
165
|
jobs, paused: null, launchBlocks: {}, runningSet: new Set(),
|
|
166
|
-
freeSlots: 4, totalSlots: 4,
|
|
166
|
+
freeSlots: 4, totalSlots: 4, lastRunAtMs: NOW - 5_000, now: NOW, cwd: CWD,
|
|
167
167
|
});
|
|
168
168
|
assert.equal(v.pending, 1);
|
|
169
169
|
assert.equal(v.needsReviewCount, 1);
|
|
@@ -23,14 +23,16 @@
|
|
|
23
23
|
|
|
24
24
|
'use strict';
|
|
25
25
|
|
|
26
|
-
import { test, expect, beforeAll, afterAll } from 'vitest';
|
|
26
|
+
import { test, expect, beforeAll, afterAll, vi } 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
|
|
|
31
32
|
let tmpHome;
|
|
32
33
|
let originalHome;
|
|
33
34
|
let originalClaudeBin;
|
|
35
|
+
let originalAutofixDisable;
|
|
34
36
|
let scheduler;
|
|
35
37
|
let queueStore;
|
|
36
38
|
|
|
@@ -41,21 +43,26 @@ let queueStore;
|
|
|
41
43
|
// guard then makes runQueueStarvationWatchdog return null for a reason that
|
|
42
44
|
// has nothing to do with what that test is actually checking.
|
|
43
45
|
function writeClaudeStub() {
|
|
44
|
-
|
|
45
|
-
const body = `
|
|
46
|
+
return claudeStub.writeClaudeStub({ body: `
|
|
46
47
|
process.stdout.write(JSON.stringify({ type: 'result', subtype: 'success', result: 'ok\\nSCHEDULER_VERDICT: PASS' }) + '\\n');
|
|
47
48
|
process.exit(0);
|
|
48
|
-
|
|
49
|
-
fs.writeFileSync(stubPath, `#!${process.execPath}\n${body}\n`, { mode: 0o755 });
|
|
50
|
-
return stubPath;
|
|
49
|
+
` });
|
|
51
50
|
}
|
|
52
51
|
|
|
53
52
|
beforeAll(() => {
|
|
54
53
|
originalHome = process.env.HOME;
|
|
55
54
|
originalClaudeBin = process.env.SM_CLAUDE_BIN;
|
|
55
|
+
originalAutofixDisable = process.env.SM_AUTOFIX_DISABLE;
|
|
56
56
|
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-starve-driver-'));
|
|
57
57
|
process.env.HOME = tmpHome;
|
|
58
58
|
process.env.SM_CLAUDE_BIN = writeClaudeStub();
|
|
59
|
+
// The forced tick's dispatched job has no PRD file and makes no commit, so
|
|
60
|
+
// it lands 'needs_review' and (without this) immediately fires a
|
|
61
|
+
// fire-and-forget auto-fix investigation — ANOTHER real claude spawn, under
|
|
62
|
+
// its own queue row, that can still be in runningSet by the time the next
|
|
63
|
+
// test in this file reads it (see writeClaudeStub's header comment; this
|
|
64
|
+
// closes the same gap one layer further down the cascade).
|
|
65
|
+
process.env.SM_AUTOFIX_DISABLE = '1';
|
|
59
66
|
fs.mkdirSync(path.join(tmpHome, '.claude', 'session-manager'), { recursive: true });
|
|
60
67
|
fs.mkdirSync(path.join(tmpHome, '.claude', 'projects'), { recursive: true });
|
|
61
68
|
scheduler = require('../scheduler.cjs');
|
|
@@ -66,6 +73,8 @@ afterAll(() => {
|
|
|
66
73
|
process.env.HOME = originalHome;
|
|
67
74
|
if (originalClaudeBin === undefined) delete process.env.SM_CLAUDE_BIN;
|
|
68
75
|
else process.env.SM_CLAUDE_BIN = originalClaudeBin;
|
|
76
|
+
if (originalAutofixDisable === undefined) delete process.env.SM_AUTOFIX_DISABLE;
|
|
77
|
+
else process.env.SM_AUTOFIX_DISABLE = originalAutofixDisable;
|
|
69
78
|
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
70
79
|
});
|
|
71
80
|
|
|
@@ -81,7 +90,7 @@ function mkProject() {
|
|
|
81
90
|
return { cwd, slug };
|
|
82
91
|
}
|
|
83
92
|
|
|
84
|
-
test('the
|
|
93
|
+
test('ticks that launch nothing do NOT refresh the clock — idleness reads lastRunAt, not lastDispatchAttemptAt', async () => {
|
|
85
94
|
const { cwd, slug } = mkProject();
|
|
86
95
|
await scheduler.writeQueue({
|
|
87
96
|
jobs: [{ slug, title: 'x', cwd, status: 'pending', dependsOn: [] }],
|
|
@@ -90,18 +99,37 @@ test('the watchdog reads idleness from lastDispatchAttemptAt, not the always-fre
|
|
|
90
99
|
});
|
|
91
100
|
|
|
92
101
|
const now = Date.now();
|
|
93
|
-
// The
|
|
94
|
-
//
|
|
95
|
-
//
|
|
102
|
+
// The structural repeat of f18e161: the loop ticks every 30 s (so
|
|
103
|
+
// lastDispatchAttemptAt is seconds old) but nothing has LAUNCHED in 40
|
|
104
|
+
// minutes (lastRunAt). The boot stamp is aged out so it can't mask it.
|
|
105
|
+
const bootedAtMs = now - 3 * 60 * 60_000;
|
|
96
106
|
const state = {
|
|
97
107
|
...queueStore.readMergedSync(),
|
|
98
|
-
lastRunAt: new Date(now -
|
|
99
|
-
lastDispatchAttemptAt: new Date(now -
|
|
108
|
+
lastRunAt: new Date(now - 40 * 60_000).toISOString(),
|
|
109
|
+
lastDispatchAttemptAt: new Date(now - 5_000).toISOString(),
|
|
100
110
|
};
|
|
101
111
|
|
|
102
|
-
const verdict = await scheduler.runQueueStarvationWatchdog(state, { now });
|
|
112
|
+
const verdict = await scheduler.runQueueStarvationWatchdog(state, { now, bootedAtMs });
|
|
103
113
|
expect(verdict).not.toBeNull();
|
|
104
114
|
expect(verdict.kind).toBe('starved');
|
|
115
|
+
|
|
116
|
+
// The watchdog's forced tick above dispatches this job for real (see
|
|
117
|
+
// writeClaudeStub's header comment) — tickQueue doesn't await the spawned
|
|
118
|
+
// process to completion, so runningSet can still hold this slug for a
|
|
119
|
+
// moment after this test's own `await` returns. Under a fast, unloaded
|
|
120
|
+
// machine the stub exits before the NEXT test's watchdog call ever reads
|
|
121
|
+
// runningSet, which is racy rather than deterministic — under a heavily
|
|
122
|
+
// loaded machine (e.g. the full test:unit run) it reliably loses,
|
|
123
|
+
// stranding a slug in runningSet and making
|
|
124
|
+
// classifyQueueStarvation's `runningCount > 0` guard return null for the
|
|
125
|
+
// next test, which has nothing to do with what that test actually checks.
|
|
126
|
+
// Wait for this job to actually leave 'running' before moving on.
|
|
127
|
+
const deadline = Date.now() + 5_000;
|
|
128
|
+
while (Date.now() < deadline) {
|
|
129
|
+
const row = queueStore.readMergedSync().jobs.find((j) => j.slug === slug);
|
|
130
|
+
if (!row || row.status !== 'running') break;
|
|
131
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
132
|
+
}
|
|
105
133
|
});
|
|
106
134
|
|
|
107
135
|
test('a wedged cancelToken does not block the watchdog from actually driving a tick', async () => {
|
|
@@ -117,21 +145,34 @@ test('a wedged cancelToken does not block the watchdog from actually driving a t
|
|
|
117
145
|
// applyPauseCleared reset (a direct state write, not the clearPause()
|
|
118
146
|
// helper) — leaving the token permanently cancelled with paused: null.
|
|
119
147
|
await scheduler.setPaused('rate_limit', null);
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
}
|
|
148
|
+
// setPaused's own broadcast({flush:true}) and a still-draining
|
|
149
|
+
// fire-and-forget mutate() from the PRIOR test's forced dispatch (e.g.
|
|
150
|
+
// tickQueue's own finally-chained call) both write through the SAME
|
|
151
|
+
// serialized mutate() queue this raw writeQueue() call bypasses — one of
|
|
152
|
+
// them can land AFTER this write and re-persist the stale paused:{...}
|
|
153
|
+
// object it read before this clear, purely by losing the race. Re-assert
|
|
154
|
+
// paused:null until it actually sticks, rather than trusting one write to
|
|
155
|
+
// win a race against mutations this test doesn't control the timing of.
|
|
156
|
+
const pausedClearDeadline = Date.now() + 5_000;
|
|
157
|
+
do {
|
|
158
|
+
await scheduler.writeQueue({
|
|
159
|
+
jobs: [{ slug, title: 'x', cwd, status: 'pending', dependsOn: [] }],
|
|
160
|
+
config: {},
|
|
161
|
+
paused: null,
|
|
162
|
+
});
|
|
163
|
+
if (!queueStore.readMergedSync().paused) break;
|
|
164
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
165
|
+
} while (Date.now() < pausedClearDeadline);
|
|
125
166
|
|
|
126
167
|
const before = queueStore.readMergedSync().lastDispatchAttemptAt;
|
|
127
168
|
|
|
128
169
|
const now = Date.now();
|
|
129
170
|
const state = {
|
|
130
171
|
...queueStore.readMergedSync(),
|
|
131
|
-
lastRunAt:
|
|
172
|
+
lastRunAt: new Date(now - 40 * 60_000).toISOString(),
|
|
132
173
|
lastDispatchAttemptAt: new Date(now - 40 * 60_000).toISOString(),
|
|
133
174
|
};
|
|
134
|
-
const verdict = await scheduler.runQueueStarvationWatchdog(state, { now });
|
|
175
|
+
const verdict = await scheduler.runQueueStarvationWatchdog(state, { now, bootedAtMs: now - 3 * 60 * 60_000 });
|
|
135
176
|
expect(verdict.kind).toBe('starved');
|
|
136
177
|
|
|
137
178
|
// tickQueue() only stamps lastDispatchAttemptAt once it gets PAST the
|
|
@@ -141,3 +182,59 @@ test('a wedged cancelToken does not block the watchdog from actually driving a t
|
|
|
141
182
|
expect(after).not.toBe(before);
|
|
142
183
|
expect(after).not.toBeNull();
|
|
143
184
|
});
|
|
185
|
+
|
|
186
|
+
const NOW = Date.parse('2026-09-18T12:00:00.000Z');
|
|
187
|
+
const CWD = '/tmp/sm-starve-clock-project';
|
|
188
|
+
const pendingRow = (slug, extra = {}) => ({ slug, cwd: CWD, status: 'pending', dependsOn: [], ...extra });
|
|
189
|
+
const idleFor = (ms) => NOW - ms;
|
|
190
|
+
|
|
191
|
+
test('a real launch (fresh lastRunAt) refreshes the clock; so do a pause clear and a boot', () => {
|
|
192
|
+
const jobs = [pendingRow('a')];
|
|
193
|
+
const base = { jobs, paused: null, runningCount: 0, now: NOW };
|
|
194
|
+
const stale = idleFor(3 * 60 * 60_000);
|
|
195
|
+
expect(scheduler.classifyQueueStarvation({ ...base, lastRunAtMs: stale }).kind).toBe('starved');
|
|
196
|
+
expect(scheduler.classifyQueueStarvation({ ...base, lastRunAtMs: idleFor(5_000) })).toBeNull();
|
|
197
|
+
expect(scheduler.classifyQueueStarvation({ ...base, lastRunAtMs: stale, lastPauseClearedAtMs: idleFor(5_000) })).toBeNull();
|
|
198
|
+
expect(scheduler.classifyQueueStarvation({ ...base, lastRunAtMs: stale, schedulerBootedAtMs: idleFor(5_000) })).toBeNull();
|
|
199
|
+
expect(scheduler.dispatchIdleMs({ lastRunAtMs: 1000, lastPauseClearedAtMs: 4000, schedulerBootedAtMs: 2000, now: 10_000 })).toBe(6000);
|
|
200
|
+
expect(scheduler.dispatchIdleMs({ now: 10_000 })).toBe(Infinity);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('rows held by a launch breaker / lease read blocked, not starved (per project too)', () => {
|
|
204
|
+
const jobs = [pendingRow('a'), pendingRow('b')];
|
|
205
|
+
const args = { jobs, paused: null, runningCount: 0, lastRunAtMs: idleFor(3 * 60 * 60_000), now: NOW };
|
|
206
|
+
expect(scheduler.classifyQueueStarvation(args).kind).toBe('starved');
|
|
207
|
+
const partial = scheduler.classifyQueueStarvation({ ...args, heldSlugs: new Set(['a']) });
|
|
208
|
+
expect(partial.kind).toBe('starved');
|
|
209
|
+
expect(partial.dispatchable).toBe(1);
|
|
210
|
+
const all = scheduler.classifyQueueStarvation({ ...args, heldSlugs: new Map([['a', 'x'], ['b', 'y']]) });
|
|
211
|
+
expect(all.kind).toBe('blocked');
|
|
212
|
+
expect(all.dispatchable).toBe(0);
|
|
213
|
+
const byProject = scheduler.classifyQueueStarvationByProject({
|
|
214
|
+
jobs, paused: null, runningSet: new Set(), lastRunAtMs: args.lastRunAtMs, heldSlugs: new Set(['a', 'b']), now: NOW,
|
|
215
|
+
});
|
|
216
|
+
expect(byProject.map((v) => v.kind)).toEqual(['blocked']);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test('the watchdog latches: one forced tick / audit per starve episode, re-armed by threshold or running-count change', async () => {
|
|
220
|
+
const cwd = '/tmp/sm-starve-latch-project';
|
|
221
|
+
const jobs = [{ slug: 'latch-a', cwd, status: 'pending', dependsOn: [] }];
|
|
222
|
+
const opts = { bootedAtMs: NOW - 3 * 60 * 60_000 };
|
|
223
|
+
const state = { jobs, paused: null, lastRunAt: new Date(NOW - 40 * 60_000).toISOString() };
|
|
224
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
225
|
+
const starvedLogs = () => warn.mock.calls.filter((c) => String(c[0]).includes('QUEUE STARVED')).length;
|
|
226
|
+
try {
|
|
227
|
+
// The row exists only in this hand-built state, never in queue.json, so
|
|
228
|
+
// the forced tick has nothing real to launch.
|
|
229
|
+
expect((await scheduler.runQueueStarvationWatchdog(state, { now: NOW, ...opts })).kind).toBe('starved');
|
|
230
|
+
expect(starvedLogs()).toBe(1);
|
|
231
|
+
// Same episode, well inside QUEUE_STARVATION_MS: still reported, not re-forced.
|
|
232
|
+
expect((await scheduler.runQueueStarvationWatchdog(state, { now: NOW + 60_000, ...opts })).kind).toBe('starved');
|
|
233
|
+
expect(starvedLogs()).toBe(1);
|
|
234
|
+
// QUEUE_STARVATION_MS elapsed again: a new episode.
|
|
235
|
+
await scheduler.runQueueStarvationWatchdog(state, { now: NOW + scheduler.QUEUE_STARVATION_MS + 1, ...opts });
|
|
236
|
+
expect(starvedLogs()).toBe(2);
|
|
237
|
+
} finally {
|
|
238
|
+
warn.mockRestore();
|
|
239
|
+
}
|
|
240
|
+
});
|
|
@@ -53,11 +53,11 @@ beforeEach(() => {
|
|
|
53
53
|
// Fresh require per test so HISTORY_PATH's target file starts clean.
|
|
54
54
|
delete require.cache[require.resolve('../lib/queueHistory.cjs')];
|
|
55
55
|
queueHistory = require('../lib/queueHistory.cjs');
|
|
56
|
-
try { fs.unlinkSync(queueHistory.
|
|
56
|
+
try { fs.unlinkSync(queueHistory.historyPath()); } catch { /* ok if absent */ }
|
|
57
57
|
});
|
|
58
58
|
|
|
59
59
|
afterEach(() => {
|
|
60
|
-
try { fs.unlinkSync(queueHistory.
|
|
60
|
+
try { fs.unlinkSync(queueHistory.historyPath()); } catch { /* ok if absent */ }
|
|
61
61
|
if (prevOverride === undefined) delete process.env.SM_HISTORY_PATH_OVERRIDE;
|
|
62
62
|
else process.env.SM_HISTORY_PATH_OVERRIDE = prevOverride;
|
|
63
63
|
});
|
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
* pollLoop): the 'meter_rate_limited' branch used to never back off and
|
|
5
5
|
* never call persistSchedulerState(), letting scheduler-state.json freeze
|
|
6
6
|
* stale while the loop kept failing every POLL_INTERVAL_MS underneath it.
|
|
7
|
-
* This suite covers the
|
|
8
|
-
* curve/cap (nextBackoffMs)
|
|
9
|
-
* (shouldWarnFailureStreak)
|
|
7
|
+
* This suite covers the pure pieces of the fix: the shared backoff
|
|
8
|
+
* curve/cap (nextBackoffMs), the once-per-streak WARN gate
|
|
9
|
+
* (shouldWarnFailureStreak), and the periodic escalation ladder that fires
|
|
10
|
+
* every FAILURE_STREAK_ESCALATION_MS while a streak PERSISTS past the
|
|
11
|
+
* initial WARN, re-arming once the streak clears (shouldEscalateFailureStreak).
|
|
10
12
|
*
|
|
11
13
|
* Run: timeout 120 npx vitest run src/main/__tests__/rateLimitPollerStreak.test.cjs
|
|
12
14
|
*/
|
|
@@ -17,8 +19,10 @@ import { test, expect } from 'vitest';
|
|
|
17
19
|
const {
|
|
18
20
|
nextBackoffMs,
|
|
19
21
|
shouldWarnFailureStreak,
|
|
22
|
+
shouldEscalateFailureStreak,
|
|
20
23
|
BACKOFF_MAX_MS,
|
|
21
24
|
FAILURE_STREAK_WARN_THRESHOLD,
|
|
25
|
+
FAILURE_STREAK_ESCALATION_MS,
|
|
22
26
|
} = require('../scheduler.cjs');
|
|
23
27
|
|
|
24
28
|
test('nextBackoffMs starts at 30s from a zero/falsy backoff', () => {
|
|
@@ -77,3 +81,34 @@ test('shouldWarnFailureStreak respects a custom threshold', () => {
|
|
|
77
81
|
expect(shouldWarnFailureStreak(3, false, 3)).toBe(true);
|
|
78
82
|
expect(shouldWarnFailureStreak(2, false, 3)).toBe(false);
|
|
79
83
|
});
|
|
84
|
+
|
|
85
|
+
test('shouldEscalateFailureStreak: false below the WARN threshold, regardless of elapsed time', () => {
|
|
86
|
+
expect(shouldEscalateFailureStreak(FAILURE_STREAK_WARN_THRESHOLD - 1, null, Date.now())).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('shouldEscalateFailureStreak: fires immediately the first time (lastEscalatedAtMs null) once past the WARN threshold', () => {
|
|
90
|
+
expect(shouldEscalateFailureStreak(FAILURE_STREAK_WARN_THRESHOLD, null, Date.now())).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('shouldEscalateFailureStreak: does not re-fire before FAILURE_STREAK_ESCALATION_MS has elapsed since the last one', () => {
|
|
94
|
+
const lastEscalatedAtMs = 1_000_000;
|
|
95
|
+
const tooSoon = lastEscalatedAtMs + FAILURE_STREAK_ESCALATION_MS - 1;
|
|
96
|
+
expect(shouldEscalateFailureStreak(FAILURE_STREAK_WARN_THRESHOLD, lastEscalatedAtMs, tooSoon)).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('shouldEscalateFailureStreak: fires again once FAILURE_STREAK_ESCALATION_MS has elapsed — the persisting-streak ladder', () => {
|
|
100
|
+
const lastEscalatedAtMs = 1_000_000;
|
|
101
|
+
const dueNow = lastEscalatedAtMs + FAILURE_STREAK_ESCALATION_MS;
|
|
102
|
+
expect(shouldEscalateFailureStreak(FAILURE_STREAK_WARN_THRESHOLD, lastEscalatedAtMs, dueNow)).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('shouldEscalateFailureStreak: re-arms after a streak clears (lastEscalatedAtMs reset to null) — a later independent streak escalates again', () => {
|
|
106
|
+
const firstStreakNow = 1_000_000;
|
|
107
|
+
expect(shouldEscalateFailureStreak(FAILURE_STREAK_WARN_THRESHOLD, null, firstStreakNow)).toBe(true);
|
|
108
|
+
// Streak clears (a success resets both failureStreakWarned and the
|
|
109
|
+
// escalation timestamp) — a LATER, independent streak crossing the WARN
|
|
110
|
+
// threshold again must escalate again immediately, not wait out a stale
|
|
111
|
+
// 30-minute window from the previous incident.
|
|
112
|
+
const laterStreakNow = firstStreakNow + 5 * 60_000; // well under 30 min later
|
|
113
|
+
expect(shouldEscalateFailureStreak(FAILURE_STREAK_WARN_THRESHOLD, null, laterStreakNow)).toBe(true);
|
|
114
|
+
});
|