@passioncode-ai/passioncode 0.2.0 → 0.2.2

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 (25) 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/skills/adapting-projects-to-fabric/SKILL.md +3 -3
  8. package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
  9. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +25 -6
  10. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +24 -1
  11. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +5 -5
  12. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +102 -0
  13. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +2 -2
  14. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +26 -0
  15. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +144 -11
  16. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +590 -0
  17. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.schema.json +631 -0
  18. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +182 -0
  19. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +140 -10
  20. package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +824 -0
  21. package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
  22. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  23. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +20 -0
  24. package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
  25. package/payload/plugins/passioncode/.claude-plugin/plugin.json +1 -1
@@ -2328,3 +2328,185 @@ export function taskUninstall(task, { run = defaultRun, platform = process.platf
2328
2328
  run(['schtasks', '/End', '/TN', task]);
2329
2329
  check(run(['schtasks', '/Delete', '/TN', task, '/F']), 'schtasks /Delete');
2330
2330
  }
2331
+
2332
+ // --- launchd (macOS; lifecycle.md "Install, upgrade, uninstall", LC-14; FAA-04) ------------------------------------
2333
+ // The twins of fabric_service.launchd_plist / launchd_loaded / launchd_override / launchd_install / launchd_uninstall.
2334
+ // `run(argv)` returns spawnSync's {status, stdout, stderr}; the tests pass a launchd stand-in, never the real launchctl.
2335
+
2336
+ const LABEL = /^[A-Za-z0-9][A-Za-z0-9._-]{0,250}$/;
2337
+ const DEFAULT_PATH = '/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin';
2338
+ const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2339
+
2340
+ function launchdLabel(label) {
2341
+ if (!LABEL.test(String(label))) throw new ServiceError(`label must match ${LABEL.source}`);
2342
+ return String(label);
2343
+ }
2344
+
2345
+ function plistValue(value, depth) {
2346
+ // plistlib.dumps's layout, byte for byte: one tab per level, keys sorted, & < > escaped, empty containers self-closed.
2347
+ const pad = '\t'.repeat(depth);
2348
+ if (value === true || value === false) return `${pad}<${value}/>\n`;
2349
+ if (Number.isInteger(value)) return `${pad}<integer>${value}</integer>\n`;
2350
+ if (typeof value === 'string') return `${pad}<string>${plain(value, 'A plist string').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</string>\n`;
2351
+ if (Array.isArray(value)) return value.length ? `${pad}<array>\n${value.map((v) => plistValue(v, depth + 1)).join('')}${pad}</array>\n` : `${pad}<array/>\n`;
2352
+ const keys = Object.keys(value).sort();
2353
+ if (!keys.length) return `${pad}<dict/>\n`;
2354
+ const inner = keys.map((k) => `${pad}\t<key>${plain(k, 'A plist key').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</key>\n${plistValue(value[k], depth + 1)}`).join('');
2355
+ return `${pad}<dict>\n${inner}${pad}</dict>\n`;
2356
+ }
2357
+
2358
+ /**
2359
+ * The launchd job: RunAtLoad + KeepAlive true + ThrottleInterval 10, ProcessType Standard, ExitTimeOut above the drain.
2360
+ * FABRIC_SERVICE_SUPERVISOR=launchd tells the lock to back off instead of exiting into a respawn loop (DEC-0057).
2361
+ * No secret is written: an environment name ending TOKEN/SECRET/PASSWORD/KEY (a `*_FILE` path is fine), a value or an
2362
+ * argument shaped like a credential is refused. Returns the XML text, the same bytes Python's plistlib writes.
2363
+ */
2364
+ export function launchdPlist(label, programArguments, { workingDirectory, stdoutPath, environment = {}, exitTimeout = DEFAULT_EXIT_TIMEOUT } = {}) {
2365
+ launchdLabel(label);
2366
+ const argv = programArguments.map(String);
2367
+ if (!argv.length) throw new ServiceError('A launchd job starts a program; ProgramArguments is empty.');
2368
+ if (!(exitTimeout > DRAIN_SECONDS)) throw new ServiceError(`ExitTimeOut must outlast the ${DRAIN_SECONDS} s drain.`);
2369
+ if (workingDirectory === undefined || stdoutPath === undefined) throw new ServiceError('A launchd job names its working directory and its log file.');
2370
+ const env = { ...environment };
2371
+ for (const [name, value] of Object.entries(env)) {
2372
+ if (/(TOKEN|SECRET|PASSWORD|KEY)$/.test(name) && !name.endsWith('_FILE')) throw new ServiceError(`Environment variable ${name} looks like a secret; pass a *_FILE path instead.`);
2373
+ if (secretValue(String(value))) throw new ServiceError(`Environment ${name} holds a value shaped like a credential; a plist never carries one.`);
2374
+ }
2375
+ for (const arg of argv) {
2376
+ if (secretArgument(arg)) throw new ServiceError('An argument carries a credential; a plist never does (read it from the token file or a secret reference).');
2377
+ }
2378
+ noMarkerArgument(argv);
2379
+ if (env.PATH === undefined) env.PATH = DEFAULT_PATH;
2380
+ env[SUPERVISOR_ENV] = 'launchd'; // the plist's writer writes the marker, whatever the caller passed
2381
+ const job = {
2382
+ Label: label,
2383
+ ProgramArguments: argv,
2384
+ WorkingDirectory: String(workingDirectory),
2385
+ RunAtLoad: true,
2386
+ KeepAlive: true,
2387
+ ThrottleInterval: 10,
2388
+ ExitTimeOut: Math.trunc(exitTimeout),
2389
+ // Standard, never Background: macOS starves a Background job under load and every host then reports an outage.
2390
+ ProcessType: 'Standard',
2391
+ StandardOutPath: String(stdoutPath),
2392
+ StandardErrorPath: String(stdoutPath),
2393
+ EnvironmentVariables: env,
2394
+ };
2395
+ return '<?xml version="1.0" encoding="UTF-8"?>\n'
2396
+ + '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
2397
+ + `<plist version="1.0">\n${plistValue(job, 0)}</plist>\n`;
2398
+ }
2399
+
2400
+ function launchdDomain(platform, uid) {
2401
+ // DEC-0032: launchd is macOS's supervisor; Linux has systemd and Windows Task Scheduler.
2402
+ if (platform !== 'darwin') throw new ServiceError('launchd is the supervisor on macOS only; use systemdInstall on Linux and taskInstall on Windows.');
2403
+ return `gui/${uid ?? process.getuid()}`;
2404
+ }
2405
+
2406
+ /** True when launchd has the job loaded (`launchctl print gui/<uid>/<label>` answers). */
2407
+ export function launchdLoaded(label, { run = defaultRun, platform = process.platform, uid } = {}) {
2408
+ return run(['launchctl', 'print', `${launchdDomain(platform, uid)}/${launchdLabel(label)}`])?.status === 0;
2409
+ }
2410
+
2411
+ const OVERRIDE_ROW = /^\s*"([^"]+)"\s*=>\s*(\S+)\s*$/;
2412
+
2413
+ /**
2414
+ * The operator's launchd override for `label`: 'disabled', 'enabled', null when none is recorded (a first install), or
2415
+ * 'unknown' when the table cannot be read. macOS 14+ prints `=> disabled|enabled`, older releases `=> true|false`.
2416
+ */
2417
+ export function launchdOverride(label, { run = defaultRun, platform = process.platform, uid } = {}) {
2418
+ const result = run(['launchctl', 'print-disabled', launchdDomain(platform, uid)]);
2419
+ if (result?.status !== 0) return 'unknown';
2420
+ for (const line of String(result.stdout ?? '').split('\n')) {
2421
+ const match = OVERRIDE_ROW.exec(line);
2422
+ if (match && match[1] === label) return ['disabled', 'true'].includes(match[2]) ? 'disabled' : 'enabled';
2423
+ }
2424
+ return null;
2425
+ }
2426
+
2427
+ async function wellKnownOf(origin) {
2428
+ try {
2429
+ const response = await fetch(`${origin}/.well-known/fabric-service`, { signal: AbortSignal.timeout(2000) });
2430
+ return response.ok ? await response.json() : null;
2431
+ } catch {
2432
+ return null;
2433
+ }
2434
+ }
2435
+
2436
+ /**
2437
+ * Write and lint the plist, bootout and WAIT for the unload, bootstrap (retrying launchd's EIO 5 times), then poll the
2438
+ * well-known document until it answers with this identity; resolves to that document.
2439
+ *
2440
+ * The operator's intent wins (LC-14): only a first install (no override recorded) enables the label. A label the
2441
+ * operator disabled stays disabled and unloaded — the plist is still rewritten, so the new release is what starts once
2442
+ * they turn it back on — and the answer is {disabled: true, label, plist, loaded}. `forceEnable` is for an operator
2443
+ * who asked for the service to be switched on.
2444
+ */
2445
+ export async function launchdInstall(label, plistPath, plistText, { origin, serviceId, instance = 'default', timeout = 40, forceEnable = false,
2446
+ run = defaultRun, platform = process.platform, uid, fetchWellKnown = wellKnownOf, sleep = pause } = {}) {
2447
+ const domain = launchdDomain(platform, uid);
2448
+ launchdLabel(label);
2449
+ if (!origin || !serviceId) throw new ServiceError('launchdInstall needs the origin and the service id it waits to see answer.');
2450
+ const opts = { run, platform, uid };
2451
+ atomicWrite(plistPath, plistText, 0o644);
2452
+ const lint = run(['plutil', '-lint', String(plistPath)]);
2453
+ if (lint?.status !== 0) throw new ServiceError(`plist failed plutil -lint: ${String(lint?.stdout || lint?.stderr || '').trim().slice(0, 200)}`);
2454
+ const target = `${domain}/${label}`;
2455
+ const override = launchdOverride(label, opts);
2456
+ if (forceEnable || override === null) run(['launchctl', 'enable', target]);
2457
+ else if (override === 'disabled') return { disabled: true, label, plist: String(plistPath), loaded: launchdLoaded(label, opts) };
2458
+ run(['launchctl', 'bootout', target]);
2459
+ const unloaded = Date.now() + 15_000;
2460
+ while (launchdLoaded(label, opts) && Date.now() < unloaded) await sleep(250);
2461
+ let last = '';
2462
+ for (let attempt = 0; ; attempt += 1) {
2463
+ const result = run(['launchctl', 'bootstrap', domain, String(plistPath)]);
2464
+ if (result?.status === 0) break;
2465
+ last = String(result?.stderr || result?.stdout || '').trim();
2466
+ if (attempt === 4) throw new ServiceError(`launchctl bootstrap failed: ${last.slice(0, 200)}`);
2467
+ await sleep(1000);
2468
+ }
2469
+ const deadline = Date.now() + timeout * 1000;
2470
+ while (Date.now() < deadline) {
2471
+ const doc = await fetchWellKnown(origin);
2472
+ if (doc?.service?.id === serviceId && doc?.service?.instance === instance) return doc;
2473
+ await sleep(500);
2474
+ }
2475
+ throw new ServiceError(`${origin} did not answer as ${serviceId}.${instance} within ${timeout} s; read its log.`);
2476
+ }
2477
+
2478
+ /**
2479
+ * Symmetric with launchdInstall (LC-14): bootout and WAIT until the job is gone, remove the plist, and reset the
2480
+ * override to enabled — launchctl has no verb that deletes an override, and a leftover `disabled` would keep a later
2481
+ * fresh install off. Data stays unless `purge` (then `serviceId` names whose data, logs and cache go). A job still
2482
+ * loaded at `timeout` seconds throws, leaving the plist and the data in place.
2483
+ */
2484
+ export async function launchdUninstall(label, plistPath, { timeout = 30, purge = false, serviceId, run = defaultRun, platform = process.platform, uid, sleep = pause } = {}) {
2485
+ const domain = launchdDomain(platform, uid);
2486
+ launchdLabel(label);
2487
+ if (purge && !serviceId) throw new ServiceError('purge needs the service id whose data, logs and cache to remove.');
2488
+ const dirs = purge ? serviceDirs(serviceId, platform) : {};
2489
+ const opts = { run, platform, uid };
2490
+ const target = `${domain}/${label}`;
2491
+ run(['launchctl', 'bootout', target]);
2492
+ const deadline = Date.now() + timeout * 1000;
2493
+ while (launchdLoaded(label, opts)) {
2494
+ if (Date.now() >= deadline) throw new ServiceError(`launchd job ${label} is still loaded ${timeout} s after bootout; nothing was removed.`);
2495
+ await sleep(100);
2496
+ }
2497
+ let plistRemoved = true;
2498
+ try {
2499
+ fs.unlinkSync(plistPath);
2500
+ } catch (error) {
2501
+ if (error.code !== 'ENOENT') throw error;
2502
+ plistRemoved = false;
2503
+ }
2504
+ run(['launchctl', 'enable', target]);
2505
+ const purged = [];
2506
+ for (const dir of Object.values(dirs)) {
2507
+ try { fs.lstatSync(dir); } catch { continue; }
2508
+ fs.rmSync(dir, { recursive: true, force: true }); // a symlinked folder: the link goes, never what it points at
2509
+ purged.push(dir);
2510
+ }
2511
+ return { unloaded: true, plistRemoved, override: 'enabled', purged };
2512
+ }
@@ -60,13 +60,21 @@ REFUSAL_CODES = frozenset(["invalid_arguments", "body_too_large", "not_authorize
60
60
  "unsupported_capability", "unsupported_operation", "not_available", "idempotency_conflict",
61
61
  "idempotency_window_expired", "capacity_exceeded", "digest_conflict", "revision_conflict",
62
62
  "fenced", "lease_expired", "invalid_transition", "reconcile_required", "observation_required",
63
- "cursor_reset_required", "immutable_participants"])
63
+ "cursor_reset_required", "immutable_participants", "held"])
64
64
  #: On these the attempt is no longer ours: stop acting at once (C4).
