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
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// schedulerRuntimeState.cjs + quietMachineLease.cjs — fail-closed expiry.
|
|
2
|
+
const assert = require('node:assert');
|
|
3
|
+
const state = require('../schedulerRuntimeState.cjs');
|
|
4
|
+
const lease = require('../quietMachineLease.cjs');
|
|
5
|
+
|
|
6
|
+
const NOW = 10_000_000;
|
|
7
|
+
const GRACE = 10 * 60 * 1000;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => { state.__resetForTests(); lease.__resetForTests(); });
|
|
10
|
+
afterEach(() => { state.__resetForTests(); lease.__resetForTests(); });
|
|
11
|
+
|
|
12
|
+
test('lease: expires only when holder not running AND past grace', () => {
|
|
13
|
+
lease.acquire('q', { claimedAt: NOW - GRACE - 1 });
|
|
14
|
+
assert.equal(lease.expireDead({ liveSlugs: new Set(['q']), now: NOW, graceMs: GRACE }), false, 'running row keeps it');
|
|
15
|
+
assert.equal(lease.isHeld(), true);
|
|
16
|
+
assert.equal(lease.expireDead({ liveSlugs: new Set(), now: NOW, graceMs: GRACE }), true);
|
|
17
|
+
assert.equal(lease.isHeld(), false);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('lease: inside the grace window or claimed after the pass began is kept', () => {
|
|
21
|
+
lease.acquire('q', { claimedAt: NOW - 1000 });
|
|
22
|
+
assert.equal(lease.expireDead({ liveSlugs: new Set(), now: NOW, graceMs: GRACE }), false);
|
|
23
|
+
lease.release('q');
|
|
24
|
+
lease.acquire('q', { claimedAt: NOW + 10 });
|
|
25
|
+
assert.equal(lease.expireDead({ liveSlugs: new Set(), now: NOW, graceMs: GRACE }), false);
|
|
26
|
+
assert.equal(lease.isHeld(), true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('investigations: set semantics, no double reservation, idempotent release', () => {
|
|
30
|
+
assert.equal(state.reserveInvestigation('a'), true);
|
|
31
|
+
assert.equal(state.reserveInvestigation('a'), false);
|
|
32
|
+
assert.equal(state.investigationCount(), 1);
|
|
33
|
+
assert.equal(state.releaseInvestigation('a'), true);
|
|
34
|
+
assert.equal(state.releaseInvestigation('a'), false);
|
|
35
|
+
assert.equal(state.investigationCount(), 0);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('investigations: dead stamped pid with no investigating row expires; live pid/row survives', () => {
|
|
39
|
+
state.reserveInvestigation('dead', { claimedAt: NOW - 1000 });
|
|
40
|
+
state.stampInvestigationPid('dead', 111);
|
|
41
|
+
state.reserveInvestigation('alive', { claimedAt: NOW - 1000 });
|
|
42
|
+
state.stampInvestigationPid('alive', 222);
|
|
43
|
+
state.reserveInvestigation('rowlive', { claimedAt: NOW - 2 * GRACE });
|
|
44
|
+
const out = state.expireDeadInvestigations({
|
|
45
|
+
liveSlugs: new Set(['rowlive']), pidAlive: (p) => p === 222, now: NOW, graceMs: GRACE,
|
|
46
|
+
});
|
|
47
|
+
assert.deepEqual(out, ['dead']);
|
|
48
|
+
assert.equal(state.investigationCount(), 2);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('investigations: pidless reservation expires past grace only', () => {
|
|
52
|
+
state.reserveInvestigation('old', { claimedAt: NOW - GRACE - 1 });
|
|
53
|
+
state.reserveInvestigation('fresh', { claimedAt: NOW - 1000 });
|
|
54
|
+
const out = state.expireDeadInvestigations({ liveSlugs: new Set(), pidAlive: () => false, now: NOW, graceMs: GRACE });
|
|
55
|
+
assert.deepEqual(out, ['old']);
|
|
56
|
+
});
|
|
@@ -97,3 +97,48 @@ test('snapshot names holders for attribution', () => {
|
|
|
97
97
|
assert.equal(snap.inUse, 2);
|
|
98
98
|
assert.deepEqual(snap.holders.map((h) => h.owner).sort(), ['chat:tab-1', 'scheduler:42-fix-thing']);
|
|
99
99
|
});
|
|
100
|
+
|
|
101
|
+
// ── expireDead: fail-closed release of provably-dead scheduler reservations ──
|
|
102
|
+
const NOW = 10_000_000;
|
|
103
|
+
const GRACE = 10 * 60 * 1000;
|
|
104
|
+
|
|
105
|
+
test('expireDead: stamped dead pid with no live row is released', () => {
|
|
106
|
+
const t = slots.acquire('scheduler:a', { claimedAt: NOW - 1000 });
|
|
107
|
+
slots.stampPid(t, 4242);
|
|
108
|
+
const out = slots.expireDead({ liveSlugs: new Set(), pidAlive: () => false, now: NOW, graceMs: GRACE });
|
|
109
|
+
assert.deepEqual(out, ['scheduler:a']);
|
|
110
|
+
assert.equal(slots.inUse(), 0);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('expireDead: a live pid or a live row keeps the token', () => {
|
|
114
|
+
const t1 = slots.acquire('scheduler:a', { claimedAt: NOW - 2 * GRACE });
|
|
115
|
+
slots.stampPid(t1, 4242);
|
|
116
|
+
const t2 = slots.acquire('scheduler:b', { claimedAt: NOW - 2 * GRACE });
|
|
117
|
+
slots.expireDead({ liveSlugs: new Set(['b']), pidAlive: () => true, now: NOW, graceMs: GRACE });
|
|
118
|
+
assert.equal(slots.inUse(), 2, 'live pid (a) and live row (b) both survive');
|
|
119
|
+
assert.ok(t1 && t2);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('expireDead: pidless token expires only past the grace window', () => {
|
|
123
|
+
slots.acquire('scheduler:old', { claimedAt: NOW - GRACE - 1 });
|
|
124
|
+
slots.acquire('scheduler:fresh', { claimedAt: NOW - GRACE + 1000 });
|
|
125
|
+
const out = slots.expireDead({ liveSlugs: new Set(), pidAlive: () => false, now: NOW, graceMs: GRACE });
|
|
126
|
+
assert.deepEqual(out, ['scheduler:old']);
|
|
127
|
+
assert.equal(slots.inUse(), 1);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('expireDead: chat and project-brief tokens are never touched', () => {
|
|
131
|
+
slots.acquire('chat:tab1', { claimedAt: 0 });
|
|
132
|
+
slots.acquire('project-brief:x', { claimedAt: 0 });
|
|
133
|
+
const out = slots.expireDead({ liveSlugs: new Set(), pidAlive: () => false, now: NOW, graceMs: GRACE });
|
|
134
|
+
assert.deepEqual(out, []);
|
|
135
|
+
assert.equal(slots.inUse(), 2);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('expireDead: a mid-launch reservation (claimed after the pass began) survives even with a dead pid', () => {
|
|
139
|
+
const t = slots.acquire('scheduler:mid', { claimedAt: NOW + 5 });
|
|
140
|
+
slots.stampPid(t, 4242);
|
|
141
|
+
const out = slots.expireDead({ liveSlugs: new Set(), pidAlive: () => false, now: NOW, graceMs: GRACE });
|
|
142
|
+
assert.deepEqual(out, []);
|
|
143
|
+
assert.equal(slots.inUse(), 1);
|
|
144
|
+
});
|
|
@@ -183,7 +183,7 @@ test('duplicate cwds across tabs are visited once; a missing cwd is skipped with
|
|
|
183
183
|
|
|
184
184
|
test('an ephemeral epic-worktree cwd is never scanned as its own project', async () => {
|
|
185
185
|
const home = await mkHome();
|
|
186
|
-
const ephemeral = path.join(os.tmpdir(), 'session-manager-epic-worktrees', 'abc123', 'psess-xyz');
|
|
186
|
+
const ephemeral = path.join(process.env.SM_WORKTREE_ROOT || os.tmpdir(), 'session-manager-epic-worktrees', 'abc123', 'psess-xyz');
|
|
187
187
|
const opsDir = path.join(ephemeral, 'session-manager-operations', 'logs');
|
|
188
188
|
fs.mkdirSync(opsDir, { recursive: true });
|
|
189
189
|
fs.writeFileSync(path.join(opsDir, `errors-${recentDateStr()}.jsonl`), JSON.stringify(errLine(1)) + '\n');
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Run: timeout 120 npx vitest run src/main/lib/__tests__/upgradeDrain.test.cjs
|
|
4
|
+
|
|
5
|
+
const assert = require('node:assert/strict');
|
|
6
|
+
const fs = require('node:fs');
|
|
7
|
+
const os = require('node:os');
|
|
8
|
+
const path = require('node:path');
|
|
9
|
+
const ud = require('../upgradeDrain.cjs');
|
|
10
|
+
|
|
11
|
+
const T0 = Date.parse('2026-09-18T00:00:00Z');
|
|
12
|
+
const req = (extra = {}) => ({ reason: 'r', requestedBy: 'u', requestedAt: new Date(T0).toISOString(), ...extra });
|
|
13
|
+
const ev = (o) => ud.evaluateDrain({ now: T0 + 1000, ...o }).action;
|
|
14
|
+
|
|
15
|
+
function tmp() { return fs.mkdtempSync(path.join(os.tmpdir(), 'upgrade-drain-')); }
|
|
16
|
+
|
|
17
|
+
describe('evaluateDrain state machine', () => {
|
|
18
|
+
test('no request → none', () => {
|
|
19
|
+
assert.equal(ev({ request: null, queueSnapshot: { running: 2 }, drainState: null }), 'none');
|
|
20
|
+
});
|
|
21
|
+
test('pending request, drain not active → pause (even when idle)', () => {
|
|
22
|
+
assert.equal(ev({ request: req(), queueSnapshot: { running: 0, investigating: 0 }, drainState: null }), 'pause');
|
|
23
|
+
assert.equal(ev({ request: req(), queueSnapshot: { running: 3 }, drainState: null }), 'pause');
|
|
24
|
+
});
|
|
25
|
+
test('drain active + running or investigating → wait', () => {
|
|
26
|
+
assert.equal(ev({ request: req(), queueSnapshot: { running: 1, investigating: 0 }, drainState: { active: true } }), 'wait');
|
|
27
|
+
assert.equal(ev({ request: req(), queueSnapshot: { running: 0, investigating: 1 }, drainState: { active: true } }), 'wait');
|
|
28
|
+
});
|
|
29
|
+
test('drain active + zero busy → restart', () => {
|
|
30
|
+
assert.equal(ev({ request: req(), queueSnapshot: { running: 0, investigating: 0 }, drainState: { active: true } }), 'restart');
|
|
31
|
+
});
|
|
32
|
+
test('deadline converts to abort, default 4 h', () => {
|
|
33
|
+
const now = T0 + ud.DEFAULT_DRAIN_DEADLINE_MS;
|
|
34
|
+
assert.equal(ud.DEFAULT_DRAIN_DEADLINE_MS, 4 * 60 * 60_000);
|
|
35
|
+
assert.equal(ud.evaluateDrain({ request: req(), queueSnapshot: { running: 1 }, drainState: { active: true }, now }).action, 'abort');
|
|
36
|
+
assert.equal(ud.evaluateDrain({ request: req(), queueSnapshot: { running: 1 }, drainState: { active: true }, now: now - 1 }).action, 'wait');
|
|
37
|
+
assert.equal(ud.evaluateDrain({ request: req(), queueSnapshot: {}, drainState: { active: true }, now: T0 + 500, deadlineMs: 100 }).action, 'abort');
|
|
38
|
+
});
|
|
39
|
+
test('completed request → none; stale drain without request → abort', () => {
|
|
40
|
+
assert.equal(ev({ request: req({ drainCompletedAt: 'x' }), queueSnapshot: {}, drainState: { active: true } }), 'none');
|
|
41
|
+
assert.equal(ev({ request: null, queueSnapshot: {}, drainState: { active: true } }), 'abort');
|
|
42
|
+
});
|
|
43
|
+
test('a rate-limit pause during a drain does not change the verdict: drain is its own field', () => {
|
|
44
|
+
// evaluateDrain never sees state.paused; a rate_limit pause overwriting
|
|
45
|
+
// `paused` leaves the drain field (and the verdict) untouched.
|
|
46
|
+
const state = { paused: { reason: 'rate_limit' }, drain: { active: true } };
|
|
47
|
+
assert.equal(ev({ request: req(), queueSnapshot: { running: 0, investigating: 0 }, drainState: state.drain }), 'restart');
|
|
48
|
+
assert.equal(ud.effectivePaused(state).reason, 'rate_limit');
|
|
49
|
+
assert.equal(ud.effectivePaused({ paused: null, drain: { active: true } }).reason, 'drain-for-upgrade');
|
|
50
|
+
assert.equal(ud.effectivePaused({ paused: null, drain: null }), null);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe('request file lifecycle', () => {
|
|
55
|
+
test('requestRestart writes atomically and is idempotent while pending', () => {
|
|
56
|
+
const dir = tmp();
|
|
57
|
+
try {
|
|
58
|
+
const file = path.join(dir, 'restart-request.json');
|
|
59
|
+
const a = ud.requestRestart({ reason: 'x', requestedBy: 'user' }, { file, now: T0 });
|
|
60
|
+
const b = ud.requestRestart({ reason: 'y', requestedBy: 'auto' }, { file, now: T0 + 5000 });
|
|
61
|
+
assert.deepEqual(b, a, 'a second request must not reset the deadline');
|
|
62
|
+
assert.deepEqual(fs.readdirSync(dir), ['restart-request.json'], 'no tmp file left behind');
|
|
63
|
+
assert.equal(ud.readRestartRequest(file).requestedBy, 'user');
|
|
64
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
65
|
+
});
|
|
66
|
+
test('stampDrainCompleted marks it; a new request after completion starts fresh', () => {
|
|
67
|
+
const dir = tmp();
|
|
68
|
+
try {
|
|
69
|
+
const file = path.join(dir, 'restart-request.json');
|
|
70
|
+
ud.requestRestart({ reason: 'x', requestedBy: 'u' }, { file, now: T0 });
|
|
71
|
+
assert.ok(ud.stampDrainCompleted(T0 + 1, file).drainCompletedAt);
|
|
72
|
+
const fresh = ud.requestRestart({ reason: 'again', requestedBy: 'u' }, { file, now: T0 + 9 });
|
|
73
|
+
assert.equal(fresh.reason, 'again');
|
|
74
|
+
assert.equal(fresh.drainCompletedAt, undefined);
|
|
75
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
76
|
+
});
|
|
77
|
+
test('abort retires the request file', () => {
|
|
78
|
+
const dir = tmp();
|
|
79
|
+
try {
|
|
80
|
+
const file = path.join(dir, 'restart-request.json');
|
|
81
|
+
ud.requestRestart({ reason: 'x', requestedBy: 'u' }, { file, now: T0 });
|
|
82
|
+
ud.retireRestartRequest(file);
|
|
83
|
+
assert.equal(ud.readRestartRequest(file), null);
|
|
84
|
+
ud.retireRestartRequest(file); // absent → no throw
|
|
85
|
+
// With the request gone, a re-evaluation is 'none' — no abort flap.
|
|
86
|
+
assert.equal(ev({ request: ud.readRestartRequest(file), queueSnapshot: {}, drainState: null }), 'none');
|
|
87
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('boot', () => {
|
|
92
|
+
test('clears a leftover drain whose request is complete or gone; keeps a pending one', () => {
|
|
93
|
+
assert.equal(ud.bootDrainAction({ drainState: { active: true }, request: req({ drainCompletedAt: 'x' }) }), 'clear');
|
|
94
|
+
assert.equal(ud.bootDrainAction({ drainState: { active: true }, request: null }), 'clear');
|
|
95
|
+
assert.equal(ud.bootDrainAction({ drainState: { active: true }, request: req() }), 'keep');
|
|
96
|
+
assert.equal(ud.bootDrainAction({ drainState: null, request: req() }), 'none');
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('automatic trigger', () => {
|
|
101
|
+
test('only when both shas are known and differ — never from npm', () => {
|
|
102
|
+
assert.equal(ud.installedBuildDiffers({ running: 'aaa', installed: 'bbb' }), true);
|
|
103
|
+
assert.equal(ud.installedBuildDiffers({ running: 'aaa', installed: 'aaa' }), false);
|
|
104
|
+
assert.equal(ud.installedBuildDiffers({ running: null, installed: 'bbb' }), false);
|
|
105
|
+
assert.equal(ud.installedBuildDiffers({ running: 'aaa', installed: null }), false);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('queueBusyCount', () => {
|
|
110
|
+
test('counts running + investigating only', () => {
|
|
111
|
+
const jobs = [{ status: 'running' }, { status: 'investigating' }, { status: 'pending' }, { status: 'needs_review' }];
|
|
112
|
+
assert.equal(ud.queueBusyCount(jobs), 2);
|
|
113
|
+
assert.equal(ud.queueBusyCount(jobs, 1), 3);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('restarting marker', () => {
|
|
118
|
+
test('active until expiry, then lapses', () => {
|
|
119
|
+
const dir = tmp();
|
|
120
|
+
try {
|
|
121
|
+
const file = path.join(dir, 'restarting.json');
|
|
122
|
+
assert.equal(ud.isRestartingMarkerActive({ file, now: T0 }), false);
|
|
123
|
+
ud.markRestarting({ file, now: T0, ttlMs: 1000 });
|
|
124
|
+
assert.equal(ud.isRestartingMarkerActive({ file, now: T0 + 999 }), true);
|
|
125
|
+
assert.equal(ud.isRestartingMarkerActive({ file, now: T0 + 1000 }), false);
|
|
126
|
+
ud.clearRestartingMarker(file);
|
|
127
|
+
assert.equal(ud.isRestartingMarkerActive({ file, now: T0 }), false);
|
|
128
|
+
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -208,6 +208,67 @@ test('singleFlight: concurrent callers share the same in-flight promise, fn invo
|
|
|
208
208
|
await p3;
|
|
209
209
|
});
|
|
210
210
|
|
|
211
|
+
test('openedAt: null while closed, stamped the moment it opens, stays stamped through a failed half_open probe, clears on success', () => {
|
|
212
|
+
let nowMs = 1000;
|
|
213
|
+
const circuit = createUsageCircuit({ now: () => nowMs });
|
|
214
|
+
assert.strictEqual(circuit.openedAt(), null);
|
|
215
|
+
circuit.recordFailure('a');
|
|
216
|
+
circuit.recordFailure('a');
|
|
217
|
+
assert.strictEqual(circuit.openedAt(), null); // still closed, 2 of 3
|
|
218
|
+
circuit.recordFailure('a');
|
|
219
|
+
assert.strictEqual(circuit.state(), 'open');
|
|
220
|
+
assert.strictEqual(circuit.openedAt(), 1000);
|
|
221
|
+
|
|
222
|
+
nowMs += 60 * 60 * 1000;
|
|
223
|
+
assert.strictEqual(circuit.state(), 'half_open');
|
|
224
|
+
assert.strictEqual(circuit.openedAt(), 1000); // unchanged through the probe
|
|
225
|
+
|
|
226
|
+
circuit.recordFailure('a'); // failed probe -> reopens
|
|
227
|
+
assert.strictEqual(circuit.state(), 'open');
|
|
228
|
+
assert.strictEqual(circuit.openedAt(), 1000); // still the ORIGINAL open time, not re-stamped
|
|
229
|
+
|
|
230
|
+
circuit.recordSuccess({});
|
|
231
|
+
assert.strictEqual(circuit.openedAt(), null);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('getConsecutiveFailures: tracks failures, resets on success', () => {
|
|
235
|
+
const circuit = createUsageCircuit({ now: () => 0 });
|
|
236
|
+
assert.strictEqual(circuit.getConsecutiveFailures(), 0);
|
|
237
|
+
circuit.recordFailure('a');
|
|
238
|
+
circuit.recordFailure('a');
|
|
239
|
+
assert.strictEqual(circuit.getConsecutiveFailures(), 2);
|
|
240
|
+
circuit.recordSuccess({});
|
|
241
|
+
assert.strictEqual(circuit.getConsecutiveFailures(), 0);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('SM_USAGE_CIRCUIT=0 bypasses the breaker entirely: state stays closed no matter how many failures', () => {
|
|
245
|
+
const prev = process.env.SM_USAGE_CIRCUIT;
|
|
246
|
+
process.env.SM_USAGE_CIRCUIT = '0';
|
|
247
|
+
try {
|
|
248
|
+
const circuit = createUsageCircuit({ now: () => 0 });
|
|
249
|
+
circuit.recordFailure('a');
|
|
250
|
+
circuit.recordFailure('a');
|
|
251
|
+
circuit.recordFailure('a');
|
|
252
|
+
circuit.recordFailure('a');
|
|
253
|
+
assert.strictEqual(circuit.state(), 'closed');
|
|
254
|
+
assert.strictEqual(circuit.openedAt(), null);
|
|
255
|
+
} finally {
|
|
256
|
+
if (prev === undefined) delete process.env.SM_USAGE_CIRCUIT; else process.env.SM_USAGE_CIRCUIT = prev;
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('SM_USAGE_DEGRADED_CAP overrides degradedBudget concurrencyCap outright, bypassing min(cap, 2)', () => {
|
|
261
|
+
const prev = process.env.SM_USAGE_DEGRADED_CAP;
|
|
262
|
+
process.env.SM_USAGE_DEGRADED_CAP = '7';
|
|
263
|
+
try {
|
|
264
|
+
const payload = { five_hour: { utilization: 30, resets_at: null } };
|
|
265
|
+
const { concurrencyCap } = degradedBudget(payload, { configuredCap: 2 });
|
|
266
|
+
assert.strictEqual(concurrencyCap, 7);
|
|
267
|
+
} finally {
|
|
268
|
+
if (prev === undefined) delete process.env.SM_USAGE_DEGRADED_CAP; else process.env.SM_USAGE_DEGRADED_CAP = prev;
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
211
272
|
test('singleFlight: de-dupes even when fn rejects, next call gets a fresh attempt', async () => {
|
|
212
273
|
let calls = 0;
|
|
213
274
|
const fn = () => (calls++ === 0 ? Promise.reject(new Error('boom')) : Promise.resolve('ok'));
|
|
@@ -8,6 +8,7 @@ const os = require('node:os');
|
|
|
8
8
|
const path = require('node:path');
|
|
9
9
|
const {
|
|
10
10
|
heartbeatFresh,
|
|
11
|
+
evaluateDispatchLiveness,
|
|
11
12
|
readLastHeartbeat,
|
|
12
13
|
localDateStr,
|
|
13
14
|
maybeFinalizeHistory,
|
|
@@ -310,3 +311,65 @@ test('maybeFinalizeHistory: dryRun computes without stamping or requiring a writ
|
|
|
310
311
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
311
312
|
}
|
|
312
313
|
});
|
|
314
|
+
|
|
315
|
+
describe('evaluateDispatchLiveness', () => {
|
|
316
|
+
const now = 10_000_000_000;
|
|
317
|
+
const deadMs = 30 * 60_000;
|
|
318
|
+
const base = () => ({
|
|
319
|
+
ts: now - 1_000,
|
|
320
|
+
dispatch: {
|
|
321
|
+
lastDispatchAttemptAt: new Date(now - 1_000).toISOString(),
|
|
322
|
+
lastRunAt: new Date(now - deadMs - 60_000).toISOString(),
|
|
323
|
+
lastTickReason: 'held',
|
|
324
|
+
pendingDispatchable: 3,
|
|
325
|
+
runningCount: 0,
|
|
326
|
+
paused: false,
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test('all conditions met → dead', () => {
|
|
331
|
+
assert.deepEqual(evaluateDispatchLiveness(base(), now, { deadMs }), { dead: true, reason: 'dispatch-dead' });
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test('default deadMs is 30 min', () => {
|
|
335
|
+
assert.equal(evaluateDispatchLiveness(base(), now).dead, true);
|
|
336
|
+
const e = base();
|
|
337
|
+
e.dispatch.lastRunAt = new Date(now - 29 * 60_000).toISOString();
|
|
338
|
+
assert.equal(evaluateDispatchLiveness(e, now).dead, false);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test('each condition is conjunctive', () => {
|
|
342
|
+
const recent = base(); recent.dispatch.lastRunAt = new Date(now - 60_000).toISOString();
|
|
343
|
+
const none = base(); none.dispatch.pendingDispatchable = 0;
|
|
344
|
+
const running = base(); running.dispatch.runningCount = 1;
|
|
345
|
+
const stale = base(); stale.ts = now - 10 * 60_000;
|
|
346
|
+
for (const e of [recent, none, running, stale]) {
|
|
347
|
+
assert.equal(evaluateDispatchLiveness(e, now, { deadMs }).dead, false);
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
test('never-dispatched (null lastRunAt) counts as older than deadMs', () => {
|
|
352
|
+
const e = base(); e.dispatch.lastRunAt = null;
|
|
353
|
+
assert.equal(evaluateDispatchLiveness(e, now, { deadMs }).dead, true);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test('paused suppresses', () => {
|
|
357
|
+
const e = base(); e.dispatch.paused = true;
|
|
358
|
+
assert.deepEqual(evaluateDispatchLiveness(e, now, { deadMs }), { dead: false, reason: 'paused' });
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test('drained queue (nothing dispatchable) suppresses', () => {
|
|
362
|
+
const e = base(); e.dispatch.pendingDispatchable = 0;
|
|
363
|
+
assert.equal(evaluateDispatchLiveness(e, now, { deadMs }).reason, 'nothing-dispatchable');
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test('a degraded line is ignored', () => {
|
|
367
|
+
const e = { ...base(), degraded: true, errors: [] };
|
|
368
|
+
assert.equal(evaluateDispatchLiveness(e, now, { deadMs }).dead, false);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
test('null entry / missing dispatch field → not dead', () => {
|
|
372
|
+
assert.equal(evaluateDispatchLiveness(null, now).dead, false);
|
|
373
|
+
assert.equal(evaluateDispatchLiveness({ ts: now }, now).dead, false);
|
|
374
|
+
});
|
|
375
|
+
});
|
|
@@ -9,6 +9,8 @@ const path = require('node:path');
|
|
|
9
9
|
const { spawnSync } = require('node:child_process');
|
|
10
10
|
const {
|
|
11
11
|
maybeRelaunchApp,
|
|
12
|
+
evaluateDispatchLiveness,
|
|
13
|
+
readLastHeartbeat,
|
|
12
14
|
readRelaunchState,
|
|
13
15
|
DEFAULT_MAX_RELAUNCH_ATTEMPTS,
|
|
14
16
|
} = require('../watchdogHelpers.cjs');
|
|
@@ -191,3 +193,74 @@ test('attemptCount resets to 0 once a fresh heartbeat is observed', () => {
|
|
|
191
193
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
192
194
|
}
|
|
193
195
|
});
|
|
196
|
+
|
|
197
|
+
test('dispatch-dead (fresh heartbeat, dead dispatch chain) never relaunches', () => {
|
|
198
|
+
const dir = tmpDir();
|
|
199
|
+
try {
|
|
200
|
+
const hbPath = path.join(dir, 'heartbeat.log');
|
|
201
|
+
const statePath = path.join(dir, 'state.json');
|
|
202
|
+
const now = Date.now();
|
|
203
|
+
fs.writeFileSync(hbPath, JSON.stringify({
|
|
204
|
+
ts: now - 1_000,
|
|
205
|
+
pid: process.pid,
|
|
206
|
+
dispatch: {
|
|
207
|
+
lastDispatchAttemptAt: new Date(now - 1_000).toISOString(),
|
|
208
|
+
lastRunAt: new Date(now - 2 * 60 * 60_000).toISOString(),
|
|
209
|
+
lastTickReason: 'held',
|
|
210
|
+
pendingDispatchable: 5,
|
|
211
|
+
runningCount: 0,
|
|
212
|
+
paused: false,
|
|
213
|
+
},
|
|
214
|
+
}) + '\n');
|
|
215
|
+
assert.equal(evaluateDispatchLiveness(readLastHeartbeat(hbPath), now).dead, true);
|
|
216
|
+
|
|
217
|
+
const spawnFn = makeSpawnSpy();
|
|
218
|
+
const result = maybeRelaunchApp({ heartbeatPath: hbPath, maxAgeMs: 180_000, statePath, now, spawnFn });
|
|
219
|
+
assert.equal(result.relaunched, false);
|
|
220
|
+
assert.equal(result.reason, 'alive');
|
|
221
|
+
assert.equal(spawnFn.calls.length, 0);
|
|
222
|
+
} finally {
|
|
223
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ── restarting marker (drain-restart exit window) ───────────────────────────
|
|
228
|
+
|
|
229
|
+
test('stale heartbeat + active restarting marker → no relaunch; expired marker → relaunch', () => {
|
|
230
|
+
const dir = tmpDir();
|
|
231
|
+
try {
|
|
232
|
+
const hbPath = path.join(dir, 'heartbeat.log');
|
|
233
|
+
const statePath = path.join(dir, 'state.json');
|
|
234
|
+
const markerPath = path.join(dir, 'restarting.json');
|
|
235
|
+
const now = Date.now();
|
|
236
|
+
writeHeartbeat(hbPath, now - 10 * 60_000, DEAD_PID);
|
|
237
|
+
fs.writeFileSync(markerPath, JSON.stringify({ pid: DEAD_PID, at: now - 1_000, expiresAt: now + 60_000 }));
|
|
238
|
+
const spawnFn = makeSpawnSpy();
|
|
239
|
+
|
|
240
|
+
const held = maybeRelaunchApp({ heartbeatPath: hbPath, maxAgeMs: 180_000, statePath, restartingMarkerPath: markerPath, now, spawnFn });
|
|
241
|
+
assert.equal(held.relaunched, false);
|
|
242
|
+
assert.equal(held.reason, 'restarting');
|
|
243
|
+
assert.equal(spawnFn.calls.length, 0);
|
|
244
|
+
assert.equal(fs.existsSync(statePath), false, 'a held relaunch must not burn an attempt');
|
|
245
|
+
|
|
246
|
+
// Bounded lifetime: once the marker expires the watchdog resumes duty.
|
|
247
|
+
const lapsed = maybeRelaunchApp({ heartbeatPath: hbPath, maxAgeMs: 180_000, statePath, restartingMarkerPath: markerPath, now: now + 61_000, spawnFn });
|
|
248
|
+
assert.equal(lapsed.relaunched, true);
|
|
249
|
+
assert.equal(spawnFn.calls.length, 1);
|
|
250
|
+
} finally {
|
|
251
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('fresh heartbeat carrying dispatch.drain is not dispatch-dead', () => {
|
|
256
|
+
const now = Date.now();
|
|
257
|
+
const entry = {
|
|
258
|
+
ts: now - 1_000,
|
|
259
|
+
dispatch: {
|
|
260
|
+
lastRunAt: new Date(now - 5 * 60 * 60_000).toISOString(),
|
|
261
|
+
pendingDispatchable: 5, runningCount: 0, paused: false,
|
|
262
|
+
drain: { since: new Date(now - 60_000).toISOString(), requestedAt: new Date(now - 60_000).toISOString() },
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
assert.deepEqual(evaluateDispatchLiveness(entry, now), { dead: false, reason: 'draining' });
|
|
266
|
+
});
|
|
@@ -48,9 +48,7 @@ function listMirrorFiles(dir) {
|
|
|
48
48
|
* `skipped` — file names not turned into a row.
|
|
49
49
|
* `reasons` — `{ [fileName]: humanReadableReason }`, one entry per skipped
|
|
50
50
|
* file. A file is never guessed at: no mirrored `status` is a skip, not a
|
|
51
|
-
* fabricated 'unknown' row
|
|
52
|
-
* only place that vocabulary is deliberately introduced, for the
|
|
53
|
-
* migration's own back-fill bookkeeping).
|
|
51
|
+
* fabricated 'unknown' row.
|
|
54
52
|
*
|
|
55
53
|
* `events` and `tombstones` from the existing on-disk index (if any) are
|
|
56
54
|
* carried through completely untouched — this function only ever
|
|
@@ -13,6 +13,7 @@ const os = require('node:os');
|
|
|
13
13
|
const path = require('node:path');
|
|
14
14
|
const { KIND_CONFIG: WORKTREE_KIND_CONFIG } = require('./gitWorktree.cjs');
|
|
15
15
|
const { OPS_ROOT_DIR } = require('./opsOwnership.cjs');
|
|
16
|
+
const { classifyCwd, worktreeMainRootOf } = require('./cwdClassify.cjs');
|
|
16
17
|
|
|
17
18
|
const HOME = os.homedir();
|
|
18
19
|
const TMPDIR = os.tmpdir();
|
|
@@ -32,7 +33,7 @@ const TMPDIR = os.tmpdir();
|
|
|
32
33
|
// could not — e.g. a bare, .git-less worktree-root directory.
|
|
33
34
|
const TMP_DROP_ROOTS = {
|
|
34
35
|
exactDropCwds: [TMPDIR],
|
|
35
|
-
prefixDropRoots
|
|
36
|
+
get prefixDropRoots() { return [WORKTREE_KIND_CONFIG.job.root, WORKTREE_KIND_CONFIG.epic.root]; },
|
|
36
37
|
};
|
|
37
38
|
|
|
38
39
|
// Transcript reads only need the last line with a `cwd` field — a few dozen
|
|
@@ -55,7 +56,10 @@ const MAX_CWDS = 50;
|
|
|
55
56
|
// fine: a project already in the list stays in the list) plus a short TTL
|
|
56
57
|
// as the backstop for everything else. Same mtime-keyed idiom as
|
|
57
58
|
// scheduler/prdParser.cjs's dirCache and queueHistory.cjs's historyCacheKey.
|
|
58
|
-
|
|
59
|
+
// TTL sits above the 60 s dispatch-loop interval (scheduler POLL_INTERVAL_MS) so
|
|
60
|
+
// one pass never re-scans cold. Cached arrays are frozen — shared, never mutated.
|
|
61
|
+
const CACHE_TTL_MS = 120_000;
|
|
62
|
+
const EMPTY_CWDS = Object.freeze([]);
|
|
59
63
|
const cwdScanCache = new Map(); // key -> { dirMtimeMs, cachedAt, result }
|
|
60
64
|
|
|
61
65
|
/** Forces the next activeProjectCwds/allProjectCwds call to rescan. */
|
|
@@ -79,93 +83,16 @@ function bustProjectCwdCache() {
|
|
|
79
83
|
// signal survives instead of being silently lost.
|
|
80
84
|
const OPS_DIRNAME = OPS_ROOT_DIR;
|
|
81
85
|
|
|
82
|
-
// Bound on the ancestor walk in worktreeMainRootOf — well past any real
|
|
83
|
-
// filesystem depth, purely to guarantee termination without relying on
|
|
84
|
-
// reaching '/' (e.g. a symlink loop or an unusually deep path).
|
|
85
|
-
const MAX_WORKTREE_WALK = 40;
|
|
86
|
-
|
|
87
|
-
const WORKTREES_MARKER = `${path.sep}.git${path.sep}worktrees${path.sep}`;
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* worktreeMainRootOf(cwd) → the main tree's root when cwd sits inside a
|
|
91
|
-
* linked `git worktree` (job/epic worktrees under os.tmpdir(), or a
|
|
92
|
-
* user-made one anywhere). Walks UP from cwd looking for a `.git` entry:
|
|
93
|
-
* - `.git` is a FILE (linked worktree) whose first line is
|
|
94
|
-
* `gitdir: <main>/.git/worktrees/<name>` → returns <main>.
|
|
95
|
-
* - `.git` is a DIRECTORY (already the main tree) → returns that ancestor.
|
|
96
|
-
* - nothing found within MAX_WORKTREE_WALK levels → returns null.
|
|
97
|
-
* Pure fs, synchronous, never throws — any unexpected shape (garbled file,
|
|
98
|
-
* unreadable entry, gitdir path without the worktrees marker) yields null so
|
|
99
|
-
* the caller leaves the cwd unchanged rather than guessing.
|
|
100
|
-
*/
|
|
101
|
-
function worktreeMainRootOf(cwd) {
|
|
102
|
-
let dir = cwd;
|
|
103
|
-
for (let i = 0; i < MAX_WORKTREE_WALK; i++) {
|
|
104
|
-
const gitPath = path.join(dir, '.git');
|
|
105
|
-
let stat;
|
|
106
|
-
try {
|
|
107
|
-
stat = fs.statSync(gitPath);
|
|
108
|
-
} catch {
|
|
109
|
-
stat = null;
|
|
110
|
-
}
|
|
111
|
-
if (stat) {
|
|
112
|
-
if (stat.isDirectory()) return dir;
|
|
113
|
-
if (stat.isFile()) {
|
|
114
|
-
let body;
|
|
115
|
-
try {
|
|
116
|
-
body = fs.readFileSync(gitPath, 'utf8');
|
|
117
|
-
} catch {
|
|
118
|
-
return null;
|
|
119
|
-
}
|
|
120
|
-
const firstLine = body.split('\n', 1)[0].trim();
|
|
121
|
-
const prefix = 'gitdir:';
|
|
122
|
-
if (!firstLine.startsWith(prefix)) return null;
|
|
123
|
-
const gitdir = firstLine.slice(prefix.length).trim();
|
|
124
|
-
const markerIdx = gitdir.indexOf(WORKTREES_MARKER);
|
|
125
|
-
if (markerIdx <= 0) return null;
|
|
126
|
-
const candidateMain = gitdir.slice(0, markerIdx);
|
|
127
|
-
const worktreeName = gitdir.slice(markerIdx + WORKTREES_MARKER.length).split(path.sep)[0];
|
|
128
|
-
// Round-trip verification: gitdir's content is a plain file that
|
|
129
|
-
// anything on disk could have written (e.g. a nested `.git` file
|
|
130
|
-
// tracked inside an untrusted repo), so it must not be trusted to
|
|
131
|
-
// redirect callers to an arbitrary attacker-chosen absolute path.
|
|
132
|
-
// A genuine linked worktree's admin dir back-references the exact
|
|
133
|
-
// `.git` FILE we are resolving via its own `gitdir` pointer file —
|
|
134
|
-
// require that round trip before accepting candidateMain.
|
|
135
|
-
if (!worktreeName) return null;
|
|
136
|
-
const adminGitdirFile = path.join(candidateMain, '.git', 'worktrees', worktreeName, 'gitdir');
|
|
137
|
-
let backRef;
|
|
138
|
-
try {
|
|
139
|
-
backRef = fs.readFileSync(adminGitdirFile, 'utf8').trim();
|
|
140
|
-
} catch {
|
|
141
|
-
return null;
|
|
142
|
-
}
|
|
143
|
-
if (path.resolve(backRef) !== path.resolve(gitPath)) return null;
|
|
144
|
-
return candidateMain;
|
|
145
|
-
}
|
|
146
|
-
return null;
|
|
147
|
-
}
|
|
148
|
-
const parent = path.dirname(dir);
|
|
149
|
-
if (parent === dir) return null;
|
|
150
|
-
dir = parent;
|
|
151
|
-
}
|
|
152
|
-
return null;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
86
|
/**
|
|
156
87
|
* projectRootOf(cwd) → the project root for a cwd that may sit inside an ops
|
|
157
88
|
* tree and/or a linked git worktree. Returns cwd unchanged when neither
|
|
158
|
-
* applies
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
89
|
+
* applies, or when the worktree cannot be proven (kind `unknown` — callers that
|
|
90
|
+
* WRITE must consult classifyCwd; this stays total so dispatch never breaks).
|
|
91
|
+
* The classification itself (outermost ops truncation, then worktree
|
|
92
|
+
* resolution) lives in cwdClassify.cjs.
|
|
162
93
|
*/
|
|
163
94
|
function projectRootOf(cwd) {
|
|
164
|
-
|
|
165
|
-
const i = parts.indexOf(OPS_DIRNAME);
|
|
166
|
-
const truncated = i > 0 ? (parts.slice(0, i).join(path.sep) || path.sep) : cwd;
|
|
167
|
-
const mainRoot = worktreeMainRootOf(truncated);
|
|
168
|
-
return mainRoot || truncated;
|
|
95
|
+
return classifyCwd(cwd).projectRoot ?? cwd;
|
|
169
96
|
}
|
|
170
97
|
|
|
171
98
|
/**
|
|
@@ -221,7 +148,7 @@ function activeProjectCwds(maxAgeMin = 90, {
|
|
|
221
148
|
// this key must not survive to be handed back once the dir later
|
|
222
149
|
// appears — drop it rather than caching this empty result.
|
|
223
150
|
cwdScanCache.delete(cacheKey);
|
|
224
|
-
return
|
|
151
|
+
return EMPTY_CWDS;
|
|
225
152
|
}
|
|
226
153
|
const cached = cwdScanCache.get(cacheKey);
|
|
227
154
|
const now0 = Date.now();
|
|
@@ -241,14 +168,18 @@ function activeProjectCwds(maxAgeMin = 90, {
|
|
|
241
168
|
// Normalize an ops-internal cwd up to its project root BEFORE any of the
|
|
242
169
|
// checks below — the stray-ops-root incident (see OPS_DIRNAME above) got
|
|
243
170
|
// through precisely because such a path is absolute and does exist.
|
|
244
|
-
const
|
|
171
|
+
const classified = classifyCwd(rawCwd);
|
|
172
|
+
// `unknown` (a .git file we cannot resolve) is non-registrable: registering
|
|
173
|
+
// it would hand every consumer a project whose ops writes are refused.
|
|
174
|
+
if (classified.kind === 'unknown') return;
|
|
175
|
+
const cwd = classified.projectRoot;
|
|
245
176
|
// Must be ABSOLUTE. A relative fragment would pass the statSync below
|
|
246
177
|
// whenever it happens to resolve against THIS process's own cwd, and
|
|
247
178
|
// every consumer (queueStore.projectStateDir, prdLocations) then joins
|
|
248
179
|
// it into an ops-root path that lands somewhere arbitrary. Callers key
|
|
249
180
|
// whole per-project state off these strings — a project is a cwd, and a
|
|
250
181
|
// cwd is an absolute path.
|
|
251
|
-
if (!path.isAbsolute(cwd)) return;
|
|
182
|
+
if (!cwd || !path.isAbsolute(cwd)) return;
|
|
252
183
|
// Drop-guard of last resort: a cwd that is (or sits inside) a known
|
|
253
184
|
// worktree-scratch root after projectRootOf's normalization is one
|
|
254
185
|
// worktreeMainRootOf could not resolve to a main tree — never a real
|
|
@@ -270,7 +201,7 @@ function activeProjectCwds(maxAgeMin = 90, {
|
|
|
270
201
|
|
|
271
202
|
// Scan ~/.claude/projects/*/ transcript *.jsonl files.
|
|
272
203
|
let slugs;
|
|
273
|
-
try { slugs = fs.readdirSync(projectsDir); } catch { cwdScanCache.delete(cacheKey); return result; }
|
|
204
|
+
try { slugs = fs.readdirSync(projectsDir); } catch { cwdScanCache.delete(cacheKey); return Object.freeze(result); }
|
|
274
205
|
|
|
275
206
|
for (const slug of slugs) {
|
|
276
207
|
if (result.length >= maxCwds) break;
|
|
@@ -305,6 +236,7 @@ function activeProjectCwds(maxAgeMin = 90, {
|
|
|
305
236
|
}
|
|
306
237
|
}
|
|
307
238
|
|
|
239
|
+
Object.freeze(result);
|
|
308
240
|
cwdScanCache.set(cacheKey, { dirMtimeMs, cachedAt: now0, result });
|
|
309
241
|
return result;
|
|
310
242
|
}
|