python-delphi-lsp 2.0.5__tar.gz → 2.1.0__tar.gz

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.
Files changed (142) hide show
  1. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/.github/workflows/ci.yml +1 -0
  2. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/MANIFEST.in +1 -0
  3. {python_delphi_lsp-2.0.5/python_delphi_lsp.egg-info → python_delphi_lsp-2.1.0}/PKG-INFO +30 -4
  4. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/README.md +29 -3
  5. python_delphi_lsp-2.1.0/delphi_lsp/_version.py +1 -0
  6. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/delphi_lsp/agent_cache.py +211 -32
  7. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/delphi_lsp/agent_cli.py +44 -5
  8. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/delphi_lsp/agent_context.py +74 -29
  9. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/delphi_lsp/agent_layers.py +32 -21
  10. python_delphi_lsp-2.1.0/delphi_lsp/parallel_outline.py +278 -0
  11. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/pyproject.toml +1 -1
  12. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0/python_delphi_lsp.egg-info}/PKG-INFO +30 -4
  13. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/python_delphi_lsp.egg-info/SOURCES.txt +3 -0
  14. python_delphi_lsp-2.1.0/scripts/benchmark_parallel_cache.py +71 -0
  15. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/test_agent_cache.py +183 -7
  16. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/test_agent_codebase.py +27 -1
  17. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/test_agent_context.py +47 -24
  18. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/test_agent_worker.py +48 -3
  19. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/test_lsp_support.py +15 -10
  20. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/test_package_metadata.py +4 -3
  21. python_delphi_lsp-2.1.0/tests/test_parallel_outline.py +422 -0
  22. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/test_progress.py +48 -3
  23. python_delphi_lsp-2.0.5/delphi_lsp/_version.py +0 -1
  24. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/LICENSE +0 -0
  25. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/delphi_lsp/__init__.py +0 -0
  26. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/delphi_lsp/agent_metrics.py +0 -0
  27. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/delphi_lsp/agent_protocol.py +0 -0
  28. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/delphi_lsp/agent_relations.py +0 -0
  29. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/delphi_lsp/agent_templates.py +0 -0
  30. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/delphi_lsp/agent_workspace.py +0 -0
  31. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/delphi_lsp/binary.py +0 -0
  32. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/delphi_lsp/comment_builder.py +0 -0
  33. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/delphi_lsp/consts.py +0 -0
  34. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/delphi_lsp/grammar.py +0 -0
  35. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/delphi_lsp/lark_builder.py +0 -0
  36. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/delphi_lsp/lark_tokens.py +0 -0
  37. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/delphi_lsp/lsp_server.py +0 -0
  38. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/delphi_lsp/metrics.py +0 -0
  39. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/delphi_lsp/nodes.py +0 -0
  40. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/delphi_lsp/parser.py +0 -0
  41. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/delphi_lsp/preprocessor.py +0 -0
  42. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/delphi_lsp/progress.py +0 -0
  43. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/delphi_lsp/project_discovery.py +0 -0
  44. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/delphi_lsp/project_indexer.py +0 -0
  45. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/delphi_lsp/semantic.py +0 -0
  46. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/delphi_lsp/semantic_builder.py +0 -0
  47. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/delphi_lsp/source_reader.py +0 -0
  48. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/delphi_lsp/workspace.py +0 -0
  49. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/delphi_lsp/writer.py +0 -0
  50. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/opencode.json +0 -0
  51. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/python_delphi_lsp.egg-info/dependency_links.txt +0 -0
  52. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/python_delphi_lsp.egg-info/entry_points.txt +0 -0
  53. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/python_delphi_lsp.egg-info/requires.txt +0 -0
  54. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/python_delphi_lsp.egg-info/top_level.txt +0 -0
  55. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/scripts/audit_delphi_language_features.py +0 -0
  56. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/scripts/benchmark_github_corpus.py +0 -0
  57. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/scripts/bootstrap_vllm_codebase_skill_test.py +0 -0
  58. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/scripts/bootstrap_vllm_opencode_test.ps1 +0 -0
  59. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/scripts/bootstrap_vllm_opencode_test.py +0 -0
  60. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/scripts/bootstrap_vllm_opencode_test.sh +0 -0
  61. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/scripts/build_github_performance_corpus.py +0 -0
  62. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/scripts/check_ornith_cache.py +0 -0
  63. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/scripts/generate_progress_pdf.py +0 -0
  64. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/scripts/generate_release_evidence.py +0 -0
  65. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/scripts/ollama/ornith-lspctx.Modelfile +0 -0
  66. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/scripts/prepare_ornith_cache.py +0 -0
  67. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/scripts/run_opencode_lsp_probe.py +0 -0
  68. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/scripts/run_openrouter_github_e2e.py +0 -0
  69. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/scripts/start_ornith_vllm.sh +0 -0
  70. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/scripts/summarize_opencode_request_payloads.py +0 -0
  71. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/setup.cfg +0 -0
  72. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/corpora.performance.lock.json +0 -0
  73. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/legacy_snippets/DeprecatedOnConst.pas +0 -0
  74. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/legacy_snippets/VariantRecordFieldAttributes.pas +0 -0
  75. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/legacy_snippets/alignedrecords.pas +0 -0
  76. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/legacy_snippets/constset.pas +0 -0
  77. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/legacy_snippets/deprecatedtype.pas +0 -0
  78. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/legacy_snippets/dottedtypes.pas +0 -0
  79. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/legacy_snippets/endtoken.pas +0 -0
  80. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/legacy_snippets/experimentals.pas +0 -0
  81. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/legacy_snippets/externalfunction.pas +0 -0
  82. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/legacy_snippets/finalizationinitializationexports.pas +0 -0
  83. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/legacy_snippets/forwardoverloaded.pas +0 -0
  84. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/legacy_snippets/forwardwithoutsemicolon.pas +0 -0
  85. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/legacy_snippets/genericconstraints.pas +0 -0
  86. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/legacy_snippets/genericinterfacemethoddelegation.pas +0 -0
  87. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/legacy_snippets/implementsgenerictype.pas +0 -0
  88. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/legacy_snippets/include file2.inc +0 -0
  89. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/legacy_snippets/includefile.inc +0 -0
  90. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/legacy_snippets/includefile.pas +0 -0
  91. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/legacy_snippets/managedrecords.pas +0 -0
  92. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/legacy_snippets/messagemethod.pas +0 -0
  93. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/legacy_snippets/multiline.pas +0 -0
  94. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/legacy_snippets/nonalignedrecords.pas +0 -0
  95. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/legacy_snippets/numbers.pas +0 -0
  96. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/legacy_snippets/pointerchars.pas +0 -0
  97. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/legacy_snippets/properties.pas +0 -0
  98. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/legacy_snippets/strictvisibility.pas +0 -0
  99. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/legacy_snippets/tryexcept.pas +0 -0
  100. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/legacy_snippets/umlauts.pas +0 -0
  101. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/legacy_snippets/whitespacearoundifdefcondition.pas +0 -0
  102. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/library_demo.dpr +0 -0
  103. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/package_demo.dpk +0 -0
  104. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/program_demo.dpr +0 -0
  105. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/unit_advanced.pas +0 -0
  106. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/unit_attributes.pas +0 -0
  107. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/unit_basic.pas +0 -0
  108. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/unit_consumer.pas +0 -0
  109. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/unit_generics.pas +0 -0
  110. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/unit_inheritance.pas +0 -0
  111. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/unit_math.pas +0 -0
  112. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/unit_properties.pas +0 -0
  113. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/unit_sections.pas +0 -0
  114. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/unit_statements.pas +0 -0
  115. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/unit_types.pas +0 -0
  116. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/unit_unresolved.pas +0 -0
  117. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/fixtures/unit_with.pas +0 -0
  118. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/test_agent_metrics.py +0 -0
  119. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/test_agent_protocol.py +0 -0
  120. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/test_agent_relations.py +0 -0
  121. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/test_agent_workspace.py +0 -0
  122. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/test_bootstrap_vllm_codebase_skill.py +0 -0
  123. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/test_bootstrap_vllm_opencode.py +0 -0
  124. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/test_diagnostics.py +0 -0
  125. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/test_github_performance_corpus.py +0 -0
  126. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/test_language_feature_matrix.py +0 -0
  127. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/test_legacy_snippets.py +0 -0
  128. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/test_lsp_features.py +0 -0
  129. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/test_metrics.py +0 -0
  130. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/test_opencode_config.py +0 -0
  131. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/test_opencode_probe.py +0 -0
  132. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/test_opencode_request_payloads.py +0 -0
  133. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/test_openrouter_github_e2e.py +0 -0
  134. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/test_ornith_cache_prepare.py +0 -0
  135. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/test_parser.py +0 -0
  136. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/test_preprocessor.py +0 -0
  137. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/test_project_discovery.py +0 -0
  138. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/test_project_indexer.py +0 -0
  139. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/test_release_evidence.py +0 -0
  140. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/test_semantic.py +0 -0
  141. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/test_vllm_start_script.py +0 -0
  142. {python_delphi_lsp-2.0.5 → python_delphi_lsp-2.1.0}/tests/test_workspace.py +0 -0
