@passioncode-ai/fabric-agent-adapter 0.31.0 → 0.32.0
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 +54 -0
- package/README.md +1 -1
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +24 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +144 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +140 -10
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +1 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +20 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,59 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.32.0 - 2026-10-14
|
|
4
|
+
|
|
5
|
+
The adapter is pinned at contract `1636fa9`. The board kits now read the operator's hold (DEC-0079) and the owner's
|
|
6
|
+
settlement (DEC-0081) (FAA-42, contract CT-42). Before this, a Fabric serving either decision would have broken a
|
|
7
|
+
responder at this pin:
|
|
8
|
+
- a `held` refusal was read as a malformed answer, and the responder blocked;
|
|
9
|
+
- a receipt carrying `settledBy`, a directory page carrying `held`, and a held refusal were all refused as shapes the
|
|
10
|
+
contract does not name.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- **Repin** from `f00a768` to `1636fa9`. Every vendored vector is byte-identical except `comms-vectors.json`, which
|
|
15
|
+
gains 59 comms fixtures and one digest. `f00a768` stays a supported revision.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **`held` (DEC-0079)** in `fabric_board.py` / `fabric-board.mjs`:
|
|
20
|
+
- `held` is a known refusal code and must be `retryable: true`.
|
|
21
|
+
- A slot that `com.status` names `held`, or whose `com.enroll`, `com.claim` or `com.responder_replace` is refused
|
|
22
|
+
`held`, pauses. It is asked again after 15 s, the wait doubling up to 15 minutes. One `board.slot.held` event per
|
|
23
|
+
capability is emitted, then `board.slot.released`; no `board.down`.
|
|
24
|
+
- Accepted work continues: `com.renew`, `com.progress` and `com.complete` are never held.
|
|
25
|
+
- `com.effect_begin` refused `held` waits and asks again with the same key. Nothing outside the service starts until
|
|
26
|
+
it is answered.
|
|
27
|
+
- A hold between the claim and the accept does nothing; the request waits on the board.
|
|
28
|
+
- Accepted work found at restart under a hold is kept for a transfer once the hold is lifted.
|
|
29
|
+
- `hold_refuses` / `holdRefuses`: the contract's matrix, held to its 114 cases (`comms-hold-vectors.json`, vendored
|
|
30
|
+
and re-answered by the contract's own function).
|
|
31
|
+
- **`settledBy: "owner"` (DEC-0081)** read on a receipt's request (only `completed` or `failed_known`), and **`held`**
|
|
32
|
+
read on a directory page's capability and on `com.status` responders.
|
|
33
|
+
- **Docs:**
|
|
34
|
+
- `references/board.md`: "When the operator holds work".
|
|
35
|
+
- `working-with-fabric`'s `references/project-board.md`:
|
|
36
|
+
- `held` means wait and retry with the same key, never route around the hold;
|
|
37
|
+
- a structured finding (DEC-0080), sent only where `com.submit` takes `finding`, with an idempotency key of at
|
|
38
|
+
least 128 random bits and `repeated: true` read as the standing finding;
|
|
39
|
+
- `settledBy` read as the owner's attestation.
|
|
40
|
+
|
|
41
|
+
### Evidence
|
|
42
|
+
|
|
43
|
+
- Two scenario evals (`board-responder-held`, `board-held-and-finding`) were written before the prose.
|
|
44
|
+
- The fake board models holds from its own records.
|
|
45
|
+
- 13 Python hold cases (`HoldShapeTests`, `HoldTests`, two in `RestartTests`) and 5 Node ones (`test/fabric-board.test.mjs`, the cases after "DEC-0079 and DEC-0081").
|
|
46
|
+
- 30 planted defects (16 Python, 14 Node) were all caught, two of them only after a case was added for them.
|
|
47
|
+
|
|
48
|
+
## 0.31.1 - 2026-10-14
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
|
|
52
|
+
- `fabric_package.py` no longer carries a PEM private-key header as one literal string. It is still the line that
|
|
53
|
+
reads a PKCS#8 key file, and it reads files exactly as before. The PassionCode launcher's vendor step scans every
|
|
54
|
+
shipped file for that marker, and the literal made it refuse to vendor 0.31.0. A test now fails if any file under
|
|
55
|
+
`plugins/` or `bin/` carries the marker. Planted check: an unsplit marker fails the test.
|
|
56
|
+
|
|
3
57
|
## 0.31.0 - 2026-10-14
|
|
4
58
|
|
|
5
59
|
An agent is packed, signed and verified as a `fabric-package/1` folder (FAA-27a and FAA-36, `RM-28 pilot`; contract
|
package/README.md
CHANGED
|
@@ -251,7 +251,7 @@ Scaffolding is non-destructive by default. It writes only the paths declared in
|
|
|
251
251
|
The default pin for new bundles is [`fabric-contract.lock.json`](fabric-contract.lock.json):
|
|
252
252
|
|
|
253
253
|
- version: `0.1.0`
|
|
254
|
-
- commit: `
|
|
254
|
+
- commit: `1636fa97262f4a26e2086572d540c2ee701e0010`
|
|
255
255
|
|
|
256
256
|
Every other mention of the default contract revision — this section, the skills' metadata, the
|
|
257
257
|
skill card, `adapt_project.py` — must equal it; `python3 test/validate.py` fails a tree
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@passioncode-ai/fabric-agent-adapter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"description": "Create Fabric-compatible agents, run them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract. This package is the installer CLI.",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "python3 test/validate.py && python3 -m unittest discover -s test -q && node --test 'test/*.test.mjs'"
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "fabric-agent-adapter",
|
|
4
4
|
"displayName": "Fabric Agent Adapter",
|
|
5
5
|
"description": "Create new Fabric-compatible agents, build them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles; and teach a coding agent to work in a project through Fabric (fabric-mcp, fabric_whoami, claim and release), with Claude Code session hooks.",
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.32.0",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "PassionCode.ai",
|
|
9
9
|
"url": "https://passioncode.ai/"
|
|
@@ -5,9 +5,9 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
|
5
5
|
compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode.
|
|
6
6
|
metadata:
|
|
7
7
|
author: PassionCode.ai
|
|
8
|
-
version: "0.
|
|
8
|
+
version: "0.32.0"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
|
-
contract-commit: "
|
|
10
|
+
contract-commit: "1636fa97262f4a26e2086572d540c2ee701e0010"
|
|
11
11
|
---
|
|
12
12
|
|
|
13
13
|
# Adapting projects to Fabric
|
|
@@ -70,7 +70,7 @@ Use exactly:
|
|
|
70
70
|
|
|
71
71
|
- contract version `0.1.0`;
|
|
72
72
|
- repository `https://github.com/passioncode-ai/fabric-agent-contract`;
|
|
73
|
-
- commit `
|
|
73
|
+
- commit `1636fa97262f4a26e2086572d540c2ee701e0010`.
|
|
74
74
|
|
|
75
75
|
This is the default for new bundles. When checking an issued legacy bundle, preserve its
|
|
76
76
|
explicit supported lock and use that exact clean checkout. Never silently upgrade or
|
package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py
CHANGED
|
@@ -21,10 +21,10 @@ import fabric_secrets as _secrets # noqa: E402 - the contract's credential dete
|
|
|
21
21
|
|
|
22
22
|
CONTRACT_VERSION = "0.1.0"
|
|
23
23
|
CONTRACT_REPOSITORY = "https://github.com/passioncode-ai/fabric-agent-contract"
|
|
24
|
-
CONTRACT_COMMIT = "
|
|
24
|
+
CONTRACT_COMMIT = "1636fa97262f4a26e2086572d540c2ee701e0010"
|
|
25
25
|
# #region supported-contract-revisions — docs: README.md#contract-pin
|
|
26
26
|
# Only this declaration may name a legacy revision in live files.
|
|
27
|
-
SUPPORTED_CONTRACT_COMMITS = (CONTRACT_COMMIT, "a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b", "fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0", "b291bf09e0a9f9fd3c16cf540832cd021203b7d1", "0cacd08b0e95b6abf131eae1206b60792138bc3c", "2eb91c00e852d9c31d804310d38fad91589fcbac", "8e49fbd5fbbf230e32c1969cd24ac3c22422f0a5", "a83bec6b47776af688402c18efec507143061f68", "52da526d7cc1e063d74c5b520dbc03d0ace15246", "78b2da018d2c4bd9ac1bde1dc3b7f1a110a175f5", "623bf61358c339cb10297807b3f024b5d9f1f327", "23f9fda4c05f8a3852246ee98d4f2adf74ed0875", "9091d3d6606b0c4b5591a38c3674356a01fd7f55", "df55c8c54a23251342a7ee57ba95642b7eb39e61", "2ce392291c6668598d12cd38327e24696b5ca15c")
|
|
27
|
+
SUPPORTED_CONTRACT_COMMITS = (CONTRACT_COMMIT, "f00a7685e3861c27f7899c033fb850a145a1708f", "a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b", "fe55fc67cba1a9e34a736da7d148d4fe2a8fbfa0", "b291bf09e0a9f9fd3c16cf540832cd021203b7d1", "0cacd08b0e95b6abf131eae1206b60792138bc3c", "2eb91c00e852d9c31d804310d38fad91589fcbac", "8e49fbd5fbbf230e32c1969cd24ac3c22422f0a5", "a83bec6b47776af688402c18efec507143061f68", "52da526d7cc1e063d74c5b520dbc03d0ace15246", "78b2da018d2c4bd9ac1bde1dc3b7f1a110a175f5", "623bf61358c339cb10297807b3f024b5d9f1f327", "23f9fda4c05f8a3852246ee98d4f2adf74ed0875", "9091d3d6606b0c4b5591a38c3674356a01fd7f55", "df55c8c54a23251342a7ee57ba95642b7eb39e61", "2ce392291c6668598d12cd38327e24696b5ca15c")
|
|
28
28
|
# #endregion supported-contract-revisions
|
|
29
29
|
INTEROP_KEY = "https://fabric.passioncode.ai/agent-contract/extensions/interop/0.1"
|
|
30
30
|
MCP_REVISION = "2026-07-28"
|
|
@@ -18,10 +18,10 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
|
18
18
|
compatibility: Python 3.9+ or Node.js 20+ for the kits; the probe needs Python 3.9+. launchd steps are macOS-only; Linux services install a systemd user unit and Windows ones a per-user Scheduled Task with the kits' writers. Dashboard handoff optionally uses Fabric Dashboards MCP link/host_status/open; without it, report unresolved host capability. The contract checkout is optional.
|
|
19
19
|
metadata:
|
|
20
20
|
author: PassionCode.ai
|
|
21
|
-
version: "0.
|
|
21
|
+
version: "0.32.0"
|
|
22
22
|
contract-version: "0.1.0"
|
|
23
23
|
extension: "fabric-service/0.1"
|
|
24
|
-
extension-commit: "
|
|
24
|
+
extension-commit: "1636fa97262f4a26e2086572d540c2ee701e0010"
|
|
25
25
|
---
|
|
26
26
|
|
|
27
27
|
# Building Fabric services
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Answering a Project's requests on Fabric's project board
|
|
2
2
|
|
|
3
3
|
Read this when a service should answer requests other Projects post to its Project on Fabric's project board
|
|
4
|
-
(`fabric-project-comms/0.1`, contract DEC-0022, DEC-0073, DEC-0077, DEC-0078; Fabric ADR-0149). The kits do it with
|
|
4
|
+
(`fabric-project-comms/0.1`, contract DEC-0022, DEC-0073, DEC-0077, DEC-0078, DEC-0079, DEC-0081; Fabric ADR-0149). The kits do it with
|
|
5
5
|
two modules each: the hub door client [`fabric_hub.py`](../scripts/fabric_hub.py) /
|
|
6
6
|
[`fabric-hub.mjs`](../scripts/fabric-hub.mjs) and the responder [`fabric_board.py`](../scripts/fabric_board.py) /
|
|
7
7
|
[`fabric-board.mjs`](../scripts/fabric-board.mjs). A coding agent that only reads and writes the board is the other
|
|
@@ -15,6 +15,7 @@ skill's business (working-with-fabric, `references/project-board.md`).
|
|
|
15
15
|
- The request is data: the input gate
|
|
16
16
|
- Effects, observation, evidence and the reply
|
|
17
17
|
- When the board says stop, and restarts
|
|
18
|
+
- When the operator holds work (DEC-0079)
|
|
18
19
|
- What a board session may call
|
|
19
20
|
- Looking a Project up in the directory (FAC-SEM-107)
|
|
20
21
|
- Events, the probe and the live smoke
|
|
@@ -115,6 +116,28 @@ accepted ones go back to this responder with `com.responder_replace {expectedGen
|
|
|
115
116
|
contract has no `com.claim` read-back and `com.claim` never moves an accepted request. A known outcome is sent after the
|
|
116
117
|
transfer without running the handler again. `shutdown()` drains, then leaves the session (its DELETE releases the slot).
|
|
117
118
|
|
|
119
|
+
## When the operator holds work (DEC-0079)
|
|
120
|
+
|
|
121
|
+
The operator can hold a thread, a whole Project or one (Project, capability) slot from Fabric's own controls. While the
|
|
122
|
+
hold lasts, the board refuses **new** work in its scope with `held`, which is always `retryable: true`. It never
|
|
123
|
+
refuses a call that settles, reports on or keeps alive work already started (`com.renew`, `com.progress`,
|
|
124
|
+
`com.complete`, `com.reconcile`), a cancel or a read. `hold_refuses(tool, target, holds)` / `holdRefuses` is the
|
|
125
|
+
contract's matrix, held to its vectors (`test/fixtures/contract/comms-hold-vectors.json`).
|
|
126
|
+
|
|
127
|
+
| Refused `held` | What the kit does |
|
|
128
|
+
|---|---|
|
|
129
|
+
| `com.status` names the slot `held: true` at connect | the slot is not enrolled; it pauses |
|
|
130
|
+
| `com.enroll`, `com.claim`, `com.responder_replace` | the slot pauses: asked again after 15 s, then 30 s, doubling to 15 minutes; one `board.slot.held` event per capability, and `board.slot.released` when an enroll succeeds again |
|
|
131
|
+
| `com.accept` (the hold came after the claim) | nothing was done: the entry is dropped, the claim lapses and the request waits on the board (`board.request.waiting`); the slot pauses |
|
|
132
|
+
| `com.effect_begin` | the effect has not begun: the handler waits (same idempotency key, the pause doubling) while the tick keeps renewing the lease, then begins once the hold is lifted; `ctx.stopped` ends the wait |
|
|
133
|
+
| accepted work at restart under a hold | kept as `lapsed`, then taken back with `com.responder_replace` and a transfer once the hold is lifted — never claimed back |
|
|
134
|
+
|
|
135
|
+
A hold is not an outage: no `board.down`, no notify. Work already accepted keeps running and completes.
|
|
136
|
+
|
|
137
|
+
A receipt's request may carry `settledBy: "owner"` (DEC-0081): the estate owner settled an `outcome_unknown` request
|
|
138
|
+
whose responder was gone. Read it as the owner's attestation, not the responder's; it appears only on `completed` or
|
|
139
|
+
`failed_known`, and its absence means the responder settled it.
|
|
140
|
+
|
|
118
141
|
## What a board session may call
|
|
119
142
|
|
|
120
143
|
A board session (`fabric.board.join`) is served the respond scope only: `com.status`, `com.list`, `com.get`,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# fabric-service/0.1 — wire reference
|
|
2
2
|
|
|
3
|
-
Pinned to `fabric-agent-contract` commit `
|
|
3
|
+
Pinned to `fabric-agent-contract` commit `1636fa97262f4a26e2086572d540c2ee701e0010`
|
|
4
4
|
(`docs/specification/service.md`, DEC-0015; the usage report DEC-0021; Windows and Linux supervision
|
|
5
5
|
DEC-0032 and DEC-0033; the operator channel DEC-0034 and DEC-0035; the embedded dashboard DEC-0036;
|
|
6
6
|
delegation and the operator session's lifetime DEC-0039; the meter's call window DEC-0041; session handoff DEC-0042;
|
|
@@ -8,7 +8,7 @@ canonical forms DEC-0043; `degraded[].setup` and `run.mcp.stdio.scope` DEC-0044;
|
|
|
8
8
|
DEC-0045; runner and model profiles DEC-0046; the estate record DEC-0055; one copy under any supervisor and its marker,
|
|
9
9
|
the Windows token-file reader DEC-0057; the hub key the estate certifies DEC-0060; signing credentials DEC-0063; the
|
|
10
10
|
estate record's 60 s skew DEC-0066; an app's own descriptor DEC-0069; the descriptor for listings DEC-0061; identifier equality
|
|
11
|
-
by normal form DEC-0076; the project board's directory page DEC-0077 and a replacement's transferred attempts DEC-0078). The contract's schemas are normative;
|
|
11
|
+
by normal form DEC-0076; the project board's directory page DEC-0077 and a replacement's transferred attempts DEC-0078; an operator's hold DEC-0079, a structured finding DEC-0080 and the owner's settlement DEC-0081). The contract's schemas are normative;
|
|
12
12
|
this page is the working summary. Extension key:
|
|
13
13
|
`https://fabric.passioncode.ai/agent-contract/extensions/service/0.1`.
|
|
14
14
|
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs
CHANGED
|
@@ -39,11 +39,20 @@ export const RESPOND_TOOLS = Object.freeze(new Set(['com.status', 'com.list', 'c
|
|
|
39
39
|
export const REFUSAL_CODES = Object.freeze(new Set(['invalid_arguments', 'body_too_large', 'not_authorized', 'authority_changed',
|
|
40
40
|
'unsupported_capability', 'unsupported_operation', 'not_available', 'idempotency_conflict', 'idempotency_window_expired',
|
|
41
41
|
'capacity_exceeded', 'digest_conflict', 'revision_conflict', 'fenced', 'lease_expired', 'invalid_transition', 'reconcile_required',
|
|
42
|
-
'observation_required', 'cursor_reset_required', 'immutable_participants']));
|
|
42
|
+
'observation_required', 'cursor_reset_required', 'immutable_participants', 'held']));
|
|
43
43
|
export const STOP_CODES = Object.freeze(new Set(['fenced', 'lease_expired', 'authority_changed']));
|
|
44
44
|
const REQUEST_STATES = ['queued', 'claimed', 'accepted', 'in_progress', 'completed', 'failed_known', 'cancelled', 'expired', 'outcome_unknown'];
|
|
45
45
|
const EFFECT_STATES = ['not_started', 'started', 'succeeded_observed', 'failed_observed', 'unknown'];
|
|
46
46
|
const TERMINAL = new Set(['completed', 'failed_known', 'cancelled', 'expired']);
|
|
47
|
+
/**
|
|
48
|
+
* DEC-0079: what an operator's hold may refuse, by the scopes that refuse it. A tool not named here is never refused
|
|
49
|
+
* `held`: it settles, reports on or keeps alive started work, cancels or reads. The contract's HOLD_SCOPES.
|
|
50
|
+
*/
|
|
51
|
+
export const HOLD_SCOPES = Object.freeze({ 'com.submit': ['thread'], 'com.reply': ['thread'], 'com.enroll': ['project', 'slot'],
|
|
52
|
+
'com.responder_replace': ['project', 'slot'], 'com.claim': ['thread', 'project', 'slot'], 'com.accept': ['thread', 'project', 'slot'],
|
|
53
|
+
'com.effect_begin': ['thread', 'project', 'slot'] });
|
|
54
|
+
/** How long a held slot waits before it asks again: doubling from the poll interval, never past this. */
|
|
55
|
+
export const HOLD_RETRY_MAX_SECONDS = 900;
|
|
47
56
|
const OPAQUE = /^[A-Za-z0-9_-]{8,128}$/;
|
|
48
57
|
const PROJECT = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,191}$/;
|
|
49
58
|
const CAPABILITY = /^[a-z][a-z0-9._-]{1,127}$/;
|
|
@@ -138,11 +147,16 @@ export function receiptProblems(value, op) {
|
|
|
138
147
|
if ('request' in value) {
|
|
139
148
|
const r = value.request;
|
|
140
149
|
const keys = ['id', 'state', 'effect', 'revision'];
|
|
141
|
-
if (closed(r, '/request', keys, keys, problems)) {
|
|
150
|
+
if (closed(r, '/request', keys, [...keys, 'settledBy'], problems)) {
|
|
142
151
|
if (!str(r.id, OPAQUE)) problems.push('/request/id is a board id');
|
|
143
152
|
if (!REQUEST_STATES.includes(r.state)) problems.push('/request/state is a request state');
|
|
144
153
|
if (!EFFECT_STATES.includes(r.effect)) problems.push('/request/effect is an effect state');
|
|
145
154
|
if (!isInt(r.revision, 1)) problems.push('/request/revision is an integer ≥ 1');
|
|
155
|
+
if ('settledBy' in r) {
|
|
156
|
+
// DEC-0081: present only when the estate owner settled it; absent, the responder did.
|
|
157
|
+
if (r.settledBy !== 'owner') problems.push('/request/settledBy is owner');
|
|
158
|
+
if (!['completed', 'failed_known'].includes(r.state)) problems.push('/request/settledBy names a settled request (completed or failed_known)');
|
|
159
|
+
}
|
|
146
160
|
}
|
|
147
161
|
}
|
|
148
162
|
if ('slot' in value) {
|
|
@@ -187,6 +201,7 @@ export function refusalProblems(value) {
|
|
|
187
201
|
if (!REFUSAL_CODES.has(e.code)) problems.push('/error/code is a refusal code the contract names');
|
|
188
202
|
if (typeof e.message !== 'string' || e.message.length < 1 || e.message.length > 300) problems.push('/error/message is 1 to 300 characters');
|
|
189
203
|
if (typeof e.retryable !== 'boolean') problems.push('/error/retryable is a boolean');
|
|
204
|
+
else if (e.code === 'held' && e.retryable !== true) problems.push('/error/retryable is true for held: the operator lifts a hold (DEC-0079)');
|
|
190
205
|
}
|
|
191
206
|
}
|
|
192
207
|
return problems;
|
|
@@ -236,14 +251,40 @@ export function directoryProblems(page) {
|
|
|
236
251
|
}
|
|
237
252
|
project.capabilities.forEach((entry, c) => {
|
|
238
253
|
const cat = `${at}/capabilities/${c}`;
|
|
239
|
-
if (!closed(entry, cat, ['capability', 'responder'], ['capability', 'responder'], problems)) return;
|
|
254
|
+
if (!closed(entry, cat, ['capability', 'responder'], ['capability', 'responder', 'held'], problems)) return;
|
|
240
255
|
if (!str(entry.capability, CAPABILITY)) problems.push(`${cat}/capability is a capability name`);
|
|
241
256
|
if (!['active', 'stale', 'absent'].includes(entry.responder)) problems.push(`${cat}/responder is active, stale or absent`);
|
|
257
|
+
if ('held' in entry && typeof entry.held !== 'boolean') problems.push(`${cat}/held is a boolean (DEC-0079)`);
|
|
242
258
|
});
|
|
243
259
|
});
|
|
244
260
|
return [...problems, ...directoryFindings(page).map((f) => `${f.instancePath} ${f.message}`)];
|
|
245
261
|
}
|
|
246
262
|
|
|
263
|
+
/**
|
|
264
|
+
* DEC-0079, the contract's holdRefuses: true when a board refuses `tool` on `target` with `held` under `holds`. `target`
|
|
265
|
+
* is what the board resolved ({thread, project, capability, fenced, repeat}); `holds` is {threads, projects, slots:
|
|
266
|
+
* [{project, capability}]}. Ids compare as written; an inherited or mistyped member counts as absent; never throws.
|
|
267
|
+
*/
|
|
268
|
+
export function holdRefuses(tool, target, holds) {
|
|
269
|
+
const own = (value, key) => (isObj(value) && Object.hasOwn(value, key) ? value[key] : undefined);
|
|
270
|
+
const scopes = typeof tool === 'string' && Object.hasOwn(HOLD_SCOPES, tool) ? HOLD_SCOPES[tool] : undefined;
|
|
271
|
+
if (!scopes || !isObj(target)) return false;
|
|
272
|
+
if (own(target, 'repeat') === true) return false;
|
|
273
|
+
if (tool === 'com.reply' && own(target, 'fenced') === true) return false;
|
|
274
|
+
const text = (v) => (typeof v === 'string' ? v : undefined);
|
|
275
|
+
const strings = (v) => new Set(Array.isArray(v) ? v.filter((item) => typeof item === 'string') : []);
|
|
276
|
+
const thread = text(own(target, 'thread'));
|
|
277
|
+
const project = text(own(target, 'project'));
|
|
278
|
+
const capability = text(own(target, 'capability'));
|
|
279
|
+
if (scopes.includes('thread') && thread !== undefined && strings(own(holds, 'threads')).has(thread)) return true;
|
|
280
|
+
if (scopes.includes('project') && project !== undefined && strings(own(holds, 'projects')).has(project)) return true;
|
|
281
|
+
const slots = own(holds, 'slots');
|
|
282
|
+
if (scopes.includes('slot') && project !== undefined && capability !== undefined && Array.isArray(slots)) {
|
|
283
|
+
return slots.some((slot) => own(slot, 'project') === project && own(slot, 'capability') === capability);
|
|
284
|
+
}
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
|
|
247
288
|
/** Look a Project up in one com.directory page a Project session read; the page is checked first (FAC-SEM-107). */
|
|
248
289
|
export function findProject(page, projectId, capability) {
|
|
249
290
|
const problems = directoryProblems(page);
|
|
@@ -493,6 +534,9 @@ export class Responder {
|
|
|
493
534
|
this.said = {};
|
|
494
535
|
this.rr = 0;
|
|
495
536
|
this.reconcileAt = 0;
|
|
537
|
+
/** The first pause under an operator's hold (DEC-0079); it doubles to HOLD_RETRY_MAX_SECONDS. */
|
|
538
|
+
this.holdDelay = POLL_SECONDS;
|
|
539
|
+
this.onHold = new Set();
|
|
496
540
|
}
|
|
497
541
|
|
|
498
542
|
// -- events --
|
|
@@ -568,6 +612,10 @@ export class Responder {
|
|
|
568
612
|
try {
|
|
569
613
|
receipt = this.receipt('com.enroll', await this.call('com.enroll', args));
|
|
570
614
|
} catch (e) {
|
|
615
|
+
if (e.code === 'held') {
|
|
616
|
+
this.pause(capability);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
571
619
|
if (e.code === 'fenced') {
|
|
572
620
|
this.slots[capability] = { state: 'contested', renewedAt: this.now() };
|
|
573
621
|
this.emit('board.slot.contested', 'warn', `Another responder holds the ${capability} slot on this Project; this service waits for it to lapse and never takes it over by itself.`, { notify: true, episode: capability, capability });
|
|
@@ -577,6 +625,27 @@ export class Responder {
|
|
|
577
625
|
}
|
|
578
626
|
this.slots[capability] = { state: 'held', generation: receipt.slot.generation, renewedAt: this.now() };
|
|
579
627
|
delete this.said['board.slot.contested'];
|
|
628
|
+
this.lifted(capability);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* The operator holds this slot or its Project (DEC-0079): ask again later, the pause doubling each time, and never act
|
|
633
|
+
* around the hold. Accepted work in progress continues; only new work waits.
|
|
634
|
+
*/
|
|
635
|
+
pause(capability) {
|
|
636
|
+
const slot = this.slots[capability] ?? {};
|
|
637
|
+
const pauses = slot.state === 'paused' ? (slot.pauses ?? 0) + 1 : 1;
|
|
638
|
+
const wait = Math.min(HOLD_RETRY_MAX_SECONDS, this.holdDelay * 2 ** (pauses - 1));
|
|
639
|
+
this.slots[capability] = { state: 'paused', pauses, retryAt: this.now() + wait, renewedAt: this.now() };
|
|
640
|
+
if (!this.onHold.has(capability)) { // once per hold, per capability
|
|
641
|
+
this.onHold.add(capability);
|
|
642
|
+
this.emit('board.slot.held', 'info', `The operator holds new work for ${capability} on this Project; the service waits and asks again later. Work already accepted continues.`, { capability });
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
lifted(capability) {
|
|
647
|
+
if (!this.onHold.delete(capability)) return;
|
|
648
|
+
this.emit('board.slot.released', 'info', `The operator's hold on ${capability} was lifted; the service takes new work again.`, { capability });
|
|
580
649
|
}
|
|
581
650
|
|
|
582
651
|
// -- restart: reconcile and transfer --
|
|
@@ -607,9 +676,17 @@ export class Responder {
|
|
|
607
676
|
else if (state === 'accepted') (transfer[entry.capability] ??= []).push(entry);
|
|
608
677
|
else reconcile.push(entry);
|
|
609
678
|
}
|
|
610
|
-
const
|
|
679
|
+
const responders = (status.responders ?? []).filter(isObj);
|
|
680
|
+
const generations = Object.fromEntries(responders.map((r) => [r.capability, r.generation ?? 0]));
|
|
681
|
+
const onHold = new Set(responders.filter((r) => r.held === true).map((r) => r.capability)); // DEC-0079
|
|
611
682
|
for (const capability of capabilities) {
|
|
612
|
-
if (
|
|
683
|
+
if (onHold.has(capability)) {
|
|
684
|
+
for (const entry of transfer[capability] ?? []) {
|
|
685
|
+
entry.step = 'lapsed'; // taken back by a transfer once the hold is lifted
|
|
686
|
+
this.checkpoint.put(entry);
|
|
687
|
+
}
|
|
688
|
+
this.pause(capability);
|
|
689
|
+
} else if (transfer[capability]?.length) await this.replace(capability, transfer[capability], generations[capability] ?? 0);
|
|
613
690
|
else await this.enroll(capability);
|
|
614
691
|
}
|
|
615
692
|
for (const entry of reconcile) if (this.slots[entry.capability]?.state === 'held') await this.reconcile(entry);
|
|
@@ -618,8 +695,20 @@ export class Responder {
|
|
|
618
695
|
async replace(capability, entries, generation) {
|
|
619
696
|
const args = { idempotency: this.idem(this.key('replace')), capability, expectedGeneration: isInt(generation, 0) ? generation : 0,
|
|
620
697
|
transfer: { requests: entries.map((e) => e.request).slice(0, 64), checkpoint: { summary: 'Resumed by the same service after a restart; no effect had begun.' } } };
|
|
621
|
-
|
|
698
|
+
let receipt;
|
|
699
|
+
try {
|
|
700
|
+
receipt = this.receipt('com.responder_replace', await this.call('com.responder_replace', args));
|
|
701
|
+
} catch (e) {
|
|
702
|
+
if (e.code !== 'held') throw e;
|
|
703
|
+
for (const entry of entries) {
|
|
704
|
+
entry.step = 'lapsed'; // nothing moved: taken back by a transfer once the hold is lifted
|
|
705
|
+
this.checkpoint.put(entry);
|
|
706
|
+
}
|
|
707
|
+
this.pause(capability);
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
622
710
|
this.slots[capability] = { state: 'held', generation: receipt.slot.generation, renewedAt: this.now() };
|
|
711
|
+
this.lifted(capability);
|
|
623
712
|
const moved = new Map((receipt.transferred ?? []).map((item) => [item.request, item]));
|
|
624
713
|
for (const entry of entries) {
|
|
625
714
|
const item = moved.get(entry.request);
|
|
@@ -697,6 +786,9 @@ export class Responder {
|
|
|
697
786
|
this.state = 'not-authorized';
|
|
698
787
|
await this.dropSession();
|
|
699
788
|
this.emit('board.access.needed', 'warn', 'This service may not answer requests for this Project: the operator has to allow it in Fabric (Agents & access). Nothing is claimed until then.', { notify: true, episode: 'access', reason: code });
|
|
789
|
+
} else if (code === 'held') {
|
|
790
|
+
this.state = 'paused';
|
|
791
|
+
this.emit('board.held', 'info', 'The operator holds new work on this Project; the responder waits and asks again with a growing pause.', { episode: 'held', reason: code });
|
|
700
792
|
} else if (['no-epoch', 'schema-unsupported', 'checkpoint', 'receipt-shape'].includes(code)) {
|
|
701
793
|
this.state = 'down';
|
|
702
794
|
await this.dropSession();
|
|
@@ -736,6 +828,11 @@ export class Responder {
|
|
|
736
828
|
}
|
|
737
829
|
const heldByWork = work && this.current ? work.entry.capability : null;
|
|
738
830
|
for (const [capability, slot] of Object.entries(this.slots)) {
|
|
831
|
+
if (slot.state === 'paused') {
|
|
832
|
+
// still held: a longer pause; lifted: the slot is ours again
|
|
833
|
+
if (capability !== heldByWork && this.now() >= (slot.retryAt ?? 0)) await this.enroll(capability);
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
739
836
|
if (capability !== heldByWork && this.now() - (slot.renewedAt ?? 0) >= RENEW_SECONDS - 0.5) await this.enroll(capability);
|
|
740
837
|
}
|
|
741
838
|
if (!this.current && !this.stopping) await this.continueLapsed();
|
|
@@ -790,6 +887,10 @@ export class Responder {
|
|
|
790
887
|
this.slots[capability] = { state: 'contested', renewedAt: this.now() };
|
|
791
888
|
continue;
|
|
792
889
|
}
|
|
890
|
+
if (e.code === 'held') {
|
|
891
|
+
this.pause(capability);
|
|
892
|
+
continue;
|
|
893
|
+
}
|
|
793
894
|
throw e;
|
|
794
895
|
}
|
|
795
896
|
if (!('request' in answer)) continue; // nothing claimable; the same key polls again
|
|
@@ -843,7 +944,17 @@ export class Responder {
|
|
|
843
944
|
entry.keys.accept ??= this.key('accept');
|
|
844
945
|
entry.step = 'accepting';
|
|
845
946
|
this.checkpoint.put(entry);
|
|
846
|
-
|
|
947
|
+
let receipt;
|
|
948
|
+
try {
|
|
949
|
+
receipt = await this.fenced('com.accept', entry, { fence: this.fence(entry, entry.keys.accept) });
|
|
950
|
+
} catch (e) {
|
|
951
|
+
if (e.code !== 'held') throw e;
|
|
952
|
+
// A hold came between the claim and the accept (DEC-0079): nothing was done, the claim lapses and the request waits.
|
|
953
|
+
this.checkpoint.drop(entry.request);
|
|
954
|
+
this.emit('board.request.waiting', 'info', 'A claimed request was not accepted: the operator holds it. Nothing was done; it waits on the board.', { request: entry.request, capability: entry.capability });
|
|
955
|
+
this.pause(entry.capability);
|
|
956
|
+
return false;
|
|
957
|
+
}
|
|
847
958
|
if (!receipt) return false; // the claim was lost before it was accepted; nothing was done
|
|
848
959
|
entry.revision = receipt.request.revision;
|
|
849
960
|
entry.step = 'accepted';
|
|
@@ -916,20 +1027,42 @@ export class Responder {
|
|
|
916
1027
|
if (!(await this.fenced('com.progress', entry, args))) throw new Stopped();
|
|
917
1028
|
}
|
|
918
1029
|
|
|
919
|
-
async effectBegin(entry, summary) {
|
|
1030
|
+
async effectBegin(entry, summary, ctx) {
|
|
920
1031
|
entry.keys.effect_begin ??= this.key('effect');
|
|
921
1032
|
entry.step = 'effect-beginning';
|
|
922
1033
|
entry.effect = true; // from here a lost attempt is an unknown outcome, never "nothing happened"
|
|
923
1034
|
this.checkpoint.put(entry);
|
|
924
1035
|
const args = { fence: this.fence(entry, entry.keys.effect_begin), ...(summary ? { summary: clean(summary) } : {}) };
|
|
925
1036
|
let receipt = null;
|
|
926
|
-
|
|
1037
|
+
let lost = 0;
|
|
1038
|
+
let delay = this.holdDelay;
|
|
1039
|
+
for (;;) { // a lost answer: the same key again tells whether it was stored
|
|
927
1040
|
try {
|
|
928
1041
|
receipt = await this.fenced('com.effect_begin', entry, args);
|
|
929
1042
|
break;
|
|
930
1043
|
} catch (e) {
|
|
931
|
-
if (e.code
|
|
932
|
-
|
|
1044
|
+
if (e.code === 'held') {
|
|
1045
|
+
// The operator holds this work (DEC-0079): the effect has not begun. Wait, keep the lease (the tick renews it;
|
|
1046
|
+
// com.renew is never held), and ask again with the same key.
|
|
1047
|
+
entry.effect = false;
|
|
1048
|
+
entry.step = 'effect-held';
|
|
1049
|
+
this.checkpoint.put(entry);
|
|
1050
|
+
this.emit('board.request.waiting', 'info', 'The operator holds this request before its effect: the service waits and does nothing outside it until the hold is lifted.', { episode: entry.request, request: entry.request, capability: entry.capability });
|
|
1051
|
+
const until = Date.now() + delay * 1000;
|
|
1052
|
+
while (Date.now() < until) {
|
|
1053
|
+
if (ctx?.stopped) throw new Stopped();
|
|
1054
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(250, Math.max(1, until - Date.now()))));
|
|
1055
|
+
}
|
|
1056
|
+
if (ctx?.stopped) throw new Stopped();
|
|
1057
|
+
delay = Math.min(HOLD_RETRY_MAX_SECONDS, delay * 2);
|
|
1058
|
+
entry.effect = true;
|
|
1059
|
+
entry.step = 'effect-beginning';
|
|
1060
|
+
this.checkpoint.put(entry);
|
|
1061
|
+
continue;
|
|
1062
|
+
}
|
|
1063
|
+
if (e.code !== 'unreachable' || lost === 2) throw new Stopped();
|
|
1064
|
+
lost += 1;
|
|
1065
|
+
await new Promise((resolve) => setTimeout(resolve, lost * 1000));
|
|
933
1066
|
}
|
|
934
1067
|
}
|
|
935
1068
|
if (!receipt) throw new Stopped();
|
package/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"
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py
CHANGED
|
@@ -141,7 +141,7 @@ def read_signing_key(path: Any, kid: Optional[str] = None) -> Tuple[bytes, str]:
|
|
|
141
141
|
if kid is not None and named is not None and kid != named:
|
|
142
142
|
raise PackageError("--kid %s disagrees with the key's own kid %s." % (kid, named))
|
|
143
143
|
kid = kid if kid is not None else named
|
|
144
|
-
elif "-----BEGIN PRIVATE KEY-----" in text:
|
|
144
|
+
elif "-----BEGIN " + "PRIVATE KEY-----" in text: # split, so a secret scanner never reads this line as a key
|
|
145
145
|
body = "".join(line for line in text.splitlines() if line and not line.startswith("-----"))
|
|
146
146
|
try:
|
|
147
147
|
der = base64.b64decode(body, validate=True)
|
|
@@ -5,9 +5,9 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
|
5
5
|
compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode. Ships in one plugin with adapting-projects-to-fabric, whose scripts it reuses.
|
|
6
6
|
metadata:
|
|
7
7
|
author: PassionCode.ai
|
|
8
|
-
version: "0.
|
|
8
|
+
version: "0.32.0"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
|
-
contract-commit: "
|
|
10
|
+
contract-commit: "1636fa97262f4a26e2086572d540c2ee701e0010"
|
|
11
11
|
---
|
|
12
12
|
|
|
13
13
|
# Creating Fabric-compatible agents
|
|
@@ -123,7 +123,7 @@ python3 <plugin-dir>/skills/adapting-projects-to-fabric/scripts/adapt_project.py
|
|
|
123
123
|
The effect class is declared, never defaulted: ask the author what the agent does outside itself when the request
|
|
124
124
|
does not say. Cost presets are only for a run whose cost varies; their amounts come from the author or measured runs.
|
|
125
125
|
|
|
126
|
-
Pin exactly contract `0.1.0` at commit `
|
|
126
|
+
Pin exactly contract `0.1.0` at commit `1636fa97262f4a26e2086572d540c2ee701e0010` and
|
|
127
127
|
read the pinned guide before implementing protocol details. If this skill is installed
|
|
128
128
|
without its sibling, the scaffolder is absent: create the bundle by hand from the pinned
|
|
129
129
|
contract's `docs/guides/connecting-compatible-agents.md` and mark the structural check
|
|
@@ -46,6 +46,26 @@ capability (a plain question may be `kind: "message"`), in a new thread whose pa
|
|
|
46
46
|
SendMessage between sessions. agent-sync leases guard files; they carry no asks.
|
|
47
47
|
|
|
48
48
|
A request the board stored has not been carried out. Only its state (`completed`, `failed_known`) says what happened.
|
|
49
|
+
`settledBy: "owner"` on a request (DEC-0081) means the estate owner settled it after its responder was gone: read it as
|
|
50
|
+
the owner's attestation, not the responder's.
|
|
51
|
+
|
|
52
|
+
**`held` (DEC-0079).** The operator holds that thread, that Project or that capability, and the board refuses new
|
|
53
|
+
work there with `held`, which is always retryable. Tell the person what is held. Retry later, the pause growing, with
|
|
54
|
+
the **same idempotency key and arguments**. Never post the ask into another thread or to another Project to get around
|
|
55
|
+
the hold. Reads, cancels and replies to work already started are never held.
|
|
56
|
+
|
|
57
|
+
**A problem for another Project: a structured finding (DEC-0080).** Send one only when the running Fabric's
|
|
58
|
+
`com.submit` input schema lists `finding`; otherwise send a plain message. A finding carries:
|
|
59
|
+
|
|
60
|
+
- `severity`: `info`, `warning` or `blocking`;
|
|
61
|
+
- `consequence`: one sentence on what it breaks;
|
|
62
|
+
- `affects`: the backlog rows (`{repository, path, id}`) or host tasks it touches;
|
|
63
|
+
- a `dedupeKey` that stays the same for the same problem.
|
|
64
|
+
|
|
65
|
+
Its idempotency key is at least 128 random bits (for example 32 hex characters from a cryptographic generator), kept
|
|
66
|
+
on every retry. A receipt with `repeated: true` and a fresh key means that finding already stands on the board: its
|
|
67
|
+
thread may not be the one you named, and nothing new was posted. Never put a `{redacted: true}` item into a finding you
|
|
68
|
+
send.
|
|
49
69
|
|
|
50
70
|
## The project directory
|
|
51
71
|
|
|
@@ -60,7 +60,7 @@ WATCH_MAX_BACKOFF_S = 900
|
|
|
60
60
|
SESSION_ID = re.compile(r"[A-Za-z0-9._:-]{1,128}")
|
|
61
61
|
LOOPBACK_ORIGIN = re.compile(r"http://127\.0\.0\.1:(\d{1,5})")
|
|
62
62
|
LOCAL_ORIGIN = re.compile(r'"origin": "http://(?:127\.0\.0\.1|localhost)')
|
|
63
|
-
VERSION = "0.
|
|
63
|
+
VERSION = "0.32.0"
|
|
64
64
|
HOSTS = {"claude": "Claude Code", "codex": "Codex", "kimi": "Kimi Code"}
|
|
65
65
|
# A session Fabric launched. FABRIC_LAUNCHED_SESSION=1 is the marker Fabric is asked to set (need D6); the others are
|
|
66
66
|
# what Fabric already gives a launched session (fabric sessionBundle.ts): only their NAMES are looked at, never a value
|