@passioncode-ai/passioncode 0.2.42 → 0.2.44
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/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 +82 -11
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +2 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +24 -8
- 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 +2 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-actions.mjs +22 -9
- 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-delegation.mjs +6 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +14 -6
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_actions.py +23 -13
- 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_delegation.py +6 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +9 -5
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +16 -5
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +276 -12
- 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/working-with-fabric/SKILL.md +20 -13
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +4 -1
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +38 -9
- 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 +11 -1
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +358 -14
- 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"
|
|
@@ -818,9 +818,14 @@ def refusal_from_action(answer: Any) -> Optional[Dict[str, Any]]:
|
|
|
818
818
|
"""DL-19 (DEC-0121): the refusal a service answers for an action call that fabric_actions.action_decision refused,
|
|
819
819
|
or None when it took the call. operator-decision-required, decision-invalid and approval-required carry their
|
|
820
820
|
detail; human-step-required carries `humanStep` and `url` (AP-4). A `fault` is the service's own and is never
|
|
821
|
-
sent: the caller reads unknown-state with the detail `service-fault`, as for anything else that is not an answer
|
|
821
|
+
sent: the caller reads unknown-state with the detail `service-fault`, as for anything else that is not an answer;
|
|
822
|
+
but `mandate-capability-missing` (DEC-0141 MJ-1), a job's step no mandate can be read for, is answered as
|
|
823
|
+
operator-decision-required with that detail, so the job stays input_required."""
|
|
822
824
|
if isinstance(answer, dict) and answer.get("ok") is True:
|
|
823
825
|
return None
|
|
826
|
+
if isinstance(answer, dict) and answer.get("fault") == "mandate-capability-missing":
|
|
827
|
+
return _refuse("operator-decision-required", _ACTION_DETAILS["operator-decision-required"],
|
|
828
|
+
{"detail": "mandate-capability-missing"})
|
|
824
829
|
code = answer.get("code") if isinstance(answer, dict) and answer.get("ok") is False else None
|
|
825
830
|
if not isinstance(code, str) or code not in _ACTION_DETAILS:
|
|
826
831
|
return _refuse("unknown-state", "service-fault")
|
|
@@ -510,7 +510,8 @@ class CallContext:
|
|
|
510
510
|
carried (`_meta.mandate`) is read for it (job_step). Covered: answers the record (`via: mandate`, `source` when
|
|
511
511
|
the mandate carries one) and asks nothing; the job goes on as the operator's decision. Otherwise the job waits
|
|
512
512
|
input_required on `request`, its message naming the part not covered (or `message`), and answers None; the step
|
|
513
|
-
is kept so an answer's `_meta.mandate` that covers it is read when input_actions is on.
|
|
513
|
+
is kept so an answer's `_meta.mandate` that covers it is read when input_actions is on. Any fault job_step answers
|
|
514
|
+
(mandate-capability-missing, MJ-1, or the server clock's now-unreadable) goes to on_fault under the job's tool."""
|
|
514
515
|
if self.server.jobs is None:
|
|
515
516
|
raise InteropError("This server has no job store.")
|
|
516
517
|
step: Dict[str, Any] = {"message": None}
|
|
@@ -521,6 +522,7 @@ class CallContext:
|
|
|
521
522
|
spend_usd=spend_usd, project=project)
|
|
522
523
|
if step["covered"]:
|
|
523
524
|
return step["record"]
|
|
525
|
+
self.server._fault(self.tool, step) # the start hears it as the answer does (MJ-1)
|
|
524
526
|
kept = {"effect": effect, **({"spendUsd": spend_usd} if spend_usd is not None else {}),
|
|
525
527
|
**({"project": project} if project is not None else {})}
|
|
526
528
|
self.server.jobs.request_input(job_id, {key: request}, step["message"] or message, steps={key: kept})
|
|
@@ -644,7 +646,8 @@ class McpToolServer:
|
|
|
644
646
|
# approval the call presented, or None; elicited(tool, arguments, caller) is True only on the service's own
|
|
645
647
|
# record of an accepted elicitation. input_actions=True gates each answered input request as an action (D8).
|
|
646
648
|
# on_fault(tool, fault) hears a service fault the caller only sees as unknown-state (e.g. caller-missing: an
|
|
647
|
-
# action-gated server needs a tier, identify or authorize that names who calls)
|
|
649
|
+
# action-gated server needs a tier, identify or authorize that names who calls), but for mandate-capability-missing
|
|
650
|
+
# (MJ-1), which the caller reads as operator-decision-required naming it in data.detail.
|
|
648
651
|
# DEC-0125: agent_id is this service's own agent id; with it the server reads a call's _meta.mandate for a
|
|
649
652
|
# relayed action added with its effect (the chain must end in agent_id, and in the delegation's caller before
|
|
650
653
|
# it when authorize accepted a token); without it a mandate is not read, as before.
|
|
@@ -796,8 +799,8 @@ class McpToolServer:
|
|
|
796
799
|
action = {"id": key, "title": "Answer %s" % key, "tool": "fabric.job.get", "gate": "relayed"}
|
|
797
800
|
step, mandate = steps.get(key), {}
|
|
798
801
|
if self.agent_id is not None and isinstance(step, dict):
|
|
799
|
-
# A kept step's mandate is read as approval_step read it, under the job's own capability
|
|
800
|
-
# itself stays fabric.job.get, so it is readable
|
|
802
|
+
# A kept step's mandate is read as approval_step read it, under the job's own capability, which must
|
|
803
|
+
# be a capability name (MJ-1); the action itself stays fabric.job.get, so it is a readable action.
|
|
801
804
|
capability = self.jobs.private(job_id).get("capability")
|
|
802
805
|
agent = (verdict or {}).get("caller")
|
|
803
806
|
mandate = {"self_id": self.agent_id, "effect": step.get("effect"), "spend_usd": step.get("spendUsd"),
|
|
@@ -811,7 +814,8 @@ class McpToolServer:
|
|
|
811
814
|
return {"ok": True, "records": records}
|
|
812
815
|
|
|
813
816
|
def _fault(self, tool: str, gated: Dict[str, Any]) -> None:
|
|
814
|
-
"""Hand a service fault to on_fault; the caller saw
|
|
817
|
+
"""Hand a service fault to on_fault; the caller saw unknown-state (operator-decision-required naming it for
|
|
818
|
+
mandate-capability-missing, MJ-1). A hook that raises is the service's."""
|
|
815
819
|
if gated.get("fault") and self._on_fault is not None:
|
|
816
820
|
try:
|
|
817
821
|
self._on_fault(tool, str(gated["fault"]))
|
|
@@ -17,7 +17,7 @@ if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from
|
|
|
17
17
|
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
18
18
|
|
|
19
19
|
from fabric_base import ServiceError # noqa: E402
|
|
20
|
-
from fabric_actions import action_decision, decision_shape # noqa: E402 - DEC-0121
|
|
20
|
+
from fabric_actions import action_decision, decision_shape, job_capability # noqa: E402 - DEC-0121
|
|
21
21
|
from fabric_mandate import ( # noqa: E402 - DEC-0125, DEC-0126
|
|
22
22
|
EFFECT_CLASSES, OPERATOR_SOURCE_PROTOCOL, mandate_covers, mandate_problem, without_source)
|
|
23
23
|
|
|
@@ -319,6 +319,10 @@ def request_meta(traceparent: Optional[str] = None, decision: Any = None, mandat
|
|
|
319
319
|
return meta
|
|
320
320
|
|
|
321
321
|
|
|
322
|
+
_UNREADABLE_JOB_CAPABILITY = ("The operator's mandate cannot be read for this step (mandate-capability-missing): answer it, "
|
|
323
|
+
"or call fabric.job.get with a decision in _meta.decision.")
|
|
324
|
+
|
|
325
|
+
|
|
322
326
|
def job_step(mandate: Any, *, tier: Any, now: Any, self_id: Any, capability: Any, key: Any, effect: Any,
|
|
323
327
|
caller_agent: Any = None, spend_usd: Any = None, project: Any = None) -> Dict[str, Any]:
|
|
324
328
|
"""A job's own approval step under the mandate its start call carried (interop C3.3, DEC-0125 MD-3, MD-4;
|
|
@@ -330,6 +334,7 @@ def job_step(mandate: Any, *, tier: Any, now: Any, self_id: Any, capability: Any
|
|
|
330
334
|
mandate_covers' recordAs (`via: mandate`, the task, the chain and `source` when the mandate carries one).
|
|
331
335
|
- {"covered": False, "message", "detail"}: the step waits input_required, `message` its statusMessage naming the
|
|
332
336
|
part not covered, or saying the mandate expired or cannot be read; never the operator's words or `source`.
|
|
337
|
+
With `fault` mandate-capability-missing beside it when the job's capability cannot read the step (MJ-1).
|
|
333
338
|
- {"covered": False, "message": None}: no mandate, or a T2 caller's (MD-5): ask as before; with `fault` when the
|
|
334
339
|
service's own clock does not read."""
|
|
335
340
|
if mandate is None:
|
|
@@ -345,10 +350,13 @@ def job_step(mandate: Any, *, tier: Any, now: Any, self_id: Any, capability: Any
|
|
|
345
350
|
if isinstance(caller_agent, str) and caller_agent:
|
|
346
351
|
covers["callerAgent"] = caller_agent
|
|
347
352
|
answer = mandate_covers(covers)
|
|
353
|
+
if "ignored" in answer or "fault" in answer: # a T2 caller's mandate changes nothing (MD-5), first
|
|
354
|
+
return dict({"covered": False, "message": None}, **({"fault": answer["fault"]} if "fault" in answer else {}))
|
|
355
|
+
if not job_capability(capability): # MJ-1: read alike at the start and at the answer (action_gate)
|
|
356
|
+
return {"covered": False, "detail": "mandate-capability-missing", "fault": "mandate-capability-missing",
|
|
357
|
+
"message": _UNREADABLE_JOB_CAPABILITY}
|
|
348
358
|
if answer.get("ok") is True:
|
|
349
359
|
return {"covered": True, "record": answer["recordAs"]}
|
|
350
|
-
if "ignored" in answer or "fault" in answer:
|
|
351
|
-
return dict({"covered": False, "message": None}, **({"fault": answer["fault"]} if "fault" in answer else {}))
|
|
352
360
|
detail = answer["data"]["detail"]
|
|
353
361
|
if detail == "mandate-expired":
|
|
354
362
|
said = "The operator's mandate for this task expired before this step"
|
|
@@ -371,13 +379,14 @@ def action_gate(action: Any, *, tier: Any, caller: Any, meta: Any = None, target
|
|
|
371
379
|
record of an accepted elicitation), `approval` (verify_approval's accepted outcome), `action_digest`,
|
|
372
380
|
`human_step_url` and `now` (RFC 3339). Answers {ok: True, record, decision}, `decision` being the one taken as the
|
|
373
381
|
operator's (None otherwise), or {ok: False, refusal} in the DL-19 form (fabric_delegation.refusal_tool_result
|
|
374
|
-
sends it); a service's own fault is the refusal unknown-state with `fault` beside it for the service's log
|
|
382
|
+
sends it); a service's own fault is the refusal unknown-state with `fault` beside it for the service's log, but for
|
|
383
|
+
mandate-capability-missing (MJ-1): operator-decision-required naming it in data.detail, `fault` still beside it.
|
|
375
384
|
|
|
376
385
|
DEC-0125: a service that names its own agent id (`self_id`) reads the call's `_meta.mandate` when it carries no
|
|
377
386
|
decision, given `effect` (the action's effect class, required: without it the call is the service's fault
|
|
378
387
|
effect-missing), `caller_agent` (only an agent id the service authenticated: a delegation's caller, its own
|
|
379
388
|
per-agent credential; never a name the call carries), `spend_usd` (its own estimate), `project`, and `mandate_capability` (a job's kept step: the job's capability
|
|
380
|
-
the mandate is read under,
|
|
389
|
+
the mandate is read under, DEC-0141 MJ-1; unusable, the refusal is operator-decision-required). A covered call
|
|
381
390
|
answers {ok: True, record: {by: operator, via: mandate, task, chain, caller}, decision: None, mandate}; without
|
|
382
391
|
`self_id` the mandate is not read and the call is answered as before it existed."""
|
|
383
392
|
import fabric_delegation as _fd # noqa: E402 - beside this file: DL-19
|
|
@@ -401,6 +410,8 @@ def action_gate(action: Any, *, tier: Any, caller: Any, meta: Any = None, target
|
|
|
401
410
|
if isinstance(record, dict) and record.get("via") == "mandate":
|
|
402
411
|
out["mandate"] = inputs["mandate"]
|
|
403
412
|
return out
|
|
413
|
+
# MJ-1 (DEC-0141): refusal_from_action answers mandate-capability-missing as operator-decision-required; nothing is
|
|
414
|
+
# applied, so the job stays input_required.
|
|
404
415
|
out: Dict[str, Any] = {"ok": False, "refusal": _fd.refusal_from_action(answer)}
|
|
405
416
|
if "fault" in answer:
|
|
406
417
|
out["fault"] = answer["fault"]
|