codegraph-engine 2.1.1__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.
- codegraph/__init__.py +37 -0
- codegraph/agent.py +26 -0
- codegraph/architecture.py +328 -0
- codegraph/audit.py +106 -0
- codegraph/cache.py +95 -0
- codegraph/cli.py +854 -0
- codegraph/config.py +43 -0
- codegraph/constraints.py +238 -0
- codegraph/context.py +1228 -0
- codegraph/epistemic.py +90 -0
- codegraph/errors.py +275 -0
- codegraph/evidence/__init__.py +15 -0
- codegraph/evidence/citations.py +397 -0
- codegraph/frameworks.py +434 -0
- codegraph/freshness.py +295 -0
- codegraph/git.py +278 -0
- codegraph/graph/__init__.py +46 -0
- codegraph/graph/models.py +41 -0
- codegraph/graph/traversal.py +1291 -0
- codegraph/indexing/__init__.py +4 -0
- codegraph/indexing/classifier.py +274 -0
- codegraph/indexing/indexer.py +943 -0
- codegraph/indexing/models.py +338 -0
- codegraph/indexing/parser.py +1240 -0
- codegraph/indexing/scanner.py +200 -0
- codegraph/indexing/test_framework.py +116 -0
- codegraph/interrogation.py +1582 -0
- codegraph/llm/__init__.py +3 -0
- codegraph/llm/base.py +15 -0
- codegraph/llm/context.py +20 -0
- codegraph/mcp/__init__.py +3 -0
- codegraph/mcp/server.py +736 -0
- codegraph/memory/__init__.py +3 -0
- codegraph/memory/store.py +46 -0
- codegraph/models.py +289 -0
- codegraph/observability.py +151 -0
- codegraph/optimizer.py +372 -0
- codegraph/planner.py +417 -0
- codegraph/py.typed +1 -0
- codegraph/query_expansion.py +199 -0
- codegraph/ranking.py +363 -0
- codegraph/resolver.py +843 -0
- codegraph/resources/__init__.py +45 -0
- codegraph/resources/cache.py +117 -0
- codegraph/resources/coalescer.py +83 -0
- codegraph/resources/debouncer.py +98 -0
- codegraph/resources/governor.py +232 -0
- codegraph/resources/policy.py +123 -0
- codegraph/retrieval_policy.py +220 -0
- codegraph/search/__init__.py +23 -0
- codegraph/search/hybrid.py +301 -0
- codegraph/search/semantic.py +28 -0
- codegraph/security/__init__.py +3 -0
- codegraph/security/paths.py +35 -0
- codegraph/target_resolver.py +348 -0
- codegraph/task.py +637 -0
- codegraph_engine-2.1.1.dist-info/METADATA +334 -0
- codegraph_engine-2.1.1.dist-info/RECORD +62 -0
- codegraph_engine-2.1.1.dist-info/WHEEL +5 -0
- codegraph_engine-2.1.1.dist-info/entry_points.txt +2 -0
- codegraph_engine-2.1.1.dist-info/licenses/LICENSE +21 -0
- codegraph_engine-2.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Resource Management, Concurrency Throttling, and Laptop Protection.
|
|
2
|
+
|
|
3
|
+
Provides:
|
|
4
|
+
- ResourceGovernor: Priority scheduling, cooperative yielding, process memory monitoring
|
|
5
|
+
- ResourcePolicy: Profiles (LIGHT, BALANCED, PERFORMANCE), bounds, and thresholds
|
|
6
|
+
- RequestCoalescer: De-duplicating identical concurrent flights
|
|
7
|
+
- FileChangeDebouncer: Coalescing editor save bursts
|
|
8
|
+
- BoundedMemoryCache: Memory-bounded LRU caches for parse results and graphs
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from .cache import (
|
|
13
|
+
BoundedMemoryCache,
|
|
14
|
+
get_graph_cache,
|
|
15
|
+
get_parse_cache,
|
|
16
|
+
)
|
|
17
|
+
from .coalescer import RequestCoalescer, get_global_coalescer
|
|
18
|
+
from .debouncer import FileChangeDebouncer
|
|
19
|
+
from .governor import ResourceGovernor, get_global_governor, get_process_memory_mb
|
|
20
|
+
from .policy import (
|
|
21
|
+
ActivityMode,
|
|
22
|
+
PressureLevel,
|
|
23
|
+
ResourcePolicy,
|
|
24
|
+
ResourceProfile,
|
|
25
|
+
ResourceState,
|
|
26
|
+
TaskPriority,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"ActivityMode",
|
|
31
|
+
"BoundedMemoryCache",
|
|
32
|
+
"FileChangeDebouncer",
|
|
33
|
+
"PressureLevel",
|
|
34
|
+
"RequestCoalescer",
|
|
35
|
+
"ResourceGovernor",
|
|
36
|
+
"ResourcePolicy",
|
|
37
|
+
"ResourceProfile",
|
|
38
|
+
"ResourceState",
|
|
39
|
+
"TaskPriority",
|
|
40
|
+
"get_global_coalescer",
|
|
41
|
+
"get_global_governor",
|
|
42
|
+
"get_graph_cache",
|
|
43
|
+
"get_parse_cache",
|
|
44
|
+
"get_process_memory_mb",
|
|
45
|
+
]
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Bounded LRU Memory Caches with Size Tracking and Eviction.
|
|
2
|
+
|
|
3
|
+
Prevents unbounded memory growth from ParseResults, graph neighborhoods,
|
|
4
|
+
and retrieval artifacts during sustained coding sessions.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
from collections import OrderedDict
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _approx_size(obj: Any) -> int:
|
|
16
|
+
"""Approximate memory footprint of an object."""
|
|
17
|
+
try:
|
|
18
|
+
if isinstance(obj, (str, bytes)):
|
|
19
|
+
return len(obj)
|
|
20
|
+
elif hasattr(obj, "__sizeof__"):
|
|
21
|
+
return int(obj.__sizeof__())
|
|
22
|
+
return sys.getsizeof(obj, 128)
|
|
23
|
+
except Exception:
|
|
24
|
+
return 128
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class BoundedMemoryCache[K, V]:
|
|
28
|
+
"""Thread-safe LRU cache with both item count and memory byte limits."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, max_items: int = 500, max_bytes: int = 50 * 1024 * 1024) -> None:
|
|
31
|
+
self.max_items = max_items
|
|
32
|
+
self.max_bytes = max_bytes
|
|
33
|
+
self._lock = threading.Lock()
|
|
34
|
+
self._cache: OrderedDict[K, tuple[V, int]] = OrderedDict()
|
|
35
|
+
self._current_bytes: int = 0
|
|
36
|
+
self._hits: int = 0
|
|
37
|
+
self._misses: int = 0
|
|
38
|
+
|
|
39
|
+
def get(self, key: K) -> V | None:
|
|
40
|
+
with self._lock:
|
|
41
|
+
if key not in self._cache:
|
|
42
|
+
self._misses += 1
|
|
43
|
+
return None
|
|
44
|
+
val, size = self._cache.pop(key)
|
|
45
|
+
self._cache[key] = (val, size) # move to end (MRU)
|
|
46
|
+
self._hits += 1
|
|
47
|
+
return val
|
|
48
|
+
|
|
49
|
+
def put(self, key: K, value: V, size_bytes: int | None = None) -> None:
|
|
50
|
+
sz = size_bytes if size_bytes is not None else _approx_size(value)
|
|
51
|
+
with self._lock:
|
|
52
|
+
if key in self._cache:
|
|
53
|
+
_, old_sz = self._cache.pop(key)
|
|
54
|
+
self._current_bytes -= old_sz
|
|
55
|
+
|
|
56
|
+
self._cache[key] = (value, sz)
|
|
57
|
+
self._current_bytes += sz
|
|
58
|
+
|
|
59
|
+
# Evict until limits are satisfied
|
|
60
|
+
while len(self._cache) > self.max_items or (self._current_bytes > self.max_bytes and len(self._cache) > 1):
|
|
61
|
+
_, (_, evicted_sz) = self._cache.popitem(last=False) # pop LRU
|
|
62
|
+
self._current_bytes -= evicted_sz
|
|
63
|
+
|
|
64
|
+
def set(self, key: K, value: V, size_bytes: int | None = None) -> None:
|
|
65
|
+
"""Alias for put."""
|
|
66
|
+
self.put(key, value, size_bytes)
|
|
67
|
+
|
|
68
|
+
def invalidate(self, predicate: Callable[[K], bool]) -> int:
|
|
69
|
+
removed = 0
|
|
70
|
+
with self._lock:
|
|
71
|
+
keys_to_del = [k for k in self._cache if predicate(k)]
|
|
72
|
+
for k in keys_to_del:
|
|
73
|
+
_, sz = self._cache.pop(k)
|
|
74
|
+
self._current_bytes -= sz
|
|
75
|
+
removed += 1
|
|
76
|
+
return removed
|
|
77
|
+
|
|
78
|
+
def clear(self) -> None:
|
|
79
|
+
with self._lock:
|
|
80
|
+
self._cache.clear()
|
|
81
|
+
self._current_bytes = 0
|
|
82
|
+
|
|
83
|
+
def stats(self) -> dict[str, object]:
|
|
84
|
+
with self._lock:
|
|
85
|
+
return {
|
|
86
|
+
"items": len(self._cache),
|
|
87
|
+
"max_items": self.max_items,
|
|
88
|
+
"current_mb": round(self._current_bytes / (1024 * 1024), 2),
|
|
89
|
+
"max_mb": round(self.max_bytes / (1024 * 1024), 2),
|
|
90
|
+
"hits": self._hits,
|
|
91
|
+
"misses": self._misses,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# Global Specialized Caches
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
# ParseResult cache: (source_hash, parser_version, language) -> ParseResult
|
|
100
|
+
_PARSE_CACHE: BoundedMemoryCache[tuple[str, str, str], Any] = BoundedMemoryCache(
|
|
101
|
+
max_items=300,
|
|
102
|
+
max_bytes=30 * 1024 * 1024, # 30 MB
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# Graph neighborhood cache: (repo_generation, symbol_id, relationship, depth) -> list[dict]
|
|
106
|
+
_GRAPH_CACHE: BoundedMemoryCache[tuple[int, str, str, int], Any] = BoundedMemoryCache(
|
|
107
|
+
max_items=500,
|
|
108
|
+
max_bytes=20 * 1024 * 1024, # 20 MB
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def get_parse_cache() -> BoundedMemoryCache[tuple[str, str, str], Any]:
|
|
113
|
+
return _PARSE_CACHE
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def get_graph_cache() -> BoundedMemoryCache[tuple[int, str, str, int], Any]:
|
|
117
|
+
return _GRAPH_CACHE
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Thread-safe Request Coalescing and In-Flight Flight Sharing.
|
|
2
|
+
|
|
3
|
+
Prevents duplicate concurrent executions of identical read or compilation requests
|
|
4
|
+
by allowing concurrent callers to share a single in-flight computation.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import threading
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _InFlight[T]:
|
|
14
|
+
def __init__(self) -> None:
|
|
15
|
+
self.event = threading.Event()
|
|
16
|
+
self.result: T | None = None
|
|
17
|
+
self.error: BaseException | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RequestCoalescer:
|
|
21
|
+
"""Coalesces identical concurrent tasks into a single execution."""
|
|
22
|
+
|
|
23
|
+
def __init__(self) -> None:
|
|
24
|
+
self._lock = threading.Lock()
|
|
25
|
+
self._flights: dict[str, _InFlight[Any]] = {}
|
|
26
|
+
self._coalesced_count: int = 0
|
|
27
|
+
self._total_requests: int = 0
|
|
28
|
+
|
|
29
|
+
def coalesce[T](self, key: str, fn: Callable[[], T]) -> T:
|
|
30
|
+
"""Execute fn() or wait for an identical in-flight execution under the given key."""
|
|
31
|
+
with self._lock:
|
|
32
|
+
self._total_requests += 1
|
|
33
|
+
flight: _InFlight[T] | None = self._flights.get(key)
|
|
34
|
+
if flight is not None:
|
|
35
|
+
is_leader = False
|
|
36
|
+
self._coalesced_count += 1
|
|
37
|
+
else:
|
|
38
|
+
is_leader = True
|
|
39
|
+
flight = _InFlight[T]()
|
|
40
|
+
self._flights[key] = flight
|
|
41
|
+
|
|
42
|
+
if not is_leader:
|
|
43
|
+
# Wait for leader to complete computation
|
|
44
|
+
flight.event.wait()
|
|
45
|
+
if flight.error is not None:
|
|
46
|
+
raise flight.error
|
|
47
|
+
return flight.result # type: ignore[return-value]
|
|
48
|
+
|
|
49
|
+
# Leader executes fn()
|
|
50
|
+
try:
|
|
51
|
+
res = fn()
|
|
52
|
+
flight.result = res
|
|
53
|
+
return res
|
|
54
|
+
except BaseException as exc:
|
|
55
|
+
flight.error = exc
|
|
56
|
+
raise
|
|
57
|
+
finally:
|
|
58
|
+
with self._lock:
|
|
59
|
+
self._flights.pop(key, None)
|
|
60
|
+
flight.event.set()
|
|
61
|
+
|
|
62
|
+
def in_flight_count(self) -> int:
|
|
63
|
+
with self._lock:
|
|
64
|
+
return len(self._flights)
|
|
65
|
+
|
|
66
|
+
def clear(self) -> None:
|
|
67
|
+
with self._lock:
|
|
68
|
+
self._flights.clear()
|
|
69
|
+
|
|
70
|
+
def stats(self) -> dict[str, object]:
|
|
71
|
+
with self._lock:
|
|
72
|
+
return {
|
|
73
|
+
"in_flight": len(self._flights),
|
|
74
|
+
"total_requests": self._total_requests,
|
|
75
|
+
"coalesced_requests": self._coalesced_count,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
_GLOBAL_COALESCER = RequestCoalescer()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_global_coalescer() -> RequestCoalescer:
|
|
83
|
+
return _GLOBAL_COALESCER
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""File Change Debouncer and Burst Coalescer.
|
|
2
|
+
|
|
3
|
+
Coalesces rapid file system save events from code editors into bounded batches
|
|
4
|
+
to prevent repeated, wasteful index cycles.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class FileChangeDebouncer:
|
|
15
|
+
"""Thread-safe debounce accumulator for editor save bursts."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
debounce_ms: int = 500,
|
|
20
|
+
max_batch_size: int = 100,
|
|
21
|
+
on_batch: Callable[[set[str]], None] | None = None,
|
|
22
|
+
) -> None:
|
|
23
|
+
self.debounce_ms = debounce_ms
|
|
24
|
+
self.max_batch_size = max_batch_size
|
|
25
|
+
self.on_batch = on_batch
|
|
26
|
+
|
|
27
|
+
self._lock = threading.Lock()
|
|
28
|
+
self._pending_paths: set[str] = set()
|
|
29
|
+
self._timer: threading.Timer | None = None
|
|
30
|
+
self._last_event_time: float = 0.0
|
|
31
|
+
|
|
32
|
+
def add_change(self, path: str | Path) -> None:
|
|
33
|
+
"""Record a file modification or save event."""
|
|
34
|
+
p_str = path.as_posix() if isinstance(path, Path) else str(path)
|
|
35
|
+
with self._lock:
|
|
36
|
+
self._pending_paths.add(p_str)
|
|
37
|
+
self._last_event_time = time.time()
|
|
38
|
+
|
|
39
|
+
if len(self._pending_paths) >= self.max_batch_size:
|
|
40
|
+
self._dispatch_locked()
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
if self._timer is not None:
|
|
44
|
+
self._timer.cancel()
|
|
45
|
+
|
|
46
|
+
delay_sec = self.debounce_ms / 1000.0
|
|
47
|
+
self._timer = threading.Timer(delay_sec, self._on_timer_fired)
|
|
48
|
+
self._timer.daemon = True
|
|
49
|
+
self._timer.start()
|
|
50
|
+
|
|
51
|
+
def _on_timer_fired(self) -> None:
|
|
52
|
+
with self._lock:
|
|
53
|
+
self._dispatch_locked()
|
|
54
|
+
|
|
55
|
+
def _dispatch_locked(self) -> None:
|
|
56
|
+
if self._timer is not None:
|
|
57
|
+
self._timer.cancel()
|
|
58
|
+
self._timer = None
|
|
59
|
+
|
|
60
|
+
if not self._pending_paths:
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
batch = set(self._pending_paths)
|
|
64
|
+
self._pending_paths.clear()
|
|
65
|
+
|
|
66
|
+
if self.on_batch:
|
|
67
|
+
# Dispatch outside lock or in thread to avoid deadlocks
|
|
68
|
+
threading.Thread(target=self.on_batch, args=(batch,), daemon=True).start()
|
|
69
|
+
|
|
70
|
+
def flush(self) -> set[str]:
|
|
71
|
+
"""Synchronously drain all pending coalesced paths."""
|
|
72
|
+
with self._lock:
|
|
73
|
+
if self._timer is not None:
|
|
74
|
+
self._timer.cancel()
|
|
75
|
+
self._timer = None
|
|
76
|
+
batch = set(self._pending_paths)
|
|
77
|
+
self._pending_paths.clear()
|
|
78
|
+
return batch
|
|
79
|
+
|
|
80
|
+
def pending_count(self) -> int:
|
|
81
|
+
with self._lock:
|
|
82
|
+
return len(self._pending_paths)
|
|
83
|
+
|
|
84
|
+
def record_change(self, path: str | Path) -> None:
|
|
85
|
+
"""Alias for add_change."""
|
|
86
|
+
self.add_change(path)
|
|
87
|
+
|
|
88
|
+
def start(self, on_batch: Callable[[set[str]], None]) -> None:
|
|
89
|
+
"""Configure or update batch callback."""
|
|
90
|
+
with self._lock:
|
|
91
|
+
self.on_batch = on_batch
|
|
92
|
+
|
|
93
|
+
def stop(self) -> None:
|
|
94
|
+
"""Cancel any pending timer."""
|
|
95
|
+
with self._lock:
|
|
96
|
+
if self._timer is not None:
|
|
97
|
+
self._timer.cancel()
|
|
98
|
+
self._timer = None
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Central Resource Governor for CodeGraph MCP.
|
|
2
|
+
|
|
3
|
+
Protects developer laptop responsiveness during active coding by bounding concurrency,
|
|
4
|
+
throttling background indexing, managing process memory, and cooperatively yielding.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import resource
|
|
9
|
+
import sys
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
|
|
15
|
+
from .policy import (
|
|
16
|
+
ActivityMode,
|
|
17
|
+
PressureLevel,
|
|
18
|
+
ResourcePolicy,
|
|
19
|
+
ResourceProfile,
|
|
20
|
+
ResourceState,
|
|
21
|
+
TaskPriority,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_process_memory_mb() -> float:
|
|
26
|
+
"""Return estimated resident process memory in megabytes portably."""
|
|
27
|
+
try:
|
|
28
|
+
usage = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
|
29
|
+
if sys.platform == "darwin":
|
|
30
|
+
return round(usage / (1024 * 1024), 2)
|
|
31
|
+
else:
|
|
32
|
+
return round(usage / 1024, 2)
|
|
33
|
+
except Exception:
|
|
34
|
+
return 0.0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ResourceGovernor:
|
|
38
|
+
"""Thread-safe central resource governor and priority scheduler."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, policy: ResourcePolicy | None = None) -> None:
|
|
41
|
+
self.policy = policy or ResourcePolicy.for_profile(ResourceProfile.BALANCED)
|
|
42
|
+
self._lock = threading.RLock()
|
|
43
|
+
self._condition = threading.Condition(self._lock)
|
|
44
|
+
|
|
45
|
+
self._active_interactive: int = 0
|
|
46
|
+
self._active_background: int = 0
|
|
47
|
+
self._queued_tasks: int = 0
|
|
48
|
+
self._indexing_active: bool = False
|
|
49
|
+
self._watcher_active: bool = False
|
|
50
|
+
|
|
51
|
+
self._last_interactive_time: float = 0.0
|
|
52
|
+
self._last_file_change_time: float = 0.0
|
|
53
|
+
|
|
54
|
+
self._recent_cpu_time_ms: float = 0.0
|
|
55
|
+
self._recent_wall_time_ms: float = 0.0
|
|
56
|
+
|
|
57
|
+
self._interactive_sem = threading.Semaphore(self.policy.max_interactive_workers)
|
|
58
|
+
self._background_sem = threading.Semaphore(self.policy.max_background_workers)
|
|
59
|
+
|
|
60
|
+
def set_policy(self, policy: ResourcePolicy) -> None:
|
|
61
|
+
"""Update runtime policy dynamically."""
|
|
62
|
+
with self._lock:
|
|
63
|
+
self.policy = policy
|
|
64
|
+
self._interactive_sem = threading.Semaphore(policy.max_interactive_workers)
|
|
65
|
+
self._background_sem = threading.Semaphore(policy.max_background_workers)
|
|
66
|
+
self._condition.notify_all()
|
|
67
|
+
|
|
68
|
+
def record_activity(self, kind: str = "interactive") -> None:
|
|
69
|
+
"""Record non-invasive activity to maintain active coding state."""
|
|
70
|
+
now = time.time()
|
|
71
|
+
with self._lock:
|
|
72
|
+
if kind == "file_change":
|
|
73
|
+
self._last_file_change_time = now
|
|
74
|
+
else:
|
|
75
|
+
self._last_interactive_time = now
|
|
76
|
+
|
|
77
|
+
def get_activity_mode(self) -> ActivityMode:
|
|
78
|
+
"""Infer activity mode from recent MCP and file change events."""
|
|
79
|
+
with self._lock:
|
|
80
|
+
now = time.time()
|
|
81
|
+
recent_interactive = (now - self._last_interactive_time) < 45.0
|
|
82
|
+
recent_files = (now - self._last_file_change_time) < 45.0
|
|
83
|
+
if recent_interactive or recent_files:
|
|
84
|
+
return ActivityMode.ACTIVE_CODING
|
|
85
|
+
if self._active_background > 0 or self._indexing_active:
|
|
86
|
+
return ActivityMode.BACKGROUND
|
|
87
|
+
return ActivityMode.IDLE
|
|
88
|
+
|
|
89
|
+
def is_active_coding(self) -> bool:
|
|
90
|
+
return self.get_activity_mode() == ActivityMode.ACTIVE_CODING
|
|
91
|
+
|
|
92
|
+
def get_pressure(self) -> PressureLevel:
|
|
93
|
+
"""Compute system resource pressure with conservative hysteresis."""
|
|
94
|
+
mem_mb = get_process_memory_mb()
|
|
95
|
+
limit_mb = self.policy.max_memory_mb
|
|
96
|
+
|
|
97
|
+
with self._lock:
|
|
98
|
+
active_inter = self._active_interactive
|
|
99
|
+
|
|
100
|
+
if limit_mb and mem_mb >= limit_mb * 0.90:
|
|
101
|
+
return PressureLevel.CRITICAL
|
|
102
|
+
if active_inter >= self.policy.max_interactive_workers * 2:
|
|
103
|
+
return PressureLevel.CRITICAL
|
|
104
|
+
|
|
105
|
+
if limit_mb and mem_mb >= limit_mb * 0.75:
|
|
106
|
+
return PressureLevel.HIGH
|
|
107
|
+
if active_inter > self.policy.max_interactive_workers:
|
|
108
|
+
return PressureLevel.HIGH
|
|
109
|
+
|
|
110
|
+
if limit_mb and mem_mb >= limit_mb * 0.60:
|
|
111
|
+
return PressureLevel.ELEVATED
|
|
112
|
+
if active_inter > 0:
|
|
113
|
+
return PressureLevel.ELEVATED
|
|
114
|
+
|
|
115
|
+
return PressureLevel.NORMAL
|
|
116
|
+
|
|
117
|
+
def should_throttle(self, priority: TaskPriority = TaskPriority.BACKGROUND) -> bool:
|
|
118
|
+
"""Check whether a background or maintenance task should throttle or yield."""
|
|
119
|
+
if priority in (TaskPriority.INTERACTIVE_HIGH, TaskPriority.INTERACTIVE_NORMAL):
|
|
120
|
+
return False
|
|
121
|
+
pressure = self.get_pressure()
|
|
122
|
+
if pressure in (PressureLevel.CRITICAL, PressureLevel.HIGH):
|
|
123
|
+
return True
|
|
124
|
+
return self.is_active_coding()
|
|
125
|
+
|
|
126
|
+
def yield_if_needed(self, priority: TaskPriority = TaskPriority.BACKGROUND) -> None:
|
|
127
|
+
"""Cooperative yield point for long-running iterative tasks."""
|
|
128
|
+
if priority in (TaskPriority.INTERACTIVE_HIGH, TaskPriority.INTERACTIVE_NORMAL):
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
pressure = self.get_pressure()
|
|
132
|
+
if pressure == PressureLevel.CRITICAL:
|
|
133
|
+
# Under critical pressure, background work yields for 100ms
|
|
134
|
+
time.sleep(0.10)
|
|
135
|
+
elif pressure == PressureLevel.HIGH or self.is_active_coding():
|
|
136
|
+
# Under high pressure or active coding, yield 20ms to free up CPU
|
|
137
|
+
time.sleep(0.02)
|
|
138
|
+
else:
|
|
139
|
+
# Minimal cooperative yield to let OS reschedule other threads
|
|
140
|
+
time.sleep(0.001)
|
|
141
|
+
|
|
142
|
+
def get_state(self) -> ResourceState:
|
|
143
|
+
"""Snapshot current resource state."""
|
|
144
|
+
mem = get_process_memory_mb()
|
|
145
|
+
pressure = self.get_pressure()
|
|
146
|
+
mode = self.get_activity_mode()
|
|
147
|
+
with self._lock:
|
|
148
|
+
return ResourceState(
|
|
149
|
+
policy_profile=self.policy.profile.value,
|
|
150
|
+
active_interactive_requests=self._active_interactive,
|
|
151
|
+
active_background_tasks=self._active_background,
|
|
152
|
+
queued_tasks=self._queued_tasks,
|
|
153
|
+
recent_cpu_time_ms=round(self._recent_cpu_time_ms, 2),
|
|
154
|
+
recent_wall_time_ms=round(self._recent_wall_time_ms, 2),
|
|
155
|
+
estimated_memory_mb=mem,
|
|
156
|
+
pressure_level=pressure.value,
|
|
157
|
+
activity_mode=mode.value,
|
|
158
|
+
indexing_active=self._indexing_active,
|
|
159
|
+
watcher_active=self._watcher_active,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
@contextmanager
|
|
163
|
+
def task_scope(
|
|
164
|
+
self,
|
|
165
|
+
priority: TaskPriority = TaskPriority.INTERACTIVE_HIGH,
|
|
166
|
+
task_id: str | None = None,
|
|
167
|
+
) -> Iterator[None]:
|
|
168
|
+
"""Scoped execution guard that handles priority scheduling, tracking, and yielding."""
|
|
169
|
+
del task_id
|
|
170
|
+
is_interactive = priority in (TaskPriority.INTERACTIVE_HIGH, TaskPriority.INTERACTIVE_NORMAL)
|
|
171
|
+
sem = self._interactive_sem if is_interactive else self._background_sem
|
|
172
|
+
|
|
173
|
+
with self._lock:
|
|
174
|
+
self._queued_tasks += 1
|
|
175
|
+
if is_interactive:
|
|
176
|
+
self.record_activity("interactive")
|
|
177
|
+
|
|
178
|
+
sem.acquire()
|
|
179
|
+
try:
|
|
180
|
+
throttle = False
|
|
181
|
+
with self._lock:
|
|
182
|
+
self._queued_tasks = max(0, self._queued_tasks - 1)
|
|
183
|
+
if is_interactive:
|
|
184
|
+
self._active_interactive += 1
|
|
185
|
+
else:
|
|
186
|
+
self._active_background += 1
|
|
187
|
+
if self.is_active_coding() and self.policy.aggressive_throttling:
|
|
188
|
+
throttle = True
|
|
189
|
+
if throttle:
|
|
190
|
+
time.sleep(0.01)
|
|
191
|
+
|
|
192
|
+
t0_cpu = time.process_time()
|
|
193
|
+
t0_wall = time.perf_counter()
|
|
194
|
+
try:
|
|
195
|
+
yield
|
|
196
|
+
finally:
|
|
197
|
+
elapsed_cpu = (time.process_time() - t0_cpu) * 1000
|
|
198
|
+
elapsed_wall = (time.perf_counter() - t0_wall) * 1000
|
|
199
|
+
with self._lock:
|
|
200
|
+
if is_interactive:
|
|
201
|
+
self._active_interactive = max(0, self._active_interactive - 1)
|
|
202
|
+
self._last_interactive_time = time.time()
|
|
203
|
+
else:
|
|
204
|
+
self._active_background = max(0, self._active_background - 1)
|
|
205
|
+
self._recent_cpu_time_ms = elapsed_cpu
|
|
206
|
+
self._recent_wall_time_ms = elapsed_wall
|
|
207
|
+
self._condition.notify_all()
|
|
208
|
+
finally:
|
|
209
|
+
sem.release()
|
|
210
|
+
|
|
211
|
+
def set_indexing_active(self, active: bool) -> None:
|
|
212
|
+
with self._lock:
|
|
213
|
+
self._indexing_active = active
|
|
214
|
+
|
|
215
|
+
def set_watcher_active(self, active: bool) -> None:
|
|
216
|
+
with self._lock:
|
|
217
|
+
self._watcher_active = active
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
_GLOBAL_GOVERNOR: ResourceGovernor | None = None
|
|
221
|
+
_GOVERNOR_LOCK = threading.Lock()
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def get_global_governor(policy: ResourcePolicy | None = None) -> ResourceGovernor:
|
|
225
|
+
"""Access the singleton resource governor for the current process."""
|
|
226
|
+
global _GLOBAL_GOVERNOR
|
|
227
|
+
with _GOVERNOR_LOCK:
|
|
228
|
+
if _GLOBAL_GOVERNOR is None:
|
|
229
|
+
_GLOBAL_GOVERNOR = ResourceGovernor(policy)
|
|
230
|
+
elif policy is not None:
|
|
231
|
+
_GLOBAL_GOVERNOR.set_policy(policy)
|
|
232
|
+
return _GLOBAL_GOVERNOR
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Resource Policies, Profiles, Task Priorities, and State Models.
|
|
2
|
+
|
|
3
|
+
Defines bounded execution budgets, memory limits, concurrency profiles,
|
|
4
|
+
and cooperative pressure levels without platform-specific hardware assumptions.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import asdict, dataclass
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ResourceProfile(StrEnum):
|
|
13
|
+
LIGHT = "LIGHT"
|
|
14
|
+
BALANCED = "BALANCED"
|
|
15
|
+
PERFORMANCE = "PERFORMANCE"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PressureLevel(StrEnum):
|
|
19
|
+
NORMAL = "NORMAL"
|
|
20
|
+
ELEVATED = "ELEVATED"
|
|
21
|
+
HIGH = "HIGH"
|
|
22
|
+
CRITICAL = "CRITICAL"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TaskPriority(StrEnum):
|
|
26
|
+
INTERACTIVE_HIGH = "INTERACTIVE_HIGH"
|
|
27
|
+
INTERACTIVE_NORMAL = "INTERACTIVE_NORMAL"
|
|
28
|
+
BACKGROUND = "BACKGROUND"
|
|
29
|
+
MAINTENANCE = "MAINTENANCE"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ActivityMode(StrEnum):
|
|
33
|
+
ACTIVE_CODING = "ACTIVE_CODING"
|
|
34
|
+
IDLE = "IDLE"
|
|
35
|
+
BACKGROUND = "BACKGROUND"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class ResourcePolicy:
|
|
40
|
+
profile: ResourceProfile = ResourceProfile.BALANCED
|
|
41
|
+
max_interactive_workers: int = 2
|
|
42
|
+
max_background_workers: int = 1
|
|
43
|
+
max_files_per_incremental_batch: int = 25
|
|
44
|
+
max_graph_nodes_per_query: int = 300
|
|
45
|
+
max_graph_edges_per_query: int = 1200
|
|
46
|
+
max_git_commits: int = 15
|
|
47
|
+
max_memory_mb: int = 1024
|
|
48
|
+
max_cpu_time_ms_per_request: int = 5000
|
|
49
|
+
background_enabled: bool = True
|
|
50
|
+
aggressive_throttling: bool = True
|
|
51
|
+
debounce_ms: int = 500
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def for_profile(cls, profile: str | ResourceProfile) -> ResourcePolicy:
|
|
55
|
+
p = ResourceProfile(str(profile).upper()) if isinstance(profile, str) else profile
|
|
56
|
+
if p == ResourceProfile.LIGHT:
|
|
57
|
+
return cls(
|
|
58
|
+
profile=ResourceProfile.LIGHT,
|
|
59
|
+
max_interactive_workers=1,
|
|
60
|
+
max_background_workers=1,
|
|
61
|
+
max_files_per_incremental_batch=10,
|
|
62
|
+
max_graph_nodes_per_query=100,
|
|
63
|
+
max_graph_edges_per_query=400,
|
|
64
|
+
max_git_commits=5,
|
|
65
|
+
max_memory_mb=512,
|
|
66
|
+
max_cpu_time_ms_per_request=2000,
|
|
67
|
+
background_enabled=True,
|
|
68
|
+
aggressive_throttling=True,
|
|
69
|
+
debounce_ms=800,
|
|
70
|
+
)
|
|
71
|
+
elif p == ResourceProfile.PERFORMANCE:
|
|
72
|
+
return cls(
|
|
73
|
+
profile=ResourceProfile.PERFORMANCE,
|
|
74
|
+
max_interactive_workers=4,
|
|
75
|
+
max_background_workers=2,
|
|
76
|
+
max_files_per_incremental_batch=100,
|
|
77
|
+
max_graph_nodes_per_query=1000,
|
|
78
|
+
max_graph_edges_per_query=5000,
|
|
79
|
+
max_git_commits=50,
|
|
80
|
+
max_memory_mb=4096,
|
|
81
|
+
max_cpu_time_ms_per_request=15000,
|
|
82
|
+
background_enabled=True,
|
|
83
|
+
aggressive_throttling=False,
|
|
84
|
+
debounce_ms=250,
|
|
85
|
+
)
|
|
86
|
+
else:
|
|
87
|
+
return cls(
|
|
88
|
+
profile=ResourceProfile.BALANCED,
|
|
89
|
+
max_interactive_workers=2,
|
|
90
|
+
max_background_workers=1,
|
|
91
|
+
max_files_per_incremental_batch=25,
|
|
92
|
+
max_graph_nodes_per_query=300,
|
|
93
|
+
max_graph_edges_per_query=1200,
|
|
94
|
+
max_git_commits=15,
|
|
95
|
+
max_memory_mb=1024,
|
|
96
|
+
max_cpu_time_ms_per_request=5000,
|
|
97
|
+
background_enabled=True,
|
|
98
|
+
aggressive_throttling=True,
|
|
99
|
+
debounce_ms=500,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
def as_dict(self) -> dict[str, object]:
|
|
103
|
+
d = asdict(self)
|
|
104
|
+
d["profile"] = self.profile.value
|
|
105
|
+
return d
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class ResourceState:
|
|
110
|
+
policy_profile: str = ResourceProfile.BALANCED.value
|
|
111
|
+
active_interactive_requests: int = 0
|
|
112
|
+
active_background_tasks: int = 0
|
|
113
|
+
queued_tasks: int = 0
|
|
114
|
+
recent_cpu_time_ms: float = 0.0
|
|
115
|
+
recent_wall_time_ms: float = 0.0
|
|
116
|
+
estimated_memory_mb: float = 0.0
|
|
117
|
+
pressure_level: str = PressureLevel.NORMAL.value
|
|
118
|
+
activity_mode: str = ActivityMode.IDLE.value
|
|
119
|
+
indexing_active: bool = False
|
|
120
|
+
watcher_active: bool = False
|
|
121
|
+
|
|
122
|
+
def as_dict(self) -> dict[str, object]:
|
|
123
|
+
return asdict(self)
|