emdash-core 0.1.33__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.
Files changed (67) hide show
  1. emdash_core/agent/agents.py +93 -23
  2. emdash_core/agent/background.py +481 -0
  3. emdash_core/agent/hooks.py +419 -0
  4. emdash_core/agent/inprocess_subagent.py +114 -10
  5. emdash_core/agent/mcp/config.py +78 -2
  6. emdash_core/agent/prompts/main_agent.py +88 -1
  7. emdash_core/agent/prompts/plan_mode.py +65 -44
  8. emdash_core/agent/prompts/subagents.py +96 -8
  9. emdash_core/agent/prompts/workflow.py +215 -50
  10. emdash_core/agent/providers/models.py +1 -1
  11. emdash_core/agent/providers/openai_provider.py +10 -0
  12. emdash_core/agent/research/researcher.py +154 -45
  13. emdash_core/agent/runner/agent_runner.py +157 -19
  14. emdash_core/agent/runner/context.py +28 -9
  15. emdash_core/agent/runner/sdk_runner.py +29 -2
  16. emdash_core/agent/skills.py +81 -1
  17. emdash_core/agent/toolkit.py +87 -11
  18. emdash_core/agent/toolkits/__init__.py +117 -18
  19. emdash_core/agent/toolkits/base.py +87 -2
  20. emdash_core/agent/toolkits/explore.py +18 -0
  21. emdash_core/agent/toolkits/plan.py +18 -0
  22. emdash_core/agent/tools/__init__.py +2 -0
  23. emdash_core/agent/tools/coding.py +344 -52
  24. emdash_core/agent/tools/lsp.py +361 -0
  25. emdash_core/agent/tools/skill.py +21 -1
  26. emdash_core/agent/tools/task.py +27 -23
  27. emdash_core/agent/tools/task_output.py +262 -32
  28. emdash_core/agent/verifier/__init__.py +11 -0
  29. emdash_core/agent/verifier/manager.py +295 -0
  30. emdash_core/agent/verifier/models.py +97 -0
  31. emdash_core/{swarm/worktree_manager.py → agent/worktree.py} +19 -1
  32. emdash_core/api/agent.py +451 -5
  33. emdash_core/api/research.py +3 -3
  34. emdash_core/api/router.py +0 -4
  35. emdash_core/context/longevity.py +197 -0
  36. emdash_core/context/providers/explored_areas.py +83 -39
  37. emdash_core/context/reranker.py +35 -144
  38. emdash_core/context/simple_reranker.py +500 -0
  39. emdash_core/context/tool_relevance.py +84 -0
  40. emdash_core/core/config.py +8 -0
  41. emdash_core/graph/__init__.py +8 -1
  42. emdash_core/graph/connection.py +24 -3
  43. emdash_core/graph/writer.py +7 -1
  44. emdash_core/ingestion/repository.py +17 -198
  45. emdash_core/models/agent.py +14 -0
  46. emdash_core/server.py +1 -6
  47. emdash_core/sse/stream.py +16 -1
  48. emdash_core/utils/__init__.py +0 -2
  49. emdash_core/utils/git.py +103 -0
  50. emdash_core/utils/image.py +147 -160
  51. {emdash_core-0.1.33.dist-info → emdash_core-0.1.60.dist-info}/METADATA +7 -5
  52. {emdash_core-0.1.33.dist-info → emdash_core-0.1.60.dist-info}/RECORD +54 -58
  53. emdash_core/api/swarm.py +0 -223
  54. emdash_core/db/__init__.py +0 -67
  55. emdash_core/db/auth.py +0 -134
  56. emdash_core/db/models.py +0 -91
  57. emdash_core/db/provider.py +0 -222
  58. emdash_core/db/providers/__init__.py +0 -5
  59. emdash_core/db/providers/supabase.py +0 -452
  60. emdash_core/swarm/__init__.py +0 -17
  61. emdash_core/swarm/merge_agent.py +0 -383
  62. emdash_core/swarm/session_manager.py +0 -274
  63. emdash_core/swarm/swarm_runner.py +0 -226
  64. emdash_core/swarm/task_definition.py +0 -137
  65. emdash_core/swarm/worker_spawner.py +0 -319
  66. {emdash_core-0.1.33.dist-info → emdash_core-0.1.60.dist-info}/WHEEL +0 -0
  67. {emdash_core-0.1.33.dist-info → emdash_core-0.1.60.dist-info}/entry_points.txt +0 -0
