@passioncode-ai/passioncode 0.2.1 → 0.2.2
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 +12 -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/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 +23 -5
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +24 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +102 -0
- 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-board.mjs +144 -11
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +590 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.schema.json +631 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +140 -10
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +824 -0
- 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 +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +20 -0
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
- package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py
CHANGED
|
@@ -60,13 +60,21 @@ REFUSAL_CODES = frozenset(["invalid_arguments", "body_too_large", "not_authorize
|
|
|
60
60
|
"unsupported_capability", "unsupported_operation", "not_available", "idempotency_conflict",
|
|
61
61
|
"idempotency_window_expired", "capacity_exceeded", "digest_conflict", "revision_conflict",
|
|
62
62
|
"fenced", "lease_expired", "invalid_transition", "reconcile_required", "observation_required",
|
|
63
|
-
"cursor_reset_required", "immutable_participants"])
|
|
63
|
+
"cursor_reset_required", "immutable_participants", "held"])
|
|
64
64
|
#: On these the attempt is no longer ours: stop acting at once (C4).
|
|
65
65
|
STOP_CODES = frozenset(["fenced", "lease_expired", "authority_changed"])
|
|
66
66
|
REQUEST_STATES = ("queued", "claimed", "accepted", "in_progress", "completed", "failed_known", "cancelled", "expired",
|
|
67
67
|
"outcome_unknown")
|
|
68
68
|
EFFECT_STATES = ("not_started", "started", "succeeded_observed", "failed_observed", "unknown")
|
|
69
69
|
TERMINAL = frozenset(["completed", "failed_known", "cancelled", "expired"])
|
|
70
|
+
#: DEC-0079: what an operator's hold may refuse, by the scopes that refuse it. A tool not named here is never refused
|
|
71
|
+
#: `held` — it settles, reports on or keeps alive started work (com.renew, com.progress, com.complete, com.reconcile),
|
|
72
|
+
#: cancels or reads. The contract's HOLD_SCOPES.
|
|
73
|
+
HOLD_SCOPES = {"com.submit": ("thread",), "com.reply": ("thread",), "com.enroll": ("project", "slot"),
|
|
74
|
+
"com.responder_replace": ("project", "slot"), "com.claim": ("thread", "project", "slot"),
|
|
75
|
+
"com.accept": ("thread", "project", "slot"), "com.effect_begin": ("thread", "project", "slot")}
|
|
76
|
+
#: How long a held slot waits before it asks again: doubling from the poll interval, never past this.
|
|
77
|
+
HOLD_RETRY_MAX_SECONDS = 900.0
|
|
70
78
|
|
|
71
79
|
_OPAQUE = re.compile(r"[A-Za-z0-9_-]{8,128}")
|
|
72
80
|
_PROJECT = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,191}")
|
|
@@ -188,7 +196,7 @@ def receipt_problems(value: Any, op: Optional[str] = None) -> List[str]:
|
|
|
188
196
|
if "request" in value:
|
|
189
197
|
r = value["request"]
|
|
190
198
|
keys = ("id", "state", "effect", "revision")
|
|
191
|
-
if _closed(r, "/request", keys, keys, problems):
|
|
199
|
+
if _closed(r, "/request", keys, keys + ("settledBy",), problems):
|
|
192
200
|
if not (isinstance(r.get("id"), str) and _OPAQUE.fullmatch(r["id"])):
|
|
193
201
|
problems.append("/request/id is a board id")
|
|
194
202
|
if r.get("state") not in REQUEST_STATES:
|
|
@@ -197,6 +205,12 @@ def receipt_problems(value: Any, op: Optional[str] = None) -> List[str]:
|
|
|
197
205
|
problems.append("/request/effect is an effect state")
|
|
198
206
|
if not _is_int(r.get("revision"), 1):
|
|
199
207
|
problems.append("/request/revision is an integer ≥ 1")
|
|
208
|
+
if "settledBy" in r:
|
|
209
|
+
# DEC-0081: present only when the estate owner settled it; absent, the responder did.
|
|
210
|
+
if r["settledBy"] != "owner":
|
|
211
|
+
problems.append("/request/settledBy is owner")
|
|
212
|
+
if r.get("state") not in ("completed", "failed_known"):
|
|
213
|
+
problems.append("/request/settledBy names a settled request (completed or failed_known)")
|
|
200
214
|
if "slot" in value:
|
|
201
215
|
s = value["slot"]
|
|
202
216
|
keys = ("project", "capability", "generation", "leaseExpiresAt")
|
|
@@ -248,6 +262,8 @@ def refusal_problems(value: Any) -> List[str]:
|
|
|
248
262
|
problems.append("/error/message is 1 to 300 characters")
|
|
249
263
|
if not isinstance(e.get("retryable"), bool):
|
|
250
264
|
problems.append("/error/retryable is a boolean")
|
|
265
|
+
elif e.get("code") == "held" and e["retryable"] is not True:
|
|
266
|
+
problems.append("/error/retryable is true for held: the operator lifts a hold (DEC-0079)")
|
|
251
267
|
return problems
|
|
252
268
|
|
|
253
269
|
|
|
@@ -312,14 +328,48 @@ def directory_problems(page: Any) -> List[str]:
|
|
|
312
328
|
continue
|
|
313
329
|
for c, entry in enumerate(caps):
|
|
314
330
|
cat = "%s/capabilities/%d" % (at, c)
|
|
315
|
-
if _closed(entry, cat, ("capability", "responder"), ("capability", "responder"), problems):
|
|
331
|
+
if _closed(entry, cat, ("capability", "responder"), ("capability", "responder", "held"), problems):
|
|
316
332
|
if not (isinstance(entry.get("capability"), str) and _CAPABILITY.fullmatch(entry["capability"])):
|
|
317
333
|
problems.append("%s/capability is a capability name" % cat)
|
|
318
334
|
if entry.get("responder") not in ("active", "stale", "absent"):
|
|
319
335
|
problems.append("%s/responder is active, stale or absent" % cat)
|
|
336
|
+
if "held" in entry and not isinstance(entry["held"], bool):
|
|
337
|
+
problems.append("%s/held is a boolean (DEC-0079)" % cat)
|
|
320
338
|
return problems + ["%s %s" % (f["instancePath"], f["message"]) for f in directory_findings(page)]
|
|
321
339
|
|
|
322
340
|
|
|
341
|
+
def hold_refuses(tool: Any, target: Any, holds: Any) -> bool:
|
|
342
|
+
"""DEC-0079, the contract's ``holdRefuses``: True when a board refuses ``tool`` on ``target`` with ``held`` under
|
|
343
|
+
``holds``. ``target`` is what the board resolved (``thread``, ``project``, ``capability``, ``fenced``, ``repeat``);
|
|
344
|
+
``holds`` is ``{threads, projects, slots: [{project, capability}]}``. Ids compare as written. Malformed input never
|
|
345
|
+
raises: a member of the wrong type counts as absent, and an unknown tool is never refused ``held``."""
|
|
346
|
+
scopes = HOLD_SCOPES.get(tool) if isinstance(tool, str) else None
|
|
347
|
+
if not scopes or not isinstance(target, dict):
|
|
348
|
+
return False
|
|
349
|
+
if target.get("repeat") is True:
|
|
350
|
+
return False
|
|
351
|
+
if tool == "com.reply" and target.get("fenced") is True:
|
|
352
|
+
return False
|
|
353
|
+
holds = holds if isinstance(holds, dict) else {}
|
|
354
|
+
|
|
355
|
+
def text(value: Any) -> Optional[str]:
|
|
356
|
+
return value if isinstance(value, str) else None
|
|
357
|
+
|
|
358
|
+
def strings(value: Any) -> set:
|
|
359
|
+
return {item for item in value if isinstance(item, str)} if isinstance(value, list) else set()
|
|
360
|
+
|
|
361
|
+
thread, project, capability = text(target.get("thread")), text(target.get("project")), text(target.get("capability"))
|
|
362
|
+
if "thread" in scopes and thread is not None and thread in strings(holds.get("threads")):
|
|
363
|
+
return True
|
|
364
|
+
if "project" in scopes and project is not None and project in strings(holds.get("projects")):
|
|
365
|
+
return True
|
|
366
|
+
slots = holds.get("slots")
|
|
367
|
+
if "slot" in scopes and project is not None and capability is not None and isinstance(slots, list):
|
|
368
|
+
return any(isinstance(slot, dict) and slot.get("project") == project and slot.get("capability") == capability
|
|
369
|
+
for slot in slots)
|
|
370
|
+
return False
|
|
371
|
+
|
|
372
|
+
|
|
323
373
|
def find_project(page: Any, project_id: str, capability: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
|
324
374
|
"""Look a Project up in one `com.directory` page (read by a Project session — a board session is not served the
|
|
325
375
|
directory). The page is checked first: a page the contract would refuse, FAC-SEM-107 included, raises
|
|
@@ -630,6 +680,9 @@ class Responder:
|
|
|
630
680
|
self.claim_keys: Dict[str, str] = {}
|
|
631
681
|
self._said: Dict[str, str] = {}
|
|
632
682
|
self._rr = 0
|
|
683
|
+
#: The first pause under an operator's hold (DEC-0079); it doubles to HOLD_RETRY_MAX_SECONDS.
|
|
684
|
+
self.hold_delay = POLL_SECONDS
|
|
685
|
+
self._on_hold: set = set()
|
|
633
686
|
|
|
634
687
|
# -- events -------------------------------------------------------------------------------------------------
|
|
635
688
|
def _emit(self, kind: str, level: str, text: str, notify: bool = False, episode: Optional[str] = None, **value: Any) -> None:
|
|
@@ -712,6 +765,9 @@ class Responder:
|
|
|
712
765
|
try:
|
|
713
766
|
receipt = self._receipt("com.enroll", self._call("com.enroll", args))
|
|
714
767
|
except BoardError as exc:
|
|
768
|
+
if exc.code == "held":
|
|
769
|
+
self._pause(capability)
|
|
770
|
+
return
|
|
715
771
|
if exc.code == "fenced":
|
|
716
772
|
self.slots[capability] = {"state": "contested", "renewed_at": self.now()}
|
|
717
773
|
self._emit("board.slot.contested", "warn", "Another responder holds the %s slot on this Project; this service "
|
|
@@ -721,6 +777,25 @@ class Responder:
|
|
|
721
777
|
raise
|
|
722
778
|
self.slots[capability] = {"state": "held", "generation": receipt["slot"]["generation"], "renewed_at": self.now()}
|
|
723
779
|
self._said.pop("board.slot.contested", None)
|
|
780
|
+
self._lifted(capability)
|
|
781
|
+
|
|
782
|
+
def _pause(self, capability: str) -> None:
|
|
783
|
+
"""The operator holds this slot or its Project (DEC-0079): ask again later, the pause doubling each time, and
|
|
784
|
+
never act around the hold. Accepted work in progress continues; only new work waits."""
|
|
785
|
+
slot = self.slots.get(capability) or {}
|
|
786
|
+
pauses = slot.get("pauses", 0) + 1 if slot.get("state") == "paused" else 1
|
|
787
|
+
wait = min(HOLD_RETRY_MAX_SECONDS, self.hold_delay * 2 ** (pauses - 1))
|
|
788
|
+
self.slots[capability] = {"state": "paused", "pauses": pauses, "retry_at": self.now() + wait, "renewed_at": self.now()}
|
|
789
|
+
if capability not in self._on_hold: # once per hold, per capability
|
|
790
|
+
self._on_hold.add(capability)
|
|
791
|
+
self._emit("board.slot.held", "info", "The operator holds new work for %s on this Project; the service waits and "
|
|
792
|
+
"asks again later. Work already accepted continues." % capability, capability=capability)
|
|
793
|
+
|
|
794
|
+
def _lifted(self, capability: str) -> None:
|
|
795
|
+
if capability in self._on_hold:
|
|
796
|
+
self._on_hold.discard(capability)
|
|
797
|
+
self._emit("board.slot.released", "info", "The operator's hold on %s was lifted; the service takes new work "
|
|
798
|
+
"again." % capability, capability=capability)
|
|
724
799
|
|
|
725
800
|
# -- restart: reconcile and transfer -------------------------------------------------------------------------
|
|
726
801
|
def _read_state(self, entry: Dict[str, Any]) -> Optional[str]:
|
|
@@ -750,10 +825,17 @@ class Responder:
|
|
|
750
825
|
transfer.setdefault(entry["capability"], []).append(entry)
|
|
751
826
|
else: # in_progress (lost result once a new generation holds the slot) or outcome_unknown
|
|
752
827
|
reconcile.append(entry)
|
|
753
|
-
|
|
828
|
+
responders = [r for r in status.get("responders") or [] if isinstance(r, dict)]
|
|
829
|
+
generations = {r.get("capability"): r.get("generation", 0) for r in responders}
|
|
830
|
+
on_hold = {r.get("capability") for r in responders if r.get("held") is True} # DEC-0079
|
|
754
831
|
for capability in capabilities:
|
|
755
832
|
held = transfer.get(capability)
|
|
756
|
-
if
|
|
833
|
+
if capability in on_hold:
|
|
834
|
+
for entry in held or []:
|
|
835
|
+
entry["step"] = "lapsed" # taken back by a transfer once the hold is lifted
|
|
836
|
+
self.checkpoint.put(entry)
|
|
837
|
+
self._pause(capability)
|
|
838
|
+
elif held:
|
|
757
839
|
self._replace(capability, held, generations.get(capability, 0))
|
|
758
840
|
else:
|
|
759
841
|
self._enroll(capability)
|
|
@@ -767,8 +849,18 @@ class Responder:
|
|
|
767
849
|
args = {"idempotency": self._idem(self._key("replace")), "capability": capability, "expectedGeneration": generation,
|
|
768
850
|
"transfer": {"requests": [e["request"] for e in entries][:64],
|
|
769
851
|
"checkpoint": {"summary": "Resumed by the same service after a restart; no effect had begun."}}}
|
|
770
|
-
|
|
852
|
+
try:
|
|
853
|
+
receipt = self._receipt("com.responder_replace", self._call("com.responder_replace", args))
|
|
854
|
+
except BoardError as exc:
|
|
855
|
+
if exc.code != "held":
|
|
856
|
+
raise
|
|
857
|
+
for entry in entries:
|
|
858
|
+
entry["step"] = "lapsed" # nothing moved: taken back by a transfer once the hold is lifted
|
|
859
|
+
self.checkpoint.put(entry)
|
|
860
|
+
self._pause(capability)
|
|
861
|
+
return
|
|
771
862
|
self.slots[capability] = {"state": "held", "generation": receipt["slot"]["generation"], "renewed_at": self.now()}
|
|
863
|
+
self._lifted(capability)
|
|
772
864
|
moved = {item["request"]: item for item in receipt.get("transferred") or []}
|
|
773
865
|
for entry in entries:
|
|
774
866
|
item = moved.get(entry["request"])
|
|
@@ -848,6 +940,10 @@ class Responder:
|
|
|
848
940
|
self._emit("board.access.needed", "warn", "This service may not answer requests for this Project: the operator "
|
|
849
941
|
"has to allow it in Fabric (Agents & access). Nothing is claimed until then.", notify=True,
|
|
850
942
|
episode="access", reason=code)
|
|
943
|
+
elif code == "held":
|
|
944
|
+
self.state = "paused"
|
|
945
|
+
self._emit("board.held", "info", "The operator holds new work on this Project; the responder waits and asks again "
|
|
946
|
+
"with a growing pause.", episode="held", reason=code)
|
|
851
947
|
elif code in ("no-epoch", "schema-unsupported", "checkpoint", "receipt-shape"):
|
|
852
948
|
self.state = "down"
|
|
853
949
|
self._drop_session()
|
|
@@ -887,6 +983,10 @@ class Responder:
|
|
|
887
983
|
self._renew(work)
|
|
888
984
|
held_by_work = work.entry["capability"] if work is not None and self.current is not None else None
|
|
889
985
|
for capability, slot in list(self.slots.items()):
|
|
986
|
+
if slot.get("state") == "paused":
|
|
987
|
+
if capability != held_by_work and self.now() >= slot.get("retry_at", 0.0):
|
|
988
|
+
self._enroll(capability) # still held: a longer pause; lifted: the slot is ours again
|
|
989
|
+
continue
|
|
890
990
|
if capability != held_by_work and self.now() - slot.get("renewed_at", 0) >= RENEW_SECONDS - 0.5:
|
|
891
991
|
self._enroll(capability) # the holder's enroll renews its lease (no event); a lapsed one re-enrolls
|
|
892
992
|
if self.current is None and not self.stopping:
|
|
@@ -937,6 +1037,9 @@ class Responder:
|
|
|
937
1037
|
if exc.code == "fenced":
|
|
938
1038
|
self.slots[capability] = {"state": "contested", "renewed_at": self.now()}
|
|
939
1039
|
continue
|
|
1040
|
+
if exc.code == "held":
|
|
1041
|
+
self._pause(capability)
|
|
1042
|
+
continue
|
|
940
1043
|
raise
|
|
941
1044
|
if "request" not in answer:
|
|
942
1045
|
continue # nothing claimable; the same key polls again
|
|
@@ -992,7 +1095,18 @@ class Responder:
|
|
|
992
1095
|
key = entry["keys"].get("accept") or self._key("accept")
|
|
993
1096
|
entry["keys"]["accept"], entry["step"] = key, "accepting"
|
|
994
1097
|
self.checkpoint.put(entry)
|
|
995
|
-
|
|
1098
|
+
try:
|
|
1099
|
+
receipt = self._fenced("com.accept", entry, {"fence": self._fence(entry, key)})
|
|
1100
|
+
except BoardError as exc:
|
|
1101
|
+
if exc.code != "held":
|
|
1102
|
+
raise
|
|
1103
|
+
# A hold came between the claim and the accept (DEC-0079): nothing was done, the claim lapses and the request
|
|
1104
|
+
# waits on the board for the hold to be lifted.
|
|
1105
|
+
self.checkpoint.drop(entry["request"])
|
|
1106
|
+
self._emit("board.request.waiting", "info", "A claimed request was not accepted: the operator holds it. Nothing "
|
|
1107
|
+
"was done; it waits on the board.", request=entry["request"], capability=entry["capability"])
|
|
1108
|
+
self._pause(entry["capability"])
|
|
1109
|
+
return False
|
|
996
1110
|
if receipt is None:
|
|
997
1111
|
return False # the claim was lost before it was accepted; nothing was done
|
|
998
1112
|
entry["revision"], entry["step"] = receipt["request"]["revision"], "accepted"
|
|
@@ -1087,14 +1201,30 @@ class Responder:
|
|
|
1087
1201
|
if summary:
|
|
1088
1202
|
args["summary"] = " ".join(str(summary).split())[:SUMMARY_MAX]
|
|
1089
1203
|
receipt = None
|
|
1090
|
-
|
|
1204
|
+
lost, delay = 0, self.hold_delay
|
|
1205
|
+
while True: # a lost answer: the same key again tells whether it was stored
|
|
1091
1206
|
try:
|
|
1092
1207
|
receipt = self._fenced("com.effect_begin", entry, args)
|
|
1093
1208
|
break
|
|
1094
1209
|
except BoardError as exc:
|
|
1095
|
-
if exc.code
|
|
1210
|
+
if exc.code == "held":
|
|
1211
|
+
# The operator holds this work (DEC-0079): the effect has not begun. Wait, keep the lease (the tick
|
|
1212
|
+
# renews it; com.renew is never held), and ask again with the same key.
|
|
1213
|
+
entry["effect"], entry["step"] = False, "effect-held"
|
|
1214
|
+
self.checkpoint.put(entry)
|
|
1215
|
+
self._emit("board.request.waiting", "info", "The operator holds this request before its effect: the "
|
|
1216
|
+
"service waits and does nothing outside it until the hold is lifted.", episode=entry["request"],
|
|
1217
|
+
request=entry["request"], capability=entry["capability"])
|
|
1218
|
+
if ctx.stopped.wait(delay):
|
|
1219
|
+
raise Stopped() from None
|
|
1220
|
+
delay = min(HOLD_RETRY_MAX_SECONDS, delay * 2)
|
|
1221
|
+
entry["effect"], entry["step"] = True, "effect-beginning"
|
|
1222
|
+
self.checkpoint.put(entry)
|
|
1223
|
+
continue
|
|
1224
|
+
if exc.code != "unreachable" or lost == 2:
|
|
1096
1225
|
raise Stopped() from None
|
|
1097
|
-
|
|
1226
|
+
lost += 1
|
|
1227
|
+
time.sleep(lost)
|
|
1098
1228
|
if receipt is None:
|
|
1099
1229
|
raise Stopped()
|
|
1100
1230
|
entry["revision"], entry["step"] = receipt["request"]["revision"], "in-progress"
|