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.
Files changed (118) hide show
  1. codegraph/__init__.py +1 -1
  2. codegraph/__main__.py +7 -0
  3. codegraph/agent_brain.py +253 -2
  4. codegraph/agent_capabilities.py +1348 -67
  5. codegraph/agent_rules.py +2 -1
  6. codegraph/api_drift.py +455 -0
  7. codegraph/architecture_rules.py +123 -0
  8. codegraph/async_queue/__init__.py +50 -0
  9. codegraph/async_queue/detectors/__init__.py +48 -0
  10. codegraph/async_queue/detectors/celery.py +160 -0
  11. codegraph/async_queue/detectors/kafka.py +168 -0
  12. codegraph/async_queue/detectors/rabbitmq.py +116 -0
  13. codegraph/async_queue/detectors/redis.py +130 -0
  14. codegraph/async_queue/detectors/sqs.py +135 -0
  15. codegraph/async_queue/models.py +161 -0
  16. codegraph/async_queue/otel_models.py +74 -0
  17. codegraph/async_queue/otel_parser.py +337 -0
  18. codegraph/async_queue/reconciler.py +427 -0
  19. codegraph/async_queue/scanner.py +291 -0
  20. codegraph/async_queue/traversal.py +447 -0
  21. codegraph/change_impact.py +320 -110
  22. codegraph/changelog.py +170 -0
  23. codegraph/cli.py +1605 -62
  24. codegraph/cli_output.py +17 -2
  25. codegraph/context.py +135 -24
  26. codegraph/context_freshness.py +2 -0
  27. codegraph/database/__init__.py +14 -0
  28. codegraph/database/extractor.py +712 -17
  29. codegraph/database/interrogation.py +388 -2
  30. codegraph/database/schema_drift.py +862 -0
  31. codegraph/dataflow_propagation.py +118 -0
  32. codegraph/dead_code.py +387 -0
  33. codegraph/epistemic.py +15 -0
  34. codegraph/evidence_contract.py +34 -3
  35. codegraph/federation.py +245 -0
  36. codegraph/frameworks.py +234 -12
  37. codegraph/frameworks_nestjs.py +502 -0
  38. codegraph/frameworks_nextjs.py +337 -0
  39. codegraph/frameworks_react.py +457 -0
  40. codegraph/freshness.py +2 -0
  41. codegraph/frontend_html.py +109 -0
  42. codegraph/git.py +2 -0
  43. codegraph/git_state.py +2 -0
  44. codegraph/graph/traversal.py +150 -7
  45. codegraph/graph_csr.py +218 -0
  46. codegraph/incremental.py +150 -0
  47. codegraph/indexing/__init__.py +7 -1
  48. codegraph/indexing/classifier.py +5 -0
  49. codegraph/indexing/indexer.py +292 -38
  50. codegraph/indexing/languages/__init__.py +1 -0
  51. codegraph/indexing/languages/cpp.py +457 -0
  52. codegraph/indexing/languages/go.py +459 -0
  53. codegraph/indexing/languages/jvm.py +583 -0
  54. codegraph/indexing/languages/rust.py +507 -0
  55. codegraph/indexing/models.py +15 -1
  56. codegraph/indexing/parser.py +385 -9
  57. codegraph/indexing/pool.py +136 -0
  58. codegraph/indexing/protocols.py +220 -0
  59. codegraph/indexing/scanner.py +23 -0
  60. codegraph/indexing/templates.py +418 -0
  61. codegraph/interrogation.py +207 -39
  62. codegraph/lsp.py +318 -0
  63. codegraph/lsp_bridge/__init__.py +16 -0
  64. codegraph/lsp_bridge/bridge.py +102 -0
  65. codegraph/lsp_bridge/client.py +207 -0
  66. codegraph/lsp_bridge/models.py +71 -0
  67. codegraph/mcp/server.py +502 -29
  68. codegraph/memory/trajectory.py +282 -0
  69. codegraph/models.py +7 -0
  70. codegraph/monorepo.py +3 -0
  71. codegraph/multi_agent/__init__.py +13 -0
  72. codegraph/multi_agent/coordinator.py +229 -0
  73. codegraph/multi_agent/models.py +76 -0
  74. codegraph/optimizer.py +42 -5
  75. codegraph/polymorphism.py +95 -0
  76. codegraph/pr_review.py +296 -0
  77. codegraph/process_lifecycle.py +50 -12
  78. codegraph/reachability.py +198 -0
  79. codegraph/reachability_index.py +74 -0
  80. codegraph/refactor/__init__.py +48 -0
  81. codegraph/refactor/models.py +114 -0
  82. codegraph/refactor/patcher.py +348 -0
  83. codegraph/refactor/renamer.py +844 -0
  84. codegraph/refactor/transactions.py +414 -0
  85. codegraph/repair.py +233 -0
  86. codegraph/resolver.py +1357 -161
  87. codegraph/retrieval_policy.py +22 -1
  88. codegraph/roblox.py +2174 -0
  89. codegraph/route_composer.py +7 -0
  90. codegraph/route_topology.py +172 -0
  91. codegraph/runtime/__init__.py +25 -0
  92. codegraph/runtime/collector.py +155 -0
  93. codegraph/runtime/db_watcher.py +191 -0
  94. codegraph/runtime/ingestor.py +30 -10
  95. codegraph/runtime/otel_receiver.py +208 -0
  96. codegraph/runtime/port_inspector.py +304 -0
  97. codegraph/runtime/runner.py +155 -6
  98. codegraph/runtime/trace_linker.py +175 -0
  99. codegraph/sandbox.py +215 -0
  100. codegraph/search/hybrid.py +26 -0
  101. codegraph/security/supply_chain.py +188 -0
  102. codegraph/security/taint.py +421 -0
  103. codegraph/semantic_decorators.py +34 -3
  104. codegraph/structural_diff.py +2 -0
  105. codegraph/styles.py +144 -0
  106. codegraph/symbol_history.py +2 -0
  107. codegraph/test_gen.py +437 -0
  108. codegraph/tsconfig.py +236 -0
  109. codegraph/ui/__init__.py +6 -0
  110. codegraph/ui/server.py +876 -0
  111. codegraph/watcher.py +323 -0
  112. {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.1.dist-info}/METADATA +365 -67
  113. codegraph_engine-3.0.1.dist-info/RECORD +164 -0
  114. {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.1.dist-info}/WHEEL +1 -1
  115. codegraph_engine-2.3.0.dist-info/RECORD +0 -94
  116. {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.1.dist-info}/entry_points.txt +0 -0
  117. {codegraph_engine-2.3.0.dist-info → codegraph_engine-3.0.1.dist-info}/licenses/LICENSE +0 -0
  118. {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
@@ -920,3 +920,6 @@ def detect_workspace(
920
920
  unresolved_dependencies=unresolved_dependencies,
921
921
  ambiguous_packages=ambiguous_packages,
922
922
  )
923
+
924
+
925
+ discover_workspace = detect_workspace
@@ -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
+ }