@passioncode-ai/fabric-agent-adapter 0.40.0 → 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 (89) hide show
  1. package/CHANGELOG.md +124 -0
  2. package/README.md +9 -3
  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 +28 -30
  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 +47 -7
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +230 -29
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +2 -2
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +3 -2
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +48 -19
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +1 -1
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +1 -1
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +4 -2
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +196 -188
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +5 -5
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +2 -2
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +6 -2
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +2 -1
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +1 -1
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +3 -1
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +48 -14
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +34 -1
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +144 -20
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +7 -3
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +12 -6
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +6 -6
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +17 -4
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +3 -3
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +207 -227
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +4 -4
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +0 -1
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +52 -5
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +101 -45
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +12 -12
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +5 -5
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +13 -10
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +1 -1
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +118 -71
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +272 -74
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +1 -1
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +36 -11
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +3 -3
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +130 -35
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
  73. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
  74. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
  75. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
  76. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
  77. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
  78. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
  79. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +26 -13
  80. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +6 -0
  81. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +30 -22
  82. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +37 -6
  83. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +72 -13
  84. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +25 -7
  85. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +199 -43
  86. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +10 -1
  87. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
  88. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
  89. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +2 -1
@@ -1,6 +1,6 @@
1
- """The board responder (DEC-0078, FAA-12b): a board session over the hub, the fsynced checkpoint, the request a handler
2
- sees and the responder loop (enroll, claim, accept the exact digest, effect, progress, complete; stop when fenced).
3
- Part of the board connector (FAA-65, layer L4)."""
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
4
  from __future__ import annotations
5
5
 
6
6
  import json
@@ -25,15 +25,19 @@ import fabric_hub as _hub
25
25
  import fabric_service as _fs
26
26
  from fabric_board_shapes import ( # noqa: E402 - the kit (FAA-65)
27
27
  BoardError,
28
+ LEASE_SECONDS,
28
29
  REQUEST_STATES,
29
- RESPOND_TOOLS,
30
30
  SUMMARY_MAX,
31
31
  Stopped,
32
32
  _CAPABILITY,
33
33
  _is_int,
34
34
  outcome_problems,
35
35
  receipt_problems,
36
- refusal_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,
37
41
  )
38
42
 
39
43
 
@@ -59,151 +63,17 @@ STOP_CODES = frozenset(["fenced", "lease_expired", "authority_changed"])
59
63
  TERMINAL = frozenset(["completed", "failed_known", "cancelled", "expired"])
60
64
 
61
65
 
