pi-durable-python 1.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. pi_durable/__init__.py +320 -0
  2. pi_durable/context.py +328 -0
  3. pi_durable/delta.py +269 -0
  4. pi_durable/documents.py +366 -0
  5. pi_durable/entries.py +66 -0
  6. pi_durable/env/__init__.py +408 -0
  7. pi_durable/env/decode.py +48 -0
  8. pi_durable/env/line_scan.py +190 -0
  9. pi_durable/env/posix.py +1547 -0
  10. pi_durable/errors.py +89 -0
  11. pi_durable/harness/__init__.py +1 -0
  12. pi_durable/harness/agent.py +344 -0
  13. pi_durable/harness/compaction.py +673 -0
  14. pi_durable/harness/context.py +515 -0
  15. pi_durable/harness/define.py +111 -0
  16. pi_durable/harness/events.py +766 -0
  17. pi_durable/harness/generation.py +1079 -0
  18. pi_durable/harness/harness.py +570 -0
  19. pi_durable/harness/inbox.py +255 -0
  20. pi_durable/harness/live.py +374 -0
  21. pi_durable/harness/output.py +467 -0
  22. pi_durable/harness/prompt.py +288 -0
  23. pi_durable/harness/provider.py +78 -0
  24. pi_durable/harness/registry.py +136 -0
  25. pi_durable/harness/scheduler.py +1977 -0
  26. pi_durable/harness/submissions.py +357 -0
  27. pi_durable/harness/task_graph.py +334 -0
  28. pi_durable/harness/tool.py +1365 -0
  29. pi_durable/harness/types.py +772 -0
  30. pi_durable/harness/usage.py +88 -0
  31. pi_durable/harness/util.py +204 -0
  32. pi_durable/harness/validation.py +460 -0
  33. pi_durable/harness/view.py +324 -0
  34. pi_durable/ids.py +63 -0
  35. pi_durable/json.py +186 -0
  36. pi_durable/providers/__init__.py +362 -0
  37. pi_durable/providers/anthropic.py +603 -0
  38. pi_durable/providers/base.py +97 -0
  39. pi_durable/providers/json_parse.py +117 -0
  40. pi_durable/providers/openai.py +574 -0
  41. pi_durable/providers/request.py +35 -0
  42. pi_durable/providers/retry.py +127 -0
  43. pi_durable/providers/stream.py +183 -0
  44. pi_durable/providers/transcript.py +224 -0
  45. pi_durable/providers/usage.py +58 -0
  46. pi_durable/py.typed +0 -0
  47. pi_durable/search.py +115 -0
  48. pi_durable/session/__init__.py +5 -0
  49. pi_durable/session/drafts.py +762 -0
  50. pi_durable/session/forks.py +109 -0
  51. pi_durable/session/observation.py +374 -0
  52. pi_durable/session/session.py +1193 -0
  53. pi_durable/session/transaction.py +1088 -0
  54. pi_durable/storage/__init__.py +5 -0
  55. pi_durable/storage/jsonl/__init__.py +15 -0
  56. pi_durable/storage/jsonl/storage.py +958 -0
  57. pi_durable/storage/memory.py +889 -0
  58. pi_durable/storage/scan.py +57 -0
  59. pi_durable/storage/sqlite/__init__.py +26 -0
  60. pi_durable/storage/sqlite/database.py +62 -0
  61. pi_durable/storage/sqlite/migrations.py +191 -0
  62. pi_durable/storage/sqlite/posix.py +314 -0
  63. pi_durable/storage/sqlite/schema.py +97 -0
  64. pi_durable/storage/sqlite/storage.py +1130 -0
  65. pi_durable/tasks.py +89 -0
  66. pi_durable/testing/__init__.py +41 -0
  67. pi_durable/testing/assertions.py +44 -0
  68. pi_durable/testing/env_conformance.py +828 -0
  69. pi_durable/testing/runner.py +64 -0
  70. pi_durable/testing/storage_conformance.py +2008 -0
  71. pi_durable/testing/types.py +99 -0
  72. pi_durable/tool_search/__init__.py +159 -0
  73. pi_durable/tools/__init__.py +81 -0
  74. pi_durable/tools/_values.py +43 -0
  75. pi_durable/tools/bash.py +250 -0
  76. pi_durable/tools/edit.py +214 -0
  77. pi_durable/tools/edit_diff.py +452 -0
  78. pi_durable/tools/env.py +19 -0
  79. pi_durable/tools/file_mutation_queue.py +68 -0
  80. pi_durable/tools/image.py +248 -0
  81. pi_durable/tools/image_processor.py +77 -0
  82. pi_durable/tools/path_utils.py +111 -0
  83. pi_durable/tools/read.py +506 -0
  84. pi_durable/tools/write.py +71 -0
  85. pi_durable/truncate.py +185 -0
  86. pi_durable/types.py +1297 -0
  87. pi_durable_python-1.3.0.dist-info/METADATA +276 -0
  88. pi_durable_python-1.3.0.dist-info/RECORD +90 -0
  89. pi_durable_python-1.3.0.dist-info/WHEEL +4 -0
  90. pi_durable_python-1.3.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,570 @@
