emdash-core 0.1.7__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 (187) hide show
  1. emdash_core/__init__.py +3 -0
  2. emdash_core/agent/__init__.py +37 -0
  3. emdash_core/agent/agents.py +225 -0
  4. emdash_core/agent/code_reviewer.py +476 -0
  5. emdash_core/agent/compaction.py +143 -0
  6. emdash_core/agent/context_manager.py +140 -0
  7. emdash_core/agent/events.py +338 -0
  8. emdash_core/agent/handlers.py +224 -0
  9. emdash_core/agent/inprocess_subagent.py +377 -0
  10. emdash_core/agent/mcp/__init__.py +50 -0
  11. emdash_core/agent/mcp/client.py +346 -0
  12. emdash_core/agent/mcp/config.py +302 -0
  13. emdash_core/agent/mcp/manager.py +496 -0
  14. emdash_core/agent/mcp/tool_factory.py +213 -0
  15. emdash_core/agent/prompts/__init__.py +38 -0
  16. emdash_core/agent/prompts/main_agent.py +104 -0
  17. emdash_core/agent/prompts/subagents.py +131 -0
  18. emdash_core/agent/prompts/workflow.py +136 -0
  19. emdash_core/agent/providers/__init__.py +34 -0
  20. emdash_core/agent/providers/base.py +143 -0
  21. emdash_core/agent/providers/factory.py +80 -0
  22. emdash_core/agent/providers/models.py +220 -0
  23. emdash_core/agent/providers/openai_provider.py +463 -0
  24. emdash_core/agent/providers/transformers_provider.py +217 -0
  25. emdash_core/agent/research/__init__.py +81 -0
  26. emdash_core/agent/research/agent.py +143 -0
  27. emdash_core/agent/research/controller.py +254 -0
  28. emdash_core/agent/research/critic.py +428 -0
  29. emdash_core/agent/research/macros.py +469 -0
  30. emdash_core/agent/research/planner.py +449 -0
  31. emdash_core/agent/research/researcher.py +436 -0
  32. emdash_core/agent/research/state.py +523 -0
  33. emdash_core/agent/research/synthesizer.py +594 -0
  34. emdash_core/agent/reviewer_profile.py +475 -0
  35. emdash_core/agent/rules.py +123 -0
  36. emdash_core/agent/runner.py +601 -0
  37. emdash_core/agent/session.py +262 -0
  38. emdash_core/agent/spec_schema.py +66 -0
  39. emdash_core/agent/specification.py +479 -0
  40. emdash_core/agent/subagent.py +397 -0
  41. emdash_core/agent/subagent_prompts.py +13 -0
  42. emdash_core/agent/toolkit.py +482 -0
  43. emdash_core/agent/toolkits/__init__.py +64 -0
  44. emdash_core/agent/toolkits/base.py +96 -0
  45. emdash_core/agent/toolkits/explore.py +47 -0
  46. emdash_core/agent/toolkits/plan.py +55 -0
  47. emdash_core/agent/tools/__init__.py +141 -0
  48. emdash_core/agent/tools/analytics.py +436 -0
  49. emdash_core/agent/tools/base.py +131 -0
  50. emdash_core/agent/tools/coding.py +484 -0
  51. emdash_core/agent/tools/github_mcp.py +592 -0
  52. emdash_core/agent/tools/history.py +13 -0
  53. emdash_core/agent/tools/modes.py +153 -0
  54. emdash_core/agent/tools/plan.py +206 -0
  55. emdash_core/agent/tools/plan_write.py +135 -0
  56. emdash_core/agent/tools/search.py +412 -0
  57. emdash_core/agent/tools/spec.py +341 -0
  58. emdash_core/agent/tools/task.py +262 -0
  59. emdash_core/agent/tools/task_output.py +204 -0
  60. emdash_core/agent/tools/tasks.py +454 -0
  61. emdash_core/agent/tools/traversal.py +588 -0
  62. emdash_core/agent/tools/web.py +179 -0
  63. emdash_core/analytics/__init__.py +5 -0
  64. emdash_core/analytics/engine.py +1286 -0
  65. emdash_core/api/__init__.py +5 -0
  66. emdash_core/api/agent.py +308 -0
  67. emdash_core/api/agents.py +154 -0
  68. emdash_core/api/analyze.py +264 -0
  69. emdash_core/api/auth.py +173 -0
  70. emdash_core/api/context.py +77 -0
  71. emdash_core/api/db.py +121 -0
  72. emdash_core/api/embed.py +131 -0
  73. emdash_core/api/feature.py +143 -0
  74. emdash_core/api/health.py +93 -0
  75. emdash_core/api/index.py +162 -0
  76. emdash_core/api/plan.py +110 -0
  77. emdash_core/api/projectmd.py +210 -0
  78. emdash_core/api/query.py +320 -0
  79. emdash_core/api/research.py +122 -0
  80. emdash_core/api/review.py +161 -0
  81. emdash_core/api/router.py +76 -0
  82. emdash_core/api/rules.py +116 -0
  83. emdash_core/api/search.py +119 -0
  84. emdash_core/api/spec.py +99 -0
  85. emdash_core/api/swarm.py +223 -0
  86. emdash_core/api/tasks.py +109 -0
  87. emdash_core/api/team.py +120 -0
  88. emdash_core/auth/__init__.py +17 -0
  89. emdash_core/auth/github.py +389 -0
  90. emdash_core/config.py +74 -0
  91. emdash_core/context/__init__.py +52 -0
  92. emdash_core/context/models.py +50 -0
  93. emdash_core/context/providers/__init__.py +11 -0
  94. emdash_core/context/providers/base.py +74 -0
  95. emdash_core/context/providers/explored_areas.py +183 -0
  96. emdash_core/context/providers/touched_areas.py +360 -0
  97. emdash_core/context/registry.py +73 -0
  98. emdash_core/context/reranker.py +199 -0
  99. emdash_core/context/service.py +260 -0
  100. emdash_core/context/session.py +352 -0
  101. emdash_core/core/__init__.py +104 -0
  102. emdash_core/core/config.py +454 -0
  103. emdash_core/core/exceptions.py +55 -0
  104. emdash_core/core/models.py +265 -0
  105. emdash_core/core/review_config.py +57 -0
  106. emdash_core/db/__init__.py +67 -0
  107. emdash_core/db/auth.py +134 -0
  108. emdash_core/db/models.py +91 -0
  109. emdash_core/db/provider.py +222 -0
  110. emdash_core/db/providers/__init__.py +5 -0
  111. emdash_core/db/providers/supabase.py +452 -0
  112. emdash_core/embeddings/__init__.py +24 -0
  113. emdash_core/embeddings/indexer.py +534 -0
  114. emdash_core/embeddings/models.py +192 -0
  115. emdash_core/embeddings/providers/__init__.py +7 -0
  116. emdash_core/embeddings/providers/base.py +112 -0
  117. emdash_core/embeddings/providers/fireworks.py +141 -0
  118. emdash_core/embeddings/providers/openai.py +104 -0
  119. emdash_core/embeddings/registry.py +146 -0
  120. emdash_core/embeddings/service.py +215 -0
  121. emdash_core/graph/__init__.py +26 -0
  122. emdash_core/graph/builder.py +134 -0
  123. emdash_core/graph/connection.py +692 -0
  124. emdash_core/graph/schema.py +416 -0
  125. emdash_core/graph/writer.py +667 -0
  126. emdash_core/ingestion/__init__.py +7 -0
  127. emdash_core/ingestion/change_detector.py +150 -0
  128. emdash_core/ingestion/git/__init__.py +5 -0
  129. emdash_core/ingestion/git/commit_analyzer.py +196 -0
  130. emdash_core/ingestion/github/__init__.py +6 -0
  131. emdash_core/ingestion/github/pr_fetcher.py +296 -0
  132. emdash_core/ingestion/github/task_extractor.py +100 -0
  133. emdash_core/ingestion/orchestrator.py +540 -0
  134. emdash_core/ingestion/parsers/__init__.py +10 -0
  135. emdash_core/ingestion/parsers/base_parser.py +66 -0
  136. emdash_core/ingestion/parsers/call_graph_builder.py +121 -0
  137. emdash_core/ingestion/parsers/class_extractor.py +154 -0
  138. emdash_core/ingestion/parsers/function_extractor.py +202 -0
  139. emdash_core/ingestion/parsers/import_analyzer.py +119 -0
  140. emdash_core/ingestion/parsers/python_parser.py +123 -0
  141. emdash_core/ingestion/parsers/registry.py +72 -0
  142. emdash_core/ingestion/parsers/ts_ast_parser.js +313 -0
  143. emdash_core/ingestion/parsers/typescript_parser.py +278 -0
  144. emdash_core/ingestion/repository.py +346 -0
  145. emdash_core/models/__init__.py +38 -0
  146. emdash_core/models/agent.py +68 -0
  147. emdash_core/models/index.py +77 -0
  148. emdash_core/models/query.py +113 -0
  149. emdash_core/planning/__init__.py +7 -0
  150. emdash_core/planning/agent_api.py +413 -0
  151. emdash_core/planning/context_builder.py +265 -0
  152. emdash_core/planning/feature_context.py +232 -0
  153. emdash_core/planning/feature_expander.py +646 -0
  154. emdash_core/planning/llm_explainer.py +198 -0
  155. emdash_core/planning/similarity.py +509 -0
  156. emdash_core/planning/team_focus.py +821 -0
  157. emdash_core/server.py +153 -0
  158. emdash_core/sse/__init__.py +5 -0
  159. emdash_core/sse/stream.py +196 -0
  160. emdash_core/swarm/__init__.py +17 -0
  161. emdash_core/swarm/merge_agent.py +383 -0
  162. emdash_core/swarm/session_manager.py +274 -0
  163. emdash_core/swarm/swarm_runner.py +226 -0
  164. emdash_core/swarm/task_definition.py +137 -0
  165. emdash_core/swarm/worker_spawner.py +319 -0
  166. emdash_core/swarm/worktree_manager.py +278 -0
  167. emdash_core/templates/__init__.py +10 -0
  168. emdash_core/templates/defaults/agent-builder.md.template +82 -0
  169. emdash_core/templates/defaults/focus.md.template +115 -0
  170. emdash_core/templates/defaults/pr-review-enhanced.md.template +309 -0
  171. emdash_core/templates/defaults/pr-review.md.template +80 -0
  172. emdash_core/templates/defaults/project.md.template +85 -0
  173. emdash_core/templates/defaults/research_critic.md.template +112 -0
  174. emdash_core/templates/defaults/research_planner.md.template +85 -0
  175. emdash_core/templates/defaults/research_synthesizer.md.template +128 -0
  176. emdash_core/templates/defaults/reviewer.md.template +81 -0
  177. emdash_core/templates/defaults/spec.md.template +41 -0
  178. emdash_core/templates/defaults/tasks.md.template +78 -0
  179. emdash_core/templates/loader.py +296 -0
  180. emdash_core/utils/__init__.py +45 -0
  181. emdash_core/utils/git.py +84 -0
  182. emdash_core/utils/image.py +502 -0
  183. emdash_core/utils/logger.py +51 -0
  184. emdash_core-0.1.7.dist-info/METADATA +35 -0
  185. emdash_core-0.1.7.dist-info/RECORD +187 -0
  186. emdash_core-0.1.7.dist-info/WHEEL +4 -0
  187. emdash_core-0.1.7.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,383 @@
