c8ctl-plugin-nano 1.22.1 → 1.23.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.
Files changed (2) hide show
  1. package/c8ctl-plugin.js +114 -4
  2. package/package.json +8 -8
package/c8ctl-plugin.js CHANGED
@@ -212,6 +212,34 @@ function getSupervisorLogDir() {
212
212
  return join(getLogDir(), 'supervisor');
213
213
  }
214
214
 
215
+ /**
216
+ * Per-worker activity directory + file. A supervised `nano work` child writes a
217
+ * small JSON marker here reporting which job(s) it is currently servicing (or
218
+ * that it is idle); the daemon reads it for `supervisor status`. Worker ids are
219
+ * validated (`isValidWorkerName`: letters, digits, . _ -) so they are safe as a
220
+ * filename with no traversal risk.
221
+ */
222
+ function getSupervisorActivityDir() {
223
+ return join(getStateHome(), 'supervisor-activity');
224
+ }
225
+
226
+ function supervisorWorkerActivityFile(id) {
227
+ return join(getSupervisorActivityDir(), `${id}.json`);
228
+ }
229
+
230
+ /**
231
+ * Read a worker's activity marker. Returns the parsed object, or `null` when the
232
+ * file is absent (worker not reporting yet, or a standalone/older worker) or
233
+ * unreadable. Pure enough for status rendering (best-effort IO).
234
+ */
235
+ function readWorkerActivity(id) {
236
+ try {
237
+ return JSON.parse(readFileSync(supervisorWorkerActivityFile(id), 'utf-8'));
238
+ } catch {
239
+ return null;
240
+ }
241
+ }
242
+
215
243
  /**
216
244
  * Deterministic control-socket path shared by the daemon and every client.
217
245
  * Derived from a hash of the (possibly overridden) state home so distinct
@@ -3071,6 +3099,40 @@ async function workAgent(req, flags) {
3071
3099
  logger.info(` max parallel: ${maxParallelJobs}; job timeout: ${jobKillMs}ms; activation lock: ${jobLockMs}ms; poll timeout: ${pollTimeoutMs}ms`);
3072
3100
  logger.info('Polling for work — press Ctrl-C to stop.');
3073
3101
 
3102
+ // When launched under the supervisor, report per-job activity — which job(s)
3103
+ // this worker is currently servicing, or that it is idle — to a small marker
3104
+ // file the supervisor reads for `supervisor status`. The daemon passes the
3105
+ // path via NANO_SUPERVISOR_ACTIVITY_FILE; a standalone `nano work` has no such
3106
+ // env var and writes nothing (this is entirely advisory).
3107
+ const activityFile = process.env.NANO_SUPERVISOR_ACTIVITY_FILE || null;
3108
+ const activeJobs = new Map(); // jobKey -> { type, since (ms epoch) }
3109
+ const writeActivity = () => {
3110
+ if (!activityFile) return;
3111
+ const jobs = [...activeJobs.entries()].map(([key, v]) => ({ key, type: v.type, since: v.since }));
3112
+ const payload = { pid: process.pid, updatedAt: Date.now(), busy: jobs.length > 0, jobs };
3113
+ const tmp = `${activityFile}.${process.pid}.tmp`;
3114
+ try {
3115
+ mkdirSync(dirname(activityFile), { recursive: true });
3116
+ writeFileSync(tmp, JSON.stringify(payload), { mode: 0o600 });
3117
+ renameSync(tmp, activityFile); // atomic swap so a reader never sees a half-write
3118
+ } catch {
3119
+ try { rmSync(tmp, { force: true }); } catch { /* best effort */ }
3120
+ /* best effort — activity is advisory, never fail a job over it */
3121
+ }
3122
+ };
3123
+ const recordJobStart = (job, jobType) => {
3124
+ if (!activityFile) return;
3125
+ activeJobs.set(String(job.jobKey), { type: jobType, since: Date.now() });
3126
+ writeActivity();
3127
+ };
3128
+ const recordJobEnd = (job) => {
3129
+ if (!activityFile) return;
3130
+ activeJobs.delete(String(job.jobKey));
3131
+ writeActivity();
3132
+ };
3133
+ // Seed an initial idle marker so status reports 'idle' immediately after spawn.
3134
+ writeActivity();
3135
+
3074
3136
  // A per-job-type worker factory. Captures all the CLI-local + profile context
3075
3137
  // in closure scope so the profile watcher below can (re)spawn a poller for any
3076
3138
  // job type on demand without re-reading the flags.
