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
|
@@ -24,6 +24,7 @@ let originalJobDisable;
|
|
|
24
24
|
let originalJobMax;
|
|
25
25
|
let originalEpicDisable;
|
|
26
26
|
let originalEpicMax;
|
|
27
|
+
let originalWorktreeRoot;
|
|
27
28
|
|
|
28
29
|
function git(args, cwd) {
|
|
29
30
|
return execFileSync('git', args, { cwd, encoding: 'utf8' });
|
|
@@ -43,6 +44,9 @@ beforeEach(() => {
|
|
|
43
44
|
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-gitworktree-'));
|
|
44
45
|
repoCwd = path.join(tmpRoot, 'repo');
|
|
45
46
|
initRepo(repoCwd);
|
|
47
|
+
// Every sweep below runs against THIS throwaway root, never the live job-worktree root.
|
|
48
|
+
originalWorktreeRoot = process.env.SM_WORKTREE_ROOT;
|
|
49
|
+
process.env.SM_WORKTREE_ROOT = path.join(tmpRoot, 'worktrees');
|
|
46
50
|
originalJobDisable = process.env.SM_JOB_WORKTREE_DISABLE;
|
|
47
51
|
originalJobMax = process.env.SM_JOB_WORKTREE_MAX;
|
|
48
52
|
originalEpicDisable = process.env.SM_EPIC_WORKTREE_DISABLE;
|
|
@@ -65,6 +69,7 @@ afterEach(async () => {
|
|
|
65
69
|
// Best-effort: prune any worktree either kind's tests created before removing the repo.
|
|
66
70
|
try { await gitWorktree.reconcileWorktreesOnBoot([repoCwd], { kind: 'job' }); } catch { /* ignore */ }
|
|
67
71
|
try { await gitWorktree.reconcileWorktreesOnBoot([repoCwd], { kind: 'epic' }); } catch { /* ignore */ }
|
|
72
|
+
restore('SM_WORKTREE_ROOT', originalWorktreeRoot);
|
|
68
73
|
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
69
74
|
});
|
|
70
75
|
|
|
@@ -1043,7 +1048,9 @@ test('[job] sweepStaleWorktreeCheckouts does NOT remove an old-mtime checkout wi
|
|
|
1043
1048
|
test('[job] sweepStaleWorktreeCheckouts removes an old-mtime checkout when there is no live holder', async () => {
|
|
1044
1049
|
const worktree = await gitWorktree.createJobWorktree({ cwd: repoCwd, slug: 'stale-and-dead' });
|
|
1045
1050
|
expect(worktree.ok).toBe(true);
|
|
1046
|
-
|
|
1051
|
+
// A populated holders set (some unrelated live cwd) that does not cover this checkout —
|
|
1052
|
+
// exercises the liveness gate for real instead of defeating it with an empty set.
|
|
1053
|
+
const holders = new Set([path.resolve(tmpRoot, 'unrelated-live-holder')]);
|
|
1047
1054
|
|
|
1048
1055
|
const result = await gitWorktree.sweepStaleWorktreeCheckouts('job', { staleAgeMs: 0, holders });
|
|
1049
1056
|
|
|
@@ -1,17 +1,49 @@
|
|
|
1
1
|
// PRD 834 — machine-wide scheduler-ownership lock. Secondary instances must
|
|
2
2
|
// go scheduler-passive instead of running boot reconciliation / admin server
|
|
3
3
|
// against the owner's queue state (live incident 2026-07-31).
|
|
4
|
-
|
|
4
|
+
//
|
|
5
|
+
// HOME is stubbed to a mkdtemp dir BEFORE requiring instanceLock.cjs (mirrors
|
|
6
|
+
// scheduler-reconcile-invalid-repair.test.cjs): instanceLock.cjs pulls in
|
|
7
|
+
// auditLog.cjs, whose audit log path resolves from
|
|
8
|
+
// os.homedir() — so real ~/.claude/session-manager state must never be
|
|
9
|
+
// touched by this suite.
|
|
10
|
+
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
|
|
5
11
|
import fs from 'node:fs';
|
|
6
12
|
import os from 'node:os';
|
|
7
13
|
import path from 'node:path';
|
|
8
14
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
+
let originalHome;
|
|
16
|
+
let tmpHome;
|
|
17
|
+
let instanceLock;
|
|
18
|
+
let auditLogPath;
|
|
19
|
+
|
|
20
|
+
beforeAll(() => {
|
|
21
|
+
originalHome = process.env.HOME;
|
|
22
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-instance-lock-home-'));
|
|
23
|
+
process.env.HOME = tmpHome;
|
|
24
|
+
instanceLock = require('../instanceLock.cjs');
|
|
25
|
+
({ auditLogPath } = require('../auditLog.cjs'));
|
|
26
|
+
if (!auditLogPath().startsWith(tmpHome)) {
|
|
27
|
+
throw new Error(`refusing to run: auditLogPath() (${auditLogPath()}) is not under the temp HOME (${tmpHome})`);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterAll(() => {
|
|
32
|
+
process.env.HOME = originalHome;
|
|
33
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Thin pass-throughs so every test body below can keep calling these as
|
|
37
|
+
// bare names, resolved lazily against the module loaded in beforeAll.
|
|
38
|
+
const acquireSchedulerOwnership = (...args) => instanceLock.acquireSchedulerOwnership(...args);
|
|
39
|
+
const releaseSchedulerOwnership = (...args) => instanceLock.releaseSchedulerOwnership(...args);
|
|
40
|
+
const pidAlive = (...args) => instanceLock.pidAlive(...args);
|
|
41
|
+
const lockPath = (...args) => instanceLock.lockPath(...args);
|
|
42
|
+
|
|
43
|
+
function readAuditEvents() {
|
|
44
|
+
if (!fs.existsSync(auditLogPath())) return [];
|
|
45
|
+
return fs.readFileSync(auditLogPath(), 'utf8').trim().split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
46
|
+
}
|
|
15
47
|
|
|
16
48
|
let dir;
|
|
17
49
|
|
|
@@ -22,7 +54,9 @@ beforeEach(() => {
|
|
|
22
54
|
|
|
23
55
|
afterEach(() => {
|
|
24
56
|
delete process.env.SM_SCHEDULER_LOCK_PATH;
|
|
57
|
+
delete process.env.SM_PROC_ROOT;
|
|
25
58
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
59
|
+
if (fs.existsSync(auditLogPath())) fs.rmSync(auditLogPath(), { force: true });
|
|
26
60
|
});
|
|
27
61
|
|
|
28
62
|
describe('instanceLock (PRD 834)', () => {
|
|
@@ -84,4 +118,56 @@ describe('instanceLock (PRD 834)', () => {
|
|
|
84
118
|
expect(pidAlive(-5)).toBe(false);
|
|
85
119
|
expect(pidAlive(0)).toBe(false);
|
|
86
120
|
});
|
|
121
|
+
|
|
122
|
+
it('a legacy lock ({pid, startedAt} only, no identity) is respected while its pid is alive', () => {
|
|
123
|
+
// Mirrors the on-disk shape of every lock written before this PRD.
|
|
124
|
+
fs.mkdirSync(path.dirname(lockPath()), { recursive: true });
|
|
125
|
+
fs.writeFileSync(lockPath(), JSON.stringify({ pid: 1, startedAt: 'x' }));
|
|
126
|
+
const res = acquireSchedulerOwnership();
|
|
127
|
+
expect(res.owner).toBe(false);
|
|
128
|
+
expect(res.holderPid).toBe(1);
|
|
129
|
+
expect(JSON.parse(fs.readFileSync(lockPath(), 'utf8')).pid).toBe(1);
|
|
130
|
+
// No identity information to compare, so no stale-lock-broken audit event.
|
|
131
|
+
expect(readAuditEvents().some((e) => e.kind === 'stale_lock_broken')).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('a recorded identity that no longer matches the live process at that (recycled) pid is broken, and audited', () => {
|
|
135
|
+
// process.ppid is guaranteed alive for the lifetime of this test (the
|
|
136
|
+
// vitest worker's own parent) — stand-in for "a hard crash left a lock
|
|
137
|
+
// whose pid was later reused by an unrelated live process".
|
|
138
|
+
const recycledPid = process.ppid;
|
|
139
|
+
const procRoot = path.join(dir, 'proc');
|
|
140
|
+
fs.mkdirSync(path.join(procRoot, String(recycledPid)), { recursive: true });
|
|
141
|
+
fs.writeFileSync(
|
|
142
|
+
path.join(procRoot, String(recycledPid), 'stat'),
|
|
143
|
+
`${recycledPid} (bash) S 1 ${recycledPid} ${recycledPid} 0 -1 4194304 0 0 0 0 0 0 0 0 20 0 1 0 222222 0 0 0 0 0 0 0 0 0 0 0 0 0 17 0 0 0 0 0 0 0 0 0 0 0 0\n`,
|
|
144
|
+
);
|
|
145
|
+
fs.writeFileSync(path.join(procRoot, String(recycledPid), 'cmdline'), 'bash\0');
|
|
146
|
+
process.env.SM_PROC_ROOT = procRoot;
|
|
147
|
+
|
|
148
|
+
fs.mkdirSync(path.dirname(lockPath()), { recursive: true });
|
|
149
|
+
fs.writeFileSync(lockPath(), JSON.stringify({
|
|
150
|
+
pid: recycledPid,
|
|
151
|
+
startedAt: 'x',
|
|
152
|
+
// The identity the crashed scheduler instance actually recorded — a
|
|
153
|
+
// different cmdline/startTicks than the process now holding this pid.
|
|
154
|
+
identity: { pid: recycledPid, startTicks: 111111, cmdline: 'claude --session-id old', complete: true },
|
|
155
|
+
}));
|
|
156
|
+
|
|
157
|
+
const res = acquireSchedulerOwnership();
|
|
158
|
+
expect(res.owner).toBe(true);
|
|
159
|
+
expect(JSON.parse(fs.readFileSync(lockPath(), 'utf8')).pid).toBe(process.pid);
|
|
160
|
+
|
|
161
|
+
const auditEvents = readAuditEvents().filter((e) => e.kind === 'stale_lock_broken');
|
|
162
|
+
expect(auditEvents.length).toBe(1);
|
|
163
|
+
expect(auditEvents[0].recordedPid).toBe(recycledPid);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('writeLockExclusive (via acquire) records a complete identity for this process', () => {
|
|
167
|
+
acquireSchedulerOwnership();
|
|
168
|
+
const onDisk = JSON.parse(fs.readFileSync(lockPath(), 'utf8'));
|
|
169
|
+
expect(onDisk.identity).toBeTruthy();
|
|
170
|
+
expect(onDisk.identity.pid).toBe(process.pid);
|
|
171
|
+
expect(onDisk.identity.complete).toBe(true);
|
|
172
|
+
});
|
|
87
173
|
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jobSupervisorRecord.test.cjs — record round-trip, live listing, retention
|
|
3
|
+
* claim, and the pure classifyAdoption matrix.
|
|
4
|
+
*
|
|
5
|
+
* Run: timeout 120 npx vitest run src/main/lib/__tests__/jobSupervisorRecord.test.cjs
|
|
6
|
+
*/
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
import { test, expect, beforeEach, afterEach } from 'vitest';
|
|
10
|
+
const fs = require('node:fs');
|
|
11
|
+
const os = require('node:os');
|
|
12
|
+
const path = require('node:path');
|
|
13
|
+
const rec = require('../jobSupervisorRecord.cjs');
|
|
14
|
+
|
|
15
|
+
let root;
|
|
16
|
+
beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-supervisor-')); });
|
|
17
|
+
afterEach(() => { fs.rmSync(root, { recursive: true, force: true }); });
|
|
18
|
+
|
|
19
|
+
const base = (over = {}) => ({
|
|
20
|
+
runDir: path.join(root, 'run-1'), slug: 'p1', cwd: '/x', runId: 'run-1', pid: 42, pgid: 42,
|
|
21
|
+
identity: { pid: 42, startTicks: 7, cmdline: 'claude -p', complete: true },
|
|
22
|
+
execCwd: '/wt', worktreeDir: '/wt', worktreeBranch: 'sm-job/p1', sessionId: 's',
|
|
23
|
+
startedAt: 1_000, budgetMs: 10_000, maxDurationMs: null, idleKillMs: 5_000, schedulerPid: 1, codeSha: 'abc', ...over,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('write → read round-trips every field at <runDir>/<slug>.supervisor.json', () => {
|
|
27
|
+
const input = base();
|
|
28
|
+
rec.writeSupervisorRecord(input);
|
|
29
|
+
expect(fs.existsSync(path.join(input.runDir, 'p1.supervisor.json'))).toBe(true);
|
|
30
|
+
const back = rec.readSupervisorRecord(input.runDir, 'p1');
|
|
31
|
+
const { runDir, ...persisted } = input;
|
|
32
|
+
expect(back).toMatchObject({ ...persisted, kind: 'job' });
|
|
33
|
+
expect(back.runDir).toBeUndefined();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('readSupervisorRecord returns null for absent or torn files', () => {
|
|
37
|
+
expect(rec.readSupervisorRecord(path.join(root, 'nope'), 'p1')).toBeNull();
|
|
38
|
+
fs.mkdirSync(path.join(root, 'r'));
|
|
39
|
+
fs.writeFileSync(path.join(root, 'r', 'p1.supervisor.json'), '{"pid":');
|
|
40
|
+
expect(rec.readSupervisorRecord(path.join(root, 'r'), 'p1')).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('listLiveSupervisorRecords skips exited (meta.json) and stale records', () => {
|
|
44
|
+
rec.writeSupervisorRecord(base({ slug: 'live' }));
|
|
45
|
+
rec.writeSupervisorRecord(base({ slug: 'done' }));
|
|
46
|
+
fs.writeFileSync(path.join(root, 'run-1', 'done.meta.json'), '{}');
|
|
47
|
+
const live = rec.listLiveSupervisorRecords(root, { maxAgeMs: 60_000 });
|
|
48
|
+
expect(live.map((r) => r.slug)).toEqual(['live']);
|
|
49
|
+
expect(live[0].runDir).toBe(path.join(root, 'run-1'));
|
|
50
|
+
expect(rec.listLiveSupervisorRecords(root, { maxAgeMs: 1, now: Date.now() + 10_000 })).toEqual([]);
|
|
51
|
+
expect(rec.listLiveSupervisorRecords(path.join(root, 'missing'), { maxAgeMs: 1 })).toEqual([]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('investigation records use the .investigation.log exit line as their marker', () => {
|
|
55
|
+
const runDir = path.join(root, 'run-1');
|
|
56
|
+
rec.writeSupervisorRecord(base({ slug: 'p1.investigation', kind: 'investigation' }));
|
|
57
|
+
fs.writeFileSync(path.join(runDir, 'p1.investigation.log'), 'working\n');
|
|
58
|
+
expect(rec.listLiveSupervisorRecords(root, { maxAgeMs: 60_000 })).toHaveLength(1);
|
|
59
|
+
fs.appendFileSync(path.join(runDir, 'p1.investigation.log'), '\n[scheduler] investigation exit code=0\n');
|
|
60
|
+
expect(rec.listLiveSupervisorRecords(root, { maxAgeMs: 60_000 })).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('classifyAdoption matrix', () => {
|
|
64
|
+
const r = base();
|
|
65
|
+
const same = { pid: 42, startTicks: 7, cmdline: 'claude -p', complete: true };
|
|
66
|
+
const other = { pid: 42, startTicks: 99, cmdline: 'claude -p', complete: true };
|
|
67
|
+
const now = 2_000;
|
|
68
|
+
const c = (o) => rec.classifyAdoption(r, { identity: same, pidAlive: true, logMtimeMs: now, exitMarker: false, now, ...o });
|
|
69
|
+
expect(c({})).toBe('adopt');
|
|
70
|
+
expect(c({ pidAlive: false })).toBe('dead');
|
|
71
|
+
expect(c({ pidAlive: false, exitMarker: true })).toBe('exited');
|
|
72
|
+
expect(c({ identity: other })).toBe('foreign-pid');
|
|
73
|
+
expect(c({ identity: { pid: 42, startTicks: null, cmdline: null, complete: false } })).toBe('adopt'); // fail-closed
|
|
74
|
+
expect(c({ now: 1_000 + 10_001, logMtimeMs: 1_000 + 10_000 })).toBe('over-budget');
|
|
75
|
+
expect(c({ now: 1_000 + 5_001, logMtimeMs: 1_000 })).toBe('over-budget'); // log stalled past idleKillMs
|
|
76
|
+
expect(rec.classifyAdoption(base({ budgetMs: null, maxDurationMs: 100 }), { identity: same, pidAlive: true, logMtimeMs: 1_050, now: 1_200 })).toBe('over-budget');
|
|
77
|
+
expect(rec.classifyAdoption(base({ budgetMs: null, idleKillMs: null }), { identity: same, pidAlive: true, now: 9e9 })).toBe('adopt');
|
|
78
|
+
});
|
|
@@ -69,3 +69,14 @@ test('a precomputed cwdHolders set is forwarded through to hasLiveHolder unchang
|
|
|
69
69
|
isLive('job-d', { worktree: '/tmp/checkout-d' });
|
|
70
70
|
expect(hasLiveHolder).toHaveBeenCalledWith('/tmp/checkout-d', holders);
|
|
71
71
|
});
|
|
72
|
+
|
|
73
|
+
test('rowPid (the record/log ladder) is consulted instead of runtime.pid alone', () => {
|
|
74
|
+
const claudePidAlive = vi.fn((pid) => pid === 777);
|
|
75
|
+
const isLive = buildJobWorktreeIsLive({
|
|
76
|
+
bootJobs: [{ slug: 'job-e', status: 'running' }],
|
|
77
|
+
claudePidAlive,
|
|
78
|
+
hasLiveHolder: () => false,
|
|
79
|
+
rowPid: () => 777,
|
|
80
|
+
});
|
|
81
|
+
expect(isLive('job-e', { worktree: '/tmp/checkout-e' })).toBe(true);
|
|
82
|
+
});
|
|
@@ -173,7 +173,7 @@ test('with neither SM_DEV nor SM_E2E set, resolves to the original admin-api.jso
|
|
|
173
173
|
delete process.env.SM_E2E;
|
|
174
174
|
delete process.env.SM_ADMIN_TOKEN_PATH;
|
|
175
175
|
try {
|
|
176
|
-
expect(resolveTokenPath()).toBe(
|
|
176
|
+
expect(resolveTokenPath()).toBe(path.join(require('../schedulerPaths.cjs').schedulerHome(), 'admin-api.json'));
|
|
177
177
|
} finally {
|
|
178
178
|
if (prevDev === undefined) delete process.env.SM_DEV; else process.env.SM_DEV = prevDev;
|
|
179
179
|
if (prevE2e === undefined) delete process.env.SM_E2E; else process.env.SM_E2E = prevE2e;
|
|
@@ -78,12 +78,17 @@ test.each(MCP_TOOL_CATALOG)('$name composeDescription joins purpose/whenToUse/wh
|
|
|
78
78
|
expect(composeDescription(entry).length).toBeGreaterThan(0);
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
+
test('project-home group is exactly the single project_home_write tool', () => {
|
|
82
|
+
expect(MCP_TOOL_CATALOG.filter((e) => e.group === 'project-home').map((e) => e.name)).toEqual(['project_home_write']);
|
|
83
|
+
});
|
|
84
|
+
|
|
81
85
|
test('MCP_RECIPES covers queue-work, unstick-needs-review, and hand-off-to-another-project', () => {
|
|
82
86
|
const ids = MCP_RECIPES.map((r) => r.id);
|
|
83
87
|
expect(ids).toContain('queue-work-via-develop');
|
|
84
88
|
expect(ids).toContain('unstick-needs-review-job');
|
|
85
89
|
expect(ids).toContain('hand-finding-to-another-project');
|
|
86
|
-
|
|
90
|
+
// the single-tool project_home_write needs no recipe; the old 4-step one is gone
|
|
91
|
+
expect(ids).not.toContain('generate-project-home');
|
|
87
92
|
for (const recipe of MCP_RECIPES) {
|
|
88
93
|
expect(recipe.title).toBeTruthy();
|
|
89
94
|
expect(Array.isArray(recipe.steps)).toBe(true);
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// PRD: fail-closed process identity so a recycled pid can never pin
|
|
2
|
+
// instanceLock ownership or dodge killOrphanClaudePid's veto. Stat parsing is
|
|
3
|
+
// exercised against fixture files under SM_PROC_ROOT (never real /proc) so a
|
|
4
|
+
// synthetic `comm` containing parens/spaces can be asserted precisely.
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
const { identity, isDifferentProcess } = require('../procIdentity.cjs');
|
|
11
|
+
|
|
12
|
+
let dir;
|
|
13
|
+
|
|
14
|
+
function writeProcEntry(root, pid, { stat, cmdline }) {
|
|
15
|
+
const entryDir = path.join(root, String(pid));
|
|
16
|
+
fs.mkdirSync(entryDir, { recursive: true });
|
|
17
|
+
if (stat !== undefined) fs.writeFileSync(path.join(entryDir, 'stat'), stat);
|
|
18
|
+
if (cmdline !== undefined) fs.writeFileSync(path.join(entryDir, 'cmdline'), cmdline);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Real /proc/<pid>/stat line, comm replaced with one containing a space and
|
|
22
|
+
// an embedded ')' — field 22 (starttime) is `124686`, at the position AFTER
|
|
23
|
+
// the LAST ')', not the first.
|
|
24
|
+
function statLine(comm, startTicks) {
|
|
25
|
+
return `12345 (${comm}) R 1 12345 12345 0 -1 4194304 118 0 0 0 0 0 0 0 32 12 1 0 ${startTicks} 18698240 440 0 0 0 0 0 0 0 0 0 0 0 17 7 0 0 0 0 0 0 0 0 0 0 0 0\n`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-proc-identity-'));
|
|
30
|
+
process.env.SM_PROC_ROOT = dir;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
afterEach(() => {
|
|
34
|
+
delete process.env.SM_PROC_ROOT;
|
|
35
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('procIdentity.identity — /proc parsing', () => {
|
|
39
|
+
it('parses field 22 located after the LAST ")" when comm contains a paren and a space', () => {
|
|
40
|
+
writeProcEntry(dir, 4242, {
|
|
41
|
+
stat: statLine('claude (worker)', 999888),
|
|
42
|
+
cmdline: 'node\0claude\0--session-id\0abc\0',
|
|
43
|
+
});
|
|
44
|
+
const id = identity(4242);
|
|
45
|
+
expect(id.pid).toBe(4242);
|
|
46
|
+
expect(id.startTicks).toBe(999888);
|
|
47
|
+
expect(id.cmdline).toBe('node claude --session-id abc');
|
|
48
|
+
expect(id.complete).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('reads the live node process itself from real /proc (sanity check against production code path)', () => {
|
|
52
|
+
delete process.env.SM_PROC_ROOT;
|
|
53
|
+
const id = identity(process.pid);
|
|
54
|
+
expect(id.pid).toBe(process.pid);
|
|
55
|
+
expect(typeof id.startTicks).toBe('number');
|
|
56
|
+
expect(id.startTicks).toBeGreaterThan(0);
|
|
57
|
+
expect(typeof id.cmdline).toBe('string');
|
|
58
|
+
expect(id.cmdline.length).toBeGreaterThan(0);
|
|
59
|
+
expect(id.complete).toBe(true);
|
|
60
|
+
process.env.SM_PROC_ROOT = dir; // restore for afterEach
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('tri-state: missing stat file → incomplete (startTicks null) even though cmdline is readable', () => {
|
|
64
|
+
writeProcEntry(dir, 5001, { cmdline: 'node\0claude\0' });
|
|
65
|
+
const id = identity(5001);
|
|
66
|
+
expect(id.startTicks).toBeNull();
|
|
67
|
+
expect(id.cmdline).toBe('node claude');
|
|
68
|
+
expect(id.complete).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('tri-state: missing cmdline file → incomplete (cmdline null) even though stat is readable', () => {
|
|
72
|
+
writeProcEntry(dir, 5002, { stat: statLine('claude', 111) });
|
|
73
|
+
const id = identity(5002);
|
|
74
|
+
expect(id.startTicks).toBe(111);
|
|
75
|
+
expect(id.cmdline).toBeNull();
|
|
76
|
+
expect(id.complete).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('tri-state: neither file exists (pid never existed / already reaped) → incomplete, never throws', () => {
|
|
80
|
+
const id = identity(999999);
|
|
81
|
+
expect(id.startTicks).toBeNull();
|
|
82
|
+
expect(id.cmdline).toBeNull();
|
|
83
|
+
expect(id.complete).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('rejects a non-positive-integer pid without touching the filesystem', () => {
|
|
87
|
+
expect(identity(-1).complete).toBe(false);
|
|
88
|
+
expect(identity(0).complete).toBe(false);
|
|
89
|
+
expect(identity(NaN).complete).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('procIdentity.isDifferentProcess — fail-closed decision', () => {
|
|
94
|
+
const complete = (over = {}) => ({ pid: 100, startTicks: 555, cmdline: 'node claude', complete: true, ...over });
|
|
95
|
+
|
|
96
|
+
it('true only when both sides are complete AND a field differs', () => {
|
|
97
|
+
expect(isDifferentProcess(complete(), complete({ startTicks: 999 }))).toBe(true);
|
|
98
|
+
expect(isDifferentProcess(complete(), complete({ cmdline: 'node other' }))).toBe(true);
|
|
99
|
+
expect(isDifferentProcess(complete(), complete())).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('an incomplete recorded or live side never reads as different (fail closed)', () => {
|
|
103
|
+
const incomplete = { pid: 100, startTicks: null, cmdline: null, complete: false };
|
|
104
|
+
expect(isDifferentProcess(incomplete, complete())).toBe(false);
|
|
105
|
+
expect(isDifferentProcess(complete(), incomplete)).toBe(false);
|
|
106
|
+
expect(isDifferentProcess(incomplete, incomplete)).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('a null/undefined probe (unreadable /proc, process gone) never reads as different', () => {
|
|
110
|
+
expect(isDifferentProcess(complete(), null)).toBe(false);
|
|
111
|
+
expect(isDifferentProcess(null, complete())).toBe(false);
|
|
112
|
+
expect(isDifferentProcess(undefined, undefined)).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('a legacy record with no identity fields at all never reads as different', () => {
|
|
116
|
+
const legacy = { pid: 100 }; // e.g. an old {pid, startedAt}-only lock body
|
|
117
|
+
expect(isDifferentProcess(legacy, complete())).toBe(false);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// PRD pn-01: procName primitive. HOME is redirected to a temp dir for the whole file
|
|
2
|
+
// (procnamesRoot() resolves lazily from HOME) — never the real ~/.claude.
|
|
3
|
+
import { describe, it, expect, afterAll } from 'vitest';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'procname-'));
|
|
9
|
+
const realHome = process.env.HOME;
|
|
10
|
+
process.env.HOME = tmpHome; // stays redirected: procnamesRoot() resolves lazily
|
|
11
|
+
const { aliasBinFor, smArgv0, procnamesRoot, pruneStaleAliases } = require('../procName.cjs');
|
|
12
|
+
|
|
13
|
+
afterAll(() => {
|
|
14
|
+
process.env.HOME = realHome;
|
|
15
|
+
try { fs.chmodSync(procnamesRoot(), 0o700); } catch { /* absent */ }
|
|
16
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
function fakeBin(name) {
|
|
20
|
+
const p = path.join(tmpHome, name);
|
|
21
|
+
fs.writeFileSync(p, '#!/bin/sh\nexit 0\n', { mode: 0o755 });
|
|
22
|
+
return p;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('procName', () => {
|
|
26
|
+
it('procnamesRoot() lives under the temp HOME machine-state root', () => {
|
|
27
|
+
expect(procnamesRoot()).toBe(path.join(tmpHome, '.claude', 'session-manager', 'procnames'));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('creates a symlink and returns the alias path', () => {
|
|
31
|
+
const bin = fakeBin('bin-a');
|
|
32
|
+
const out = aliasBinFor(bin, 'sm-claude-job');
|
|
33
|
+
expect(out).toBe(path.join(procnamesRoot(), 'sm-claude-job'));
|
|
34
|
+
expect(fs.readlinkSync(out)).toBe(bin);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('self-repairs a drifted link', () => {
|
|
38
|
+
const a = fakeBin('bin-b1');
|
|
39
|
+
const b = fakeBin('bin-b2');
|
|
40
|
+
aliasBinFor(a, 'sm-claude-chat');
|
|
41
|
+
const out = aliasBinFor(b, 'sm-claude-chat');
|
|
42
|
+
expect(fs.readlinkSync(out)).toBe(b);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('throws on an alias longer than 15 chars', () => {
|
|
46
|
+
expect(() => aliasBinFor(fakeBin('bin-c'), 'sm-claude-toolong-x')).toThrow(/TASK_COMM_LEN/);
|
|
47
|
+
expect(() => aliasBinFor(fakeBin('bin-c'), 'session-manager')).not.toThrow();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('returns the original bin for an unresolvable bare name', () => {
|
|
51
|
+
expect(aliasBinFor('definitely-not-a-real-cmd-xyz', 'sm-claude-aux')).toBe('definitely-not-a-real-cmd-xyz');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it.skipIf(process.getuid && process.getuid() === 0)('fails open when procnamesRoot() is read-only', () => {
|
|
55
|
+
const bin = fakeBin('bin-d');
|
|
56
|
+
fs.mkdirSync(procnamesRoot(), { recursive: true });
|
|
57
|
+
fs.chmodSync(procnamesRoot(), 0o500);
|
|
58
|
+
try {
|
|
59
|
+
expect(aliasBinFor(bin, 'sm-shell')).toBe(bin);
|
|
60
|
+
} finally {
|
|
61
|
+
fs.chmodSync(procnamesRoot(), 0o700);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('smArgv0 shape, sanitisation, and length bound', () => {
|
|
66
|
+
expect(smArgv0('job', 'prd 12/x!')).toBe('sm-claude-job:prd12x');
|
|
67
|
+
expect(smArgv0('job', 'a'.repeat(500)).length).toBeLessThanOrEqual(120);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('smArgv0 always matches the live /\\bclaude\\b/ gates for every role', () => {
|
|
71
|
+
// Replicated verbatim from scheduler.cjs killOrphanClaudePid & reaperHelpers.cjs claudePidAlive.
|
|
72
|
+
const gate = /\bclaude\b/;
|
|
73
|
+
for (const role of ['job', 'chat', 'aux', 'helper', '', undefined, '!!!', 'x'.repeat(300)]) {
|
|
74
|
+
for (const detail of ['abc', '', undefined, null, '///', 'y'.repeat(300)]) {
|
|
75
|
+
const label = smArgv0(role, detail);
|
|
76
|
+
expect(gate.test(label)).toBe(true);
|
|
77
|
+
expect(label.length).toBeLessThanOrEqual(120);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('pruneStaleAliases removes dead links and keeps live ones', () => {
|
|
83
|
+
const live = fakeBin('bin-live');
|
|
84
|
+
const dead = fakeBin('bin-dead');
|
|
85
|
+
const liveAlias = aliasBinFor(live, 'sm-live');
|
|
86
|
+
const deadAlias = aliasBinFor(dead, 'sm-dead');
|
|
87
|
+
fs.rmSync(dead);
|
|
88
|
+
expect(() => pruneStaleAliases()).not.toThrow();
|
|
89
|
+
expect(fs.existsSync(liveAlias)).toBe(true);
|
|
90
|
+
expect(fs.lstatSync(deadAlias, { throwIfNoEntry: false })).toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -121,3 +121,70 @@ test('readMerged (async) recovers the same real fixture', async () => {
|
|
|
121
121
|
expect(state.machineStateRecoveryMode).toBe('prefix');
|
|
122
122
|
expect(state.lastRunAt).toBe('2026-09-11T10:00:14.441Z');
|
|
123
123
|
});
|
|
124
|
+
|
|
125
|
+
// ---------- per-cwd shard quarantine (never recover, never write) ----------
|
|
126
|
+
|
|
127
|
+
function makeProject(name, jobs) {
|
|
128
|
+
const cwd = path.join(tmpHome, 'Projects', name);
|
|
129
|
+
const stateDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'state');
|
|
130
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
131
|
+
const file = path.join(stateDir, 'queue.json');
|
|
132
|
+
if (jobs) fs.writeFileSync(file, JSON.stringify({ jobs }));
|
|
133
|
+
const slugDir = path.join(tmpHome, '.claude', 'projects', `slug-${name}`);
|
|
134
|
+
fs.mkdirSync(slugDir, { recursive: true });
|
|
135
|
+
fs.writeFileSync(path.join(slugDir, 't.jsonl'), JSON.stringify({ cwd }) + '\n');
|
|
136
|
+
return { cwd, file };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const TORN = '{"jobs":[{"slug":"a","status":"running"}]} {"jobs":[{"slug":"a","stat';
|
|
140
|
+
const goodJob = (slug, cwd) => ({
|
|
141
|
+
slug, status: 'pending', prdPath: `/x/${slug}.md`, cwd, scheduledFor: null, createdAt: new Date().toISOString(),
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('a torn shard is quarantined per cwd; the machine-level `unreadable` stays unset and the sibling shard still loads', () => {
|
|
145
|
+
const good = makeProject('good', [goodJob('g1', path.join(tmpHome, 'Projects', 'good'))]);
|
|
146
|
+
const torn = makeProject('torn');
|
|
147
|
+
fs.writeFileSync(torn.file, TORN);
|
|
148
|
+
const opts = { projectsDir: path.join(tmpHome, '.claude', 'projects') };
|
|
149
|
+
|
|
150
|
+
const state = queueStore.readMergedSync(opts);
|
|
151
|
+
|
|
152
|
+
expect(state.unreadable).toBeUndefined();
|
|
153
|
+
expect(state.unreadableCwds).toHaveLength(1);
|
|
154
|
+
expect(state.unreadableCwds[0]).toMatchObject({ cwd: torn.cwd, file: torn.file });
|
|
155
|
+
expect(typeof state.unreadableCwds[0].error).toBe('string');
|
|
156
|
+
expect(state.jobs.map((j) => j.slug)).toEqual(['g1']);
|
|
157
|
+
expect(state.sourceCwds).not.toContain(torn.cwd);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('readMerged (async) quarantines the same way', async () => {
|
|
161
|
+
const torn = makeProject('torn2');
|
|
162
|
+
fs.writeFileSync(torn.file, TORN);
|
|
163
|
+
const state = await queueStore.readMerged({ projectsDir: path.join(tmpHome, '.claude', 'projects') });
|
|
164
|
+
expect(state.unreadable).toBeUndefined();
|
|
165
|
+
expect(state.unreadableCwds.map((u) => u.cwd)).toEqual([torn.cwd]);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('writeSplit leaves a torn shard byte-identical even when merged rows carry its cwd, and still writes healthy shards', async () => {
|
|
169
|
+
const good = makeProject('good3', [goodJob('g1', path.join(tmpHome, 'Projects', 'good3'))]);
|
|
170
|
+
const torn = makeProject('torn3');
|
|
171
|
+
fs.writeFileSync(torn.file, TORN);
|
|
172
|
+
const before = fs.readFileSync(torn.file);
|
|
173
|
+
const opts = { projectsDir: path.join(tmpHome, '.claude', 'projects') };
|
|
174
|
+
|
|
175
|
+
const state = await queueStore.readMerged(opts);
|
|
176
|
+
state.jobs.push(goodJob('intruder', torn.cwd)); // a row that claims the torn cwd
|
|
177
|
+
state.jobs[0].status = 'running';
|
|
178
|
+
await queueStore.writeSplit(state, good.cwd);
|
|
179
|
+
|
|
180
|
+
expect(fs.readFileSync(torn.file).equals(before)).toBe(true);
|
|
181
|
+
const written = JSON.parse(fs.readFileSync(good.file, 'utf8'));
|
|
182
|
+
expect(written.jobs.map((j) => j.status)).toEqual(['running']);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('writeSplit never re-creates a quarantined shard that has no file at all', async () => {
|
|
186
|
+
const ghost = makeProject('ghost');
|
|
187
|
+
const state = { config: {}, jobs: [goodJob('x', ghost.cwd)], sourceCwds: [], unreadableCwds: [{ cwd: ghost.cwd, file: ghost.file, error: 'x' }] };
|
|
188
|
+
await queueStore.writeSplit(state, ghost.cwd);
|
|
189
|
+
expect(fs.existsSync(ghost.file)).toBe(false);
|
|
190
|
+
});
|
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
* when the admin API is unreachable — only the readiness section may report
|
|
7
7
|
* unavailable.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* the
|
|
13
|
-
*
|
|
9
|
+
* The admin token path (schedulerPaths.adminTokenPath) resolves lazily from
|
|
10
|
+
* os.homedir(). Since os.homedir() reads process.env.HOME on POSIX, each test
|
|
11
|
+
* repoints HOME at a fresh tmp dir for its duration (restored in afterEach)
|
|
12
|
+
* so the token path resolves under that tmp dir, never the real
|
|
13
|
+
* ~/.claude/session-manager/admin-api.json.
|
|
14
14
|
*
|
|
15
15
|
* Run: timeout 120 npx vitest run src/main/lib/__tests__/schedulerMcpServerHelp.test.cjs
|
|
16
16
|
*/
|
|
@@ -72,8 +72,7 @@ function requireServerWithHome(homeDir) {
|
|
|
72
72
|
process.env.HOME = homeDir;
|
|
73
73
|
delete require.cache[require.resolve(SERVER_PATH)];
|
|
74
74
|
const mod = require(SERVER_PATH);
|
|
75
|
-
|
|
76
|
-
originalHome = undefined;
|
|
75
|
+
// HOME stays redirected until afterEach: admin-token path resolves lazily.
|
|
77
76
|
return mod;
|
|
78
77
|
}
|
|
79
78
|
|