65
65
  STOP_CODES = frozenset(["fenced", "lease_expired", "authority_changed"])
66
66
  REQUEST_STATES = ("queued", "claimed", "accepted", "in_progress", "completed", "failed_known", "cancelled", "expired",
67
67
  "outcome_unknown")
68
68
  EFFECT_STATES = ("not_started", "started", "succeeded_observed", "failed_observed", "unknown")
69
69
  TERMINAL = frozenset(["completed", "failed_known", "cancelled", "expired"])
70
+ #: DEC-0079: what an operator's hold may refuse, by the scopes that refuse it. A tool not named here is never refused
71
+ #: `held` — it settles, reports on or keeps alive started work (com.renew, com.progress, com.complete, com.reconcile),
72
+ #: cancels or reads. The contract's HOLD_SCOPES.
73
+ HOLD_SCOPES = {"com.submit": ("thread",), "com.reply": ("thread",), "com.enroll": ("project", "slot"),
74
+ "com.responder_replace": ("project", "slot"), "com.claim": ("thread", "project", "slot"),
75
+ "com.accept": ("thread", "project", "slot"), "com.effect_begin": ("thread", "project", "slot")}
76
+ #: How long a held slot waits before it asks again: doubling from the poll interval, never past this.
77
+ HOLD_RETRY_MAX_SECONDS = 900.0
70
78
 
