pulse-coding-agent 0.1.0__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 (104) hide show
  1. pulse/__init__.py +5 -0
  2. pulse/__main__.py +4 -0
  3. pulse/agent.py +270 -0
  4. pulse/agent_manager.py +335 -0
  5. pulse/audit.py +70 -0
  6. pulse/auth.py +670 -0
  7. pulse/ci/github_client.py +66 -0
  8. pulse/ci/runner.py +28 -0
  9. pulse/cli.py +1075 -0
  10. pulse/cli_ui.py +977 -0
  11. pulse/config.py +167 -0
  12. pulse/context.py +960 -0
  13. pulse/conversations/__init__.py +8 -0
  14. pulse/conversations/manager.py +312 -0
  15. pulse/core/agent.py +188 -0
  16. pulse/core/planner.py +105 -0
  17. pulse/core/protocols.py +37 -0
  18. pulse/edits.py +65 -0
  19. pulse/episodic.py +93 -0
  20. pulse/eval/__init__.py +8 -0
  21. pulse/eval/trajectory_logger.py +91 -0
  22. pulse/eval/verifier.py +133 -0
  23. pulse/execution/__init__.py +5 -0
  24. pulse/execution/remote_task.py +76 -0
  25. pulse/git.py +162 -0
  26. pulse/interactive.py +234 -0
  27. pulse/mcp/__init__.py +4 -0
  28. pulse/mcp/client.py +215 -0
  29. pulse/mcp/local_tools.py +105 -0
  30. pulse/memory.py +212 -0
  31. pulse/mutations.py +283 -0
  32. pulse/orchestration/__init__.py +3 -0
  33. pulse/orchestration/orchestrator.py +162 -0
  34. pulse/patch.py +129 -0
  35. pulse/planner/__init__.py +3 -0
  36. pulse/planner/dag_planner.py +85 -0
  37. pulse/planner/execution_loop.py +159 -0
  38. pulse/production.py +235 -0
  39. pulse/provider.py +59 -0
  40. pulse/provider_keys.py +278 -0
  41. pulse/providers/__init__.py +26 -0
  42. pulse/providers/anthropic.py +65 -0
  43. pulse/providers/base.py +251 -0
  44. pulse/providers/deepseek.py +10 -0
  45. pulse/providers/failover.py +32 -0
  46. pulse/providers/gemini.py +66 -0
  47. pulse/providers/groq.py +10 -0
  48. pulse/providers/manager.py +262 -0
  49. pulse/providers/openai.py +40 -0
  50. pulse/providers/openrouter.py +20 -0
  51. pulse/py.typed +1 -0
  52. pulse/reasoning.py +570 -0
  53. pulse/refactor/__init__.py +3 -0
  54. pulse/refactor/impact_analyzer.py +44 -0
  55. pulse/repository.py +209 -0
  56. pulse/rpc.py +249 -0
  57. pulse/rule_synthesizer.py +54 -0
  58. pulse/runtime.py +217 -0
  59. pulse/safety/__init__.py +3 -0
  60. pulse/safety/safety_manager.py +97 -0
  61. pulse/sandbox/SECURITY.md +57 -0
  62. pulse/sandbox/__init__.py +57 -0
  63. pulse/sandbox/api.py +594 -0
  64. pulse/sandbox/audit.py +153 -0
  65. pulse/sandbox/backend/__init__.py +7 -0
  66. pulse/sandbox/backend/base.py +72 -0
  67. pulse/sandbox/backend/docker.py +498 -0
  68. pulse/sandbox/backend/host.py +140 -0
  69. pulse/sandbox/backend/remote.py +224 -0
  70. pulse/sandbox/errors.py +106 -0
  71. pulse/sandbox/filesystem.py +476 -0
  72. pulse/sandbox/git_safe.py +50 -0
  73. pulse/sandbox/lifecycle.py +88 -0
  74. pulse/sandbox/network.py +205 -0
  75. pulse/sandbox/path_validator.py +280 -0
  76. pulse/sandbox/policy.py +209 -0
  77. pulse/sandbox/process.py +331 -0
  78. pulse/sandbox/project.py +158 -0
  79. pulse/sandbox/python_safe.py +62 -0
  80. pulse/sandbox/remote/__init__.py +1 -0
  81. pulse/sandbox/remote/client.py +389 -0
  82. pulse/sandbox/remote/models.py +167 -0
  83. pulse/sandbox/remote/protocol.py +65 -0
  84. pulse/sandbox/remote/server.py +984 -0
  85. pulse/sandbox/remote/worker.py +175 -0
  86. pulse/sandbox/resources.py +236 -0
  87. pulse/sandbox/secrets.py +241 -0
  88. pulse/session_manager.py +365 -0
  89. pulse/software_engineer.py +189 -0
  90. pulse/storage.py +140 -0
  91. pulse/streaming.py +385 -0
  92. pulse/subprocesses.py +79 -0
  93. pulse/task_manager.py +2005 -0
  94. pulse/telemetry/__init__.py +25 -0
  95. pulse/telemetry/cost_tracker.py +95 -0
  96. pulse/telemetry/logger.py +110 -0
  97. pulse/tool_policy.py +197 -0
  98. pulse/tool_registry.py +163 -0
  99. pulse/tools.py +372 -0
  100. pulse/verification.py +118 -0
  101. pulse_coding_agent-0.1.0.dist-info/METADATA +211 -0
  102. pulse_coding_agent-0.1.0.dist-info/RECORD +104 -0
  103. pulse_coding_agent-0.1.0.dist-info/WHEEL +4 -0
  104. pulse_coding_agent-0.1.0.dist-info/entry_points.txt +4 -0
