code-context-control 2.153.0__py3-none-any.whl → 2.154.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.
- cli/c3.py +63 -1
- cli/commands/parser.py +11 -0
- cli/guide/index.html +4 -0
- {code_context_control-2.153.0.dist-info → code_context_control-2.154.0.dist-info}/METADATA +1 -1
- {code_context_control-2.153.0.dist-info → code_context_control-2.154.0.dist-info}/RECORD +10 -10
- services/embedding_index.py +434 -4
- {code_context_control-2.153.0.dist-info → code_context_control-2.154.0.dist-info}/WHEEL +0 -0
- {code_context_control-2.153.0.dist-info → code_context_control-2.154.0.dist-info}/entry_points.txt +0 -0
- {code_context_control-2.153.0.dist-info → code_context_control-2.154.0.dist-info}/licenses/LICENSE +0 -0
- {code_context_control-2.153.0.dist-info → code_context_control-2.154.0.dist-info}/top_level.txt +0 -0
cli/c3.py
CHANGED
|
@@ -91,7 +91,7 @@ console = Console() if HAS_RICH else None
|
|
|
91
91
|
# Config
|
|
92
92
|
CONFIG_DIR = ".c3"
|
|
93
93
|
CONFIG_FILE = ".c3/config.json"
|
|
94
|
-
__version__ = "2.
|
|
94
|
+
__version__ = "2.154.0"
|
|
95
95
|
|
|
96
96
|
# The PreToolUse matcher for native subagent calls (installer and hub migration).
|
|
97
97
|
AGENT_MATCHER = "Agent|Task"
|
|
@@ -1288,6 +1288,14 @@ def cmd_init(args):
|
|
|
1288
1288
|
except Exception:
|
|
1289
1289
|
pass
|
|
1290
1290
|
|
|
1291
|
+
swept = _prune_project_quarantines(project_path)
|
|
1292
|
+
if swept["pruned"]:
|
|
1293
|
+
print(f" Fixed : freed {_format_bytes(swept['bytes'])} from "
|
|
1294
|
+
f"{len(swept['pruned'])} old embedding quarantine dir(s)")
|
|
1295
|
+
for name in swept["failed"]:
|
|
1296
|
+
print(f" [warn] {name} could not be fully removed (in use?); "
|
|
1297
|
+
"run 'c3 prune-quarantine' later")
|
|
1298
|
+
|
|
1291
1299
|
# Delegate backends
|
|
1292
1300
|
try:
|
|
1293
1301
|
from services.ollama_client import OllamaClient as _OC
|
|
@@ -1463,6 +1471,59 @@ def cmd_index(args):
|
|
|
1463
1471
|
return common_cmd_index(args, _command_deps())
|
|
1464
1472
|
|
|
1465
1473
|
|
|
1474
|
+
def _format_bytes(n: float) -> str:
|
|
1475
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
1476
|
+
if n < 1024:
|
|
1477
|
+
return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
|
|
1478
|
+
n /= 1024
|
|
1479
|
+
return f"{n:.1f} TB"
|
|
1480
|
+
|
|
1481
|
+
|
|
1482
|
+
def _prune_project_quarantines(project_path: str, keep=None, dry_run: bool = False) -> dict:
|
|
1483
|
+
"""Prune one project's ``.c3/embeddings/quarantine_corrupt_*`` dirs; returns the sweep result."""
|
|
1484
|
+
from cli.progress import ProgressLine
|
|
1485
|
+
from services.embedding_index import prune_quarantines
|
|
1486
|
+
|
|
1487
|
+
prog = ProgressLine()
|
|
1488
|
+
try:
|
|
1489
|
+
return prune_quarantines(
|
|
1490
|
+
Path(project_path) / CONFIG_DIR / "embeddings", keep=keep, dry_run=dry_run,
|
|
1491
|
+
on_progress=lambda done, total: prog.update(
|
|
1492
|
+
f" removing quarantine copies {done}/{total}"))
|
|
1493
|
+
finally:
|
|
1494
|
+
prog.done()
|
|
1495
|
+
|
|
1496
|
+
|
|
1497
|
+
def cmd_prune_quarantine(args):
|
|
1498
|
+
"""Delete old corrupt-embedding-store quarantine copies, keeping the newest few."""
|
|
1499
|
+
if args.all:
|
|
1500
|
+
from services.project_manager import ProjectManager
|
|
1501
|
+
projects = [p["path"] for p in ProjectManager().list_registered()]
|
|
1502
|
+
else:
|
|
1503
|
+
projects = [str(Path(args.project_path or ".").resolve())]
|
|
1504
|
+
|
|
1505
|
+
verb = "would remove" if args.dry_run else "removed"
|
|
1506
|
+
pruned = failed = freed = 0
|
|
1507
|
+
for project in projects:
|
|
1508
|
+
res = _prune_project_quarantines(project, keep=args.keep, dry_run=args.dry_run)
|
|
1509
|
+
if not (res["pruned"] or res["failed"]):
|
|
1510
|
+
continue
|
|
1511
|
+
pruned += len(res["pruned"])
|
|
1512
|
+
failed += len(res["failed"])
|
|
1513
|
+
freed += res["bytes"]
|
|
1514
|
+
print(f"{project}: {verb} {len(res['pruned'])} quarantine dir(s), "
|
|
1515
|
+
f"{_format_bytes(res['bytes'])}; kept {len(res['kept'])}")
|
|
1516
|
+
for name in res["failed"]:
|
|
1517
|
+
print(f" [warn] {name} could not be fully removed (in use?); run again later")
|
|
1518
|
+
|
|
1519
|
+
if not (pruned or failed):
|
|
1520
|
+
print(f"Nothing to prune in {len(projects)} project(s).")
|
|
1521
|
+
return
|
|
1522
|
+
print(f"\n{verb.capitalize()} {pruned} quarantine dir(s), {_format_bytes(freed)}.")
|
|
1523
|
+
if failed:
|
|
1524
|
+
sys.exit(1)
|
|
1525
|
+
|
|
1526
|
+
|
|
1466
1527
|
def cmd_compress(args):
|
|
1467
1528
|
"""Compress a file and show results."""
|
|
1468
1529
|
return common_cmd_compress(args, _command_deps())
|
|
@@ -9603,6 +9664,7 @@ def main():
|
|
|
9603
9664
|
"enforce": cmd_enforce,
|
|
9604
9665
|
"override": cmd_override,
|
|
9605
9666
|
"locks": cmd_locks,
|
|
9667
|
+
"prune-quarantine": cmd_prune_quarantine,
|
|
9606
9668
|
"ci": cmd_ci,
|
|
9607
9669
|
"oracle": cmd_oracle,
|
|
9608
9670
|
"upgrade": cmd_upgrade,
|
cli/commands/parser.py
CHANGED
|
@@ -37,6 +37,17 @@ def build_parser(version: str, parse_cli_ide_arg):
|
|
|
37
37
|
p_upgrade.add_argument("--check", action="store_true",
|
|
38
38
|
help="Only report whether a newer version exists; don't install")
|
|
39
39
|
|
|
40
|
+
p_prune = subparsers.add_parser(
|
|
41
|
+
"prune-quarantine",
|
|
42
|
+
help="Delete old corrupt-embedding-store quarantine copies (frees disk)")
|
|
43
|
+
p_prune.add_argument("project_path", nargs="?", default=".")
|
|
44
|
+
p_prune.add_argument("--all", action="store_true",
|
|
45
|
+
help="Every project registered with the hub, not just this one")
|
|
46
|
+
p_prune.add_argument("--keep", type=int, default=None,
|
|
47
|
+
help="Newest quarantine dirs to keep per project (default 2)")
|
|
48
|
+
p_prune.add_argument("--dry-run", action="store_true",
|
|
49
|
+
help="Report what would be removed without deleting")
|
|
50
|
+
|
|
40
51
|
p_index = subparsers.add_parser("index", help="Rebuild code index")
|
|
41
52
|
p_index.add_argument("--max-files", type=int, default=None,
|
|
42
53
|
help="Cap files indexed this run. Unset reads "
|
cli/guide/index.html
CHANGED
|
@@ -663,6 +663,10 @@
|
|
|
663
663
|
<code>c3 map {status|ensure|refresh}</code>
|
|
664
664
|
<span>Inspect or rebuild the live repo map at .c3/MAP.md</span>
|
|
665
665
|
</div>
|
|
666
|
+
<div class="quick-item">
|
|
667
|
+
<code>c3 prune-quarantine [--all] [--dry-run]</code>
|
|
668
|
+
<span>Free disk held by old corrupt-embedding-store copies in .c3/embeddings (<code>c3 init</code> runs it too)</span>
|
|
669
|
+
</div>
|
|
666
670
|
<div class="quick-item">
|
|
667
671
|
<code>c3 hub</code>
|
|
668
672
|
<span>Launch the Project Hub web dashboard (localhost:3330)</span>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: code-context-control
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.154.0
|
|
4
4
|
Summary: Local MCP code-intelligence for AI coding tools: surgical search/read/edit, agent-config version history, path-level access + masking guards, and a multi-project hub.
|
|
5
5
|
Author-email: Dimitri Tselenchuk <dtselenc@gmail.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
cli/__init__.py,sha256=ec66drCZGNMRU4V6ov0zVhYZph1us12Vn8OvG_LJyRY,22
|
|
2
2
|
cli/_hook_utils.py,sha256=RhfS2WS5VMSi2w2Jxcts1wg5WC-UFp89JOQvcO5Jjb8,23002
|
|
3
3
|
cli/_shell_writes.py,sha256=XzUNlUDljmg-wZxK03xHnRe36WeQxe4tpO9HKVFwP5o,5090
|
|
4
|
-
cli/c3.py,sha256=
|
|
4
|
+
cli/c3.py,sha256=FWPIwjhUOyJUG75lTOIVl2jxNj5bKmk7ffziGfLZSg8,434309
|
|
5
5
|
cli/docs.html,sha256=pPxcP5ieY0DcfEDvegwIcRxbz1woLZoKLVSF-4JVy0Q,146784
|
|
6
6
|
cli/edits.html,sha256=UjAhoCmBmQ89cklGvJqzC6eyNP2tc8H6T-e01DVkLvE,43418
|
|
7
7
|
cli/hook_access_guard.py,sha256=tzK3pQBGehDDZi6fECcYk8nRhy0QkmUtTF2TuJG7nbY,27928
|
|
@@ -34,14 +34,14 @@ cli/ui_legacy.html,sha256=cI8tC6RKmE2NIJOcsu7CY-zT4VznjcbD6NTjxb_fvUY,378460
|
|
|
34
34
|
cli/ui_nano.html,sha256=UAwQ6bbTOXAoGq191AZ7slhngR9edJSa3IhqpynveDg,27740
|
|
35
35
|
cli/commands/__init__.py,sha256=0Z8MABNzwSFJGT4Xv9R5AJVR8XxraTsuVTz5b0bShmo,38
|
|
36
36
|
cli/commands/common.py,sha256=X5sjog7O274vmmVOMe3pO2QzbBOdhp-nWKSj7wqi8SA,14411
|
|
37
|
-
cli/commands/parser.py,sha256=
|
|
37
|
+
cli/commands/parser.py,sha256=ebcJDpekCNU8Bf_RRjrsBn-_xdeWnVmr2Wf2yuPIldk,69945
|
|
38
38
|
cli/guide/access.html,sha256=54KbBEZhr3ef_UbWPZzK6Ad91w_47qsMdx6rDmvF8DQ,47279
|
|
39
39
|
cli/guide/bitbucket.html,sha256=lDm94czbl2iiAMmJ4gLxXB9m5As-8Dy5Q7b39jR_eKI,37012
|
|
40
40
|
cli/guide/ci.html,sha256=ms3K49vfBZeerW_09NrlL82lKgWSEyFmNtG4BpD_jq8,37465
|
|
41
41
|
cli/guide/credentials.html,sha256=zC3jLZcVieiKNxKPN7gqXhfE8KuoRlXoMjmvMLCYTlM,66385
|
|
42
42
|
cli/guide/delegate.html,sha256=sS96lGojyAZ296rQBaq0liAV01KeaYOImGSlaPTRl1g,47601
|
|
43
43
|
cli/guide/getting-started.html,sha256=lELRulCRHuU0So9rKn6rGYGYvCgDqka-lexM3xV1a3E,29529
|
|
44
|
-
cli/guide/index.html,sha256=
|
|
44
|
+
cli/guide/index.html,sha256=5yDe8gJTX2aH8seYwVmWKoL4wx5jrwgfb2_Z25zzamI,28666
|
|
45
45
|
cli/guide/jira.html,sha256=K-1XOggBOuWiVXhySQvM2eFzNpY05-ungDRIWa788Yo,39226
|
|
46
46
|
cli/guide/masking.html,sha256=BcsTxlnpRD-fdJKBKn7naUfXPfNFnU0lM4mPi4GLOqs,20126
|
|
47
47
|
cli/guide/oracle.html,sha256=yRNN9xH8KPIT72C1bl9ZHv8m0ENvaFMvreN-gbtXXoo,33352
|
|
@@ -132,7 +132,7 @@ cli/ui/components/sessions.js,sha256=FIKtil76B8tCkAmcFV7hlj6GQ_DCJK2jCzvEmdK7NBE
|
|
|
132
132
|
cli/ui/components/settings.js,sha256=FXcn-UQE2mXC6HFsLeEas-wAUFKt0OJaLySroZQMhGA,84095
|
|
133
133
|
cli/ui/components/sidebar.js,sha256=K2ym2kUgpbyG-EBA_wBIIiqQY8qTatsiBZwzseMWuIQ,9939
|
|
134
134
|
cli/ui/components/tasks.js,sha256=vyKQ3uwoppMwvdEaHlhWXW4oWcAisx4NveqzMhsYqHo,38438
|
|
135
|
-
code_context_control-2.
|
|
135
|
+
code_context_control-2.154.0.dist-info/licenses/LICENSE,sha256=l8Kh5QCNWNvR6kIt8L0BUZvc2LAFiHv2c-FnsGnUZf4,11301
|
|
136
136
|
core/__init__.py,sha256=TSDCEcM4V7gcZVM3w2ykJaqEUch4Dkon-rivV17T73s,2501
|
|
137
137
|
core/codex_patch.py,sha256=CxvT_rQIwPNTRJZj9EHJmx0ZdOeSLxjiixuLI3Z7lPc,2107
|
|
138
138
|
core/config.py,sha256=wCjrZKXRywUbR6WRcO7optyOirAQ2DeRNdPCU0X5zIY,23762
|
|
@@ -236,7 +236,7 @@ services/e2e_evaluator.py,sha256=FsB4vMzcFUcmicfIwRtA8JZ_XeMpuk2_-86F2iVNEDc,177
|
|
|
236
236
|
services/e2e_tasks.py,sha256=Ln5VbGDIcS3NY3aml5ERbgjfjLxEslmRZ8AyaYxpWEo,34524
|
|
237
237
|
services/edit_blobs.py,sha256=iPxHo7k_FMq8tsAKmEyb1e7yVlDZmt5_udmTYJPrlNA,6548
|
|
238
238
|
services/edit_ledger.py,sha256=Tj8Q14o19VHQHpofRHdbZKKcVs17v-ctZ3LOeVTqvdQ,29362
|
|
239
|
-
services/embedding_index.py,sha256
|
|
239
|
+
services/embedding_index.py,sha256=-d6Gatqq5OCrneGbvDTGrWyzbVx7562SrwABOFBRhjY,44294
|
|
240
240
|
services/enforcement_policy.py,sha256=znjABRB-8TSl0ELazY2ALzux2YJ5nWf8TvxLRhDsL2w,18905
|
|
241
241
|
services/error_reporting.py,sha256=HZ3ru8i5RLf8nq2R4iRnTs5sm1blUxknSbv5hdxuxs0,4139
|
|
242
242
|
services/file_map.py,sha256=j37DTR-UE_PMNj7vG2HiHucpwpJNaKgiD94UNUU-UXE,17091
|
|
@@ -337,8 +337,8 @@ tui/screens/search_view.py,sha256=MMHjVdlk3HZSuDBSvq8IGrqv_Mh5Us6YqXQ80bcWSMk,19
|
|
|
337
337
|
tui/screens/session_view.py,sha256=eZ1eDwHTvPOck1wCCviixtOaCxIkBT_95ytNNNriGNA,5991
|
|
338
338
|
tui/screens/stats.py,sha256=p81PjzdaIv7hllb8f45-rlVe4lJZwSdIMqu7e86_u5s,6223
|
|
339
339
|
tui/screens/ui_view.py,sha256=1QJCgLh2YfgWIpvzRG1KOGXYEaOYX6ojN61Azjf2oX0,2125
|
|
340
|
-
code_context_control-2.
|
|
341
|
-
code_context_control-2.
|
|
342
|
-
code_context_control-2.
|
|
343
|
-
code_context_control-2.
|
|
344
|
-
code_context_control-2.
|
|
340
|
+
code_context_control-2.154.0.dist-info/METADATA,sha256=B9QZpxCE_K6EV78qi2ogB4IVaiW_aCL9BLTKYvxtB0o,28902
|
|
341
|
+
code_context_control-2.154.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
342
|
+
code_context_control-2.154.0.dist-info/entry_points.txt,sha256=7kX_WUsDCF2hbXzvbNyscyaBb9AeA-DJY5v_5hN0DlU,93
|
|
343
|
+
code_context_control-2.154.0.dist-info/top_level.txt,sha256=wRt41zBybVF3qAiNXHz9BURbkKvUvfhmWWtKMhaw6eE,29
|
|
344
|
+
code_context_control-2.154.0.dist-info/RECORD,,
|
services/embedding_index.py
CHANGED
|
@@ -10,8 +10,12 @@ import gc
|
|
|
10
10
|
import hashlib
|
|
11
11
|
import json
|
|
12
12
|
import logging
|
|
13
|
+
import os
|
|
13
14
|
import shutil
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
14
17
|
import threading
|
|
18
|
+
import time
|
|
15
19
|
from datetime import datetime
|
|
16
20
|
from pathlib import Path
|
|
17
21
|
|
|
@@ -28,6 +32,336 @@ DEFAULT_MIN_SCORE_OTHER = 0.55
|
|
|
28
32
|
# and degrade rather than block. See _acquire_build_lock().
|
|
29
33
|
_BUILD_LOCK_WAIT_SECONDS = 30.0
|
|
30
34
|
|
|
35
|
+
# ── Store safety (issue #180) ─────────────────────────────
|
|
36
|
+
# A damaged HNSW segment does not just fail to load. chromadb's loader
|
|
37
|
+
# allocates as it reads, so one bad segment made a single MCP server commit
|
|
38
|
+
# 140-200 GB before the load gave up, and every new session repeated it
|
|
39
|
+
# (measured 2026-10-02: count() on a copy of the store, 165 GB private, 0.25 GB
|
|
40
|
+
# working set, then "Error loading hnsw index"). An in-process probe cannot
|
|
41
|
+
# undo that commit, so an existing store is first opened in a child process
|
|
42
|
+
# under a memory cap, and only opened here if the child could read it.
|
|
43
|
+
_PROBE_TIMEOUT_SECONDS = 180.0
|
|
44
|
+
_PROBE_POLL_SECONDS = 0.1
|
|
45
|
+
_PROBE_MIN_CAP_BYTES = 4 * 1024 ** 3 # floor; scaled up for big stores below
|
|
46
|
+
_PROBE_CAP_STORE_MULTIPLE = 4
|
|
47
|
+
_PROBE_OK_FILE = "store_probe_ok.json"
|
|
48
|
+
_PROBE_OK_MARKER = "C3_STORE_PROBE_OK"
|
|
49
|
+
# Each quarantine keeps a full copy of the store for post-mortem. Unbounded,
|
|
50
|
+
# that reached 284 copies and 384 GB in one project; keep only the newest few.
|
|
51
|
+
_QUARANTINE_KEEP = 2
|
|
52
|
+
_OWNER_LOCK_FILE = "chromadb.owner.lock"
|
|
53
|
+
|
|
54
|
+
_PROBE_CHILD_CODE = """
|
|
55
|
+
import sys
|
|
56
|
+
import chromadb
|
|
57
|
+
from chromadb.config import Settings
|
|
58
|
+
client = chromadb.PersistentClient(
|
|
59
|
+
path=sys.argv[1], settings=Settings(anonymized_telemetry=False))
|
|
60
|
+
listed = client.list_collections()
|
|
61
|
+
names = {c if isinstance(c, str) else getattr(c, "name", "") for c in listed}
|
|
62
|
+
if sys.argv[2] in names:
|
|
63
|
+
client.get_collection(sys.argv[2]).count()
|
|
64
|
+
print("C3_STORE_PROBE_OK", flush=True)
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class StoreBusyError(RuntimeError):
|
|
69
|
+
"""Another C3 process owns this project's embedding store."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _tree_bytes(path: Path) -> int:
|
|
73
|
+
total = 0
|
|
74
|
+
for root, _dirs, files in os.walk(path):
|
|
75
|
+
for name in files:
|
|
76
|
+
try:
|
|
77
|
+
total += os.lstat(os.path.join(root, name)).st_size
|
|
78
|
+
except OSError:
|
|
79
|
+
pass
|
|
80
|
+
return total
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def prune_quarantines(index_dir: Path, keep: int | None = None,
|
|
84
|
+
dry_run: bool = False, on_progress=None) -> dict:
|
|
85
|
+
"""Delete all but the newest *keep* ``quarantine_corrupt_*`` dirs in *index_dir*.
|
|
86
|
+
|
|
87
|
+
``keep=None`` keeps the default of two.
|
|
88
|
+
|
|
89
|
+
Newest is by name, which embeds the quarantine timestamp. Symlinks and
|
|
90
|
+
non-directories matching the pattern are never touched. A dir that cannot
|
|
91
|
+
be fully removed is reported in ``failed`` and retried by the next call.
|
|
92
|
+
``on_progress(done, total)`` runs after each dir. With ``dry_run`` nothing
|
|
93
|
+
is deleted and ``pruned`` / ``bytes`` describe what a real run would remove.
|
|
94
|
+
|
|
95
|
+
Returns ``{"kept": [...], "pruned": [...], "failed": [...], "bytes": int}``
|
|
96
|
+
with directory names; ``bytes`` is the size freed (or that would be freed).
|
|
97
|
+
"""
|
|
98
|
+
try:
|
|
99
|
+
found = sorted(p for p in Path(index_dir).glob("quarantine_corrupt_*")
|
|
100
|
+
if p.is_dir() and not p.is_symlink())
|
|
101
|
+
except OSError:
|
|
102
|
+
found = []
|
|
103
|
+
keep = _QUARANTINE_KEEP if keep is None else max(int(keep), 0)
|
|
104
|
+
doomed = found[:len(found) - keep] if keep < len(found) else []
|
|
105
|
+
result = {"kept": [p.name for p in found[len(doomed):]],
|
|
106
|
+
"pruned": [], "failed": [], "bytes": 0}
|
|
107
|
+
for done, old in enumerate(doomed, 1):
|
|
108
|
+
size = _tree_bytes(old)
|
|
109
|
+
if dry_run:
|
|
110
|
+
result["pruned"].append(old.name)
|
|
111
|
+
result["bytes"] += size
|
|
112
|
+
else:
|
|
113
|
+
shutil.rmtree(old, ignore_errors=True)
|
|
114
|
+
left = _tree_bytes(old) if old.exists() else 0
|
|
115
|
+
result["bytes"] += size - left
|
|
116
|
+
result["failed" if old.exists() else "pruned"].append(old.name)
|
|
117
|
+
if on_progress:
|
|
118
|
+
on_progress(done, len(doomed))
|
|
119
|
+
return result
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _private_bytes(proc) -> int:
|
|
123
|
+
"""Committed (Windows) or resident (POSIX) bytes of a psutil.Process."""
|
|
124
|
+
info = proc.memory_info()
|
|
125
|
+
return int(getattr(info, "private", 0) or info.rss)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _windows_job_memory_cap(pid: int, cap_bytes: int):
|
|
129
|
+
"""Put *pid* in a Job Object whose per-process commit limit is *cap_bytes*.
|
|
130
|
+
|
|
131
|
+
Polling alone is not enough here: the corrupt-segment loader committed
|
|
132
|
+
~100 GB between two 100 ms polls (measured 2026-10-02). Under a job limit
|
|
133
|
+
the allocation fails inside the child instead. Returns the job handle
|
|
134
|
+
(keep it open while the child runs) or None when unsupported.
|
|
135
|
+
"""
|
|
136
|
+
if os.name != "nt":
|
|
137
|
+
return None
|
|
138
|
+
try:
|
|
139
|
+
import ctypes
|
|
140
|
+
from ctypes import wintypes
|
|
141
|
+
|
|
142
|
+
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
143
|
+
|
|
144
|
+
class IO_COUNTERS(ctypes.Structure):
|
|
145
|
+
_fields_ = [(n, ctypes.c_ulonglong) for n in (
|
|
146
|
+
"ReadOperationCount", "WriteOperationCount", "OtherOperationCount",
|
|
147
|
+
"ReadTransferCount", "WriteTransferCount", "OtherTransferCount")]
|
|
148
|
+
|
|
149
|
+
class BASIC(ctypes.Structure):
|
|
150
|
+
_fields_ = [
|
|
151
|
+
("PerProcessUserTimeLimit", ctypes.c_longlong),
|
|
152
|
+
("PerJobUserTimeLimit", ctypes.c_longlong),
|
|
153
|
+
("LimitFlags", wintypes.DWORD),
|
|
154
|
+
("MinimumWorkingSetSize", ctypes.c_size_t),
|
|
155
|
+
("MaximumWorkingSetSize", ctypes.c_size_t),
|
|
156
|
+
("ActiveProcessLimit", wintypes.DWORD),
|
|
157
|
+
("Affinity", ctypes.c_size_t),
|
|
158
|
+
("PriorityClass", wintypes.DWORD),
|
|
159
|
+
("SchedulingClass", wintypes.DWORD),
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
class EXTENDED(ctypes.Structure):
|
|
163
|
+
_fields_ = [
|
|
164
|
+
("BasicLimitInformation", BASIC),
|
|
165
|
+
("IoInfo", IO_COUNTERS),
|
|
166
|
+
("ProcessMemoryLimit", ctypes.c_size_t),
|
|
167
|
+
("JobMemoryLimit", ctypes.c_size_t),
|
|
168
|
+
("PeakProcessMemoryUsed", ctypes.c_size_t),
|
|
169
|
+
("PeakJobMemoryUsed", ctypes.c_size_t),
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
JOB_OBJECT_LIMIT_PROCESS_MEMORY = 0x100
|
|
173
|
+
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000
|
|
174
|
+
JobObjectExtendedLimitInformation = 9
|
|
175
|
+
PROCESS_SET_QUOTA, PROCESS_TERMINATE = 0x0100, 0x0001
|
|
176
|
+
|
|
177
|
+
k32.CreateJobObjectW.restype = wintypes.HANDLE
|
|
178
|
+
k32.OpenProcess.restype = wintypes.HANDLE
|
|
179
|
+
job = k32.CreateJobObjectW(None, None)
|
|
180
|
+
if not job:
|
|
181
|
+
return None
|
|
182
|
+
info = EXTENDED()
|
|
183
|
+
info.BasicLimitInformation.LimitFlags = (
|
|
184
|
+
JOB_OBJECT_LIMIT_PROCESS_MEMORY | JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE)
|
|
185
|
+
info.ProcessMemoryLimit = cap_bytes
|
|
186
|
+
if not k32.SetInformationJobObject(
|
|
187
|
+
wintypes.HANDLE(job), JobObjectExtendedLimitInformation,
|
|
188
|
+
ctypes.byref(info), ctypes.sizeof(info)):
|
|
189
|
+
k32.CloseHandle(wintypes.HANDLE(job))
|
|
190
|
+
return None
|
|
191
|
+
proc = k32.OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, False, pid)
|
|
192
|
+
if not proc:
|
|
193
|
+
k32.CloseHandle(wintypes.HANDLE(job))
|
|
194
|
+
return None
|
|
195
|
+
try:
|
|
196
|
+
assigned = k32.AssignProcessToJobObject(wintypes.HANDLE(job), wintypes.HANDLE(proc))
|
|
197
|
+
finally:
|
|
198
|
+
k32.CloseHandle(wintypes.HANDLE(proc))
|
|
199
|
+
if not assigned:
|
|
200
|
+
k32.CloseHandle(wintypes.HANDLE(job))
|
|
201
|
+
return None
|
|
202
|
+
return job
|
|
203
|
+
except Exception:
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _close_job(job) -> None:
|
|
208
|
+
if job is None:
|
|
209
|
+
return
|
|
210
|
+
try:
|
|
211
|
+
import ctypes
|
|
212
|
+
from ctypes import wintypes
|
|
213
|
+
ctypes.WinDLL("kernel32").CloseHandle(wintypes.HANDLE(job))
|
|
214
|
+
except Exception:
|
|
215
|
+
pass
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def run_isolated_probe(
|
|
219
|
+
args: list,
|
|
220
|
+
*,
|
|
221
|
+
cap_bytes: int,
|
|
222
|
+
timeout: float = _PROBE_TIMEOUT_SECONDS,
|
|
223
|
+
marker: str = _PROBE_OK_MARKER,
|
|
224
|
+
) -> tuple:
|
|
225
|
+
"""Run ``python <args>`` in a child that is killed if it outgrows *cap_bytes*.
|
|
226
|
+
|
|
227
|
+
Returns ``(ok, reason)``. ``ok`` needs a zero exit AND *marker* on stdout,
|
|
228
|
+
so a child that dies quietly is a failure, not a pass. The cap is enforced
|
|
229
|
+
by polling the child's memory (psutil when installed; RLIMIT_AS on POSIX
|
|
230
|
+
otherwise), which bounds the damage to the cap plus one poll interval.
|
|
231
|
+
"""
|
|
232
|
+
try:
|
|
233
|
+
import psutil # optional: C3 does not declare it
|
|
234
|
+
except Exception:
|
|
235
|
+
psutil = None
|
|
236
|
+
|
|
237
|
+
preexec = None
|
|
238
|
+
if psutil is None and os.name != "nt":
|
|
239
|
+
def preexec(): # pragma: no cover - POSIX without psutil
|
|
240
|
+
try: # best effort: macOS refuses to lower RLIMIT_AS
|
|
241
|
+
import resource
|
|
242
|
+
resource.setrlimit(resource.RLIMIT_AS, (cap_bytes, cap_bytes))
|
|
243
|
+
except (ValueError, OSError):
|
|
244
|
+
pass
|
|
245
|
+
|
|
246
|
+
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0) if os.name == "nt" else 0
|
|
247
|
+
import tempfile
|
|
248
|
+
|
|
249
|
+
# Files, not pipes: a child that logs more than a pipe buffer while nobody
|
|
250
|
+
# reads it would block and be misreported as a timeout.
|
|
251
|
+
with tempfile.TemporaryFile() as out_f, tempfile.TemporaryFile() as err_f:
|
|
252
|
+
child = subprocess.Popen(
|
|
253
|
+
[sys.executable, *args],
|
|
254
|
+
stdin=subprocess.DEVNULL,
|
|
255
|
+
stdout=out_f,
|
|
256
|
+
stderr=err_f,
|
|
257
|
+
preexec_fn=preexec,
|
|
258
|
+
creationflags=creationflags,
|
|
259
|
+
)
|
|
260
|
+
job = _windows_job_memory_cap(child.pid, cap_bytes)
|
|
261
|
+
try:
|
|
262
|
+
reason = _watch_probe(child, psutil, cap_bytes, timeout)
|
|
263
|
+
finally:
|
|
264
|
+
_close_job(job)
|
|
265
|
+
if reason:
|
|
266
|
+
return False, reason
|
|
267
|
+
out_f.seek(0)
|
|
268
|
+
err_f.seek(0)
|
|
269
|
+
text = out_f.read().decode("utf-8", "replace")
|
|
270
|
+
err = err_f.read().decode("utf-8", "replace")
|
|
271
|
+
if child.returncode == 0 and marker in text:
|
|
272
|
+
return True, ""
|
|
273
|
+
tail = err.strip().splitlines()
|
|
274
|
+
detail = tail[-1] if tail else f"exit code {child.returncode}"
|
|
275
|
+
capped = " (under a hard memory cap)" if job is not None else ""
|
|
276
|
+
return False, f"probe failed{capped}: {detail[:300]}"
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _watch_probe(child, psutil, cap_bytes, timeout) -> str:
|
|
280
|
+
"""Wait for *child*; kill it on the cap or the deadline. Returns a reason or ""."""
|
|
281
|
+
watched = None
|
|
282
|
+
if psutil is not None:
|
|
283
|
+
try:
|
|
284
|
+
watched = psutil.Process(child.pid)
|
|
285
|
+
except Exception:
|
|
286
|
+
watched = None
|
|
287
|
+
deadline = time.monotonic() + timeout
|
|
288
|
+
peak = 0
|
|
289
|
+
reason = ""
|
|
290
|
+
while child.poll() is None:
|
|
291
|
+
if watched is not None:
|
|
292
|
+
try:
|
|
293
|
+
peak = max(peak, _private_bytes(watched))
|
|
294
|
+
except Exception:
|
|
295
|
+
pass
|
|
296
|
+
if peak > cap_bytes:
|
|
297
|
+
reason = (f"probe exceeded its memory cap "
|
|
298
|
+
f"({peak / 1024 ** 3:.1f} GB > {cap_bytes / 1024 ** 3:.1f} GB)")
|
|
299
|
+
break
|
|
300
|
+
if time.monotonic() >= deadline:
|
|
301
|
+
reason = f"probe timed out after {timeout:.0f}s"
|
|
302
|
+
break
|
|
303
|
+
time.sleep(_PROBE_POLL_SECONDS)
|
|
304
|
+
if reason:
|
|
305
|
+
child.kill()
|
|
306
|
+
try:
|
|
307
|
+
child.wait(timeout=30)
|
|
308
|
+
except Exception:
|
|
309
|
+
pass
|
|
310
|
+
return reason
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
class _OwnerLock:
|
|
314
|
+
"""Non-blocking, process-lifetime exclusive lock on one file.
|
|
315
|
+
|
|
316
|
+
chromadb's PersistentClient is not safe for several processes at once, yet
|
|
317
|
+
every C3 MCP server for a project opened the same store. The OS drops the
|
|
318
|
+
lock when the holder dies, so a crashed server never strands it.
|
|
319
|
+
"""
|
|
320
|
+
|
|
321
|
+
def __init__(self, path: Path):
|
|
322
|
+
self.path = path
|
|
323
|
+
self._fh = None
|
|
324
|
+
|
|
325
|
+
@property
|
|
326
|
+
def held(self) -> bool:
|
|
327
|
+
return self._fh is not None
|
|
328
|
+
|
|
329
|
+
def try_acquire(self) -> bool:
|
|
330
|
+
if self._fh is not None:
|
|
331
|
+
return True
|
|
332
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
333
|
+
fh = open(self.path, "ab") # never read/written; fd exists to hold the lock
|
|
334
|
+
try:
|
|
335
|
+
if os.name == "nt":
|
|
336
|
+
import msvcrt
|
|
337
|
+
fh.seek(0)
|
|
338
|
+
msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
|
|
339
|
+
else:
|
|
340
|
+
import fcntl
|
|
341
|
+
fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
342
|
+
except OSError:
|
|
343
|
+
fh.close()
|
|
344
|
+
return False
|
|
345
|
+
self._fh = fh
|
|
346
|
+
return True
|
|
347
|
+
|
|
348
|
+
def release(self) -> None:
|
|
349
|
+
fh, self._fh = self._fh, None
|
|
350
|
+
if fh is None:
|
|
351
|
+
return
|
|
352
|
+
try:
|
|
353
|
+
if os.name == "nt":
|
|
354
|
+
import msvcrt
|
|
355
|
+
fh.seek(0)
|
|
356
|
+
msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1)
|
|
357
|
+
else:
|
|
358
|
+
import fcntl
|
|
359
|
+
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
|
|
360
|
+
except OSError:
|
|
361
|
+
pass
|
|
362
|
+
finally:
|
|
363
|
+
fh.close()
|
|
364
|
+
|
|
31
365
|
|
|
32
366
|
class EmbeddingIndex:
|
|
33
367
|
"""Semantic code search via embeddings over CodeIndex chunks."""
|
|
@@ -62,6 +396,8 @@ class EmbeddingIndex:
|
|
|
62
396
|
self._chroma_client = None
|
|
63
397
|
self._collection = None
|
|
64
398
|
self._available = False
|
|
399
|
+
self._unavailable_why = "" # set when chromadb is installed but unusable
|
|
400
|
+
self._owner_lock = _OwnerLock(self._index_dir / _OWNER_LOCK_FILE)
|
|
65
401
|
self._ollama_ok = False
|
|
66
402
|
self._ollama_up = False
|
|
67
403
|
self._model_ok = False
|
|
@@ -112,9 +448,17 @@ class EmbeddingIndex:
|
|
|
112
448
|
self._open_chroma()
|
|
113
449
|
self._available = True
|
|
114
450
|
self._drop_legacy_collection()
|
|
115
|
-
except
|
|
451
|
+
except StoreBusyError as e:
|
|
452
|
+
log.info("embedding index disabled in this process: %s", e)
|
|
453
|
+
self._unavailable_why = str(e)
|
|
454
|
+
self._available = False
|
|
455
|
+
except ImportError as e:
|
|
116
456
|
log.debug("chromadb unavailable for embedding index: %s", e)
|
|
117
457
|
self._available = False
|
|
458
|
+
except Exception as e:
|
|
459
|
+
log.warning("embedding store unusable: %s", e)
|
|
460
|
+
self._unavailable_why = f"embedding store unusable: {e}"
|
|
461
|
+
self._available = False
|
|
118
462
|
|
|
119
463
|
try:
|
|
120
464
|
self._ollama_up = self.ollama.is_available(timeout=2)
|
|
@@ -144,7 +488,19 @@ class EmbeddingIndex:
|
|
|
144
488
|
from chromadb.config import Settings
|
|
145
489
|
|
|
146
490
|
persist_dir = self._index_dir / "chromadb"
|
|
491
|
+
if not self._owner_lock.try_acquire():
|
|
492
|
+
raise StoreBusyError(
|
|
493
|
+
"embedding store is in use by another C3 server for this "
|
|
494
|
+
"project; dense search is off in this one")
|
|
147
495
|
persist_dir.mkdir(parents=True, exist_ok=True)
|
|
496
|
+
if not _retried and self._store_has_segments(persist_dir):
|
|
497
|
+
ok, why = self._probe_store_isolated(persist_dir)
|
|
498
|
+
if not ok:
|
|
499
|
+
log.warning(
|
|
500
|
+
"embedding store failed its isolated probe (%s); "
|
|
501
|
+
"quarantining and rebuilding", why)
|
|
502
|
+
self._quarantine_store(persist_dir)
|
|
503
|
+
persist_dir.mkdir(parents=True, exist_ok=True)
|
|
148
504
|
self._chroma_client = chromadb.PersistentClient(
|
|
149
505
|
path=str(persist_dir),
|
|
150
506
|
settings=Settings(anonymized_telemetry=False),
|
|
@@ -179,17 +535,90 @@ class EmbeddingIndex:
|
|
|
179
535
|
pass
|
|
180
536
|
gc.collect()
|
|
181
537
|
|
|
538
|
+
@staticmethod
|
|
539
|
+
def _store_has_segments(persist_dir: Path) -> bool:
|
|
540
|
+
"""True when the store holds at least one on-disk HNSW segment."""
|
|
541
|
+
try:
|
|
542
|
+
return any(persist_dir.glob("*/header.bin"))
|
|
543
|
+
except OSError:
|
|
544
|
+
return False
|
|
545
|
+
|
|
546
|
+
@staticmethod
|
|
547
|
+
def _store_signature(persist_dir: Path) -> dict:
|
|
548
|
+
sig = {}
|
|
549
|
+
for f in sorted(persist_dir.rglob("*")):
|
|
550
|
+
try:
|
|
551
|
+
if f.is_file():
|
|
552
|
+
st = f.stat()
|
|
553
|
+
sig[f.relative_to(persist_dir).as_posix()] = [st.st_size, st.st_mtime_ns]
|
|
554
|
+
except OSError:
|
|
555
|
+
continue
|
|
556
|
+
return sig
|
|
557
|
+
|
|
558
|
+
def _probe_store_isolated(self, persist_dir: Path) -> tuple:
|
|
559
|
+
"""Open the store in a capped child first; skip if unchanged since a pass.
|
|
560
|
+
|
|
561
|
+
Returns ``(ok, reason)``. A pass is remembered against the files' sizes
|
|
562
|
+
and mtimes, so an untouched store is not re-probed on every start.
|
|
563
|
+
"""
|
|
564
|
+
sig = self._store_signature(persist_dir)
|
|
565
|
+
ok_file = self._index_dir / _PROBE_OK_FILE
|
|
566
|
+
try:
|
|
567
|
+
if json.loads(ok_file.read_text(encoding="utf-8")) == sig:
|
|
568
|
+
return True, ""
|
|
569
|
+
except Exception:
|
|
570
|
+
pass
|
|
571
|
+
store_bytes = sum(v[0] for v in sig.values())
|
|
572
|
+
cap = max(_PROBE_MIN_CAP_BYTES, _PROBE_CAP_STORE_MULTIPLE * store_bytes)
|
|
573
|
+
started = time.monotonic()
|
|
574
|
+
try:
|
|
575
|
+
ok, why = run_isolated_probe(
|
|
576
|
+
["-c", _PROBE_CHILD_CODE, str(persist_dir), COLLECTION_NAME],
|
|
577
|
+
cap_bytes=cap,
|
|
578
|
+
)
|
|
579
|
+
except Exception as e: # could not spawn: the in-process probe still runs
|
|
580
|
+
log.warning("isolated store probe could not run (%s); skipping", e)
|
|
581
|
+
return True, ""
|
|
582
|
+
log.info("isolated store probe: ok=%s in %.1fs %s",
|
|
583
|
+
ok, time.monotonic() - started, why)
|
|
584
|
+
if ok:
|
|
585
|
+
try:
|
|
586
|
+
# The child may have compacted the store; record what it left.
|
|
587
|
+
ok_file.write_text(json.dumps(self._store_signature(persist_dir)),
|
|
588
|
+
encoding="utf-8")
|
|
589
|
+
except OSError:
|
|
590
|
+
pass
|
|
591
|
+
return ok, why
|
|
592
|
+
|
|
593
|
+
def _prune_quarantines(self) -> None:
|
|
594
|
+
prune_quarantines(self._index_dir)
|
|
595
|
+
|
|
182
596
|
def _quarantine_store(self, persist_dir: Path) -> None:
|
|
183
597
|
"""Move a corrupt store and its hash file aside for post-mortem.
|
|
184
598
|
|
|
185
599
|
The hashes go with it: they claim vectors that the fresh store does not
|
|
186
600
|
have, so leaving them behind would suppress the very rebuild this is
|
|
187
601
|
meant to trigger.
|
|
602
|
+
|
|
603
|
+
Rename only, never copy. When another process still had the files open,
|
|
604
|
+
``shutil.move`` fell back to copying the whole store and then failed to
|
|
605
|
+
delete the original, so every session left one more full copy behind
|
|
606
|
+
and reopened the same corrupt store (issue #180). A failed rename now
|
|
607
|
+
raises and the index degrades for this process.
|
|
188
608
|
"""
|
|
189
609
|
self._close_chroma()
|
|
190
|
-
dest = self._index_dir / f"quarantine_corrupt_{datetime.now():%Y%m%d_%H%M%
|
|
610
|
+
dest = self._index_dir / f"quarantine_corrupt_{datetime.now():%Y%m%d_%H%M%S_%f}"
|
|
191
611
|
dest.mkdir(parents=True, exist_ok=True)
|
|
192
|
-
|
|
612
|
+
try:
|
|
613
|
+
os.rename(str(persist_dir), str(dest / persist_dir.name))
|
|
614
|
+
except OSError as e:
|
|
615
|
+
try:
|
|
616
|
+
dest.rmdir()
|
|
617
|
+
except OSError:
|
|
618
|
+
pass
|
|
619
|
+
raise RuntimeError(
|
|
620
|
+
f"could not move the corrupt embedding store aside ({e})") from e
|
|
621
|
+
(self._index_dir / _PROBE_OK_FILE).unlink(missing_ok=True)
|
|
193
622
|
try:
|
|
194
623
|
if self._hash_file.exists():
|
|
195
624
|
shutil.move(str(self._hash_file), str(dest / self._hash_file.name))
|
|
@@ -197,6 +626,7 @@ class EmbeddingIndex:
|
|
|
197
626
|
self._hash_file.unlink(missing_ok=True)
|
|
198
627
|
self._file_hashes = {}
|
|
199
628
|
log.warning("quarantined corrupt embedding store to %s", dest)
|
|
629
|
+
self._prune_quarantines()
|
|
200
630
|
|
|
201
631
|
def _drop_legacy_collection(self) -> None:
|
|
202
632
|
"""Best-effort removal of the pre-2.108.0 collection and hash file.
|
|
@@ -290,7 +720,7 @@ class EmbeddingIndex:
|
|
|
290
720
|
def unavailable_reason(self) -> str:
|
|
291
721
|
"""Human-readable reason ``ready`` is False (after probe/init)."""
|
|
292
722
|
if not self._available:
|
|
293
|
-
return "chromadb not installed"
|
|
723
|
+
return self._unavailable_why or "chromadb not installed"
|
|
294
724
|
if not self._ollama_up:
|
|
295
725
|
return "Ollama not reachable"
|
|
296
726
|
if not self._model_ok:
|
|
File without changes
|
{code_context_control-2.153.0.dist-info → code_context_control-2.154.0.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{code_context_control-2.153.0.dist-info → code_context_control-2.154.0.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
{code_context_control-2.153.0.dist-info → code_context_control-2.154.0.dist-info}/top_level.txt
RENAMED
|
File without changes
|