1
+ """Durable Harness: a Session with task scheduling, conversations, and inspection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Awaitable, Callable, Coroutine, Mapping
6
+ from functools import partial
7
+ from typing import Any, Literal, cast
8
+
9
+ from ..context import Context, with_abort_signal, without_abort_signal
10
+ from ..entries import ResetEntry
11
+ from ..ids import ConversationId, EntryId, SubmissionId, TaskId
12
+ from ..session.session import SessionImpl, _now_ms, contained
13
+ from ..session.transaction import Transaction, TransactionScope
14
+ from ..types import (
15
+ ROOT_CONVERSATION_ID,
16
+ ConversationOwnership,
17
+ ConversationRecord,
18
+ EntryRecord,
19
+ Page,
20
+ Storage,
21
+ SubmissionRecord,
22
+ TaskOutcome,
23
+ TaskRecord,
24
+ )
25
+ from .agent import AgentDoc, configure, create_agent, resolve_agent, resolve_settings
26
+ from .compaction import create_compaction
27
+ from .inbox import InboxDoc, QueueModes, withdraw_queued_inputs
28
+ from .live import LiveDoc, settle_scheduler_outcome, start_generation
29
+ from .provider import ProviderDoc
30
+ from .registry import BUILTIN_TASKS
31
+ from .scheduler import TaskScheduler
32
+ from .submissions import AbortResult, Submissions
33
+ from .task_graph import TaskGraphView
34
+ from .types import (
35
+ Agent,
36
+ ContextView,
37
+ ConversationHandle,
38
+ HarnessInspection,
39
+ RegistryReader,
40
+ RegistrySnapshot,
41
+ SettledSubmissionRecord,
42
+ SettledTask,
43
+ Submission,
44
+ )
45
+ from .usage import UsageDoc, UsageState, add_usage_state
46
+ from .util import closed_error, scan_all
47
+ from .view import ConversationViews
48
+
49
+ _SCAN_PAGE_SIZE = 256
50
+
51
+
52
+ def _ownership(options: Mapping[str, Any]) -> ConversationOwnership:
53
+ """Host callers choose ownership explicitly; a conversation handle never infers it."""
54
+
55
+ ownership = options.get("ownership")
56
+ if not isinstance(ownership, Mapping):
57
+ raise TypeError("Conversation creation requires explicit ownership")
58
+ return cast(ConversationOwnership, ownership)
59
+
60
+
61
+ def _safe_report(on_report: Callable[[BaseException], Any] | None) -> Callable[[BaseException], None]:
62
+ def report(error: BaseException) -> None:
63
+ def call() -> Any:
64
+ return None if on_report is None else on_report(error)
65
+
66
+ contained(call, lambda _error: None)
67
+
68
+ return report
69
+
70
+
71
+ def safe_now(now: Callable[[], int] | None, report: Callable[[BaseException], None]) -> Callable[[], int]:
72
+ if now is None:
73
+ return _now_ms
74
+ reported = False
75
+
76
+ def clock() -> int:
77
+ nonlocal reported
78
+ try:
79
+ return now()
80
+ except Exception as error:
81
+ if not reported:
82
+ reported = True
83
+ report(error)
84
+ return _now_ms()
85
+
86
+ return clock
87
+
88
+
89
+ class BoundConversation:
90
+ """Invocation-bound conversation handle for tasks and tools."""
91
+
92
+ __slots__ = ("id", "_harness", "_binding")
93
+
94
+ def __init__(self, id: ConversationId, harness: Harness, binding: Mapping[str, Any]) -> None:
95
+ self.id = id
96
+ self._harness = harness
97
+ self._binding = binding
98
+
99
+ def _bind(self, context: Context) -> Context:
100
+ return _bind_context(self._binding, context)
101
+
102
+ async def submit(self, submission: Any, context: Context) -> Any:
103
+ cast(Callable[[], None], self._binding["check"])()
104
+ # An invocation-bound handle accepts only input submissions; tools use ordinary transaction
105
+ # writes for passive entries (spec section 7.3).
106
+ if not isinstance(submission, Mapping):
107
+ raise TypeError("A conversation handle accepts only input submissions")
108
+ kind = submission.get("type")
109
+ if kind is not None and kind != "input":
110
+ raise TypeError("A conversation handle accepts only input submissions")
111
+ if kind is None and "content" not in submission:
112
+ raise TypeError("A conversation handle accepts only input submissions")
113
+ admitted = await self._harness.submit(self.id, submission, self._bind(context))
114
+ return BoundSubmission(admitted, self._binding)
115
+
116
+ async def abort(self, context: Context, options: Mapping[str, Any] | None = None) -> None:
117
+ cast(Callable[[], None], self._binding["check"])()
118
+ await self._harness._tasks.abort_conversation(
119
+ self.id, (options or {}).get("background") is True, self._bind(context)
120
+ )
121
+
122
+ async def wait_for_idle(self, context: Context) -> None:
123
+ cast(Callable[[], None], self._binding["check"])()
124
+ await self._harness._tasks.wait_for_idle(self.id, self._bind(context))
125
+
126
+
127
+ class BoundSubmission:
128
+ """Invocation-bound submission handle for tasks and tools."""
129
+
130
+ __slots__ = ("id", "_submission", "_binding")
131
+
132
+ def __init__(self, submission: Submission, binding: Mapping[str, Any]) -> None:
133
+ self.id = submission.id
134
+ self._submission = submission
135
+ self._binding = binding
136
+
137
+ def _bind(self, context: Context) -> Context:
138
+ return _bind_context(self._binding, context)
139
+
140
+ async def status(self, context: Context) -> SubmissionRecord:
141
+ cast(Callable[[], None], self._binding["check"])()
142
+ return await self._submission.status(self._bind(context))
143
+
144
+ async def wait(self, context: Context) -> SettledSubmissionRecord:
145
+ cast(Callable[[], None], self._binding["check"])()
146
+ return await self._submission.wait(self._bind(context))
147
+
148
+ async def abort(self, context: Context) -> AbortResult:
149
+ cast(Callable[[], None], self._binding["check"])()
150
+ return await self._submission.abort(self._bind(context))
151
+
152
+
153
+ class ConversationHandleImpl:
154
+ """Stateless handle for one conversation, bound to the Harness that returned it."""
155
+
156
+ __slots__ = ("id", "_harness")
157
+
158
+ def __init__(self, id: ConversationId, harness: Harness) -> None:
159
+ self.id = id
160
+ self._harness = harness
161
+
162
+ async def agent(self, context: Context) -> Agent:
163
+ return await self._harness.resolve_agent(self.id, None, context)
164
+
165
+ async def configure(self, change: Mapping[str, Any], context: Context) -> None:
166
+ async def apply(tx: Transaction) -> None:
167
+ await configure(tx, self.id, change)
168
+
169
+ await self._harness.commit(apply, context)
170
+
171
+ async def submit(self, submission: Any, context: Context) -> Any:
172
+ return await self._harness.submit(self.id, submission, context)
173
+
174
+ async def commit(self, change: Callable[[Transaction], Any], context: Context) -> Any:
175
+ return await self._harness.commit_with(change, context, TransactionScope(conversation_id=self.id))
176
+
177
+ async def context(self, context: Context, options: Mapping[str, Any] | None = None) -> ContextView:
178
+ return await self._harness.context(self.id, context, (options or {}).get("at"))
179
+
180
+ async def entries(
181
+ self,
182
+ query: Mapping[str, Any],
183
+ limit: int,
184
+ cursor: Any,
185
+ context: Context,
186
+ ) -> Page[EntryRecord, Any]:
187
+ bounded = {"conversationId": self.id, **dict(query)}
188
+ return await self._harness.read_on_line(
189
+ lambda: self._harness.storage.scan_entries(cast(Any, bounded), limit, cursor, context)
190
+ )
191
+
192
+ async def fork(self, at: EntryId, options: Mapping[str, Any], context: Context) -> ConversationHandleImpl:
193
+ return await self._harness._fork(self.id, at, options, context)
194
+
195
+ async def abort(self, context: Context, options: Mapping[str, Any] | None = None) -> None:
196
+ self._harness.resume()
197
+ await self._harness._tasks.abort_conversation(
198
+ self.id, (options or {}).get("background") is True, context
199
+ )
200
+
201
+ async def wait_for_idle(self, context: Context) -> None:
202
+ self._harness.resume()
203
+ await self._harness._tasks.wait_for_idle(self.id, context)
204
+
205
+ async def compact(self, instructions: str | None, context: Context) -> TaskId:
206
+ self._harness.resume()
207
+ value: dict[str, Any] = {"reason": "manual"}
208
+ if instructions is not None:
209
+ value["instructions"] = instructions
210
+ return cast(
211
+ TaskId,
212
+ await self.commit(lambda tx: create_compaction(tx, self.id, cast(Any, value)), context),
213
+ )
214
+
215
+ async def reset(self, handoff: str | None, context: Context) -> None:
216
+ entry: dict[str, Any] = {"kind": ResetEntry.kind, "head": "self"}
217
+ if handoff is not None:
218
+ entry["model"] = [{"role": "user", "content": handoff, "timestamp": self._harness.now}]
219
+ await self.submit({"type": "write", "entry": entry}, context)
220
+
221
+ async def view_state(self, context: Context) -> Any:
222
+ return await self._harness._views.state(self.id, context)
223
+
224
+ async def watch(self, context: Context) -> Any:
225
+ return await self._harness._views.watch(self.id, context)
226
+
227
+
228
+ class Harness(SessionImpl):
229
+ """Durable agent harness over one Session."""
230
+
231
+ def __init__(self, raw_storage: Storage, options: Mapping[str, Any], context: Context) -> None:
232
+ report = _safe_report(options.get("on_report"))
233
+ now = safe_now(options.get("now"), report)
234
+ settings_option = options.get("settings")
235
+ super().__init__(
236
+ raw_storage,
237
+ now,
238
+ lambda: _settings_value(settings_option, "document_retention_ms"),
239
+ )
240
+ self._options = dict(options)
241
+ self._report_sink = report
242
+ self._views = ConversationViews(self, self.storage)
243
+ self._graph = TaskGraphView(self, self.storage)
244
+ self._tasks = TaskScheduler(
245
+ session=self,
246
+ storage=self.storage,
247
+ registry=cast(RegistryReader, options.get("registry")),
248
+ models=options.get("models"),
249
+ agent=lambda id, snapshot, call_context: self.resolve_agent(id, snapshot, call_context),
250
+ settings=lambda: resolve_settings(settings_option),
251
+ env=lambda id, call_context: self.build_env(id, call_context),
252
+ now=now,
253
+ report=report,
254
+ settle_outcome=lambda tx, record, outcome: self._settle_outcome(tx, record, cast(Any, outcome)),
255
+ withdraw_inputs=lambda tx, conversation_id: self._withdraw_inputs(tx, conversation_id),
256
+ conversation=lambda id, binding, call_context: self._bound_conversation(
257
+ id, binding, call_context
258
+ ),
259
+ context=without_abort_signal(context),
260
+ )
261
+ self._settings_option = settings_option
262
+ self._submissions = Submissions(
263
+ session=self,
264
+ now=now,
265
+ queue_modes=lambda: _queue_modes(settings_option),
266
+ resume=self._tasks.resume,
267
+ start_run=self._start_run,
268
+ )
269
+
270
+ # ── Construction ───────────────────────────────────────────────────────
271
+
272
+ @classmethod
273
+ async def open(
274
+ cls,
275
+ storage: Storage,
276
+ options: Mapping[str, Any],
277
+ context: Context,
278
+ ) -> Harness:
279
+ """Open a Harness over storage; the registry may keep changing while it runs."""
280
+
281
+ signal = context.abort_signal
282
+ if signal is not None:
283
+ signal.raise_if_aborted()
284
+ snapshot = cast(RegistryReader, options["registry"]).snapshot()
285
+ missing = [
286
+ task.definition.name
287
+ for task in BUILTIN_TASKS
288
+ if snapshot.task(task.definition.name) is None
289
+ ]
290
+ if missing:
291
+ names = ", ".join(missing)
292
+ raise RuntimeError(
293
+ f"Registry lacks built-in tasks {names}; create it with create_registry()"
294
+ )
295
+ harness = cls(storage, options, context)
296
+ try:
297
+ await harness._tasks.open(context)
298
+ except BaseException:
299
+ try:
300
+ await harness.close(without_abort_signal(context))
301
+ except Exception:
302
+ pass
303
+ raise
304
+ return harness
305
+
306
+ # ── Harness surface ────────────────────────────────────────────────────
307
+
308
+ def report(self, error: BaseException) -> None:
309
+ self._report_sink(error)
310
+
311
+ def close(self, context: Context) -> Coroutine[Any, Any, None]:
312
+ """A Harness seals admission synchronously, before it signals and joins its handlers."""
313
+
314
+ self._begin_close()
315
+
316
+ async def wait() -> None:
317
+ await self._wait_close(context)
318
+
319
+ return wait()
320
+
321
+ async def before_close(self) -> None:
322
+ await self._tasks.join()
323
+
324
+ def _closed_error(self) -> Exception:
325
+ return cast(Exception, closed_error(self))
326
+
327
+ async def conversation_created(self, tx: Transaction, record: ConversationRecord) -> None:
328
+ await tx.doc(LiveDoc, record["id"])
329
+ await tx.doc(InboxDoc, record["id"])
330
+ await tx.doc(UsageDoc, record["id"])
331
+ await tx.doc(ProviderDoc, record["id"])
332
+ await tx.doc(AgentDoc, record["id"])
333
+ await create_agent(tx, record)
334
+ custom = self._options.get("conversation_created") or self._options.get("conversationCreated")
335
+ if custom is not None:
336
+ result = custom(tx, record)
337
+ if hasattr(result, "__await__"):
338
+ await cast(Any, result)
339
+
340
+ async def resolve_agent(
341
+ self,
342
+ id: ConversationId,
343
+ snapshot: RegistrySnapshot | None,
344
+ context: Context,
345
+ ) -> Agent:
346
+ registry = snapshot or cast(RegistryReader, self._options["registry"]).snapshot()
347
+ state = await self.snapshot(AgentDoc, id, context)
348
+ settings = resolve_settings(self._settings_option)
349
+ return resolve_agent(state, registry, settings, self.report)
350
+
351
+ async def build_env(self, id: ConversationId, context: Context) -> Any:
352
+ build = self._options.get("env")
353
+ if build is None:
354
+ return None
355
+ state = await self.snapshot(AgentDoc, id, context)
356
+ target: dict[str, Any] = {"conversation_id": id, "read": self}
357
+ if state is not None and "cwd" in state:
358
+ target["cwd"] = state["cwd"]
359
+ result = build(target, context)
360
+ if hasattr(result, "__await__"):
361
+ return await cast(Any, result)
362
+ return result
363
+
364
+ def resume(self) -> None:
365
+ self.assert_usable()
366
+ self._tasks.resume()
367
+
368
+ async def get_task(self, id: TaskId, context: Context) -> TaskRecord | None:
369
+ return await self.read_on_line(lambda: self.storage.task(id, context))
370
+
371
+ @property
372
+ def scheduler_index_sizes(self) -> dict[str, int]:
373
+ """Internal, for tests: sizes of the scheduler's ownership indexes."""
374
+
375
+ return self._tasks.index_sizes
376
+
377
+ async def inspect(self, context: Context) -> HarnessInspection:
378
+ async def read() -> HarnessInspection:
379
+ scheduling, tasks = await self._tasks.inspect(
380
+ cast(RegistryReader, self._options["registry"]).snapshot()
381
+ )
382
+ submissions: list[SubmissionRecord] = []
383
+
384
+ async def scan(status: Literal["queued", "placed"], cursor: Any) -> Any:
385
+ return await self.storage.scan_submissions(
386
+ {"status": status}, _SCAN_PAGE_SIZE, cursor, context
387
+ )
388
+
389
+ for status in ("queued", "placed"):
390
+ submissions.extend(await scan_all(partial(scan, status)))
391
+ submissions.sort(key=lambda record: record["id"])
392
+ return {"scheduling": cast(Any, scheduling), "tasks": tasks, "submissions": submissions}
393
+
394
+ return await self.read_queued_before_close(read)
395
+
396
+ async def abort_task(self, id: TaskId, context: Context) -> str:
397
+ return await self._tasks.abort(id, context)
398
+
399
+ async def wait_for_task(self, id: TaskId, context: Context) -> SettledTask:
400
+ self._tasks.resume()
401
+ return await self._tasks.wait_for_task(id, context)
402
+
403
+ async def wait_for_idle(self, context: Context) -> None:
404
+ self._tasks.resume()
405
+ await self._tasks.wait_for_idle(None, context)
406
+
407
+ async def root(
408
+ self, context: Context, options: Mapping[str, Any] | None = None
409
+ ) -> ConversationHandleImpl:
410
+ self.assert_usable()
411
+ values = options or {}
412
+
413
+ async def create(tx: Transaction) -> ConversationId:
414
+ if await tx.conversation(ROOT_CONVERSATION_ID) is not None:
415
+ return ROOT_CONVERSATION_ID
416
+ record = await tx.create_root_conversation()
417
+ await self._apply_creation(tx, record["id"], values)
418
+ return record["id"]
419
+
420
+ return ConversationHandleImpl(await self.commit(create, context), self)
421
+
422
+ async def conversation(self, id: ConversationId, context: Context) -> ConversationHandleImpl | None:
423
+ self.assert_usable()
424
+ record = await self.read_on_line(lambda: self.storage.conversation(id, context))
425
+ return None if record is None else ConversationHandleImpl(record["id"], self)
426
+
427
+ async def create_conversation(
428
+ self, options: Mapping[str, Any], context: Context
429
+ ) -> ConversationHandleImpl:
430
+ ownership = _ownership(options)
431
+ return await self._create(
432
+ options,
433
+ context,
434
+ lambda tx: tx.create_conversation({"ownership": ownership}),
435
+ )
436
+
437
+ async def _fork(
438
+ self,
439
+ parent_id: ConversationId,
440
+ at: EntryId,
441
+ options: Mapping[str, Any],
442
+ context: Context,
443
+ ) -> ConversationHandleImpl:
444
+ ownership = _ownership(options)
445
+ return await self._create(
446
+ options,
447
+ context,
448
+ lambda tx: tx.fork_conversation(parent_id, at, {"ownership": ownership}),
449
+ )
450
+
451
+ async def _create(
452
+ self,
453
+ options: Mapping[str, Any],
454
+ context: Context,
455
+ make: Callable[[Transaction], Awaitable[ConversationRecord]],
456
+ ) -> ConversationHandleImpl:
457
+ self.assert_usable()
458
+
459
+ async def create(tx: Transaction) -> ConversationId:
460
+ record = await make(tx)
461
+ await self._apply_creation(tx, record["id"], options)
462
+ return record["id"]
463
+
464
+ return ConversationHandleImpl(await self.commit(create, context), self)
465
+
466
+ async def _apply_creation(
467
+ self, tx: Transaction, conversation_id: ConversationId, options: Mapping[str, Any]
468
+ ) -> None:
469
+ """Apply the conveniences' ``agent`` change and ``init`` inside the creating commit."""
470
+
471
+ agent = options.get("agent")
472
+ if agent is not None:
473
+ await configure(tx, conversation_id, agent)
474
+ init = options.get("init")
475
+ if init is not None:
476
+ result = init(tx, conversation_id)
477
+ if hasattr(result, "__await__"):
478
+ await cast(Any, result)
479
+
480
+ # ── Submissions ────────────────────────────────────────────────────────
481
+
482
+ async def submit(
483
+ self, conversation_id: ConversationId, submission: Any, context: Context
484
+ ) -> Submission:
485
+ return await self._submissions.submit(conversation_id, submission, context)
486
+
487
+ async def submission(self, id: SubmissionId, context: Context) -> Submission | None:
488
+ return await self._submissions.get(id, context)
489
+
490
+ async def abort_submission(
491
+ self, id: SubmissionId, context: Context, conversation_id: ConversationId | None = None
492
+ ) -> AbortResult | Literal["not_found"]:
493
+ return await self._submissions.abort(id, context, conversation_id)
494
+
495
+ async def _start_run(
496
+ self, tx: Transaction, conversation_id: ConversationId, live: Any, inputs: list[Any]
497
+ ) -> None:
498
+ registry = cast(RegistryReader, self._options["registry"]).snapshot()
499
+ await start_generation(tx, conversation_id, live, inputs, registry)
500
+
501
+ # ── Internal scheduler callbacks ───────────────────────────────────────
502
+
503
+ async def _settle_outcome(self, tx: Transaction, record: TaskRecord, outcome: TaskOutcome) -> None:
504
+ await settle_scheduler_outcome(tx, record, cast(Any, outcome))
505
+
506
+ async def _withdraw_inputs(self, tx: Transaction, conversation_id: ConversationId) -> None:
507
+ await withdraw_queued_inputs(tx, conversation_id)
508
+
509
+ async def _bound_conversation(
510
+ self,
511
+ id: ConversationId,
512
+ binding: Mapping[str, Any],
513
+ context: Context,
514
+ ) -> ConversationHandle | None:
515
+ record = await self.read_on_line(lambda: self.storage.conversation(id, context))
516
+ return None if record is None else BoundConversation(id, self, binding)
517
+
518
+ # ── Not yet ported ─────────────────────────────────────────────────────
519
+
520
+ async def usage(self, context: Context) -> UsageState:
521
+ """Sum every conversation's committed ``pi.usage`` into the Session total."""
522
+
523
+ conversations = await self.read_on_line(
524
+ lambda: scan_all(
525
+ lambda cursor: self.storage.scan_conversations({}, _SCAN_PAGE_SIZE, cursor, context)
526
+ )
527
+ )
528
+ total = cast(UsageState, UsageDoc.definition.initial())
529
+ for record in conversations:
530
+ state = await self.snapshot(UsageDoc, record["id"], context)
531
+ if state is not None:
532
+ add_usage_state(total, state)
533
+ return total
534
+
535
+ async def task_graph(self, context: Context) -> Any:
536
+ """Return the task-graph mount as a disposable read-only state."""
537
+
538
+ self.assert_usable()
539
+ return await self._graph.state(context)
540
+
541
+ async def watch_task_graph(self, context: Context) -> Any:
542
+ """Return the task-graph mount through its serialized exact-frame watch."""
543
+
544
+ self.assert_usable()
545
+ return await self._graph.watch(context)
546
+
547
+
548
+
549
+ def _bind_context(binding: Mapping[str, Any], context: Context) -> Context:
550
+ signal = binding["signal"]
551
+ return context if signal is None else with_abort_signal(signal, context)
552
+
553
+
554
+ def _queue_modes(settings: Any) -> QueueModes:
555
+ resolved = resolve_settings(settings)
556
+ return QueueModes(
557
+ steering_mode=resolved.steering_mode,
558
+ follow_up_mode=resolved.follow_up_mode,
559
+ )
560
+
561
+
562
+ def _settings_value(settings: Any, name: str) -> Any:
563
+ if settings is None:
564
+ return None
565
+ if isinstance(settings, Mapping):
566
+ return settings.get(name)
567
+ return getattr(settings, name, None)
568
+
569
+
570
+ __all__ = ["Harness", "safe_now"]