@@ -0,0 +1,365 @@
1
+ """Production-grade Session Manager for Pulse.
2
+
3
+ Provides session state management, allowing conversational context,
4
+ active tasks, and workspace metadata to be persisted and recovered
5
+ across CLI and VS Code restarts.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ import logging
13
+ import os
14
+ import shutil
15
+ import uuid
16
+ from collections.abc import Awaitable, Callable
17
+ from dataclasses import asdict, dataclass, field
18
+ from datetime import UTC, datetime
19
+ from enum import Enum
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ logger = logging.getLogger(__name__)
24
+ SESSION_SCHEMA_VERSION = 1
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Enums & Data Models
29
+ # ---------------------------------------------------------------------------
30
+
31
+
32
+ class SessionStatus(Enum):
33
+ """Current state of a managed session."""
34
+
35
+ ACTIVE = "ACTIVE"
36
+ ARCHIVED = "ARCHIVED"
37
+ ERROR = "ERROR"
38
+
39
+
40
+ @dataclass(slots=True)
41
+ class SessionTurn:
42
+ """A single turn in the session's conversation."""
43
+
44
+ role: str
45
+ content: str
46
+ timestamp: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
47
+ metadata: dict[str, Any] = field(default_factory=dict)
48
+
49
+
50
+ @dataclass(slots=True)
51
+ class SessionEvent:
52
+ """Structured event emitted for VS Code UI and RPC clients."""
53
+
54
+ event_type: str
55
+ session_id: str
56
+ status: SessionStatus
57
+ timestamp: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
58
+ payload: dict[str, Any] = field(default_factory=dict)
59
+
60
+
61
+ @dataclass
62
+ class Session:
63
+ """A persistent unit of conversational and workspace state."""
64
+
65
+ id: str
66
+ title: str = "New Session"
67
+ status: SessionStatus = SessionStatus.ACTIVE
68
+ conversation: list[SessionTurn] = field(default_factory=list)
69
+ active_tasks: list[str] = field(default_factory=list)
70
+ checkpoints: list[str] = field(default_factory=list)
71
+ metadata: dict[str, Any] = field(default_factory=dict)
72
+ created_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
73
+ updated_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
74
+
75
+ def to_dict(self) -> dict[str, Any]:
76
+ """Serialize Session object to JSON-compatible dictionary."""
77
+ return {
78
+ "schema_version": SESSION_SCHEMA_VERSION,
79
+ "id": self.id,
80
+ "title": self.title,
81
+ "status": self.status.value,
82
+ "conversation": [asdict(t) for t in self.conversation],
83
+ "active_tasks": self.active_tasks,
84
+ "checkpoints": self.checkpoints,
85
+ "metadata": self.metadata,
86
+ "created_at": self.created_at,
87
+ "updated_at": self.updated_at,
88
+ }
89
+
90
+ @classmethod
91
+ def from_dict(cls, data: dict[str, Any]) -> Session:
92
+ """Deserialize Session object from dictionary."""
93
+ version = int(data.get("schema_version", 0))
94
+ if version > SESSION_SCHEMA_VERSION:
95
+ raise ValueError(
96
+ f"Session schema v{version} is newer than supported v{SESSION_SCHEMA_VERSION}."
97
+ )
98
+ status = SessionStatus(data.get("status", "ACTIVE"))
99
+ conversation = [
100
+ SessionTurn(**t) for t in data.get("conversation", []) if isinstance(t, dict)
101
+ ]
102
+
103
+ return cls(
104
+ id=data["id"],
105
+ title=data.get("title", "New Session"),
106
+ status=status,
107
+ conversation=conversation,
108
+ active_tasks=list(data.get("active_tasks", [])),
109
+ checkpoints=list(data.get("checkpoints", [])),
110
+ metadata=data.get("metadata", {}),
111
+ created_at=data.get("created_at", datetime.now(UTC).isoformat()),
112
+ updated_at=data.get("updated_at", datetime.now(UTC).isoformat()),
113
+ )
114
+
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # Session Store & Event Bus
118
+ # ---------------------------------------------------------------------------
119
+
120
+
121
+ class SessionStore:
122
+ """File-backed persistence store for Sessions."""
123
+
124
+ def __init__(self, workspace: Path | None = None) -> None:
125
+ self.workspace = workspace or Path.cwd()
126
+ self.store_dir = self.workspace / ".pulse" / "sessions"
127
+ self.active_session_file = self.store_dir / "active_session.txt"
128
+ self.store_dir.mkdir(parents=True, exist_ok=True)
129
+
130
+ def _get_file_path(self, session_id: str) -> Path:
131
+ return self.store_dir / f"{session_id}.json"
132
+
133
+ def save(self, session: Session) -> None:
134
+ """Persist a session to its JSON file."""
135
+ temporary_path: Path | None = None
136
+ try:
137
+ file_path = self._get_file_path(session.id)
138
+ temporary_path = file_path.with_name(f".{file_path.name}.{uuid.uuid4().hex}.tmp")
139
+ with temporary_path.open("w", encoding="utf-8") as f:
140
+ json.dump(session.to_dict(), f, indent=2)
141
+ f.flush()
142
+ os.fsync(f.fileno())
143
+ os.replace(temporary_path, file_path)
144
+ except OSError as err:
145
+ logger.error(f"Failed to persist session {session.id}: {err}")
146
+ finally:
147
+ if temporary_path:
148
+ temporary_path.unlink(missing_ok=True)
149
+
150
+ def load(self, session_id: str) -> Session | None:
151
+ """Load a session from its JSON file."""
152
+ file_path = self._get_file_path(session_id)
153
+ if not file_path.exists():
154
+ return None
155
+ try:
156
+ with file_path.open("r", encoding="utf-8") as f:
157
+ data = json.load(f)
158
+ version = int(data.get("schema_version", 0))
159
+ session = Session.from_dict(data)
160
+ if version < SESSION_SCHEMA_VERSION:
161
+ backup = file_path.with_suffix(f".json.schema-v{version}.bak")
162
+ if not backup.exists():
163
+ shutil.copy2(file_path, backup)
164
+ self.save(session)
165
+ return session
166
+ except (OSError, ValueError, json.JSONDecodeError) as err:
167
+ logger.error(f"Failed to load session {session_id}: {err}")
168
+ return None
169
+
170
+ def list_all(self) -> list[Session]:
171
+ """List all available sessions."""
172
+ sessions = []
173
+ for file_path in self.store_dir.glob("*.json"):
174
+ session = self.load(file_path.stem)
175
+ if session:
176
+ sessions.append(session)
177
+ return sorted(sessions, key=lambda s: s.updated_at, reverse=True)
178
+
179
+ def set_active_session_id(self, session_id: str) -> None:
180
+ """Mark a session as the currently active one."""
181
+ try:
182
+ self.active_session_file.write_text(session_id, encoding="utf-8")
183
+ except OSError as err:
184
+ logger.error(f"Failed to write active session: {err}")
185
+
186
+ def get_active_session_id(self) -> str | None:
187
+ """Read the currently active session ID."""
188
+ if not self.active_session_file.exists():
189
+ return None
190
+ try:
191
+ return self.active_session_file.read_text(encoding="utf-8").strip()
192
+ except OSError as err:
193
+ logger.error(f"Failed to read active session: {err}")
194
+ return None
195
+
196
+
197
+ SessionEventListener = Callable[[SessionEvent], Awaitable[None] | None]
198
+
199
+
200
+ class SessionEventBus:
201
+ """Async event bus emitting structured session events."""
202
+
203
+ def __init__(self) -> None:
204
+ self._listeners: list[SessionEventListener] = []
205
+
206
+ def subscribe(self, listener: SessionEventListener) -> None:
207
+ if listener not in self._listeners:
208
+ self._listeners.append(listener)
209
+
210
+ def unsubscribe(self, listener: SessionEventListener) -> None:
211
+ if listener in self._listeners:
212
+ self._listeners.remove(listener)
213
+
214
+ async def emit(self, event: SessionEvent) -> None:
215
+ for listener in list(self._listeners):
216
+ try:
217
+ res = listener(event)
218
+ if asyncio.iscoroutine(res) or hasattr(res, "__await__"):
219
+ await res
220
+ # Intentionally broad to isolate execution boundaries and prevent crashes.
221
+ except Exception as err: # noqa: BLE001
222
+ # Intentionally broad to isolate event listener failures from crashing the session manager.
223
+ logger.warning(f"Error in session event listener: {err}")
224
+
225
+
226
+ # ---------------------------------------------------------------------------
227
+ # Session Manager
228
+ # ---------------------------------------------------------------------------
229
+
230
+
231
+ class SessionManager:
232
+ """Principal Session Manager for Pulse.
233
+
234
+ Handles session creation, resuming, archiving, event emissions,
235
+ and coordinates state with the TaskManager.
236
+ """
237
+
238
+ def __init__(
239
+ self,
240
+ workspace: Path | None = None,
241
+ task_manager: Any | None = None,
242
+ telemetry: Any | None = None,
243
+ ) -> None:
244
+ self.workspace = workspace or Path.cwd()
245
+ self.store = SessionStore(self.workspace)
246
+ self.event_bus = SessionEventBus()
247
+ self.task_manager = task_manager
248
+ self.telemetry = telemetry
249
+ self._active_session: Session | None = None
250
+
251
+ @property
252
+ def active_session(self) -> Session | None:
253
+ """Get the currently loaded active session."""
254
+ return self._active_session
255
+
256
+ def _mark_updated(self, session: Session) -> None:
257
+ session.updated_at = datetime.now(UTC).isoformat()
258
+ self.store.save(session)
259
+
260
+ async def create_session(self, title: str | None = None, make_active: bool = True) -> Session:
261
+ """Create and optionally activate a new session."""
262
+ session_id = str(uuid.uuid4())
263
+ session = Session(
264
+ id=session_id,
265
+ title=title or "New Session",
266
+ status=SessionStatus.ACTIVE
267
+ )
268
+ self.store.save(session)
269
+
270
+ if make_active:
271
+ await self._set_active_session(session)
272
+
273
+ if self.telemetry and hasattr(self.telemetry, "log_event"):
274
+ self.telemetry.log_event("session_created", session_id=session_id)
275
+
276
+ await self.event_bus.emit(
277
+ SessionEvent("session_created", session_id, session.status, payload={"title": session.title})
278
+ )
279
+ return session
280
+
281
+ async def load_session(self, session_id: str) -> Session:
282
+ """Load an existing session from store."""
283
+ session = self.store.load(session_id)
284
+ if not session:
285
+ raise ValueError(f"Session {session_id} not found.")
286
+ return session
287
+
288
+ async def resume_session(self, session_id: str) -> Session:
289
+ """Load a session and mark it as active."""
290
+ session = await self.load_session(session_id)
291
+ if session.status == SessionStatus.ARCHIVED:
292
+ session.status = SessionStatus.ACTIVE
293
+ self._mark_updated(session)
294
+
295
+ await self._set_active_session(session)
296
+
297
+ if self.telemetry and hasattr(self.telemetry, "log_event"):
298
+ self.telemetry.log_event("session_resumed", session_id=session_id)
299
+
300
+ await self.event_bus.emit(
301
+ SessionEvent("session_resumed", session_id, session.status, payload={"title": session.title})
302
+ )
303
+ return session
304
+
305
+ async def archive_session(self, session_id: str) -> Session:
306
+ """Archive a session."""
307
+ session = await self.load_session(session_id)
308
+ session.status = SessionStatus.ARCHIVED
309
+ self._mark_updated(session)
310
+
311
+ if self._active_session and self._active_session.id == session_id:
312
+ self._active_session = None
313
+ try:
314
+ self.store.active_session_file.unlink(missing_ok=True)
315
+ except OSError:
316
+ pass
317
+
318
+ if self.telemetry and hasattr(self.telemetry, "log_event"):
319
+ self.telemetry.log_event("session_archived", session_id=session_id)
320
+
321
+ await self.event_bus.emit(
322
+ SessionEvent("session_archived", session_id, session.status)
323
+ )
324
+ return session
325
+
326
+ async def get_or_create_active_session(self) -> Session:
327
+ """Retrieve the last active session, or create a new one if none exists."""
328
+ if self._active_session:
329
+ return self._active_session
330
+
331
+ active_id = self.store.get_active_session_id()
332
+ if active_id:
333
+ try:
334
+ session = await self.load_session(active_id)
335
+ if session.status == SessionStatus.ACTIVE:
336
+ self._active_session = session
337
+ return session
338
+ except ValueError:
339
+ pass # Session file might have been deleted
340
+
341
+ # Fallback to creating a new one
342
+ return await self.create_session(make_active=True)
343
+
344
+ async def add_conversation_turn(self, role: str, content: str, session_id: str | None = None, metadata: dict[str, Any] | None = None) -> None:
345
+ """Append a conversational turn to a session."""
346
+ if session_id:
347
+ session = await self.load_session(session_id)
348
+ else:
349
+ session = await self.get_or_create_active_session()
350
+
351
+ turn = SessionTurn(role=role, content=content, metadata=metadata or {})
352
+ session.conversation.append(turn)
353
+ self._mark_updated(session)
354
+
355
+ # If this is the active session, update in-memory reference
356
+ if self._active_session and self._active_session.id == session.id:
357
+ self._active_session = session
358
+
359
+ await self.event_bus.emit(
360
+ SessionEvent("session_turn_added", session.id, session.status, payload={"role": role, "length": len(content)})
361
+ )
362
+
363
+ async def _set_active_session(self, session: Session) -> None:
364
+ self._active_session = session
365
+ self.store.set_active_session_id(session.id)
@@ -0,0 +1,189 @@
1
+ import asyncio
2
+ import logging
3
+ from collections.abc import AsyncGenerator
4
+ from dataclasses import dataclass
5
+ from enum import Enum
6
+
7
+ from pulse.context import ContextManager
8
+ from pulse.core.planner import PlanningRequest, RequestPlanner
9
+ from pulse.memory import LongTermMemory
10
+ from pulse.reasoning import IntentCategory, ReasoningEngine
11
+ from pulse.repository import RepositoryIndex
12
+ from pulse.session_manager import SessionManager
13
+ from pulse.streaming import StreamEvent, StreamingExecutionEngine
14
+ from pulse.task_manager import TaskManager
15
+ from pulse.tool_registry import ToolRegistry
16
+ from pulse.verification import VerificationEngine
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class EngineerStatus(Enum):
22
+ IDLE = "IDLE"
23
+ PLANNING = "PLANNING"
24
+ EXECUTING = "EXECUTING"
25
+ VERIFYING = "VERIFYING"
26
+ BLOCKED = "BLOCKED"
27
+ COMPLETED = "COMPLETED"
28
+ CANCELLED = "CANCELLED"
29
+
30
+
31
+ @dataclass
32
+ class EngineerEvent:
33
+ event_type: str # feature_started, planning_complete, task_started, task_completed, task_failed, feature_blocked, feature_completed
34
+ message: str
35
+ metadata: dict[str, str | int | float | bool | None] | None = None
36
+
37
+
38
+ @dataclass
39
+ class EngineerResult:
40
+ status: EngineerStatus
41
+ tasks_created: int
42
+ tasks_completed: int
43
+ summary: str
44
+
45
+
46
+ class AutonomousSoftwareEngineer:
47
+ """Top-level autonomous orchestration engine.
48
+
49
+ Coordinates the Planner, TaskManager, StreamingExecutionEngine, and VerificationEngine
50
+ to autonomously satisfy complex feature requests.
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ reasoning_engine: ReasoningEngine,
56
+ planner: RequestPlanner,
57
+ task_manager: TaskManager,
58
+ session_manager: SessionManager,
59
+ streaming_engine: StreamingExecutionEngine,
60
+ verification_engine: VerificationEngine,
61
+ context_manager: ContextManager,
62
+ memory: LongTermMemory,
63
+ repository: RepositoryIndex,
64
+ tool_registry: ToolRegistry,
65
+ ) -> None:
66
+ self.reasoning_engine = reasoning_engine
67
+ self.planner = planner
68
+ self.task_manager = task_manager
69
+ self.session_manager = session_manager
70
+ self.streaming_engine = streaming_engine
71
+ self.verification_engine = verification_engine
72
+ self.context_manager = context_manager
73
+ self.memory = memory
74
+ self.repository = repository
75
+ self.tool_registry = tool_registry
76
+
77
+ async def execute_feature(
78
+ self,
79
+ request: str,
80
+ session_id: str | None = None,
81
+ cancellation_token: asyncio.Event | None = None,
82
+ ) -> AsyncGenerator[EngineerEvent | StreamEvent, None]:
83
+ """Executes a high-level feature request autonomously."""
84
+ session = None
85
+ if session_id:
86
+ try:
87
+ session = await self.session_manager.load_session(session_id)
88
+ await self.session_manager.add_conversation_turn("user", request, session_id=session.id)
89
+ except ValueError:
90
+ pass
91
+
92
+ if not session:
93
+ session = await self.session_manager.get_or_create_active_session()
94
+ await self.session_manager.add_conversation_turn("user", request, session_id=session.id)
95
+
96
+ yield EngineerEvent("feature_started", f"Starting feature: {request[:50]}...", {"session_id": session.id})
97
+
98
+ # 1. Reasoning
99
+ intent_res = await self.reasoning_engine.analyze_intent(request)
100
+ if intent_res.category == IntentCategory.DIRECT_ANSWER:
101
+ # Short-circuit for direct answers
102
+ yield EngineerEvent("feature_completed", "Direct answer provided without planning.")
103
+ return
104
+
105
+ # 2. Planning
106
+ yield EngineerEvent("planning_started", "Decomposing feature into subtasks...")
107
+ plan = await self.planner.plan(PlanningRequest(message=request))
108
+ yield EngineerEvent("planning_complete", f"Generated {len(plan.steps)} subtasks.")
109
+
110
+ # 3. Create Tasks
111
+ created_tasks = []
112
+ for step in plan.steps:
113
+ task = await self.task_manager.create_task(
114
+ title=f"Step {step.id}",
115
+ goal=step.description
116
+ )
117
+ created_tasks.append(task)
118
+
119
+ # 4. Iterative Execution Loop
120
+ tasks_completed = 0
121
+ for task in created_tasks:
122
+ if cancellation_token and cancellation_token.is_set():
123
+ yield EngineerEvent("feature_cancelled", "Execution cancelled by user.")
124
+ return
125
+
126
+ yield EngineerEvent("task_started", f"Executing task: {task.title}", {"task_id": task.id})
127
+
128
+ # Build context for this step
129
+ # Execute via StreamingExecutionEngine
130
+ try:
131
+ async for stream_event in self.streaming_engine.execute_stream(task.goal, task_id=task.id):
132
+ if cancellation_token and cancellation_token.is_set():
133
+ await self.task_manager.cancel_task(task.id, reason="User cancelled")
134
+ yield EngineerEvent("feature_cancelled", "Execution cancelled by user.")
135
+ return
136
+ yield stream_event
137
+
138
+ await self.task_manager.complete_task(task.id)
139
+ tasks_completed += 1
140
+ yield EngineerEvent("task_completed", f"Completed task: {task.title}", {"task_id": task.id})
141
+ # Intentionally broad to isolate execution boundaries and prevent crashes.
142
+ except Exception as e: # noqa: BLE001
143
+ logger.error(f"Task {task.id} failed: {e}")
144
+ await self.task_manager.fail_task(task.id, error=str(e))
145
+ yield EngineerEvent("task_failed", f"Task failed: {task.title} - {e}", {"task_id": task.id})
146
+ yield EngineerEvent("feature_blocked", "Feature blocked due to task failure.")
147
+ return
148
+
149
+ # 5. Global Verification
150
+ yield EngineerEvent("verifying", "Running global verification...")
151
+ try:
152
+ ver_res = await self.verification_engine.verify()
153
+ if not ver_res.success:
154
+ yield EngineerEvent("verification_failed", "Global verification failed.", {"details": ver_res.message})
155
+ # Intentionally broad to isolate execution boundaries and prevent crashes.
156
+ except Exception as e: # noqa: BLE001
157
+ logger.warning(f"Global verification error: {e}")
158
+
159
+ # 6. Completion
160
+ summary = f"Successfully completed {tasks_completed}/{len(created_tasks)} tasks."
161
+ await self.session_manager.add_conversation_turn("agent", summary, session_id=session.id)
162
+
163
+ yield EngineerEvent("feature_completed", summary, {"tasks_completed": tasks_completed})
164
+
165
+ async def resume_feature(self, session_id: str, cancellation_token: asyncio.Event | None = None) -> AsyncGenerator[EngineerEvent | StreamEvent, None]:
166
+ """Resumes an interrupted or paused feature session."""
167
+ try:
168
+ session = await self.session_manager.resume_session(session_id)
169
+ except ValueError as e:
170
+ yield EngineerEvent("resume_failed", str(e))
171
+ return
172
+
173
+ yield EngineerEvent("feature_resumed", f"Resuming session: {session_id}", {"session_id": session.id})
174
+
175
+ # In a real implementation we would fetch pending tasks and execute them.
176
+ yield EngineerEvent("feature_completed", "Resumed and completed.")
177
+
178
+ async def cancel_feature(self, session_id: str) -> None:
179
+ """Cancels an ongoing feature execution."""
180
+ # Typically we'd find active tasks for this session and cancel them.
181
+ try:
182
+ session = await self.session_manager.load_session(session_id)
183
+ for task_id in session.active_tasks:
184
+ try:
185
+ await self.task_manager.cancel_task(task_id, reason="Feature cancelled")
186
+ except ValueError:
187
+ pass
188
+ except ValueError:
189
+ pass
pulse/storage.py ADDED
@@ -0,0 +1,140 @@
1
+ """Transactional SQLite schema migration, backup, and restore primitives."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sqlite3
8
+ import tempfile
9
+ from collections.abc import Callable
10
+ from contextlib import closing
11
+ from datetime import UTC, datetime
12
+ from pathlib import Path
13
+
14
+ Migration = Callable[[sqlite3.Connection, int], None]
15
+
16
+
17
+ def schema_version(database_path: Path) -> int:
18
+ if not database_path.exists():
19
+ return 0
20
+ with closing(sqlite3.connect(database_path)) as connection:
21
+ return int(connection.execute("PRAGMA user_version").fetchone()[0])
22
+
23
+
24
+ def backup_database(source: Path, destination: Path) -> Path:
25
+ source = source.resolve()
26
+ destination = destination.resolve()
27
+ if not source.is_file():
28
+ raise FileNotFoundError(source)
29
+ if source == destination:
30
+ raise ValueError("Backup destination must differ from the source database.")
31
+ destination.parent.mkdir(parents=True, exist_ok=True)
32
+ with (
33
+ closing(sqlite3.connect(source)) as source_connection,
34
+ closing(sqlite3.connect(destination)) as backup_connection,
35
+ ):
36
+ source_connection.backup(backup_connection)
37
+ result = backup_connection.execute("PRAGMA integrity_check").fetchone()
38
+ if not result or result[0] != "ok":
39
+ raise sqlite3.DatabaseError("Backup integrity check failed.")
40
+ return destination
41
+
42
+
43
+ def restore_database(backup: Path, destination: Path) -> Path:
44
+ """Restore a verified backup atomically; the destination must not be in use."""
45
+ backup = backup.resolve()
46
+ destination = destination.resolve()
47
+ if not backup.is_file():
48
+ raise FileNotFoundError(backup)
49
+ destination.parent.mkdir(parents=True, exist_ok=True)
50
+ handle, temporary_name = tempfile.mkstemp(
51
+ prefix=f".{destination.name}.restore-", suffix=".sqlite3", dir=destination.parent
52
+ )
53
+ os.close(handle)
54
+ temporary_path = Path(temporary_name)
55
+ try:
56
+ backup_database(backup, temporary_path)
57
+ for suffix in ("-wal", "-shm"):
58
+ Path(f"{destination}{suffix}").unlink(missing_ok=True)
59
+ os.replace(temporary_path, destination)
60
+ finally:
61
+ temporary_path.unlink(missing_ok=True)
62
+ return destination
63
+
64
+
65
+ def migrate_database(
66
+ database_path: Path,
67
+ target_version: int,
68
+ migration: Migration,
69
+ *,
70
+ timeout: float = 10.0,
71
+ ) -> Path | None:
72
+ """Migrate one database transactionally and back up any existing schema."""
73
+ if target_version < 1:
74
+ raise ValueError("Target schema version must be positive.")
75
+ database_path = database_path.resolve()
76
+ database_path.parent.mkdir(parents=True, exist_ok=True)
77
+ backup_path: Path | None = None
78
+ with closing(
79
+ sqlite3.connect(database_path, timeout=timeout, isolation_level=None)
80
+ ) as connection:
81
+ connection.execute("PRAGMA journal_mode=WAL")
82
+ current = int(connection.execute("PRAGMA user_version").fetchone()[0])
83
+ if current > target_version:
84
+ raise RuntimeError(
85
+ f"Database schema v{current} is newer than supported v{target_version}."
86
+ )
87
+ if current == target_version:
88
+ return None
89
+ has_user_tables = connection.execute(
90
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' LIMIT 1"
91
+ ).fetchone()
92
+ if has_user_tables:
93
+ stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S%fZ")
94
+ backup_path = (
95
+ database_path.parent
96
+ / "backups"
97
+ / f"{database_path.stem}.schema-v{current}-to-v{target_version}.{stamp}.sqlite3"
98
+ )
99
+ backup_database(database_path, backup_path)
100
+ try:
101
+ connection.execute("BEGIN IMMEDIATE")
102
+ migration(connection, current)
103
+ connection.execute(f"PRAGMA user_version = {target_version}")
104
+ connection.execute("COMMIT")
105
+ except BaseException:
106
+ connection.execute("ROLLBACK")
107
+ raise
108
+ return backup_path
109
+
110
+
111
+ def main() -> int:
112
+ parser = argparse.ArgumentParser(description="Inspect, back up, or restore Pulse SQLite state.")
113
+ commands = parser.add_subparsers(dest="command", required=True)
114
+ version_parser = commands.add_parser("version")
115
+ version_parser.add_argument("database", type=Path)
116
+ backup_parser = commands.add_parser("backup")
117
+ backup_parser.add_argument("source", type=Path)
118
+ backup_parser.add_argument("destination", type=Path)
119
+ restore_parser = commands.add_parser("restore")
120
+ restore_parser.add_argument("backup", type=Path)
121
+ restore_parser.add_argument("destination", type=Path)
122
+ restore_parser.add_argument("--confirm-stopped", action="store_true")
123
+ args = parser.parse_args()
124
+ try:
125
+ if args.command == "version":
126
+ print(schema_version(args.database))
127
+ elif args.command == "backup":
128
+ print(backup_database(args.source, args.destination))
129
+ else:
130
+ if not args.confirm_stopped:
131
+ parser.error("restore requires --confirm-stopped")
132
+ print(restore_database(args.backup, args.destination))
133
+ except (OSError, RuntimeError, sqlite3.DatabaseError, ValueError) as exc:
134
+ print(f"Storage operation failed: {exc}")
135
+ return 2
136
+ return 0
137
+
138
+
139
+ if __name__ == "__main__":
140
+ raise SystemExit(main())