c8ctl-plugin-nano 1.59.1 → 1.60.1
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/c8ctl-plugin.js +143 -46
- package/package.json +8 -8
- package/supervisor-engine.mjs +71 -9
- package/supervisor.dist.js +11 -11
package/c8ctl-plugin.js
CHANGED
|
@@ -3323,6 +3323,23 @@ async function createAgenticEndpoint(opts) {
|
|
|
3323
3323
|
// @param {typeof fetch} [opts.fetchImpl] injected fetch (tests)
|
|
3324
3324
|
// @param {NodeJS.ProcessEnv} [opts.env]
|
|
3325
3325
|
// @returns {Promise<{ deps: object, registry: object, settle: { complete: Function, fail: Function }, makeSupervisor: Function, Effect: object, Fiber: object }>}
|
|
3326
|
+
// Bind a settle seam to a SINGLE activation's lease token, captured from `job`
|
|
3327
|
+
// in closure scope. A settlement is thereby fenced with the exact activation
|
|
3328
|
+
// that ran — NOT a token re-read from a shared activeJobs map at settle time: a
|
|
3329
|
+
// same-key reactivation can overwrite that entry while an interrupted runner is
|
|
3330
|
+
// still unwinding, so a map lookup could fence the completion with the WRONG
|
|
3331
|
+
// (newer) token and clobber the new activation — the very lease bypass this
|
|
3332
|
+
// fence exists to prevent. `job.leaseToken` in the returned closures cannot drift.
|
|
3333
|
+
// @param {{ complete: Function, fail: Function }} settle the raw settle seam
|
|
3334
|
+
// @param {{ jobKey: string, leaseToken?: string }} job the activation whose lease fences the settlement
|
|
3335
|
+
// @returns {{ complete: (variables:any)=>any, fail: (opts?:object)=>any }}
|
|
3336
|
+
function bindJobSettle(settle, job) {
|
|
3337
|
+
return {
|
|
3338
|
+
complete: (variables) => settle.complete(job.jobKey, variables, job.leaseToken),
|
|
3339
|
+
fail: (opts2) => settle.fail(job.jobKey, { ...(opts2 || {}), leaseToken: job.leaseToken }),
|
|
3340
|
+
};
|
|
3341
|
+
}
|
|
3342
|
+
|
|
3326
3343
|
async function createSupervisorDeps(opts = {}) {
|
|
3327
3344
|
const {
|
|
3328
3345
|
runner,
|
|
@@ -3424,7 +3441,7 @@ async function createSupervisorDeps(opts = {}) {
|
|
|
3424
3441
|
// the runtime uses (activate/extendLock), and any future port instrumentation
|
|
3425
3442
|
// covers the settle path too.
|
|
3426
3443
|
const settle = {
|
|
3427
|
-
complete: (jobKey, variables) => rt.Effect.runPromise(engine.complete(jobKey, variables)),
|
|
3444
|
+
complete: (jobKey, variables, leaseToken) => rt.Effect.runPromise(engine.complete(jobKey, variables, leaseToken)),
|
|
3428
3445
|
fail: (jobKey, opts2) => rt.Effect.runPromise(engine.fail(jobKey, opts2)),
|
|
3429
3446
|
};
|
|
3430
3447
|
|
|
@@ -6373,16 +6390,25 @@ function normalizeProjectApps(projects) {
|
|
|
6373
6390
|
}
|
|
6374
6391
|
|
|
6375
6392
|
/**
|
|
6376
|
-
* Probe whether
|
|
6377
|
-
*
|
|
6393
|
+
* Probe whether a `/agentic` endpoint answers a WebSocket upgrade. Connects to
|
|
6394
|
+
* `ws(s)://<host>:<port><pathPrefix>/agentic?token=…` (host defaults to
|
|
6378
6395
|
* `127.0.0.1`; a bare IPv6 literal is bracketed for the URL authority) and
|
|
6379
6396
|
* resolves `true` only if the socket opens within `timeoutMs`; a refused
|
|
6380
|
-
* connection,
|
|
6381
|
-
*
|
|
6382
|
-
* outcome is known. Never throws.
|
|
6397
|
+
* connection, a `404`/`501`, or a timeout all resolve `false`. Self-cleaning —
|
|
6398
|
+
* the probe socket is closed as soon as the outcome is known. Never throws.
|
|
6383
6399
|
*
|
|
6384
|
-
*
|
|
6385
|
-
*
|
|
6400
|
+
* `pathPrefix` targets either the app's own port (empty prefix → `/agentic`) or
|
|
6401
|
+
* the engine's console **app-view WebSocket tunnel** (engine #1054), where the
|
|
6402
|
+
* prefix is `/console/app-view/<project>` so the channel rides the single engine
|
|
6403
|
+
* port instead of the app's direct port (see {@link discoverAgenticHubs}).
|
|
6404
|
+
*
|
|
6405
|
+
* `secure` selects the WS scheme: `false` → `ws://` (plain), `true` → `wss://`.
|
|
6406
|
+
* The tunnel leg rides the engine's own port, so an `https://` engine base
|
|
6407
|
+
* requires a `wss://` upgrade — probing it with plain `ws://` would spuriously
|
|
6408
|
+
* fail the tunnel and force the direct-port fallback (see {@link discoverAgenticHubs}).
|
|
6409
|
+
*
|
|
6410
|
+
* @param {number|string} port the port the WS connects to (app port, or engine port for the tunnel)
|
|
6411
|
+
* @param {{ host?: string, token?: string, WebSocketImpl?: Function, timeoutMs?: number, pathPrefix?: string, secure?: boolean }} [opts]
|
|
6386
6412
|
* @returns {Promise<boolean>}
|
|
6387
6413
|
*/
|
|
6388
6414
|
function probeAgenticChannel(port, {
|
|
@@ -6390,9 +6416,12 @@ function probeAgenticChannel(port, {
|
|
|
6390
6416
|
token = LOCAL_AGENTIC_TOKEN,
|
|
6391
6417
|
WebSocketImpl = globalThis.WebSocket,
|
|
6392
6418
|
timeoutMs = AGENTIC_DISCOVERY_TIMEOUT_MS,
|
|
6419
|
+
pathPrefix = '',
|
|
6420
|
+
secure = false,
|
|
6393
6421
|
} = {}) {
|
|
6394
6422
|
if (typeof WebSocketImpl !== 'function') return Promise.resolve(false);
|
|
6395
|
-
const
|
|
6423
|
+
const wsScheme = secure ? 'wss' : 'ws';
|
|
6424
|
+
const url = `${wsScheme}://${wsHostPart(host)}:${port}${pathPrefix}/agentic?token=${encodeURIComponent(token)}`;
|
|
6396
6425
|
return new Promise((resolve) => {
|
|
6397
6426
|
let done = false;
|
|
6398
6427
|
let ws;
|
|
@@ -6499,8 +6528,8 @@ async function resolveProbeCandidates(host, { lookupImpl = dnsLookup } = {}) {
|
|
|
6499
6528
|
* slow-to-open one can't stall the whole probe: a fast later candidate still
|
|
6500
6529
|
* wins. Each per-host probe is bounded by `timeoutMs`. A single candidate skips
|
|
6501
6530
|
* the racing machinery entirely (unchanged legacy path).
|
|
6502
|
-
* @param {number} port
|
|
6503
|
-
* @param {{ hosts?: string[], token?: string, timeoutMs?: number, wsProbe?: Function, staggerMs?: number }} [opts]
|
|
6531
|
+
* @param {number|string} port
|
|
6532
|
+
* @param {{ hosts?: string[], token?: string, timeoutMs?: number, wsProbe?: Function, staggerMs?: number, pathPrefix?: string, secure?: boolean }} [opts]
|
|
6504
6533
|
* @returns {Promise<string|null>} the winning host, or null
|
|
6505
6534
|
*/
|
|
6506
6535
|
async function raceProbeCandidates(port, {
|
|
@@ -6509,12 +6538,14 @@ async function raceProbeCandidates(port, {
|
|
|
6509
6538
|
timeoutMs = AGENTIC_DISCOVERY_TIMEOUT_MS,
|
|
6510
6539
|
wsProbe = probeAgenticChannel,
|
|
6511
6540
|
staggerMs = 250,
|
|
6541
|
+
pathPrefix = '',
|
|
6542
|
+
secure = false,
|
|
6512
6543
|
} = {}) {
|
|
6513
6544
|
const list = Array.isArray(hosts) ? hosts.filter(Boolean) : [];
|
|
6514
6545
|
if (list.length === 0) return null;
|
|
6515
6546
|
if (list.length === 1) {
|
|
6516
6547
|
try {
|
|
6517
|
-
return (await wsProbe(port, { host: list[0], token, timeoutMs })) ? list[0] : null;
|
|
6548
|
+
return (await wsProbe(port, { host: list[0], token, timeoutMs, pathPrefix, secure })) ? list[0] : null;
|
|
6518
6549
|
} catch {
|
|
6519
6550
|
return null;
|
|
6520
6551
|
}
|
|
@@ -6531,7 +6562,7 @@ async function raceProbeCandidates(port, {
|
|
|
6531
6562
|
};
|
|
6532
6563
|
const start = (host) => {
|
|
6533
6564
|
Promise.resolve()
|
|
6534
|
-
.then(() => wsProbe(port, { host, token, timeoutMs }))
|
|
6565
|
+
.then(() => wsProbe(port, { host, token, timeoutMs, pathPrefix, secure }))
|
|
6535
6566
|
.catch(() => false)
|
|
6536
6567
|
.then((ok) => {
|
|
6537
6568
|
if (ok) done(host);
|
|
@@ -6547,23 +6578,32 @@ async function raceProbeCandidates(port, {
|
|
|
6547
6578
|
|
|
6548
6579
|
/**
|
|
6549
6580
|
* Auto-discover the embedded nwf agentic hub(s) reachable from an engine base
|
|
6550
|
-
* URL (#75, #96). Reads `GET <engine>/console/api/projects`, keeps the apps
|
|
6551
|
-
* advertise an agentic UI port, and WS-probes each app's `/agentic` **on the
|
|
6552
|
-
* engine's own host** to confirm the channel is actually served there
|
|
6553
|
-
*
|
|
6554
|
-
*
|
|
6555
|
-
*
|
|
6556
|
-
*
|
|
6557
|
-
*
|
|
6558
|
-
*
|
|
6559
|
-
*
|
|
6560
|
-
*
|
|
6561
|
-
*
|
|
6562
|
-
*
|
|
6581
|
+
* URL (#75, #96, #97). Reads `GET <engine>/console/api/projects`, keeps the apps
|
|
6582
|
+
* that advertise an agentic UI port, and WS-probes each app's `/agentic` **on the
|
|
6583
|
+
* engine's own host** to confirm the channel is actually served there. Works
|
|
6584
|
+
* cross-machine on a trusted LAN: a loopback engine probes `127.0.0.1`, a remote
|
|
6585
|
+
* engine (e.g. `merlin.local`) probes that same host — the port is taken from the
|
|
6586
|
+
* projects API but the host is always the engine's, so a rogue projects API can
|
|
6587
|
+
* never steer a probe at the worker's own loopback (#76).
|
|
6588
|
+
*
|
|
6589
|
+
* **Single-port hardening (#97, engine #1054):** each app is probed **tunnel-first** —
|
|
6590
|
+
* `ws://<engineHost>:<enginePort>/console/app-view/<project>/agentic`, the console
|
|
6591
|
+
* app-view WebSocket tunnel on the engine's *own* port (the same host:port the
|
|
6592
|
+
* worker already reached for the projects read). A surviving tunnel hub is marked
|
|
6593
|
+
* `via:'tunnel'` and needs only the engine port to be reachable — the app's direct
|
|
6594
|
+
* port need not be LAN-open. If the tunnel probe fails (an engine that predates
|
|
6595
|
+
* #1054 refuses the `/agentic` WS upgrade with `501`), it falls back to probing the
|
|
6596
|
+
* app's **direct** port and marks the hub `via:'direct'` (unchanged #96 behaviour).
|
|
6597
|
+
*
|
|
6598
|
+
* Gives the projects fetch and each WS probe INDEPENDENT deadlines (#133) so a slow
|
|
6599
|
+
* fetch can't starve the probe, prefers a routable address over a link-local `fe80::`
|
|
6600
|
+
* one (Happy-Eyeballs), and is fail-open: any error — not a nano engine (Camunda),
|
|
6601
|
+
* network failure, malformed body, or a timeout — degrades to `[]` so the worker's
|
|
6602
|
+
* real job is never blocked.
|
|
6563
6603
|
*
|
|
6564
6604
|
* @param {string} engineBaseUrl the engine base URL (e.g. `http://merlin.local:8080`)
|
|
6565
6605
|
* @param {{ token?: string, fetchImpl?: Function, wsProbe?: Function, lookupImpl?: Function, timeoutMs?: number, fetchTimeoutMs?: number, probeTimeoutMs?: number }} [opts]
|
|
6566
|
-
* @returns {Promise<Array<{ project: string, port: number, label?: string, host: string }>>}
|
|
6606
|
+
* @returns {Promise<Array<{ project: string, port: number, label?: string, host: string, via: 'tunnel'|'direct', enginePort?: string, scheme?: string }>>}
|
|
6567
6607
|
*/
|
|
6568
6608
|
async function discoverAgenticHubs(engineBaseUrl, {
|
|
6569
6609
|
token = LOCAL_AGENTIC_TOKEN,
|
|
@@ -6586,12 +6626,15 @@ async function discoverAgenticHubs(engineBaseUrl, {
|
|
|
6586
6626
|
// loopback services — which was the actual #76 concern (a rogue projects API
|
|
6587
6627
|
// making the worker probe its own localhost). So the port comes from the
|
|
6588
6628
|
// engine's projects API, but the HOST is always the engine's, never guessed.
|
|
6589
|
-
let
|
|
6629
|
+
let engineUrl;
|
|
6590
6630
|
try {
|
|
6591
|
-
|
|
6631
|
+
engineUrl = new URL(base);
|
|
6592
6632
|
} catch {
|
|
6593
6633
|
return [];
|
|
6594
6634
|
}
|
|
6635
|
+
const host = engineUrl.hostname;
|
|
6636
|
+
const engineScheme = engineUrl.protocol; // 'http:' | 'https:'
|
|
6637
|
+
const enginePort = engineUrl.port || (engineScheme === 'https:' ? '443' : '80');
|
|
6595
6638
|
const probeHost = isLoopbackHost(host) ? '127.0.0.1' : host;
|
|
6596
6639
|
// (C) Decoupled budgets (#133): the projects fetch and each WS probe get their
|
|
6597
6640
|
// OWN independent deadline. Previously they shared one 2s budget, so a fetch
|
|
@@ -6623,13 +6666,32 @@ async function discoverAgenticHubs(engineBaseUrl, {
|
|
|
6623
6666
|
const candidates = await resolveProbeCandidates(probeHost, { lookupImpl });
|
|
6624
6667
|
const settled = await Promise.all(apps.map(async (app) => {
|
|
6625
6668
|
try {
|
|
6626
|
-
|
|
6669
|
+
// Tunnel-first (#97): confirm the channel over the console app-view WS
|
|
6670
|
+
// tunnel on the ENGINE port — the same host:port the projects read just
|
|
6671
|
+
// used — so the app's direct port need not be reachable. A pre-#1054
|
|
6672
|
+
// engine 501s the upgrade and the probe fails; we then fall back to the
|
|
6673
|
+
// app's direct port (unchanged #96 path).
|
|
6674
|
+
const tunnelHost = await raceProbeCandidates(enginePort, {
|
|
6675
|
+
hosts: candidates,
|
|
6676
|
+
token,
|
|
6677
|
+
timeoutMs: probeTimeoutMs,
|
|
6678
|
+
wsProbe,
|
|
6679
|
+
pathPrefix: `/console/app-view/${encodeURIComponent(app.project)}`,
|
|
6680
|
+
// The tunnel rides the engine's own port, so match its TLS: an
|
|
6681
|
+
// `https://` engine base upgrades over `wss://`, not `ws://` (else the
|
|
6682
|
+
// tunnel probe spuriously fails and we drop to the direct port).
|
|
6683
|
+
secure: engineScheme === 'https:',
|
|
6684
|
+
});
|
|
6685
|
+
if (tunnelHost) {
|
|
6686
|
+
return { ...app, host: tunnelHost, via: 'tunnel', enginePort, scheme: engineScheme };
|
|
6687
|
+
}
|
|
6688
|
+
const directHost = await raceProbeCandidates(app.port, {
|
|
6627
6689
|
hosts: candidates,
|
|
6628
6690
|
token,
|
|
6629
6691
|
timeoutMs: probeTimeoutMs,
|
|
6630
6692
|
wsProbe,
|
|
6631
6693
|
});
|
|
6632
|
-
return
|
|
6694
|
+
return directHost ? { ...app, host: directHost, via: 'direct' } : null;
|
|
6633
6695
|
} catch {
|
|
6634
6696
|
return null;
|
|
6635
6697
|
}
|
|
@@ -6646,8 +6708,11 @@ async function discoverAgenticHubs(engineBaseUrl, {
|
|
|
6646
6708
|
* half-configured. No discovery attempted.
|
|
6647
6709
|
* - `{ status: 'connect', config }` — a target to connect to. Either the
|
|
6648
6710
|
* explicit `NANO_AGENTIC_URL`/`agenticUrl` verbatim (no discovery), or the
|
|
6649
|
-
* single discovered app
|
|
6650
|
-
*
|
|
6711
|
+
* single discovered app: the console app-view WS tunnel on the engine port
|
|
6712
|
+
* (`<engineHost>:<enginePort>/console/app-view/<project>/agentic`, `via:'tunnel'`,
|
|
6713
|
+
* #97) when available, else the direct app port
|
|
6714
|
+
* (`ws://<engineHost>:<appPort>/agentic`, `via:'direct'`, #96) — loopback for a
|
|
6715
|
+
* local engine, the engine's LAN host for a remote one.
|
|
6651
6716
|
* - `{ status: 'ambiguous', message, candidates }` — two+ apps expose a
|
|
6652
6717
|
* channel. Hard stop for the worker: it must not silently pick one.
|
|
6653
6718
|
* - `{ status: 'advisory', message }` — nothing discoverable (zero matches,
|
|
@@ -6681,10 +6746,18 @@ async function resolveAgenticTarget({ camunda, cache, ...opts } = {}) {
|
|
|
6681
6746
|
} catch { /* keep the loopback default */ }
|
|
6682
6747
|
|
|
6683
6748
|
if (hubs.length === 1) {
|
|
6684
|
-
const { project, port, host } = hubs[0];
|
|
6749
|
+
const { project, port, host, via, enginePort, scheme } = hubs[0];
|
|
6750
|
+
// A tunnel hub rides the console app-view WS bridge on the engine's own port
|
|
6751
|
+
// (#97): `<scheme>//<engineHost>:<enginePort>/console/app-view/<project>`, to
|
|
6752
|
+
// which `buildAgenticUrl` appends `/agentic`. A direct hub keeps the #96
|
|
6753
|
+
// `http://<host>:<appPort>` form. `discovered.port` stays the app's advertised
|
|
6754
|
+
// port either way (the hub identity); the local `via` records the route taken.
|
|
6755
|
+
const url = via === 'tunnel'
|
|
6756
|
+
? `${scheme}//${wsHostPart(host)}:${enginePort}/console/app-view/${encodeURIComponent(project)}`
|
|
6757
|
+
: `http://${wsHostPart(host)}:${port}`;
|
|
6685
6758
|
const config = {
|
|
6686
6759
|
...base,
|
|
6687
|
-
url
|
|
6760
|
+
url,
|
|
6688
6761
|
discovered: { project, port, host },
|
|
6689
6762
|
};
|
|
6690
6763
|
// Cache the known-good hub so a later blip self-heals from cache (#133-C).
|
|
@@ -7715,10 +7788,18 @@ async function workAgent(req, flags) {
|
|
|
7715
7788
|
// dispatch claim/release lifecycle keyed by this worker's instance drives the
|
|
7716
7789
|
// cockpit's jobKeys — so the recorders no longer poke a per-process channel.
|
|
7717
7790
|
const recordJobStart = (job, jobType) => {
|
|
7718
|
-
activeJobs.set(String(job.jobKey), { type: jobType, since: Date.now(), retries: Number(job.retries) });
|
|
7791
|
+
activeJobs.set(String(job.jobKey), { type: jobType, since: Date.now(), retries: Number(job.retries), leaseToken: job.leaseToken });
|
|
7719
7792
|
writeActivity();
|
|
7720
7793
|
};
|
|
7721
7794
|
const recordJobEnd = (job) => {
|
|
7795
|
+
// Delete only if the current entry is THIS activation. A same-key
|
|
7796
|
+
// reactivation may have replaced the entry (new leaseToken) while this
|
|
7797
|
+
// (interrupted) run unwinds — deleting then would erase the NEWER activation
|
|
7798
|
+
// from status and can make force-stop miss its yield. The per-activation
|
|
7799
|
+
// leaseToken is the identity; an unleased job (no token both sides) deletes as
|
|
7800
|
+
// before.
|
|
7801
|
+
const cur = activeJobs.get(String(job.jobKey));
|
|
7802
|
+
if (cur && cur.leaseToken !== job.leaseToken) return;
|
|
7722
7803
|
activeJobs.delete(String(job.jobKey));
|
|
7723
7804
|
writeActivity();
|
|
7724
7805
|
};
|
|
@@ -7910,6 +7991,14 @@ async function workAgent(req, flags) {
|
|
|
7910
7991
|
run: async (job, abortSignal) => {
|
|
7911
7992
|
const jobType = job.type;
|
|
7912
7993
|
recordJobStart(job, jobType);
|
|
7994
|
+
// Bind the settler to THIS activation's lease token, captured from the job
|
|
7995
|
+
// in closure scope. A settlement is fenced with the exact activation that
|
|
7996
|
+
// ran — NOT a token re-read from the shared activeJobs map at settle time: a
|
|
7997
|
+
// same-key reactivation can overwrite that entry while an interrupted runner
|
|
7998
|
+
// is still unwinding, so a map lookup could fence the completion with the
|
|
7999
|
+
// WRONG (newer) token and clobber the new activation — the very lease bypass
|
|
8000
|
+
// this fence exists to prevent. `job.leaseToken` in the closure cannot drift.
|
|
8001
|
+
const settleJob = bindJobSettle(settle, job);
|
|
7913
8002
|
try {
|
|
7914
8003
|
logger.info(`[${jobType}] job ${job.jobKey} (instance ${job.processInstanceKey ?? '-'}) → ${buildAgentCommandLine(profile.command, effectiveArgs)}`);
|
|
7915
8004
|
|
|
@@ -7922,7 +8011,7 @@ async function workAgent(req, flags) {
|
|
|
7922
8011
|
const freeMb = budget.free != null ? Math.round(budget.free / 1_048_576) : '?';
|
|
7923
8012
|
const retries = Math.max(0, (Number(job.retries) || 1) - 1);
|
|
7924
8013
|
logger.warn(`[${jobType}] job ${job.jobKey} shed — low disk (${freeMb}MB free); retries left ${retries}`);
|
|
7925
|
-
return
|
|
8014
|
+
return settleJob.fail({ errorMessage: `disk budget exceeded (only ${freeMb}MB free)`, retries, retryBackOff: 30_000 });
|
|
7926
8015
|
}
|
|
7927
8016
|
}
|
|
7928
8017
|
|
|
@@ -7954,7 +8043,7 @@ async function workAgent(req, flags) {
|
|
|
7954
8043
|
const retries = Math.max(0, (Number(job.retries) || 1) - 1);
|
|
7955
8044
|
const msg = err instanceof ProvisionError ? err.message : `prompt resource fetch failed: ${err.message}`;
|
|
7956
8045
|
logger.warn(`[${jobType}] job ${job.jobKey} not provisioned — ${msg}; retries left ${retries}`);
|
|
7957
|
-
return
|
|
8046
|
+
return settleJob.fail({ errorMessage: msg.slice(0, 2000), retries, retryBackOff: 15_000 });
|
|
7958
8047
|
}
|
|
7959
8048
|
|
|
7960
8049
|
// Assemble + normalize the task envelope from headers (defaults) and
|
|
@@ -7965,7 +8054,7 @@ async function workAgent(req, flags) {
|
|
|
7965
8054
|
const retries = Math.max(0, (Number(job.retries) || 1) - 1);
|
|
7966
8055
|
const msg = `missing secret(s): ${missing.join(', ')} (resolver: ${secretResolver.kind})`;
|
|
7967
8056
|
logger.warn(`[${jobType}] job ${job.jobKey} not provisioned — ${msg}; retries left ${retries}`);
|
|
7968
|
-
return
|
|
8057
|
+
return settleJob.fail({ errorMessage: msg, retries });
|
|
7969
8058
|
}
|
|
7970
8059
|
|
|
7971
8060
|
// #130: per-task liveness overrides. Precedence: envelope override →
|
|
@@ -8031,7 +8120,7 @@ async function workAgent(req, flags) {
|
|
|
8031
8120
|
: 'repository.url is missing';
|
|
8032
8121
|
const msg = `incomplete repository envelope — ${why}; refusing to run in the launch/temp cwd (likely an orchestrator bug emitting a half-specified repository block)`;
|
|
8033
8122
|
logger.warn(`[${jobType}] job ${job.jobKey} not provisioned — ${msg}; retries left ${retries}`);
|
|
8034
|
-
return
|
|
8123
|
+
return settleJob.fail({ errorMessage: msg.slice(0, 2000), retries, retryBackOff: 15_000 });
|
|
8035
8124
|
}
|
|
8036
8125
|
}
|
|
8037
8126
|
|
|
@@ -8086,7 +8175,7 @@ async function workAgent(req, flags) {
|
|
|
8086
8175
|
const retries = Math.max(0, (Number(job.retries) || 1) - 1);
|
|
8087
8176
|
const msg = err instanceof ProvisionError ? err.message : `provisioning error: ${err.message}`;
|
|
8088
8177
|
logger.warn(`[${jobType}] job ${job.jobKey} not provisioned — ${msg}; retries left ${retries}`);
|
|
8089
|
-
return
|
|
8178
|
+
return settleJob.fail({ errorMessage: msg.slice(0, 2000), retries, retryBackOff: 15_000 });
|
|
8090
8179
|
}
|
|
8091
8180
|
} else if (!isContainer) {
|
|
8092
8181
|
// Repo-less host job (issue #129, hardening 1): nothing is provisioned,
|
|
@@ -8112,7 +8201,7 @@ async function workAgent(req, flags) {
|
|
|
8112
8201
|
const retries = Math.max(0, (Number(job.retries) || 1) - 1);
|
|
8113
8202
|
const msg = `could not create a temp workspace under the worker namespace: ${err.message}`;
|
|
8114
8203
|
logger.warn(`[${jobType}] job ${job.jobKey} not provisioned — ${msg}; retries left ${retries}`);
|
|
8115
|
-
return
|
|
8204
|
+
return settleJob.fail({ errorMessage: msg.slice(0, 2000), retries, retryBackOff: 15_000 });
|
|
8116
8205
|
}
|
|
8117
8206
|
}
|
|
8118
8207
|
|
|
@@ -8310,7 +8399,7 @@ async function workAgent(req, flags) {
|
|
|
8310
8399
|
const resultKeys = Object.keys(resultVars);
|
|
8311
8400
|
if (resultKeys.length === 0) logger.warn(`[${jobType}] job ${job.jobKey}: agent returned no usable result vars — write a JSON object of result variables to $AGENT_RESULT_FILE (or print a "${RESULT_SENTINEL} {…}" line) so downstream gateways see status/summary/etc.`);
|
|
8312
8401
|
else logger.info(`[${jobType}] job ${job.jobKey}: merged agent result vars [${resultKeys.join(', ')}]`);
|
|
8313
|
-
return await
|
|
8402
|
+
return await settleJob.complete({
|
|
8314
8403
|
...resultVars,
|
|
8315
8404
|
[AGENT_RESULT_KEY]: resultEnvelope,
|
|
8316
8405
|
output: result.stdout,
|
|
@@ -8327,7 +8416,7 @@ async function workAgent(req, flags) {
|
|
|
8327
8416
|
|| (result.stderr || '').trim() + (result.stderrTruncated && (result.stderr || '').trim() ? ' [stderr truncated]' : '')
|
|
8328
8417
|
|| (result.signal ? `terminated by signal ${result.signal}` : `exit code ${result.exitCode}`);
|
|
8329
8418
|
logger.warn(`[${jobType}] job ${job.jobKey} failed (${detail}); retries left ${retries}`);
|
|
8330
|
-
return await
|
|
8419
|
+
return await settleJob.fail({
|
|
8331
8420
|
errorMessage: `agent "${profile.name}" failed: ${detail}`.slice(0, 2000),
|
|
8332
8421
|
retries,
|
|
8333
8422
|
variables: { [AGENT_RESULT_KEY]: resultEnvelope },
|
|
@@ -8360,6 +8449,12 @@ async function workAgent(req, flags) {
|
|
|
8360
8449
|
dispatch: { recoveryWindowMs, extendIntervalMs: lockExtendIntervalMs },
|
|
8361
8450
|
},
|
|
8362
8451
|
});
|
|
8452
|
+
// The settle seam is used directly with an EXPLICIT lease token per call: the
|
|
8453
|
+
// runner binds `settleJob` to its activation's `job.leaseToken` (race-free —
|
|
8454
|
+
// see the runner), and the force-stop yield passes the token captured in
|
|
8455
|
+
// `inflight`. We deliberately do NOT wrap `settle` to look the token up from the
|
|
8456
|
+
// shared `activeJobs` map by jobKey, because a same-key reactivation could make
|
|
8457
|
+
// that lookup fence a settlement with the wrong activation's token.
|
|
8363
8458
|
settle = composed.settle;
|
|
8364
8459
|
const {
|
|
8365
8460
|
deps: supervisorDeps,
|
|
@@ -8555,7 +8650,7 @@ async function workAgent(req, flags) {
|
|
|
8555
8650
|
logger.info(`Received ${signal} — aborting in-flight work and stopping worker...`);
|
|
8556
8651
|
// Snapshot in-flight jobs (with their retry budget) BEFORE the interrupt
|
|
8557
8652
|
// clears the ownership registry, so we can yield each one afterwards.
|
|
8558
|
-
const inflight = [...activeJobs.entries()].map(([jobKey, info]) => ({ jobKey, retries: info?.retries }));
|
|
8653
|
+
const inflight = [...activeJobs.entries()].map(([jobKey, info]) => ({ jobKey, retries: info?.retries, leaseToken: info?.leaseToken }));
|
|
8559
8654
|
// Interrupt the runtime: this aborts each running job's AbortSignal (the
|
|
8560
8655
|
// makeJobRunner seam) so runAgentJob killTree's the harness process group,
|
|
8561
8656
|
// and runs dispatch's bracketed teardown (release ownership + slot). The
|
|
@@ -8569,12 +8664,13 @@ async function workAgent(req, flags) {
|
|
|
8569
8664
|
// Yield each in-flight job so the broker re-activates it at once (retries
|
|
8570
8665
|
// preserved — a force-stop doesn't consume an attempt). Best-effort: a
|
|
8571
8666
|
// failed yield just lets the lock lapse (the honest fallback).
|
|
8572
|
-
for (const { jobKey, retries } of inflight) {
|
|
8667
|
+
for (const { jobKey, retries, leaseToken } of inflight) {
|
|
8573
8668
|
try {
|
|
8574
8669
|
await SupervisorEffect.runPromise(settle.fail(jobKey, {
|
|
8575
8670
|
errorMessage: `worker force-stopped (${signal}); job yielded for retry`,
|
|
8576
8671
|
retries: Number.isFinite(retries) && retries > 0 ? retries : 1,
|
|
8577
8672
|
retryBackOff: 0,
|
|
8673
|
+
leaseToken,
|
|
8578
8674
|
}));
|
|
8579
8675
|
logger.info(` yielded job ${jobKey} for immediate retry.`);
|
|
8580
8676
|
} catch (err) {
|
|
@@ -13805,6 +13901,7 @@ export {
|
|
|
13805
13901
|
loadSupervisorRuntime,
|
|
13806
13902
|
createAgenticEndpoint,
|
|
13807
13903
|
createSupervisorDeps,
|
|
13904
|
+
bindJobSettle,
|
|
13808
13905
|
enableEngineHappyEyeballs,
|
|
13809
13906
|
preferIpv4Resolution,
|
|
13810
13907
|
isLikelyLocalNetworkTccBlock,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "c8ctl-plugin-nano",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.60.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "c8ctl plugin to start, inspect, and stop a local Nano BPM (nanobpmn) cluster",
|
|
6
6
|
"main": "c8ctl-plugin.js",
|
|
@@ -73,12 +73,12 @@
|
|
|
73
73
|
},
|
|
74
74
|
"optionalDependencies": {
|
|
75
75
|
"node-pty": "^1.0.0",
|
|
76
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.
|
|
77
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.
|
|
78
|
-
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.
|
|
79
|
-
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.
|
|
80
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.
|
|
81
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.
|
|
82
|
-
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.
|
|
76
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.60.1",
|
|
77
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.60.1",
|
|
78
|
+
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.60.1",
|
|
79
|
+
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.60.1",
|
|
80
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.60.1",
|
|
81
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.60.1",
|
|
82
|
+
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.60.1"
|
|
83
83
|
}
|
|
84
84
|
}
|
package/supervisor-engine.mjs
CHANGED
|
@@ -36,6 +36,13 @@
|
|
|
36
36
|
* else the SAME calls issued raw via `fetchImpl` — the settle surface the
|
|
37
37
|
* supervisor's JobRunner uses once an agent finishes.
|
|
38
38
|
*
|
|
39
|
+
* The three POST-ACTIVATION commands take an optional lease token that FENCES them
|
|
40
|
+
* against a superseded worker (the engine validates `updateJob` and requires it on
|
|
41
|
+
* `completeJob`/`failJob`): `extendLock(jobKey, ms, leaseToken?)`,
|
|
42
|
+
* `complete(jobKey, variables?, leaseToken?)`, and `fail(jobKey, { …, leaseToken? })`.
|
|
43
|
+
* On every path (SDK + raw) it is sent as the TOP-LEVEL camelCase `leaseToken` wire
|
|
44
|
+
* field and OMITTED when blank/absent (a non-leased job needs no fence).
|
|
45
|
+
*
|
|
39
46
|
* Every method on this surface (`activate`→`activateJobs`,
|
|
40
47
|
* `extendLock`→`updateJob`, `complete`→`completeJob`, `fail`→`failJob`) PREFERS
|
|
41
48
|
* the injected `camunda` client and only hand-rolls the REST call as a
|
|
@@ -95,6 +102,18 @@ function isPlainObjectMap(v) {
|
|
|
95
102
|
return proto === Object.prototype || proto === null;
|
|
96
103
|
}
|
|
97
104
|
|
|
105
|
+
/**
|
|
106
|
+
* A non-blank lease token — the opaque per-activation `leaseToken` the engine
|
|
107
|
+
* stamps on an agent job. When present it fences a post-activation command
|
|
108
|
+
* (`updateJob`/complete/fail) so a SUPERSEDED worker's call is rejected 409
|
|
109
|
+
* (`JobLeaseMismatch`) instead of silently mutating a job it no longer owns. A
|
|
110
|
+
* blank/absent token is OMITTED so the unfenced operator/bulk path (the engine
|
|
111
|
+
* treats a missing token as "always applies") is preserved untouched.
|
|
112
|
+
*/
|
|
113
|
+
function isNonBlankString(v) {
|
|
114
|
+
return typeof v === "string" && v.trim() !== "";
|
|
115
|
+
}
|
|
116
|
+
|
|
98
117
|
/**
|
|
99
118
|
* Map one raw v2 activated-job record to the port's {@link ActivatedJob}. Keys are
|
|
100
119
|
* strings in v2. A record missing `jobKey`/`type` violates the `ActivatedJob`
|
|
@@ -179,8 +198,8 @@ async function readErrorBody(res) {
|
|
|
179
198
|
* @param {Record<string,string>|(() => (Record<string,string>|Promise<Record<string,string>>))} [opts.authHeaders] Ready-made auth header map, OR a resolver invoked per request (so rotating SDK auth — e.g. an OAuth bearer that refreshes — is re-derived each call rather than frozen). Wins over `token`.
|
|
180
199
|
* @param {typeof fetch} [opts.fetchImpl] Injected `fetch` (defaults to the global; overridden in tests).
|
|
181
200
|
* @param {number} [opts.requestTimeoutSlackMs] Extra ms added to a call's abort budget over its server long-poll (default 5000).
|
|
182
|
-
* @param {{ activateJobs?: (input: { type: string, worker?: string, maxJobsToActivate: number, timeout: number, requestTimeout?: number }) => (Promise<{ jobs?: object[] }> & { cancel?: () => void }), updateJob?: (req: { jobKey: string, changeset: { timeout: number } }) => Promise<unknown
|
|
183
|
-
* @returns {{ activate(req: ActivateRequest, signal?: AbortSignal): Promise<ReadonlyArray<ActivatedJob>>, extendLock(jobKey: string, ms: number): Promise<void>, complete(jobKey: string, variables?: object): Promise<void>, fail(jobKey: string, opts?: { retries?: number, errorMessage?: string, retryBackOff?: number, variables?: object }): Promise<void> }}
|
|
201
|
+
* @param {{ activateJobs?: (input: { type: string, worker?: string, maxJobsToActivate: number, timeout: number, requestTimeout?: number }) => (Promise<{ jobs?: object[] }> & { cancel?: () => void }), updateJob?: (req: { jobKey: string, changeset: { timeout: number }, leaseToken?: string }) => (Promise<unknown> & { cancel?: () => void }), completeJob?: (req: { jobKey: string, variables?: object, leaseToken?: string }) => Promise<unknown>, failJob?: (req: { jobKey: string, retries?: number, errorMessage?: string, retryBackOff?: number, variables?: object, leaseToken?: string }) => Promise<unknown> }} [opts.camunda] Optional Camunda SDK client. When present, each engine method prefers its typed SDK method over the raw fetch fallback: `activate`→`activateJobs` (`POST /v2/jobs/activation`, cancelled via the returned `CancelablePromise.cancel()`), `extendLock`→`updateJob` (`PATCH /v2/jobs/{jobKey}` `{ changeset: { timeout }, leaseToken? }`, likewise cancelled via `cancel()` when the extend's `AbortSignal` fires), `complete`→`completeJob` (`POST /v2/jobs/{jobKey}/completion`), `fail`→`failJob` (`POST /v2/jobs/{jobKey}/failure`). Each post-activation request carries the activation's top-level `leaseToken` (camelCase wire field) so the engine's lease fence accepts it.
|
|
202
|
+
* @returns {{ activate(req: ActivateRequest, signal?: AbortSignal): Promise<ReadonlyArray<ActivatedJob>>, extendLock(jobKey: string, ms: number, leaseToken?: string, signal?: AbortSignal): Promise<void>, complete(jobKey: string, variables?: object, leaseToken?: string): Promise<void>, fail(jobKey: string, opts?: { retries?: number, errorMessage?: string, retryBackOff?: number, variables?: object, leaseToken?: string }): Promise<void> }} Every post-activation method takes the activation's `leaseToken` (Camunda-v10 lease fence): the engine validates it before mutating a leased job, so `extendLock`/`complete`/`fail` must each carry the token from the activation that owns the lock — omitting it is rejected once a job is leased. `extendLock` also accepts an optional `AbortSignal` so a hung extend can be cancelled (not merely abandoned) when the caller's deadline fires.
|
|
184
203
|
*/
|
|
185
204
|
export function createRawEngineClient(opts = {}) {
|
|
186
205
|
const {
|
|
@@ -304,22 +323,53 @@ export function createRawEngineClient(opts = {}) {
|
|
|
304
323
|
return jobs.map(mapJob);
|
|
305
324
|
},
|
|
306
325
|
|
|
307
|
-
async extendLock(jobKey, ms) {
|
|
326
|
+
async extendLock(jobKey, ms, leaseToken, signal) {
|
|
308
327
|
// Prefer the SDK's typed `updateJob` (operationId `updateJob` →
|
|
309
328
|
// `PATCH /v2/jobs/{jobKey}` with `{ changeset: { timeout } }`) so the lock
|
|
310
329
|
// extension tracks the engine contract instead of a hand-rolled URL. Fall
|
|
311
330
|
// back to the SAME call issued raw when no SDK client is injected (keeps
|
|
312
331
|
// this module wire-testable and usable standalone).
|
|
332
|
+
//
|
|
333
|
+
// `leaseToken` (when present) is a TOP-LEVEL sibling of `changeset`/`jobKey`
|
|
334
|
+
// — NOT nested in the changeset — matching the Camunda v10 `JobUpdateRequest`.
|
|
335
|
+
// The wire field is camelCase `leaseToken` (the engine's `JobUpdateRequest`
|
|
336
|
+
// deserializes it via `#[serde(rename = "leaseToken")]` into its internal
|
|
337
|
+
// `lease_token` field), so BOTH the SDK and this raw PATCH send the same
|
|
338
|
+
// camelCase key. It FENCES the extend: a superseded
|
|
339
|
+
// worker whose lease has been reassigned is rejected 409 (`JobLeaseMismatch`)
|
|
340
|
+
// rather than renewing a lock it no longer owns, which is what let a reclaimed
|
|
341
|
+
// agent job keep running and loop (empty transcript husks). Omitted when blank
|
|
342
|
+
// so the unfenced operator/bulk path is unchanged.
|
|
343
|
+
const fence = isNonBlankString(leaseToken) ? { leaseToken } : {};
|
|
313
344
|
if (camunda && typeof camunda.updateJob === "function") {
|
|
345
|
+
// Mirror `activate`: wire the external abort `signal` (fired when the
|
|
346
|
+
// dispatch-side `Effect.timeout`/interruption cancels a hung beat) onto the
|
|
347
|
+
// SDK call's `CancelablePromise.cancel()` when it exposes one, so a wedged
|
|
348
|
+
// `updateJob` is actually cancelled rather than left in flight to (late)
|
|
349
|
+
// re-lock a job whose lease we've already treated as lost.
|
|
350
|
+
const p = camunda.updateJob({ changeset: { timeout: ms }, jobKey: String(jobKey), ...fence });
|
|
351
|
+
const cancel = typeof p?.cancel === "function" ? () => p.cancel() : () => {};
|
|
352
|
+
const onExtAbort = () => cancel();
|
|
353
|
+
if (signal) {
|
|
354
|
+
if (signal.aborted) cancel();
|
|
355
|
+
else signal.addEventListener("abort", onExtAbort, { once: true });
|
|
356
|
+
}
|
|
314
357
|
try {
|
|
315
|
-
await
|
|
358
|
+
await p;
|
|
316
359
|
return;
|
|
317
360
|
} catch (err) {
|
|
318
361
|
throw new Error(`extendLock ${jobKey}: SDK updateJob failed: ${err?.message ?? err}`, { cause: err });
|
|
362
|
+
} finally {
|
|
363
|
+
if (signal) signal.removeEventListener("abort", onExtAbort);
|
|
319
364
|
}
|
|
320
365
|
}
|
|
321
366
|
const url = `${base}/jobs/${encodeURIComponent(jobKey)}`;
|
|
322
|
-
const res = await call(
|
|
367
|
+
const res = await call(
|
|
368
|
+
url,
|
|
369
|
+
{ method: "PATCH", body: JSON.stringify({ changeset: { timeout: ms }, ...fence }) },
|
|
370
|
+
15_000,
|
|
371
|
+
signal,
|
|
372
|
+
);
|
|
323
373
|
if (!res || !res.ok) {
|
|
324
374
|
const status = res ? res.status : "?";
|
|
325
375
|
throw new Error(`extendLock ${jobKey}: HTTP ${status} from ${url}${res ? await readErrorBody(res) : ""}`);
|
|
@@ -338,23 +388,31 @@ export function createRawEngineClient(opts = {}) {
|
|
|
338
388
|
// SDK rejection (e.g. a 409 when the lock lapsed and the job was reclaimed)
|
|
339
389
|
// surfaces as a rejected promise for the port to map.
|
|
340
390
|
|
|
341
|
-
async complete(jobKey, variables) {
|
|
391
|
+
async complete(jobKey, variables, leaseToken) {
|
|
342
392
|
// Prefer the SDK's typed `completeJob` (operationId `completeJob` →
|
|
343
393
|
// `POST /v2/jobs/{jobKey}/completion` with `{ variables }`) when a `camunda`
|
|
344
394
|
// SDK client is injected, else the SAME call issued raw via `fetchImpl`. The
|
|
345
395
|
// result-variable map the model produced is merged onto the process
|
|
346
396
|
// instance. C8 v2 answers 204 No Content on success.
|
|
397
|
+
//
|
|
398
|
+
// `leaseToken` (top-level, like the extend) FENCES the completion: the engine
|
|
399
|
+
// validates it with `required=true`, so for a LEASED job the matching token
|
|
400
|
+
// MUST be supplied — a superseded worker (whose token rotated when the job was
|
|
401
|
+
// re-activated) is rejected `JobLeaseMismatch`, never clobbering the newer
|
|
402
|
+
// activation's result. A non-leased job carries no `job.leaseToken`, so the
|
|
403
|
+
// field is omitted and no token is required.
|
|
347
404
|
const vars = isPlainObjectMap(variables) ? { variables } : {};
|
|
405
|
+
const fence = isNonBlankString(leaseToken) ? { leaseToken } : {};
|
|
348
406
|
if (camunda && typeof camunda.completeJob === "function") {
|
|
349
407
|
try {
|
|
350
|
-
await camunda.completeJob({ jobKey: String(jobKey), ...vars });
|
|
408
|
+
await camunda.completeJob({ jobKey: String(jobKey), ...vars, ...fence });
|
|
351
409
|
return;
|
|
352
410
|
} catch (err) {
|
|
353
411
|
throw new Error(`complete ${jobKey}: SDK completeJob failed: ${err?.message ?? err}`, { cause: err });
|
|
354
412
|
}
|
|
355
413
|
}
|
|
356
414
|
const url = `${base}/jobs/${encodeURIComponent(jobKey)}/completion`;
|
|
357
|
-
const res = await call(url, { method: "POST", body: JSON.stringify(vars) }, 15_000);
|
|
415
|
+
const res = await call(url, { method: "POST", body: JSON.stringify({ ...vars, ...fence }) }, 15_000);
|
|
358
416
|
if (!res || !res.ok) {
|
|
359
417
|
const status = res ? res.status : "?";
|
|
360
418
|
throw new Error(`complete ${jobKey}: HTTP ${status} from ${url}${res ? await readErrorBody(res) : ""}`);
|
|
@@ -365,7 +423,7 @@ export function createRawEngineClient(opts = {}) {
|
|
|
365
423
|
// Tolerate a `null` opts the same as `undefined` (mirrors `complete`'s
|
|
366
424
|
// null-safe `variables`), so a caller passing `null` never trips the
|
|
367
425
|
// signature-destructure TypeError.
|
|
368
|
-
const { retries = 0, errorMessage, retryBackOff, variables } = opts || {};
|
|
426
|
+
const { retries = 0, errorMessage, retryBackOff, variables, leaseToken } = opts || {};
|
|
369
427
|
// Normalize retries to a non-negative integer (mirrors `mapJob`), so a
|
|
370
428
|
// string/float/negative never reaches the engine (or SDK) as an invalid
|
|
371
429
|
// count. `retries > 0` re-queues for another attempt, `retries === 0`
|
|
@@ -377,6 +435,10 @@ export function createRawEngineClient(opts = {}) {
|
|
|
377
435
|
if (errorMessage !== undefined && errorMessage !== null) extra.errorMessage = String(errorMessage);
|
|
378
436
|
if (Number.isFinite(retryBackOff) && retryBackOff > 0) extra.retryBackOff = retryBackOff;
|
|
379
437
|
if (isPlainObjectMap(variables)) extra.variables = variables;
|
|
438
|
+
// Same lease fencing as `complete` (engine validates `failJob` with
|
|
439
|
+
// `required=true`): a leased job's failure must carry the matching token or a
|
|
440
|
+
// superseded worker's fail is rejected; omitted for a non-leased job.
|
|
441
|
+
if (isNonBlankString(leaseToken)) extra.leaseToken = leaseToken;
|
|
380
442
|
// Prefer the SDK's typed `failJob` (operationId `failJob` →
|
|
381
443
|
// `POST /v2/jobs/{jobKey}/failure`) when a `camunda` SDK client is injected,
|
|
382
444
|
// else the SAME call issued raw via `fetchImpl`. C8 v2 answers 204.
|