71
79
  _OPAQUE = re.compile(r"[A-Za-z0-9_-]{8,128}")
72
80
  _PROJECT = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,191}")
@@ -188,7 +196,7 @@ def receipt_problems(value: Any, op: Optional[str] = None) -> List[str]:
188
196
  if "request" in value:
189
197
  r = value["request"]
190
198
  keys = ("id", "state", "effect", "revision")
191
- if _closed(r, "/request", keys, keys, problems):
199
+ if _closed(r, "/request", keys, keys + ("settledBy",), problems):
192
200
  if not (isinstance(r.get("id"), str) and _OPAQUE.fullmatch(r["id"])):
193
201
  problems.append("/request/id is a board id")
194
202
  if r.get("state") not in REQUEST_STATES:
@@ -197,6 +205,12 @@ def receipt_problems(value: Any, op: Optional[str] = None) -> List[str]:
197
205
  problems.append("/request/effect is an effect state")
198
206
  if not _is_int(r.get("revision"), 1):
199
207
  problems.append("/request/revision is an integer ≥ 1")
208
+ if "settledBy" in r:
209
+ # DEC-0081: present only when the estate owner settled it; absent, the responder did.
210
+ if r["settledBy"] != "owner":
211
+ problems.append("/request/settledBy is owner")
212
+ if r.get("state") not in ("completed", "failed_known"):
213
+ problems.append("/request/settledBy names a settled request (completed or failed_known)")
200
214
  if "slot" in value:
201
215
  s = value["slot"]
202
216
  keys = ("project", "capability", "generation", "leaseExpiresAt")
@@ -248,6 +262,8 @@ def refusal_problems(value: Any) -> List[str]:
248
262
  problems.append("/error/message is 1 to 300 characters")
249
263
  if not isinstance(e.get("retryable"), bool):
250
264
  problems.append("/error/retryable is a boolean")
265
+ elif e.get("code") == "held" and e["retryable"] is not True:
266
+ problems.append("/error/retryable is true for held: the operator lifts a hold (DEC-0079)")
251
267
  return problems
252
268
 
253
269
 
@@ -312,14 +328,48 @@ def directory_problems(page: Any) -> List[str]:
312
328
  continue
313
329
  for c, entry in enumerate(caps):
314
330
  cat = "%s/capabilities/%d" % (at, c)
315
- if _closed(entry, cat, ("capability", "responder"), ("capability", "responder"), problems):
331
+ if _closed(entry, cat, ("capability", "responder"), ("capability", "responder", "held"), problems):
316
332
  if not (isinstance(entry.get("capability"), str) and _CAPABILITY.fullmatch(entry["capability"])):
317
333
  problems.append("%s/capability is a capability name" % cat)
318
334
  if entry.get("responder") not in ("active", "stale", "absent"):
