c8ctl-plugin-nano 1.65.1 → 1.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +16 -0
  2. package/c8ctl-plugin.js +549 -17
  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
- * @param {{ pid:number, updatedAt:number, jobs:Array<{key:string,type:string,since:number}>, engine:(string|null), agentic:object }} fields
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.length > 0,
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 = bindJobSettle(settle, job);
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.map((j) => ({
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
- activity = { state: jobs.length > 0 ? 'busy' : 'idle', jobs };
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. The daemon uses this to push a refreshed status to attached
10955
- * consoles only when something actually changed, keeping a quiet fleet silent.
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
- if (a.state !== 'busy' || a.jobs.length === 0) return 'idle';
10986
- const [first, ...rest] = a.jobs;
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
- const more = rest.length > 0 ? ` +${rest.length}` : '';
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
- activity = {
11046
- ...activity,
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
- if (w && w.activity && Array.isArray(w.activity.jobs)) n += w.activity.jobs.length;
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.65.1",
3
+ "version": "1.66.0",
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.65.1",
79
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.65.1",
80
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.65.1",
81
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.65.1",
82
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.65.1",
83
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.65.1",
84
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.65.1"
78
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.66.0",
79
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.66.0",
80
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.66.0",
81
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.66.0",
82
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.66.0",
83
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.66.0",
84
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.66.0"
85
85
  }
86
86
  }