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.
- package/c8ctl-plugin.js +164 -1
- 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.
|
|
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.
|
|
61
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.44.
|
|
62
|
-
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.44.
|
|
63
|
-
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.44.
|
|
64
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.44.
|
|
65
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.44.
|
|
66
|
-
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.44.
|
|
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
|
}
|