c8ctl-plugin-nano 1.53.1 → 1.54.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.
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
- let fd;
8845
- try { fd = openSync(w.logFile, 'a'); } catch { fd = 'ignore'; }
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.53.1",
3
+ "version": "1.54.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",
@@ -70,12 +70,12 @@
70
70
  },
71
71
  "optionalDependencies": {
72
72
  "node-pty": "^1.0.0",
73
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.53.1",
74
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.53.1",
75
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.53.1",
76
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.53.1",
77
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.53.1",
78
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.53.1",
79
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.53.1"
73
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.54.0",
74
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.54.0",
75
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.54.0",
76
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.54.0",
77
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.54.0",
78
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.54.0",
79
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.54.0"
80
80
  }
81
81
  }