@passioncode-ai/passioncode 0.2.42 → 0.2.44

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 (37) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/family.json +1 -1
  3. package/package.json +1 -1
  4. package/payload/.claude-plugin/marketplace.json +2 -2
  5. package/payload/manifest.json +8 -8
  6. package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/payload/plugins/fabric-agent-adapter/hooks/hooks.json +1 -1
  8. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
  9. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  10. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +8 -4
  11. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +82 -11
  12. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +2 -1
  13. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +24 -8
  14. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +1 -1
  15. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +2 -2
  16. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-actions.mjs +22 -9
  17. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +33 -3
  18. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +146 -7
  19. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +6 -1
  20. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +14 -6
  21. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_actions.py +23 -13
  22. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +34 -3
  23. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +149 -45
  24. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +6 -1
  25. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +9 -5
  26. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +16 -5
  27. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +276 -12
  28. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +13 -1
  29. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
  30. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +20 -13
  31. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +4 -1
  32. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +38 -9
  33. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +11 -0
  34. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +12 -3
  35. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +11 -1
  36. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +358 -14
  37. package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
@@ -83,6 +83,7 @@ export class BoardSession {
83
83
  export class Checkpoint {
84
84
  constructor(file) {
85
85
  this.file = file;
86
+ this.slots = {}; // the responder's own record of each slot it held: {project, generation, moved?} (FAA-155)
86
87
  this.entries = this.#load();
87
88
  }
88
89
  #load() {
@@ -100,13 +101,16 @@ export class Checkpoint {
100
101
  doc = null;
101
102
  }
102
103
  if (!isObj(doc) || doc.version !== 1 || !isObj(doc.requests)) throw new BoardError('checkpoint', `The board checkpoint ${this.file} is not one this kit writes.`);
104
+ // a record that is not an object is ignored, as a file without one is
105
+ this.slots = isObj(doc.slots) ? Object.fromEntries(Object.entries(doc.slots).filter(([, v]) => isObj(v))) : {};
103
106
  return doc.requests;
104
107
  }
105
- #write(entries) {
108
+ #write(entries, slots = this.slots) {
106
109
  try {
107
110
  ensurePrivateDir(path.dirname(this.file));
108
- const sorted = Object.fromEntries(Object.keys(entries).sort().map((k) => [k, entries[k]]));
109
- atomicWrite(this.file, `${JSON.stringify({ requests: sorted, version: 1 }, null, 1)}\n`, 0o600);
111
+ const sort = (o) => Object.fromEntries(Object.keys(o).sort().map((k) => [k, o[k]]));
112
+ const doc = Object.keys(slots).length ? { requests: sort(entries), slots: sort(slots), version: 1 } : { requests: sort(entries), version: 1 };
113
+ atomicWrite(this.file, `${JSON.stringify(doc, null, 1)}\n`, 0o600);
110
114
  } catch (e) {
111
115
  throw new BoardError('checkpoint', `The board checkpoint could not be written (${e.code ?? e.message}); nothing was sent.`);
112
116
  }
@@ -122,8 +126,34 @@ export class Checkpoint {
122
126
  this.#write(entries);
123
127
  this.entries = entries;
124
128
  }
129
+ /** Writes the record of a slot this responder held (null forgets it); nothing is written when it is unchanged. */
130
+ slot(capability, record) {
131
+ const { [capability]: _old, ...slots } = this.slots;
132
+ if (record !== null) slots[capability] = JSON.parse(JSON.stringify(record));
133
+ if (JSON.stringify(sortDeep(slots)) === JSON.stringify(sortDeep(this.slots))) return;
134
+ this.#write(this.entries, slots);
135
+ this.slots = slots;
136
+ }
137
+ /**
138
+ * At a clean leave (the session's DELETE released the slots): forget the record of each slot this service held for
139
+ * `project` with no request left and not moved away, so a later generation is another session taking a free slot, and
140
+ * a kit rolled back to before 0.53.0 leaves nothing that reads as a move. A record that cannot be written stays.
141
+ */
142
+ leave(project) {
143
+ const busy = new Set(Object.values(this.entries).map((e) => e.capability));
144
+ for (const [capability, record] of Object.entries(this.slots)) {
145
+ if (!isObj(record) || record.project !== project || 'moved' in record || busy.has(capability)) continue;
146
+ try {
147
+ this.slot(capability, null);
148
+ } catch {
149
+ // stays for the next start
150
+ }
151
+ }
152
+ }
125
153
  }
