c8ctl-plugin-nano 1.44.6 → 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 +164 -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
@@ -4568,6 +4573,15 @@ function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, i
4568
4573
  // One-time warning latch for the reserved escalate/filter policies so the
4569
4574
  // deferral is observable (not silent) but never spams a warning per request.
4570
4575
  let interimWarned = false;
4576
+ // #137: count of canonical transcript chunks actually PUBLISHED to the relay's
4577
+ // transcript-chunk seam, plus a one-time latch for the fallback floor. Some ACP
4578
+ // agents (e.g. copilot 1.0.82 on some hosts) complete a turn without ever
4579
+ // emitting a mappable `session/update`, so `emitTranscript` never fires and the
4580
+ // cockpit drill-in would show an empty session. When the turn completes having
4581
+ // published zero chunks, we synthesise ONE structured floor chunk so the
4582
+ // drill-in shows the outcome instead of nothing (see `maybeEmitTranscriptFloor`).
4583
+ let transcriptChunksPublished = 0;
4584
+ let floorEmitted = false;
4571
4585
 
4572
4586
  // Live "spy" tee (--stream), line-buffered, mirroring the other paths.
4573
4587
  // Separate line buffers per lane (stdout-human vs stderr) so a partial line
@@ -4638,6 +4652,13 @@ function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, i
4638
4652
  if (idleMon) idleMon.stop();
4639
4653
  if (settleTimer) { clearTimeout(settleTimer); settleTimer = null; }
4640
4654
  if (detachSteer) { try { detachSteer(); } catch { /* best effort */ } detachSteer = null; }
4655
+ // #137: a turn that completed but produced no mappable session/update gets a
4656
+ // synthesised structured floor so the cockpit drill-in isn't empty. Only for
4657
+ // a resolved turn (`promptResolved`) — a handshake/spawn failure has nothing
4658
+ // to floor and its error is surfaced in the result envelope. Best-effort and
4659
+ // guarded, so it can never turn a settle into a throw. Runs before the tee is
4660
+ // flushed so a --stream watcher sees the floored line too.
4661
+ if (promptResolved) { try { maybeEmitTranscriptFloor(); } catch { /* floor best-effort */ } }
4641
4662
  if (teeSink) { tee('', true); teeErr('', true); }
4642
4663
  // Reap the child if it is still alive (turn resolved but agent lingering).
4643
4664
  try { if (child && childClosed === null) killTree(child); } catch { /* best effort */ }
@@ -4775,6 +4796,7 @@ function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, i
4775
4796
  let published = false;
4776
4797
  try { relayTap.relayTranscriptChunk(chunk); published = true; } catch { /* relay best-effort */ }
