@passioncode-ai/fabric-agent-adapter 0.40.0 → 0.42.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +160 -0
- package/README.md +9 -3
- package/SECURITY.md +27 -6
- package/bin/fabric-agent-adapter.js +71 -6
- package/package.json +2 -2
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +42 -66
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +28 -30
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +47 -7
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +230 -29
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +68 -24
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +196 -188
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +5 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +48 -14
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +34 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +157 -22
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +7 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +12 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +6 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +17 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +3 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +207 -227
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +4 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +0 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +52 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +101 -45
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +12 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +5 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +13 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +118 -71
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +272 -74
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +36 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +3 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +270 -35
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +36 -16
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +11 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +44 -22
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +54 -6
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +72 -13
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +28 -7
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +277 -49
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +10 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +2 -1
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
"""The board responder (DEC-0078, FAA-12b): a
|
|
2
|
-
|
|
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
|
-
|
|
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
|
-
#:
|
|
63
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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
|
|
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
|
-
|
|
442
|
-
|
|
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.
|
|
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
|
|
474
|
-
|
|
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
|
-
|
|
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,
|
|
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],
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
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
|
-
|
|
748
|
-
self.current.
|
|
749
|
-
|
|
750
|
-
entry
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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
|
-
|
|
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
|
-
|
|
781
|
-
if
|
|
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
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
if summary
|
|
793
|
-
|
|
794
|
-
receipt
|
|
795
|
-
|
|
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,
|
|
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
|
-
|
|
805
|
-
|
|
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
|
-
|
|
813
|
-
|
|
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")
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
852
|
+
receipt = self._fenced("com.complete", entry, key, {"outcome": sent})
|
|
873
853
|
self.current = None
|
|
874
854
|
if receipt is None:
|
|
875
855
|
return
|