c8ctl-plugin-nano 1.51.0 → 1.52.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 (2) hide show
  1. package/c8ctl-plugin.js +157 -383
  2. package/package.json +9 -8
package/c8ctl-plugin.js CHANGED
@@ -136,11 +136,6 @@ const READINESS_TIMEOUT_MS = 60_000;
136
136
  const READINESS_POLL_MS = 500;
137
137
  const HEALTH_TIMEOUT_MS = 1_500;
138
138
  const STOP_GRACE_MS = 8_000;
139
- // Backoff applied when a poller fails a lease fast because the worker is already
140
- // running another job (issue #142 single-flight). Long enough that the deferred
141
- // job doesn't tight-loop re-activating while the first runs, short enough that it
142
- // is picked up promptly once the worker frees up.
143
- const WORKER_BUSY_RETRY_BACKOFF_MS = 5_000;
144
139
  // Upper bound on one `--auto` engine-read reconcile (enumerate deployed
145
140
  // definitions + fetch each BPMN). A read that stalls past this is treated as a
146
141
  // transient failure so the running poller set is KEPT and, crucially, shutdown
@@ -3288,14 +3283,12 @@ async function createAgenticEndpoint(opts) {
3288
3283
  //
3289
3284
  // Returns everything a JS caller needs to run `makeSupervisor` — the assembled
3290
3285
  // `deps`, the seeded `registry` (so workers can be add/remove'd live), and the
3291
- // `makeSupervisor` + `Effect` handles from the bundle. NOTE (deferred, issue
3292
- // #156): the actual hot-path flip deleting the per-type SDK pollers, the
3293
- // process-wide `singleFlight`, and the per-process reconcile crawl in `workAgent`
3294
- // and running `makeSupervisor(deps).run` as the single per-host owner is NOT
3295
- // wired here; it deletes battle-tested crash-safety code and can only be
3296
- // validated against a live engine, so it is intentionally left as the follow-up
3297
- // this seam unblocks. Constructing deps is side-effect-free (no socket opens, no
3298
- // activation) until the caller forks `supervisor.run`.
3286
+ // `makeSupervisor` + `Effect` handles from the bundle. NOTE (issue #172): the
3287
+ // hot-path flip this seam unblocks is now DONE — `workAgent` runs
3288
+ // `makeSupervisor(deps).run` as the single per-host owner, having retired the
3289
+ // per-type SDK pollers, the process-wide `singleFlight`, the per-process reconcile
3290
+ // crawl, and the per-job lock extender. Constructing deps stays side-effect-free
3291
+ // (no socket opens, no activation) until the caller forks `supervisor.run`.
3299
3292
  //
3300
3293
  // @param {object} opts
3301
3294
  // @param {{ run(job): Promise<void> }} opts.runner raw job runner (required to run)
@@ -5529,80 +5522,12 @@ function baseAgentEnv(profile, job) {
5529
5522
  };
5530
5523
  }
5531
5524
 
5532
- /**
5533
- * Process-wide single-flight guard (issue #142).
5534
- *
5535
- * `maxParallelJobs = 1` only caps concurrency WITHIN one job-type poller, but a
5536
- * single `work` process runs one poller per job type (rank×capability matrix, or
5537
- * every deployed agent type under `--auto`). Without a shared gate a worker
5538
- * serving N job types could lease and run up to N jobs at once — each holding its
5539
- * own PTY + git workspace + broker lock-extender — the exact failure the
5540
- * "one job per worker" invariant exists to prevent.
5541
- *
5542
- * This is a capacity-1, non-blocking mutex shared by EVERY per-type poller: the
5543
- * first poller to `tryAcquire()` runs its job to completion (releasing in a
5544
- * `finally`); any other poller that finds the permit already held must NOT begin
5545
- * a second job (the caller fails the lease fast so the broker re-queues it rather
5546
- * than leaving it "claimed but idle"). `tryAcquire`/`release` are synchronous
5547
- * check-and-set, so the single-threaded event loop makes them race-free across
5548
- * the concurrently-invoked async job handlers.
5549
- */
5550
- function createSingleFlight() {
5551
- let held = false;
5552
- return {
5553
- /** Take the permit if free; returns false when a job is already in flight. */
5554
- tryAcquire() {
5555
- if (held) return false;
5556
- held = true;
5557
- return true;
5558
- },
5559
- /** Release the permit. Idempotent: redundant calls are safe no-ops, though the normal path releases once per acquire (in a `finally`). */
5560
- release() {
5561
- held = false;
5562
- },
5563
- /** True while a job holds the permit. */
5564
- get busy() {
5565
- return held;
5566
- },
5567
- };
5568
- }
5569
-
5570
- /**
5571
- * Keep a leased job's broker activation lock ahead of *now* while the harness is
5572
- * running, so a long agent run never has its lock lapse and get re-activated (a
5573
- * second worker starting → the classic stale complete/fail 409). The lock is NOT
5574
- * hardcoded up front: we refresh it to `windowMs` — a duration-from-now, per the
5575
- * UpdateJobTimeout contract ("the duration of the new timeout in ms, starting
5576
- * from the current moment"), so calls SET rather than accumulate — every
5577
- * `intervalMs`. The deadline therefore stays a bounded `windowMs` ahead of now.
5578
- * The instant we stop refreshing (harness exit / idle-kill / hard cap) the lock
5579
- * lapses within `windowMs` and the broker reclaims the job — fast node-loss
5580
- * recovery. Because the harness is always killed locally before we stop, the lock
5581
- * strictly outlives our local run, so a reclaim never races a still-running agent.
5582
- *
5583
- * Returns a stop() to call once the run settles. Extension failures are logged
5584
- * and swallowed — a transient network blip must not crash the job handler. Older
5585
- * SDKs without `modifyJobTimeout` degrade to the fixed initial lock (a no-op stop).
5586
- */
5587
- function startLockExtender(job, windowMs, intervalMs, tag, logger) {
5588
- if (!(windowMs > 0) || !(intervalMs > 0)) {
5589
- return () => {};
5590
- }
5591
- if (typeof job?.modifyJobTimeout !== 'function') {
5592
- logger?.warn?.(`${tag}: job.modifyJobTimeout unavailable — activation lock will NOT be auto-extended; a run longer than ${windowMs}ms risks being reclaimed and executed twice`);
5593
- return () => {};
5594
- }
5595
- const extend = () => Promise.resolve()
5596
- .then(() => job.modifyJobTimeout({ newTimeoutMs: windowMs }))
5597
- .catch((err) => logger?.warn?.(`${tag}: lock extend failed — ${err?.message ?? err}`));
5598
- // Renew immediately so the harness starts with a full, fresh window no matter
5599
- // how much of the initial activation lease provisioning (clone/checkout) ate.
5600
- extend();
5601
- const timer = setInterval(extend, intervalMs);
5602
- // Never let the heartbeat keep the process alive on shutdown.
5603
- if (typeof timer.unref === 'function') timer.unref();
5604
- return () => clearInterval(timer);
5605
- }
5525
+ // Issue #172 retired `createSingleFlight` (the process-wide capacity-1 mutex the
5526
+ // per-type SDK pollers shared) and `startLockExtender` (the per-job broker-lock
5527
+ // heartbeat). Both are now owned by the single-owner supervisor runtime: race-free
5528
+ // per-type slot accounting lives in `supervisor/src/registry.ts` (this worker
5529
+ // registers with capacity 1), and the lock lifecycle extend-winner-before-start
5530
+ // plus a `Schedule`-driven heartbeat lives in `supervisor/src/dispatch.ts`.
5606
5531
 