4777
4798
  if (published) {
4799
+ transcriptChunksPublished++;
4778
4800
  // Mirror the human text locally (spy tee + captured stdout) so the
4779
4801
  // result envelope and --stream spy are unchanged — without re-emitting
4780
4802
  // raw text onto the relay lane, which now carries the canonical chunk.
@@ -4787,6 +4809,76 @@ function spawnCaptureAcp({ command, args = [], cwd, env, stdinData, timeoutMs, i
4787
4809
  emitHuman(describeUpdate(update));
4788
4810
  };
4789
4811
 
4812
+ // #137: build the human-readable text for a fallback transcript FLOOR — used
4813
+ // only when a turn completed having produced no mappable session/update, so
4814
+ // the cockpit drill-in shows the run's OUTCOME instead of an empty session.
4815
+ // Prefer the agent's own structured result (from $AGENT_RESULT_FILE: summary,
4816
+ // status, pr), then its stderr diagnostics, then a fixed explanatory note.
4817
+ const buildFloorText = () => {
4818
+ const resultPath = env && typeof env === 'object' ? env[AGENT_RESULT_FILE_ENV] : null;
4819
+ const res = readAgentResultFile(resultPath);
4820
+ if (res && typeof res === 'object') {
4821
+ const parts = [];
4822
+ if (typeof res.summary === 'string' && res.summary.trim()) parts.push(res.summary.trim());
4823
+ if (typeof res.status === 'string' && res.status.trim()) parts.push(`(status: ${res.status.trim()})`);
4824
+ if (typeof res.pr === 'string' && res.pr.trim()) parts.push(`PR: ${res.pr.trim()}`);
4825
+ else if (res.pr && typeof res.pr === 'object' && typeof res.pr.url === 'string' && res.pr.url.trim()) parts.push(`PR: ${res.pr.url.trim()}`);
4826
+ if (parts.length) return parts.join(' ');
4827
+ }
4828
+ const diag = joinCapped(stderrChunks).trim();
4829
+ if (diag) {
4830
+ // Surface the tail of the agent's stderr (its own diagnostics) as the
4831
+ // floor — capped so a chatty agent can't blow up a single transcript card.
4832
+ const FLOOR_DIAG_CAP = 2_000;
4833
+ const tail = diag.length > FLOOR_DIAG_CAP ? `…${diag.slice(diag.length - FLOOR_DIAG_CAP)}` : diag;
4834
+ return `Agent published no structured/canonical transcript content. Diagnostics:\n${tail}`;
4835
+ }
4836
+ return 'Agent completed the turn but published no structured/canonical transcript content, so no transcript messages were produced.';
4837
+ };
4838
+
4839
+ // #137: synthesise ONE structured transcript-chunk FLOOR when a completed turn
4840
+ // published zero canonical chunks. The floor rides the SAME canonical bridge
4841
+ // (an `agent_message_chunk` run through `acpUpdateToTranscriptChunk`) so the
4842
+ // cockpit renders a real assistant-message card — never a hand-rolled envelope
4843
+ // — and honours the acceptance's "structured/raw transcript floor" so the
4844
+ // drill-in shows something useful rather than an empty session. Idempotent
4845
+ // (one-time latch), inert unless the relay exposes the transcript-chunk seam,
4846
+ // and skipped entirely when any real chunk already reached the lane.
4847
+ const maybeEmitTranscriptFloor = () => {
4848
+ if (floorEmitted) return;
4849
+ if (transcriptChunksPublished > 0) return;
4850
+ if (!relayTap || typeof relayTap.relayTranscriptChunk !== 'function') return;
4851
+ floorEmitted = true;
4852
+ const text = buildFloorText();
4853
+ if (!text) return;
4854
+ const chunk = encodeTranscriptChunk({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } });
4855
+ if (!chunk) {
4856
+ // Canonical bridge couldn't produce a chunk (bridge throw or unmapped
4857
+ // classification) → emit the floor on the text lane rather than dropping
4858
+ // it, exactly like `emitTranscript`, so the cockpit transcript is never
4859
+ // left empty in the very failure mode this floor exists to mitigate.
4860
+ emitHuman(text);
4861
+ return;
4862
+ }
4863
+ // Only skip the text lane when the chunk publish ACTUALLY succeeded. If the
4864
+ // seam throws (a downstream tap implementation bug, not just the built-in
4865
+ // best-effort guard), the floor never reached the relay lane — so fall back
4866
+ // to the text lane, exactly like `emitTranscript`, or the cockpit transcript
4867
+ // could still be empty in that failure mode.
4868
+ let published = false;
4869
+ try { relayTap.relayTranscriptChunk(chunk); published = true; } catch { /* relay best-effort */ }
4870
+ if (published) {
4871
+ transcriptChunksPublished++;
4872
+ // Mirror locally (spy tee + captured stdout) so a --stream watcher and the
4873
+ // result envelope also reflect the floor, matching the mapped-update path.
4874
+ captureHuman(text);
4875
+ return;
4876
+ }
4877
+ // Chunk publish threw → emit the floor on the text lane (relay text + spy
4878
+ // tee + capture) so nothing is dropped.
4879
+ emitHuman(text);
4880
+ };
4881
+
4790
4882
  const handleMessage = (msg) => {
4791
4883
  if (!msg || typeof msg !== 'object') return;
4792
4884
  // A response to one of OUR requests.
@@ -5077,6 +5169,44 @@ function baseAgentEnv(profile, job) {
5077
5169
  };
5078
5170
  }
5079
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
+
5080
5210
  /**
5081
5211
  * Keep a leased job's broker activation lock ahead of *now* while the harness is
5082
5212
  * running, so a long agent run never has its lock lapse and get re-activated (a
@@ -6179,6 +6309,13 @@ async function workAgent(req, flags) {
6179
6309
  // SDK derives maxJobsToActivate = maxParallelJobs - activeJobs, so 1 means
6180
6310
  // "activate one job, then stop polling until it completes".
6181
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();
6182
6319
  // The broker job-activation lock is NOT hardcoded up front. A fixed timeout is
6183
6320
  // impossible to size for an agent: too short reclaims a still-working job (a
6184
6321
  // second agent starts + the stale complete/fail is rejected 409), too long
@@ -6378,7 +6515,7 @@ async function workAgent(req, flags) {
6378
6515
  }
6379
6516
  const extraNote = extraJobTypes.length > 0 ? ` (${extraJobTypes.length} via --job-type)` : '';
6380
6517
  logger.info(` listening on ${jobTypes.length} job type(s)${extraNote}: ${jobTypes.join(' ')}`);
6381
- 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`);
6382
6519
  // Warm the gh-token cache now, off the job-handling path: githubCloneToken()
6383
6520
  // may consult `gh auth token` (a synchronous spawn, up to 10s) as its default
6384
6521
  // credential fallback, and doing that inside a job handler would block the
@@ -6666,6 +6803,27 @@ async function workAgent(req, flags) {
6666
6803
  jobTimeoutMs: recoveryWindowMs,
6667
6804
  pollTimeoutMs,
6668
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
+ }
6669
6827
  recordJobStart(job, jobType);
6670
6828
  // Auto-extend the broker lock for the whole life of this job (harness run
6671
6829
  // + git finalize + complete/fail), stopped in the outer finally. The lock
@@ -7004,6 +7162,10 @@ async function workAgent(req, flags) {
7004
7162
  } finally {
7005
7163
  stopLockExtender();
7006
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();
7007
7169
  }
7008
7170
  },
7009
7171
  });
@@ -11644,6 +11806,7 @@ export {
11644
11806
  parsePsTime,
11645
11807
  ensureAcpFlag,
11646
11808
  startLockExtender,
11809
+ createSingleFlight,
11647
11810
  provisionRepo,
11648
11811
  finalizeGit,
11649
11812
  describeGitFailure,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.44.6",
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.6",
61
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.44.6",
62
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.44.6",
63
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.44.6",
64
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.44.6",
65
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.44.6",
66
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.44.6"
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
  }