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,255 @@
1
+ """Built-in ``pi.inbox`` and the submission boundary (spec section 6).
2
+
3
+ Queued submissions wait in one conversation-scoped document, in ID order. A boundary reads the
4
+ document on the Session line, places the selected writes and user items, and removes them
5
+ positionally; run control itself lives in ``pi.live``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import Any, Literal, TypedDict, cast
12
+
13
+ from ..documents import define_doc
14
+ from ..entries import UserEntry
15
+ from ..ids import ConversationId, EntryId, SubmissionId
16
+ from ..json import JsonObject, JsonValue, copy_json
17
+ from ..session.drafts import DocumentDraft, DraftList, DraftObject
18
+ from ..session.transaction import Transaction
19
+ from ..types import EntryDraft, EntryRecord
20
+ from .types import InputEntry, QueueMode, UserInput
21
+
22
+
23
+ class InboxItem(TypedDict, total=False):
24
+ """One queued submission: an input with its content, or a passive entry write."""
25
+
26
+ id: SubmissionId
27
+ mode: Literal["steer", "followUp", "write"]
28
+ content: UserInput
29
+ entry: JsonObject
30
+
31
+
32
+ class InboxState(TypedDict):
33
+ """The inbox document value: queued submissions in submission-ID order."""
34
+
35
+ items: list[InboxItem]
36
+
37
+
38
+ InboxDoc = define_doc(
39
+ {
40
+ "kind": "pi.inbox",
41
+ "version": 1,
42
+ "scope": "conversation",
43
+ "history": "latest",
44
+ "fork": "initial",
45
+ "initial": lambda: {"items": []},
46
+ "checkpoint_when": lambda value, _ops, _info: len(value.get("items") or []) == 0,
47
+ }
48
+ )
49
+
50
+
51
+ @dataclass(slots=True)
52
+ class QueueModes:
53
+ """The queue modes a boundary reads on the Session line."""
54
+
55
+ steering_mode: QueueMode
56
+ follow_up_mode: QueueMode
57
+
58
+
59
+ @dataclass(slots=True)
60
+ class Boundary:
61
+ """What a boundary reads before the commit's first table write."""
62
+
63
+ conversation_id: ConversationId
64
+ inbox: DocumentDraft
65
+ steering_mode: QueueMode
66
+ follow_up_mode: QueueMode
67
+ head: EntryId | None
68
+
69
+
70
+ @dataclass(frozen=True, slots=True)
71
+ class BoundaryResult:
72
+ """Selected user submission IDs and whether a reset was among the placed writes."""
73
+
74
+ users: list[SubmissionId]
75
+ reset: bool
76
+
77
+
78
+ def _plain(value: Any) -> Any:
79
+ """Return a plain JSON value from a document draft member or a record."""
80
+
81
+ if isinstance(value, DraftObject):
82
+ return value.snapshot()
83
+ if isinstance(value, DraftList):
84
+ return value.snapshot()
85
+ if isinstance(value, dict):
86
+ return {key: _plain(item) for key, item in value.items()}
87
+ if isinstance(value, list):
88
+ return [_plain(item) for item in value]
89
+ return value
90
+
91
+
92
+ def _entry_draft(item: Any) -> EntryDraft:
93
+ return cast(EntryDraft, _plain(item["entry"]))
94
+
95
+
96
+ async def prepare_boundary(
97
+ tx: Transaction, conversation_id: ConversationId, modes: QueueModes
98
+ ) -> Boundary:
99
+ """Read the active-range start and the inbox before the commit's first table write."""
100
+
101
+ marker = await tx.latest_head_marker(conversation_id)
102
+ inbox = await tx.doc(InboxDoc, conversation_id)
103
+ return Boundary(
104
+ conversation_id=conversation_id,
105
+ inbox=inbox,
106
+ steering_mode=modes.steering_mode,
107
+ follow_up_mode=modes.follow_up_mode,
108
+ head=None if marker is None else marker.get("head"),
109
+ )
110
+
111
+
112
+ async def apply_boundary(
113
+ tx: Transaction, boundary: Boundary, at: Literal["postTools", "final"], now: int
114
+ ) -> BoundaryResult:
115
+ """Place the queued items a boundary selects, writes first and then user items, each in ID order.
116
+
117
+ A selected reset turns a ``postTools`` boundary into ``final``. A head write whose target is
118
+ older than the active range, including one started earlier in this commit, is stale. Selected
119
+ and stale items are removed positionally.
120
+ """
121
+
122
+ conversation_id = boundary.conversation_id
123
+ items = boundary.inbox["items"]
124
+ reset = has_queued_reset(items)
125
+ final = at == "final" or reset
126
+
127
+ def pick(mode: Literal["steer", "followUp"], queue_mode: QueueMode) -> list[int]:
128
+ indexes = [index for index, item in enumerate(items) if item["mode"] == mode]
129
+ return indexes if queue_mode == "all" else indexes[:1]
130
+
131
+ writes = [index for index, item in enumerate(items) if item["mode"] == "write"]
132
+ users = [*pick("steer", boundary.steering_mode)]
133
+ if final:
134
+ users.extend(pick("followUp", boundary.follow_up_mode))
135
+ users.sort()
136
+
137
+ placed: list[SubmissionId] = []
138
+ for index in writes:
139
+ item = items[index]
140
+ draft = _entry_draft(item)
141
+ if is_stale(boundary, draft):
142
+ tx.settle_submission(cast(SubmissionId, item["id"]), {"status": "unanswered", "reason": "stale"})
143
+ continue
144
+ entry = await tx.append_entry(conversation_id, draft)
145
+ head = draft.get("head")
146
+ if head is not None:
147
+ boundary.head = entry["id"] if head == "self" else head
148
+ tx.place_submission(cast(SubmissionId, item["id"]), entry["id"])
149
+
150
+ for index in users:
151
+ item = items[index]
152
+ entry = await append_input(
153
+ tx,
154
+ conversation_id,
155
+ cast(UserInput, _plain(item.get("content"))),
156
+ _plain(item["entry"]) if "entry" in item else None,
157
+ now,
158
+ )
159
+ tx.place_submission(cast(SubmissionId, item["id"]), entry["id"])
160
+ placed.append(cast(SubmissionId, item["id"]))
161
+
162
+ for index in sorted([*writes, *users], reverse=True):
163
+ del items[index]
164
+ return BoundaryResult(users=placed, reset=reset)
165
+
166
+
167
+ async def append_input(
168
+ tx: Transaction,
169
+ conversation_id: ConversationId,
170
+ content: UserInput,
171
+ entry: InputEntry | None,
172
+ now: int,
173
+ ) -> EntryRecord:
174
+ """Append the entry an input is placed as: ``entry``'s kind and data, or ``pi.user``."""
175
+
176
+ message = {"role": "user", "content": content, "timestamp": now}
177
+ if entry is None:
178
+ draft = cast(EntryDraft, {"model": [message]})
179
+ return await tx.append_entry(UserEntry, conversation_id, draft)
180
+ draft = cast(EntryDraft, {**pick_input_entry(entry), "model": [message]})
181
+ return await tx.append_entry(conversation_id, draft)
182
+
183
+
184
+ def pick_input_entry(entry: InputEntry) -> InputEntry:
185
+ """Only ``kind`` and ``data`` of an input's entry, as strict JSON."""
186
+
187
+ picked: dict[str, JsonValue] = {"kind": entry.get("kind", "")}
188
+ if "data" in entry:
189
+ picked["data"] = copy_json(entry["data"])
190
+ return cast(InputEntry, picked)
191
+
192
+
193
+ def is_display_only(entry: EntryDraft) -> bool:
194
+ """Whether a write changes nothing the model sees: no ``model``, ``head``, or ``edits``."""
195
+
196
+ return entry.get("model") is None and entry.get("head") is None and entry.get("edits") is None
197
+
198
+
199
+ def has_queued_reset(items: Any) -> bool:
200
+ """Whether a reset (``head: "self"`` write) is queued."""
201
+
202
+ return any(item["mode"] == "write" and item["entry"].get("head") == "self" for item in items)
203
+
204
+
205
+ def is_stale(boundary: Boundary, entry: EntryDraft) -> bool:
206
+ """Whether a head write targets an entry before the active range."""
207
+
208
+ head = entry.get("head")
209
+ return (
210
+ isinstance(head, int)
211
+ and not isinstance(head, bool)
212
+ and boundary.head is not None
213
+ and head < boundary.head
214
+ )
215
+
216
+
217
+ async def remove_inbox_item(tx: Transaction, conversation_id: ConversationId, id: SubmissionId) -> None:
218
+ """Remove a withdrawn submission's item; the caller settles the submission."""
219
+
220
+ items = (await tx.doc(InboxDoc, conversation_id))["items"]
221
+ for index in range(len(items)):
222
+ if items[index]["id"] == id:
223
+ del items[index]
224
+ return
225
+
226
+
227
+ async def withdraw_queued_inputs(tx: Transaction, conversation_id: ConversationId) -> None:
228
+ """Withdraw every queued input of one conversation; queued writes stay for later placement."""
229
+
230
+ items = (await tx.doc(InboxDoc, conversation_id))["items"]
231
+ for index in range(len(items) - 1, -1, -1):
232
+ item = items[index]
233
+ if item["mode"] == "write":
234
+ continue
235
+ tx.settle_submission(cast(SubmissionId, item["id"]), {"status": "unanswered", "reason": "aborted"})
236
+ del items[index]
237
+
238
+
239
+ __all__ = [
240
+ "Boundary",
241
+ "BoundaryResult",
242
+ "InboxDoc",
243
+ "InboxItem",
244
+ "InboxState",
245
+ "QueueModes",
246
+ "append_input",
247
+ "apply_boundary",
248
+ "has_queued_reset",
249
+ "is_display_only",
250
+ "is_stale",
251
+ "pick_input_entry",
252
+ "prepare_boundary",
253
+ "remove_inbox_item",
254
+ "withdraw_queued_inputs",
255
+ ]
@@ -0,0 +1,374 @@
1
+ """Built-in ``pi.live``: run control and presentation of current work (spec section 8.2).
2
+
3
+ ``run`` is present exactly while a run owns the conversation. Generation publishes its attempt,
4
+ partial, retry, and deferred state here; a tool call publishes throttled running output into its
5
+ slot, model-issued or nested. A compaction task lists its status here while it selects, summarizes,
6
+ or waits out a retry backoff, and the newest failure stays for a client to show.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Mapping
12
+ from typing import Any, NotRequired, TypedDict, cast
13
+
14
+ from ..documents import define_doc
15
+ from ..errors import error_message
16
+ from ..ids import ConversationId, EntryId, SubmissionId, TaskId
17
+ from ..json import JsonValue
18
+ from ..session.drafts import DocumentDraft
19
+ from ..session.transaction import Transaction
20
+ from ..types import SubmissionSettlement, TaskRecord
21
+ from .types import CompactionReason, ToolDiagnostic
22
+
23
+
24
+ class RunState(TypedDict):
25
+ """The task that settles the run's inputs and those inputs; present exactly while busy."""
26
+
27
+ taskId: TaskId
28
+ inputs: list[SubmissionId]
29
+
30
+
31
+ class SlotProgress(TypedDict, total=False):
32
+ """What a running tool call publishes, shared by model-issued and nested calls."""
33
+
34
+ callId: str
35
+ name: str
36
+ status: str
37
+ output: str
38
+ droppedBytes: int
39
+ droppedLines: int
40
+ details: JsonValue
41
+ diagnostics: list[ToolDiagnostic]
42
+
43
+
44
+ class ToolSlot(SlotProgress):
45
+ """Presentation of one tool call of the current round.
46
+
47
+ ``taskId`` is absent for a call not started yet (a sequential round) and for a call its request
48
+ did not offer, which starts ``done`` with the ``entry`` generation wrote. ``entry`` is absent
49
+ when the tool task faulted or was orphaned.
50
+ """
51
+
52
+ taskId: NotRequired[TaskId]
53
+ entry: NotRequired[EntryId]
54
+
55
+
56
+ class NestedToolSummary(TypedDict, total=False):
57
+ """How a nested call ended: an error flag, its execution time, spend, and bounded error text."""
58
+
59
+ isError: bool
60
+ durationMs: int
61
+ usage: dict[str, JsonValue]
62
+ error: str
63
+
64
+
65
+ class NestedToolSlot(SlotProgress):
66
+ """Presentation of one nested call: a call a running tool made through ``executeTool()``."""
67
+
68
+ parentCallId: str
69
+ parentTaskId: TaskId
70
+ taskId: TaskId
71
+ arguments: dict[str, JsonValue]
72
+ summary: NotRequired[NestedToolSummary]
73
+
74
+
75
+ class CompactionStatus(TypedDict, total=False):
76
+ """Presentation of one live compaction task (spec section 8.7)."""
77
+
78
+ taskId: TaskId
79
+ reason: CompactionReason
80
+ blocking: bool
81
+ attempt: int
82
+ retry: dict[str, JsonValue]
83
+
84
+
85
+ class CompactionFailure(TypedDict):
86
+ """The newest compaction that failed since a compaction last produced a summary."""
87
+
88
+ taskId: TaskId
89
+ reason: CompactionReason
90
+ error: str
91
+
92
+
93
+ class LiveState(TypedDict, total=False):
94
+ """Built-in live conversation state."""
95
+
96
+ run: RunState
97
+ generation: dict[str, JsonValue]
98
+ tools: list[ToolSlot]
99
+ nestedTools: list[NestedToolSlot]
100
+ compactions: list[CompactionStatus]
101
+ compactionFailure: CompactionFailure
102
+
103
+
104
+ def _checkpoint_when(value: dict[str, Any], _ops: Any, _info: Any) -> bool:
105
+ """A complete base whenever nothing runs: no generation and no running tool slots."""
106
+
107
+ if value.get("generation") is not None:
108
+ return False
109
+ for key in ("tools", "nestedTools"):
110
+ slots = value.get(key) or []
111
+ if any(slot.get("status") == "running" for slot in slots):
112
+ return False
113
+ return True
114
+
115
+
116
+ LiveDoc = define_doc(
117
+ {
118
+ "kind": "pi.live",
119
+ "version": 1,
120
+ "scope": "conversation",
121
+ "history": "latest",
122
+ "fork": "initial",
123
+ "initial": lambda: {},
124
+ "checkpoint_when": _checkpoint_when,
125
+ }
126
+ )
127
+
128
+ GENERATION_TASK_NAME = "pi.generation"
129
+ TOOL_TASK_KIND = "pi.tool"
130
+ COMPACTION_TASK_KIND = "pi.compaction"
131
+ RUN_TASK_KINDS = frozenset({GENERATION_TASK_NAME})
132
+
133
+
134
+ async def start_generation(
135
+ tx: Transaction,
136
+ conversation_id: ConversationId,
137
+ live: Any,
138
+ inputs: list[SubmissionId],
139
+ registry: Any,
140
+ ) -> None:
141
+ """Create the registry's generation task and give it run control of ``inputs``."""
142
+
143
+ task = registry.task(GENERATION_TASK_NAME)
144
+ if task is None:
145
+ raise RuntimeError("Registry lacks the pi.generation task")
146
+ await start_run(tx, conversation_id, live, inputs, task)
147
+
148
+
149
+ async def start_run(
150
+ tx: Transaction,
151
+ conversation_id: ConversationId,
152
+ live: DocumentDraft,
153
+ inputs: list[SubmissionId],
154
+ task: Any,
155
+ ) -> None:
156
+ """Create the generation task and give it run control of ``inputs``."""
157
+
158
+ task_id = await tx.create_task(
159
+ task,
160
+ {},
161
+ {"ownership": {"kind": "conversation"}, "conversationId": conversation_id},
162
+ )
163
+ live["run"] = {"taskId": task_id, "inputs": list(inputs)}
164
+
165
+
166
+ def end_run(
167
+ tx: Transaction,
168
+ live: DocumentDraft,
169
+ task_id: TaskId,
170
+ settlement: SubmissionSettlement,
171
+ ) -> None:
172
+ """End the run owned by ``task_id``: settle its inputs and remove run control."""
173
+
174
+ run = live.get("run")
175
+ if run is not None and run["taskId"] == task_id:
176
+ for id in run["inputs"]:
177
+ tx.settle_submission(cast(SubmissionId, id), settlement)
178
+ del live["run"]
179
+ for key in ("generation", "tools", "nestedTools"):
180
+ if key in live:
181
+ del live[key]
182
+
183
+
184
+ def add_compaction_status(live: Any, status: Mapping[str, Any]) -> None:
185
+ """Add the status of a compaction task created in this commit; statuses stay in task ID order."""
186
+
187
+ statuses = live.get("compactions")
188
+ if statuses is None:
189
+ live["compactions"] = [dict(status)]
190
+ else:
191
+ statuses.append(dict(status))
192
+
193
+
194
+ def compaction_status(live: Any, task_id: TaskId) -> Any:
195
+ """The status draft of compaction task ``task_id``, if listed."""
196
+
197
+ statuses = live.get("compactions")
198
+ if statuses is None:
199
+ return None
200
+ return next((status for status in statuses if status.get("taskId") == task_id), None)
201
+
202
+
203
+ def remove_compaction_status(live: Any, task_id: TaskId) -> None:
204
+ """Remove the status of compaction task ``task_id``, and the list once empty."""
205
+
206
+ statuses = live.get("compactions")
207
+ if statuses is None:
208
+ return
209
+ for index in range(len(statuses)):
210
+ if statuses[index].get("taskId") == task_id:
211
+ del statuses[index]
212
+ break
213
+ if len(statuses) == 0:
214
+ del live["compactions"]
215
+
216
+
217
+ def clear_compaction_failure(live: Any) -> None:
218
+ """Remove the recorded compaction failure, once a compaction produces a summary."""
219
+
220
+ if "compactionFailure" in live:
221
+ del live["compactionFailure"]
222
+
223
+
224
+ def fail_compaction_status(live: Any, task_id: TaskId, error: str) -> None:
225
+ """Remove the status of compaction task ``task_id`` and record that it failed with ``error``."""
226
+
227
+ status = compaction_status(live, task_id)
228
+ if status is not None:
229
+ live["compactionFailure"] = {
230
+ "taskId": status["taskId"],
231
+ "reason": status["reason"],
232
+ "error": error,
233
+ }
234
+ remove_compaction_status(live, task_id)
235
+
236
+
237
+ def tool_slot(live: Any, task_id: TaskId) -> Any:
238
+ """The slot of tool task ``task_id``, model-issued or nested, if ``pi.live`` still lists it.
239
+
240
+ Nested slots are found by binary search: a tool may make many nested calls, each of which looks
241
+ up its slot in several commits, and their task IDs ascend.
242
+ """
243
+
244
+ slots = live.get("tools") if hasattr(live, "get") else None
245
+ for slot in slots or ():
246
+ if slot.get("taskId") == task_id:
247
+ return slot
248
+ nested = live.get("nestedTools") if hasattr(live, "get") else None
249
+ if nested is None:
250
+ return None
251
+ low = 0
252
+ high = len(nested) - 1
253
+ while low <= high:
254
+ middle = (low + high) >> 1
255
+ candidate = nested[middle]
256
+ candidate_id = candidate.get("taskId")
257
+ if candidate_id == task_id:
258
+ return candidate
259
+ if candidate_id is not None and candidate_id < task_id:
260
+ low = middle + 1
261
+ else:
262
+ high = middle - 1
263
+ return None
264
+
265
+
266
+ def finish_slot(slot: Any) -> None:
267
+ """Mark a slot done and clear its running output; the caller sets ``entry`` or ``summary``."""
268
+
269
+ slot["status"] = "done"
270
+ clear_progress(slot)
271
+
272
+
273
+ def remove_nested_slots(live: Any, task_id: TaskId) -> None:
274
+ """Remove the nested slots below tool task ``task_id``, transitively; parents precede children."""
275
+
276
+ nested = live.get("nestedTools") if hasattr(live, "get") else None
277
+ if nested is None:
278
+ return
279
+ removed = {task_id}
280
+ for slot in nested:
281
+ if slot.get("parentTaskId") in removed:
282
+ removed.add(slot.get("taskId"))
283
+ for index in range(len(nested) - 1, -1, -1):
284
+ if nested[index].get("parentTaskId") in removed:
285
+ del nested[index]
286
+ if len(nested) == 0:
287
+ del live["nestedTools"]
288
+
289
+
290
+ def clear_progress(slot: Any) -> None:
291
+ """Remove what a tool published while running; its result entry or a rerun replaces it."""
292
+
293
+ for key in ("output", "droppedBytes", "droppedLines", "details", "diagnostics"):
294
+ if key in slot:
295
+ del slot[key]
296
+
297
+
298
+ async def settle_scheduler_outcome(tx: Transaction, record: TaskRecord, outcome: dict[str, Any]) -> None:
299
+ """Harness cleanup for a terminal outcome the scheduler writes itself (faulted or orphaned)."""
300
+
301
+ if record["kind"] == TOOL_TASK_KIND:
302
+ live = await tx.doc(LiveDoc, record["conversationId"])
303
+ slot = tool_slot(live, record["id"])
304
+ if slot is not None:
305
+ finish_slot(slot)
306
+ remove_nested_slots(live, record["id"])
307
+ return
308
+ if record["kind"] == COMPACTION_TASK_KIND:
309
+ failure_text = (
310
+ str(cast(Mapping[str, Any], outcome.get("error") or {}).get("message", ""))
311
+ if outcome.get("status") == "faulted"
312
+ else str(outcome.get("reason", ""))
313
+ )
314
+ fail_compaction_status(
315
+ await tx.doc(LiveDoc, record["conversationId"]), record["id"], failure_text
316
+ )
317
+ return
318
+ if record["kind"] not in RUN_TASK_KINDS:
319
+ return
320
+ live = await tx.doc(LiveDoc, record["conversationId"])
321
+ run = live.get("run")
322
+ if run is None or run["taskId"] != record["id"]:
323
+ return
324
+ from .generation import convert_partial
325
+
326
+ await convert_partial(tx, live, record["conversationId"])
327
+ if outcome.get("status") == "faulted":
328
+ error = outcome.get("error") or {}
329
+ end_run(
330
+ tx,
331
+ live,
332
+ record["id"],
333
+ {
334
+ "status": "unanswered",
335
+ "reason": "faulted",
336
+ "detail": error.get("message", error_message(error)),
337
+ },
338
+ )
339
+ return
340
+ end_run(
341
+ tx,
342
+ live,
343
+ record["id"],
344
+ {"status": "unanswered", "reason": outcome.get("reason", "aborted")},
345
+ )
346
+
347
+
348
+ __all__ = [
349
+ "COMPACTION_TASK_KIND",
350
+ "GENERATION_TASK_NAME",
351
+ "CompactionFailure",
352
+ "CompactionStatus",
353
+ "LiveDoc",
354
+ "LiveState",
355
+ "NestedToolSlot",
356
+ "NestedToolSummary",
357
+ "RUN_TASK_KINDS",
358
+ "RunState",
359
+ "TOOL_TASK_KIND",
360
+ "ToolSlot",
361
+ "add_compaction_status",
362
+ "clear_compaction_failure",
363
+ "clear_progress",
364
+ "compaction_status",
365
+ "end_run",
366
+ "fail_compaction_status",
367
+ "finish_slot",
368
+ "remove_compaction_status",
369
+ "remove_nested_slots",
370
+ "settle_scheduler_outcome",
371
+ "start_generation",
372
+ "start_run",
373
+ "tool_slot",
374
+ ]