@@ -2,6 +2,7 @@ name: CI
2
2
 
3
3
  on:
4
4
  push:
5
+ branches: [main]
5
6
  pull_request:
6
7
 
7
8
  jobs:
@@ -6,6 +6,7 @@ include scripts/check_ornith_cache.py
6
6
  include scripts/audit_delphi_language_features.py
7
7
  include scripts/build_github_performance_corpus.py
8
8
  include scripts/benchmark_github_corpus.py
9
+ include scripts/benchmark_parallel_cache.py
9
10
  include scripts/bootstrap_vllm_opencode_test.py
10
11
  include scripts/bootstrap_vllm_codebase_skill_test.py
11
12
  include scripts/bootstrap_vllm_opencode_test.ps1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-delphi-lsp
3
- Version: 2.0.5
3
+ Version: 2.1.0
4
4
  Summary: Python Delphi/Object Pascal parser, semantic indexer, and language server.
5
5
  Author: Dark Light
6
6
  License-Expression: MPL-2.0
@@ -41,7 +41,7 @@ Dynamic: license-file
41
41
 
42
42
  `python-delphi-lsp` parses Delphi/Object Pascal, builds semantic and project
43
43
  indexes, serves LSP, and provides bounded codebase navigation for agents.
44
- Version 2.0.5 is authored by Dark Light and supports Windows, macOS, and Linux.
44
+ Version 2.1.0 is authored by Dark Light and supports Windows, macOS, and Linux.
45
45
 
46
46
  ## Install and quick start
47
47
 
