@passioncode-ai/passioncode 0.2.45 → 0.2.46
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.
- package/CHANGELOG.md +9 -0
- package/family.json +1 -1
- package/package.json +1 -1
- package/payload/.claude-plugin/marketplace.json +2 -2
- package/payload/manifest.json +8 -8
- package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/payload/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +5 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +73 -18
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +6 -5
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +70 -20
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +7 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +9 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +14 -5
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +1 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +4 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +16 -9
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +87 -156
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +180 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +1 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +4 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +2 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +15 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +12 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +12 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +1 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +32 -13
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +115 -147
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +173 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +9 -42
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +53 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +7 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +4 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +87 -11
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +19 -8
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +30 -6
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +9 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +15 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +73 -18
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +134 -24
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +155 -21
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +10 -10
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +2 -1
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +39 -13
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +6 -2
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +17 -3
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +23 -6
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +10 -39
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +190 -0
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +1 -1
- package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
|
@@ -3,6 +3,7 @@ accept the exact digest, effect, progress, complete; stop when fenced). The sess
|
|
|
3
3
|
in fabric_board_link and are re-exported here. Part of the board connector (FAA-65, layer L4)."""
|
|
4
4
|
from __future__ import annotations
|
|
5
5
|
|
|
6
|
+
import inspect
|
|
6
7
|
import json
|
|
7
8
|
import random
|
|
8
9
|
import re
|
|
@@ -26,6 +27,13 @@ from fabric_board_link import ( # noqa: E402,F401 - re-exported: callers import
|
|
|
26
27
|
BoardSession,
|
|
27
28
|
Checkpoint,
|
|
28
29
|
)
|
|
30
|
+
from fabric_board_slots import ( # noqa: E402,F401 - the slot records (FAA-155, FAA-163); the constants re-exported
|
|
31
|
+
HOLD_RETRY_MAX_SECONDS,
|
|
32
|
+
LEASE_MARGIN_SECONDS,
|
|
33
|
+
SESSION_CODES,
|
|
34
|
+
SlotKeeper,
|
|
35
|
+
_whole,
|
|
36
|
+
)
|
|
29
37
|
|
|
30
38
|
|
|
31
39
|
RENEW_SECONDS = 20.0
|
|
@@ -36,6 +44,9 @@ POLL_JITTER_SECONDS = 5.0
|
|
|
36
44
|
|
|
37
45
|
DRAIN_SECONDS = 10.0
|
|
38
46
|
|
|
47
|
+
#: An idle responder's leave waits this long for the DELETE, so an idle stop ends within LC-01's 5 s (FAA-173).
|
|
48
|
+
IDLE_LEAVE_SECONDS = 3.0
|
|
49
|
+
|
|
39
50
|
EVENT_SCHEMA = "urn:fabric:schema:board-responder-event:1"
|
|
40
51
|
|
|
41
52
|
#: On these the attempt is no longer ours: stop acting at once (C4).
|
|
@@ -46,11 +57,7 @@ TERMINAL = frozenset(["completed", "failed_known", "cancelled", "expired"])
|
|
|
46
57
|
#: participant reply (DEC-0098, DEC-0102; not terminal, the requester may still cancel it, and nobody claims it).
|
|
47
58
|
NOT_OURS = frozenset(["queued", "claimed", "answered"])
|
|
48
59
|
|
|
49
|
-
#: A lost call is sent again this long before the lease it keeps would lapse (FAA-77, finding 38).
|
|
50
|
-
LEASE_MARGIN_SECONDS = 10.0
|
|
51
60
|
|
|
52
|
-
#: How long a held slot waits before it asks again: doubling from the poll interval, never past this.
|
|
53
|
-
HOLD_RETRY_MAX_SECONDS = 900.0
|
|
54
61
|
|
|
55
62
|
|
|
56
63
|
class WorkContext:
|
|
@@ -84,10 +91,35 @@ class _Work:
|
|
|
84
91
|
|
|
85
92
|
Handler = Callable[[BoardRequest, WorkContext], Dict[str, Any]]
|
|
86
93
|
|
|
87
|
-
_whole = lambda v: int(v) if isinstance(v, float) and v.is_integer() else v # noqa: E731 - 2.0 is 2, as Node reads it
|
|
88
94
|
|
|
89
95
|
|
|
90
|
-
|
|
96
|
+
def _notice_subject(value: Dict[str, Any], project: str, episode: Optional[str] = None) -> Dict[str, str]:
|
|
97
|
+
"""A notice's subject (`{type, id}`): its request, else its capability, else its episode (a cause such as a block's
|
|
98
|
+
code, so two causes are two episodes for a host, seam r31), else the Project."""
|
|
99
|
+
for kind, given in (("request", value.get("request")), ("capability", value.get("capability")), ("episode", episode)):
|
|
100
|
+
if isinstance(given, str) and 0 < len(given) <= 128:
|
|
101
|
+
return {"type": kind, "id": given}
|
|
102
|
+
return {"type": "project", "id": project}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _takes_subject(emit: Callable[..., Any]) -> bool:
|
|
106
|
+
"""Whether an emit's signature names a `subject` parameter, as JsonlEventLog.append does. Any other emit (one with
|
|
107
|
+
only **kwargs, which may forward them to a sink without it or set its own, or one whose signature cannot be read)
|
|
108
|
+
is called as 0.55.0 called it, without one (0.56.0 unit r33). A subject already bound into the emit, as with
|
|
109
|
+
functools.partial(log.append, subject=...), stands.
|
|
110
|
+
|
|
111
|
+
The signature is the emit's own, not a wrapped function's: a functools.wraps forwarder that sets its own subject
|
|
112
|
+
would otherwise be passed a second one (0.56.0 unit r34). Nothing here raises, so a notice never stops the
|
|
113
|
+
responder."""
|
|
114
|
+
try:
|
|
115
|
+
params = inspect.signature(emit, follow_wrapped=False).parameters.values()
|
|
116
|
+
return any(p.name == "subject" and p.kind is not inspect.Parameter.VAR_KEYWORD
|
|
117
|
+
and (p.default is inspect.Parameter.empty or p.default is None) for p in params)
|
|
118
|
+
except Exception: # noqa: BLE001 - an unreadable signature (or a default that raises) is called without one
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class Responder(SlotKeeper):
|
|
91
123
|
"""One service's responder for one Project and one or more capabilities, one request at a time.
|
|
92
124
|
|
|
93
125
|
`handler(request, ctx)` returns the outcome `{state, observed?, summary?, evidence?, reply?}`; `admit(request)`
|
|
@@ -132,21 +164,31 @@ class Responder:
|
|
|
132
164
|
self._lock = threading.RLock()
|
|
133
165
|
self._to_resume: List[str] = [] # transferred requests waiting until nothing runs (finding 39)
|
|
134
166
|
self._suspect: set = set() # capabilities a fenced call was refused on: com.status says whether they moved (FAA-155)
|
|
167
|
+
self._confirmed: Dict[str, Any] = {} # this session's: the generation it held, or com.status showed unmoved
|
|
135
168
|
|
|
136
169
|
# -- events -------------------------------------------------------------------------------------------------
|
|
137
|
-
def _emit(self, kind: str, level: str, text: str, notify: bool = False, episode: Optional[str] = None,
|
|
138
|
-
|
|
139
|
-
|
|
170
|
+
def _emit(self, kind: str, level: str, text: str, notify: bool = False, episode: Optional[str] = None,
|
|
171
|
+
said_as: Optional[str] = None, **value: Any) -> None:
|
|
172
|
+
if episode is not None: # `said_as` keeps one episode per key, such as one per capability
|
|
173
|
+
key = said_as or kind
|
|
174
|
+
if self._said.get(key) == episode:
|
|
140
175
|
return # once per episode
|
|
141
|
-
self._said[
|
|
176
|
+
self._said[key] = episode
|
|
142
177
|
data = {"schema": EVENT_SCHEMA, "value": {k: v for k, v in value.items() if v is not None}}
|
|
143
178
|
problems = _fs.feed_data_problems(data)
|
|
144
179
|
if problems: # FAC-SEM-101: never a credential in data; drop the data, keep the sentence
|
|
145
180
|
data = None
|
|
146
181
|
if self._emit_to is None:
|
|
147
182
|
return
|
|
183
|
+
# The thing the notice is about, so a host tells one request's or one capability's episode from another's
|
|
184
|
+
# (events-and-notifications.md, "Say who wants what"; 0.56.0 seam r30).
|
|
185
|
+
extra = {"data": data} if data else {}
|
|
186
|
+
# An emit written before 0.56.0 may take no subject: read from its signature, never from an error, so a notice
|
|
187
|
+
# goes once, without a subject there (unit r31, seam r31).
|
|
188
|
+
if _takes_subject(self._emit_to):
|
|
189
|
+
extra["subject"] = _notice_subject(value, self.project, episode)
|
|
148
190
|
try:
|
|
149
|
-
self._emit_to(kind, level, text, notify=notify, **
|
|
191
|
+
self._emit_to(kind, level, text, notify=notify, **extra)
|
|
150
192
|
except Exception: # noqa: BLE001 - an event that cannot be written never stops the responder
|
|
151
193
|
pass
|
|
152
194
|
|
|
@@ -193,6 +235,8 @@ class Responder:
|
|
|
193
235
|
|
|
194
236
|
def _connect(self) -> None:
|
|
195
237
|
self.state = "connecting"
|
|
238
|
+
self.slots = {} # the previous session's slots are not this one's: a refusal now reconnects (FAA-163)
|
|
239
|
+
self._confirmed = {} # nor its confirmations: a slot may have moved between the two sessions
|
|
196
240
|
grant = self.hub.join_board(self.project, self.credential_file)
|
|
197
241
|
missing = [c for c in self.capabilities if c not in grant.capabilities]
|
|
198
242
|
session = self.session_factory(grant)
|
|
@@ -200,7 +244,7 @@ class Responder:
|
|
|
200
244
|
self.session = session
|
|
201
245
|
status = self._status()
|
|
202
246
|
if missing:
|
|
203
|
-
self._emit("board.access.needed", "
|
|
247
|
+
self._emit("board.access.needed", "warning", "The operator has not let this service answer %s on this Project; ask in "
|
|
204
248
|
"Fabric (Agents & access)." % ", ".join(missing), notify=True, episode=",".join(missing),
|
|
205
249
|
capabilities=missing)
|
|
206
250
|
self.slots = {}
|
|
@@ -221,14 +265,14 @@ class Responder:
|
|
|
221
265
|
if self._gone(capability):
|
|
222
266
|
return
|
|
223
267
|
self.slots[capability] = {"state": "contested", "renewed_at": self.now()}
|
|
224
|
-
self._emit("board.slot.contested", "
|
|
268
|
+
self._emit("board.slot.contested", "warning", "Another responder holds the %s slot on this Project; this service "
|
|
225
269
|
"waits for it to lapse and never takes it over by itself." % capability, notify=True,
|
|
226
|
-
episode=capability, capability=capability)
|
|
270
|
+
episode=capability, said_as="board.slot.contested:" + capability, capability=capability)
|
|
227
271
|
return
|
|
228
272
|
raise
|
|
229
273
|
self.slots[capability] = {"state": "held", "generation": receipt["slot"]["generation"], "renewed_at": self.now()}
|
|
230
274
|
self._held_at(capability, receipt["slot"]["generation"])
|
|
231
|
-
self._said.pop("board.slot.contested", None)
|
|
275
|
+
self._said.pop("board.slot.contested:" + capability, None)
|
|
232
276
|
self._lifted(capability)
|
|
233
277
|
|
|
234
278
|
def _pause(self, capability: str) -> None:
|
|
@@ -248,108 +292,6 @@ class Responder:
|
|
|
248
292
|
self._emit("board.slot.released", "info", "The operator's hold on %s was lifted; the service takes new work "
|
|
249
293
|
"again." % capability, capability=capability)
|
|
250
294
|
|
|
251
|
-
# -- a slot moved to another session (FAA-155, DEC-0140 PR-2) ------------------------------------------------
|
|
252
|
-
def _held_at(self, capability: str, generation: int) -> None:
|
|
253
|
-
"""The generation this responder holds the slot at, kept beside its checkpoint so a restart knows it."""
|
|
254
|
-
self._note(capability, {"project": self.project, "generation": generation})
|
|
255
|
-
|
|
256
|
-
def _note(self, capability: str, record: Optional[Dict[str, Any]]) -> None:
|
|
257
|
-
"""The slot record, best effort: one that cannot be written now is written at the next receipt."""
|
|
258
|
-
try:
|
|
259
|
-
self.checkpoint.slot(capability, record)
|
|
260
|
-
except BoardError:
|
|
261
|
-
pass
|
|
262
|
-
|
|
263
|
-
def _ask(self, capability: str, op: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
|
264
|
-
"""An enroll or replace that can move the generation is recorded with its key first (a lost answer is no move)."""
|
|
265
|
-
held = self.checkpoint.slots.get(capability) or {}
|
|
266
|
-
if held.get("project") != self.project:
|
|
267
|
-
return self._call(op, args) # no record: nothing a lost answer could be mistaken for
|
|
268
|
-
slot = self.slots.get(capability) or {}
|
|
269
|
-
if op == "com.enroll" and slot.get("state") == "held" and slot.get("generation") == held.get("generation") \
|
|
270
|
-
and self.now() - slot.get("renewed_at", 0.0) < LEASE_SECONDS - LEASE_MARGIN_SECONDS and "asking" not in held:
|
|
271
|
-
return self._call(op, args) # a live holder's renew cannot change its generation: nothing to record
|
|
272
|
-
if isinstance(held.get("asking"), dict):
|
|
273
|
-
if self._gone(capability): # an earlier lost ask is settled first; it may show the slot moved
|
|
274
|
-
raise BoardError("fenced", "The %s slot moved to another session." % capability, op=op)
|
|
275
|
-
held = self.checkpoint.slots.get(capability) or {}
|
|
276
|
-
try:
|
|
277
|
-
self.checkpoint.slot(capability, dict(held, asking={"op": op, "args": args}))
|
|
278
|
-
return self._call(op, args)
|
|
279
|
-
except BoardError as exc:
|
|
280
|
-
if exc.code == "checkpoint":
|
|
281
|
-
return self._call(op, args)
|
|
282
|
-
if exc.code not in ("unreachable", "session-ended"):
|
|
283
|
-
self._note(capability, held)
|
|
284
|
-
raise
|
|
285
|
-
|
|
286
|
-
def _replay(self, capability: str, held: Dict[str, Any], before: Any) -> Dict[str, Any]:
|
|
287
|
-
"""Asks a lost enroll or replace again under its key. `repeated: true`: the generation is ours. Else it ran only now: past
|
|
288
|
-
a move (`before` beyond the record) the move is read. A refusal drops the record; a lost answer keeps it."""
|
|
289
|
-
asking = held.get("asking")
|
|
290
|
-
rest = {k: v for k, v in held.items() if k != "asking"}
|
|
291
|
-
if isinstance(asking, dict) and asking.get("op") in ("com.enroll", "com.responder_replace"):
|
|
292
|
-
try:
|
|
293
|
-
answer = self._receipt(asking["op"], self._call(asking["op"], asking.get("args")))
|
|
294
|
-
generation, moved = _whole(answer["slot"]["generation"]), _is_int(before, 1) and before > rest.get("generation", before)
|
|
295
|
-
if _is_int(generation, 1) and (answer.get("repeated") is True or not moved):
|
|
296
|
-
rest = {"project": self.project, "generation": generation}
|
|
297
|
-
except BoardError as exc:
|
|
298
|
-
if exc.code in ("unreachable", "session-ended"):
|
|
299
|
-
raise
|
|
300
|
-
if exc.code == "idempotency_window_expired":
|
|
301
|
-
rest = {} # a retired epoch refuses even a repeat: unknown, so no record (a first start, never a move)
|
|
302
|
-
except (KeyError, TypeError):
|
|
303
|
-
pass
|
|
304
|
-
self._note(capability, rest or None)
|
|
305
|
-
return rest
|
|
306
|
-
|
|
307
|
-
def _gone(self, capability: str, status: Optional[Dict[str, Any]] = None) -> bool:
|
|
308
|
-
"""True when `com.status` shows the slot's generation past the record (the slot is left `moved`); else False."""
|
|
309
|
-
held = self.checkpoint.slots.get(capability) or {}
|
|
310
|
-
if held.get("project") != self.project or (not _is_int(held.get("generation"), 1) and "asking" not in held):
|
|
311
|
-
return False
|
|
312
|
-
status = self._status() if status is None else status
|
|
313
|
-
now = _whole(next((r.get("generation") for r in status.get("responders") or [] if isinstance(r, dict) and r.get("capability") == capability), None))
|
|
314
|
-
held = self._replay(capability, held, now) if "asking" in held else held # its own lost ask first
|
|
315
|
-
if "moved" in held and _is_int(held.get("moved"), 1) and not (_is_int(now, 1) and now > held.get("generation", now)):
|
|
316
|
-
now = held["moved"] # a recorded move stays until take_back, whatever com.status says of the slot now
|
|
317
|
-
if not _is_int(now, 1) or not _is_int(held.get("generation"), 1) or now <= held["generation"]:
|
|
318
|
-
return False
|
|
319
|
-
with self._lock:
|
|
320
|
-
work = self.current
|
|
321
|
-
if work is not None and work.entry["capability"] == capability:
|
|
322
|
-
work.ctx.stopped.set()
|
|
323
|
-
self.current = None
|
|
324
|
-
for entry in [dict(e) for e in self.checkpoint.entries.values() if e.get("capability") == capability]:
|
|
325
|
-
if entry.get("step") == "claimed":
|
|
326
|
-
self.checkpoint.drop(entry["request"]) # the move returned the claim to the queue
|
|
327
|
-
elif entry.get("step") not in ("unknown", "lapsed"):
|
|
328
|
-
# Begun: outcome_unknown, which the slot's holder reconciles. Accepted: held on the board, continued only
|
|
329
|
-
# by a transfer if the slot is this service's again; never a com.responder_replace on its own.
|
|
330
|
-
entry["step"] = "unknown" if entry.get("effect") else "lapsed"
|
|
331
|
-
self.checkpoint.put(entry)
|
|
332
|
-
self._to_resume = [r for r in self._to_resume if (self.checkpoint.entries.get(r) or {}).get("capability") != capability]
|
|
333
|
-
self.slots[capability] = {"state": "moved", "generation": now, "renewed_at": self.now()}
|
|
334
|
-
if "moved" not in held: # once, even across restarts
|
|
335
|
-
self._emit("board.slot.moved", "warn", "The %s slot on this Project was moved to another session. This service stopped "
|
|
336
|
-
"answering it there, left its accepted work held (work already begun is the new holder's to settle) and does not "
|
|
337
|
-
"take the slot back by itself. Its take-back action brings it back once the other session lets it go; without one, ask its "
|
|
338
|
-
"maintainer." % capability,
|
|
339
|
-
notify=True, episode=capability, capability=capability, generation=now, held=held["generation"])
|
|
340
|
-
self._note(capability, dict(held, moved=now)) # said again at the next start if it cannot be written
|
|
341
|
-
return True
|
|
342
|
-
|
|
343
|
-
def take_back(self, capability: str) -> None:
|
|
344
|
-
"""The operator's act, never the kit's: forget the move; the next tick enrolls once the slot is free (FAA-155)."""
|
|
345
|
-
if capability not in self.capabilities:
|
|
346
|
-
raise BoardError("invalid_arguments", "This responder does not answer %s." % capability)
|
|
347
|
-
with self._lock:
|
|
348
|
-
self.checkpoint.slot(capability, None)
|
|
349
|
-
if (self.slots.get(capability) or {}).get("state") == "moved":
|
|
350
|
-
self.slots[capability] = {"state": "contested", "renewed_at": 0.0}
|
|
351
|
-
self._said.pop("board.slot.moved", None)
|
|
352
|
-
|
|
353
295
|
# -- restart: reconcile and transfer -------------------------------------------------------------------------
|
|
354
296
|
def _read_state(self, entry: Dict[str, Any]) -> Optional[str]:
|
|
355
297
|
message = self._call("com.get", {"message": entry["message"]})
|
|
@@ -381,26 +323,40 @@ class Responder:
|
|
|
381
323
|
responders = [r for r in status.get("responders") or [] if isinstance(r, dict)]
|
|
382
324
|
generations = {r.get("capability"): _whole(r.get("generation", 0)) for r in responders}
|
|
383
325
|
on_hold = {r.get("capability") for r in responders if r.get("held") is True} # DEC-0079
|
|
326
|
+
failure: Optional[BoardError] = None
|
|
384
327
|
for capability in capabilities:
|
|
385
328
|
held, was = transfer.get(capability), (self.checkpoint.slots.get(capability) or {}).get("generation")
|
|
386
|
-
if
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
mine = (self.checkpoint.slots.get(capability) or {}).get("generation")
|
|
395
|
-
self._replace(capability, held, mine if _is_int(mine, 1) and mine != was else generations.get(capability, 0))
|
|
396
|
-
else:
|
|
397
|
-
self._enroll(capability)
|
|
329
|
+
if failure is not None: # the board session failed: the rest wait for the next tick (FAA-163)
|
|
330
|
+
self._retry_later(capability, held or [], failure)
|
|
331
|
+
self._suspect.add(capability) # its move check comes before its retry enrolls (0.56.0 seam r2)
|
|
332
|
+
continue
|
|
333
|
+
try:
|
|
334
|
+
self._settle(capability, lambda: self._recover_one(capability, status, held, was, generations, on_hold), held)
|
|
335
|
+
except BoardError as exc:
|
|
336
|
+
failure = exc
|
|
398
337
|
for entry in reconcile:
|
|
399
338
|
if self.slots.get(entry["capability"], {}).get("state") == "held":
|
|
400
339
|
self._reconcile(entry)
|
|
401
340
|
elif entry.get("step") != "unknown":
|
|
402
341
|
entry["step"] = "unknown" # the slot is not this session's yet: a later tick reconciles it (finding 38)
|
|
403
342
|
self.checkpoint.put(entry)
|
|
343
|
+
if failure is not None:
|
|
344
|
+
raise failure
|
|
345
|
+
|
|
346
|
+
def _recover_one(self, capability: str, status: Dict[str, Any], held: Optional[List[Dict[str, Any]]], was: Any,
|
|
347
|
+
generations: Dict[Any, Any], on_hold: set) -> None:
|
|
348
|
+
if self._gone(capability, status):
|
|
349
|
+
return # moved away: never replaced at recovery (FAA-155)
|
|
350
|
+
if capability in on_hold:
|
|
351
|
+
for entry in held or []:
|
|
352
|
+
entry["step"] = "lapsed" # taken back by a transfer once the hold is lifted
|
|
353
|
+
self.checkpoint.put(entry)
|
|
354
|
+
self._pause(capability)
|
|
355
|
+
elif held: # at the generation a replay of its own lost ask just recorded, else com.status's (seam 6)
|
|
356
|
+
mine = (self.checkpoint.slots.get(capability) or {}).get("generation")
|
|
357
|
+
self._replace(capability, held, mine if _is_int(mine, 1) and mine != was else generations.get(capability, 0))
|
|
358
|
+
else:
|
|
359
|
+
self._enroll(capability)
|
|
404
360
|
|
|
405
361
|
def _replace(self, capability: str, entries: List[Dict[str, Any]], generation: Any) -> None:
|
|
406
362
|
if not _is_int(generation, 0):
|
|
@@ -432,7 +388,7 @@ class Responder:
|
|
|
432
388
|
# start (or the operator) decides.
|
|
433
389
|
entry["step"] = "held"
|
|
434
390
|
self.checkpoint.put(entry)
|
|
435
|
-
self._emit("board.request.held", "
|
|
391
|
+
self._emit("board.request.held", "warning", "An accepted request could not be resumed: this Fabric does not hand "
|
|
436
392
|
"back a transferred attempt (DEC-0078). It stays held; nothing was done.", notify=True,
|
|
437
393
|
episode=entry["request"], request=entry["request"], capability=capability)
|
|
438
394
|
continue
|
|
@@ -517,7 +473,7 @@ class Responder:
|
|
|
517
473
|
if outcome is None:
|
|
518
474
|
entry["step"] = "unknown"
|
|
519
475
|
self.checkpoint.put(entry)
|
|
520
|
-
self._emit("board.reconcile.needed", "
|
|
476
|
+
self._emit("board.reconcile.needed", "warning", "A request's outcome is unknown: the work may have happened. It stays "
|
|
521
477
|
"unknown until this service observes what happened.", notify=True, episode=entry["request"],
|
|
522
478
|
request=entry["request"], capability=entry["capability"])
|
|
523
479
|
return
|
|
@@ -559,7 +515,8 @@ class Responder:
|
|
|
559
515
|
def _lease_left(self) -> float:
|
|
560
516
|
"""Seconds this session's leases still run, by its own clock: the running attempt's, else the oldest held slot's."""
|
|
561
517
|
renewed = [self.current.renewed_at or 0.0] if self.current is not None else \
|
|
562
|
-
[s.get("renewed_at", 0.0) for s in self.slots.values() if s.get("state") == "held"
|
|
518
|
+
[s.get("renewed_at", 0.0) for s in self.slots.values() if s.get("state") == "held"
|
|
519
|
+
and s.get("renewed_at", 0.0) != float("-inf")] # a lapsed slot holds no lease (FAA-163)
|
|
563
520
|
return min(renewed) + LEASE_SECONDS - self.now() if renewed and self.session is not None else 0.0
|
|
564
521
|
|
|
565
522
|
def _fault(self, exc: Exception) -> float:
|
|
@@ -574,7 +531,7 @@ class Responder:
|
|
|
574
531
|
if code in ("not_authorized", "authority_changed", "credential"):
|
|
575
532
|
self.state = "not-authorized"
|
|
576
533
|
self._drop_session()
|
|
577
|
-
self._emit("board.access.needed", "
|
|
534
|
+
self._emit("board.access.needed", "warning", "This service may not answer requests for this Project: the operator "
|
|
578
535
|
"has to allow it in Fabric (Agents & access). Nothing is claimed until then.", notify=True,
|
|
579
536
|
episode="access", reason=code)
|
|
580
537
|
elif code == "held":
|
|
@@ -592,10 +549,16 @@ class Responder:
|
|
|
592
549
|
# In recovery with no slot held a refusal reconnects (seam 6); with some held they are served (seam 7).
|
|
593
550
|
if (connecting or code in ("session-ended", "unreachable", "not-running", "not-a-hub", "hub-unavailable", "protocol")) and not keep:
|
|
594
551
|
self._drop_session()
|
|
595
|
-
self._emit("board.down", "
|
|
552
|
+
self._emit("board.down", "warning", "Fabric's board cannot be reached; the responder tries again with a growing pause.",
|
|
596
553
|
episode="down", reason=code)
|
|
597
554
|
return wait
|
|
598
555
|
|
|
556
|
+
def _held(self) -> Dict[str, Any]:
|
|
557
|
+
"""The generations this session confirmed (held, or compared with com.status and found unmoved), paused and
|
|
558
|
+
retrying slots included: only their records a clean leave may forget (0.56.0 seam r2, r3)."""
|
|
559
|
+
return {c: g for c, g in dict(self._confirmed).items() # a fenced call (maybe moved) or a move withholds it
|
|
560
|
+
if c not in self._suspect and (self.slots.get(c) or {}).get("state") != "moved"}
|
|
561
|
+
|
|
599
562
|
def _drop_session(self) -> None:
|
|
600
563
|
work, self.current, self._to_resume = self.current, None, [] # the next session transfers them again
|
|
601
564
|
if work is not None:
|
|
@@ -610,8 +573,8 @@ class Responder:
|
|
|
610
573
|
else:
|
|
611
574
|
work.ctx.stopped.set() # the lease lapses; acting on would outlive the attempt
|
|
612
575
|
session, self.session = self.session, None
|
|
613
|
-
if session is not None:
|
|
614
|
-
|
|
576
|
+
if session is not None and session.close():
|
|
577
|
+
self.checkpoint.leave(self.project, self._held()) # the DELETE released them: a standby taking one is no move
|
|
615
578
|
|
|
616
579
|
def _tick(self) -> None:
|
|
617
580
|
work = self.current
|
|
@@ -628,8 +591,12 @@ class Responder:
|
|
|
628
591
|
if slot.get("state") == "moved":
|
|
629
592
|
continue # never enrolled again on its own, even when the new holder's lease lapses (FAA-155)
|
|
630
593
|
if slot.get("state") == "paused":
|
|
631
|
-
if capability != held_by_work and self.now() >= slot.get("retry_at", 0.0):
|
|
632
|
-
self._enroll(capability) # still held: a longer pause; lifted: the slot is ours again
|
|
594
|
+
if capability != held_by_work and self.now() >= slot.get("retry_at", 0.0) and not self._gone(capability):
|
|
595
|
+
self._enroll(capability) # still held: a longer pause; lifted: the slot is ours again (moved: read first)
|
|
596
|
+
continue
|
|
597
|
+
if slot.get("state") == "retry": # its enroll was refused: asked again alone (FAA-163)
|
|
598
|
+
if self.now() >= slot.get("retry_at", 0.0):
|
|
599
|
+
self._settle(capability, lambda: self._gone(capability) or self._enroll(capability))
|
|
633
600
|
continue
|
|
634
601
|
if capability != held_by_work and self.now() - slot.get("renewed_at", 0) >= RENEW_SECONDS - 0.5:
|
|
635
602
|
self._enroll(capability) # the holder's enroll renews its lease (no event); a lapsed one re-enrolls
|
|
@@ -707,14 +674,14 @@ class Responder:
|
|
|
707
674
|
req = message.get("request") if isinstance(message, dict) else None
|
|
708
675
|
if message.get("digest") != entry["digest"]:
|
|
709
676
|
self.checkpoint.drop(entry["request"])
|
|
710
|
-
self._emit("board.request.skipped", "
|
|
677
|
+
self._emit("board.request.skipped", "warning", "A claimed request was not accepted: the message read back carries "
|
|
711
678
|
"another digest than the claim. Nothing was done; the claim lapses.", request=entry["request"],
|
|
712
679
|
capability=capability, reason="digest-mismatch")
|
|
713
680
|
return
|
|
714
681
|
if not isinstance(req, dict) or req.get("id") != entry["request"] or req.get("capability") != capability \
|
|
715
682
|
or req.get("target") != self.project:
|
|
716
683
|
self.checkpoint.drop(entry["request"])
|
|
717
|
-
self._emit("board.request.skipped", "
|
|
684
|
+
self._emit("board.request.skipped", "warning", "A claimed request was not accepted: it is not addressed to this "
|
|
718
685
|
"Project and capability. Nothing was done; the claim lapses.", request=entry["request"],
|
|
719
686
|
capability=capability, reason="not-addressed")
|
|
720
687
|
return
|
|
@@ -819,7 +786,7 @@ class Responder:
|
|
|
819
786
|
if entry.get("effect"):
|
|
820
787
|
entry["step"] = "unknown"
|
|
821
788
|
self.checkpoint.put(entry)
|
|
822
|
-
self._emit("board.reconcile.needed", "
|
|
789
|
+
self._emit("board.reconcile.needed", "warning", "The attempt was lost after its effect began; the outcome stays "
|
|
823
790
|
"unknown until this service observes what happened.", notify=True, episode=entry["request"],
|
|
824
791
|
request=entry["request"], capability=entry["capability"], reason=code)
|
|
825
792
|
elif entry.get("step") not in ("claimed", "accepting") and (code == "lease_expired" or (
|
|
@@ -828,13 +795,13 @@ class Responder:
|
|
|
828
795
|
# it (C4). It continues only by a checkpoint transfer to this responder while it holds the slot.
|
|
829
796
|
entry["step"] = "lapsed"
|
|
830
797
|
self.checkpoint.put(entry)
|
|
831
|
-
self._emit("board.attempt.lost", "
|
|
798
|
+
self._emit("board.attempt.lost", "warning", ("This service's lease on an accepted request lapsed; it stopped working "
|
|
832
799
|
"on it and takes it back by a transfer." if code == "lease_expired" else "The board fenced an accepted "
|
|
833
800
|
"request away from this service; it stopped working on it, and nothing had been done outside it."),
|
|
834
801
|
request=entry["request"], capability=entry["capability"], reason=code)
|
|
835
802
|
else:
|
|
836
803
|
self.checkpoint.drop(entry["request"])
|
|
837
|
-
self._emit("board.attempt.lost", "
|
|
804
|
+
self._emit("board.attempt.lost", "warning", "The board took a request back from this service (%s); it stopped working "
|
|
838
805
|
"on it, and nothing had been done outside the service." % code, request=entry["request"],
|
|
839
806
|
capability=entry["capability"], reason=code)
|
|
840
807
|
|
|
@@ -910,7 +877,7 @@ class Responder:
|
|
|
910
877
|
"summary": "The service could not do this (%s)." % work.failure.__class__.__name__}
|
|
911
878
|
problems = outcome_problems(outcome, bool(entry.get("effect")))
|
|
912
879
|
if problems:
|
|
913
|
-
self._emit("board.outcome.invalid", "
|
|
880
|
+
self._emit("board.outcome.invalid", "warning", "The handler's outcome was not sent: %s." % problems[0],
|
|
914
881
|
request=entry["request"], capability=entry["capability"])
|
|
915
882
|
if entry.get("effect"):
|
|
916
883
|
self._settle_live(entry, "observation_required" if any("observation_required" in p for p in problems) else "outcome")
|
|
@@ -994,7 +961,8 @@ class Responder:
|
|
|
994
961
|
except (BoardError, _hub.HubError):
|
|
995
962
|
pass # the checkpoint keeps it
|
|
996
963
|
if self.session is not None:
|
|
997
|
-
self.session.
|
|
998
|
-
|
|
999
|
-
|
|
964
|
+
session, self.session = self.session, None
|
|
965
|
+
# Only a DELETE Fabric answered released the slots (FAA-163); an idle stop waits for it 3 s (FAA-173).
|
|
966
|
+
if session.close(IDLE_LEAVE_SECONDS if work is None else None):
|
|
967
|
+
self.checkpoint.leave(self.project, self._held()) # no record of them can read as a move
|
|
1000
968
|
self.state = "stopped"
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""The board responder's slot records (FAA-155, FAA-163): the generation it holds each slot at, a move to another
|
|
2
|
+
session and its take-back, and a capability whose enroll was refused, retried on its own. Part of the board connector
|
|
3
|
+
(FAA-65, layer L4); `Responder` (fabric_board_session) is a `SlotKeeper`."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
import sys as _sys # noqa: E402
|
|
9
|
+
from pathlib import Path as _Path # noqa: E402
|
|
10
|
+
if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
|
|
11
|
+
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
12
|
+
from fabric_board_shapes import BoardError, LEASE_SECONDS, _is_int # noqa: E402 - the kit (FAA-65)
|
|
13
|
+
|
|
14
|
+
#: A lost call is sent again this long before the lease it keeps would lapse (FAA-77, finding 38).
|
|
15
|
+
LEASE_MARGIN_SECONDS = 10.0
|
|
16
|
+
|
|
17
|
+
#: How long a held slot waits before it asks again: doubling from the poll interval, never past this.
|
|
18
|
+
HOLD_RETRY_MAX_SECONDS = 900.0
|
|
19
|
+
|
|
20
|
+
#: Refusals that are about the board session, not one capability: they end recovery and go to the responder's fault.
|
|
21
|
+
SESSION_CODES = frozenset(["unreachable", "session-ended", "not_authorized", "authority_changed", "credential", "no-epoch",
|
|
22
|
+
"schema-unsupported", "checkpoint", "receipt-shape", "idempotency_conflict", "not-running",
|
|
23
|
+
"not-a-hub", "hub-unavailable", "protocol", "not_available"])
|
|
24
|
+
|
|
25
|
+
_whole = lambda v: int(v) if isinstance(v, float) and v.is_integer() else v # noqa: E731 - 2.0 is 2, as Node reads it
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SlotKeeper:
|
|
29
|
+
"""The slot methods of `Responder`, which owns every attribute they read (`checkpoint`, `slots`, `project`, `_lock`)."""
|
|
30
|
+
|
|
31
|
+
# -- a slot moved to another session (FAA-155, DEC-0140 PR-2) ------------------------------------------------
|
|
32
|
+
def _held_at(self, capability: str, generation: int) -> None:
|
|
33
|
+
"""The generation this responder holds the slot at, kept beside its checkpoint so a restart knows it."""
|
|
34
|
+
self._note(capability, {"project": self.project, "generation": generation})
|
|
35
|
+
self._confirmed[capability] = generation
|
|
36
|
+
self._said.pop("board.slot.refused:" + capability, None) # held again: a later refusal is said again
|
|
37
|
+
self._said.pop("board.slot.contested:" + capability, None) # and a later contest, by enroll or replace
|
|
38
|
+
|
|
39
|
+
def _note(self, capability: str, record: Optional[Dict[str, Any]]) -> None:
|
|
40
|
+
"""The slot record, best effort: one that cannot be written now is written at the next receipt."""
|
|
41
|
+
try:
|
|
42
|
+
self.checkpoint.slot(capability, record)
|
|
43
|
+
except BoardError:
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
def _ask(self, capability: str, op: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
|
47
|
+
"""An enroll or replace that can move the generation is recorded with its key first (a lost answer is no move)."""
|
|
48
|
+
held = self.checkpoint.slots.get(capability) or {}
|
|
49
|
+
if held.get("project") != self.project:
|
|
50
|
+
return self._call(op, args) # no record: nothing a lost answer could be mistaken for
|
|
51
|
+
slot = self.slots.get(capability) or {}
|
|
52
|
+
if op == "com.enroll" and slot.get("state") == "held" and slot.get("generation") == held.get("generation") \
|
|
53
|
+
and self.now() - slot.get("renewed_at", 0.0) < LEASE_SECONDS - LEASE_MARGIN_SECONDS and "asking" not in held:
|
|
54
|
+
return self._call(op, args) # a live holder's renew cannot change its generation: nothing to record
|
|
55
|
+
if isinstance(held.get("asking"), dict):
|
|
56
|
+
if self._gone(capability): # an earlier lost ask is settled first; it may show the slot moved
|
|
57
|
+
raise BoardError("fenced", "The %s slot moved to another session." % capability, op=op)
|
|
58
|
+
held = self.checkpoint.slots.get(capability) or {}
|
|
59
|
+
try:
|
|
60
|
+
self.checkpoint.slot(capability, dict(held, asking={"op": op, "args": args}))
|
|
61
|
+
return self._call(op, args)
|
|
62
|
+
except BoardError as exc:
|
|
63
|
+
if exc.code == "checkpoint":
|
|
64
|
+
return self._call(op, args)
|
|
65
|
+
if exc.code not in ("unreachable", "session-ended"):
|
|
66
|
+
self._note(capability, held)
|
|
67
|
+
raise
|
|
68
|
+
|
|
69
|
+
def _replay(self, capability: str, held: Dict[str, Any], before: Any) -> Dict[str, Any]:
|
|
70
|
+
"""Asks a lost enroll or replace again under its key. `repeated: true`: the generation is ours. Else it ran only now: past
|
|
71
|
+
a move (`before` beyond the record) the move is read. A refusal drops the record; a lost answer keeps it."""
|
|
72
|
+
asking = held.get("asking")
|
|
73
|
+
rest = {k: v for k, v in held.items() if k != "asking"}
|
|
74
|
+
if isinstance(asking, dict) and asking.get("op") in ("com.enroll", "com.responder_replace"):
|
|
75
|
+
try:
|
|
76
|
+
answer = self._receipt(asking["op"], self._call(asking["op"], asking.get("args")))
|
|
77
|
+
generation, moved = _whole(answer["slot"]["generation"]), _is_int(before, 1) and before > rest.get("generation", before)
|
|
78
|
+
if _is_int(generation, 1) and (answer.get("repeated") is True or not moved):
|
|
79
|
+
rest = {"project": self.project, "generation": generation}
|
|
80
|
+
except BoardError as exc:
|
|
81
|
+
if exc.code in ("unreachable", "session-ended"):
|
|
82
|
+
raise
|
|
83
|
+
if exc.code == "idempotency_window_expired":
|
|
84
|
+
rest = {} # a retired epoch refuses even a repeat: unknown, so no record (a first start, never a move)
|
|
85
|
+
except (KeyError, TypeError):
|
|
86
|
+
pass
|
|
87
|
+
self._note(capability, rest or None)
|
|
88
|
+
return rest
|
|
89
|
+
|
|
90
|
+
def _gone(self, capability: str, status: Optional[Dict[str, Any]] = None) -> bool:
|
|
91
|
+
"""True when `com.status` shows the slot's generation past the record (the slot is left `moved`); else False."""
|
|
92
|
+
held = self.checkpoint.slots.get(capability) or {}
|
|
93
|
+
if held.get("project") != self.project or (not _is_int(held.get("generation"), 1) and "asking" not in held):
|
|
94
|
+
return False
|
|
95
|
+
status = self._status() if status is None else status
|
|
96
|
+
now = _whole(next((r.get("generation") for r in status.get("responders") or [] if isinstance(r, dict) and r.get("capability") == capability), None))
|
|
97
|
+
held = self._replay(capability, held, now) if "asking" in held else held # its own lost ask first
|
|
98
|
+
if "moved" in held and _is_int(held.get("moved"), 1) and not (_is_int(now, 1) and now > held.get("generation", now)):
|
|
99
|
+
now = held["moved"] # a recorded move stays until take_back, whatever com.status says of the slot now
|
|
100
|
+
if not _is_int(now, 1) or not _is_int(held.get("generation"), 1) or now <= held["generation"]:
|
|
101
|
+
if _is_int(now, 1) and _is_int(held.get("generation"), 1) and "moved" not in held:
|
|
102
|
+
self._confirmed[capability] = held["generation"] # com.status shows it unmoved (0.56.0 seam r3)
|
|
103
|
+
return False
|
|
104
|
+
with self._lock:
|
|
105
|
+
record = self.checkpoint.slots.get(capability) or {}
|
|
106
|
+
if record.get("project") != self.project or record.get("generation") != held.get("generation"):
|
|
107
|
+
return False # take_back (an action thread) forgot the record meanwhile: no move to keep (FAA-158)
|
|
108
|
+
work = self.current
|
|
109
|
+
if work is not None and work.entry["capability"] == capability:
|
|
110
|
+
work.ctx.stopped.set()
|
|
111
|
+
self.current = None
|
|
112
|
+
for entry in [dict(e) for e in self.checkpoint.entries.values() if e.get("capability") == capability]:
|
|
113
|
+
if entry.get("step") == "claimed":
|
|
114
|
+
self.checkpoint.drop(entry["request"]) # the move returned the claim to the queue
|
|
115
|
+
elif entry.get("step") not in ("unknown", "lapsed"):
|
|
116
|
+
# Begun: outcome_unknown, which the slot's holder reconciles. Accepted: held on the board, continued only
|
|
117
|
+
# by a transfer if the slot is this service's again; never a com.responder_replace on its own.
|
|
118
|
+
entry["step"] = "unknown" if entry.get("effect") else "lapsed"
|
|
119
|
+
self.checkpoint.put(entry)
|
|
120
|
+
self._to_resume = [r for r in self._to_resume if (self.checkpoint.entries.get(r) or {}).get("capability") != capability]
|
|
121
|
+
self.slots[capability] = {"state": "moved", "generation": now, "renewed_at": self.now()}
|
|
122
|
+
self._confirmed.pop(capability, None)
|
|
123
|
+
if "moved" not in held:
|
|
124
|
+
self._note(capability, dict(held, moved=now)) # said again at the next start if it cannot be written
|
|
125
|
+
if "moved" not in held: # once, even across restarts
|
|
126
|
+
self._emit("board.slot.moved", "warning", "The %s slot on this Project moved to another session. This service stopped answering it there and left its "
|
|
127
|
+
"accepted work `accepted` (work begun is the new holder's to settle). Its take-back action brings the "
|
|
128
|
+
"slot back once the other session lets it go, or at a later restart or reconnect while accepted work "
|
|
129
|
+
"waits; a restart alone does not. Without it, ask its maintainer." % capability,
|
|
130
|
+
notify=True, episode=capability, capability=capability, generation=now, held=held["generation"])
|
|
131
|
+
return True
|
|
132
|
+
|
|
133
|
+
def take_back(self, capability: str) -> None:
|
|
134
|
+
"""The operator's act, never the kit's: forget the move; the next tick enrolls once the slot is free (FAA-155)."""
|
|
135
|
+
if capability not in self.capabilities:
|
|
136
|
+
raise BoardError("invalid_arguments", "This responder does not answer %s." % capability)
|
|
137
|
+
with self._lock:
|
|
138
|
+
self.checkpoint.slot(capability, None)
|
|
139
|
+
if (self.slots.get(capability) or {}).get("state") == "moved":
|
|
140
|
+
self.slots[capability] = {"state": "contested", "renewed_at": 0.0}
|
|
141
|
+
self._said.pop("board.slot.moved", None)
|
|
142
|
+
|
|
143
|
+
# -- one capability's refusal (FAA-163) ------------------------------------------------------------------------
|
|
144
|
+
def _retry_later(self, capability: str, entries: List[Dict[str, Any]], exc: BoardError) -> None:
|
|
145
|
+
"""An enroll or replace of one capability was refused: its accepted work waits `lapsed` (a transfer takes it
|
|
146
|
+
back once the slot is held) and the capability asks again after a doubling pause; the others go on. A refusal
|
|
147
|
+
of the board session (SESSION_CODES) is not this capability's: it is left to retry and nothing is said, since
|
|
148
|
+
the responder's fault says the board is down."""
|
|
149
|
+
if (self.slots.get(capability) or {}).get("state") == "held":
|
|
150
|
+
return # the replace landed: what failed came after it
|
|
151
|
+
for entry in entries:
|
|
152
|
+
if entry.get("step") not in ("unknown", "lapsed"):
|
|
153
|
+
entry["step"] = "lapsed"
|
|
154
|
+
self.checkpoint.put(entry)
|
|
155
|
+
slot = self.slots.get(capability) or {}
|
|
156
|
+
tries = slot.get("tries", 0) + 1 if slot.get("state") == "retry" else 1
|
|
157
|
+
wait = min(HOLD_RETRY_MAX_SECONDS, self.hold_delay * 2 ** (tries - 1))
|
|
158
|
+
self.slots[capability] = {"state": "retry", "tries": tries, "retry_at": self.now() + wait, "renewed_at": self.now()}
|
|
159
|
+
if exc.code not in SESSION_CODES: # once per episode (said_as), also after a silent session refusal
|
|
160
|
+
self._emit("board.slot.refused", "warning", "Fabric refused to give this service the %s slot (%s); it asks again "
|
|
161
|
+
"later. Its other capabilities go on." % (capability, exc.code), episode=capability,
|
|
162
|
+
said_as="board.slot.refused:" + capability,
|
|
163
|
+
capability=capability, reason=exc.code)
|
|
164
|
+
|
|
165
|
+
def _settle(self, capability: str, act, entries: Optional[List[Dict[str, Any]]] = None) -> None:
|
|
166
|
+
"""Runs one capability's enroll or replace (FAA-163). A refusal of the board session is raised after the
|
|
167
|
+
capability is left to retry; any other leaves it to retry and the next capability goes on."""
|
|
168
|
+
try:
|
|
169
|
+
act()
|
|
170
|
+
except BoardError as exc:
|
|
171
|
+
self._retry_later(capability, entries or [], exc)
|
|
172
|
+
if exc.code in SESSION_CODES:
|
|
173
|
+
raise
|