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
|
@@ -35,44 +35,23 @@
|
|
|
35
35
|
* unrelated use that a blanket match would wrongly refuse.
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
|
-
const os = require('node:os');
|
|
39
38
|
const path = require('node:path');
|
|
40
|
-
const {
|
|
41
|
-
const { KIND_CONFIG } = require('./gitWorktree.cjs');
|
|
42
|
-
|
|
43
|
-
const TMPDIR = path.resolve(os.tmpdir());
|
|
44
|
-
const MANAGED_WORKTREE_ROOTS = [KIND_CONFIG.job.root, KIND_CONFIG.epic.root].map((p) => path.resolve(p));
|
|
45
|
-
|
|
46
|
-
function isExactlyTmpdir(absCwd) {
|
|
47
|
-
return absCwd === TMPDIR;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function isUnderManagedWorktreeRoot(absCwd) {
|
|
51
|
-
return MANAGED_WORKTREE_ROOTS.some((root) => {
|
|
52
|
-
const rel = path.relative(root, absCwd);
|
|
53
|
-
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
54
|
-
});
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function isLinkedWorktreeRoot(absCwd) {
|
|
58
|
-
const mainRoot = worktreeMainRootOf(absCwd);
|
|
59
|
-
return Boolean(mainRoot) && path.resolve(mainRoot) !== absCwd;
|
|
60
|
-
}
|
|
39
|
+
const { classifyCwd } = require('./cwdClassify.cjs');
|
|
61
40
|
|
|
62
41
|
/**
|
|
63
42
|
* isEphemeralCwd(cwd) → true when `cwd` must never have ops state written
|
|
64
43
|
* into it (tmpdir-resident, or a linked git worktree root). Pure, synchronous,
|
|
65
44
|
* never throws — an unresolvable or non-absolute cwd is treated as NOT
|
|
66
45
|
* ephemeral so this predicate only ever adds refusals, never masks the
|
|
67
|
-
* existing "cwd must be absolute" checks callers already perform.
|
|
46
|
+
* existing "cwd must be absolute" checks callers already perform. The
|
|
47
|
+
* unprovable-worktree case is NOT ephemeral here: it is kind `unknown`, which
|
|
48
|
+
* opsOwnership.assertOpsWrite refuses separately (writes only — dispatch and
|
|
49
|
+
* reads stay open).
|
|
68
50
|
*/
|
|
69
51
|
function isEphemeralCwd(cwd) {
|
|
70
52
|
if (!cwd || typeof cwd !== 'string' || !path.isAbsolute(cwd)) return false;
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
if (isUnderManagedWorktreeRoot(absCwd)) return true;
|
|
74
|
-
if (isLinkedWorktreeRoot(absCwd)) return true;
|
|
75
|
-
return false;
|
|
53
|
+
const { kind } = classifyCwd(path.resolve(cwd));
|
|
54
|
+
return kind === 'ephemeral' || kind === 'worktree';
|
|
76
55
|
}
|
|
77
56
|
|
|
78
57
|
module.exports = { isEphemeralCwd };
|
|
@@ -55,6 +55,7 @@ const os = require('node:os');
|
|
|
55
55
|
const crypto = require('node:crypto');
|
|
56
56
|
const { execFile } = require('node:child_process');
|
|
57
57
|
const { OPS_ROOT_DIR } = require('./opsOwnership.cjs');
|
|
58
|
+
const schedulerPaths = require('./schedulerPaths.cjs');
|
|
58
59
|
|
|
59
60
|
// Per-kind configuration. Roots are kept OUTSIDE any project's own tree
|
|
60
61
|
// (os.tmpdir(), not `<cwd>/.git/...`) so a managed worktree never shows up in
|
|
@@ -86,7 +87,7 @@ const { OPS_ROOT_DIR } = require('./opsOwnership.cjs');
|
|
|
86
87
|
// days, same rationale as defaultMax above.
|
|
87
88
|
const KIND_CONFIG = {
|
|
88
89
|
job: {
|
|
89
|
-
root
|
|
90
|
+
get root() { return schedulerPaths.worktreeRoot('job'); },
|
|
90
91
|
branchPrefix: 'sm-job/',
|
|
91
92
|
disableEnv: 'SM_JOB_WORKTREE_DISABLE',
|
|
92
93
|
maxEnv: 'SM_JOB_WORKTREE_MAX',
|
|
@@ -95,7 +96,7 @@ const KIND_CONFIG = {
|
|
|
95
96
|
defaultStaleSweepAgeMs: 24 * 60 * 60 * 1000,
|
|
96
97
|
},
|
|
97
98
|
epic: {
|
|
98
|
-
root
|
|
99
|
+
get root() { return schedulerPaths.worktreeRoot('epic'); },
|
|
99
100
|
branchPrefix: 'sm-epic/',
|
|
100
101
|
disableEnv: 'SM_EPIC_WORKTREE_DISABLE',
|
|
101
102
|
maxEnv: 'SM_EPIC_WORKTREE_MAX',
|
|
@@ -184,6 +185,13 @@ function isUnderRoot(p, root) {
|
|
|
184
185
|
// corrected down to match reality when it's proven to have leaked.
|
|
185
186
|
const activeWorktreeCount = { job: 0, epic: 0 };
|
|
186
187
|
|
|
188
|
+
// Registered checkouts this process created and has not yet cleaned up:
|
|
189
|
+
// dir -> { kind, branch }. Lets `expireDeadWorktreeRegistrations` prove a
|
|
190
|
+
// counted checkout is dead (no cwd holder + terminal row) and release its
|
|
191
|
+
// count — accounting only, never a kill or an rm.
|
|
192
|
+
const registeredCheckouts = new Map();
|
|
193
|
+
const expiredCheckoutDirs = new Set(); // released by expiry, awaiting their (late) cleanupWorktree
|
|
194
|
+
|
|
187
195
|
// Per-kind promise chain used as a simple async mutex (see `withKindLock`)
|
|
188
196
|
// so the cap-check-and-reserve decision in `reserveWorktreeSlot` — which now
|
|
189
197
|
// sometimes needs to `await` a fresh on-disk observation — stays a single
|
|
@@ -216,6 +224,46 @@ function execGit(args, { cwd, timeout = 20_000 } = {}) {
|
|
|
216
224
|
});
|
|
217
225
|
}
|
|
218
226
|
|
|
227
|
+
// Unquote a single git porcelain v1 path token. Git wraps a path in double
|
|
228
|
+
// quotes and C-style-escapes it (\", \\, \t, \n, and \NNN octal per raw UTF-8
|
|
229
|
+
// byte) whenever it contains a double quote, backslash, control character, or
|
|
230
|
+
// any byte >= 0x80 (core.quotepath's default "ASCII-safe" behavior) — a plain
|
|
231
|
+
// path with none of those passes through untouched. Pure. Lives here (not
|
|
232
|
+
// scheduler.cjs, which also parses porcelain output) because scheduler.cjs
|
|
233
|
+
// requires this module — the reverse would be circular — and every porcelain
|
|
234
|
+
// consumer in either file must unquote a path before treating it as a real
|
|
235
|
+
// on-disk path (fs.existsSync, a pathspec, a Set membership check).
|
|
236
|
+
function unquotePorcelainPath(raw) {
|
|
237
|
+
if (raw.length < 2 || raw[0] !== '"' || raw[raw.length - 1] !== '"') return raw;
|
|
238
|
+
const inner = raw.slice(1, -1);
|
|
239
|
+
const bytes = [];
|
|
240
|
+
const simpleEscapes = { '"': 0x22, '\\': 0x5c, n: 0x0a, t: 0x09, r: 0x0d, a: 0x07, b: 0x08, f: 0x0c, v: 0x0b };
|
|
241
|
+
for (let i = 0; i < inner.length; i += 1) {
|
|
242
|
+
const c = inner[i];
|
|
243
|
+
if (c === '\\' && i + 1 < inner.length) {
|
|
244
|
+
const next = inner[i + 1];
|
|
245
|
+
if (Object.prototype.hasOwnProperty.call(simpleEscapes, next)) {
|
|
246
|
+
bytes.push(simpleEscapes[next]);
|
|
247
|
+
i += 1;
|
|
248
|
+
} else if (next >= '0' && next <= '7') {
|
|
249
|
+
let octal = '';
|
|
250
|
+
let j = i + 1;
|
|
251
|
+
while (j < inner.length && octal.length < 3 && inner[j] >= '0' && inner[j] <= '7') {
|
|
252
|
+
octal += inner[j];
|
|
253
|
+
j += 1;
|
|
254
|
+
}
|
|
255
|
+
bytes.push(parseInt(octal, 8) & 0xff);
|
|
256
|
+
i = j - 1;
|
|
257
|
+
} else {
|
|
258
|
+
bytes.push(c.charCodeAt(0));
|
|
259
|
+
}
|
|
260
|
+
} else {
|
|
261
|
+
for (const b of Buffer.from(c, 'utf8')) bytes.push(b);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return Buffer.from(bytes).toString('utf8');
|
|
265
|
+
}
|
|
266
|
+
|
|
219
267
|
async function isGitRepo(cwd) {
|
|
220
268
|
if (!cwd) return false;
|
|
221
269
|
try {
|
|
@@ -282,11 +330,11 @@ async function removeWorktreeDir(cwd, dir) {
|
|
|
282
330
|
/* best-effort */
|
|
283
331
|
}
|
|
284
332
|
const parentDir = path.dirname(dir);
|
|
285
|
-
// Guard against ever rmdir-ing os.tmpdir() itself — every real caller's
|
|
333
|
+
// Guard against ever rmdir-ing the worktree base (os.tmpdir() or SM_WORKTREE_ROOT) itself — every real caller's
|
|
286
334
|
// `dir` is `<kind root>/<hash>/<key>`, so `parentDir` is always the hash
|
|
287
335
|
// dir, never the tmpdir root, but this keeps the guarantee explicit rather
|
|
288
336
|
// than relying solely on call-site discipline.
|
|
289
|
-
if (parentDir ===
|
|
337
|
+
if (parentDir === schedulerPaths.worktreeBase()) return;
|
|
290
338
|
try {
|
|
291
339
|
const remaining = await fsp.readdir(parentDir);
|
|
292
340
|
if (remaining.length === 0) await fsp.rmdir(parentDir);
|
|
@@ -526,7 +574,11 @@ async function captureAndCarryBaseDiff({ cwd, dir }) {
|
|
|
526
574
|
if (stat.size === 0) return { ok: true, paths: [] };
|
|
527
575
|
|
|
528
576
|
try {
|
|
529
|
-
|
|
577
|
+
// Same 120s ceiling as the capture step above: applying a patch large
|
|
578
|
+
// enough to need that long to generate can just as easily need that
|
|
579
|
+
// long to apply — leaving this at the old 30s reintroduces the same
|
|
580
|
+
// large-dirty-tree degrade-to-shared-tree failure one call later.
|
|
581
|
+
await execGit(['apply', '--binary', patchFile], { cwd: dir, timeout: 120_000 });
|
|
530
582
|
} catch (e) {
|
|
531
583
|
return { ok: false, reason: `git apply failed: ${(e && (e.stderrText || e.message)) || e}` };
|
|
532
584
|
}
|
|
@@ -800,9 +852,31 @@ async function createWorktree({ kind, cwd, key }) {
|
|
|
800
852
|
carriedPaths = carry.paths;
|
|
801
853
|
}
|
|
802
854
|
|
|
855
|
+
registeredCheckouts.set(dir, { kind, branch });
|
|
803
856
|
return { ok: true, dir, branch, baseCwd: cwd, carriedPaths };
|
|
804
857
|
}
|
|
805
858
|
|
|
859
|
+
/**
|
|
860
|
+
* Release the count of any registered checkout whose branch has no live
|
|
861
|
+
* holder (no process cwd under its dir) AND whose row is terminal
|
|
862
|
+
* (`isTerminalBranch(branch)`). Fail-closed: an unknown/live row or a live
|
|
863
|
+
* holder keeps the count. Returns the expired dirs. O(registered) plus one
|
|
864
|
+
* /proc scan.
|
|
865
|
+
*/
|
|
866
|
+
function expireDeadWorktreeRegistrations({ isTerminalBranch, holders = listCwdHolders() } = {}) {
|
|
867
|
+
const expired = [];
|
|
868
|
+
if (typeof isTerminalBranch !== 'function') return expired;
|
|
869
|
+
for (const [dir, reg] of [...registeredCheckouts]) {
|
|
870
|
+
if (hasLiveHolder(dir, holders)) continue;
|
|
871
|
+
if (!isTerminalBranch(reg.branch)) continue;
|
|
872
|
+
registeredCheckouts.delete(dir);
|
|
873
|
+
expiredCheckoutDirs.add(dir);
|
|
874
|
+
activeWorktreeCount[reg.kind] = Math.max(0, activeWorktreeCount[reg.kind] - 1);
|
|
875
|
+
expired.push(dir);
|
|
876
|
+
}
|
|
877
|
+
return expired;
|
|
878
|
+
}
|
|
879
|
+
|
|
806
880
|
/**
|
|
807
881
|
* True iff every path in `paths` has working-tree content in `cwd`
|
|
808
882
|
* byte-identical to its committed blob on `branch`. Used both by the
|
|
@@ -1064,7 +1138,10 @@ async function cleanupWorktree({ kind, cwd, dir, branch, keepBranch }) {
|
|
|
1064
1138
|
try { await execGit(['branch', '-D', branch], { cwd, timeout: 10_000 }); } catch { /* already gone */ }
|
|
1065
1139
|
}
|
|
1066
1140
|
try { await execGit(['worktree', 'prune'], { cwd, timeout: 10_000 }); } catch { /* best effort */ }
|
|
1067
|
-
|
|
1141
|
+
// A checkout the expiry pass already released must not be counted down twice.
|
|
1142
|
+
const alreadyExpired = dir ? expiredCheckoutDirs.delete(dir) : false;
|
|
1143
|
+
if (dir) registeredCheckouts.delete(dir);
|
|
1144
|
+
if (!alreadyExpired) activeWorktreeCount[kind] = Math.max(0, activeWorktreeCount[kind] - 1);
|
|
1068
1145
|
}
|
|
1069
1146
|
|
|
1070
1147
|
/**
|
|
@@ -1143,7 +1220,12 @@ async function salvageDirtyDelta({ cwd, paths, outFile }) {
|
|
|
1143
1220
|
for (const line of String(statusOut || '').split('\n')) {
|
|
1144
1221
|
if (!line) continue;
|
|
1145
1222
|
const code = line.slice(0, 2);
|
|
1146
|
-
|
|
1223
|
+
// Unquote before use: a raw quoted+C-escaped token (any path with a
|
|
1224
|
+
// quote, backslash, control char, or non-ASCII byte) is not a real
|
|
1225
|
+
// on-disk path — passing it straight through as a pathspec to the
|
|
1226
|
+
// `git diff` calls below silently fails to match the real file,
|
|
1227
|
+
// dropping that path's content out of the salvaged patch entirely.
|
|
1228
|
+
const p = unquotePorcelainPath(line.slice(3));
|
|
1147
1229
|
if (!p) continue;
|
|
1148
1230
|
if (code === '??') untrackedPaths.push(p);
|
|
1149
1231
|
else trackedPaths.push(p);
|
|
@@ -1301,7 +1383,10 @@ async function reclaimTerminalJobOrphans({ cwd, terminalSlugs, isLive }) {
|
|
|
1301
1383
|
} catch {
|
|
1302
1384
|
continue; // can't prove the checkout is clean-of-real-work — never assume
|
|
1303
1385
|
}
|
|
1304
|
-
const dirtyPaths = statusOut.split('\n')
|
|
1386
|
+
const dirtyPaths = statusOut.split('\n')
|
|
1387
|
+
.filter((l) => l.length > 0)
|
|
1388
|
+
.map((l) => unquotePorcelainPath(l.slice(3).trim()))
|
|
1389
|
+
.filter(Boolean);
|
|
1305
1390
|
const opsOnly = dirtyPaths.every(
|
|
1306
1391
|
(p) => p === OPS_ROOT_DIR || p.startsWith(`${OPS_ROOT_DIR}/`)
|
|
1307
1392
|
);
|
|
@@ -1432,6 +1517,7 @@ module.exports = {
|
|
|
1432
1517
|
getMaxConcurrentWorktrees,
|
|
1433
1518
|
getStaleSweepAgeMs,
|
|
1434
1519
|
isGitRepo,
|
|
1520
|
+
unquotePorcelainPath,
|
|
1435
1521
|
isBaseTreeClean,
|
|
1436
1522
|
worktreeDirFor,
|
|
1437
1523
|
branchNameFor,
|
|
@@ -1454,6 +1540,7 @@ module.exports = {
|
|
|
1454
1540
|
mainTreeFromWorktreeGitFile,
|
|
1455
1541
|
getObservedWorktreeCount,
|
|
1456
1542
|
reserveWorktreeSlot,
|
|
1543
|
+
expireDeadWorktreeRegistrations,
|
|
1457
1544
|
reclaimTerminalJobOrphans,
|
|
1458
1545
|
// Job-kind convenience wrappers — same call shape jobWorktree.cjs has
|
|
1459
1546
|
// always exposed.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* guardShims.cjs — stable, app-upgrade-proof indirection for
|
|
3
|
-
* PreToolUse guard
|
|
2
|
+
* guardShims.cjs — stable, app-upgrade-proof indirection for every
|
|
3
|
+
* PreToolUse guard hook (each name in GUARD_NAMES) that delegationReadiness.cjs installs into an
|
|
4
4
|
* adopting project's `.claude/settings.json`.
|
|
5
5
|
*
|
|
6
6
|
* Why this exists: those installers used to bake
|
|
@@ -66,7 +66,7 @@ require(path.join(ptr.appRoot, 'scripts', 'hooks', '${guardName}'));
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
/**
|
|
69
|
-
* Write the pointer file +
|
|
69
|
+
* Write the pointer file + every shim in GUARD_NAMES under `homeDir`. Idempotent: a
|
|
70
70
|
* re-run with the same `appRoot` produces byte-identical files; a re-run with
|
|
71
71
|
* a DIFFERENT `appRoot` (an app upgrade) rewrites only the pointer, which is
|
|
72
72
|
* exactly what lets already-installed project hooks follow the upgrade with
|
|
@@ -26,10 +26,9 @@
|
|
|
26
26
|
'use strict';
|
|
27
27
|
|
|
28
28
|
const path = require('node:path');
|
|
29
|
-
const os = require('node:os');
|
|
30
29
|
const config = require('../config.cjs');
|
|
31
30
|
|
|
32
|
-
const
|
|
31
|
+
const { historyRollupPath } = require('./schedulerPaths.cjs');
|
|
33
32
|
const COMPACT_THRESHOLD_BYTES = 5 * 1024 * 1024;
|
|
34
33
|
|
|
35
34
|
const TOTALS_MODEL_ID = '';
|
|
@@ -75,7 +74,7 @@ function mergeRollupLines(lines) {
|
|
|
75
74
|
* only want data lines should filter on modelId/projectDir.
|
|
76
75
|
*/
|
|
77
76
|
async function readRollup(fromDate, toDate) {
|
|
78
|
-
const { exists, text } = await config.readText(
|
|
77
|
+
const { exists, text } = await config.readText(historyRollupPath());
|
|
79
78
|
if (!exists || !text) return new Map();
|
|
80
79
|
const merged = mergeRollupLines(text.split('\n'));
|
|
81
80
|
const filtered = new Map();
|
|
@@ -108,7 +107,7 @@ function isDateFinalized(mergedMap, date) {
|
|
|
108
107
|
async function appendRollupDays(entries) {
|
|
109
108
|
if (!entries || entries.length === 0) return;
|
|
110
109
|
|
|
111
|
-
const real = config.validatePath(
|
|
110
|
+
const real = config.validatePath(historyRollupPath());
|
|
112
111
|
config.validateWrite(real);
|
|
113
112
|
const fsp = require('node:fs/promises');
|
|
114
113
|
await fsp.mkdir(path.dirname(real), { recursive: true });
|
|
@@ -125,15 +124,15 @@ async function appendRollupDays(entries) {
|
|
|
125
124
|
|
|
126
125
|
/** Dedupe the rollup file down to its last-write-wins merged lines. */
|
|
127
126
|
async function compact() {
|
|
128
|
-
const { exists, text } = await config.readText(
|
|
127
|
+
const { exists, text } = await config.readText(historyRollupPath());
|
|
129
128
|
if (!exists || !text) return;
|
|
130
129
|
const merged = mergeRollupLines(text.split('\n'));
|
|
131
130
|
const rewritten = Array.from(merged.values()).map((b) => JSON.stringify(b)).join('\n') + '\n';
|
|
132
|
-
await config.writeTextAtomic(
|
|
131
|
+
await config.writeTextAtomic(historyRollupPath(), rewritten);
|
|
133
132
|
}
|
|
134
133
|
|
|
135
134
|
module.exports = {
|
|
136
|
-
|
|
135
|
+
historyRollupPath,
|
|
137
136
|
COMPACT_THRESHOLD_BYTES,
|
|
138
137
|
TOTALS_MODEL_ID,
|
|
139
138
|
FINALIZED_MODEL_ID,
|
|
@@ -26,14 +26,16 @@
|
|
|
26
26
|
'use strict';
|
|
27
27
|
|
|
28
28
|
const fs = require('node:fs');
|
|
29
|
-
const os = require('node:os');
|
|
30
29
|
const path = require('node:path');
|
|
30
|
+
const { identity, isDifferentProcess } = require('./procIdentity.cjs');
|
|
31
|
+
const { instanceLockPath } = require('./schedulerPaths.cjs');
|
|
32
|
+
const { appendAuditEvent } = require('./auditLog.cjs');
|
|
31
33
|
|
|
32
34
|
// Env override is for unit tests only (isolates the lock from the real
|
|
33
35
|
// ~/.claude of the machine running the suite).
|
|
34
36
|
function lockPath() {
|
|
35
37
|
return process.env.SM_SCHEDULER_LOCK_PATH
|
|
36
|
-
||
|
|
38
|
+
|| instanceLockPath();
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
function pidAlive(pid) {
|
|
@@ -58,7 +60,11 @@ function readLock() {
|
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
function writeLockExclusive() {
|
|
61
|
-
const body = JSON.stringify({
|
|
63
|
+
const body = JSON.stringify({
|
|
64
|
+
pid: process.pid,
|
|
65
|
+
startedAt: new Date().toISOString(),
|
|
66
|
+
identity: identity(process.pid),
|
|
67
|
+
});
|
|
62
68
|
fs.mkdirSync(path.dirname(lockPath()), { recursive: true });
|
|
63
69
|
// 'wx' = atomic create-or-fail — two racing instances cannot both win.
|
|
64
70
|
fs.writeFileSync(lockPath(), body, { flag: 'wx', mode: 0o600 });
|
|
@@ -67,14 +73,35 @@ function writeLockExclusive() {
|
|
|
67
73
|
/**
|
|
68
74
|
* Try to become the machine's scheduler owner.
|
|
69
75
|
* Returns { owner: true } or { owner: false, holderPid } — never throws.
|
|
76
|
+
*
|
|
77
|
+
* A holder pid that is alive is normally respected — but if the lock body
|
|
78
|
+
* carries a COMPLETE recorded identity that provably differs from the live
|
|
79
|
+
* process now holding that pid (procIdentity.isDifferentProcess), the pid
|
|
80
|
+
* was recycled after a hard crash: today's bare pidAlive() check would pin
|
|
81
|
+
* ownership on that unrelated live process forever. That case is treated as
|
|
82
|
+
* stale and the lock is broken (audited as `stale_lock_broken`). A missing
|
|
83
|
+
* identity (legacy lock body, or an unreadable /proc on either side) is
|
|
84
|
+
* fail-closed to "not different" — i.e. today's pid-only behaviour.
|
|
70
85
|
*/
|
|
71
86
|
function acquireSchedulerOwnership() {
|
|
72
87
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
73
88
|
const existing = readLock();
|
|
74
89
|
if (existing && existing.pid !== process.pid && pidAlive(existing.pid)) {
|
|
75
|
-
|
|
90
|
+
const liveIdentity = identity(existing.pid);
|
|
91
|
+
if (!isDifferentProcess(existing.identity, liveIdentity)) {
|
|
92
|
+
return { owner: false, holderPid: existing.pid };
|
|
93
|
+
}
|
|
94
|
+
appendAuditEvent('stale_lock_broken', {
|
|
95
|
+
path: lockPath(),
|
|
96
|
+
recordedPid: existing.pid,
|
|
97
|
+
recordedStartedAt: existing.startedAt ?? null,
|
|
98
|
+
recordedIdentity: existing.identity ?? null,
|
|
99
|
+
liveIdentity,
|
|
100
|
+
});
|
|
76
101
|
}
|
|
77
|
-
// Missing, unreadable, our own,
|
|
102
|
+
// Missing, unreadable, our own, stale (holder pid dead), or a recorded
|
|
103
|
+
// identity proven different from the live process at that recycled pid
|
|
104
|
+
// — take it.
|
|
78
105
|
try {
|
|
79
106
|
if (existing || fs.existsSync(lockPath())) fs.unlinkSync(lockPath());
|
|
80
107
|
} catch { /* raced with another breaker — retry loop below settles it */ }
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* jobSupervisorRecord.cjs — the one authoritative, SYNCHRONOUS record of a
|
|
5
|
+
* dispatch, written at the same point scheduler.cjs logs `[scheduler] spawned
|
|
6
|
+
* pid=`.
|
|
7
|
+
*
|
|
8
|
+
* Before this, the only durable trace of a dispatch was runtime={pid,...},
|
|
9
|
+
* written asynchronously by a mutate() inside the onPid callback, and the run
|
|
10
|
+
* dir held only the log until an exit path wrote meta.json. A booting process
|
|
11
|
+
* therefore had nothing to tell "my run, still alive" from "dead" and fell
|
|
12
|
+
* back to a ladder of inferences. `<runDir>/<slug>.supervisor.json` is that
|
|
13
|
+
* missing fact.
|
|
14
|
+
*
|
|
15
|
+
* Plain Node, no electron. The run dir is machine-wide scheduler state (not
|
|
16
|
+
* an ops namespace), so queueStore's writeJsonAtomicSync is the right writer.
|
|
17
|
+
* This module never signals a process and adds no cap accounting.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const fs = require('node:fs');
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
const { writeJsonAtomicSync } = require('./queueStore.cjs');
|
|
23
|
+
const { isDifferentProcess } = require('./procIdentity.cjs');
|
|
24
|
+
|
|
25
|
+
const SUFFIX = '.supervisor.json';
|
|
26
|
+
const META_SUFFIX = '.meta.json';
|
|
27
|
+
const INVESTIGATION_EXIT_RE = /\[scheduler\] investigation exit code=/;
|
|
28
|
+
const EXIT_TAIL_BYTES = 8 * 1024;
|
|
29
|
+
|
|
30
|
+
function recordPath(runDir, slug) {
|
|
31
|
+
return path.join(runDir, `${slug}${SUFFIX}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Writes `<runDir>/<slug>.supervisor.json` atomically. Throws on failure —
|
|
36
|
+
* the caller owns the "never fail the dispatch" try/catch. `kind` defaults to
|
|
37
|
+
* "job"; an investigation probe passes kind: "investigation" (and a slug of
|
|
38
|
+
* `<failedSlug>.investigation`, so it never overwrites the failed job's own
|
|
39
|
+
* record in the shared run dir).
|
|
40
|
+
*/
|
|
41
|
+
function writeSupervisorRecord({
|
|
42
|
+
runDir, slug, cwd, runId, pid, pgid, identity, execCwd, worktreeDir, worktreeBranch,
|
|
43
|
+
sessionId, startedAt, budgetMs, maxDurationMs, idleKillMs, schedulerPid, codeSha, kind,
|
|
44
|
+
}) {
|
|
45
|
+
if (!runDir || !slug) throw new Error('writeSupervisorRecord: runDir and slug are required');
|
|
46
|
+
const record = {
|
|
47
|
+
kind: kind || 'job',
|
|
48
|
+
slug, cwd: cwd ?? null, runId: runId ?? null,
|
|
49
|
+
pid: pid ?? null, pgid: pgid ?? null, identity: identity ?? null,
|
|
50
|
+
execCwd: execCwd ?? null, worktreeDir: worktreeDir ?? null, worktreeBranch: worktreeBranch ?? null,
|
|
51
|
+
sessionId: sessionId ?? null, startedAt: startedAt ?? null,
|
|
52
|
+
budgetMs: budgetMs ?? null, maxDurationMs: maxDurationMs ?? null, idleKillMs: idleKillMs ?? null,
|
|
53
|
+
schedulerPid: schedulerPid ?? null, codeSha: codeSha ?? null,
|
|
54
|
+
};
|
|
55
|
+
writeJsonAtomicSync(recordPath(runDir, slug), record);
|
|
56
|
+
return record;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Returns the parsed record, or null when absent/unparseable. Never throws. */
|
|
60
|
+
function readSupervisorRecord(runDir, slug) {
|
|
61
|
+
try {
|
|
62
|
+
const rec = JSON.parse(fs.readFileSync(recordPath(runDir, slug), 'utf8'));
|
|
63
|
+
return rec && typeof rec === 'object' ? rec : null;
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* True when the run has a durable exit marker: `<slug>.meta.json` for a job,
|
|
71
|
+
* the `investigation exit code=` log line for a probe. This is what separates
|
|
72
|
+
* "exited" from "dead" — a dead pid with no marker never got to write one.
|
|
73
|
+
*/
|
|
74
|
+
function hasExitMarker(runDir, record) {
|
|
75
|
+
try {
|
|
76
|
+
if (record.kind === 'investigation') {
|
|
77
|
+
const failedSlug = String(record.slug).replace(/\.investigation$/, '');
|
|
78
|
+
const logPath = path.join(runDir, `${failedSlug}.investigation.log`);
|
|
79
|
+
const { size } = fs.statSync(logPath);
|
|
80
|
+
const fd = fs.openSync(logPath, 'r');
|
|
81
|
+
try {
|
|
82
|
+
const len = Math.min(size, EXIT_TAIL_BYTES);
|
|
83
|
+
const buf = Buffer.alloc(len);
|
|
84
|
+
fs.readSync(fd, buf, 0, len, size - len);
|
|
85
|
+
return INVESTIGATION_EXIT_RE.test(buf.toString('utf8'));
|
|
86
|
+
} finally {
|
|
87
|
+
fs.closeSync(fd);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return fs.existsSync(path.join(runDir, `${record.slug}${META_SUFFIX}`));
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Every record under `runsDir/<runId>/` whose file was written within
|
|
98
|
+
* maxAgeMs and which has no exit marker yet. Each entry is the record plus
|
|
99
|
+
* `runDir`. Never throws; an unreadable dir yields [].
|
|
100
|
+
* Complexity: O(run dirs + files in recent dirs).
|
|
101
|
+
*/
|
|
102
|
+
function listLiveSupervisorRecords(runsDir, { maxAgeMs, now = Date.now() } = {}) {
|
|
103
|
+
const out = [];
|
|
104
|
+
let dirs;
|
|
105
|
+
try { dirs = fs.readdirSync(runsDir, { withFileTypes: true }); } catch { return out; }
|
|
106
|
+
for (const d of dirs) {
|
|
107
|
+
if (!d.isDirectory()) continue;
|
|
108
|
+
const runDir = path.join(runsDir, d.name);
|
|
109
|
+
let files;
|
|
110
|
+
try { files = fs.readdirSync(runDir); } catch { continue; }
|
|
111
|
+
for (const f of files) {
|
|
112
|
+
if (!f.endsWith(SUFFIX)) continue;
|
|
113
|
+
try {
|
|
114
|
+
if (Number.isFinite(maxAgeMs) && now - fs.statSync(path.join(runDir, f)).mtimeMs > maxAgeMs) continue;
|
|
115
|
+
const record = readSupervisorRecord(runDir, f.slice(0, -SUFFIX.length));
|
|
116
|
+
if (!record || hasExitMarker(runDir, record)) continue;
|
|
117
|
+
out.push({ ...record, runDir });
|
|
118
|
+
} catch { /* raced with a delete — skip */ }
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Pure. What should a booting process do with this record?
|
|
126
|
+
* - not alive + exit marker → 'exited' (finished; outcome is on disk)
|
|
127
|
+
* - not alive, no marker → 'dead' (died without writing an outcome)
|
|
128
|
+
* - alive, different process → 'foreign-pid' (pid recycled; never ours)
|
|
129
|
+
* - alive, ours, past budget or log stalled past idleKillMs → 'over-budget'
|
|
130
|
+
* - alive, ours → 'adopt'
|
|
131
|
+
* `identity` is the live probe of record.pid; isDifferentProcess is
|
|
132
|
+
* fail-closed, so an incomplete identity on either side reads as "same".
|
|
133
|
+
*/
|
|
134
|
+
function classifyAdoption(record, { identity, pidAlive, logMtimeMs, exitMarker, now = Date.now() } = {}) {
|
|
135
|
+
if (!pidAlive) return exitMarker ? 'exited' : 'dead';
|
|
136
|
+
if (exitMarker) return 'exited';
|
|
137
|
+
if (isDifferentProcess(record.identity, identity)) return 'foreign-pid';
|
|
138
|
+
const ceiling = record.budgetMs > 0 ? record.budgetMs : (record.maxDurationMs > 0 ? record.maxDurationMs : 0);
|
|
139
|
+
if (ceiling > 0 && Number.isFinite(record.startedAt) && now - record.startedAt > ceiling) return 'over-budget';
|
|
140
|
+
if (record.idleKillMs > 0 && Number.isFinite(logMtimeMs) && now - logMtimeMs > record.idleKillMs) return 'over-budget';
|
|
141
|
+
return 'adopt';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = {
|
|
145
|
+
writeSupervisorRecord, readSupervisorRecord, listLiveSupervisorRecords, classifyAdoption, hasExitMarker,
|
|
146
|
+
SUFFIX,
|
|
147
|
+
};
|
|
@@ -22,15 +22,19 @@
|
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
*
|
|
25
|
+
* `rowPid(job)` resolves a running row's pid; scheduler.cjs passes the same
|
|
26
|
+
* supervisor-record → runtime.pid → run-log ladder partitionBootOrphans uses,
|
|
27
|
+
* so the two boot passes agree. Default: runtime.pid alone.
|
|
28
|
+
*
|
|
29
|
+
* @param {{ bootJobs: Array<object>, claudePidAlive: (pid: number) => boolean, hasLiveHolder: (dir: string, holders?: Set<string>) => boolean, cwdHolders?: Set<string>, rowPid?: (job: object) => number|null }} deps
|
|
26
30
|
* @returns {(slug: string, entry: { worktree: string, branch: string|null }) => boolean}
|
|
27
31
|
*/
|
|
28
|
-
function buildJobWorktreeIsLive({ bootJobs, claudePidAlive, hasLiveHolder, cwdHolders }) {
|
|
32
|
+
function buildJobWorktreeIsLive({ bootJobs, claudePidAlive, hasLiveHolder, cwdHolders, rowPid = (j) => j.runtime && j.runtime.pid }) {
|
|
29
33
|
const runningPidBySlug = new Map();
|
|
30
34
|
for (const j of Array.isArray(bootJobs) ? bootJobs : []) {
|
|
31
|
-
if (j
|
|
32
|
-
|
|
33
|
-
|
|
35
|
+
if (!j || j.status !== 'running') continue;
|
|
36
|
+
const pid = rowPid(j);
|
|
37
|
+
if (pid) runningPidBySlug.set(j.slug, pid);
|
|
34
38
|
}
|
|
35
39
|
|
|
36
40
|
return function isLive(slug, entry) {
|
|
@@ -32,40 +32,20 @@
|
|
|
32
32
|
'use strict';
|
|
33
33
|
|
|
34
34
|
const http = require('node:http');
|
|
35
|
-
const os = require('node:os');
|
|
36
35
|
const path = require('node:path');
|
|
37
36
|
const crypto = require('node:crypto');
|
|
38
37
|
const fsp = require('node:fs/promises');
|
|
39
38
|
const config = require('../config.cjs');
|
|
40
|
-
|
|
41
|
-
const TOKEN_PATH = path.join(os.homedir(), '.claude', 'session-manager', 'admin-api.json');
|
|
39
|
+
const schedulerPaths = require('./schedulerPaths.cjs');
|
|
42
40
|
|
|
43
41
|
/**
|
|
44
|
-
* Mode-aware token file path, resolved fresh on every call (
|
|
42
|
+
* Mode-aware token file path, resolved fresh on every call (never cached at
|
|
45
43
|
* module load) so a dev/e2e launch never collides with a production
|
|
46
|
-
* instance's admin-api.json.
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* harmless tie-break, since real launches only ever set one or the other.
|
|
44
|
+
* instance's admin-api.json. The SM_ADMIN_TOKEN_PATH / SM_DEV / SM_E2E ladder
|
|
45
|
+
* lives in schedulerPaths.adminTokenPath() (the SM_ADMIN_TOKEN_PATH override
|
|
46
|
+
* exists because unit tests once overwrote the live app's port+token).
|
|
50
47
|
*/
|
|
51
|
-
|
|
52
|
-
// Explicit override, highest priority. Exists because the unit tests call
|
|
53
|
-
// createAdminHttp().start() directly with neither SM_DEV nor SM_E2E set,
|
|
54
|
-
// which resolved to the PRODUCTION admin-api.json and overwrote the
|
|
55
|
-
// running app's port+token with a throwaway test server's — silently
|
|
56
|
-
// orphaning scheduler-mcp-server.cjs's access to the live app until the
|
|
57
|
-
// next app restart (confirmed live 2026-08-01: `npm run test:unit` made
|
|
58
|
-
// every scheduler_* MCP tool report "app is not running"). Tests point
|
|
59
|
-
// this at a tmp file; production never sets it.
|
|
60
|
-
if (process.env.SM_ADMIN_TOKEN_PATH) return process.env.SM_ADMIN_TOKEN_PATH;
|
|
61
|
-
if (process.env.SM_DEV === '1') {
|
|
62
|
-
return path.join(os.homedir(), '.claude', 'session-manager', 'admin-api.dev.json');
|
|
63
|
-
}
|
|
64
|
-
if (process.env.SM_E2E === '1') {
|
|
65
|
-
return path.join(os.homedir(), '.claude', 'session-manager', 'admin-api.e2e.json');
|
|
66
|
-
}
|
|
67
|
-
return TOKEN_PATH;
|
|
68
|
-
}
|
|
48
|
+
const resolveTokenPath = schedulerPaths.adminTokenPath;
|
|
69
49
|
|
|
70
50
|
// Reserved health-check path, handled directly (not via registerRoute) so
|
|
71
51
|
// start()'s post-boot self-check never depends on route-registration order
|
|
@@ -238,9 +218,11 @@ function createAdminHttp() {
|
|
|
238
218
|
|
|
239
219
|
module.exports = {
|
|
240
220
|
createAdminHttp,
|
|
241
|
-
TOKEN_PATH,
|
|
242
221
|
resolveTokenPath,
|
|
243
222
|
timingSafeEqualStrings,
|
|
244
223
|
readBody,
|
|
245
224
|
sendJson,
|
|
246
225
|
};
|
|
226
|
+
|
|
227
|
+
// Lazy getter (the token path is mode-aware and env-driven; never frozen at require).
|
|
228
|
+
Object.defineProperty(module.exports, 'TOKEN_PATH', { get: () => schedulerPaths.adminTokenPath(), enumerable: true });
|