@@ -2,16 +2,74 @@
2
2
 
3
3
  Allows users to define custom agent configurations with
4
4
  specialized system prompts and tool selections.
5
+
6
+ Example agent file:
7
+ ```markdown
8
+ ---
9
+ description: GitHub integration agent
10
+ model: claude-sonnet-4-20250514
11
+ tools: [grep, glob, read_file]
12
+ mcp_servers:
13
+ github:
14
+ command: github-mcp-server
15
+ args: []
16
+ env:
17
+ GITHUB_TOKEN: ${GITHUB_TOKEN}
18
+ enabled: true
19
+ filesystem:
20
+ command: npx
21
+ args: [-y, "@anthropic/mcp-server-filesystem", "/tmp"]
22
+ enabled: false # Disabled - won't be started
23
+ ---
24
+
25
+ # System Prompt
26
+
27
+ You are a GitHub integration specialist...
28
+ ```
5
29
  """
6
30
 
7
31
  from dataclasses import dataclass, field
8
32
  from pathlib import Path
9
- from typing import Optional
33
+ from typing import Any, Optional
10
34
  import re
11
35
 
36
+ import yaml
37
+
12
38
  from ..utils.logger import log
13
39
 
14
40
 
41
+ @dataclass
42
+ class AgentMCPServerConfig:
43
+ """MCP server configuration for a custom agent.
44
+
45
+ Attributes:
46
+ name: Server name (key in mcp_servers dict)
47
+ command: Command to run the server
48
+ args: Arguments to pass to the command
49
+ env: Environment variables (supports ${VAR} syntax)
50
+ enabled: Whether this server is enabled (default: True)
51
+ timeout: Timeout in seconds for tool calls
52
+ """
53
+ name: str
54
+ command: str
55
+ args: list[str] = field(default_factory=list)
56
+ env: dict[str, str] = field(default_factory=dict)
57
+ enabled: bool = True
58
+ timeout: int = 30
59
+
60
+ @classmethod
61
+ def from_dict(cls, name: str, data: dict[str, Any]) -> "AgentMCPServerConfig":
62
+ """Create from dictionary parsed from YAML."""
63
+ return cls(
64
+ name=name,
65
+ command=data.get("command", ""),
66
+ args=data.get("args", []),
67
+ env=data.get("env", {}),
68
+ enabled=data.get("enabled", True),
69
+ timeout=data.get("timeout", 30),
70
+ )
71
+
72
+
15
73
  @dataclass
16
74
  class CustomAgent:
17
75
  """A custom agent configuration loaded from markdown.
@@ -19,16 +77,26 @@ class CustomAgent:
19
77
  Attributes:
20
78
  name: Agent name (from filename)
21
79
  description: Brief description
80
+ model: Model to use for this agent (optional, uses default if not set)
22
81
  system_prompt: Custom system prompt
23
82
  tools: List of tools to enable
83
+ mcp_servers: MCP server configurations for this agent
84
+ rules: List of rule names to apply (references .emdash/rules/)
85
+ skills: List of skill names to enable (references .emdash/skills/)
86
+ verifiers: List of verifier names to use (references .emdash/verifiers.json)
24
87
  examples: Example interactions
25
88
  file_path: Source file path
