@passioncode-ai/passioncode 0.2.45 → 0.2.47

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 (63) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +1 -1
  3. package/SECURITY.md +2 -1
  4. package/family.json +1 -1
  5. package/lib/doctor.js +25 -3
  6. package/lib/launcher.js +34 -5
  7. package/lib/releases.js +3 -2
  8. package/lib/setup.js +2 -1
  9. package/lib/windows-apps.js +74 -0
  10. package/package.json +1 -1
  11. package/payload/.claude-plugin/marketplace.json +2 -2
  12. package/payload/manifest.json +8 -8
  13. package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  14. package/payload/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
  15. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
  16. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +5 -1
  17. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +73 -18
  18. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +6 -5
  19. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +70 -20
  20. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +7 -1
  21. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +9 -0
  22. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +14 -5
  23. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +1 -0
  24. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +1 -1
  25. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +4 -1
  26. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +16 -9
  27. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +87 -156
  28. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +180 -0
  29. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +1 -0
  30. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +4 -2
  31. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +2 -0
  32. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +15 -3
  33. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -0
  34. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +12 -1
  35. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +12 -1
  36. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +1 -0
  37. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +32 -13
  38. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +115 -147
  39. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +173 -0
  40. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +9 -42
  41. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +53 -4
  42. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +7 -3
  43. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +4 -2
  44. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +87 -11
  45. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +19 -8
  46. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +30 -6
  47. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +9 -1
  48. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +15 -1
  49. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +73 -18
  50. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +1 -1
  51. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +134 -24
  52. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +155 -21
  53. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
  54. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +10 -10
  55. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +2 -1
  56. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +39 -13
  57. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +6 -2
  58. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +17 -3
  59. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +23 -6
  60. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +10 -39
  61. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +190 -0
  62. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +1 -1
  63. package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