@@ -186,12 +186,15 @@ problems; paths are not guessed.
186
186
 
187
187
  ```text
188
188
  delphi-lsp-agent cache start --root PATH [--project-file FILE] [--max-memory 512M]
189
+ [--workers auto|N] [--startup-timeout 120]
189
190
  [--idle-timeout 1800]
190
191
  delphi-lsp-agent cache status --root PATH [--format text|json]
191
192
  delphi-lsp-agent cache stop --root PATH
192
193
  delphi-lsp-agent view --root PATH [--project-file FILE] --layer LAYER
193
194
  [--query TEXT] [--format markdown|json] [--deep-projects]
195
+ [--workers auto|N]
194
196
  delphi-lsp-agent index --root PATH [--project-file FILE] [--out FILE]
197
+ [--workers auto|N]
195
198
  delphi-lsp-agent query --root PATH ACTION [VALUE]
196
199
  [--project-id FILE] [--detail summary|declaration|members|context|body|implementations]
197
200
  [--relation references|callers|callees|uses|used_by|inherits|implements]
@@ -199,7 +202,7 @@ delphi-lsp-agent query --root PATH ACTION [VALUE]
199
202
  delphi-lsp-agent skill install [--target PATH] [--force]
200
203
  delphi-lsp-agent opencode install [--target PATH] [--python PYTHON]
201
204
  [--force] [--write-agent|--write-config]
202
- delphi-lsp-agent worker --root PATH [--project-file FILE]
205
+ delphi-lsp-agent worker --root PATH [--project-file FILE] [--workers auto|N]
203
206
  ```
204
207
 
205
208
  The `cache` commands manage one daemon per canonical root. Use these:
@@ -233,6 +236,27 @@ fast. The cache retained-cache budget is `512 MiB` by default and tracks retaine
233
236
  cache usage only, not a hard RSS/parse peak. Warnings are emitted on stderr at or
234
237
  above 80 percent.
235
238
 
239
+ Cold builds parse independent source units in short-lived processes created with
240
+ the cross-platform `spawn` method. `--workers auto|N` defaults to `auto`.
241
+ Automatic selection uses at most four worker processes, leaves one detected CPU
242
+ free, never exceeds the source task count, and—for the cache daemon—allows one
243
+ worker per `128 MiB` of retained-cache budget. `view` and `index` use the same
244
+ task, CPU, and four-worker caps without the cache-budget term. An explicit value
245
+ from 1 through 32 overrides the automatic CPU and memory caps but is still
246
+ limited by the number of tasks.
247
+
248
+ Worker processes exit before retained-cache accounting. Their models and source
249
+ text are streamed into the parent, so transient worker memory is separate from
250
+ the retained navigation structures and the existing 80-percent warning. If an
251
+ automatic pool fails before accepting a result, one automatic serial fallback
252
+ is attempted; explicit worker counts fail instead of silently changing the
253
+ requested configuration.
254
+
255
+ `cache start` waits up to `--startup-timeout 120` seconds by default for a large
256
+ workspace to prewarm. The timeout belongs to the starting client and does not
257
+ change daemon compatibility or idle shutdown. Starting a live root with a
258
+ different worker configuration reports a configuration conflict.
259
+
236
260
  Eviction is ordered: auxiliary caches are evicted first, navigation caches second.
237
261
  If compaction removes navigable data, the daemon rebuilds the navigation state on demand
238
262
  while preserving focus state for the next request.
@@ -240,7 +264,9 @@ while preserving focus state for the next request.
240
264
  The daemon tracks a 30-minute idle timeout; idle state shows in JSON status (`cache status`).
241
265
  `source revision` changes on source edits and invalidate reused request caches.
242
266
  Workspace state appears in status as `requests`, `warm_hits`, `rebuilds`, `invalidations`,
243
- `evictions`, and `cache_state`.
267
+ `evictions`, and `cache_state`. Parallel prewarm status adds
268
+ `workers_configured`, `workers_effective`, `parallel_files_completed`,
269
+ `prewarm_seconds`, `parallel_seconds`, and `parallel_fallbacks`.
244
270
 
245
271
  Metadata is stored in `.delphi-lsp/agent-cache/daemon.json` with owner-only token and
246
272
  permissions (`daemon.json` mode 600 and parent 700). Do not copy or share this token
@@ -2,7 +2,7 @@
2
2
 
3
3
  `python-delphi-lsp` parses Delphi/Object Pascal, builds semantic and project
4
4
  indexes, serves LSP, and provides bounded codebase navigation for agents.
5
- Version 2.0.5 is authored by Dark Light and supports Windows, macOS, and Linux.
5
+ Version 2.1.0 is authored by Dark Light and supports Windows, macOS, and Linux.
6
6
 
7
7
  ## Install and quick start
8
8
 
@@ -147,12 +147,15 @@ problems; paths are not guessed.
147
147
 