26
89
  """
27
90
 
28
91
  name: str
29
92
  description: str = ""
93
+ model: Optional[str] = None
30
94
  system_prompt: str = ""
31
95
  tools: list[str] = field(default_factory=list)
96
+ mcp_servers: list[AgentMCPServerConfig] = field(default_factory=list)
97
+ rules: list[str] = field(default_factory=list)
98
+ skills: list[str] = field(default_factory=list)
99
+ verifiers: list[str] = field(default_factory=list)
32
100
  examples: list[dict] = field(default_factory=list)
33
101
  file_path: Optional[Path] = None
34
102
 
@@ -121,46 +189,48 @@ def _parse_agent_file(file_path: Path) -> Optional[CustomAgent]:
121
189
  if system_prompt.startswith("# System Prompt"):
122
190
  system_prompt = system_prompt[len("# System Prompt") :].strip()
123
191
 
192
+ # Parse MCP servers from frontmatter
193
+ mcp_servers = []
194
+ mcp_servers_data = frontmatter.get("mcp_servers", {})
195
+ if isinstance(mcp_servers_data, dict):
196
+ for server_name, server_config in mcp_servers_data.items():
197
+ if isinstance(server_config, dict):
198
+ mcp_servers.append(
199
+ AgentMCPServerConfig.from_dict(server_name, server_config)
200
+ )
201
+
124
202
  return CustomAgent(
125
203
  name=file_path.stem,
126
204
  description=frontmatter.get("description", ""),
205
+ model=frontmatter.get("model"),
127
206
  system_prompt=system_prompt,
128
207
  tools=frontmatter.get("tools", []),
208
+ mcp_servers=mcp_servers,
209
+ rules=frontmatter.get("rules", []),
210
+ skills=frontmatter.get("skills", []),
211
+ verifiers=frontmatter.get("verifiers", []),
129
212
  examples=examples,
130
213
  file_path=file_path,
131
214
  )
132
215
 
133
216
 
134
217
  def _parse_frontmatter(frontmatter_str: str) -> dict:
135
- """Parse YAML-like frontmatter.
218
+ """Parse YAML frontmatter.
136
219
 
137
- Simple parser for key: value pairs.
220
+ Uses PyYAML for proper nested structure parsing.
138
221
 
139
222
  Args:
140
- frontmatter_str: Frontmatter string
223
+ frontmatter_str: Frontmatter string (YAML format)
141
224
 
142
225
  Returns:
143
226
  Dict of parsed values
144
227
  """
145
- result = {}
146
-
147
- for line in frontmatter_str.strip().split("\n"):
148
- if ":" not in line:
149
- continue
150
-
151
- key, value = line.split(":", 1)
152
- key = key.strip()
153
- value = value.strip()
154
-
155
- # Parse list values
156
- if value.startswith("[") and value.endswith("]"):
157
- # Simple list parsing
158
- items = value[1:-1].split(",")
159
- result[key] = [item.strip().strip("'\"") for item in items if item.strip()]
160
- else:
161
- result[key] = value.strip("'\"")
162
-
163
- return result
228
+ try:
229
+ result = yaml.safe_load(frontmatter_str)
230
+ return result if isinstance(result, dict) else {}
231
+ except yaml.YAMLError as e:
232
+ log.warning(f"Failed to parse frontmatter as YAML: {e}")
233
+ return {}
164
234
 
165
235
 
166
236
  def _parse_examples(examples_str: str) -> list[dict]:
