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.
- pi_durable/__init__.py +320 -0
- pi_durable/context.py +328 -0
- pi_durable/delta.py +269 -0
- pi_durable/documents.py +366 -0
- pi_durable/entries.py +66 -0
- pi_durable/env/__init__.py +408 -0
- pi_durable/env/decode.py +48 -0
- pi_durable/env/line_scan.py +190 -0
- pi_durable/env/posix.py +1547 -0
- pi_durable/errors.py +89 -0
- pi_durable/harness/__init__.py +1 -0
- pi_durable/harness/agent.py +344 -0
- pi_durable/harness/compaction.py +673 -0
- pi_durable/harness/context.py +515 -0
- pi_durable/harness/define.py +111 -0
- pi_durable/harness/events.py +766 -0
- pi_durable/harness/generation.py +1079 -0
- pi_durable/harness/harness.py +570 -0
- pi_durable/harness/inbox.py +255 -0
- pi_durable/harness/live.py +374 -0
- pi_durable/harness/output.py +467 -0
- pi_durable/harness/prompt.py +288 -0
- pi_durable/harness/provider.py +78 -0
- pi_durable/harness/registry.py +136 -0
- pi_durable/harness/scheduler.py +1977 -0
- pi_durable/harness/submissions.py +357 -0
- pi_durable/harness/task_graph.py +334 -0
- pi_durable/harness/tool.py +1365 -0
- pi_durable/harness/types.py +772 -0
- pi_durable/harness/usage.py +88 -0
- pi_durable/harness/util.py +204 -0
- pi_durable/harness/validation.py +460 -0
- pi_durable/harness/view.py +324 -0
- pi_durable/ids.py +63 -0
- pi_durable/json.py +186 -0
- pi_durable/providers/__init__.py +362 -0
- pi_durable/providers/anthropic.py +603 -0
- pi_durable/providers/base.py +97 -0
- pi_durable/providers/json_parse.py +117 -0
- pi_durable/providers/openai.py +574 -0
- pi_durable/providers/request.py +35 -0
- pi_durable/providers/retry.py +127 -0
- pi_durable/providers/stream.py +183 -0
- pi_durable/providers/transcript.py +224 -0
- pi_durable/providers/usage.py +58 -0
- pi_durable/py.typed +0 -0
- pi_durable/search.py +115 -0
- pi_durable/session/__init__.py +5 -0
- pi_durable/session/drafts.py +762 -0
- pi_durable/session/forks.py +109 -0
- pi_durable/session/observation.py +374 -0
- pi_durable/session/session.py +1193 -0
- pi_durable/session/transaction.py +1088 -0
- pi_durable/storage/__init__.py +5 -0
- pi_durable/storage/jsonl/__init__.py +15 -0
- pi_durable/storage/jsonl/storage.py +958 -0
- pi_durable/storage/memory.py +889 -0
- pi_durable/storage/scan.py +57 -0
- pi_durable/storage/sqlite/__init__.py +26 -0
- pi_durable/storage/sqlite/database.py +62 -0
- pi_durable/storage/sqlite/migrations.py +191 -0
- pi_durable/storage/sqlite/posix.py +314 -0
- pi_durable/storage/sqlite/schema.py +97 -0
- pi_durable/storage/sqlite/storage.py +1130 -0
- pi_durable/tasks.py +89 -0
- pi_durable/testing/__init__.py +41 -0
- pi_durable/testing/assertions.py +44 -0
- pi_durable/testing/env_conformance.py +828 -0
- pi_durable/testing/runner.py +64 -0
- pi_durable/testing/storage_conformance.py +2008 -0
- pi_durable/testing/types.py +99 -0
- pi_durable/tool_search/__init__.py +159 -0
- pi_durable/tools/__init__.py +81 -0
- pi_durable/tools/_values.py +43 -0
- pi_durable/tools/bash.py +250 -0
- pi_durable/tools/edit.py +214 -0
- pi_durable/tools/edit_diff.py +452 -0
- pi_durable/tools/env.py +19 -0
- pi_durable/tools/file_mutation_queue.py +68 -0
- pi_durable/tools/image.py +248 -0
- pi_durable/tools/image_processor.py +77 -0
- pi_durable/tools/path_utils.py +111 -0
- pi_durable/tools/read.py +506 -0
- pi_durable/tools/write.py +71 -0
- pi_durable/truncate.py +185 -0
- pi_durable/types.py +1297 -0
- pi_durable_python-1.3.0.dist-info/METADATA +276 -0
- pi_durable_python-1.3.0.dist-info/RECORD +90 -0
- pi_durable_python-1.3.0.dist-info/WHEEL +4 -0
- 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
|
+
]
|