@passioncode-ai/passioncode 0.2.45 → 0.2.46

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