@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.40.0

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 (86) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +9 -3
  3. package/package.json +1 -1
  4. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  5. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
  6. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -4
  7. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +64 -0
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +264 -0
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +3 -2
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +128 -0
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +10 -183
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +286 -0
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +840 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +1 -16
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +13 -45
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +8 -67
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -2
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +202 -0
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +2 -12
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +400 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +402 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +78 -0
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +902 -0
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -38
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +289 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +15 -180
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +39 -400
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +137 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +1 -6
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +21 -43
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +31 -38
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +412 -0
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1526 -0
  77. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
  78. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
  79. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  80. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +116 -1392
  81. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +270 -0
  82. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +508 -0
  83. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +265 -0
  84. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +155 -0
  85. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +335 -0
  86. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +1 -0
@@ -0,0 +1,902 @@
1
+ """The board responder (DEC-0078, FAA-12b): a board session over the hub, the fsynced checkpoint, the request a handler
2
+ sees and the responder loop (enroll, claim, accept the exact digest, effect, progress, complete; stop when fenced).
3
+ Part of the board connector (FAA-65, layer L4)."""
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import random
8
+ import re
9
+ import secrets
10
+ import threading
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Any
14
+ from typing import Callable
15
+ from typing import Dict
16
+ from typing import List
17
+ from typing import Optional
18
+ from typing import Sequence
19
+
20
+ import sys as _sys # noqa: E402
21
+ from pathlib import Path as _Path # noqa: E402
22
+ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
23
+ _sys.path.insert(0, str(_Path(__file__).resolve().parent))
24
+ import fabric_hub as _hub
25
+ import fabric_service as _fs
26
+ from fabric_board_shapes import ( # noqa: E402 - the kit (FAA-65)
27
+ BoardError,
28
+ REQUEST_STATES,
29
+ RESPOND_TOOLS,
30
+ SUMMARY_MAX,
31
+ Stopped,
32
+ _CAPABILITY,
33
+ _is_int,
34
+ outcome_problems,
35
+ receipt_problems,
36
+ refusal_problems,
37
+ )
38
+
39
+
40
+ RENEW_SECONDS = 20.0
41
+
42
+
43
+ POLL_SECONDS = 15.0
44
+
45
+
46
+ POLL_JITTER_SECONDS = 5.0
47
+
48
+
49
+ DRAIN_SECONDS = 10.0
50
+
51
+
52
+ EVENT_SCHEMA = "urn:fabric:schema:board-responder-event:1"
53
+
54
+
55
+ #: On these the attempt is no longer ours: stop acting at once (C4).
56
+ STOP_CODES = frozenset(["fenced", "lease_expired", "authority_changed"])
57
+
58
+
59
+ TERMINAL = frozenset(["completed", "failed_known", "cancelled", "expired"])
60
+
61
+
62
+ #: How long a held slot waits before it asks again: doubling from the poll interval, never past this.
63
+ HOLD_RETRY_MAX_SECONDS = 900.0
64
+
65
+
66
+ class BoardSession:
67
+ """MCP over HTTP with a board session's one-session bearer: `open()` spends it with `initialize` and keeps the
68
+ `mcp-session-id`; `call(tool, args)` answers the tool's value or raises `BoardError`; `close()` sends the session's
69
+ DELETE, which releases every slot it holds (DEC-0073 §5). Thread-safe; the bearer never leaves the headers."""
70
+
71
+ def __init__(self, grant: _hub.BoardGrant, *, timeout: float = 15.0, post: Callable[..., Any] = _hub.post_mcp):
72
+ self.grant, self.timeout, self.post = grant, timeout, post
73
+ self.mcp_session: Optional[str] = None
74
+ self.protocol_version = _hub.MCP_PROTOCOL_VERSION
75
+ self._lock = threading.Lock()
76
+ self._rpc = 0
77
+
78
+ def _send(self, body: Dict[str, Any], method: str = "POST"):
79
+ try:
80
+ return self.post(self.grant.origin, body, self.grant.bearer(), session=self.mcp_session, timeout=self.timeout,
81
+ method=method, protocol_version=self.protocol_version if self.mcp_session else None)
82
+ except _hub.HubError as exc:
83
+ raise BoardError("unreachable", "Fabric's hub did not answer; a write may have been stored, so send it again with "
84
+ "the same idempotency key.", True) from exc
85
+
86
+ def open(self) -> None:
87
+ with self._lock:
88
+ self._rpc += 1
89
+ status, headers, parsed = self._send({"jsonrpc": "2.0", "id": self._rpc, "method": "initialize", "params": {
90
+ "protocolVersion": _hub.MCP_PROTOCOL_VERSION, "capabilities": {},
91
+ "clientInfo": {"name": "fabric-agent-adapter-board", "version": "1"}}})
92
+ if status == 401:
93
+ raise BoardError("session-ended", "Fabric refused the board session's bearer; join again.", True)
94
+ if status != 200 or not isinstance(parsed, dict) or not isinstance(parsed.get("result"), dict) or not headers.get("mcp-session-id"):
95
+ raise BoardError("unreachable", "Fabric did not open the board session (HTTP %d)." % status, True)
96
+ self.mcp_session = headers["mcp-session-id"]
97
+ version = parsed["result"].get("protocolVersion")
98
+ if isinstance(version, str):
99
+ self.protocol_version = version
100
+ self._send({"jsonrpc": "2.0", "method": "notifications/initialized"})
101
+
102
+ def call(self, tool: str, args: Dict[str, Any]) -> Dict[str, Any]:
103
+ if tool not in RESPOND_TOOLS:
104
+ raise BoardError("not-in-scope", "%s is not served to a board session; use a Project session for it." % tool, op=tool)
105
+ with self._lock:
106
+ if self.mcp_session is None:
107
+ raise BoardError("session-ended", "The board session is not open.", True, op=tool)
108
+ self._rpc += 1
109
+ status, _, parsed = self._send({"jsonrpc": "2.0", "id": self._rpc, "method": "tools/call",
110
+ "params": {"name": tool, "arguments": args}})
111
+ if status == 401:
112
+ raise BoardError("session-ended", "Fabric ended the board session (it left, was revoked or expired); join again.", True, op=tool)
113
+ try:
114
+ value = _hub.tool_answer(status, parsed, tool)
115
+ except _hub.HubError as exc:
116
+ raise BoardError("unreachable" if exc.retryable else "receipt-shape", exc.message, exc.retryable, op=tool) from None
117
+ if "error" in value:
118
+ problems = refusal_problems(value)
119
+ if problems:
120
+ raise BoardError("receipt-shape", "Fabric refused %s in a form the contract does not name." % tool, op=tool)
121
+ e = value["error"]
122
+ raise BoardError(e["code"], e["message"], e["retryable"], op=tool)
123
+ return value
124
+
125
+ def close(self) -> None:
126
+ with self._lock:
127
+ if self.mcp_session is None:
128
+ return
129
+ try:
130
+ self._send({}, method="DELETE")
131
+ except BoardError:
132
+ pass # best effort: the lease lapses in 60 s anyway
133
+ self.mcp_session = None
134
+
135
+
136
+ class Checkpoint:
137
+ """The responder's own journal: one JSON file (0600) holding every request it has claimed and not yet settled, each
138
+ with its fence and the idempotency key of the step in flight. Written atomically and fsynced BEFORE the step it
139
+ announces is sent; a write that fails raises `BoardError("checkpoint")` and the step is not taken."""
140
+
141
+ def __init__(self, path: Path):
142
+ self.path = Path(path)
143
+ self._lock = threading.Lock()
144
+ self.entries: Dict[str, Dict[str, Any]] = self._load()
145
+
146
+ def _load(self) -> Dict[str, Dict[str, Any]]:
147
+ try:
148
+ doc = json.loads(self.path.read_text(encoding="utf-8"))
149
+ except FileNotFoundError:
150
+ return {}
151
+ except (OSError, ValueError) as exc:
152
+ raise BoardError("checkpoint", "The board checkpoint %s cannot be read (%s); fix or move it before answering "
153
+ "requests." % (self.path, exc.__class__.__name__)) from None
154
+ entries = doc.get("requests") if isinstance(doc, dict) and doc.get("version") == 1 else None
155
+ if not isinstance(entries, dict):
156
+ raise BoardError("checkpoint", "The board checkpoint %s is not one this kit writes." % self.path)
157
+ return entries
158
+
159
+ def _write(self, entries: Dict[str, Dict[str, Any]]) -> None:
160
+ try:
161
+ _fs.ensure_private_dir(self.path.parent)
162
+ _fs.atomic_write(self.path, (json.dumps({"version": 1, "requests": entries}, indent=1, sort_keys=True) + "\n").encode())
163
+ except OSError as exc:
164
+ raise BoardError("checkpoint", "The board checkpoint could not be written (%s); nothing was sent." % (exc.strerror or exc.__class__.__name__)) from None
165
+
166
+ def put(self, entry: Dict[str, Any]) -> None:
167
+ with self._lock:
168
+ entries = dict(self.entries)
169
+ entries[entry["request"]] = json.loads(json.dumps(entry))
170
+ self._write(entries)
171
+ self.entries = entries
172
+
173
+ def drop(self, request_id: str) -> None:
174
+ with self._lock:
175
+ if request_id not in self.entries:
176
+ return
177
+ entries = dict(self.entries)
178
+ del entries[request_id]
179
+ self._write(entries)
180
+ self.entries = entries
181
+
182
+
183
+ class BoardRequest:
184
+ """A claimed request as data: never an instruction. `body` is the requester's text; `sender` its Project id and
185
+ principal (with provenance); `artifacts` its references, unverified."""
186
+
187
+ __slots__ = ("request_id", "message_id", "capability", "body", "body_format", "artifacts", "sender", "deadline", "thread")
188
+
189
+ def __init__(self, message: Dict[str, Any], capability: str):
190
+ req = message.get("request") or {}
191
+ body = message.get("body") or {}
192
+ self.request_id, self.message_id, self.capability = req.get("id"), message.get("id"), capability
193
+ self.body = body.get("text") if isinstance(body, dict) else None
194
+ self.body_format = body.get("format", "text/plain") if isinstance(body, dict) else "text/plain"
195
+ self.artifacts = list(message.get("artifacts") or [])
196
+ self.sender = message.get("sender") or {}
197
+ self.deadline = req.get("deadline")
198
+ self.thread = message.get("thread")
199
+
200
+ def __repr__(self) -> str: # never the body
201
+ return "BoardRequest(%r, %r)" % (self.request_id, self.capability)
202
+
203
+
204
+ class WorkContext:
205
+ """What a handler may do while it works: `progress(note)` posts a note on the request's thread (and renews the
206
+ lease); `begin_effect(summary)` must be called — and must return — before the handler spends or acts outside
207
+ the service; `stopped` is set when the attempt is no longer this responder's. Both raise `Stopped` then."""
208
+
209
+ def __init__(self, responder: "Responder", entry: Dict[str, Any]):
210
+ self._responder, self._entry = responder, entry
211
+ self.stopped = threading.Event()
212
+
213
+ def progress(self, note: Optional[str] = None) -> None:
214
+ if self.stopped.is_set():
215
+ raise Stopped()
216
+ self._responder._progress(self._entry, note, self)
217
+
218
+ def begin_effect(self, summary: Optional[str] = None) -> None:
219
+ if self.stopped.is_set():
220
+ raise Stopped()
221
+ self._responder._effect_begin(self._entry, summary, self)
222
+
223
+
224
+ class _Work:
225
+ def __init__(self, entry: Dict[str, Any], request: BoardRequest, ctx: WorkContext):
226
+ self.entry, self.request, self.ctx = entry, request, ctx
227
+ self.done = threading.Event()
228
+ self.outcome: Optional[Dict[str, Any]] = None
229
+ self.failure: Optional[BaseException] = None
230
+ self.renewed_at: Optional[float] = None
231
+
232
+
233
+ Handler = Callable[[BoardRequest, WorkContext], Dict[str, Any]]
234
+
235
+
236
+ class Responder:
237
+ """One service's responder for one Project and one or more capabilities, one request at a time.
238
+
239
+ `handler(request, ctx)` returns the outcome `{state, observed?, summary?, evidence?, reply?}`; `admit(request)`
240
+ is the service's input gate (None admits; a sentence refuses); `observe(entry)` reads the world after a restart and
241
+ answers an observed outcome or None, for `com.reconcile`. `emit(kind, level, text, **extra)` receives the
242
+ responder's events (`JsonlEventLog.append` fits). `run(stop)` loops until `stop` is set; `step()` is one tick and
243
+ answers the seconds until the next."""
244
+
245
+ def __init__(self, *, hub: _hub.HubClient, project_id: str, capabilities: Sequence[str], credential_file: Path,
246
+ checkpoint_file: Path, handler: Handler, admit: Callable[[BoardRequest], Optional[str]],
247
+ observe: Optional[Callable[[Dict[str, Any]], Optional[Dict[str, Any]]]] = None,
248
+ emit: Optional[Callable[..., Any]] = None, service: str = "service",
249
+ now: Callable[[], float] = time.monotonic, rand: Callable[[], float] = random.random,
250
+ spawn: Optional[Callable[[Callable[[], None]], Any]] = None, session_factory: Optional[Callable[..., BoardSession]] = None):
251
+ if admit is None or handler is None:
252
+ raise BoardError("invalid_arguments", "A responder needs the service's own input gate (admit) and a handler.")
253
+ caps = list(capabilities)
254
+ if not caps or any(not isinstance(c, str) or not _CAPABILITY.fullmatch(c) for c in caps) or len(set(caps)) != len(caps):
255
+ raise BoardError("invalid_arguments", "Name each capability once, [a-z][a-z0-9._-]{1,127}.")
256
+ self.hub, self.project = hub, _hub._project(project_id)
257
+ self.capabilities = caps
258
+ self.credential_file, self.handler, self.admit, self.observe = Path(credential_file), handler, admit, observe
259
+ self.checkpoint = Checkpoint(Path(checkpoint_file))
260
+ self._emit_to, self.service, self.now, self.rand = emit, re.sub(r"[^A-Za-z0-9._-]", "-", service)[:40] or "service", now, rand
261
+ self.spawn = spawn or (lambda fn: threading.Thread(target=fn, name="board-handler", daemon=True).start())
262
+ self.session_factory = session_factory or (lambda grant: BoardSession(grant))
263
+ self.backoff = _hub.Backoff(rand=rand)
264
+ self.session: Optional[BoardSession] = None
265
+ self.epoch: Optional[int] = None
266
+ self.slots: Dict[str, Dict[str, Any]] = {}
267
+ self.current: Optional[_Work] = None
268
+ self.state = "idle"
269
+ self.wait_until = 0.0
270
+ self.stopping = False
271
+ self.claim_keys: Dict[str, str] = {}
272
+ self._said: Dict[str, str] = {}
273
+ self._rr = 0
274
+ #: The first pause under an operator's hold (DEC-0079); it doubles to HOLD_RETRY_MAX_SECONDS.
275
+ self.hold_delay = POLL_SECONDS
276
+ self._on_hold: set = set()
277
+
278
+ # -- events -------------------------------------------------------------------------------------------------
279
+ def _emit(self, kind: str, level: str, text: str, notify: bool = False, episode: Optional[str] = None, **value: Any) -> None:
280
+ if episode is not None:
281
+ if self._said.get(kind) == episode:
282
+ return # once per episode
283
+ self._said[kind] = episode
284
+ data = {"schema": EVENT_SCHEMA, "value": {k: v for k, v in value.items() if v is not None}}
285
+ problems = _fs.feed_data_problems(data)
286
+ if problems: # FAC-SEM-101: never a credential in data; drop the data, keep the sentence
287
+ data = None
288
+ if self._emit_to is None:
289
+ return
290
+ try:
291
+ self._emit_to(kind, level, text, notify=notify, **({"data": data} if data else {}))
292
+ except Exception: # noqa: BLE001 - an event that cannot be written never stops the responder
293
+ pass
294
+
295
+ # -- keys and fences ------------------------------------------------------------------------------------------
296
+ def _key(self, op: str) -> str:
297
+ return "%s.%s.%s" % (self.service, op, secrets.token_hex(8))
298
+
299
+ def _idem(self, key: str) -> Dict[str, Any]:
300
+ if self.epoch is None:
301
+ raise BoardError("no-epoch", "Fabric told this board session no idempotency epoch.", op="com.status")
302
+ return {"epoch": self.epoch, "key": key}
303
+
304
+ def _fence(self, entry: Dict[str, Any], key: str) -> Dict[str, Any]:
305
+ return {"request": entry["request"], "generation": entry["generation"], "attempt": entry["attempt"],
306
+ "digest": entry["digest"], "expectedRevision": entry["revision"], "idempotency": self._idem(key)}
307
+
308
+ def _receipt(self, op: str, answer: Dict[str, Any]) -> Dict[str, Any]:
309
+ problems = receipt_problems(answer, op)
310
+ if problems:
311
+ raise BoardError("receipt-shape", "Fabric answered %s with a receipt the contract does not name (%s)." % (op, problems[0]), op=op)
312
+ return answer
313
+
314
+ def _call(self, op: str, args: Dict[str, Any]) -> Dict[str, Any]:
315
+ if self.session is None:
316
+ raise BoardError("session-ended", "No board session.", True, op=op)
317
+ return self.session.call(op, args)
318
+
319
+ # -- connecting --------------------------------------------------------------------------------------------
320
+ def _status(self) -> Dict[str, Any]:
321
+ status = self._call("com.status", {})
322
+ verdict = _fs.negotiate_revision(status.get("protocol"), _fs.accepted_revisions("fabric-project-comms"))
323
+ if "error" in verdict:
324
+ raise BoardError("schema-unsupported", "Fabric's board speaks %s, which this kit does not read." % status.get("protocol"),
325
+ data=verdict["error"]["data"])
326
+ if (status.get("board") or {}).get("state") == "unavailable":
327
+ raise BoardError("not_available", "Fabric's board cannot be read right now.", True, op="com.status")
328
+ epoch = (status.get("idempotency") or {}).get("epoch")
329
+ if not _is_int(epoch, 1):
330
+ # Fail closed: a guessed epoch would store keys under a namespace the board never named.
331
+ raise BoardError("no-epoch", "Fabric told this board session no idempotency epoch (Fabric before its board-session "
332
+ "fix); the responder waits rather than guess one.", op="com.status")
333
+ self.epoch = epoch
334
+ return status
335
+
336
+ def _connect(self) -> None:
337
+ self.state = "connecting"
338
+ grant = self.hub.join_board(self.project, self.credential_file)
339
+ missing = [c for c in self.capabilities if c not in grant.capabilities]
340
+ session = self.session_factory(grant)
341
+ session.open()
342
+ self.session = session
343
+ status = self._status()
344
+ if missing:
345
+ self._emit("board.access.needed", "warn", "The operator has not let this service answer %s on this Project; ask in "
346
+ "Fabric (Agents & access)." % ", ".join(missing), notify=True, episode=",".join(missing),
347
+ capabilities=missing)
348
+ self.slots = {}
349
+ self._recover(status, [c for c in self.capabilities if c in grant.capabilities])
350
+ self.state = "ready"
351
+ for kind in ("board.down", "board.blocked") + (() if missing else ("board.access.needed",)):
352
+ self._said.pop(kind, None)
353
+
354
+ def _enroll(self, capability: str) -> None:
355
+ args = {"idempotency": self._idem(self._key("enroll")), "capability": capability}
356
+ try:
357
+ receipt = self._receipt("com.enroll", self._call("com.enroll", args))
358
+ except BoardError as exc:
359
+ if exc.code == "held":
360
+ self._pause(capability)
361
+ return
362
+ if exc.code == "fenced":
363
+ self.slots[capability] = {"state": "contested", "renewed_at": self.now()}
364
+ self._emit("board.slot.contested", "warn", "Another responder holds the %s slot on this Project; this service "
365
+ "waits for it to lapse and never takes it over by itself." % capability, notify=True,
366
+ episode=capability, capability=capability)
367
+ return
368
+ raise
369
+ self.slots[capability] = {"state": "held", "generation": receipt["slot"]["generation"], "renewed_at": self.now()}
370
+ self._said.pop("board.slot.contested", None)
371
+ self._lifted(capability)
372
+
373
+ def _pause(self, capability: str) -> None:
374
+ """The operator holds this slot or its Project (DEC-0079): ask again later, the pause doubling each time, and
375
+ never act around the hold. Accepted work in progress continues; only new work waits."""
376
+ slot = self.slots.get(capability) or {}
377
+ pauses = slot.get("pauses", 0) + 1 if slot.get("state") == "paused" else 1
378
+ wait = min(HOLD_RETRY_MAX_SECONDS, self.hold_delay * 2 ** (pauses - 1))
379
+ self.slots[capability] = {"state": "paused", "pauses": pauses, "retry_at": self.now() + wait, "renewed_at": self.now()}
380
+ if capability not in self._on_hold: # once per hold, per capability
381
+ self._on_hold.add(capability)
382
+ self._emit("board.slot.held", "info", "The operator holds new work for %s on this Project; the service waits and "
383
+ "asks again later. Work already accepted continues." % capability, capability=capability)
384
+
385
+ def _lifted(self, capability: str) -> None:
386
+ if capability in self._on_hold:
387
+ self._on_hold.discard(capability)
388
+ self._emit("board.slot.released", "info", "The operator's hold on %s was lifted; the service takes new work "
389
+ "again." % capability, capability=capability)
390
+
391
+ # -- restart: reconcile and transfer -------------------------------------------------------------------------
392
+ def _read_state(self, entry: Dict[str, Any]) -> Optional[str]:
393
+ message = self._call("com.get", {"message": entry["message"]})
394
+ req = message.get("request") if isinstance(message, dict) else None
395
+ if not isinstance(req, dict) or req.get("id") != entry["request"] or req.get("state") not in REQUEST_STATES:
396
+ return None
397
+ entry["revision"] = req.get("revision", entry.get("revision"))
398
+ return req["state"]
399
+
400
+ def _recover(self, status: Dict[str, Any], capabilities: List[str]) -> None:
401
+ transfer: Dict[str, List[Dict[str, Any]]] = {}
402
+ reconcile: List[Dict[str, Any]] = []
403
+ for entry in list(self.checkpoint.entries.values()):
404
+ if entry.get("capability") not in capabilities:
405
+ continue
406
+ try:
407
+ state = self._read_state(entry)
408
+ except BoardError as exc:
409
+ if exc.code in ("not_authorized",):
410
+ self.checkpoint.drop(entry["request"])
411
+ continue
412
+ raise
413
+ if state is None or state in TERMINAL or state in ("queued", "claimed"):
414
+ self.checkpoint.drop(entry["request"]) # settled, or back in the queue for whoever claims next
415
+ elif state == "accepted":
416
+ transfer.setdefault(entry["capability"], []).append(entry)
417
+ else: # in_progress (lost result once a new generation holds the slot) or outcome_unknown
418
+ reconcile.append(entry)
419
+ responders = [r for r in status.get("responders") or [] if isinstance(r, dict)]
420
+ generations = {r.get("capability"): r.get("generation", 0) for r in responders}
421
+ on_hold = {r.get("capability") for r in responders if r.get("held") is True} # DEC-0079
422
+ for capability in capabilities:
423
+ held = transfer.get(capability)
424
+ if capability in on_hold:
425
+ for entry in held or []:
426
+ entry["step"] = "lapsed" # taken back by a transfer once the hold is lifted
427
+ self.checkpoint.put(entry)
428
+ self._pause(capability)
429
+ elif held:
430
+ self._replace(capability, held, generations.get(capability, 0))
431
+ else:
432
+ self._enroll(capability)
433
+ for entry in reconcile:
434
+ if self.slots.get(entry["capability"], {}).get("state") == "held":
435
+ self._reconcile(entry)
436
+
437
+ def _replace(self, capability: str, entries: List[Dict[str, Any]], generation: Any) -> None:
438
+ if not _is_int(generation, 0):
439
+ generation = 0
440
+ args = {"idempotency": self._idem(self._key("replace")), "capability": capability, "expectedGeneration": generation,
441
+ "transfer": {"requests": [e["request"] for e in entries][:64],
442
+ "checkpoint": {"summary": "Resumed by the same service after a restart; no effect had begun."}}}
443
+ try:
444
+ receipt = self._receipt("com.responder_replace", self._call("com.responder_replace", args))
445
+ except BoardError as exc:
446
+ if exc.code != "held":
447
+ raise
448
+ for entry in entries:
449
+ entry["step"] = "lapsed" # nothing moved: taken back by a transfer once the hold is lifted
450
+ self.checkpoint.put(entry)
451
+ self._pause(capability)
452
+ return
453
+ self.slots[capability] = {"state": "held", "generation": receipt["slot"]["generation"], "renewed_at": self.now()}
454
+ self._lifted(capability)
455
+ moved = {item["request"]: item for item in receipt.get("transferred") or []}
456
+ for entry in entries:
457
+ item = moved.get(entry["request"])
458
+ if item is None:
459
+ # A board pinned before DEC-0078 hands back no attempt. Never claim it back: it stays held, and the next
460
+ # start (or the operator) decides.
461
+ entry["step"] = "held"
462
+ self.checkpoint.put(entry)
463
+ self._emit("board.request.held", "warn", "An accepted request could not be resumed: this Fabric does not hand "
464
+ "back a transferred attempt (DEC-0078). It stays held; nothing was done.", notify=True,
465
+ episode=entry["request"], request=entry["request"], capability=capability)
466
+ continue
467
+ entry.update(generation=item["attempt"]["generation"], attempt=item["attempt"]["id"], digest=item["attempt"]["digest"],
468
+ revision=item["revision"], step="accepted" if entry.get("step") in ("accepting", "held", "lapsed") else entry.get("step"))
469
+ self.checkpoint.put(entry)
470
+ if self.current is None:
471
+ self._resume(entry)
472
+
473
+ def _reconcile(self, entry: Dict[str, Any]) -> None:
474
+ state = self._read_state(entry)
475
+ if state != "outcome_unknown":
476
+ if state in TERMINAL or state in ("queued", "claimed"):
477
+ self.checkpoint.drop(entry["request"])
478
+ return
479
+ outcome = entry.get("outcome") if isinstance(entry.get("outcome"), dict) and entry["outcome"].get("observed") is True else None
480
+ if outcome is None and self.observe is not None:
481
+ try:
482
+ outcome = self.observe(dict(entry))
483
+ except Exception: # noqa: BLE001 - an observation that failed is no observation
484
+ outcome = None
485
+ if not outcome or outcome_problems(dict(outcome, observed=True), True) or outcome.get("observed") is not True:
486
+ entry["step"] = "unknown"
487
+ self.checkpoint.put(entry)
488
+ self._emit("board.reconcile.needed", "warn", "A request's outcome is unknown: the work may have happened. It stays "
489
+ "unknown until this service observes what happened.", notify=True, episode=entry["request"],
490
+ request=entry["request"], capability=entry["capability"])
491
+ return
492
+ clean = {k: outcome[k] for k in ("state", "summary", "evidence") if k in outcome}
493
+ clean["observed"] = True
494
+ args = {"idempotency": self._idem(entry.get("keys", {}).get("reconcile") or self._key("reconcile")), "request": entry["request"],
495
+ "digest": entry["digest"], "outcome": clean}
496
+ entry.setdefault("keys", {})["reconcile"] = args["idempotency"]["key"]
497
+ entry["step"] = "reconciling"
498
+ self.checkpoint.put(entry)
499
+ self._receipt("com.reconcile", self._call("com.reconcile", args))
500
+ self.checkpoint.drop(entry["request"])
501
+ self._emit("board.request.reconciled", "info", "A request whose outcome was unknown was settled from what this service "
502
+ "observed.", request=entry["request"], capability=entry["capability"], state=clean["state"])
503
+
504
+ # -- one tick ------------------------------------------------------------------------------------------------
505
+ def interval(self) -> float:
506
+ return max(1.0, POLL_SECONDS + POLL_JITTER_SECONDS * (2 * self.rand() - 1))
507
+
508
+ def step(self) -> float:
509
+ """One tick. Answers the seconds to wait before the next."""
510
+ if self.stopping:
511
+ return DRAIN_SECONDS
512
+ now = self.now()
513
+ if now < self.wait_until:
514
+ return self.wait_until - now
515
+ try:
516
+ if self.session is None:
517
+ self._connect()
518
+ self._tick()
519
+ self.backoff.reset()
520
+ return self.interval()
521
+ except (BoardError, _hub.HubError) as exc:
522
+ return self._fault(exc)
523
+
524
+ def _fault(self, exc: Exception) -> float:
525
+ code = getattr(exc, "code", "unreachable")
526
+ wait = self.backoff.next()
527
+ self.wait_until = self.now() + wait
528
+ if code in ("not_authorized", "authority_changed", "credential"):
529
+ self.state = "not-authorized"
530
+ self._drop_session()
531
+ self._emit("board.access.needed", "warn", "This service may not answer requests for this Project: the operator "
532
+ "has to allow it in Fabric (Agents & access). Nothing is claimed until then.", notify=True,
533
+ episode="access", reason=code)
534
+ elif code == "held":
535
+ self.state = "paused"
536
+ self._emit("board.held", "info", "The operator holds new work on this Project; the responder waits and asks again "
537
+ "with a growing pause.", episode="held", reason=code)
538
+ elif code in ("no-epoch", "schema-unsupported", "checkpoint", "receipt-shape"):
539
+ self.state = "down"
540
+ self._drop_session()
541
+ self._emit("board.blocked", "error", "The responder stopped answering requests: %s" % getattr(exc, "message", str(exc)),
542
+ notify=True, episode=code, reason=code)
543
+ else:
544
+ self.state = "down"
545
+ if code in ("session-ended", "unreachable", "not-running", "not-a-hub", "hub-unavailable", "protocol"):
546
+ self._drop_session()
547
+ self._emit("board.down", "warn", "Fabric's board cannot be reached; the responder tries again with a growing pause.",
548
+ episode="down", reason=code)
549
+ return wait
550
+
551
+ def _drop_session(self) -> None:
552
+ work, self.current = self.current, None
553
+ if work is not None:
554
+ entry = work.entry
555
+ if work.done.is_set() and work.failure is None and not outcome_problems(work.outcome, bool(entry.get("effect"))):
556
+ # The answer is known: keep it, so the next session sends it instead of doing the work again.
557
+ entry["outcome"] = dict(work.outcome, observed=work.outcome.get("observed", not entry.get("effect")))
558
+ try:
559
+ self.checkpoint.put(entry)
560
+ except BoardError:
561
+ pass
562
+ else:
563
+ work.ctx.stopped.set() # the lease lapses; acting on would outlive the attempt
564
+ session, self.session = self.session, None
565
+ if session is not None:
566
+ session.close()
567
+
568
+ def _tick(self) -> None:
569
+ work = self.current
570
+ if work is not None:
571
+ if work.done.is_set():
572
+ self._finish(work)
573
+ else:
574
+ self._renew(work)
575
+ held_by_work = work.entry["capability"] if work is not None and self.current is not None else None
576
+ for capability, slot in list(self.slots.items()):
577
+ if slot.get("state") == "paused":
578
+ if capability != held_by_work and self.now() >= slot.get("retry_at", 0.0):
579
+ self._enroll(capability) # still held: a longer pause; lifted: the slot is ours again
580
+ continue
581
+ if capability != held_by_work and self.now() - slot.get("renewed_at", 0) >= RENEW_SECONDS - 0.5:
582
+ self._enroll(capability) # the holder's enroll renews its lease (no event); a lapsed one re-enrolls
583
+ if self.current is None and not self.stopping:
584
+ self._continue_lapsed()
585
+ if self.current is None and not self.stopping:
586
+ self._reconcile_pending()
587
+ self._claim_next()
588
+
589
+ def _continue_lapsed(self) -> None:
590
+ """Accepted work whose lease lapsed goes back to this responder by `com.responder_replace` with a transfer."""
591
+ lapsed: Dict[str, List[Dict[str, Any]]] = {}
592
+ for entry in self.checkpoint.entries.values():
593
+ if entry.get("step") == "lapsed" and self.slots.get(entry.get("capability"), {}).get("state") == "held":
594
+ lapsed.setdefault(entry["capability"], []).append(json.loads(json.dumps(entry)))
595
+ for capability, entries in lapsed.items():
596
+ live = [e for e in entries if self._read_state(e) == "accepted"]
597
+ for e in entries:
598
+ if e not in live:
599
+ self.checkpoint.drop(e["request"])
600
+ if live:
601
+ self._replace(capability, live, self.slots[capability].get("generation", 0))
602
+
603
+ def _reconcile_pending(self) -> None:
604
+ """Work whose outcome became unknown while running (its attempt was lost after the effect began) is tried
605
+ again every five minutes: the slot's holder reconciles from what `observe` reads, or it stays unknown."""
606
+ if self.now() < getattr(self, "_reconcile_at", 0.0):
607
+ return
608
+ self._reconcile_at = self.now() + 300.0
609
+ for entry in list(self.checkpoint.entries.values()):
610
+ if entry.get("step") == "unknown" and self.slots.get(entry.get("capability"), {}).get("state") == "held":
611
+ self._reconcile(entry)
612
+
613
+ def _claim_next(self) -> None:
614
+ held = [c for c in self.capabilities if self.slots.get(c, {}).get("state") == "held"]
615
+ for i in range(len(held)):
616
+ capability = held[(self._rr + i) % len(held)]
617
+ key = self.claim_keys.setdefault(capability, self._key("claim"))
618
+ try:
619
+ answer = self._receipt("com.claim", self._call("com.claim", {"idempotency": self._idem(key), "capability": capability}))
620
+ except BoardError as exc:
621
+ if exc.code == "lease_expired":
622
+ self._enroll(capability) # the slot lapsed: enroll again (a new generation)
623
+ continue
624
+ if exc.code in ("idempotency_window_expired", "capacity_exceeded"):
625
+ self.claim_keys.pop(capability, None)
626
+ self._status()
627
+ continue
628
+ if exc.code == "fenced":
629
+ self.slots[capability] = {"state": "contested", "renewed_at": self.now()}
630
+ continue
631
+ if exc.code == "held":
632
+ self._pause(capability)
633
+ continue
634
+ raise
635
+ if "request" not in answer:
636
+ continue # nothing claimable; the same key polls again
637
+ self.claim_keys.pop(capability, None)
638
+ self._rr += 1
639
+ self._start(capability, answer)
640
+ return
641
+
642
+ # -- a claimed request -------------------------------------------------------------------------------------
643
+ def _start(self, capability: str, receipt: Dict[str, Any]) -> None:
644
+ if "attempt" not in receipt or "message" not in receipt:
645
+ raise BoardError("receipt-shape", "com.claim answered a request without its attempt or message.", op="com.claim")
646
+ attempt = receipt["attempt"]
647
+ entry = {"request": receipt["request"]["id"], "message": receipt["message"], "capability": capability,
648
+ "generation": attempt["generation"], "attempt": attempt["id"], "digest": attempt["digest"],
649
+ "revision": receipt["request"]["revision"], "step": "claimed", "keys": {}, "effect": False}
650
+ self.checkpoint.put(entry)
651
+ message = self._call("com.get", {"message": entry["message"]})
652
+ req = message.get("request") if isinstance(message, dict) else None
653
+ if message.get("digest") != entry["digest"]:
654
+ self.checkpoint.drop(entry["request"])
655
+ self._emit("board.request.skipped", "warn", "A claimed request was not accepted: the message read back carries "
656
+ "another digest than the claim. Nothing was done; the claim lapses.", request=entry["request"],
657
+ capability=capability, reason="digest-mismatch")
658
+ return
659
+ if not isinstance(req, dict) or req.get("id") != entry["request"] or req.get("capability") != capability \
660
+ or req.get("target") != self.project:
661
+ self.checkpoint.drop(entry["request"])
662
+ self._emit("board.request.skipped", "warn", "A claimed request was not accepted: it is not addressed to this "
663
+ "Project and capability. Nothing was done; the claim lapses.", request=entry["request"],
664
+ capability=capability, reason="not-addressed")
665
+ return
666
+ request = BoardRequest(message, capability)
667
+ if message.get("redacted") is True or not isinstance(request.body, str):
668
+ reason: Optional[str] = "The request's body was redacted, so there is nothing to act on."
669
+ else:
670
+ try:
671
+ reason = self.admit(request)
672
+ except Exception: # noqa: BLE001 - a gate that fails refuses
673
+ reason = "The service's input gate could not read this request."
674
+ if not self._accept(entry):
675
+ return
676
+ if reason is not None:
677
+ text = ("Refused by this service's input gate: %s" % " ".join(str(reason).split()))[:SUMMARY_MAX]
678
+ entry["outcome"] = {"state": "failed_known", "observed": True, "summary": text}
679
+ self._complete(entry)
680
+ self._emit("board.request.refused", "info", "A request was refused by the service's input gate and completed as "
681
+ "failed.", request=entry["request"], capability=capability)
682
+ return
683
+ self._run(entry, request)
684
+
685
+ def _accept(self, entry: Dict[str, Any]) -> bool:
686
+ key = entry["keys"].get("accept") or self._key("accept")
687
+ entry["keys"]["accept"], entry["step"] = key, "accepting"
688
+ self.checkpoint.put(entry)
689
+ try:
690
+ receipt = self._fenced("com.accept", entry, {"fence": self._fence(entry, key)})
691
+ except BoardError as exc:
692
+ if exc.code != "held":
693
+ raise
694
+ # A hold came between the claim and the accept (DEC-0079): nothing was done, the claim lapses and the request
695
+ # waits on the board for the hold to be lifted.
696
+ self.checkpoint.drop(entry["request"])
697
+ self._emit("board.request.waiting", "info", "A claimed request was not accepted: the operator holds it. Nothing "
698
+ "was done; it waits on the board.", request=entry["request"], capability=entry["capability"])
699
+ self._pause(entry["capability"])
700
+ return False
701
+ if receipt is None:
702
+ return False # the claim was lost before it was accepted; nothing was done
703
+ entry["revision"], entry["step"] = receipt["request"]["revision"], "accepted"
704
+ self.checkpoint.put(entry)
705
+ return True
706
+
707
+ def _run(self, entry: Dict[str, Any], request: BoardRequest) -> None:
708
+ ctx = WorkContext(self, entry)
709
+ work = _Work(entry, request, ctx)
710
+ work.renewed_at = self.now()
711
+ self.current = work
712
+
713
+ def body() -> None:
714
+ try:
715
+ work.outcome = self.handler(request, ctx)
716
+ except BaseException as exc: # noqa: BLE001 - recorded, decided by _finish
717
+ work.failure = exc
718
+ finally:
719
+ work.done.set()
720
+ self.spawn(body)
721
+ if work.done.is_set():
722
+ self._finish(work)
723
+
724
+ def _resume(self, entry: Dict[str, Any]) -> None:
725
+ """A transferred request continues: an outcome already known is sent; otherwise the handler runs again (no
726
+ effect had begun, so nothing happened yet)."""
727
+ if isinstance(entry.get("outcome"), dict):
728
+ self._complete(entry)
729
+ return
730
+ message = self._call("com.get", {"message": entry["message"]})
731
+ self._run(entry, BoardRequest(message, entry["capability"]))
732
+
733
+ def _fenced(self, op: str, entry: Dict[str, Any], args: Dict[str, Any]) -> Optional[Dict[str, Any]]:
734
+ """A fenced call. On `fenced`, `lease_expired` or `authority_changed` the attempt is lost: the work stops, and
735
+ answers None (`authority_changed` raises after that, so the responder stops claiming)."""
736
+ try:
737
+ return self._receipt(op, self._call(op, args))
738
+ except BoardError as exc:
739
+ if exc.code in STOP_CODES or exc.code in ("revision_conflict", "invalid_transition", "digest_conflict"):
740
+ self._lose(entry, exc.code)
741
+ if exc.code == "authority_changed":
742
+ raise
743
+ return None
744
+ raise
745
+
746
+ def _lose(self, entry: Dict[str, Any], code: str) -> None:
747
+ if self.current is not None and self.current.entry is entry:
748
+ self.current.ctx.stopped.set()
749
+ if entry.get("effect"):
750
+ entry["step"] = "unknown"
751
+ self.checkpoint.put(entry)
752
+ self._emit("board.reconcile.needed", "warn", "The attempt was lost after its effect began; the outcome stays unknown "
753
+ "until this service observes what happened.", notify=True, episode=entry["request"],
754
+ request=entry["request"], capability=entry["capability"], reason=code)
755
+ elif code == "lease_expired" and entry.get("step") not in ("claimed", "accepting"):
756
+ # Our own lease lapsed on accepted work: the board holds it (C4). It continues only by a checkpoint transfer
757
+ # to this responder, which the next tick asks for; nothing outside the service had begun.
758
+ entry["step"] = "lapsed"
759
+ self.checkpoint.put(entry)
760
+ self._emit("board.attempt.lost", "warn", "This service's lease on an accepted request lapsed; it stopped working "
761
+ "on it and takes it back by a transfer.", request=entry["request"], capability=entry["capability"], reason=code)
762
+ else:
763
+ self.checkpoint.drop(entry["request"])
764
+ self._emit("board.attempt.lost", "warn", "The board took a request back from this service (%s); it stopped working "
765
+ "on it, and nothing had been done outside the service." % code, request=entry["request"],
766
+ capability=entry["capability"], reason=code)
767
+
768
+ def _renew(self, work: _Work) -> None:
769
+ entry = work.entry
770
+ if self.now() - (work.renewed_at or 0.0) < RENEW_SECONDS - 0.5:
771
+ return
772
+ receipt = self._fenced("com.renew", entry, {"fence": self._fence(entry, self._key("renew"))})
773
+ if receipt is None:
774
+ self.current = None
775
+ return
776
+ work.renewed_at = self.now()
777
+ self.slots.setdefault(entry["capability"], {})["renewed_at"] = self.now()
778
+
779
+ def _progress(self, entry: Dict[str, Any], note: Optional[str], ctx: WorkContext) -> None:
780
+ args: Dict[str, Any] = {"fence": self._fence(entry, self._key("progress"))}
781
+ if note is not None:
782
+ args["note"] = {"text": " ".join(str(note).split())[:SUMMARY_MAX] or "Working."}
783
+ if self._fenced("com.progress", entry, args) is None:
784
+ raise Stopped()
785
+
786
+ def _effect_begin(self, entry: Dict[str, Any], summary: Optional[str], ctx: WorkContext) -> None:
787
+ key = entry["keys"].get("effect_begin") or self._key("effect")
788
+ entry["keys"]["effect_begin"], entry["step"] = key, "effect-beginning"
789
+ entry["effect"] = True # from here a lost attempt is an unknown outcome, never "nothing happened"
790
+ self.checkpoint.put(entry)
791
+ args: Dict[str, Any] = {"fence": self._fence(entry, key)}
792
+ if summary:
793
+ args["summary"] = " ".join(str(summary).split())[:SUMMARY_MAX]
794
+ receipt = None
795
+ lost, delay = 0, self.hold_delay
796
+ while True: # a lost answer: the same key again tells whether it was stored
797
+ try:
798
+ receipt = self._fenced("com.effect_begin", entry, args)
799
+ break
800
+ except BoardError as exc:
801
+ if exc.code == "held":
802
+ # The operator holds this work (DEC-0079): the effect has not begun. Wait, keep the lease (the tick
803
+ # renews it; com.renew is never held), and ask again with the same key.
804
+ entry["effect"], entry["step"] = False, "effect-held"
805
+ self.checkpoint.put(entry)
806
+ self._emit("board.request.waiting", "info", "The operator holds this request before its effect: the "
807
+ "service waits and does nothing outside it until the hold is lifted.", episode=entry["request"],
808
+ request=entry["request"], capability=entry["capability"])
809
+ if ctx.stopped.wait(delay):
810
+ raise Stopped() from None
811
+ delay = min(HOLD_RETRY_MAX_SECONDS, delay * 2)
812
+ entry["effect"], entry["step"] = True, "effect-beginning"
813
+ self.checkpoint.put(entry)
814
+ continue
815
+ if exc.code != "unreachable" or lost == 2:
816
+ raise Stopped() from None
817
+ lost += 1
818
+ time.sleep(lost)
819
+ if receipt is None:
820
+ raise Stopped()
821
+ entry["revision"], entry["step"] = receipt["request"]["revision"], "in-progress"
822
+ self.checkpoint.put(entry)
823
+
824
+ def _finish(self, work: _Work) -> None:
825
+ entry = work.entry
826
+ if work.ctx.stopped.is_set() and entry["request"] not in self.checkpoint.entries:
827
+ self.current = None
828
+ return
829
+ outcome = work.outcome
830
+ if work.failure is not None:
831
+ if entry.get("effect") or isinstance(work.failure, Stopped):
832
+ self.current = None
833
+ if entry.get("effect"):
834
+ self._lose(entry, "handler-failed")
835
+ return
836
+ outcome = {"state": "failed_known", "observed": True,
837
+ "summary": "The service could not do this (%s)." % work.failure.__class__.__name__}
838
+ problems = outcome_problems(outcome, bool(entry.get("effect")))
839
+ if problems:
840
+ self.current = None
841
+ if entry.get("effect"):
842
+ self._lose(entry, "observation_required" if any("observation_required" in p for p in problems) else "outcome")
843
+ else:
844
+ entry["outcome"] = {"state": "failed_known", "observed": True, "summary": "The service answered in a form it cannot send."}
845
+ self._complete(entry)
846
+ self._emit("board.outcome.invalid", "warn", "The handler's outcome was not sent: %s." % problems[0],
847
+ request=entry["request"], capability=entry["capability"])
848
+ return
849
+ entry["outcome"] = dict(outcome, observed=outcome.get("observed", not entry.get("effect")))
850
+ self._complete(entry)
851
+
852
+ def _complete(self, entry: Dict[str, Any]) -> None:
853
+ outcome = entry["outcome"]
854
+ reply = outcome.get("reply")
855
+ if reply and not entry.get("replied"):
856
+ key = entry["keys"].get("reply") or self._key("reply")
857
+ entry["keys"]["reply"], entry["step"] = key, "replying"
858
+ self.checkpoint.put(entry)
859
+ body = {"text": reply["text"], **({"format": reply["format"]} if "format" in reply else {})}
860
+ args: Dict[str, Any] = {"fence": self._fence(entry, key), "body": body}
861
+ if reply.get("artifacts"):
862
+ args["artifacts"] = reply["artifacts"]
863
+ if self._fenced("com.reply", entry, args) is None:
864
+ self.current = None
865
+ return
866
+ entry["replied"] = True
867
+ self.checkpoint.put(entry)
868
+ key = entry["keys"].get("complete") or self._key("complete")
869
+ entry["keys"]["complete"], entry["step"] = key, "completing"
870
+ self.checkpoint.put(entry)
871
+ sent = {k: outcome[k] for k in ("state", "observed", "summary", "evidence") if k in outcome}
872
+ receipt = self._fenced("com.complete", entry, {"fence": self._fence(entry, key), "outcome": sent})
873
+ self.current = None
874
+ if receipt is None:
875
+ return
876
+ self.checkpoint.drop(entry["request"])
877
+ self._emit("board.request.completed", "info", "A request was answered: %s." % ("completed" if sent["state"] == "completed" else "failed, as known"),
878
+ request=entry["request"], capability=entry["capability"], state=sent["state"])
879
+
880
+ # -- running ----------------------------------------------------------------------------------------------
881
+ def run(self, stop: threading.Event, drain: float = DRAIN_SECONDS) -> None:
882
+ """Tick until `stop` is set, then `shutdown(drain)`."""
883
+ while not stop.is_set():
884
+ stop.wait(self.step())
885
+ self.shutdown(drain)
886
+
887
+ def shutdown(self, drain: float = DRAIN_SECONDS) -> None:
888
+ """Stop claiming, give running work up to `drain` seconds to finish and be completed, then leave the session
889
+ (its DELETE releases the slots). What is still open stays in the checkpoint for the next start."""
890
+ self.stopping = True
891
+ work = self.current
892
+ if work is not None and not work.done.wait(drain):
893
+ work.ctx.stopped.set()
894
+ if work is not None and work.done.is_set() and self.session is not None:
895
+ try:
896
+ self._finish(work)
897
+ except (BoardError, _hub.HubError):
898
+ pass # the checkpoint keeps it
899
+ if self.session is not None:
900
+ self.session.close()
901
+ self.session = None
902
+ self.state = "stopped"