pi-durable-python 1.3.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 (90) hide show
  1. pi_durable/__init__.py +320 -0
  2. pi_durable/context.py +328 -0
  3. pi_durable/delta.py +269 -0
  4. pi_durable/documents.py +366 -0
  5. pi_durable/entries.py +66 -0
  6. pi_durable/env/__init__.py +408 -0
  7. pi_durable/env/decode.py +48 -0
  8. pi_durable/env/line_scan.py +190 -0
  9. pi_durable/env/posix.py +1547 -0
  10. pi_durable/errors.py +89 -0
  11. pi_durable/harness/__init__.py +1 -0
  12. pi_durable/harness/agent.py +344 -0
  13. pi_durable/harness/compaction.py +673 -0
  14. pi_durable/harness/context.py +515 -0
  15. pi_durable/harness/define.py +111 -0
  16. pi_durable/harness/events.py +766 -0
  17. pi_durable/harness/generation.py +1079 -0
  18. pi_durable/harness/harness.py +570 -0
  19. pi_durable/harness/inbox.py +255 -0
  20. pi_durable/harness/live.py +374 -0
  21. pi_durable/harness/output.py +467 -0
  22. pi_durable/harness/prompt.py +288 -0
  23. pi_durable/harness/provider.py +78 -0
  24. pi_durable/harness/registry.py +136 -0
  25. pi_durable/harness/scheduler.py +1977 -0
  26. pi_durable/harness/submissions.py +357 -0
  27. pi_durable/harness/task_graph.py +334 -0
  28. pi_durable/harness/tool.py +1365 -0
  29. pi_durable/harness/types.py +772 -0
  30. pi_durable/harness/usage.py +88 -0
  31. pi_durable/harness/util.py +204 -0
  32. pi_durable/harness/validation.py +460 -0
  33. pi_durable/harness/view.py +324 -0
  34. pi_durable/ids.py +63 -0
  35. pi_durable/json.py +186 -0
  36. pi_durable/providers/__init__.py +362 -0
  37. pi_durable/providers/anthropic.py +603 -0
  38. pi_durable/providers/base.py +97 -0
  39. pi_durable/providers/json_parse.py +117 -0
  40. pi_durable/providers/openai.py +574 -0
  41. pi_durable/providers/request.py +35 -0
  42. pi_durable/providers/retry.py +127 -0
  43. pi_durable/providers/stream.py +183 -0
  44. pi_durable/providers/transcript.py +224 -0
  45. pi_durable/providers/usage.py +58 -0
  46. pi_durable/py.typed +0 -0
  47. pi_durable/search.py +115 -0
  48. pi_durable/session/__init__.py +5 -0
  49. pi_durable/session/drafts.py +762 -0
  50. pi_durable/session/forks.py +109 -0
  51. pi_durable/session/observation.py +374 -0
  52. pi_durable/session/session.py +1193 -0
  53. pi_durable/session/transaction.py +1088 -0
  54. pi_durable/storage/__init__.py +5 -0
  55. pi_durable/storage/jsonl/__init__.py +15 -0
  56. pi_durable/storage/jsonl/storage.py +958 -0
  57. pi_durable/storage/memory.py +889 -0
  58. pi_durable/storage/scan.py +57 -0
  59. pi_durable/storage/sqlite/__init__.py +26 -0
  60. pi_durable/storage/sqlite/database.py +62 -0
  61. pi_durable/storage/sqlite/migrations.py +191 -0
  62. pi_durable/storage/sqlite/posix.py +314 -0
  63. pi_durable/storage/sqlite/schema.py +97 -0
  64. pi_durable/storage/sqlite/storage.py +1130 -0
  65. pi_durable/tasks.py +89 -0
  66. pi_durable/testing/__init__.py +41 -0
  67. pi_durable/testing/assertions.py +44 -0
  68. pi_durable/testing/env_conformance.py +828 -0
  69. pi_durable/testing/runner.py +64 -0
  70. pi_durable/testing/storage_conformance.py +2008 -0
  71. pi_durable/testing/types.py +99 -0
  72. pi_durable/tool_search/__init__.py +159 -0
  73. pi_durable/tools/__init__.py +81 -0
  74. pi_durable/tools/_values.py +43 -0
  75. pi_durable/tools/bash.py +250 -0
  76. pi_durable/tools/edit.py +214 -0
  77. pi_durable/tools/edit_diff.py +452 -0
  78. pi_durable/tools/env.py +19 -0
  79. pi_durable/tools/file_mutation_queue.py +68 -0
  80. pi_durable/tools/image.py +248 -0
  81. pi_durable/tools/image_processor.py +77 -0
  82. pi_durable/tools/path_utils.py +111 -0
  83. pi_durable/tools/read.py +506 -0
  84. pi_durable/tools/write.py +71 -0
  85. pi_durable/truncate.py +185 -0
  86. pi_durable/types.py +1297 -0
  87. pi_durable_python-1.3.0.dist-info/METADATA +276 -0
  88. pi_durable_python-1.3.0.dist-info/RECORD +90 -0
  89. pi_durable_python-1.3.0.dist-info/WHEEL +4 -0
  90. pi_durable_python-1.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1977 @@
