@passioncode-ai/fabric-agent-adapter 0.31.1 → 0.32.1

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 (18) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  5. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  7. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +2 -2
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +24 -1
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +58 -16
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +2 -2
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +144 -11
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +2 -1
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +140 -10
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +4 -3
  15. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
  16. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  17. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +24 -3
  18. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,76 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.32.1 - 2026-10-14
4
+
5
+ The `building-fabric-services` lifecycle reference is now the rule its readers follow (release iteration 1, N-12;
6
+ operator decision O6). It used to link the organization's private lifecycle contract and say "the contract wins and
7
+ this page is the defect", which left a public reader with a rule they could not open.
8
+
9
+ ### Changed
10
+
11
+ - **`references/lifecycle.md` is normative.** A new *The rules* section states the lifecycle itself: the supervisor
12
+ owns the process (never kill a service; start, stop and restart only through launchd, systemd, the Task Scheduler
13
+ or the node; state is read from the supervisor; restart no sooner than 10 s; off stays off), the descriptor, and
14
+ readiness through the well-known document. A table states each lifecycle rule the kits serve (LC-01, -02, -03, -04,
15
+ -05, -09, -11, -12, -14, -15, -21, -22) in one sentence, with the section and the kit function that serve it. It
16
+ links only public sources: the Fabric Agent Contract's service specification, *Lifecycle*. The `LC-` ids remain
17
+ for provenance.
18
+ - **`working-with-fabric` `references/project-board.md`** no longer says the private page "wins": it is the rule a
19
+ coding agent with this plugin follows, and cites the knowledge base for provenance.
20
+ - The two kits' lifecycle comments (`fabric_service.py`, `fabric-service.mjs`) point at the reference and the
21
+ public specification instead of the private page.
22
+
23
+ ### Added
24
+
25
+ - **`test/validate.py` refuses a skill page that defers to a private source**: a paragraph that names
26
+ `fabric-workspace` and says it "wins", "takes precedence", "prevails" or "is authoritative", or that this page "is
27
+ the defect". The validator's self-test plants one; `test/test_validate.py` covers the cases.
28
+
29
+ ## 0.32.0 - 2026-10-14
30
+
31
+ The adapter is pinned at contract `1636fa9`. The board kits now read the operator's hold (DEC-0079) and the owner's
32
+ settlement (DEC-0081) (FAA-42, contract CT-42). Before this, a Fabric serving either decision would have broken a
33
+ responder at this pin:
34
+ - a `held` refusal was read as a malformed answer, and the responder blocked;
35
+ - a receipt carrying `settledBy`, a directory page carrying `held`, and a held refusal were all refused as shapes the
36
+ contract does not name.
37
+
38
+ ### Changed
39
+
40
+ - **Repin** from `f00a768` to `1636fa9`. Every vendored vector is byte-identical except `comms-vectors.json`, which
41
+ gains 59 comms fixtures and one digest. `f00a768` stays a supported revision.
42
+
43
+ ### Added
44
+
45
+ - **`held` (DEC-0079)** in `fabric_board.py` / `fabric-board.mjs`:
46
+ - `held` is a known refusal code and must be `retryable: true`.
47
+ - A slot that `com.status` names `held`, or whose `com.enroll`, `com.claim` or `com.responder_replace` is refused
48
+ `held`, pauses. It is asked again after 15 s, the wait doubling up to 15 minutes. One `board.slot.held` event per
49
+ capability is emitted, then `board.slot.released`; no `board.down`.
50
+ - Accepted work continues: `com.renew`, `com.progress` and `com.complete` are never held.
51
+ - `com.effect_begin` refused `held` waits and asks again with the same key. Nothing outside the service starts until
52
+ it is answered.
53
+ - A hold between the claim and the accept does nothing; the request waits on the board.
54
+ - Accepted work found at restart under a hold is kept for a transfer once the hold is lifted.
55
+ - `hold_refuses` / `holdRefuses`: the contract's matrix, held to its 114 cases (`comms-hold-vectors.json`, vendored
56
+ and re-answered by the contract's own function).
57
+ - **`settledBy: "owner"` (DEC-0081)** read on a receipt's request (only `completed` or `failed_known`), and **`held`**
58
+ read on a directory page's capability and on `com.status` responders.
59
+ - **Docs:**
60
+ - `references/board.md`: "When the operator holds work".
61
+ - `working-with-fabric`'s `references/project-board.md`:
62
+ - `held` means wait and retry with the same key, never route around the hold;
63
+ - a structured finding (DEC-0080), sent only where `com.submit` takes `finding`, with an idempotency key of at
64
+ least 128 random bits and `repeated: true` read as the standing finding;
65
+ - `settledBy` read as the owner's attestation.
66
+
67
+ ### Evidence
68
+
69
+ - Two scenario evals (`board-responder-held`, `board-held-and-finding`) were written before the prose.
70
+ - The fake board models holds from its own records.
71
+ - 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").
72
+ - 30 planted defects (16 Python, 14 Node) were all caught, two of them only after a case was added for them.
73
+
3
74
  ## 0.31.1 - 2026-10-14
