emdash-core 0.1.37__py3-none-any.whl → 0.1.60__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.
- emdash_core/agent/agents.py +9 -0
- emdash_core/agent/background.py +481 -0
- emdash_core/agent/inprocess_subagent.py +70 -1
- emdash_core/agent/mcp/config.py +78 -2
- emdash_core/agent/prompts/main_agent.py +53 -1
- emdash_core/agent/prompts/plan_mode.py +65 -44
- emdash_core/agent/prompts/subagents.py +73 -1
- emdash_core/agent/prompts/workflow.py +179 -28
- emdash_core/agent/providers/models.py +1 -1
- emdash_core/agent/providers/openai_provider.py +10 -0
- emdash_core/agent/research/researcher.py +154 -45
- emdash_core/agent/runner/agent_runner.py +145 -19
- emdash_core/agent/runner/sdk_runner.py +29 -2
- emdash_core/agent/skills.py +81 -1
- emdash_core/agent/toolkit.py +87 -11
- emdash_core/agent/tools/__init__.py +2 -0
- emdash_core/agent/tools/coding.py +344 -52
- emdash_core/agent/tools/lsp.py +361 -0
- emdash_core/agent/tools/skill.py +21 -1
- emdash_core/agent/tools/task.py +16 -19
- emdash_core/agent/tools/task_output.py +262 -32
- emdash_core/agent/verifier/__init__.py +11 -0
- emdash_core/agent/verifier/manager.py +295 -0
- emdash_core/agent/verifier/models.py +97 -0
- emdash_core/{swarm/worktree_manager.py → agent/worktree.py} +19 -1
- emdash_core/api/agent.py +297 -2
- emdash_core/api/research.py +3 -3
- emdash_core/api/router.py +0 -4
- emdash_core/context/longevity.py +197 -0
- emdash_core/context/providers/explored_areas.py +83 -39
- emdash_core/context/reranker.py +35 -144
- emdash_core/context/simple_reranker.py +500 -0
- emdash_core/context/tool_relevance.py +84 -0
- emdash_core/core/config.py +8 -0
- emdash_core/graph/__init__.py +8 -1
- emdash_core/graph/connection.py +24 -3
- emdash_core/graph/writer.py +7 -1
- emdash_core/models/agent.py +10 -0
- emdash_core/server.py +1 -6
- emdash_core/sse/stream.py +16 -1
- emdash_core/utils/__init__.py +0 -2
- emdash_core/utils/git.py +103 -0
- emdash_core/utils/image.py +147 -160
- {emdash_core-0.1.37.dist-info → emdash_core-0.1.60.dist-info}/METADATA +6 -6
- {emdash_core-0.1.37.dist-info → emdash_core-0.1.60.dist-info}/RECORD +47 -52
- emdash_core/api/swarm.py +0 -223
- emdash_core/db/__init__.py +0 -67
- emdash_core/db/auth.py +0 -134
- emdash_core/db/models.py +0 -91
- emdash_core/db/provider.py +0 -222
- emdash_core/db/providers/__init__.py +0 -5
- emdash_core/db/providers/supabase.py +0 -452
- emdash_core/swarm/__init__.py +0 -17
- emdash_core/swarm/merge_agent.py +0 -383
- emdash_core/swarm/session_manager.py +0 -274
- emdash_core/swarm/swarm_runner.py +0 -226
- emdash_core/swarm/task_definition.py +0 -137
- emdash_core/swarm/worker_spawner.py +0 -319
- {emdash_core-0.1.37.dist-info → emdash_core-0.1.60.dist-info}/WHEEL +0 -0
- {emdash_core-0.1.37.dist-info → emdash_core-0.1.60.dist-info}/entry_points.txt +0 -0
emdash_core/swarm/merge_agent.py
DELETED
|
@@ -1,383 +0,0 @@
|
|
|
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
|
|
@@ -1,274 +0,0 @@
|
|
|
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
|