126
154
 
155
+ const sortDeep = (v) => (isObj(v) ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortDeep(v[k])])) : v);
156
+
127
157
  /** A claimed request as data: never an instruction. */
128
158
  export class BoardRequest {
129
159
  constructor(message, capability) {
@@ -26,6 +26,9 @@ const TERMINAL = new Set(['completed', 'failed_known', 'cancelled', 'expired']);
26
26
  // participant reply (DEC-0098, DEC-0102; not terminal, the requester may still cancel it, and nobody claims it).
27
27
  const NOT_OURS = new Set(['queued', 'claimed', 'answered']);
28
28
 
29
+ // The answer may have been stored: send it again with the same key (BoardError).
30
+ const LOST = ['unreachable', 'session-ended'];
31
+
29
32
  /** A lost call is sent again this long before the lease it keeps would lapse (FAA-77, finding 38). */
30
33
  export const LEASE_MARGIN_SECONDS = 10;
31
34
 
@@ -87,6 +90,7 @@ export class Responder {
87
90
  /** One fenced call at a time, and the entry edits beside them (FAA-77, finding 36). */
88
91
  this.fenceQueue = Promise.resolve();
89
92
  this.toResume = []; // transferred requests waiting until nothing runs (finding 39)
93
+ this.suspect = new Set(); // capabilities a fenced call was refused on: com.status says whether they moved (FAA-155)
90
94
  }
91
95
 
92
96
  /** Runs fn after every fenced call queued before it (finding 36): an await inside a call never lets another in. */
@@ -167,13 +171,14 @@ export class Responder {
167
171
  const args = { idempotency: this.idem(this.key('enroll')), capability };
168
172
  let receipt;
169
173
  try {
170
- receipt = this.receipt('com.enroll', await this.call('com.enroll', args));
174
+ receipt = this.receipt('com.enroll', await this.ask(capability, 'com.enroll', args));
171
175
  } catch (e) {
172
176
  if (e.code === 'held') {
173
177
  this.pause(capability);
174
178
  return;
175
179
  }
176
180
  if (e.code === 'fenced') {
181
+ if (await this.gone(capability)) return;
177
182
  this.slots[capability] = { state: 'contested', renewedAt: this.now() };
178
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 });
179
184
  return;
@@ -181,6 +186,7 @@ export class Responder {
181
186
  throw e;
182
187
  }
183
188
  this.slots[capability] = { state: 'held', generation: receipt.slot.generation, renewedAt: this.now() };
189
+ this.heldAt(capability, receipt.slot.generation);
184
190
  delete this.said['board.slot.contested'];
185
191
  this.lifted(capability);
186
192
  }
@@ -205,6 +211,118 @@ export class Responder {
205
211
  this.emit('board.slot.released', 'info', `The operator's hold on ${capability} was lifted; the service takes new work again.`, { capability });
206
212
  }
207
213
 
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
+
208
326
  // -- restart: reconcile and transfer --
209
327
  async readState(entry) {
210
328
  const message = await this.call('com.get', { message: entry.message });
@@ -237,13 +355,18 @@ export class Responder {
237
355
  const generations = Object.fromEntries(responders.map((r) => [r.capability, r.generation ?? 0]));
238
356
  const onHold = new Set(responders.filter((r) => r.held === true).map((r) => r.capability)); // DEC-0079
239
357
  for (const capability of capabilities) {
358
+ const was = this.checkpoint.slots[capability]?.generation;
359
+ if (await this.gone(capability, status)) continue; // moved away: never replaced at recovery (FAA-155)
240
360
  if (onHold.has(capability)) {
241
361
  for (const entry of transfer[capability] ?? []) {
242
362
  entry.step = 'lapsed'; // taken back by a transfer once the hold is lifted
243
363
  this.checkpoint.put(entry);
244
364
  }
245
365
  this.pause(capability);
246
- } else if (transfer[capability]?.length) await this.replace(capability, transfer[capability], generations[capability] ?? 0);
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);
369
+ }
247
370
  else await this.enroll(capability);
248
371
  }
249
372
  for (const entry of reconcile) {
@@ -260,8 +383,10 @@ export class Responder {
260
383
  if (entries.length) args.transfer = { requests: entries.map((e) => e.request).slice(0, 64), checkpoint: { summary: 'Resumed by the same service after a restart; no effect had begun.' } };
261
384
  let receipt;
262
385
  try {
263
- receipt = this.receipt('com.responder_replace', await this.call('com.responder_replace', args));
386
+ receipt = this.receipt('com.responder_replace', await this.ask(capability, 'com.responder_replace', args));
264
387
  } catch (e) {
388
+ // An earlier lost ask showed the slot moved: said once as board.slot.moved, no outage.
389
+ if (e.code === 'fenced' && this.slots[capability]?.state === 'moved') return;
265
390
  if (e.code !== 'held') throw e;
266
391
  for (const entry of entries) {
267
392
  entry.step = 'lapsed'; // nothing moved: taken back by a transfer once the hold is lifted
@@ -271,6 +396,7 @@ export class Responder {
271
396
  return;
272
397
  }
273
398
  this.slots[capability] = { state: 'held', generation: receipt.slot.generation, renewedAt: this.now() };
399
+ this.heldAt(capability, receipt.slot.generation);
274
400
  this.lifted(capability);
275
401
  const moved = new Map((receipt.transferred ?? []).map((item) => [item.request, item]));
276
402
  for (const entry of entries) {
@@ -418,6 +544,7 @@ export class Responder {
418
544
 
419
545
  async fault(e) {
420
546
  const code = e.code ?? 'unreachable';
547
+ const connecting = this.state === 'connecting' && !Object.values(this.slots).some((s) => s?.state === 'held');
421
548
  let wait = this.backoff.next();
422
549
  const left = this.leaseLeft() - LEASE_MARGIN_SECONDS;
423
550
  const keep = code === 'unreachable' && left > 0; // finding 38: the lease holds, so the session and the work stay
@@ -437,7 +564,8 @@ export class Responder {
437
564
  this.emit('board.blocked', 'error', `The responder stopped answering requests: ${e.message}`, { notify: true, episode: code, reason: code });
438
565
  } else {
439
566
  this.state = 'down';
440
- if (['session-ended', 'unreachable', 'not-running', 'not-a-hub', 'hub-unavailable', 'protocol'].includes(code) && !keep) await this.dropSession();
567
+ // A refusal in recovery that left no slot held reconnects (seam 6); one that left some held serves them (seam 7).
568
+ if ((connecting || ['session-ended', 'unreachable', 'not-running', 'not-a-hub', 'hub-unavailable', 'protocol'].includes(code)) && !keep) await this.dropSession();
441
569
  this.emit('board.down', 'warn', 'Fabric\'s board cannot be reached; the responder tries again with a growing pause.', { episode: 'down', reason: code });
442
570
  }
443
571
  return wait;
@@ -470,7 +598,12 @@ export class Responder {
470
598
  else await this.renew(work);
471
599
  }
472
600
  const heldByWork = work && this.current ? work.entry.capability : null;
601
+ for (const capability of [...this.suspect]) { // kept until com.status answered, so it is asked before any enroll
602
+ await this.gone(capability);
603
+ this.suspect.delete(capability);
604
+ }
473
605
  for (const [capability, slot] of Object.entries(this.slots)) {
606
+ if (slot.state === 'moved') continue; // never enrolled again on its own, even when the new holder's lease lapses (FAA-155)
474
607
  if (slot.state === 'paused') {
475
608
  // still held: a longer pause; lifted: the slot is ours again
476
609
  if (capability !== heldByWork && this.now() >= (slot.retryAt ?? 0)) await this.enroll(capability);
@@ -521,6 +654,7 @@ export class Responder {
521
654
  answer = this.receipt('com.claim', await this.call('com.claim', { idempotency: this.idem(this.claimKeys[capability]), capability }));
522
655
  } catch (e) {
523
656
  if (e.code === 'lease_expired') {
657
+ this.slots[capability].renewedAt = -Infinity; // lapsed: this enroll may move the generation, so it is recorded
524
658
  await this.enroll(capability);
525
659
  continue;
526
660
  }
@@ -530,7 +664,7 @@ export class Responder {
530
664
  continue;
531
665
  }
532
666
  if (e.code === 'fenced') {
533
- this.slots[capability] = { state: 'contested', renewedAt: this.now() };
667
+ if (!(await this.gone(capability))) this.slots[capability] = { state: 'contested', renewedAt: this.now() };
534
668
  continue;
535
669
  }
536
670
  if (e.code === 'held') {
@@ -648,6 +782,7 @@ export class Responder {
648
782
  continue;
649
783
  }
650
784
  if (STOP_CODES.has(e.code) || ['revision_conflict', 'invalid_transition', 'digest_conflict'].includes(e.code)) {
785
+ if (e.code === 'fenced') this.suspect.add(entry.capability); // the next tick asks com.status whether the slot moved
651
786
  this.lose(entry, e.code);
652
787
  if (e.code === 'authority_changed') throw e;
653
788
  return null;
@@ -663,10 +798,13 @@ export class Responder {
663
798
  entry.step = 'unknown';
664
799
  this.checkpoint.put(entry);
665
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 });
666
- } else if (code === 'lease_expired' && !['claimed', 'accepting'].includes(entry.step)) {
801
+ } else if (!['claimed', 'accepting'].includes(entry.step) && (code === 'lease_expired' || (code === 'fenced' && entry.capability in this.checkpoint.slots))) {
802
+ // Accepted work whose lease lapsed, or fenced off a slot this responder held (moved, FAA-155): the board holds it
803
+ // (C4). It continues only by a checkpoint transfer to this responder while it holds the slot.
667
804
  entry.step = 'lapsed';
668
805
  this.checkpoint.put(entry);
669
- 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 });
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.'
807
+ : '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 });
670
808
  } else {
671
809
  this.checkpoint.drop(entry.request);
672
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 });
@@ -854,6 +992,7 @@ export class Responder {
854
992
  if (this.session) {
855
993
  await this.session.close();
856
994
  this.session = null;
995
+ this.checkpoint.leave(this.project); // the slots were released: no record of them can read as a move
857
996
  }
858
997
  this.state = 'stopped';
859
998
  }
@@ -506,11 +506,16 @@ export function refusalFromDenial(denial) {
506
506
  * DL-19 (DEC-0121): the refusal a service answers for an action call that fabric-actions.mjs's actionDecision refused,
507
507
  * or undefined when it took the call. operator-decision-required, decision-invalid and approval-required carry their
508
508
  * detail; human-step-required carries humanStep and url (AP-4). A `fault` is the service's own and is never sent: the
509
- * caller reads unknown-state with the detail service-fault, as for anything else that is not an answer.
509
+ * caller reads unknown-state with the detail service-fault, as for anything else that is not an answer; but
510
+ * mandate-capability-missing (DEC-0141 MJ-1), a job's step no mandate can be read for, is answered as
511
+ * operator-decision-required with that detail, so the job stays input_required.
510
512
  */
511
513
  export function refusalFromAction(answer) {
512
514
  const isRecord = answer !== null && typeof answer === 'object' && !Array.isArray(answer);
513
515
  if (isRecord && answer.ok === true) return undefined;
516
+ if (isRecord && answer.fault === 'mandate-capability-missing') {
517
+ return refuse('operator-decision-required', ACTION_DETAILS.get('operator-decision-required'), { detail: 'mandate-capability-missing' });
518
+ }
514
519
  const code = isRecord && answer.ok === false && typeof answer.code === 'string' ? answer.code : undefined;
515
520
  if (code === undefined || !ACTION_DETAILS.has(code)) return refuse('unknown-state', 'service-fault');
516
521
  const data = answer.data !== null && typeof answer.data === 'object' && !Array.isArray(answer.data) && Object.keys(answer.data).length ? { ...answer.data } : undefined;
@@ -6,7 +6,7 @@
6
6
  import crypto from 'node:crypto';
7
7
  import { ServiceError } from './fabric-base.mjs';
8
8
  import { jcs, refusalFromAction, refusalToolResult } from './fabric-delegation.mjs';
9
- import { actionDecision, decisionShape } from './fabric-actions.mjs'; // DEC-0121
9
+ import { actionDecision, decisionShape, jobCapability } from './fabric-actions.mjs'; // DEC-0121
10
10
  import { EFFECT_CLASSES, OPERATOR_SOURCE_PROTOCOL, mandateCovers, mandateProblem, withoutSource } from './fabric-mandate.mjs'; // DEC-0125, DEC-0126
11
11
  import { isString } from './fabric-jsvalue.mjs'; // the core's (FAA-65)
12
12
  import { isObject } from './fabric-jsvalue.mjs'; // the core's (FAA-65)
@@ -218,6 +218,8 @@ export function requestMeta(traceparent, { decision, mandate, targetProtocols }
218
218
  return meta;
219
219
  }
220
220
 
221
+ const UNREADABLE_JOB_CAPABILITY = "The operator's mandate cannot be read for this step (mandate-capability-missing): answer it, or call fabric.job.get with a decision in _meta.decision.";
222
+
221
223
  /**
222
224
  * A job's own approval step under the mandate its start call carried (interop C3.3, DEC-0125 MD-3, MD-4; conformance:
223
225
  * "a job's approval step included"): the step is the call {capability: the job's, action: the input request's `key`,
@@ -225,7 +227,8 @@ export function requestMeta(traceparent, { decision, mandate, targetProtocols }
225
227
  * agent id it authenticated for the start call. Answers {covered: true, record} (the step goes on as the operator's
226
228
  * decision, no input request; `record` is mandateCovers' recordAs, `source` included when the mandate carries one);
227
229
  * {covered: false, message, detail} (the step waits input_required, `message` its statusMessage naming the part not
228
- * covered, or saying the mandate expired or cannot be read; never the operator's words or `source`); or {covered: false,
230
+ * covered, or saying the mandate expired or cannot be read; never the operator's words or `source`; with `fault`
231
+ * mandate-capability-missing beside it when the job's capability cannot read the step, MJ-1); or {covered: false,
229
232
  * message: null} with no mandate or a T2 caller's (MD-5), with `fault` when the service's own clock does not read.
230
233
  */
231
234
  export function jobStep({ mandate, tier, now, self, capability, key, effect, callerAgent, spendUsd, project } = {}) {
@@ -237,8 +240,10 @@ export function jobStep({ mandate, tier, now, self, capability, key, effect, cal
237
240
  const covers = { mandate, tier, now, self, call };
238
241
  if (typeof callerAgent === 'string' && callerAgent.length > 0) covers.callerAgent = callerAgent;
239
242
  const answer = mandateCovers(covers);
240
- if (answer.ok === true) return { covered: true, record: answer.recordAs };
243
+ // A T2 caller's mandate changes nothing (MD-5), first; then MJ-1: read alike at the start and at the answer (actionGate).
241
244
  if ('ignored' in answer || 'fault' in answer) return { covered: false, message: null, ...('fault' in answer ? { fault: answer.fault } : {}) };
245
+ if (!jobCapability(capability)) return { covered: false, detail: 'mandate-capability-missing', fault: 'mandate-capability-missing', message: UNREADABLE_JOB_CAPABILITY };
246
+ if (answer.ok === true) return { covered: true, record: answer.recordAs };
242
247
  const { detail } = answer.data;
243
248
  const said = detail === 'mandate-expired' ? "The operator's mandate for this task expired before this step"
244
249
  : detail.startsWith('outside-mandate') ? `The operator's mandate does not cover this step (${detail})`
@@ -253,7 +258,8 @@ export function jobStep({ mandate, tier, now, self, capability, key, effect, cal
253
258
  * elicitation), `approval` (verifyApproval's accepted outcome), `actionDigest`, `humanStepUrl` and `now` (RFC 3339).
254
259
  * Answers {ok: true, record, decision}, `decision` being the one taken as the operator's (null otherwise), or {ok: false,
255
260
  * refusal} in the DL-19 form (refusalToolResult sends it); a service's own fault is the refusal unknown-state with
256
- * `fault` beside it for the service's log.
261
+ * `fault` beside it for the service's log, but for mandate-capability-missing (MJ-1): operator-decision-required naming
262
+ * it in data.detail, `fault` still beside it.
257
263
  *
258
264
  * DEC-0125: a service that names its own agent id (`self`) reads the call's `_meta.mandate` when it carries no
259
265
  * decision, given `effect` (the action's effect class, required: without it the call is the service's fault
@@ -262,8 +268,8 @@ export function jobStep({ mandate, tier, now, self, capability, key, effect, cal
262
268
  * {ok: true, record: {by: 'operator', via: 'mandate', task, chain, caller}, decision: null, mandate}; without `self` the
263
269
  * mandate is not read and the call is answered as before it existed.
264
270
  *
265
- * mandateCapability: a job's kept step's capability the mandate is read under, not the action's tool (the kit's
266
- * input; the service's own, never a caller's).
271
+ * mandateCapability: a job's kept step's capability the mandate is read under (DEC-0141 MJ-1; the service's own input,
272
+ * never a caller's); unusable, the refusal is operator-decision-required.
267
273
  */
268
274
  export function actionGate({ action, tier, caller, meta, target, elicited = false, approval, actionDigest, humanStepUrl, now, self, effect, callerAgent, spendUsd, project, mandateCapability } = {}) {
269
275
  const inputs = { action, tier, caller, elicited: elicited === true, now };
@@ -285,6 +291,8 @@ export function actionGate({ action, tier, caller, meta, target, elicited = fals
285
291
  if (isObject(record) && record.via === 'mandate') out.mandate = inputs.mandate;
286
292
  return out;
287
293
  }
294
+ // MJ-1 (DEC-0141): refusalFromAction answers mandate-capability-missing as operator-decision-required; nothing is
295
+ // applied, so the job stays input_required.
288
296
  const out = { ok: false, refusal: refusalFromAction(answer) };
289
297
  if ('fault' in answer) out.fault = answer.fault;
290
298
  return out;
@@ -3,7 +3,10 @@ extensions/actions/0.1 (DEC-0121, FAC-SEM-142). Part of the service kit (layer L
3
3
 
4
4
  - ``action_decision(inputs)``: whether a service takes a call of one declared action and as whose act it records it,
5
5
  exactly as the contract's ``actionDecision`` answers (AP-2, AP-4; DL-17, DL-18, DL-25). A ``fault`` is the
6
- service's own and is never sent to a caller.
6
+ service's own and is never sent to a caller as such; ``action_gate`` answers ``mandate-capability-missing`` as
7
+ operator-decision-required (DEC-0141 MJ-1).
8
+ - ``JOB_STEP_TOOL`` and ``job_capability(value)``: the tool a job's answer comes through, and whether a job's
9
+ capability can read its step under a mandate (a capability name other than ``fabric.job.get``).
7
10
  - ``decision_shape(value)``: a decision as ``decision.schema.json`` reads it, ``at`` read strictly (the contract's
8
11
  ``strictTime``).
9
12
  - ``actions_view(well_known)``: the host reader of the declaration, as the contract's ``actionsView``.
@@ -166,14 +169,12 @@ def action_decision(inputs: Any) -> Dict[str, Any]:
166
169
  the service authenticated), `effect` (the action's effect class, the service's own knowledge), `spendUsd` (its
167
170
  own estimate) and `project`: covered, `{by: operator, via: mandate, task, chain, source?, caller}`; otherwise
168
171
  `operator-decision-required` or `decision-invalid` with `data.detail`.
172
+ - DEC-0141 MJ-1: a job's input request (tool `fabric.job.get`) under a mandate is read under `mandateCapability`,
173
+ the job's own capability (the tool its start call named), its action the request key; any other action reads
174
+ its own tool.
169
175
  - `{"fault": ...}` (action-invalid, caller-missing, now-unreadable, digest-missing, url-missing, effect-missing,
170
- self-missing) is the service's
171
- own: an input it owes is missing or unreadable. It is never sent to a caller.
172
-
173
-
174
- Kit input beside the contract's: `mandateCapability`, a job's kept step's capability, read by the mandate in
175
- place of the action's tool (the contract's actionDecision does not take it; the service's own input, never a
176
- caller's)."""
176
+ self-missing, mandate-capability-missing) is the service's own: an input it owes is missing or unreadable. It is
177
+ never sent to a caller (action_gate answers mandate-capability-missing as operator-decision-required)."""
177
178
  action = get(inputs, "action")
178
179
  if not readable_action(action):
179
180
  return {"fault": "action-invalid"}
@@ -219,6 +220,15 @@ def action_decision(inputs: Any) -> Dict[str, Any]:
219
220
  return {"ok": True, "recordAs": record}
220
221
 
221
222
 
223
+ #: The tool of a job's input request (interop C3.3, AP-2): the call that answers it is fabric.job.get (DEC-0141 MJ-1).
224
+ JOB_STEP_TOOL = "fabric.job.get"
225
+
226
+
227
+ def job_capability(value: Any) -> bool:
228
+ """A job's capability a mandate can read a step under (MJ-1): a capability name, never fabric.job.get itself."""
229
+ return isinstance(value, str) and _CAPABILITY.fullmatch(value) is not None and value != JOB_STEP_TOOL
230
+
231
+
222
232
  def _by_mandate(inputs: Any, action: Dict[str, Any], caller: str) -> Dict[str, Any]:
223
233
  """A relayed action's call that carries a mandate and no decision (DEC-0125 MD-3): the mandate decides, as the
224
234
  contract's byMandate reads it: the service's own `effect` and agent id `self` are required (else a fault), its
@@ -228,11 +238,11 @@ def _by_mandate(inputs: Any, action: Dict[str, Any], caller: str) -> Dict[str, A
228
238
  return {"fault": "effect-missing"}
229
239
  if not isinstance(me, str):
230
240
  return {"fault": "self-missing"}
231
- # A job's kept step is read under the job's own capability, as job_step read it at the start (`mandateCapability`,
232
- # the kit's input; the action itself stays fabric.job.get).
233
- capability = get(inputs, "mandateCapability")
234
- call: Dict[str, Any] = {"capability": capability if isinstance(capability, str) else action["tool"],
235
- "action": action["id"], "effect": effect}
241
+ # MJ-1: a job's step is read under the job's capability, never under the transport fabric.job.get itself.
242
+ capability = get(inputs, "mandateCapability") if action["tool"] == JOB_STEP_TOOL else action["tool"]
243
+ if not job_capability(capability):
244
+ return {"fault": "mandate-capability-missing"}
245
+ call: Dict[str, Any] = {"capability": capability, "action": action["id"], "effect": effect}
236
246
  spend, project = get(inputs, "spendUsd"), get(inputs, "project")
237
247
  if is_number(spend):
238
248
  call["spendUsd"] = spend
@@ -97,11 +97,14 @@ class BoardSession:
97
97
  class Checkpoint:
98
98
  """The responder's own journal: one JSON file (0600) holding every request it has claimed and not yet settled, each
99
99
  with its fence and the idempotency key of the step in flight. Written atomically and fsynced BEFORE the step it
100
- announces is sent; a write that fails raises `BoardError("checkpoint")` and the step is not taken."""
100
+ announces is sent; a write that fails raises `BoardError("checkpoint")` and the step is not taken. Beside the
101
+ requests, `slots` keeps the responder's own record of each slot it held (`{project, generation, moved?}`, FAA-155);
102
+ a record that is not an object is ignored, as a file without one is."""
101
103
 
102
104
  def __init__(self, path: Path):
103
105
  self.path = Path(path)
104
106
  self._lock = threading.Lock()
107
+ self.slots: Dict[str, Dict[str, Any]] = {}
105
108
  self.entries: Dict[str, Dict[str, Any]] = self._load()
106
109
 
107
110
  def _load(self) -> Dict[str, Dict[str, Any]]:
@@ -115,12 +118,16 @@ class Checkpoint:
115
118
  entries = doc.get("requests") if isinstance(doc, dict) and doc.get("version") == 1 else None
116
119
  if not isinstance(entries, dict):
117
120
  raise BoardError("checkpoint", "The board checkpoint %s is not one this kit writes." % self.path)
121
+ slots = doc.get("slots")
122
+ self.slots = {k: v for k, v in slots.items() if isinstance(v, dict)} if isinstance(slots, dict) else {}
118
123
  return entries
119
124
 
120
- def _write(self, entries: Dict[str, Dict[str, Any]]) -> None:
125
+ def _write(self, entries: Dict[str, Dict[str, Any]], slots: Optional[Dict[str, Dict[str, Any]]] = None) -> None:
126
+ slots = self.slots if slots is None else slots
127
+ doc: Dict[str, Any] = {"version": 1, "requests": entries, **({"slots": slots} if slots else {})}
121
128
  try:
122
129
  _fs.ensure_private_dir(self.path.parent)
123
- _fs.atomic_write(self.path, (json.dumps({"version": 1, "requests": entries}, indent=1, sort_keys=True) + "\n").encode())
130
+ _fs.atomic_write(self.path, (json.dumps(doc, indent=1, sort_keys=True) + "\n").encode())
124
131
  except OSError as exc:
125
132
  raise BoardError("checkpoint", "The board checkpoint could not be written (%s); nothing was sent." % (exc.strerror or exc.__class__.__name__)) from None
126
133
 
@@ -140,6 +147,30 @@ class Checkpoint:
140
147
  self._write(entries)
141
148
  self.entries = entries
142
149
 
150
+ def slot(self, capability: str, record: Optional[Dict[str, Any]]) -> None:
151
+ """Writes the record of a slot this responder held (None forgets it); nothing is written when it is unchanged."""
152
+ with self._lock:
153
+ slots = {k: v for k, v in self.slots.items() if k != capability}
154
+ if record is not None:
155
+ slots[capability] = json.loads(json.dumps(record))
156
+ if slots != self.slots:
157
+ self._write(self.entries, slots)
158
+ self.slots = slots
159
+
160
+
161
+ def leave(self, project: str) -> None:
162
+ """At a clean leave (the session's DELETE released the slots): forget the record of each slot this service held
163
+ for `project` with no request left in the checkpoint and not moved away. A later generation is then another
164
+ session taking a free slot, not a move, and a kit that keeps no record (rolled back to before 0.53.0) leaves
165
+ nothing behind that reads as one. A record that cannot be written stays."""
166
+ busy = {e.get("capability") for e in list(self.entries.values())}
167
+ for capability, record in list(self.slots.items()):
168
+ if isinstance(record, dict) and record.get("project") == project and "moved" not in record and capability not in busy:
169
+ try:
170
+ self.slot(capability, None)
171
+ except BoardError:
172
+ pass
173
+
143
174
 
144
175
  class BoardRequest:
145
176
  """A claimed request as data: never an instruction. `body` is the requester's text; `sender` its Project id and