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.
Files changed (43) hide show
  1. agentlings/__init__.py +3 -0
  2. agentlings/__main__.py +238 -0
  3. agentlings/cli/__init__.py +1 -0
  4. agentlings/cli/_migrations.py +78 -0
  5. agentlings/cli/_templates.py +57 -0
  6. agentlings/cli/_version.py +33 -0
  7. agentlings/cli/init.py +122 -0
  8. agentlings/cli/upgrade.py +89 -0
  9. agentlings/config.py +260 -0
  10. agentlings/core/__init__.py +1 -0
  11. agentlings/core/completion.py +219 -0
  12. agentlings/core/llm.py +509 -0
  13. agentlings/core/loop.py +134 -0
  14. agentlings/core/memory_models.py +97 -0
  15. agentlings/core/memory_store.py +109 -0
  16. agentlings/core/models.py +231 -0
  17. agentlings/core/prompt.py +122 -0
  18. agentlings/core/scheduler.py +141 -0
  19. agentlings/core/sleep.py +393 -0
  20. agentlings/core/store.py +318 -0
  21. agentlings/core/task.py +1087 -0
  22. agentlings/core/telemetry.py +181 -0
  23. agentlings/log.py +23 -0
  24. agentlings/migrations/__init__.py +37 -0
  25. agentlings/migrations/m0001_seed.py +17 -0
  26. agentlings/protocol/__init__.py +1 -0
  27. agentlings/protocol/a2a.py +220 -0
  28. agentlings/protocol/a2a_task_store.py +150 -0
  29. agentlings/protocol/agent_card.py +83 -0
  30. agentlings/protocol/mcp.py +232 -0
  31. agentlings/server.py +247 -0
  32. agentlings/templates/__init__.py +1 -0
  33. agentlings/templates/default/.env.example +16 -0
  34. agentlings/templates/default/agent.yaml +14 -0
  35. agentlings/tools/__init__.py +1 -0
  36. agentlings/tools/builtins.py +307 -0
  37. agentlings/tools/memory.py +104 -0
  38. agentlings/tools/registry.py +154 -0
  39. agentlings-0.2.0.dist-info/METADATA +406 -0
  40. agentlings-0.2.0.dist-info/RECORD +43 -0
  41. agentlings-0.2.0.dist-info/WHEEL +4 -0
  42. agentlings-0.2.0.dist-info/entry_points.txt +2 -0
  43. agentlings-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -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
+ )