@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.
- package/CHANGELOG.md +18 -0
- package/family.json +1 -1
- package/package.json +1 -1
- package/payload/.claude-plugin/marketplace.json +2 -2
- package/payload/manifest.json +8 -8
- package/payload/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +3 -3
- package/payload/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +2 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +25 -6
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +24 -1
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +5 -5
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +102 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +2 -2
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +26 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +144 -11
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +590 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.schema.json +631 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +182 -0
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +140 -10
- package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +824 -0
- package/payload/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -3
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +20 -0
- package/payload/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +1 -1
- 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, '&').replace(/</g, '<').replace(/>/g, '>')}</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, '&').replace(/</g, '<').replace(/>/g, '>')}</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
|
+
}
|
package/payload/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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"
|