148
148
  ```text
149
149
  delphi-lsp-agent cache start --root PATH [--project-file FILE] [--max-memory 512M]
150
+ [--workers auto|N] [--startup-timeout 120]
150
151
  [--idle-timeout 1800]
151
152
  delphi-lsp-agent cache status --root PATH [--format text|json]
152
153
  delphi-lsp-agent cache stop --root PATH
153
154
  delphi-lsp-agent view --root PATH [--project-file FILE] --layer LAYER
154
155
  [--query TEXT] [--format markdown|json] [--deep-projects]
156
+ [--workers auto|N]
155
157
  delphi-lsp-agent index --root PATH [--project-file FILE] [--out FILE]
158
+ [--workers auto|N]
156
159
  delphi-lsp-agent query --root PATH ACTION [VALUE]
157
160
  [--project-id FILE] [--detail summary|declaration|members|context|body|implementations]
158
161
  [--relation references|callers|callees|uses|used_by|inherits|implements]
@@ -160,7 +163,7 @@ delphi-lsp-agent query --root PATH ACTION [VALUE]
160
163
  delphi-lsp-agent skill install [--target PATH] [--force]
161
164
  delphi-lsp-agent opencode install [--target PATH] [--python PYTHON]
162
165
  [--force] [--write-agent|--write-config]
163
- delphi-lsp-agent worker --root PATH [--project-file FILE]
166
+ delphi-lsp-agent worker --root PATH [--project-file FILE] [--workers auto|N]
164
167
  ```
165
168
 
166
169
  The `cache` commands manage one daemon per canonical root. Use these:
@@ -194,6 +197,27 @@ fast. The cache retained-cache budget is `512 MiB` by default and tracks retaine
194
197
  cache usage only, not a hard RSS/parse peak. Warnings are emitted on stderr at or
195
198
  above 80 percent.
196
199
 
200
+ Cold builds parse independent source units in short-lived processes created with
201
+ the cross-platform `spawn` method. `--workers auto|N` defaults to `auto`.
202
+ Automatic selection uses at most four worker processes, leaves one detected CPU
203
+ free, never exceeds the source task count, and—for the cache daemon—allows one
204
+ worker per `128 MiB` of retained-cache budget. `view` and `index` use the same
205
+ task, CPU, and four-worker caps without the cache-budget term. An explicit value
206
+ from 1 through 32 overrides the automatic CPU and memory caps but is still
207
+ limited by the number of tasks.
208
+
209
+ Worker processes exit before retained-cache accounting. Their models and source
210
+ text are streamed into the parent, so transient worker memory is separate from
211
+ the retained navigation structures and the existing 80-percent warning. If an
212
+ automatic pool fails before accepting a result, one automatic serial fallback
213
+ is attempted; explicit worker counts fail instead of silently changing the
214
+ requested configuration.
215
+
216
+ `cache start` waits up to `--startup-timeout 120` seconds by default for a large
217
+ workspace to prewarm. The timeout belongs to the starting client and does not
218
+ change daemon compatibility or idle shutdown. Starting a live root with a
219
+ different worker configuration reports a configuration conflict.
220
+
197
221
  Eviction is ordered: auxiliary caches are evicted first, navigation caches second.
198
222
  If compaction removes navigable data, the daemon rebuilds the navigation state on demand
199
223
  while preserving focus state for the next request.
@@ -201,7 +225,9 @@ while preserving focus state for the next request.
201
225
  The daemon tracks a 30-minute idle timeout; idle state shows in JSON status (`cache status`).
202
226
  `source revision` changes on source edits and invalidate reused request caches.
203
227
  Workspace state appears in status as `requests`, `warm_hits`, `rebuilds`, `invalidations`,
204
- `evictions`, and `cache_state`.
228
+ `evictions`, and `cache_state`. Parallel prewarm status adds
229
+ `workers_configured`, `workers_effective`, `parallel_files_completed`,
230
+ `prewarm_seconds`, `parallel_seconds`, and `parallel_fallbacks`.
205
231
 
206
232
  Metadata is stored in `.delphi-lsp/agent-cache/daemon.json` with owner-only token and
207
233
  permissions (`daemon.json` mode 600 and parent 700). Do not copy or share this token
@@ -0,0 +1 @@
1
+ __version__ = "2.1.0"
@@ -6,6 +6,7 @@ import argparse
6
6
  import contextlib
7
7
  import hmac
8
8
  import json
9
+ import math
9
10
  import os
10
11
  from pathlib import Path
11
12
  import secrets
@@ -26,13 +27,15 @@ from .agent_protocol import AgentProtocolError
26
27
 
27
28
  DEFAULT_MAX_MEMORY_BYTES = 512 * 1024**2
28
29
  WARNING_THRESHOLD_PERCENT = 80
29
- DAEMON_SCHEMA = 1
30
+ DAEMON_SCHEMA = 2
30
31
  DEFAULT_IDLE_TIMEOUT = 1800
32
+ DEFAULT_STARTUP_TIMEOUT = 120.0
31
33
  _MAX_MESSAGE_BYTES = 1024 * 1024
32
34
  _CONNECTION_TIMEOUT = 2.0
33
35
  _MEMORY_SIZE = re.compile(r"^(?P<count>[1-9][0-9]*)(?P<suffix>[KMG]?)$", re.IGNORECASE)
34
36
  _STARTUP_DIAGNOSTIC_BYTES = 16 * 1024
35
37
  _STARTUP_TOKEN_RE = re.compile(r"(?i)(token\b[^\n\r]*?:?\s*['\"]?[A-Za-z0-9_-]+['\"]?|\b[a-zA-Z0-9_-]{32,})")
38
+ _START_LOCK_INCOMPLETE_GRACE_SECONDS = 1.0
36
39
 
37
40
 
38
41
  def estimate_deep_size(value: object) -> int:
@@ -72,6 +75,7 @@ class CacheStats:
72
75
  rebuilds: int = 0
73
76
  invalidations: int = 0