@@ -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
- if (this.said[kind] === episode) return;
107
- this.said[kind] = episode;
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', '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 });
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', '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 });
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['board.slot.contested'];
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 (await this.gone(capability, status)) continue; // moved away: never replaced at recovery (FAA-155)
360
- if (onHold.has(capability)) {
361
- for (const entry of transfer[capability] ?? []) {
362
- entry.step = 'lapsed'; // taken back by a transfer once the hold is lifted
363
- this.checkpoint.put(entry);
364
- }
365
- this.pause(capability);
366
- } else if (transfer[capability]?.length) { // at the generation a replay just recorded, else com.status's
367
- const mine = this.checkpoint.slots[capability]?.generation;
368
- await this.replace(capability, transfer[capability], isInt(mine, 1) && mine !== was ? mine : generations[capability] ?? 0);
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', '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 });
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', '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
+ 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] : Object.values(this.slots).filter((s) => s.state === 'held').map((s) => s.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', '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 });
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', 'warn', 'Fabric\'s board cannot be reached; the responder tries again with a growing pause.', { episode: 'down', reason: code });
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
- if (session) await session.close();
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', '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' });
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', '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' });
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', '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 });
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', 'warn', code === 'lease_expired' ? 'This service\'s lease on an accepted request lapsed; it stopped working on it and takes it back by a transfer.'
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', '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 });
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', 'warn', `The handler's outcome was not sent: ${problems[0]}.`, { request: entry.request, capability: entry.capability });
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
- await this.session.close();
922
+ const session = this.session;
994
923
  this.session = null;
995
- this.checkpoint.leave(this.project); // the slots were released: no record of them can read as a move
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
  }
@@ -0,0 +1,180 @@
1
+ // The board responder's slot records (FAA-155, FAA-163): the generation it holds each slot at, a move to another
2
+ // session and its take-back, and a capability whose enroll was refused, retried on its own. Part of the board
3
+ // connector (FAA-65, layer L4); Responder (fabric-board-session.mjs) extends SlotKeeper.
4
+ import { BoardError, LEASE_SECONDS, isInt, isObj } from './fabric-board-shapes.mjs';
5
+
6
+ // The answer may have been stored: send it again with the same key (BoardError).
7
+ export const LOST = ['unreachable', 'session-ended'];
8
+
9
+ /** A lost call is sent again this long before the lease it keeps would lapse (FAA-77, finding 38). */
10
+ export const LEASE_MARGIN_SECONDS = 10;
11
+
12
+ /** How long a held slot waits before it asks again: doubling from the poll interval, never past this. */
13
+ export const HOLD_RETRY_MAX_SECONDS = 900;
14
+
15
+ /** Refusals that are about the board session, not one capability: they end recovery and go to the responder's fault. */
16
+ export const SESSION_CODES = Object.freeze(new Set(['unreachable', 'session-ended', 'not_authorized', 'authority_changed', 'credential', 'no-epoch',
17
+ 'schema-unsupported', 'checkpoint', 'receipt-shape', 'idempotency_conflict', 'not-running', 'not-a-hub', 'hub-unavailable', 'protocol', 'not_available']));
18
+
19
+ /** The slot methods of Responder, which owns every field they read (checkpoint, slots, project, locked). */
20
+ export class SlotKeeper {
21
+ // -- a slot moved to another session (FAA-155, DEC-0140 PR-2) --
22
+ /** The generation this responder holds the slot at, kept beside its checkpoint; a failed write is retried at the next receipt. */
23
+ heldAt(capability, generation) {
24
+ this.note(capability, { project: this.project, generation });
25
+ this.confirmed[capability] = generation;
26
+ delete this.said[`board.slot.refused:${capability}`]; // held again: a later refusal is said again
27
+ delete this.said[`board.slot.contested:${capability}`]; // and a later contest, by enroll or replace
28
+ }
29
+
30
+ /** The slot record, best effort: one that cannot be written now is written at the next receipt. */
31
+ note(capability, record) {
32
+ try {
33
+ this.checkpoint.slot(capability, record);
34
+ } catch {
35
+ // the request steps fail closed on their own
36
+ }
37
+ }
38
+
39
+ /**
40
+ * 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
41
+ * drops the record, a receipt replaces it (heldAt), a record that cannot be written is skipped.
42
+ */
43
+ async ask(capability, op, args) {
44
+ let held = this.checkpoint.slots[capability] ?? {};
45
+ if (held.project !== this.project) return this.call(op, args); // no record: nothing a lost answer could be mistaken for
46
+ const slot = this.slots[capability] ?? {};
47
+ if (op === 'com.enroll' && slot.state === 'held' && slot.generation === held.generation
48
+ && this.now() - (slot.renewedAt ?? 0) < LEASE_SECONDS - LEASE_MARGIN_SECONDS && !('asking' in held)) {
49
+ return this.call(op, args); // a live holder's renew cannot change its generation: nothing to record
50
+ }
51
+ if (isObj(held.asking)) {
52
+ // An earlier lost ask is settled first; it may show the slot moved.
53
+ if (await this.gone(capability)) throw new BoardError('fenced', `The ${capability} slot moved to another session.`, false, op);
54
+ held = this.checkpoint.slots[capability] ?? {};
55
+ }
56
+ try {
57
+ this.checkpoint.slot(capability, { ...held, asking: { op, args } });
58
+ } catch {
59
+ return this.call(op, args);
60
+ }
61
+ try {
62
+ return await this.call(op, args);
63
+ } catch (e) {
64
+ if (!LOST.includes(e.code)) this.note(capability, held);
65
+ throw e;
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Asks a lost enroll or replace again under its key. `repeated: true`: it landed, the generation is ours. Else it ran
71
+ * only now: if the slot had moved past the record (`before`), the record stays and the move is read (the slot just
72
+ * taken lapses unused). A refusal or an odd receipt drops the record; a lost answer keeps it.
73
+ */
74
+ async replay(capability, held, before) {
75
+ const { asking, ...rest } = held;
76
+ let record = rest;
77
+ if (isObj(asking) && ['com.enroll', 'com.responder_replace'].includes(asking.op)) {
78
+ try {
79
+ const answer = this.receipt(asking.op, await this.call(asking.op, asking.args));
80
+ const generation = answer.slot?.generation;
81
+ const moved = isInt(before, 1) && isInt(rest.generation, 1) && before > rest.generation;
82
+ if (isInt(generation, 1) && (answer.repeated === true || !moved)) record = { project: this.project, generation };
83
+ } catch (e) {
84
+ if (LOST.includes(e.code)) throw e;
85
+ if (e.code === 'idempotency_window_expired') record = {}; // a retired epoch: unknown, so a first start, no move
86
+ }
87
+ }
88
+ this.note(capability, Object.keys(record).length ? record : null);
89
+ return record;
90
+ }
91
+
92
+ /**
93
+ * True when com.status shows the slot's generation past the one recorded held: the operator or another session moved
94
+ * it. The slot is left `moved`. No record, another Project's, an odd value or no later generation: false.
95
+ */
96
+ async gone(capability, status = null) {
97
+ let held = this.checkpoint.slots[capability] ?? {};
98
+ if (held.project !== this.project || (!isInt(held.generation, 1) && !('asking' in held))) return false;
99
+ let now = ((status ?? await this.status()).responders ?? []).find((r) => isObj(r) && r.capability === capability)?.generation;
100
+ if ('asking' in held) held = await this.replay(capability, held, now); // its own lost ask first
101
+ if (isInt(held.moved, 1) /* a recorded move stays until takeBack */ && !(isInt(now, 1) && now > (held.generation ?? now))) now = held.moved;
102
+ if (!isInt(now, 1) || !isInt(held.generation, 1) || now <= held.generation) {
103
+ if (isInt(now, 1) && isInt(held.generation, 1) && !('moved' in held)) this.confirmed[capability] = held.generation; // com.status shows it unmoved (0.56.0 seam r3)
104
+ return false;
105
+ }
106
+ // On the fence queue (FAA-158): a fenced call in flight lands first, and a takeBack meanwhile wins.
107
+ return this.locked(() => this.moved(capability, held, now));
108
+ }
109
+
110
+ /** The slot moved past `held` to `now`: stop its work, keep its accepted requests held, say it once. */
111
+ moved(capability, held, now) {
112
+ const record = this.checkpoint.slots[capability] ?? {};
113
+ if (record.project !== this.project || record.generation !== held.generation) return false; // a takeBack forgot it
114
+ if (this.current?.entry.capability === capability) {
115
+ this.current.ctx.stopped = true;
116
+ this.current = null;
117
+ }
118
+ for (const entry of Object.values(this.checkpoint.entries).filter((e) => e.capability === capability).map((e) => ({ ...e }))) {
119
+ if (entry.step === 'claimed') this.checkpoint.drop(entry.request); // the move returned the claim to the queue
120
+ else if (!['unknown', 'lapsed'].includes(entry.step)) {
121
+ // Begun: outcome_unknown, which the slot's holder reconciles. Accepted: held on the board, continued only by a
122
+ // transfer if the slot is this service's again; never a com.responder_replace on its own.
123
+ entry.step = entry.effect ? 'unknown' : 'lapsed';
124
+ this.checkpoint.put(entry);
125
+ }
126
+ }
127
+ this.toResume = this.toResume.filter((r) => this.checkpoint.entries[r]?.capability !== capability);
128
+ this.slots[capability] = { state: 'moved', generation: now, renewedAt: this.now() };
129
+ delete this.confirmed[capability];
130
+ if (!('moved' in held)) { // once, even across restarts
131
+ this.emit('board.slot.moved', 'warning', `The ${capability} slot on this Project moved to another session. This service stopped answering it there and left its accepted work \`accepted\` (work begun is the new holder's to settle). Its take-back action brings the slot back once the other session lets it go, or at a later restart or reconnect while accepted work waits; a restart alone does not. Without it, ask its maintainer.`, { notify: true, episode: capability, capability, generation: now, held: held.generation });
132
+ this.note(capability, { ...held, moved: now }); // said again at the next start if it cannot be written
133
+ }
134
+ return true;
135
+ }
136
+
137
+ /**
138
+ * The operator's act, never the kit's: forget that a capability's slot moved away. In the same board session the next
139
+ * tick enrolls once the slot is free; a reconnect with held work takes it back at once by a transfer (FAA-155).
140
+ */
141
+ takeBack(capability) {
142
+ if (!this.capabilities.includes(capability)) throw new BoardError('invalid_arguments', `This responder does not answer ${capability}.`);
143
+ this.checkpoint.slot(capability, null);
144
+ if (this.slots[capability]?.state === 'moved') this.slots[capability] = { state: 'contested', renewedAt: 0 };
145
+ delete this.said['board.slot.moved'];
146
+ }
147
+
148
+ // -- one capability's refusal (FAA-163) --
149
+ /**
150
+ * An enroll or replace of one capability was refused: its accepted work waits `lapsed` (a transfer takes it back once
151
+ * the slot is held) and the capability asks again after a doubling pause; the others go on.
152
+ */
153
+ retryLater(capability, entries, e) {
154
+ if (this.slots[capability]?.state === 'held') return; // the replace landed: what failed came after it
155
+ for (const entry of entries) {
156
+ if (!['unknown', 'lapsed'].includes(entry.step)) {
157
+ entry.step = 'lapsed';
158
+ this.checkpoint.put(entry);
159
+ }
160
+ }
161
+ const slot = this.slots[capability] ?? {};
162
+ const tries = slot.state === 'retry' ? (slot.tries ?? 0) + 1 : 1;
163
+ const wait = Math.min(HOLD_RETRY_MAX_SECONDS, this.holdDelay * 2 ** (tries - 1));
164
+ this.slots[capability] = { state: 'retry', tries, retryAt: this.now() + wait, renewedAt: this.now() };
165
+ if (!SESSION_CODES.has(e.code)) { // once per episode (saidAs), also after a silent session refusal // a refusal of the board session is the fault's to say
166
+ this.emit('board.slot.refused', 'warning', `Fabric refused to give this service the ${capability} slot (${e.code}); it asks again later. Its other capabilities go on.`, { episode: capability, saidAs: `board.slot.refused:${capability}`, capability, reason: e.code });
167
+ }
168
+ }
169
+
170
+ /** Runs one capability's enroll or replace (FAA-163): a refusal of the board session is raised after the capability is left to retry. */
171
+ async settle(capability, act, entries = []) {
172
+ try {
173
+ await act();
174
+ } catch (e) {
175
+ if (!(e instanceof BoardError)) throw e;
176
+ this.retryLater(capability, entries, e);
177
+ if (SESSION_CODES.has(e.code)) throw e;
178
+ }
179
+ }
180
+ }
@@ -31,6 +31,7 @@ export {
31
31
  EVENT_SCHEMA,
32
32
  STOP_CODES,
33
33
  HOLD_RETRY_MAX_SECONDS,
34
+ IDLE_LEAVE_SECONDS,
34
35
  BoardSession,
35
36
  Checkpoint,
36
37
  BoardRequest,