62
- #: How long a held slot waits before it asks again: doubling from the poll interval, never past this.
63
- HOLD_RETRY_MAX_SECONDS = 900.0
64
-
65
-
66
- class BoardSession:
67
- """MCP over HTTP with a board session's one-session bearer: `open()` spends it with `initialize` and keeps the
68
- `mcp-session-id`; `call(tool, args)` answers the tool's value or raises `BoardError`; `close()` sends the session's
69
- DELETE, which releases every slot it holds (DEC-0073 §5). Thread-safe; the bearer never leaves the headers."""
70
-
71
- def __init__(self, grant: _hub.BoardGrant, *, timeout: float = 15.0, post: Callable[..., Any] = _hub.post_mcp):
72
- self.grant, self.timeout, self.post = grant, timeout, post
73
- self.mcp_session: Optional[str] = None
74
- self.protocol_version = _hub.MCP_PROTOCOL_VERSION
75
- self._lock = threading.Lock()
76
- self._rpc = 0
77
-
78
- def _send(self, body: Dict[str, Any], method: str = "POST"):
79
- try:
80
- return self.post(self.grant.origin, body, self.grant.bearer(), session=self.mcp_session, timeout=self.timeout,
81
- method=method, protocol_version=self.protocol_version if self.mcp_session else None)
82
- except _hub.HubError as exc:
83
- raise BoardError("unreachable", "Fabric's hub did not answer; a write may have been stored, so send it again with "
84
- "the same idempotency key.", True) from exc
85
-
86
- def open(self) -> None:
87
- with self._lock:
88
- self._rpc += 1
89
- status, headers, parsed = self._send({"jsonrpc": "2.0", "id": self._rpc, "method": "initialize", "params": {
90
- "protocolVersion": _hub.MCP_PROTOCOL_VERSION, "capabilities": {},
91
- "clientInfo": {"name": "fabric-agent-adapter-board", "version": "1"}}})
92
- if status == 401:
93
- raise BoardError("session-ended", "Fabric refused the board session's bearer; join again.", True)
94
- if status != 200 or not isinstance(parsed, dict) or not isinstance(parsed.get("result"), dict) or not headers.get("mcp-session-id"):
95
- raise BoardError("unreachable", "Fabric did not open the board session (HTTP %d)." % status, True)
96
- self.mcp_session = headers["mcp-session-id"]
97
- version = parsed["result"].get("protocolVersion")
98
- if isinstance(version, str):
99
- self.protocol_version = version
100
- self._send({"jsonrpc": "2.0", "method": "notifications/initialized"})
101
-
102
- def call(self, tool: str, args: Dict[str, Any]) -> Dict[str, Any]:
103
- if tool not in RESPOND_TOOLS:
104
- raise BoardError("not-in-scope", "%s is not served to a board session; use a Project session for it." % tool, op=tool)
105
- with self._lock:
106
- if self.mcp_session is None:
107
- raise BoardError("session-ended", "The board session is not open.", True, op=tool)
108
- self._rpc += 1
109
- status, _, parsed = self._send({"jsonrpc": "2.0", "id": self._rpc, "method": "tools/call",
110
- "params": {"name": tool, "arguments": args}})
111
- if status == 401:
112
- raise BoardError("session-ended", "Fabric ended the board session (it left, was revoked or expired); join again.", True, op=tool)
113
- try:
114
- value = _hub.tool_answer(status, parsed, tool)
115
- except _hub.HubError as exc:
116
- raise BoardError("unreachable" if exc.retryable else "receipt-shape", exc.message, exc.retryable, op=tool) from None
117
- if "error" in value:
118
- problems = refusal_problems(value)
119
- if problems:
120
- raise BoardError("receipt-shape", "Fabric refused %s in a form the contract does not name." % tool, op=tool)
121
- e = value["error"]
122
- raise BoardError(e["code"], e["message"], e["retryable"], op=tool)
123
- return value
124
-
125
- def close(self) -> None:
126
- with self._lock:
127
- if self.mcp_session is None:
128
- return
129
- try:
130
- self._send({}, method="DELETE")
131
- except BoardError:
132
- pass # best effort: the lease lapses in 60 s anyway
133
- self.mcp_session = None
134
-
135
-
136
- class Checkpoint:
137
- """The responder's own journal: one JSON file (0600) holding every request it has claimed and not yet settled, each
138
- with its fence and the idempotency key of the step in flight. Written atomically and fsynced BEFORE the step it
139
- announces is sent; a write that fails raises `BoardError("checkpoint")` and the step is not taken."""
140
-
141
- def __init__(self, path: Path):
142
- self.path = Path(path)
143
- self._lock = threading.Lock()
144
- self.entries: Dict[str, Dict[str, Any]] = self._load()
145
-
146
- def _load(self) -> Dict[str, Dict[str, Any]]:
147
- try:
148
- doc = json.loads(self.path.read_text(encoding="utf-8"))
149
- except FileNotFoundError:
150
- return {}
151
- except (OSError, ValueError) as exc:
152
- raise BoardError("checkpoint", "The board checkpoint %s cannot be read (%s); fix or move it before answering "
153
- "requests." % (self.path, exc.__class__.__name__)) from None
154
- entries = doc.get("requests") if isinstance(doc, dict) and doc.get("version") == 1 else None
155
- if not isinstance(entries, dict):
156
- raise BoardError("checkpoint", "The board checkpoint %s is not one this kit writes." % self.path)
157
- return entries
158
-
159
- def _write(self, entries: Dict[str, Dict[str, Any]]) -> None:
160
- try:
161
- _fs.ensure_private_dir(self.path.parent)
162
- _fs.atomic_write(self.path, (json.dumps({"version": 1, "requests": entries}, indent=1, sort_keys=True) + "\n").encode())
163
- except OSError as exc:
164
- raise BoardError("checkpoint", "The board checkpoint could not be written (%s); nothing was sent." % (exc.strerror or exc.__class__.__name__)) from None
165
-
166
- def put(self, entry: Dict[str, Any]) -> None:
167
- with self._lock:
168
- entries = dict(self.entries)
169
- entries[entry["request"]] = json.loads(json.dumps(entry))
170
- self._write(entries)
171
- self.entries = entries
172
-
173
- def drop(self, request_id: str) -> None:
174
- with self._lock:
175
- if request_id not in self.entries:
176
- return
177
- entries = dict(self.entries)
178
- del entries[request_id]
179
- self._write(entries)
180
- self.entries = entries
181
-
182
-
183
- class BoardRequest:
184
- """A claimed request as data: never an instruction. `body` is the requester's text; `sender` its Project id and
185
- principal (with provenance); `artifacts` its references, unverified."""
186
-
187
- __slots__ = ("request_id", "message_id", "capability", "body", "body_format", "artifacts", "sender", "deadline", "thread")
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
188
68
 