74
77
  evictions: int = 0
78
+ parallel_fallbacks: int = 0
75
79
 
76
80
 
77
81
  @dataclass(frozen=True)
@@ -232,6 +236,7 @@ class CacheMetadata:
232
236
  version: str
233
237
  project_file: str
234
238
  max_memory_bytes: int
239
+ workers: int
235
240
  idle_timeout: int
236
241
  started_at: float
237
242
 
@@ -291,7 +296,7 @@ def _write_metadata(metadata: CacheMetadata) -> None:
291
296
  os.unlink(temporary)
292
297
 
293
298
 
294
- def _read_metadata(root: str | Path) -> CacheMetadata | None:
299
+ def _read_metadata_record(root: str | Path) -> object | None:
295
300
  path = _safe_metadata_path(root)
296
301
  if not path.exists():
297
302
  return None
@@ -309,15 +314,36 @@ def _read_metadata(root: str | Path) -> CacheMetadata | None:
309
314
  raise
310
315
  except (OSError, ValueError, UnicodeError):
311
316
  return None
317
+ return raw
318
+
319
+
320
+ def _read_metadata(root: str | Path) -> CacheMetadata | None:
321
+ raw = _read_metadata_record(root)
312
322
  required = {field.name for field in fields(CacheMetadata)}
313
323
  if not isinstance(raw, dict) or set(raw) != required:
314
324
  return None
315
- values = tuple(raw[name] for name in ("schema", "root", "pid", "port", "token", "version", "project_file", "max_memory_bytes", "idle_timeout", "started_at"))
325
+ values = tuple(
326
+ raw[name]
327
+ for name in (
328
+ "schema",
329
+ "root",
330
+ "pid",
331
+ "port",
332
+ "token",
333
+ "version",
334
+ "project_file",
335
+ "max_memory_bytes",
336
+ "workers",
337
+ "idle_timeout",
338
+ "started_at",
339
+ )
340
+ )
316
341
  if (type(values[0]) is not int or values[0] != DAEMON_SCHEMA or not isinstance(values[1], str)
317
342
  or type(values[2]) is not int or values[2] <= 0 or type(values[3]) is not int or not 0 < values[3] < 65536
318
343
  or not isinstance(values[4], str) or len(values[4]) < 32 or not isinstance(values[5], str)
319
344
  or not isinstance(values[6], str) or type(values[7]) is not int or values[7] <= 0
320
- or type(values[8]) is not int or values[8] <= 0 or type(values[9]) not in (int, float)):
345
+ or type(values[8]) is not int or not 0 <= values[8] <= 32
346
+ or type(values[9]) is not int or values[9] <= 0 or type(values[10]) not in (int, float)):
321
347
  return None
322
348
  canonical = str(Path(root).resolve())
323
349
  if values[1] != canonical:
@@ -325,15 +351,56 @@ def _read_metadata(root: str | Path) -> CacheMetadata | None:
325
351
  return CacheMetadata(*values)
326
352
 
327
353
 
354
+ def _remove_incompatible_metadata_if_stale(root: str | Path) -> None:
355
+ path = _safe_metadata_path(root)
356
+ if not path.exists():
357
+ return
358
+ raw = _read_metadata_record(root)
359
+ canonical = str(Path(root).resolve())
360
+ pid = raw.get("pid") if isinstance(raw, dict) and raw.get("root") == canonical else None
361
+ if type(pid) is int and pid > 0 and _pid_alive(pid):
362
+ raise CacheClientError("unavailable", "Live cache daemon is unavailable.")
363
+ with contextlib.suppress(FileNotFoundError):
364
+ path.unlink()
365
+
366
+
367
+ def _metadata_is_owned(metadata: CacheMetadata) -> bool:
368
+ current = _read_metadata(metadata.root)
369
+ return current is not None and current.pid == metadata.pid and hmac.compare_digest(current.token, metadata.token)
370
+
371
+
328
372
  def _remove_metadata_if_owned(metadata: CacheMetadata) -> None:
329
373
  path = _safe_metadata_path(metadata.root)
330
- current = _read_metadata(metadata.root)
331
- if current is not None and current.pid == metadata.pid and hmac.compare_digest(current.token, metadata.token):
374
+ if _metadata_is_owned(metadata):
332
375
  with contextlib.suppress(FileNotFoundError):
333
376
  path.unlink()
334
377
 
335
378
 
379
+ def _windows_pid_alive(pid: int) -> bool:
380
+ import ctypes
381
+ from ctypes import wintypes
382
+
383
+ synchronize = 0x00100000
384
+ wait_timeout = 0x00000102
385
+ kernel32 = ctypes.windll.kernel32
386
+ kernel32.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
387
+ kernel32.OpenProcess.restype = wintypes.HANDLE
388
+ kernel32.WaitForSingleObject.argtypes = (wintypes.HANDLE, wintypes.DWORD)
389
+ kernel32.WaitForSingleObject.restype = wintypes.DWORD
390
+ kernel32.CloseHandle.argtypes = (wintypes.HANDLE,)
391
+ kernel32.CloseHandle.restype = wintypes.BOOL
392
+ handle = kernel32.OpenProcess(synchronize, False, pid)
393
+ if not handle:
394
+ return False
395
+ try:
396
+ return kernel32.WaitForSingleObject(handle, 0) == wait_timeout
397
+ finally:
398
+ kernel32.CloseHandle(handle)
399
+
400
+
336
401
  def _pid_alive(pid: int) -> bool:
