@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.
Files changed (37) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/family.json +1 -1
  3. package/package.json +1 -1
  4. package/payload/.claude-plugin/marketplace.json +2 -2
  5. package/payload/manifest.json +8 -8
  6. package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/payload/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
  8. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
  9. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  10. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +8 -4
  11. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +82 -11
  12. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +2 -1
  13. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +24 -8
  14. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +1 -1
  15. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +2 -2
  16. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-actions.mjs +22 -9
  17. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +33 -3
  18. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +146 -7
  19. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +6 -1
  20. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +14 -6
  21. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_actions.py +23 -13
  22. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +34 -3
  23. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +149 -45
  24. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +6 -1
  25. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +9 -5
  26. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +16 -5
  27. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +276 -12
  28. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +13 -1
  29. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
  30. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +20 -13
  31. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +4 -1
  32. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +38 -9
  33. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +11 -0
  34. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +12 -3
  35. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +11 -1
  36. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +358 -14
  37. 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._call("com.enroll", args))
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 each time, and
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._replace(capability, held, generations.get(capability, 0))
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._call("com.responder_replace", args))
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
- if code in ("session-ended", "unreachable", "not-running", "not-a-hub", "hub-unavailable", "protocol") and not keep:
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 (its attempt was lost after the effect began) is tried
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
- self.slots[capability] = {"state": "contested", "renewed_at": self.now()}
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: an outcome already known is sent; otherwise the handler runs again (no
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 code == "lease_expired" and entry.get("step") not in ("claimed", "accepting"):
726
- # Our own lease lapsed on accepted work: the board holds it (C4). It continues only by a checkpoint transfer
727
- # to this responder, which the next tick asks for; nothing outside the service had begun.
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.", request=entry["request"], capability=entry["capability"], reason=code)
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; the action
800
- # itself stays fabric.job.get, so it is readable whatever the tool is named.
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 only unknown-state. A hook that raises is the service's."""
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, not the action's tool). A covered call
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"]