python-delphi-lsp 2.0.5__py3-none-any.whl → 2.2.0__py3-none-any.whl
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.
- delphi_lsp/_version.py +1 -1
- delphi_lsp/agent_cache.py +257 -38
- delphi_lsp/agent_cli.py +44 -5
- delphi_lsp/agent_context.py +385 -50
- delphi_lsp/agent_layers.py +32 -21
- delphi_lsp/agent_relations.py +28 -3
- delphi_lsp/agent_workspace.py +35 -0
- delphi_lsp/delphiast_lexer.py +284 -0
- delphi_lsp/delphiast_parser.py +1498 -0
- delphi_lsp/delphiast_tokens.py +41 -0
- delphi_lsp/metrics.py +35 -1
- delphi_lsp/parallel_outline.py +317 -0
- delphi_lsp/parser.py +54 -4
- delphi_lsp/parser_backend.py +57 -0
- delphi_lsp/project_indexer.py +32 -1
- {python_delphi_lsp-2.0.5.dist-info → python_delphi_lsp-2.2.0.dist-info}/METADATA +40 -4
- {python_delphi_lsp-2.0.5.dist-info → python_delphi_lsp-2.2.0.dist-info}/RECORD +21 -16
- {python_delphi_lsp-2.0.5.dist-info → python_delphi_lsp-2.2.0.dist-info}/WHEEL +0 -0
- {python_delphi_lsp-2.0.5.dist-info → python_delphi_lsp-2.2.0.dist-info}/entry_points.txt +0 -0
- {python_delphi_lsp-2.0.5.dist-info → python_delphi_lsp-2.2.0.dist-info}/licenses/LICENSE +0 -0
- {python_delphi_lsp-2.0.5.dist-info → python_delphi_lsp-2.2.0.dist-info}/top_level.txt +0 -0
delphi_lsp/_version.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "2.0
|
|
1
|
+
__version__ = "2.2.0"
|
delphi_lsp/agent_cache.py
CHANGED
|
@@ -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
|
|
@@ -19,6 +20,8 @@ import threading
|
|
|
19
20
|
import time
|
|
20
21
|
from types import ModuleType
|
|
21
22
|
|
|
23
|
+
from watchfiles import watch
|
|
24
|
+
|
|
22
25
|
from ._version import __version__
|
|
23
26
|
from .agent_context import AgentContext
|
|
24
27
|
from .agent_protocol import AgentProtocolError
|
|
@@ -26,13 +29,20 @@ from .agent_protocol import AgentProtocolError
|
|
|
26
29
|
|
|
27
30
|
DEFAULT_MAX_MEMORY_BYTES = 512 * 1024**2
|
|
28
31
|
WARNING_THRESHOLD_PERCENT = 80
|
|
29
|
-
DAEMON_SCHEMA =
|
|
32
|
+
DAEMON_SCHEMA = 2
|
|
30
33
|
DEFAULT_IDLE_TIMEOUT = 1800
|
|
34
|
+
DEFAULT_STARTUP_TIMEOUT = 120.0
|
|
31
35
|
_MAX_MESSAGE_BYTES = 1024 * 1024
|
|
32
36
|
_CONNECTION_TIMEOUT = 2.0
|
|
37
|
+
_CLIENT_RESPONSE_TIMEOUT = 120.0
|
|
33
38
|
_MEMORY_SIZE = re.compile(r"^(?P<count>[1-9][0-9]*)(?P<suffix>[KMG]?)$", re.IGNORECASE)
|
|
34
39
|
_STARTUP_DIAGNOSTIC_BYTES = 16 * 1024
|
|
35
40
|
_STARTUP_TOKEN_RE = re.compile(r"(?i)(token\b[^\n\r]*?:?\s*['\"]?[A-Za-z0-9_-]+['\"]?|\b[a-zA-Z0-9_-]{32,})")
|
|
41
|
+
_START_LOCK_INCOMPLETE_GRACE_SECONDS = 1.0
|
|
42
|
+
_CACHE_REVISION_CHECK_INTERVAL_SECONDS = 3600.0
|
|
43
|
+
_WATCHED_SUFFIXES = frozenset(
|
|
44
|
+
{".pas", ".pp", ".inc", ".dpr", ".dpk", ".dproj", ".cfg"}
|
|
45
|
+
)
|
|
36
46
|
|
|
37
47
|
|
|
38
48
|
def estimate_deep_size(value: object) -> int:
|
|
@@ -72,6 +82,7 @@ class CacheStats:
|
|
|
72
82
|
rebuilds: int = 0
|
|
73
83
|
invalidations: int = 0
|
|
74
84
|
evictions: int = 0
|
|
85
|
+
parallel_fallbacks: int = 0
|
|
75
86
|
|
|
76
87
|
|
|
77
88
|
@dataclass(frozen=True)
|
|
@@ -232,6 +243,7 @@ class CacheMetadata:
|
|
|
232
243
|
version: str
|
|
233
244
|
project_file: str
|
|
234
245
|
max_memory_bytes: int
|
|
246
|
+
workers: int
|
|
235
247
|
idle_timeout: int
|
|
236
248
|
started_at: float
|
|
237
249
|
|
|
@@ -291,7 +303,7 @@ def _write_metadata(metadata: CacheMetadata) -> None:
|
|
|
291
303
|
os.unlink(temporary)
|
|
292
304
|
|
|
293
305
|
|
|
294
|
-
def
|
|
306
|
+
def _read_metadata_record(root: str | Path) -> object | None:
|
|
295
307
|
path = _safe_metadata_path(root)
|
|
296
308
|
if not path.exists():
|
|
297
309
|
return None
|
|
@@ -309,15 +321,36 @@ def _read_metadata(root: str | Path) -> CacheMetadata | None:
|
|
|
309
321
|
raise
|
|
310
322
|
except (OSError, ValueError, UnicodeError):
|
|
311
323
|
return None
|
|
324
|
+
return raw
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _read_metadata(root: str | Path) -> CacheMetadata | None:
|
|
328
|
+
raw = _read_metadata_record(root)
|
|
312
329
|
required = {field.name for field in fields(CacheMetadata)}
|
|
313
330
|
if not isinstance(raw, dict) or set(raw) != required:
|
|
314
331
|
return None
|
|
315
|
-
values = tuple(
|
|
332
|
+
values = tuple(
|
|
333
|
+
raw[name]
|
|
334
|
+
for name in (
|
|
335
|
+
"schema",
|
|
336
|
+
"root",
|
|
337
|
+
"pid",
|
|
338
|
+
"port",
|
|
339
|
+
"token",
|
|
340
|
+
"version",
|
|
341
|
+
"project_file",
|
|
342
|
+
"max_memory_bytes",
|
|
343
|
+
"workers",
|
|
344
|
+
"idle_timeout",
|
|
345
|
+
"started_at",
|
|
346
|
+
)
|
|
347
|
+
)
|
|
316
348
|
if (type(values[0]) is not int or values[0] != DAEMON_SCHEMA or not isinstance(values[1], str)
|
|
317
349
|
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
350
|
or not isinstance(values[4], str) or len(values[4]) < 32 or not isinstance(values[5], str)
|
|
319
351
|
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
|
|
352
|
+
or type(values[8]) is not int or not 0 <= values[8] <= 32
|
|
353
|
+
or type(values[9]) is not int or values[9] <= 0 or type(values[10]) not in (int, float)):
|
|
321
354
|
return None
|
|
322
355
|
canonical = str(Path(root).resolve())
|
|
323
356
|
if values[1] != canonical:
|
|
@@ -325,15 +358,56 @@ def _read_metadata(root: str | Path) -> CacheMetadata | None:
|
|
|
325
358
|
return CacheMetadata(*values)
|
|
326
359
|
|
|
327
360
|
|
|
361
|
+
def _remove_incompatible_metadata_if_stale(root: str | Path) -> None:
|
|
362
|
+
path = _safe_metadata_path(root)
|
|
363
|
+
if not path.exists():
|
|
364
|
+
return
|
|
365
|
+
raw = _read_metadata_record(root)
|
|
366
|
+
canonical = str(Path(root).resolve())
|
|
367
|
+
pid = raw.get("pid") if isinstance(raw, dict) and raw.get("root") == canonical else None
|
|
368
|
+
if type(pid) is int and pid > 0 and _pid_alive(pid):
|
|
369
|
+
raise CacheClientError("unavailable", "Live cache daemon is unavailable.")
|
|
370
|
+
with contextlib.suppress(FileNotFoundError):
|
|
371
|
+
path.unlink()
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _metadata_is_owned(metadata: CacheMetadata) -> bool:
|
|
375
|
+
current = _read_metadata(metadata.root)
|
|
376
|
+
return current is not None and current.pid == metadata.pid and hmac.compare_digest(current.token, metadata.token)
|
|
377
|
+
|
|
378
|
+
|
|
328
379
|
def _remove_metadata_if_owned(metadata: CacheMetadata) -> None:
|
|
329
380
|
path = _safe_metadata_path(metadata.root)
|
|
330
|
-
|
|
331
|
-
if current is not None and current.pid == metadata.pid and hmac.compare_digest(current.token, metadata.token):
|
|
381
|
+
if _metadata_is_owned(metadata):
|
|
332
382
|
with contextlib.suppress(FileNotFoundError):
|
|
333
383
|
path.unlink()
|
|
334
384
|
|
|
335
385
|
|
|
386
|
+
def _windows_pid_alive(pid: int) -> bool:
|
|
387
|
+
import ctypes
|
|
388
|
+
from ctypes import wintypes
|
|
389
|
+
|
|
390
|
+
synchronize = 0x00100000
|
|
391
|
+
wait_timeout = 0x00000102
|
|
392
|
+
kernel32 = ctypes.windll.kernel32
|
|
393
|
+
kernel32.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
|
|
394
|
+
kernel32.OpenProcess.restype = wintypes.HANDLE
|
|
395
|
+
kernel32.WaitForSingleObject.argtypes = (wintypes.HANDLE, wintypes.DWORD)
|
|
396
|
+
kernel32.WaitForSingleObject.restype = wintypes.DWORD
|
|
397
|
+
kernel32.CloseHandle.argtypes = (wintypes.HANDLE,)
|
|
398
|
+
kernel32.CloseHandle.restype = wintypes.BOOL
|
|
399
|
+
handle = kernel32.OpenProcess(synchronize, False, pid)
|
|
400
|
+
if not handle:
|
|
401
|
+
return False
|
|
402
|
+
try:
|
|
403
|
+
return kernel32.WaitForSingleObject(handle, 0) == wait_timeout
|
|
404
|
+
finally:
|
|
405
|
+
kernel32.CloseHandle(handle)
|
|
406
|
+
|
|
407
|
+
|
|
336
408
|
def _pid_alive(pid: int) -> bool:
|
|
409
|
+
if os.name == "nt":
|
|
410
|
+
return _windows_pid_alive(pid)
|
|
337
411
|
try:
|
|
338
412
|
os.kill(pid, 0)
|
|
339
413
|
except OSError:
|
|
@@ -341,15 +415,41 @@ def _pid_alive(pid: int) -> bool:
|
|
|
341
415
|
return True
|
|
342
416
|
|
|
343
417
|
|
|
418
|
+
def _reap_child_if_exited(pid: int) -> bool:
|
|
419
|
+
waitpid = getattr(os, "waitpid", None)
|
|
420
|
+
nohang = getattr(os, "WNOHANG", None)
|
|
421
|
+
if not callable(waitpid) or nohang is None:
|
|
422
|
+
return False
|
|
423
|
+
try:
|
|
424
|
+
return waitpid(pid, nohang)[0] == pid
|
|
425
|
+
except (ChildProcessError, OSError):
|
|
426
|
+
return False
|
|
427
|
+
|
|
428
|
+
|
|
344
429
|
def _start_lock_path(root: str | Path) -> Path:
|
|
345
430
|
return _safe_metadata_path(root, create=True).with_name("start.lock")
|
|
346
431
|
|
|
347
432
|
|
|
433
|
+
def _start_lock_is_stale(path: Path) -> bool:
|
|
434
|
+
try:
|
|
435
|
+
age = max(0.0, time.time() - path.stat().st_mtime)
|
|
436
|
+
except OSError:
|
|
437
|
+
return True
|
|
438
|
+
try:
|
|
439
|
+
record = json.loads(path.read_text(encoding="utf-8"))
|
|
440
|
+
except (OSError, ValueError, UnicodeError):
|
|
441
|
+
return age >= _START_LOCK_INCOMPLETE_GRACE_SECONDS
|
|
442
|
+
owner = record.get("pid") if isinstance(record, dict) else None
|
|
443
|
+
if type(owner) is int and owner > 0:
|
|
444
|
+
return not _pid_alive(owner)
|
|
445
|
+
return age >= _START_LOCK_INCOMPLETE_GRACE_SECONDS
|
|
446
|
+
|
|
447
|
+
|
|
348
448
|
@contextlib.contextmanager
|
|
349
|
-
def _start_lock(root: str | Path):
|
|
449
|
+
def _start_lock(root: str | Path, timeout: float):
|
|
350
450
|
path = _start_lock_path(root)
|
|
351
451
|
token = secrets.token_urlsafe(24)
|
|
352
|
-
deadline = time.monotonic() +
|
|
452
|
+
deadline = time.monotonic() + timeout
|
|
353
453
|
while True:
|
|
354
454
|
try:
|
|
355
455
|
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
@@ -361,14 +461,7 @@ def _start_lock(root: str | Path):
|
|
|
361
461
|
os.close(descriptor)
|
|
362
462
|
break
|
|
363
463
|
except FileExistsError:
|
|
364
|
-
|
|
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:
|
|
464
|
+
if _start_lock_is_stale(path):
|
|
372
465
|
with contextlib.suppress(FileNotFoundError):
|
|
373
466
|
path.unlink()
|
|
374
467
|
continue
|
|
@@ -389,7 +482,7 @@ def _start_lock(root: str | Path):
|
|
|
389
482
|
def _client_exchange(metadata: CacheMetadata, request: dict[str, object]) -> CacheClientResponse:
|
|
390
483
|
try:
|
|
391
484
|
with socket.create_connection(("127.0.0.1", metadata.port), timeout=2) as connection:
|
|
392
|
-
connection.settimeout(
|
|
485
|
+
connection.settimeout(_CLIENT_RESPONSE_TIMEOUT)
|
|
393
486
|
request_without_token = {key: value for key, value in request.items() if key != "token"}
|
|
394
487
|
connection.sendall(json.dumps({"token": metadata.token, **request_without_token}, separators=(",", ":")).encode("utf-8") + b"\n")
|
|
395
488
|
response = _read_line(connection)
|
|
@@ -450,7 +543,13 @@ def _read_startup_tail(diagnostics: object, *, max_bytes: int = _STARTUP_DIAGNOS
|
|
|
450
543
|
class _CacheService:
|
|
451
544
|
def __init__(self, metadata: CacheMetadata) -> None:
|
|
452
545
|
self.metadata = metadata
|
|
453
|
-
self.context = AgentContext.open(
|
|
546
|
+
self.context = AgentContext.open(
|
|
547
|
+
metadata.root,
|
|
548
|
+
metadata.project_file or None,
|
|
549
|
+
workers=metadata.workers,
|
|
550
|
+
worker_memory_budget_bytes=metadata.max_memory_bytes,
|
|
551
|
+
revision_check_interval_seconds=_CACHE_REVISION_CHECK_INTERVAL_SECONDS,
|
|
552
|
+
)
|
|
454
553
|
self.budget = CacheBudget(metadata.max_memory_bytes)
|
|
455
554
|
self.stats = CacheStats()
|
|
456
555
|
self.lock = threading.Lock()
|
|
@@ -459,25 +558,28 @@ class _CacheService:
|
|
|
459
558
|
self.cache_state = "warming"
|
|
460
559
|
self.last_revision = self.context.workspace.workspace_revision
|
|
461
560
|
self.last_budget = BudgetResult(0, 0.0, 0.0, False, False, False)
|
|
561
|
+
self.prewarm_seconds = 0.0
|
|
462
562
|
self.shutdown = threading.Event()
|
|
463
563
|
|
|
464
564
|
def prewarm(self) -> None:
|
|
565
|
+
started = time.monotonic()
|
|
465
566
|
try:
|
|
466
|
-
self.context.
|
|
467
|
-
self.last_revision = self.context.workspace.workspace_revision
|
|
567
|
+
self.last_revision = self.context.prewarm_navigation()
|
|
468
568
|
self.cache_state = "warm"
|
|
469
569
|
except AgentProtocolError as error:
|
|
470
570
|
if error.code != "project_required":
|
|
471
571
|
raise
|
|
472
572
|
self.cache_state = "ready"
|
|
473
573
|
self.last_budget = self.budget.enforce(
|
|
474
|
-
measure=lambda:
|
|
574
|
+
measure=lambda: self.context.estimated_cache_bytes,
|
|
475
575
|
evict_auxiliary=self.context.evict_auxiliary_caches,
|
|
476
576
|
evict_navigation=self.context.evict_navigation_caches,
|
|
477
577
|
)
|
|
478
578
|
if self.last_budget.compacted:
|
|
479
579
|
self.stats.evictions += 1
|
|
480
580
|
self.cache_state = "compact"
|
|
581
|
+
self.prewarm_seconds = time.monotonic() - started
|
|
582
|
+
self.stats.parallel_fallbacks = self.context.parallel_stats.fallbacks
|
|
481
583
|
|
|
482
584
|
def request(self, request: dict[str, object]) -> CacheClientResponse:
|
|
483
585
|
with self.lock:
|
|
@@ -506,10 +608,11 @@ class _CacheService:
|
|
|
506
608
|
self.stats.warm_hits += 1
|
|
507
609
|
else:
|
|
508
610
|
self.stats.rebuilds += 1
|
|
611
|
+
self.stats.parallel_fallbacks += self.context.parallel_stats.fallbacks
|
|
509
612
|
if before != after:
|
|
510
613
|
self.stats.invalidations += 1
|
|
511
614
|
self.last_budget = self.budget.enforce(
|
|
512
|
-
measure=lambda:
|
|
615
|
+
measure=lambda: self.context.estimated_cache_bytes,
|
|
513
616
|
evict_auxiliary=self.context.evict_auxiliary_caches,
|
|
514
617
|
evict_navigation=self.context.evict_navigation_caches,
|
|
515
618
|
)
|
|
@@ -539,11 +642,38 @@ class _CacheService:
|
|
|
539
642
|
"warning_active": self.last_budget.warning_active, "warning_threshold_percent": WARNING_THRESHOLD_PERCENT,
|
|
540
643
|
"cache_state": self.cache_state, "requests": self.stats.requests, "warm_hits": self.stats.warm_hits,
|
|
541
644
|
"rebuilds": self.stats.rebuilds, "invalidations": self.stats.invalidations, "evictions": self.stats.evictions,
|
|
645
|
+
"workers_configured": "auto" if self.metadata.workers == 0 else self.metadata.workers,
|
|
646
|
+
"workers_effective": self.context.parallel_stats.effective_workers,
|
|
647
|
+
"parallel_files_completed": self.context.parallel_stats.files_completed,
|
|
648
|
+
"prewarm_seconds": self.prewarm_seconds,
|
|
649
|
+
"parallel_seconds": self.context.parallel_stats.elapsed_seconds,
|
|
650
|
+
"parallel_fallbacks": self.stats.parallel_fallbacks,
|
|
542
651
|
"idle_timeout": self.metadata.idle_timeout, "idle_remaining": max(0.0, self.metadata.idle_timeout - idle),
|
|
543
|
-
"workspace_revision": self.
|
|
652
|
+
"workspace_revision": self.last_revision,
|
|
544
653
|
}
|
|
545
654
|
|
|
546
655
|
|
|
656
|
+
def _watch_filter(_change: object, path: str) -> bool:
|
|
657
|
+
return Path(path).suffix.casefold() in _WATCHED_SUFFIXES
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _watch_workspace(service: _CacheService) -> None:
|
|
661
|
+
try:
|
|
662
|
+
for changes in watch(
|
|
663
|
+
service.metadata.root,
|
|
664
|
+
watch_filter=_watch_filter,
|
|
665
|
+
stop_event=service.shutdown,
|
|
666
|
+
debounce=50,
|
|
667
|
+
step=20,
|
|
668
|
+
recursive=True,
|
|
669
|
+
raise_interrupt=False,
|
|
670
|
+
):
|
|
671
|
+
if changes:
|
|
672
|
+
service.context.invalidate_revision_cache()
|
|
673
|
+
except (OSError, RuntimeError):
|
|
674
|
+
service.context.invalidate_revision_cache()
|
|
675
|
+
|
|
676
|
+
|
|
547
677
|
def _serve_connection(connection: socket.socket, service: _CacheService) -> None:
|
|
548
678
|
try:
|
|
549
679
|
line = _read_line(connection)
|
|
@@ -562,17 +692,44 @@ def _serve_connection(connection: socket.socket, service: _CacheService) -> None
|
|
|
562
692
|
connection.sendall(json.dumps(response, separators=(",", ":")).encode("utf-8") + b"\n")
|
|
563
693
|
|
|
564
694
|
|
|
565
|
-
def run_cache_daemon(
|
|
695
|
+
def run_cache_daemon(
|
|
696
|
+
root: str | Path,
|
|
697
|
+
*,
|
|
698
|
+
project_file: str = "",
|
|
699
|
+
max_memory_bytes: int = DEFAULT_MAX_MEMORY_BYTES,
|
|
700
|
+
workers: int = 0,
|
|
701
|
+
idle_timeout: int = DEFAULT_IDLE_TIMEOUT,
|
|
702
|
+
) -> None:
|
|
566
703
|
canonical = str(Path(root).resolve())
|
|
567
704
|
listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
568
705
|
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
569
706
|
listener.bind(("127.0.0.1", 0))
|
|
570
707
|
listener.listen(16)
|
|
571
708
|
listener.settimeout(0.25)
|
|
572
|
-
metadata = CacheMetadata(
|
|
709
|
+
metadata = CacheMetadata(
|
|
710
|
+
DAEMON_SCHEMA,
|
|
711
|
+
canonical,
|
|
712
|
+
os.getpid(),
|
|
713
|
+
listener.getsockname()[1],
|
|
714
|
+
secrets.token_urlsafe(32),
|
|
715
|
+
__version__,
|
|
716
|
+
project_file,
|
|
717
|
+
max_memory_bytes,
|
|
718
|
+
workers,
|
|
719
|
+
idle_timeout,
|
|
720
|
+
time.time(),
|
|
721
|
+
)
|
|
722
|
+
watcher: threading.Thread | None = None
|
|
573
723
|
try:
|
|
574
724
|
service = _CacheService(metadata)
|
|
575
725
|
service.prewarm()
|
|
726
|
+
watcher = threading.Thread(
|
|
727
|
+
target=_watch_workspace,
|
|
728
|
+
args=(service,),
|
|
729
|
+
name="delphi-cache-watcher",
|
|
730
|
+
daemon=True,
|
|
731
|
+
)
|
|
732
|
+
watcher.start()
|
|
576
733
|
_write_metadata(metadata)
|
|
577
734
|
while not service.shutdown.is_set() and time.monotonic() - service.last_activity < idle_timeout:
|
|
578
735
|
try:
|
|
@@ -583,11 +740,23 @@ def run_cache_daemon(root: str | Path, *, project_file: str = "", max_memory_byt
|
|
|
583
740
|
connection.settimeout(_CONNECTION_TIMEOUT)
|
|
584
741
|
_serve_connection(connection, service)
|
|
585
742
|
finally:
|
|
743
|
+
if "service" in locals():
|
|
744
|
+
service.shutdown.set()
|
|
745
|
+
if watcher is not None:
|
|
746
|
+
watcher.join(timeout=2.0)
|
|
586
747
|
listener.close()
|
|
587
748
|
_remove_metadata_if_owned(metadata)
|
|
588
749
|
|
|
589
750
|
|
|
590
|
-
def _start_cache_unlocked(
|
|
751
|
+
def _start_cache_unlocked(
|
|
752
|
+
root: str | Path,
|
|
753
|
+
*,
|
|
754
|
+
project_file: str | Path | None = None,
|
|
755
|
+
max_memory_bytes: int = DEFAULT_MAX_MEMORY_BYTES,
|
|
756
|
+
workers: int = 0,
|
|
757
|
+
idle_timeout: int = DEFAULT_IDLE_TIMEOUT,
|
|
758
|
+
startup_timeout: float = DEFAULT_STARTUP_TIMEOUT,
|
|
759
|
+
) -> CacheMetadata:
|
|
591
760
|
canonical = str(Path(root).resolve())
|
|
592
761
|
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
762
|
existing = _read_metadata(canonical)
|
|
@@ -596,12 +765,32 @@ def _start_cache_unlocked(root: str | Path, *, project_file: str | Path | None =
|
|
|
596
765
|
_client_exchange(existing, {"action": "status", "_startup_probe": True})
|
|
597
766
|
except CacheClientError as error:
|
|
598
767
|
raise CacheClientError("unavailable", "Live cache daemon is unavailable.") from error
|
|
599
|
-
if (existing.project_file, existing.max_memory_bytes, existing.idle_timeout) != (
|
|
768
|
+
if (existing.project_file, existing.max_memory_bytes, existing.workers, existing.idle_timeout) != (
|
|
769
|
+
project,
|
|
770
|
+
max_memory_bytes,
|
|
771
|
+
workers,
|
|
772
|
+
idle_timeout,
|
|
773
|
+
):
|
|
600
774
|
raise CacheClientError("configuration_conflict", "A live cache daemon has conflicting configuration.")
|
|
601
775
|
return existing
|
|
602
776
|
if existing:
|
|
603
777
|
_remove_metadata_if_owned(existing)
|
|
604
|
-
|
|
778
|
+
else:
|
|
779
|
+
_remove_incompatible_metadata_if_stale(canonical)
|
|
780
|
+
command = [
|
|
781
|
+
sys.executable,
|
|
782
|
+
"-m",
|
|
783
|
+
"delphi_lsp.agent_cache",
|
|
784
|
+
"serve",
|
|
785
|
+
"--root",
|
|
786
|
+
canonical,
|
|
787
|
+
"--max-memory",
|
|
788
|
+
str(max_memory_bytes),
|
|
789
|
+
"--workers",
|
|
790
|
+
str(workers),
|
|
791
|
+
"--idle-timeout",
|
|
792
|
+
str(idle_timeout),
|
|
793
|
+
]
|
|
605
794
|
if project:
|
|
606
795
|
command.extend(("--project-file", project))
|
|
607
796
|
options: dict[str, object] = {"stdin": subprocess.DEVNULL, "stdout": subprocess.DEVNULL}
|
|
@@ -612,7 +801,7 @@ def _start_cache_unlocked(root: str | Path, *, project_file: str | Path | None =
|
|
|
612
801
|
with tempfile.TemporaryFile() as diagnostics:
|
|
613
802
|
options["stderr"] = diagnostics
|
|
614
803
|
process = subprocess.Popen(command, **options)
|
|
615
|
-
deadline = time.monotonic() +
|
|
804
|
+
deadline = time.monotonic() + startup_timeout
|
|
616
805
|
startup_ready = False
|
|
617
806
|
try:
|
|
618
807
|
while time.monotonic() < deadline:
|
|
@@ -649,14 +838,34 @@ def _start_cache_unlocked(root: str | Path, *, project_file: str | Path | None =
|
|
|
649
838
|
process.wait()
|
|
650
839
|
|
|
651
840
|
|
|
652
|
-
def start_cache(
|
|
653
|
-
|
|
654
|
-
|
|
841
|
+
def start_cache(
|
|
842
|
+
root: str | Path,
|
|
843
|
+
*,
|
|
844
|
+
project_file: str | Path | None = None,
|
|
845
|
+
max_memory_bytes: int = DEFAULT_MAX_MEMORY_BYTES,
|
|
846
|
+
workers: int = 0,
|
|
847
|
+
idle_timeout: int = DEFAULT_IDLE_TIMEOUT,
|
|
848
|
+
startup_timeout: float = DEFAULT_STARTUP_TIMEOUT,
|
|
849
|
+
) -> CacheMetadata:
|
|
850
|
+
if type(workers) is not int or not 0 <= workers <= 32:
|
|
851
|
+
raise ValueError("workers must be auto or an integer from 1 through 32.")
|
|
852
|
+
if not math.isfinite(startup_timeout) or startup_timeout <= 0:
|
|
853
|
+
raise ValueError("startup_timeout must be greater than zero.")
|
|
854
|
+
with _start_lock(root, startup_timeout):
|
|
855
|
+
return _start_cache_unlocked(
|
|
856
|
+
root,
|
|
857
|
+
project_file=project_file,
|
|
858
|
+
max_memory_bytes=max_memory_bytes,
|
|
859
|
+
workers=workers,
|
|
860
|
+
idle_timeout=idle_timeout,
|
|
861
|
+
startup_timeout=startup_timeout,
|
|
862
|
+
)
|
|
655
863
|
|
|
656
864
|
|
|
657
865
|
def query_cache(root: str | Path, request: dict[str, object]) -> CacheClientResponse:
|
|
658
866
|
metadata = _read_metadata(root)
|
|
659
867
|
if metadata is None:
|
|
868
|
+
_remove_incompatible_metadata_if_stale(root)
|
|
660
869
|
raise CacheClientError("cache_not_running", "Cache daemon is not running.")
|
|
661
870
|
if not _pid_alive(metadata.pid):
|
|
662
871
|
_remove_metadata_if_owned(metadata)
|
|
@@ -671,6 +880,7 @@ def cache_status(root: str | Path) -> dict[str, object]:
|
|
|
671
880
|
def stop_cache(root: str | Path) -> None:
|
|
672
881
|
metadata = _read_metadata(root)
|
|
673
882
|
if metadata is None:
|
|
883
|
+
_remove_incompatible_metadata_if_stale(root)
|
|
674
884
|
return
|
|
675
885
|
if not _pid_alive(metadata.pid):
|
|
676
886
|
_remove_metadata_if_owned(metadata)
|
|
@@ -681,11 +891,13 @@ def stop_cache(root: str | Path) -> None:
|
|
|
681
891
|
pass
|
|
682
892
|
deadline = time.monotonic() + 3
|
|
683
893
|
stopped = False
|
|
684
|
-
while
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
894
|
+
while time.monotonic() < deadline:
|
|
895
|
+
if _reap_child_if_exited(metadata.pid):
|
|
896
|
+
stopped = True
|
|
897
|
+
break
|
|
898
|
+
if not _pid_alive(metadata.pid):
|
|
899
|
+
stopped = True
|
|
900
|
+
break
|
|
689
901
|
time.sleep(0.05)
|
|
690
902
|
if not stopped and _pid_alive(metadata.pid):
|
|
691
903
|
raise CacheClientError("stop_failed", "Cache daemon did not stop.")
|
|
@@ -699,10 +911,17 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
699
911
|
serve.add_argument("--root", required=True)
|
|
700
912
|
serve.add_argument("--project-file", default="")
|
|
701
913
|
serve.add_argument("--max-memory", type=int, default=DEFAULT_MAX_MEMORY_BYTES)
|
|
914
|
+
serve.add_argument("--workers", type=int, default=0)
|
|
702
915
|
serve.add_argument("--idle-timeout", type=int, default=DEFAULT_IDLE_TIMEOUT)
|
|
703
916
|
args = parser.parse_args(argv)
|
|
704
917
|
if args.command == "serve":
|
|
705
|
-
run_cache_daemon(
|
|
918
|
+
run_cache_daemon(
|
|
919
|
+
args.root,
|
|
920
|
+
project_file=args.project_file,
|
|
921
|
+
max_memory_bytes=args.max_memory,
|
|
922
|
+
workers=args.workers,
|
|
923
|
+
idle_timeout=args.idle_timeout,
|
|
924
|
+
)
|
|
706
925
|
return 0
|
|
707
926
|
|
|
708
927
|
|
delphi_lsp/agent_cli.py
CHANGED
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
4
|
import json
|
|
5
|
+
import math
|
|
5
6
|
import os
|
|
6
7
|
import sys
|
|
7
8
|
from pathlib import Path
|
|
@@ -12,6 +13,7 @@ from .agent_cache import (
|
|
|
12
13
|
CacheClientError,
|
|
13
14
|
DEFAULT_IDLE_TIMEOUT,
|
|
14
15
|
DEFAULT_MAX_MEMORY_BYTES,
|
|
16
|
+
DEFAULT_STARTUP_TIMEOUT,
|
|
15
17
|
parse_memory_size,
|
|
16
18
|
query_cache,
|
|
17
19
|
run_cache_daemon,
|
|
@@ -21,6 +23,7 @@ from .agent_cache import (
|
|
|
21
23
|
from .agent_layers import build_codebase_index, layer_payload, render_layer
|
|
22
24
|
from .agent_protocol import AgentProtocolError, SUPPORTED_ACTIONS, SUPPORTED_DETAILS, SUPPORTED_RELATIONS
|
|
23
25
|
from .agent_templates import install_opencode_support, install_skill
|
|
26
|
+
from .parallel_outline import parse_worker_setting
|
|
24
27
|
|
|
25
28
|
|
|
26
29
|
_MAX_WORKER_RECORD_BYTES = 1024 * 1024
|
|
@@ -60,12 +63,14 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
60
63
|
view.add_argument("--query", default="")
|
|
61
64
|
view.add_argument("--format", default="markdown", choices=["markdown", "json"])
|
|
62
65
|
view.add_argument("--deep-projects", action="store_true", help="Deep-parse project dependencies for the projects layer.")
|
|
66
|
+
view.add_argument("--workers", type=parse_worker_setting, default=0)
|
|
63
67
|
view.set_defaults(func=_view)
|
|
64
68
|
|
|
65
69
|
index = subcommands.add_parser("index", help="Materialize a JSON codebase index.")
|
|
66
70
|
index.add_argument("--root", type=Path, default=Path("."))
|
|
67
71
|
index.add_argument("--project-file", type=Path)
|
|
68
72
|
index.add_argument("--out", type=Path, default=Path(".delphi-lsp") / "agent-index" / "index.json")
|
|
73
|
+
index.add_argument("--workers", type=parse_worker_setting, default=0)
|
|
69
74
|
index.set_defaults(func=_index)
|
|
70
75
|
|
|
71
76
|
skill = subcommands.add_parser("skill", help="Install agent skill templates.")
|
|
@@ -95,6 +100,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
95
100
|
worker = subcommands.add_parser("worker", help="Serve Protocol v2 NDJSON requests.")
|
|
96
101
|
worker.add_argument("--root", type=Path, required=True)
|
|
97
102
|
worker.add_argument("--project-file", type=Path)
|
|
103
|
+
worker.add_argument("--workers", type=parse_worker_setting, default=0)
|
|
98
104
|
worker.set_defaults(func=_worker)
|
|
99
105
|
|
|
100
106
|
cache = subcommands.add_parser("cache", help="Manage the shared Protocol v2 cache daemon.")
|
|
@@ -113,6 +119,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
113
119
|
cache_serve.add_argument("--root", type=Path, required=True)
|
|
114
120
|
cache_serve.add_argument("--project-file", type=Path)
|
|
115
121
|
cache_serve.add_argument("--max-memory", type=parse_memory_size, required=True)
|
|
122
|
+
cache_serve.add_argument("--workers", type=parse_worker_setting, required=True)
|
|
116
123
|
cache_serve.add_argument("--idle-timeout", type=int, required=True)
|
|
117
124
|
cache_serve.set_defaults(func=_cache_serve)
|
|
118
125
|
|
|
@@ -135,7 +142,16 @@ def _add_cache_start_arguments(parser: argparse.ArgumentParser) -> None:
|
|
|
135
142
|
parser.add_argument("--root", type=Path, default=Path("."))
|
|
136
143
|
parser.add_argument("--project-file", type=Path)
|
|
137
144
|
parser.add_argument("--max-memory", type=parse_memory_size, default=DEFAULT_MAX_MEMORY_BYTES)
|
|
145
|
+
parser.add_argument("--workers", type=parse_worker_setting, default=0)
|
|
138
146
|
parser.add_argument("--idle-timeout", type=int, default=DEFAULT_IDLE_TIMEOUT)
|
|
147
|
+
parser.add_argument("--startup-timeout", type=_positive_float, default=DEFAULT_STARTUP_TIMEOUT)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _positive_float(value: str) -> float:
|
|
151
|
+
parsed = float(value)
|
|
152
|
+
if not math.isfinite(parsed) or parsed <= 0:
|
|
153
|
+
raise argparse.ArgumentTypeError("value must be greater than zero")
|
|
154
|
+
return parsed
|
|
139
155
|
|
|
140
156
|
|
|
141
157
|
def main(argv: list[str] | None = None) -> int:
|
|
@@ -173,12 +189,22 @@ def _discard_broken_stdout() -> None:
|
|
|
173
189
|
|
|
174
190
|
|
|
175
191
|
def _view(args: argparse.Namespace) -> None:
|
|
176
|
-
index = build_codebase_index(
|
|
192
|
+
index = build_codebase_index(
|
|
193
|
+
args.root,
|
|
194
|
+
project_file=args.project_file,
|
|
195
|
+
index_projects=args.deep_projects,
|
|
196
|
+
workers=args.workers,
|
|
197
|
+
)
|
|
177
198
|
sys.stdout.write(render_layer(index, args.layer, query=args.query, output_format=args.format))
|
|
178
199
|
|
|
179
200
|
|
|
180
201
|
def _index(args: argparse.Namespace) -> None:
|
|
181
|
-
index = build_codebase_index(
|
|
202
|
+
index = build_codebase_index(
|
|
203
|
+
args.root,
|
|
204
|
+
project_file=args.project_file,
|
|
205
|
+
index_projects=True,
|
|
206
|
+
workers=args.workers,
|
|
207
|
+
)
|
|
182
208
|
payload = {
|
|
183
209
|
"overview": layer_payload(index, "overview"),
|
|
184
210
|
"projects": layer_payload(index, "projects"),
|
|
@@ -207,7 +233,7 @@ def _opencode_install(args: argparse.Namespace) -> None:
|
|
|
207
233
|
|
|
208
234
|
|
|
209
235
|
def _worker(args: argparse.Namespace) -> None:
|
|
210
|
-
context = AgentContext.open(args.root, args.project_file)
|
|
236
|
+
context = AgentContext.open(args.root, args.project_file, workers=args.workers)
|
|
211
237
|
try:
|
|
212
238
|
_serve_worker(context, sys.stdin.buffer, sys.stdout.buffer, sys.stderr)
|
|
213
239
|
finally:
|
|
@@ -235,7 +261,14 @@ def _cache_error(error: CacheClientError) -> int:
|
|
|
235
261
|
|
|
236
262
|
def _cache_start(args: argparse.Namespace) -> int:
|
|
237
263
|
try:
|
|
238
|
-
start_cache(
|
|
264
|
+
start_cache(
|
|
265
|
+
args.root,
|
|
266
|
+
project_file=args.project_file,
|
|
267
|
+
max_memory_bytes=args.max_memory,
|
|
268
|
+
workers=args.workers,
|
|
269
|
+
idle_timeout=args.idle_timeout,
|
|
270
|
+
startup_timeout=args.startup_timeout,
|
|
271
|
+
)
|
|
239
272
|
response = query_cache(args.root, {"action": "status"})
|
|
240
273
|
except CacheClientError as error:
|
|
241
274
|
return _cache_error(error)
|
|
@@ -283,7 +316,13 @@ def _cache_stop(args: argparse.Namespace) -> int:
|
|
|
283
316
|
|
|
284
317
|
|
|
285
318
|
def _cache_serve(args: argparse.Namespace) -> int:
|
|
286
|
-
run_cache_daemon(
|
|
319
|
+
run_cache_daemon(
|
|
320
|
+
args.root,
|
|
321
|
+
project_file=str(args.project_file or ""),
|
|
322
|
+
max_memory_bytes=args.max_memory,
|
|
323
|
+
workers=args.workers,
|
|
324
|
+
idle_timeout=args.idle_timeout,
|
|
325
|
+
)
|
|
287
326
|
return 0
|
|
288
327
|
|
|
289
328
|
|