@passioncode-ai/passioncode 0.2.41 → 0.2.43
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 +18 -0
- package/family.json +1 -1
- package/package.json +1 -1
- package/payload/.claude-plugin/marketplace.json +2 -2
- package/payload/manifest.json +8 -8
- package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/payload/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +4 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +8 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +85 -13
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +22 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +44 -6
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +48 -9
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +6 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +7 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-actions.mjs +30 -15
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +33 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +146 -7
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +5 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +17 -9
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate-rules.mjs +606 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +48 -525
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +43 -6
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +14 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-mandate.mjs +48 -15
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +4 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_actions.py +39 -16
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +34 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +149 -45
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +8 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +22 -11
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +86 -642
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate_rules.py +766 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +70 -12
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +59 -8
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_mandate.py +56 -16
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +4 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +314 -25
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +13 -1
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +4 -1
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +7 -0
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +75 -36
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +28 -14
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +42 -12
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +11 -0
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +12 -3
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +12 -2
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +360 -16
- package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
|
@@ -10,12 +10,7 @@ import secrets
|
|
|
10
10
|
import threading
|
|
11
11
|
import time
|
|
12
12
|
from pathlib import Path
|
|
13
|
-
from typing import Any
|
|
14
|
-
from typing import Callable
|
|
15
|
-
from typing import Dict
|
|
16
|
-
from typing import List
|
|
17
|
-
from typing import Optional
|
|
18
|
-
from typing import Sequence
|
|
13
|
+
from typing import Any, Callable, Dict, List, Optional, Sequence
|
|
19
14
|
|
|
20
15
|
import sys as _sys # noqa: E402
|
|
21
16
|
from pathlib import Path as _Path # noqa: E402
|
|
@@ -24,15 +19,7 @@ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from
|
|
|
24
19
|
import fabric_hub as _hub
|
|
25
20
|
import fabric_service as _fs
|
|
26
21
|
from fabric_board_shapes import ( # noqa: E402 - the kit (FAA-65)
|
|
27
|
-
BoardError,
|
|
28
|
-
LEASE_SECONDS,
|
|
29
|
-
REQUEST_STATES,
|
|
30
|
-
SUMMARY_MAX,
|
|
31
|
-
Stopped,
|
|
32
|
-
_CAPABILITY,
|
|
33
|
-
_is_int,
|
|
34
|
-
outcome_problems,
|
|
35
|
-
receipt_problems,
|
|
22
|
+
BoardError, LEASE_SECONDS, REQUEST_STATES, SUMMARY_MAX, Stopped, _CAPABILITY, _is_int, outcome_problems, receipt_problems,
|
|
36
23
|
)
|
|
37
24
|
from fabric_board_link import ( # noqa: E402,F401 - re-exported: callers import them from here since 0.40.0
|
|
38
25
|
BoardRequest,
|
|
@@ -43,33 +30,25 @@ from fabric_board_link import ( # noqa: E402,F401 - re-exported: callers import
|
|
|
43
30
|
|
|
44
31
|
RENEW_SECONDS = 20.0
|
|
45
32
|
|
|
46
|
-
|
|
47
33
|
POLL_SECONDS = 15.0
|
|
48
34
|
|
|
49
|
-
|
|
50
35
|
POLL_JITTER_SECONDS = 5.0
|
|
51
36
|
|
|
52
|
-
|
|
53
37
|
DRAIN_SECONDS = 10.0
|
|
54
38
|
|
|
55
|
-
|
|
56
39
|
EVENT_SCHEMA = "urn:fabric:schema:board-responder-event:1"
|
|
57
40
|
|
|
58
|
-
|
|
59
41
|
#: On these the attempt is no longer ours: stop acting at once (C4).
|
|
60
42
|
STOP_CODES = frozenset(["fenced", "lease_expired", "authority_changed"])
|
|
61
43
|
|
|
62
|
-
|
|
63
44
|
TERMINAL = frozenset(["completed", "failed_known", "cancelled", "expired"])
|
|
64
45
|
#: No longer this responder's to work on: back in the queue, claimed again, or answered by the target Project's
|
|
65
46
|
#: participant reply (DEC-0098, DEC-0102; not terminal, the requester may still cancel it, and nobody claims it).
|
|
66
47
|
NOT_OURS = frozenset(["queued", "claimed", "answered"])
|
|
67
48
|
|
|
68
|
-
|
|
69
49
|
#: A lost call is sent again this long before the lease it keeps would lapse (FAA-77, finding 38).
|
|
70
50
|
LEASE_MARGIN_SECONDS = 10.0
|
|
71
51
|
|
|
72
|
-
|
|
73
52
|
#: How long a held slot waits before it asks again: doubling from the poll interval, never past this.
|
|
74
53
|
HOLD_RETRY_MAX_SECONDS = 900.0
|
|
75
54
|
|
|
@@ -105,6 +84,8 @@ class _Work:
|
|
|
105
84
|
|
|
106
85
|
Handler = Callable[[BoardRequest, WorkContext], Dict[str, Any]]
|
|
107
86
|
|
|
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
|
+
|
|
108
89
|
|
|
109
90
|
class Responder:
|
|
110
91
|
"""One service's responder for one Project and one or more capabilities, one request at a time.
|
|
@@ -150,6 +131,7 @@ class Responder:
|
|
|
150
131
|
#: One fenced call at a time, and the entry edits beside them (FAA-77, finding 36).
|
|
151
132
|
self._lock = threading.RLock()
|
|
152
133
|
self._to_resume: List[str] = [] # transferred requests waiting until nothing runs (finding 39)
|
|
134
|
+
self._suspect: set = set() # capabilities a fenced call was refused on: com.status says whether they moved (FAA-155)
|
|
153
135
|
|
|
154
136
|
# -- events -------------------------------------------------------------------------------------------------
|
|
155
137
|
def _emit(self, kind: str, level: str, text: str, notify: bool = False, episode: Optional[str] = None, **value: Any) -> None:
|
|
@@ -230,12 +212,14 @@ class Responder:
|
|
|
230
212
|
def _enroll(self, capability: str) -> None:
|
|
231
213
|
args = {"idempotency": self._idem(self._key("enroll")), "capability": capability}
|
|
232
214
|
try:
|
|
233
|
-
receipt = self._receipt("com.enroll", self.
|
|
215
|
+
receipt = self._receipt("com.enroll", self._ask(capability, "com.enroll", args))
|
|
234
216
|
except BoardError as exc:
|
|
235
217
|
if exc.code == "held":
|
|
236
218
|
self._pause(capability)
|
|
237
219
|
return
|
|
238
220
|
if exc.code == "fenced":
|
|
221
|
+
if self._gone(capability):
|
|
222
|
+
return
|
|
239
223
|
self.slots[capability] = {"state": "contested", "renewed_at": self.now()}
|
|
240
224
|
self._emit("board.slot.contested", "warn", "Another responder holds the %s slot on this Project; this service "
|
|
241
225
|
"waits for it to lapse and never takes it over by itself." % capability, notify=True,
|
|
@@ -243,12 +227,12 @@ class Responder:
|
|
|
243
227
|
return
|
|
244
228
|
raise
|
|
245
229
|
self.slots[capability] = {"state": "held", "generation": receipt["slot"]["generation"], "renewed_at": self.now()}
|
|
230
|
+
self._held_at(capability, receipt["slot"]["generation"])
|
|
246
231
|
self._said.pop("board.slot.contested", None)
|
|
247
232
|
self._lifted(capability)
|
|
248
233
|
|
|
249
234
|
def _pause(self, capability: str) -> None:
|
|
250
|
-
"""The operator holds this slot or its Project (DEC-0079): ask again later, the pause doubling
|
|
251
|
-
never act around the hold. Accepted work in progress continues; only new work waits."""
|
|
235
|
+
"""The operator holds this slot or its Project (DEC-0079): ask again later, the pause doubling; work goes on."""
|
|
252
236
|
slot = self.slots.get(capability) or {}
|
|
253
237
|
pauses = slot.get("pauses", 0) + 1 if slot.get("state") == "paused" else 1
|
|
254
238
|
wait = min(HOLD_RETRY_MAX_SECONDS, self.hold_delay * 2 ** (pauses - 1))
|
|
@@ -264,6 +248,108 @@ class Responder:
|
|
|
264
248
|
self._emit("board.slot.released", "info", "The operator's hold on %s was lifted; the service takes new work "
|
|
265
249
|
"again." % capability, capability=capability)
|
|
266
250
|
|
|
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
|
+
|
|
267
353
|
# -- restart: reconcile and transfer -------------------------------------------------------------------------
|
|
268
354
|
def _read_state(self, entry: Dict[str, Any]) -> Optional[str]:
|
|
269
355
|
message = self._call("com.get", {"message": entry["message"]})
|
|
@@ -293,17 +379,20 @@ class Responder:
|
|
|
293
379
|
else: # in_progress (lost result once a new generation holds the slot) or outcome_unknown
|
|
294
380
|
reconcile.append(entry)
|
|
295
381
|
responders = [r for r in status.get("responders") or [] if isinstance(r, dict)]
|
|
296
|
-
generations = {r.get("capability"): r.get("generation", 0) for r in responders}
|
|
382
|
+
generations = {r.get("capability"): _whole(r.get("generation", 0)) for r in responders}
|
|
297
383
|
on_hold = {r.get("capability") for r in responders if r.get("held") is True} # DEC-0079
|
|
298
384
|
for capability in capabilities:
|
|
299
|
-
held = transfer.get(capability)
|
|
385
|
+
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)
|
|
300
388
|
if capability in on_hold:
|
|
301
389
|
for entry in held or []:
|
|
302
390
|
entry["step"] = "lapsed" # taken back by a transfer once the hold is lifted
|
|
303
391
|
self.checkpoint.put(entry)
|
|
304
392
|
self._pause(capability)
|
|
305
|
-
elif held:
|
|
306
|
-
self.
|
|
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))
|
|
307
396
|
else:
|
|
308
397
|
self._enroll(capability)
|
|
309
398
|
for entry in reconcile:
|
|
@@ -321,8 +410,10 @@ class Responder:
|
|
|
321
410
|
args["transfer"] = {"requests": [e["request"] for e in entries][:64],
|
|
322
411
|
"checkpoint": {"summary": "Resumed by the same service after a restart; no effect had begun."}}
|
|
323
412
|
try:
|
|
324
|
-
receipt = self._receipt("com.responder_replace", self.
|
|
413
|
+
receipt = self._receipt("com.responder_replace", self._ask(capability, "com.responder_replace", args))
|
|
325
414
|
except BoardError as exc:
|
|
415
|
+
if exc.code == "fenced" and (self.slots.get(capability) or {}).get("state") == "moved":
|
|
416
|
+
return # an earlier lost ask showed the slot moved: said once as board.slot.moved, no outage
|
|
326
417
|
if exc.code != "held":
|
|
327
418
|
raise
|
|
328
419
|
for entry in entries:
|
|
@@ -331,6 +422,7 @@ class Responder:
|
|
|
331
422
|
self._pause(capability)
|
|
332
423
|
return
|
|
333
424
|
self.slots[capability] = {"state": "held", "generation": receipt["slot"]["generation"], "renewed_at": self.now()}
|
|
425
|
+
self._held_at(capability, receipt["slot"]["generation"])
|
|
334
426
|
self._lifted(capability)
|
|
335
427
|
moved = {item["request"]: item for item in receipt.get("transferred") or []}
|
|
336
428
|
for entry in entries:
|
|
@@ -471,7 +563,8 @@ class Responder:
|
|
|
471
563
|
return min(renewed) + LEASE_SECONDS - self.now() if renewed and self.session is not None else 0.0
|
|
472
564
|
|
|
473
565
|
def _fault(self, exc: Exception) -> float:
|
|
474
|
-
code = getattr(exc, "code", "unreachable")
|
|
566
|
+
code, slots = getattr(exc, "code", "unreachable"), self.slots.values()
|
|
567
|
+
connecting = self.state == "connecting" and not any(s.get("state") == "held" for s in slots)
|
|
475
568
|
wait = self.backoff.next()
|
|
476
569
|
left = self._lease_left() - LEASE_MARGIN_SECONDS
|
|
477
570
|
keep = code == "unreachable" and left > 0 # finding 38: the lease holds, so the session and the work stay
|
|
@@ -496,15 +589,15 @@ class Responder:
|
|
|
496
589
|
notify=True, episode=code, reason=code)
|
|
497
590
|
else:
|
|
498
591
|
self.state = "down"
|
|
499
|
-
|
|
592
|
+
# In recovery with no slot held a refusal reconnects (seam 6); with some held they are served (seam 7).
|
|
593
|
+
if (connecting or code in ("session-ended", "unreachable", "not-running", "not-a-hub", "hub-unavailable", "protocol")) and not keep:
|
|
500
594
|
self._drop_session()
|
|
501
595
|
self._emit("board.down", "warn", "Fabric's board cannot be reached; the responder tries again with a growing pause.",
|
|
502
596
|
episode="down", reason=code)
|
|
503
597
|
return wait
|
|
504
598
|
|
|
505
599
|
def _drop_session(self) -> None:
|
|
506
|
-
work, self.current = self.current, None
|
|
507
|
-
self._to_resume = [] # the next session transfers them again
|
|
600
|
+
work, self.current, self._to_resume = self.current, None, [] # the next session transfers them again
|
|
508
601
|
if work is not None:
|
|
509
602
|
entry = work.entry
|
|
510
603
|
if work.done.is_set() and work.failure is None and not outcome_problems(work.outcome, bool(entry.get("effect"))):
|
|
@@ -528,7 +621,12 @@ class Responder:
|
|
|
528
621
|
else:
|
|
529
622
|
self._renew(work)
|
|
530
623
|
held_by_work = work.entry["capability"] if work is not None and self.current is not None else None
|
|
624
|
+
for capability in list(self._suspect): # kept until com.status answered, so it is asked before any enroll
|
|
625
|
+
self._gone(capability)
|
|
626
|
+
self._suspect.discard(capability)
|
|
531
627
|
for capability, slot in list(self.slots.items()):
|
|
628
|
+
if slot.get("state") == "moved":
|
|
629
|
+
continue # never enrolled again on its own, even when the new holder's lease lapses (FAA-155)
|
|
532
630
|
if slot.get("state") == "paused":
|
|
533
631
|
if capability != held_by_work and self.now() >= slot.get("retry_at", 0.0):
|
|
534
632
|
self._enroll(capability) # still held: a longer pause; lifted: the slot is ours again
|
|
@@ -557,8 +655,7 @@ class Responder:
|
|
|
557
655
|
self._replace(capability, live, self.slots[capability].get("generation", 0))
|
|
558
656
|
|
|
559
657
|
def _reconcile_pending(self) -> None:
|
|
560
|
-
"""Work whose outcome became unknown while running
|
|
561
|
-
again every five minutes: the slot's holder reconciles from what `observe` reads, or it stays unknown."""
|
|
658
|
+
"""Work whose outcome became unknown while running is reconciled from `observe` every five minutes, or stays so."""
|
|
562
659
|
if self.now() < getattr(self, "_reconcile_at", 0.0):
|
|
563
660
|
return
|
|
564
661
|
self._reconcile_at = self.now() + 300.0
|
|
@@ -575,6 +672,7 @@ class Responder:
|
|
|
575
672
|
answer = self._receipt("com.claim", self._call("com.claim", {"idempotency": self._idem(key), "capability": capability}))
|
|
576
673
|
except BoardError as exc:
|
|
577
674
|
if exc.code == "lease_expired":
|
|
675
|
+
self.slots[capability]["renewed_at"] = float("-inf") # lapsed: the enroll below is recorded first
|
|
578
676
|
self._enroll(capability) # the slot lapsed: enroll again (a new generation)
|
|
579
677
|
continue
|
|
580
678
|
if exc.code in ("idempotency_window_expired", "capacity_exceeded"):
|
|
@@ -582,7 +680,8 @@ class Responder:
|
|
|
582
680
|
self._status()
|
|
583
681
|
continue
|
|
584
682
|
if exc.code == "fenced":
|
|
585
|
-
|
|
683
|
+
if not self._gone(capability):
|
|
684
|
+
self.slots[capability] = {"state": "contested", "renewed_at": self.now()}
|
|
586
685
|
continue
|
|
587
686
|
if exc.code == "held":
|
|
588
687
|
self._pause(capability)
|
|
@@ -678,8 +777,7 @@ class Responder:
|
|
|
678
777
|
self._finish(work)
|
|
679
778
|
|
|
680
779
|
def _resume(self, entry: Dict[str, Any]) -> None:
|
|
681
|
-
"""A transferred request continues:
|
|
682
|
-
effect had begun, so nothing happened yet)."""
|
|
780
|
+
"""A transferred request continues: a known outcome is sent; else the handler runs again (no effect had begun)."""
|
|
683
781
|
if isinstance(entry.get("outcome"), dict):
|
|
684
782
|
self._complete(entry)
|
|
685
783
|
return
|
|
@@ -706,6 +804,8 @@ class Responder:
|
|
|
706
804
|
self._read_state(entry)
|
|
707
805
|
continue
|
|
708
806
|
if exc.code in STOP_CODES or exc.code in ("revision_conflict", "invalid_transition", "digest_conflict"):
|
|
807
|
+
if exc.code == "fenced":
|
|
808
|
+
self._suspect.add(entry["capability"]) # the next tick asks com.status whether the slot moved
|
|
709
809
|
self._lose(entry, exc.code)
|
|
710
810
|
if exc.code == "authority_changed":
|
|
711
811
|
raise
|
|
@@ -722,13 +822,16 @@ class Responder:
|
|
|
722
822
|
self._emit("board.reconcile.needed", "warn", "The attempt was lost after its effect began; the outcome stays "
|
|
723
823
|
"unknown until this service observes what happened.", notify=True, episode=entry["request"],
|
|
724
824
|
request=entry["request"], capability=entry["capability"], reason=code)
|
|
725
|
-
elif
|
|
726
|
-
|
|
727
|
-
#
|
|
825
|
+
elif entry.get("step") not in ("claimed", "accepting") and (code == "lease_expired" or (
|
|
826
|
+
code == "fenced" and entry.get("capability") in self.checkpoint.slots)):
|
|
827
|
+
# Accepted work whose lease lapsed, or fenced off a slot this responder held (moved, FAA-155): the board holds
|
|
828
|
+
# it (C4). It continues only by a checkpoint transfer to this responder while it holds the slot.
|
|
728
829
|
entry["step"] = "lapsed"
|
|
729
830
|
self.checkpoint.put(entry)
|
|
730
|
-
self._emit("board.attempt.lost", "warn", "This service's lease on an accepted request lapsed; it stopped working "
|
|
731
|
-
"on it and takes it back by a transfer."
|
|
831
|
+
self._emit("board.attempt.lost", "warn", ("This service's lease on an accepted request lapsed; it stopped working "
|
|
832
|
+
"on it and takes it back by a transfer." if code == "lease_expired" else "The board fenced an accepted "
|
|
833
|
+
"request away from this service; it stopped working on it, and nothing had been done outside it."),
|
|
834
|
+
request=entry["request"], capability=entry["capability"], reason=code)
|
|
732
835
|
else:
|
|
733
836
|
self.checkpoint.drop(entry["request"])
|
|
734
837
|
self._emit("board.attempt.lost", "warn", "The board took a request back from this service (%s); it stopped working "
|
|
@@ -893,4 +996,5 @@ class Responder:
|
|
|
893
996
|
if self.session is not None:
|
|
894
997
|
self.session.close()
|
|
895
998
|
self.session = None
|
|
999
|
+
self.checkpoint.leave(self.project) # the slots were released: no record of them can read as a move
|
|
896
1000
|
self.state = "stopped"
|
|
@@ -8,7 +8,14 @@ does; an organization's instance runs only under a placement of this machine's e
|
|
|
8
8
|
share grant for that context (FAC-SEM-111), inside the grant's window; a data folder never changes context (its
|
|
9
9
|
``fabric-context.json`` marker). A refusal exits 78 (EX_CONFIG) with nothing created. The facade ``fabric_service`` does
|
|
10
10
|
not re-export this module, so a service that does not run per context keeps the estate reader out of its closure:
|
|
11
|
-
``fabric_kit.py install <dir> --module fabric_context``. Docs: references/estate-record.md (Contexts).
|
|
11
|
+
``fabric_kit.py install <dir> --module fabric_context``. Docs: references/estate-record.md (Contexts).
|
|
12
|
+
|
|
13
|
+
An organization's instance whose context the record does not pin (``organizations``, DEC-0127) still starts: the helper
|
|
14
|
+
checks the placement and its share grant, not the pin (decision D5 of 0.52.0). Its tokens are refused instead:
|
|
15
|
+
``fabric_estate.hub_key_certified`` reads a context's hub keys only while the record pins that organization (DL-14a,
|
|
16
|
+
DEC-0128), so the service passes ``estate_context=info["context"]`` to its delegation gate. The contract lets Fabric
|
|
17
|
+
write ``organizations`` only once every reader is pinned at or after DEC-0127, so a refusal at start would stop every
|
|
18
|
+
organization instance on today's records."""
|
|
12
19
|
from __future__ import annotations
|
|
13
20
|
|
|
14
21
|
import hashlib
|
|
@@ -387,14 +387,16 @@ def _readable_clock(now: Any) -> bool:
|
|
|
387
387
|
|
|
388
388
|
|
|
389
389
|
def _estate_problem(estate: Any, kid: str, x: str, now: float, applied: Any = _MISSING,
|
|
390
|
-
revocations: Any = _MISSING) -> Optional[Tuple[str, str]]:
|
|
390
|
+
revocations: Any = _MISSING, context: Any = _MISSING) -> Optional[Tuple[str, str]]:
|
|
391
391
|
"""DL-14 row 4a (DEC-0055, DEC-0060), right after the signature: when the callee reads the estate record, the
|
|
392
392
|
token's kid and the published key must be certified by that revision. `estate` is a revision the caller verified
|
|
393
393
|
(fabric_estate.verify_estate_record) and its machine applied, or a callable returning it; only an omitted `estate`
|
|
394
394
|
skips the rule, and None (given, or returned by the callable) is an unreadable source, as Node's null is (D3).
|
|
395
395
|
`applied` is the highest revision the machine applied (`{"revision", "digest"?}`); `revocations` the revocation
|
|
396
|
-
delta's entries applied since (a list or a callable). An omitted one is absent; None is unreadable.
|
|
397
|
-
|
|
396
|
+
delta's entries applied since (a list or a callable). An omitted one is absent; None is unreadable. `context` is the
|
|
397
|
+
context this callee serves (DEC-0128): an organization's `org:<orgId>` reads that context's hub keys while the record
|
|
398
|
+
pins it; omitted or `personal`, the personal hub keys. A source that raises is unreadable, never a pass. Returns
|
|
399
|
+
("token" | "issuer", detail) or None."""
|
|
398
400
|
if estate is _MISSING:
|
|
399
401
|
return None
|
|
400
402
|
try:
|
|
@@ -404,8 +406,10 @@ def _estate_problem(estate: Any, kid: str, x: str, now: float, applied: Any = _M
|
|
|
404
406
|
return ("issuer", "estate-unreadable")
|
|
405
407
|
if applied is None: # the contract's hubKeyCertified refuses a null applied mark after its record checks: unreadable
|
|
406
408
|
return ("issuer", "estate-unreadable")
|
|
407
|
-
import fabric_estate # beside this file; imported
|
|
409
|
+
import fabric_estate # beside this file; imported where it is used
|
|
408
410
|
given = {} if applied is _MISSING else {"applied": applied}
|
|
411
|
+
if context is not _MISSING:
|
|
412
|
+
given["context"] = context
|
|
409
413
|
verdict = fabric_estate.hub_key_certified(record, kid, x, now, extra_revocations=extra, **given)
|
|
410
414
|
if verdict["certified"]:
|
|
411
415
|
return None
|
|
@@ -415,14 +419,17 @@ def _estate_problem(estate: Any, kid: str, x: str, now: float, applied: Any = _M
|
|
|
415
419
|
|
|
416
420
|
def verify_delegation(token: Any, *, audience: str, jwks: Any, now: float, capability: str, effect: str,
|
|
417
421
|
issuer: Optional[str], read_only: bool = False, estate: Any = _MISSING,
|
|
418
|
-
estate_applied: Any = _MISSING, estate_revocations: Any = _MISSING
|
|
422
|
+
estate_applied: Any = _MISSING, estate_revocations: Any = _MISSING,
|
|
423
|
+
estate_context: Any = _MISSING) -> Dict[str, Any]:
|
|
419
424
|
"""DL-13, in its fixed order: nothing about a token is believed before its signature is.
|
|
420
425
|
`now` is seconds since the epoch. Returns {ok: True, claims, principal, caller, chain, kid} or a refusal
|
|
421
426
|
{ok: False, code, detail[, data]} whose detail is a short machine word, never the token. `issuer` is required.
|
|
422
427
|
With `estate` (a verified estate revision, or a callable returning the current one), the token's kid must be one
|
|
423
428
|
the revision certifies for a machine (DL-14, DEC-0055, DEC-0060); `estate_applied` is the highest revision the
|
|
424
|
-
machine applied and `estate_revocations` the revocation delta's entries since.
|
|
425
|
-
|
|
429
|
+
machine applied and `estate_revocations` the revocation delta's entries since. `estate_context` is the context this
|
|
430
|
+
callee serves (DL-14a, DEC-0128): an organization's `org:<orgId>` verifies the token under that context's hub key,
|
|
431
|
+
and `issuer` is then that organization pin's context issuer (`organizations[].iss`); omitted or `personal`, the
|
|
432
|
+
personal hub key. A clock that is not a number of seconds is refused `unknown-state` `no-clock` before row 1."""
|
|
426
433
|
# Before row 1: every time row compares against the clock, and a comparison with NaN is always false.
|
|
427
434
|
if not _readable_clock(now):
|
|
428
435
|
return _refuse("unknown-state", "no-clock")
|
|
@@ -433,7 +440,7 @@ def verify_delegation(token: Any, *, audience: str, jwks: Any, now: float, capab
|
|
|
433
440
|
checked = _authentic(token, jwks, DELEGATION_TYP)
|
|
434
441
|
if "problem" in checked:
|
|
435
442
|
return _refuse("issuer-unavailable" if checked.get("issuer") else "unauthenticated", checked["problem"])
|
|
436
|
-
uncertified = _estate_problem(estate, checked["kid"], checked["x"], now, estate_applied, estate_revocations)
|
|
443
|
+
uncertified = _estate_problem(estate, checked["kid"], checked["x"], now, estate_applied, estate_revocations, estate_context)
|
|
437
444
|
if uncertified:
|
|
438
445
|
return _refuse("issuer-unavailable" if uncertified[0] == "issuer" else "unauthenticated", uncertified[1])
|
|
439
446
|
claims = checked["compact"]["payload"]
|
|
@@ -756,7 +763,8 @@ TIERS = ("T0", "T1", "T2")
|
|
|
756
763
|
|
|
757
764
|
def delegation_gate(*, audience: str, jwks: Any, effects: Mapping[str, str], issuer: str,
|
|
758
765
|
read_only: bool = False, clock: Callable[[], float] = time.time, estate: Any = _MISSING,
|
|
759
|
-
estate_applied: Any = _MISSING, estate_revocations: Any = _MISSING
|
|
766
|
+
estate_applied: Any = _MISSING, estate_revocations: Any = _MISSING,
|
|
767
|
+
estate_context: Any = _MISSING) -> Callable[..., Dict[str, Any]]:
|
|
760
768
|
"""A check for one tools/call: `gate(tool, headers, tier="T2")` answers the accepted delegation or a DL-19
|
|
761
769
|
refusal. `effects` maps each tool to its declared effect class (its manifest entry); a tool with none is
|
|
762
770
|
refused rather than guessed. `jwks` is a JwksCache or a key set; `issuer` (auth.delegation.issuer) is required.
|
|
@@ -767,7 +775,9 @@ def delegation_gate(*, audience: str, jwks: Any, effects: Mapping[str, str], iss
|
|
|
767
775
|
passes with no delegation ({ok: True, tier, principal: None, ...}). A present token is verified at every tier
|
|
768
776
|
and refused when invalid. Unknown tiers count as T2. `estate` (a callable returning the current verified estate
|
|
769
777
|
revision) makes the gate trust only kids that revision certifies (DL-14, DEC-0055, DEC-0060); `estate_applied` and
|
|
770
|
-
`estate_revocations` pass the applied mark and the revocation delta's entries through
|
|
778
|
+
`estate_revocations` pass the applied mark and the revocation delta's entries through, and `estate_context` the
|
|
779
|
+
context the service's instance runs in (DL-14a, DEC-0128: an organization's instance passes its `org:<orgId>`, and
|
|
780
|
+
`issuer` its pin's context issuer)."""
|
|
771
781
|
if not _is_string(issuer):
|
|
772
782
|
raise ValueError("the gate needs the issuer it trusts (auth.delegation.issuer)")
|
|
773
783
|
|
|
@@ -783,7 +793,8 @@ def delegation_gate(*, audience: str, jwks: Any, effects: Mapping[str, str], iss
|
|
|
783
793
|
keys = jwks.get(_kid_of(carried["token"])) if isinstance(jwks, JwksCache) else jwks
|
|
784
794
|
verdict = verify_delegation(carried["token"], audience=audience, jwks=keys, now=clock(), capability=tool,
|
|
785
795
|
effect=effect, issuer=issuer, read_only=read_only, estate=estate,
|
|
786
|
-
estate_applied=estate_applied, estate_revocations=estate_revocations
|
|
796
|
+
estate_applied=estate_applied, estate_revocations=estate_revocations,
|
|
797
|
+
estate_context=estate_context)
|
|
787
798
|
if verdict["ok"]:
|
|
788
799
|
verdict["tier"] = tier if tier in TIERS else "T2"
|
|
789
800
|
return verdict
|