agentlings 0.2.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.
- agentlings/__init__.py +3 -0
- agentlings/__main__.py +238 -0
- agentlings/cli/__init__.py +1 -0
- agentlings/cli/_migrations.py +78 -0
- agentlings/cli/_templates.py +57 -0
- agentlings/cli/_version.py +33 -0
- agentlings/cli/init.py +122 -0
- agentlings/cli/upgrade.py +89 -0
- agentlings/config.py +260 -0
- agentlings/core/__init__.py +1 -0
- agentlings/core/completion.py +219 -0
- agentlings/core/llm.py +509 -0
- agentlings/core/loop.py +134 -0
- agentlings/core/memory_models.py +97 -0
- agentlings/core/memory_store.py +109 -0
- agentlings/core/models.py +231 -0
- agentlings/core/prompt.py +122 -0
- agentlings/core/scheduler.py +141 -0
- agentlings/core/sleep.py +393 -0
- agentlings/core/store.py +318 -0
- agentlings/core/task.py +1087 -0
- agentlings/core/telemetry.py +181 -0
- agentlings/log.py +23 -0
- agentlings/migrations/__init__.py +37 -0
- agentlings/migrations/m0001_seed.py +17 -0
- agentlings/protocol/__init__.py +1 -0
- agentlings/protocol/a2a.py +220 -0
- agentlings/protocol/a2a_task_store.py +150 -0
- agentlings/protocol/agent_card.py +83 -0
- agentlings/protocol/mcp.py +232 -0
- agentlings/server.py +247 -0
- agentlings/templates/__init__.py +1 -0
- agentlings/templates/default/.env.example +16 -0
- agentlings/templates/default/agent.yaml +14 -0
- agentlings/tools/__init__.py +1 -0
- agentlings/tools/builtins.py +307 -0
- agentlings/tools/memory.py +104 -0
- agentlings/tools/registry.py +154 -0
- agentlings-0.2.0.dist-info/METADATA +406 -0
- agentlings-0.2.0.dist-info/RECORD +43 -0
- agentlings-0.2.0.dist-info/WHEEL +4 -0
- agentlings-0.2.0.dist-info/entry_points.txt +2 -0
- agentlings-0.2.0.dist-info/licenses/LICENSE +21 -0
agentlings/core/task.py
ADDED
|
@@ -0,0 +1,1087 @@
|
|
|
1
|
+
"""Task execution engine: every request becomes a task with its own sub-journal.
|
|
2
|
+
|
|
3
|
+
Core responsibilities:
|
|
4
|
+
|
|
5
|
+
- Registry tracks live (in-flight) tasks, one per context.
|
|
6
|
+
- Workers run the message loop in a per-task sub-journal.
|
|
7
|
+
- Merge-back writes successful results to the parent journal under a per-context lock.
|
|
8
|
+
- Cancellation is cooperative; the worker checks a flag between checkpoints.
|
|
9
|
+
- Startup recovery closes out orphaned sub-journals and merge-backs.
|
|
10
|
+
|
|
11
|
+
The engine is the only public entry point protocol adapters should talk to.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import functools
|
|
18
|
+
import logging
|
|
19
|
+
import traceback
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from enum import Enum
|
|
22
|
+
from typing import Any
|
|
23
|
+
from uuid import uuid4
|
|
24
|
+
|
|
25
|
+
from agentlings.config import AgentConfig
|
|
26
|
+
from agentlings.core.completion import (
|
|
27
|
+
CancellationRequested,
|
|
28
|
+
CompletionResult,
|
|
29
|
+
run_completion,
|
|
30
|
+
)
|
|
31
|
+
from agentlings.core.llm import BaseLLMClient
|
|
32
|
+
from agentlings.core.memory_store import MemoryFileStore
|
|
33
|
+
from agentlings.core.models import (
|
|
34
|
+
CompactionEntry,
|
|
35
|
+
MergeCommitted,
|
|
36
|
+
MergeStarted,
|
|
37
|
+
MessageEntry,
|
|
38
|
+
TaskCancelled,
|
|
39
|
+
TaskCompleted,
|
|
40
|
+
TaskDispatched,
|
|
41
|
+
TaskFailed,
|
|
42
|
+
TaskStarted,
|
|
43
|
+
)
|
|
44
|
+
from agentlings.core.prompt import build_system_prompt
|
|
45
|
+
from agentlings.core.store import JournalStore, TaskJournal
|
|
46
|
+
from agentlings.core.telemetry import get_meter, otel_span
|
|
47
|
+
from agentlings.tools.registry import ToolRegistry
|
|
48
|
+
|
|
49
|
+
logger = logging.getLogger(__name__)
|
|
50
|
+
|
|
51
|
+
# Cap on the stored traceback. Preserves enough stack context for
|
|
52
|
+
# post-mortem diagnosis without bloating the journal when a deep error chain
|
|
53
|
+
# serializes dozens of KB.
|
|
54
|
+
_FAILURE_DETAIL_MAX = 4096
|
|
55
|
+
|
|
56
|
+
# Grace window given to in-flight workers during ``TaskEngine.shutdown``.
|
|
57
|
+
# Beyond this the worker's asyncio task is hard-cancelled.
|
|
58
|
+
_SHUTDOWN_GRACE_SECONDS = 5.0
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class _TaskMetrics:
|
|
63
|
+
"""Lifecycle counters and gauges surfaced via OpenTelemetry.
|
|
64
|
+
|
|
65
|
+
Enterprise operators rely on these to alert on failure-rate spikes,
|
|
66
|
+
track active concurrency, and size capacity. Lookup is cached at
|
|
67
|
+
module init so individual call sites pay no repeat cost.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
tasks_spawned: Any
|
|
71
|
+
tasks_completed: Any
|
|
72
|
+
tasks_failed: Any
|
|
73
|
+
tasks_cancelled: Any
|
|
74
|
+
tasks_active: Any
|
|
75
|
+
context_busy_rejections: Any
|
|
76
|
+
crash_recovery_repaired: Any
|
|
77
|
+
crash_recovery_failed: Any
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@functools.lru_cache(maxsize=1)
|
|
81
|
+
def _build_metrics() -> _TaskMetrics:
|
|
82
|
+
m = get_meter()
|
|
83
|
+
return _TaskMetrics(
|
|
84
|
+
tasks_spawned=m.create_counter(
|
|
85
|
+
"agentling.tasks.spawned_total",
|
|
86
|
+
description="Total tasks ingressed into the engine.",
|
|
87
|
+
),
|
|
88
|
+
tasks_completed=m.create_counter(
|
|
89
|
+
"agentling.tasks.completed_total",
|
|
90
|
+
description="Total tasks that reached completed terminal state.",
|
|
91
|
+
),
|
|
92
|
+
tasks_failed=m.create_counter(
|
|
93
|
+
"agentling.tasks.failed_total",
|
|
94
|
+
description="Total tasks that failed (exception or asyncio cancel).",
|
|
95
|
+
),
|
|
96
|
+
tasks_cancelled=m.create_counter(
|
|
97
|
+
"agentling.tasks.cancelled_total",
|
|
98
|
+
description="Total tasks that reached cancelled terminal state.",
|
|
99
|
+
),
|
|
100
|
+
tasks_active=m.create_up_down_counter(
|
|
101
|
+
"agentling.tasks.active",
|
|
102
|
+
description="Tasks currently registered (gauge via up/down counter).",
|
|
103
|
+
),
|
|
104
|
+
context_busy_rejections=m.create_counter(
|
|
105
|
+
"agentling.tasks.context_busy_rejections_total",
|
|
106
|
+
description="Spawn attempts rejected because the context was busy.",
|
|
107
|
+
),
|
|
108
|
+
crash_recovery_repaired=m.create_counter(
|
|
109
|
+
"agentling.tasks.crash_recovery_repaired_total",
|
|
110
|
+
description="Orphaned sub-journals or partial merges repaired on startup.",
|
|
111
|
+
),
|
|
112
|
+
crash_recovery_failed=m.create_counter(
|
|
113
|
+
"agentling.tasks.crash_recovery_failed_total",
|
|
114
|
+
description="Recovery passes that raised an exception for a context.",
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
_METRICS = _build_metrics()
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class TaskStatus(str, Enum):
|
|
123
|
+
"""Lifecycle states tracked by the registry for a live task.
|
|
124
|
+
|
|
125
|
+
Sub-journal terminal markers (``task_done``/``task_fail``/``task_cancel``)
|
|
126
|
+
mirror the corresponding enum values. ``working`` means the task exists
|
|
127
|
+
and may still do more work; ``cancelling`` means a cancel flag was raised
|
|
128
|
+
and the worker has not yet observed the terminal checkpoint.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
WORKING = "working"
|
|
132
|
+
CANCELLING = "cancelling"
|
|
133
|
+
COMPLETED = "completed"
|
|
134
|
+
FAILED = "failed"
|
|
135
|
+
CANCELLED = "cancelled"
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
TERMINAL_STATUSES: frozenset[TaskStatus] = frozenset({
|
|
139
|
+
TaskStatus.COMPLETED,
|
|
140
|
+
TaskStatus.FAILED,
|
|
141
|
+
TaskStatus.CANCELLED,
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# --------------------------------------------------------------------------- #
|
|
146
|
+
# Errors
|
|
147
|
+
# --------------------------------------------------------------------------- #
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class TaskError(Exception):
|
|
151
|
+
"""Base class for engine-raised exceptions."""
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class ContextBusyError(TaskError):
|
|
155
|
+
"""Raised when a new request targets a context that already has a live task."""
|
|
156
|
+
|
|
157
|
+
def __init__(self, context_id: str, active_task_id: str) -> None:
|
|
158
|
+
super().__init__(
|
|
159
|
+
f"context {context_id} is busy with task {active_task_id}"
|
|
160
|
+
)
|
|
161
|
+
self.context_id = context_id
|
|
162
|
+
self.active_task_id = active_task_id
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class TaskNotFoundError(TaskError):
|
|
166
|
+
"""Raised when a poll/cancel targets a task the engine cannot locate.
|
|
167
|
+
|
|
168
|
+
Carries a ``searched`` breadcrumb so operators can tell whether the
|
|
169
|
+
lookup exhausted the in-memory registry, a specific context directory,
|
|
170
|
+
or both.
|
|
171
|
+
"""
|
|
172
|
+
|
|
173
|
+
def __init__(self, task_id: str, searched: str | None = None) -> None:
|
|
174
|
+
detail = f" (searched: {searched})" if searched else ""
|
|
175
|
+
super().__init__(f"task {task_id} not found{detail}")
|
|
176
|
+
self.task_id = task_id
|
|
177
|
+
self.searched = searched
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class TaskContextMismatchError(TaskError):
|
|
181
|
+
"""Raised when the caller asserts a contextId that doesn't match the task's."""
|
|
182
|
+
|
|
183
|
+
def __init__(self, task_id: str, expected: str, actual: str) -> None:
|
|
184
|
+
super().__init__(
|
|
185
|
+
f"task {task_id}: expected context {expected}, actual context {actual}"
|
|
186
|
+
)
|
|
187
|
+
self.task_id = task_id
|
|
188
|
+
self.expected = expected
|
|
189
|
+
self.actual = actual
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class InvalidTaskInputError(TaskError):
|
|
193
|
+
"""Raised on malformed inputs (e.g. both ``message`` and ``taskId`` present)."""
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# --------------------------------------------------------------------------- #
|
|
197
|
+
# Registry
|
|
198
|
+
# --------------------------------------------------------------------------- #
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass
|
|
202
|
+
class TaskRecord:
|
|
203
|
+
"""Registry entry for a live task.
|
|
204
|
+
|
|
205
|
+
Attributes:
|
|
206
|
+
task_id: Unique task identifier.
|
|
207
|
+
context_id: Parent context this task belongs to.
|
|
208
|
+
status: Current lifecycle state.
|
|
209
|
+
cancel_flag: Set to ``True`` to request cooperative cancellation.
|
|
210
|
+
completion_event: Fires when the task reaches a terminal state.
|
|
211
|
+
owner: Placeholder for future multi-tenant auth binding.
|
|
212
|
+
message: The original user message text.
|
|
213
|
+
via: Protocol surface that created this task.
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
task_id: str
|
|
217
|
+
context_id: str
|
|
218
|
+
message: str
|
|
219
|
+
via: str = "a2a"
|
|
220
|
+
status: TaskStatus = TaskStatus.WORKING
|
|
221
|
+
cancel_flag: bool = False
|
|
222
|
+
completion_event: asyncio.Event = field(default_factory=asyncio.Event)
|
|
223
|
+
owner: str | None = None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class TaskRegistry:
|
|
227
|
+
"""In-memory store of live tasks with per-context admission control.
|
|
228
|
+
|
|
229
|
+
One task per context at a time — attempting to register a second task
|
|
230
|
+
for the same context raises ``ContextBusyError``. Terminal tasks are
|
|
231
|
+
removed immediately via ``unregister``.
|
|
232
|
+
|
|
233
|
+
All mutations are synchronous. asyncio is single-threaded and there
|
|
234
|
+
are no ``await`` points inside the critical sections here, so the
|
|
235
|
+
"check then set" in ``register`` runs atomically without needing an
|
|
236
|
+
``asyncio.Lock``. Keeping these methods synchronous also means
|
|
237
|
+
``unregister`` in a worker's ``finally`` block cannot be interrupted
|
|
238
|
+
by a re-delivered ``CancelledError`` during shutdown — the registry
|
|
239
|
+
always releases state cleanly.
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
def __init__(self) -> None:
|
|
243
|
+
self._tasks: dict[str, TaskRecord] = {}
|
|
244
|
+
self._by_context: dict[str, str] = {}
|
|
245
|
+
|
|
246
|
+
def register(
|
|
247
|
+
self,
|
|
248
|
+
task_id: str,
|
|
249
|
+
context_id: str,
|
|
250
|
+
message: str,
|
|
251
|
+
via: str = "a2a",
|
|
252
|
+
owner: str | None = None,
|
|
253
|
+
) -> TaskRecord:
|
|
254
|
+
"""Atomically check context-busy and register a new task.
|
|
255
|
+
|
|
256
|
+
Raises:
|
|
257
|
+
ContextBusyError: If ``context_id`` already has a live task.
|
|
258
|
+
"""
|
|
259
|
+
if context_id in self._by_context:
|
|
260
|
+
raise ContextBusyError(
|
|
261
|
+
context_id=context_id,
|
|
262
|
+
active_task_id=self._by_context[context_id],
|
|
263
|
+
)
|
|
264
|
+
record = TaskRecord(
|
|
265
|
+
task_id=task_id,
|
|
266
|
+
context_id=context_id,
|
|
267
|
+
message=message,
|
|
268
|
+
via=via,
|
|
269
|
+
owner=owner,
|
|
270
|
+
)
|
|
271
|
+
self._tasks[task_id] = record
|
|
272
|
+
self._by_context[context_id] = task_id
|
|
273
|
+
return record
|
|
274
|
+
|
|
275
|
+
def unregister(self, task_id: str) -> None:
|
|
276
|
+
"""Remove a task from the registry. Idempotent."""
|
|
277
|
+
record = self._tasks.pop(task_id, None)
|
|
278
|
+
if record is not None:
|
|
279
|
+
self._by_context.pop(record.context_id, None)
|
|
280
|
+
|
|
281
|
+
def get(self, task_id: str) -> TaskRecord | None:
|
|
282
|
+
"""Return the record for ``task_id`` or ``None`` if unknown."""
|
|
283
|
+
return self._tasks.get(task_id)
|
|
284
|
+
|
|
285
|
+
def get_by_context(self, context_id: str) -> TaskRecord | None:
|
|
286
|
+
"""Return the live task for a context, if any."""
|
|
287
|
+
tid = self._by_context.get(context_id)
|
|
288
|
+
return self._tasks.get(tid) if tid else None
|
|
289
|
+
|
|
290
|
+
def request_cancel(self, task_id: str) -> bool:
|
|
291
|
+
"""Flip the cancel flag on a live task. Returns ``False`` if unknown."""
|
|
292
|
+
record = self._tasks.get(task_id)
|
|
293
|
+
if record is None:
|
|
294
|
+
return False
|
|
295
|
+
record.cancel_flag = True
|
|
296
|
+
record.status = TaskStatus.CANCELLING
|
|
297
|
+
return True
|
|
298
|
+
|
|
299
|
+
def active_count(self) -> int:
|
|
300
|
+
"""Number of currently-registered tasks."""
|
|
301
|
+
return len(self._tasks)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# --------------------------------------------------------------------------- #
|
|
305
|
+
# Task state returned to callers
|
|
306
|
+
# --------------------------------------------------------------------------- #
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@dataclass
|
|
310
|
+
class TaskState:
|
|
311
|
+
"""Snapshot of a task returned to protocol handlers.
|
|
312
|
+
|
|
313
|
+
Attributes:
|
|
314
|
+
task_id: Task identifier.
|
|
315
|
+
context_id: Parent context.
|
|
316
|
+
status: Current lifecycle state.
|
|
317
|
+
content: Final response content blocks (populated when status is
|
|
318
|
+
``completed``; otherwise empty).
|
|
319
|
+
error: Short reason/details string for failed or cancelled states.
|
|
320
|
+
"""
|
|
321
|
+
|
|
322
|
+
task_id: str
|
|
323
|
+
context_id: str
|
|
324
|
+
status: TaskStatus
|
|
325
|
+
content: list[dict[str, Any]] = field(default_factory=list)
|
|
326
|
+
error: str | None = None
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# --------------------------------------------------------------------------- #
|
|
330
|
+
# Worker
|
|
331
|
+
# --------------------------------------------------------------------------- #
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class TaskWorker:
|
|
335
|
+
"""Executes one task end-to-end: snapshot → loop → terminal → merge-back.
|
|
336
|
+
|
|
337
|
+
Workers never touch the parent journal until merge-back, which is guarded
|
|
338
|
+
by a per-context lock held by the engine. Sub-journal writes happen
|
|
339
|
+
continuously as the worker runs so ops can inspect progress and crash
|
|
340
|
+
recovery has something to read.
|
|
341
|
+
"""
|
|
342
|
+
|
|
343
|
+
def __init__(
|
|
344
|
+
self,
|
|
345
|
+
record: TaskRecord,
|
|
346
|
+
config: AgentConfig,
|
|
347
|
+
llm: BaseLLMClient,
|
|
348
|
+
tools: ToolRegistry,
|
|
349
|
+
store: JournalStore,
|
|
350
|
+
task_journal: TaskJournal,
|
|
351
|
+
registry: TaskRegistry,
|
|
352
|
+
context_lock: asyncio.Lock,
|
|
353
|
+
memory_store: MemoryFileStore | None = None,
|
|
354
|
+
) -> None:
|
|
355
|
+
self._record = record
|
|
356
|
+
self._config = config
|
|
357
|
+
self._llm = llm
|
|
358
|
+
self._tools = tools
|
|
359
|
+
self._store = store
|
|
360
|
+
self._journal = task_journal
|
|
361
|
+
self._registry = registry
|
|
362
|
+
self._context_lock = context_lock
|
|
363
|
+
self._memory_store = memory_store
|
|
364
|
+
|
|
365
|
+
async def run(self) -> None:
|
|
366
|
+
"""Drive the task to a terminal state.
|
|
367
|
+
|
|
368
|
+
The worker writes exactly one terminal sub-journal entry before
|
|
369
|
+
returning, regardless of success, failure, or cancellation.
|
|
370
|
+
"""
|
|
371
|
+
record = self._record
|
|
372
|
+
with otel_span("agentling.task.worker", {
|
|
373
|
+
"task.id": record.task_id,
|
|
374
|
+
"task.context_id": record.context_id,
|
|
375
|
+
"task.via": record.via,
|
|
376
|
+
}) as span:
|
|
377
|
+
try:
|
|
378
|
+
await self._run_inner()
|
|
379
|
+
except CancellationRequested:
|
|
380
|
+
span.set_attribute("task.terminal", "cancelled")
|
|
381
|
+
cancel = TaskCancelled(
|
|
382
|
+
ctx=record.context_id,
|
|
383
|
+
task_id=record.task_id,
|
|
384
|
+
reason="cooperative_cancel",
|
|
385
|
+
)
|
|
386
|
+
self._journal.append(cancel)
|
|
387
|
+
self._store.append(record.context_id, cancel)
|
|
388
|
+
record.status = TaskStatus.CANCELLED
|
|
389
|
+
_METRICS.tasks_cancelled.add(1)
|
|
390
|
+
logger.info(
|
|
391
|
+
"task cancelled",
|
|
392
|
+
extra={
|
|
393
|
+
"task_id": record.task_id,
|
|
394
|
+
"context_id": record.context_id,
|
|
395
|
+
"reason": "cooperative_cancel",
|
|
396
|
+
},
|
|
397
|
+
)
|
|
398
|
+
except asyncio.CancelledError:
|
|
399
|
+
# Re-raised so asyncio teardown observes the cancel; the
|
|
400
|
+
# terminal markers below ensure operators can still see
|
|
401
|
+
# what happened post-mortem.
|
|
402
|
+
fail = TaskFailed(
|
|
403
|
+
ctx=record.context_id,
|
|
404
|
+
task_id=record.task_id,
|
|
405
|
+
reason="asyncio_cancel",
|
|
406
|
+
)
|
|
407
|
+
self._journal.append(fail)
|
|
408
|
+
self._store.append(record.context_id, fail)
|
|
409
|
+
record.status = TaskStatus.FAILED
|
|
410
|
+
_METRICS.tasks_failed.add(1)
|
|
411
|
+
raise
|
|
412
|
+
except Exception as exc: # noqa: BLE001 — recorded as failure
|
|
413
|
+
span.set_attribute("task.terminal", "failed")
|
|
414
|
+
tb = traceback.format_exc()
|
|
415
|
+
logger.error(
|
|
416
|
+
"task failed",
|
|
417
|
+
extra={
|
|
418
|
+
"task_id": record.task_id,
|
|
419
|
+
"context_id": record.context_id,
|
|
420
|
+
"exc_type": type(exc).__name__,
|
|
421
|
+
},
|
|
422
|
+
exc_info=True,
|
|
423
|
+
)
|
|
424
|
+
fail = TaskFailed(
|
|
425
|
+
ctx=record.context_id,
|
|
426
|
+
task_id=record.task_id,
|
|
427
|
+
reason="worker_exception",
|
|
428
|
+
error_details=tb[-_FAILURE_DETAIL_MAX:],
|
|
429
|
+
)
|
|
430
|
+
self._journal.append(fail)
|
|
431
|
+
self._store.append(record.context_id, fail)
|
|
432
|
+
record.status = TaskStatus.FAILED
|
|
433
|
+
_METRICS.tasks_failed.add(1)
|
|
434
|
+
else:
|
|
435
|
+
_METRICS.tasks_completed.add(1)
|
|
436
|
+
finally:
|
|
437
|
+
record.completion_event.set()
|
|
438
|
+
# Synchronous — no await point, so even a re-delivered
|
|
439
|
+
# CancelledError during shutdown cannot interrupt cleanup.
|
|
440
|
+
self._registry.unregister(record.task_id)
|
|
441
|
+
|
|
442
|
+
async def _run_inner(self) -> None:
|
|
443
|
+
"""The happy path: snapshot, complete, merge back.
|
|
444
|
+
|
|
445
|
+
On cancellation or exception, the outer ``run`` method handles terminal
|
|
446
|
+
marker writes and registry cleanup.
|
|
447
|
+
"""
|
|
448
|
+
record = self._record
|
|
449
|
+
|
|
450
|
+
messages = list(self._store.replay(record.context_id))
|
|
451
|
+
messages.append({
|
|
452
|
+
"role": "user",
|
|
453
|
+
"content": [{"type": "text", "text": record.message}],
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
memory = self._memory_store.load() if self._memory_store else None
|
|
457
|
+
memory_config = self._config.memory_config
|
|
458
|
+
system = build_system_prompt(
|
|
459
|
+
config=self._config,
|
|
460
|
+
memory=memory,
|
|
461
|
+
data_dir=self._config.agent_data_dir,
|
|
462
|
+
injection_prompt=memory_config.injection_prompt if memory_config else None,
|
|
463
|
+
token_budget=memory_config.token_budget if memory_config else 2000,
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
async def _on_turn(turn: Any) -> None:
|
|
467
|
+
self._journal.append(MessageEntry(
|
|
468
|
+
ctx=record.context_id,
|
|
469
|
+
role="assistant",
|
|
470
|
+
content=turn.response.content,
|
|
471
|
+
via=record.via, # type: ignore[arg-type]
|
|
472
|
+
task_id=record.task_id,
|
|
473
|
+
))
|
|
474
|
+
if turn.tool_results:
|
|
475
|
+
self._journal.append(MessageEntry(
|
|
476
|
+
ctx=record.context_id,
|
|
477
|
+
role="user",
|
|
478
|
+
content=turn.tool_results,
|
|
479
|
+
via=record.via, # type: ignore[arg-type]
|
|
480
|
+
task_id=record.task_id,
|
|
481
|
+
))
|
|
482
|
+
for block in turn.response.content:
|
|
483
|
+
if block.get("type") == "compaction":
|
|
484
|
+
self._journal.append(CompactionEntry(
|
|
485
|
+
ctx=record.context_id,
|
|
486
|
+
content=block.get("content", ""),
|
|
487
|
+
))
|
|
488
|
+
|
|
489
|
+
result = await run_completion(
|
|
490
|
+
llm=self._llm,
|
|
491
|
+
system=system,
|
|
492
|
+
messages=messages,
|
|
493
|
+
tools=self._tools,
|
|
494
|
+
turn_callback=_on_turn,
|
|
495
|
+
should_cancel=lambda: record.cancel_flag,
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
self._journal.append(TaskCompleted(
|
|
499
|
+
ctx=record.context_id,
|
|
500
|
+
task_id=record.task_id,
|
|
501
|
+
final_response=result.content,
|
|
502
|
+
))
|
|
503
|
+
|
|
504
|
+
await self._merge_back(result)
|
|
505
|
+
record.status = TaskStatus.COMPLETED
|
|
506
|
+
|
|
507
|
+
async def _merge_back(self, result: CompletionResult) -> None:
|
|
508
|
+
"""Atomically propagate the task's outcome to the parent journal.
|
|
509
|
+
|
|
510
|
+
Ordering:
|
|
511
|
+
1. ``MergeStarted`` wrapper.
|
|
512
|
+
2. User ``MessageEntry`` with ``task_id`` meta.
|
|
513
|
+
3. Latest ``CompactionEntry`` from the sub-journal, if any.
|
|
514
|
+
4. Assistant ``MessageEntry`` (final response).
|
|
515
|
+
5. ``MergeCommitted`` wrapper.
|
|
516
|
+
|
|
517
|
+
All five writes happen under the context lock in one
|
|
518
|
+
``append_many`` call so startup crash recovery can detect a partial
|
|
519
|
+
merge via an orphaned ``MergeStarted``.
|
|
520
|
+
"""
|
|
521
|
+
record = self._record
|
|
522
|
+
entries: list[Any] = [
|
|
523
|
+
MergeStarted(ctx=record.context_id, task_id=record.task_id),
|
|
524
|
+
MessageEntry(
|
|
525
|
+
ctx=record.context_id,
|
|
526
|
+
role="user",
|
|
527
|
+
content=[{"type": "text", "text": record.message}],
|
|
528
|
+
via=record.via, # type: ignore[arg-type]
|
|
529
|
+
task_id=record.task_id,
|
|
530
|
+
),
|
|
531
|
+
]
|
|
532
|
+
latest_comp = self._journal.latest_compaction()
|
|
533
|
+
if latest_comp is not None:
|
|
534
|
+
entries.append(CompactionEntry(
|
|
535
|
+
ctx=record.context_id,
|
|
536
|
+
content=latest_comp.get("content", ""),
|
|
537
|
+
))
|
|
538
|
+
entries.append(MessageEntry(
|
|
539
|
+
ctx=record.context_id,
|
|
540
|
+
role="assistant",
|
|
541
|
+
content=result.content,
|
|
542
|
+
via=record.via, # type: ignore[arg-type]
|
|
543
|
+
task_id=record.task_id,
|
|
544
|
+
))
|
|
545
|
+
entries.append(MergeCommitted(ctx=record.context_id, task_id=record.task_id))
|
|
546
|
+
|
|
547
|
+
async with self._context_lock:
|
|
548
|
+
self._store.append_many(record.context_id, entries)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
# --------------------------------------------------------------------------- #
|
|
552
|
+
# Engine
|
|
553
|
+
# --------------------------------------------------------------------------- #
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
class TaskEngine:
|
|
557
|
+
"""Top-level orchestrator. Spawns, polls, and cancels tasks.
|
|
558
|
+
|
|
559
|
+
Concurrency model:
|
|
560
|
+
- The registry lock serializes admission (one task per context).
|
|
561
|
+
- ``_context_locks_guard`` serializes access to the lock dict itself.
|
|
562
|
+
- Per-context ``asyncio.Lock`` objects serialize merge-back writes to
|
|
563
|
+
the parent journal. Held briefly by workers only.
|
|
564
|
+
|
|
565
|
+
Lock acquisition order (never violated to avoid deadlock):
|
|
566
|
+
registry._lock → _context_locks_guard → per-context lock
|
|
567
|
+
"""
|
|
568
|
+
|
|
569
|
+
def __init__(
|
|
570
|
+
self,
|
|
571
|
+
config: AgentConfig,
|
|
572
|
+
store: JournalStore,
|
|
573
|
+
llm: BaseLLMClient,
|
|
574
|
+
tools: ToolRegistry,
|
|
575
|
+
memory_store: MemoryFileStore | None = None,
|
|
576
|
+
) -> None:
|
|
577
|
+
self._config = config
|
|
578
|
+
self._store = store
|
|
579
|
+
self._llm = llm
|
|
580
|
+
self._tools = tools
|
|
581
|
+
self._memory_store = memory_store
|
|
582
|
+
self._registry = TaskRegistry()
|
|
583
|
+
self._context_locks: dict[str, asyncio.Lock] = {}
|
|
584
|
+
self._context_locks_guard = asyncio.Lock()
|
|
585
|
+
self._workers: dict[str, asyncio.Task[None]] = {}
|
|
586
|
+
self._shutdown_started = False
|
|
587
|
+
|
|
588
|
+
@property
|
|
589
|
+
def registry(self) -> TaskRegistry:
|
|
590
|
+
"""Underlying registry (for introspection / tests)."""
|
|
591
|
+
return self._registry
|
|
592
|
+
|
|
593
|
+
async def _lock_for(self, context_id: str) -> asyncio.Lock:
|
|
594
|
+
async with self._context_locks_guard:
|
|
595
|
+
lock = self._context_locks.get(context_id)
|
|
596
|
+
if lock is None:
|
|
597
|
+
lock = asyncio.Lock()
|
|
598
|
+
self._context_locks[context_id] = lock
|
|
599
|
+
return lock
|
|
600
|
+
|
|
601
|
+
def _on_worker_done(self, task_id: str, context_id: str) -> None:
|
|
602
|
+
"""Worker post-finish housekeeping fired from the asyncio callback.
|
|
603
|
+
|
|
604
|
+
Removes the ``_workers`` entry and — critically — garbage-collects
|
|
605
|
+
the context lock if no task remains on that context. Without this,
|
|
606
|
+
``_context_locks`` grows unbounded in long-running servers.
|
|
607
|
+
|
|
608
|
+
Must not touch asyncio locks directly (we're in a ``done_callback``
|
|
609
|
+
synchronous hook). We only drop keys that are provably safe.
|
|
610
|
+
"""
|
|
611
|
+
self._workers.pop(task_id, None)
|
|
612
|
+
_METRICS.tasks_active.add(-1)
|
|
613
|
+
if self._registry.get_by_context(context_id) is not None:
|
|
614
|
+
return
|
|
615
|
+
lock = self._context_locks.get(context_id)
|
|
616
|
+
if lock is None or lock.locked():
|
|
617
|
+
return
|
|
618
|
+
self._context_locks.pop(context_id, None)
|
|
619
|
+
|
|
620
|
+
async def spawn(
|
|
621
|
+
self,
|
|
622
|
+
message: str,
|
|
623
|
+
context_id: str | None = None,
|
|
624
|
+
via: str = "a2a",
|
|
625
|
+
await_seconds: float = 60.0,
|
|
626
|
+
owner: str | None = None,
|
|
627
|
+
task_id: str | None = None,
|
|
628
|
+
) -> TaskState:
|
|
629
|
+
"""Start a new task, block up to ``await_seconds`` for completion.
|
|
630
|
+
|
|
631
|
+
Returns a ``TaskState`` reflecting the state at the moment the HTTP
|
|
632
|
+
handler should respond. If the task finished within the await window,
|
|
633
|
+
``status == COMPLETED`` and ``content`` is the final response. If the
|
|
634
|
+
await timed out, ``status == WORKING`` and ``content`` is empty — the
|
|
635
|
+
caller yields the task handle to its client.
|
|
636
|
+
|
|
637
|
+
Args:
|
|
638
|
+
task_id: Optional caller-supplied task id. Used by protocol
|
|
639
|
+
adapters (e.g. A2A) whose SDK generates the id themselves and
|
|
640
|
+
require it to match across request/response.
|
|
641
|
+
"""
|
|
642
|
+
if self._shutdown_started:
|
|
643
|
+
raise TaskError("engine is shutting down")
|
|
644
|
+
|
|
645
|
+
if not message or not message.strip():
|
|
646
|
+
raise InvalidTaskInputError("message must not be empty")
|
|
647
|
+
|
|
648
|
+
ctx_id = context_id or str(uuid4())
|
|
649
|
+
if not self._store.exists(ctx_id):
|
|
650
|
+
self._store.create(ctx_id)
|
|
651
|
+
logger.info(
|
|
652
|
+
"context created",
|
|
653
|
+
extra={"context_id": ctx_id, "via": via},
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
task_id = task_id or str(uuid4())
|
|
657
|
+
|
|
658
|
+
try:
|
|
659
|
+
record = self._registry.register(
|
|
660
|
+
task_id=task_id,
|
|
661
|
+
context_id=ctx_id,
|
|
662
|
+
message=message,
|
|
663
|
+
via=via,
|
|
664
|
+
owner=owner,
|
|
665
|
+
)
|
|
666
|
+
except ContextBusyError:
|
|
667
|
+
_METRICS.context_busy_rejections.add(1)
|
|
668
|
+
raise
|
|
669
|
+
|
|
670
|
+
self._store.append(ctx_id, TaskDispatched(ctx=ctx_id, task_id=task_id))
|
|
671
|
+
|
|
672
|
+
task_journal = TaskJournal(self._store.task_path(ctx_id, task_id))
|
|
673
|
+
task_journal.create()
|
|
674
|
+
task_journal.append(TaskStarted(
|
|
675
|
+
ctx=ctx_id,
|
|
676
|
+
task_id=task_id,
|
|
677
|
+
message=message,
|
|
678
|
+
via=via, # type: ignore[arg-type]
|
|
679
|
+
))
|
|
680
|
+
|
|
681
|
+
ctx_lock = await self._lock_for(ctx_id)
|
|
682
|
+
worker = TaskWorker(
|
|
683
|
+
record=record,
|
|
684
|
+
config=self._config,
|
|
685
|
+
llm=self._llm,
|
|
686
|
+
tools=self._tools,
|
|
687
|
+
store=self._store,
|
|
688
|
+
task_journal=task_journal,
|
|
689
|
+
registry=self._registry,
|
|
690
|
+
context_lock=ctx_lock,
|
|
691
|
+
memory_store=self._memory_store,
|
|
692
|
+
)
|
|
693
|
+
asyncio_task = asyncio.create_task(worker.run(), name=f"task:{task_id}")
|
|
694
|
+
self._workers[task_id] = asyncio_task
|
|
695
|
+
asyncio_task.add_done_callback(
|
|
696
|
+
lambda _t: self._on_worker_done(task_id, ctx_id)
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
_METRICS.tasks_spawned.add(1)
|
|
700
|
+
_METRICS.tasks_active.add(1)
|
|
701
|
+
logger.info(
|
|
702
|
+
"task spawned",
|
|
703
|
+
extra={"task_id": task_id, "context_id": ctx_id, "via": via},
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
try:
|
|
707
|
+
if await_seconds > 0:
|
|
708
|
+
await asyncio.wait_for(
|
|
709
|
+
record.completion_event.wait(),
|
|
710
|
+
timeout=await_seconds,
|
|
711
|
+
)
|
|
712
|
+
except asyncio.TimeoutError:
|
|
713
|
+
pass
|
|
714
|
+
|
|
715
|
+
return self._state_from_task(task_id, ctx_id, record, task_journal)
|
|
716
|
+
|
|
717
|
+
async def poll(
|
|
718
|
+
self,
|
|
719
|
+
task_id: str,
|
|
720
|
+
context_id: str | None = None,
|
|
721
|
+
wait_seconds: float = 0.0,
|
|
722
|
+
cap_seconds: float = 60.0,
|
|
723
|
+
) -> TaskState:
|
|
724
|
+
"""Poll a task for its current state.
|
|
725
|
+
|
|
726
|
+
Args:
|
|
727
|
+
task_id: Target task.
|
|
728
|
+
context_id: Optional assertion — if provided, must match the task's
|
|
729
|
+
context or a ``TaskContextMismatchError`` is raised.
|
|
730
|
+
wait_seconds: If positive, block up to this many seconds for a
|
|
731
|
+
terminal state (capped at ``cap_seconds``).
|
|
732
|
+
cap_seconds: Server-side maximum for ``wait_seconds``.
|
|
733
|
+
|
|
734
|
+
Raises:
|
|
735
|
+
TaskNotFoundError: If no sub-journal and no registry entry exist.
|
|
736
|
+
TaskContextMismatchError: If the assertion doesn't match.
|
|
737
|
+
"""
|
|
738
|
+
record = self._registry.get(task_id)
|
|
739
|
+
resolved_ctx = context_id or (record.context_id if record else None)
|
|
740
|
+
|
|
741
|
+
if resolved_ctx is None:
|
|
742
|
+
found = self._locate_task_context(task_id)
|
|
743
|
+
if found is None:
|
|
744
|
+
raise TaskNotFoundError(
|
|
745
|
+
task_id,
|
|
746
|
+
searched="registry+filesystem",
|
|
747
|
+
)
|
|
748
|
+
resolved_ctx = found
|
|
749
|
+
|
|
750
|
+
if context_id is not None and record is not None and context_id != record.context_id:
|
|
751
|
+
raise TaskContextMismatchError(task_id, context_id, record.context_id)
|
|
752
|
+
|
|
753
|
+
task_journal = TaskJournal(self._store.task_path(resolved_ctx, task_id))
|
|
754
|
+
if not task_journal.exists() and record is None:
|
|
755
|
+
raise TaskNotFoundError(
|
|
756
|
+
task_id,
|
|
757
|
+
searched=f"context={resolved_ctx}",
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
if wait_seconds > 0 and record is not None:
|
|
761
|
+
effective = min(wait_seconds, cap_seconds)
|
|
762
|
+
try:
|
|
763
|
+
await asyncio.wait_for(
|
|
764
|
+
record.completion_event.wait(),
|
|
765
|
+
timeout=effective,
|
|
766
|
+
)
|
|
767
|
+
except asyncio.TimeoutError:
|
|
768
|
+
pass
|
|
769
|
+
|
|
770
|
+
record = self._registry.get(task_id) or record
|
|
771
|
+
return self._state_from_task(task_id, resolved_ctx, record, task_journal)
|
|
772
|
+
|
|
773
|
+
async def cancel(
|
|
774
|
+
self,
|
|
775
|
+
task_id: str,
|
|
776
|
+
context_id: str | None = None,
|
|
777
|
+
) -> TaskState:
|
|
778
|
+
"""Request cooperative cancellation. Returns the state after the request.
|
|
779
|
+
|
|
780
|
+
If the task already terminal, returns its terminal state unchanged.
|
|
781
|
+
If the task is active, flips the cancel flag (the worker observes it
|
|
782
|
+
at its next checkpoint and writes the cancel markers).
|
|
783
|
+
"""
|
|
784
|
+
record = self._registry.get(task_id)
|
|
785
|
+
if record is None:
|
|
786
|
+
resolved_ctx = context_id or self._locate_task_context(task_id)
|
|
787
|
+
if resolved_ctx is None:
|
|
788
|
+
raise TaskNotFoundError(
|
|
789
|
+
task_id,
|
|
790
|
+
searched="registry+filesystem",
|
|
791
|
+
)
|
|
792
|
+
tj = TaskJournal(self._store.task_path(resolved_ctx, task_id))
|
|
793
|
+
if not tj.exists():
|
|
794
|
+
raise TaskNotFoundError(
|
|
795
|
+
task_id,
|
|
796
|
+
searched=f"context={resolved_ctx}",
|
|
797
|
+
)
|
|
798
|
+
return self._state_from_task(task_id, resolved_ctx, None, tj)
|
|
799
|
+
|
|
800
|
+
if context_id is not None and context_id != record.context_id:
|
|
801
|
+
raise TaskContextMismatchError(task_id, context_id, record.context_id)
|
|
802
|
+
|
|
803
|
+
self._registry.request_cancel(task_id)
|
|
804
|
+
tj = TaskJournal(self._store.task_path(record.context_id, task_id))
|
|
805
|
+
return self._state_from_task(task_id, record.context_id, record, tj)
|
|
806
|
+
|
|
807
|
+
# ------------------------------------------------------------------ #
|
|
808
|
+
# Internal helpers
|
|
809
|
+
# ------------------------------------------------------------------ #
|
|
810
|
+
|
|
811
|
+
def _locate_task_context(self, task_id: str) -> str | None:
|
|
812
|
+
"""Scan the data directory for a sub-journal matching ``task_id``.
|
|
813
|
+
|
|
814
|
+
Used as a fallback when a poll arrives without a ``context_id`` and
|
|
815
|
+
the task isn't in the registry (already terminal and unregistered).
|
|
816
|
+
Path layout is ``data/{ctx_id}/tasks/{task_id}.jsonl``.
|
|
817
|
+
"""
|
|
818
|
+
matches = list(self._store.data_dir.glob(f"*/tasks/{task_id}.jsonl"))
|
|
819
|
+
if not matches:
|
|
820
|
+
return None
|
|
821
|
+
return matches[0].parent.parent.name
|
|
822
|
+
|
|
823
|
+
def _state_from_task(
|
|
824
|
+
self,
|
|
825
|
+
task_id: str,
|
|
826
|
+
context_id: str,
|
|
827
|
+
record: TaskRecord | None,
|
|
828
|
+
journal: TaskJournal,
|
|
829
|
+
) -> TaskState:
|
|
830
|
+
"""Build a ``TaskState`` from the registry + sub-journal tail."""
|
|
831
|
+
terminal = journal.terminal_entry()
|
|
832
|
+
if terminal is not None:
|
|
833
|
+
t = terminal["t"]
|
|
834
|
+
if t == "task_done":
|
|
835
|
+
return TaskState(
|
|
836
|
+
task_id=task_id,
|
|
837
|
+
context_id=context_id,
|
|
838
|
+
status=TaskStatus.COMPLETED,
|
|
839
|
+
content=terminal.get("final_response", []),
|
|
840
|
+
)
|
|
841
|
+
if t == "task_fail":
|
|
842
|
+
return TaskState(
|
|
843
|
+
task_id=task_id,
|
|
844
|
+
context_id=context_id,
|
|
845
|
+
status=TaskStatus.FAILED,
|
|
846
|
+
error=terminal.get("error_details") or terminal.get("reason"),
|
|
847
|
+
)
|
|
848
|
+
if t == "task_cancel":
|
|
849
|
+
return TaskState(
|
|
850
|
+
task_id=task_id,
|
|
851
|
+
context_id=context_id,
|
|
852
|
+
status=TaskStatus.CANCELLED,
|
|
853
|
+
error=terminal.get("reason"),
|
|
854
|
+
)
|
|
855
|
+
# No terminal yet — must still be live.
|
|
856
|
+
status = (
|
|
857
|
+
record.status if record is not None else TaskStatus.WORKING
|
|
858
|
+
)
|
|
859
|
+
return TaskState(
|
|
860
|
+
task_id=task_id,
|
|
861
|
+
context_id=context_id,
|
|
862
|
+
status=status,
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
# ------------------------------------------------------------------ #
|
|
866
|
+
# Graceful shutdown
|
|
867
|
+
# ------------------------------------------------------------------ #
|
|
868
|
+
|
|
869
|
+
async def shutdown(self, grace_seconds: float = _SHUTDOWN_GRACE_SECONDS) -> None:
|
|
870
|
+
"""Stop accepting new spawns and drain in-flight workers.
|
|
871
|
+
|
|
872
|
+
1. Mark the engine as shutting down (future ``spawn`` calls raise).
|
|
873
|
+
2. Flip every live task's cancel flag so workers stop at their next
|
|
874
|
+
cooperative checkpoint.
|
|
875
|
+
3. Wait up to ``grace_seconds`` for all workers to finish naturally.
|
|
876
|
+
4. Hard-cancel any that remain, then await their ``CancelledError``
|
|
877
|
+
teardown so the asyncio event loop doesn't emit ``Task was
|
|
878
|
+
destroyed but it is pending`` warnings at process exit.
|
|
879
|
+
|
|
880
|
+
Idempotent — subsequent calls are no-ops.
|
|
881
|
+
"""
|
|
882
|
+
if self._shutdown_started:
|
|
883
|
+
return
|
|
884
|
+
self._shutdown_started = True
|
|
885
|
+
logger.info(
|
|
886
|
+
"task engine shutdown initiated",
|
|
887
|
+
extra={"active_tasks": len(self._workers)},
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
for task_id in list(self._workers):
|
|
891
|
+
self._registry.request_cancel(task_id)
|
|
892
|
+
|
|
893
|
+
pending = list(self._workers.values())
|
|
894
|
+
if pending:
|
|
895
|
+
done, still_pending = await asyncio.wait(
|
|
896
|
+
pending,
|
|
897
|
+
timeout=grace_seconds,
|
|
898
|
+
return_when=asyncio.ALL_COMPLETED,
|
|
899
|
+
)
|
|
900
|
+
for worker in still_pending:
|
|
901
|
+
worker.cancel()
|
|
902
|
+
if still_pending:
|
|
903
|
+
# Drain cancellations so teardown is clean even if workers
|
|
904
|
+
# were blocked on I/O when we pulled the plug.
|
|
905
|
+
await asyncio.gather(*still_pending, return_exceptions=True)
|
|
906
|
+
logger.info(
|
|
907
|
+
"task engine shutdown complete",
|
|
908
|
+
extra={
|
|
909
|
+
"workers_drained": len(done),
|
|
910
|
+
"workers_hard_cancelled": len(still_pending),
|
|
911
|
+
},
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
# ------------------------------------------------------------------ #
|
|
915
|
+
# Crash recovery
|
|
916
|
+
# ------------------------------------------------------------------ #
|
|
917
|
+
|
|
918
|
+
def recover_on_startup(self) -> None:
|
|
919
|
+
"""Repair orphaned sub-journals and incomplete merge-backs.
|
|
920
|
+
|
|
921
|
+
Three crash windows are handled:
|
|
922
|
+
|
|
923
|
+
1. Sub-journal has ``TaskStarted`` but no terminal marker — the
|
|
924
|
+
worker never reached a terminal. Append
|
|
925
|
+
``TaskFailed { reason: "process_crash_recovery" }``.
|
|
926
|
+
|
|
927
|
+
2. Sub-journal has ``TaskCompleted`` but the parent never received
|
|
928
|
+
the corresponding merge-back (crash between ``TaskCompleted``
|
|
929
|
+
write and ``MergeStarted`` write, or after ``MergeStarted`` but
|
|
930
|
+
before ``MergeCommitted``). Idempotently apply whatever merge
|
|
931
|
+
entries are missing and close with ``MergeCommitted``.
|
|
932
|
+
|
|
933
|
+
3. Parent has ``MergeStarted`` with no matching ``MergeCommitted``
|
|
934
|
+
and the sub-journal is missing or not ``TaskCompleted`` (highly
|
|
935
|
+
unusual — sub-journal might have been purged). Close the wrapper
|
|
936
|
+
with ``MergeCommitted`` alone, no conversational content.
|
|
937
|
+
"""
|
|
938
|
+
self._recover_orphaned_subjournals()
|
|
939
|
+
self._recover_incomplete_merges()
|
|
940
|
+
|
|
941
|
+
def _recover_orphaned_subjournals(self) -> None:
|
|
942
|
+
for sub_path in self._store.data_dir.glob("*/tasks/*.jsonl"):
|
|
943
|
+
ctx_id = sub_path.parent.parent.name
|
|
944
|
+
task_id = sub_path.stem
|
|
945
|
+
tj = TaskJournal(sub_path)
|
|
946
|
+
entries = tj.read_entries()
|
|
947
|
+
if not entries:
|
|
948
|
+
continue
|
|
949
|
+
if any(e.get("t") == "task_start" for e in entries) and tj.terminal_entry() is None:
|
|
950
|
+
marker = TaskFailed(
|
|
951
|
+
ctx=ctx_id,
|
|
952
|
+
task_id=task_id,
|
|
953
|
+
reason="process_crash_recovery",
|
|
954
|
+
)
|
|
955
|
+
tj.append(marker)
|
|
956
|
+
try:
|
|
957
|
+
self._store.append(ctx_id, marker)
|
|
958
|
+
except Exception: # pragma: no cover — parent may be missing
|
|
959
|
+
_METRICS.crash_recovery_failed.add(1)
|
|
960
|
+
logger.exception(
|
|
961
|
+
"recovery: could not mirror marker to parent",
|
|
962
|
+
extra={"context_id": ctx_id, "task_id": task_id},
|
|
963
|
+
)
|
|
964
|
+
continue
|
|
965
|
+
_METRICS.crash_recovery_repaired.add(1)
|
|
966
|
+
logger.info(
|
|
967
|
+
"recovery: closed orphaned task",
|
|
968
|
+
extra={"context_id": ctx_id, "task_id": task_id},
|
|
969
|
+
)
|
|
970
|
+
|
|
971
|
+
def _recover_incomplete_merges(self) -> None:
|
|
972
|
+
"""Walk every context and close out merges that never committed."""
|
|
973
|
+
for ctx_id in self._store.iter_context_ids():
|
|
974
|
+
try:
|
|
975
|
+
parent_entries = self._store.read_entries(ctx_id)
|
|
976
|
+
except Exception: # pragma: no cover
|
|
977
|
+
_METRICS.crash_recovery_failed.add(1)
|
|
978
|
+
logger.exception(
|
|
979
|
+
"recovery: failed to read parent journal",
|
|
980
|
+
extra={"context_id": ctx_id},
|
|
981
|
+
)
|
|
982
|
+
continue
|
|
983
|
+
|
|
984
|
+
merge_states: dict[str, str] = {}
|
|
985
|
+
for entry in parent_entries:
|
|
986
|
+
t = entry.get("t")
|
|
987
|
+
if t == "merge_start":
|
|
988
|
+
merge_states[entry["task_id"]] = "started"
|
|
989
|
+
elif t == "merge_commit":
|
|
990
|
+
merge_states[entry["task_id"]] = "committed"
|
|
991
|
+
|
|
992
|
+
repaired_in_ctx: set[str] = set()
|
|
993
|
+
tasks_dir = self._store.context_dir(ctx_id) / "tasks"
|
|
994
|
+
if tasks_dir.exists():
|
|
995
|
+
for sub_path in tasks_dir.glob("*.jsonl"):
|
|
996
|
+
task_id = sub_path.stem
|
|
997
|
+
if merge_states.get(task_id) == "committed":
|
|
998
|
+
continue
|
|
999
|
+
tj = TaskJournal(sub_path)
|
|
1000
|
+
terminal = tj.terminal_entry()
|
|
1001
|
+
if terminal is None or terminal["t"] != "task_done":
|
|
1002
|
+
continue
|
|
1003
|
+
self._repair_merge(ctx_id, task_id)
|
|
1004
|
+
repaired_in_ctx.add(task_id)
|
|
1005
|
+
|
|
1006
|
+
for task_id, state in merge_states.items():
|
|
1007
|
+
if state != "started":
|
|
1008
|
+
continue
|
|
1009
|
+
if task_id in repaired_in_ctx:
|
|
1010
|
+
continue
|
|
1011
|
+
sub_path = self._store.task_path(ctx_id, task_id)
|
|
1012
|
+
if sub_path.exists():
|
|
1013
|
+
# The sub-journal exists but its terminal wasn't task_done
|
|
1014
|
+
# (task_fail or task_cancel). Close the wrapper so this
|
|
1015
|
+
# entry doesn't resurface on every subsequent startup;
|
|
1016
|
+
# no conversational content leaks since _repair_merge
|
|
1017
|
+
# only appends content on task_done.
|
|
1018
|
+
self._repair_merge(ctx_id, task_id)
|
|
1019
|
+
continue
|
|
1020
|
+
# Sub-journal missing entirely — close wrapper only.
|
|
1021
|
+
self._store.append(ctx_id, MergeCommitted(ctx=ctx_id, task_id=task_id))
|
|
1022
|
+
_METRICS.crash_recovery_repaired.add(1)
|
|
1023
|
+
logger.info(
|
|
1024
|
+
"recovery: closed orphaned merge_start with no sub-journal",
|
|
1025
|
+
extra={"context_id": ctx_id, "task_id": task_id},
|
|
1026
|
+
)
|
|
1027
|
+
|
|
1028
|
+
def _repair_merge(self, ctx_id: str, task_id: str) -> None:
|
|
1029
|
+
"""Complete a partial merge-back idempotently.
|
|
1030
|
+
|
|
1031
|
+
If the sub-journal has a ``task_done`` marker, re-apply any missing
|
|
1032
|
+
message/compaction entries then append ``MergeCommitted``. Otherwise
|
|
1033
|
+
just close the wrapper.
|
|
1034
|
+
"""
|
|
1035
|
+
tj = TaskJournal(self._store.task_path(ctx_id, task_id))
|
|
1036
|
+
terminal = tj.terminal_entry() if tj.exists() else None
|
|
1037
|
+
|
|
1038
|
+
parent_entries = self._store.read_entries(ctx_id)
|
|
1039
|
+
already_msgs = {
|
|
1040
|
+
(e.get("role"), e.get("task_id"))
|
|
1041
|
+
for e in parent_entries
|
|
1042
|
+
if e.get("t") == "msg"
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
repair: list[Any] = []
|
|
1046
|
+
if terminal is not None and terminal["t"] == "task_done":
|
|
1047
|
+
sub_entries = tj.read_entries()
|
|
1048
|
+
start = next(
|
|
1049
|
+
(e for e in sub_entries if e.get("t") == "task_start"),
|
|
1050
|
+
None,
|
|
1051
|
+
)
|
|
1052
|
+
user_needed = ("user", task_id) not in already_msgs
|
|
1053
|
+
assistant_needed = ("assistant", task_id) not in already_msgs
|
|
1054
|
+
|
|
1055
|
+
if start is not None and user_needed:
|
|
1056
|
+
repair.append(MessageEntry(
|
|
1057
|
+
ctx=ctx_id,
|
|
1058
|
+
role="user",
|
|
1059
|
+
content=[{"type": "text", "text": start["message"]}],
|
|
1060
|
+
via=start.get("via", "a2a"),
|
|
1061
|
+
task_id=task_id,
|
|
1062
|
+
))
|
|
1063
|
+
# Compaction is only re-ported when the assistant message is ALSO
|
|
1064
|
+
# being re-ported. Appending a duplicate compaction after an
|
|
1065
|
+
# already-written assistant reply would move the replay cursor
|
|
1066
|
+
# forward of that reply and shadow it on future turns.
|
|
1067
|
+
if assistant_needed:
|
|
1068
|
+
latest_comp = tj.latest_compaction()
|
|
1069
|
+
if latest_comp is not None:
|
|
1070
|
+
repair.append(CompactionEntry(
|
|
1071
|
+
ctx=ctx_id,
|
|
1072
|
+
content=latest_comp.get("content", ""),
|
|
1073
|
+
))
|
|
1074
|
+
repair.append(MessageEntry(
|
|
1075
|
+
ctx=ctx_id,
|
|
1076
|
+
role="assistant",
|
|
1077
|
+
content=terminal.get("final_response", []),
|
|
1078
|
+
via=start.get("via", "a2a") if start else "a2a",
|
|
1079
|
+
task_id=task_id,
|
|
1080
|
+
))
|
|
1081
|
+
repair.append(MergeCommitted(ctx=ctx_id, task_id=task_id))
|
|
1082
|
+
self._store.append_many(ctx_id, repair)
|
|
1083
|
+
_METRICS.crash_recovery_repaired.add(1)
|
|
1084
|
+
logger.info(
|
|
1085
|
+
"recovery: closed merge-back",
|
|
1086
|
+
extra={"context_id": ctx_id, "task_id": task_id},
|
|
1087
|
+
)
|