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