402
+ if os.name == "nt":
403
+ return _windows_pid_alive(pid)
337
404
  try:
338
405
  os.kill(pid, 0)
339
406
  except OSError:
@@ -341,15 +408,41 @@ def _pid_alive(pid: int) -> bool:
341
408
  return True
342
409
 
343
410
 
411
+ def _reap_child_if_exited(pid: int) -> bool:
412
+ waitpid = getattr(os, "waitpid", None)
413
+ nohang = getattr(os, "WNOHANG", None)
414
+ if not callable(waitpid) or nohang is None:
415
+ return False
416
+ try:
417
+ return waitpid(pid, nohang)[0] == pid
418
+ except (ChildProcessError, OSError):
419
+ return False
420
+
421
+
344
422
  def _start_lock_path(root: str | Path) -> Path:
345
423
  return _safe_metadata_path(root, create=True).with_name("start.lock")
346
424
 
347
425
 
426
+ def _start_lock_is_stale(path: Path) -> bool:
427
+ try:
428
+ age = max(0.0, time.time() - path.stat().st_mtime)
429
+ except OSError:
430
+ return True
431
+ try:
432
+ record = json.loads(path.read_text(encoding="utf-8"))
433
+ except (OSError, ValueError, UnicodeError):
434
+ return age >= _START_LOCK_INCOMPLETE_GRACE_SECONDS
435
+ owner = record.get("pid") if isinstance(record, dict) else None
436
+ if type(owner) is int and owner > 0:
437
+ return not _pid_alive(owner)
438
+ return age >= _START_LOCK_INCOMPLETE_GRACE_SECONDS
439
+
440
+
348
441
  @contextlib.contextmanager
349
- def _start_lock(root: str | Path):
442
+ def _start_lock(root: str | Path, timeout: float):
350
443
  path = _start_lock_path(root)
351
444
  token = secrets.token_urlsafe(24)
352
- deadline = time.monotonic() + 10
445
+ deadline = time.monotonic() + timeout
353
446
  while True:
354
447
  try:
355
448
  descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
@@ -361,14 +454,7 @@ def _start_lock(root: str | Path):
361
454
  os.close(descriptor)
362
455
  break
363
456
  except FileExistsError:
364
- try:
365
- record = json.loads(path.read_text(encoding="utf-8"))
366
- owner = record.get("pid") if isinstance(record, dict) else None
367
- started = record.get("started_at") if isinstance(record, dict) else None
368
- stale = type(owner) is not int or not _pid_alive(owner) or type(started) not in (int, float) or time.time() - started > 30
369
- except (OSError, ValueError, UnicodeError):
370
- stale = True
371
- if stale:
457
+ if _start_lock_is_stale(path):
372
458
  with contextlib.suppress(FileNotFoundError):
373
459
  path.unlink()
374
460
  continue