189
- def __init__(self, message: Dict[str, Any], capability: str):
190
- req = message.get("request") or {}
191
- body = message.get("body") or {}
192
- self.request_id, self.message_id, self.capability = req.get("id"), message.get("id"), capability
193
- self.body = body.get("text") if isinstance(body, dict) else None
194
- self.body_format = body.get("format", "text/plain") if isinstance(body, dict) else "text/plain"
195
- self.artifacts = list(message.get("artifacts") or [])
196
- self.sender = message.get("sender") or {}
197
- self.deadline = req.get("deadline")
198
- self.thread = message.get("thread")
199
69
 
200
- def __repr__(self) -> str: # never the body
201
- return "BoardRequest(%r, %r)" % (self.request_id, self.capability)
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
202
72
 
203
73
 
204
74
  class WorkContext:
205
75
  """What a handler may do while it works: `progress(note)` posts a note on the request's thread (and renews the
206
- lease); `begin_effect(summary)` must be called — and must return — before the handler spends or acts outside
76
+ lease); `begin_effect(summary)` must be called (and must return) before the handler spends or acts outside
207
77
  the service; `stopped` is set when the attempt is no longer this responder's. Both raise `Stopped` then."""
208
78
 
209
79
  def __init__(self, responder: "Responder", entry: Dict[str, Any]):
@@ -274,6 +144,9 @@ class Responder:
274
144
  #: The first pause under an operator's hold (DEC-0079); it doubles to HOLD_RETRY_MAX_SECONDS.
275
145
  self.hold_delay = POLL_SECONDS
276
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)
277
150
 
278
151
  # -- events -------------------------------------------------------------------------------------------------
279
152
  def _emit(self, kind: str, level: str, text: str, notify: bool = False, episode: Optional[str] = None, **value: Any) -> None:
@@ -433,13 +306,17 @@ class Responder:
433
306
  for entry in reconcile:
434
307
  if self.slots.get(entry["capability"], {}).get("state") == "held":
435
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)
436
312
 
437
313
  def _replace(self, capability: str, entries: List[Dict[str, Any]], generation: Any) -> None:
438
314
  if not _is_int(generation, 0):
439
315
  generation = 0
440
- args = {"idempotency": self._idem(self._key("replace")), "capability": capability, "expectedGeneration": generation,
441
- "transfer": {"requests": [e["request"] for e in entries][:64],
442
- "checkpoint": {"summary": "Resumed by the same service after a restart; no effect had begun."}}}
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."}}
443
320
  try:
444
321
  receipt = self._receipt("com.responder_replace", self._call("com.responder_replace", args))
445
322
  except BoardError as exc:
@@ -464,25 +341,74 @@ class Responder:
464
341
  "back a transferred attempt (DEC-0078). It stays held; nothing was done.", notify=True,
465
342
  episode=entry["request"], request=entry["request"], capability=capability)
466
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"]}
467
350
  entry.update(generation=item["attempt"]["generation"], attempt=item["attempt"]["id"], digest=item["attempt"]["digest"],
468
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"}
469
354
  self.checkpoint.put(entry)
470
- if self.current is None:
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)
471
386
  self._resume(entry)
387
+ else:
388
+ self._to_resume.pop(0)
472
389
 
