jharness-kernel 0.1.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 (49) hide show
  1. jharness/kernel/__init__.py +200 -0
  2. jharness/kernel/_digest.py +242 -0
  3. jharness/kernel/_engine/__init__.py +1 -0
  4. jharness/kernel/_engine/change.py +117 -0
  5. jharness/kernel/_engine/commit.py +82 -0
  6. jharness/kernel/_engine/deadline.py +108 -0
  7. jharness/kernel/_engine/engine.py +428 -0
  8. jharness/kernel/_engine/planning.py +263 -0
  9. jharness/kernel/_engine/tooling.py +563 -0
  10. jharness/kernel/_engine/verification.py +315 -0
  11. jharness/kernel/_history.py +132 -0
  12. jharness/kernel/_validation.py +133 -0
  13. jharness/kernel/approval.py +66 -0
  14. jharness/kernel/checkpoint.py +468 -0
  15. jharness/kernel/commands.py +124 -0
  16. jharness/kernel/context.py +55 -0
  17. jharness/kernel/control.py +163 -0
  18. jharness/kernel/diagnostics/__init__.py +21 -0
  19. jharness/kernel/diagnostics/trace.py +162 -0
  20. jharness/kernel/diagnostics/verification.py +604 -0
  21. jharness/kernel/errors.py +109 -0
  22. jharness/kernel/events.py +59 -0
  23. jharness/kernel/history.py +49 -0
  24. jharness/kernel/invocation.py +180 -0
  25. jharness/kernel/json_values.py +243 -0
  26. jharness/kernel/limits.py +58 -0
  27. jharness/kernel/messages.py +316 -0
  28. jharness/kernel/models.py +356 -0
  29. jharness/kernel/py.typed +0 -0
  30. jharness/kernel/repository.py +320 -0
  31. jharness/kernel/runtime.py +168 -0
  32. jharness/kernel/snapshot.py +79 -0
  33. jharness/kernel/state.py +165 -0
  34. jharness/kernel/tools.py +430 -0
  35. jharness/kernel/wire/__init__.py +107 -0
  36. jharness/kernel/wire/_helpers.py +175 -0
  37. jharness/kernel/wire/checkpoint.py +362 -0
  38. jharness/kernel/wire/events.py +311 -0
  39. jharness/kernel/wire/messages.py +377 -0
  40. jharness/kernel/wire/models.py +132 -0
  41. jharness/kernel/wire/requests.py +127 -0
  42. jharness/kernel/wire/snapshot.py +109 -0
  43. jharness/kernel/wire/state.py +169 -0
  44. jharness/kernel/wire/tools.py +175 -0
  45. jharness/kernel/wire/trace.py +102 -0
  46. jharness_kernel-0.1.0.dist-info/METADATA +49 -0
  47. jharness_kernel-0.1.0.dist-info/RECORD +49 -0
  48. jharness_kernel-0.1.0.dist-info/WHEEL +4 -0
  49. jharness_kernel-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,200 @@