@@ -3082,6 +3144,8 @@ async function workAgent(req, flags) {
3082
3144
  jobTimeoutMs: jobLockMs,
3083
3145
  pollTimeoutMs,
3084
3146
  jobHandler: async (job) => {
3147
+ recordJobStart(job, jobType);
3148
+ try {
3085
3149
  logger.info(`[${jobType}] job ${job.jobKey} (instance ${job.processInstanceKey ?? '-'}) → ${buildAgentCommandLine(profile.command, effectiveArgs)}`);
3086
3150
 
3087
3151
  // Disk-budget admission shed: if the engine data root is below the free
@@ -3254,6 +3318,9 @@ async function workAgent(req, flags) {
3254
3318
  retries,
3255
3319
  variables: { [AGENT_RESULT_KEY]: resultEnvelope },
3256
3320
  });
3321
+ } finally {
3322
+ recordJobEnd(job);
3323
+ }
3257
3324
  },
3258
3325
  });
3259
3326
 
@@ -3659,6 +3726,23 @@ function formatDuration(ms) {
3659
3726
  function summarizeSupervisorWorker(w, now = Date.now()) {
3660
3727
  const alive = isPidAlive(w.pid);
3661
3728
  const uptimeMs = alive && w.startedAt ? Math.max(0, now - new Date(w.startedAt).getTime()) : 0;
3729
+ // Per-job activity (supervised workers only). Guard on pid so a stale marker
3730
+ // left by a previous incarnation can't show a dead job as in-flight.
3731
+ let activity = null; // { state: 'busy'|'idle', jobs: [{ key, type, sinceMs }] }
3732
+ if (alive) {
3733
+ const act = readWorkerActivity(w.id);
3734
+ if (act && act.pid === w.pid) {
3735
+ const jobs = Array.isArray(act.jobs)
3736
+ ? act.jobs.map((j) => ({
3737
+ key: String(j.key),
3738
+ type: j.type ?? null,
3739
+ sinceMs: Number.isFinite(j.since) ? Math.max(0, now - j.since) : null,
3740
+ }))
3741
+ : [];
3742
+ activity = { state: jobs.length > 0 ? 'busy' : 'idle', jobs };
3743
+ }
3744
+ // No marker (or a stale-pid one): leave activity null → rendered as unknown.
3745
+ }
3662
3746
  return {
3663
3747
  id: w.id,
3664
3748
  profile: w.profile,
@@ -3668,9 +3752,22 @@ function summarizeSupervisorWorker(w, now = Date.now()) {
3668
3752
  uptimeMs,
3669
3753
  lastExit: w.lastExit ?? null,
3670
3754
  args: Array.isArray(w.args) ? w.args : [],
3755
+ activity,
3671
3756
  };
3672
3757
  }
3673
3758
 
3759
+ /** One-line JOB cell for a status row: the serviced job key, `idle`, or `-`. */
3760
+ function supervisorJobCell(w) {
3761
+ if (w.state !== 'running') return '-';
3762
+ const a = w.activity;
3763
+ if (!a) return '?'; // alive but not reporting (older worker / marker not yet written)
3764
+ if (a.state !== 'busy' || a.jobs.length === 0) return 'idle';
3765
+ const [first, ...rest] = a.jobs;
3766
+ const dur = first.sinceMs != null ? ` (${formatDuration(first.sinceMs)})` : '';
3767
+ const more = rest.length > 0 ? ` +${rest.length}` : '';
3768
+ return `${first.key}${more}${dur}`;
3769
+ }
3770
+
3674
3771
  /** Render a supervisor status object as an aligned text table. */
3675
3772
  function formatSupervisorStatus(status) {
3676
3773
  const lines = [];
@@ -3690,13 +3787,14 @@ function formatSupervisorStatus(status) {
3690
3787
  id: String(w.id),
3691
3788
  profile: String(w.profile),
3692
3789
  state: String(w.state),
3790
+ job: supervisorJobCell(w),
3693
3791
  pid: w.pid ? String(w.pid) : '-',
3694
3792
  restarts: String(w.restarts),
3695
3793
  uptime: w.state === 'running' ? formatDuration(w.uptimeMs) : '-',
3696
3794
  last: w.lastExit ? String(w.lastExit) : '-',
3697
3795
  }));
3698
- const head = { id: 'ID', profile: 'PROFILE', state: 'STATE', pid: 'PID', restarts: 'RESTARTS', uptime: 'UPTIME', last: 'LAST EXIT' };
3699
- const cols = ['id', 'profile', 'state', 'pid', 'restarts', 'uptime', 'last'];
3796
+ const head = { id: 'ID', profile: 'PROFILE', state: 'STATE', job: 'JOB', pid: 'PID', restarts: 'RESTARTS', uptime: 'UPTIME', last: 'LAST EXIT' };
3797
+ const cols = ['id', 'profile', 'state', 'job', 'pid', 'restarts', 'uptime', 'last'];
3700
3798
  const width = {};
3701
3799
  for (const c of cols) width[c] = Math.max(head[c].length, ...rows.map((r) => r[c].length));
3702
3800
  const fmt = (r) => ' ' + cols.map((c) => r[c].padEnd(width[c])).join(' ');
@@ -3855,10 +3953,17 @@ async function runSupervisorDaemon() {
3855
3953
  const startWorker = (w) => {
3856
3954
  let fd;
3857
3955
  try { fd = openSync(w.logFile, 'a'); } catch { fd = 'ignore'; }
3956
+ // Clear any stale activity marker from a previous incarnation so a freshly
3957
+ // (re)started worker never briefly shows a dead job as in-flight.
3958
+ const activityFile = supervisorWorkerActivityFile(w.id);
3959
+ w.activityFile = activityFile;
3960
+ try { rmSync(activityFile, { force: true }); } catch { /* best effort */ }
3858
3961
  // `--name w.id` makes the child's broker workerName match this worker's
3859
3962
  // supervisor id, so the same profile launched twice is distinct end-to-end.
3963
+ // NANO_SUPERVISOR_ACTIVITY_FILE tells the child where to report per-job
3964
+ // activity for `supervisor status` (idle vs the job key it is servicing).
3860
3965
  const child = spawn(exec, [entry, 'nano', 'work', w.profile, '--name', w.id, ...w.args], {
3861
- env: process.env,
3966
+ env: { ...process.env, NANO_SUPERVISOR_ACTIVITY_FILE: activityFile },
3862
3967
  stdio: ['ignore', fd, fd],
3863
3968
  });
3864
3969
  if (typeof fd === 'number') { try { closeSync(fd); } catch { /* dup'd into child */ } }
@@ -3880,6 +3985,8 @@ async function runSupervisorDaemon() {
3880
3985
  settled = true;
3881
3986
  w.pid = null;
3882
3987
  w.lastExit = reason;
3988
+ // Drop the activity marker — a dead worker services no job.
3989
+ try { rmSync(w.activityFile || supervisorWorkerActivityFile(w.id), { force: true }); } catch { /* best effort */ }
3883
3990
  const ranMs = Date.now() - (w.spawnedAt || Date.now());
3884
3991
  if (ranMs >= SUPERVISOR_HEALTHY_UPTIME_MS) w.restarts = 0;
3885
3992
  if (w.stopping || shuttingDown || !workers.has(w.id)) { persist(); return; }
@@ -3939,6 +4046,7 @@ async function runSupervisorDaemon() {
3939
4046
  if (!workers.has(id)) return false;
3940
4047
  await stopWorker(id);
3941
4048
  workers.delete(id);
4049
+ try { rmSync(supervisorWorkerActivityFile(id), { force: true }); } catch { /* best effort */ }
3942
4050
  dlog(`worker '${id}' removed`);
3943
4051
  broadcast({ type: 'event', event: 'worker-remove', id });
3944
4052
  persist();
@@ -5952,6 +6060,8 @@ export {
5952
6060
  formatDuration,
5953
6061
  summarizeSupervisorWorker,
5954
6062
  formatSupervisorStatus,
6063
+ supervisorJobCell,
6064
+ supervisorWorkerActivityFile,
5955
6065
  WORK_FORWARD_FLAGS,
5956
6066
  runSupervisorDaemon,
5957
6067
  startSupervisorDaemon,
@@ -6003,7 +6113,7 @@ export const metadata = {
6003
6113
  { command: 'c8ctl nano work coder --sandbox docker --image ghcr.io/acme/agent:1', description: 'Run jobs in isolated containers with disk-hygiene reaping' },
6004
6114
  { command: 'c8ctl nano supervisor start --worker reviewer --worker coder', description: 'Start a detached supervisor managing several workers from one terminal' },
6005
6115
  { command: 'c8ctl nano supervisor', description: 'Attach an interactive console to the supervisor (detach with Ctrl-D, leaving it running)' },
6006
- { command: 'c8ctl nano supervisor status', description: 'List supervised workers (pid, state, restarts, uptime) without the console' },
6116
+ { command: 'c8ctl nano supervisor status', description: 'List supervised workers (pid, state, serviced job / idle, restarts, uptime) without the console' },
6007
6117
  { command: 'c8ctl nano supervisor add decider --max-parallel 2', description: 'Add a supervised worker (forwarding work flags) to the running supervisor' },
6008
6118
  { command: 'c8ctl nano supervisor restart reviewer', description: 'Restart a supervised worker by id or profile' },
6009
6119
  { command: 'c8ctl nano supervisor stop', description: 'Stop the supervisor daemon and all its workers' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.22.1",
3
+ "version": "1.23.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",
@@ -47,12 +47,12 @@
47
47
  "semantic-release": "^25.0.3"
48
48
  },
49
49
  "optionalDependencies": {
50
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.22.1",
51
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.22.1",
52
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.22.1",
53
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.22.1",
54
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.22.1",
55
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.22.1",
56
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.22.1"
50
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.23.0",
51
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.23.0",
52
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.23.0",
53
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.23.0",
54
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.23.0",
55
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.23.0",
56
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.23.0"
57
57
  }
58
58
  }