5607
5532
  /**
5608
5533
  * Run a single activated job through the profile's CLI command (one-shot),
@@ -7032,26 +6957,21 @@ async function workAgent(req, flags) {
7032
6957
  };
7033
6958
  // One job per worker, hard-wired (there is deliberately no --max-parallel
7034
6959
  // flag): an agent harness holds a PTY + a git workspace for the whole life of
7035
- // a job, so a worker must never lease a second job concurrently. The @camunda8
7036
- // SDK derives maxJobsToActivate = maxParallelJobs - activeJobs, so 1 means
7037
- // "activate one job, then stop polling until it completes".
7038
- const maxParallelJobs = 1;
7039
- // Process-wide single-flight guard (issue #142). The SDK's maxParallelJobs=1
7040
- // only serializes ONE job-type poller, but this process runs one poller per
7041
- // job type, so nothing stops N pollers from each leasing + running a job
7042
- // concurrently. This capacity-1 mutex, shared by every poller's jobHandler,
7043
- // enforces the real "one job per worker" invariant: while any job is in flight
7044
- // on any job type, no other poller starts a second one.
7045
- const singleFlight = createSingleFlight();
6960
+ // a job, so a worker must never lease a second job concurrently. Issue #172:
6961
+ // this is now enforced by registering this worker with capacity 1 in the
6962
+ // single-owner runtime's shared registry (one worker, capacity 1, across all
6963
+ // job types) — retiring the process-wide capacity-1 single-flight the per-type
6964
+ // SDK pollers needed. Race-free slot accounting lives in the runtime registry.
7046
6965
  // The broker job-activation lock is NOT hardcoded up front. A fixed timeout is
7047
6966
  // impossible to size for an agent: too short reclaims a still-working job (a
7048
6967
  // second agent starts + the stale complete/fail is rejected 409), too long
7049
- // strands a dead worker's job. Instead the worker keeps the lock a bounded
7050
- // `recovery-window` ahead of *now* while the harness runs (see
7051
- // startLockExtender), so long runs never lose their lock, and a dead/killed
7052
- // worker's job is reclaimed within one window. Liveness is enforced by
7053
- // `idle-timeout` (max silence before the harness is killed as wedged), so the
7054
- // lock is held only while the agent is alive AND producing output.
6968
+ // strands a dead worker's job. Instead the runtime's dispatch lifecycle keeps
6969
+ // the lock a bounded `recovery-window` ahead of *now* while the harness runs
6970
+ // (extend-winner-before-start + a Schedule heartbeat in dispatch.ts), so long
6971
+ // runs never lose their lock, and a dead/killed worker's job is reclaimed
6972
+ // within one window. Liveness is enforced by `idle-timeout` (max silence before
6973
+ // the harness is killed as wedged), so the lock is held only while the agent is
6974
+ // alive AND producing output.
7055
6975
  const recoveryWindowMs = intFlag(flags?.['recovery-window'], 5 * 60_000);
7056
6976
  const idleTimeoutMs = intFlag(flags?.['idle-timeout'], 5 * 60_000);
7057
6977
  // `--job-timeout` is now an OPTIONAL absolute hard cap on total harness runtime
@@ -7250,7 +7170,7 @@ async function workAgent(req, flags) {
7250
7170
  }
7251
7171
  const extraNote = extraJobTypes.length > 0 ? ` (${extraJobTypes.length} via --job-type)` : '';
7252
7172
  logger.info(` listening on ${jobTypes.length} job type(s)${extraNote}: ${jobTypes.join(' ')}`);
7253
- logger.info(` one job per worker (single-flight across all ${jobTypes.length} job type(s)); recovery window: ${recoveryWindowMs}ms; idle timeout: ${idleTimeoutMs}ms; hard cap: ${hardCapMs > 0 ? `${hardCapMs}ms` : 'off'}; poll timeout: ${pollTimeoutMs}ms`);
7173
+ logger.info(` one job per worker (registry capacity 1 across all ${jobTypes.length} job type(s)); recovery window: ${recoveryWindowMs}ms; idle timeout: ${idleTimeoutMs}ms; hard cap: ${hardCapMs > 0 ? `${hardCapMs}ms` : 'off'}; poll timeout: ${pollTimeoutMs}ms`);
7254
7174
  // Warm the gh-token cache now, off the job-handling path: githubCloneToken()
7255
7175
  // may consult `gh auth token` (a synchronous spawn, up to 10s) as its default
7256
7176
  // credential fallback, and doing that inside a job handler would block the
@@ -7646,46 +7566,20 @@ async function workAgent(req, flags) {
7646
7566
  const envPermission = (process.env.NANO_AGENTIC_PERMISSION || '').trim().toLowerCase();
7647
7567
  const rolePermission = resolveAgenticSetting(envPermission, profile.permission, PERMISSION_MODES, 'yolo');
7648
7568
 
7649
- // A per-job-type worker factory. Captures all the CLI-local + profile context
7650
- // in closure scope so the profile watcher below can (re)spawn a poller for any
7651
- // job type on demand without re-reading the flags.
7652
- const makeWorker = (jobType) =>
7653
- camunda.createJobWorker({
7654
- jobType,
7655
- workerName: `${workerName}:${jobType}`,
7656
- maxParallelJobs,
7657
- jobTimeoutMs: recoveryWindowMs,
7658
- pollTimeoutMs,
7659
- jobHandler: async (job) => {
7660
- // Process-wide single-flight (issue #142): if another job is already
7661
- // running on ANY poller, do not start a second harness. Fail this lease
7662
- // FAST — before recording it active, extending its lock, or provisioning
7663
- // anything — so the broker re-queues it (retries preserved) instead of it
7664
- // sitting "claimed but idle" while the first job runs. Gating here, at the
7665
- // point activation surfaces as a handler call, is the cross-poller gate
7666
- // the per-type maxParallelJobs cannot provide.
7667
- if (!singleFlight.tryAcquire()) {
7668
- // Not a failure — preserve the broker-provided retries verbatim so
7669
- // re-dispatch doesn't decrement (or resurrect) the job. Keep a real 0
7670
- // as 0 (an already-incidentable job must stay that way); only default
7671
- // to 1 when the count is missing/invalid.
7672
- const rawRetries = Number(job.retries);
7673
- const retries = Number.isInteger(rawRetries) && rawRetries >= 0 ? rawRetries : 1;
7674
- logger.info(`[${jobType}] job ${job.jobKey} deferred — worker already running another job; releasing lease for re-dispatch.`);
7675
- return job.fail({
7676
- errorMessage: 'worker busy: one job per worker (single-flight across all job types)',
7677
- retries,
7678
- retryBackOff: WORKER_BUSY_RETRY_BACKOFF_MS,
7679
- });
7680
- }
7681
- recordJobStart(job, jobType);
7682
- // Auto-extend the broker lock for the whole life of this job (harness run
7683
- // + git finalize + complete/fail), stopped in the outer finally. The lock
7684
- // is held only while the harness stays alive and productive — a silent
7685
- // hang is killed by the idle-timeout, which resolves runAgentJob and stops
7686
- // the extension, so the broker can reclaim the job.
7687
- let stopLockExtender = () => {};
7688
- try {
7569
+ // The agent job runner (issue #172 hot-path flip). The single-owner supervisor
7570
+ // runtime dispatches each activated job to this `run(job)`; it executes the
7571
+ // harness exactly as the retired per-type SDK jobHandler did, but SETTLES via the
7572
+ // injected `settle` seam (engine complete/fail) because the plain ActivatedJob
7573
+ // carries no SDK `job.complete()/job.fail()`. Capacity (one job per worker), the
7574
+ // activation long-polls, the lock lifecycle, and reconcile are all owned by the
7575
+ // runtime now — retiring the per-type pollers, the process-wide single-flight,
7576
+ // the per-process 1+N reconcile crawl, and the per-job lock extender.
7577
+ let settle;
7578
+ const runner = {
7579
+ run: async (job) => {
7580
+ const jobType = job.type;
7581
+ recordJobStart(job, jobType);
7582
+ try {
7689
7583
  logger.info(`[${jobType}] job ${job.jobKey} (instance ${job.processInstanceKey ?? '-'}) → ${buildAgentCommandLine(profile.command, effectiveArgs)}`);
7690
7584
 
7691
7585
  // Disk-budget admission shed: if the engine data root is below the free
@@ -7697,7 +7591,7 @@ async function workAgent(req, flags) {
7697
7591
  const freeMb = budget.free != null ? Math.round(budget.free / 1_048_576) : '?';
7698
7592
  const retries = Math.max(0, (Number(job.retries) || 1) - 1);
7699
7593
  logger.warn(`[${jobType}] job ${job.jobKey} shed — low disk (${freeMb}MB free); retries left ${retries}`);
7700
- return job.fail({ errorMessage: `disk budget exceeded (only ${freeMb}MB free)`, retries, retryBackOff: 30_000 });
7594
+ return settle.fail(job.jobKey, { errorMessage: `disk budget exceeded (only ${freeMb}MB free)`, retries, retryBackOff: 30_000 });
7701
7595
  }
7702
7596
  }
7703
7597
 
@@ -7729,7 +7623,7 @@ async function workAgent(req, flags) {
7729
7623
  const retries = Math.max(0, (Number(job.retries) || 1) - 1);
7730
7624
  const msg = err instanceof ProvisionError ? err.message : `prompt resource fetch failed: ${err.message}`;
7731
7625
  logger.warn(`[${jobType}] job ${job.jobKey} not provisioned — ${msg}; retries left ${retries}`);
7732
- return job.fail({ errorMessage: msg.slice(0, 2000), retries, retryBackOff: 15_000 });
7626
+ return settle.fail(job.jobKey, { errorMessage: msg.slice(0, 2000), retries, retryBackOff: 15_000 });
7733
7627
  }
7734
7628
 
7735
7629
  // Assemble + normalize the task envelope from headers (defaults) and
@@ -7740,27 +7634,24 @@ async function workAgent(req, flags) {
7740
7634
  const retries = Math.max(0, (Number(job.retries) || 1) - 1);
7741
7635
  const msg = `missing secret(s): ${missing.join(', ')} (resolver: ${secretResolver.kind})`;
7742
7636
  logger.warn(`[${jobType}] job ${job.jobKey} not provisioned — ${msg}; retries left ${retries}`);
7743
- return job.fail({ errorMessage: msg, retries });
7637
+ return settle.fail(job.jobKey, { errorMessage: msg, retries });
7744
7638
  }
7745
7639
 
7746
7640
  // #130: per-task liveness overrides. Precedence: envelope override →
7747
7641
  // worker-flag default → built-in default, each clamped to a sane max so
7748
7642
  // a task can't request an unbounded window. Absent envelope fields leave
7749
- // the worker-flag behaviour unchanged. These drive BOTH the harness idle
7750
- // liveness (idle/recovery) AND the broker lock recovery window, so a
7751
- // JVM-heavy task can widen its own window without a global flag change.
7643
+ // the worker-flag behaviour unchanged. These drive the harness idle
7644
+ // liveness (idle/recovery/hard-cap) so a JVM-heavy task can widen its own
7645
+ // window without a global flag change. NOTE: after the hot-path flip
7646
+ // (#172) the broker lock cadence/window is owned by the supervisor
7647
+ // runtime dispatch config at the worker level (the `dispatch` config's
7648
+ // `recoveryWindowMs` / `extendIntervalMs`), so a per-task override no
7649
+ // longer widens the broker lock window — only the harness liveness.
7752
7650
  const {
7753
7651
  idleTimeoutMs: effectiveIdleTimeoutMs,
7754
7652
  recoveryWindowMs: effectiveRecoveryWindowMs,
7755
7653
  hardCapMs: effectiveHardCapMs,
7756
7654
  } = resolveLivenessOverrides(envelope.task, { idleTimeoutMs, recoveryWindowMs, hardCapMs });
7757
- // Recompute the lock-extend cadence from the effective recovery window
7758
- // (same ~1/3-of-window rule as at startup) so a widened window still
7759
- // renews comfortably before it lapses.
7760
- const effectiveLockExtendIntervalMs = Math.min(
7761
- Math.max(5_000, Math.floor(effectiveRecoveryWindowMs / 3)),
7762
- Math.max(1, Math.floor(effectiveRecoveryWindowMs * 0.75)),
7763
- );
7764
7655
  if (
7765
7656
  effectiveIdleTimeoutMs !== idleTimeoutMs ||
7766
7657
  effectiveRecoveryWindowMs !== recoveryWindowMs ||
@@ -7790,7 +7681,7 @@ async function workAgent(req, flags) {
7790
7681
  : 'repository.url is missing';
7791
7682
  const msg = `incomplete repository envelope — ${why}; refusing to run in the launch/temp cwd (likely an orchestrator bug emitting a half-specified repository block)`;
7792
7683
  logger.warn(`[${jobType}] job ${job.jobKey} not provisioned — ${msg}; retries left ${retries}`);
7793
- return job.fail({ errorMessage: msg.slice(0, 2000), retries, retryBackOff: 15_000 });
7684
+ return settle.fail(job.jobKey, { errorMessage: msg.slice(0, 2000), retries, retryBackOff: 15_000 });
7794
7685
  }
7795
7686
  }
7796
7687
 
@@ -7800,12 +7691,10 @@ async function workAgent(req, flags) {
7800
7691
  const hasRepo = !isContainer && !!envelope.repository?.url;
7801
7692
  let runDir = null;
7802
7693
  let provisioned = null;
7803
- // Start refreshing the broker activation lock BEFORE any potentially-long
7804
- // work (host git clone/checkout can outlast the initial window). Starting
7805
- // here ahead of provisionRepo guarantees the first renewal is queued
7806
- // before the clone, so the lock can't lapse mid-provision and trigger the
7807
- // duplicate-activation / stale-409 race. The `finally` below stops it.
7808
- stopLockExtender = startLockExtender(job, effectiveRecoveryWindowMs, effectiveLockExtendIntervalMs, `[${jobType}] job ${job.jobKey}`, logger);
7694
+ // The broker activation lock is owned by the single-owner runtime's dispatch
7695
+ // lifecycle (supervisor/src/dispatch.ts): it extends the winner to the
7696
+ // recovery window BEFORE this runner starts and heartbeats it on a Schedule
7697
+ // for the whole run, so the retired per-job startLockExtender is gone here.
7809
7698
  let cwd;
7810
7699
  let extraEnv;
7811
7700
  let repoToken = null;
@@ -7847,7 +7736,7 @@ async function workAgent(req, flags) {
7847
7736
  const retries = Math.max(0, (Number(job.retries) || 1) - 1);
7848
7737
  const msg = err instanceof ProvisionError ? err.message : `provisioning error: ${err.message}`;
7849
7738
  logger.warn(`[${jobType}] job ${job.jobKey} not provisioned — ${msg}; retries left ${retries}`);
7850
- return job.fail({ errorMessage: msg.slice(0, 2000), retries, retryBackOff: 15_000 });
7739
+ return settle.fail(job.jobKey, { errorMessage: msg.slice(0, 2000), retries, retryBackOff: 15_000 });
7851
7740
  }
7852
7741
  } else if (!isContainer) {
7853
7742
  // Repo-less host job (issue #129, hardening 1): nothing is provisioned,
@@ -7873,7 +7762,7 @@ async function workAgent(req, flags) {
7873
7762
  const retries = Math.max(0, (Number(job.retries) || 1) - 1);
7874
7763
  const msg = `could not create a temp workspace under the runs root: ${err.message}`;
7875
7764
  logger.warn(`[${jobType}] job ${job.jobKey} not provisioned — ${msg}; retries left ${retries}`);
7876
- return job.fail({ errorMessage: msg.slice(0, 2000), retries, retryBackOff: 15_000 });
7765
+ return settle.fail(job.jobKey, { errorMessage: msg.slice(0, 2000), retries, retryBackOff: 15_000 });
7877
7766
  }
7878
7767
  }
7879
7768
 
@@ -8029,7 +7918,7 @@ async function workAgent(req, flags) {
8029
7918
  const resultKeys = Object.keys(resultVars);
8030
7919
  if (resultKeys.length === 0) logger.warn(`[${jobType}] job ${job.jobKey}: agent returned no usable result vars — write a JSON object of result variables to $AGENT_RESULT_FILE (or print a "${RESULT_SENTINEL} {…}" line) so downstream gateways see status/summary/etc.`);
8031
7920
  else logger.info(`[${jobType}] job ${job.jobKey}: merged agent result vars [${resultKeys.join(', ')}]`);
8032
- return await job.complete({
7921
+ return await settle.complete(job.jobKey, {
8033
7922
  ...resultVars,
8034
7923
  [AGENT_RESULT_KEY]: resultEnvelope,
8035
7924
  output: result.stdout,
@@ -8046,249 +7935,136 @@ async function workAgent(req, flags) {
8046
7935
  || (result.stderr || '').trim() + (result.stderrTruncated && (result.stderr || '').trim() ? ' [stderr truncated]' : '')
8047
7936
  || (result.signal ? `terminated by signal ${result.signal}` : `exit code ${result.exitCode}`);
8048
7937
  logger.warn(`[${jobType}] job ${job.jobKey} failed (${detail}); retries left ${retries}`);
8049
- return await job.fail({
7938
+ return await settle.fail(job.jobKey, {
8050
7939
  errorMessage: `agent "${profile.name}" failed: ${detail}`.slice(0, 2000),
8051
7940
  retries,
8052
7941
  variables: { [AGENT_RESULT_KEY]: resultEnvelope },
8053
7942
  });
8054
- } finally {
8055
- stopLockExtender();
8056
- recordJobEnd(job);
8057
- // Release the process-wide single-flight permit LAST, once this job's
8058
- // lock-extender is stopped and its bookkeeping cleared, so another
8059
- // poller can only begin after this job is fully settled.
8060
- singleFlight.release();
8061
- }
8062
- },
8063
- });
8064
-
8065
- // Live worker registry keyed by job type, so the profile watcher can add or
8066
- // drain individual pollers without disturbing the others. `draining` is the
8067
- // shutdown latch (shared with the watcher so a reconcile can't race a stop).
8068
- const workers = new Map();
8069
- let draining = false;
8070
-
8071
- const drainWorker = async (w) => {
8072
- try {
8073
- if (typeof w.stopGracefully === 'function') {
8074
- await w.stopGracefully({ waitUpToMs: STOP_GRACE_MS });
8075
- } else if (typeof w.stop === 'function') {
8076
- await w.stop();
7943
+ } finally {
7944
+ recordJobEnd(job);
8077
7945
  }
8078
- return true;
8079
- } catch {
8080
- return false; // best-effort: never let one worker's stop failure hang us
8081
- }
8082
- };
8083
-
8084
- const spawnJobType = (jobType) => {
8085
- if (workers.has(jobType)) return false;
8086
- workers.set(jobType, makeWorker(jobType));
8087
- return true;
7946
+ },
8088
7947
  };
8089
7948
 
8090
- for (const jobType of jobTypes) spawnJobType(jobType);
8091
-
8092
- // ---- Live reconcile: keep the poller set in step with the desired job-type
8093
- // set start pollers for added types, gracefully drain pollers for removed
8094
- // types without a restart and without disturbing unchanged types' in-flight
8095
- // work. The DESIRED set comes from one of two sources depending on mode:
8096
- // - default: the watched profile's rank×capability matrix (∪ --job-type),
8097
- // reconciled when the on-disk profile changes (e.g. `nano assign`);
8098
- // - --auto: the engine's deployed *agent* job types, reconciled by polling
8099
- // the engine (the deployed set changes as apps deploy/undeploy). ----
7949
+ // Compose the runtime deps over the plugin's real edges (issue #156 seam) and
7950
+ // register THIS worker (capacity 1) into the shared registry. In --auto the
7951
+ // runtime's reconcile loop rewrites this worker's serviceable types from the
7952
+ // engine read (`autoWorkerId`); otherwise the profile watch below does.
7953
+ const composed = await createSupervisorDeps({
7954
+ runner,
7955
+ camunda,
7956
+ restConfig,
7957
+ worker: workerName,
7958
+ workers: [{ id: workerName, types: jobTypes, capacity: 1 }],
7959
+ autoWorkerId: autoMode ? workerName : undefined,
7960
+ scope: autoScope,
7961
+ config: {
7962
+ activation: { requestTimeoutMs: pollTimeoutMs },
7963
+ dispatch: { recoveryWindowMs, extendIntervalMs: lockExtendIntervalMs },
7964
+ },
7965
+ });
7966
+ settle = composed.settle;
7967
+ const {
7968
+ deps: supervisorDeps,
7969
+ registry: workerRegistry,
7970
+ makeSupervisor: makeSupervisorRuntime,
7971
+ Effect: SupervisorEffect,
7972
+ Fiber: SupervisorFiber,
7973
+ } = composed;
7974
+
7975
+ // Run the single per-host owner. `Effect.runFork` keeps the process alive on the
7976
+ // runtime's own Schedule cadences (reconcile + activation/idle loop), so even an
7977
+ // --auto worker that starts with zero types stays up and fills them in on the
7978
+ // next reconcile — the same liveness the retired ref'd auto-poll timer provided.
7979
+ const supervisor = await SupervisorEffect.runPromise(makeSupervisorRuntime(supervisorDeps));
7980
+ const supervisorFiber = SupervisorEffect.runFork(supervisor.run);
7981
+
7982
+ // Non-auto live retype: the runtime's reconcile loop only rewrites the --auto
7983
+ // worker's types (from the engine read), so keep watching the profile file to
7984
+ // honour `nano assign` — a profile edit rewrites THIS worker's serviceable types
7985
+ // in the shared registry without a restart (a single `registry.setTypes` call,
7986
+ // the direct analogue of the retired per-process reconcile). In --auto the
7987
+ // runtime owns the desired set, so no profile watch is installed.
8100
7988
  const configFile = getConfigFile();
8101
7989
  const WATCH_INTERVAL_MS = 1500;
8102
- // How often `--auto` re-reads the engine's deployed agent job types to pick up
8103
- // newly deployed / undeployed agent processes. Deploys are occasional, so a
8104
- // few seconds of latency is fine; the read is a couple of cheap C8 REST calls.
8105
- const AUTO_POLL_INTERVAL_MS = 5000;
8106
- let reconciling = false;
8107
- // Set when a profile change arrives while a reconcile is already in flight, so
8108
- // we run one more pass after the current drain completes instead of dropping
8109
- // the update until the next change fires.
8110
- let reconcileRequested = false;
8111
- // Handle to the in-flight reconcile so shutdown can wait for it to finish
8112
- // before snapshotting `workers` (avoids double-stops / missed drains).
8113
- let inFlightReconcile = null;
8114
-
8115
- // Desired job types. In `--auto` this is the engine's deployed agent job types
8116
- // (∪ --job-type extras), read fresh each pass; a transient engine-read failure
8117
- // returns { skip } so the running set is KEPT, never torn down. Otherwise it is
8118
- // the CURRENT on-disk profile's matrix (∪ extras), with { skip } for a
8119
- // transient/torn read, a vanished profile, or an invalid edit — callers must
8120
- // then KEEP the running set, never tear down.
8121
- const desiredJobTypes = async () => {
8122
- if (autoMode) {
8123
- try {
8124
- const autoTypes = await resolveAutoJobTypes({ restConfig, scope: autoScope });
8125
- return { jobTypes: [...new Set([...autoTypes, ...extraJobTypes])] };
8126
- } catch (err) {
8127
- return { skip: `engine read failed: ${err?.message || err}` };
8128
- }
8129
- }
8130
- let stored;
8131
- try {
8132
- stored = readHiresStrict()[name];
8133
- } catch {
8134
- // config.json exists but doesn't parse (e.g. a torn write): the profile is
8135
- // NOT necessarily gone, so don't claim it was deleted — skip this pass.
8136
- return { skip: 'config unreadable' };
8137
- }
8138
- if (!stored) return { skip: 'deleted' };
8139
- const norm = normalizeStoredProfile(name, stored);
8140
- if (norm.error) return { skip: norm.error };
8141
- const m = jobTypeMatrix(norm.profile.rank, norm.profile.capabilities);
8142
- return { jobTypes: [...new Set([...m, ...extraJobTypes])] };
8143
- };
8144
-
8145
- const reconcile = () => {
8146
- if (draining) return inFlightReconcile || Promise.resolve();
8147
- if (reconciling) {
8148
- // A change landed mid-reconcile — remember it so the current pass loops
8149
- // once more rather than leaving the worker set stale until the next edit.
8150
- // Return the ACTUAL in-flight promise (not a fresh short-lived one) so a
8151
- // caller — including shutdown — waits for the real reconcile to finish.
8152
- reconcileRequested = true;
8153
- return inFlightReconcile || Promise.resolve();
8154
- }
8155
- reconciling = true;
8156
- reconcileRequested = false;
8157
- inFlightReconcile = (async () => {
8158
- try {
8159
- do {
8160
- reconcileRequested = false;
8161
- await runReconcilePass();
8162
- } while (reconcileRequested && !draining);
8163
- } finally {
8164
- reconciling = false;
8165
- inFlightReconcile = null;
8166
- }
8167
- })();
8168
- return inFlightReconcile;
8169
- };
8170
-
8171
- const runReconcilePass = async () => {
8172
- const desired = await desiredJobTypes();
8173
- if (desired.skip) {
8174
- if (autoMode) {
8175
- logger.warn(`--auto reconcile skipped — ${desired.skip}; keeping the current ${workers.size} worker(s) running.`);
8176
- } else if (desired.skip === 'deleted') {
8177
- logger.warn(`Profile "${name}" is gone from config — keeping the current ${workers.size} worker(s) running.`);
8178
- } else {
8179
- logger.warn(`Profile "${name}" reload skipped — ${desired.skip}; keeping current workers.`);
8180
- }
8181
- return;
8182
- }
8183
- const { added, removed } = diffJobTypes([...workers.keys()], desired.jobTypes);
8184
- if (added.length === 0 && removed.length === 0) return;
8185
- const source = autoMode ? 'engine deployed set' : `Profile "${name}"`;
8186
- logger.info(`${source} changed — reconciling job types (+${added.length} / -${removed.length}).`);
8187
- for (const jt of added) {
8188
- spawnJobType(jt);
8189
- logger.info(` + now listening on ${jt}`);
8190
- }
8191
- await Promise.all(
8192
- removed.map(async (jt) => {
8193
- const w = workers.get(jt);
8194
- logger.info(` - draining ${jt} …`);
8195
- const ok = await drainWorker(w);
8196
- if (ok) {
8197
- // Only drop it from the registry once it has actually stopped, so a
8198
- // failed drain stays tracked and gets retried on the next reconcile
8199
- // pass (or on shutdown) instead of leaking an untracked poller.
8200
- workers.delete(jt);
8201
- logger.info(` - stopped ${jt}`);
8202
- } else {
8203
- logger.warn(` - ${jt} did not stop cleanly; keeping it tracked so it is retried on the next reconcile or shutdown.`);
8204
- }
8205
- }),
8206
- );
8207
- logger.info(` now listening on ${workers.size} job type(s): ${[...workers.keys()].join(' ')}`);
8208
- };
8209
-
8210
- // Reconcile trigger. In `--auto` a periodic engine poll re-reads the deployed
8211
- // agent job types; otherwise a profile-file watch fires on profile edits.
8212
- let autoPollTimer = null;
8213
- if (autoMode) {
8214
- // Self-standing interval poll (not watchFile) since the desired set is
8215
- // derived from the engine, not the on-disk profile. Skip a tick while a
8216
- // reconcile is already in flight: calling reconcile() then would set
8217
- // reconcileRequested and make the in-flight pass loop back-to-back, so an
8218
- // engine read that consistently outlasts AUTO_POLL_INTERVAL_MS would run
8219
- // reconciles as fast as the read completes and hammer the broker. Skipping
8220
- // keeps polling rate-limited to the configured interval regardless of
8221
- // engine-read latency; the next tick re-reads the latest engine state.
8222
- autoPollTimer = setInterval(() => {
8223
- if (inFlightReconcile) return;
8224
- reconcile().catch((err) => logger.warn(`--auto reconcile failed: ${err?.message || err}`));
8225
- }, AUTO_POLL_INTERVAL_MS);
8226
- // Deliberately REF'd (unlike the reaper/run-dir hygiene timers, which are
8227
- // unref'd): in `--auto` this poll IS the retry loop, and it must keep the
8228
- // process alive even with zero pollers. When the INITIAL engine read fails
8229
- // (transient miss, or the engine isn't up yet) the worker registers 0
8230
- // pollers; nothing else holds the event loop open (the SDK client with no
8231
- // job workers doesn't, and the hygiene timers are unref'd), so an unref'd
8232
- // poll timer would let the process exit 0 — the observed crash-loop under a
8233
- // supervisor (jwulf/c8ctl-plugin-nano#93). Keeping it ref'd makes the worker
8234
- // stay up and re-read on the next poll, exactly as the initial-read warning
8235
- // promises. Shutdown clears it (clearInterval), so Ctrl-C/SIGTERM still exit.
8236
- } else {
8237
- // `watchFile` (polling stat) is deliberate over `fs.watch`: it survives the
8238
- // atomic temp+rename that `writeConfig` does (fs.watch would rebind to the old
8239
- // inode and go silent), and it's uniform across platforms. Profile edits are
8240
- // rare + manual, so a ~1.5s poll latency is fine.
7990
+ let draining = false;
7991
+ if (!autoMode) {
7992
+ // Serialize reloads on a chain (never overlap a `setTypes` write) and coalesce
7993
+ // with a monotonic generation guard, so a slow older reload can never apply a
7994
+ // stale job-type set after a newer edit has already superseded it.
7995
+ let reloadSeq = 0;
7996
+ let reloadChain = Promise.resolve();
8241
7997
  watchFile(configFile, { interval: WATCH_INTERVAL_MS }, (curr, prev) => {
7998
+ // A callback can already be queued when teardown flips `draining`; bail so we
7999
+ // never write to the shared registry (or race its teardown) during shutdown.
8000
+ if (draining) return;
8242
8001
  // Fires each interval; act only on real changes. Compare mtime, ctime and
8243
- // size, not mtime alone: on filesystems with coarse mtime resolution (or two
8244
- // edits within one mtime tick) mtimeMs can be unchanged while size/ctimeMs
8245
- // differ, and an mtime-only guard would skip a genuine profile update.
8002
+ // size, not mtime alone: coarse-mtime filesystems (or two edits in one tick)
8003
+ // can leave mtimeMs unchanged while size/ctimeMs differ.
8246
8004
  if (
8247
8005
  curr.mtimeMs === prev.mtimeMs &&
8248
8006
  curr.ctimeMs === prev.ctimeMs &&
8249
8007
  curr.size === prev.size
8250
8008
  ) return;
8251
- // `reconcile()` owns the `inFlightReconcile` handle: a change arriving while
8252
- // a reconcile is already running coalesces into the current pass and returns
8253
- // that same in-flight promise, so shutdown always waits for the real one.
8254
- reconcile().catch((err) => logger.warn(`profile reload failed: ${err?.message || err}`));
8009
+ const mySeq = ++reloadSeq;
8010
+ reloadChain = reloadChain.then(async () => {
8011
+ if (draining) return;
8012
+ // A newer edit already landed while we were queued — skip this stale
8013
+ // reload so its (older) job-type set never lands after the newer one.
8014
+ if (mySeq !== reloadSeq) return;
8015
+ let stored;
8016
+ try {
8017
+ stored = readHiresStrict()[name];
8018
+ } catch (err) {
8019
+ // config exists but doesn't parse (torn write) — keep current types
8020
+ logger.warn(`Profile "${name}" reload skipped — ${configFile} unreadable/unparseable (keeping current job types): ${err?.message || err}`);
8021
+ return;
8022
+ }
8023
+ if (!stored) {
8024
+ // profile vanished — keep serving the current types
8025
+ logger.warn(`Profile "${name}" reload skipped — profile no longer present in ${configFile} (keeping current job types)`);
8026
+ return;
8027
+ }
8028
+ const norm = normalizeStoredProfile(name, stored);
8029
+ if (norm.error) {
8030
+ // invalid edit — keep current types
8031
+ logger.warn(`Profile "${name}" reload skipped — invalid profile edit (keeping current job types): ${norm.error}`);
8032
+ return;
8033
+ }
8034
+ const m = jobTypeMatrix(norm.profile.rank, norm.profile.capabilities);
8035
+ const desired = [...new Set([...m, ...extraJobTypes])];
8036
+ if (draining) return; // teardown began while we were reading — don't write
8037
+ if (mySeq !== reloadSeq) return; // superseded during the async read — skip
8038
+ await SupervisorEffect.runPromise(workerRegistry.setTypes(workerName, desired));
8039
+ logger.info(`Profile "${name}" changed — now servicing ${desired.length} job type(s): ${desired.join(' ')}`);
8040
+ }).catch((err) => logger.warn(`profile reload failed: ${err?.message || err}`));
8255
8041
  });
8256
8042
  }
8257
8043
 
8258
- // Keep the process alive until a stop signal, then drain gracefully.
8044
+ // Keep the process alive until a stop signal, then interrupt the runtime loop
8045
+ // and tear down visibility. Interrupting the supervisor fiber runs the runtime's
8046
+ // bracketed teardown (release slots, stop the heartbeat + agentic scope).
8259
8047
  await new Promise((resolve) => {
8260
8048
  const stop = async (signal) => {
8261
8049
  if (draining) return;
8262
8050
  draining = true;
8263
- // Stop the reconcile trigger first so no new reconcile can be triggered,
8264
- // then wait for any in-flight reconcile to finish before snapshotting
8265
- // `workers` — this prevents double-stops, missed drains, or a wrong worker
8266
- // count on exit.
8267
- if (autoPollTimer) clearInterval(autoPollTimer);
8268
- else unwatchFile(configFile);
8269
- if (inFlightReconcile) {
8270
- logger.info('Waiting for in-flight reconcile to finish before shutdown…');
8271
- await inFlightReconcile;
8272
- }
8273
- const list = [...workers.values()];
8274
- logger.info(`Received ${signal} — stopping ${list.length} worker(s)...`);
8051
+ if (!autoMode) unwatchFile(configFile);
8052
+ logger.info(`Received ${signal} stopping worker...`);
8275
8053
  if (reaperTimer) clearInterval(reaperTimer);
8276
8054
  if (runDirTimer) clearInterval(runDirTimer);
8277
8055
  // Stop the #144 liveness watchdog so it can't kick off a re-discovery
8278
8056
  // mid-teardown (which would resurrect the channel we're about to close).
8279
8057
  if (agenticWatchdog) { try { agenticWatchdog.stop(); } catch { /* best effort */ } agenticWatchdog = null; }