319
335
  problems.append("%s/responder is active, stale or absent" % cat)
336
+ if "held" in entry and not isinstance(entry["held"], bool):
337
+ problems.append("%s/held is a boolean (DEC-0079)" % cat)
320
338
  return problems + ["%s %s" % (f["instancePath"], f["message"]) for f in directory_findings(page)]
321
339
 
322
340
 
341
+ def hold_refuses(tool: Any, target: Any, holds: Any) -> bool:
342
+ """DEC-0079, the contract's ``holdRefuses``: True when a board refuses ``tool`` on ``target`` with ``held`` under
343
+ ``holds``. ``target`` is what the board resolved (``thread``, ``project``, ``capability``, ``fenced``, ``repeat``);
344
+ ``holds`` is ``{threads, projects, slots: [{project, capability}]}``. Ids compare as written. Malformed input never
345
+ raises: a member of the wrong type counts as absent, and an unknown tool is never refused ``held``."""
346
+ scopes = HOLD_SCOPES.get(tool) if isinstance(tool, str) else None
347
+ if not scopes or not isinstance(target, dict):
348
+ return False
349
+ if target.get("repeat") is True:
350
+ return False
351
+ if tool == "com.reply" and target.get("fenced") is True:
352
+ return False
353
+ holds = holds if isinstance(holds, dict) else {}
354
+
355
+ def text(value: Any) -> Optional[str]:
356
+ return value if isinstance(value, str) else None
357
+
358
+ def strings(value: Any) -> set:
359
+ return {item for item in value if isinstance(item, str)} if isinstance(value, list) else set()
360
+
361
+ thread, project, capability = text(target.get("thread")), text(target.get("project")), text(target.get("capability"))
362
+ if "thread" in scopes and thread is not None and thread in strings(holds.get("threads")):
363
+ return True
364
+ if "project" in scopes and project is not None and project in strings(holds.get("projects")):
365
+ return True
366
+ slots = holds.get("slots")
367
+ if "slot" in scopes and project is not None and capability is not None and isinstance(slots, list):
368
+ return any(isinstance(slot, dict) and slot.get("project") == project and slot.get("capability") == capability
369
+ for slot in slots)
370
+ return False
371
+
372
+
323
373
  def find_project(page: Any, project_id: str, capability: Optional[str] = None) -> Optional[Dict[str, Any]]:
