c8ctl-plugin-nano 1.44.7 → 1.44.8

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 +77 -1
  2. package/package.json +8 -8
package/c8ctl-plugin.js CHANGED
@@ -135,6 +135,11 @@ const READINESS_TIMEOUT_MS = 60_000;
135
135
  const READINESS_POLL_MS = 500;
136
136
  const HEALTH_TIMEOUT_MS = 1_500;
137
137
  const STOP_GRACE_MS = 8_000;
138
+ // Backoff applied when a poller fails a lease fast because the worker is already
139
+ // running another job (issue #142 single-flight). Long enough that the deferred
140
+ // job doesn't tight-loop re-activating while the first runs, short enough that it
141
+ // is picked up promptly once the worker frees up.
142
+ const WORKER_BUSY_RETRY_BACKOFF_MS = 5_000;
138
143
  // Upper bound on one `--auto` engine-read reconcile (enumerate deployed
139
144
  // definitions + fetch each BPMN). A read that stalls past this is treated as a
140
145
  // transient failure so the running poller set is KEPT and, crucially, shutdown
@@ -5164,6 +5169,44 @@ function baseAgentEnv(profile, job) {
5164
5169
  };
5165
5170
  }
5166
5171
 
5172
+ /**
5173
+ * Process-wide single-flight guard (issue #142).
5174
+ *
5175
+ * `maxParallelJobs = 1` only caps concurrency WITHIN one job-type poller, but a
5176
+ * single `work` process runs one poller per job type (rank×capability matrix, or
5177
+ * every deployed agent type under `--auto`). Without a shared gate a worker
5178
+ * serving N job types could lease and run up to N jobs at once — each holding its
5179
+ * own PTY + git workspace + broker lock-extender — the exact failure the
5180
+ * "one job per worker" invariant exists to prevent.
5181
+ *
5182
+ * This is a capacity-1, non-blocking mutex shared by EVERY per-type poller: the
5183
+ * first poller to `tryAcquire()` runs its job to completion (releasing in a
5184
+ * `finally`); any other poller that finds the permit already held must NOT begin
5185
+ * a second job (the caller fails the lease fast so the broker re-queues it rather
5186
+ * than leaving it "claimed but idle"). `tryAcquire`/`release` are synchronous
5187
+ * check-and-set, so the single-threaded event loop makes them race-free across
5188
+ * the concurrently-invoked async job handlers.
5189
+ */
5190
+ function createSingleFlight() {
5191
+ let held = false;
5192
+ return {
5193
+ /** Take the permit if free; returns false when a job is already in flight. */
5194
+ tryAcquire() {
5195
+ if (held) return false;
5196
+ held = true;
5197
+ return true;
5198
+ },
5199
+ /** Release the permit. Idempotent: redundant calls are safe no-ops, though the normal path releases once per acquire (in a `finally`). */
5200
+ release() {
5201
+ held = false;
5202
+ },
5203
+ /** True while a job holds the permit. */
5204
+ get busy() {
5205
+ return held;
5206
+ },
5207
+ };
5208
+ }
5209
+
5167
5210
  /**
5168
5211
  * Keep a leased job's broker activation lock ahead of *now* while the harness is
5169
5212
  * running, so a long agent run never has its lock lapse and get re-activated (a
@@ -6266,6 +6309,13 @@ async function workAgent(req, flags) {
6266
6309
  // SDK derives maxJobsToActivate = maxParallelJobs - activeJobs, so 1 means
6267
6310
  // "activate one job, then stop polling until it completes".
6268
6311
  const maxParallelJobs = 1;
6312
+ // Process-wide single-flight guard (issue #142). The SDK's maxParallelJobs=1
6313
+ // only serializes ONE job-type poller, but this process runs one poller per
6314
+ // job type, so nothing stops N pollers from each leasing + running a job
6315
+ // concurrently. This capacity-1 mutex, shared by every poller's jobHandler,
6316
+ // enforces the real "one job per worker" invariant: while any job is in flight
6317
+ // on any job type, no other poller starts a second one.
6318
+ const singleFlight = createSingleFlight();
6269
6319
  // The broker job-activation lock is NOT hardcoded up front. A fixed timeout is
6270
6320
  // impossible to size for an agent: too short reclaims a still-working job (a
6271
6321
  // second agent starts + the stale complete/fail is rejected 409), too long
@@ -6465,7 +6515,7 @@ async function workAgent(req, flags) {
6465
6515
  }
6466
6516
  const extraNote = extraJobTypes.length > 0 ? ` (${extraJobTypes.length} via --job-type)` : '';
6467
6517
  logger.info(` listening on ${jobTypes.length} job type(s)${extraNote}: ${jobTypes.join(' ')}`);
6468
- logger.info(` one job per worker; recovery window: ${recoveryWindowMs}ms; idle timeout: ${idleTimeoutMs}ms; hard cap: ${hardCapMs > 0 ? `${hardCapMs}ms` : 'off'}; poll timeout: ${pollTimeoutMs}ms`);
6518
+ 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`);
6469
6519
  // Warm the gh-token cache now, off the job-handling path: githubCloneToken()
6470
6520
  // may consult `gh auth token` (a synchronous spawn, up to 10s) as its default
6471
6521
  // credential fallback, and doing that inside a job handler would block the
@@ -6753,6 +6803,27 @@ async function workAgent(req, flags) {
6753
6803
  jobTimeoutMs: recoveryWindowMs,
6754
6804
  pollTimeoutMs,
6755
6805
  jobHandler: async (job) => {
6806
+ // Process-wide single-flight (issue #142): if another job is already
6807
+ // running on ANY poller, do not start a second harness. Fail this lease
6808
+ // FAST — before recording it active, extending its lock, or provisioning
6809
+ // anything — so the broker re-queues it (retries preserved) instead of it
6810
+ // sitting "claimed but idle" while the first job runs. Gating here, at the
6811
+ // point activation surfaces as a handler call, is the cross-poller gate
6812
+ // the per-type maxParallelJobs cannot provide.
6813
+ if (!singleFlight.tryAcquire()) {
6814
+ // Not a failure — preserve the broker-provided retries verbatim so
6815
+ // re-dispatch doesn't decrement (or resurrect) the job. Keep a real 0
6816
+ // as 0 (an already-incidentable job must stay that way); only default
6817
+ // to 1 when the count is missing/invalid.
6818
+ const rawRetries = Number(job.retries);
6819
+ const retries = Number.isInteger(rawRetries) && rawRetries >= 0 ? rawRetries : 1;
6820
+ logger.info(`[${jobType}] job ${job.jobKey} deferred — worker already running another job; releasing lease for re-dispatch.`);
6821
+ return job.fail({
6822
+ errorMessage: 'worker busy: one job per worker (single-flight across all job types)',
6823
+ retries,
6824
+ retryBackOff: WORKER_BUSY_RETRY_BACKOFF_MS,
6825
+ });
6826
+ }
6756
6827
  recordJobStart(job, jobType);
6757
6828
  // Auto-extend the broker lock for the whole life of this job (harness run
6758
6829
  // + git finalize + complete/fail), stopped in the outer finally. The lock
@@ -7091,6 +7162,10 @@ async function workAgent(req, flags) {
7091
7162
  } finally {
7092
7163
  stopLockExtender();
7093
7164
  recordJobEnd(job);
7165
+ // Release the process-wide single-flight permit LAST, once this job's
7166
+ // lock-extender is stopped and its bookkeeping cleared, so another
7167
+ // poller can only begin after this job is fully settled.
7168
+ singleFlight.release();
7094
7169
  }
7095
7170
  },
7096
7171
  });
@@ -11731,6 +11806,7 @@ export {
11731
11806
  parsePsTime,
11732
11807
  ensureAcpFlag,
11733
11808
  startLockExtender,
11809
+ createSingleFlight,
11734
11810
  provisionRepo,
11735
11811
  finalizeGit,
11736
11812
  describeGitFailure,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.44.7",
3
+ "version": "1.44.8",
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",
@@ -57,12 +57,12 @@
57
57
  },
58
58
  "optionalDependencies": {
59
59
  "node-pty": "^1.0.0",
60
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.44.7",
61
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.44.7",
62
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.44.7",
63
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.44.7",
64
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.44.7",
65
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.44.7",
66
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.44.7"
60
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.44.8",
61
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.44.8",
62
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.44.8",
63
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.44.8",
64
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.44.8",
65
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.44.8",
66
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.44.8"
67
67
  }
68
68
  }