8280
- const results = await Promise.all(list.map(drainWorker));
8281
- const stopFailures = results.filter((ok) => !ok).length;
8282
- if (stopFailures > 0) {
8283
- logger.warn(`${stopFailures} of ${list.length} worker(s) did not stop cleanly; some connections may still be open.`);
8284
- } else {
8285
- logger.info('All workers stopped.');
8058
+ try {
8059
+ await SupervisorEffect.runPromise(SupervisorFiber.interrupt(supervisorFiber));
8060
+ logger.info('Worker stopped.');
8061
+ } catch (err) {
8062
+ logger.warn(`supervisor shutdown error — runtime loop may not have shut down cleanly: ${err?.message || err}`);
8286
8063
  }
8287
- // Deregister from the visibility channel LAST, so the worker disappears
8288
- // from the page only once its jobs have drained. Best-effort — a channel
8289
- // teardown must never hang shutdown.
8064
+ // Deregister from the visibility channel LAST, so the worker disappears from
8065
+ // the page only once its jobs have drained. Best-effort — a channel teardown
8066
+ // must never hang shutdown.
8290
8067
  if (workChannel) {
8291
- // Stop the buffer monitor first so its sampler can't fire mid-teardown.
8292
8068
  try {
8293
8069
  bufferMonitor?.stop();
8294
8070
  } catch { /* best effort */ }
@@ -12708,8 +12484,6 @@ export {
12708
12484
  resolveLivenessOverrides,
12709
12485
  parsePsTime,
12710
12486
  ensureAcpFlag,
12711
- startLockExtender,
12712
- createSingleFlight,
12713
12487
  provisionRepo,
12714
12488
  finalizeGit,
12715
12489
  describeGitFailure,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.51.0",
3
+ "version": "1.52.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",
@@ -55,6 +55,7 @@
55
55
  "devDependencies": {
56
56
  "@commitlint/cli": "^20.4.1",
57
57
  "@commitlint/config-conventional": "^20.4.1",
58
+ "@nanobpm/engine-wasm": "^0.8.6",
58
59
  "@semantic-release/exec": "^7.1.0",
59
60
  "@semantic-release/github": "^12.0.6",
60
61
  "@types/node": "^22.20.1",
@@ -69,12 +70,12 @@
69
70
  },
70
71
  "optionalDependencies": {
71
72
  "node-pty": "^1.0.0",
72
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.51.0",
73
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.51.0",
74
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.51.0",
75
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.51.0",
76
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.51.0",
77
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.51.0",
78
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.51.0"
73
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.52.0",
74
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.52.0",
75
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.52.0",
76
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.52.0",
77
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.52.0",
78
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.52.0",
79
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.52.0"
79
80
  }
80
81
  }