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.
- pulse/__init__.py +5 -0
- pulse/__main__.py +4 -0
- pulse/agent.py +270 -0
- pulse/agent_manager.py +335 -0
- pulse/audit.py +70 -0
- pulse/auth.py +670 -0
- pulse/ci/github_client.py +66 -0
- pulse/ci/runner.py +28 -0
- pulse/cli.py +1075 -0
- pulse/cli_ui.py +977 -0
- pulse/config.py +167 -0
- pulse/context.py +960 -0
- pulse/conversations/__init__.py +8 -0
- pulse/conversations/manager.py +312 -0
- pulse/core/agent.py +188 -0
- pulse/core/planner.py +105 -0
- pulse/core/protocols.py +37 -0
- pulse/edits.py +65 -0
- pulse/episodic.py +93 -0
- pulse/eval/__init__.py +8 -0
- pulse/eval/trajectory_logger.py +91 -0
- pulse/eval/verifier.py +133 -0
- pulse/execution/__init__.py +5 -0
- pulse/execution/remote_task.py +76 -0
- pulse/git.py +162 -0
- pulse/interactive.py +234 -0
- pulse/mcp/__init__.py +4 -0
- pulse/mcp/client.py +215 -0
- pulse/mcp/local_tools.py +105 -0
- pulse/memory.py +212 -0
- pulse/mutations.py +283 -0
- pulse/orchestration/__init__.py +3 -0
- pulse/orchestration/orchestrator.py +162 -0
- pulse/patch.py +129 -0
- pulse/planner/__init__.py +3 -0
- pulse/planner/dag_planner.py +85 -0
- pulse/planner/execution_loop.py +159 -0
- pulse/production.py +235 -0
- pulse/provider.py +59 -0
- pulse/provider_keys.py +278 -0
- pulse/providers/__init__.py +26 -0
- pulse/providers/anthropic.py +65 -0
- pulse/providers/base.py +251 -0
- pulse/providers/deepseek.py +10 -0
- pulse/providers/failover.py +32 -0
- pulse/providers/gemini.py +66 -0
- pulse/providers/groq.py +10 -0
- pulse/providers/manager.py +262 -0
- pulse/providers/openai.py +40 -0
- pulse/providers/openrouter.py +20 -0
- pulse/py.typed +1 -0
- pulse/reasoning.py +570 -0
- pulse/refactor/__init__.py +3 -0
- pulse/refactor/impact_analyzer.py +44 -0
- pulse/repository.py +209 -0
- pulse/rpc.py +249 -0
- pulse/rule_synthesizer.py +54 -0
- pulse/runtime.py +217 -0
- pulse/safety/__init__.py +3 -0
- pulse/safety/safety_manager.py +97 -0
- pulse/sandbox/SECURITY.md +57 -0
- pulse/sandbox/__init__.py +57 -0
- pulse/sandbox/api.py +594 -0
- pulse/sandbox/audit.py +153 -0
- pulse/sandbox/backend/__init__.py +7 -0
- pulse/sandbox/backend/base.py +72 -0
- pulse/sandbox/backend/docker.py +498 -0
- pulse/sandbox/backend/host.py +140 -0
- pulse/sandbox/backend/remote.py +224 -0
- pulse/sandbox/errors.py +106 -0
- pulse/sandbox/filesystem.py +476 -0
- pulse/sandbox/git_safe.py +50 -0
- pulse/sandbox/lifecycle.py +88 -0
- pulse/sandbox/network.py +205 -0
- pulse/sandbox/path_validator.py +280 -0
- pulse/sandbox/policy.py +209 -0
- pulse/sandbox/process.py +331 -0
- pulse/sandbox/project.py +158 -0
- pulse/sandbox/python_safe.py +62 -0
- pulse/sandbox/remote/__init__.py +1 -0
- pulse/sandbox/remote/client.py +389 -0
- pulse/sandbox/remote/models.py +167 -0
- pulse/sandbox/remote/protocol.py +65 -0
- pulse/sandbox/remote/server.py +984 -0
- pulse/sandbox/remote/worker.py +175 -0
- pulse/sandbox/resources.py +236 -0
- pulse/sandbox/secrets.py +241 -0
- pulse/session_manager.py +365 -0
- pulse/software_engineer.py +189 -0
- pulse/storage.py +140 -0
- pulse/streaming.py +385 -0
- pulse/subprocesses.py +79 -0
- pulse/task_manager.py +2005 -0
- pulse/telemetry/__init__.py +25 -0
- pulse/telemetry/cost_tracker.py +95 -0
- pulse/telemetry/logger.py +110 -0
- pulse/tool_policy.py +197 -0
- pulse/tool_registry.py +163 -0
- pulse/tools.py +372 -0
- pulse/verification.py +118 -0
- pulse_coding_agent-0.1.0.dist-info/METADATA +211 -0
- pulse_coding_agent-0.1.0.dist-info/RECORD +104 -0
- pulse_coding_agent-0.1.0.dist-info/WHEEL +4 -0
- 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}")
|