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
pulse/task_manager.py ADDED
@@ -0,0 +1,2005 @@
1
+ """Production-grade Task Manager for Pulse.
2
+
3
+ Provides task creation, priority queuing, pausing, resuming, canceling,
4
+ checkpointing, persistent state storage across sessions, VS Code UI event emissions,
5
+ telemetry/memory integrations, and priority-scheduled concurrent execution.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ import logging
13
+ import os
14
+ import sqlite3
15
+ import uuid
16
+ from collections.abc import Awaitable, Callable, Sequence
17
+ from dataclasses import asdict, dataclass, field
18
+ from datetime import UTC, datetime, timedelta
19
+ from enum import Enum
20
+ from pathlib import Path
21
+ from typing import Any, ClassVar
22
+
23
+ from pulse.storage import migrate_database
24
+ from pulse.telemetry import get_correlation_id
25
+
26
+ TASK_SCHEMA_VERSION = 3
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Enums & Data Models
33
+ # ---------------------------------------------------------------------------
34
+
35
+
36
+ class TaskConcurrencyError(Exception):
37
+ """Raised when a stale task update is rejected via OCC."""
38
+
39
+ def __init__(self, task_id: str):
40
+ super().__init__(
41
+ f"Task {task_id} was modified by another process. Stale update rejected."
42
+ )
43
+ self.task_id = task_id
44
+
45
+
46
+ class StaleWorkerError(Exception):
47
+ """Raised when a worker attempts to modify a task it no longer owns."""
48
+
49
+ def __init__(self, task_id: str):
50
+ super().__init__(f"Worker no longer owns task {task_id}.")
51
+ self.task_id = task_id
52
+
53
+
54
+ class LeaseLostError(Exception):
55
+ """Raised when a heartbeat detects that task ownership has been lost.
56
+
57
+ This exception is used by the execution supervisor to cancel the
58
+ running worker_func when the lease can no longer be renewed.
59
+ It is distinct from StaleWorkerError (which guards individual mutations)
60
+ because it signals that the *entire execution* must terminate.
61
+
62
+ NOTE: asyncio.Task.cancel() delivers CancelledError at the next await
63
+ point. It cannot terminate an already-issued HTTP request, LLM call,
64
+ or spawned shell process. This mechanism stops the Pulse worker loop
65
+ from progressing after lease loss but does not provide universal
66
+ transactional rollback of external effects. Remote execution
67
+ reconciliation remains GAP-07 scope.
68
+ """
69
+
70
+ def __init__(self, task_id: str):
71
+ super().__init__(f"Lease lost for task {task_id}. Execution must terminate.")
72
+ self.task_id = task_id
73
+
74
+
75
+ class RetryDeferredError(Exception):
76
+ """Raised when a queued task has not reached its durable retry deadline."""
77
+
78
+ def __init__(self, task_id: str, retry_at: str):
79
+ super().__init__(f"Task {task_id} retry is deferred until {retry_at}.")
80
+ self.task_id = task_id
81
+ self.retry_at = retry_at
82
+
83
+
84
+ class TaskStatus(Enum):
85
+ """Current state of a managed task."""
86
+
87
+ PENDING = "PENDING"
88
+ QUEUED = "QUEUED"
89
+ RUNNING = "RUNNING"
90
+ PAUSED = "PAUSED"
91
+ COMPLETED = "COMPLETED"
92
+ FAILED = "FAILED"
93
+ CANCELLED = "CANCELLED"
94
+ RECOVERY_PENDING = "RECOVERY_PENDING"
95
+ DEAD_LETTER = "DEAD_LETTER"
96
+
97
+
98
+ class TaskPriority(Enum):
99
+ """Priority level for task scheduling."""
100
+
101
+ LOW = 10
102
+ MEDIUM = 20
103
+ HIGH = 30
104
+ CRITICAL = 40
105
+
106
+ @classmethod
107
+ def from_str(cls, val: str) -> TaskPriority:
108
+ val_upper = val.upper().strip()
109
+ for p in cls:
110
+ if p.name == val_upper:
111
+ return p
112
+ return cls.MEDIUM
113
+
114
+
115
+ @dataclass(slots=True)
116
+ class TaskCheckpoint:
117
+ """State snapshot for task resumption across sessions."""
118
+
119
+ checkpoint_id: str
120
+ task_id: str
121
+ step_index: int
122
+ state_data: dict[str, Any]
123
+ timestamp: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
124
+ metadata: dict[str, Any] = field(default_factory=dict)
125
+ version: int = 1
126
+
127
+
128
+ @dataclass(slots=True)
129
+ class TaskExecutionRecord:
130
+ """Audit record for a single step execution within a task."""
131
+
132
+ timestamp: str
133
+ action: str
134
+ detail: str
135
+ duration_ms: float = 0.0
136
+ success: bool = True
137
+
138
+
139
+ @dataclass(slots=True)
140
+ class TaskEvent:
141
+ """Structured event emitted for VS Code UI and RPC clients."""
142
+
143
+ event_type: str
144
+ task_id: str
145
+ status: TaskStatus
146
+ progress: float
147
+ timestamp: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
148
+ payload: dict[str, Any] = field(default_factory=dict)
149
+
150
+
151
+ @dataclass(frozen=True, slots=True)
152
+ class TaskOutboxEvent:
153
+ """Durable lifecycle event emitted with a task-store transaction.
154
+
155
+ Consumers may safely retry delivery: events are immutable, ordered per
156
+ task, and remain available until an explicit acknowledgement is recorded.
157
+ """
158
+
159
+ event_id: str
160
+ task_id: str
161
+ sequence: int
162
+ event_type: str
163
+ payload: dict[str, Any]
164
+ created_at: str
165
+ delivered_at: str | None = None
166
+
167
+
168
+ @dataclass
169
+ class Task:
170
+ """A unit of work managed by Pulse."""
171
+
172
+ id: str
173
+ title: str
174
+ goal: str
175
+ priority: TaskPriority = TaskPriority.MEDIUM
176
+ status: TaskStatus = TaskStatus.PENDING
177
+ progress: float = 0.0 # 0.0 to 100.0
178
+ retries: int = 0
179
+ max_retries: int = 3
180
+ depends_on: list[str] = field(default_factory=list)
181
+ checkpoints: list[TaskCheckpoint] = field(default_factory=list)
182
+ history: list[TaskExecutionRecord] = field(default_factory=list)
183
+ created_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
184
+ updated_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
185
+ result: str | None = None
186
+ error: str | None = None
187
+ metadata: dict[str, Any] = field(default_factory=dict)
188
+ version: int = 1
189
+ owner_id: str | None = None
190
+ lease_expires_at: str | None = None
191
+ lease_epoch: int = 0
192
+ owner_pid: int | None = None
193
+ remote_execution_id: str | None = None
194
+ next_retry_at: str | None = None
195
+
196
+ def to_dict(self) -> dict[str, Any]:
197
+ """Serialize Task object to JSON-compatible dictionary."""
198
+ return {
199
+ "id": self.id,
200
+ "title": self.title,
201
+ "goal": self.goal,
202
+ "priority": self.priority.name,
203
+ "status": self.status.value,
204
+ "progress": self.progress,
205
+ "retries": self.retries,
206
+ "max_retries": self.max_retries,
207
+ "depends_on": self.depends_on,
208
+ "checkpoints": [asdict(cp) for cp in self.checkpoints],
209
+ "history": [asdict(rec) for rec in self.history],
210
+ "created_at": self.created_at,
211
+ "updated_at": self.updated_at,
212
+ "result": self.result,
213
+ "error": self.error,
214
+ "metadata": self.metadata,
215
+ "version": self.version,
216
+ "owner_id": self.owner_id,
217
+ "lease_expires_at": self.lease_expires_at,
218
+ "lease_epoch": self.lease_epoch,
219
+ "owner_pid": self.owner_pid,
220
+ "remote_execution_id": self.remote_execution_id,
221
+ "next_retry_at": self.next_retry_at,
222
+ }
223
+
224
+ @classmethod
225
+ def from_dict(cls, data: dict[str, Any]) -> Task:
226
+ """Deserialize Task object from dictionary."""
227
+ priority = TaskPriority.from_str(data.get("priority", "MEDIUM"))
228
+ status = TaskStatus(data.get("status", "PENDING"))
229
+
230
+ checkpoints = [
231
+ TaskCheckpoint(**cp)
232
+ for cp in data.get("checkpoints", [])
233
+ if isinstance(cp, dict)
234
+ ]
235
+ history = [
236
+ TaskExecutionRecord(**rec)
237
+ for rec in data.get("history", [])
238
+ if isinstance(rec, dict)
239
+ ]
240
+
241
+ return cls(
242
+ id=data["id"],
243
+ title=data.get("title", ""),
244
+ goal=data.get("goal", ""),
245
+ priority=priority,
246
+ status=status,
247
+ progress=float(data.get("progress", 0.0)),
248
+ retries=int(data.get("retries", 0)),
249
+ max_retries=int(data.get("max_retries", 3)),
250
+ depends_on=list(data.get("depends_on", [])),
251
+ checkpoints=checkpoints,
252
+ history=history,
253
+ created_at=data.get("created_at", datetime.now(UTC).isoformat()),
254
+ updated_at=data.get("updated_at", datetime.now(UTC).isoformat()),
255
+ result=data.get("result"),
256
+ error=data.get("error"),
257
+ metadata=data.get("metadata", {}),
258
+ version=int(data.get("version", 1)),
259
+ owner_id=data.get("owner_id"),
260
+ lease_expires_at=data.get("lease_expires_at"),
261
+ lease_epoch=int(data.get("lease_epoch", 0)),
262
+ owner_pid=data.get("owner_pid"),
263
+ remote_execution_id=data.get("remote_execution_id"),
264
+ next_retry_at=data.get("next_retry_at"),
265
+ )
266
+
267
+
268
+ # ---------------------------------------------------------------------------
269
+ # Task Store & Event Bus
270
+ # ---------------------------------------------------------------------------
271
+
272
+
273
+ class TaskStore:
274
+ """SQLite-backed persistence store for Tasks and Checkpoints."""
275
+
276
+ def __init__(self, workspace: Path | None = None) -> None:
277
+ self.workspace = workspace or Path.cwd()
278
+ self.store_dir = self.workspace / ".pulse"
279
+ self.store_dir.mkdir(parents=True, exist_ok=True)
280
+ self.store_file = self.store_dir / "tasks.sqlite3"
281
+ self._ensure_schema()
282
+ self._migrate_legacy_json()
283
+
284
+ def _connect(self) -> sqlite3.Connection:
285
+ conn = sqlite3.connect(self.store_file, timeout=10.0)
286
+ conn.execute("PRAGMA journal_mode=WAL")
287
+ conn.execute("PRAGMA synchronous=NORMAL")
288
+ return conn
289
+
290
+ def _ensure_schema(self) -> None:
291
+ def migration(conn: sqlite3.Connection, _current: int) -> None:
292
+ conn.execute("""CREATE TABLE IF NOT EXISTS tasks (
293
+ id TEXT PRIMARY KEY,
294
+ title TEXT NOT NULL,
295
+ goal TEXT NOT NULL,
296
+ priority TEXT NOT NULL,
297
+ status TEXT NOT NULL,
298
+ progress REAL NOT NULL,
299
+ retries INTEGER NOT NULL,
300
+ max_retries INTEGER NOT NULL,
301
+ depends_on TEXT NOT NULL,
302
+ created_at TEXT NOT NULL,
303
+ updated_at TEXT NOT NULL,
304
+ result TEXT,
305
+ error TEXT,
306
+ metadata TEXT NOT NULL,
307
+ checkpoints TEXT NOT NULL,
308
+ history TEXT NOT NULL,
309
+ version INTEGER NOT NULL DEFAULT 1,
310
+ owner_id TEXT,
311
+ lease_expires_at TEXT,
312
+ lease_epoch INTEGER NOT NULL DEFAULT 0,
313
+ owner_pid INTEGER,
314
+ remote_execution_id TEXT,
315
+ next_retry_at TEXT
316
+ )""")
317
+ columns = {
318
+ row[1] for row in conn.execute("PRAGMA table_info(tasks)").fetchall()
319
+ }
320
+ additions = {
321
+ "version": "INTEGER NOT NULL DEFAULT 1",
322
+ "owner_id": "TEXT",
323
+ "lease_expires_at": "TEXT",
324
+ "lease_epoch": "INTEGER NOT NULL DEFAULT 0",
325
+ "owner_pid": "INTEGER",
326
+ "remote_execution_id": "TEXT",
327
+ "next_retry_at": "TEXT",
328
+ }
329
+ for name, declaration in additions.items():
330
+ if name not in columns:
331
+ conn.execute(f"ALTER TABLE tasks ADD COLUMN {name} {declaration}")
332
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)")
333
+ conn.execute("""CREATE TABLE IF NOT EXISTS task_outbox_events (
334
+ event_id TEXT PRIMARY KEY,
335
+ task_id TEXT NOT NULL,
336
+ sequence INTEGER NOT NULL,
337
+ event_type TEXT NOT NULL,
338
+ payload TEXT NOT NULL,
339
+ created_at TEXT NOT NULL,
340
+ delivered_at TEXT,
341
+ UNIQUE(task_id, sequence),
342
+ FOREIGN KEY(task_id) REFERENCES tasks(id)
343
+ )""")
344
+ conn.execute(
345
+ "CREATE INDEX IF NOT EXISTS idx_task_outbox_pending "
346
+ "ON task_outbox_events(delivered_at, created_at)"
347
+ )
348
+
349
+ migrate_database(self.store_file, TASK_SCHEMA_VERSION, migration)
350
+
351
+ def _migrate_legacy_json(self) -> None:
352
+ legacy_file = self.store_dir / "tasks.json"
353
+ if not legacy_file.exists():
354
+ return
355
+
356
+ # Check if tasks table is empty, if not, sqlite takes precedence
357
+ with self._connect() as conn:
358
+ cursor = conn.execute("SELECT COUNT(*) FROM tasks")
359
+ count = cursor.fetchone()[0]
360
+ if count > 0:
361
+ return
362
+
363
+ try:
364
+ with legacy_file.open("r", encoding="utf-8") as f:
365
+ data = json.load(f)
366
+
367
+ tasks_to_migrate = {}
368
+ for task_id, raw in data.items():
369
+ if isinstance(raw, dict):
370
+ tasks_to_migrate[task_id] = Task.from_dict(raw)
371
+
372
+ if not tasks_to_migrate:
373
+ return
374
+
375
+ # Insert transactionally
376
+ with self._connect() as conn:
377
+ for task in tasks_to_migrate.values():
378
+ self.create_task(task)
379
+
380
+ # Safe backup
381
+ legacy_file.rename(legacy_file.with_suffix(".json.bak"))
382
+ except (OSError, json.JSONDecodeError, ValueError) as err:
383
+ logger.error(f"Failed to migrate legacy tasks.json: {err}")
384
+
385
+ def create_task(self, task: Task) -> None:
386
+ """Insert a newly created task."""
387
+ try:
388
+ with self._connect() as conn:
389
+ conn.execute(
390
+ """
391
+ INSERT INTO tasks (
392
+ id, title, goal, priority, status, progress, retries,
393
+ max_retries, depends_on, created_at, updated_at,
394
+ result, error, metadata, checkpoints, history, version, owner_id, lease_expires_at, lease_epoch, owner_pid, remote_execution_id, next_retry_at
395
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
396
+ """,
397
+ (
398
+ task.id,
399
+ task.title,
400
+ task.goal,
401
+ task.priority.name,
402
+ task.status.value,
403
+ task.progress,
404
+ task.retries,
405
+ task.max_retries,
406
+ json.dumps(task.depends_on),
407
+ task.created_at,
408
+ task.updated_at,
409
+ task.result,
410
+ task.error,
411
+ json.dumps(task.metadata),
412
+ json.dumps([asdict(cp) for cp in task.checkpoints]),
413
+ json.dumps([asdict(rec) for rec in task.history]),
414
+ task.version,
415
+ task.owner_id,
416
+ task.lease_expires_at,
417
+ task.lease_epoch,
418
+ task.owner_pid,
419
+ task.remote_execution_id,
420
+ task.next_retry_at,
421
+ ),
422
+ )
423
+ self._append_outbox_event(conn, task, "task_created")
424
+ except sqlite3.Error as err:
425
+ logger.error(f"Failed to create task {task.id}: {err}")
426
+ raise
427
+
428
+ def update_task(
429
+ self,
430
+ task: Task,
431
+ *,
432
+ expected_status: TaskStatus | None = None,
433
+ expected_owner_id: str | None = None,
434
+ expected_lease_epoch: int | None = None,
435
+ require_unexpired_lease: bool = False,
436
+ require_expired_lease: bool = False,
437
+ ) -> None:
438
+ """Update an existing task safely using OCC."""
439
+ try:
440
+ with self._connect() as conn:
441
+ current = conn.execute(
442
+ "SELECT status, progress, remote_execution_id, owner_id, lease_epoch, lease_expires_at FROM tasks WHERE id = ?",
443
+ (task.id,),
444
+ ).fetchone()
445
+ if current is None:
446
+ raise TaskConcurrencyError(task.id)
447
+ if current[0] == TaskStatus.RUNNING.value and (
448
+ expected_status != TaskStatus.RUNNING
449
+ or expected_owner_id is None
450
+ or expected_lease_epoch is None
451
+ ):
452
+ raise StaleWorkerError(task.id)
453
+
454
+ where = "WHERE id = ? AND version = ?"
455
+ params: list[Any] = [
456
+ task.title,
457
+ task.goal,
458
+ task.priority.name,
459
+ task.status.value,
460
+ task.progress,
461
+ task.retries,
462
+ task.max_retries,
463
+ json.dumps(task.depends_on),
464
+ task.created_at,
465
+ task.updated_at,
466
+ task.result,
467
+ task.error,
468
+ json.dumps(task.metadata),
469
+ json.dumps([asdict(cp) for cp in task.checkpoints]),
470
+ json.dumps([asdict(rec) for rec in task.history]),
471
+ task.owner_id,
472
+ task.lease_expires_at,
473
+ task.lease_epoch,
474
+ task.owner_pid,
475
+ task.remote_execution_id,
476
+ task.next_retry_at,
477
+ task.id,
478
+ task.version,
479
+ ]
480
+ if expected_status is not None:
481
+ where += " AND status = ?"
482
+ params.append(expected_status.value)
483
+ if expected_owner_id is not None:
484
+ where += " AND owner_id = ?"
485
+ params.append(expected_owner_id)
486
+ if expected_lease_epoch is not None:
487
+ where += " AND lease_epoch = ?"
488
+ params.append(expected_lease_epoch)
489
+ if require_unexpired_lease:
490
+ where += " AND lease_expires_at > ?"
491
+ params.append(datetime.now(UTC).isoformat())
492
+ if require_expired_lease:
493
+ where += " AND (lease_expires_at IS NULL OR lease_expires_at <= ?)"
494
+ params.append(datetime.now(UTC).isoformat())
495
+ cursor = conn.execute(
496
+ """
497
+ UPDATE tasks SET
498
+ title=?, goal=?, priority=?, status=?, progress=?,
499
+ retries=?, max_retries=?, depends_on=?, created_at=?,
500
+ updated_at=?, result=?, error=?, metadata=?,
501
+ checkpoints=?, history=?, version=version + 1,
502
+ owner_id=?, lease_expires_at=?, lease_epoch=?, owner_pid=?, remote_execution_id=?, next_retry_at=?
503
+ """
504
+ + where,
505
+ params,
506
+ )
507
+ if cursor.rowcount == 0:
508
+ raise TaskConcurrencyError(task.id)
509
+ event_type = self._event_type_for_update(current, task)
510
+ if event_type:
511
+ self._append_outbox_event(conn, task, event_type)
512
+ except sqlite3.Error as err:
513
+ logger.error(f"Failed to update task {task.id}: {err}")
514
+ raise
515
+
516
+ def load(self) -> dict[str, Task]:
517
+ """Load all tasks from SQLite."""
518
+ if not self.store_file.exists():
519
+ return {}
520
+ tasks = {}
521
+ try:
522
+ with self._connect() as conn:
523
+ cursor = conn.execute("SELECT * FROM tasks")
524
+ columns = [col[0] for col in cursor.description]
525
+ for row in cursor.fetchall():
526
+ row_dict = dict(zip(columns, row))
527
+
528
+ data = {
529
+ "id": row_dict["id"],
530
+ "title": row_dict["title"],
531
+ "goal": row_dict["goal"],
532
+ "priority": row_dict["priority"],
533
+ "status": row_dict["status"],
534
+ "progress": row_dict["progress"],
535
+ "retries": row_dict["retries"],
536
+ "max_retries": row_dict["max_retries"],
537
+ "depends_on": json.loads(row_dict["depends_on"]),
538
+ "created_at": row_dict["created_at"],
539
+ "updated_at": row_dict["updated_at"],
540
+ "result": row_dict["result"],
541
+ "error": row_dict["error"],
542
+ "metadata": json.loads(row_dict["metadata"]),
543
+ "checkpoints": json.loads(row_dict["checkpoints"]),
544
+ "history": json.loads(row_dict["history"]),
545
+ "version": row_dict["version"],
546
+ "owner_id": row_dict.get("owner_id"),
547
+ "lease_expires_at": row_dict.get("lease_expires_at"),
548
+ "lease_epoch": row_dict.get("lease_epoch", 0),
549
+ "owner_pid": row_dict.get("owner_pid"),
550
+ "remote_execution_id": row_dict.get("remote_execution_id"),
551
+ "next_retry_at": row_dict.get("next_retry_at"),
552
+ }
553
+ tasks[row_dict["id"]] = Task.from_dict(data)
554
+ return tasks
555
+ except (sqlite3.Error, json.JSONDecodeError) as err:
556
+ logger.error(f"Failed to load task store: {err}")
557
+ return {}
558
+
559
+ @staticmethod
560
+ def _event_type_for_update(current: sqlite3.Row | tuple[Any, ...], task: Task) -> str | None:
561
+ """Infer the meaningful durable event from an already-CASed update."""
562
+ if (
563
+ task.status == TaskStatus.QUEUED
564
+ and task.history
565
+ and task.history[-1].action == "retry_scheduled"
566
+ ):
567
+ return "task_retry_scheduled"
568
+ if current[0] != task.status.value:
569
+ return {
570
+ TaskStatus.QUEUED.value: "task_queued",
571
+ TaskStatus.RUNNING.value: "task_started",
572
+ TaskStatus.PAUSED.value: "task_paused",
573
+ TaskStatus.COMPLETED.value: "task_completed",
574
+ TaskStatus.FAILED.value: "task_failed",
575
+ TaskStatus.CANCELLED.value: "task_cancelled",
576
+ TaskStatus.RECOVERY_PENDING.value: "task_recovery_pending",
577
+ TaskStatus.DEAD_LETTER.value: "task_dead_lettered",
578
+ }.get(task.status.value, "task_updated")
579
+ if current[2] != task.remote_execution_id:
580
+ return "task_remote_execution_bound"
581
+ if current[1] != task.progress:
582
+ return "task_progress"
583
+ return None
584
+
585
+ @staticmethod
586
+ def _append_outbox_event(
587
+ conn: sqlite3.Connection, task: Task, event_type: str
588
+ ) -> None:
589
+ """Append a task event in the caller's transaction."""
590
+ sequence = conn.execute(
591
+ "SELECT COALESCE(MAX(sequence), 0) + 1 FROM task_outbox_events WHERE task_id = ?",
592
+ (task.id,),
593
+ ).fetchone()[0]
594
+ payload = {
595
+ "correlation_id": task.metadata.get("correlation_id"),
596
+ "status": task.status.value,
597
+ "progress": task.progress,
598
+ "retries": task.retries,
599
+ "lease_epoch": task.lease_epoch,
600
+ "remote_execution_id": task.remote_execution_id,
601
+ "next_retry_at": task.next_retry_at,
602
+ }
603
+ conn.execute(
604
+ """
605
+ INSERT INTO task_outbox_events (
606
+ event_id, task_id, sequence, event_type, payload, created_at
607
+ ) VALUES (?, ?, ?, ?, ?, ?)
608
+ """,
609
+ (
610
+ f"evt-{uuid.uuid4().hex}",
611
+ task.id,
612
+ sequence,
613
+ event_type,
614
+ json.dumps(payload, sort_keys=True),
615
+ task.updated_at,
616
+ ),
617
+ )
618
+
619
+ def pending_outbox_events(self, *, limit: int = 100) -> list[TaskOutboxEvent]:
620
+ """Return undelivered lifecycle events in global creation order."""
621
+ with self._connect() as conn:
622
+ rows = conn.execute(
623
+ """
624
+ SELECT event_id, task_id, sequence, event_type, payload, created_at, delivered_at
625
+ FROM task_outbox_events
626
+ WHERE delivered_at IS NULL
627
+ ORDER BY created_at, task_id, sequence
628
+ LIMIT ?
629
+ """,
630
+ (limit,),
631
+ ).fetchall()
632
+ return [
633
+ TaskOutboxEvent(
634
+ event_id=row[0],
635
+ task_id=row[1],
636
+ sequence=row[2],
637
+ event_type=row[3],
638
+ payload=json.loads(row[4]),
639
+ created_at=row[5],
640
+ delivered_at=row[6],
641
+ )
642
+ for row in rows
643
+ ]
644
+
645
+ def acknowledge_outbox_event(self, event_id: str) -> bool:
646
+ """Idempotently mark one durable event delivered."""
647
+ with self._connect() as conn:
648
+ cursor = conn.execute(
649
+ """
650
+ UPDATE task_outbox_events
651
+ SET delivered_at = ?
652
+ WHERE event_id = ? AND delivered_at IS NULL
653
+ """,
654
+ (datetime.now(UTC).isoformat(), event_id),
655
+ )
656
+ return cursor.rowcount == 1
657
+
658
+
659
+ EventListener = Callable[[TaskEvent], Awaitable[None] | None]
660
+
661
+
662
+ class TaskEventBus:
663
+ """Async event bus emitting structured task events for VS Code / RPC clients."""
664
+
665
+ def __init__(self) -> None:
666
+ self._listeners: list[EventListener] = []
667
+
668
+ def subscribe(self, listener: EventListener) -> None:
669
+ if listener not in self._listeners:
670
+ self._listeners.append(listener)
671
+
672
+ def unsubscribe(self, listener: EventListener) -> None:
673
+ if listener in self._listeners:
674
+ self._listeners.remove(listener)
675
+
676
+ async def emit(self, event: TaskEvent) -> None:
677
+ for listener in list(self._listeners):
678
+ try:
679
+ res = listener(event)
680
+ if asyncio.iscoroutine(res) or hasattr(res, "__await__"):
681
+ await res
682
+ # Intentionally broad to isolate execution boundaries and prevent crashes.
683
+ except Exception as err: # noqa: BLE001
684
+ # Intentionally broad to isolate event listener failures from crashing the task manager.
685
+ logger.warning(f"Error in task event listener: {err}")
686
+
687
+
688
+ # ---------------------------------------------------------------------------
689
+ # Task Manager
690
+ # ---------------------------------------------------------------------------
691
+
692
+
693
+ class TaskManager:
694
+ _active_executions: ClassVar[set[tuple[int, str]]] = set()
695
+ _active_worker_tasks: ClassVar[dict[tuple[int, str], asyncio.Task[Any]]] = {}
696
+
697
+ """Principal Task Manager for Pulse.
698
+
699
+ Handles task creation, priority queue scheduling, status lifecycle,
700
+ persistence, checkpoints, telemetry logging, long-term memory updates,
701
+ and VS Code event broadcasts.
702
+ """
703
+ CANCELLATION_GRACE_SECONDS = 1.0
704
+
705
+ def __init__(
706
+ self,
707
+ workspace: Path | None = None,
708
+ *,
709
+ telemetry: Any | None = None,
710
+ memory: Any | None = None,
711
+ store: TaskStore | None = None,
712
+ event_bus: TaskEventBus | None = None,
713
+ heartbeat_interval: float = 15.0,
714
+ lease_duration: float = 60.0,
715
+ ) -> None:
716
+ if (
717
+ heartbeat_interval <= 0
718
+ or lease_duration <= 0
719
+ or heartbeat_interval >= lease_duration
720
+ ):
721
+ raise ValueError(
722
+ "Invalid heartbeat config: interval must be > 0 and strictly less than lease_duration."
723
+ )
724
+
725
+ self.workspace = workspace or Path.cwd()
726
+ self.worker_id = f"worker-{uuid.uuid4().hex[:8]}"
727
+ self.heartbeat_interval = heartbeat_interval
728
+ self.lease_duration = lease_duration
729
+ self.telemetry = telemetry
730
+ self.memory = memory
731
+ self.store = store or TaskStore(self.workspace)
732
+ self.event_bus = event_bus or TaskEventBus()
733
+ self._tasks: dict[str, Task] = self.store.load()
734
+ self._queue: list[str] = []
735
+ self._lock = asyncio.Lock()
736
+ self._heartbeat_tasks: dict[str, asyncio.Task] = {}
737
+ # Tracks leases acquired by this manager. It lets mutation methods
738
+ # distinguish an active worker from legacy/manual lifecycle calls that
739
+ # may transition a queued task directly.
740
+ self._lease_epochs: dict[str, int] = {}
741
+ self._uncontained_tasks: set[asyncio.Task] = set()
742
+
743
+ # We don't automatically recover in __init__ because we want async startup.
744
+ # But for simplicity, we provide a `recover_startup_tasks` method to be called.
745
+
746
+ # ---------------------------------------------------------------------------
747
+ # Public Task Lifecycle Methods
748
+ # ---------------------------------------------------------------------------
749
+
750
+ async def recover_tasks(self) -> None:
751
+ """Reconcile interrupted executions from their durable task records.
752
+
753
+ Recovery deliberately works in two phases. First it changes an
754
+ expired RUNNING record to RECOVERY_PENDING with a compare-and-swap.
755
+ That durable state transition fences the old worker before this
756
+ manager asks an external executor what happened. A lease timeout is
757
+ evidence that liveness was lost, not evidence that the old work did
758
+ not complete.
759
+ """
760
+ # A manager can outlive changes written by another worker. Recovery
761
+ # must begin from the store, not from this manager's construction-time
762
+ # cache, otherwise an expired lease can be missed indefinitely.
763
+ tasks_to_recover = []
764
+ async with self._lock:
765
+ self._tasks = self.store.load()
766
+ for task in self._tasks.values():
767
+ if task.status == TaskStatus.RECOVERY_PENDING:
768
+ tasks_to_recover.append(task.id)
769
+ continue
770
+ if task.status == TaskStatus.RUNNING:
771
+ if not task.lease_expires_at:
772
+ tasks_to_recover.append(task.id)
773
+ else:
774
+ try:
775
+ expires_at = datetime.fromisoformat(task.lease_expires_at)
776
+ if datetime.now(UTC) > expires_at:
777
+ tasks_to_recover.append(task.id)
778
+ except ValueError:
779
+ tasks_to_recover.append(task.id)
780
+
781
+ for task_id in tasks_to_recover:
782
+ await self._recover_single_task(task_id)
783
+
784
+ async def _recover_single_task(self, task_id: str) -> None:
785
+ """Fence a stale execution, then reconcile it without holding the lock."""
786
+ for attempt in range(3):
787
+ async with self._lock:
788
+ task = self._get_task_or_raise(task_id)
789
+ if task.status not in (TaskStatus.RUNNING, TaskStatus.RECOVERY_PENDING):
790
+ return
791
+ if task.status == TaskStatus.RUNNING and task.lease_expires_at:
792
+ try:
793
+ if datetime.now(UTC) <= datetime.fromisoformat(
794
+ task.lease_expires_at
795
+ ):
796
+ return
797
+ except ValueError:
798
+ pass
799
+ if task.status == TaskStatus.RUNNING:
800
+ previous_owner = task.owner_id
801
+ previous_epoch = task.lease_epoch
802
+ is_remote = task.metadata.get("execution_mode") == "remote"
803
+ task.status = TaskStatus.RECOVERY_PENDING
804
+ task.lease_expires_at = None
805
+ # A remote supervisor can no longer use this local lease.
806
+ # Its durable result is reconciled by execution id below.
807
+ if is_remote:
808
+ task.owner_id = None
809
+ task.owner_pid = None
810
+ task.updated_at = datetime.now(UTC).isoformat()
811
+ task.history.append(
812
+ TaskExecutionRecord(
813
+ timestamp=task.updated_at,
814
+ action="recovery_fenced",
815
+ detail="Expired lease fenced before reconciliation.",
816
+ success=True,
817
+ )
818
+ )
819
+ try:
820
+ self.store.update_task(
821
+ task,
822
+ expected_status=TaskStatus.RUNNING,
823
+ expected_owner_id=previous_owner,
824
+ expected_lease_epoch=previous_epoch,
825
+ require_expired_lease=True,
826
+ )
827
+ task.version += 1
828
+ except TaskConcurrencyError:
829
+ fresh = self.store.load().get(task.id)
830
+ if fresh:
831
+ self._tasks[task.id] = fresh
832
+ if attempt == 2:
833
+ raise
834
+ continue
835
+
836
+ # The durable fence is committed. Never perform network I/O
837
+ # while holding the lifecycle lock.
838
+ is_remote = task.metadata.get("execution_mode") == "remote"
839
+ owner_pid = task.owner_pid
840
+ owner_epoch = task.lease_epoch
841
+ owner_id = task.owner_id
842
+ break
843
+
844
+ if is_remote:
845
+ await self._reconcile_remote_recovery(task_id, owner_id, owner_epoch)
846
+ return
847
+
848
+ # An in-process worker receives the RECOVERY_PENDING state through
849
+ # its next fenced mutation/heartbeat and acknowledges its own stop.
850
+ active_key = (owner_pid, task_id) if owner_pid else None
851
+ if active_key and active_key in self._active_executions:
852
+ # A second manager in the same process can explicitly interrupt
853
+ # the supervised coroutine. The worker remains fenced until its
854
+ # finally block acknowledges that interruption.
855
+ active_worker = self._active_worker_tasks.get(active_key)
856
+ if active_worker and not active_worker.done():
857
+ active_worker.cancel()
858
+ return
859
+
860
+ # ``_active_executions`` is intentionally process-local. A recovery
861
+ # manager in another process cannot use its absence as proof that the
862
+ # original executor stopped. Requeueing in that state would allow two
863
+ # workers to continue the same task. Keep the durable fence pending
864
+ # until the recorded local owner is known to be gone (or that owner
865
+ # acknowledges its own cancellation above).
866
+ if owner_pid and self._process_alive(owner_pid):
867
+ await self._record_recovery_pending(
868
+ task_id,
869
+ owner_id,
870
+ owner_epoch,
871
+ "Previous local executor is still alive; awaiting termination acknowledgement.",
872
+ )
873
+ return
874
+ await self._finalize_recovery_requeue(task_id, owner_id, owner_epoch)
875
+
876
+ async def _finalize_recovery_requeue(
877
+ self, task_id: str, owner_id: str | None, owner_epoch: int
878
+ ) -> None:
879
+ """Release a fenced execution only when a retry is known to be safe."""
880
+ async with self._lock:
881
+ task = self._get_task_or_raise(task_id)
882
+ if (
883
+ task.status != TaskStatus.RECOVERY_PENDING
884
+ or task.lease_epoch != owner_epoch
885
+ ):
886
+ return
887
+ task.status = TaskStatus.QUEUED
888
+ task.owner_id = None
889
+ task.owner_pid = None
890
+ task.lease_expires_at = None
891
+ task.lease_epoch += 1
892
+ task.retries += 1
893
+ task.updated_at = datetime.now(UTC).isoformat()
894
+ task.history.append(
895
+ TaskExecutionRecord(
896
+ timestamp=task.updated_at,
897
+ action="recovery_requeued",
898
+ detail="Fenced execution was not active locally; queued from its last checkpoint.",
899
+ success=True,
900
+ )
901
+ )
902
+ self.store.update_task(
903
+ task,
904
+ expected_status=TaskStatus.RECOVERY_PENDING,
905
+ expected_owner_id=owner_id,
906
+ expected_lease_epoch=owner_epoch,
907
+ )
908
+ task.version += 1
909
+ if task_id not in self._queue:
910
+ self._queue.append(task_id)
911
+ self._sort_queue()
912
+ await self._emit_event("task_queued", task)
913
+
914
+ async def _reconcile_remote_recovery(
915
+ self, task_id: str, owner_id: str | None, owner_epoch: int
916
+ ) -> None:
917
+ """Use the remote executor as authority after local fencing.
918
+
919
+ An absent or unreachable remote record is intentionally *not* retried
920
+ automatically. Its side effects are unknowable; leaving the task in
921
+ RECOVERY_PENDING prevents a duplicate command. Callers may opt in to
922
+ a retry only by setting ``recovery_safe_to_retry`` after making their
923
+ operation idempotent.
924
+ """
925
+ remote_url = os.environ.get("PULSE_REMOTE_URL")
926
+ remote_token = os.environ.get("PULSE_REMOTE_TOKEN")
927
+ if not remote_url or not remote_token:
928
+ await self._record_recovery_pending(
929
+ task_id, owner_id, owner_epoch, "Remote credentials are unavailable."
930
+ )
931
+ return
932
+ from pulse.sandbox.remote.client import RemoteClient
933
+
934
+ client = RemoteClient(remote_url, remote_token)
935
+ try:
936
+ task = self.get_task(task_id)
937
+ # A task ID identifies Pulse's durable workflow record; a remote
938
+ # execution may have a different provider-generated identifier.
939
+ # Persisting this mapping at submission time lets recovery query
940
+ # the same operation rather than accidentally treating it as lost.
941
+ if task:
942
+ remote_execution_id = task.remote_execution_id or task.metadata.get(
943
+ "remote_execution_id", task_id
944
+ )
945
+ else:
946
+ remote_execution_id = task_id
947
+ status = await client.status(remote_execution_id)
948
+ if status == "RUNNING":
949
+ await self._record_recovery_pending(
950
+ task_id, owner_id, owner_epoch, "Remote execution is still running."
951
+ )
952
+ return
953
+ if status in ("COMPLETED", "FAILED"):
954
+ result = await client.attach(remote_execution_id)
955
+ await self._finalize_remote_result(
956
+ task_id, owner_id, owner_epoch, status, result
957
+ )
958
+ return
959
+ task = self._tasks.get(task_id)
960
+ if task and task.metadata.get("recovery_safe_to_retry") is True:
961
+ await self._finalize_recovery_requeue(task_id, owner_id, owner_epoch)
962
+ return
963
+ await self._record_recovery_pending(
964
+ task_id,
965
+ owner_id,
966
+ owner_epoch,
967
+ f"Remote state is {status}; outcome is unknown.",
968
+ )
969
+ except Exception as err: # noqa: BLE001
970
+ await self._record_recovery_pending(
971
+ task_id, owner_id, owner_epoch, f"Remote reconciliation failed: {err}"
972
+ )
973
+ finally:
974
+ await client.disconnect()
975
+
976
+ async def _record_recovery_pending(
977
+ self, task_id: str, owner_id: str | None, owner_epoch: int, detail: str
978
+ ) -> None:
979
+ async with self._lock:
980
+ task = self._get_task_or_raise(task_id)
981
+ if (
982
+ task.status != TaskStatus.RECOVERY_PENDING
983
+ or task.lease_epoch != owner_epoch
984
+ ):
985
+ return
986
+ task.updated_at = datetime.now(UTC).isoformat()
987
+ task.history.append(
988
+ TaskExecutionRecord(
989
+ timestamp=task.updated_at,
990
+ action="recovery_pending",
991
+ detail=detail,
992
+ success=False,
993
+ )
994
+ )
995
+ self.store.update_task(
996
+ task,
997
+ expected_status=TaskStatus.RECOVERY_PENDING,
998
+ expected_owner_id=owner_id,
999
+ expected_lease_epoch=owner_epoch,
1000
+ )
1001
+ task.version += 1
1002
+
1003
+ async def _finalize_remote_result(
1004
+ self,
1005
+ task_id: str,
1006
+ owner_id: str | None,
1007
+ owner_epoch: int,
1008
+ status: str,
1009
+ result: Any,
1010
+ ) -> None:
1011
+ async with self._lock:
1012
+ task = self._get_task_or_raise(task_id)
1013
+ if (
1014
+ task.status != TaskStatus.RECOVERY_PENDING
1015
+ or task.lease_epoch != owner_epoch
1016
+ ):
1017
+ return
1018
+ task.status = (
1019
+ TaskStatus.COMPLETED if status == "COMPLETED" else TaskStatus.FAILED
1020
+ )
1021
+ task.owner_id = None
1022
+ task.owner_pid = None
1023
+ task.lease_expires_at = None
1024
+ task.updated_at = datetime.now(UTC).isoformat()
1025
+ if status == "COMPLETED":
1026
+ task.progress = 100.0
1027
+ task.result = json.dumps(result.to_dict())
1028
+ else:
1029
+ task.error = result.stderr or "Remote execution failed."
1030
+ task.history.append(
1031
+ TaskExecutionRecord(
1032
+ timestamp=task.updated_at,
1033
+ action="remote_reconciled",
1034
+ detail=f"Remote execution {status.lower()}.",
1035
+ success=status == "COMPLETED",
1036
+ )
1037
+ )
1038
+ self.store.update_task(
1039
+ task,
1040
+ expected_status=TaskStatus.RECOVERY_PENDING,
1041
+ expected_owner_id=owner_id,
1042
+ expected_lease_epoch=owner_epoch,
1043
+ )
1044
+ task.version += 1
1045
+ await self._emit_event(
1046
+ "task_completed" if status == "COMPLETED" else "task_failed", task
1047
+ )
1048
+
1049
+ async def create_task(
1050
+ self,
1051
+ goal: str,
1052
+ *,
1053
+ title: str = "",
1054
+ priority: TaskPriority = TaskPriority.MEDIUM,
1055
+ depends_on: Sequence[str] = (),
1056
+ max_retries: int = 3,
1057
+ metadata: dict[str, Any] | None = None,
1058
+ ) -> Task:
1059
+ """Create and store a new task."""
1060
+ async with self._lock:
1061
+ task_id = f"task-{uuid.uuid4().hex[:8]}"
1062
+ clean_title = title.strip() or (
1063
+ goal[:45] + "..." if len(goal) > 45 else goal
1064
+ )
1065
+
1066
+ task_metadata = dict(metadata or {})
1067
+ task_metadata.setdefault("correlation_id", get_correlation_id())
1068
+ task = Task(
1069
+ id=task_id,
1070
+ title=clean_title,
1071
+ goal=goal,
1072
+ priority=priority,
1073
+ status=TaskStatus.PENDING,
1074
+ depends_on=list(depends_on),
1075
+ max_retries=max_retries,
1076
+ metadata=task_metadata,
1077
+ )
1078
+ self._tasks[task_id] = task
1079
+ self.store.create_task(task)
1080
+
1081
+ await self._emit_event("task_created", task)
1082
+ self._log_telemetry("task_created", task_id=task_id, priority=priority.name)
1083
+ return task
1084
+
1085
+ async def queue_task(self, task_id: str) -> Task:
1086
+ """Transition task to QUEUED and place it into priority queue."""
1087
+ for attempt in range(3):
1088
+ async with self._lock:
1089
+ task = self._get_task_or_raise(task_id)
1090
+ if task.status in (TaskStatus.RUNNING, TaskStatus.COMPLETED):
1091
+ return task
1092
+
1093
+ task.status = TaskStatus.QUEUED
1094
+ task.updated_at = datetime.now(UTC).isoformat()
1095
+ if task_id not in self._queue:
1096
+ self._queue.append(task_id)
1097
+ self._sort_queue()
1098
+ try:
1099
+ self.store.update_task(task)
1100
+ task.version += 1
1101
+ break # OCC success
1102
+ except (TaskConcurrencyError, StaleWorkerError):
1103
+ # Reload the authoritative state from DB to heal our cache
1104
+ fresh_tasks = self.store.load()
1105
+ if task.id in fresh_tasks:
1106
+ self._tasks[task.id] = fresh_tasks[task.id]
1107
+ fresh = fresh_tasks[task.id]
1108
+ if (
1109
+ fresh.status == TaskStatus.RUNNING
1110
+ and fresh.lease_expires_at
1111
+ ):
1112
+ try:
1113
+ if datetime.now(UTC) <= datetime.fromisoformat(
1114
+ fresh.lease_expires_at
1115
+ ):
1116
+ raise RuntimeError(
1117
+ f"Task {task_id} is currently owned and lease is active."
1118
+ )
1119
+ except ValueError:
1120
+ pass
1121
+ if attempt == 2:
1122
+ raise
1123
+
1124
+ await self._emit_event("task_queued", task)
1125
+ self._log_telemetry("task_queued", task_id=task_id)
1126
+ return task
1127
+
1128
+ @staticmethod
1129
+ def _process_alive(pid: int) -> bool:
1130
+ if pid <= 0:
1131
+ return False
1132
+
1133
+ if os.name == "nt":
1134
+ import ctypes
1135
+ from ctypes import wintypes
1136
+
1137
+ synchronize = 0x00100000
1138
+ wait_object_0 = 0x00000000
1139
+ wait_timeout = 0x00000102
1140
+ error_access_denied = 5
1141
+
1142
+ win_dll = ctypes.WinDLL
1143
+ get_last_error = ctypes.get_last_error
1144
+ kernel32 = win_dll("kernel32", use_last_error=True)
1145
+
1146
+ open_process = kernel32.OpenProcess
1147
+ open_process.argtypes = [
1148
+ wintypes.DWORD,
1149
+ wintypes.BOOL,
1150
+ wintypes.DWORD,
1151
+ ]
1152
+ open_process.restype = wintypes.HANDLE
1153
+
1154
+ wait_for_process = kernel32.WaitForSingleObject
1155
+ wait_for_process.argtypes = [wintypes.HANDLE, wintypes.DWORD]
1156
+ wait_for_process.restype = wintypes.DWORD
1157
+
1158
+ close_handle = kernel32.CloseHandle
1159
+ close_handle.argtypes = [wintypes.HANDLE]
1160
+ close_handle.restype = wintypes.BOOL
1161
+
1162
+ handle = open_process(synchronize, False, pid)
1163
+ if not handle:
1164
+ # Access denied normally means the process exists but is protected.
1165
+ return get_last_error() == error_access_denied
1166
+
1167
+ try:
1168
+ status = wait_for_process(handle, 0)
1169
+ if status == wait_object_0:
1170
+ return False
1171
+ if status == wait_timeout:
1172
+ return True
1173
+
1174
+ # Conservatively treat an unknown state as alive.
1175
+ return True
1176
+ finally:
1177
+ close_handle(handle)
1178
+
1179
+ try:
1180
+ os.kill(pid, 0)
1181
+ except ProcessLookupError:
1182
+ return False
1183
+ except PermissionError:
1184
+ return True
1185
+ except OSError:
1186
+ return False
1187
+ return True
1188
+
1189
+ async def _acknowledge_recovery_stopped(self, task_id: str, epoch: int) -> None:
1190
+ """Release a RECOVERY_PENDING handoff only after local execution stops."""
1191
+ requeued = False
1192
+ async with self._lock:
1193
+ # Another manager performed the recovery fence, so this
1194
+ # supervisor's cache is intentionally stale. Reload before the
1195
+ # acknowledgement; the CAS below still prevents a newer recovery
1196
+ # attempt from being overwritten.
1197
+ task = self.store.load().get(task_id)
1198
+ if task:
1199
+ self._tasks[task_id] = task
1200
+ if not task or task.status != TaskStatus.RECOVERY_PENDING:
1201
+ return
1202
+ if task.owner_id != self.worker_id or task.lease_epoch != epoch:
1203
+ return
1204
+ task.status = TaskStatus.QUEUED
1205
+ task.owner_id = None
1206
+ task.owner_pid = None
1207
+ task.lease_epoch += 1
1208
+ task.retries += 1
1209
+ task.updated_at = datetime.now(UTC).isoformat()
1210
+ if task_id not in self._queue:
1211
+ self._queue.append(task_id)
1212
+ self._sort_queue()
1213
+ self.store.update_task(
1214
+ task,
1215
+ expected_status=TaskStatus.RECOVERY_PENDING,
1216
+ expected_owner_id=self.worker_id,
1217
+ expected_lease_epoch=epoch,
1218
+ )
1219
+ task.version += 1
1220
+ requeued = True
1221
+
1222
+ if requeued:
1223
+ await self._emit_event("task_queued", task)
1224
+
1225
+ async def start_task(self, task_id: str) -> Task:
1226
+ """Transition task to RUNNING status."""
1227
+ for attempt in range(3):
1228
+ async with self._lock:
1229
+ task = self._get_task_or_raise(task_id)
1230
+
1231
+ # Check dependencies
1232
+ unresolved = [
1233
+ dep_id
1234
+ for dep_id in task.depends_on
1235
+ if dep_id in self._tasks
1236
+ and self._tasks[dep_id].status != TaskStatus.COMPLETED
1237
+ ]
1238
+ if unresolved:
1239
+ raise RuntimeError(
1240
+ f"Cannot start task {task_id}; unresolved dependencies: {unresolved}"
1241
+ )
1242
+
1243
+ if task.status == TaskStatus.QUEUED and task.next_retry_at:
1244
+ try:
1245
+ if datetime.now(UTC) < datetime.fromisoformat(task.next_retry_at):
1246
+ raise RetryDeferredError(task_id, task.next_retry_at)
1247
+ except ValueError:
1248
+ # A malformed legacy deadline must not block recovery.
1249
+ task.next_retry_at = None
1250
+
1251
+ # Guard acquisition
1252
+ if task.status not in (TaskStatus.QUEUED, TaskStatus.PENDING):
1253
+ # We can also steal it if it's RUNNING but expired.
1254
+ if task.status == TaskStatus.RUNNING and task.lease_expires_at:
1255
+ try:
1256
+ expires_at = datetime.fromisoformat(task.lease_expires_at)
1257
+ if datetime.now(UTC) <= expires_at:
1258
+ raise RuntimeError(
1259
+ f"Task {task_id} is currently owned and lease is active."
1260
+ )
1261
+ except ValueError:
1262
+ raise RuntimeError(f"Task {task_id} has invalid lease.")
1263
+ else:
1264
+ raise RuntimeError(
1265
+ f"Cannot start task {task_id}; wrong status {task.status.value}"
1266
+ )
1267
+
1268
+ takeover = task.status == TaskStatus.RUNNING
1269
+ previous_owner = task.owner_id
1270
+ previous_epoch = task.lease_epoch
1271
+
1272
+ task.status = TaskStatus.RUNNING
1273
+ task.next_retry_at = None
1274
+ task.owner_id = self.worker_id
1275
+ task.owner_pid = os.getpid()
1276
+ task.lease_epoch += 1
1277
+ if task.metadata.get("execution_mode") == "remote":
1278
+ # Bind the external operation *before* the task is
1279
+ # persisted as RUNNING. The identifier is stable for
1280
+ # retries of this attempt and changes for a new fenced
1281
+ # attempt, preventing recovery from attaching to stale
1282
+ # remote work.
1283
+ task.remote_execution_id = (
1284
+ task.metadata.get("remote_execution_id")
1285
+ if task.lease_epoch == 1
1286
+ else None
1287
+ ) or f"{task.id}-attempt-{task.lease_epoch}"
1288
+ task.lease_expires_at = (
1289
+ datetime.now(UTC) + timedelta(seconds=self.lease_duration)
1290
+ ).isoformat()
1291
+ task.updated_at = datetime.now(UTC).isoformat()
1292
+ if task_id in self._queue:
1293
+ self._queue.remove(task_id)
1294
+ try:
1295
+ if takeover:
1296
+ self.store.update_task(
1297
+ task,
1298
+ expected_status=TaskStatus.RUNNING,
1299
+ expected_owner_id=previous_owner,
1300
+ expected_lease_epoch=previous_epoch,
1301
+ require_expired_lease=True,
1302
+ )
1303
+ else:
1304
+ self.store.update_task(task)
1305
+ task.version += 1
1306
+ self._lease_epochs[task_id] = task.lease_epoch
1307
+ break # OCC success
1308
+ except TaskConcurrencyError:
1309
+ # Reload the authoritative state from DB to heal our cache
1310
+ fresh_tasks = self.store.load()
1311
+ if task.id in fresh_tasks:
1312
+ self._tasks[task.id] = fresh_tasks[task.id]
1313
+ if attempt == 2:
1314
+ raise
1315
+
1316
+ await self._emit_event("task_started", task)
1317
+ self._log_telemetry("task_started", task_id=task_id)
1318
+ return task
1319
+
1320
+ async def remote_execution_id_for_task(self, task_id: str) -> str:
1321
+ """Return the durable remote ID for the caller's active task attempt.
1322
+
1323
+ A remote dispatcher calls this before submission and passes the
1324
+ returned value as the backend's ``execution_id``. Requiring the
1325
+ active lease prevents an arbitrary caller from inventing a recovery
1326
+ target or rebinding another worker's task.
1327
+ """
1328
+ async with self._lock:
1329
+ task = self._check_ownership(self._get_task_or_raise(task_id))
1330
+ if task.metadata.get("execution_mode") != "remote":
1331
+ raise ValueError(f"Task {task_id} is not configured for remote execution.")
1332
+ if not task.remote_execution_id:
1333
+ raise RuntimeError(
1334
+ f"Task {task_id} has no durable remote execution binding."
1335
+ )
1336
+ return task.remote_execution_id
1337
+
1338
+ async def update_progress(
1339
+ self, task_id: str, progress: float, detail: str = ""
1340
+ ) -> Task:
1341
+ """Update task progress percentage (0.0 - 100.0)."""
1342
+ for attempt in range(3):
1343
+ async with self._lock:
1344
+ task = self._check_ownership(self._get_task_or_raise(task_id))
1345
+ fence = self._fence_for(task)
1346
+
1347
+ task.progress = min(max(progress, 0.0), 100.0)
1348
+ task.updated_at = datetime.now(UTC).isoformat()
1349
+ if detail:
1350
+ task.history.append(
1351
+ TaskExecutionRecord(
1352
+ timestamp=datetime.now(UTC).isoformat(),
1353
+ action="progress_update",
1354
+ detail=detail,
1355
+ )
1356
+ )
1357
+ try:
1358
+ self._update_task_with_fence(task, fence)
1359
+ task.version += 1
1360
+ break # OCC success
1361
+ except TaskConcurrencyError:
1362
+ # Reload the authoritative state from DB to heal our cache
1363
+ fresh_tasks = self.store.load()
1364
+ if task.id in fresh_tasks:
1365
+ self._tasks[task.id] = fresh_tasks[task.id]
1366
+ if attempt == 2:
1367
+ raise
1368
+
1369
+ await self._emit_event("task_progress", task, {"detail": detail})
1370
+ return task
1371
+
1372
+ async def pause_task(self, task_id: str, reason: str = "") -> Task:
1373
+ """Pause a running or queued task."""
1374
+ for attempt in range(3):
1375
+ async with self._lock:
1376
+ task = self._check_ownership(self._get_task_or_raise(task_id))
1377
+ fence = self._fence_for(task)
1378
+
1379
+ task.status = TaskStatus.PAUSED
1380
+ task.owner_id = None
1381
+ task.lease_expires_at = None
1382
+ self._stop_heartbeat(task_id)
1383
+ task.updated_at = datetime.now(UTC).isoformat()
1384
+ if task_id in self._queue:
1385
+ self._queue.remove(task_id)
1386
+ task.history.append(
1387
+ TaskExecutionRecord(
1388
+ timestamp=datetime.now(UTC).isoformat(),
1389
+ action="paused",
1390
+ detail=reason or "Task paused by user",
1391
+ )
1392
+ )
1393
+ try:
1394
+ self._update_task_with_fence(task, fence)
1395
+ task.version += 1
1396
+ break # OCC success
1397
+ except TaskConcurrencyError:
1398
+ # Reload the authoritative state from DB to heal our cache
1399
+ fresh_tasks = self.store.load()
1400
+ if task.id in fresh_tasks:
1401
+ self._tasks[task.id] = fresh_tasks[task.id]
1402
+ if attempt == 2:
1403
+ raise
1404
+
1405
+ self._lease_epochs.pop(task_id, None)
1406
+ await self._emit_event("task_paused", task, {"reason": reason})
1407
+ self._log_telemetry("task_paused", task_id=task_id, reason=reason)
1408
+ return task
1409
+
1410
+ async def resume_task(self, task_id: str) -> Task:
1411
+ """Manually resume a paused, failed, or dead-lettered task."""
1412
+ for attempt in range(3):
1413
+ async with self._lock:
1414
+ task = self._get_task_or_raise(task_id)
1415
+ if task.status not in (
1416
+ TaskStatus.PAUSED,
1417
+ TaskStatus.FAILED,
1418
+ TaskStatus.DEAD_LETTER,
1419
+ ):
1420
+ raise ValueError(
1421
+ f"Task {task_id} is in state {task.status.value} and cannot be resumed."
1422
+ )
1423
+
1424
+ task.status = TaskStatus.QUEUED
1425
+ task.next_retry_at = None
1426
+ task.updated_at = datetime.now(UTC).isoformat()
1427
+ if task_id not in self._queue:
1428
+ self._queue.append(task_id)
1429
+ self._sort_queue()
1430
+
1431
+ latest_cp = task.checkpoints[-1] if task.checkpoints else None
1432
+ detail = (
1433
+ f"Resumed from checkpoint {latest_cp.checkpoint_id}"
1434
+ if latest_cp
1435
+ else "Resumed execution"
1436
+ )
1437
+
1438
+ task.history.append(
1439
+ TaskExecutionRecord(
1440
+ timestamp=datetime.now(UTC).isoformat(),
1441
+ action="resumed",
1442
+ detail=detail,
1443
+ )
1444
+ )
1445
+ try:
1446
+ self.store.update_task(task)
1447
+ task.version += 1
1448
+ self._lease_epochs.pop(task_id, None)
1449
+ break # OCC success
1450
+ except TaskConcurrencyError:
1451
+ # Reload the authoritative state from DB to heal our cache
1452
+ fresh_tasks = self.store.load()
1453
+ if task.id in fresh_tasks:
1454
+ self._tasks[task.id] = fresh_tasks[task.id]
1455
+ if attempt == 2:
1456
+ raise
1457
+
1458
+ await self._emit_event("task_resumed", task)
1459
+ self._log_telemetry("task_resumed", task_id=task_id)
1460
+ return task
1461
+
1462
+ async def cancel_task(self, task_id: str, reason: str = "") -> Task:
1463
+ """Cancel a pending, queued, or running task."""
1464
+ for attempt in range(3):
1465
+ async with self._lock:
1466
+ task = self._check_ownership(self._get_task_or_raise(task_id))
1467
+ fence = self._fence_for(task)
1468
+
1469
+ task.status = TaskStatus.CANCELLED
1470
+ task.owner_id = None
1471
+ task.lease_expires_at = None
1472
+ self._stop_heartbeat(task_id)
1473
+ task.updated_at = datetime.now(UTC).isoformat()
1474
+ if task_id in self._queue:
1475
+ self._queue.remove(task_id)
1476
+ task.history.append(
1477
+ TaskExecutionRecord(
1478
+ timestamp=datetime.now(UTC).isoformat(),
1479
+ action="cancelled",
1480
+ detail=reason or "Task cancelled by user",
1481
+ )
1482
+ )
1483
+ try:
1484
+ self._update_task_with_fence(task, fence)
1485
+ task.version += 1
1486
+ self._lease_epochs.pop(task_id, None)
1487
+ break # OCC success
1488
+ except TaskConcurrencyError:
1489
+ # Reload the authoritative state from DB to heal our cache
1490
+ fresh_tasks = self.store.load()
1491
+ if task.id in fresh_tasks:
1492
+ self._tasks[task.id] = fresh_tasks[task.id]
1493
+ if attempt == 2:
1494
+ raise
1495
+
1496
+ await self._emit_event("task_cancelled", task, {"reason": reason})
1497
+ self._log_telemetry("task_cancelled", task_id=task_id, reason=reason)
1498
+ return task
1499
+
1500
+ async def complete_task(self, task_id: str, result: str = "") -> Task:
1501
+ """Mark task as successfully COMPLETED."""
1502
+ for attempt in range(3):
1503
+ async with self._lock:
1504
+ task = self._check_ownership(self._get_task_or_raise(task_id))
1505
+ fence = self._fence_for(task)
1506
+
1507
+ task.status = TaskStatus.COMPLETED
1508
+ task.owner_id = None
1509
+ task.lease_expires_at = None
1510
+ self._stop_heartbeat(task_id)
1511
+ task.progress = 100.0
1512
+ task.result = result
1513
+ task.updated_at = datetime.now(UTC).isoformat()
1514
+ task.history.append(
1515
+ TaskExecutionRecord(
1516
+ timestamp=datetime.now(UTC).isoformat(),
1517
+ action="completed",
1518
+ detail=f"Task completed: {result[:60]}",
1519
+ )
1520
+ )
1521
+ try:
1522
+ self._update_task_with_fence(task, fence)
1523
+ task.version += 1
1524
+ self._lease_epochs.pop(task_id, None)
1525
+ break # OCC success
1526
+ except TaskConcurrencyError:
1527
+ # Reload the authoritative state from DB to heal our cache
1528
+ fresh_tasks = self.store.load()
1529
+ if task.id in fresh_tasks:
1530
+ self._tasks[task.id] = fresh_tasks[task.id]
1531
+ if attempt == 2:
1532
+ raise
1533
+
1534
+ # Update long-term memory if available
1535
+ if self.memory and hasattr(self.memory, "save_context"):
1536
+ try:
1537
+ await self.memory.save_context(
1538
+ f"Completed task '{task.title}': {result[:200]}"
1539
+ )
1540
+ # Intentionally broad to isolate execution boundaries and prevent crashes.
1541
+ except Exception as err: # noqa: BLE001
1542
+ # Intentionally broad to prevent memory update failures from crashing the task loop.
1543
+ logger.warning(f"Memory update failed: {err}")
1544
+
1545
+ await self._emit_event("task_completed", task, {"result": result})
1546
+ self._log_telemetry("task_completed", task_id=task_id)
1547
+ return task
1548
+
1549
+ async def fail_task(
1550
+ self,
1551
+ task_id: str,
1552
+ error: str,
1553
+ *,
1554
+ retryable: bool = True,
1555
+ ) -> Task:
1556
+ """Record failure with bounded exponential retry or dead-lettering."""
1557
+ for attempt in range(3):
1558
+ async with self._lock:
1559
+ task = self._check_ownership(self._get_task_or_raise(task_id))
1560
+ fence = self._fence_for(task)
1561
+
1562
+ task.owner_id = None
1563
+ task.lease_expires_at = None
1564
+ self._stop_heartbeat(task_id)
1565
+
1566
+ task.retries += 1
1567
+ task.updated_at = datetime.now(UTC).isoformat()
1568
+ task.error = error
1569
+
1570
+ if retryable and task.retries <= task.max_retries:
1571
+ task.status = TaskStatus.QUEUED
1572
+ retry_delay = self._retry_delay_seconds(task.retries)
1573
+ task.next_retry_at = (
1574
+ datetime.now(UTC) + timedelta(seconds=retry_delay)
1575
+ ).isoformat()
1576
+ if task_id not in self._queue:
1577
+ self._queue.append(task_id)
1578
+ self._sort_queue()
1579
+ task.history.append(
1580
+ TaskExecutionRecord(
1581
+ timestamp=datetime.now(UTC).isoformat(),
1582
+ action="retry_scheduled",
1583
+ detail=(
1584
+ f"Retry {task.retries}/{task.max_retries} in "
1585
+ f"{retry_delay}s after error: {error[:60]}"
1586
+ ),
1587
+ success=False,
1588
+ )
1589
+ )
1590
+ event_name = "task_retry_scheduled"
1591
+ else:
1592
+ task.status = TaskStatus.DEAD_LETTER
1593
+ task.next_retry_at = None
1594
+ task.history.append(
1595
+ TaskExecutionRecord(
1596
+ timestamp=datetime.now(UTC).isoformat(),
1597
+ action="dead_lettered",
1598
+ detail=(
1599
+ "Task requires manual resolution: "
1600
+ f"{error[:60]}"
1601
+ ),
1602
+ success=False,
1603
+ )
1604
+ )
1605
+ event_name = "task_dead_lettered"
1606
+
1607
+ try:
1608
+ self._update_task_with_fence(task, fence)
1609
+ task.version += 1
1610
+ break # OCC success
1611
+ except TaskConcurrencyError:
1612
+ # Reload the authoritative state from DB to heal our cache
1613
+ fresh_tasks = self.store.load()
1614
+ if task.id in fresh_tasks:
1615
+ self._tasks[task.id] = fresh_tasks[task.id]
1616
+ if attempt == 2:
1617
+ raise
1618
+
1619
+ self._lease_epochs.pop(task_id, None)
1620
+ await self._emit_event(event_name, task, {"error": error})
1621
+ self._log_telemetry(event_name, task_id=task_id, error=error)
1622
+ return task
1623
+
1624
+ # ---------------------------------------------------------------------------
1625
+ # Checkpointing Methods
1626
+ # ---------------------------------------------------------------------------
1627
+
1628
+ async def create_checkpoint(
1629
+ self, task_id: str, step_index: int, state_data: dict[str, Any]
1630
+ ) -> TaskCheckpoint:
1631
+ """Create and append a state checkpoint for a task."""
1632
+ for attempt in range(3):
1633
+ async with self._lock:
1634
+ task = self._check_ownership(self._get_task_or_raise(task_id))
1635
+ fence = self._fence_for(task)
1636
+
1637
+ cp_id = f"cp-{task_id}-{step_index}-{uuid.uuid4().hex[:4]}"
1638
+ checkpoint = TaskCheckpoint(
1639
+ checkpoint_id=cp_id,
1640
+ task_id=task_id,
1641
+ step_index=step_index,
1642
+ state_data=state_data,
1643
+ )
1644
+ task.checkpoints.append(checkpoint)
1645
+ task.updated_at = datetime.now(UTC).isoformat()
1646
+ try:
1647
+ self._update_task_with_fence(task, fence)
1648
+ task.version += 1
1649
+ break # OCC success
1650
+ except TaskConcurrencyError:
1651
+ # Reload the authoritative state from DB to heal our cache
1652
+ fresh_tasks = self.store.load()
1653
+ if task.id in fresh_tasks:
1654
+ self._tasks[task.id] = fresh_tasks[task.id]
1655
+ if attempt == 2:
1656
+ raise
1657
+
1658
+ await self._emit_event("task_checkpoint_saved", task, {"checkpoint_id": cp_id})
1659
+ return checkpoint
1660
+
1661
+ async def restore_checkpoint(
1662
+ self, task_id: str, checkpoint_id: str | None = None
1663
+ ) -> TaskCheckpoint:
1664
+ """Retrieve a checkpoint for restoration."""
1665
+ for attempt in range(3):
1666
+ async with self._lock:
1667
+ task = self._get_task_or_raise(task_id)
1668
+ if not task.checkpoints:
1669
+ raise ValueError(f"Task {task_id} has no checkpoints.")
1670
+
1671
+ if checkpoint_id:
1672
+ cp = next(
1673
+ (
1674
+ c
1675
+ for c in task.checkpoints
1676
+ if c.checkpoint_id == checkpoint_id
1677
+ ),
1678
+ None,
1679
+ )
1680
+ if not cp:
1681
+ raise ValueError(
1682
+ f"Checkpoint {checkpoint_id} not found for task {task_id}."
1683
+ )
1684
+ return cp
1685
+
1686
+ return task.checkpoints[-1] # latest checkpoint
1687
+
1688
+ # ---------------------------------------------------------------------------
1689
+ # Query & Worker Queue Methods
1690
+ # ---------------------------------------------------------------------------
1691
+
1692
+ def get_task(self, task_id: str) -> Task | None:
1693
+ """Retrieve the authoritative task record, refreshing this cache."""
1694
+ fresh = self.store.load().get(task_id)
1695
+ if fresh is not None:
1696
+ self._tasks[task_id] = fresh
1697
+ return fresh
1698
+
1699
+ def list_tasks(self, *, status: TaskStatus | None = None) -> list[Task]:
1700
+ """List all tasks, optionally filtered by status, sorted by priority and created_at."""
1701
+ tasks = list(self._tasks.values())
1702
+ if status:
1703
+ tasks = [t for t in tasks if t.status == status]
1704
+ return sorted(tasks, key=lambda t: (-t.priority.value, t.created_at))
1705
+
1706
+ @staticmethod
1707
+ def _retry_delay_seconds(retries: int) -> int:
1708
+ """Return a bounded deterministic retry delay for durable scheduling."""
1709
+ return min(60, 2 ** max(0, retries - 1))
1710
+
1711
+ @staticmethod
1712
+ def _retry_ready(task: Task) -> bool:
1713
+ if not task.next_retry_at:
1714
+ return True
1715
+ try:
1716
+ return datetime.now(UTC) >= datetime.fromisoformat(task.next_retry_at)
1717
+ except ValueError:
1718
+ return True
1719
+
1720
+ async def process_queue(
1721
+ self, worker_func: Callable[[Task], Awaitable[str]]
1722
+ ) -> list[Task]:
1723
+ """Processes queued tasks with lease-bound execution supervision.
1724
+
1725
+ Each worker_func invocation is wrapped in an asyncio.Task that is
1726
+ raced against the heartbeat task via asyncio.wait(FIRST_COMPLETED).
1727
+ If the heartbeat detects a lost lease, it cancels the worker and
1728
+ leaves recovery to the new owner.
1729
+ """
1730
+ async with self._lock:
1731
+ ready_ids = [
1732
+ task_id
1733
+ for task_id in self._queue
1734
+ if task_id in self._tasks and self._retry_ready(self._tasks[task_id])
1735
+ ]
1736
+
1737
+ processed: list[Task] = []
1738
+ for task_id in ready_ids:
1739
+ result = await self.execute_task(task_id, worker_func)
1740
+ if result is not None:
1741
+ processed.append(result)
1742
+ return processed
1743
+
1744
+ async def execute_task(
1745
+ self, task_id: str, worker_func: Callable[[Task], Awaitable[str]]
1746
+ ) -> Task | None:
1747
+ """Run one local worker under the authoritative lease supervisor."""
1748
+ try:
1749
+ task = await self.start_task(task_id)
1750
+ except RetryDeferredError:
1751
+ return None
1752
+ except Exception as err: # noqa: BLE001
1753
+ try:
1754
+ return await self.fail_task(task_id, str(err))
1755
+ except (StaleWorkerError, LeaseLostError):
1756
+ logger.warning("Cannot record start failure for %s.", task_id)
1757
+ return None
1758
+ worker_task = asyncio.create_task(worker_func(task))
1759
+ execution_key = (os.getpid(), task_id)
1760
+ self._active_executions.add(execution_key)
1761
+ self._active_worker_tasks[execution_key] = worker_task
1762
+ heartbeat_task = asyncio.create_task(self._heartbeat_loop(task_id))
1763
+ self._heartbeat_tasks[task_id] = heartbeat_task
1764
+ try:
1765
+ result = await self._supervise_execution(
1766
+ task_id, worker_task, heartbeat_task
1767
+ )
1768
+ return await self.complete_task(task_id, result)
1769
+ except LeaseLostError:
1770
+ logger.warning("Lease lost for task %s; execution fenced.", task_id)
1771
+ return None
1772
+ except asyncio.CancelledError:
1773
+ raise
1774
+ except StaleWorkerError:
1775
+ logger.warning("Stale worker for task %s; discarding result.", task_id)
1776
+ return None
1777
+ except Exception as err: # noqa: BLE001
1778
+ try:
1779
+ return await self.fail_task(task_id, str(err))
1780
+ except StaleWorkerError:
1781
+ logger.warning("Cannot fail task %s: ownership lost.", task_id)
1782
+ return None
1783
+ finally:
1784
+ self._active_executions.discard(execution_key)
1785
+ self._active_worker_tasks.pop(execution_key, None)
1786
+ await self._cancel_and_await(worker_task)
1787
+ await self._cancel_and_await(heartbeat_task)
1788
+ if worker_task.done():
1789
+ epoch = self._lease_epochs.get(task_id)
1790
+ if epoch is not None:
1791
+ await self._acknowledge_recovery_stopped(task_id, epoch)
1792
+ if self._heartbeat_tasks.get(task_id) is heartbeat_task:
1793
+ del self._heartbeat_tasks[task_id]
1794
+
1795
+ async def _supervise_execution(
1796
+ self,
1797
+ task_id: str,
1798
+ worker_task: asyncio.Task,
1799
+ heartbeat_task: asyncio.Task | None,
1800
+ ) -> str:
1801
+ """Supervise worker execution, cancelling it if the lease is lost.
1802
+
1803
+ Races worker_task against heartbeat_task using
1804
+ asyncio.wait(FIRST_COMPLETED).
1805
+
1806
+ Returns:
1807
+ The worker result string on success.
1808
+
1809
+ Raises:
1810
+ LeaseLostError: if the heartbeat detects ownership loss.
1811
+ Exception: re-raises the worker's exception if the worker fails.
1812
+ """
1813
+ if heartbeat_task is None:
1814
+ raise LeaseLostError(task_id)
1815
+
1816
+ done, _pending = await asyncio.wait(
1817
+ {worker_task, heartbeat_task}, return_when=asyncio.FIRST_COMPLETED
1818
+ )
1819
+ # A simultaneous completion is a lease-loss outcome: blindly choosing
1820
+ # the worker would permit its result to win an ownership-loss race.
1821
+ if heartbeat_task in done:
1822
+ await self._cancel_and_await(worker_task)
1823
+ if heartbeat_task.cancelled():
1824
+ raise LeaseLostError(task_id)
1825
+ exc = heartbeat_task.exception()
1826
+ if exc is not None:
1827
+ raise exc
1828
+ raise LeaseLostError(task_id)
1829
+
1830
+ # The worker finished first, but the database remains authoritative.
1831
+ # Check ownership before stopping the heartbeat and before completion.
1832
+ self._verify_active_ownership(task_id)
1833
+ await self._cancel_and_await(heartbeat_task)
1834
+ return worker_task.result()
1835
+
1836
+ # ---------------------------------------------------------------------------
1837
+ # Internal Helpers
1838
+ # ---------------------------------------------------------------------------
1839
+
1840
+ def _check_ownership(self, task: Task) -> Task:
1841
+ """Return the authoritative record and enforce its durable lease fence."""
1842
+ fresh = self.store.load().get(task.id)
1843
+ if fresh is None:
1844
+ raise StaleWorkerError(task.id)
1845
+ self._tasks[task.id] = fresh
1846
+ local_epoch = self._lease_epochs.get(task.id)
1847
+ lease_valid = False
1848
+ if fresh.lease_expires_at:
1849
+ try:
1850
+ lease_valid = datetime.now(UTC) < datetime.fromisoformat(
1851
+ fresh.lease_expires_at
1852
+ )
1853
+ except ValueError:
1854
+ lease_valid = False
1855
+ if (fresh.status == TaskStatus.RUNNING or local_epoch is not None) and (
1856
+ fresh.status != TaskStatus.RUNNING
1857
+ or fresh.owner_id != self.worker_id
1858
+ or local_epoch != fresh.lease_epoch
1859
+ or not lease_valid
1860
+ ):
1861
+ raise StaleWorkerError(task.id)
1862
+ return fresh
1863
+
1864
+ def _fence_for(self, task: Task) -> tuple[str, int] | None:
1865
+ """Capture the durable execution capability before a mutation."""
1866
+ if task.status != TaskStatus.RUNNING:
1867
+ return None
1868
+ return (self.worker_id, task.lease_epoch)
1869
+
1870
+ def _update_task_with_fence(
1871
+ self, task: Task, fence: tuple[str, int] | None
1872
+ ) -> None:
1873
+ if fence is None:
1874
+ self.store.update_task(task)
1875
+ return
1876
+ owner_id, epoch = fence
1877
+ self.store.update_task(
1878
+ task,
1879
+ expected_status=TaskStatus.RUNNING,
1880
+ expected_owner_id=owner_id,
1881
+ expected_lease_epoch=epoch,
1882
+ require_unexpired_lease=True,
1883
+ )
1884
+
1885
+ def _verify_active_ownership(self, task_id: str) -> None:
1886
+ """Load the authoritative record before finalizing worker output."""
1887
+ task = self.store.load().get(task_id)
1888
+ if (
1889
+ task is None
1890
+ or task.status != TaskStatus.RUNNING
1891
+ or task.owner_id != self.worker_id
1892
+ ):
1893
+ raise LeaseLostError(task_id)
1894
+ self._tasks[task_id] = task
1895
+
1896
+ async def _cancel_and_await(self, task: asyncio.Task | None) -> None:
1897
+ """Cancel a child with a bounded wait; never treat timeout as stopped."""
1898
+ if task is None or task.done():
1899
+ return
1900
+ task.cancel()
1901
+ try:
1902
+ await asyncio.wait_for(
1903
+ asyncio.shield(task), timeout=self.CANCELLATION_GRACE_SECONDS
1904
+ )
1905
+ except asyncio.CancelledError:
1906
+ pass
1907
+ except TimeoutError as err:
1908
+ # Keep a strong reference so an uncontained task is visible rather
1909
+ # than becoming an unobserved background task. Its lease epoch is
1910
+ # already fenced, so it cannot commit TaskManager mutations.
1911
+ self._uncontained_tasks.add(task)
1912
+ task.add_done_callback(self._uncontained_tasks.discard)
1913
+ logger.critical("Worker ignored cancellation; lease remains fenced.")
1914
+ raise LeaseLostError("uncontained-worker") from err
1915
+
1916
+ def _stop_heartbeat(self, task_id: str) -> None:
1917
+ if task_id in self._heartbeat_tasks:
1918
+ self._heartbeat_tasks[task_id].cancel()
1919
+ del self._heartbeat_tasks[task_id]
1920
+
1921
+ async def _heartbeat_loop(self, task_id: str) -> None:
1922
+ """Background heartbeat that renews the lease periodically.
1923
+
1924
+ If ownership is lost (StaleWorkerError from renew_lease), this
1925
+ method raises LeaseLostError so that the execution supervisor
1926
+ (_supervise_execution) can observe it and cancel the worker.
1927
+
1928
+ CancelledError is swallowed — it indicates normal shutdown by
1929
+ a terminal transition (complete_task, fail_task, etc.).
1930
+ """
1931
+ try:
1932
+ while True:
1933
+ await asyncio.sleep(self.heartbeat_interval)
1934
+ await self.renew_lease(task_id)
1935
+ except asyncio.CancelledError:
1936
+ pass # Normal cancellation by completion/failure/pause
1937
+ except StaleWorkerError:
1938
+ # Ownership conclusively lost — signal supervisor.
1939
+ raise LeaseLostError(task_id)
1940
+ except Exception as e: # noqa: BLE001
1941
+ # Any other failure (DB error, etc.) — treat as lease lost
1942
+ # because we can no longer guarantee ownership.
1943
+ logger.warning(f"Heartbeat loop for {task_id} failed: {e}")
1944
+ raise LeaseLostError(task_id)
1945
+
1946
+ async def renew_lease(self, task_id: str) -> None:
1947
+ for attempt in range(3):
1948
+ async with self._lock:
1949
+ task = self._check_ownership(self._get_task_or_raise(task_id))
1950
+ fence = self._fence_for(task)
1951
+
1952
+ task.lease_expires_at = (
1953
+ datetime.now(UTC) + timedelta(seconds=self.lease_duration)
1954
+ ).isoformat()
1955
+
1956
+ try:
1957
+ self._update_task_with_fence(task, fence)
1958
+ task.version += 1
1959
+ break
1960
+ except TaskConcurrencyError:
1961
+ fresh_tasks = self.store.load()
1962
+ if task.id in fresh_tasks:
1963
+ self._tasks[task.id] = fresh_tasks[task.id]
1964
+ if attempt == 2:
1965
+ raise
1966
+
1967
+ def _get_task_or_raise(self, task_id: str) -> Task:
1968
+ fresh = self.store.load().get(task_id)
1969
+ if fresh is not None:
1970
+ self._tasks[task_id] = fresh
1971
+ if task_id not in self._tasks:
1972
+ raise KeyError(f"Task with ID '{task_id}' not found.")
1973
+ return self._tasks[task_id]
1974
+
1975
+ def _sort_queue(self) -> None:
1976
+ """Sort queue by priority (descending) and created_at (ascending)."""
1977
+ self._queue.sort(
1978
+ key=lambda tid: (
1979
+ -self._tasks[tid].priority.value if tid in self._tasks else 0,
1980
+ self._tasks[tid].created_at if tid in self._tasks else "",
1981
+ )
1982
+ )
1983
+
1984
+ async def _emit_event(
1985
+ self, event_type: str, task: Task, payload: dict[str, Any] | None = None
1986
+ ) -> None:
1987
+ event = TaskEvent(
1988
+ event_type=event_type,
1989
+ task_id=task.id,
1990
+ status=task.status,
1991
+ progress=task.progress,
1992
+ payload={**(payload or {}), "title": task.title},
1993
+ )
1994
+ await self.event_bus.emit(event)
1995
+
1996
+ def _log_telemetry(self, event_type: str, **kwargs: Any) -> None:
1997
+ if self.telemetry and hasattr(self.telemetry, "log_event"):
1998
+ try:
1999
+ self.telemetry.log_event(
2000
+ event_type=f"task_manager_{event_type}", **kwargs
2001
+ )
2002
+ # Intentionally broad to isolate execution boundaries and prevent crashes.
2003
+ except Exception as err: # noqa: BLE001
2004
+ # Intentionally broad to prevent telemetry failures from crashing the system.
2005
+ logger.warning(f"Telemetry logging failed: {err}")