324
374
  """Look a Project up in one `com.directory` page (read by a Project session — a board session is not served the
325
375
  directory). The page is checked first: a page the contract would refuse, FAC-SEM-107 included, raises
@@ -630,6 +680,9 @@ class Responder:
630
680
  self.claim_keys: Dict[str, str] = {}
631
681
  self._said: Dict[str, str] = {}
632
682
  self._rr = 0
683
+ #: The first pause under an operator's hold (DEC-0079); it doubles to HOLD_RETRY_MAX_SECONDS.
684
+ self.hold_delay = POLL_SECONDS
685
+ self._on_hold: set = set()
633
686
 
634
687
  # -- events -------------------------------------------------------------------------------------------------
635
688
  def _emit(self, kind: str, level: str, text: str, notify: bool = False, episode: Optional[str] = None, **value: Any) -> None:
@@ -712,6 +765,9 @@ class Responder:
712
765
  try:
713
766
  receipt = self._receipt("com.enroll", self._call("com.enroll", args))
714
767
  except BoardError as exc:
768
+ if exc.code == "held":
769
+ self._pause(capability)
770
+ return
715
771
  if exc.code == "fenced":
716
772
  self.slots[capability] = {"state": "contested", "renewed_at": self.now()}
717
773
  self._emit("board.slot.contested", "warn", "Another responder holds the %s slot on this Project; this service "
@@ -721,6 +777,25 @@ class Responder:
721
777
  raise
722
778
  self.slots[capability] = {"state": "held", "generation": receipt["slot"]["generation"], "renewed_at": self.now()}
723
779
  self._said.pop("board.slot.contested", None)
780
+ self._lifted(capability)
781
+
782
+ def _pause(self, capability: str) -> None:
783
+ """The operator holds this slot or its Project (DEC-0079): ask again later, the pause doubling each time, and
784
+ never act around the hold. Accepted work in progress continues; only new work waits."""
785
+ slot = self.slots.get(capability) or {}
786
+ pauses = slot.get("pauses", 0) + 1 if slot.get("state") == "paused" else 1
787
+ wait = min(HOLD_RETRY_MAX_SECONDS, self.hold_delay * 2 ** (pauses - 1))
788
+ self.slots[capability] = {"state": "paused", "pauses": pauses, "retry_at": self.now() + wait, "renewed_at": self.now()}
789
+ if capability not in self._on_hold: # once per hold, per capability
790
+ self._on_hold.add(capability)
791
+ self._emit("board.slot.held", "info", "The operator holds new work for %s on this Project; the service waits and "
792
+ "asks again later. Work already accepted continues." % capability, capability=capability)
793
+
794
+ def _lifted(self, capability: str) -> None:
795
+ if capability in self._on_hold:
796
+ self._on_hold.discard(capability)
797
+ self._emit("board.slot.released", "info", "The operator's hold on %s was lifted; the service takes new work "
798
+ "again." % capability, capability=capability)
724
799
 
725
800
  # -- restart: reconcile and transfer -------------------------------------------------------------------------
726
801
  def _read_state(self, entry: Dict[str, Any]) -> Optional[str]:
@@ -750,10 +825,17 @@ class Responder:
750
825
  transfer.setdefault(entry["capability"], []).append(entry)
751
826
  else: # in_progress (lost result once a new generation holds the slot) or outcome_unknown
752
827
  reconcile.append(entry)
753
- generations = {r.get("capability"): r.get("generation", 0) for r in status.get("responders") or [] if isinstance(r, dict)}
828
+ responders = [r for r in status.get("responders") or [] if isinstance(r, dict)]
829
+ generations = {r.get("capability"): r.get("generation", 0) for r in responders}
830
+ on_hold = {r.get("capability") for r in responders if r.get("held") is True} # DEC-0079
754
831
  for capability in capabilities:
755
832
  held = transfer.get(capability)
756
- if held:
833
+ if capability in on_hold:
834
+ for entry in held or []:
835
+ entry["step"] = "lapsed" # taken back by a transfer once the hold is lifted
836
+ self.checkpoint.put(entry)
837
+ self._pause(capability)
838
+ elif held:
757
839
  self._replace(capability, held, generations.get(capability, 0))
758
840
  else:
759
841
  self._enroll(capability)
@@ -767,8 +849,18 @@ class Responder:
767
849
  args = {"idempotency": self._idem(self._key("replace")), "capability": capability, "expectedGeneration": generation,
768
850
  "transfer": {"requests": [e["request"] for e in entries][:64],
769
851
  "checkpoint": {"summary": "Resumed by the same service after a restart; no effect had begun."}}}
770
- receipt = self._receipt("com.responder_replace", self._call("com.responder_replace", args))
852
+ try:
853
+ receipt = self._receipt("com.responder_replace", self._call("com.responder_replace", args))
854
+ except BoardError as exc:
855
+ if exc.code != "held":
856
+ raise
857
+ for entry in entries:
858
+ entry["step"] = "lapsed" # nothing moved: taken back by a transfer once the hold is lifted
859
+ self.checkpoint.put(entry)
860
+ self._pause(capability)
861
+ return
771
862
  self.slots[capability] = {"state": "held", "generation": receipt["slot"]["generation"], "renewed_at": self.now()}
863
+ self._lifted(capability)
772
864
  moved = {item["request"]: item for item in receipt.get("transferred") or []}
773
865
  for entry in entries:
774
866
  item = moved.get(entry["request"])
@@ -848,6 +940,10 @@ class Responder:
848
940
  self._emit("board.access.needed", "warn", "This service may not answer requests for this Project: the operator "
849
941
  "has to allow it in Fabric (Agents & access). Nothing is claimed until then.", notify=True,
850
942
  episode="access", reason=code)
943
+ elif code == "held":
944
+ self.state = "paused"
945
+ self._emit("board.held", "info", "The operator holds new work on this Project; the responder waits and asks again "
946
+ "with a growing pause.", episode="held", reason=code)
851
947
  elif code in ("no-epoch", "schema-unsupported", "checkpoint", "receipt-shape"):
852
948
  self.state = "down"
853
949
  self._drop_session()
@@ -887,6 +983,10 @@ class Responder:
887
983
  self._renew(work)
888
984
  held_by_work = work.entry["capability"] if work is not None and self.current is not None else None
889
985
  for capability, slot in list(self.slots.items()):
986
+ if slot.get("state") == "paused":
987
+ if capability != held_by_work and self.now() >= slot.get("retry_at", 0.0):
988
+ self._enroll(capability) # still held: a longer pause; lifted: the slot is ours again
989
+ continue
890
990
  if capability != held_by_work and self.now() - slot.get("renewed_at", 0) >= RENEW_SECONDS - 0.5:
891
991
  self._enroll(capability) # the holder's enroll renews its lease (no event); a lapsed one re-enrolls
892
992
  if self.current is None and not self.stopping:
@@ -937,6 +1037,9 @@ class Responder:
937
1037
  if exc.code == "fenced":
938
1038
  self.slots[capability] = {"state": "contested", "renewed_at": self.now()}
939
1039
  continue
1040
+ if exc.code == "held":
1041
+ self._pause(capability)
1042
+ continue
940
1043
  raise
941
1044
  if "request" not in answer:
942
1045
  continue # nothing claimable; the same key polls again
@@ -992,7 +1095,18 @@ class Responder:
992
1095
  key = entry["keys"].get("accept") or self._key("accept")
993
1096
  entry["keys"]["accept"], entry["step"] = key, "accepting"
994
1097
  self.checkpoint.put(entry)
995
- receipt = self._fenced("com.accept", entry, {"fence": self._fence(entry, key)})
1098
+ try:
1099
+ receipt = self._fenced("com.accept", entry, {"fence": self._fence(entry, key)})
1100
+ except BoardError as exc:
1101
+ if exc.code != "held":
1102
+ raise
1103
+ # A hold came between the claim and the accept (DEC-0079): nothing was done, the claim lapses and the request
1104
+ # waits on the board for the hold to be lifted.
1105
+ self.checkpoint.drop(entry["request"])
1106
+ self._emit("board.request.waiting", "info", "A claimed request was not accepted: the operator holds it. Nothing "
1107
+ "was done; it waits on the board.", request=entry["request"], capability=entry["capability"])
1108
+ self._pause(entry["capability"])
1109
+ return False
996
1110
  if receipt is None:
997
1111
  return False # the claim was lost before it was accepted; nothing was done
998
1112
  entry["revision"], entry["step"] = receipt["request"]["revision"], "accepted"
@@ -1087,14 +1201,30 @@ class Responder:
1087
1201
  if summary:
1088
1202
  args["summary"] = " ".join(str(summary).split())[:SUMMARY_MAX]
1089
1203
  receipt = None
1090
- for attempt in range(3): # a lost answer: the same key again tells whether it was stored
1204
+ lost, delay = 0, self.hold_delay
1205
+ while True: # a lost answer: the same key again tells whether it was stored
1091
1206
  try:
1092
1207
  receipt = self._fenced("com.effect_begin", entry, args)
1093
1208
  break
1094
1209
  except BoardError as exc:
1095
- if exc.code != "unreachable" or attempt == 2:
1210
+ if exc.code == "held":
1211
+ # The operator holds this work (DEC-0079): the effect has not begun. Wait, keep the lease (the tick
1212
+ # renews it; com.renew is never held), and ask again with the same key.
1213
+ entry["effect"], entry["step"] = False, "effect-held"
1214
+ self.checkpoint.put(entry)
1215
+ self._emit("board.request.waiting", "info", "The operator holds this request before its effect: the "
1216
+ "service waits and does nothing outside it until the hold is lifted.", episode=entry["request"],
1217
+ request=entry["request"], capability=entry["capability"])
1218
+ if ctx.stopped.wait(delay):
1219
+ raise Stopped() from None
1220
+ delay = min(HOLD_RETRY_MAX_SECONDS, delay * 2)
1221
+ entry["effect"], entry["step"] = True, "effect-beginning"
1222
+ self.checkpoint.put(entry)
1223
+ continue
1224
+ if exc.code != "unreachable" or lost == 2:
1096
1225
  raise Stopped() from None
1097
- time.sleep(attempt + 1)
1226
+ lost += 1
1227
+ time.sleep(lost)
1098
1228
  if receipt is None:
1099
1229
  raise Stopped()
1100
1230
  entry["revision"], entry["step"] = receipt["request"]["revision"], "in-progress"