473
- def _reconcile(self, entry: Dict[str, Any]) -> None:
474
- state = self._read_state(entry)
475
- if state != "outcome_unknown":
476
- if state in TERMINAL or state in ("queued", "claimed"):
477
- self.checkpoint.drop(entry["request"])
478
- return
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."""
479
392
  outcome = entry.get("outcome") if isinstance(entry.get("outcome"), dict) and entry["outcome"].get("observed") is True else None
480
393
  if outcome is None and self.observe is not None:
481
394
  try:
482
395
  outcome = self.observe(dict(entry))
483
396
  except Exception: # noqa: BLE001 - an observation that failed is no observation
484
397
  outcome = None
485
- if not outcome or outcome_problems(dict(outcome, observed=True), True) or outcome.get("observed") is not True:
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:
486
412
  entry["step"] = "unknown"
487
413
  self.checkpoint.put(entry)
488
414
  self._emit("board.reconcile.needed", "warn", "A request's outcome is unknown: the work may have happened. It stays "
@@ -517,13 +443,26 @@ class Responder:
517
443
  self._connect()
518
444
  self._tick()
519
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)
520
449
  return self.interval()
521
450
  except (BoardError, _hub.HubError) as exc:
522
451
  return self._fault(exc)
523
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
+
524
459
  def _fault(self, exc: Exception) -> float:
525
460
  code = getattr(exc, "code", "unreachable")
526
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
527
466
  self.wait_until = self.now() + wait
528
467
  if code in ("not_authorized", "authority_changed", "credential"):
529
468
  self.state = "not-authorized"
@@ -535,14 +474,15 @@ class Responder:
535
474
  self.state = "paused"
536
475
  self._emit("board.held", "info", "The operator holds new work on this Project; the responder waits and asks again "
537
476
  "with a growing pause.", episode="held", reason=code)
538
- elif code in ("no-epoch", "schema-unsupported", "checkpoint", "receipt-shape"):
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)
539
479
  self.state = "down"
540
480
  self._drop_session()
541
481
  self._emit("board.blocked", "error", "The responder stopped answering requests: %s" % getattr(exc, "message", str(exc)),
542
482
  notify=True, episode=code, reason=code)
543
483
  else:
544
484
  self.state = "down"
545
- if code in ("session-ended", "unreachable", "not-running", "not-a-hub", "hub-unavailable", "protocol"):
485
+ if code in ("session-ended", "unreachable", "not-running", "not-a-hub", "hub-unavailable", "protocol") and not keep:
546
486
  self._drop_session()
547
487
  self._emit("board.down", "warn", "Fabric's board cannot be reached; the responder tries again with a growing pause.",
548
488
  episode="down", reason=code)
@@ -550,6 +490,7 @@ class Responder:
550
490
 
551
491
  def _drop_session(self) -> None:
552
492
  work, self.current = self.current, None
493
+ self._to_resume = [] # the next session transfers them again
553
494
  if work is not None:
554
495
  entry = work.entry
555
496
  if work.done.is_set() and work.failure is None and not outcome_problems(work.outcome, bool(entry.get("effect"))):
@@ -582,6 +523,7 @@ class Responder:
582
523
  self._enroll(capability) # the holder's enroll renews its lease (no event); a lapsed one re-enrolls
583
524
  if self.current is None and not self.stopping:
584
525
  self._continue_lapsed()
526
+ self._resume_next()
585
527
  if self.current is None and not self.stopping:
586
528
  self._reconcile_pending()
587
529
  self._claim_next()
@@ -687,7 +629,7 @@ class Responder:
687
629
  entry["keys"]["accept"], entry["step"] = key, "accepting"
688
630
  self.checkpoint.put(entry)
689
631
  try:
690
- receipt = self._fenced("com.accept", entry, {"fence": self._fence(entry, key)})
632
+ receipt = self._fenced("com.accept", entry, key)
691
633
  except BoardError as exc:
692
634
  if exc.code != "held":
693
635
  raise
@@ -730,87 +672,102 @@ class Responder:
730
672
  message = self._call("com.get", {"message": entry["message"]})
731
673
  self._run(entry, BoardRequest(message, entry["capability"]))
732
674
 
733
- def _fenced(self, op: str, entry: Dict[str, Any], args: Dict[str, Any]) -> Optional[Dict[str, Any]]:
734
- """A fenced call. On `fenced`, `lease_expired` or `authority_changed` the attempt is lost: the work stops, and
735
- answers None (`authority_changed` raises after that, so the responder stops claiming)."""
736
- try:
737
- return self._receipt(op, self._call(op, args))
738
- except BoardError as exc:
739
- if exc.code in STOP_CODES or exc.code in ("revision_conflict", "invalid_transition", "digest_conflict"):
740
- self._lose(entry, exc.code)
741
- if exc.code == "authority_changed":
742
- raise
743
- return None
744
- raise
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
745
700
 
746
701
  def _lose(self, entry: Dict[str, Any], code: str) -> None:
747
- if self.current is not None and self.current.entry is entry:
748
- self.current.ctx.stopped.set()
749
- if entry.get("effect"):
750
- entry["step"] = "unknown"
751
- self.checkpoint.put(entry)
752
- self._emit("board.reconcile.needed", "warn", "The attempt was lost after its effect began; the outcome stays unknown "
753
- "until this service observes what happened.", notify=True, episode=entry["request"],
754
- request=entry["request"], capability=entry["capability"], reason=code)
755
- elif code == "lease_expired" and entry.get("step") not in ("claimed", "accepting"):
756
- # Our own lease lapsed on accepted work: the board holds it (C4). It continues only by a checkpoint transfer
757
- # to this responder, which the next tick asks for; nothing outside the service had begun.
758
- entry["step"] = "lapsed"
759
- self.checkpoint.put(entry)
760
- self._emit("board.attempt.lost", "warn", "This service's lease on an accepted request lapsed; it stopped working "
761
- "on it and takes it back by a transfer.", request=entry["request"], capability=entry["capability"], reason=code)
762
- else:
763
- self.checkpoint.drop(entry["request"])
764
- self._emit("board.attempt.lost", "warn", "The board took a request back from this service (%s); it stopped working "
765
- "on it, and nothing had been done outside the service." % code, request=entry["request"],
766
- capability=entry["capability"], reason=code)
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)
767
723
 
768
724
  def _renew(self, work: _Work) -> None:
769
725
  entry = work.entry
770
726
  if self.now() - (work.renewed_at or 0.0) < RENEW_SECONDS - 0.5:
771
727
  return
772
- receipt = self._fenced("com.renew", entry, {"fence": self._fence(entry, self._key("renew"))})
773
- if receipt is None:
728
+ if self._fenced("com.renew", entry, self._key("renew")) is None:
774
729
  self.current = None
775
730
  return
776
731
  work.renewed_at = self.now()
777
732
  self.slots.setdefault(entry["capability"], {})["renewed_at"] = self.now()
778
733
 
779
734
  def _progress(self, entry: Dict[str, Any], note: Optional[str], ctx: WorkContext) -> None:
780
- args: Dict[str, Any] = {"fence": self._fence(entry, self._key("progress"))}
781
- if note is not None:
782
- args["note"] = {"text": " ".join(str(note).split())[:SUMMARY_MAX] or "Working."}
783
- if self._fenced("com.progress", entry, args) is None:
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:
784
737
  raise Stopped()
785
738
 
786
739
  def _effect_begin(self, entry: Dict[str, Any], summary: Optional[str], ctx: WorkContext) -> None:
787
- key = entry["keys"].get("effect_begin") or self._key("effect")
788
- entry["keys"]["effect_begin"], entry["step"] = key, "effect-beginning"
789
- entry["effect"] = True # from here a lost attempt is an unknown outcome, never "nothing happened"
790
- self.checkpoint.put(entry)
791
- args: Dict[str, Any] = {"fence": self._fence(entry, key)}
792
- if summary:
793
- args["summary"] = " ".join(str(summary).split())[:SUMMARY_MAX]
794
- receipt = None
795
- lost, delay = 0, self.hold_delay
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
796
751
  while True: # a lost answer: the same key again tells whether it was stored
797
752
  try:
798
- receipt = self._fenced("com.effect_begin", entry, args)
753
+ receipt = self._fenced("com.effect_begin", entry, key, extra, begun)
799
754
  break
800
755
  except BoardError as exc:
801
756
  if exc.code == "held":
802
757
  # The operator holds this work (DEC-0079): the effect has not begun. Wait, keep the lease (the tick
803
758
  # renews it; com.renew is never held), and ask again with the same key.
804
- entry["effect"], entry["step"] = False, "effect-held"
805
- self.checkpoint.put(entry)
759
+ with self._lock:
760
+ entry["effect"], entry["step"] = False, "effect-held"
761
+ self.checkpoint.put(entry)
806
762
  self._emit("board.request.waiting", "info", "The operator holds this request before its effect: the "
807
763
  "service waits and does nothing outside it until the hold is lifted.", episode=entry["request"],
808
764
  request=entry["request"], capability=entry["capability"])
809
765
  if ctx.stopped.wait(delay):
810
766
  raise Stopped() from None
811
767
  delay = min(HOLD_RETRY_MAX_SECONDS, delay * 2)
812
- entry["effect"], entry["step"] = True, "effect-beginning"
813
- self.checkpoint.put(entry)
768
+ with self._lock:
769
+ entry["effect"], entry["step"] = True, "effect-beginning"
770
+ self.checkpoint.put(entry)
814
771
  continue
815
772
  if exc.code != "unreachable" or lost == 2:
816
773
  raise Stopped() from None
@@ -818,8 +775,6 @@ class Responder:
818
775
  time.sleep(lost)
819
776
  if receipt is None:
820
777
  raise Stopped()
821
- entry["revision"], entry["step"] = receipt["request"]["revision"], "in-progress"
822
- self.checkpoint.put(entry)
823
778
 
824
779
  def _finish(self, work: _Work) -> None:
825
780
  entry = work.entry
@@ -828,39 +783,64 @@ class Responder:
828
783
  return
829
784
  outcome = work.outcome
830
785
  if work.failure is not None:
831
- if entry.get("effect") or isinstance(work.failure, Stopped):
786
+ if entry.get("effect"):
787
+ self._settle_live(entry, "handler-failed")
788
+ return
789
+ if isinstance(work.failure, Stopped):
832
790
  self.current = None
833
- if entry.get("effect"):
834
- self._lose(entry, "handler-failed")
835
791
  return
836
792
  outcome = {"state": "failed_known", "observed": True,
837
793
  "summary": "The service could not do this (%s)." % work.failure.__class__.__name__}
838
794
  problems = outcome_problems(outcome, bool(entry.get("effect")))
839
795
  if problems:
840
- self.current = None
796
+ self._emit("board.outcome.invalid", "warn", "The handler's outcome was not sent: %s." % problems[0],
797
+ request=entry["request"], capability=entry["capability"])
841
798
  if entry.get("effect"):
842
- self._lose(entry, "observation_required" if any("observation_required" in p for p in problems) else "outcome")
799
+ self._settle_live(entry, "observation_required" if any("observation_required" in p for p in problems) else "outcome")
843
800
  else:
844
801
  entry["outcome"] = {"state": "failed_known", "observed": True, "summary": "The service answered in a form it cannot send."}
845
802
  self._complete(entry)
846
- self._emit("board.outcome.invalid", "warn", "The handler's outcome was not sent: %s." % problems[0],
847
- request=entry["request"], capability=entry["capability"])
848
803
  return
849
804
  entry["outcome"] = dict(outcome, observed=outcome.get("observed", not entry.get("effect")))
850
805
  self._complete(entry)
851
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
+
852
837
  def _complete(self, entry: Dict[str, Any]) -> None:
853
838
  outcome = entry["outcome"]
854
- reply = outcome.get("reply")
855
- if reply and not entry.get("replied"):
839
+ if outcome.get("reply") and not entry.get("replied"):
856
840
  key = entry["keys"].get("reply") or self._key("reply")
857
841
  entry["keys"]["reply"], entry["step"] = key, "replying"
858
842
  self.checkpoint.put(entry)
859
- body = {"text": reply["text"], **({"format": reply["format"]} if "format" in reply else {})}
860
- args: Dict[str, Any] = {"fence": self._fence(entry, key), "body": body}
861
- if reply.get("artifacts"):
862
- args["artifacts"] = reply["artifacts"]
863
- if self._fenced("com.reply", entry, args) is None:
843
+ if self._fenced("com.reply", entry, key, self._reply_extra(entry)) is None:
864
844
  self.current = None
865
845
  return
866
846
  entry["replied"] = True
@@ -869,7 +849,7 @@ class Responder:
869
849
  entry["keys"]["complete"], entry["step"] = key, "completing"
870
850
  self.checkpoint.put(entry)
871
851
  sent = {k: outcome[k] for k in ("state", "observed", "summary", "evidence") if k in outcome}
872
- receipt = self._fenced("com.complete", entry, {"fence": self._fence(entry, key), "outcome": sent})
852
+ receipt = self._fenced("com.complete", entry, key, {"outcome": sent})
873
853
  self.current = None
874
854
  if receipt is None:
875
855
  return