@passioncode-ai/passioncode 0.2.45 → 0.2.46
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 +9 -0
- package/family.json +1 -1
- package/package.json +1 -1
- package/payload/.claude-plugin/marketplace.json +2 -2
- package/payload/manifest.json +8 -8
- package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/payload/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +5 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +73 -18
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +6 -5
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +70 -20
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +7 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +9 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +14 -5
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +1 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +4 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +16 -9
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +87 -156
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +180 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +1 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +4 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +2 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +15 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +12 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +12 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +1 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +32 -13
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +115 -147
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +173 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +9 -42
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +53 -4
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +7 -3
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +4 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +87 -11
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +19 -8
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +30 -6
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +9 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +15 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +73 -18
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +134 -24
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +155 -21
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +10 -10
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +2 -1
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +39 -13
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +6 -2
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +17 -3
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +23 -6
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +10 -39
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +190 -0
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +1 -1
- package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md
CHANGED
|
@@ -162,7 +162,10 @@ path on the service origin, never an absolute URL. Optional SSE stream at
|
|
|
162
162
|
**Machine-readable `data` (DEC-0070).** An event may carry `data {schema, value}` beside its sentence: `schema` is the
|
|
163
163
|
absolute URI of a payload schema the service publishes; `value` is an object of at most 32 members, each a scalar (a
|
|
164
164
|
string of at most 500 characters, a number, a boolean, null), a list of at most 32 scalars, or an object of at most 32
|
|
165
|
-
scalar members
|
|
165
|
+
scalar members: two levels at most, so `value.cited` may be a list of ids or an object of counts, never a list of
|
|
166
|
+
objects (FAA-122). Flatten a nested record into members of its own, `{"citedIds": ["a1", "b2"], "citedCoverage":
|
|
167
|
+
[0.8, 0.4]}` in place of `{"cited": [{"id": "a1", "coverage": 0.8}, ...]}`; the refusal names the first place that
|
|
168
|
+
nests too deep (`data.value.cited[0] is an object`). `text` stays the event: a host that ignores `data` loses nothing, and one that does not know the schema
|
|
166
169
|
treats `value` as opaque. No string in `value` sits under a credential's name or has a credential's shape
|
|
167
170
|
(`FAC-SEM-101`); personal data is the service's own duty. `make_event` / `makeEvent` (and `JsonlEventLog.append`) take
|
|
168
171
|
`data=` and refuse one that breaks either rule, naming the place, never the value; `check_service.py` checks the feed it
|
|
@@ -10,7 +10,7 @@ import { BoardError, RESPOND_TOOLS, isObj, refusalProblems } from './fabric-boar
|
|
|
10
10
|
/** MCP over HTTP with a board session's one-session bearer; calls are serialised; close() sends the session's DELETE. */
|
|
11
11
|
export class BoardSession {
|
|
12
12
|
constructor(grant, { timeout = 15000, post = postMcp } = {}) {
|
|
13
|
-
Object.assign(this, { grant, timeout, post });
|
|
13
|
+
Object.assign(this, { grant, timeout: timeout > 0 ? timeout : 15000, post }); // not above 0 is the default
|
|
14
14
|
this.mcpSession = null;
|
|
15
15
|
this.protocolVersion = MCP_PROTOCOL_VERSION;
|
|
16
16
|
this.rpc = 0;
|
|
@@ -23,9 +23,9 @@ export class BoardSession {
|
|
|
23
23
|
return run;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
async #send(body, method = 'POST') {
|
|
26
|
+
async #send(body, method = 'POST', timeout = null) {
|
|
27
27
|
try {
|
|
28
|
-
return await this.post(this.grant.origin, body, this.grant.bearer(), { session: this.mcpSession, timeout: this.timeout, method, protocolVersion: this.mcpSession ? this.protocolVersion : undefined });
|
|
28
|
+
return await this.post(this.grant.origin, body, this.grant.bearer(), { session: this.mcpSession, timeout: timeout > 0 ? timeout : this.timeout, method, protocolVersion: this.mcpSession ? this.protocolVersion : undefined });
|
|
29
29
|
} catch (e) {
|
|
30
30
|
if (e instanceof HubError) throw new BoardError('unreachable', 'Fabric\'s hub did not answer; a write may have been stored, so send it again with the same idempotency key.', true);
|
|
31
31
|
throw e;
|
|
@@ -66,15 +66,19 @@ export class BoardSession {
|
|
|
66
66
|
});
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
/** Sends the DELETE (waiting `timeout` ms at most); true when Fabric answered it with a 2xx, so the slots are free now (FAA-163). A 404 (already gone) is false: a move made before it ended is read at the next com.status, so the slot records stay. */
|
|
70
|
+
close(timeout = null) {
|
|
70
71
|
return this.#serial(async () => {
|
|
71
|
-
if (!this.mcpSession) return;
|
|
72
|
+
if (!this.mcpSession) return false;
|
|
73
|
+
let status = 0;
|
|
72
74
|
try {
|
|
73
|
-
await this.#send({}, 'DELETE');
|
|
75
|
+
const answer = await this.#send({}, 'DELETE', timeout);
|
|
76
|
+
status = Number.isInteger(answer?.status) ? answer.status : 0; // an answer it cannot read is no release, as in Python
|
|
74
77
|
} catch {
|
|
75
78
|
// best effort: the lease lapses in 60 s anyway
|
|
76
79
|
}
|
|
77
80
|
this.mcpSession = null;
|
|
81
|
+
return status >= 200 && status < 300;
|
|
78
82
|
});
|
|
79
83
|
}
|
|
80
84
|
}
|
|
@@ -135,14 +139,17 @@ export class Checkpoint {
|
|
|
135
139
|
this.slots = slots;
|
|
136
140
|
}
|
|
137
141
|
/**
|
|
138
|
-
* At a clean leave (the session's DELETE released the slots): forget the record of each slot this
|
|
139
|
-
* `project
|
|
142
|
+
* At a clean leave (the session's DELETE released the slots): forget the record of each slot this session confirmed
|
|
143
|
+
* (`held`, required: capability to the generation it held, or com.status showed unmoved) at that generation or below, for `project`, with no request left and not moved away, so a later generation is another session taking a free slot, and
|
|
140
144
|
* a kit rolled back to before 0.53.0 leaves nothing that reads as a move. A record that cannot be written stays.
|
|
141
145
|
*/
|
|
142
|
-
leave(project) {
|
|
146
|
+
leave(project, held) {
|
|
143
147
|
const busy = new Set(Object.values(this.entries).map((e) => e.capability));
|
|
144
148
|
for (const [capability, record] of Object.entries(this.slots)) {
|
|
145
149
|
if (!isObj(record) || record.project !== project || 'moved' in record || busy.has(capability)) continue;
|
|
150
|
+
// Only a slot this session confirmed, at or below that generation: an unconfirmed record may be a move (0.56.0 seam r2, r3).
|
|
151
|
+
const confirmed = Object.hasOwn(held, capability) ? held[capability] : null;
|
|
152
|
+
if (!Number.isInteger(record.generation) || record.generation < 1 || !Number.isInteger(confirmed) || record.generation > confirmed) continue;
|
|
146
153
|
try {
|
|
147
154
|
this.slot(capability, null);
|
|
148
155
|
} catch {
|
|
@@ -6,8 +6,10 @@ import { Backoff, HubError, normalProject } from './fabric-hub.mjs';
|
|
|
6
6
|
import { acceptedRevisions, feedDataProblems, negotiateRevision } from './fabric-service.mjs';
|
|
7
7
|
import { BoardError, CAPABILITY, LEASE_SECONDS, REQUEST_STATES, SUMMARY_MAX, Stopped, isInt, isObj, outcomeProblems, receiptProblems } from './fabric-board-shapes.mjs';
|
|
8
8
|
import { BoardRequest, BoardSession, Checkpoint } from './fabric-board-link.mjs';
|
|
9
|
+
import { HOLD_RETRY_MAX_SECONDS, LEASE_MARGIN_SECONDS, SESSION_CODES, SlotKeeper } from './fabric-board-slots.mjs';
|
|
9
10
|
|
|
10
11
|
export { BoardRequest, BoardSession, Checkpoint }; // re-exported: callers import them from here since 0.40.0
|
|
12
|
+
export { HOLD_RETRY_MAX_SECONDS, LEASE_MARGIN_SECONDS, SESSION_CODES }; // since 0.56.0 defined in fabric-board-slots.mjs
|
|
11
13
|
|
|
12
14
|
export const RENEW_SECONDS = 20;
|
|
13
15
|
|
|
@@ -16,6 +18,8 @@ export const POLL_SECONDS = 15;
|
|
|
16
18
|
export const POLL_JITTER_SECONDS = 5;
|
|
17
19
|
|
|
18
20
|
export const DRAIN_SECONDS = 10;
|
|
21
|
+
/** An idle responder's leave waits this long for the DELETE, so an idle stop ends within LC-01's 5 s (FAA-173). */
|
|
22
|
+
export const IDLE_LEAVE_SECONDS = 3;
|
|
19
23
|
|
|
20
24
|
export const EVENT_SCHEMA = 'urn:fabric:schema:board-responder-event:1';
|
|
21
25
|
|
|
@@ -26,15 +30,6 @@ const TERMINAL = new Set(['completed', 'failed_known', 'cancelled', 'expired']);
|
|
|
26
30
|
// participant reply (DEC-0098, DEC-0102; not terminal, the requester may still cancel it, and nobody claims it).
|
|
27
31
|
const NOT_OURS = new Set(['queued', 'claimed', 'answered']);
|
|
28
32
|
|
|
29
|
-
// The answer may have been stored: send it again with the same key (BoardError).
|
|
30
|
-
const LOST = ['unreachable', 'session-ended'];
|
|
31
|
-
|
|
32
|
-
/** A lost call is sent again this long before the lease it keeps would lapse (FAA-77, finding 38). */
|
|
33
|
-
export const LEASE_MARGIN_SECONDS = 10;
|
|
34
|
-
|
|
35
|
-
/** How long a held slot waits before it asks again: doubling from the poll interval, never past this. */
|
|
36
|
-
export const HOLD_RETRY_MAX_SECONDS = 900;
|
|
37
|
-
|
|
38
33
|
/** What a handler may do while it works: progress(note), beginEffect(summary) before it spends or acts, and `stopped`. */
|
|
39
34
|
export class WorkContext {
|
|
40
35
|
#responder;
|
|
@@ -56,14 +51,26 @@ export class WorkContext {
|
|
|
56
51
|
|
|
57
52
|
const clean = (text, max = SUMMARY_MAX) => String(text).split(/\s+/).filter(Boolean).join(' ').slice(0, max);
|
|
58
53
|
|
|
54
|
+
/**
|
|
55
|
+
* A notice's subject ({type, id}): its request, else its capability, else its episode (a cause such as a block's code,
|
|
56
|
+
* so two causes are two episodes for a host), else the Project. 128 code points, as Python.
|
|
57
|
+
*/
|
|
58
|
+
function noticeSubject(value, project, episode) {
|
|
59
|
+
for (const [kind, id] of [['request', value.request], ['capability', value.capability], ['episode', episode]]) {
|
|
60
|
+
if (typeof id === 'string' && id.length > 0 && [...id].length <= 128) return { type: kind, id };
|
|
61
|
+
}
|
|
62
|
+
return { type: 'project', id: project };
|
|
63
|
+
}
|
|
64
|
+
|
|
59
65
|
/**
|
|
60
66
|
* One service's responder for one Project and its capabilities, one request at a time. handler(request, ctx) answers
|
|
61
67
|
* the outcome (may be async); admit(request) is the input gate; observe(entry) reads the world after a restart;
|
|
62
68
|
* emit(kind, level, text, extra) receives events. run({signal}) ticks until aborted; step() is one tick.
|
|
63
69
|
*/
|
|
64
|
-
export class Responder {
|
|
70
|
+
export class Responder extends SlotKeeper {
|
|
65
71
|
constructor({ hub, projectId, capabilities, credentialFile, checkpointFile, handler, admit, observe, emit, service = 'service',
|
|
66
72
|
now = () => performance.now() / 1000, rand = Math.random, sessionFactory } = {}) {
|
|
73
|
+
super();
|
|
67
74
|
if (typeof admit !== 'function' || typeof handler !== 'function') throw new BoardError('invalid_arguments', 'A responder needs the service\'s own input gate (admit) and a handler.');
|
|
68
75
|
const caps = [...(capabilities ?? [])];
|
|
69
76
|
if (!caps.length || caps.some((c) => typeof c !== 'string' || !CAPABILITY.test(c)) || new Set(caps).size !== caps.length) throw new BoardError('invalid_arguments', 'Name each capability once, [a-z][a-z0-9._-]{1,127}.');
|
|
@@ -91,6 +98,7 @@ export class Responder {
|
|
|
91
98
|
this.fenceQueue = Promise.resolve();
|
|
92
99
|
this.toResume = []; // transferred requests waiting until nothing runs (finding 39)
|
|
93
100
|
this.suspect = new Set(); // capabilities a fenced call was refused on: com.status says whether they moved (FAA-155)
|
|
101
|
+
this.confirmed = {}; // this session's: the generation it held, or com.status showed unmoved
|
|
94
102
|
}
|
|
95
103
|
|
|
96
104
|
/** Runs fn after every fenced call queued before it (finding 36): an await inside a call never lets another in. */
|
|
@@ -100,17 +108,27 @@ export class Responder {
|
|
|
100
108
|
return run;
|
|
101
109
|
}
|
|
102
110
|
|
|
111
|
+
/** The generations this session confirmed (held, or compared with com.status and found unmoved), paused and retrying slots included: only their records a clean leave may forget (0.56.0 seam r2, r3). */
|
|
112
|
+
heldNow() {
|
|
113
|
+
// a fenced call (maybe moved) or a move withholds it
|
|
114
|
+
return Object.fromEntries(Object.entries(this.confirmed).filter(([c]) => !this.suspect.has(c) && this.slots[c]?.state !== 'moved'));
|
|
115
|
+
}
|
|
116
|
+
|
|
103
117
|
// -- events --
|
|
104
|
-
emit(kind, level, text, { notify = false, episode, ...value } = {}) {
|
|
105
|
-
if (episode !== undefined) {
|
|
106
|
-
|
|
107
|
-
this.said[
|
|
118
|
+
emit(kind, level, text, { notify = false, episode, saidAs, ...value } = {}) {
|
|
119
|
+
if (episode !== undefined) { // `saidAs` keeps one episode per key, such as one per capability
|
|
120
|
+
const key = saidAs ?? kind;
|
|
121
|
+
if (this.said[key] === episode) return;
|
|
122
|
+
this.said[key] = episode;
|
|
108
123
|
}
|
|
109
124
|
let data = { schema: EVENT_SCHEMA, value: Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined && v !== null)) };
|
|
110
125
|
if (feedDataProblems(data).length) data = undefined; // FAC-SEM-101: drop the data, keep the sentence
|
|
111
126
|
if (!this.emitTo) return;
|
|
127
|
+
// The thing the notice is about, so a host tells one request's or one capability's episode from another's
|
|
128
|
+
// (events-and-notifications.md, "Say who wants what"; 0.56.0 seam r30).
|
|
129
|
+
const subject = noticeSubject(value, this.project, episode);
|
|
112
130
|
try {
|
|
113
|
-
this.emitTo(kind, level, text, { notify, ...(data ? { data } : {}) });
|
|
131
|
+
this.emitTo(kind, level, text, { notify, subject, ...(data ? { data } : {}) });
|
|
114
132
|
} catch {
|
|
115
133
|
// an event that cannot be written never stops the responder
|
|
116
134
|
}
|
|
@@ -152,6 +170,8 @@ export class Responder {
|
|
|
152
170
|
|
|
153
171
|
async connect() {
|
|
154
172
|
this.state = 'connecting';
|
|
173
|
+
this.slots = {}; // the previous session's slots are not this one's: a refusal now reconnects (FAA-163)
|
|
174
|
+
this.confirmed = {}; // nor its confirmations: a slot may have moved between the two sessions
|
|
155
175
|
const grant = await this.hub.joinBoard(this.project, this.credentialFile);
|
|
156
176
|
const missing = this.capabilities.filter((c) => !grant.capabilities.includes(c));
|
|
157
177
|
const session = this.sessionFactory(grant);
|
|
@@ -159,7 +179,7 @@ export class Responder {
|
|
|
159
179
|
this.session = session;
|
|
160
180
|
const status = await this.status();
|
|
161
181
|
if (missing.length) {
|
|
162
|
-
this.emit('board.access.needed', '
|
|
182
|
+
this.emit('board.access.needed', 'warning', `The operator has not let this service answer ${missing.join(', ')} on this Project; ask in Fabric (Agents & access).`, { notify: true, episode: missing.join(','), capabilities: missing });
|
|
163
183
|
}
|
|
164
184
|
this.slots = {};
|
|
165
185
|
await this.recover(status, this.capabilities.filter((c) => grant.capabilities.includes(c)));
|
|
@@ -180,14 +200,14 @@ export class Responder {
|
|
|
180
200
|
if (e.code === 'fenced') {
|
|
181
201
|
if (await this.gone(capability)) return;
|
|
182
202
|
this.slots[capability] = { state: 'contested', renewedAt: this.now() };
|
|
183
|
-
this.emit('board.slot.contested', '
|
|
203
|
+
this.emit('board.slot.contested', 'warning', `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, saidAs: `board.slot.contested:${capability}`, capability });
|
|
184
204
|
return;
|
|
185
205
|
}
|
|
186
206
|
throw e;
|
|
187
207
|
}
|
|
188
208
|
this.slots[capability] = { state: 'held', generation: receipt.slot.generation, renewedAt: this.now() };
|
|
189
209
|
this.heldAt(capability, receipt.slot.generation);
|
|
190
|
-
delete this.said[
|
|
210
|
+
delete this.said[`board.slot.contested:${capability}`];
|
|
191
211
|
this.lifted(capability);
|
|
192
212
|
}
|
|
193
213
|
|
|
@@ -211,118 +231,6 @@ export class Responder {
|
|
|
211
231
|
this.emit('board.slot.released', 'info', `The operator's hold on ${capability} was lifted; the service takes new work again.`, { capability });
|
|
212
232
|
}
|
|
213
233
|
|
|
214
|
-
// -- a slot moved to another session (FAA-155, DEC-0140 PR-2) --
|
|
215
|
-
/** The generation this responder holds the slot at, kept beside its checkpoint; a failed write is retried at the next receipt. */
|
|
216
|
-
heldAt(capability, generation) {
|
|
217
|
-
this.note(capability, { project: this.project, generation });
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/** The slot record, best effort: one that cannot be written now is written at the next receipt. */
|
|
221
|
-
note(capability, record) {
|
|
222
|
-
try {
|
|
223
|
-
this.checkpoint.slot(capability, record);
|
|
224
|
-
} catch {
|
|
225
|
-
// the request steps fail closed on their own
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
* An enroll or replace is recorded with its key before it is sent, so a lost answer is never read as a move; a refusal
|
|
231
|
-
* drops the record, a receipt replaces it (heldAt), a record that cannot be written is skipped.
|
|
232
|
-
*/
|
|
233
|
-
async ask(capability, op, args) {
|
|
234
|
-
let held = this.checkpoint.slots[capability] ?? {};
|
|
235
|
-
if (held.project !== this.project) return this.call(op, args); // no record: nothing a lost answer could be mistaken for
|
|
236
|
-
const slot = this.slots[capability] ?? {};
|
|
237
|
-
if (op === 'com.enroll' && slot.state === 'held' && slot.generation === held.generation
|
|
238
|
-
&& this.now() - (slot.renewedAt ?? 0) < LEASE_SECONDS - LEASE_MARGIN_SECONDS && !('asking' in held)) {
|
|
239
|
-
return this.call(op, args); // a live holder's renew cannot change its generation: nothing to record
|
|
240
|
-
}
|
|
241
|
-
if (isObj(held.asking)) {
|
|
242
|
-
// An earlier lost ask is settled first; it may show the slot moved.
|
|
243
|
-
if (await this.gone(capability)) throw new BoardError('fenced', `The ${capability} slot moved to another session.`, false, op);
|
|
244
|
-
held = this.checkpoint.slots[capability] ?? {};
|
|
245
|
-
}
|
|
246
|
-
try {
|
|
247
|
-
this.checkpoint.slot(capability, { ...held, asking: { op, args } });
|
|
248
|
-
} catch {
|
|
249
|
-
return this.call(op, args);
|
|
250
|
-
}
|
|
251
|
-
try {
|
|
252
|
-
return await this.call(op, args);
|
|
253
|
-
} catch (e) {
|
|
254
|
-
if (!LOST.includes(e.code)) this.note(capability, held);
|
|
255
|
-
throw e;
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
/**
|
|
260
|
-
* Asks a lost enroll or replace again under its key. `repeated: true`: it landed, the generation is ours. Else it ran
|
|
261
|
-
* only now: if the slot had moved past the record (`before`), the record stays and the move is read (the slot just
|
|
262
|
-
* taken lapses unused). A refusal or an odd receipt drops the record; a lost answer keeps it.
|
|
263
|
-
*/
|
|
264
|
-
async replay(capability, held, before) {
|
|
265
|
-
const { asking, ...rest } = held;
|
|
266
|
-
let record = rest;
|
|
267
|
-
if (isObj(asking) && ['com.enroll', 'com.responder_replace'].includes(asking.op)) {
|
|
268
|
-
try {
|
|
269
|
-
const answer = this.receipt(asking.op, await this.call(asking.op, asking.args));
|
|
270
|
-
const generation = answer.slot?.generation;
|
|
271
|
-
const moved = isInt(before, 1) && isInt(rest.generation, 1) && before > rest.generation;
|
|
272
|
-
if (isInt(generation, 1) && (answer.repeated === true || !moved)) record = { project: this.project, generation };
|
|
273
|
-
} catch (e) {
|
|
274
|
-
if (LOST.includes(e.code)) throw e;
|
|
275
|
-
if (e.code === 'idempotency_window_expired') record = {}; // a retired epoch: unknown, so a first start, no move
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
this.note(capability, Object.keys(record).length ? record : null);
|
|
279
|
-
return record;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
/**
|
|
283
|
-
* True when com.status shows the slot's generation past the one recorded held: the operator or another session moved
|
|
284
|
-
* it. The slot is left `moved`. No record, another Project's, an odd value or no later generation: false.
|
|
285
|
-
*/
|
|
286
|
-
async gone(capability, status = null) {
|
|
287
|
-
let held = this.checkpoint.slots[capability] ?? {};
|
|
288
|
-
if (held.project !== this.project || (!isInt(held.generation, 1) && !('asking' in held))) return false;
|
|
289
|
-
let now = ((status ?? await this.status()).responders ?? []).find((r) => isObj(r) && r.capability === capability)?.generation;
|
|
290
|
-
if ('asking' in held) held = await this.replay(capability, held, now); // its own lost ask first
|
|
291
|
-
if (isInt(held.moved, 1) /* a recorded move stays until takeBack */ && !(isInt(now, 1) && now > (held.generation ?? now))) now = held.moved;
|
|
292
|
-
if (!isInt(now, 1) || !isInt(held.generation, 1) || now <= held.generation) return false;
|
|
293
|
-
if (this.current?.entry.capability === capability) {
|
|
294
|
-
this.current.ctx.stopped = true;
|
|
295
|
-
this.current = null;
|
|
296
|
-
}
|
|
297
|
-
for (const entry of Object.values(this.checkpoint.entries).filter((e) => e.capability === capability).map((e) => ({ ...e }))) {
|
|
298
|
-
if (entry.step === 'claimed') this.checkpoint.drop(entry.request); // the move returned the claim to the queue
|
|
299
|
-
else if (!['unknown', 'lapsed'].includes(entry.step)) {
|
|
300
|
-
// Begun: outcome_unknown, which the slot's holder reconciles. Accepted: held on the board, continued only by a
|
|
301
|
-
// transfer if the slot is this service's again; never a com.responder_replace on its own.
|
|
302
|
-
entry.step = entry.effect ? 'unknown' : 'lapsed';
|
|
303
|
-
this.checkpoint.put(entry);
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
this.toResume = this.toResume.filter((r) => this.checkpoint.entries[r]?.capability !== capability);
|
|
307
|
-
this.slots[capability] = { state: 'moved', generation: now, renewedAt: this.now() };
|
|
308
|
-
if (!('moved' in held)) { // once, even across restarts
|
|
309
|
-
this.emit('board.slot.moved', 'warn', `The ${capability} slot on this Project was moved to another session. This service stopped answering it there, left its accepted work held (work already begun is the new holder's to settle) and does not take the slot back by itself. Its take-back action brings it back once the other session lets it go; without one, ask its maintainer.`, { notify: true, episode: capability, capability, generation: now, held: held.generation });
|
|
310
|
-
this.note(capability, { ...held, moved: now }); // said again at the next start if it cannot be written
|
|
311
|
-
}
|
|
312
|
-
return true;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
/**
|
|
316
|
-
* The operator's act, never the kit's: forget that a capability's slot moved away. In the same board session the next
|
|
317
|
-
* tick enrolls once the slot is free; a reconnect with held work takes it back at once by a transfer (FAA-155).
|
|
318
|
-
*/
|
|
319
|
-
takeBack(capability) {
|
|
320
|
-
if (!this.capabilities.includes(capability)) throw new BoardError('invalid_arguments', `This responder does not answer ${capability}.`);
|
|
321
|
-
this.checkpoint.slot(capability, null);
|
|
322
|
-
if (this.slots[capability]?.state === 'moved') this.slots[capability] = { state: 'contested', renewedAt: 0 };
|
|
323
|
-
delete this.said['board.slot.moved'];
|
|
324
|
-
}
|
|
325
|
-
|
|
326
234
|
// -- restart: reconcile and transfer --
|
|
327
235
|
async readState(entry) {
|
|
328
236
|
const message = await this.call('com.get', { message: entry.message });
|
|
@@ -354,20 +262,20 @@ export class Responder {
|
|
|
354
262
|
const responders = (status.responders ?? []).filter(isObj);
|
|
355
263
|
const generations = Object.fromEntries(responders.map((r) => [r.capability, r.generation ?? 0]));
|
|
356
264
|
const onHold = new Set(responders.filter((r) => r.held === true).map((r) => r.capability)); // DEC-0079
|
|
265
|
+
let failure = null;
|
|
357
266
|
for (const capability of capabilities) {
|
|
358
267
|
const was = this.checkpoint.slots[capability]?.generation;
|
|
359
|
-
if (
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
this.
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
|
|
268
|
+
if (failure) { // the board session failed: the rest wait for the next tick (FAA-163)
|
|
269
|
+
this.retryLater(capability, transfer[capability] ?? [], failure);
|
|
270
|
+
this.suspect.add(capability); // its move check comes before its retry enrolls (0.56.0 seam r2)
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
await this.settle(capability, () => this.recoverOne(capability, status, transfer[capability], was, generations, onHold), transfer[capability] ?? []);
|
|
275
|
+
} catch (e) {
|
|
276
|
+
if (!(e instanceof BoardError)) throw e; // as the Python twin: only the board's refusals are left to retry
|
|
277
|
+
failure = e;
|
|
369
278
|
}
|
|
370
|
-
else await this.enroll(capability);
|
|
371
279
|
}
|
|
372
280
|
for (const entry of reconcile) {
|
|
373
281
|
if (this.slots[entry.capability]?.state === 'held') await this.reconcile(entry);
|
|
@@ -376,6 +284,21 @@ export class Responder {
|
|
|
376
284
|
this.checkpoint.put(entry);
|
|
377
285
|
}
|
|
378
286
|
}
|
|
287
|
+
if (failure) throw failure;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async recoverOne(capability, status, held, was, generations, onHold) {
|
|
291
|
+
if (await this.gone(capability, status)) return; // moved away: never replaced at recovery (FAA-155)
|
|
292
|
+
if (onHold.has(capability)) {
|
|
293
|
+
for (const entry of held ?? []) {
|
|
294
|
+
entry.step = 'lapsed'; // taken back by a transfer once the hold is lifted
|
|
295
|
+
this.checkpoint.put(entry);
|
|
296
|
+
}
|
|
297
|
+
this.pause(capability);
|
|
298
|
+
} else if (held?.length) { // at the generation a replay just recorded, else com.status's
|
|
299
|
+
const mine = this.checkpoint.slots[capability]?.generation;
|
|
300
|
+
await this.replace(capability, held, isInt(mine, 1) && mine !== was ? mine : generations[capability] ?? 0);
|
|
301
|
+
} else await this.enroll(capability);
|
|
379
302
|
}
|
|
380
303
|
|
|
381
304
|
async replace(capability, entries, generation) {
|
|
@@ -405,7 +328,7 @@ export class Responder {
|
|
|
405
328
|
// A board pinned before DEC-0078 hands back no attempt. Never claim it back: it stays held.
|
|
406
329
|
entry.step = 'held';
|
|
407
330
|
this.checkpoint.put(entry);
|
|
408
|
-
this.emit('board.request.held', '
|
|
331
|
+
this.emit('board.request.held', 'warning', 'An accepted request could not be resumed: this Fabric does not hand back a transferred attempt (DEC-0078). It stays held; nothing was done.', { notify: true, episode: entry.request, request: entry.request, capability });
|
|
409
332
|
continue;
|
|
410
333
|
}
|
|
411
334
|
if (entry.step === 'replying' && !entry.replied && entry.keys?.reply && !entry.probe) {
|
|
@@ -497,7 +420,7 @@ export class Responder {
|
|
|
497
420
|
if (!outcome) {
|
|
498
421
|
entry.step = 'unknown';
|
|
499
422
|
this.checkpoint.put(entry);
|
|
500
|
-
this.emit('board.reconcile.needed', '
|
|
423
|
+
this.emit('board.reconcile.needed', 'warning', 'A request\'s outcome is unknown: the work may have happened. It stays unknown until this service observes what happened.', { notify: true, episode: entry.request, request: entry.request, capability: entry.capability });
|
|
501
424
|
return;
|
|
502
425
|
}
|
|
503
426
|
const sent = Object.fromEntries(['state', 'summary', 'evidence'].filter((k) => k in outcome).map((k) => [k, outcome[k]]));
|
|
@@ -538,7 +461,8 @@ export class Responder {
|
|
|
538
461
|
|
|
539
462
|
/** Seconds this session's leases still run, by its own clock: the running attempt's, else the oldest held slot's. */
|
|
540
463
|
leaseLeft() {
|
|
541
|
-
const renewed = this.current ? [this.current.renewedAt ?? 0]
|
|
464
|
+
const renewed = this.current ? [this.current.renewedAt ?? 0]
|
|
465
|
+
: Object.values(this.slots).filter((s) => s.state === 'held' && s.renewedAt !== -Infinity).map((s) => s.renewedAt ?? 0); // a lapsed slot holds no lease (FAA-163)
|
|
542
466
|
return renewed.length && this.session ? Math.min(...renewed) + LEASE_SECONDS - this.now() : 0;
|
|
543
467
|
}
|
|
544
468
|
|
|
@@ -553,7 +477,7 @@ export class Responder {
|
|
|
553
477
|
if (['not_authorized', 'authority_changed', 'credential'].includes(code)) {
|
|
554
478
|
this.state = 'not-authorized';
|
|
555
479
|
await this.dropSession();
|
|
556
|
-
this.emit('board.access.needed', '
|
|
480
|
+
this.emit('board.access.needed', 'warning', '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 });
|
|
557
481
|
} else if (code === 'held') {
|
|
558
482
|
this.state = 'paused';
|
|
559
483
|
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 });
|
|
@@ -566,7 +490,7 @@ export class Responder {
|
|
|
566
490
|
this.state = 'down';
|
|
567
491
|
// A refusal in recovery that left no slot held reconnects (seam 6); one that left some held serves them (seam 7).
|
|
568
492
|
if ((connecting || ['session-ended', 'unreachable', 'not-running', 'not-a-hub', 'hub-unavailable', 'protocol'].includes(code)) && !keep) await this.dropSession();
|
|
569
|
-
this.emit('board.down', '
|
|
493
|
+
this.emit('board.down', 'warning', 'Fabric\'s board cannot be reached; the responder tries again with a growing pause.', { episode: 'down', reason: code });
|
|
570
494
|
}
|
|
571
495
|
return wait;
|
|
572
496
|
}
|
|
@@ -588,7 +512,8 @@ export class Responder {
|
|
|
588
512
|
}
|
|
589
513
|
const session = this.session;
|
|
590
514
|
this.session = null;
|
|
591
|
-
|
|
515
|
+
// The DELETE released the slots: a standby taking one is no move (FAA-163).
|
|
516
|
+
if (session && await session.close()) this.checkpoint.leave(this.project, this.heldNow());
|
|
592
517
|
}
|
|
593
518
|
|
|
594
519
|
async tick() {
|
|
@@ -606,7 +531,11 @@ export class Responder {
|
|
|
606
531
|
if (slot.state === 'moved') continue; // never enrolled again on its own, even when the new holder's lease lapses (FAA-155)
|
|
607
532
|
if (slot.state === 'paused') {
|
|
608
533
|
// still held: a longer pause; lifted: the slot is ours again
|
|
609
|
-
if (capability !== heldByWork && this.now() >= (slot.retryAt ?? 0)) await this.enroll(capability);
|
|
534
|
+
if (capability !== heldByWork && this.now() >= (slot.retryAt ?? 0) && !(await this.gone(capability))) await this.enroll(capability); // a move is read first
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
if (slot.state === 'retry') { // its enroll was refused: asked again alone (FAA-163)
|
|
538
|
+
if (this.now() >= (slot.retryAt ?? 0)) await this.settle(capability, async () => (await this.gone(capability)) || this.enroll(capability));
|
|
610
539
|
continue;
|
|
611
540
|
}
|
|
612
541
|
if (capability !== heldByWork && this.now() - (slot.renewedAt ?? 0) >= RENEW_SECONDS - 0.5) await this.enroll(capability);
|
|
@@ -692,12 +621,12 @@ export class Responder {
|
|
|
692
621
|
const req = isObj(message) ? message.request : null;
|
|
693
622
|
if (message.digest !== entry.digest) {
|
|
694
623
|
this.checkpoint.drop(entry.request);
|
|
695
|
-
this.emit('board.request.skipped', '
|
|
624
|
+
this.emit('board.request.skipped', 'warning', 'A claimed request was not accepted: the message read back carries another digest than the claim. Nothing was done; the claim lapses.', { request: entry.request, capability, reason: 'digest-mismatch' });
|
|
696
625
|
return;
|
|
697
626
|
}
|
|
698
627
|
if (!isObj(req) || req.id !== entry.request || req.capability !== capability || req.target !== this.project) {
|
|
699
628
|
this.checkpoint.drop(entry.request);
|
|
700
|
-
this.emit('board.request.skipped', '
|
|
629
|
+
this.emit('board.request.skipped', 'warning', 'A claimed request was not accepted: it is not addressed to this Project and capability. Nothing was done; the claim lapses.', { request: entry.request, capability, reason: 'not-addressed' });
|
|
701
630
|
return;
|
|
702
631
|
}
|
|
703
632
|
const request = new BoardRequest(message, capability);
|
|
@@ -797,17 +726,17 @@ export class Responder {
|
|
|
797
726
|
if (entry.effect) {
|
|
798
727
|
entry.step = 'unknown';
|
|
799
728
|
this.checkpoint.put(entry);
|
|
800
|
-
this.emit('board.reconcile.needed', '
|
|
729
|
+
this.emit('board.reconcile.needed', 'warning', 'The attempt was lost after its effect began; the outcome stays unknown until this service observes what happened.', { notify: true, episode: entry.request, request: entry.request, capability: entry.capability, reason: code });
|
|
801
730
|
} else if (!['claimed', 'accepting'].includes(entry.step) && (code === 'lease_expired' || (code === 'fenced' && entry.capability in this.checkpoint.slots))) {
|
|
802
731
|
// Accepted work whose lease lapsed, or fenced off a slot this responder held (moved, FAA-155): the board holds it
|
|
803
732
|
// (C4). It continues only by a checkpoint transfer to this responder while it holds the slot.
|
|
804
733
|
entry.step = 'lapsed';
|
|
805
734
|
this.checkpoint.put(entry);
|
|
806
|
-
this.emit('board.attempt.lost', '
|
|
735
|
+
this.emit('board.attempt.lost', 'warning', code === 'lease_expired' ? 'This service\'s lease on an accepted request lapsed; it stopped working on it and takes it back by a transfer.'
|
|
807
736
|
: 'The board fenced an accepted request away from this service; it stopped working on it, and nothing had been done outside it.', { request: entry.request, capability: entry.capability, reason: code });
|
|
808
737
|
} else {
|
|
809
738
|
this.checkpoint.drop(entry.request);
|
|
810
|
-
this.emit('board.attempt.lost', '
|
|
739
|
+
this.emit('board.attempt.lost', 'warning', `The board took a request back from this service (${code}); it stopped working on it, and nothing had been done outside the service.`, { request: entry.request, capability: entry.capability, reason: code });
|
|
811
740
|
}
|
|
812
741
|
}
|
|
813
742
|
|
|
@@ -892,7 +821,7 @@ export class Responder {
|
|
|
892
821
|
}
|
|
893
822
|
const problems = outcomeProblems(outcome, !!entry.effect);
|
|
894
823
|
if (problems.length) {
|
|
895
|
-
this.emit('board.outcome.invalid', '
|
|
824
|
+
this.emit('board.outcome.invalid', 'warning', `The handler's outcome was not sent: ${problems[0]}.`, { request: entry.request, capability: entry.capability });
|
|
896
825
|
if (entry.effect) await this.settleLive(entry, problems.some((p) => p.includes('observation_required')) ? 'observation_required' : 'outcome');
|
|
897
826
|
else {
|
|
898
827
|
entry.outcome = { state: 'failed_known', observed: true, summary: 'The service answered in a form it cannot send.' };
|
|
@@ -990,9 +919,11 @@ export class Responder {
|
|
|
990
919
|
}
|
|
991
920
|
}
|
|
992
921
|
if (this.session) {
|
|
993
|
-
|
|
922
|
+
const session = this.session;
|
|
994
923
|
this.session = null;
|
|
995
|
-
|
|
924
|
+
// Only a DELETE Fabric answered released the slots (FAA-163): no record of them can read as a move. An idle stop
|
|
925
|
+
// waits for it 3 s (FAA-173).
|
|
926
|
+
if (await session.close(work ? null : IDLE_LEAVE_SECONDS * 1000)) this.checkpoint.leave(this.project, this.heldNow());
|
|
996
927
|
}
|
|
997
928
|
this.state = 'stopped';
|
|
998
929
|
}
|