@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +9 -3
  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 +1 -1
  6. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -4
  7. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +64 -0
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +264 -0
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +3 -2
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +128 -0
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +10 -183
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +286 -0
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +840 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +1 -16
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +13 -45
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +8 -67
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -2
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +202 -0
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +2 -12
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +400 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +402 -0
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +78 -0
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +902 -0
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -38
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +289 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +15 -180
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +39 -400
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +137 -0
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +1 -6
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
  62. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
  63. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
  64. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
  65. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
  66. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +21 -43
  67. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
  68. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
  69. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +31 -38
  70. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
  71. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
  72. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
  73. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +412 -0
  74. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
  75. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
  76. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1526 -0
  77. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
  78. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
  79. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  80. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +116 -1392
  81. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +270 -0
  82. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +508 -0
  83. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +265 -0
  84. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +155 -0
  85. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +335 -0
  86. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +1 -0
@@ -0,0 +1,840 @@
1
+ // The board responder (DEC-0078, FAA-12b): a board session over the hub, the fsynced checkpoint, the request a handler sees and the responder loop.
2
+ // Part of the Node board connector (FAA-65, layer L4); the twin of fabric_board_session.py. Node 20+, no dependency.
3
+ import crypto from 'node:crypto';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { Backoff, HubError, MCP_PROTOCOL_VERSION, normalProject, postMcp, toolAnswer } from './fabric-hub.mjs';
7
+ import { acceptedRevisions, atomicWrite, ensurePrivateDir, feedDataProblems, negotiateRevision } from './fabric-service.mjs';
8
+ import { BoardError, CAPABILITY, REQUEST_STATES, RESPOND_TOOLS, SUMMARY_MAX, Stopped, isInt, isObj, outcomeProblems, receiptProblems, refusalProblems } from './fabric-board-shapes.mjs';
9
+
10
+ export const RENEW_SECONDS = 20;
11
+
12
+ export const POLL_SECONDS = 15;
13
+
14
+ export const POLL_JITTER_SECONDS = 5;
15
+
16
+ export const DRAIN_SECONDS = 10;
17
+
18
+ export const EVENT_SCHEMA = 'urn:fabric:schema:board-responder-event:1';
19
+
20
+ export const STOP_CODES = Object.freeze(new Set(['fenced', 'lease_expired', 'authority_changed']));
21
+
22
+ const TERMINAL = new Set(['completed', 'failed_known', 'cancelled', 'expired']);
23
+
24
+ /** How long a held slot waits before it asks again: doubling from the poll interval, never past this. */
25
+ export const HOLD_RETRY_MAX_SECONDS = 900;
26
+
27
+ /** MCP over HTTP with a board session's one-session bearer; calls are serialised; close() sends the session's DELETE. */
28
+ export class BoardSession {
29
+ constructor(grant, { timeout = 15000, post = postMcp } = {}) {
30
+ Object.assign(this, { grant, timeout, post });
31
+ this.mcpSession = null;
32
+ this.protocolVersion = MCP_PROTOCOL_VERSION;
33
+ this.rpc = 0;
34
+ this.queue = Promise.resolve();
35
+ }
36
+
37
+ #serial(fn) {
38
+ const run = this.queue.then(fn, fn);
39
+ this.queue = run.catch(() => {});
40
+ return run;
41
+ }
42
+
43
+ async #send(body, method = 'POST') {
44
+ try {
45
+ return await this.post(this.grant.origin, body, this.grant.bearer(), { session: this.mcpSession, timeout: this.timeout, method, protocolVersion: this.mcpSession ? this.protocolVersion : undefined });
46
+ } catch (e) {
47
+ 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);
48
+ throw e;
49
+ }
50
+ }
51
+
52
+ open() {
53
+ return this.#serial(async () => {
54
+ this.rpc += 1;
55
+ const answer = await this.#send({ jsonrpc: '2.0', id: this.rpc, method: 'initialize', params: { protocolVersion: MCP_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: 'fabric-agent-adapter-board', version: '1' } } });
56
+ if (answer.status === 401) throw new BoardError('session-ended', 'Fabric refused the board session\'s bearer; join again.', true);
57
+ const sid = answer.headers['mcp-session-id'];
58
+ if (answer.status !== 200 || !isObj(answer.body) || !isObj(answer.body.result) || !sid) throw new BoardError('unreachable', `Fabric did not open the board session (HTTP ${answer.status}).`, true);
59
+ this.mcpSession = String(sid);
60
+ if (typeof answer.body.result.protocolVersion === 'string') this.protocolVersion = answer.body.result.protocolVersion;
61
+ await this.#send({ jsonrpc: '2.0', method: 'notifications/initialized' });
62
+ });
63
+ }
64
+
65
+ call(tool, args) {
66
+ if (!RESPOND_TOOLS.has(tool)) return Promise.reject(new BoardError('not-in-scope', `${tool} is not served to a board session; use a Project session for it.`, false, tool));
67
+ return this.#serial(async () => {
68
+ if (!this.mcpSession) throw new BoardError('session-ended', 'The board session is not open.', true, tool);
69
+ this.rpc += 1;
70
+ const answer = await this.#send({ jsonrpc: '2.0', id: this.rpc, method: 'tools/call', params: { name: tool, arguments: args } });
71
+ if (answer.status === 401) throw new BoardError('session-ended', 'Fabric ended the board session (it left, was revoked or expired); join again.', true, tool);
72
+ let value;
73
+ try {
74
+ value = toolAnswer(answer, tool);
75
+ } catch (e) {
76
+ throw new BoardError(e.retryable ? 'unreachable' : 'receipt-shape', e.message, e.retryable, tool);
77
+ }
78
+ if ('error' in value) {
79
+ if (refusalProblems(value).length) throw new BoardError('receipt-shape', `Fabric refused ${tool} in a form the contract does not name.`, false, tool);
80
+ throw new BoardError(value.error.code, value.error.message, value.error.retryable, tool);
81
+ }
82
+ return value;
83
+ });
84
+ }
85
+
86
+ close() {
87
+ return this.#serial(async () => {
88
+ if (!this.mcpSession) return;
89
+ try {
90
+ await this.#send({}, 'DELETE');
91
+ } catch {
92
+ // best effort: the lease lapses in 60 s anyway
93
+ }
94
+ this.mcpSession = null;
95
+ });
96
+ }
97
+ }
98
+
99
+ /** The responder's journal (0600 JSON, the same file fabric_board.py writes), fsynced before the step it announces. */
100
+ export class Checkpoint {
101
+ constructor(file) {
102
+ this.file = file;
103
+ this.entries = this.#load();
104
+ }
105
+ #load() {
106
+ let text;
107
+ try {
108
+ text = fs.readFileSync(this.file, 'utf8');
109
+ } catch (e) {
110
+ if (e.code === 'ENOENT') return {};
111
+ throw new BoardError('checkpoint', `The board checkpoint ${this.file} cannot be read (${e.code}); fix or move it before answering requests.`);
112
+ }
113
+ let doc;
114
+ try {
115
+ doc = JSON.parse(text);
116
+ } catch {
117
+ doc = null;
118
+ }
119
+ if (!isObj(doc) || doc.version !== 1 || !isObj(doc.requests)) throw new BoardError('checkpoint', `The board checkpoint ${this.file} is not one this kit writes.`);
120
+ return doc.requests;
121
+ }
122
+ #write(entries) {
123
+ try {
124
+ ensurePrivateDir(path.dirname(this.file));
125
+ const sorted = Object.fromEntries(Object.keys(entries).sort().map((k) => [k, entries[k]]));
126
+ atomicWrite(this.file, `${JSON.stringify({ requests: sorted, version: 1 }, null, 1)}\n`, 0o600);
127
+ } catch (e) {
128
+ throw new BoardError('checkpoint', `The board checkpoint could not be written (${e.code ?? e.message}); nothing was sent.`);
129
+ }
130
+ }
131
+ put(entry) {
132
+ const entries = { ...this.entries, [entry.request]: JSON.parse(JSON.stringify(entry)) };
133
+ this.#write(entries);
134
+ this.entries = entries;
135
+ }
136
+ drop(requestId) {
137
+ if (!(requestId in this.entries)) return;
138
+ const { [requestId]: _gone, ...entries } = this.entries;
139
+ this.#write(entries);
140
+ this.entries = entries;
141
+ }
142
+ }
143
+
144
+ /** A claimed request as data: never an instruction. */
145
+ export class BoardRequest {
146
+ constructor(message, capability) {
147
+ const req = isObj(message.request) ? message.request : {};
148
+ const body = isObj(message.body) ? message.body : {};
149
+ this.requestId = req.id;
150
+ this.messageId = message.id;
151
+ this.capability = capability;
152
+ this.body = typeof body.text === 'string' ? body.text : null;
153
+ this.bodyFormat = body.format ?? 'text/plain';
154
+ this.artifacts = Array.isArray(message.artifacts) ? [...message.artifacts] : [];
155
+ this.sender = isObj(message.sender) ? message.sender : {};
156
+ this.deadline = req.deadline;
157
+ this.thread = message.thread;
158
+ }
159
+ toJSON() {
160
+ return { requestId: this.requestId, capability: this.capability }; // never the body
161
+ }
162
+ }
163
+
164
+ /** What a handler may do while it works: progress(note), beginEffect(summary) before it spends or acts, and `stopped`. */
165
+ export class WorkContext {
166
+ #responder;
167
+ #entry;
168
+ constructor(responder, entry) {
169
+ this.#responder = responder;
170
+ this.#entry = entry;
171
+ this.stopped = false;
172
+ }
173
+ async progress(note) {
174
+ if (this.stopped) throw new Stopped();
175
+ await this.#responder.progress(this.#entry, note, this);
176
+ }
177
+ async beginEffect(summary) {
178
+ if (this.stopped) throw new Stopped();
179
+ await this.#responder.effectBegin(this.#entry, summary, this);
180
+ }
181
+ }
182
+
183
+ const clean = (text, max = SUMMARY_MAX) => String(text).split(/\s+/).filter(Boolean).join(' ').slice(0, max);
184
+
185
+ /**
186
+ * One service's responder for one Project and its capabilities, one request at a time. handler(request, ctx) answers
187
+ * the outcome (may be async); admit(request) is the input gate; observe(entry) reads the world after a restart;
188
+ * emit(kind, level, text, extra) receives events. run({signal}) ticks until aborted; step() is one tick.
189
+ */
190
+ export class Responder {
191
+ constructor({ hub, projectId, capabilities, credentialFile, checkpointFile, handler, admit, observe, emit, service = 'service',
192
+ now = () => performance.now() / 1000, rand = Math.random, sessionFactory } = {}) {
193
+ 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.');
194
+ const caps = [...(capabilities ?? [])];
195
+ 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}.');
196
+ Object.assign(this, { hub, capabilities: caps, credentialFile, handler, admit, observe, emitTo: emit, now, rand });
197
+ this.project = normalProject(projectId);
198
+ this.service = String(service).replace(/[^A-Za-z0-9._-]/g, '-').slice(0, 40) || 'service';
199
+ this.checkpoint = new Checkpoint(checkpointFile);
200
+ this.sessionFactory = sessionFactory ?? ((grant) => new BoardSession(grant));
201
+ this.backoff = new Backoff({ rand });
202
+ this.session = null;
203
+ this.epoch = null;
204
+ this.slots = {};
205
+ this.current = null;
206
+ this.state = 'idle';
207
+ this.waitUntil = 0;
208
+ this.stopping = false;
209
+ this.claimKeys = {};
210
+ this.said = {};
211
+ this.rr = 0;
212
+ this.reconcileAt = 0;
213
+ /** The first pause under an operator's hold (DEC-0079); it doubles to HOLD_RETRY_MAX_SECONDS. */
214
+ this.holdDelay = POLL_SECONDS;
215
+ this.onHold = new Set();
216
+ }
217
+
218
+ // -- events --
219
+ emit(kind, level, text, { notify = false, episode, ...value } = {}) {
220
+ if (episode !== undefined) {
221
+ if (this.said[kind] === episode) return;
222
+ this.said[kind] = episode;
223
+ }
224
+ let data = { schema: EVENT_SCHEMA, value: Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined && v !== null)) };
225
+ if (feedDataProblems(data).length) data = undefined; // FAC-SEM-101: drop the data, keep the sentence
226
+ if (!this.emitTo) return;
227
+ try {
228
+ this.emitTo(kind, level, text, { notify, ...(data ? { data } : {}) });
229
+ } catch {
230
+ // an event that cannot be written never stops the responder
231
+ }
232
+ }
233
+
234
+ // -- keys and fences --
235
+ key(op) {
236
+ return `${this.service}.${op}.${crypto.randomBytes(8).toString('hex')}`;
237
+ }
238
+ idem(key) {
239
+ if (this.epoch === null) throw new BoardError('no-epoch', 'Fabric told this board session no idempotency epoch.', false, 'com.status');
240
+ return { epoch: this.epoch, key };
241
+ }
242
+ fence(entry, key) {
243
+ return { request: entry.request, generation: entry.generation, attempt: entry.attempt, digest: entry.digest, expectedRevision: entry.revision, idempotency: this.idem(key) };
244
+ }
245
+ receipt(op, answer) {
246
+ const problems = receiptProblems(answer, op);
247
+ if (problems.length) throw new BoardError('receipt-shape', `Fabric answered ${op} with a receipt the contract does not name (${problems[0]}).`, false, op);
248
+ return answer;
249
+ }
250
+ call(op, args) {
251
+ if (!this.session) return Promise.reject(new BoardError('session-ended', 'No board session.', true, op));
252
+ return this.session.call(op, args);
253
+ }
254
+
255
+ // -- connecting --
256
+ async status() {
257
+ const status = await this.call('com.status', {});
258
+ const verdict = negotiateRevision(status.protocol, acceptedRevisions('fabric-project-comms'));
259
+ if (verdict.error) throw new BoardError('schema-unsupported', `Fabric's board speaks ${status.protocol}, which this kit does not read.`, false, 'com.status', verdict.error.data);
260
+ if (status.board?.state === 'unavailable') throw new BoardError('not_available', 'Fabric\'s board cannot be read right now.', true, 'com.status');
261
+ const epoch = status.idempotency?.epoch;
262
+ // Fail closed: a guessed epoch would store keys under a namespace the board never named.
263
+ if (!isInt(epoch, 1)) throw new BoardError('no-epoch', 'Fabric told this board session no idempotency epoch (Fabric before its board-session fix); the responder waits rather than guess one.', false, 'com.status');
264
+ this.epoch = epoch;
265
+ return status;
266
+ }
267
+
268
+ async connect() {
269
+ this.state = 'connecting';
270
+ const grant = await this.hub.joinBoard(this.project, this.credentialFile);
271
+ const missing = this.capabilities.filter((c) => !grant.capabilities.includes(c));
272
+ const session = this.sessionFactory(grant);
273
+ await session.open();
274
+ this.session = session;
275
+ const status = await this.status();
276
+ if (missing.length) {
277
+ this.emit('board.access.needed', 'warn', `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 });
278
+ }
279
+ this.slots = {};
280
+ await this.recover(status, this.capabilities.filter((c) => grant.capabilities.includes(c)));
281
+ this.state = 'ready';
282
+ for (const kind of ['board.down', 'board.blocked', ...(missing.length ? [] : ['board.access.needed'])]) delete this.said[kind];
283
+ }
284
+
285
+ async enroll(capability) {
286
+ const args = { idempotency: this.idem(this.key('enroll')), capability };
287
+ let receipt;
288
+ try {
289
+ receipt = this.receipt('com.enroll', await this.call('com.enroll', args));
290
+ } catch (e) {
291
+ if (e.code === 'held') {
292
+ this.pause(capability);
293
+ return;
294
+ }
295
+ if (e.code === 'fenced') {
296
+ this.slots[capability] = { state: 'contested', renewedAt: this.now() };
297
+ 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 });
298
+ return;
299
+ }
300
+ throw e;
301
+ }
302
+ this.slots[capability] = { state: 'held', generation: receipt.slot.generation, renewedAt: this.now() };
303
+ delete this.said['board.slot.contested'];
304
+ this.lifted(capability);
305
+ }
306
+
307
+ /**
308
+ * The operator holds this slot or its Project (DEC-0079): ask again later, the pause doubling each time, and never act
309
+ * around the hold. Accepted work in progress continues; only new work waits.
310
+ */
311
+ pause(capability) {
312
+ const slot = this.slots[capability] ?? {};
313
+ const pauses = slot.state === 'paused' ? (slot.pauses ?? 0) + 1 : 1;
314
+ const wait = Math.min(HOLD_RETRY_MAX_SECONDS, this.holdDelay * 2 ** (pauses - 1));
315
+ this.slots[capability] = { state: 'paused', pauses, retryAt: this.now() + wait, renewedAt: this.now() };
316
+ if (!this.onHold.has(capability)) { // once per hold, per capability
317
+ this.onHold.add(capability);
318
+ 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 });
319
+ }
320
+ }
321
+
322
+ lifted(capability) {
323
+ if (!this.onHold.delete(capability)) return;
324
+ this.emit('board.slot.released', 'info', `The operator's hold on ${capability} was lifted; the service takes new work again.`, { capability });
325
+ }
326
+
327
+ // -- restart: reconcile and transfer --
328
+ async readState(entry) {
329
+ const message = await this.call('com.get', { message: entry.message });
330
+ const req = isObj(message) ? message.request : null;
331
+ if (!isObj(req) || req.id !== entry.request || !REQUEST_STATES.includes(req.state)) return null;
332
+ entry.revision = req.revision ?? entry.revision;
333
+ return req.state;
334
+ }
335
+
336
+ async recover(status, capabilities) {
337
+ const transfer = {};
338
+ const reconcile = [];
339
+ for (const entry of Object.values(this.checkpoint.entries)) {
340
+ if (!capabilities.includes(entry.capability)) continue;
341
+ let state;
342
+ try {
343
+ state = await this.readState(entry);
344
+ } catch (e) {
345
+ if (e.code === 'not_authorized') {
346
+ this.checkpoint.drop(entry.request);
347
+ continue;
348
+ }
349
+ throw e;
350
+ }
351
+ if (state === null || TERMINAL.has(state) || state === 'queued' || state === 'claimed') this.checkpoint.drop(entry.request);
352
+ else if (state === 'accepted') (transfer[entry.capability] ??= []).push(entry);
353
+ else reconcile.push(entry);
354
+ }
355
+ const responders = (status.responders ?? []).filter(isObj);
356
+ const generations = Object.fromEntries(responders.map((r) => [r.capability, r.generation ?? 0]));
357
+ const onHold = new Set(responders.filter((r) => r.held === true).map((r) => r.capability)); // DEC-0079
358
+ for (const capability of capabilities) {
359
+ if (onHold.has(capability)) {
360
+ for (const entry of transfer[capability] ?? []) {
361
+ entry.step = 'lapsed'; // taken back by a transfer once the hold is lifted
362
+ this.checkpoint.put(entry);
363
+ }
364
+ this.pause(capability);
365
+ } else if (transfer[capability]?.length) await this.replace(capability, transfer[capability], generations[capability] ?? 0);
366
+ else await this.enroll(capability);
367
+ }
368
+ for (const entry of reconcile) if (this.slots[entry.capability]?.state === 'held') await this.reconcile(entry);
369
+ }
370
+
371
+ async replace(capability, entries, generation) {
372
+ const args = { idempotency: this.idem(this.key('replace')), capability, expectedGeneration: isInt(generation, 0) ? generation : 0,
373
+ transfer: { requests: entries.map((e) => e.request).slice(0, 64), checkpoint: { summary: 'Resumed by the same service after a restart; no effect had begun.' } } };
374
+ let receipt;
375
+ try {
376
+ receipt = this.receipt('com.responder_replace', await this.call('com.responder_replace', args));
377
+ } catch (e) {
378
+ if (e.code !== 'held') throw e;
379
+ for (const entry of entries) {
380
+ entry.step = 'lapsed'; // nothing moved: taken back by a transfer once the hold is lifted
381
+ this.checkpoint.put(entry);
382
+ }
383
+ this.pause(capability);
384
+ return;
385
+ }
386
+ this.slots[capability] = { state: 'held', generation: receipt.slot.generation, renewedAt: this.now() };
387
+ this.lifted(capability);
388
+ const moved = new Map((receipt.transferred ?? []).map((item) => [item.request, item]));
389
+ for (const entry of entries) {
390
+ const item = moved.get(entry.request);
391
+ if (!item) {
392
+ // A board pinned before DEC-0078 hands back no attempt. Never claim it back: it stays held.
393
+ entry.step = 'held';
394
+ this.checkpoint.put(entry);
395
+ this.emit('board.request.held', 'warn', '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 });
396
+ continue;
397
+ }
398
+ Object.assign(entry, { generation: item.attempt.generation, attempt: item.attempt.id, digest: item.attempt.digest, revision: item.revision,
399
+ step: ['accepting', 'held', 'lapsed'].includes(entry.step) ? 'accepted' : entry.step });
400
+ this.checkpoint.put(entry);
401
+ if (!this.current) await this.resume(entry);
402
+ }
403
+ }
404
+
405
+ async reconcile(entry) {
406
+ const state = await this.readState(entry);
407
+ if (state !== 'outcome_unknown') {
408
+ if (state !== null && (TERMINAL.has(state) || state === 'queued' || state === 'claimed')) this.checkpoint.drop(entry.request);
409
+ return;
410
+ }
411
+ let outcome = isObj(entry.outcome) && entry.outcome.observed === true ? entry.outcome : null;
412
+ if (!outcome && this.observe) {
413
+ try {
414
+ outcome = await this.observe({ ...entry });
415
+ } catch {
416
+ outcome = null;
417
+ }
418
+ }
419
+ if (!isObj(outcome) || outcome.observed !== true || outcomeProblems({ ...outcome, observed: true }, true).length) {
420
+ entry.step = 'unknown';
421
+ this.checkpoint.put(entry);
422
+ this.emit('board.reconcile.needed', 'warn', '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 });
423
+ return;
424
+ }
425
+ const sent = Object.fromEntries(['state', 'summary', 'evidence'].filter((k) => k in outcome).map((k) => [k, outcome[k]]));
426
+ sent.observed = true;
427
+ entry.keys ??= {};
428
+ entry.keys.reconcile ??= this.key('reconcile');
429
+ entry.step = 'reconciling';
430
+ this.checkpoint.put(entry);
431
+ this.receipt('com.reconcile', await this.call('com.reconcile', { idempotency: this.idem(entry.keys.reconcile), request: entry.request, digest: entry.digest, outcome: sent }));
432
+ this.checkpoint.drop(entry.request);
433
+ this.emit('board.request.reconciled', 'info', 'A request whose outcome was unknown was settled from what this service observed.', { request: entry.request, capability: entry.capability, state: sent.state });
434
+ }
435
+
436
+ // -- one tick --
437
+ interval() {
438
+ return Math.max(1, POLL_SECONDS + POLL_JITTER_SECONDS * (2 * this.rand() - 1));
439
+ }
440
+
441
+ /** One tick; answers the seconds to wait before the next. */
442
+ async step() {
443
+ if (this.stopping) return DRAIN_SECONDS;
444
+ const now = this.now();
445
+ if (now < this.waitUntil) return this.waitUntil - now;
446
+ try {
447
+ if (!this.session) await this.connect();
448
+ await this.tick();
449
+ this.backoff.reset();
450
+ return this.interval();
451
+ } catch (e) {
452
+ if (!(e instanceof BoardError) && !(e instanceof HubError)) throw e;
453
+ return this.fault(e);
454
+ }
455
+ }
456
+
457
+ async fault(e) {
458
+ const code = e.code ?? 'unreachable';
459
+ const wait = this.backoff.next();
460
+ this.waitUntil = this.now() + wait;
461
+ if (['not_authorized', 'authority_changed', 'credential'].includes(code)) {
462
+ this.state = 'not-authorized';
463
+ await this.dropSession();
464
+ 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 });
465
+ } else if (code === 'held') {
466
+ this.state = 'paused';
467
+ 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 });
468
+ } else if (['no-epoch', 'schema-unsupported', 'checkpoint', 'receipt-shape'].includes(code)) {
469
+ this.state = 'down';
470
+ await this.dropSession();
471
+ this.emit('board.blocked', 'error', `The responder stopped answering requests: ${e.message}`, { notify: true, episode: code, reason: code });
472
+ } else {
473
+ this.state = 'down';
474
+ if (['session-ended', 'unreachable', 'not-running', 'not-a-hub', 'hub-unavailable', 'protocol'].includes(code)) await this.dropSession();
475
+ this.emit('board.down', 'warn', 'Fabric\'s board cannot be reached; the responder tries again with a growing pause.', { episode: 'down', reason: code });
476
+ }
477
+ return wait;
478
+ }
479
+
480
+ async dropSession() {
481
+ const work = this.current;
482
+ this.current = null;
483
+ if (work) {
484
+ const { entry } = work;
485
+ if (work.done && !work.failure && !outcomeProblems(work.outcome, !!entry.effect).length) {
486
+ entry.outcome = { ...work.outcome, observed: work.outcome.observed ?? !entry.effect };
487
+ try {
488
+ this.checkpoint.put(entry);
489
+ } catch {
490
+ // the next start reruns it
491
+ }
492
+ } else work.ctx.stopped = true;
493
+ }
494
+ const session = this.session;
495
+ this.session = null;
496
+ if (session) await session.close();
497
+ }
498
+
499
+ async tick() {
500
+ const work = this.current;
501
+ if (work) {
502
+ if (work.done) await this.finish(work);
503
+ else await this.renew(work);
504
+ }
505
+ const heldByWork = work && this.current ? work.entry.capability : null;
506
+ for (const [capability, slot] of Object.entries(this.slots)) {
507
+ if (slot.state === 'paused') {
508
+ // still held: a longer pause; lifted: the slot is ours again
509
+ if (capability !== heldByWork && this.now() >= (slot.retryAt ?? 0)) await this.enroll(capability);
510
+ continue;
511
+ }
512
+ if (capability !== heldByWork && this.now() - (slot.renewedAt ?? 0) >= RENEW_SECONDS - 0.5) await this.enroll(capability);
513
+ }
514
+ if (!this.current && !this.stopping) await this.continueLapsed();
515
+ if (!this.current && !this.stopping) {
516
+ await this.reconcilePending();
517
+ await this.claimNext();
518
+ }
519
+ }
520
+
521
+ async continueLapsed() {
522
+ const lapsed = {};
523
+ for (const entry of Object.values(this.checkpoint.entries)) {
524
+ if (entry.step === 'lapsed' && this.slots[entry.capability]?.state === 'held') (lapsed[entry.capability] ??= []).push(JSON.parse(JSON.stringify(entry)));
525
+ }
526
+ for (const [capability, entries] of Object.entries(lapsed)) {
527
+ const live = [];
528
+ for (const e of entries) {
529
+ if (await this.readState(e) === 'accepted') live.push(e);
530
+ else this.checkpoint.drop(e.request);
531
+ }
532
+ if (live.length) await this.replace(capability, live, this.slots[capability].generation ?? 0);
533
+ }
534
+ }
535
+
536
+ async reconcilePending() {
537
+ if (this.now() < this.reconcileAt) return;
538
+ this.reconcileAt = this.now() + 300;
539
+ for (const entry of Object.values(this.checkpoint.entries)) {
540
+ if (entry.step === 'unknown' && this.slots[entry.capability]?.state === 'held') await this.reconcile(entry);
541
+ }
542
+ }
543
+
544
+ async claimNext() {
545
+ const held = this.capabilities.filter((c) => this.slots[c]?.state === 'held');
546
+ for (let i = 0; i < held.length; i += 1) {
547
+ const capability = held[(this.rr + i) % held.length];
548
+ this.claimKeys[capability] ??= this.key('claim');
549
+ let answer;
550
+ try {
551
+ answer = this.receipt('com.claim', await this.call('com.claim', { idempotency: this.idem(this.claimKeys[capability]), capability }));
552
+ } catch (e) {
553
+ if (e.code === 'lease_expired') {
554
+ await this.enroll(capability);
555
+ continue;
556
+ }
557
+ if (e.code === 'idempotency_window_expired' || e.code === 'capacity_exceeded') {
558
+ delete this.claimKeys[capability];
559
+ await this.status();
560
+ continue;
561
+ }
562
+ if (e.code === 'fenced') {
563
+ this.slots[capability] = { state: 'contested', renewedAt: this.now() };
564
+ continue;
565
+ }
566
+ if (e.code === 'held') {
567
+ this.pause(capability);
568
+ continue;
569
+ }
570
+ throw e;
571
+ }
572
+ if (!('request' in answer)) continue; // nothing claimable; the same key polls again
573
+ delete this.claimKeys[capability];
574
+ this.rr += 1;
575
+ await this.start(capability, answer);
576
+ return;
577
+ }
578
+ }
579
+
580
+ // -- a claimed request --
581
+ async start(capability, receipt) {
582
+ if (!receipt.attempt || !receipt.message) throw new BoardError('receipt-shape', 'com.claim answered a request without its attempt or message.', false, 'com.claim');
583
+ const { attempt } = receipt;
584
+ const entry = { request: receipt.request.id, message: receipt.message, capability, generation: attempt.generation, attempt: attempt.id, digest: attempt.digest,
585
+ revision: receipt.request.revision, step: 'claimed', keys: {}, effect: false };
586
+ this.checkpoint.put(entry);
587
+ const message = await this.call('com.get', { message: entry.message });
588
+ const req = isObj(message) ? message.request : null;
589
+ if (message.digest !== entry.digest) {
590
+ this.checkpoint.drop(entry.request);
591
+ this.emit('board.request.skipped', 'warn', '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' });
592
+ return;
593
+ }
594
+ if (!isObj(req) || req.id !== entry.request || req.capability !== capability || req.target !== this.project) {
595
+ this.checkpoint.drop(entry.request);
596
+ this.emit('board.request.skipped', 'warn', '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' });
597
+ return;
598
+ }
599
+ const request = new BoardRequest(message, capability);
600
+ let reason;
601
+ if (message.redacted === true || typeof request.body !== 'string') reason = 'The request\'s body was redacted, so there is nothing to act on.';
602
+ else {
603
+ try {
604
+ reason = (await this.admit(request)) ?? null;
605
+ } catch {
606
+ reason = 'The service\'s input gate could not read this request.';
607
+ }
608
+ }
609
+ if (!(await this.accept(entry))) return;
610
+ if (reason !== null && reason !== undefined) {
611
+ entry.outcome = { state: 'failed_known', observed: true, summary: clean(`Refused by this service's input gate: ${reason}`) };
612
+ await this.complete(entry);
613
+ this.emit('board.request.refused', 'info', 'A request was refused by the service\'s input gate and completed as failed.', { request: entry.request, capability });
614
+ return;
615
+ }
616
+ await this.runWork(entry, request);
617
+ }
618
+
619
+ async accept(entry) {
620
+ entry.keys.accept ??= this.key('accept');
621
+ entry.step = 'accepting';
622
+ this.checkpoint.put(entry);
623
+ let receipt;
624
+ try {
625
+ receipt = await this.fenced('com.accept', entry, { fence: this.fence(entry, entry.keys.accept) });
626
+ } catch (e) {
627
+ if (e.code !== 'held') throw e;
628
+ // A hold came between the claim and the accept (DEC-0079): nothing was done, the claim lapses and the request waits.
629
+ this.checkpoint.drop(entry.request);
630
+ 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 });
631
+ this.pause(entry.capability);
632
+ return false;
633
+ }
634
+ if (!receipt) return false; // the claim was lost before it was accepted; nothing was done
635
+ entry.revision = receipt.request.revision;
636
+ entry.step = 'accepted';
637
+ this.checkpoint.put(entry);
638
+ return true;
639
+ }
640
+
641
+ async runWork(entry, request) {
642
+ const ctx = new WorkContext(this, entry);
643
+ const work = { entry, request, ctx, done: false, outcome: null, failure: null, renewedAt: this.now() };
644
+ this.current = work;
645
+ work.promise = Promise.resolve().then(() => this.handler(request, ctx)).then((outcome) => { work.outcome = outcome; }, (e) => { work.failure = e; }).finally(() => { work.done = true; });
646
+ // A handler that answers at once is finished in this tick; a longer one is renewed by the next ticks.
647
+ await Promise.race([work.promise, new Promise((resolve) => setImmediate(resolve))]);
648
+ if (work.done && this.current === work) await this.finish(work);
649
+ }
650
+
651
+ async resume(entry) {
652
+ if (isObj(entry.outcome)) {
653
+ await this.complete(entry);
654
+ return;
655
+ }
656
+ const message = await this.call('com.get', { message: entry.message });
657
+ await this.runWork(entry, new BoardRequest(message, entry.capability));
658
+ }
659
+
660
+ async fenced(op, entry, args) {
661
+ try {
662
+ return this.receipt(op, await this.call(op, args));
663
+ } catch (e) {
664
+ if (STOP_CODES.has(e.code) || ['revision_conflict', 'invalid_transition', 'digest_conflict'].includes(e.code)) {
665
+ this.lose(entry, e.code);
666
+ if (e.code === 'authority_changed') throw e;
667
+ return null;
668
+ }
669
+ throw e;
670
+ }
671
+ }
672
+
673
+ lose(entry, code) {
674
+ if (this.current && this.current.entry === entry) this.current.ctx.stopped = true;
675
+ if (entry.effect) {
676
+ entry.step = 'unknown';
677
+ this.checkpoint.put(entry);
678
+ this.emit('board.reconcile.needed', 'warn', '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 });
679
+ } else if (code === 'lease_expired' && !['claimed', 'accepting'].includes(entry.step)) {
680
+ entry.step = 'lapsed';
681
+ this.checkpoint.put(entry);
682
+ this.emit('board.attempt.lost', 'warn', 'This service\'s lease on an accepted request lapsed; it stopped working on it and takes it back by a transfer.', { request: entry.request, capability: entry.capability, reason: code });
683
+ } else {
684
+ this.checkpoint.drop(entry.request);
685
+ this.emit('board.attempt.lost', 'warn', `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 });
686
+ }
687
+ }
688
+
689
+ async renew(work) {
690
+ if (this.now() - (work.renewedAt ?? 0) < RENEW_SECONDS - 0.5) return;
691
+ const receipt = await this.fenced('com.renew', work.entry, { fence: this.fence(work.entry, this.key('renew')) });
692
+ if (!receipt) {
693
+ this.current = null;
694
+ return;
695
+ }
696
+ work.renewedAt = this.now();
697
+ (this.slots[work.entry.capability] ??= {}).renewedAt = this.now();
698
+ }
699
+
700
+ async progress(entry, note) {
701
+ const args = { fence: this.fence(entry, this.key('progress')) };
702
+ if (note !== undefined && note !== null) args.note = { text: clean(note) || 'Working.' };
703
+ if (!(await this.fenced('com.progress', entry, args))) throw new Stopped();
704
+ }
705
+
706
+ async effectBegin(entry, summary, ctx) {
707
+ entry.keys.effect_begin ??= this.key('effect');
708
+ entry.step = 'effect-beginning';
709
+ entry.effect = true; // from here a lost attempt is an unknown outcome, never "nothing happened"
710
+ this.checkpoint.put(entry);
711
+ const args = { fence: this.fence(entry, entry.keys.effect_begin), ...(summary ? { summary: clean(summary) } : {}) };
712
+ let receipt = null;
713
+ let lost = 0;
714
+ let delay = this.holdDelay;
715
+ for (;;) { // a lost answer: the same key again tells whether it was stored
716
+ try {
717
+ receipt = await this.fenced('com.effect_begin', entry, args);
718
+ break;
719
+ } catch (e) {
720
+ if (e.code === 'held') {
721
+ // The operator holds this work (DEC-0079): the effect has not begun. Wait, keep the lease (the tick renews it;
722
+ // com.renew is never held), and ask again with the same key.
723
+ entry.effect = false;
724
+ entry.step = 'effect-held';
725
+ this.checkpoint.put(entry);
726
+ 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 });
727
+ const until = Date.now() + delay * 1000;
728
+ while (Date.now() < until) {
729
+ if (ctx?.stopped) throw new Stopped();
730
+ await new Promise((resolve) => setTimeout(resolve, Math.min(250, Math.max(1, until - Date.now()))));
731
+ }
732
+ if (ctx?.stopped) throw new Stopped();
733
+ delay = Math.min(HOLD_RETRY_MAX_SECONDS, delay * 2);
734
+ entry.effect = true;
735
+ entry.step = 'effect-beginning';
736
+ this.checkpoint.put(entry);
737
+ continue;
738
+ }
739
+ if (e.code !== 'unreachable' || lost === 2) throw new Stopped();
740
+ lost += 1;
741
+ await new Promise((resolve) => setTimeout(resolve, lost * 1000));
742
+ }
743
+ }
744
+ if (!receipt) throw new Stopped();
745
+ entry.revision = receipt.request.revision;
746
+ entry.step = 'in-progress';
747
+ this.checkpoint.put(entry);
748
+ }
749
+
750
+ async finish(work) {
751
+ const { entry } = work;
752
+ if (work.ctx.stopped && !(entry.request in this.checkpoint.entries)) {
753
+ this.current = null;
754
+ return;
755
+ }
756
+ let { outcome } = work;
757
+ if (work.failure) {
758
+ if (entry.effect || work.failure instanceof Stopped) {
759
+ this.current = null;
760
+ if (entry.effect) this.lose(entry, 'handler-failed');
761
+ return;
762
+ }
763
+ outcome = { state: 'failed_known', observed: true, summary: `The service could not do this (${work.failure?.name ?? 'Error'}).` };
764
+ }
765
+ const problems = outcomeProblems(outcome, !!entry.effect);
766
+ if (problems.length) {
767
+ this.current = null;
768
+ if (entry.effect) this.lose(entry, problems.some((p) => p.includes('observation_required')) ? 'observation_required' : 'outcome');
769
+ else {
770
+ entry.outcome = { state: 'failed_known', observed: true, summary: 'The service answered in a form it cannot send.' };
771
+ await this.complete(entry);
772
+ }
773
+ this.emit('board.outcome.invalid', 'warn', `The handler's outcome was not sent: ${problems[0]}.`, { request: entry.request, capability: entry.capability });
774
+ return;
775
+ }
776
+ entry.outcome = { ...outcome, observed: outcome.observed ?? !entry.effect };
777
+ await this.complete(entry);
778
+ }
779
+
780
+ async complete(entry) {
781
+ const { outcome } = entry;
782
+ const { reply } = outcome;
783
+ if (reply && !entry.replied) {
784
+ entry.keys.reply ??= this.key('reply');
785
+ entry.step = 'replying';
786
+ this.checkpoint.put(entry);
787
+ const args = { fence: this.fence(entry, entry.keys.reply), body: { text: reply.text, ...(reply.format ? { format: reply.format } : {}) }, ...(reply.artifacts?.length ? { artifacts: reply.artifacts } : {}) };
788
+ if (!(await this.fenced('com.reply', entry, args))) {
789
+ this.current = null;
790
+ return;
791
+ }
792
+ entry.replied = true;
793
+ this.checkpoint.put(entry);
794
+ }
795
+ entry.keys.complete ??= this.key('complete');
796
+ entry.step = 'completing';
797
+ this.checkpoint.put(entry);
798
+ const sent = Object.fromEntries(['state', 'observed', 'summary', 'evidence'].filter((k) => k in outcome).map((k) => [k, outcome[k]]));
799
+ const receipt = await this.fenced('com.complete', entry, { fence: this.fence(entry, entry.keys.complete), outcome: sent });
800
+ this.current = null;
801
+ if (!receipt) return;
802
+ this.checkpoint.drop(entry.request);
803
+ this.emit('board.request.completed', 'info', `A request was answered: ${sent.state === 'completed' ? 'completed' : 'failed, as known'}.`, { request: entry.request, capability: entry.capability, state: sent.state });
804
+ }
805
+
806
+ // -- running --
807
+ /** Tick until `signal` aborts, then shutdown(). */
808
+ async run({ signal, drain = DRAIN_SECONDS } = {}) {
809
+ while (!signal?.aborted) {
810
+ const wait = await this.step();
811
+ await new Promise((resolve) => {
812
+ const timer = setTimeout(resolve, wait * 1000);
813
+ signal?.addEventListener('abort', () => { clearTimeout(timer); resolve(); }, { once: true });
814
+ });
815
+ }
816
+ await this.shutdown(drain);
817
+ }
818
+
819
+ /** Stop claiming, give running work up to `drain` seconds, then leave the session (its DELETE releases the slots). */
820
+ async shutdown(drain = DRAIN_SECONDS) {
821
+ this.stopping = true;
822
+ const work = this.current;
823
+ if (work && !work.done) {
824
+ await Promise.race([work.promise, new Promise((resolve) => setTimeout(resolve, drain * 1000).unref?.())]);
825
+ if (!work.done) work.ctx.stopped = true;
826
+ }
827
+ if (work && work.done && this.session) {
828
+ try {
829
+ await this.finish(work);
830
+ } catch {
831
+ // the checkpoint keeps it
832
+ }
833
+ }
834
+ if (this.session) {
835
+ await this.session.close();
836
+ this.session = null;
837
+ }
838
+ this.state = 'stopped';
839
+ }
840
+ }