4
75
 
5
76
  ### Fixed
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: `f00a7685e3861c27f7899c033fb850a145a1708f`
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.31.1",
3
+ "version": "0.32.1",
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.31.1",
6
+ "version": "0.32.1",
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.31.1"
8
+ version: "0.32.1"
9
9
  contract-version: "0.1.0"
10
- contract-commit: "f00a7685e3861c27f7899c033fb850a145a1708f"
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 `f00a7685e3861c27f7899c033fb850a145a1708f`.
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
@@ -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 = "f00a7685e3861c27f7899c033fb850a145a1708f"
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.31.1"
21
+ version: "0.32.1"
22
22
  contract-version: "0.1.0"
23
23
  extension: "fabric-service/0.1"
24
- extension-commit: "f00a7685e3861c27f7899c033fb850a145a1708f"
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,31 +1,73 @@
1
1
  # Lifecycle — supervisor, install, release layout, state
2
2
 
3
- This page implements the organization's
4
- [product lifecycle contract](https://github.com/passioncode-ai/fabric-workspace/blob/main/knowledge/lifecycle.md)
5
- (rules LC-01…LC-15) for `fabric-service/0.1` services. Where the two differ, the contract
6
- wins and this page is the defect. The rule each section serves:
7
-
8
- | Contract rule | Here | Kit (Python / Node) |
9
- |---|---|---|
10
- | LC-01 quit reaches exit within a deadline | [Shutdown](#shutdown) | `Drain` / `Drain` |
11
- | LC-03 a job is bounded and exclusive | [one copy under a supervisor](#one-copy-under-a-supervisor) | `hold_single_instance` / `holdSingleInstance` |
12
- | LC-09 the footprint is declared | the descriptor and the plist, unit or task; the service's own `AGENTS.md` names its label (unit, task) and port | `write_descriptor`, `launchd_plist`, `systemd_unit` / `systemdUnit`, `task_xml` / `taskXml` |
13
- | LC-11 previous release kept, older pruned | [Install, upgrade, uninstall](#install-upgrade-uninstall) | `prune_releases` / `pruneReleases` |
14
- | LC-12 every file is bounded | [Logs](#logs) | `RotatingLog`, `cap_stdout_log` / `RotatingLog`, `capStdoutLog` |
15
- | LC-14 operator intent wins; uninstall is symmetric | [Install, upgrade, uninstall](#install-upgrade-uninstall), [Linux and Windows](#linux-and-windows) | `launchd_install` / `launchdInstall`, `launchd_uninstall` / `launchdUninstall`; `systemd_install` / `systemdInstall`, `task_install` / `taskInstall` and their uninstalls |
16
- | LC-15 builds clean up after themselves | release pruning runs inside the installer, not later | `prune_releases` / `pruneReleases` |
3
+ **This page is the rule** for anyone who builds, installs or runs a `fabric-service/0.1` service with this
4
+ skill. Everything you must follow is stated here or in the public Fabric Agent Contract's
5
+ [service specification, *Lifecycle*](https://github.com/passioncode-ai/fabric-agent-contract/blob/main/docs/specification/service.md#lifecycle), which this page
6
+ implements and links; where the two disagree, the specification is right and this page is the defect (report it in
7
+ this repository). Inside
8
+ PassionCode.ai the same rules make up the organization's product lifecycle contract, kept in its knowledge base. The
9
+ `LC-` ids below are that contract's, given for provenance only: nothing behind them is needed to follow this page.
17
10
 
18
11
  ## Contents
19
12
 
20
- - [launchd plist](#launchd-plist-generate-it-with-launchd_plist)
13
+ - [The rules](#the-rules)
14
+ - [launchd plist](#launchd-plist-generate-it-with-launchd_plist-or-launchdplist-both-kits-write-the-same-bytes)
21
15
  - [Install, upgrade, uninstall](#install-upgrade-uninstall)
22
16
  - [One copy under a supervisor](#one-copy-under-a-supervisor)
17
+ - [An app's own descriptor and the node](#an-apps-own-descriptor-and-the-node)
23
18
  - [Directories](#directories)
24
19
  - [Logs](#logs)
25
20
  - [Shutdown](#shutdown)
26
21
  - [Being managed by a host lifecycle broker](#being-managed-by-a-host-lifecycle-broker)
27
22
  - [Linux and Windows](#linux-and-windows)
28
23
 
24
+ ## The rules
25
+
26
+ **The supervisor owns the process.** A local service runs under exactly one supervisor, named in its descriptor's
27
+ `lifecycle.manager`: `launchd` (macOS), `systemd` (Linux), `task-scheduler` (Windows) or `node` (Project
28
+ Observatory's node supervising its children). An app a person starts says `person` and has no supervisor; where a host
29
+ runs a lifecycle broker, the broker is the one that quits it ([below](#being-managed-by-a-host-lifecycle-broker)).
30
+ Starting, stopping and restarting go through the supervisor and nothing else:
31
+
32
+ - **Never kill a service.** A host, an agent, an installer or the service's own CLI never starts a service process
33
+ directly and never kills or signals one. It asks the supervisor: on macOS `bootout` + `disable` to stop and keep
34
+ off, `enable` + `bootstrap` to start, `kickstart -k` to restart; [Linux and Windows](#linux-and-windows) have
35
+ their own verbs; a node's child is started, stopped and restarted only through the node.
36
+ - **State comes from the supervisor**: `launchctl print`, `systemctl --user show`, the task's state and last run,
37
+ or the node's record. Compare the supervisor's pid with the well-known `process.pid` to see a second copy.
38
+ - **Restart is the supervisor's.** A service that exits is started again, no sooner than 10 s after its last start
39
+ (`KeepAlive` with `ThrottleInterval` 10, `Restart=always` with `RestartSec=10`, the node likewise; a scheduled task
40
+ restarts on failure every minute). A restart onto new code shows as a new `service.build`.
41
+ - **Off stays off.** The operator's off survives a login, an upgrade and a reinstall (LC-14).
42
+
43
+ **The descriptor is the installation.** The installer writes one descriptor per instance (`<id>.<instance>.json`,
44
+ atomic, mode 0600) and the uninstaller removes it; a running service never writes its own, except an app
45
+ ([below](#an-apps-own-descriptor-and-the-node)). It names the supervisor and its file (`label` and `plist`, `unit`,
46
+ or `task`; none for `node` and `person`), the `origin` `http://127.0.0.1:<port>` (a port is a claim: an installer
47
+ refuses a port another instance declares), the service's directories, and commands as argument arrays that never
48
+ carry a credential. Only `doctor` and `update` are commands a host may run.
49
+
50
+ **Started is not ready.** Readiness is the well-known document, `GET /.well-known/fabric-service`: answered without
51
+ authentication, from memory, in under 100 ms, with `service.id` and `service.instance` equal to the descriptor's.
52
+ `status` is `starting`, `ready`, `degraded` or `stopping`; `degraded` is always present, and an empty list asserts
53
+ full health. A process that cannot serve does not answer, and a host derives `down`. An install or upgrade polls
54
+ that document until `service.id` matches (at most 40 s) before it calls the service started.
55
+
56
+ | Id | Rule | Here | Kit (Python / Node) |
57
+ |---|---|---|---|
58
+ | LC-01 | **Quit reaches exit within a deadline.** Every trigger (`SIGTERM`, `SIGINT`, logout, the app's own quit) ends the process: within 5 s idle, within 10 s with work in flight, then a hard exit. Nothing starts after quit begins | [Shutdown](#shutdown) | `Drain` / `Drain` |
59
+ | LC-02 | **Every child has an owner, a deadline and a process group.** No subprocess without a timeout; on timeout or the parent's exit the whole group goes; no orphan survives. Locks and shared resources are released in `finally` | your own code | — |
60
+ | LC-03 | **A job is bounded and exclusive.** One copy holds `service.lock`; a scheduled job has a watchdog shorter than its interval, shares its lock with every manual entry point, bounds every network call and records each run's outcome. Long work is journalled before it starts | [One copy under a supervisor](#one-copy-under-a-supervisor) | `hold_single_instance` / `holdSingleInstance` |
61
+ | LC-04 | **Credentials are silent after setup.** No background prompt; a credential read that is denied or absent is cached and backs off until a person acts | [Directories](#directories) (secret files 0600) | — |
62
+ | LC-05 | **Identity is stable across updates.** The supervisor runs a stable executable inside a release directory, never an interpreter by a versioned package-manager path; signed builds are pinned by team and identifier | [launchd plist](#launchd-plist-generate-it-with-launchd_plist-or-launchdplist-both-kits-write-the-same-bytes) | — |
63
+ | LC-09 | **The footprint is declared.** The service's `AGENTS.md` names every resident process, its supervisor label, unit or task and its port, who starts each and who stops it | the descriptor and the supervisor file | `write_descriptor`, `launchd_plist`, `systemd_unit` / `systemdUnit`, `task_xml` / `taskXml` |
64
+ | LC-11 | **An update is a lifecycle event.** An upgrade writes a new immutable release and restarts through the supervisor; the previous release is kept for rollback, older ones are pruned | [Install, upgrade, uninstall](#install-upgrade-uninstall) | `prune_releases` / `pruneReleases` |
65
+ | LC-12 | **Every file is bounded.** Logs rotate by size (5 × 5 MB), mode 0600, never in `/tmp`; temporary leftovers are swept at start | [Logs](#logs) | `RotatingLog`, `cap_stdout_log` / `RotatingLog`, `capStdoutLog` |
66
+ | LC-14 | **The operator's intent wins; uninstall is symmetric.** No install or upgrade re-enables what the operator turned off; uninstall removes what install added (supervisor file, override, descriptor), and data goes only on an explicit purge | [Install, upgrade, uninstall](#install-upgrade-uninstall), [Linux and Windows](#linux-and-windows) | `launchd_install` / `launchdInstall`, `launchd_uninstall` / `launchdUninstall`; `systemd_install` / `systemdInstall`, `task_install` / `taskInstall` and their uninstalls |
67
+ | LC-15 | **Builds clean up after themselves.** At most the current release and the one before it remain; the prune runs inside the installer, not later | [Install, upgrade, uninstall](#install-upgrade-uninstall) | `prune_releases` / `pruneReleases` |
68
+ | LC-21 | **A secret never travels in a command line.** Keys reach the process through its environment, a 0600 file or a vault, never argv; a supervisor file carries no secret | [launchd plist](#launchd-plist-generate-it-with-launchd_plist-or-launchdplist-both-kits-write-the-same-bytes), [Linux and Windows](#linux-and-windows) | the supervisor-file writers refuse a secret-shaped value |
69
+ | LC-22 | **Work survives a restart.** Work started but not finished is journalled on disk, without secrets, before it starts; the restarted service takes it back or cleans it up | [Shutdown](#shutdown) | — (the service's own journal) |
70
+
29
71
  ## launchd plist (generate it with `launchd_plist` or `launchdPlist`; both kits write the same bytes)
30
72
 
31
73
  | Key | Value | Why |
@@ -168,7 +210,7 @@ has to survive being driven from outside:
168
210
 
169
211
  - **Quit reaches exit through both doors.** A broker asks the platform's own quit first — on
170
212
  macOS `NSRunningApplication.terminate()`, the path Cmd+Q and the Dock take — and `SIGTERM` a few
171
- seconds later. Handle both and exit inside the deadline (fabric-workspace LC-01). A desktop app
213
+ seconds later. Handle both and exit inside the deadline (LC-01). A desktop app
172
214
  that answers `SIGTERM` by closing its window and staying alive is stopped late or not at all.
173
215
  - **A background launch stays in the background.** A broker opens the app non-activating
174
216
  (`NSWorkspace.OpenConfiguration.activates = false`); do not call `app.focus()`, `show()` on a
@@ -1,6 +1,6 @@
1
1
  # fabric-service/0.1 — wire reference
2
2
 
3
- Pinned to `fabric-agent-contract` commit `f00a7685e3861c27f7899c033fb850a145a1708f`
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
 
@@ -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 generations = Object.fromEntries((status.responders ?? []).filter(isObj).map((r) => [r.capability, r.generation ?? 0]));
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 (transfer[capability]?.length) await this.replace(capability, transfer[capability], generations[capability] ?? 0);
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
- const receipt = this.receipt('com.responder_replace', await this.call('com.responder_replace', args));
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
- const receipt = await this.fenced('com.accept', entry, { fence: this.fence(entry, entry.keys.accept) });
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
- for (let attempt = 0; attempt < 3; attempt += 1) {
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 !== 'unreachable' || attempt === 2) throw new Stopped();
932
- await new Promise((resolve) => setTimeout(resolve, (attempt + 1) * 1000));
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();
@@ -20,7 +20,8 @@ export const DASHBOARD_EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-cont
20
20
  export const DASHBOARD_KIT = 'fabric-dashboard-kit@1.1.0';
21
21
  export const REFERENCE_THEMES = ['graphite', 'fabric', 'paper', 'forest', 'ocean', 'ember', 'contrast'];
22
22
  export const EXIT_ALREADY_RUNNING = 75;
23
- // Lifecycle contract (fabric-workspace knowledge/lifecycle.md), the same values as fabric_service.py.
23
+ // Lifecycle rules: references/lifecycle.md (the rule for this kit's users; it implements the public
24
+ // contract's service specification, Lifecycle), the same values as fabric_service.py.
24
25
  // A launchd-supervised copy that finds the lock held backs off in-process before its exit 75 (LC-03).
25
26
  export const SUPERVISOR_ENV = 'FABRIC_SERVICE_SUPERVISOR';
26
27
  export const LOCK_WAIT_SUPERVISED_SECONDS = 300;
@@ -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
- generations = {r.get("capability"): r.get("generation", 0) for r in status.get("responders") or [] if isinstance(r, dict)}
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 held:
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
- receipt = self._receipt("com.responder_replace", self._call("com.responder_replace", args))
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
- receipt = self._fenced("com.accept", entry, {"fence": self._fence(entry, key)})
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
- for attempt in range(3): # a lost answer: the same key again tells whether it was stored
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 != "unreachable" or attempt == 2:
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
- time.sleep(attempt + 1)
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"
@@ -49,9 +49,10 @@ DASHBOARD_EXTENSION_KEY = "https://fabric.passioncode.ai/agent-contract/extensio
49
49
  DASHBOARD_KIT = "fabric-dashboard-kit@1.1.0"
50
50
  REFERENCE_THEMES = ("graphite", "fabric", "paper", "forest", "ocean", "ember", "contrast")
51
51
  EXIT_ALREADY_RUNNING = 75
52
- # Lifecycle contract (fabric-workspace knowledge/lifecycle.md). A launchd-supervised copy that finds
53
- # the lock held backs off in-process for this long before its exit 75 (LC-03, F8): under KeepAlive
54
- # with ThrottleInterval 10 that is a few starts an hour instead of one every 10 s.
52
+ # Lifecycle rules: references/lifecycle.md, the rule for this kit's users, which implements the public
53
+ # contract's service specification (Lifecycle). A launchd-supervised copy that finds the lock held backs
54
+ # off in-process for this long before its exit 75 (LC-03, F8): under KeepAlive with ThrottleInterval 10
55
+ # that is a few starts an hour instead of one every 10 s.
55
56
  SUPERVISOR_ENV = "FABRIC_SERVICE_SUPERVISOR"
56
57
  LOCK_WAIT_SUPERVISED_SECONDS = 300.0
57
58
  LOCK_BACKOFF_FIRST_SECONDS = 0.5
@@ -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.31.1"
8
+ version: "0.32.1"
9
9
  contract-version: "0.1.0"
10
- contract-commit: "f00a7685e3861c27f7899c033fb850a145a1708f"
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 `f00a7685e3861c27f7899c033fb850a145a1708f` and
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
@@ -19,7 +19,7 @@ compatibility: >-
19
19
  this plugin; Codex and Kimi Code get the session-start preamble hook on the person's consent.
20
20
  metadata:
21
21
  author: PassionCode.ai
22
- version: "0.31.1"
22
+ version: "0.32.1"
23
23
  ---
24
24
 
25
25
  # Working with Fabric
@@ -1,9 +1,10 @@
1
1
  # The project board (`fabric-project-comms/0.1`, the `com.*` tools)
2
2
 
3
3
  Read this when the session starts in a project a Fabric hub serves, when the person asks about board mail, when you
4
- need something from another Project, or when you wait for an answer. The rule itself has one home: PassionCode's
5
- knowledge base, [agents-first.md, "The project board"](https://github.com/passioncode-ai/fabric-workspace/blob/main/knowledge/agents-first.md#the-project-board).
6
- This page is how a coding agent with this plugin follows it; where the two differ, that page wins.
4
+ need something from another Project, or when you wait for an answer. This page is the rule a coding agent with this
5
+ plugin follows. Inside PassionCode.ai it implements the knowledge base's
6
+ [agents-first.md, "The project board"](https://github.com/passioncode-ai/fabric-workspace/blob/main/knowledge/agents-first.md#the-project-board)
7
+ (private to the organization), and is kept in step with it; nothing behind that link is needed to follow this page.
7
8
 
8
9
  ## Contents
9
10
 
@@ -46,6 +47,26 @@ capability (a plain question may be `kind: "message"`), in a new thread whose pa
46
47
  SendMessage between sessions. agent-sync leases guard files; they carry no asks.
47
48
 
48
49
  A request the board stored has not been carried out. Only its state (`completed`, `failed_known`) says what happened.
50
+ `settledBy: "owner"` on a request (DEC-0081) means the estate owner settled it after its responder was gone: read it as
51
+ the owner's attestation, not the responder's.
52
+
53
+ **`held` (DEC-0079).** The operator holds that thread, that Project or that capability, and the board refuses new
54
+ work there with `held`, which is always retryable. Tell the person what is held. Retry later, the pause growing, with
55
+ the **same idempotency key and arguments**. Never post the ask into another thread or to another Project to get around
56
+ the hold. Reads, cancels and replies to work already started are never held.
57
+
58
+ **A problem for another Project: a structured finding (DEC-0080).** Send one only when the running Fabric's
59
+ `com.submit` input schema lists `finding`; otherwise send a plain message. A finding carries:
60
+
61
+ - `severity`: `info`, `warning` or `blocking`;
62
+ - `consequence`: one sentence on what it breaks;
63
+ - `affects`: the backlog rows (`{repository, path, id}`) or host tasks it touches;
64
+ - a `dedupeKey` that stays the same for the same problem.
65
+
66
+ Its idempotency key is at least 128 random bits (for example 32 hex characters from a cryptographic generator), kept
67
+ on every retry. A receipt with `repeated: true` and a fresh key means that finding already stands on the board: its
68
+ thread may not be the one you named, and nothing new was posted. Never put a `{redacted: true}` item into a finding you
69
+ send.
49
70
 
50
71
  ## The project directory
51
72
 
@@ -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.31.1"
63
+ VERSION = "0.32.1"
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