c8ctl-plugin-nano 1.53.1 → 1.54.1
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/README.md +7 -0
- package/c8ctl-plugin.js +47 -3
- package/package.json +9 -8
- package/supervisor-log-ring.mjs +162 -0
package/README.md
CHANGED
|
@@ -966,6 +966,13 @@ How it works and where things live:
|
|
|
966
966
|
socket is unreachable (to report a stale/dead daemon).
|
|
967
967
|
- Per-worker and daemon logs live under `logs/supervisor/` in the state home
|
|
968
968
|
(`worker-<id>.log`, `daemon.log`).
|
|
969
|
+
- **Per-worker log cap:** each `worker-<id>.log` is bounded (default **10 MB**) —
|
|
970
|
+
the daemon pipes the child's stdout/stderr through a rotating ring, keeping the
|
|
971
|
+
**newest** output and rolling the previous fill to `worker-<id>.log.1` (so
|
|
972
|
+
on-disk usage stays ~2× the cap). `nano supervisor logs <id>` still tails the
|
|
973
|
+
live output across a rotation. Set `NANO_SUPERVISOR_LOG_MAX_BYTES` to change the
|
|
974
|
+
cap (bytes); `0` or a negative value disables the cap (unbounded append). An
|
|
975
|
+
unset/invalid value falls back to the 10 MB default.
|
|
969
976
|
- **Restart policy:** a crashed child is restarted with exponential backoff
|
|
970
977
|
(1s → 30s cap); a child that stayed up ≥60s resets its backoff. `remove`/`stop`
|
|
971
978
|
cancel any pending restart, and a `restart` cleanly swaps the child (a late
|
package/c8ctl-plugin.js
CHANGED
|
@@ -66,6 +66,7 @@ import { platformForHost } from './platforms.mjs';
|
|
|
66
66
|
import { redactAgenticUrl, buildAgenticUrl } from './work-channel.mjs';
|
|
67
67
|
import { createHostRelaySession, roleTerminalMode } from './work-relay.mjs';
|
|
68
68
|
import { resolveBufferCapacity } from './work-buffer.mjs';
|
|
69
|
+
import { createLogRing, resolveLogMaxBytes } from './supervisor-log-ring.mjs';
|
|
69
70
|
// Canonical ACP → transcript wire bridge (nanobpm/nano-ide#534), consumed through
|
|
70
71
|
// the single agentic import surface. `acpUpdateToTranscriptChunk(update)` maps one
|
|
71
72
|
// raw ACP `session/update` to the exact transcript-chunk bytes the cockpit decodes,
|
|
@@ -8840,9 +8841,21 @@ async function runSupervisorDaemon() {
|
|
|
8840
8841
|
}
|
|
8841
8842
|
};
|
|
8842
8843
|
|
|
8844
|
+
// Per-worker log cap (#183). A supervised worker is long-lived and chatty, so
|
|
8845
|
+
// an unbounded append fd could grow `worker-<id>.log` to multiple GB and fill
|
|
8846
|
+
// the disk. When capped (the default), the daemon OWNS the bytes: it pipes the
|
|
8847
|
+
// child's stdout/stderr through a rotating ring (see `supervisor-log-ring.mjs`)
|
|
8848
|
+
// that keeps the newest output and bounds on-disk usage to ~2x the cap. Set
|
|
8849
|
+
// `NANO_SUPERVISOR_LOG_MAX_BYTES=0` to opt out (legacy raw-fd append).
|
|
8850
|
+
const workerLogMaxBytes = resolveLogMaxBytes(process.env.NANO_SUPERVISOR_LOG_MAX_BYTES);
|
|
8851
|
+
|
|
8843
8852
|
const startWorker = (w) => {
|
|
8844
|
-
|
|
8845
|
-
|
|
8853
|
+
// With a cap we pipe stdout/stderr through the daemon so the ring can bound
|
|
8854
|
+
// them; unbounded (opt-out) keeps the legacy direct-fd append. `fd` is only
|
|
8855
|
+
// used on the direct-fd path.
|
|
8856
|
+
const ringed = workerLogMaxBytes > 0;
|
|
8857
|
+
let fd = 'ignore';
|
|
8858
|
+
if (!ringed) { try { fd = openSync(w.logFile, 'a'); } catch { fd = 'ignore'; } }
|
|
8846
8859
|
// Clear any stale activity marker from a previous incarnation so a freshly
|
|
8847
8860
|
// (re)started worker never briefly shows a dead job as in-flight.
|
|
8848
8861
|
const activityFile = supervisorWorkerActivityFile(w.id);
|
|
@@ -8858,9 +8871,40 @@ async function runSupervisorDaemon() {
|
|
|
8858
8871
|
// child reparents to init.
|
|
8859
8872
|
const child = spawn(exec, [entry, 'nano', 'work', w.profile, '--name', w.id, ...w.args], {
|
|
8860
8873
|
env: { ...process.env, NANO_SUPERVISOR_ACTIVITY_FILE: activityFile, NANO_SUPERVISOR_DAEMON_PID: String(process.pid) },
|
|
8861
|
-
stdio: ['ignore', fd, fd],
|
|
8874
|
+
stdio: ['ignore', ringed ? 'pipe' : fd, ringed ? 'pipe' : fd],
|
|
8862
8875
|
});
|
|
8863
8876
|
if (typeof fd === 'number') { try { closeSync(fd); } catch { /* dup'd into child */ } }
|
|
8877
|
+
// On the ringed path, relay every stdout/stderr chunk into a bounded,
|
|
8878
|
+
// rotating writer the daemon owns. Sync writes mean received bytes are on
|
|
8879
|
+
// disk before the child can die, and the ring is closed in the death handler
|
|
8880
|
+
// (and before a restart re-opens it) so fds never leak.
|
|
8881
|
+
if (ringed) {
|
|
8882
|
+
let ring = null;
|
|
8883
|
+
try {
|
|
8884
|
+
ring = createLogRing(w.logFile, workerLogMaxBytes);
|
|
8885
|
+
const feed = (chunk) => { try { ring.write(chunk); } catch { /* never crash the daemon */ } };
|
|
8886
|
+
if (child.stdout) { child.stdout.on('data', feed); child.stdout.on('error', () => {}); }
|
|
8887
|
+
if (child.stderr) { child.stderr.on('data', feed); child.stderr.on('error', () => {}); }
|
|
8888
|
+
} catch { ring = null; /* fall back to dropping output rather than crashing */ }
|
|
8889
|
+
// If ring setup threw, the child was still spawned with piped stdio, so we
|
|
8890
|
+
// MUST still drain stdout/stderr — an undrained pipe applies backpressure
|
|
8891
|
+
// and can deadlock the worker once its buffers fill. `resume()` discards
|
|
8892
|
+
// the bytes harmlessly (better than a wedged worker).
|
|
8893
|
+
if (!ring) {
|
|
8894
|
+
if (child.stdout) { child.stdout.on('error', () => {}); child.stdout.resume(); }
|
|
8895
|
+
if (child.stderr) { child.stderr.on('error', () => {}); child.stderr.resume(); }
|
|
8896
|
+
}
|
|
8897
|
+
// Close THIS child's ring once its stdio streams end ('close' fires after
|
|
8898
|
+
// stdout/stderr have flushed) AND on a spawn 'error' — an ENOENT/EMFILE
|
|
8899
|
+
// spawn failure emits only 'error' with no 'close', so without this the
|
|
8900
|
+
// ring fd would leak. Bound to the local `ring` (kept as worker-local
|
|
8901
|
+
// closure state, never on `w`) so a stale child's late event never shuts
|
|
8902
|
+
// a restarted worker's live ring;
|
|
8903
|
+
// `ring.close()` is idempotent, so the close+error double-fire is safe.
|
|
8904
|
+
const closeRing = () => { if (ring) { try { ring.close(); } catch { /* best effort */ } } };
|
|
8905
|
+
child.once('close', closeRing);
|
|
8906
|
+
child.once('error', closeRing);
|
|
8907
|
+
}
|
|
8864
8908
|
w.child = child;
|
|
8865
8909
|
w.pid = child.pid || null;
|
|
8866
8910
|
w.startedAt = new Date().toISOString();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "c8ctl-plugin-nano",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.54.1",
|
|
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",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"agentic-loader-hook.mjs",
|
|
27
27
|
"agentic-endpoint.mjs",
|
|
28
28
|
"supervisor-engine.mjs",
|
|
29
|
+
"supervisor-log-ring.mjs",
|
|
29
30
|
"work-channel.mjs",
|
|
30
31
|
"work-relay.mjs",
|
|
31
32
|
"work-buffer.mjs",
|
|
@@ -70,12 +71,12 @@
|
|
|
70
71
|
},
|
|
71
72
|
"optionalDependencies": {
|
|
72
73
|
"node-pty": "^1.0.0",
|
|
73
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.
|
|
74
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.
|
|
75
|
-
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.
|
|
76
|
-
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.
|
|
77
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.
|
|
78
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.
|
|
79
|
-
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.
|
|
74
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.54.1",
|
|
75
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.54.1",
|
|
76
|
+
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.54.1",
|
|
77
|
+
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.54.1",
|
|
78
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.54.1",
|
|
79
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.54.1",
|
|
80
|
+
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.54.1"
|
|
80
81
|
}
|
|
81
82
|
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// Bounded per-worker log writer for the supervisor daemon (jwulf/c8ctl-plugin-nano#183).
|
|
2
|
+
//
|
|
3
|
+
// A supervised `nano work` child is long-lived and chatty: it streams
|
|
4
|
+
// stdout/stderr for the entire life of the fleet. The daemon used to hand the
|
|
5
|
+
// child a *raw append fd* as its stdio, so the OS wrote straight to
|
|
6
|
+
// `logs/supervisor/worker-<id>.log` and the file only ever grew — multi-GB logs
|
|
7
|
+
// could fill the disk with no cap and no rotation.
|
|
8
|
+
//
|
|
9
|
+
// This module lets the daemon OWN the bytes instead. The child is spawned with
|
|
10
|
+
// piped stdout/stderr and every chunk is fed through {@link createLogRing},
|
|
11
|
+
// which appends to the primary log file and, when it reaches the cap, ROTATES:
|
|
12
|
+
// the primary is renamed to `<log>.1` (replacing any previous `.1`) and a fresh
|
|
13
|
+
// primary is started. That keeps the newest output always retained (a ring/tail,
|
|
14
|
+
// not a hard stop) while bounding on-disk usage to ~2x the cap (the live primary
|
|
15
|
+
// plus one rotated file). `nano supervisor logs` tails the primary, so it keeps
|
|
16
|
+
// showing the most recent output across a rotation.
|
|
17
|
+
//
|
|
18
|
+
// The cap is operator-configurable via `NANO_SUPERVISOR_LOG_MAX_BYTES`
|
|
19
|
+
// (see {@link resolveLogMaxBytes}); `0`/negative opts out (unbounded), matching
|
|
20
|
+
// the existing `NANO_SUPERVISOR_*` env conventions.
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
openSync as fsOpenSync,
|
|
24
|
+
writeSync as fsWriteSync,
|
|
25
|
+
closeSync as fsCloseSync,
|
|
26
|
+
fstatSync as fsFstatSync,
|
|
27
|
+
renameSync as fsRenameSync,
|
|
28
|
+
} from 'node:fs';
|
|
29
|
+
|
|
30
|
+
/** Default per-worker log cap: 10 MB. */
|
|
31
|
+
export const DEFAULT_LOG_MAX_BYTES = 10 * 1024 * 1024;
|
|
32
|
+
|
|
33
|
+
/** Suffix of the single rotated-out file kept alongside the primary log. */
|
|
34
|
+
export const ROTATED_SUFFIX = '.1';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the per-worker log byte cap from an operator-supplied value with a
|
|
38
|
+
* sane fallback. Accepts a number or a numeric string (env vars arrive as
|
|
39
|
+
* strings). An unset/blank/non-numeric value falls back to `fallback`
|
|
40
|
+
* (default 10 MB). A value `<= 0` means "unbounded / off" and is returned as `0`
|
|
41
|
+
* so callers can opt out of the ring entirely and keep the legacy direct-fd
|
|
42
|
+
* write.
|
|
43
|
+
*
|
|
44
|
+
* @param {unknown} raw the operator value (e.g. `process.env.NANO_SUPERVISOR_LOG_MAX_BYTES`)
|
|
45
|
+
* @param {number} [fallback] the default when `raw` is absent/invalid
|
|
46
|
+
* @returns {number} a non-negative integer byte cap (`0` == unbounded/off)
|
|
47
|
+
*/
|
|
48
|
+
export function resolveLogMaxBytes(raw, fallback = DEFAULT_LOG_MAX_BYTES) {
|
|
49
|
+
const base = Number.isFinite(fallback) && fallback > 0 ? Math.floor(fallback) : DEFAULT_LOG_MAX_BYTES;
|
|
50
|
+
if (raw === undefined || raw === null || raw === '') return base;
|
|
51
|
+
const s = typeof raw === 'number' ? raw : String(raw).trim();
|
|
52
|
+
if (s === '') return base; // whitespace-only == unset
|
|
53
|
+
const n = typeof s === 'number' ? s : Number(s);
|
|
54
|
+
if (!Number.isFinite(n)) return base;
|
|
55
|
+
if (n <= 0) return 0; // explicit opt-out: unbounded
|
|
56
|
+
return Math.floor(n);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @typedef {object} LogRing
|
|
61
|
+
* @property {(chunk: Buffer|string) => void} write append a chunk, rotating at the cap
|
|
62
|
+
* @property {() => void} close close the underlying fd (idempotent)
|
|
63
|
+
* @property {() => number} size current byte size of the live primary file
|
|
64
|
+
* @property {() => number} rotations how many times the log has rotated
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Create a bounded, rotating writer for one worker's log file.
|
|
69
|
+
*
|
|
70
|
+
* Semantics:
|
|
71
|
+
* - Opens `logFile` in append mode (preserving any existing content — its bytes
|
|
72
|
+
* count toward the cap, so a reopen of a nearly-full file rotates promptly).
|
|
73
|
+
* - On each {@link LogRing.write}, if writing the chunk would push the primary
|
|
74
|
+
* file over `maxBytes` (and the file is non-empty), it ROTATES first: close
|
|
75
|
+
* the primary, rename it to `<logFile><ROTATED_SUFFIX>` (atomically replacing
|
|
76
|
+
* any prior rotated file), then open a fresh empty primary. The chunk is then
|
|
77
|
+
* written to the fresh primary. A single chunk larger than the cap is still
|
|
78
|
+
* written whole (never split mid-line); it just triggers a rotation on the
|
|
79
|
+
* following write.
|
|
80
|
+
* - `maxBytes <= 0` disables rotation entirely (unbounded append). Callers
|
|
81
|
+
* normally take the legacy direct-fd path instead of constructing a ring in
|
|
82
|
+
* that case; this is a defensive no-op for symmetry.
|
|
83
|
+
*
|
|
84
|
+
* All IO is synchronous so that, by the time a `write` returns, the bytes are on
|
|
85
|
+
* disk — that is what makes the rotation boundary deterministically testable
|
|
86
|
+
* without wall-clock flushing, and it means the daemon never loses already
|
|
87
|
+
* received bytes if the worker dies.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} logFile absolute path to the primary log file
|
|
90
|
+
* @param {number} maxBytes byte cap (`<= 0` == unbounded); see {@link resolveLogMaxBytes}
|
|
91
|
+
* @param {object} [io] injectable fs surface for deterministic tests
|
|
92
|
+
* @param {typeof fsOpenSync} [io.openSync]
|
|
93
|
+
* @param {typeof fsWriteSync} [io.writeSync]
|
|
94
|
+
* @param {typeof fsCloseSync} [io.closeSync]
|
|
95
|
+
* @param {typeof fsFstatSync} [io.fstatSync]
|
|
96
|
+
* @param {typeof fsRenameSync} [io.renameSync]
|
|
97
|
+
* @returns {LogRing}
|
|
98
|
+
*/
|
|
99
|
+
export function createLogRing(logFile, maxBytes, io = {}) {
|
|
100
|
+
const openSync = io.openSync || fsOpenSync;
|
|
101
|
+
const writeSync = io.writeSync || fsWriteSync;
|
|
102
|
+
const closeSync = io.closeSync || fsCloseSync;
|
|
103
|
+
const fstatSync = io.fstatSync || fsFstatSync;
|
|
104
|
+
const renameSync = io.renameSync || fsRenameSync;
|
|
105
|
+
|
|
106
|
+
const cap = Number.isFinite(maxBytes) && maxBytes > 0 ? Math.floor(maxBytes) : 0;
|
|
107
|
+
const rotatedFile = `${logFile}${ROTATED_SUFFIX}`;
|
|
108
|
+
|
|
109
|
+
let fd = openSync(logFile, 'a');
|
|
110
|
+
// Seed `size` from the existing file so appending to a nearly-full log rotates
|
|
111
|
+
// at the right point rather than starting the count from zero.
|
|
112
|
+
let size = 0;
|
|
113
|
+
try { size = fstatSync(fd).size; } catch { size = 0; }
|
|
114
|
+
let rotations = 0;
|
|
115
|
+
let closed = false;
|
|
116
|
+
|
|
117
|
+
const rotate = () => {
|
|
118
|
+
try { closeSync(fd); } catch { /* fd may already be gone */ }
|
|
119
|
+
try {
|
|
120
|
+
// Replace any previous rotated file with the just-filled primary. `rename`
|
|
121
|
+
// is atomic, so a concurrent tail reopening the primary never sees a gap.
|
|
122
|
+
renameSync(logFile, rotatedFile);
|
|
123
|
+
fd = openSync(logFile, 'a');
|
|
124
|
+
size = 0;
|
|
125
|
+
rotations += 1;
|
|
126
|
+
} catch {
|
|
127
|
+
// Rotation failed (permissions / transient FS issue). `renameSync` or the
|
|
128
|
+
// subsequent `openSync` threw AFTER we closed the primary fd, so leaving it
|
|
129
|
+
// as-is would strand the ring on a closed fd and silently stop draining.
|
|
130
|
+
// Instead, best-effort reopen the primary in append mode and keep writing:
|
|
131
|
+
// we favor continued log capture over strict bounding, briefly exceeding the
|
|
132
|
+
// cap until rotation can succeed again.
|
|
133
|
+
try {
|
|
134
|
+
fd = openSync(logFile, 'a');
|
|
135
|
+
try { size = fstatSync(fd).size; } catch { size = 0; }
|
|
136
|
+
} catch { /* can't reopen either; the write() try/catch swallows the fallout */ }
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
write(chunk) {
|
|
142
|
+
if (closed) return;
|
|
143
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
144
|
+
if (buf.length === 0) return;
|
|
145
|
+
// Rotate BEFORE writing when the primary already holds bytes and this
|
|
146
|
+
// chunk would tip it over the cap — this bounds each file at <= cap + the
|
|
147
|
+
// final chunk (pipe chunks are small), and never splits a chunk mid-line.
|
|
148
|
+
if (cap > 0 && size > 0 && size + buf.length > cap) rotate();
|
|
149
|
+
try {
|
|
150
|
+
writeSync(fd, buf);
|
|
151
|
+
size += buf.length;
|
|
152
|
+
} catch { /* a transient write failure must not crash the daemon */ }
|
|
153
|
+
},
|
|
154
|
+
close() {
|
|
155
|
+
if (closed) return;
|
|
156
|
+
closed = true;
|
|
157
|
+
try { closeSync(fd); } catch { /* best effort */ }
|
|
158
|
+
},
|
|
159
|
+
size() { return size; },
|
|
160
|
+
rotations() { return rotations; },
|
|
161
|
+
};
|
|
162
|
+
}
|