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,357 @@
|
|
|
1
|
+
"""Submission admission, waits, and withdrawal (spec section 6).
|
|
2
|
+
|
|
3
|
+
``Conversation.submit()`` durably admits an input or passive write in one commit; the returned
|
|
4
|
+
handle tracks its settlement. Admission decides between immediate placement and the inbox; a
|
|
5
|
+
boundary later places queued items, and run control lives in ``pi.live``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
11
|
+
from typing import Any, Literal, cast
|
|
12
|
+
|
|
13
|
+
from ..context import Context
|
|
14
|
+
from ..errors import ConversationBusy
|
|
15
|
+
from ..ids import ConversationId, SubmissionId
|
|
16
|
+
from ..json import JsonValue, copy_json
|
|
17
|
+
from ..session.drafts import DocumentDraft
|
|
18
|
+
from ..session.transaction import Transaction
|
|
19
|
+
from ..types import EntryDraft, SubmissionCreate, SubmissionRecord
|
|
20
|
+
from .inbox import (
|
|
21
|
+
InboxDoc,
|
|
22
|
+
QueueModes,
|
|
23
|
+
append_input,
|
|
24
|
+
apply_boundary,
|
|
25
|
+
has_queued_reset,
|
|
26
|
+
is_display_only,
|
|
27
|
+
is_stale,
|
|
28
|
+
pick_input_entry,
|
|
29
|
+
prepare_boundary,
|
|
30
|
+
remove_inbox_item,
|
|
31
|
+
)
|
|
32
|
+
from .live import LiveDoc
|
|
33
|
+
from .types import InputEntry, SettledSubmissionRecord, Submission, SubmissionDraft
|
|
34
|
+
from .util import Waiters, closed_error, resolved
|
|
35
|
+
|
|
36
|
+
AbortResult = Literal["aborted", "already_placed", "settled"]
|
|
37
|
+
|
|
38
|
+
StartRun = Callable[[Transaction, ConversationId, DocumentDraft, list[SubmissionId]], Awaitable[None]]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _type(draft: Mapping[str, Any]) -> Any:
|
|
42
|
+
return draft.get("type")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _request_id(draft: Mapping[str, Any]) -> str | None:
|
|
46
|
+
value = draft.get("request_id", draft.get("requestId"))
|
|
47
|
+
if value is None:
|
|
48
|
+
return None
|
|
49
|
+
if not isinstance(value, str):
|
|
50
|
+
raise TypeError("A submission request ID must be a string")
|
|
51
|
+
return value
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _when_busy(draft: Mapping[str, Any]) -> str | None:
|
|
55
|
+
return draft.get("when_busy", draft.get("whenBusy"))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def is_settled(record: SubmissionRecord) -> bool:
|
|
59
|
+
"""Whether a submission reached ``done`` or ``unanswered``."""
|
|
60
|
+
|
|
61
|
+
return record["status"] in ("done", "unanswered")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def check_input_entry(entry: InputEntry) -> None:
|
|
65
|
+
"""Reject an input entry whose kind is not a non-empty string outside the ``pi.`` prefix."""
|
|
66
|
+
|
|
67
|
+
kind = entry.get("kind")
|
|
68
|
+
if not isinstance(kind, str) or not kind or kind.startswith("pi."):
|
|
69
|
+
raise TypeError(
|
|
70
|
+
"An input entry's kind must be a non-empty string not starting with \"pi.\", "
|
|
71
|
+
f"got {kind!r}"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _clean_entry(entry: Mapping[str, Any]) -> dict[str, JsonValue]:
|
|
76
|
+
"""Store the fields the caller supplied as strict JSON, preserving explicit JSON nulls."""
|
|
77
|
+
|
|
78
|
+
return {key: copy_json(value) for key, value in entry.items()}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Submissions:
|
|
82
|
+
"""Admission, waits, and withdrawal of the durable submissions of one Harness."""
|
|
83
|
+
|
|
84
|
+
def __init__(
|
|
85
|
+
self,
|
|
86
|
+
session: Any,
|
|
87
|
+
now: Callable[[], int],
|
|
88
|
+
queue_modes: Callable[[], QueueModes],
|
|
89
|
+
resume: Callable[[], None],
|
|
90
|
+
start_run: StartRun,
|
|
91
|
+
) -> None:
|
|
92
|
+
self._session = session
|
|
93
|
+
self._now = now
|
|
94
|
+
self._queue_modes = queue_modes
|
|
95
|
+
self._resume = resume
|
|
96
|
+
self._start_run = start_run
|
|
97
|
+
self._waiters: Waiters[SubmissionId, SettledSubmissionRecord] = Waiters()
|
|
98
|
+
self._closed = False
|
|
99
|
+
session.observe_commits(self._observe)
|
|
100
|
+
session.subscribe_close(self._close)
|
|
101
|
+
|
|
102
|
+
async def submit(
|
|
103
|
+
self, conversation_id: ConversationId, draft: SubmissionDraft, context: Context
|
|
104
|
+
) -> Submission:
|
|
105
|
+
self._resume()
|
|
106
|
+
value = cast(Mapping[str, Any], draft)
|
|
107
|
+
|
|
108
|
+
async def admit(tx: Transaction) -> SubmissionId:
|
|
109
|
+
return await admit_submission(
|
|
110
|
+
tx,
|
|
111
|
+
conversation_id,
|
|
112
|
+
value,
|
|
113
|
+
self._now(),
|
|
114
|
+
self._queue_modes(),
|
|
115
|
+
self._start_run,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
id = await self._session.commit_with(admit, context)
|
|
119
|
+
return SubmissionHandle(cast(SubmissionId, id), self)
|
|
120
|
+
|
|
121
|
+
async def _record(self, id: SubmissionId, context: Context) -> SubmissionRecord | None:
|
|
122
|
+
return await self._session.read_on_line(
|
|
123
|
+
lambda: self._session.storage.submission(id, context)
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
async def get(self, id: SubmissionId, context: Context) -> Submission | None:
|
|
127
|
+
record = await self._record(id, context)
|
|
128
|
+
return None if record is None else SubmissionHandle(record["id"], self)
|
|
129
|
+
|
|
130
|
+
async def status(self, id: SubmissionId, context: Context) -> SubmissionRecord:
|
|
131
|
+
record = await self._record(id, context)
|
|
132
|
+
if record is None:
|
|
133
|
+
raise ValueError(f"Submission {id} does not exist")
|
|
134
|
+
return record
|
|
135
|
+
|
|
136
|
+
async def wait(self, id: SubmissionId, context: Context) -> SettledSubmissionRecord:
|
|
137
|
+
self._resume()
|
|
138
|
+
|
|
139
|
+
async def find() -> dict[str, Any]:
|
|
140
|
+
record = await self._session.storage.submission(id, context)
|
|
141
|
+
if record is None:
|
|
142
|
+
raise ValueError(f"Submission {id} does not exist")
|
|
143
|
+
if is_settled(record):
|
|
144
|
+
return {"promise": resolved(cast(SettledSubmissionRecord, record))}
|
|
145
|
+
if self._closed:
|
|
146
|
+
raise closed_error(self._session)
|
|
147
|
+
return {"promise": self._waiters.add(id, context)}
|
|
148
|
+
|
|
149
|
+
found = await self._session.read_on_line(find)
|
|
150
|
+
return cast(SettledSubmissionRecord, await found["promise"])
|
|
151
|
+
|
|
152
|
+
async def abort(
|
|
153
|
+
self, id: SubmissionId, context: Context, conversation_id: ConversationId | None = None
|
|
154
|
+
) -> AbortResult | Literal["not_found"]:
|
|
155
|
+
async def withdraw(tx: Transaction) -> AbortResult | Literal["not_found"]:
|
|
156
|
+
record = await tx.submission(id)
|
|
157
|
+
if record is None or (
|
|
158
|
+
conversation_id is not None and record["conversationId"] != conversation_id
|
|
159
|
+
):
|
|
160
|
+
return "not_found"
|
|
161
|
+
if record["status"] == "queued":
|
|
162
|
+
tx.settle_submission(id, {"status": "unanswered", "reason": "aborted"})
|
|
163
|
+
await remove_inbox_item(tx, record["conversationId"], id)
|
|
164
|
+
return "aborted"
|
|
165
|
+
return "already_placed" if record["status"] == "placed" else "settled"
|
|
166
|
+
|
|
167
|
+
return await self._session.commit_with(withdraw, context)
|
|
168
|
+
|
|
169
|
+
def _observe(self, publication: Any, _context: Context) -> None:
|
|
170
|
+
for change in publication["changes"]:
|
|
171
|
+
if change["type"] != "submission":
|
|
172
|
+
continue
|
|
173
|
+
value = change["value"]
|
|
174
|
+
if is_settled(value):
|
|
175
|
+
self._waiters.resolve(value["id"], cast(SettledSubmissionRecord, value))
|
|
176
|
+
|
|
177
|
+
def _close(self) -> None:
|
|
178
|
+
self._closed = True
|
|
179
|
+
self._waiters.reject_all(closed_error(self._session))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class SubmissionHandle:
|
|
183
|
+
"""One admitted submission, tracked by the Harness that returned it."""
|
|
184
|
+
|
|
185
|
+
__slots__ = ("id", "_submissions")
|
|
186
|
+
|
|
187
|
+
def __init__(self, id: SubmissionId, submissions: Submissions) -> None:
|
|
188
|
+
self.id = id
|
|
189
|
+
self._submissions = submissions
|
|
190
|
+
|
|
191
|
+
async def status(self, context: Context) -> SubmissionRecord:
|
|
192
|
+
return await self._submissions.status(self.id, context)
|
|
193
|
+
|
|
194
|
+
async def wait(self, context: Context) -> SettledSubmissionRecord:
|
|
195
|
+
return await self._submissions.wait(self.id, context)
|
|
196
|
+
|
|
197
|
+
async def abort(self, context: Context) -> AbortResult:
|
|
198
|
+
result = await self._submissions.abort(self.id, context)
|
|
199
|
+
if result == "not_found":
|
|
200
|
+
raise ValueError(f"Submission {self.id} does not exist")
|
|
201
|
+
return result
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
async def admit_submission(
|
|
205
|
+
tx: Transaction,
|
|
206
|
+
conversation_id: ConversationId,
|
|
207
|
+
draft: Mapping[str, Any],
|
|
208
|
+
now: int,
|
|
209
|
+
queue_modes: QueueModes,
|
|
210
|
+
start_run: StartRun,
|
|
211
|
+
) -> SubmissionId:
|
|
212
|
+
"""Admit one submission inside a commit (spec section 6)."""
|
|
213
|
+
|
|
214
|
+
kind = _type(draft)
|
|
215
|
+
if kind not in ("input", "write"):
|
|
216
|
+
raise TypeError(f"Submission draft type must be 'input' or 'write', got {kind!r}")
|
|
217
|
+
entry = draft.get("entry")
|
|
218
|
+
if kind == "input" and entry is not None:
|
|
219
|
+
if not isinstance(entry, Mapping):
|
|
220
|
+
raise TypeError("An input entry must be an object")
|
|
221
|
+
check_input_entry(cast(InputEntry, entry))
|
|
222
|
+
request_id = _request_id(draft)
|
|
223
|
+
if request_id is not None:
|
|
224
|
+
existing = await tx.submission_by_request(conversation_id, request_id)
|
|
225
|
+
if existing is not None:
|
|
226
|
+
if existing["type"] != kind:
|
|
227
|
+
raise ValueError(
|
|
228
|
+
f"Request {request_id} already identifies a submission of type {existing['type']}"
|
|
229
|
+
)
|
|
230
|
+
return existing["id"]
|
|
231
|
+
request = {} if request_id is None else {"requestId": request_id}
|
|
232
|
+
|
|
233
|
+
display_only = False
|
|
234
|
+
if kind == "write":
|
|
235
|
+
if not isinstance(entry, Mapping):
|
|
236
|
+
raise TypeError("A write submission requires an entry draft")
|
|
237
|
+
inbox = await tx.doc(InboxDoc, conversation_id)
|
|
238
|
+
display_only = is_display_only(cast(Any, entry))
|
|
239
|
+
if display_only and not has_queued_reset(inbox["items"]):
|
|
240
|
+
return await _place_write(tx, conversation_id, request, entry)
|
|
241
|
+
|
|
242
|
+
live = await tx.doc(LiveDoc, conversation_id)
|
|
243
|
+
busy = live.get("run") is not None
|
|
244
|
+
when_busy = _when_busy(draft)
|
|
245
|
+
if busy and kind == "input" and when_busy == "reject":
|
|
246
|
+
raise ConversationBusy(conversation_id)
|
|
247
|
+
|
|
248
|
+
boundary = None if busy else await prepare_boundary(tx, conversation_id, queue_modes)
|
|
249
|
+
if boundary is None or len(boundary.inbox["items"]) > 0:
|
|
250
|
+
inbox = boundary.inbox if boundary is not None else await tx.doc(InboxDoc, conversation_id)
|
|
251
|
+
record = await tx.create_submission(
|
|
252
|
+
cast(
|
|
253
|
+
SubmissionCreate,
|
|
254
|
+
{
|
|
255
|
+
"conversationId": conversation_id,
|
|
256
|
+
**request,
|
|
257
|
+
"type": kind,
|
|
258
|
+
"status": "queued",
|
|
259
|
+
},
|
|
260
|
+
)
|
|
261
|
+
)
|
|
262
|
+
id = record["id"]
|
|
263
|
+
if kind == "write":
|
|
264
|
+
inbox["items"].append(
|
|
265
|
+
{"id": id, "mode": "write", "entry": _clean_entry(cast(Mapping[str, Any], entry))}
|
|
266
|
+
)
|
|
267
|
+
else:
|
|
268
|
+
mode = "steer" if when_busy == "steer" else "followUp"
|
|
269
|
+
item: dict[str, Any] = {
|
|
270
|
+
"id": id,
|
|
271
|
+
"mode": mode,
|
|
272
|
+
"content": copy_json(draft.get("content")),
|
|
273
|
+
}
|
|
274
|
+
if entry is not None:
|
|
275
|
+
item["entry"] = pick_input_entry(cast(InputEntry, entry))
|
|
276
|
+
inbox["items"].append(item)
|
|
277
|
+
if boundary is None or display_only:
|
|
278
|
+
return id
|
|
279
|
+
placed = await apply_boundary(tx, boundary, "final", now)
|
|
280
|
+
if placed.users:
|
|
281
|
+
await start_run(tx, conversation_id, live, placed.users)
|
|
282
|
+
return id
|
|
283
|
+
|
|
284
|
+
entry = cast(Mapping[str, Any], entry) if entry is not None else None
|
|
285
|
+
if kind == "write":
|
|
286
|
+
assert entry is not None
|
|
287
|
+
if is_stale(boundary, cast(Any, entry)):
|
|
288
|
+
record = await tx.create_submission(
|
|
289
|
+
cast(
|
|
290
|
+
SubmissionCreate,
|
|
291
|
+
{
|
|
292
|
+
"conversationId": conversation_id,
|
|
293
|
+
**request,
|
|
294
|
+
"type": "write",
|
|
295
|
+
"status": "unanswered",
|
|
296
|
+
"reason": "stale",
|
|
297
|
+
},
|
|
298
|
+
)
|
|
299
|
+
)
|
|
300
|
+
return record["id"]
|
|
301
|
+
return await _place_write(tx, conversation_id, request, entry)
|
|
302
|
+
|
|
303
|
+
placed_entry = await append_input(
|
|
304
|
+
tx,
|
|
305
|
+
conversation_id,
|
|
306
|
+
copy_json(draft.get("content")),
|
|
307
|
+
None if entry is None else cast(InputEntry, entry),
|
|
308
|
+
now,
|
|
309
|
+
)
|
|
310
|
+
record = await tx.create_submission(
|
|
311
|
+
cast(
|
|
312
|
+
SubmissionCreate,
|
|
313
|
+
{
|
|
314
|
+
"conversationId": conversation_id,
|
|
315
|
+
**request,
|
|
316
|
+
"type": "input",
|
|
317
|
+
"status": "placed",
|
|
318
|
+
"entry": placed_entry["id"],
|
|
319
|
+
},
|
|
320
|
+
)
|
|
321
|
+
)
|
|
322
|
+
await start_run(tx, conversation_id, live, [record["id"]])
|
|
323
|
+
return record["id"]
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
async def _place_write(
|
|
327
|
+
tx: Transaction,
|
|
328
|
+
conversation_id: ConversationId,
|
|
329
|
+
request: Mapping[str, str],
|
|
330
|
+
entry: Mapping[str, Any],
|
|
331
|
+
) -> SubmissionId:
|
|
332
|
+
"""Append a write that a boundary or display-only admission places at once."""
|
|
333
|
+
|
|
334
|
+
appended = await tx.append_entry(conversation_id, cast(EntryDraft, _clean_entry(entry)))
|
|
335
|
+
record = await tx.create_submission(
|
|
336
|
+
cast(
|
|
337
|
+
SubmissionCreate,
|
|
338
|
+
{
|
|
339
|
+
"conversationId": conversation_id,
|
|
340
|
+
**request,
|
|
341
|
+
"type": "write",
|
|
342
|
+
"status": "done",
|
|
343
|
+
"entry": appended["id"],
|
|
344
|
+
},
|
|
345
|
+
)
|
|
346
|
+
)
|
|
347
|
+
return record["id"]
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
__all__ = [
|
|
351
|
+
"AbortResult",
|
|
352
|
+
"SubmissionHandle",
|
|
353
|
+
"Submissions",
|
|
354
|
+
"admit_submission",
|
|
355
|
+
"check_input_entry",
|
|
356
|
+
"is_settled",
|
|
357
|
+
]
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
"""Task graph view: every live task, its waits, and the conversations it owns (spec §9.5).
|
|
2
|
+
|
|
3
|
+
The graph is to the Session's tasks what the conversation view is to one conversation: a
|
|
4
|
+
structural mount built on the Session line by its first observer, advanced only from committed
|
|
5
|
+
publications, and dropped with its last observer. A node holds the committed task record without
|
|
6
|
+
its input, checkpoint payload, outcome payload, or memos.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from functools import partial
|
|
15
|
+
from typing import Any, Literal, NotRequired, TypeAlias, TypedDict, cast
|
|
16
|
+
|
|
17
|
+
from ..context import Context, without_abort_signal
|
|
18
|
+
from ..delta import Op
|
|
19
|
+
from ..ids import ConversationId, TaskId
|
|
20
|
+
from ..json import JsonValue
|
|
21
|
+
from ..session.observation import CommittedState, CommittedWatch
|
|
22
|
+
from ..session.session import SessionImpl, contained
|
|
23
|
+
from ..types import (
|
|
24
|
+
CommitPublication,
|
|
25
|
+
JoinPolicy,
|
|
26
|
+
Storage,
|
|
27
|
+
TaskOutcome,
|
|
28
|
+
TaskRecord,
|
|
29
|
+
)
|
|
30
|
+
from .scheduler import json_equal
|
|
31
|
+
from .util import cancellation_error, closed_error, scan_all
|
|
32
|
+
|
|
33
|
+
LIVE_STATUSES: tuple[Literal["pending", "running", "waiting", "completing"], ...] = (
|
|
34
|
+
"pending",
|
|
35
|
+
"running",
|
|
36
|
+
"waiting",
|
|
37
|
+
"completing",
|
|
38
|
+
)
|
|
39
|
+
"""Durable statuses of a live task; terminal tasks leave the graph."""
|
|
40
|
+
|
|
41
|
+
SCAN_PAGE_SIZE = 256
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TaskGraphStatePending(TypedDict):
|
|
45
|
+
status: Literal["pending", "running"]
|
|
46
|
+
phase: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TaskGraphStateWaiting(TypedDict):
|
|
50
|
+
status: Literal["waiting"]
|
|
51
|
+
phase: str
|
|
52
|
+
on: list[TaskId]
|
|
53
|
+
policy: JoinPolicy
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class TaskGraphStateCompleting(TypedDict):
|
|
57
|
+
"""Outcome held until its ordinary owned work drains (spec §5.5)."""
|
|
58
|
+
|
|
59
|
+
status: Literal["completing"]
|
|
60
|
+
outcome: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
TaskGraphState = TaskGraphStatePending | TaskGraphStateWaiting | TaskGraphStateCompleting
|
|
64
|
+
"""One live task's durable state without its checkpoint or outcome payloads."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class TaskGraphNode(TypedDict):
|
|
68
|
+
id: TaskId
|
|
69
|
+
kind: str
|
|
70
|
+
conversationId: ConversationId
|
|
71
|
+
owner: NotRequired[TaskId]
|
|
72
|
+
background: bool
|
|
73
|
+
abortRequested: bool
|
|
74
|
+
state: TaskGraphState
|
|
75
|
+
conversations: list[ConversationId]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TaskGraph(TypedDict):
|
|
79
|
+
"""Every live task of the Session, keyed by its decimal ID."""
|
|
80
|
+
|
|
81
|
+
tasks: dict[str, TaskGraphNode]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
TaskGraphWatch: TypeAlias = CommittedWatch
|
|
85
|
+
"""A serialized exact-frame watch of the task graph."""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(slots=True)
|
|
89
|
+
class GraphMount:
|
|
90
|
+
value: TaskGraph
|
|
91
|
+
observers: set[Any] = field(default_factory=set)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class TaskGraphView:
|
|
95
|
+
"""The Harness's single task-graph mount, alive while observed."""
|
|
96
|
+
|
|
97
|
+
def __init__(self, session: SessionImpl, storage: Storage) -> None:
|
|
98
|
+
self._session = session
|
|
99
|
+
self._storage = storage
|
|
100
|
+
self._mount: GraphMount | None = None
|
|
101
|
+
self._closed = False
|
|
102
|
+
session.observe_commits(self._on_publication)
|
|
103
|
+
session.subscribe_close(self._on_close)
|
|
104
|
+
|
|
105
|
+
def report(self, error: BaseException) -> None:
|
|
106
|
+
self._session.report(error)
|
|
107
|
+
|
|
108
|
+
async def state(self, context: Context) -> CommittedState:
|
|
109
|
+
"""A disposable read-only state of the graph."""
|
|
110
|
+
|
|
111
|
+
return await self._attach(_state_observer, context)
|
|
112
|
+
|
|
113
|
+
async def watch(self, context: Context) -> CommittedWatch:
|
|
114
|
+
"""A serialized exact-frame watch of the graph; cancelling ``context`` stops it."""
|
|
115
|
+
|
|
116
|
+
observer = await self._attach(_watch_observer, context)
|
|
117
|
+
signal = context.abort_signal
|
|
118
|
+
if signal is not None and signal.aborted:
|
|
119
|
+
observer.cancel()
|
|
120
|
+
raise cancellation_error(signal)
|
|
121
|
+
if signal is not None:
|
|
122
|
+
observer.observe_cancellation(signal)
|
|
123
|
+
return observer
|
|
124
|
+
|
|
125
|
+
async def _attach(
|
|
126
|
+
self,
|
|
127
|
+
create: Callable[[TaskGraph, Callable[[], None], TaskGraphView], Any],
|
|
128
|
+
context: Context,
|
|
129
|
+
) -> Any:
|
|
130
|
+
async def job() -> Any:
|
|
131
|
+
self._session.assert_usable()
|
|
132
|
+
mount = self._mount
|
|
133
|
+
if mount is None:
|
|
134
|
+
mount = GraphMount(value=await self._build(context))
|
|
135
|
+
detached = False
|
|
136
|
+
|
|
137
|
+
def detach() -> None:
|
|
138
|
+
nonlocal detached
|
|
139
|
+
if detached:
|
|
140
|
+
return
|
|
141
|
+
detached = True
|
|
142
|
+
mount.observers.discard(observer)
|
|
143
|
+
if not mount.observers and self._mount is mount:
|
|
144
|
+
self._mount = None
|
|
145
|
+
|
|
146
|
+
observer = create(mount.value, detach, self)
|
|
147
|
+
if asyncio.iscoroutine(observer):
|
|
148
|
+
observer = await observer
|
|
149
|
+
if self._closed:
|
|
150
|
+
raise closed_error(self._session)
|
|
151
|
+
self._session.assert_usable()
|
|
152
|
+
signal = context.abort_signal
|
|
153
|
+
if signal is not None and signal.aborted:
|
|
154
|
+
raise cancellation_error(signal)
|
|
155
|
+
self._mount = mount
|
|
156
|
+
mount.observers.add(observer)
|
|
157
|
+
return observer
|
|
158
|
+
|
|
159
|
+
return await self._session.read_on_line(job)
|
|
160
|
+
|
|
161
|
+
async def _build(self, context: Context) -> TaskGraph:
|
|
162
|
+
records: list[TaskRecord] = []
|
|
163
|
+
for status in LIVE_STATUSES:
|
|
164
|
+
records.extend(
|
|
165
|
+
await scan_all(
|
|
166
|
+
partial(
|
|
167
|
+
self._storage.scan_tasks,
|
|
168
|
+
{"status": status},
|
|
169
|
+
SCAN_PAGE_SIZE,
|
|
170
|
+
context=context,
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
records.sort(key=lambda record: record["id"])
|
|
175
|
+
tasks: dict[str, TaskGraphNode] = {}
|
|
176
|
+
for record in records:
|
|
177
|
+
owned = await scan_all(
|
|
178
|
+
partial(
|
|
179
|
+
self._storage.scan_conversations,
|
|
180
|
+
{"ownerTaskId": record["id"]},
|
|
181
|
+
SCAN_PAGE_SIZE,
|
|
182
|
+
context=context,
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
conversations = sorted(conversation["id"] for conversation in owned)
|
|
186
|
+
tasks[str(record["id"])] = _node_of(record, conversations)
|
|
187
|
+
return {"tasks": tasks}
|
|
188
|
+
|
|
189
|
+
def _on_publication(self, publication: CommitPublication, context: Context) -> None:
|
|
190
|
+
if self._mount is not None:
|
|
191
|
+
advance_graph(self._mount, publication, context, self.report)
|
|
192
|
+
|
|
193
|
+
def _on_close(self) -> None:
|
|
194
|
+
self._closed = True
|
|
195
|
+
failure = self._session.failure
|
|
196
|
+
mount = self._mount
|
|
197
|
+
if mount is not None:
|
|
198
|
+
for observer in list(mount.observers):
|
|
199
|
+
contained(partial(observer.close_session, failure), self.report)
|
|
200
|
+
self._mount = None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _state_observer(
|
|
204
|
+
value: TaskGraph, release: Callable[[], None], view: TaskGraphView
|
|
205
|
+
) -> CommittedState:
|
|
206
|
+
return CommittedState(value, release, view.report)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _watch_observer(
|
|
210
|
+
value: TaskGraph, release: Callable[[], None], view: TaskGraphView
|
|
211
|
+
) -> CommittedWatch:
|
|
212
|
+
return CommittedWatch(value, release, view.report)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def advance_graph(
|
|
216
|
+
mount: GraphMount,
|
|
217
|
+
publication: CommitPublication,
|
|
218
|
+
context: Context,
|
|
219
|
+
report: Callable[[BaseException], None],
|
|
220
|
+
) -> None:
|
|
221
|
+
"""Derive the mount's operations from one publication and publish the new revision."""
|
|
222
|
+
|
|
223
|
+
ops: list[Op] = []
|
|
224
|
+
changed: dict[str, TaskGraphNode | None] = {}
|
|
225
|
+
|
|
226
|
+
def node(key: str) -> TaskGraphNode | None:
|
|
227
|
+
if key in changed:
|
|
228
|
+
return changed[key]
|
|
229
|
+
return mount.value["tasks"].get(key)
|
|
230
|
+
|
|
231
|
+
for change in publication["changes"]:
|
|
232
|
+
if change["type"] != "task":
|
|
233
|
+
continue
|
|
234
|
+
record = change["value"]
|
|
235
|
+
key = str(record["id"])
|
|
236
|
+
previous = node(key)
|
|
237
|
+
if record["state"]["status"] == "terminal":
|
|
238
|
+
if previous is None:
|
|
239
|
+
continue
|
|
240
|
+
ops.append(["d", ["tasks", key]])
|
|
241
|
+
changed[key] = None
|
|
242
|
+
continue
|
|
243
|
+
next_node = _node_of(record, previous["conversations"] if previous is not None else [])
|
|
244
|
+
if previous is not None and json_equal(next_node, previous):
|
|
245
|
+
continue
|
|
246
|
+
ops.append(["s", ["tasks", key], next_node])
|
|
247
|
+
changed[key] = next_node
|
|
248
|
+
|
|
249
|
+
# After the tasks, so a conversation created with its owner task in one commit finds the owner's node.
|
|
250
|
+
created: dict[str, list[ConversationId]] = {}
|
|
251
|
+
for change in publication["changes"]:
|
|
252
|
+
if change["type"] != "conversation":
|
|
253
|
+
continue
|
|
254
|
+
owner = change["value"].get("owner")
|
|
255
|
+
if owner is None:
|
|
256
|
+
continue
|
|
257
|
+
key = str(owner["taskId"])
|
|
258
|
+
if node(key) is not None:
|
|
259
|
+
created.setdefault(key, []).append(change["value"]["id"])
|
|
260
|
+
for key, ids in created.items():
|
|
261
|
+
current = node(key)
|
|
262
|
+
assert current is not None
|
|
263
|
+
conversations = sorted([*current["conversations"], *ids])
|
|
264
|
+
ops.append(["s", ["tasks", key, "conversations"], conversations])
|
|
265
|
+
changed[key] = {**current, "conversations": conversations}
|
|
266
|
+
|
|
267
|
+
if not ops:
|
|
268
|
+
return
|
|
269
|
+
tasks = dict(mount.value["tasks"])
|
|
270
|
+
for op in ops:
|
|
271
|
+
path = op[1]
|
|
272
|
+
if op[0] == "d":
|
|
273
|
+
tasks.pop(path[1], None)
|
|
274
|
+
elif len(path) == 2:
|
|
275
|
+
tasks[path[1]] = op[2]
|
|
276
|
+
else:
|
|
277
|
+
current = tasks[path[1]]
|
|
278
|
+
tasks[path[1]] = {**current, "conversations": op[2]}
|
|
279
|
+
mount.value = {"tasks": tasks}
|
|
280
|
+
frame_context = without_abort_signal(context)
|
|
281
|
+
for observer in list(mount.observers):
|
|
282
|
+
advance = getattr(observer, "advance_frame", None) or observer.advance
|
|
283
|
+
contained(partial(advance, mount.value, ops, frame_context), report)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _node_of(record: TaskRecord, conversations: list[ConversationId]) -> TaskGraphNode:
|
|
287
|
+
node = TaskGraphNode(
|
|
288
|
+
id=record["id"],
|
|
289
|
+
kind=record["kind"],
|
|
290
|
+
conversationId=record["conversationId"],
|
|
291
|
+
background=record["background"],
|
|
292
|
+
abortRequested=record["abortRequested"],
|
|
293
|
+
state=_state_of(record),
|
|
294
|
+
conversations=list(conversations),
|
|
295
|
+
)
|
|
296
|
+
owner = record.get("owner")
|
|
297
|
+
if owner is not None:
|
|
298
|
+
node["owner"] = owner
|
|
299
|
+
return node
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _state_of(record: TaskRecord) -> TaskGraphState:
|
|
303
|
+
state = cast(dict[str, Any], record["state"])
|
|
304
|
+
status = state["status"]
|
|
305
|
+
if status in ("pending", "running"):
|
|
306
|
+
return {"status": status, "phase": _phase_of(state["checkpoint"])}
|
|
307
|
+
if status == "waiting":
|
|
308
|
+
return {
|
|
309
|
+
"status": "waiting",
|
|
310
|
+
"phase": _phase_of(state["checkpoint"]),
|
|
311
|
+
"on": list(state["on"]),
|
|
312
|
+
"policy": state["policy"],
|
|
313
|
+
}
|
|
314
|
+
outcome = _outcome_status(state["outcome"])
|
|
315
|
+
return {"status": "completing", "outcome": outcome}
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _phase_of(checkpoint: JsonValue) -> str:
|
|
319
|
+
return str(cast(dict[str, Any], checkpoint)["phase"])
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _outcome_status(outcome: TaskOutcome) -> str:
|
|
323
|
+
return outcome["status"]
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
__all__ = [
|
|
327
|
+
"LIVE_STATUSES",
|
|
328
|
+
"TaskGraph",
|
|
329
|
+
"TaskGraphNode",
|
|
330
|
+
"TaskGraphState",
|
|
331
|
+
"TaskGraphView",
|
|
332
|
+
"TaskGraphWatch",
|
|
333
|
+
"advance_graph",
|
|
334
|
+
]
|