@@ -450,7 +536,12 @@ def _read_startup_tail(diagnostics: object, *, max_bytes: int = _STARTUP_DIAGNOS
450
536
  class _CacheService:
451
537
  def __init__(self, metadata: CacheMetadata) -> None:
452
538
  self.metadata = metadata
453
- self.context = AgentContext.open(metadata.root, metadata.project_file or None)
539
+ self.context = AgentContext.open(
540
+ metadata.root,
541
+ metadata.project_file or None,
542
+ workers=metadata.workers,
543
+ worker_memory_budget_bytes=metadata.max_memory_bytes,
544
+ )
454
545
  self.budget = CacheBudget(metadata.max_memory_bytes)
455
546
  self.stats = CacheStats()
456
547
  self.lock = threading.Lock()
@@ -459,9 +550,11 @@ class _CacheService:
459
550
  self.cache_state = "warming"
460
551
  self.last_revision = self.context.workspace.workspace_revision
461
552
  self.last_budget = BudgetResult(0, 0.0, 0.0, False, False, False)
553
+ self.prewarm_seconds = 0.0
462
554
  self.shutdown = threading.Event()
463
555
 
464
556
  def prewarm(self) -> None:
557
+ started = time.monotonic()
465
558
  try:
466
559
  self.context.handle({"action": "find", "query": "", "max_items": 1, "max_chars": 256})
467
560
  self.last_revision = self.context.workspace.workspace_revision
@@ -478,6 +571,8 @@ class _CacheService:
478
571
  if self.last_budget.compacted:
479
572
  self.stats.evictions += 1
480
573
  self.cache_state = "compact"
574
+ self.prewarm_seconds = time.monotonic() - started
575
+ self.stats.parallel_fallbacks = self.context.parallel_stats.fallbacks
481
576
 
482
577
  def request(self, request: dict[str, object]) -> CacheClientResponse:
483
578
  with self.lock:
@@ -506,6 +601,7 @@ class _CacheService:
506
601
  self.stats.warm_hits += 1
507
602
  else:
508
603
  self.stats.rebuilds += 1
604
+ self.stats.parallel_fallbacks += self.context.parallel_stats.fallbacks
509
605
  if before != after:
510
606
  self.stats.invalidations += 1
511
607
  self.last_budget = self.budget.enforce(
@@ -539,6 +635,12 @@ class _CacheService:
539
635
  "warning_active": self.last_budget.warning_active, "warning_threshold_percent": WARNING_THRESHOLD_PERCENT,
540
636
  "cache_state": self.cache_state, "requests": self.stats.requests, "warm_hits": self.stats.warm_hits,
541
637
  "rebuilds": self.stats.rebuilds, "invalidations": self.stats.invalidations, "evictions": self.stats.evictions,
638
+ "workers_configured": "auto" if self.metadata.workers == 0 else self.metadata.workers,
639
+ "workers_effective": self.context.parallel_stats.effective_workers,
640
+ "parallel_files_completed": self.context.parallel_stats.files_completed,
641
+ "prewarm_seconds": self.prewarm_seconds,
642
+ "parallel_seconds": self.context.parallel_stats.elapsed_seconds,
643
+ "parallel_fallbacks": self.stats.parallel_fallbacks,
542
644
  "idle_timeout": self.metadata.idle_timeout, "idle_remaining": max(0.0, self.metadata.idle_timeout - idle),
543
645
  "workspace_revision": self.context.workspace.workspace_revision,
544
646
  }
@@ -562,14 +664,33 @@ def _serve_connection(connection: socket.socket, service: _CacheService) -> None
562
664
  connection.sendall(json.dumps(response, separators=(",", ":")).encode("utf-8") + b"\n")
563
665
 
564
666
 
565
- def run_cache_daemon(root: str | Path, *, project_file: str = "", max_memory_bytes: int = DEFAULT_MAX_MEMORY_BYTES, idle_timeout: int = DEFAULT_IDLE_TIMEOUT) -> None:
667
+ def run_cache_daemon(
668
+ root: str | Path,
669
+ *,
670
+ project_file: str = "",
671
+ max_memory_bytes: int = DEFAULT_MAX_MEMORY_BYTES,
672
+ workers: int = 0,
673
+ idle_timeout: int = DEFAULT_IDLE_TIMEOUT,
674
+ ) -> None:
566
675
  canonical = str(Path(root).resolve())
567
676
  listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
568
677
  listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
569
678
  listener.bind(("127.0.0.1", 0))
570
679
  listener.listen(16)
571
680
  listener.settimeout(0.25)
572
- metadata = CacheMetadata(DAEMON_SCHEMA, canonical, os.getpid(), listener.getsockname()[1], secrets.token_urlsafe(32), __version__, project_file, max_memory_bytes, idle_timeout, time.time())
681
+ metadata = CacheMetadata(
682
+ DAEMON_SCHEMA,
683
+ canonical,
684
+ os.getpid(),
685
+ listener.getsockname()[1],
686
+ secrets.token_urlsafe(32),
687
+ __version__,
688
+ project_file,
689
+ max_memory_bytes,
690
+ workers,
691
+ idle_timeout,
692
+ time.time(),
693
+ )
573
694
  try:
574
695
  service = _CacheService(metadata)
575
696
  service.prewarm()
@@ -587,7 +708,15 @@ def run_cache_daemon(root: str | Path, *, project_file: str = "", max_memory_byt
587
708
  _remove_metadata_if_owned(metadata)
588
709
 
589
710
 
590
- def _start_cache_unlocked(root: str | Path, *, project_file: str | Path | None = None, max_memory_bytes: int = DEFAULT_MAX_MEMORY_BYTES, idle_timeout: int = DEFAULT_IDLE_TIMEOUT) -> CacheMetadata:
711
+ def _start_cache_unlocked(
712
+ root: str | Path,
713
+ *,
714
+ project_file: str | Path | None = None,
715
+ max_memory_bytes: int = DEFAULT_MAX_MEMORY_BYTES,
716
+ workers: int = 0,
717
+ idle_timeout: int = DEFAULT_IDLE_TIMEOUT,
718
+ startup_timeout: float = DEFAULT_STARTUP_TIMEOUT,
719
+ ) -> CacheMetadata:
591
720
  canonical = str(Path(root).resolve())
592
721
  project = str((Path(canonical) / project_file).resolve()) if project_file and not Path(project_file).is_absolute() else (str(Path(project_file).resolve()) if project_file else "")
593
722
  existing = _read_metadata(canonical)
@@ -596,12 +725,32 @@ def _start_cache_unlocked(root: str | Path, *, project_file: str | Path | None =
596
725
  _client_exchange(existing, {"action": "status", "_startup_probe": True})
597
726
  except CacheClientError as error:
598
727
  raise CacheClientError("unavailable", "Live cache daemon is unavailable.") from error
599
- if (existing.project_file, existing.max_memory_bytes, existing.idle_timeout) != (project, max_memory_bytes, idle_timeout):
728
+ if (existing.project_file, existing.max_memory_bytes, existing.workers, existing.idle_timeout) != (
729
+ project,
730
+ max_memory_bytes,
731
+ workers,
732
+ idle_timeout,
733
+ ):
600
734
  raise CacheClientError("configuration_conflict", "A live cache daemon has conflicting configuration.")
601
735
  return existing
602
736
  if existing:
603
737
  _remove_metadata_if_owned(existing)
604
- command = [sys.executable, "-m", "delphi_lsp.agent_cache", "serve", "--root", canonical, "--max-memory", str(max_memory_bytes), "--idle-timeout", str(idle_timeout)]
738
+ else:
739
+ _remove_incompatible_metadata_if_stale(canonical)
740
+ command = [
741
+ sys.executable,
742
+ "-m",
743
+ "delphi_lsp.agent_cache",
744
+ "serve",
745
+ "--root",
746
+ canonical,
747
+ "--max-memory",
748
+ str(max_memory_bytes),
749
+ "--workers",
750
+ str(workers),
751
+ "--idle-timeout",
752
+ str(idle_timeout),
753
+ ]
605
754
  if project:
606
755
  command.extend(("--project-file", project))
607
756
  options: dict[str, object] = {"stdin": subprocess.DEVNULL, "stdout": subprocess.DEVNULL}
@@ -612,7 +761,7 @@ def _start_cache_unlocked(root: str | Path, *, project_file: str | Path | None =
612
761
  with tempfile.TemporaryFile() as diagnostics:
613
762
  options["stderr"] = diagnostics
614
763
  process = subprocess.Popen(command, **options)
615
- deadline = time.monotonic() + 10
764
+ deadline = time.monotonic() + startup_timeout
616
765
  startup_ready = False
617
766
  try:
618
767
  while time.monotonic() < deadline:
@@ -649,14 +798,34 @@ def _start_cache_unlocked(root: str | Path, *, project_file: str | Path | None =
649
798
  process.wait()
650
799
 
651
800
 
652
- def start_cache(root: str | Path, *, project_file: str | Path | None = None, max_memory_bytes: int = DEFAULT_MAX_MEMORY_BYTES, idle_timeout: int = DEFAULT_IDLE_TIMEOUT) -> CacheMetadata:
653
- with _start_lock(root):
654
- return _start_cache_unlocked(root, project_file=project_file, max_memory_bytes=max_memory_bytes, idle_timeout=idle_timeout)
801
+ def start_cache(
802
+ root: str | Path,
803
+ *,
804
+ project_file: str | Path | None = None,
805
+ max_memory_bytes: int = DEFAULT_MAX_MEMORY_BYTES,
806
+ workers: int = 0,
807
+ idle_timeout: int = DEFAULT_IDLE_TIMEOUT,
808
+ startup_timeout: float = DEFAULT_STARTUP_TIMEOUT,
809
+ ) -> CacheMetadata:
810
+ if type(workers) is not int or not 0 <= workers <= 32:
811
+ raise ValueError("workers must be auto or an integer from 1 through 32.")
812
+ if not math.isfinite(startup_timeout) or startup_timeout <= 0:
813
+ raise ValueError("startup_timeout must be greater than zero.")
814
+ with _start_lock(root, startup_timeout):
815
+ return _start_cache_unlocked(
816
+ root,
817
+ project_file=project_file,
818
+ max_memory_bytes=max_memory_bytes,
819
+ workers=workers,
820
+ idle_timeout=idle_timeout,
821
+ startup_timeout=startup_timeout,
822
+ )
655
823
 
656
824
 
657
825
  def query_cache(root: str | Path, request: dict[str, object]) -> CacheClientResponse:
658
826
  metadata = _read_metadata(root)
659
827
  if metadata is None:
828
+ _remove_incompatible_metadata_if_stale(root)
660
829
  raise CacheClientError("cache_not_running", "Cache daemon is not running.")
661
830
  if not _pid_alive(metadata.pid):
662
831
  _remove_metadata_if_owned(metadata)
@@ -671,6 +840,7 @@ def cache_status(root: str | Path) -> dict[str, object]:
671
840
  def stop_cache(root: str | Path) -> None:
672
841
  metadata = _read_metadata(root)
673
842
  if metadata is None:
843
+ _remove_incompatible_metadata_if_stale(root)
674
844
  return
675
845
  if not _pid_alive(metadata.pid):
676
846
  _remove_metadata_if_owned(metadata)
@@ -681,11 +851,13 @@ def stop_cache(root: str | Path) -> None:
681
851
  pass
682
852
  deadline = time.monotonic() + 3
683
853
  stopped = False
684
- while _pid_alive(metadata.pid) and time.monotonic() < deadline:
685
- with contextlib.suppress(ChildProcessError):
686
- if os.waitpid(metadata.pid, os.WNOHANG)[0] == metadata.pid:
687
- stopped = True
688
- break
854
+ while time.monotonic() < deadline:
855
+ if _reap_child_if_exited(metadata.pid):
856
+ stopped = True
857
+ break
858
+ if not _pid_alive(metadata.pid):
859
+ stopped = True
860
+ break
689
861
  time.sleep(0.05)
690
862
  if not stopped and _pid_alive(metadata.pid):
691
863
  raise CacheClientError("stop_failed", "Cache daemon did not stop.")
@@ -699,10 +871,17 @@ def main(argv: list[str] | None = None) -> int:
699
871
  serve.add_argument("--root", required=True)
700
872
  serve.add_argument("--project-file", default="")
701
873
  serve.add_argument("--max-memory", type=int, default=DEFAULT_MAX_MEMORY_BYTES)
874
+ serve.add_argument("--workers", type=int, default=0)
702
875
  serve.add_argument("--idle-timeout", type=int, default=DEFAULT_IDLE_TIMEOUT)
703
876
  args = parser.parse_args(argv)
704
877
  if args.command == "serve":
705
- run_cache_daemon(args.root, project_file=args.project_file, max_memory_bytes=args.max_memory, idle_timeout=args.idle_timeout)
878
+ run_cache_daemon(
879
+ args.root,
880
+ project_file=args.project_file,
881
+ max_memory_bytes=args.max_memory,
882
+ workers=args.workers,
883
+ idle_timeout=args.idle_timeout,
884
+ )
706
885
  return 0
707
886
 
708
887