1
+ """Intelligent merge agent for combining parallel work."""
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Optional, TYPE_CHECKING
7
+
8
+ from git import Repo
9
+
10
+ from .task_definition import SwarmTask, TaskStatus
11
+ from .worktree_manager import WorktreeManager
12
+ from ..utils.logger import log
13
+
14
+ # Optional agent providers import (for LLM-assisted merging)
15
+ try:
16
+ from ..agent.providers import get_provider
17
+ from ..agent.providers.factory import DEFAULT_MODEL
18
+ _HAS_AGENT_PROVIDERS = True
19
+ except ImportError:
20
+ _HAS_AGENT_PROVIDERS = False
21
+ DEFAULT_MODEL = "gpt-4o-mini"
22
+ get_provider = None # type: ignore
23
+
24
+
25
+ @dataclass
26
+ class MergeResult:
27
+ """Result of merging a task branch."""
28
+ task_id: str
29
+ success: bool
30
+ merge_type: str # fast-forward, merge, rebase, manual, skipped
31
+ conflicts: list[str]
32
+ commit_sha: Optional[str]
33
+ error_message: Optional[str]
34
+
35
+
36
+ MERGE_AGENT_PROMPT = """You are an expert software engineer merging multiple feature branches.
37
+
38
+ ## Completed Tasks (Already Merged)
39
+ {task_summaries}
40
+
41
+ ## Current Branch to Merge
42
+ Branch: {current_branch}
43
+ Task: {task_title}
44
+ Files modified: {files_modified}
45
+
46
+ ## Merge Conflicts Detected
47
+ {conflict_files}
48
+
49
+ ## Conflict Content
50
+ {conflict_content}
51
+
52
+ ## Instructions
53
+ Analyze the conflicts and provide resolution guidance. For each conflict:
54
+ 1. Understand what both sides are trying to do
55
+ 2. Determine if changes are complementary (combine) or contradictory (choose one)
56
+ 3. Provide the resolved content
57
+
58
+ Output your analysis as JSON:
59
+ ```json
60
+ {{
61
+ "analysis": "Brief analysis of the conflicts",
62
+ "resolutions": [
63
+ {{
64
+ "file": "path/to/file",
65
+ "strategy": "combine|keep_ours|keep_theirs",
66
+ "explanation": "Why this resolution",
67
+ "resolved_content": "The merged content (if strategy is combine)"
68
+ }}
69
+ ],
70
+ "commit_message": "Suggested merge commit message"
71
+ }}
72
+ ```
73
+ """
74
+
75
+
76
+ class MergeAgent:
77
+ """Intelligently merges completed task branches.
78
+
79
+ Uses LLM to analyze changes and resolve conflicts when needed.
80
+ Falls back to manual intervention for complex conflicts.
81
+
82
+ Example:
83
+ agent = MergeAgent(repo_root=Path("."))
84
+
85
+ # Merge all completed tasks
86
+ results = agent.merge_all_completed(tasks, target_branch="main")
87
+
88
+ # Or merge with AI assistance
89
+ results = agent.merge_with_assistance(tasks, model="gpt-4o")
90
+ """
91
+
92
+ def __init__(self, repo_root: Path, model: str = DEFAULT_MODEL):
93
+ self.repo_root = repo_root.resolve()
94
+ self.repo = Repo(repo_root)
95
+ self.model = model
96
+ self.worktree_manager = WorktreeManager(repo_root)
97
+
98
+ def get_branch_diff_summary(self, branch: str, base: str = "main") -> dict:
99
+ """Get summary of changes on a branch."""
100
+ try:
101
+ # Get list of changed files
102
+ diff = self.repo.git.diff(f"{base}...{branch}", name_only=True)
103
+ files = diff.strip().split("\n") if diff.strip() else []
104
+
105
+ # Get commit count
106
+ commits = self.repo.git.rev_list(f"{base}..{branch}", count=True)
107
+
108
+ # Get diff stats
109
+ stat = self.repo.git.diff(f"{base}...{branch}", stat=True)
110
+
111
+ return {
112
+ "branch": branch,
113
+ "files_changed": files,
114
+ "commit_count": int(commits) if commits else 0,
115
+ "stat_summary": stat[-500:] if stat else "",
116
+ }
117
+ except Exception as e:
118
+ log.warning(f"Failed to get diff summary for {branch}: {e}")
119
+ return {
120
+ "branch": branch,
121
+ "files_changed": [],
122
+ "commit_count": 0,
123
+ "stat_summary": "",
124
+ }
125
+
126
+ def attempt_fast_forward(self, branch: str, target: str = "main") -> MergeResult:
127
+ """Try fast-forward merge (cleanest option)."""
128
+ try:
129
+ # Check if FF is possible
130
+ merge_base = self.repo.git.merge_base(target, branch)
131
+ target_sha = self.repo.refs[target].commit.hexsha
132
+
133
+ if merge_base == target_sha:
134
+ # Fast-forward possible
135
+ self.repo.git.checkout(target)
136
+ self.repo.git.merge(branch, ff_only=True)
137
+
138
+ return MergeResult(
139
+ task_id="",
140
+ success=True,
141
+ merge_type="fast-forward",
142
+ conflicts=[],
143
+ commit_sha=self.repo.head.commit.hexsha,
144
+ error_message=None,
145
+ )
146
+ except Exception:
147
+ pass
148
+
149
+ return MergeResult(
150
+ task_id="",
151
+ success=False,
152
+ merge_type="none",
153
+ conflicts=[],
154
+ commit_sha=None,
155
+ error_message="Fast-forward not possible",
156
+ )
157
+
158
+ def attempt_merge(self, branch: str, target: str = "main") -> MergeResult:
159
+ """Try standard merge."""
160
+ try:
161
+ self.repo.git.checkout(target)
162
+
163
+ try:
164
+ self.repo.git.merge(branch, no_ff=True, m=f"Merge {branch}")
165
+
166
+ return MergeResult(
167
+ task_id="",
168
+ success=True,
169
+ merge_type="merge",
170
+ conflicts=[],
171
+ commit_sha=self.repo.head.commit.hexsha,
172
+ error_message=None,
173
+ )
174
+ except Exception as e:
175
+ # Check for conflicts
176
+ status = self.repo.git.status(porcelain=True)
177
+ conflicts = []
178
+ for line in status.split("\n"):
179
+ if line.startswith("UU ") or line.startswith("AA "):
180
+ conflicts.append(line[3:])
181
+
182
+ # Abort the merge
183
+ try:
184
+ self.repo.git.merge(abort=True)
185
+ except Exception:
186
+ pass
187
+
188
+ return MergeResult(
189
+ task_id="",
190
+ success=False,
191
+ merge_type="conflicts",
192
+ conflicts=conflicts,
193
+ commit_sha=None,
194
+ error_message=str(e),
195
+ )
196
+
197
+ except Exception as e:
198
+ return MergeResult(
199
+ task_id="",
200
+ success=False,
201
+ merge_type="error",
202
+ conflicts=[],
203
+ commit_sha=None,
204
+ error_message=str(e),
205
+ )
206
+
207
+ def get_conflict_content(self, files: list[str]) -> str:
208
+ """Get the content of conflicted files."""
209
+ content_parts = []
210
+ for file_path in files[:5]: # Limit to 5 files
211
+ try:
212
+ full_path = self.repo_root / file_path
213
+ if full_path.exists():
214
+ content = full_path.read_text()
215
+ # Only include conflict markers section
216
+ if "<<<<<<<" in content:
217
+ content_parts.append(f"### {file_path}\n```\n{content[:3000]}\n```")
218
+ except Exception:
219
+ pass
220
+ return "\n\n".join(content_parts) if content_parts else "No conflict content available"
221
+
222
+ def merge_with_llm_assistance(
223
+ self,
224
+ task: SwarmTask,
225
+ target: str = "main",
226
+ other_tasks: Optional[list[SwarmTask]] = None,
227
+ ) -> MergeResult:
228
+ """Use LLM to help with merge strategy and conflict resolution."""
229
+ if not _HAS_AGENT_PROVIDERS or get_provider is None:
230
+ log.warning("Agent providers not available, falling back to standard merge")
231
+ return self.attempt_merge(task.branch, target)
232
+
233
+ provider = get_provider(self.model)
234
+
235
+ # First, try a normal merge to see if there are conflicts
236
+ self.repo.git.checkout(target)
237
+ try:
238
+ self.repo.git.merge(task.branch, no_commit=True, no_ff=True)
239
+ # No conflicts - commit the merge
240
+ self.repo.git.commit(m=f"Merge {task.branch}: {task.title}")
241
+ return MergeResult(
242
+ task_id=task.id,
243
+ success=True,
244
+ merge_type="merge",
245
+ conflicts=[],
246
+ commit_sha=self.repo.head.commit.hexsha,
247
+ error_message=None,
248
+ )
249
+ except Exception:
250
+ pass
251
+
252
+ # There are conflicts - get their details
253
+ status = self.repo.git.status(porcelain=True)
254
+ conflicts = []
255
+ for line in status.split("\n"):
256
+ if line.startswith("UU ") or line.startswith("AA "):
257
+ conflicts.append(line[3:])
258
+
259
+ if not conflicts:
260
+ # No conflicts detected, abort and return
261
+ try:
262
+ self.repo.git.merge(abort=True)
263
+ except Exception:
264
+ pass
265
+ return MergeResult(
266
+ task_id=task.id,
267
+ success=False,
268
+ merge_type="error",
269
+ conflicts=[],
270
+ commit_sha=None,
271
+ error_message="Merge failed without detectable conflicts",
272
+ )
273
+
274
+ # Get conflict content for LLM
275
+ conflict_content = self.get_conflict_content(conflicts)
276
+
277
+ # Build context for LLM
278
+ task_summaries = []
279
+ for t in (other_tasks or []):
280
+ if t.status == TaskStatus.MERGED:
281
+ task_summaries.append(f"- {t.title}: {t.completion_summary or 'No summary'}")
282
+
283
+ diff_info = self.get_branch_diff_summary(task.branch, target)
284
+
285
+ prompt = MERGE_AGENT_PROMPT.format(
286
+ task_summaries="\n".join(task_summaries) or "None yet",
287
+ current_branch=task.branch,
288
+ task_title=task.title,
289
+ files_modified=", ".join(diff_info["files_changed"][:20]),
290
+ conflict_files=", ".join(conflicts),
291
+ conflict_content=conflict_content,
292
+ )
293
+
294
+ # Get LLM recommendation
295
+ messages = [{"role": "user", "content": prompt}]
296
+ response = provider.chat(messages)
297
+
298
+ # Log the LLM's analysis
299
+ log.info(f"LLM merge analysis for {task.slug}: {response.content[:500] if response.content else 'No response'}")
300
+
301
+ # For now, abort and mark as needing manual intervention
302
+ # A more sophisticated version would parse the JSON and apply resolutions
303
+ try:
304
+ self.repo.git.merge(abort=True)
305
+ except Exception:
306
+ pass
307
+
308
+ return MergeResult(
309
+ task_id=task.id,
310
+ success=False,
311
+ merge_type="manual",
312
+ conflicts=conflicts,
313
+ commit_sha=None,
314
+ error_message=f"LLM analysis complete. Manual resolution needed. See logs for guidance.",
315
+ )
316
+
317
+ def merge_all_completed(
318
+ self,
319
+ tasks: list[SwarmTask],
320
+ target: str = "main",
321
+ use_llm: bool = False,
322
+ cleanup_worktrees: bool = True,
323
+ ) -> list[MergeResult]:
324
+ """Merge all completed tasks in order.
325
+
326
+ Args:
327
+ tasks: All tasks (will filter to completed ones)
328
+ target: Target branch to merge into
329
+ use_llm: Whether to use LLM for merge assistance
330
+ cleanup_worktrees: Whether to delete worktrees after successful merge
331
+
332
+ Returns:
333
+ List of merge results
334
+ """
335
+ results = []
336
+ completed_tasks = [t for t in tasks if t.status == TaskStatus.COMPLETED]
337
+
338
+ if not completed_tasks:
339
+ log.info("No completed tasks to merge")
340
+ return results
341
+
342
+ # Sort by completion time (merge earlier completions first)
343
+ completed_tasks.sort(key=lambda t: t.completed_at or "")
344
+
345
+ merged_tasks: list[SwarmTask] = []
346
+
347
+ for task in completed_tasks:
348
+ log.info(f"Merging task: {task.title} ({task.branch})")
349
+
350
+ # Try fast-forward first
351
+ result = self.attempt_fast_forward(task.branch, target)
352
+
353
+ if not result.success:
354
+ if use_llm:
355
+ result = self.merge_with_llm_assistance(task, target, merged_tasks)
356
+ else:
357
+ result = self.attempt_merge(task.branch, target)
358
+
359
+ result.task_id = task.id
360
+ results.append(result)
361
+
362
+ # Update task status
363
+ if result.success:
364
+ task.status = TaskStatus.MERGED
365
+ task.merge_status = "success"
366
+ merged_tasks.append(task)
367
+
368
+ # Clean up worktree after successful merge
369
+ if cleanup_worktrees and task.slug:
370
+ try:
371
+ self.worktree_manager.remove_worktree(task.slug)
372
+ log.info(f"Cleaned up worktree for merged task: {task.slug}")
373
+ except Exception as e:
374
+ log.warning(f"Failed to clean up worktree {task.slug}: {e}")
375
+ else:
376
+ task.merge_status = "conflicts"
377
+ task.merge_conflicts = result.conflicts
378
+
379
+ # Save updated task state (only if not cleaned up)
380
+ if task.worktree_path:
381
+ task.save(Path(task.worktree_path))
382
+
383
+ return results
@@ -0,0 +1,274 @@
1
+ """Session management for automatic worktree isolation."""
2
+
3
+ import atexit
4
+ import fcntl
5
+ import json
6
+ import os
7
+ import signal
8
+ import sys
9
+ from dataclasses import dataclass, asdict
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from typing import Optional
13
+ from uuid import uuid4
14
+
15
+ from .worktree_manager import WorktreeManager
16
+ from ..utils.logger import log
17
+
18
+
19
+ @dataclass
20
+ class Session:
21
+ """An active emdash agent session."""
22
+ id: str
23
+ pid: int
24
+ worktree_path: Optional[str] # None if using main repo
25
+ branch: Optional[str]
26
+ started_at: str
27
+ task_hint: str # First task or description
28
+
29
+ def to_dict(self) -> dict:
30
+ return asdict(self)
31
+
32
+ @classmethod
33
+ def from_dict(cls, data: dict) -> "Session":
34
+ return cls(**data)
35
+
36
+
37
+ class SessionManager:
38
+ """Manages multiple concurrent emdash agent sessions.
39
+
40
+ When multiple `emdash agent code` instances run:
41
+ 1. First instance uses main repo
42
+ 2. Subsequent instances get auto-created worktrees
43
+ 3. Sessions are tracked in .emdash-worktrees/.sessions.json
44
+ 4. Cleanup happens on exit
45
+
46
+ Example:
47
+ # Terminal 1
48
+ $ emdash agent code "Add auth"
49
+ # Uses main repo
50
+
51
+ # Terminal 2 (while terminal 1 is running)
52
+ $ emdash agent code "Fix bug"
53
+ # Auto-creates worktree, prints message about isolation
54
+
55
+ # Both can work in parallel without conflicts
56
+ """
57
+
58
+ SESSIONS_DIR = ".emdash-worktrees"
59
+ SESSIONS_FILE = ".sessions.json"
60
+ LOCK_FILE = ".sessions.lock"
61
+
62
+ def __init__(self, repo_root: Path):
63
+ self.repo_root = repo_root.resolve()
64
+ self.sessions_dir = self.repo_root / self.SESSIONS_DIR
65
+ self.sessions_file = self.sessions_dir / self.SESSIONS_FILE
66
+ self.lock_file = self.sessions_dir / self.LOCK_FILE
67
+ self.worktree_manager = WorktreeManager(repo_root)
68
+
69
+ self.current_session: Optional[Session] = None
70
+ self._lock_fd: Optional[int] = None
71
+
72
+ def _ensure_dir(self):
73
+ """Ensure sessions directory exists."""
74
+ self.sessions_dir.mkdir(parents=True, exist_ok=True)
75
+
76
+ def _acquire_lock(self) -> bool:
77
+ """Acquire exclusive lock for session file operations."""
78
+ self._ensure_dir()
79
+ try:
80
+ self._lock_fd = os.open(str(self.lock_file), os.O_CREAT | os.O_RDWR)
81
+ fcntl.flock(self._lock_fd, fcntl.LOCK_EX)
82
+ return True
83
+ except Exception as e:
84
+ log.warning(f"Failed to acquire session lock: {e}")
85
+ return False
86
+
87
+ def _release_lock(self):
88
+ """Release the session lock."""
89
+ if self._lock_fd is not None:
90
+ try:
91
+ fcntl.flock(self._lock_fd, fcntl.LOCK_UN)
92
+ os.close(self._lock_fd)
93
+ except Exception:
94
+ pass
95
+ self._lock_fd = None
96
+
97
+ def _load_sessions(self) -> list[Session]:
98
+ """Load current sessions from file."""
99
+ if not self.sessions_file.exists():
100
+ return []
101
+
102
+ try:
103
+ with open(self.sessions_file) as f:
104
+ data = json.load(f)
105
+ return [Session.from_dict(s) for s in data.get("sessions", [])]
106
+ except Exception:
107
+ return []
108
+
109
+ def _save_sessions(self, sessions: list[Session]):
110
+ """Save sessions to file."""
111
+ self._ensure_dir()
112
+ with open(self.sessions_file, "w") as f:
113
+ json.dump({
114
+ "sessions": [s.to_dict() for s in sessions],
115
+ "updated_at": datetime.now().isoformat(),
116
+ }, f, indent=2)
117
+
118
+ def _is_process_alive(self, pid: int) -> bool:
119
+ """Check if a process is still running."""
120
+ try:
121
+ os.kill(pid, 0)
122
+ return True
123
+ except OSError:
124
+ return False
125
+
126
+ def _cleanup_dead_sessions(self, sessions: list[Session]) -> list[Session]:
127
+ """Remove sessions from dead processes."""
128
+ alive = []
129
+ for session in sessions:
130
+ if self._is_process_alive(session.pid):
131
+ alive.append(session)
132
+ else:
133
+ log.debug(f"Cleaning up dead session {session.id} (pid {session.pid})")
134
+ return alive
135
+
136
+ def _is_main_repo_occupied(self, sessions: list[Session]) -> bool:
137
+ """Check if any session is using the main repo."""
138
+ for session in sessions:
139
+ if session.worktree_path is None:
140
+ return True
141
+ return False
142
+
143
+ def start_session(self, task_hint: str = "", base_branch: str | None = None) -> tuple[Path, Optional[str]]:
144
+ """Start a new session, creating worktree if needed.
145
+
146
+ Args:
147
+ task_hint: Description of the task (used for worktree naming)
148
+ base_branch: Branch to base worktree on
149
+
150
+ Returns:
151
+ (working_directory, branch_name or None if main repo)
152
+ """
153
+ self._acquire_lock()
154
+ try:
155
+ sessions = self._load_sessions()
156
+ sessions = self._cleanup_dead_sessions(sessions)
157
+
158
+ session_id = str(uuid4())[:8]
159
+ pid = os.getpid()
160
+
161
+ if self._is_main_repo_occupied(sessions):
162
+ # Create worktree for this session
163
+ slug = self._make_slug(task_hint, session_id)
164
+ info = self.worktree_manager.create_worktree(
165
+ task_name=slug,
166
+ base_branch=base_branch,
167
+ force=True,
168
+ )
169
+
170
+ self.current_session = Session(
171
+ id=session_id,
172
+ pid=pid,
173
+ worktree_path=str(info.path),
174
+ branch=info.branch,
175
+ started_at=datetime.now().isoformat(),
176
+ task_hint=task_hint[:100] if task_hint else "",
177
+ )
178
+
179
+ sessions.append(self.current_session)
180
+ self._save_sessions(sessions)
181
+
182
+ # Register cleanup handlers
183
+ self._register_cleanup()
184
+
185
+ return info.path, info.branch
186
+ else:
187
+ # Use main repo
188
+ self.current_session = Session(
189
+ id=session_id,
190
+ pid=pid,
191
+ worktree_path=None,
192
+ branch=None,
193
+ started_at=datetime.now().isoformat(),
194
+ task_hint=task_hint[:100] if task_hint else "",
195
+ )
196
+
197
+ sessions.append(self.current_session)
198
+ self._save_sessions(sessions)
199
+
200
+ # Register cleanup handlers
201
+ self._register_cleanup()
202
+
203
+ return self.repo_root, None
204
+
205
+ finally:
206
+ self._release_lock()
207
+
208
+ def _make_slug(self, task_hint: str, session_id: str) -> str:
209
+ """Create a slug for the worktree."""
210
+ import re
211
+ if task_hint:
212
+ slug = task_hint.lower().strip()
213
+ slug = re.sub(r"[^\w\s-]", "", slug)
214
+ slug = re.sub(r"[-\s]+", "-", slug)
215
+ slug = slug[:30]
216
+ else:
217
+ slug = f"session-{session_id}"
218
+ return slug
219
+
220
+ def end_session(self):
221
+ """End the current session."""
222
+ if not self.current_session:
223
+ return
224
+
225
+ self._acquire_lock()
226
+ try:
227
+ sessions = self._load_sessions()
228
+ sessions = [s for s in sessions if s.id != self.current_session.id]
229
+ self._save_sessions(sessions)
230
+
231
+ # Note: We don't auto-delete the worktree - user may want to merge
232
+ log.info(f"Session {self.current_session.id} ended")
233
+
234
+ if self.current_session.worktree_path:
235
+ log.info(f"Worktree preserved at: {self.current_session.worktree_path}")
236
+ log.info(f"Branch: {self.current_session.branch}")
237
+
238
+ finally:
239
+ self._release_lock()
240
+ self.current_session = None
241
+
242
+ def _register_cleanup(self):
243
+ """Register cleanup handlers for process exit."""
244
+ atexit.register(self.end_session)
245
+
246
+ # Handle signals
247
+ def signal_handler(signum, frame):
248
+ self.end_session()
249
+ sys.exit(0)
250
+
251
+ try:
252
+ signal.signal(signal.SIGTERM, signal_handler)
253
+ signal.signal(signal.SIGINT, signal_handler)
254
+ except Exception:
255
+ pass # May fail in non-main thread
256
+
257
+ def get_active_sessions(self) -> list[Session]:
258
+ """Get list of currently active sessions."""
259
+ self._acquire_lock()
260
+ try:
261
+ sessions = self._load_sessions()
262
+ return self._cleanup_dead_sessions(sessions)
263
+ finally:
264
+ self._release_lock()
265
+
266
+ def is_using_worktree(self) -> bool:
267
+ """Check if current session is using a worktree."""
268
+ return self.current_session is not None and self.current_session.worktree_path is not None
269
+
270
+ def get_current_branch(self) -> Optional[str]:
271
+ """Get branch name if using worktree."""
272
+ if self.current_session:
273
+ return self.current_session.branch
274
+ return None