ctrlrun 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.
- ctrlrun/__init__.py +67 -0
- ctrlrun/action.py +161 -0
- ctrlrun/approval.py +447 -0
- ctrlrun/cli/__init__.py +1 -0
- ctrlrun/cli/demo.py +300 -0
- ctrlrun/cli/main.py +260 -0
- ctrlrun/control.py +822 -0
- ctrlrun/effect.py +296 -0
- ctrlrun/errors.py +114 -0
- ctrlrun/policy.py +389 -0
- ctrlrun/py.typed +0 -0
- ctrlrun/receipt.py +229 -0
- ctrlrun/state.py +1131 -0
- ctrlrun-0.1.0.dist-info/METADATA +164 -0
- ctrlrun-0.1.0.dist-info/RECORD +19 -0
- ctrlrun-0.1.0.dist-info/WHEEL +5 -0
- ctrlrun-0.1.0.dist-info/entry_points.txt +2 -0
- ctrlrun-0.1.0.dist-info/licenses/LICENSE +202 -0
- ctrlrun-0.1.0.dist-info/top_level.txt +1 -0
ctrlrun/effect.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""Effect keys and effect records. Build-list items 5 and 6; SPEC-v0.1 §5.
|
|
2
|
+
|
|
3
|
+
The effect key is what duplicate protection is built on (§5.3): two attempts that resolve to
|
|
4
|
+
the same key are the same real-world effect, whatever their `action_id`s are. Resolution is
|
|
5
|
+
therefore strict — an unresolvable key is never a silent `None`, and a placeholder that
|
|
6
|
+
resolves to nothing identifiable is refused rather than rendered.
|
|
7
|
+
|
|
8
|
+
`plan_reservation` is the retry table of §5.4 as one pure function. Both StateStores decide
|
|
9
|
+
with it and then only write, so the rule that refuses a duplicate lives in exactly one place
|
|
10
|
+
and the in-memory store cannot drift into permitting what SQLite refuses.
|
|
11
|
+
|
|
12
|
+
The transitions of §5.2 are in `state.py`, where the records live. `ctrlrun resolve` — the
|
|
13
|
+
only way out of `AMBIGUOUS`, because it is the only one a human drives — arrives with item 8.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from collections.abc import Mapping
|
|
20
|
+
from dataclasses import dataclass, replace
|
|
21
|
+
from datetime import datetime, timedelta
|
|
22
|
+
from enum import StrEnum
|
|
23
|
+
from typing import Any, Final
|
|
24
|
+
|
|
25
|
+
from .action import Action
|
|
26
|
+
from .errors import (
|
|
27
|
+
AmbiguousEffect,
|
|
28
|
+
CTRLRunError,
|
|
29
|
+
DuplicateEffect,
|
|
30
|
+
EffectKeyError,
|
|
31
|
+
InvalidArgument,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
#: The decision reason recorded when an effect template cannot be resolved (SPEC §5.1, §6.1).
|
|
35
|
+
UNRESOLVED_EFFECT: Final = "effect_key_error"
|
|
36
|
+
|
|
37
|
+
#: SPEC-v0.1 §5.3 E3 — a reservation is held for five minutes unless told otherwise.
|
|
38
|
+
DEFAULT_LEASE: Final = timedelta(minutes=5)
|
|
39
|
+
|
|
40
|
+
#: `DuplicateEffect.state` values (SPEC-v0.1 §5.4).
|
|
41
|
+
COMMITTED_EFFECT: Final = "committed"
|
|
42
|
+
IN_PROGRESS_EFFECT: Final = "in_progress"
|
|
43
|
+
|
|
44
|
+
#: Recorded on a record whose holder disappeared with its lease still held (§5.3 E3).
|
|
45
|
+
LEASE_EXPIRED: Final = "lease expired: the worker holding this effect never finished"
|
|
46
|
+
|
|
47
|
+
#: In an effect template, `{resource}` names the action's `resource` field (SPEC §5.1).
|
|
48
|
+
RESOURCE_PLACEHOLDER: Final = "resource"
|
|
49
|
+
|
|
50
|
+
#: A template is literal text and `{name}` placeholders. Anything else is a typo. `name` is
|
|
51
|
+
#: an identifier — a letter or underscore, then letters, digits or underscores — because it
|
|
52
|
+
#: names an argument, and an argument name is a Python parameter name.
|
|
53
|
+
_TOKEN: Final = re.compile(r"\{(?P<name>[^\W\d]\w*)\}|(?P<text>[^{}]+)|(?P<bad>.)")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class EffectState(StrEnum):
|
|
57
|
+
"""Where a logical effect stands (SPEC-v0.1 §5.2).
|
|
58
|
+
|
|
59
|
+
`NEW` is the state of a key nobody has reserved: it is never written to a store, which
|
|
60
|
+
reports it as no record at all. `AMBIGUOUS` never collapses to `FAILED`; only a human
|
|
61
|
+
moves a record out of it.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
NEW = "new"
|
|
65
|
+
RESERVED = "reserved"
|
|
66
|
+
EXECUTING = "executing"
|
|
67
|
+
COMMITTED = "committed"
|
|
68
|
+
FAILED = "failed"
|
|
69
|
+
AMBIGUOUS = "ambiguous"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class EffectRecord:
|
|
74
|
+
"""What a StateStore holds for one effect key (ARCHITECTURE §5)."""
|
|
75
|
+
|
|
76
|
+
effect_key: str
|
|
77
|
+
state: EffectState
|
|
78
|
+
action_id: str
|
|
79
|
+
attempt: int
|
|
80
|
+
created_at: datetime
|
|
81
|
+
updated_at: datetime
|
|
82
|
+
lease_expires_at: datetime | None = None
|
|
83
|
+
result: Any = None
|
|
84
|
+
error: str | None = None
|
|
85
|
+
|
|
86
|
+
def lease_is_live(self, now: datetime) -> bool:
|
|
87
|
+
"""Whether an attempt is still holding this effect (SPEC-v0.1 §5.3 E3).
|
|
88
|
+
|
|
89
|
+
A `RESERVED` or `EXECUTING` record with no lease at all counts as expired: the
|
|
90
|
+
conservative reading, since an expired lease is refused more firmly than a live one.
|
|
91
|
+
"""
|
|
92
|
+
if self.state not in (EffectState.RESERVED, EffectState.EXECUTING):
|
|
93
|
+
return False
|
|
94
|
+
return self.lease_expires_at is not None and now <= self.lease_expires_at
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class Reservation:
|
|
99
|
+
"""The right to execute one effect once, until `lease_expires_at` (SPEC-v0.1 §5.3)."""
|
|
100
|
+
|
|
101
|
+
effect_key: str
|
|
102
|
+
action_id: str
|
|
103
|
+
attempt: int
|
|
104
|
+
lease_expires_at: datetime
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True)
|
|
108
|
+
class ReservationPlan:
|
|
109
|
+
"""What a store must do about one reservation attempt (SPEC-v0.1 §5.4).
|
|
110
|
+
|
|
111
|
+
Exactly one of `reservation` and `refusal` is set. `renews` means an existing `FAILED`
|
|
112
|
+
record is being retried, so the store updates rather than inserts — an insert that hits
|
|
113
|
+
the `UNIQUE(effect_key)` constraint is then a real violation, not an expected one.
|
|
114
|
+
`ambiguate` means the existing record must first be moved to `AMBIGUOUS`, and that write
|
|
115
|
+
kept even though the attempt is refused: an expired lease is evidence (§5.3 E3).
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
reservation: Reservation | None = None
|
|
119
|
+
refusal: CTRLRunError | None = None
|
|
120
|
+
renews: bool = False
|
|
121
|
+
ambiguate: bool = False
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def plan_reservation(
|
|
125
|
+
record: EffectRecord | None,
|
|
126
|
+
effect_key: str,
|
|
127
|
+
action_id: str,
|
|
128
|
+
lease: timedelta,
|
|
129
|
+
now: datetime,
|
|
130
|
+
) -> ReservationPlan:
|
|
131
|
+
"""Apply the retry table of SPEC-v0.1 §5.4 to one reservation attempt.
|
|
132
|
+
|
|
133
|
+
Pure: it reads a record and returns what to do. Every store decides here, so `FAILED` is
|
|
134
|
+
the only state that lets a second attempt through, in one place rather than in each.
|
|
135
|
+
"""
|
|
136
|
+
if not effect_key:
|
|
137
|
+
raise InvalidArgument("effect_key must be a non-empty string")
|
|
138
|
+
if not action_id:
|
|
139
|
+
raise InvalidArgument("action_id must be a non-empty string")
|
|
140
|
+
if lease <= timedelta(0):
|
|
141
|
+
raise InvalidArgument(f"a reservation lease must be positive, got {lease!r}")
|
|
142
|
+
|
|
143
|
+
granted = Reservation(
|
|
144
|
+
effect_key=effect_key,
|
|
145
|
+
action_id=action_id,
|
|
146
|
+
attempt=1,
|
|
147
|
+
lease_expires_at=now + lease,
|
|
148
|
+
)
|
|
149
|
+
if record is None or record.state is EffectState.NEW:
|
|
150
|
+
return ReservationPlan(reservation=granted)
|
|
151
|
+
if record.state is EffectState.COMMITTED:
|
|
152
|
+
return ReservationPlan(
|
|
153
|
+
refusal=DuplicateEffect(
|
|
154
|
+
f"effect {effect_key!r} was already committed by {record.action_id}",
|
|
155
|
+
state=COMMITTED_EFFECT,
|
|
156
|
+
effect_key=effect_key,
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
if record.state is EffectState.AMBIGUOUS:
|
|
160
|
+
return ReservationPlan(
|
|
161
|
+
refusal=AmbiguousEffect(
|
|
162
|
+
f"effect {effect_key!r} has an unknown outcome from {record.action_id}; "
|
|
163
|
+
f"resolve it with 'ctrlrun resolve {effect_key}' before retrying",
|
|
164
|
+
effect_key=effect_key,
|
|
165
|
+
action_id=record.action_id,
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
if record.state is EffectState.FAILED:
|
|
169
|
+
# SPEC §5.4 — the only automatic retry: the executor proved nothing happened (§5.5).
|
|
170
|
+
return ReservationPlan(
|
|
171
|
+
reservation=replace(granted, attempt=record.attempt + 1), renews=True
|
|
172
|
+
)
|
|
173
|
+
if record.lease_is_live(now):
|
|
174
|
+
return ReservationPlan(
|
|
175
|
+
refusal=DuplicateEffect(
|
|
176
|
+
f"effect {effect_key!r} is {record.state} under {record.action_id}",
|
|
177
|
+
state=IN_PROGRESS_EFFECT,
|
|
178
|
+
effect_key=effect_key,
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
# SPEC §5.3 E3 — the lease expired mid-flight. The remote may have committed, so the
|
|
182
|
+
# record becomes AMBIGUOUS; it is never silently released to the next caller.
|
|
183
|
+
return ReservationPlan(
|
|
184
|
+
refusal=AmbiguousEffect(
|
|
185
|
+
f"effect {effect_key!r} was left {record.state} by {record.action_id} and its "
|
|
186
|
+
f"lease expired; resolve it with 'ctrlrun resolve {effect_key}'",
|
|
187
|
+
effect_key=effect_key,
|
|
188
|
+
action_id=record.action_id,
|
|
189
|
+
),
|
|
190
|
+
ambiguate=True,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def template_placeholders(template: str) -> tuple[str, ...]:
|
|
195
|
+
"""Return the placeholder names in `template`, in order. Malformed → `InvalidArgument`.
|
|
196
|
+
|
|
197
|
+
The grammar is deliberately smaller than `str.format`: no `{{` escapes, no format specs,
|
|
198
|
+
no attribute or index access. An effect key is an identity, not a formatted string, so a
|
|
199
|
+
brace that is not part of a `{name}` placeholder is a typo — and a typo must not become
|
|
200
|
+
part of an effect identity.
|
|
201
|
+
"""
|
|
202
|
+
if not template:
|
|
203
|
+
raise InvalidArgument("a template must be a non-empty string")
|
|
204
|
+
names: list[str] = []
|
|
205
|
+
for token in _TOKEN.finditer(template):
|
|
206
|
+
if token.group("bad") is not None:
|
|
207
|
+
raise InvalidArgument(
|
|
208
|
+
f"{template!r} is not a valid template: expected literal text and "
|
|
209
|
+
f"'{{name}}' placeholders, found {token.group('bad')!r} at position "
|
|
210
|
+
f"{token.start()}"
|
|
211
|
+
)
|
|
212
|
+
name = token.group("name")
|
|
213
|
+
if name is not None:
|
|
214
|
+
names.append(name)
|
|
215
|
+
return tuple(names)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def resolve_effect_key(template: str, action: Action) -> str:
|
|
219
|
+
"""Resolve an effect template against a constructed action (SPEC-v0.1 §5.1).
|
|
220
|
+
|
|
221
|
+
Placeholders name the action's arguments; `{resource}` names its `resource` field. A
|
|
222
|
+
placeholder with no value raises `EffectKeyError`, and the action is refused: an action
|
|
223
|
+
whose logical effect cannot be identified cannot be protected against duplication.
|
|
224
|
+
"""
|
|
225
|
+
names = template_placeholders(template)
|
|
226
|
+
values: dict[str, Any] = action.canonical_arguments
|
|
227
|
+
if RESOURCE_PLACEHOLDER in names:
|
|
228
|
+
if RESOURCE_PLACEHOLDER in values:
|
|
229
|
+
# SPEC: §5.1 — the spec gives `{resource}` to the resource field but does not say
|
|
230
|
+
# what an argument of the same name does. Two candidate values for one key is the
|
|
231
|
+
# fail-closed case: refuse, rather than silently pick the one the author did not
|
|
232
|
+
# mean, because duplicate protection depends on which one it is.
|
|
233
|
+
raise EffectKeyError(
|
|
234
|
+
f"{template!r}: '{{resource}}' is ambiguous — {action.name} has both a "
|
|
235
|
+
"resource field and an argument named 'resource'; rename the argument"
|
|
236
|
+
)
|
|
237
|
+
if action.resource is None:
|
|
238
|
+
raise EffectKeyError(
|
|
239
|
+
f"{template!r}: '{{resource}}' needs a resource on the action, and "
|
|
240
|
+
f"{action.name} has none"
|
|
241
|
+
)
|
|
242
|
+
values[RESOURCE_PLACEHOLDER] = action.resource
|
|
243
|
+
return _render(template, values, EffectKeyError)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def resolve_resource(template: str, arguments: Mapping[str, Any]) -> str:
|
|
247
|
+
"""Resolve a `resource=` template against the bound call arguments (SPEC-v0.1 §5.1).
|
|
248
|
+
|
|
249
|
+
Same syntax and same resolver as an effect template, but resolved *before* the Action
|
|
250
|
+
exists: `resource` is part of the canonical form and therefore of the action hash (§2.2),
|
|
251
|
+
so a missing placeholder is an `InvalidArgument` at construction time, as in §2.
|
|
252
|
+
"""
|
|
253
|
+
return _render(template, arguments, InvalidArgument)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _render(template: str, values: Mapping[str, Any], error: type[CTRLRunError]) -> str:
|
|
257
|
+
"""Substitute `values` into `template`, raising `error` for anything unresolvable."""
|
|
258
|
+
parts: list[str] = []
|
|
259
|
+
for token in _TOKEN.finditer(template):
|
|
260
|
+
text = token.group("text")
|
|
261
|
+
if text is not None:
|
|
262
|
+
parts.append(text)
|
|
263
|
+
continue
|
|
264
|
+
name = token.group("name")
|
|
265
|
+
if name is None:
|
|
266
|
+
raise InvalidArgument(
|
|
267
|
+
f"{template!r} is not a valid template: found {token.group('bad')!r} at "
|
|
268
|
+
f"position {token.start()}"
|
|
269
|
+
)
|
|
270
|
+
if name not in values:
|
|
271
|
+
raise error(
|
|
272
|
+
f"{template!r}: no value for '{{{name}}}' (have: "
|
|
273
|
+
f"{', '.join(sorted(values)) or 'nothing'})"
|
|
274
|
+
)
|
|
275
|
+
parts.append(_rendered(name, values[name], template, error))
|
|
276
|
+
return "".join(parts)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _rendered(name: str, value: object, template: str, error: type[CTRLRunError]) -> str:
|
|
280
|
+
"""Render one placeholder value, or raise: only a non-empty `str` or an `int` will do.
|
|
281
|
+
|
|
282
|
+
SPEC: §5.1 — the spec does not restrict placeholder types. This is the fail-closed
|
|
283
|
+
reading: `None` and `""` identify nothing and would collide across unrelated actions,
|
|
284
|
+
`bool` identifies nothing either, and a container has no stable rendering. An effect key
|
|
285
|
+
must be an identity a human can read and two attempts can agree on.
|
|
286
|
+
"""
|
|
287
|
+
if isinstance(value, str):
|
|
288
|
+
if not value:
|
|
289
|
+
raise error(f"{template!r}: '{{{name}}}' is empty; an effect key must identify")
|
|
290
|
+
return value
|
|
291
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
292
|
+
return str(value)
|
|
293
|
+
raise error(
|
|
294
|
+
f"{template!r}: '{{{name}}}' is {type(value).__name__}; a placeholder must resolve "
|
|
295
|
+
"to a non-empty string or an int"
|
|
296
|
+
)
|
ctrlrun/errors.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Exception hierarchy for the public API. SPEC-v0.1 §8."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CTRLRunError(Exception):
|
|
5
|
+
"""Base class for every error raised by CTRLRun."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class InvalidArgument(CTRLRunError):
|
|
9
|
+
"""An argument cannot be accepted as given.
|
|
10
|
+
|
|
11
|
+
An Action field or argument that cannot be canonicalized (SPEC-v0.1 §2.3), and — the
|
|
12
|
+
same kind of wiring bug — a StateStore transition no record can make, such as committing
|
|
13
|
+
an effect nobody reserved.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PolicyError(CTRLRunError):
|
|
18
|
+
"""The policy is missing, unreadable, or malformed. Raised at load time (SPEC-v0.1 §3.4)."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class EffectKeyError(CTRLRunError):
|
|
22
|
+
"""An effect template cannot be resolved to a key (SPEC-v0.1 §5.1).
|
|
23
|
+
|
|
24
|
+
The action is refused rather than executed without an effect key: an action whose
|
|
25
|
+
logical effect cannot be identified cannot be protected against duplication.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ActionDenied(CTRLRunError):
|
|
30
|
+
"""The action may not run. `reason` says why, e.g. `unknown_action` (SPEC-v0.1 §3.4)."""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self, message: str | None = None, *, reason: str, action_id: str | None = None
|
|
34
|
+
) -> None:
|
|
35
|
+
super().__init__(message if message is not None else reason)
|
|
36
|
+
self.reason = reason
|
|
37
|
+
self.action_id = action_id
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ApprovalRequired(CTRLRunError):
|
|
41
|
+
"""The action needs a human. `request_id` is what `ctrlrun approve` takes (SPEC §4.3).
|
|
42
|
+
|
|
43
|
+
Raised instead of blocking, so an agent loop can surface the request and come back with
|
|
44
|
+
`ctrlrun.with_approval(request_id)` in context.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self, message: str | None = None, *, request_id: str, action_id: str | None = None
|
|
49
|
+
) -> None:
|
|
50
|
+
super().__init__(message if message is not None else request_id)
|
|
51
|
+
self.request_id = request_id
|
|
52
|
+
self.action_id = action_id
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ApprovalTimeout(CTRLRunError):
|
|
56
|
+
"""Nobody answered the approval request in time (SPEC-v0.1 §4.3)."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, message: str | None = None, *, request_id: str) -> None:
|
|
59
|
+
super().__init__(message if message is not None else request_id)
|
|
60
|
+
self.request_id = request_id
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ApprovalMismatch(CTRLRunError):
|
|
64
|
+
"""The presented approval does not authorize this action (SPEC-v0.1 §4.2).
|
|
65
|
+
|
|
66
|
+
`reason` is one of `unknown`, `mismatch`, or the status the record was in — `consumed`,
|
|
67
|
+
`expired`, `pending`, `denied`.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self, message: str | None = None, *, reason: str, approval_id: str | None = None
|
|
72
|
+
) -> None:
|
|
73
|
+
super().__init__(message if message is not None else reason)
|
|
74
|
+
self.reason = reason
|
|
75
|
+
self.approval_id = approval_id
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class DuplicateEffect(CTRLRunError):
|
|
79
|
+
"""This logical effect already happened, or is happening now (SPEC-v0.1 §5.4).
|
|
80
|
+
|
|
81
|
+
`state` is `committed` — the effect is done — or `in_progress`, meaning another attempt
|
|
82
|
+
holds a live reservation on the key. Neither permits a second execution.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self, message: str | None = None, *, state: str, effect_key: str | None = None
|
|
87
|
+
) -> None:
|
|
88
|
+
super().__init__(message if message is not None else state)
|
|
89
|
+
self.state = state
|
|
90
|
+
self.effect_key = effect_key
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class AmbiguousEffect(CTRLRunError):
|
|
94
|
+
"""The outcome of this effect is unknown; only a human may resolve it (SPEC-v0.1 §5.4).
|
|
95
|
+
|
|
96
|
+
Raised for a record already in `AMBIGUOUS`, and for one whose lease expired mid-flight:
|
|
97
|
+
the worker may have died after the remote committed. A retry is refused either way,
|
|
98
|
+
until `ctrlrun resolve` says which it was.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self, message: str | None = None, *, effect_key: str, action_id: str | None = None
|
|
103
|
+
) -> None:
|
|
104
|
+
super().__init__(message if message is not None else effect_key)
|
|
105
|
+
self.effect_key = effect_key
|
|
106
|
+
self.action_id = action_id
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class NotExecuted(CTRLRunError):
|
|
110
|
+
"""Raised by an executor to assert the remote side did nothing (SPEC-v0.1 §5.5).
|
|
111
|
+
|
|
112
|
+
This is the *only* exception that maps to `FAILED` and therefore permits a retry.
|
|
113
|
+
Every other exception is an `AMBIGUOUS` outcome.
|
|
114
|
+
"""
|