1
+ """Durable task scheduler: reservation, phases, abort, ownership, and finalization.
2
+
3
+ Port of v1.3.0 ``src/harness/scheduler.ts``. Every task transition is decided and written by one
4
+ callback serialized on the Session line: reservation, abort marks, runtime commits, finalization,
5
+ and the synchronous step before each phase. Handlers and joins run off the line.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import inspect
12
+ from collections.abc import Awaitable, Callable, Coroutine, Iterator, Mapping, Sequence
13
+ from typing import Any, Literal, cast
14
+
15
+ from ..context import (
16
+ AbortController,
17
+ AbortSignal,
18
+ Context,
19
+ await_with_context,
20
+ with_abort_signal,
21
+ )
22
+ from ..errors import error_message, is_failure
23
+ from ..ids import ConversationId, TaskId
24
+ from ..json import JsonValue, copy_json
25
+ from ..session.session import SessionImpl
26
+ from ..session.transaction import Transaction
27
+ from ..types import (
28
+ CommitPublication,
29
+ NextTaskState,
30
+ RunningTask,
31
+ Storage,
32
+ TaskOutcome,
33
+ TaskRecord,
34
+ )
35
+ from .agent import agent_hooks
36
+ from .context import read_context_from
37
+ from .types import (
38
+ Agent,
39
+ AnyTask,
40
+ ConversationHandle,
41
+ RegistryReader,
42
+ RegistrySnapshot,
43
+ Settings,
44
+ SettledTask,
45
+ TaskInspection,
46
+ )
47
+ from .util import Waiters, closed_error, rejected, resolved, scan_all
48
+
49
+ _SCAN_PAGE_SIZE = 256
50
+ MAX_TIMER_DELAY_MS = 2_147_483_647
51
+ LIVE_STATUSES = ("pending", "running", "waiting", "completing")
52
+
53
+ BlockedReason = Literal["missing_task", "task_too_old", "migration_failed"]
54
+
55
+ AnyTaskRecord = TaskRecord
56
+ RunnableTaskRecord = TaskRecord
57
+ Checkpoint = dict[str, Any]
58
+ ErasedDefinition = Any
59
+ ErasedRuntime = Any
60
+ ErasedRunningTask = RunningTask
61
+
62
+ Node = int | str
63
+ Up = dict[str, Any]
64
+ Scope = dict[str, Any]
65
+
66
+
67
+ def conversation_node(conversation_id: ConversationId) -> str:
68
+ return f"c{conversation_id}"
69
+
70
+
71
+ def conversation_of(node: str) -> ConversationId:
72
+ return cast(ConversationId, int(node[1:]))
73
+
74
+
75
+ def parent_node(node: Mapping[str, Any]) -> Node:
76
+ owner = node.get("owner")
77
+ return owner if owner is not None else conversation_node(node["conversationId"])
78
+
79
+
80
+ def node_of(record: Mapping[str, Any]) -> dict[str, Any]:
81
+ node = {"conversationId": record["conversationId"], "background": record["background"]}
82
+ if record.get("owner") is not None:
83
+ node["owner"] = record["owner"]
84
+ return node
85
+
86
+
87
+ def parent_of(node: Mapping[str, Any]) -> Up:
88
+ owner = node.get("owner")
89
+ return {"task": owner} if owner is not None else {"conversation": node["conversationId"]}
90
+
91
+
92
+ def with_abort_mark(record: Mapping[str, Any], reason: str | None = None) -> AnyTaskRecord:
93
+ result = dict(record)
94
+ result.pop("abortReason", None)
95
+ result["abortRequested"] = True
96
+ if reason is not None:
97
+ result["abortReason"] = reason
98
+ return cast(AnyTaskRecord, result)
99
+
100
+
101
+ def failed_outcome(record: Mapping[str, Any]) -> bool:
102
+ state = record["state"]
103
+ return state["status"] in ("completing", "terminal") and state["outcome"]["status"] != "completed"
104
+
105
+
106
+ def cancellation_intent(record: Mapping[str, Any]) -> bool:
107
+ return record["state"]["status"] != "terminal" and (
108
+ record.get("abortRequested") is True or failed_outcome(record)
109
+ )
110
+
111
+
112
+ def with_state(record: Mapping[str, Any], state: Mapping[str, Any]) -> AnyTaskRecord:
113
+ result = dict(record)
114
+ if state["status"] in ("terminal", "completing"):
115
+ result.pop("memos", None)
116
+ result["state"] = copy_json(state)
117
+ return cast(AnyTaskRecord, result)
118
+
119
+
120
+ def can_reserve(task: AnyTask, record: Mapping[str, Any]) -> bool:
121
+ definition = task.definition
122
+ return definition.version == record["version"] or (
123
+ definition.version > record["version"] and definition.migrate is not None
124
+ )
125
+
126
+
127
+ _MISSING_MEMO = object()
128
+
129
+
130
+ def memo_of(record: Mapping[str, Any] | None, name: str) -> Any:
131
+ memos = (record or {}).get("memos")
132
+ if not isinstance(memos, Mapping) or name not in memos:
133
+ return _MISSING_MEMO
134
+ return memos[name]
135
+
136
+
137
+ def json_equal(left: Any, right: Any) -> bool:
138
+ if left is right:
139
+ return True
140
+ if isinstance(left, bool) or isinstance(right, bool):
141
+ return type(left) is type(right) and left is right
142
+ if isinstance(left, (int, float)) and isinstance(right, (int, float)):
143
+ return left == right
144
+ if isinstance(left, list) or isinstance(right, list):
145
+ return (
146
+ isinstance(left, list)
147
+ and isinstance(right, list)
148
+ and len(left) == len(right)
149
+ and all(json_equal(a, b) for a, b in zip(left, right, strict=True))
150
+ )
151
+ if not isinstance(left, Mapping) or not isinstance(right, Mapping):
152
+ return type(left) is type(right) and left == right
153
+ return left.keys() == right.keys() and all(json_equal(left[key], right[key]) for key in left)
154
+
155
+
156
+ def _add_to(mapping: dict[Any, set[Any]], key: Any, value: Any) -> None:
157
+ mapping.setdefault(key, set()).add(value)
158
+
159
+
160
+ def _delete_from(mapping: dict[Any, set[Any]], key: Any, value: Any) -> None:
161
+ values = mapping.get(key)
162
+ if values is None:
163
+ return
164
+ values.discard(value)
165
+ if not values:
166
+ del mapping[key]
167
+
168
+
169
+ class _Invocation:
170
+ """One in-memory execution of a task in run or abort mode."""
171
+
172
+ __slots__ = (
173
+ "task_id",
174
+ "conversation_id",
175
+ "mode",
176
+ "controller",
177
+ "context",
178
+ "watches",
179
+ "commits",
180
+ "ended",
181
+ "done",
182
+ "finish",
183
+ )
184
+
185
+ def __init__(self, record: Mapping[str, Any], mode: Literal["run", "abort"], context: Context) -> None:
186
+ self.task_id = cast(TaskId, record["id"])
187
+ self.conversation_id = cast(ConversationId, record["conversationId"])
188
+ self.mode = mode
189
+ self.controller = AbortController()
190
+ self.context = with_abort_signal(self.controller.signal, context)
191
+ self.watches: set[Any] = set()
192
+ self.commits: set[asyncio.Future[Any]] = set()
193
+ self.ended = False
194
+ self.done: asyncio.Future[None] = asyncio.get_running_loop().create_future()
195
+ self.done.add_done_callback(lambda future: future.exception() if not future.cancelled() else None)
196
+ self.finish = self._finish
197
+
198
+ def _finish(self) -> None:
199
+ if not self.done.done():
200
+ self.done.set_result(None)
201
+
202
+
203
+ def _ended_error(invocation: _Invocation) -> Exception:
204
+ return RuntimeError(f"Task {invocation.task_id} invocation has ended")
205
+
206
+
207
+ def _missing_migration(record: Mapping[str, Any], definition: Any) -> Exception:
208
+ return RuntimeError(
209
+ f"Task {record['kind']} version {definition.version} has no migration from {record['version']}"
210
+ )
211
+
212
+
213
+ class _OldDefinitionError(RuntimeError):
214
+ """A task that keeps running under its old definition; ``cause`` names why it could not hand over."""
215
+
216
+ def __init__(self, task_id: TaskId, kind: str, cause: str) -> None:
217
+ super().__init__(f"Task {task_id} keeps running under its old {kind} definition")
218
+ self.cause = cause
219
+
220
+
221
+ def _overlay_of(tx: Transaction) -> dict[str, Any]:
222
+ tasks = {record["id"]: record for record in tx.staged_tasks()}
223
+ edges = {
224
+ record["id"]: (record["owner"]["taskId"] if "owner" in record else None)
225
+ for record in tx.staged_conversations()
226
+ }
227
+ return {"tasks": tasks, "edges": edges}
228
+
229
+
230
+ def _delay(ms: float, signal: AbortSignal) -> Awaitable[None]:
231
+ loop = asyncio.get_running_loop()
232
+ future: asyncio.Future[None] = loop.create_future()
233
+
234
+ def cancel() -> None:
235
+ if not future.done():
236
+ future.set_exception(_cancellation_error(signal))
237
+
238
+ def done() -> None:
239
+ if not future.done():
240
+ future.set_result(None)
241
+
242
+ handle = loop.call_later(max(0.0, ms) / 1000.0, done)
243
+ remove = signal.add_listener(cancel)
244
+ if signal.aborted:
245
+ cancel()
246
+
247
+ def cleanup(_: Any) -> None:
248
+ handle.cancel()
249
+ remove()
250
+
251
+ future.add_done_callback(cleanup)
252
+ future.add_done_callback(lambda f: f.exception() if not f.cancelled() else None)
253
+ return future
254
+
255
+
256
+ def _cancellation_error(signal: AbortSignal) -> BaseException:
257
+ from .util import cancellation_error
258
+
259
+ return cancellation_error(signal)
260
+
261
+
262
+ class TaskScheduler:
263
+ """Durable task scheduler of one Harness."""
264
+
265
+ def __init__(
266
+ self,
267
+ *,
268
+ session: SessionImpl,
269
+ storage: Storage,
270
+ registry: RegistryReader,
271
+ models: Any,
272
+ agent: Callable[[ConversationId, RegistrySnapshot, Context], Awaitable[Agent]],
273
+ settings: Callable[[], Settings],
274
+ env: Callable[[ConversationId, Context], Awaitable[Any]],
275
+ now: Callable[[], int],
276
+ report: Callable[[BaseException], None],
277
+ settle_outcome: Callable[[Transaction, AnyTaskRecord, Mapping[str, Any]], Awaitable[None]],
278
+ withdraw_inputs: Callable[[Transaction, ConversationId], Awaitable[None]],
279
+ conversation: Callable[[ConversationId, Any, Context], Awaitable[ConversationHandle | None]],
280
+ context: Context,
281
+ ) -> None:
282
+ self._session = session
283
+ self._storage = storage
284
+ self._registry = registry
285
+ self._models = models
286
+ self._agent = agent
287
+ self._settings = settings
288
+ self._env = env
289
+ self._now = now
290
+ self._report = report
291
+ self._settle_outcome = settle_outcome
292
+ self._withdraw_inputs = withdraw_inputs
293
+ self._conversation = conversation
294
+ self._context = context
295
+ self._live: dict[TaskId, AnyTaskRecord] = {}
296
+ self._contexts: dict[ConversationId, dict[str, Any]] = {}
297
+ self._expiry: dict[str, Any] | None = None
298
+ self._invocations: dict[TaskId, _Invocation] = {}
299
+ self._task_waiters: Waiters[TaskId, SettledTask] = Waiters()
300
+ self._idle_waiters: Waiters[ConversationId | None, None] = Waiters()
301
+ self._failed_migrations: dict[TaskId, dict[str, Any]] = {}
302
+ self._edges: dict[ConversationId, TaskId | None] = {}
303
+ self._settled: dict[TaskId, dict[str, Any]] = {}
304
+ self._drop_queue: set[Node] = set()
305
+ self._below: dict[Node, set[Node]] = {}
306
+ self._roots: set[ConversationId] = set()
307
+ self._unloaded: set[TaskId] = set()
308
+ self._intent: set[TaskId] = set()
309
+ self._runnable: set[TaskId] = set()
310
+ self._finalize_checks: set[TaskId] = set()
311
+ self._waiters: dict[TaskId, set[TaskId]] = {}
312
+ self._fail_fast_checks: set[TaskId] = set()
313
+ self._abandoned: set[TaskId] = set()
314
+ self._reconcile_scheduled = False
315
+ self._sweep_scheduled = False
316
+ self._cascade_pending = False
317
+ self._unsubscribe_registry: Callable[[], None] = lambda: None
318
+ self._enabled = False
319
+ self._closing = False
320
+ self._dirty = False
321
+ self._draining = False
322
+ self._expiry_handle: asyncio.TimerHandle | None = None
323
+ self._detached: set[asyncio.Task[Any]] = set()
324
+
325
+ # ── Lifecycle ──────────────────────────────────────────────────────────
326
+
327
+ async def open(self, context: Context) -> None:
328
+ """Load live tasks and change surviving ``running`` tasks back to ``pending``."""
329
+
330
+ self._session.observe_commits(self._observe)
331
+ self._session.subscribe_close(self._seal)
332
+ self._unsubscribe_registry = self._registry.subscribe(self._kick)
333
+
334
+ async def load(tx: Transaction) -> None:
335
+ scans: list[list[AnyTaskRecord]] = []
336
+ for status in LIVE_STATUSES:
337
+ scans.append(
338
+ await scan_all(
339
+ lambda cursor, status=status: tx.scan_tasks(
340
+ {"status": status}, _SCAN_PAGE_SIZE, cursor
341
+ )
342
+ )
343
+ )
344
+ for records in scans:
345
+ for record in records:
346
+ self._track(None, record)
347
+ if record.get("abandonOnRestart") is True:
348
+ self._abandoned.add(record["id"])
349
+ if record["state"]["status"] == "running":
350
+ tx.set_task(
351
+ with_state(
352
+ record,
353
+ {
354
+ "status": "pending",
355
+ "checkpoint": record["state"]["checkpoint"],
356
+ },
357
+ )
358
+ )
359
+ if record["state"]["status"] == "waiting" and record["state"]["policy"] == "failFast":
360
+ self._fail_fast_checks.add(record["id"])
361
+
362
+ await self._session.commit_with(load, context)
363
+ self._cascade_pending = True
364
+ self._schedule_reconcile()
365
+
366
+ def resume(self) -> None:
367
+ """Enable scheduling. Idempotent; the kick does nothing once closing."""
368
+
369
+ self._enabled = True
370
+ self._kick()
371
+
372
+ async def join(self) -> None:
373
+ """Signal every invocation and wait for them; writes nothing."""
374
+
375
+ for invocation in list(self._invocations.values()):
376
+ invocation.controller.abort()
377
+ await asyncio.gather(
378
+ *[invocation.done for invocation in list(self._invocations.values())],
379
+ return_exceptions=True,
380
+ )
381
+
382
+ async def abort(
383
+ self, id: TaskId, context: Context, keep_restart: bool = False
384
+ ) -> Literal["marked", "terminal"]:
385
+ """Commit the abort mark, or settle a task no definition can take as ``orphaned``."""
386
+
387
+ async def mark(tx: Transaction) -> dict[str, Any]:
388
+ current = await tx.task(id)
389
+ if current is None:
390
+ raise ValueError(f"Task {id} does not exist")
391
+ if current["state"]["status"] == "terminal":
392
+ return {"result": "terminal"}
393
+ invocation = self._invocations.get(id)
394
+ if keep_restart and current.get("abortReason") == "restart":
395
+ return {"result": "marked"}
396
+ if invocation is None and current["state"]["status"] != "completing":
397
+ await self._load_scopes(False)
398
+ if not self._has_owned_live(id):
399
+ resolution = self._resolve(cast(RunnableTaskRecord, current), self._registry.snapshot())
400
+ if resolution["kind"] == "blocked":
401
+ await self._terminate(
402
+ tx,
403
+ current,
404
+ {"status": "orphaned", "reason": resolution["reason"]},
405
+ )
406
+ return {"result": "marked"}
407
+ if not current.get("abortRequested") or current.get("abortReason") is not None:
408
+ tx.set_task(with_abort_mark(current))
409
+ return {
410
+ "result": "marked",
411
+ "run": invocation if invocation is not None and invocation.mode == "run" else None,
412
+ }
413
+
414
+ marked = await self._session.commit_with(mark, context)
415
+ invocation = marked.get("run")
416
+ if invocation is not None:
417
+ await await_with_context(_race(invocation.done, self._session.failed), context)
418
+ return cast(Literal["marked", "terminal"], marked["result"])
419
+
420
+ async def wait_for_task(self, id: TaskId, context: Context) -> SettledTask:
421
+ """Resolve with the terminal receipt; cancelling ``context`` cancels only this wait."""
422
+
423
+ async def find() -> Any:
424
+ if self._closing:
425
+ raise closed_error(self._session)
426
+ if id in self._live:
427
+ return {"promise": self._task_waiters.add(id, context)}
428
+ record = await self._storage.task(id, context)
429
+ if record is None:
430
+ raise ValueError(f"Task {id} does not exist")
431
+ return {"promise": resolved(cast(SettledTask, record))}
432
+
433
+ found = await self._session.read_on_line(find)
434
+ return cast(SettledTask, await found["promise"])
435
+
436
+ def wait_for_idle(self, conversation_id: ConversationId | None, context: Context) -> Awaitable[None]:
437
+ """Resolve when ordinary traversal reaches no live non-background task."""
438
+
439
+ if self._closing:
440
+ return rejected(closed_error(self._session))
441
+ if self._idle(conversation_id):
442
+ return resolved(None)
443
+ self._schedule_reconcile()
444
+ return self._idle_waiters.add(conversation_id, context)
445
+
446
+ async def abort_conversation(
447
+ self, conversation_id: ConversationId, background: bool, context: Context
448
+ ) -> None:
449
+ """Withdraw queued inputs, mark the ordinary scope, and wait until it is idle."""
450
+
451
+ async def mark(tx: Transaction) -> list[TaskId]:
452
+ queued = await self._load_scopes(True)
453
+ scope = {"conversation": conversation_id}
454
+ reached: list[TaskId] = []
455
+ for step in self._walk_down(conversation_node(conversation_id), cross_background=background):
456
+ node = step["node"]
457
+ live = step["live"]
458
+ if live is None or (node["background"] and not background):
459
+ continue
460
+ reached.append(live["id"])
461
+ if not live.get("abortRequested") or live.get("abortReason") is not None:
462
+ tx.set_task(with_abort_mark(live))
463
+ for id in queued:
464
+ if self._in_scope({"conversation": id}, scope, background) is True:
465
+ await self._withdraw_inputs(tx, id)
466
+ return reached
467
+
468
+ reached = await self._session.commit_with(mark, context)
469
+ if background:
470
+ for id in reached:
471
+ await self.wait_for_task(id, context)
472
+ await self.wait_for_idle(conversation_id, context)
473
+
474
+ # ── Scheduling ─────────────────────────────────────────────────────────
475
+
476
+ def _observe(self, publication: CommitPublication, context: Context) -> None:
477
+ updated: list[AnyTaskRecord] = []
478
+ failed: list[TaskId] = []
479
+ changed = False
480
+ for change in publication["changes"]:
481
+ if change["type"] == "conversation" and change["value"]["id"] not in self._edges:
482
+ owner = change["value"].get("owner")
483
+ self._set_edge(change["value"]["id"], owner["taskId"] if owner is not None else None)
484
+ for change in publication["changes"]:
485
+ if change["type"] != "task":
486
+ continue
487
+ changed = True
488
+ record = change["value"]
489
+ previous = self._live.get(record["id"])
490
+ if failed_outcome(record) and (previous is None or not failed_outcome(previous)):
491
+ failed.append(record["id"])
492
+ self._track(previous, record)
493
+ if record["state"]["status"] == "terminal":
494
+ self._failed_migrations.pop(record["id"], None)
495
+ self._fail_fast_checks.discard(record["id"])
496
+ self._task_waiters.resolve(record["id"], cast(SettledTask, record))
497
+ self._schedule_reconcile()
498
+ continue
499
+ if record.get("abortRequested") and not (previous or {}).get("abortRequested"):
500
+ self._cascade_pending = True
501
+ invocation = self._invocations.get(record["id"])
502
+ if invocation is not None and invocation.mode == "run":
503
+ invocation.controller.abort()
504
+ if (previous or {}).get("abortReason") is not None and record.get("abortReason") is None:
505
+ self._cascade_pending = True
506
+ status = record["state"]["status"]
507
+ if status == "completing" and (previous or {}).get("state", {}).get("status") != "completing":
508
+ if cancellation_intent(record):
509
+ self._cascade_pending = True
510
+ self._schedule_reconcile()
511
+ if (
512
+ status == "waiting"
513
+ and record["state"]["policy"] == "failFast"
514
+ and (previous or {}).get("state", {}).get("status") != "waiting"
515
+ ):
516
+ self._fail_fast_checks.add(record["id"])
517
+ self._schedule_reconcile()
518
+ if previous is None:
519
+ updated.append(record)
520
+ for id in failed:
521
+ for waiter in self._waiters.get(id, ()):
522
+ state = (self._live.get(waiter) or {}).get("state")
523
+ if state is not None and state["status"] == "waiting" and state["policy"] == "failFast":
524
+ self._fail_fast_checks.add(waiter)
525
+ self._schedule_reconcile()
526
+ for change in publication["changes"]:
527
+ if change["type"] != "submission":
528
+ continue
529
+ value = change["value"]
530
+ if value["status"] != "queued" or value["type"] != "input":
531
+ continue
532
+ if not self._intent:
533
+ continue
534
+ conversation = value["conversationId"]
535
+ if not self._known(conversation_node(conversation)) or self._below_cancelled(
536
+ {"conversation": conversation}
537
+ ):
538
+ self._cascade_pending = True
539
+ for record in updated:
540
+ if record["id"] in self._unloaded:
541
+ self._schedule_reconcile()
542
+ elif (
543
+ not record["background"]
544
+ and not record.get("abortRequested")
545
+ and self._below_cancelled(parent_of(record))
546
+ ):
547
+ self._cascade_pending = True
548
+ if self._cascade_pending:
549
+ self._schedule_reconcile()
550
+ self._sweep()
551
+ if not changed:
552
+ return
553
+ self._settle_idle()
554
+ self._kick()
555
+
556
+ def _context_retention_ms(self) -> int:
557
+ try:
558
+ return self._settings().context_retention_ms
559
+ except Exception as error:
560
+ self._report(error)
561
+ return 0
562
+
563
+ def _settle_idle(self) -> None:
564
+ for conversation_id in self._idle_waiters.keys():
565
+ if self._idle(conversation_id):
566
+ self._idle_waiters.resolve(conversation_id, None)
567
+ now = self._now()
568
+ retention = self._context_retention_ms()
569
+ for conversation_id, kept in list(self._contexts.items()):
570
+ if kept["idle_since"] is not None and now - kept["idle_since"] >= retention:
571
+ del self._contexts[conversation_id]
572
+ elif not self._idle(conversation_id):
573
+ kept["idle_since"] = None
574
+ elif retention > 0:
575
+ if kept["idle_since"] is None:
576
+ kept["idle_since"] = now
577
+ else:
578
+ del self._contexts[conversation_id]
579
+ self._schedule_expiry()
580
+
581
+ def _schedule_expiry(self) -> None:
582
+ retention = self._context_retention_ms()
583
+ at: int | None = None
584
+ for kept in self._contexts.values():
585
+ if kept["idle_since"] is not None:
586
+ candidate = kept["idle_since"] + retention
587
+ if at is None or candidate < at:
588
+ at = candidate
589
+ if self._expiry is not None and self._expiry["at"] == at:
590
+ return
591
+ if self._expiry_handle is not None:
592
+ self._expiry_handle.cancel()
593
+ self._expiry_handle = None
594
+ self._expiry = None
595
+ if at is None or self._closing:
596
+ return
597
+ delay = min(max(0, at - self._now()), MAX_TIMER_DELAY_MS)
598
+ loop = asyncio.get_running_loop()
599
+ self._expiry_handle = loop.call_later(delay / 1000.0, self._expire)
600
+ self._expiry = {"at": at}
601
+
602
+ def _expire(self) -> None:
603
+ self._expiry = None
604
+ self._expiry_handle = None
605
+ self._settle_idle()
606
+
607
+ def _schedule_reconcile(self) -> None:
608
+ if self._reconcile_scheduled or self._closing:
609
+ return
610
+ self._reconcile_scheduled = True
611
+ asyncio.get_running_loop().call_soon(lambda: self._spawn(self._reconcile()))
612
+
613
+ async def _reconcile(self) -> None:
614
+ self._reconcile_scheduled = False
615
+ cascade = self._cascade_pending
616
+ self._cascade_pending = False
617
+ checks = list(self._fail_fast_checks)
618
+ self._fail_fast_checks.clear()
619
+
620
+ async def apply(tx: Transaction) -> None:
621
+ if self._closing:
622
+ return
623
+ queued = await self._load_scopes(cascade)
624
+ marks: dict[TaskId, dict[str, Any]] = {}
625
+
626
+ def mark(record: Mapping[str, Any], reason: str | None) -> None:
627
+ staged = marks.get(record["id"])
628
+ if staged is not None and staged["reason"] is None:
629
+ return
630
+ if record.get("abortRequested") and (record.get("abortReason") is None or reason is not None):
631
+ return
632
+ marks[record["id"]] = {"record": record, "reason": reason}
633
+
634
+ for id in list(self._intent):
635
+ owner = self._live.get(id)
636
+ if owner is None:
637
+ continue
638
+ restart = owner.get("abortReason") == "restart" and not failed_outcome(owner)
639
+ for step in self._walk_down(id, enter=lambda below: below not in self._intent):
640
+ live = step["live"]
641
+ if live is not None and not step["node"]["background"]:
642
+ mark(live, "restart" if restart else None)
643
+ for id in checks:
644
+ waiter = self._live.get(id)
645
+ if waiter is None or waiter["state"]["status"] != "waiting":
646
+ continue
647
+ if not await self._any_failed(waiter["state"]["on"]):
648
+ continue
649
+ for member in waiter["state"]["on"]:
650
+ record = self._live.get(member)
651
+ if record is not None and not failed_outcome(record):
652
+ mark(record, None)
653
+ for mark_value in marks.values():
654
+ tx.set_task(with_abort_mark(mark_value["record"], mark_value["reason"]))
655
+ for id in queued:
656
+ if self._below_cancelled({"conversation": id}):
657
+ await self._withdraw_inputs(tx, id)
658
+ await self._finalize(tx)
659
+
660
+ try:
661
+ await self._session.commit_with(apply, self._context)
662
+ except Exception as error:
663
+ self._fail_session(error)
664
+ self._settle_idle()
665
+
666
+ async def _any_failed(self, ids: Sequence[TaskId]) -> bool:
667
+ for id in ids:
668
+ record = self._live.get(id)
669
+ if record is None:
670
+ record = await self._storage.task(id, self._context)
671
+ if record is not None and failed_outcome(record):
672
+ return True
673
+ return False
674
+
675
+ async def _finalize(self, tx: Transaction) -> None:
676
+ round_ids = list(self._finalize_checks)
677
+ self._finalize_checks.clear()
678
+ done: set[TaskId] = set()
679
+ overlay = _overlay_of(tx)
680
+ while round_ids:
681
+ following: set[TaskId] = set()
682
+ for id in round_ids:
683
+ record = overlay["tasks"].get(id) or self._live.get(id)
684
+ if record is None or record["state"]["status"] != "completing" or id in done:
685
+ continue
686
+ if self._has_owned_live(id, overlay, done):
687
+ continue
688
+ outcome = record["state"]["outcome"]
689
+ terminal = with_state(record, {"status": "terminal", "outcome": outcome})
690
+ tx.set_task(terminal)
691
+ overlay["tasks"][id] = terminal
692
+ done.add(id)
693
+ if outcome["status"] in ("faulted", "orphaned"):
694
+ await self._settle_outcome(tx, record, outcome)
695
+ above = self._nearest_live_above(record, overlay)
696
+ if above is not None and above["state"]["status"] == "completing":
697
+ following.add(above["id"])
698
+ round_ids = list(following)
699
+
700
+ async def _load_scopes(self, queued: bool) -> list[ConversationId]:
701
+ for id in list(self._unloaded):
702
+ if id in self._unloaded:
703
+ await self._load_chain({"task": id})
704
+ if not queued:
705
+ return []
706
+ submissions = await scan_all(
707
+ lambda cursor: self._storage.scan_submissions(
708
+ {"status": "queued"}, _SCAN_PAGE_SIZE, cursor, self._context
709
+ )
710
+ )
711
+ inputs = [submission for submission in submissions if submission["type"] == "input"]
712
+ conversations = list({submission["conversationId"] for submission in inputs})
713
+ for id in conversations:
714
+ await self._load_chain({"conversation": id})
715
+ return conversations
716
+
717
+ async def _load_chain(self, start: Up, overlay: dict[str, Any] | None = None) -> None:
718
+ passed: list[TaskId] = []
719
+ at: Up | None = start
720
+ while at is not None:
721
+ if "task" in at:
722
+ id = at["task"]
723
+ node = self._live.get(id) or self._settled.get(id)
724
+ if node is None and overlay is not None and id in overlay["tasks"]:
725
+ break
726
+ if node is not None and id not in self._unloaded:
727
+ break
728
+ if node is None:
729
+ record = await self._storage.task(id, self._context)
730
+ if record is None:
731
+ return
732
+ node = node_of(record)
733
+ if record["state"]["status"] == "terminal":
734
+ self._settle(id, node)
735
+ self._schedule_reconcile()
736
+ passed.append(id)
737
+ at = parent_of(node)
738
+ else:
739
+ conversation = at["conversation"]
740
+ if overlay is not None and conversation in overlay["edges"]:
741
+ edge = overlay["edges"][conversation]
742
+ elif conversation in self._edges:
743
+ edge = self._edges[conversation]
744
+ else:
745
+ record = await self._storage.conversation(conversation, self._context)
746
+ edge = record["owner"]["taskId"] if record is not None and "owner" in record else None
747
+ self._set_edge(conversation, edge)
748
+ self._schedule_reconcile()
749
+ at = None if edge is None else {"task": edge}
750
+ for id in passed:
751
+ self._unloaded.discard(id)
752
+
753
+ def _set_edge(self, conversation_id: ConversationId, owner: TaskId | None) -> None:
754
+ self._edges[conversation_id] = owner
755
+ self._relist(conversation_node(conversation_id))
756
+ self._drop_if_unneeded(conversation_node(conversation_id))
757
+
758
+ def _settle(self, id: TaskId, node: dict[str, Any]) -> None:
759
+ self._settled[id] = node
760
+ self._unloaded.add(id)
761
+ self._relist(id)
762
+ self._drop_if_unneeded(id)
763
+
764
+ def _drop_if_unneeded(self, node: Node) -> None:
765
+ if not isinstance(node, str) and node in self._live:
766
+ return
767
+ if node in self._below:
768
+ return
769
+ self._drop_queue.add(node)
770
+ self._schedule_sweep()
771
+
772
+ def _schedule_sweep(self) -> None:
773
+ if self._sweep_scheduled or self._closing:
774
+ return
775
+ self._sweep_scheduled = True
776
+ loop = asyncio.get_running_loop()
777
+
778
+ async def sweep() -> None:
779
+ self._sweep_scheduled = False
780
+ self._sweep()
781
+
782
+ async def queued() -> None:
783
+ try:
784
+ await self._session.read_on_line(sweep)
785
+ except Exception:
786
+ pass
787
+
788
+ loop.call_soon(lambda: self._spawn(queued()))
789
+
790
+ def _sweep(self) -> None:
791
+ for node in list(self._drop_queue):
792
+ if node in self._below:
793
+ continue
794
+ if isinstance(node, str):
795
+ conversation = conversation_of(node)
796
+ self._edges.pop(conversation, None)
797
+ self._roots.discard(conversation)
798
+ elif node not in self._live:
799
+ self._settled.pop(node, None)
800
+ self._unloaded.discard(node)
801
+ self._drop_queue.clear()
802
+
803
+ def _track(self, previous: AnyTaskRecord | None, record: AnyTaskRecord) -> None:
804
+ id = record["id"]
805
+ if previous is not None and previous["state"]["status"] == "waiting":
806
+ for on in previous["state"]["on"]:
807
+ _delete_from(self._waiters, on, id)
808
+ if record["state"]["status"] == "terminal":
809
+ node = node_of(record)
810
+ self._live.pop(id, None)
811
+ self._intent.discard(id)
812
+ self._runnable.discard(id)
813
+ self._finalize_checks.discard(id)
814
+ self._settled[id] = node
815
+ self._release(node)
816
+ for waiter in self._waiters.get(id, ()):
817
+ self._runnable.add(waiter)
818
+ self._relist(id)
819
+ self._drop_if_unneeded(id)
820
+ return
821
+ self._live[id] = record
822
+ if previous is None:
823
+ if not self._known(parent_node(record)):
824
+ self._unloaded.add(id)
825
+ self._relist(id)
826
+ if cancellation_intent(record):
827
+ self._intent.add(id)
828
+ else:
829
+ self._intent.discard(id)
830
+ if record["state"]["status"] == "completing":
831
+ self._runnable.discard(id)
832
+ self._finalize_checks.add(id)
833
+ else:
834
+ self._runnable.add(id)
835
+ if record["state"]["status"] == "waiting":
836
+ for on in record["state"]["on"]:
837
+ _add_to(self._waiters, on, id)
838
+
839
+ def _release(self, node: dict[str, Any], overlay: dict[str, Any] | None = None) -> AnyTaskRecord | None:
840
+ above = self._nearest_live_above(node, overlay)
841
+ if above is not None and above["state"]["status"] == "completing":
842
+ self._finalize_checks.add(above["id"])
843
+ elif above is not None and above.get("abortRequested") is True:
844
+ self._runnable.add(above["id"])
845
+ return above
846
+
847
+ def _relist(self, start: Node) -> None:
848
+ node = start
849
+ while True:
850
+ needed = (not isinstance(node, str) and node in self._live) or node in self._below
851
+ parent: Node | None
852
+ if isinstance(node, str):
853
+ conversation = conversation_of(node)
854
+ # An owner edge not loaded yet: the chain load relists once it knows it.
855
+ if conversation not in self._edges:
856
+ return
857
+ parent = self._edges[conversation]
858
+ else:
859
+ known = self._live.get(node) or self._settled.get(node)
860
+ if known is None:
861
+ return
862
+ parent = parent_node(known)
863
+ if parent is None:
864
+ root = conversation_of(cast(str, node))
865
+ if needed:
866
+ self._roots.add(root)
867
+ else:
868
+ self._roots.discard(root)
869
+ return
870
+ current = self._below.get(parent)
871
+ has = current is not None and node in current
872
+ if has == needed:
873
+ return
874
+ if needed:
875
+ first = parent not in self._below
876
+ _add_to(self._below, parent, node)
877
+ if not first:
878
+ return
879
+ else:
880
+ _delete_from(self._below, parent, node)
881
+ if parent in self._below:
882
+ return
883
+ self._drop_if_unneeded(parent)
884
+ node = parent
885
+
886
+ def _nearest_live_above(
887
+ self, node: dict[str, Any], overlay: dict[str, Any] | None = None
888
+ ) -> AnyTaskRecord | None:
889
+ current = node
890
+ while not current["background"]:
891
+ owner = current.get("owner")
892
+ if owner is None:
893
+ owner = self._edge(current["conversationId"], overlay)
894
+ if owner is None:
895
+ return None
896
+ record = (overlay or {}).get("tasks", {}).get(owner) or self._live.get(owner)
897
+ if record is not None and record["state"]["status"] != "terminal":
898
+ return record
899
+ above = record or self._settled.get(owner)
900
+ if above is None:
901
+ return None
902
+ current = above
903
+ return None
904
+
905
+ def _known(self, node: Node) -> bool:
906
+ if not isinstance(node, str):
907
+ return (node in self._live or node in self._settled) and node not in self._unloaded
908
+ conversation = conversation_of(node)
909
+ if conversation not in self._edges:
910
+ return False
911
+ owner = self._edges[conversation]
912
+ return owner is None or self._known(owner)
913
+
914
+ def _edge(self, id: ConversationId, overlay: dict[str, Any] | None = None) -> TaskId | None | Any:
915
+ edges = (overlay or {}).get("edges")
916
+ if edges is not None and id in edges:
917
+ return edges[id]
918
+ return self._edges.get(id)
919
+
920
+ def _node(self, id: TaskId, overlay: dict[str, Any] | None = None) -> dict[str, Any] | None:
921
+ return ((overlay or {}).get("tasks", {}).get(id)) or self._live.get(id) or self._settled.get(id)
922
+
923
+ def _above(self, start: Up, overlay: dict[str, Any] | None = None) -> Iterator[dict[str, Any]]:
924
+ at: Up | None = start
925
+ while at is not None:
926
+ if "task" in at:
927
+ node = self._node(at["task"], overlay)
928
+ if node is None:
929
+ yield {"unknown": True}
930
+ return
931
+ yield {"task": at["task"], "node": node}
932
+ at = parent_of(node)
933
+ else:
934
+ yield {"conversation": at["conversation"]}
935
+ conversation = at["conversation"]
936
+ if overlay is not None and conversation in overlay["edges"]:
937
+ edge = overlay["edges"][conversation]
938
+ elif conversation in self._edges:
939
+ edge = self._edges[conversation]
940
+ else:
941
+ yield {"unknown": True}
942
+ return
943
+ at = None if edge is None else {"task": edge}
944
+
945
+ def _links(self, overlay: dict[str, Any]) -> dict[Node, set[Node]]:
946
+ if "links" in overlay:
947
+ return cast(dict[Node, set[Node]], overlay["links"])
948
+ links: dict[Node, set[Node]] = {}
949
+ for record in overlay["tasks"].values():
950
+ if record["id"] in self._live or record["id"] in self._settled:
951
+ continue
952
+ child: Node = record["id"]
953
+ parent: Node | None = parent_node(record)
954
+ while parent is not None and child not in links.get(parent, set()):
955
+ _add_to(links, parent, child)
956
+ if not isinstance(parent, str) and (parent in self._live or parent in overlay["tasks"]):
957
+ break
958
+ above: Node | None
959
+ if isinstance(parent, str):
960
+ above = self._edge(conversation_of(parent), overlay)
961
+ else:
962
+ fields = self._settled.get(parent)
963
+ above = None if fields is None else parent_node(fields)
964
+ if above is not None and parent in self._below.get(above, set()):
965
+ break
966
+ child = parent
967
+ parent = above
968
+ overlay["links"] = links
969
+ return links
970
+
971
+ def _walk_down(
972
+ self,
973
+ start: Node,
974
+ *,
975
+ overlay: dict[str, Any] | None = None,
976
+ cross_background: bool = False,
977
+ enter: Callable[[TaskId], bool] | None = None,
978
+ ) -> Iterator[dict[str, Any]]:
979
+ links = None if overlay is None else self._links(overlay)
980
+ pending: list[Node] = [start]
981
+ while pending:
982
+ at = pending.pop()
983
+ children_sets = (self._below.get(at), None if links is None else links.get(at))
984
+ for children in children_sets:
985
+ for child in children or ():
986
+ if isinstance(child, str):
987
+ pending.append(child)
988
+ continue
989
+ record = (overlay or {}).get("tasks", {}).get(child) or self._live.get(child)
990
+ node = record or self._settled.get(child)
991
+ if node is None:
992
+ continue
993
+ live = record if record is not None and record["state"]["status"] != "terminal" else None
994
+ yield {"id": child, "node": node, "live": live}
995
+ if (cross_background or not node["background"]) and (enter is None or enter(child)):
996
+ pending.append(child)
997
+
998
+ def _owned_live(
999
+ self,
1000
+ owner: TaskId,
1001
+ *,
1002
+ overlay: dict[str, Any] | None = None,
1003
+ first: bool = False,
1004
+ done: set[TaskId] | None = None,
1005
+ ) -> list[TaskId]:
1006
+ found: list[TaskId] = []
1007
+ walk = self._walk_down(
1008
+ owner,
1009
+ overlay=overlay,
1010
+ enter=(lambda id: id not in done) if done is not None else None,
1011
+ )
1012
+ for step in walk:
1013
+ if step["live"] is None or step["node"]["background"]:
1014
+ continue
1015
+ found.append(step["id"])
1016
+ if first:
1017
+ break
1018
+ return found
1019
+
1020
+ def _has_owned_live(
1021
+ self,
1022
+ owner: TaskId,
1023
+ overlay: dict[str, Any] | None = None,
1024
+ done: set[TaskId] | None = None,
1025
+ ) -> bool:
1026
+ return bool(self._owned_live(owner, overlay=overlay, first=True, done=done))
1027
+
1028
+ def _in_scope(self, start: Up, scope: Scope, cross_background: bool = False) -> bool | None:
1029
+ for step in self._above(start):
1030
+ if "unknown" in step:
1031
+ return None
1032
+ if "conversation" in step:
1033
+ if "conversation" in scope and step["conversation"] == scope["conversation"]:
1034
+ return True
1035
+ elif step["node"]["background"] and not cross_background:
1036
+ return False
1037
+ return "roots" in scope
1038
+
1039
+ def _below_cancelled(self, start: Up) -> bool:
1040
+ return self._cancelling_owner(start) is not None
1041
+
1042
+ def _cancelling_owner(self, start: Up) -> AnyTaskRecord | None:
1043
+ if not self._intent:
1044
+ return None
1045
+ for step in self._above(start):
1046
+ if "unknown" in step:
1047
+ return None
1048
+ if "task" not in step:
1049
+ continue
1050
+ live = self._live.get(step["task"])
1051
+ if live is not None and cancellation_intent(live):
1052
+ return live
1053
+ if step["node"]["background"]:
1054
+ return None
1055
+ return None
1056
+
1057
+ def _fail_session(self, error: BaseException) -> None:
1058
+ if not self._closing:
1059
+ self._session.fail(error)
1060
+
1061
+ def _seal(self) -> None:
1062
+ self._closing = True
1063
+ try:
1064
+ self._unsubscribe_registry()
1065
+ except Exception as error:
1066
+ self._report(error)
1067
+ error = closed_error(self._session)
1068
+ self._task_waiters.reject_all(error)
1069
+ self._idle_waiters.reject_all(error)
1070
+ self._contexts.clear()
1071
+ if self._expiry_handle is not None:
1072
+ self._expiry_handle.cancel()
1073
+ self._expiry_handle = None
1074
+ self._expiry = None
1075
+
1076
+ def _kick(self) -> None:
1077
+ self._dirty = True
1078
+ if self._draining or not self._enabled or self._closing:
1079
+ return
1080
+ self._draining = True
1081
+ asyncio.get_running_loop().call_soon(lambda: self._spawn(self._drain()))
1082
+
1083
+ def _spawn(self, work: Coroutine[Any, Any, Any]) -> None:
1084
+ """Run detached scheduler work, keeping it referenced until it finishes.
1085
+
1086
+ A teardown signal means the Harness is being collected, not that its work failed:
1087
+ nothing owns the coroutine anymore, so scheduling anything else would resurrect it.
1088
+ """
1089
+
1090
+ async def guarded() -> None:
1091
+ try:
1092
+ await work
1093
+ except BaseException as error:
1094
+ if not is_failure(error):
1095
+ self._seal()
1096
+ raise
1097
+
1098
+ task = asyncio.ensure_future(guarded())
1099
+ self._detached.add(task)
1100
+ task.add_done_callback(self._detached.discard)
1101
+
1102
+ async def _drain(self) -> None:
1103
+ try:
1104
+ while self._dirty and self._enabled and not self._closing:
1105
+ self._dirty = False
1106
+ for reservation in await self._reserve():
1107
+ self._start(reservation)
1108
+ except Exception as error:
1109
+ self._fail_session(error)
1110
+ finally:
1111
+ self._draining = False
1112
+ if self._dirty:
1113
+ self._kick()
1114
+
1115
+ async def _reserve(self) -> list[dict[str, Any]]:
1116
+ reservations: list[dict[str, Any]] = []
1117
+ abandoned: list[TaskId] = []
1118
+
1119
+ async def reserve(tx: Transaction) -> None:
1120
+ if not self._enabled or self._closing:
1121
+ return
1122
+ for id in list(self._abandoned):
1123
+ record = self._live.get(id)
1124
+ if record is not None and not record.get("abortRequested"):
1125
+ tx.set_task(with_abort_mark(record, "restart"))
1126
+ if self._abandoned:
1127
+ abandoned.extend(self._abandoned)
1128
+ return
1129
+ await self._load_scopes(False)
1130
+ snapshot: RegistrySnapshot | None = None
1131
+ for id in list(self._runnable):
1132
+ record = self._live.get(id)
1133
+ if record is None or record["state"]["status"] == "completing" or id in self._invocations:
1134
+ self._runnable.discard(id)
1135
+ continue
1136
+ if self._waiting_on(record, True):
1137
+ self._runnable.discard(id)
1138
+ continue
1139
+ runnable = record
1140
+ mode: Literal["run", "abort"] = "abort" if record.get("abortRequested") else "run"
1141
+ if mode == "run" and not record["background"] and self._below_cancelled(parent_of(record)):
1142
+ self._cascade_pending = True
1143
+ self._schedule_reconcile()
1144
+ continue
1145
+ if snapshot is None:
1146
+ snapshot = self._registry.snapshot()
1147
+ resolution = self._resolve(runnable, snapshot)
1148
+ if resolution["kind"] == "blocked":
1149
+ if mode == "abort" and record.get("abortReason") is None:
1150
+ await self._terminate(
1151
+ tx,
1152
+ record,
1153
+ {"status": "orphaned", "reason": resolution["reason"]},
1154
+ )
1155
+ continue
1156
+ if resolution["record"] is not runnable or record["state"]["status"] != "running":
1157
+ tx.set_task(
1158
+ with_state(
1159
+ resolution["record"],
1160
+ {"status": "running", "checkpoint": resolution["record"]["state"]["checkpoint"]},
1161
+ )
1162
+ )
1163
+ self._runnable.discard(id)
1164
+ invocation = self._create_invocation(record, mode)
1165
+ reservations.append(
1166
+ {"invocation": invocation, "task": resolution["task"], "snapshot": snapshot}
1167
+ )
1168
+
1169
+ try:
1170
+ await self._session.commit_with(reserve, self._context)
1171
+ except Exception:
1172
+ for reservation in reservations:
1173
+ self._invocations.pop(reservation["invocation"].task_id, None)
1174
+ self._runnable.add(reservation["invocation"].task_id)
1175
+ reservation["invocation"].finish()
1176
+ raise
1177
+ for id in abandoned:
1178
+ self._abandoned.discard(id)
1179
+ if abandoned:
1180
+ self._dirty = True
1181
+ return reservations
1182
+
1183
+ def _waiting_on(self, record: Mapping[str, Any], first: bool = False) -> list[TaskId]:
1184
+ if record.get("abortRequested"):
1185
+ return self._owned_live(record["id"], first=first)
1186
+ if record["state"]["status"] != "waiting":
1187
+ return []
1188
+ on = record["state"]["on"]
1189
+ if not first:
1190
+ return [id for id in on if id in self._live]
1191
+ live = next((id for id in on if id in self._live), None)
1192
+ return [] if live is None else [live]
1193
+
1194
+ def _resolve(self, record: RunnableTaskRecord, snapshot: RegistrySnapshot) -> dict[str, Any]:
1195
+ fit = self._fit(record, snapshot.task(record["kind"]))
1196
+ if "reason" in fit:
1197
+ return {"kind": "blocked", "reason": fit["reason"]}
1198
+ if not fit["migrates"]:
1199
+ return {"kind": "ready", "task": fit["task"], "record": record}
1200
+ definition = fit["task"].definition
1201
+ try:
1202
+ if definition.migrate is None:
1203
+ raise _missing_migration(record, definition)
1204
+ migrated = definition.migrate(record["input"], record["state"]["checkpoint"], record["version"])
1205
+ state = dict(record["state"])
1206
+ state["checkpoint"] = copy_json(migrated["checkpoint"])
1207
+ migrated_record = dict(record)
1208
+ migrated_record["version"] = definition.version
1209
+ migrated_record["input"] = copy_json(migrated["input"])
1210
+ migrated_record["state"] = state
1211
+ return {"kind": "ready", "task": fit["task"], "record": migrated_record}
1212
+ except Exception as error:
1213
+ self._failed_migrations[record["id"]] = {"task": fit["task"], "error": error}
1214
+ self._report(error)
1215
+ return {"kind": "blocked", "reason": "migration_failed"}
1216
+
1217
+ def _fit(self, record: Mapping[str, Any], task: AnyTask | None) -> dict[str, Any]:
1218
+ if task is None:
1219
+ return {"reason": "missing_task"}
1220
+ version = task.definition.version
1221
+ if version == record["version"]:
1222
+ return {"task": task, "migrates": False}
1223
+ if version < record["version"]:
1224
+ return {"reason": "task_too_old"}
1225
+ failed = self._failed_migrations.get(record["id"])
1226
+ if failed is not None and failed["task"] is task:
1227
+ return {"reason": "migration_failed", "error": failed["error"]}
1228
+ return {"task": task, "migrates": True}
1229
+
1230
+ @property
1231
+ def index_sizes(self) -> dict[str, int]:
1232
+ """Sizes of the ownership indexes, for tests that bound the scheduler's memory."""
1233
+
1234
+ return {
1235
+ "live": len(self._live),
1236
+ "settled": len(self._settled),
1237
+ "edges": len(self._edges),
1238
+ "below": sum(len(children) for children in self._below.values()),
1239
+ "roots": len(self._roots),
1240
+ "unloaded": len(self._unloaded),
1241
+ "drop_queue": len(self._drop_queue),
1242
+ }
1243
+
1244
+ async def inspect(self, snapshot: RegistrySnapshot) -> tuple[str, list[TaskInspection]]:
1245
+ await self._load_scopes(False)
1246
+ tasks: list[TaskInspection] = [
1247
+ {
1248
+ "record": cast(AnyTaskRecord, copy_json(record)),
1249
+ "state": self._inspect_task(record, snapshot),
1250
+ }
1251
+ for record in self._live.values()
1252
+ ]
1253
+ scheduling = "closing" if self._closing else ("running" if self._enabled else "paused")
1254
+ return scheduling, tasks
1255
+
1256
+ def _inspect_task(self, record: AnyTaskRecord, snapshot: RegistrySnapshot) -> dict[str, Any]:
1257
+ if record["id"] in self._invocations:
1258
+ return {"kind": "running"}
1259
+ if record["state"]["status"] == "completing":
1260
+ return {"kind": "completing"}
1261
+ on = self._waiting_on(record)
1262
+ if on:
1263
+ return {"kind": "waiting", "on": on}
1264
+ fit = self._fit(record, snapshot.task(record["kind"]))
1265
+ if "reason" in fit:
1266
+ return {"kind": "blocked", **fit}
1267
+ if fit["migrates"] and fit["task"].definition.migrate is None:
1268
+ return {
1269
+ "kind": "blocked",
1270
+ "reason": "migration_failed",
1271
+ "error": _missing_migration(record, fit["task"].definition),
1272
+ }
1273
+ return {"kind": "ready", "migrates": fit["migrates"]}
1274
+
1275
+ def _create_invocation(self, record: Mapping[str, Any], mode: Literal["run", "abort"]) -> _Invocation:
1276
+ invocation = _Invocation(record, mode, self._context)
1277
+ self._invocations[invocation.task_id] = invocation
1278
+ return invocation
1279
+
1280
+ def _start(self, reservation: dict[str, Any]) -> None:
1281
+ invocation: _Invocation = reservation["invocation"]
1282
+
1283
+ async def run() -> None:
1284
+ try:
1285
+ if invocation.mode == "run":
1286
+ await self._run(reservation)
1287
+ else:
1288
+ await self._run_abort(reservation)
1289
+ except BaseException as error:
1290
+ if not is_failure(error):
1291
+ raise
1292
+ self._report(error)
1293
+ finally:
1294
+ self._end(invocation)
1295
+ invocation.finish()
1296
+ self._kick()
1297
+
1298
+ self._spawn(run())
1299
+
1300
+ async def _run(self, reservation: dict[str, Any]) -> None:
1301
+ invocation: _Invocation = reservation["invocation"]
1302
+ state = {
1303
+ "task": reservation["task"],
1304
+ "snapshot": reservation["snapshot"],
1305
+ "reported": None,
1306
+ }
1307
+ phase: dict[str, Any] = {
1308
+ "snapshot": lambda: state["snapshot"],
1309
+ "task": lambda: state["task"],
1310
+ "agent": None,
1311
+ }
1312
+ runtime = _Runtime(self, invocation, phase)
1313
+ previous: dict[str, Any] | None = None
1314
+ while True:
1315
+ current = await self._step(
1316
+ invocation,
1317
+ lambda tx, current, previous=previous: self._decide(tx, current, previous, state),
1318
+ )
1319
+ if current is None or self._closing:
1320
+ return
1321
+ checkpoint = current["state"]["checkpoint"]
1322
+ phase["agent"] = None
1323
+ try:
1324
+ handler = state["task"].definition.phases[checkpoint["phase"]]
1325
+ await _invoke_handler(handler, current, runtime, invocation.context)
1326
+ previous = {"checkpoint": checkpoint}
1327
+ except BaseException as error:
1328
+ if not is_failure(error):
1329
+ raise
1330
+ previous = {"checkpoint": checkpoint, "failure": {"error": error}}
1331
+ # Detached commits the handler started land before the step, as upstream promises.
1332
+ for commit in list(invocation.commits):
1333
+ try:
1334
+ await commit
1335
+ except BaseException as error:
1336
+ if not is_failure(error):
1337
+ raise
1338
+
1339
+ def _decide(
1340
+ self,
1341
+ tx: Transaction,
1342
+ current: ErasedRunningTask,
1343
+ previous: dict[str, Any] | None,
1344
+ state: dict[str, Any],
1345
+ ) -> Any:
1346
+ if current.get("abortRequested"):
1347
+ return False
1348
+ if not current["background"] and self._below_cancelled(parent_of(current)):
1349
+ self._cascade_pending = True
1350
+ self._schedule_reconcile()
1351
+ return False
1352
+ if previous is None:
1353
+ return True
1354
+ if previous.get("failure") is not None:
1355
+ return {"fault": previous["failure"]["error"]}
1356
+ if json_equal(current["state"]["checkpoint"], previous["checkpoint"]):
1357
+ message = (
1358
+ f"Task {current['kind']} phase {previous['checkpoint']['phase']} "
1359
+ "returned without durable progress"
1360
+ )
1361
+ return {"fault": RuntimeError(message)}
1362
+ state["snapshot"] = self._registry.snapshot()
1363
+ following = state["snapshot"].task(current["kind"])
1364
+ if following is not state["task"]:
1365
+ if following is not None and can_reserve(following, current):
1366
+ tx.set_task(
1367
+ with_state(current, {"status": "pending", "checkpoint": current["state"]["checkpoint"]})
1368
+ )
1369
+ return False
1370
+ if state["reported"] is None or state["reported"]["task"] is not following:
1371
+ state["reported"] = {"task": following}
1372
+ cause = "missing_task" if following is None else "incompatible_task"
1373
+ self._report(_OldDefinitionError(current["id"], current["kind"], cause))
1374
+ return True
1375
+
1376
+ async def _run_abort(self, reservation: dict[str, Any]) -> None:
1377
+ invocation: _Invocation = reservation["invocation"]
1378
+ current = self._live.get(invocation.task_id)
1379
+ if current is None or current["state"]["status"] != "running" or self._closing:
1380
+ return
1381
+ failure: BaseException | None = None
1382
+ try:
1383
+ phase = {
1384
+ "snapshot": lambda: reservation["snapshot"],
1385
+ "task": lambda: reservation["task"],
1386
+ "agent": None,
1387
+ }
1388
+ runtime = _Runtime(self, invocation, phase)
1389
+ await _invoke_handler(reservation["task"].definition.abort, current, runtime, invocation.context)
1390
+ except BaseException as error:
1391
+ if not is_failure(error):
1392
+ raise
1393
+ failure = error
1394
+ message = f"Abort handler of task {invocation.task_id} returned without a terminal outcome"
1395
+ await self._step(
1396
+ invocation,
1397
+ lambda _tx, _current: {"fault": failure if failure is not None else RuntimeError(message)},
1398
+ )
1399
+
1400
+ async def _step(
1401
+ self,
1402
+ invocation: _Invocation,
1403
+ decide: Callable[[Transaction, ErasedRunningTask], Any],
1404
+ ) -> ErasedRunningTask | None:
1405
+ async def step(tx: Transaction) -> ErasedRunningTask | None:
1406
+ found = self._live.get(invocation.task_id)
1407
+ current = (
1408
+ cast(ErasedRunningTask, found)
1409
+ if found is not None and found["state"]["status"] == "running"
1410
+ else None
1411
+ )
1412
+ decision = decide(tx, current) if current is not None and not self._closing else False
1413
+ if decision is True:
1414
+ return current
1415
+ self._end(invocation)
1416
+ if decision is not False:
1417
+ message = error_message(decision["fault"])
1418
+ await self._terminate(
1419
+ tx,
1420
+ cast(AnyTaskRecord, current),
1421
+ {"status": "faulted", "error": {"message": message}},
1422
+ )
1423
+ return None
1424
+
1425
+ try:
1426
+ return await self._session.commit_with(step, self._context)
1427
+ except BaseException as error:
1428
+ self._end(invocation)
1429
+ if not is_failure(error):
1430
+ raise
1431
+ self._fail_session(error)
1432
+ return None
1433
+
1434
+ async def _terminate(self, tx: Transaction, record: AnyTaskRecord, outcome: Mapping[str, Any]) -> None:
1435
+ await self._load_scopes(False)
1436
+ if self._has_owned_live(record["id"], _overlay_of(tx)):
1437
+ tx.set_task(with_state(record, {"status": "completing", "outcome": outcome}))
1438
+ return
1439
+ tx.set_task(with_state(record, {"status": "terminal", "outcome": outcome}))
1440
+ await self._settle_outcome(tx, record, outcome)
1441
+
1442
+ async def _commit_state(
1443
+ self,
1444
+ tx: Transaction,
1445
+ invocation: _Invocation,
1446
+ current: ErasedRunningTask,
1447
+ following: NextTaskState,
1448
+ ) -> None:
1449
+ if following["status"] == "waiting":
1450
+ await self._validate_wait(tx, invocation, current, following["on"], following["policy"])
1451
+ if following["status"] == "terminal":
1452
+ overlay = _overlay_of(tx)
1453
+ await self._load_scopes(False)
1454
+ for record in overlay["tasks"].values():
1455
+ await self._load_chain(parent_of(record), overlay)
1456
+ if self._has_owned_live(current["id"], overlay):
1457
+ tx.set_task(with_state(current, {"status": "completing", "outcome": following["outcome"]}))
1458
+ return
1459
+ tx.set_task(with_state(current, following))
1460
+
1461
+ async def _validate_wait(
1462
+ self,
1463
+ tx: Transaction,
1464
+ invocation: _Invocation,
1465
+ current: ErasedRunningTask,
1466
+ on: Sequence[TaskId],
1467
+ policy: str,
1468
+ ) -> None:
1469
+ if invocation.mode == "abort":
1470
+ raise RuntimeError(f"Abort handler of task {current['id']} cannot wait")
1471
+ overlay = _overlay_of(tx)
1472
+ targets: list[TaskId] = []
1473
+ for id in on:
1474
+ member = (
1475
+ overlay["tasks"].get(id)
1476
+ or self._live.get(id)
1477
+ or (await self._storage.task(id, self._context))
1478
+ )
1479
+ if member is None:
1480
+ raise ValueError(f"Task {id} does not exist")
1481
+ if policy == "failFast" and member.get("owner") != current["id"]:
1482
+ raise ValueError(
1483
+ f"Task {current['id']} can wait failFast only on tasks it owns; {id} is not one"
1484
+ )
1485
+ if member["state"]["status"] != "terminal" and member.get("owner") != current["id"]:
1486
+ targets.append(id)
1487
+ if not targets:
1488
+ return
1489
+ cycle = await self._wait_cycle(current["id"], targets, overlay)
1490
+ if cycle is not None:
1491
+ path = " -> ".join(str(id) for id in [current["id"], *cycle])
1492
+ raise ValueError(f"Task {current['id']} cannot wait on {cycle[0]}: {path} is a wait cycle")
1493
+
1494
+ async def _wait_cycle(
1495
+ self, task: TaskId, on: Sequence[TaskId], overlay: dict[str, Any]
1496
+ ) -> list[TaskId] | None:
1497
+ await self._load_scopes(False)
1498
+ from_map: dict[TaskId, TaskId | None] = {}
1499
+ walked: set[TaskId] = set()
1500
+ pending: list[TaskId] = []
1501
+
1502
+ def reach(id: TaskId, via: TaskId | None) -> None:
1503
+ if id in from_map:
1504
+ return
1505
+ from_map[id] = via
1506
+ pending.append(id)
1507
+
1508
+ def path(end: TaskId) -> list[TaskId]:
1509
+ steps: list[TaskId] = []
1510
+ at: TaskId | None = end
1511
+ while at is not None:
1512
+ steps.insert(0, at)
1513
+ at = from_map[at]
1514
+ return steps
1515
+
1516
+ for id in on:
1517
+ reach(id, None)
1518
+ while pending:
1519
+ id = pending.pop()
1520
+ if id == task:
1521
+ return path(id)
1522
+ record = overlay["tasks"].get(id) or self._live.get(id)
1523
+ if record is None or record["state"]["status"] == "terminal":
1524
+ continue
1525
+ if record["state"]["status"] == "waiting" and not record.get("abortRequested"):
1526
+ for member in record["state"]["on"]:
1527
+ reach(member, id)
1528
+ if id in walked:
1529
+ continue
1530
+ walked.add(id)
1531
+ found: list[TaskId] = []
1532
+ for step in self._walk_down(id, overlay=overlay, enter=lambda below: below not in walked):
1533
+ if step["live"] is None or step["node"]["background"]:
1534
+ continue
1535
+ found.append(step["id"])
1536
+ reach(step["id"], id)
1537
+ walked.update(found)
1538
+ return None
1539
+
1540
+ def _end(self, invocation: _Invocation) -> None:
1541
+ if invocation.ended:
1542
+ return
1543
+ invocation.ended = True
1544
+ if self._invocations.get(invocation.task_id) is invocation:
1545
+ del self._invocations[invocation.task_id]
1546
+ if invocation.task_id in self._live:
1547
+ self._runnable.add(invocation.task_id)
1548
+ for watch in list(invocation.watches):
1549
+ watch.stop()
1550
+ invocation.controller.abort(_ended_error(invocation))
1551
+
1552
+ def _idle(self, conversation_id: ConversationId | None) -> bool:
1553
+ scope: Scope = {"roots": True} if conversation_id is None else {"conversation": conversation_id}
1554
+ for id in self._unloaded:
1555
+ record = self._live.get(id)
1556
+ if (
1557
+ record is not None
1558
+ and not record["background"]
1559
+ and self._in_scope(parent_of(record), scope) is not False
1560
+ ):
1561
+ return False
1562
+ starts = self._roots if conversation_id is None else [conversation_id]
1563
+ for start in starts:
1564
+ for step in self._walk_down(conversation_node(start)):
1565
+ if step["live"] is not None and not step["node"]["background"]:
1566
+ return False
1567
+ return True
1568
+
1569
+ # ── Invocation runtime ──────────────────────────────────────────────────
1570
+
1571
+
1572
+ class _HookRunner:
1573
+ def __init__(self, scheduler: TaskScheduler, invocation: _Invocation, phase: dict[str, Any]) -> None:
1574
+ self._scheduler = scheduler
1575
+ self._invocation = invocation
1576
+ self._phase = phase
1577
+
1578
+ async def each(self, name: str, invoke: Callable[[Any], Any]) -> None:
1579
+ agent = await self._agent()
1580
+ for handlers in agent_hooks(agent, self._phase["task"]().definition.name):
1581
+ handler = handlers.get(name)
1582
+ if not callable(handler):
1583
+ continue
1584
+ try:
1585
+ result = invoke(handler)
1586
+ if hasattr(result, "__await__"):
1587
+ await cast(Awaitable[Any], result)
1588
+ except BaseException as error:
1589
+ if not is_failure(error) or self._invocation.controller.signal.aborted:
1590
+ raise
1591
+ self._scheduler._report(error)
1592
+
1593
+ async def _agent(self) -> Agent:
1594
+ runtime = _Runtime(self._scheduler, self._invocation, self._phase)
1595
+ return await runtime.agent(self._invocation.context)
1596
+
1597
+
1598
+ class _Runtime:
1599
+ """What a phase handler may do: committed reads, gated commits, waits, and the Harness clock."""
1600
+
1601
+ def __init__(self, scheduler: TaskScheduler, invocation: _Invocation, phase: dict[str, Any]) -> None:
1602
+ self._scheduler = scheduler
1603
+ self._invocation = invocation
1604
+ self._phase = phase
1605
+ self.task_id = invocation.task_id
1606
+ self.conversation_id = invocation.conversation_id
1607
+ self.signal = invocation.controller.signal
1608
+ self.models = scheduler._models
1609
+
1610
+ @property
1611
+ def registry(self) -> RegistrySnapshot:
1612
+ self._check()
1613
+ return self._phase["snapshot"]()
1614
+
1615
+ @property
1616
+ def settings(self) -> Settings:
1617
+ return self._scheduler._settings()
1618
+
1619
+ async def agent(self, context: Context) -> Agent:
1620
+ self._check()
1621
+ if self._phase["agent"] is None:
1622
+ future = asyncio.ensure_future(
1623
+ self._scheduler._agent(
1624
+ self._invocation.conversation_id, self._phase["snapshot"](), self._invocation.context
1625
+ )
1626
+ )
1627
+ future.add_done_callback(lambda f: f.exception() if not f.cancelled() else None)
1628
+ self._phase["agent"] = future
1629
+ return await await_with_context(self._phase["agent"], context)
1630
+
1631
+ async def env(self, context: Context) -> Any:
1632
+ self._check()
1633
+ return await self._scheduler._env(self._invocation.conversation_id, context)
1634
+
1635
+ @property
1636
+ def hooks(self) -> _HookRunner:
1637
+ return _HookRunner(self._scheduler, self._invocation, self._phase)
1638
+
1639
+ def commit(self, change: Callable[..., Any], context: Context) -> asyncio.Future[None]:
1640
+ async def run() -> None:
1641
+ async def apply(tx: Transaction, current: ErasedRunningTask) -> None:
1642
+ result = _invoke_change(change, tx, current)
1643
+ if hasattr(result, "__await__"):
1644
+ following = await cast(Awaitable[Any], result)
1645
+ else:
1646
+ following = result
1647
+ if following is not None:
1648
+ await self._scheduler._commit_state(tx, self._invocation, current, following)
1649
+
1650
+ await self._gated(apply, context)
1651
+
1652
+ commit = asyncio.ensure_future(run())
1653
+ self._invocation.commits.add(commit)
1654
+ commit.add_done_callback(lambda done: self._invocation.commits.discard(done))
1655
+ return commit
1656
+
1657
+ async def memo(self, name: str, *args: Any) -> JsonValue | None:
1658
+ if len(args) == 1:
1659
+ context = args[0]
1660
+
1661
+ def read() -> JsonValue | None:
1662
+ winner = memo_of(self._scheduler._live.get(self._invocation.task_id), name)
1663
+ return None if winner is _MISSING_MEMO else cast(JsonValue, winner)
1664
+
1665
+ return await self._read(lambda: resolved(read()), context)
1666
+ candidate, context = args
1667
+
1668
+ def write(tx: Transaction, current: ErasedRunningTask) -> JsonValue | None:
1669
+ winner = memo_of(current, name)
1670
+ if winner is not _MISSING_MEMO:
1671
+ return cast(JsonValue, winner)
1672
+ record = dict(current)
1673
+ record["memos"] = {**(current.get("memos") or {}), name: candidate}
1674
+ tx.set_task(cast(AnyTaskRecord, record))
1675
+ return cast(JsonValue, candidate)
1676
+
1677
+ return await self._gated(write, context)
1678
+
1679
+ async def sleep(self, until: int, context: Context) -> None:
1680
+ self._check()
1681
+ signals = [self._invocation.controller.signal]
1682
+ if context.abort_signal is not None:
1683
+ signals.append(context.abort_signal)
1684
+ signal = AbortSignal.any(signals)
1685
+ while True:
1686
+ if signal.aborted:
1687
+ raise _cancellation_error(signal)
1688
+ remaining = until - self._scheduler._now()
1689
+ if remaining <= 0:
1690
+ return
1691
+ await _delay(min(remaining, MAX_TIMER_DELAY_MS), signal)
1692
+
1693
+ async def watch_doc(self, *args: Any) -> Any:
1694
+ self._check()
1695
+ watch = await self._scheduler._session.watch_doc(*args)
1696
+ if watch is None:
1697
+ return None
1698
+ if self._invocation.ended:
1699
+ watch.stop()
1700
+ raise _ended_error(self._invocation)
1701
+ self._invocation.watches.add(watch)
1702
+
1703
+ def remove(_: Any) -> None:
1704
+ self._invocation.watches.discard(watch)
1705
+
1706
+ watch.closed.add_done_callback(remove)
1707
+ return watch
1708
+
1709
+ async def snapshot(self, *args: Any) -> Any:
1710
+ self._check()
1711
+ return await self._scheduler._session.snapshot(*args)
1712
+
1713
+ async def snapshot_as_of(self, *args: Any) -> Any:
1714
+ self._check()
1715
+ return await self._scheduler._session.snapshot_as_of(*args)
1716
+
1717
+ async def get_task(self, id: TaskId, context: Context) -> TaskRecord | None:
1718
+ self._check()
1719
+ return await self._read(
1720
+ lambda: self._scheduler._session.read_on_line(lambda: self._scheduler._storage.task(id, context)),
1721
+ context,
1722
+ )
1723
+
1724
+ async def wait_for_task(self, id: TaskId, context: Context) -> SettledTask:
1725
+ self._check()
1726
+ bound = with_abort_signal(self._invocation.controller.signal, context)
1727
+ return await self._read(
1728
+ lambda: self._scheduler.wait_for_task(id, bound),
1729
+ context,
1730
+ )
1731
+
1732
+ async def owned_tasks(self, context: Context) -> list[TaskRecord]:
1733
+ self._check()
1734
+
1735
+ async def read() -> list[TaskRecord]:
1736
+ if self._invocation.ended:
1737
+ raise _ended_error(self._invocation)
1738
+ if context.abort_signal is not None:
1739
+ context.abort_signal.raise_if_aborted()
1740
+ owned: list[TaskRecord] = []
1741
+ for node in self._scheduler._below.get(self._invocation.task_id, set()):
1742
+ if isinstance(node, str):
1743
+ continue
1744
+ record = self._scheduler._live.get(node)
1745
+ if record is not None and record.get("owner") == self._invocation.task_id:
1746
+ owned.append(record)
1747
+ return sorted(
1748
+ (cast(TaskRecord, copy_json(record)) for record in owned),
1749
+ key=lambda record: record["id"],
1750
+ )
1751
+
1752
+ return await self._read(
1753
+ lambda: self._scheduler._session.read_on_line(read),
1754
+ context,
1755
+ )
1756
+
1757
+ async def abort_owned(self, id: TaskId, context: Context) -> None:
1758
+ self._check()
1759
+ bound = with_abort_signal(self._invocation.controller.signal, context)
1760
+
1761
+ async def abort() -> None:
1762
+ record = await self._scheduler._session.read_on_line(
1763
+ lambda: self._scheduler._storage.task(id, bound)
1764
+ )
1765
+ if record is None or record.get("owner") != self._invocation.task_id:
1766
+ raise ValueError(f"Task {id} is not owned by task {self._invocation.task_id}")
1767
+ if record["state"]["status"] == "terminal":
1768
+ return
1769
+ await self._scheduler.abort(id, bound, True)
1770
+ await self._scheduler.wait_for_task(id, bound)
1771
+
1772
+ await self._read(abort, context)
1773
+
1774
+ async def outcomes(self, ids: Sequence[TaskId], context: Context) -> list[TaskOutcome]:
1775
+ self._check()
1776
+
1777
+ async def read() -> list[TaskOutcome]:
1778
+ outcomes: list[TaskOutcome] = []
1779
+ for id in ids:
1780
+ record = await self._scheduler._storage.task(id, context)
1781
+ state = None if record is None else record["state"]
1782
+ if state is None or state["status"] != "terminal":
1783
+ raise ValueError(f"Task {id} is not terminal")
1784
+ outcomes.append(state["outcome"])
1785
+ return outcomes
1786
+
1787
+ return await self._read(
1788
+ lambda: self._scheduler._session.read_on_line(read),
1789
+ context,
1790
+ )
1791
+
1792
+ async def conversation(self, id: ConversationId, context: Context) -> ConversationHandle | None:
1793
+ self._check()
1794
+
1795
+ async def read() -> Any:
1796
+ binding = {
1797
+ "signal": self._invocation.controller.signal,
1798
+ "check": lambda: self._check(),
1799
+ }
1800
+ return await self._scheduler._conversation(id, binding, context)
1801
+
1802
+ return await self._read(read, context)
1803
+
1804
+ async def entry(self, *args: Any) -> Any:
1805
+ self._check()
1806
+ if len(args) == 2:
1807
+ token, id, context = None, args[0], args[1]
1808
+ else:
1809
+ token, id, context = args[0], args[1], args[2]
1810
+
1811
+ async def read() -> Any:
1812
+ found = await self._scheduler._session.read_on_line(
1813
+ lambda: self._scheduler._storage.entry(self._invocation.conversation_id, id, context)
1814
+ )
1815
+ entry = None if found is None else found["entry"]
1816
+ if token is None or entry is None or entry.get("kind") == token.kind:
1817
+ return entry
1818
+ return None
1819
+
1820
+ return await self._read(read, context)
1821
+
1822
+ async def context(self, conversation_id: ConversationId, context: Context, *args: Any) -> Any:
1823
+ self._check()
1824
+ options = args[0] if args else None
1825
+ at = options.get("at") if isinstance(options, Mapping) else None
1826
+ kept = self._scheduler._contexts.get(conversation_id)
1827
+
1828
+ async def read() -> Any:
1829
+ view, context_range = await read_context_from(
1830
+ self._scheduler._session,
1831
+ self._scheduler._storage,
1832
+ conversation_id,
1833
+ context,
1834
+ at,
1835
+ None if kept is None else kept["range"],
1836
+ )
1837
+ current = self._scheduler._contexts.get(conversation_id)
1838
+ if (
1839
+ context_range is not None
1840
+ and not self._scheduler._closing
1841
+ and not self._invocation.ended
1842
+ and (current is None or current["range"]["bounds"]["tail"] <= context_range["bounds"]["tail"])
1843
+ ):
1844
+ if not self._scheduler._idle(conversation_id):
1845
+ self._scheduler._contexts[conversation_id] = {
1846
+ "range": context_range,
1847
+ "idle_since": None,
1848
+ }
1849
+ elif self._scheduler._context_retention_ms() > 0:
1850
+ self._scheduler._contexts[conversation_id] = {
1851
+ "range": context_range,
1852
+ "idle_since": (
1853
+ current["idle_since"]
1854
+ if current is not None and current["idle_since"] is not None
1855
+ else self._scheduler._now()
1856
+ ),
1857
+ }
1858
+ self._scheduler._schedule_expiry()
1859
+ return view
1860
+
1861
+ return await self._read(read, context)
1862
+
1863
+ def now(self) -> int:
1864
+ self._check()
1865
+ return self._scheduler._now()
1866
+
1867
+ def report(self, error: BaseException) -> None:
1868
+ self._check()
1869
+ self._scheduler._report(error)
1870
+
1871
+ async def _read(self, read: Callable[[], Awaitable[Any]], context: Context) -> Any:
1872
+ self._check()
1873
+ result = await read()
1874
+ return await await_with_context(resolved(result), context)
1875
+
1876
+ async def _gated(
1877
+ self,
1878
+ change: Callable[[Transaction, ErasedRunningTask], Awaitable[Any] | Any],
1879
+ context: Context,
1880
+ ) -> Any:
1881
+ if self._invocation.ended:
1882
+ raise _ended_error(self._invocation)
1883
+
1884
+ async def apply(tx: Transaction) -> Any:
1885
+ if self._invocation.ended:
1886
+ raise _ended_error(self._invocation)
1887
+ if self._scheduler._closing:
1888
+ raise closed_error(self._scheduler._session)
1889
+ found = self._scheduler._live.get(self._invocation.task_id)
1890
+ if found is None:
1891
+ raise ValueError(f"Task {self._invocation.task_id} is terminal")
1892
+ if found["state"]["status"] != "running":
1893
+ raise ValueError(f"Task {self._invocation.task_id} is {found['state']['status']}")
1894
+ current = cast(ErasedRunningTask, found)
1895
+ if self._invocation.mode == "run" and current.get("abortRequested"):
1896
+ raise ValueError(f"Task {self._invocation.task_id} has a durable abort mark")
1897
+ result = change(tx, current)
1898
+ if hasattr(result, "__await__"):
1899
+ result = await cast(Awaitable[Any], result)
1900
+ return result
1901
+
1902
+ from ..session.transaction import TransactionScope
1903
+
1904
+ try:
1905
+ return await self._scheduler._session.commit_with(
1906
+ apply,
1907
+ context,
1908
+ TransactionScope(
1909
+ conversation_id=self._invocation.conversation_id,
1910
+ task_id=self._invocation.task_id,
1911
+ ),
1912
+ )
1913
+ except BaseException:
1914
+ # Close seals admission, then signals handlers. A commit that was queued on the
1915
+ # line rejects as closed, not with its handler's cancellation reason.
1916
+ if self._scheduler._closing:
1917
+ raise closed_error(self._scheduler._session) from None
1918
+ raise
1919
+
1920
+ def _check(self) -> None:
1921
+ if self._invocation.ended:
1922
+ raise _ended_error(self._invocation)
1923
+
1924
+
1925
+ def _invoke_change(change: Callable[..., Any], tx: Transaction, current: ErasedRunningTask) -> Any:
1926
+ """Call a commit callback with the arguments it declares, as JavaScript ignores extras."""
1927
+
1928
+ try:
1929
+ parameters = list(inspect.signature(change).parameters.values())
1930
+ except (TypeError, ValueError):
1931
+ return change(tx, current)
1932
+ positional = [
1933
+ parameter
1934
+ for parameter in parameters
1935
+ if parameter.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
1936
+ ]
1937
+ if (
1938
+ any(parameter.kind is inspect.Parameter.VAR_POSITIONAL for parameter in parameters)
1939
+ or len(positional) >= 2
1940
+ ):
1941
+ return change(tx, current)
1942
+ if len(positional) == 1:
1943
+ return change(tx)
1944
+ return change()
1945
+
1946
+
1947
+ async def _invoke_handler(handler: Any, task: Any, runtime: Any, context: Context) -> Any:
1948
+ result = handler(task, runtime, context)
1949
+ if hasattr(result, "__await__"):
1950
+ return await cast(Awaitable[Any], result)
1951
+ return result
1952
+
1953
+
1954
+ async def _race(first: Awaitable[Any], second: Awaitable[Any]) -> Any:
1955
+ tasks = [asyncio.ensure_future(first), asyncio.ensure_future(second)]
1956
+ done, _pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
1957
+ for task in done:
1958
+ return task.result()
1959
+ raise AssertionError("unreachable")
1960
+
1961
+
1962
+ __all__ = [
1963
+ "LIVE_STATUSES",
1964
+ "TaskScheduler",
1965
+ "can_reserve",
1966
+ "cancellation_intent",
1967
+ "conversation_node",
1968
+ "conversation_of",
1969
+ "failed_outcome",
1970
+ "json_equal",
1971
+ "memo_of",
1972
+ "node_of",
1973
+ "parent_node",
1974
+ "parent_of",
1975
+ "with_abort_mark",
1976
+ "with_state",
1977
+ ]