c8ctl-plugin-nano 1.65.1 → 1.66.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/README.md +16 -0
- package/c8ctl-plugin.js +549 -17
- package/nanobpmn-binary.json +3 -3
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -379,6 +379,22 @@ If workers show `advisory` (or stay `connecting`) while jobs still run, that's t
|
|
|
379
379
|
"connected to the engine but empty Cockpit" case: point them at the app with
|
|
380
380
|
`export NANO_AGENTIC_URL=http://<engine-host>:<appUi.port>` (e.g. `:3000`).
|
|
381
381
|
|
|
382
|
+
**`settlement-pending` in the `JOB` column.** A worker's `JOB` cell normally
|
|
383
|
+
shows the job key it is running (or `idle`). If an agent finished its external
|
|
384
|
+
side effect but the worker then lost its activation lease around
|
|
385
|
+
`completeJob`/`failJob` — so the fenced settle failed and the engine may still
|
|
386
|
+
project the job as `CREATED` — the cell instead shows
|
|
387
|
+
`<jobKey> settlement-pending (<age>)`, where `<age>` is how long settlement has
|
|
388
|
+
been pending (measured from the failed settle, not the job's start). This is
|
|
389
|
+
**observational only**: recovery is
|
|
390
|
+
already handled by the lease-fence + transcript-resume path, and such a job is
|
|
391
|
+
*not* counted as busy/in-flight (it doesn't hold up a drain). The state lets you
|
|
392
|
+
*see* a job caught between side effect and settlement rather than inferring it
|
|
393
|
+
from a silently-idle worker plus a stuck `CREATED` job; it clears when the job is
|
|
394
|
+
re-activated on this worker, self-expires after a bounded TTL (so a re-activation
|
|
395
|
+
that lands on a *different* worker can't leave a ghost lingering here), or the
|
|
396
|
+
worker restarts.
|
|
397
|
+
|
|
382
398
|
**Liveness watchdog (auto-recovery from a wedged channel).** If the nano server
|
|
383
399
|
restarts, crashes, or a network partition drops the connection *without* a clean
|
|
384
400
|
close (a **half-open** socket), a worker's channel client can sit `disconnected`
|
package/c8ctl-plugin.js
CHANGED
|
@@ -3599,6 +3599,258 @@ function bindJobSettle(settle, job) {
|
|
|
3599
3599
|
};
|
|
3600
3600
|
}
|
|
3601
3601
|
|
|
3602
|
+
// #254: how long a settlement-pending ghost may linger before it self-expires.
|
|
3603
|
+
// A ghost is process-local (see settlementPendingJobs), so a same-key
|
|
3604
|
+
// reactivation on ANOTHER worker can never clear this worker's in-memory entry;
|
|
3605
|
+
// the TTL bounds the stale-observation window without shared cross-worker state.
|
|
3606
|
+
// Module-scoped so BOTH the producer (writeActivity prunes on write) and the
|
|
3607
|
+
// reader (summarizeSupervisorWorker drops on read) enforce the same bound — an
|
|
3608
|
+
// idle worker stops writing activity, so read-time enforcement is what actually
|
|
3609
|
+
// expires a ghost on an otherwise-quiet worker.
|
|
3610
|
+
const SETTLEMENT_PENDING_GHOST_TTL_MS = 30 * 60 * 1000;
|
|
3611
|
+
// #256 review: cap the per-key `settleInFlightByKey` entry Map. A same-key
|
|
3612
|
+
// redelivery loop whose fenced settles never resolve could otherwise append
|
|
3613
|
+
// unboundedly many live entries WITHIN the ghost TTL window (TTL GC only drops
|
|
3614
|
+
// AGED entries). Evicting oldest-first when a key exceeds this cap keeps the map
|
|
3615
|
+
// constant-space per key while retaining the newest live identity that governs
|
|
3616
|
+
// protection — a generous headroom over the 1–2 realistic concurrent same-key
|
|
3617
|
+
// activations (an interrupted older runner still settling while a newer one runs).
|
|
3618
|
+
const MAX_SETTLE_IN_FLIGHT_PER_KEY = 16;
|
|
3619
|
+
|
|
3620
|
+
// #254 (PR #256 review): does a fenced-settle rejection mean the activation was
|
|
3621
|
+
// DEFINITIVELY lost — the lock lapsed and the broker reclaimed the job, or the
|
|
3622
|
+
// lease was superseded — leaving the engine still projecting the job `CREATED`
|
|
3623
|
+
// and awaiting settlement? That is the ONLY case the settlement-pending ghost is
|
|
3624
|
+
// meant to surface. Only the unambiguous ownership-loss signals count: a 409
|
|
3625
|
+
// (reclaim / `JobLeaseMismatch`), a 404 (job gone), or the engine's "not
|
|
3626
|
+
// activated" body. Everything TRANSIENT (a 5xx, a 429, a network blip, a timeout)
|
|
3627
|
+
// or a deterministic client error (a 400 validation, a 401/403 auth failure) is
|
|
3628
|
+
// NOT lease loss — those settles retry or fail deterministically, so marking them
|
|
3629
|
+
// as `settlement-pending` would report a false 30-minute stuck window. Mirrors the
|
|
3630
|
+
// supervisor runtime's `dispatch.isLeaseLostError` contract (regex on the message
|
|
3631
|
+
// + a numeric 409/404 anywhere on the cause chain).
|
|
3632
|
+
//
|
|
3633
|
+
// #256 review: the STRUCTURED status is authoritative. The raw settle client stamps
|
|
3634
|
+
// the message as `… HTTP <status> from <url><arbitrary response body>` (readErrorBody
|
|
3635
|
+
// appends the ARBITRARY response body), so EITHER an engine-semantic phrase
|
|
3636
|
+
// ("not activated"/lease mismatch) OR a generic ownership word ("not found",
|
|
3637
|
+
// "reclaim") can appear in the body of a NON-loss response (e.g. a 500/400) and must
|
|
3638
|
+
// NOT then be read as lease loss. So we parse every explicit HTTP status first: a
|
|
3639
|
+
// 404/409 anywhere is lease loss; a message ownership phrase (strong OR weak) is
|
|
3640
|
+
// trusted only when NO contradictory (non-404/409) status is present. A GENUINE
|
|
3641
|
+
// engine lease loss is always stamped 404/409, so this veto only ever removes a
|
|
3642
|
+
// phrase echoed inside some other status's body, never a real loss (#256 review).
|
|
3643
|
+
const LEASE_LOST_STRONG_RE = /\bnot activated\b|joblease\s*mismatch|lease\s*mismatch/i;
|
|
3644
|
+
const LEASE_LOST_WEAK_RE = /\bnot found\b|\breclaim/i;
|
|
3645
|
+
// The AUTHORITATIVE transport status the raw settle client stamps: it formats the
|
|
3646
|
+
// message as `<phase> <jobKey>: HTTP <n> from <url><arbitrary response body>`
|
|
3647
|
+
// (supervisor-engine.mjs), so only an `HTTP <n> from` (or an SDK's `status code
|
|
3648
|
+
// <n>`) is a real status — a bare `HTTP 409`/`status code 404` mentioned INSIDE the
|
|
3649
|
+
// appended response body is not. The `from` anchor pins the HTTP alternative to the
|
|
3650
|
+
// stamped transport prefix, and we read only the FIRST match (the transport status
|
|
3651
|
+
// precedes the body) so body text can never override the real status (#256 review).
|
|
3652
|
+
const HTTP_STATUS_RE = /\bHTTP\s+(\d{3})\s+from\b|status\s*code\s*(\d{3})\b/i;
|
|
3653
|
+
function isLeaseLostSettleError(err) {
|
|
3654
|
+
if (err == null) return false;
|
|
3655
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
3656
|
+
// Parse the explicit status from the message (transport status only — see
|
|
3657
|
+
// HTTP_STATUS_RE) AND the cause chain, separating a definitive lease-loss status
|
|
3658
|
+
// (404/409) from a contradictory one. SDK rejections often carry the status as
|
|
3659
|
+
// `err.status`/`err.statusCode`/`err.response.status`/`err.response.statusCode`
|
|
3660
|
+
// or a NUMERIC `err.code` (a string code like `ECONNRESET` is NOT a status)
|
|
3661
|
+
// rather than in the message (all the nested shapes `describeSdkError` normalizes,
|
|
3662
|
+
// agent-instance.mjs); the cause walk is bounded so a cycle can't loop. The
|
|
3663
|
+
// STRUCTURED status (from the object / cause chain) is AUTHORITATIVE and is tracked
|
|
3664
|
+
// SEPARATELY from a status parsed out of the message text: readErrorBody appends the
|
|
3665
|
+
// arbitrary response body, and an SDK's own message may echo a `status code <n>`
|
|
3666
|
+
// that DISAGREES with its structured response, so a message-only status match must
|
|
3667
|
+
// stay SUBORDINATE to a contradictory structured status (#256 review).
|
|
3668
|
+
let msgLease = false;
|
|
3669
|
+
let msgOther = false;
|
|
3670
|
+
const m = HTTP_STATUS_RE.exec(msg);
|
|
3671
|
+
if (m) {
|
|
3672
|
+
const code = Number(m[1] ?? m[2]);
|
|
3673
|
+
if (code === 404 || code === 409) msgLease = true;
|
|
3674
|
+
else msgOther = true;
|
|
3675
|
+
}
|
|
3676
|
+
let structLease = false;
|
|
3677
|
+
let structOther = false;
|
|
3678
|
+
let e = err;
|
|
3679
|
+
for (let depth = 0; e != null && typeof e === 'object' && depth <= 4; depth += 1) {
|
|
3680
|
+
const s = e.status ?? e.statusCode ?? (e.response && (e.response.status ?? e.response.statusCode))
|
|
3681
|
+
?? (typeof e.code === 'number' ? e.code : undefined);
|
|
3682
|
+
if (s === 409 || s === 404) structLease = true;
|
|
3683
|
+
else if (Number.isFinite(s)) structOther = true;
|
|
3684
|
+
e = e.cause;
|
|
3685
|
+
}
|
|
3686
|
+
// Structured status is AUTHORITATIVE: a definitive 404/409 on the object/cause chain
|
|
3687
|
+
// IS lease loss, and a contradictory (non-404/409) structured status vetoes
|
|
3688
|
+
// EVERYTHING below it — even a message that itself mentions `status code 404`, which
|
|
3689
|
+
// may merely be echoed from the arbitrary response body of a 5xx rejection (#256).
|
|
3690
|
+
if (structLease) return true;
|
|
3691
|
+
if (structOther) return false;
|
|
3692
|
+
// No structured status — fall back to the message-STAMPED transport status. The raw
|
|
3693
|
+
// settle client's authoritative status IS the `HTTP <n> from` stamp, so a 404/409
|
|
3694
|
+
// there is lease loss.
|
|
3695
|
+
if (msgLease) return true;
|
|
3696
|
+
// Message ownership phrases — engine-semantic (STRONG) or generic (WEAK) — are read
|
|
3697
|
+
// from the message, and readErrorBody appends the ARBITRARY response body to it, so
|
|
3698
|
+
// a NON-loss transport response (e.g. HTTP 500/400) whose body merely ECHOES
|
|
3699
|
+
// "not activated"/"lease mismatch"/"not found" is NOT a lease loss. A genuine engine
|
|
3700
|
+
// loss is always stamped 404/409 (handled above), so a contradictory (non-404/409)
|
|
3701
|
+
// message status vetoes EITHER phrase — removing only the false-ghost class.
|
|
3702
|
+
if (!msgOther && (LEASE_LOST_STRONG_RE.test(msg) || LEASE_LOST_WEAK_RE.test(msg))) return true;
|
|
3703
|
+
return false;
|
|
3704
|
+
}
|
|
3705
|
+
|
|
3706
|
+
// #254 (PR #256 review): prune the per-key activation identity guard
|
|
3707
|
+
// (`lastActivationByKey`) so it stays bounded no matter how settlements behave.
|
|
3708
|
+
// A key whose fenced settle is still in flight (`settleInFlightByKey`, a Map of
|
|
3709
|
+
// per-activation `id -> since`) is RETAINED past the soft size cap so an interrupted older
|
|
3710
|
+
// runner's late settle can still find its identity and not resurrect a stale
|
|
3711
|
+
// ghost. But that protection is itself TIME-BOUNDED — a settle that has hung past
|
|
3712
|
+
// the ghost TTL (the production SDK settle path has no deadline, so a
|
|
3713
|
+
// never-resolving `complete`/`fail` promise could otherwise pin its guard and its
|
|
3714
|
+
// per-key counter forever) is treated as never-resolving, GC'd from
|
|
3715
|
+
// `settleInFlightByKey`, and no longer protects its guard — AND a HARD ceiling
|
|
3716
|
+
// (`hardMax`) evicts oldest-first REGARDLESS of protection, so even a flood of
|
|
3717
|
+
// simultaneously-stuck settles can never grow the guard without bound. The
|
|
3718
|
+
// `settleInFlightByKey` map is ALSO bounded on BOTH axes: per-KEY (`maxInFlightPerKey`)
|
|
3719
|
+
// and total-KEYS (`maxKeys`, evicting oldest keys), so a stream of UNIQUE stuck keys
|
|
3720
|
+
// can't grow it (or the per-prune scan cost) unbounded within the TTL either. The worst
|
|
3721
|
+
// case is a bounded, self-expiring stale ghost (already capped + TTL'd elsewhere),
|
|
3722
|
+
// never unbounded memory. Pure w.r.t. the two maps (mutated in place); `nowMs` is
|
|
3723
|
+
// injected for deterministic tests.
|
|
3724
|
+
function pruneActivationGuard(lastActivationByKey, settleInFlightByKey, opts = {}) {
|
|
3725
|
+
const nowMs = Number.isFinite(opts.nowMs) ? opts.nowMs : Date.now();
|
|
3726
|
+
const ttlMs = Number.isFinite(opts.ttlMs) ? opts.ttlMs : SETTLEMENT_PENDING_GHOST_TTL_MS;
|
|
3727
|
+
const maxGhosts = Number.isFinite(opts.maxGhosts) ? opts.maxGhosts : 64;
|
|
3728
|
+
const hardMax = Number.isFinite(opts.hardMax) ? opts.hardMax : maxGhosts * 4;
|
|
3729
|
+
const maxInFlightPerKey = Number.isFinite(opts.maxInFlightPerKey)
|
|
3730
|
+
? opts.maxInFlightPerKey
|
|
3731
|
+
: MAX_SETTLE_IN_FLIGHT_PER_KEY;
|
|
3732
|
+
// Total-KEYS ceiling on settleInFlightByKey itself. Defaults to the guard's hard
|
|
3733
|
+
// ceiling so the settle map is bounded by the same absolute constant.
|
|
3734
|
+
const maxKeys = Number.isFinite(opts.maxKeys) ? opts.maxKeys : hardMax;
|
|
3735
|
+
const isProtected = (key) => {
|
|
3736
|
+
const entries = settleInFlightByKey.get(key);
|
|
3737
|
+
if (!entries) return false;
|
|
3738
|
+
// Retained while ANY non-expired activation is still in flight: a newer same-key
|
|
3739
|
+
// run's fresh `since` keeps the guard even after an older run's `since` ages out.
|
|
3740
|
+
for (const since of entries.values()) {
|
|
3741
|
+
if (nowMs - (since ?? nowMs) <= ttlMs) return true;
|
|
3742
|
+
}
|
|
3743
|
+
return false;
|
|
3744
|
+
};
|
|
3745
|
+
// GC never-resolving in-flight markers so settleInFlightByKey itself stays
|
|
3746
|
+
// bounded (a hung settle's clear() may never run). Drop aged per-activation
|
|
3747
|
+
// entries individually, cap the per-key count so a same-key redelivery loop
|
|
3748
|
+
// cannot accumulate unbounded live entries WITHIN the TTL window, then drop a
|
|
3749
|
+
// key once it has no entries left.
|
|
3750
|
+
for (const [key, entries] of settleInFlightByKey.entries()) {
|
|
3751
|
+
for (const [id, since] of entries) {
|
|
3752
|
+
if (nowMs - (since ?? nowMs) > ttlMs) entries.delete(id);
|
|
3753
|
+
}
|
|
3754
|
+
// Per-key cap: evict oldest-first (insertion order === id order ===
|
|
3755
|
+
// non-decreasing `since`), retaining the newest live identity that governs
|
|
3756
|
+
// protection (#256 review).
|
|
3757
|
+
while (entries.size > maxInFlightPerKey) {
|
|
3758
|
+
const oldest = entries.keys().next().value;
|
|
3759
|
+
entries.delete(oldest);
|
|
3760
|
+
}
|
|
3761
|
+
if (entries.size === 0) settleInFlightByKey.delete(key);
|
|
3762
|
+
}
|
|
3763
|
+
// Total-keys ceiling on settleInFlightByKey itself. The per-key cap above bounds
|
|
3764
|
+
// each key's entry count, but a stream of UNIQUE keys each with a never-resolving
|
|
3765
|
+
// settle would still grow the OUTER map until each key's TTL — so a flood of
|
|
3766
|
+
// distinct stuck keys could enlarge the map (and make every prune scan more
|
|
3767
|
+
// expensive) within the TTL window. Evict oldest-first (insertion order) so the
|
|
3768
|
+
// settle map is ABSOLUTELY bounded like lastActivationByKey. Defined behavior for
|
|
3769
|
+
// an evicted identity: its marks are dropped, so its later clearSettleInFlight(mark)
|
|
3770
|
+
// is a harmless no-op (entries absent) and its guard loses protection here — it
|
|
3771
|
+
// then falls to the TTL / soft-cap / hard-ceiling eviction below, the SAME graceful
|
|
3772
|
+
// degradation as a TTL-expired settle. Worst case a bounded self-expiring stale
|
|
3773
|
+
// ghost, never unbounded memory (#256 review).
|
|
3774
|
+
if (settleInFlightByKey.size > maxKeys) {
|
|
3775
|
+
for (const key of [...settleInFlightByKey.keys()]) {
|
|
3776
|
+
if (settleInFlightByKey.size <= maxKeys) break;
|
|
3777
|
+
settleInFlightByKey.delete(key);
|
|
3778
|
+
}
|
|
3779
|
+
}
|
|
3780
|
+
// 1) TTL: drop aged, unprotected identity entries.
|
|
3781
|
+
for (const [key, v] of lastActivationByKey.entries()) {
|
|
3782
|
+
if (nowMs - (v.at ?? nowMs) > ttlMs && !isProtected(key)) lastActivationByKey.delete(key);
|
|
3783
|
+
}
|
|
3784
|
+
// 2) Soft size cap: evict oldest-first, retaining a live settle's guard.
|
|
3785
|
+
if (lastActivationByKey.size > maxGhosts) {
|
|
3786
|
+
for (const key of [...lastActivationByKey.keys()]) {
|
|
3787
|
+
if (lastActivationByKey.size <= maxGhosts) break;
|
|
3788
|
+
if (isProtected(key)) continue;
|
|
3789
|
+
lastActivationByKey.delete(key);
|
|
3790
|
+
}
|
|
3791
|
+
}
|
|
3792
|
+
// 3) Hard ceiling: protected keys alone can never grow past this — evict
|
|
3793
|
+
// oldest-first regardless so the guard is ABSOLUTELY bounded.
|
|
3794
|
+
if (lastActivationByKey.size > hardMax) {
|
|
3795
|
+
for (const key of [...lastActivationByKey.keys()]) {
|
|
3796
|
+
if (lastActivationByKey.size <= hardMax) break;
|
|
3797
|
+
lastActivationByKey.delete(key);
|
|
3798
|
+
}
|
|
3799
|
+
}
|
|
3800
|
+
}
|
|
3801
|
+
|
|
3802
|
+
// #254: wrap a job's fenced settle seam so a settle FAILURE that means the
|
|
3803
|
+
// activation's lease was DEFINITIVELY lost (see isLeaseLostSettleError) — the
|
|
3804
|
+
// fenced `complete`/`fail` was rejected with a 409 reclaim / 404 / "not activated"
|
|
3805
|
+
// while the engine still projects the job `CREATED` — is recorded as
|
|
3806
|
+
// `settlement-pending` for `supervisor status` BEFORE the error is re-thrown
|
|
3807
|
+
// unchanged. A transient (5xx/429/network) or deterministic (validation/auth)
|
|
3808
|
+
// settle failure is NOT recorded: those do not establish a stuck settlement, so
|
|
3809
|
+
// marking them would surface a false stuck window. Purely observational: it never
|
|
3810
|
+
// swallows the rejection or alters the settle outcome, so the runtime's dispatch
|
|
3811
|
+
// handles the failed settle exactly as before. `onPending(job, phase)` is the
|
|
3812
|
+
// recorder; `phase` is 'complete' or 'fail' (which settle call was stuck), and a
|
|
3813
|
+
// throw from the recorder itself is swallowed so a marker write can never mask the
|
|
3814
|
+
// real error. Retention of the activation identity guard across an interrupted
|
|
3815
|
+
// runner's late settle is owned by the RUNNER lifecycle (markSettleInFlight from
|
|
3816
|
+
// runner start to its finally), not this wrapper — so the guard is protected for
|
|
3817
|
+
// the whole runner, including the awaits BEFORE the settle promise begins.
|
|
3818
|
+
// @param {{ complete: Function, fail: Function }} settleJob the per-activation settle seam (see bindJobSettle)
|
|
3819
|
+
// @param {{ jobKey: string, type?: string, leaseToken?: string }} job the activation being settled
|
|
3820
|
+
// @param {(job: object, phase: 'complete'|'fail') => void} onPending records the stuck settlement
|
|
3821
|
+
// @returns {{ complete: Function, fail: Function }}
|
|
3822
|
+
function withSettlementPendingMarker(settleJob, job, onPending) {
|
|
3823
|
+
const mark = (phase) => {
|
|
3824
|
+
try { if (typeof onPending === 'function') onPending(job, phase); } catch { /* advisory only */ }
|
|
3825
|
+
};
|
|
3826
|
+
const around = async (phase, call) => {
|
|
3827
|
+
try {
|
|
3828
|
+
return await call();
|
|
3829
|
+
} catch (err) {
|
|
3830
|
+
// Only a definitive lease-loss rejection is a stuck settlement (#256 review).
|
|
3831
|
+
if (isLeaseLostSettleError(err)) mark(phase);
|
|
3832
|
+
throw err;
|
|
3833
|
+
}
|
|
3834
|
+
};
|
|
3835
|
+
return {
|
|
3836
|
+
complete: (variables) => around('complete', () => settleJob.complete(variables)),
|
|
3837
|
+
fail: (opts2) => around('fail', () => settleJob.fail(opts2)),
|
|
3838
|
+
};
|
|
3839
|
+
}
|
|
3840
|
+
|
|
3841
|
+
// #256 review: the SINGLE composition the runner hot path uses to build a job's
|
|
3842
|
+
// fenced, settlement-pending-aware settle seam — `withSettlementPendingMarker`
|
|
3843
|
+
// wrapping a `bindJobSettle`-fenced settler. Extracted so the exact production
|
|
3844
|
+
// wiring (not a test-local reconstruction of it) is exercised by a test: removing
|
|
3845
|
+
// or mis-wiring either layer here reddens `composeFencedSettleJob`'s coverage.
|
|
3846
|
+
// @param {{ complete: Function, fail: Function }} settle the raw per-key engine settle seam
|
|
3847
|
+
// @param {{ jobKey: string, type?: string, leaseToken?: string }} job the activation being settled
|
|
3848
|
+
// @param {(job: object, phase: 'complete'|'fail') => void} onPending records the stuck settlement
|
|
3849
|
+
// @returns {{ complete: Function, fail: Function }}
|
|
3850
|
+
function composeFencedSettleJob(settle, job, onPending) {
|
|
3851
|
+
return withSettlementPendingMarker(bindJobSettle(settle, job), job, onPending);
|
|
3852
|
+
}
|
|
3853
|
+
|
|
3602
3854
|
async function createSupervisorDeps(opts = {}) {
|
|
3603
3855
|
const {
|
|
3604
3856
|
runner,
|
|
@@ -8419,14 +8671,20 @@ function agenticStateForTarget(target, safeUrl = (u) => u) {
|
|
|
8419
8671
|
* `agentic` from THIS payload — leaving every supervised worker's Engine/Agentic
|
|
8420
8672
|
* column stuck at `?` — would otherwise slip through. `jobs` is the live active-job
|
|
8421
8673
|
* list; `busy` is derived so callers can't desync it from `jobs`.
|
|
8422
|
-
*
|
|
8674
|
+
*
|
|
8675
|
+
* `busy` counts only ACTIVELY-RUNNING jobs, never a `settlementPending` ghost
|
|
8676
|
+
* (#254): a job whose fenced settle failed (activation ownership lost around
|
|
8677
|
+
* `completeJob`/`failJob`, so the engine may still project it `CREATED`) is no
|
|
8678
|
+
* longer running here, so it rides the marker as a flagged, observational entry
|
|
8679
|
+
* without making an idle worker look busy or inflating the drain's in-flight count.
|
|
8680
|
+
* @param {{ pid:number, updatedAt:number, jobs:Array<{key:string,type:string,since:number,settlementPending?:boolean}>, engine:(string|null), agentic:object }} fields
|
|
8423
8681
|
*/
|
|
8424
8682
|
function buildActivityPayload({ pid, updatedAt, jobs, engine, agentic, readyAt }) {
|
|
8425
8683
|
const jobList = Array.isArray(jobs) ? jobs : [];
|
|
8426
8684
|
return {
|
|
8427
8685
|
pid,
|
|
8428
8686
|
updatedAt,
|
|
8429
|
-
busy: jobList.
|
|
8687
|
+
busy: jobList.some((j) => j && !j.settlementPending),
|
|
8430
8688
|
jobs: jobList,
|
|
8431
8689
|
engine: engine ?? null,
|
|
8432
8690
|
agentic,
|
|
@@ -9138,6 +9396,120 @@ async function workAgent(req, flags, ctx) {
|
|
|
9138
9396
|
installParentDeathWatchdog({ parentPid: Number.isInteger(daemonPid) ? daemonPid : undefined });
|
|
9139
9397
|
}
|
|
9140
9398
|
const activeJobs = new Map(); // jobKey -> { type, since (ms epoch) }
|
|
9399
|
+
// #254: settlement-pending ghosts — jobKey -> { type, since, phase, leaseToken }.
|
|
9400
|
+
// A job whose fenced settle FAILED (activation ownership lost around
|
|
9401
|
+
// `completeJob`/`failJob`, so the engine may still project it `CREATED`) is
|
|
9402
|
+
// recorded here so `supervisor status` can SURFACE the stuck window instead of
|
|
9403
|
+
// an operator inferring it from a silently-idle worker + a stuck `CREATED` job.
|
|
9404
|
+
// Kept OUT of `activeJobs` on purpose: these jobs are no longer running here, so
|
|
9405
|
+
// they must not count as busy/in-flight (force-abort yield, drain, capacity) —
|
|
9406
|
+
// this is observation only; recovery is already owned by the lease-fence +
|
|
9407
|
+
// transcript-resume path. Bounded so a long-lived worker can't accumulate ghosts.
|
|
9408
|
+
//
|
|
9409
|
+
// A ghost is INVALIDATED locally when THIS worker re-activates the same key
|
|
9410
|
+
// (recordJobStart). That invalidation is process-local, so in a multi-worker
|
|
9411
|
+
// fleet a key re-activated on ANOTHER worker never clears this worker's ghost
|
|
9412
|
+
// (that worker can't reach this in-memory map). To stop such a ghost lingering
|
|
9413
|
+
// until this worker restarts, every ghost also carries an explicit TTL: it
|
|
9414
|
+
// self-expires SETTLEMENT_PENDING_GHOST_TTL_MS after the settle FAILED (pruned
|
|
9415
|
+
// in writeActivity on the producer, AND dropped at read time in
|
|
9416
|
+
// summarizeSupervisorWorker so an idle worker that has stopped writing activity
|
|
9417
|
+
// still expires the ghost), bounding the stale-observation window without
|
|
9418
|
+
// needing shared cross-worker state. Both the TTL and the ghost's displayed age
|
|
9419
|
+
// are anchored at recordedAt (when settlement failed), not the job's activation
|
|
9420
|
+
// start.
|
|
9421
|
+
const settlementPendingJobs = new Map();
|
|
9422
|
+
const MAX_SETTLEMENT_PENDING_GHOSTS = 64;
|
|
9423
|
+
// The most recent activation leaseToken seen per key, retained AFTER the job
|
|
9424
|
+
// ends so a late fenced settle from an OLDER, interrupted runner cannot
|
|
9425
|
+
// resurrect a ghost once its own activeJobs entry is gone (recordJobEnd removed
|
|
9426
|
+
// it). Pruned by TTL/size in pruneLastActivation — called from recordJobStart
|
|
9427
|
+
// (so a standalone `nano work`, whose writeActivity is a no-op, still bounds it)
|
|
9428
|
+
// AND writeActivity. The soft size cap retains a key whose fenced settle is still
|
|
9429
|
+
// outstanding (settleInFlightByKey) — a settle is not cancelled when its runner is
|
|
9430
|
+
// interrupted, so evicting the guard purely by key count could let that late
|
|
9431
|
+
// rejection resurrect a stale ghost — but that retention is TIME-BOUNDED and a
|
|
9432
|
+
// HARD ceiling caps the map absolutely, so a never-resolving settle (the SDK
|
|
9433
|
+
// settle path has no deadline) can never grow it without bound (#256 review). See
|
|
9434
|
+
// recordSettlementPending's absent-`cur` guard.
|
|
9435
|
+
const lastActivationByKey = new Map();
|
|
9436
|
+
// Keys with an UNOBSERVED fenced settle in flight. Each key maps to a Map of
|
|
9437
|
+
// per-ACTIVATION entries `id -> since` (NOT a single {count, since}): concurrent
|
|
9438
|
+
// same-key activations (an interrupted older runner still settling while a newer
|
|
9439
|
+
// one starts) must each carry their OWN start time and identity, so (a) protection
|
|
9440
|
+
// is retained while ANY non-expired activation remains — a newer run's fresh
|
|
9441
|
+
// `since` keeps the guard even after the older run's `since` crosses the TTL — and
|
|
9442
|
+
// (b) an older runner's `clearSettleInFlight` removes only ITS entry, never a
|
|
9443
|
+
// same-key newer runner's marker (#256 review). An interrupted older runner's
|
|
9444
|
+
// settle keeps running (dispatch does not cancel it), so while its outcome is
|
|
9445
|
+
// pending its identity guard in lastActivationByKey must be retained past the size
|
|
9446
|
+
// cap. Bracketed by the RUNNER lifecycle (markSettleInFlight right BEFORE
|
|
9447
|
+
// recordJobStart → clearSettleInFlight in the runner's finally), NOT the settle
|
|
9448
|
+
// promise, so the guard is protected across the awaits BEFORE the settle even
|
|
9449
|
+
// begins — AND across recordJobStart's own pruneLastActivation, which would
|
|
9450
|
+
// otherwise evict this activation's just-inserted (still unprotected) guard at the
|
|
9451
|
+
// soft-cap boundary when every other guard is protected (#256 review). Each entry's
|
|
9452
|
+
// `since` (runner start) time-bounds the protection: a settle
|
|
9453
|
+
// hung past the ghost TTL is treated as never-resolving and GC'd, so the map can
|
|
9454
|
+
// never grow without bound.
|
|
9455
|
+
const settleInFlightByKey = new Map();
|
|
9456
|
+
let settleMarkSeq = 0;
|
|
9457
|
+
const markSettleInFlight = (key, nowMs = Date.now()) => {
|
|
9458
|
+
let entries = settleInFlightByKey.get(key);
|
|
9459
|
+
if (!entries) { entries = new Map(); settleInFlightByKey.set(key, entries); }
|
|
9460
|
+
const id = (settleMarkSeq += 1);
|
|
9461
|
+
entries.set(id, nowMs);
|
|
9462
|
+
// Bound the per-key entry count immediately (not only at the next prune): a
|
|
9463
|
+
// same-key redelivery loop whose settles never resolve could otherwise append
|
|
9464
|
+
// unbounded live entries within the TTL window. Evict oldest-first (insertion
|
|
9465
|
+
// order === id order === non-decreasing `since`), retaining the newest live
|
|
9466
|
+
// identity that governs protection (#256 review).
|
|
9467
|
+
while (entries.size > MAX_SETTLE_IN_FLIGHT_PER_KEY) {
|
|
9468
|
+
const oldest = entries.keys().next().value;
|
|
9469
|
+
entries.delete(oldest);
|
|
9470
|
+
}
|
|
9471
|
+
// Bound the TOTAL key count immediately too (#256 review): recordJobStart runs
|
|
9472
|
+
// pruneLastActivation (which enforces the maxKeys ceiling on this map) BEFORE this
|
|
9473
|
+
// insertion, and writeActivity no-ops for a standalone worker, so without an
|
|
9474
|
+
// eviction here a fresh activation could leave settleInFlightByKey above its
|
|
9475
|
+
// absolute key ceiling until the next job start. Evict oldest keys first (Map
|
|
9476
|
+
// insertion order), but never THIS key — it is the newest and holds the live
|
|
9477
|
+
// in-flight marker we just recorded.
|
|
9478
|
+
if (settleInFlightByKey.size > HARD_MAX_LAST_ACTIVATIONS) {
|
|
9479
|
+
for (const k of [...settleInFlightByKey.keys()]) {
|
|
9480
|
+
if (settleInFlightByKey.size <= HARD_MAX_LAST_ACTIVATIONS) break;
|
|
9481
|
+
if (k === key) continue;
|
|
9482
|
+
settleInFlightByKey.delete(k);
|
|
9483
|
+
}
|
|
9484
|
+
}
|
|
9485
|
+
return { key, id };
|
|
9486
|
+
};
|
|
9487
|
+
const clearSettleInFlight = (mark) => {
|
|
9488
|
+
if (!mark) return;
|
|
9489
|
+
const entries = settleInFlightByKey.get(mark.key);
|
|
9490
|
+
if (!entries) return;
|
|
9491
|
+
entries.delete(mark.id);
|
|
9492
|
+
if (entries.size === 0) settleInFlightByKey.delete(mark.key);
|
|
9493
|
+
};
|
|
9494
|
+
const MAX_SETTLEMENT_PENDING_LAST_ACTIVATIONS = MAX_SETTLEMENT_PENDING_GHOSTS;
|
|
9495
|
+
// Hard ceiling: even a flood of simultaneously-stuck settles can never grow the
|
|
9496
|
+
// guard past this — pruneActivationGuard evicts oldest-first regardless of
|
|
9497
|
+
// protection above it, so the map is ABSOLUTELY bounded (#256 review).
|
|
9498
|
+
const HARD_MAX_LAST_ACTIVATIONS = MAX_SETTLEMENT_PENDING_GHOSTS * 4;
|
|
9499
|
+
// Bound lastActivationByKey INDEPENDENTLY of writeActivity (which returns early
|
|
9500
|
+
// for a standalone `nano work` with no NANO_SUPERVISOR_ACTIVITY_FILE), so a
|
|
9501
|
+
// long-lived standalone worker cannot accumulate one retained token per key.
|
|
9502
|
+
// Delegates to the pure pruneActivationGuard (TTL → soft size cap retaining live
|
|
9503
|
+
// settles → hard ceiling), so an interrupted runner's late settle can find its
|
|
9504
|
+
// guard while a never-resolving settle can never pin it unbounded.
|
|
9505
|
+
const pruneLastActivation = (nowMs = Date.now()) =>
|
|
9506
|
+
pruneActivationGuard(lastActivationByKey, settleInFlightByKey, {
|
|
9507
|
+
nowMs,
|
|
9508
|
+
ttlMs: SETTLEMENT_PENDING_GHOST_TTL_MS,
|
|
9509
|
+
maxGhosts: MAX_SETTLEMENT_PENDING_LAST_ACTIVATIONS,
|
|
9510
|
+
hardMax: HARD_MAX_LAST_ACTIVATIONS,
|
|
9511
|
+
maxKeys: HARD_MAX_LAST_ACTIVATIONS,
|
|
9512
|
+
});
|
|
9141
9513
|
// Which engine this worker polls jobs from, surfaced to `supervisor status` via
|
|
9142
9514
|
// the activity marker (#99). Derived from resolveWorkerPollEngineBase — the SDK
|
|
9143
9515
|
// client's OWN profile restAddress (the base createJobWorker actually activates
|
|
@@ -9171,6 +9543,24 @@ async function workAgent(req, flags, ctx) {
|
|
|
9171
9543
|
const writeActivity = () => {
|
|
9172
9544
|
if (!activityFile) return;
|
|
9173
9545
|
const jobs = [...activeJobs.entries()].map(([key, v]) => ({ key, type: v.type, since: v.since }));
|
|
9546
|
+
// #254: append settlement-pending ghosts as flagged, observational entries. A
|
|
9547
|
+
// ghost whose key was re-activated (now live in `activeJobs`) is superseded —
|
|
9548
|
+
// skip it so a reactivation replaces the stuck row rather than duplicating it.
|
|
9549
|
+
// A ghost past its TTL is pruned here (see SETTLEMENT_PENDING_GHOST_TTL_MS): a
|
|
9550
|
+
// same-key reactivation on a DIFFERENT worker can never clear this worker's
|
|
9551
|
+
// in-memory entry, so the TTL bounds how long a stale ghost lingers.
|
|
9552
|
+
const nowMs = Date.now();
|
|
9553
|
+
for (const [key, v] of settlementPendingJobs.entries()) {
|
|
9554
|
+
if (nowMs - (v.recordedAt ?? v.since ?? nowMs) > SETTLEMENT_PENDING_GHOST_TTL_MS) {
|
|
9555
|
+
settlementPendingJobs.delete(key);
|
|
9556
|
+
continue;
|
|
9557
|
+
}
|
|
9558
|
+
if (activeJobs.has(key)) continue;
|
|
9559
|
+
jobs.push({ key, type: v.type, since: v.since, settlementPending: true, settlePhase: v.phase ?? null });
|
|
9560
|
+
}
|
|
9561
|
+
// Bound the last-activation guard map (TTL + settle-aware size cap). Shared
|
|
9562
|
+
// with recordJobStart so a standalone worker still prunes it.
|
|
9563
|
+
pruneLastActivation(nowMs);
|
|
9174
9564
|
const payload = buildActivityPayload({ pid: process.pid, updatedAt: Date.now(), jobs, engine: workerEngine, agentic: agenticState, readyAt });
|
|
9175
9565
|
const tmp = `${activityFile}.${process.pid}.tmp`;
|
|
9176
9566
|
try {
|
|
@@ -9211,6 +9601,15 @@ async function workAgent(req, flags, ctx) {
|
|
|
9211
9601
|
// cockpit's jobKeys — so the recorders no longer poke a per-process channel.
|
|
9212
9602
|
const recordJobStart = (job, jobType) => {
|
|
9213
9603
|
activeJobs.set(String(job.jobKey), { type: jobType, since: Date.now(), retries: Number(job.retries), leaseToken: job.leaseToken });
|
|
9604
|
+
// #254: a fresh activation of this key supersedes any earlier stuck-settlement
|
|
9605
|
+
// ghost — clear it so status shows the live run, not a stale pending row.
|
|
9606
|
+
settlementPendingJobs.delete(String(job.jobKey));
|
|
9607
|
+
// Remember this as the newest activation for the key so a late settle from an
|
|
9608
|
+
// older runner (whose activeJobs entry is already gone) can't resurrect a ghost.
|
|
9609
|
+
lastActivationByKey.set(String(job.jobKey), { token: job.leaseToken, at: Date.now() });
|
|
9610
|
+
// Bound the guard map here too: writeActivity no-ops for a standalone worker
|
|
9611
|
+
// (no activity file), so the activation recorder must prune it unconditionally.
|
|
9612
|
+
pruneLastActivation();
|
|
9214
9613
|
writeActivity();
|
|
9215
9614
|
};
|
|
9216
9615
|
const recordJobEnd = (job) => {
|
|
@@ -9225,6 +9624,69 @@ async function workAgent(req, flags, ctx) {
|
|
|
9225
9624
|
activeJobs.delete(String(job.jobKey));
|
|
9226
9625
|
writeActivity();
|
|
9227
9626
|
};
|
|
9627
|
+
// #254: record a job whose fenced settle FAILED as settlement-pending so
|
|
9628
|
+
// `supervisor status` surfaces the stuck window. Derived purely from the
|
|
9629
|
+
// in-flight settle outcome the worker already knows (no durable journal, no
|
|
9630
|
+
// extra engine read) — the superseded settlement approach of the closed PR #226.
|
|
9631
|
+
// `since` is anchored at the moment the settle failed (the settlement-pending
|
|
9632
|
+
// window, which is what the JOB cell labels), NOT the job's activation start —
|
|
9633
|
+
// otherwise a job that ran 20m and then failed settlement would immediately
|
|
9634
|
+
// render as `settlement-pending (20m)`. Only the CURRENT activation may create a
|
|
9635
|
+
// ghost (leaseToken guard), so a superseded run's late settle failure can't
|
|
9636
|
+
// resurrect a stale row. The map is bounded (oldest evicted) so ghosts can't
|
|
9637
|
+
// grow without bound on a long-lived worker.
|
|
9638
|
+
const recordSettlementPending = (job, phase) => {
|
|
9639
|
+
const key = String(job.jobKey);
|
|
9640
|
+
// Settlement-pending is a FENCED-settle concept: it surfaces a lease whose
|
|
9641
|
+
// ownership we could no longer prove on settle (a 404/409 on the fenced
|
|
9642
|
+
// complete/fail). An UNLEASED job carries NO fencing identity — `leaseToken` is
|
|
9643
|
+
// documented as absent for ordinary service jobs (supervisor/src/ports.ts) — so
|
|
9644
|
+
// two same-key unleased activations are indistinguishable (both `leaseToken`
|
|
9645
|
+
// values compare === undefined), and an older superseded runner's late 404/409
|
|
9646
|
+
// could resurrect a false ghost against a newer run's identity. There is no
|
|
9647
|
+
// identity to fence, hence no ghost to record: skip unleased failures entirely
|
|
9648
|
+
// (#256 review). "Leased" must mirror the ENGINE's fencing predicate: it fences
|
|
9649
|
+
// only NON-BLANK tokens (`isNonBlankString`, supervisor-engine.mjs) and sends a
|
|
9650
|
+
// blank/whitespace token's complete/fail UNFENCED — so such a token carries no
|
|
9651
|
+
// fencing identity either and must be treated as unleased here, else a '' /
|
|
9652
|
+
// whitespace token would record a false ghost for an unfenced 404/409 (#256 review).
|
|
9653
|
+
if (!(typeof job.leaseToken === "string" && job.leaseToken.trim() !== "")) return;
|
|
9654
|
+
const cur = activeJobs.get(key);
|
|
9655
|
+
// Only the CURRENT activation may create a ghost. A same-key reactivation
|
|
9656
|
+
// (recordJobStart) installs a newer leaseToken; if THIS (older) activation's
|
|
9657
|
+
// fenced settle then fails, recording a ghost would surface a stale row after
|
|
9658
|
+
// the newer run's recordJobEnd — even when the newer settle succeeded. Skip it
|
|
9659
|
+
// (mirrors recordJobEnd's identity guard).
|
|
9660
|
+
//
|
|
9661
|
+
// `cur` can also be ABSENT here: the newer activation already ran AND finished,
|
|
9662
|
+
// so recordJobEnd removed its activeJobs entry. A bare `cur &&` guard would then
|
|
9663
|
+
// let this older runner's late rejection resurrect a stale ghost. Fall back to
|
|
9664
|
+
// the retained last-activation identity: record only if the newest activation
|
|
9665
|
+
// seen for this key is THIS job (matching leaseToken). If the identity guard is
|
|
9666
|
+
// ALSO gone (TTL / hard cap), we can no longer prove currency — treat it as
|
|
9667
|
+
// stale/unknown and skip (#256 review).
|
|
9668
|
+
if (cur) {
|
|
9669
|
+
if (cur.leaseToken !== job.leaseToken) return;
|
|
9670
|
+
} else {
|
|
9671
|
+
const last = lastActivationByKey.get(key);
|
|
9672
|
+
// Token-bearing job (unleased already returned above): record only when the
|
|
9673
|
+
// retained identity still names THIS run; a pruned guard can't prove currency.
|
|
9674
|
+
if (!last || last.token !== job.leaseToken) return;
|
|
9675
|
+
}
|
|
9676
|
+
const recordedAt = Date.now();
|
|
9677
|
+
settlementPendingJobs.set(key, {
|
|
9678
|
+
type: cur?.type ?? job.type ?? null,
|
|
9679
|
+
since: recordedAt,
|
|
9680
|
+
phase: phase ?? null,
|
|
9681
|
+
leaseToken: job.leaseToken,
|
|
9682
|
+
recordedAt,
|
|
9683
|
+
});
|
|
9684
|
+
while (settlementPendingJobs.size > MAX_SETTLEMENT_PENDING_GHOSTS) {
|
|
9685
|
+
const oldest = settlementPendingJobs.keys().next().value;
|
|
9686
|
+
settlementPendingJobs.delete(oldest);
|
|
9687
|
+
}
|
|
9688
|
+
writeActivity();
|
|
9689
|
+
};
|
|
9228
9690
|
// Seed an initial idle marker so status reports 'idle' immediately after spawn.
|
|
9229
9691
|
writeActivity();
|
|
9230
9692
|
|
|
@@ -9412,6 +9874,22 @@ async function workAgent(req, flags, ctx) {
|
|
|
9412
9874
|
const runner = {
|
|
9413
9875
|
run: async (job, abortSignal) => {
|
|
9414
9876
|
const jobType = job.type;
|
|
9877
|
+
// #256 review: mark THIS activation's settle in flight BEFORE recordJobStart,
|
|
9878
|
+
// so its identity guard is already PROTECTED (present in settleInFlightByKey)
|
|
9879
|
+
// when recordJobStart runs its own pruneLastActivation. Otherwise, at the
|
|
9880
|
+
// soft-cap boundary the just-inserted guard is still unprotected (this key is
|
|
9881
|
+
// not in settleInFlightByKey yet) and — if every OTHER guard is protected by an
|
|
9882
|
+
// older interrupted settle — the soft-cap eviction drops THIS newly inserted
|
|
9883
|
+
// (unprotected) key, leaving the subsequent settle with no matching identity
|
|
9884
|
+
// guard; an interrupted run whose fenced settle then rejects after recordJobEnd
|
|
9885
|
+
// clears activeJobs would find no currency proof and silently drop the ghost.
|
|
9886
|
+
// Marking first also retains the guard from runner START — not merely once the
|
|
9887
|
+
// settle promise begins — so a same-key eviction can't drop it during the awaits
|
|
9888
|
+
// BEFORE settlement either. Balanced in the finally (clearSettleInFlight) via the
|
|
9889
|
+
// returned per-activation handle, so an older runner's clear removes only ITS
|
|
9890
|
+
// marker, never a same-key newer runner's; the guard is then pruned with a time
|
|
9891
|
+
// bound + hard ceiling so a never-resolving settle cannot pin it forever.
|
|
9892
|
+
const settleMark = markSettleInFlight(String(job.jobKey));
|
|
9415
9893
|
recordJobStart(job, jobType);
|
|
9416
9894
|
// Bind the settler to THIS activation's lease token, captured from the job
|
|
9417
9895
|
// in closure scope. A settlement is fenced with the exact activation that
|
|
@@ -9420,7 +9898,7 @@ async function workAgent(req, flags, ctx) {
|
|
|
9420
9898
|
// is still unwinding, so a map lookup could fence the completion with the
|
|
9421
9899
|
// WRONG (newer) token and clobber the new activation — the very lease bypass
|
|
9422
9900
|
// this fence exists to prevent. `job.leaseToken` in the closure cannot drift.
|
|
9423
|
-
const settleJob =
|
|
9901
|
+
const settleJob = composeFencedSettleJob(settle, job, recordSettlementPending);
|
|
9424
9902
|
try {
|
|
9425
9903
|
logger.info(`[${jobType}] job ${job.jobKey} (instance ${job.processInstanceKey ?? '-'}) → ${buildAgentCommandLine(profile.command, effectiveArgs)}`);
|
|
9426
9904
|
|
|
@@ -10215,6 +10693,7 @@ async function workAgent(req, flags, ctx) {
|
|
|
10215
10693
|
variables: { [AGENT_RESULT_KEY]: resultEnvelope },
|
|
10216
10694
|
});
|
|
10217
10695
|
} finally {
|
|
10696
|
+
clearSettleInFlight(settleMark);
|
|
10218
10697
|
recordJobEnd(job);
|
|
10219
10698
|
}
|
|
10220
10699
|
},
|
|
@@ -10909,16 +11388,31 @@ function summarizeSupervisorWorker(w, now = Date.now()) {
|
|
|
10909
11388
|
const act = readWorkerActivity(w.id);
|
|
10910
11389
|
if (act && act.pid === w.pid) {
|
|
10911
11390
|
const jobs = Array.isArray(act.jobs)
|
|
10912
|
-
? act.jobs
|
|
11391
|
+
? act.jobs
|
|
11392
|
+
.map((j) => ({
|
|
10913
11393
|
key: String(j.key),
|
|
10914
11394
|
type: j.type ?? null,
|
|
10915
11395
|
// Both the snapshot-time duration and its absolute base, so the
|
|
10916
11396
|
// console can re-age the job cell locally (mirrors uptimeMs above).
|
|
10917
11397
|
sinceMs: Number.isFinite(j.since) ? Math.max(0, now - j.since) : null,
|
|
10918
11398
|
sinceEpochMs: Number.isFinite(j.since) ? j.since : null,
|
|
11399
|
+
// #254: carry the settlement-pending flag (+ which settle call was
|
|
11400
|
+
// stuck) so the JOB cell can surface the stuck window; a plain running
|
|
11401
|
+
// job leaves both absent/false.
|
|
11402
|
+
settlementPending: Boolean(j.settlementPending),
|
|
11403
|
+
settlePhase: j.settlementPending ? (j.settlePhase ?? null) : null,
|
|
10919
11404
|
}))
|
|
11405
|
+
// #254: enforce the ghost TTL at READ time too. writeActivity prunes
|
|
11406
|
+
// expired ghosts only when it runs, but an idle worker stops writing
|
|
11407
|
+
// activity, so a persisted ghost would otherwise linger in the JOB cell
|
|
11408
|
+
// past its TTL. Drop a settlement-pending entry once it is older than
|
|
11409
|
+
// SETTLEMENT_PENDING_GHOST_TTL_MS so it self-expires as documented even
|
|
11410
|
+
// on a quiet worker. A running job (no epoch, or not pending) is kept.
|
|
11411
|
+
.filter((j) => !(j.settlementPending && j.sinceEpochMs != null && now - j.sinceEpochMs > SETTLEMENT_PENDING_GHOST_TTL_MS))
|
|
10920
11412
|
: [];
|
|
10921
|
-
|
|
11413
|
+
// A worker with ONLY settlement-pending ghosts is idle (it no longer runs
|
|
11414
|
+
// them) — derive busy from the actively-running jobs, never a ghost (#254).
|
|
11415
|
+
activity = { state: jobs.some((j) => !j.settlementPending) ? 'busy' : 'idle', jobs };
|
|
10922
11416
|
// Engine + agentic-channel status ride the same pid-guarded marker, so a
|
|
10923
11417
|
// stale incarnation can't show a dead worker as connected to a hub.
|
|
10924
11418
|
engine = typeof act.engine === 'string' && act.engine ? act.engine : null;
|
|
@@ -10951,8 +11445,12 @@ function summarizeSupervisorWorker(w, now = Date.now()) {
|
|
|
10951
11445
|
* Deliberately excludes ticking durations (uptimeMs, per-job sinceMs) so that a
|
|
10952
11446
|
* merely-elapsing clock doesn't count as a change — only real transitions (a
|
|
10953
11447
|
* worker going up/down, idle↔busy, picking up/finishing a job, a restart) alter
|
|
10954
|
-
* the signature.
|
|
10955
|
-
*
|
|
11448
|
+
* the signature. For a settlement-pending ghost it also folds in the STABLE
|
|
11449
|
+
* pending-instance identity (sinceEpochMs + settlePhase, not the ticking sinceMs)
|
|
11450
|
+
* so a same-key job that fails settlement again between ticks — yielding a fresh
|
|
11451
|
+
* ghost with a new sinceEpochMs — is detected as a change and repaints, rather
|
|
11452
|
+
* than being masked by the previous ghost's identical [key,type,sp] tuple.
|
|
11453
|
+
* The daemon uses this to push a refreshed status to attached consoles only when something actually changed, keeping a quiet fleet silent.
|
|
10956
11454
|
* `workers` is an array of `summarizeSupervisorWorker` results.
|
|
10957
11455
|
*/
|
|
10958
11456
|
function supervisorStatusSignature(workers) {
|
|
@@ -10967,7 +11465,7 @@ function supervisorStatusSignature(workers) {
|
|
|
10967
11465
|
w.lastExit ?? '',
|
|
10968
11466
|
w.activity ? w.activity.state : null,
|
|
10969
11467
|
w.activity
|
|
10970
|
-
? w.activity.jobs.map((j) => `${j.key}\u0000${j.type ?? ''}`).sort()
|
|
11468
|
+
? w.activity.jobs.map((j) => `${j.key}\u0000${j.type ?? ''}\u0000${j.settlementPending ? `sp\u0000${j.sinceEpochMs ?? ''}\u0000${j.settlePhase ?? ''}` : ''}`).sort()
|
|
10971
11469
|
: null,
|
|
10972
11470
|
// Engine + agentic-channel status: a connect/disconnect or an engine
|
|
10973
11471
|
// change is a real transition that must repaint attached consoles (#99).
|
|
@@ -10982,10 +11480,25 @@ function supervisorJobCell(w) {
|
|
|
10982
11480
|
if (w.state !== 'running') return '-';
|
|
10983
11481
|
const a = w.activity;
|
|
10984
11482
|
if (!a) return '?'; // alive but not reporting (older worker / marker not yet written)
|
|
10985
|
-
|
|
10986
|
-
const
|
|
11483
|
+
const jobs = Array.isArray(a.jobs) ? a.jobs : [];
|
|
11484
|
+
const running = jobs.filter((j) => !j.settlementPending);
|
|
11485
|
+
const pending = jobs.filter((j) => j.settlementPending);
|
|
11486
|
+
// #254: a worker with no running job but a stuck settlement shows the
|
|
11487
|
+
// settlement-pending window (job key + how long it's been stuck) rather than a
|
|
11488
|
+
// bare `idle`, so an operator can see (and act on) a job caught between an
|
|
11489
|
+
// agent's side effect and broker settlement instead of inferring it.
|
|
11490
|
+
if (running.length === 0) {
|
|
11491
|
+
if (pending.length === 0) return 'idle';
|
|
11492
|
+
const [first] = pending;
|
|
11493
|
+
const dur = first.sinceMs != null ? ` (${formatDuration(first.sinceMs)})` : '';
|
|
11494
|
+
const more = pending.length > 1 ? ` +${pending.length - 1}` : '';
|
|
11495
|
+
return `${first.key} settlement-pending${more}${dur}`;
|
|
11496
|
+
}
|
|
11497
|
+
const [first, ...rest] = running;
|
|
10987
11498
|
const dur = first.sinceMs != null ? ` (${formatDuration(first.sinceMs)})` : '';
|
|
10988
|
-
|
|
11499
|
+
// Fold any additional running jobs AND settlement-pending ghosts into the +N.
|
|
11500
|
+
const extra = rest.length + pending.length;
|
|
11501
|
+
const more = extra > 0 ? ` +${extra}` : '';
|
|
10989
11502
|
return `${first.key}${more}${dur}`;
|
|
10990
11503
|
}
|
|
10991
11504
|
|
|
@@ -11042,14 +11555,25 @@ function reageSupervisorStatus(status, now = Date.now()) {
|
|
|
11042
11555
|
Number.isFinite(w.startedAtMs) ? Math.max(0, now - w.startedAtMs) : w.uptimeMs;
|
|
11043
11556
|
let activity = w.activity;
|
|
11044
11557
|
if (activity && Array.isArray(activity.jobs)) {
|
|
11045
|
-
|
|
11046
|
-
|
|
11047
|
-
jobs: activity.jobs.map((j) =>
|
|
11558
|
+
const jobs = activity.jobs
|
|
11559
|
+
.map((j) =>
|
|
11048
11560
|
j && typeof j === 'object' && Number.isFinite(j.sinceEpochMs)
|
|
11049
11561
|
? { ...j, sinceMs: Math.max(0, now - j.sinceEpochMs) }
|
|
11050
11562
|
: j,
|
|
11051
|
-
)
|
|
11052
|
-
|
|
11563
|
+
)
|
|
11564
|
+
// #254 (#256 review): apply the same read-time ghost TTL while re-aging.
|
|
11565
|
+
// An attached console re-ages the cached snapshot on every tick (it never
|
|
11566
|
+
// rebuilds it via summarizeSupervisorWorker), so with NANO_SUPERVISOR_MONITOR_MS=0
|
|
11567
|
+
// — or simply between status frames — a settlement-pending ghost past its
|
|
11568
|
+
// TTL would otherwise render indefinitely. Drop it here too so it
|
|
11569
|
+
// self-expires as documented on the live view. A running job (no epoch, or
|
|
11570
|
+
// not pending) is kept.
|
|
11571
|
+
.filter((j) => !(j && typeof j === 'object' && j.settlementPending && Number.isFinite(j.sinceEpochMs) && now - j.sinceEpochMs > SETTLEMENT_PENDING_GHOST_TTL_MS));
|
|
11572
|
+
// Re-derive busy/idle from the surviving actively-running jobs (a pure ghost
|
|
11573
|
+
// is idle), mirroring summarizeSupervisorWorker — so expiring the last ghost
|
|
11574
|
+
// flips the worker to idle instead of leaving a stale 'busy'.
|
|
11575
|
+
const state = jobs.some((j) => j && typeof j === 'object' && !j.settlementPending) ? 'busy' : 'idle';
|
|
11576
|
+
activity = { ...activity, jobs, state };
|
|
11053
11577
|
}
|
|
11054
11578
|
return { ...w, uptimeMs, activity };
|
|
11055
11579
|
}),
|
|
@@ -12624,7 +13148,9 @@ async function supervisorReloadCmd(req) {
|
|
|
12624
13148
|
function countSupervisorInFlight(workers) {
|
|
12625
13149
|
let n = 0;
|
|
12626
13150
|
for (const w of workers || []) {
|
|
12627
|
-
|
|
13151
|
+
// #254: a settlement-pending ghost is no longer running here (its worker
|
|
13152
|
+
// released it), so it must not inflate the in-flight count an operator drains.
|
|
13153
|
+
if (w && w.activity && Array.isArray(w.activity.jobs)) n += w.activity.jobs.filter((j) => !j.settlementPending).length;
|
|
12628
13154
|
}
|
|
12629
13155
|
return n;
|
|
12630
13156
|
}
|
|
@@ -16242,6 +16768,10 @@ export {
|
|
|
16242
16768
|
createAgenticEndpoint,
|
|
16243
16769
|
createSupervisorDeps,
|
|
16244
16770
|
bindJobSettle,
|
|
16771
|
+
withSettlementPendingMarker,
|
|
16772
|
+
composeFencedSettleJob,
|
|
16773
|
+
isLeaseLostSettleError,
|
|
16774
|
+
pruneActivationGuard,
|
|
16245
16775
|
enableEngineHappyEyeballs,
|
|
16246
16776
|
preferIpv4Resolution,
|
|
16247
16777
|
isLikelyLocalNetworkTccBlock,
|
|
@@ -16292,6 +16822,7 @@ export {
|
|
|
16292
16822
|
printSupervisorStatus,
|
|
16293
16823
|
supervisorStatusSignature,
|
|
16294
16824
|
supervisorJobCell,
|
|
16825
|
+
countSupervisorInFlight,
|
|
16295
16826
|
supervisorEngineCell,
|
|
16296
16827
|
supervisorAgenticCell,
|
|
16297
16828
|
agenticStateForTarget,
|
|
@@ -16300,6 +16831,7 @@ export {
|
|
|
16300
16831
|
activityMarkerReadyFor,
|
|
16301
16832
|
waitForChildExit,
|
|
16302
16833
|
supervisorWorkerActivityFile,
|
|
16834
|
+
SETTLEMENT_PENDING_GHOST_TTL_MS,
|
|
16303
16835
|
WORK_FORWARD_FLAGS,
|
|
16304
16836
|
installParentDeathWatchdog,
|
|
16305
16837
|
runSupervisorDaemon,
|
package/nanobpmn-binary.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "c8ctl-plugin-nano",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.66.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",
|
|
@@ -75,12 +75,12 @@
|
|
|
75
75
|
},
|
|
76
76
|
"optionalDependencies": {
|
|
77
77
|
"node-pty": "^1.0.0",
|
|
78
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.
|
|
79
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.
|
|
80
|
-
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.
|
|
81
|
-
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.
|
|
82
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.
|
|
83
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.
|
|
84
|
-
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.
|
|
78
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.66.1",
|
|
79
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.66.1",
|
|
80
|
+
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.66.1",
|
|
81
|
+
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.66.1",
|
|
82
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.66.1",
|
|
83
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.66.1",
|
|
84
|
+
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.66.1"
|
|
85
85
|
}
|
|
86
86
|
}
|