codegraph-engine 2.3.0__py3-none-any.whl → 3.0.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 +1 -1
- codegraph/__main__.py +7 -0
- codegraph/agent_brain.py +253 -2
- codegraph/agent_capabilities.py +1348 -67
- codegraph/agent_rules.py +2 -1
- codegraph/api_drift.py +455 -0
- codegraph/architecture_rules.py +123 -0
- codegraph/async_queue/__init__.py +50 -0
- codegraph/async_queue/detectors/__init__.py +48 -0
- codegraph/async_queue/detectors/celery.py +160 -0
- codegraph/async_queue/detectors/kafka.py +168 -0
- codegraph/async_queue/detectors/rabbitmq.py +116 -0
- codegraph/async_queue/detectors/redis.py +130 -0
- codegraph/async_queue/detectors/sqs.py +135 -0
- codegraph/async_queue/models.py +161 -0
- codegraph/async_queue/otel_models.py +74 -0
- codegraph/async_queue/otel_parser.py +337 -0
- codegraph/async_queue/reconciler.py +427 -0
- codegraph/async_queue/scanner.py +291 -0
- codegraph/async_queue/traversal.py +447 -0
- codegraph/change_impact.py +320 -110
- codegraph/changelog.py +170 -0
- codegraph/cli.py +1605 -62
- codegraph/cli_output.py +17 -2
- codegraph/context.py +135 -24
- codegraph/context_freshness.py +2 -0
- codegraph/database/__init__.py +14 -0
- codegraph/database/extractor.py +712 -17
- codegraph/database/interrogation.py +388 -2
- codegraph/database/schema_drift.py +862 -0
- codegraph/dataflow_propagation.py +118 -0
- codegraph/dead_code.py +387 -0
- codegraph/epistemic.py +15 -0
- codegraph/evidence_contract.py +34 -3
- codegraph/federation.py +245 -0
- codegraph/frameworks.py +234 -12
- codegraph/frameworks_nestjs.py +502 -0
- codegraph/frameworks_nextjs.py +337 -0
- codegraph/frameworks_react.py +457 -0
- codegraph/freshness.py +2 -0
- codegraph/frontend_html.py +109 -0
- codegraph/git.py +2 -0
- codegraph/git_state.py +2 -0
- codegraph/graph/traversal.py +150 -7
- codegraph/graph_csr.py +218 -0
- codegraph/incremental.py +150 -0
- codegraph/indexing/__init__.py +7 -1
- codegraph/indexing/classifier.py +5 -0
- codegraph/indexing/indexer.py +292 -38
- codegraph/indexing/languages/__init__.py +1 -0
- codegraph/indexing/languages/cpp.py +457 -0
- codegraph/indexing/languages/go.py +459 -0
- codegraph/indexing/languages/jvm.py +583 -0
- codegraph/indexing/languages/rust.py +507 -0
- codegraph/indexing/models.py +15 -1
- codegraph/indexing/parser.py +385 -9
- codegraph/indexing/pool.py +136 -0
- codegraph/indexing/protocols.py +220 -0
- codegraph/indexing/scanner.py +23 -0
- codegraph/indexing/templates.py +418 -0
- codegraph/interrogation.py +207 -39
- codegraph/lsp.py +318 -0
- codegraph/lsp_bridge/__init__.py +16 -0
- codegraph/lsp_bridge/bridge.py +102 -0
- codegraph/lsp_bridge/client.py +207 -0
- codegraph/lsp_bridge/models.py +71 -0
- codegraph/mcp/server.py +502 -29
- codegraph/memory/trajectory.py +282 -0
- codegraph/models.py +7 -0
- codegraph/monorepo.py +3 -0
- codegraph/multi_agent/__init__.py +13 -0
- codegraph/multi_agent/coordinator.py +229 -0
- codegraph/multi_agent/models.py +76 -0
- codegraph/optimizer.py +42 -5
- codegraph/polymorphism.py +95 -0
- codegraph/pr_review.py +296 -0
- codegraph/process_lifecycle.py +50 -12
- codegraph/reachability.py +198 -0
- codegraph/reachability_index.py +74 -0
- codegraph/refactor/__init__.py +48 -0
- codegraph/refactor/models.py +114 -0
- codegraph/refactor/patcher.py +348 -0
- codegraph/refactor/renamer.py +844 -0
- codegraph/refactor/transactions.py +414 -0
- codegraph/repair.py +233 -0
- codegraph/resolver.py +1357 -161
- codegraph/retrieval_policy.py +22 -1
- codegraph/roblox.py +2174 -0
- codegraph/route_composer.py +7 -0
- codegraph/route_topology.py +172 -0
- codegraph/runtime/__init__.py +25 -0
- codegraph/runtime/collector.py +155 -0
- codegraph/runtime/db_watcher.py +191 -0
- codegraph/runtime/ingestor.py +30 -10
- codegraph/runtime/otel_receiver.py +208 -0
- codegraph/runtime/port_inspector.py +304 -0
- codegraph/runtime/runner.py +155 -6
- codegraph/runtime/trace_linker.py +175 -0
- codegraph/sandbox.py +215 -0
- codegraph/search/hybrid.py +26 -0
- codegraph/security/supply_chain.py +188 -0
- codegraph/security/taint.py +421 -0
- codegraph/semantic_decorators.py +34 -3
- codegraph/structural_diff.py +2 -0
- codegraph/styles.py +144 -0
- codegraph/symbol_history.py +2 -0
- codegraph/test_gen.py +437 -0
- codegraph/tsconfig.py +236 -0
- codegraph/ui/__init__.py +6 -0
- codegraph/ui/server.py +876 -0
- codegraph/watcher.py +323 -0
- {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.1.dist-info}/METADATA +365 -67
- codegraph_engine-3.0.1.dist-info/RECORD +164 -0
- {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.1.dist-info}/WHEEL +1 -1
- codegraph_engine-2.3.0.dist-info/RECORD +0 -94
- {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.1.dist-info}/entry_points.txt +0 -0
- {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.1.dist-info}/licenses/LICENSE +0 -0
- {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Long-Term Agent Trajectory & Debugging Memory for CodeGraph.
|
|
2
|
+
|
|
3
|
+
Maintains persistent, FTS5 full-text indexed records of past agent debugging sessions,
|
|
4
|
+
bug investigations, test repair trajectories, and architectural decisions to accelerate future
|
|
5
|
+
agent tasks and eliminate repeated troubleshooting mistakes.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
import sqlite3
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class AgentMemoryRecord:
|
|
21
|
+
id: str
|
|
22
|
+
created_at: str
|
|
23
|
+
session_id: str
|
|
24
|
+
category: str # BUG_FIX, ARCHITECTURE_DECISION, TEST_PATTERN, ROOT_CAUSE, REFACTORING
|
|
25
|
+
title: str
|
|
26
|
+
symptoms: str
|
|
27
|
+
root_cause: str
|
|
28
|
+
resolution: str
|
|
29
|
+
files: list[str] = field(default_factory=list)
|
|
30
|
+
tags: list[str] = field(default_factory=list)
|
|
31
|
+
outcome: str = "SUCCESS"
|
|
32
|
+
score: float = 1.0
|
|
33
|
+
|
|
34
|
+
def as_dict(self) -> dict[str, Any]:
|
|
35
|
+
return {
|
|
36
|
+
"id": self.id,
|
|
37
|
+
"created_at": self.created_at,
|
|
38
|
+
"session_id": self.session_id,
|
|
39
|
+
"category": self.category,
|
|
40
|
+
"title": self.title,
|
|
41
|
+
"symptoms": self.symptoms,
|
|
42
|
+
"root_cause": self.root_cause,
|
|
43
|
+
"resolution": self.resolution,
|
|
44
|
+
"files": self.files,
|
|
45
|
+
"tags": self.tags,
|
|
46
|
+
"outcome": self.outcome,
|
|
47
|
+
"score": round(self.score, 3),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class TrajectoryMemoryStore:
|
|
52
|
+
"""Persistent SQLite + FTS5 store for agent debugging trajectories."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, db_path: Path) -> None:
|
|
55
|
+
self.db_path = db_path
|
|
56
|
+
self._init_db()
|
|
57
|
+
|
|
58
|
+
def _init_db(self) -> None:
|
|
59
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
with sqlite3.connect(self.db_path) as con:
|
|
61
|
+
con.execute("PRAGMA journal_mode = WAL")
|
|
62
|
+
con.execute("PRAGMA synchronous = NORMAL")
|
|
63
|
+
con.execute(
|
|
64
|
+
"""
|
|
65
|
+
CREATE TABLE IF NOT EXISTS agent_trajectories (
|
|
66
|
+
id TEXT PRIMARY KEY,
|
|
67
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
68
|
+
session_id TEXT NOT NULL,
|
|
69
|
+
category TEXT NOT NULL,
|
|
70
|
+
title TEXT NOT NULL,
|
|
71
|
+
symptoms TEXT NOT NULL DEFAULT '',
|
|
72
|
+
root_cause TEXT NOT NULL DEFAULT '',
|
|
73
|
+
resolution TEXT NOT NULL DEFAULT '',
|
|
74
|
+
files_json TEXT NOT NULL DEFAULT '[]',
|
|
75
|
+
tags_json TEXT NOT NULL DEFAULT '[]',
|
|
76
|
+
outcome TEXT NOT NULL DEFAULT 'SUCCESS'
|
|
77
|
+
)
|
|
78
|
+
"""
|
|
79
|
+
)
|
|
80
|
+
con.execute(
|
|
81
|
+
"""
|
|
82
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS agent_trajectories_fts USING fts5(
|
|
83
|
+
id UNINDEXED,
|
|
84
|
+
title,
|
|
85
|
+
symptoms,
|
|
86
|
+
root_cause,
|
|
87
|
+
resolution,
|
|
88
|
+
content='agent_trajectories',
|
|
89
|
+
content_rowid='rowid'
|
|
90
|
+
)
|
|
91
|
+
"""
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def store(
|
|
95
|
+
self,
|
|
96
|
+
title: str,
|
|
97
|
+
resolution: str,
|
|
98
|
+
category: str = "BUG_FIX",
|
|
99
|
+
session_id: str | None = None,
|
|
100
|
+
symptoms: str = "",
|
|
101
|
+
root_cause: str = "",
|
|
102
|
+
files: list[str] | None = None,
|
|
103
|
+
tags: list[str] | None = None,
|
|
104
|
+
outcome: str = "SUCCESS",
|
|
105
|
+
) -> AgentMemoryRecord:
|
|
106
|
+
"""Store an agent debugging or architectural trajectory into memory."""
|
|
107
|
+
mem_id = f"mem_{uuid.uuid4().hex[:12]}"
|
|
108
|
+
sess = session_id or f"sess_{uuid.uuid4().hex[:8]}"
|
|
109
|
+
files_json = json.dumps(files or [])
|
|
110
|
+
tags_json = json.dumps(tags or [])
|
|
111
|
+
|
|
112
|
+
with sqlite3.connect(self.db_path) as con:
|
|
113
|
+
cur = con.cursor()
|
|
114
|
+
cur.execute(
|
|
115
|
+
"""
|
|
116
|
+
INSERT INTO agent_trajectories (
|
|
117
|
+
id, session_id, category, title, symptoms, root_cause, resolution, files_json, tags_json, outcome
|
|
118
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
119
|
+
""",
|
|
120
|
+
(mem_id, sess, category, title, symptoms, root_cause, resolution, files_json, tags_json, outcome),
|
|
121
|
+
)
|
|
122
|
+
# Update FTS5
|
|
123
|
+
cur.execute(
|
|
124
|
+
"""
|
|
125
|
+
INSERT INTO agent_trajectories_fts (rowid, id, title, symptoms, root_cause, resolution)
|
|
126
|
+
VALUES (last_insert_rowid(), ?, ?, ?, ?, ?)
|
|
127
|
+
""",
|
|
128
|
+
(mem_id, title, symptoms, root_cause, resolution),
|
|
129
|
+
)
|
|
130
|
+
con.commit()
|
|
131
|
+
|
|
132
|
+
return AgentMemoryRecord(
|
|
133
|
+
id=mem_id,
|
|
134
|
+
created_at=time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
135
|
+
session_id=sess,
|
|
136
|
+
category=category,
|
|
137
|
+
title=title,
|
|
138
|
+
symptoms=symptoms,
|
|
139
|
+
root_cause=root_cause,
|
|
140
|
+
resolution=resolution,
|
|
141
|
+
files=files or [],
|
|
142
|
+
tags=tags or [],
|
|
143
|
+
outcome=outcome,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def search(
|
|
147
|
+
self,
|
|
148
|
+
query: str,
|
|
149
|
+
category: str | None = None,
|
|
150
|
+
limit: int = 5,
|
|
151
|
+
max_tokens: int = 1500,
|
|
152
|
+
) -> list[AgentMemoryRecord]:
|
|
153
|
+
"""Search past agent memories using FTS5 BM25 relevance and recency weighting."""
|
|
154
|
+
clean_q = query.strip()
|
|
155
|
+
if not clean_q:
|
|
156
|
+
return self.list_recent(limit=limit)
|
|
157
|
+
|
|
158
|
+
records: list[AgentMemoryRecord] = []
|
|
159
|
+
with sqlite3.connect(self.db_path) as con:
|
|
160
|
+
con.row_factory = sqlite3.Row
|
|
161
|
+
# FTS5 search
|
|
162
|
+
safe_query = re.sub(r'[^\w\s]', ' ', clean_q).strip()
|
|
163
|
+
if not safe_query:
|
|
164
|
+
return self.list_recent(limit=limit)
|
|
165
|
+
|
|
166
|
+
match_clause = "agent_trajectories_fts MATCH ?"
|
|
167
|
+
params: list[Any] = [safe_query]
|
|
168
|
+
|
|
169
|
+
cat_clause = ""
|
|
170
|
+
if category:
|
|
171
|
+
cat_clause = "AND t.category = ?"
|
|
172
|
+
params.append(category)
|
|
173
|
+
|
|
174
|
+
sql = f"""
|
|
175
|
+
SELECT t.id, t.created_at, t.session_id, t.category, t.title,
|
|
176
|
+
t.symptoms, t.root_cause, t.resolution, t.files_json, t.tags_json, t.outcome,
|
|
177
|
+
bm25(agent_trajectories_fts) as rank
|
|
178
|
+
FROM agent_trajectories_fts fts
|
|
179
|
+
JOIN agent_trajectories t ON t.rowid = fts.rowid
|
|
180
|
+
WHERE {match_clause} {cat_clause}
|
|
181
|
+
ORDER BY rank ASC
|
|
182
|
+
LIMIT ?
|
|
183
|
+
"""
|
|
184
|
+
params.append(limit * 2)
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
rows = con.execute(sql, params).fetchall()
|
|
188
|
+
except sqlite3.OperationalError:
|
|
189
|
+
# Fallback to LIKE
|
|
190
|
+
like_sql = """
|
|
191
|
+
SELECT id, created_at, session_id, category, title, symptoms, root_cause, resolution,
|
|
192
|
+
files_json, tags_json, outcome, 1.0 as rank
|
|
193
|
+
FROM agent_trajectories
|
|
194
|
+
WHERE title LIKE ? OR resolution LIKE ? OR symptoms LIKE ?
|
|
195
|
+
ORDER BY id DESC LIMIT ?
|
|
196
|
+
"""
|
|
197
|
+
rows = con.execute(like_sql, (f"%{clean_q}%", f"%{clean_q}%", f"%{clean_q}%", limit)).fetchall()
|
|
198
|
+
|
|
199
|
+
total_chars = 0
|
|
200
|
+
char_budget = max_tokens * 4
|
|
201
|
+
|
|
202
|
+
for r in rows:
|
|
203
|
+
files = json.loads(r["files_json"]) if r["files_json"] else []
|
|
204
|
+
tags = json.loads(r["tags_json"]) if r["tags_json"] else []
|
|
205
|
+
bm_score = float(r["rank"])
|
|
206
|
+
relevance = round(1.0 / (1.0 + max(0.0, bm_score)), 3)
|
|
207
|
+
|
|
208
|
+
rec = AgentMemoryRecord(
|
|
209
|
+
id=r["id"],
|
|
210
|
+
created_at=str(r["created_at"]),
|
|
211
|
+
session_id=r["session_id"],
|
|
212
|
+
category=r["category"],
|
|
213
|
+
title=r["title"],
|
|
214
|
+
symptoms=r["symptoms"],
|
|
215
|
+
root_cause=r["root_cause"],
|
|
216
|
+
resolution=r["resolution"],
|
|
217
|
+
files=files,
|
|
218
|
+
tags=tags,
|
|
219
|
+
outcome=r["outcome"],
|
|
220
|
+
score=relevance,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# Token budget check
|
|
224
|
+
rec_len = len(rec.title) + len(rec.symptoms) + len(rec.root_cause) + len(rec.resolution)
|
|
225
|
+
if total_chars + rec_len > char_budget and records:
|
|
226
|
+
break
|
|
227
|
+
total_chars += rec_len
|
|
228
|
+
records.append(rec)
|
|
229
|
+
if len(records) >= limit:
|
|
230
|
+
break
|
|
231
|
+
|
|
232
|
+
return records
|
|
233
|
+
|
|
234
|
+
def list_recent(self, limit: int = 10) -> list[AgentMemoryRecord]:
|
|
235
|
+
"""List most recent agent memories."""
|
|
236
|
+
records: list[AgentMemoryRecord] = []
|
|
237
|
+
with sqlite3.connect(self.db_path) as con:
|
|
238
|
+
con.row_factory = sqlite3.Row
|
|
239
|
+
rows = con.execute(
|
|
240
|
+
"""
|
|
241
|
+
SELECT id, created_at, session_id, category, title, symptoms, root_cause,
|
|
242
|
+
resolution, files_json, tags_json, outcome
|
|
243
|
+
FROM agent_trajectories
|
|
244
|
+
ORDER BY rowid DESC
|
|
245
|
+
LIMIT ?
|
|
246
|
+
""",
|
|
247
|
+
(limit,),
|
|
248
|
+
).fetchall()
|
|
249
|
+
|
|
250
|
+
for r in rows:
|
|
251
|
+
files = json.loads(r["files_json"]) if r["files_json"] else []
|
|
252
|
+
tags = json.loads(r["tags_json"]) if r["tags_json"] else []
|
|
253
|
+
records.append(
|
|
254
|
+
AgentMemoryRecord(
|
|
255
|
+
id=r["id"],
|
|
256
|
+
created_at=str(r["created_at"]),
|
|
257
|
+
session_id=r["session_id"],
|
|
258
|
+
category=r["category"],
|
|
259
|
+
title=r["title"],
|
|
260
|
+
symptoms=r["symptoms"],
|
|
261
|
+
root_cause=r["root_cause"],
|
|
262
|
+
resolution=r["resolution"],
|
|
263
|
+
files=files,
|
|
264
|
+
tags=tags,
|
|
265
|
+
outcome=r["outcome"],
|
|
266
|
+
score=1.0,
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
return records
|
|
270
|
+
|
|
271
|
+
def clear(self) -> None:
|
|
272
|
+
"""Clear all stored agent trajectories."""
|
|
273
|
+
with sqlite3.connect(self.db_path) as con:
|
|
274
|
+
con.execute("DELETE FROM agent_trajectories")
|
|
275
|
+
con.execute("DELETE FROM agent_trajectories_fts")
|
|
276
|
+
con.commit()
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def get_trajectory_memory_store(repository: Path) -> TrajectoryMemoryStore:
|
|
280
|
+
"""Get the default trajectory memory store for a repository."""
|
|
281
|
+
db_path = repository.resolve() / ".codegraph" / "memory.sqlite3"
|
|
282
|
+
return TrajectoryMemoryStore(db_path)
|
codegraph/models.py
CHANGED
|
@@ -56,6 +56,13 @@ EdgeType = Literal[
|
|
|
56
56
|
"EVENT_LISTENER",
|
|
57
57
|
"TASK_HANDLER",
|
|
58
58
|
"COMMAND_HANDLER",
|
|
59
|
+
"DISPATCHES_TASK",
|
|
60
|
+
"TRIGGERS_SIGNAL",
|
|
61
|
+
"HANDLES_SIGNAL",
|
|
62
|
+
"RENDERS_TEMPLATE",
|
|
63
|
+
"DISPATCHES_FORWARD",
|
|
64
|
+
"TOOL_HANDLER",
|
|
65
|
+
"PIPELINE_STEP",
|
|
59
66
|
"TESTS",
|
|
60
67
|
"TESTS_ROUTE",
|
|
61
68
|
"TESTS_SYMBOL",
|
codegraph/monorepo.py
CHANGED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Multi-Agent Coordination & Shared Working State for CodeGraph."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from .coordinator import MultiAgentCoordinator
|
|
5
|
+
from .models import AgentSession, FileLease, MultiAgentStatusReport, SemanticConflict
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"AgentSession",
|
|
9
|
+
"FileLease",
|
|
10
|
+
"MultiAgentCoordinator",
|
|
11
|
+
"MultiAgentStatusReport",
|
|
12
|
+
"SemanticConflict",
|
|
13
|
+
]
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Multi-Agent Coordinator & Shared Working State Manager for CodeGraph.
|
|
2
|
+
|
|
3
|
+
Provides concurrent multi-agent collaboration with:
|
|
4
|
+
- SQLite-backed agent registration and TTL heartbeats (.codegraph/agents.sqlite3)
|
|
5
|
+
- File and module lease locks (EXCLUSIVE_WRITE vs SHARED_READ)
|
|
6
|
+
- Automatic reaping of dead/orphaned agent leases
|
|
7
|
+
- Semantic AST conflict prediction between concurrent agent edits
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sqlite3
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from .models import AgentSession, FileLease, MultiAgentStatusReport, SemanticConflict
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class MultiAgentCoordinator:
|
|
19
|
+
"""Coordinates multiple AI coding agents working concurrently on a repository."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, repository: Path):
|
|
22
|
+
self.repository = repository.resolve()
|
|
23
|
+
self.dot_codegraph = self.repository / ".codegraph"
|
|
24
|
+
self.dot_codegraph.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
self.db_path = self.dot_codegraph / "agents.sqlite3"
|
|
26
|
+
self._init_db()
|
|
27
|
+
|
|
28
|
+
def _init_db(self) -> None:
|
|
29
|
+
with sqlite3.connect(str(self.db_path)) as con:
|
|
30
|
+
con.execute("PRAGMA journal_mode=WAL;")
|
|
31
|
+
con.execute("PRAGMA synchronous=NORMAL;")
|
|
32
|
+
con.execute("""
|
|
33
|
+
CREATE TABLE IF NOT EXISTS agent_sessions (
|
|
34
|
+
agent_id TEXT PRIMARY KEY,
|
|
35
|
+
name TEXT NOT NULL,
|
|
36
|
+
task_description TEXT NOT NULL,
|
|
37
|
+
registered_at REAL NOT NULL,
|
|
38
|
+
last_heartbeat REAL NOT NULL,
|
|
39
|
+
status TEXT NOT NULL
|
|
40
|
+
);
|
|
41
|
+
""")
|
|
42
|
+
con.execute("""
|
|
43
|
+
CREATE TABLE IF NOT EXISTS file_leases (
|
|
44
|
+
file_path TEXT NOT NULL,
|
|
45
|
+
agent_id TEXT NOT NULL,
|
|
46
|
+
lease_type TEXT NOT NULL,
|
|
47
|
+
granted_at REAL NOT NULL,
|
|
48
|
+
expires_at REAL NOT NULL,
|
|
49
|
+
PRIMARY KEY (file_path, agent_id)
|
|
50
|
+
);
|
|
51
|
+
""")
|
|
52
|
+
con.commit()
|
|
53
|
+
|
|
54
|
+
def register_session(self, agent_id: str, name: str, task_description: str) -> AgentSession:
|
|
55
|
+
"""Register a new or re-activating AI agent session."""
|
|
56
|
+
now = time.time()
|
|
57
|
+
with sqlite3.connect(str(self.db_path)) as con:
|
|
58
|
+
con.execute("""
|
|
59
|
+
INSERT INTO agent_sessions (agent_id, name, task_description, registered_at, last_heartbeat, status)
|
|
60
|
+
VALUES (?, ?, ?, ?, ?, 'ACTIVE')
|
|
61
|
+
ON CONFLICT(agent_id) DO UPDATE SET
|
|
62
|
+
name=excluded.name,
|
|
63
|
+
task_description=excluded.task_description,
|
|
64
|
+
last_heartbeat=excluded.last_heartbeat,
|
|
65
|
+
status='ACTIVE';
|
|
66
|
+
""", (agent_id, name, task_description, now, now))
|
|
67
|
+
con.commit()
|
|
68
|
+
return AgentSession(
|
|
69
|
+
agent_id=agent_id,
|
|
70
|
+
name=name,
|
|
71
|
+
task_description=task_description,
|
|
72
|
+
registered_at=now,
|
|
73
|
+
last_heartbeat=now,
|
|
74
|
+
status="ACTIVE",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def heartbeat(self, agent_id: str) -> bool:
|
|
78
|
+
"""Send heartbeat to maintain active session and leases."""
|
|
79
|
+
now = time.time()
|
|
80
|
+
with sqlite3.connect(str(self.db_path)) as con:
|
|
81
|
+
cur = con.execute("UPDATE agent_sessions SET last_heartbeat = ? WHERE agent_id = ?;", (now, agent_id))
|
|
82
|
+
con.commit()
|
|
83
|
+
return cur.rowcount > 0
|
|
84
|
+
|
|
85
|
+
def claim_lease(
|
|
86
|
+
self,
|
|
87
|
+
agent_id: str,
|
|
88
|
+
file_path: str,
|
|
89
|
+
lease_type: str = "EXCLUSIVE_WRITE",
|
|
90
|
+
ttl_seconds: float = 60.0,
|
|
91
|
+
) -> tuple[bool, str]:
|
|
92
|
+
"""Attempt to claim a lease on a file path."""
|
|
93
|
+
self.reap_expired_leases()
|
|
94
|
+
now = time.time()
|
|
95
|
+
expires = now + ttl_seconds
|
|
96
|
+
clean_path = str(Path(file_path).as_posix())
|
|
97
|
+
|
|
98
|
+
with sqlite3.connect(str(self.db_path)) as con:
|
|
99
|
+
# Check existing leases on path
|
|
100
|
+
cur = con.execute("SELECT agent_id, lease_type, expires_at FROM file_leases WHERE file_path = ?;", (clean_path,))
|
|
101
|
+
rows = cur.fetchall()
|
|
102
|
+
|
|
103
|
+
for existing_agent, existing_type, existing_exp in rows:
|
|
104
|
+
if existing_agent == agent_id:
|
|
105
|
+
# Renew own lease
|
|
106
|
+
con.execute("UPDATE file_leases SET expires_at = ? WHERE file_path = ? AND agent_id = ?;", (expires, clean_path, agent_id))
|
|
107
|
+
con.commit()
|
|
108
|
+
return True, "Renewed existing lease."
|
|
109
|
+
|
|
110
|
+
if existing_exp > now:
|
|
111
|
+
if lease_type == "EXCLUSIVE_WRITE" or existing_type == "EXCLUSIVE_WRITE":
|
|
112
|
+
return False, f"Conflict: file '{clean_path}' currently leased by agent '{existing_agent}' ({existing_type})."
|
|
113
|
+
|
|
114
|
+
con.execute("""
|
|
115
|
+
INSERT INTO file_leases (file_path, agent_id, lease_type, granted_at, expires_at)
|
|
116
|
+
VALUES (?, ?, ?, ?, ?)
|
|
117
|
+
ON CONFLICT(file_path, agent_id) DO UPDATE SET
|
|
118
|
+
lease_type=excluded.lease_type,
|
|
119
|
+
expires_at=excluded.expires_at;
|
|
120
|
+
""", (clean_path, agent_id, lease_type, now, expires))
|
|
121
|
+
con.commit()
|
|
122
|
+
return True, f"Lease granted on '{clean_path}' until {expires:.0f}."
|
|
123
|
+
|
|
124
|
+
def release_lease(self, agent_id: str, file_path: str | None = None) -> int:
|
|
125
|
+
"""Release leases held by agent. If file_path omitted, releases all leases for agent."""
|
|
126
|
+
with sqlite3.connect(str(self.db_path)) as con:
|
|
127
|
+
if file_path:
|
|
128
|
+
clean_path = str(Path(file_path).as_posix())
|
|
129
|
+
cur = con.execute("DELETE FROM file_leases WHERE agent_id = ? AND file_path = ?;", (agent_id, clean_path))
|
|
130
|
+
else:
|
|
131
|
+
cur = con.execute("DELETE FROM file_leases WHERE agent_id = ?;", (agent_id,))
|
|
132
|
+
con.commit()
|
|
133
|
+
return cur.rowcount
|
|
134
|
+
|
|
135
|
+
def reap_expired_leases(self) -> int:
|
|
136
|
+
"""Reap leases that have expired past TTL or whose agent heartbeat is stale (>60s)."""
|
|
137
|
+
now = time.time()
|
|
138
|
+
with sqlite3.connect(str(self.db_path)) as con:
|
|
139
|
+
cur = con.execute("DELETE FROM file_leases WHERE expires_at < ?;", (now,))
|
|
140
|
+
# Mark agents inactive if heartbeat older than 90s
|
|
141
|
+
con.execute("UPDATE agent_sessions SET status = 'INACTIVE' WHERE (? - last_heartbeat) > 90.0;", (now,))
|
|
142
|
+
con.commit()
|
|
143
|
+
return cur.rowcount
|
|
144
|
+
|
|
145
|
+
def check_conflicts(self, con: sqlite3.Connection | None = None) -> list[SemanticConflict]:
|
|
146
|
+
"""Inspect all active leases and detect direct write-write conflicts or downstream caller breaks."""
|
|
147
|
+
self.reap_expired_leases()
|
|
148
|
+
conflicts: list[SemanticConflict] = []
|
|
149
|
+
now = time.time()
|
|
150
|
+
|
|
151
|
+
with sqlite3.connect(str(self.db_path)) as agent_con:
|
|
152
|
+
rows = agent_con.execute(
|
|
153
|
+
"SELECT file_path, agent_id, lease_type FROM file_leases WHERE expires_at > ?;",
|
|
154
|
+
(now,),
|
|
155
|
+
).fetchall()
|
|
156
|
+
|
|
157
|
+
leases_by_file: dict[str, list[tuple[str, str]]] = {}
|
|
158
|
+
for fp, aid, ltype in rows:
|
|
159
|
+
leases_by_file.setdefault(fp, []).append((aid, ltype))
|
|
160
|
+
|
|
161
|
+
# 1. Direct multiple-writer conflict
|
|
162
|
+
for fp, holders in leases_by_file.items():
|
|
163
|
+
writers = [aid for aid, ltype in holders if ltype == "EXCLUSIVE_WRITE"]
|
|
164
|
+
if len(writers) > 1:
|
|
165
|
+
conflicts.append(
|
|
166
|
+
SemanticConflict(
|
|
167
|
+
file_path=fp,
|
|
168
|
+
conflicting_agents=tuple(writers),
|
|
169
|
+
conflict_type="WRITE_WRITE",
|
|
170
|
+
reason=f"Multiple agents {writers} hold concurrent write leases on '{fp}'.",
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# 2. Downstream caller contract breaks across leased files if repo connection available
|
|
175
|
+
if con is not None:
|
|
176
|
+
try:
|
|
177
|
+
leased_paths = list(leases_by_file.keys())
|
|
178
|
+
for fp in leased_paths:
|
|
179
|
+
agents_on_file = [aid for aid, _ in leases_by_file[fp]]
|
|
180
|
+
# Check calls where source_file is leased by another agent
|
|
181
|
+
cur_calls = con.execute(
|
|
182
|
+
"SELECT source_file, target_file FROM calls WHERE target_file = ? AND source_file != ?;",
|
|
183
|
+
(fp, fp),
|
|
184
|
+
)
|
|
185
|
+
for caller_file, _ in cur_calls.fetchall():
|
|
186
|
+
if caller_file in leases_by_file:
|
|
187
|
+
caller_agents = [aid for aid, _ in leases_by_file[caller_file]]
|
|
188
|
+
other_agents = [a for a in caller_agents if a not in agents_on_file]
|
|
189
|
+
if other_agents:
|
|
190
|
+
conflicts.append(
|
|
191
|
+
SemanticConflict(
|
|
192
|
+
file_path=fp,
|
|
193
|
+
conflicting_agents=tuple(sorted(set(agents_on_file + other_agents))),
|
|
194
|
+
conflict_type="DOWNSTREAM_CONTRACT_BREAK",
|
|
195
|
+
reason=f"File '{fp}' leased by {agents_on_file} has caller '{caller_file}' leased by {other_agents}.",
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
except Exception:
|
|
199
|
+
pass
|
|
200
|
+
|
|
201
|
+
return conflicts
|
|
202
|
+
|
|
203
|
+
def get_status_report(self) -> MultiAgentStatusReport:
|
|
204
|
+
"""Return consolidated multi-agent status report."""
|
|
205
|
+
self.reap_expired_leases()
|
|
206
|
+
now = time.time()
|
|
207
|
+
with sqlite3.connect(str(self.db_path)) as con:
|
|
208
|
+
cur_agents = con.execute("SELECT agent_id, name, task_description, registered_at, last_heartbeat, status FROM agent_sessions WHERE status = 'ACTIVE';")
|
|
209
|
+
agents = [
|
|
210
|
+
AgentSession(agent_id=r[0], name=r[1], task_description=r[2], registered_at=r[3], last_heartbeat=r[4], status=r[5])
|
|
211
|
+
for r in cur_agents.fetchall()
|
|
212
|
+
]
|
|
213
|
+
|
|
214
|
+
cur_leases = con.execute("SELECT file_path, agent_id, lease_type, granted_at, expires_at FROM file_leases WHERE expires_at > ?;", (now,))
|
|
215
|
+
leases = [
|
|
216
|
+
FileLease(file_path=r[0], agent_id=r[1], lease_type=r[2], granted_at=r[3], expires_at=r[4])
|
|
217
|
+
for r in cur_leases.fetchall()
|
|
218
|
+
]
|
|
219
|
+
|
|
220
|
+
conflicts = self.check_conflicts()
|
|
221
|
+
return MultiAgentStatusReport(
|
|
222
|
+
workspace=str(self.repository),
|
|
223
|
+
total_agents=len(agents),
|
|
224
|
+
active_leases_count=len(leases),
|
|
225
|
+
conflicts_count=len(conflicts),
|
|
226
|
+
agents=agents,
|
|
227
|
+
leases=leases,
|
|
228
|
+
conflicts=conflicts,
|
|
229
|
+
)
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Data models for Multi-Agent Shared Working State and Lease Management."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import asdict, dataclass, field
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class AgentSession:
|
|
10
|
+
"""An active AI coding agent registered in the repository workspace."""
|
|
11
|
+
|
|
12
|
+
agent_id: str
|
|
13
|
+
name: str
|
|
14
|
+
task_description: str
|
|
15
|
+
registered_at: float
|
|
16
|
+
last_heartbeat: float
|
|
17
|
+
status: str = "ACTIVE"
|
|
18
|
+
|
|
19
|
+
def as_dict(self) -> dict[str, Any]:
|
|
20
|
+
return asdict(self)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class FileLease:
|
|
25
|
+
"""A lease on a file or module held by an AI agent."""
|
|
26
|
+
|
|
27
|
+
file_path: str
|
|
28
|
+
agent_id: str
|
|
29
|
+
lease_type: str # EXCLUSIVE_WRITE | SHARED_READ
|
|
30
|
+
granted_at: float
|
|
31
|
+
expires_at: float
|
|
32
|
+
|
|
33
|
+
def as_dict(self) -> dict[str, Any]:
|
|
34
|
+
return asdict(self)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class SemanticConflict:
|
|
39
|
+
"""A detected or predicted conflict between multiple concurrent agents."""
|
|
40
|
+
|
|
41
|
+
file_path: str
|
|
42
|
+
conflicting_agents: tuple[str, ...]
|
|
43
|
+
conflict_type: str # WRITE_WRITE | DOWNSTREAM_CONTRACT_BREAK
|
|
44
|
+
reason: str
|
|
45
|
+
|
|
46
|
+
def as_dict(self) -> dict[str, Any]:
|
|
47
|
+
return {
|
|
48
|
+
"file_path": self.file_path,
|
|
49
|
+
"conflicting_agents": list(self.conflicting_agents),
|
|
50
|
+
"conflict_type": self.conflict_type,
|
|
51
|
+
"reason": self.reason,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class MultiAgentStatusReport:
|
|
57
|
+
"""Consolidated workspace report of active agents, leases, and conflicts."""
|
|
58
|
+
|
|
59
|
+
workspace: str
|
|
60
|
+
total_agents: int
|
|
61
|
+
active_leases_count: int
|
|
62
|
+
conflicts_count: int
|
|
63
|
+
agents: list[AgentSession] = field(default_factory=list)
|
|
64
|
+
leases: list[FileLease] = field(default_factory=list)
|
|
65
|
+
conflicts: list[SemanticConflict] = field(default_factory=list)
|
|
66
|
+
|
|
67
|
+
def as_dict(self) -> dict[str, Any]:
|
|
68
|
+
return {
|
|
69
|
+
"workspace": self.workspace,
|
|
70
|
+
"total_agents": self.total_agents,
|
|
71
|
+
"active_leases_count": self.active_leases_count,
|
|
72
|
+
"conflicts_count": self.conflicts_count,
|
|
73
|
+
"agents": [a.as_dict() for a in self.agents],
|
|
74
|
+
"leases": [lease.as_dict() for lease in self.leases],
|
|
75
|
+
"conflicts": [c.as_dict() for c in self.conflicts],
|
|
76
|
+
}
|