@@ -0,0 +1,481 @@
1
+ """Background task management for shell commands and sub-agents.
2
+
3
+ This module provides a centralized manager for tracking background tasks,
4
+ checking for completions, and generating notifications to inject into
5
+ the agent's context.
6
+
7
+ Inspired by Claude Code's background task system with notification-based
8
+ completion handling.
9
+ """
10
+
11
+ import subprocess
12
+ import threading
13
+ import time
14
+ import uuid
15
+ from concurrent.futures import Future, ThreadPoolExecutor
16
+ from dataclasses import dataclass, field
17
+ from enum import Enum
18
+ from pathlib import Path
19
+ from typing import Any, Callable, Optional
20
+
21
+ from ..utils.logger import log
22
+
23
+
24
+ class TaskType(Enum):
25
+ """Type of background task."""
26
+ SHELL = "shell"
27
+ SUBAGENT = "subagent"
28
+
29
+
30
+ class TaskStatus(Enum):
31
+ """Status of a background task."""
32
+ RUNNING = "running"
33
+ COMPLETED = "completed"
34
+ FAILED = "failed"
35
+ KILLED = "killed"
36
+
37
+
38
+ @dataclass
39
+ class BackgroundTask:
40
+ """Represents a background task (shell command or sub-agent)."""
41
+
42
+ task_id: str
43
+ task_type: TaskType
44
+ description: str
45
+ started_at: float = field(default_factory=time.time)
46
+
47
+ # Shell-specific
48
+ process: Optional[subprocess.Popen] = None
49
+ command: Optional[str] = None
50
+
51
+ # Sub-agent specific
52
+ future: Optional[Future] = None
53
+ agent_type: Optional[str] = None
54
+
55
+ # Output capture
56
+ output_file: Optional[Path] = None
57
+ stdout: str = ""
58
+ stderr: str = ""
59
+
60
+ # Status
61
+ status: TaskStatus = TaskStatus.RUNNING
62
+ exit_code: Optional[int] = None
63
+ result: Optional[Any] = None
64
+ error: Optional[str] = None
65
+ completed_at: Optional[float] = None
66
+
67
+ # Whether the agent has been notified of completion
68
+ notified: bool = False
69
+
70
+ def to_dict(self) -> dict:
71
+ """Convert to dictionary for serialization."""
72
+ return {
73
+ "task_id": self.task_id,
74
+ "task_type": self.task_type.value,
75
+ "description": self.description,
76
+ "status": self.status.value,
77
+ "exit_code": self.exit_code,
78
+ "started_at": self.started_at,
79
+ "completed_at": self.completed_at,
80
+ "command": self.command,
81
+ "agent_type": self.agent_type,
82
+ "stdout": self.stdout[-5000:] if self.stdout else "",
83
+ "stderr": self.stderr[-2000:] if self.stderr else "",
84
+ "error": self.error,
85
+ }
86
+
87
+
88
+ class BackgroundTaskManager:
89
+ """Manages background tasks with notification-based completion.
90
+
91
+ This is a singleton that tracks all background tasks (shell commands
92
+ and sub-agents), monitors their completion, and provides notifications
93
+ to inject into the agent's context.
94
+
95
+ Usage:
96
+ manager = BackgroundTaskManager.get_instance()
97
+
98
+ # Start a shell command
99
+ task_id = manager.start_shell("npm test", description="Run tests")
100
+
101
+ # Check for completed tasks to notify agent
102
+ notifications = manager.get_pending_notifications()
103
+
104
+ # Get task status
105
+ task = manager.get_task(task_id)
106
+
107
+ # Kill a task
108
+ manager.kill_task(task_id)
109
+ """
110
+
111
+ _instance: Optional["BackgroundTaskManager"] = None
112
+ _lock = threading.Lock()
113
+
114
+ def __init__(self):
115
+ """Initialize the manager."""
116
+ self._tasks: dict[str, BackgroundTask] = {}
117
+ self._executor = ThreadPoolExecutor(max_workers=10, thread_name_prefix="bg-task-")
118
+ self._monitor_thread: Optional[threading.Thread] = None
119
+ self._stop_monitor = threading.Event()
120
+ self._start_monitor()
121
+
122
+ @classmethod
123
+ def get_instance(cls) -> "BackgroundTaskManager":
124
+ """Get the singleton instance."""
125
+ if cls._instance is None:
126
+ with cls._lock:
127
+ if cls._instance is None:
128
+ cls._instance = cls()
129
+ return cls._instance
130
+
131
+ @classmethod
132
+ def reset_instance(cls) -> None:
133
+ """Reset the singleton (for testing)."""
134
+ with cls._lock:
135
+ if cls._instance is not None:
136
+ cls._instance.shutdown()
137
+ cls._instance = None
138
+
139
+ def _start_monitor(self) -> None:
140
+ """Start the background monitor thread."""
141
+ if self._monitor_thread is not None and self._monitor_thread.is_alive():
142
+ return
143
+
144
+ self._stop_monitor.clear()
145
+ self._monitor_thread = threading.Thread(
146
+ target=self._monitor_loop,
147
+ name="bg-task-monitor",
148
+ daemon=True,
149
+ )
150
+ self._monitor_thread.start()
151
+
152
+ def _monitor_loop(self) -> None:
153
+ """Monitor running tasks for completion."""
154
+ while not self._stop_monitor.is_set():
155
+ try:
156
+ self._check_tasks()
157
+ except Exception as e:
158
+ log.warning(f"Error in background task monitor: {e}")
159
+
160
+ # Check every 500ms
161
+ self._stop_monitor.wait(0.5)
162
+
163
+ def _check_tasks(self) -> None:
164
+ """Check all running tasks for completion."""
165
+ for task in list(self._tasks.values()):
166
+ if task.status != TaskStatus.RUNNING:
167
+ continue
168
+
169
+ if task.task_type == TaskType.SHELL:
170
+ self._check_shell_task(task)
171
+ elif task.task_type == TaskType.SUBAGENT:
172
+ self._check_subagent_task(task)
173
+
174
+ def _check_shell_task(self, task: BackgroundTask) -> None:
175
+ """Check if a shell task has completed."""
176
+ if task.process is None:
177
+ return
178
+
179
+ poll = task.process.poll()
180
+ if poll is not None:
181
+ # Process completed
182
+ task.exit_code = poll
183
+ task.completed_at = time.time()
184
+
185
+ # Capture any remaining output
186
+ try:
187
+ stdout, stderr = task.process.communicate(timeout=1)
188
+ task.stdout += stdout
189
+ task.stderr += stderr
190
+ except Exception:
191
+ pass
192
+
193
+ if poll == 0:
194
+ task.status = TaskStatus.COMPLETED
195
+ else:
196
+ task.status = TaskStatus.FAILED
197
+ task.error = f"Command exited with code {poll}"
198
+
199
+ log.info(f"Shell task {task.task_id} completed with exit code {poll}")
200
+
201
+ def _check_subagent_task(self, task: BackgroundTask) -> None:
202
+ """Check if a sub-agent task has completed."""
203
+ if task.future is None:
204
+ return
205
+
206
+ if task.future.done():
207
+ task.completed_at = time.time()
208
+
209
+ try:
210
+ result = task.future.result(timeout=0)
211
+ task.result = result
212
+ task.status = TaskStatus.COMPLETED
213
+ task.exit_code = 0
214
+ log.info(f"Sub-agent task {task.task_id} completed successfully")
215
+ except Exception as e:
216
+ task.status = TaskStatus.FAILED
217
+ task.error = str(e)
218
+ task.exit_code = 1
219
+ log.warning(f"Sub-agent task {task.task_id} failed: {e}")
220
+
221
+ def start_shell(
222
+ self,
223
+ command: str,
224
+ description: str = "",
225
+ cwd: Optional[Path] = None,
226
+ timeout: Optional[int] = None,
227
+ ) -> str:
228
+ """Start a shell command in the background.
229
+
230
+ Args:
231
+ command: Shell command to execute
232
+ description: Human-readable description
233
+ cwd: Working directory (defaults to current)
234
+ timeout: Optional timeout in seconds (not enforced, just metadata)
235
+
236
+ Returns:
237
+ Task ID for tracking
238
+ """
239
+ task_id = f"shell_{uuid.uuid4().hex[:8]}"
240
+
241
+ log.info(f"Starting background shell task {task_id}: {command[:50]}...")
242
+
243
+ # Start process with pipes for output capture
244
+ process = subprocess.Popen(
245
+ command,
246
+ shell=True,
247
+ stdout=subprocess.PIPE,
248
+ stderr=subprocess.PIPE,
249
+ text=True,
250
+ cwd=cwd,
251
+ )
252
+
253
+ task = BackgroundTask(
254
+ task_id=task_id,
255
+ task_type=TaskType.SHELL,
256
+ description=description or command[:50],
257
+ process=process,
258
+ command=command,
259
+ )
260
+
261
+ # Start output reader threads
262
+ self._start_output_reader(task)
263
+
264
+ self._tasks[task_id] = task
265
+ return task_id
266
+
267
+ def _start_output_reader(self, task: BackgroundTask) -> None:
268
+ """Start threads to read stdout/stderr without blocking."""
269
+ def read_stream(stream, attr_name):
270
+ try:
271
+ for line in stream:
272
+ current = getattr(task, attr_name)
273
+ setattr(task, attr_name, current + line)
274
+ except Exception:
275
+ pass
276
+
277
+ if task.process and task.process.stdout:
278
+ threading.Thread(
279
+ target=read_stream,
280
+ args=(task.process.stdout, "stdout"),
281
+ daemon=True,
282
+ ).start()
283
+
284
+ if task.process and task.process.stderr:
285
+ threading.Thread(
286
+ target=read_stream,
287
+ args=(task.process.stderr, "stderr"),
288
+ daemon=True,
289
+ ).start()
290
+
291
+ def start_subagent(
292
+ self,
293
+ future: Future,
294
+ agent_type: str,
295
+ description: str = "",
296
+ ) -> str:
297
+ """Register a sub-agent task for tracking.
298
+
299
+ Args:
300
+ future: Future from async sub-agent execution
301
+ agent_type: Type of sub-agent (Explore, Plan, etc.)
302
+ description: Human-readable description
303
+
304
+ Returns:
305
+ Task ID for tracking
306
+ """
307
+ task_id = f"agent_{uuid.uuid4().hex[:8]}"
308
+
309
+ log.info(f"Registering background sub-agent {task_id}: {agent_type}")
310
+
311
+ task = BackgroundTask(
312
+ task_id=task_id,
313
+ task_type=TaskType.SUBAGENT,
314
+ description=description,
315
+ future=future,
316
+ agent_type=agent_type,
317
+ )
318
+
319
+ self._tasks[task_id] = task
320
+ return task_id
321
+
322
+ def get_task(self, task_id: str) -> Optional[BackgroundTask]:
323
+ """Get a task by ID."""
324
+ return self._tasks.get(task_id)
325
+
326
+ def get_all_tasks(self) -> list[BackgroundTask]:
327
+ """Get all tasks."""
328
+ return list(self._tasks.values())
329
+
330
+ def get_running_tasks(self) -> list[BackgroundTask]:
331
+ """Get all currently running tasks."""
332
+ return [t for t in self._tasks.values() if t.status == TaskStatus.RUNNING]
333
+
334
+ def get_pending_notifications(self) -> list[BackgroundTask]:
335
+ """Get completed tasks that haven't been notified yet.
336
+
337
+ Returns:
338
+ List of tasks that completed since last check.
339
+ Marks them as notified so they won't be returned again.
340
+ """
341
+ notifications = []
342
+
343
+ for task in self._tasks.values():
344
+ if task.status != TaskStatus.RUNNING and not task.notified:
345
+ notifications.append(task)
346
+ task.notified = True
347
+
348
+ return notifications
349
+
350
+ def format_notification(self, task: BackgroundTask) -> str:
351
+ """Format a task completion as a notification message.
352
+
353
+ Args:
354
+ task: Completed task
355
+
356
+ Returns:
357
+ Formatted notification string for injection into context
358
+ """
359
+ status_str = "completed successfully" if task.status == TaskStatus.COMPLETED else "failed"
360
+
361
+ if task.task_type == TaskType.SHELL:
362
+ msg = f"[Background shell task {task.task_id} {status_str}]"
363
+ msg += f"\nCommand: {task.command}"
364
+ msg += f"\nExit code: {task.exit_code}"
365
+
366
+ if task.stdout:
367
+ # Truncate long output
368
+ stdout = task.stdout[-3000:] if len(task.stdout) > 3000 else task.stdout
369
+ if len(task.stdout) > 3000:
370
+ stdout = "...(truncated)\n" + stdout
371
+ msg += f"\n\nStdout:\n{stdout}"
372
+
373
+ if task.stderr:
374
+ stderr = task.stderr[-1500:] if len(task.stderr) > 1500 else task.stderr
375
+ if len(task.stderr) > 1500:
376
+ stderr = "...(truncated)\n" + stderr
377
+ msg += f"\n\nStderr:\n{stderr}"
378
+
379
+ else: # SUBAGENT
380
+ msg = f"[Background sub-agent {task.task_id} ({task.agent_type}) {status_str}]"
381
+
382
+ if task.error:
383
+ msg += f"\nError: {task.error}"
384
+ elif task.result:
385
+ # Include summary from sub-agent result
386
+ if hasattr(task.result, "summary"):
387
+ msg += f"\n\nSummary:\n{task.result.summary}"
388
+ elif isinstance(task.result, dict) and "summary" in task.result:
389
+ msg += f"\n\nSummary:\n{task.result['summary']}"
390
+
391
+ msg += f"\n\nUse task_output(task_id='{task.task_id}') for full details."
392
+
393
+ return msg
394
+
395
+ def kill_task(self, task_id: str) -> bool:
396
+ """Kill a running task.
397
+
398
+ Args:
399
+ task_id: Task to kill
400
+
401
+ Returns:
402
+ True if task was killed, False if not found or already completed
403
+ """
404
+ task = self._tasks.get(task_id)
405
+ if task is None:
406
+ return False
407
+
408
+ if task.status != TaskStatus.RUNNING:
409
+ return False
410
+
411
+ log.info(f"Killing background task {task_id}")
412
+
413
+ if task.task_type == TaskType.SHELL and task.process:
414
+ try:
415
+ task.process.terminate()
416
+ # Give it a moment to terminate gracefully
417
+ try:
418
+ task.process.wait(timeout=2)
419
+ except subprocess.TimeoutExpired:
420
+ task.process.kill()
421
+
422
+ task.status = TaskStatus.KILLED
423
+ task.exit_code = -15 # SIGTERM
424
+ task.completed_at = time.time()
425
+ return True
426
+ except Exception as e:
427
+ log.warning(f"Failed to kill shell task {task_id}: {e}")
428
+ return False
429
+
430
+ elif task.task_type == TaskType.SUBAGENT and task.future:
431
+ # Can't really kill a future, but we can mark it
432
+ task.future.cancel()
433
+ task.status = TaskStatus.KILLED
434
+ task.completed_at = time.time()
435
+ return True
436
+
437
+ return False
438
+
439
+ def cleanup_old_tasks(self, max_age_seconds: int = 3600) -> int:
440
+ """Remove old completed tasks.
441
+
442
+ Args:
443
+ max_age_seconds: Remove tasks older than this (default 1 hour)
444
+
445
+ Returns:
446
+ Number of tasks removed
447
+ """
448
+ now = time.time()
449
+ to_remove = []
450
+
451
+ for task_id, task in self._tasks.items():
452
+ if task.status == TaskStatus.RUNNING:
453
+ continue
454
+
455
+ if task.completed_at and (now - task.completed_at) > max_age_seconds:
456
+ to_remove.append(task_id)
457
+
458
+ for task_id in to_remove:
459
+ del self._tasks[task_id]
460
+
461
+ if to_remove:
462
+ log.debug(f"Cleaned up {len(to_remove)} old background tasks")
463
+
464
+ return len(to_remove)
465
+
466
+ def shutdown(self) -> None:
467
+ """Shutdown the manager and clean up resources."""
468
+ log.info("Shutting down BackgroundTaskManager")
469
+
470
+ # Stop monitor thread
471
+ self._stop_monitor.set()
472
+ if self._monitor_thread:
473
+ self._monitor_thread.join(timeout=2)
474
+
475
+ # Kill any running tasks
476
+ for task in list(self._tasks.values()):
477
+ if task.status == TaskStatus.RUNNING:
478
+ self.kill_task(task.task_id)
479
+
480
+ # Shutdown executor
481
+ self._executor.shutdown(wait=False)