1
+ """Model-neutral immutable execution kernel."""
2
+
3
+ from jharness.kernel.approval import (
4
+ ApprovalAllow,
5
+ ApprovalDecision,
6
+ ApprovalDeny,
7
+ ApprovalPolicy,
8
+ ApprovalRequest,
9
+ ApprovalSuspend,
10
+ )
11
+ from jharness.kernel.checkpoint import (
12
+ Checkpoint,
13
+ ControlFact,
14
+ ConversationInsertFact,
15
+ Fact,
16
+ FailedControl,
17
+ HistoryRewriteFact,
18
+ LimitedControl,
19
+ ModelTurnFact,
20
+ ModelTurnResult,
21
+ ResumedFact,
22
+ StartedFact,
23
+ SuspendedControl,
24
+ SuspensionView,
25
+ ToolBatchFact,
26
+ ToolOutcomeKind,
27
+ )
28
+ from jharness.kernel.commands import SuspensionSelector
29
+ from jharness.kernel.context import RunContext
30
+ from jharness.kernel.errors import (
31
+ CommitError,
32
+ KernelError,
33
+ ModelError,
34
+ ModelErrorInfo,
35
+ ProtocolError,
36
+ RepositoryError,
37
+ RevisionConflict,
38
+ ToolError,
39
+ )
40
+ from jharness.kernel.events import Event, EventKind
41
+ from jharness.kernel.history import HistoryReducer, HistoryRewrite
42
+ from jharness.kernel.invocation import Invocation
43
+ from jharness.kernel.json_values import (
44
+ JsonScalar,
45
+ JsonValue,
46
+ freeze_json_value,
47
+ thaw_json_value,
48
+ )
49
+ from jharness.kernel.limits import LimitReason, RunLimits
50
+ from jharness.kernel.messages import ArtifactRef, ContentPart, ErrorInfo, Message, TaskRef, ToolCall
51
+ from jharness.kernel.models import (
52
+ DeltaSink,
53
+ Model,
54
+ ModelCapabilities,
55
+ ModelContentDelta,
56
+ ModelDelta,
57
+ ModelOptions,
58
+ ModelReasoningDelta,
59
+ ModelRequest,
60
+ ModelResponse,
61
+ ModelToolCallDelta,
62
+ ModelUsage,
63
+ ModelUsageDelta,
64
+ ResponseFormat,
65
+ ToolChoice,
66
+ )
67
+ from jharness.kernel.repository import EphemeralRepository, RunRepository
68
+ from jharness.kernel.runtime import Runtime
69
+ from jharness.kernel.snapshot import RunSnapshot
70
+ from jharness.kernel.state import (
71
+ ActiveState,
72
+ Completed,
73
+ Failed,
74
+ Limited,
75
+ Planning,
76
+ RunMetrics,
77
+ RunState,
78
+ Suspended,
79
+ Suspension,
80
+ ToolsPending,
81
+ )
82
+ from jharness.kernel.tools import (
83
+ BatchPolicy,
84
+ SettledOutcome,
85
+ SettledResult,
86
+ ToolAccepted,
87
+ ToolBatch,
88
+ ToolBinding,
89
+ ToolCatalog,
90
+ ToolCatalogProvider,
91
+ ToolContext,
92
+ ToolExecution,
93
+ ToolFailure,
94
+ ToolOutcome,
95
+ ToolResult,
96
+ ToolRisk,
97
+ ToolSpec,
98
+ ToolSuccess,
99
+ ToolWaiting,
100
+ WaitingResult,
101
+ tool_message,
102
+ )
103
+
104
+ __all__ = [
105
+ "ActiveState",
106
+ "ApprovalAllow",
107
+ "ApprovalDecision",
108
+ "ApprovalDeny",
109
+ "ApprovalPolicy",
110
+ "ApprovalRequest",
111
+ "ApprovalSuspend",
112
+ "ArtifactRef",
113
+ "BatchPolicy",
114
+ "Checkpoint",
115
+ "CommitError",
116
+ "Completed",
117
+ "ContentPart",
118
+ "ControlFact",
119
+ "ConversationInsertFact",
120
+ "DeltaSink",
121
+ "EphemeralRepository",
122
+ "ErrorInfo",
123
+ "Event",
124
+ "EventKind",
125
+ "Fact",
126
+ "Failed",
127
+ "FailedControl",
128
+ "HistoryReducer",
129
+ "HistoryRewrite",
130
+ "HistoryRewriteFact",
131
+ "Invocation",
132
+ "JsonScalar",
133
+ "JsonValue",
134
+ "KernelError",
135
+ "LimitReason",
136
+ "Limited",
137
+ "LimitedControl",
138
+ "Message",
139
+ "Model",
140
+ "ModelCapabilities",
141
+ "ModelContentDelta",
142
+ "ModelDelta",
143
+ "ModelError",
144
+ "ModelErrorInfo",
145
+ "ModelOptions",
146
+ "ModelReasoningDelta",
147
+ "ModelRequest",
148
+ "ModelResponse",
149
+ "ModelToolCallDelta",
150
+ "ModelTurnFact",
151
+ "ModelTurnResult",
152
+ "ModelUsage",
153
+ "ModelUsageDelta",
154
+ "Planning",
155
+ "ProtocolError",
156
+ "RepositoryError",
157
+ "ResponseFormat",
158
+ "ResumedFact",
159
+ "RevisionConflict",
160
+ "RunContext",
161
+ "RunLimits",
162
+ "RunMetrics",
163
+ "RunRepository",
164
+ "RunSnapshot",
165
+ "RunState",
166
+ "Runtime",
167
+ "SettledOutcome",
168
+ "SettledResult",
169
+ "StartedFact",
170
+ "Suspended",
171
+ "SuspendedControl",
172
+ "Suspension",
173
+ "SuspensionSelector",
174
+ "SuspensionView",
175
+ "TaskRef",
176
+ "ToolAccepted",
177
+ "ToolBatch",
178
+ "ToolBatchFact",
179
+ "ToolBinding",
180
+ "ToolCall",
181
+ "ToolCatalog",
182
+ "ToolCatalogProvider",
183
+ "ToolChoice",
184
+ "ToolContext",
185
+ "ToolError",
186
+ "ToolExecution",
187
+ "ToolFailure",
188
+ "ToolOutcome",
189
+ "ToolOutcomeKind",
190
+ "ToolResult",
191
+ "ToolRisk",
192
+ "ToolSpec",
193
+ "ToolSuccess",
194
+ "ToolWaiting",
195
+ "ToolsPending",
196
+ "WaitingResult",
197
+ "freeze_json_value",
198
+ "thaw_json_value",
199
+ "tool_message",
200
+ ]
@@ -0,0 +1,242 @@
1
+ """Small explicit digest writer for immutable kernel content."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from hashlib import sha256
7
+ from typing import Protocol, cast
8
+
9
+ from jharness.kernel.messages import ArtifactRef, ContentPart, ErrorInfo, Message, TaskRef, ToolCall
10
+ from jharness.kernel.tools import ToolAccepted, ToolFailure, ToolOutcome, ToolWaiting
11
+
12
+
13
+ class _Hash(Protocol):
14
+ def update(self, data: bytes, /) -> None: ...
15
+
16
+ def digest(self) -> bytes: ...
17
+
18
+
19
+ class DigestWriter:
20
+ """Domain-separated, length-prefixed writer with explicit scalar types."""
21
+
22
+ __slots__ = ("_hash",)
23
+
24
+ def __init__(self, domain: str) -> None:
25
+ self._hash: _Hash = sha256()
26
+ self._chunk(b"d", domain.encode())
27
+
28
+ def field(self, name: str) -> None:
29
+ self._chunk(b"k", name.encode())
30
+
31
+ def none(self) -> None:
32
+ self._hash.update(b"n")
33
+
34
+ def boolean(self, value: bool) -> None:
35
+ self._hash.update(b"b1" if value else b"b0")
36
+
37
+ def integer(self, value: int) -> None:
38
+ self._chunk(b"i", str(value).encode())
39
+
40
+ def number(self, value: float) -> None:
41
+ self._chunk(b"f", value.hex().encode())
42
+
43
+ def string(self, value: str) -> None:
44
+ self._chunk(b"s", value.encode())
45
+
46
+ def bytes(self, value: bytes) -> None:
47
+ self._chunk(b"x", value)
48
+
49
+ def sequence(self, length: int) -> None:
50
+ self._chunk(b"q", str(length).encode())
51
+
52
+ def mapping(self, length: int) -> None:
53
+ self._chunk(b"m", str(length).encode())
54
+
55
+ def json(self, value: object) -> None:
56
+ """Write a validated JSON value with stable mapping order and scalar types."""
57
+
58
+ if value is None:
59
+ self.none()
60
+ elif isinstance(value, bool):
61
+ self.boolean(value)
62
+ elif isinstance(value, int):
63
+ self.integer(value)
64
+ elif isinstance(value, float):
65
+ self.number(value)
66
+ elif isinstance(value, str):
67
+ self.string(value)
68
+ elif isinstance(value, Mapping):
69
+ self._json_mapping(cast(Mapping[object, object], value))
70
+ elif isinstance(value, Sequence) and not isinstance(value, bytes | bytearray):
71
+ items = cast(Sequence[object], value)
72
+ self.sequence(len(items))
73
+ for item in items:
74
+ self.json(item)
75
+ else:
76
+ raise TypeError(f"unsupported JSON digest value: {type(value).__qualname__}")
77
+
78
+ def finish(self) -> bytes:
79
+ return self._hash.digest()
80
+
81
+ def _json_mapping(self, value: Mapping[object, object]) -> None:
82
+ if any(not isinstance(key, str) for key in value):
83
+ raise TypeError("JSON digest mapping keys must be strings")
84
+ items = cast(Mapping[str, object], value)
85
+ keys = sorted(items)
86
+ self.mapping(len(keys))
87
+ for key in keys:
88
+ self.string(key)
89
+ self.json(items[key])
90
+
91
+ def _chunk(self, tag: bytes, value: bytes) -> None:
92
+ self._hash.update(tag)
93
+ self._hash.update(len(value).to_bytes(8, "big"))
94
+ self._hash.update(value)
95
+
96
+
97
+ def empty_history_digest() -> bytes:
98
+ return DigestWriter("jharness.kernel.history.empty.v0").finish()
99
+
100
+
101
+ def append_history_digest(previous: bytes, message: Message) -> bytes:
102
+ writer = DigestWriter("jharness.kernel.history.append.v0")
103
+ writer.field("previous")
104
+ writer.bytes(previous)
105
+ writer.field("message")
106
+ write_message(writer, message)
107
+ return writer.finish()
108
+
109
+
110
+ def write_message(writer: DigestWriter, value: Message) -> None:
111
+ writer.field("role")
112
+ writer.string(value.role)
113
+ writer.field("parts")
114
+ write_parts(writer, value.parts)
115
+ writer.field("tool_calls")
116
+ write_tool_calls(writer, value.tool_calls)
117
+ writer.field("tool_call_id")
118
+ write_optional_string(writer, value.tool_call_id)
119
+ writer.field("outcome")
120
+ if value.outcome is None:
121
+ writer.none()
122
+ else:
123
+ _write_tool_outcome(writer, value.outcome)
124
+ writer.field("metadata")
125
+ writer.json(value.metadata)
126
+
127
+
128
+ def write_parts(writer: DigestWriter, values: tuple[ContentPart, ...]) -> None:
129
+ writer.sequence(len(values))
130
+ for value in values:
131
+ _write_content_part(writer, value)
132
+
133
+
134
+ def _write_content_part(writer: DigestWriter, value: ContentPart) -> None:
135
+ writer.field("content_part")
136
+ writer.field("type")
137
+ writer.string(value.type)
138
+ writer.field("text")
139
+ write_optional_string(writer, value.text)
140
+ writer.field("uri")
141
+ write_optional_string(writer, value.uri)
142
+ writer.field("artifact")
143
+ if value.artifact is None:
144
+ writer.none()
145
+ else:
146
+ _write_artifact(writer, value.artifact)
147
+ writer.field("media_type")
148
+ write_optional_string(writer, value.media_type)
149
+ writer.field("name")
150
+ write_optional_string(writer, value.name)
151
+ writer.field("data")
152
+ writer.json(value.data)
153
+ writer.field("metadata")
154
+ writer.json(value.metadata)
155
+
156
+
157
+ def _write_artifact(writer: DigestWriter, value: ArtifactRef) -> None:
158
+ writer.field("artifact_ref")
159
+ writer.field("ref")
160
+ writer.string(value.ref)
161
+ writer.field("media_type")
162
+ write_optional_string(writer, value.media_type)
163
+ writer.field("name")
164
+ write_optional_string(writer, value.name)
165
+ writer.field("size_bytes")
166
+ write_optional_integer(writer, value.size_bytes)
167
+ writer.field("sha256")
168
+ write_optional_string(writer, value.sha256)
169
+ writer.field("metadata")
170
+ writer.json(value.metadata)
171
+
172
+
173
+ def write_tool_calls(writer: DigestWriter, values: tuple[ToolCall, ...]) -> None:
174
+ writer.sequence(len(values))
175
+ for value in values:
176
+ _write_tool_call(writer, value)
177
+
178
+
179
+ def _write_tool_call(writer: DigestWriter, value: ToolCall) -> None:
180
+ writer.field("tool_call")
181
+ writer.field("id")
182
+ writer.string(value.id)
183
+ writer.field("name")
184
+ writer.string(value.name)
185
+ writer.field("arguments")
186
+ writer.json(value.arguments)
187
+
188
+
189
+ def _write_tool_outcome(writer: DigestWriter, value: ToolOutcome) -> None:
190
+ writer.field("tool_outcome")
191
+ writer.field("kind")
192
+ writer.string(value.kind)
193
+ writer.field("parts")
194
+ write_parts(writer, value.parts)
195
+ writer.field("structured_content")
196
+ writer.json(value.structured_content)
197
+ if isinstance(value, ToolFailure):
198
+ writer.field("error")
199
+ write_error(writer, value.error)
200
+ elif isinstance(value, ToolAccepted):
201
+ writer.field("correlation_id")
202
+ writer.string(value.correlation_id)
203
+ writer.field("task")
204
+ _write_optional_task(writer, value.task)
205
+ elif isinstance(value, ToolWaiting):
206
+ writer.field("task")
207
+ _write_optional_task(writer, value.task)
208
+
209
+
210
+ def _write_optional_task(writer: DigestWriter, value: TaskRef | None) -> None:
211
+ if value is None:
212
+ writer.none()
213
+ return
214
+ writer.field("task_ref")
215
+ writer.field("id")
216
+ writer.string(value.id)
217
+ writer.field("status")
218
+ writer.string(value.status)
219
+ writer.field("metadata")
220
+ writer.json(value.metadata)
221
+
222
+
223
+ def write_error(writer: DigestWriter, value: ErrorInfo) -> None:
224
+ writer.field("error_info")
225
+ writer.field("code")
226
+ writer.string(value.code)
227
+ writer.field("message")
228
+ writer.string(value.message)
229
+
230
+
231
+ def write_optional_string(writer: DigestWriter, value: str | None) -> None:
232
+ if value is None:
233
+ writer.none()
234
+ else:
235
+ writer.string(value)
236
+
237
+
238
+ def write_optional_integer(writer: DigestWriter, value: int | None) -> None:
239
+ if value is None:
240
+ writer.none()
241
+ else:
242
+ writer.integer(value)
@@ -0,0 +1 @@
1
+ """Private execution engine."""
@@ -0,0 +1,117 @@
1
+ """Closed durable change consumed by the pure reducer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from time import time
7
+ from typing import TYPE_CHECKING, cast
8
+
9
+ from jharness.kernel._validation import expect_instance, expect_instance_tuple
10
+ from jharness.kernel.checkpoint import (
11
+ ControlFact,
12
+ ConversationInsertFact,
13
+ Fact,
14
+ FailedControl,
15
+ LimitedControl,
16
+ SuspendedControl,
17
+ )
18
+ from jharness.kernel.control import Insert
19
+ from jharness.kernel.limits import LimitReason
20
+ from jharness.kernel.messages import ErrorInfo, Message
21
+ from jharness.kernel.models import ModelUsage
22
+ from jharness.kernel.state import (
23
+ ActiveState,
24
+ Failed,
25
+ Limited,
26
+ Planning,
27
+ RunState,
28
+ Suspended,
29
+ Suspension,
30
+ )
31
+
32
+ if TYPE_CHECKING:
33
+ from jharness.kernel.checkpoint import Checkpoint
34
+ from jharness.kernel.snapshot import RunSnapshot
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class Change:
39
+ fact: Fact
40
+ state: RunState
41
+ append: tuple[Message, ...] = ()
42
+ replace: tuple[Message, ...] | None = None
43
+ planning_steps: int = 0
44
+ tool_calls: int = 0
45
+ usage: ModelUsage | None = None
46
+
47
+ def __post_init__(self) -> None:
48
+ if not isinstance(cast(object, self.fact), Fact):
49
+ raise TypeError("change fact must be a Fact")
50
+ if not isinstance(cast(object, self.state), RunState):
51
+ raise TypeError("change state must be a RunState")
52
+ append = expect_instance_tuple(self.append, Message, "change append")
53
+ replace = (
54
+ None
55
+ if self.replace is None
56
+ else expect_instance_tuple(self.replace, Message, "change replacement")
57
+ )
58
+ if replace is not None and (not replace or append):
59
+ raise ValueError("change replacement must be non-empty and exclusive with append")
60
+ object.__setattr__(self, "append", append)
61
+ object.__setattr__(self, "replace", replace)
62
+
63
+
64
+ def failed(code: str, message: str) -> Change:
65
+ return Change(
66
+ ControlFact(time(), FailedControl(code)),
67
+ Failed(ErrorInfo(code, message)),
68
+ )
69
+
70
+
71
+ def limited(reason: LimitReason) -> Change:
72
+ return Change(ControlFact(time(), LimitedControl(reason)), Limited(reason))
73
+
74
+
75
+ def insert(control: Insert) -> Change:
76
+ return Change(
77
+ ConversationInsertFact(time(), control.source),
78
+ Planning(),
79
+ append=(control.message,),
80
+ )
81
+
82
+
83
+ def suspend(state: ActiveState, suspension: Suspension) -> Change:
84
+ return Change(
85
+ ControlFact(
86
+ time(),
87
+ SuspendedControl(
88
+ suspension.reason,
89
+ suspension.source,
90
+ suspension.wait_id,
91
+ tuple(sorted(suspension.metadata)),
92
+ ),
93
+ ),
94
+ Suspended(state, suspension),
95
+ )
96
+
97
+
98
+ def reduce(snapshot: RunSnapshot, change: Change, *, checkpoint_id: str) -> Checkpoint:
99
+ """Purely apply one trusted change and return the next Checkpoint."""
100
+
101
+ from jharness.kernel.checkpoint import Checkpoint
102
+ from jharness.kernel.snapshot import RunSnapshot
103
+
104
+ snapshot = expect_instance(snapshot, RunSnapshot, "snapshot")
105
+ change = expect_instance(change, Change, "change")
106
+ metrics = snapshot.metrics.advance(
107
+ planning_steps=change.planning_steps,
108
+ tool_calls=change.tool_calls,
109
+ usage=change.usage,
110
+ )
111
+ next_snapshot = snapshot._evolve( # pyright: ignore[reportPrivateUsage]
112
+ append=change.append,
113
+ replace=change.replace,
114
+ metrics=metrics,
115
+ state=change.state,
116
+ )
117
+ return Checkpoint(checkpoint_id, next_snapshot, change.fact)
@@ -0,0 +1,82 @@
1
+ """Single Checkpoint construction and persistence path."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from uuid import uuid4
7
+
8
+ from jharness.kernel._engine.change import Change, reduce
9
+ from jharness.kernel._validation import expect_instance
10
+ from jharness.kernel.checkpoint import Checkpoint
11
+ from jharness.kernel.errors import CommitError
12
+ from jharness.kernel.repository import RunRepository
13
+
14
+
15
+ class WorkCommitDeadlineReached(Exception):
16
+ pass
17
+
18
+
19
+ class Committer:
20
+ __slots__ = ("_repository", "_timeout")
21
+
22
+ def __init__(self, repository: RunRepository, *, timeout_seconds: float) -> None:
23
+ self._repository = expect_instance(repository, RunRepository, "repository")
24
+ if timeout_seconds <= 0:
25
+ raise ValueError("repository timeout must be > 0")
26
+ self._timeout = timeout_seconds
27
+
28
+ async def persist_start(
29
+ self,
30
+ checkpoint: Checkpoint,
31
+ *,
32
+ work_timeout_seconds: float | None = None,
33
+ ) -> Checkpoint:
34
+ checkpoint = expect_instance(checkpoint, Checkpoint, "start checkpoint")
35
+ if checkpoint.snapshot.revision != 0:
36
+ raise ValueError("start checkpoint revision must be 0")
37
+ return await self._persist(
38
+ checkpoint,
39
+ previous=None,
40
+ work_timeout_seconds=work_timeout_seconds,
41
+ )
42
+
43
+ async def apply(
44
+ self,
45
+ previous: Checkpoint,
46
+ change: Change,
47
+ *,
48
+ work_timeout_seconds: float | None = None,
49
+ ) -> Checkpoint:
50
+ previous = expect_instance(previous, Checkpoint, "previous checkpoint")
51
+ checkpoint = reduce(previous.snapshot, change, checkpoint_id=str(uuid4()))
52
+ return await self._persist(
53
+ checkpoint,
54
+ previous=previous,
55
+ work_timeout_seconds=work_timeout_seconds,
56
+ )
57
+
58
+ async def _persist(
59
+ self,
60
+ checkpoint: Checkpoint,
61
+ *,
62
+ previous: Checkpoint | None,
63
+ work_timeout_seconds: float | None = None,
64
+ ) -> Checkpoint:
65
+ work_limited = work_timeout_seconds is not None and work_timeout_seconds <= self._timeout
66
+ timeout = (
67
+ self._timeout
68
+ if work_timeout_seconds is None
69
+ else min(self._timeout, max(0.0, work_timeout_seconds))
70
+ )
71
+ if timeout <= 0:
72
+ raise WorkCommitDeadlineReached
73
+ try:
74
+ async with asyncio.timeout(timeout):
75
+ await self._repository.commit(checkpoint)
76
+ except TimeoutError as exc:
77
+ if work_limited:
78
+ raise WorkCommitDeadlineReached from exc
79
+ raise CommitError("repository commit timed out", last_checkpoint=previous) from exc
80
+ except Exception as exc:
81
+ raise CommitError(str(exc) or exc.__class__.__name__, last_checkpoint=previous) from exc
82
+ return checkpoint