c8ctl-plugin-nano 1.26.1 → 1.26.3
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 +11 -3
- package/c8ctl-plugin.js +256 -15
- package/package.json +8 -8
package/README.md
CHANGED
|
@@ -346,13 +346,21 @@ the harness:
|
|
|
346
346
|
as the checkout target) into a throwaway workspace under
|
|
347
347
|
`<state>/agent-runs/run-*`;
|
|
348
348
|
3. create `branch.create` (if set) off that target;
|
|
349
|
-
4.
|
|
349
|
+
4. set a **committer identity** on the workspace, preferring the operator's own
|
|
350
|
+
(`GIT_AUTHOR_*` env → global `git config user.name/email` → the
|
|
351
|
+
`gh`-authenticated GitHub user), and only falling back to `nano-agent` when
|
|
352
|
+
none resolve — so autonomous commits are authored by the human running the
|
|
353
|
+
fleet (who has signed any CLA/DCO), not an anonymous bot;
|
|
354
|
+
5. run the harness **in the workspace** (`cwd`), with `AGENT_WORKSPACE`,
|
|
350
355
|
`AGENT_REPO_URL`, `AGENT_REPO_BRANCH`, `AGENT_REPO_REF` exported and the job
|
|
351
356
|
envelope on stdin;
|
|
352
|
-
|
|
357
|
+
6. on success, enumerate new commits, `git push` the branch when `branch.push`
|
|
353
358
|
(default true), and — when `task.allowPr` — **reconcile the PR the agent
|
|
354
359
|
opened** for the branch (`gh pr list --head <branch>`; `openedBy` reports the
|
|
355
|
-
PR's actual author login, or `null` when none is found)
|
|
360
|
+
PR's actual author login, or `null` when none is found), then post a one-time
|
|
361
|
+
attribution comment recording that the change was agent-generated (marker-
|
|
362
|
+
guarded so convergence rounds don't repeat it; disable with
|
|
363
|
+
`NANO_AGENT_ATTRIBUTION=0`, rename the agent with `NANO_AGENT_NAME`).
|
|
356
364
|
|
|
357
365
|
The token is delivered to git via `GIT_ASKPASS` (env), never on argv or in the
|
|
358
366
|
remote URL, and is redacted from all logs/results. Credential helpers are
|
package/c8ctl-plugin.js
CHANGED
|
@@ -2526,6 +2526,71 @@ function primeGhAuthToken() {
|
|
|
2526
2526
|
return ghAuthTokenCache !== GH_AUTH_TOKEN_UNSET;
|
|
2527
2527
|
}
|
|
2528
2528
|
|
|
2529
|
+
// Read the operator's own git identity from their GLOBAL config, using the real
|
|
2530
|
+
// host environment (process.env) rather than a job's sanitized gitEnv — so it
|
|
2531
|
+
// resolves even on the anonymous clone path, where gitEnv points
|
|
2532
|
+
// GIT_CONFIG_GLOBAL at /dev/null. Returns { name, email }, each possibly ''.
|
|
2533
|
+
function hostGitIdentity() {
|
|
2534
|
+
const read = (key) => {
|
|
2535
|
+
try {
|
|
2536
|
+
const r = spawnSync('git', ['config', '--global', '--get', key], { encoding: 'utf8', timeout: 5_000 });
|
|
2537
|
+
return r.status === 0 ? (r.stdout || '').trim() : '';
|
|
2538
|
+
} catch {
|
|
2539
|
+
return '';
|
|
2540
|
+
}
|
|
2541
|
+
};
|
|
2542
|
+
return { name: read('user.name'), email: read('user.email') };
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
// Fall back to the gh-authenticated GitHub user for a committer identity. Uses
|
|
2546
|
+
// the account's public email, or the id+login noreply address when the email is
|
|
2547
|
+
// private/unset. Returns { name, email }, each possibly ''.
|
|
2548
|
+
function ghUserIdentity() {
|
|
2549
|
+
try {
|
|
2550
|
+
const r = spawnSync('gh', ['api', 'user', '--jq', '[.name // "", .login // "", .email // "", (.id // "" | tostring)] | @tsv'],
|
|
2551
|
+
{ encoding: 'utf8', timeout: 10_000,
|
|
2552
|
+
env: { ...process.env, GH_PROMPT_DISABLED: '1', GH_NO_UPDATE_NOTIFIER: '1' } });
|
|
2553
|
+
if (r.status !== 0) return { name: '', email: '' };
|
|
2554
|
+
const [name = '', login = '', email = '', id = ''] = (r.stdout || '').trim().split('\t');
|
|
2555
|
+
const resolvedName = name || login || '';
|
|
2556
|
+
const resolvedEmail = email || (id && login ? `${id}+${login}@users.noreply.github.com` : '');
|
|
2557
|
+
return { name: resolvedName, email: resolvedEmail };
|
|
2558
|
+
} catch {
|
|
2559
|
+
return { name: '', email: '' };
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
// Resolve the committer identity the harness stamps onto the cloned workspace.
|
|
2564
|
+
// Per-field precedence: explicit GIT_AUTHOR_* env → the operator's global git
|
|
2565
|
+
// config → the gh-authenticated GitHub user → the `nano-agent` fallback.
|
|
2566
|
+
// Preferring the operator's real identity means autonomous commits are authored
|
|
2567
|
+
// by the human running the fleet (who has signed any CLA) rather than an
|
|
2568
|
+
// anonymous bot that hasn't; the agent's own authorship is recorded as a PR
|
|
2569
|
+
// comment (see postAgentAttribution) instead of forged onto the commit. Both the
|
|
2570
|
+
// git-config and gh lookups are lazy and performed at most once each, and only
|
|
2571
|
+
// when a higher-precedence source didn't already supply the field — so explicit
|
|
2572
|
+
// GIT_AUTHOR_* env fully short-circuits them (no `git config`/`gh` spawns, hence
|
|
2573
|
+
// no added latency or failure modes when the override is present).
|
|
2574
|
+
// `gitIdentity`/`ghIdentity` are injectable for testing.
|
|
2575
|
+
function resolveCommitterIdentity({ gitIdentity = hostGitIdentity, ghIdentity = ghUserIdentity } = {}) {
|
|
2576
|
+
const envName = process.env.GIT_AUTHOR_NAME || '';
|
|
2577
|
+
const envEmail = process.env.GIT_AUTHOR_EMAIL || '';
|
|
2578
|
+
let g = null;
|
|
2579
|
+
const gitOnce = () => (g ??= (gitIdentity() || { name: '', email: '' }));
|
|
2580
|
+
let gh = null;
|
|
2581
|
+
const ghOnce = () => (gh ??= (ghIdentity() || { name: '', email: '' }));
|
|
2582
|
+
|
|
2583
|
+
const name = envName || gitOnce().name || ghOnce().name || 'nano-agent';
|
|
2584
|
+
const email = envEmail || gitOnce().email || ghOnce().email || 'nano-agent@users.noreply.github.com';
|
|
2585
|
+
|
|
2586
|
+
const source =
|
|
2587
|
+
(envName || envEmail) ? 'env'
|
|
2588
|
+
: (g && (g.name || g.email)) ? 'git-global'
|
|
2589
|
+
: (gh && (gh.name || gh.email)) ? 'gh'
|
|
2590
|
+
: 'fallback';
|
|
2591
|
+
return { name, email, source };
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2529
2594
|
// Normalize a repository authRef into one of three intents. Trimming matters so
|
|
2530
2595
|
// a present-but-blank authRef ('' or whitespace) is treated as a misconfiguration
|
|
2531
2596
|
// rather than "absent": absence enables the default/gh fallback, but a blank
|
|
@@ -2616,9 +2681,15 @@ function provisionRepo({ envelope, token, runDir, timeoutMs = 120_000 }) {
|
|
|
2616
2681
|
}
|
|
2617
2682
|
}
|
|
2618
2683
|
|
|
2619
|
-
// Give the harness a committer identity in case it commits (many do).
|
|
2620
|
-
|
|
2621
|
-
|
|
2684
|
+
// Give the harness a committer identity in case it commits (many do). Prefer
|
|
2685
|
+
// the operator's real identity (git global / gh user) over the `nano-agent`
|
|
2686
|
+
// fallback so autonomous commits are authored by the human running the fleet —
|
|
2687
|
+
// who has signed any CLA — instead of an anonymous bot. The agent's authorship
|
|
2688
|
+
// is instead recorded as a PR comment (postAgentAttribution). Set via repo-
|
|
2689
|
+
// level config, which overrides global, so the identity is deterministic.
|
|
2690
|
+
const committer = resolveCommitterIdentity();
|
|
2691
|
+
runGit(['config', 'user.name', committer.name], { cwd: workspaceDir, env: gitEnv });
|
|
2692
|
+
runGit(['config', 'user.email', committer.email], { cwd: workspaceDir, env: gitEnv });
|
|
2622
2693
|
|
|
2623
2694
|
// Determine the working branch. With branch.create we make a real branch.
|
|
2624
2695
|
// Otherwise we're on whatever the clone checked out: a branch only if
|
|
@@ -2648,15 +2719,7 @@ function provisionRepo({ envelope, token, runDir, timeoutMs = 120_000 }) {
|
|
|
2648
2719
|
// gh returns whatever PR is open for the head branch, which may not be ours.
|
|
2649
2720
|
function reconcileAgentPr({ workspaceDir, token, branch, provider }) {
|
|
2650
2721
|
if (provider && provider !== 'github') return { openedBy: null, found: false, error: `PR reconcile unsupported for provider "${provider}"` };
|
|
2651
|
-
const env =
|
|
2652
|
-
if (token) {
|
|
2653
|
-
env.GH_TOKEN = token;
|
|
2654
|
-
} else {
|
|
2655
|
-
// No job token ⇒ honor the anonymous guarantee: never let gh fall back to an
|
|
2656
|
-
// operator-provided token in the ambient env. Scrub every gh auth source so
|
|
2657
|
-
// PR reconcile can only use credentials we were explicitly handed.
|
|
2658
|
-
for (const k of ['GH_TOKEN', 'GITHUB_TOKEN', 'GH_ENTERPRISE_TOKEN', 'GITHUB_ENTERPRISE_TOKEN']) delete env[k];
|
|
2659
|
-
}
|
|
2722
|
+
const env = ghAuthEnv(token, workspaceDir);
|
|
2660
2723
|
try {
|
|
2661
2724
|
const r = spawnSync('gh', ['pr', 'list', '--head', branch, '--state', 'all', '--json', 'number,url,state,isDraft,title,author', '--limit', '1'],
|
|
2662
2725
|
{ cwd: workspaceDir, env, encoding: 'utf8', timeout: 30_000 });
|
|
@@ -2671,6 +2734,96 @@ function reconcileAgentPr({ workspaceDir, token, branch, provider }) {
|
|
|
2671
2734
|
}
|
|
2672
2735
|
}
|
|
2673
2736
|
|
|
2737
|
+
// Build the gh environment for PR-side calls: inject the resolved job token, or
|
|
2738
|
+
// (anonymous path) scrub every ambient gh credential so we can only use what we
|
|
2739
|
+
// were explicitly handed. Scrubbing the token env vars alone is not enough — gh
|
|
2740
|
+
// will still authenticate from its on-disk config (hosts.yml / OS keychain), so
|
|
2741
|
+
// in the anonymous path we also point gh at a private, empty GH_CONFIG_DIR
|
|
2742
|
+
// (created inside the harness-reaped workspace) and disable interactive prompts,
|
|
2743
|
+
// guaranteeing a token-less job cannot act as the operator via stored creds.
|
|
2744
|
+
function ghAuthEnv(token, workspaceDir) {
|
|
2745
|
+
const env = { ...process.env };
|
|
2746
|
+
// These apply on every path: workers are non-interactive, so gh must fail
|
|
2747
|
+
// fast rather than block on an auth/update prompt — even a provided token can
|
|
2748
|
+
// be invalid/expired, in which case gh would otherwise try to prompt.
|
|
2749
|
+
env.GH_PROMPT_DISABLED = '1';
|
|
2750
|
+
env.GH_NO_UPDATE_NOTIFIER = '1';
|
|
2751
|
+
if (token) {
|
|
2752
|
+
env.GH_TOKEN = token;
|
|
2753
|
+
return env;
|
|
2754
|
+
}
|
|
2755
|
+
for (const k of ['GH_TOKEN', 'GITHUB_TOKEN', 'GH_ENTERPRISE_TOKEN', 'GITHUB_ENTERPRISE_TOKEN']) delete env[k];
|
|
2756
|
+
// Fail closed: always point gh at an isolated (empty) config dir so it can
|
|
2757
|
+
// never fall back to the operator's on-disk config/keychain. Set GH_CONFIG_DIR
|
|
2758
|
+
// unconditionally — even if mkdirSync fails, gh reading a missing/empty dir
|
|
2759
|
+
// errors out rather than silently authenticating as the operator, preserving
|
|
2760
|
+
// the "token-less job cannot act as the operator" guarantee.
|
|
2761
|
+
const dir = join(workspaceDir || tmpdir(), '.nano-gh-anon');
|
|
2762
|
+
env.GH_CONFIG_DIR = dir;
|
|
2763
|
+
try {
|
|
2764
|
+
mkdirSync(dir, { recursive: true });
|
|
2765
|
+
} catch {
|
|
2766
|
+
// Directory couldn't be created; GH_CONFIG_DIR still points at it so gh
|
|
2767
|
+
// fails closed rather than using ambient operator credentials.
|
|
2768
|
+
}
|
|
2769
|
+
return env;
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
// The agent whose authorship we record on the PR. Because commits are now
|
|
2773
|
+
// authored under the operator's own identity (so they satisfy CLA/DCO), this
|
|
2774
|
+
// comment preserves the provenance that the change was machine-generated.
|
|
2775
|
+
const AGENT_ATTRIBUTION_NAME = process.env.NANO_AGENT_NAME || 'nano-agent';
|
|
2776
|
+
const ATTRIBUTION_MARKER = '<!-- nano-agent-attribution -->';
|
|
2777
|
+
|
|
2778
|
+
// Post a one-time attribution comment on the agent-opened PR, recording that the
|
|
2779
|
+
// change was produced by the autonomous agent even though the commits carry the
|
|
2780
|
+
// operator's identity. Idempotent via a hidden marker so convergence's repeated
|
|
2781
|
+
// rounds don't spam the thread. Gated off with NANO_AGENT_ATTRIBUTION=0. Best
|
|
2782
|
+
// effort: never throws; returns a small status object.
|
|
2783
|
+
function postAgentAttribution({ workspaceDir, token, number, agentName = AGENT_ATTRIBUTION_NAME }) {
|
|
2784
|
+
if (!coerceBool(process.env.NANO_AGENT_ATTRIBUTION, true)) return { posted: false, reason: 'disabled' };
|
|
2785
|
+
if (!number) return { posted: false, reason: 'no-pr' };
|
|
2786
|
+
const env = ghAuthEnv(token, workspaceDir);
|
|
2787
|
+
try {
|
|
2788
|
+
// Ask jq for a single boolean ("marker present?") rather than streaming every
|
|
2789
|
+
// comment body back through stdout. On PRs with many/large comments the full
|
|
2790
|
+
// dump can be slow and can exceed spawnSync's output buffer (maxBuffer),
|
|
2791
|
+
// surfacing as existing.error and wedging attribution forever; a lone
|
|
2792
|
+
// true/false keeps output tiny while preserving idempotency.
|
|
2793
|
+
const markerFilter = `any((.comments // [])[].body; contains(${JSON.stringify(ATTRIBUTION_MARKER)}))`;
|
|
2794
|
+
const existing = spawnSync('gh', ['pr', 'view', String(number), '--json', 'comments', '--jq', markerFilter],
|
|
2795
|
+
{ cwd: workspaceDir, env, encoding: 'utf8', timeout: 30_000 });
|
|
2796
|
+
// Idempotency hinges on reliably reading the existing comments: if we cannot
|
|
2797
|
+
// verify whether the marker is already present (transient gh failure — rate
|
|
2798
|
+
// limit, auth glitch, timeout), do NOT post. Posting blind would let repeated
|
|
2799
|
+
// convergence rounds spam duplicate attribution comments. Bail out instead.
|
|
2800
|
+
if (existing.error) {
|
|
2801
|
+
return { posted: false, error: `gh not runnable: ${redactToken(existing.error.message, token).trim().slice(0, 200)}` };
|
|
2802
|
+
}
|
|
2803
|
+
if (existing.status !== 0) {
|
|
2804
|
+
return { posted: false, error: redactToken(existing.stderr || existing.stdout, token).trim().slice(0, 200) || `gh pr view failed (exit ${existing.status ?? 'null'}${existing.signal ? `, signal ${existing.signal}` : ''})` };
|
|
2805
|
+
}
|
|
2806
|
+
if ((existing.stdout || '').trim() === 'true') {
|
|
2807
|
+
return { posted: false, reason: 'exists' };
|
|
2808
|
+
}
|
|
2809
|
+
const body = `${ATTRIBUTION_MARKER}\n`
|
|
2810
|
+
+ `🤖 The changes in this PR were produced by **${agentName}**, an autonomous agent. `
|
|
2811
|
+
+ `Commits are authored under the operator's own git identity when one is resolvable (the human running the fleet), so they can satisfy CLA/DCO requirements; `
|
|
2812
|
+
+ `this note records that the work was generated by the agent.`;
|
|
2813
|
+
const r = spawnSync('gh', ['pr', 'comment', String(number), '--body', body],
|
|
2814
|
+
{ cwd: workspaceDir, env, encoding: 'utf8', timeout: 30_000 });
|
|
2815
|
+
if (r.error) {
|
|
2816
|
+
return { posted: false, error: `gh not runnable: ${redactToken(r.error.message, token).trim().slice(0, 200)}` };
|
|
2817
|
+
}
|
|
2818
|
+
if (r.status !== 0) {
|
|
2819
|
+
return { posted: false, error: redactToken(r.stderr || r.stdout, token).trim().slice(0, 200) || `gh pr comment failed (exit ${r.status ?? 'null'}${r.signal ? `, signal ${r.signal}` : ''})` };
|
|
2820
|
+
}
|
|
2821
|
+
return { posted: true };
|
|
2822
|
+
} catch (err) {
|
|
2823
|
+
return { posted: false, error: err.message };
|
|
2824
|
+
}
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2674
2827
|
// After the harness runs: enumerate new commits, push the branch (when
|
|
2675
2828
|
// branch.push), and reconcile the agent-opened PR (when task.allowPr). A push
|
|
2676
2829
|
// failure is reported (pushError) rather than thrown — the process model decides
|
|
@@ -2703,6 +2856,11 @@ function finalizeGit({ workspaceDir, gitEnv, startSha, workingBranch, envelope,
|
|
|
2703
2856
|
|
|
2704
2857
|
if (workingBranch && envelope.task?.allowPr) {
|
|
2705
2858
|
out.pr = reconcileAgentPr({ workspaceDir, token, branch: workingBranch, provider: envelope.repository?.provider || 'github' });
|
|
2859
|
+
// Record the agent's authorship on the PR (commits carry the operator's
|
|
2860
|
+
// identity now, so this preserves the machine-generated provenance).
|
|
2861
|
+
if (out.pr?.found && out.pr.number && (envelope.repository?.provider || 'github') === 'github') {
|
|
2862
|
+
out.attribution = postAgentAttribution({ workspaceDir, token, number: out.pr.number });
|
|
2863
|
+
}
|
|
2706
2864
|
}
|
|
2707
2865
|
return out;
|
|
2708
2866
|
}
|
|
@@ -3744,6 +3902,12 @@ const SUPERVISOR_PROBE_RESPONSE_TIMEOUT_MS = 2_000;
|
|
|
3744
3902
|
// Hard cap on a single connection's inbound buffer, so a misbehaving client
|
|
3745
3903
|
// can't grow the daemon's memory without bound with a newline-free frame.
|
|
3746
3904
|
const SUPERVISOR_MAX_FRAME_BYTES = 1 << 20; // 1 MiB
|
|
3905
|
+
// How often the daemon re-samples worker activity to push a refreshed status to
|
|
3906
|
+
// attached consoles. The push is change-gated (see supervisorStatusSignature),
|
|
3907
|
+
// so a quiet fleet stays silent; only real transitions (idle↔busy, a new job,
|
|
3908
|
+
// restart/exit) reprint the table. `NANO_SUPERVISOR_MONITOR_MS=0` disables the
|
|
3909
|
+
// live refresh (falling back to the attach-time snapshot + lifecycle events).
|
|
3910
|
+
const SUPERVISOR_MONITOR_INTERVAL_MS = 1_000;
|
|
3747
3911
|
|
|
3748
3912
|
// The `nano work` flags forwarded verbatim to each spawned child.
|
|
3749
3913
|
// kind: 'value' → `--flag v`; 'boolean' → `--flag`; 'list' → repeated `--flag v`.
|
|
@@ -4026,6 +4190,33 @@ function summarizeSupervisorWorker(w, now = Date.now()) {
|
|
|
4026
4190
|
};
|
|
4027
4191
|
}
|
|
4028
4192
|
|
|
4193
|
+
/**
|
|
4194
|
+
* A stable fingerprint of the fleet's *observable* state for change detection.
|
|
4195
|
+
* Deliberately excludes ticking durations (uptimeMs, per-job sinceMs) so that a
|
|
4196
|
+
* merely-elapsing clock doesn't count as a change — only real transitions (a
|
|
4197
|
+
* worker going up/down, idle↔busy, picking up/finishing a job, a restart) alter
|
|
4198
|
+
* the signature. The daemon uses this to push a refreshed status to attached
|
|
4199
|
+
* consoles only when something actually changed, keeping a quiet fleet silent.
|
|
4200
|
+
* `workers` is an array of `summarizeSupervisorWorker` results.
|
|
4201
|
+
*/
|
|
4202
|
+
function supervisorStatusSignature(workers) {
|
|
4203
|
+
const list = Array.isArray(workers) ? workers : [];
|
|
4204
|
+
return JSON.stringify(
|
|
4205
|
+
list.map((w) => [
|
|
4206
|
+
w.id,
|
|
4207
|
+
w.profile ?? '',
|
|
4208
|
+
w.state,
|
|
4209
|
+
w.pid ?? 0,
|
|
4210
|
+
Number(w.restarts) || 0,
|
|
4211
|
+
w.lastExit ?? '',
|
|
4212
|
+
w.activity ? w.activity.state : null,
|
|
4213
|
+
w.activity
|
|
4214
|
+
? w.activity.jobs.map((j) => `${j.key}\u0000${j.type ?? ''}`).sort()
|
|
4215
|
+
: null,
|
|
4216
|
+
]),
|
|
4217
|
+
);
|
|
4218
|
+
}
|
|
4219
|
+
|
|
4029
4220
|
/** One-line JOB cell for a status row: the serviced job key, `idle`, or `-`. */
|
|
4030
4221
|
function supervisorJobCell(w) {
|
|
4031
4222
|
if (w.state !== 'running') return '-';
|
|
@@ -4180,6 +4371,10 @@ async function runSupervisorDaemon() {
|
|
|
4180
4371
|
const workers = new Map();
|
|
4181
4372
|
const attachClients = new Set();
|
|
4182
4373
|
let shuttingDown = false;
|
|
4374
|
+
// Live-view monitor: tracks the last-broadcast fleet signature so we push a
|
|
4375
|
+
// refreshed status to attached consoles only on real change (see below).
|
|
4376
|
+
let monitorTimer = null;
|
|
4377
|
+
let lastMonitorSig = null;
|
|
4183
4378
|
|
|
4184
4379
|
// Daemon-wide mutation serialization: `add`/`remove`/`restart` must not
|
|
4185
4380
|
// interleave, or two clients racing the same worker could each spawn an
|
|
@@ -4343,11 +4538,15 @@ async function runSupervisorDaemon() {
|
|
|
4343
4538
|
return [...workers.values()].filter((w) => w.profile === t).map((w) => w.id);
|
|
4344
4539
|
};
|
|
4345
4540
|
|
|
4346
|
-
|
|
4541
|
+
// `pub` lets a caller that has already sampled the fleet (e.g. the monitor
|
|
4542
|
+
// tick, which needs the snapshot to compute its change signature) reuse that
|
|
4543
|
+
// exact snapshot for the frame — so the broadcast payload is guaranteed to
|
|
4544
|
+
// match the signature that decided to send it, with no second re-sample.
|
|
4545
|
+
const statusFrame = (final, pub) => ({
|
|
4347
4546
|
ok: true,
|
|
4348
4547
|
type: 'status',
|
|
4349
4548
|
daemon: { pid: process.pid, startedAt, socket: socketPath, logFile: daemonLogFile },
|
|
4350
|
-
workers: [...workers.values()].map(workerPublic),
|
|
4549
|
+
workers: pub || [...workers.values()].map(workerPublic),
|
|
4351
4550
|
...(final ? { final: true } : {}),
|
|
4352
4551
|
});
|
|
4353
4552
|
|
|
@@ -4357,6 +4556,7 @@ async function runSupervisorDaemon() {
|
|
|
4357
4556
|
// Let any in-flight mutation finish before we snapshot the worker set, so
|
|
4358
4557
|
// an add/restart racing the shutdown can't leave an orphaned child behind.
|
|
4359
4558
|
try { await opQueue; } catch { /* mutation already logged */ }
|
|
4559
|
+
if (monitorTimer) { try { clearInterval(monitorTimer); } catch { /* ignore */ } monitorTimer = null; }
|
|
4360
4560
|
dlog(`received ${signal || 'stop'} — stopping ${workers.size} worker(s)`);
|
|
4361
4561
|
await Promise.all([...workers.keys()].map((id) => stopWorker(id)));
|
|
4362
4562
|
broadcast({ type: 'event', event: 'daemon-stop' });
|
|
@@ -4497,6 +4697,37 @@ async function runSupervisorDaemon() {
|
|
|
4497
4697
|
dlog(`supervisor daemon up (pid ${process.pid}) — control ${socketPath}`);
|
|
4498
4698
|
persist();
|
|
4499
4699
|
|
|
4700
|
+
// Live-view refresh: periodically re-sample worker activity and push a fresh
|
|
4701
|
+
// status to attached consoles, but only when the fleet's observable state
|
|
4702
|
+
// actually changed since the last push (idle↔busy, a new/finished job, a
|
|
4703
|
+
// restart/exit). This keeps an attached `supervisor` console current without
|
|
4704
|
+
// spamming a quiet fleet. The signature always tracks the latest state (even
|
|
4705
|
+
// with no clients attached) so an idle-fleet attach — whose snapshot already
|
|
4706
|
+
// matches the tracked signature — won't provoke a redundant reprint for
|
|
4707
|
+
// everyone on the next tick. (A change that lands in the sub-tick window
|
|
4708
|
+
// *between* a tick and a fresh attach can still yield one extra identical
|
|
4709
|
+
// frame to the newcomer; that reprint is required to inform the already-
|
|
4710
|
+
// attached clients of the change, and is harmless — same content, re-rendered.)
|
|
4711
|
+
// Env-gated: NANO_SUPERVISOR_MONITOR_MS=0 disables; otherwise it's the cadence.
|
|
4712
|
+
const monitorMs = (() => {
|
|
4713
|
+
const raw = process.env.NANO_SUPERVISOR_MONITOR_MS;
|
|
4714
|
+
if (raw == null || raw === '') return SUPERVISOR_MONITOR_INTERVAL_MS;
|
|
4715
|
+
const n = Number(raw);
|
|
4716
|
+
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : SUPERVISOR_MONITOR_INTERVAL_MS;
|
|
4717
|
+
})();
|
|
4718
|
+
if (monitorMs > 0) {
|
|
4719
|
+
lastMonitorSig = supervisorStatusSignature([...workers.values()].map(workerPublic));
|
|
4720
|
+
monitorTimer = setInterval(() => {
|
|
4721
|
+
if (shuttingDown) return;
|
|
4722
|
+
const pub = [...workers.values()].map(workerPublic);
|
|
4723
|
+
const sig = supervisorStatusSignature(pub);
|
|
4724
|
+
const changed = sig !== lastMonitorSig;
|
|
4725
|
+
lastMonitorSig = sig;
|
|
4726
|
+
if (changed && attachClients.size > 0) broadcast(statusFrame(false, pub));
|
|
4727
|
+
}, monitorMs);
|
|
4728
|
+
if (typeof monitorTimer.unref === 'function') monitorTimer.unref();
|
|
4729
|
+
}
|
|
4730
|
+
|
|
4500
4731
|
// Keep the event loop alive indefinitely; the server holds it, but add an
|
|
4501
4732
|
// explicit never-resolving guard so a transient server close can't exit us.
|
|
4502
4733
|
await new Promise(() => {});
|
|
@@ -4828,10 +5059,12 @@ async function attachSupervisorConsole(state) {
|
|
|
4828
5059
|
sock.write(encodeFrame({ op: 'attach' }));
|
|
4829
5060
|
|
|
4830
5061
|
let buf = '';
|
|
5062
|
+
let rl = null;
|
|
4831
5063
|
sock.on('data', (chunk) => {
|
|
4832
5064
|
buf += chunk;
|
|
4833
5065
|
const { frames, rest } = decodeFrames(buf);
|
|
4834
5066
|
buf = rest;
|
|
5067
|
+
if (frames.length === 0) return;
|
|
4835
5068
|
for (const frame of frames) {
|
|
4836
5069
|
if (frame.type === 'status') {
|
|
4837
5070
|
out('');
|
|
@@ -4852,9 +5085,13 @@ async function attachSupervisorConsole(state) {
|
|
|
4852
5085
|
out(`! ${frame.error}`);
|
|
4853
5086
|
}
|
|
4854
5087
|
}
|
|
5088
|
+
// A pushed frame writes straight to stdout, stepping on the readline prompt
|
|
5089
|
+
// and any half-typed command. Re-render the prompt (preserving the input
|
|
5090
|
+
// buffer) so an async live-view refresh doesn't corrupt what the user typed.
|
|
5091
|
+
if (rl) { try { rl.prompt(true); } catch { /* ignore */ } }
|
|
4855
5092
|
});
|
|
4856
5093
|
|
|
4857
|
-
|
|
5094
|
+
rl = createReadline({ input: process.stdin, output: process.stdout, prompt: 'supervisor> ' });
|
|
4858
5095
|
rl.prompt();
|
|
4859
5096
|
|
|
4860
5097
|
await new Promise((resolve) => {
|
|
@@ -6320,11 +6557,14 @@ export {
|
|
|
6320
6557
|
provisionRepo,
|
|
6321
6558
|
finalizeGit,
|
|
6322
6559
|
reconcileAgentPr,
|
|
6560
|
+
resolveCommitterIdentity,
|
|
6561
|
+
postAgentAttribution,
|
|
6323
6562
|
reapAgentRunDirs,
|
|
6324
6563
|
authUrl,
|
|
6325
6564
|
githubCloneToken,
|
|
6326
6565
|
ghAuthTokenFromCli,
|
|
6327
6566
|
primeGhAuthToken,
|
|
6567
|
+
ghAuthEnv,
|
|
6328
6568
|
redactToken,
|
|
6329
6569
|
agentRunsRoot,
|
|
6330
6570
|
ProvisionError,
|
|
@@ -6358,6 +6598,7 @@ export {
|
|
|
6358
6598
|
formatDuration,
|
|
6359
6599
|
summarizeSupervisorWorker,
|
|
6360
6600
|
formatSupervisorStatus,
|
|
6601
|
+
supervisorStatusSignature,
|
|
6361
6602
|
supervisorJobCell,
|
|
6362
6603
|
supervisorWorkerActivityFile,
|
|
6363
6604
|
WORK_FORWARD_FLAGS,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "c8ctl-plugin-nano",
|
|
3
|
-
"version": "1.26.
|
|
3
|
+
"version": "1.26.3",
|
|
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.26.
|
|
51
|
-
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.26.
|
|
52
|
-
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.26.
|
|
53
|
-
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.26.
|
|
54
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.26.
|
|
55
|
-
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.26.
|
|
56
|
-
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.26.
|
|
50
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.26.3",
|
|
51
|
+
"@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.26.3",
|
|
52
|
+
"@nanobpm/c8ctl-plugin-nano-linux-x64": "1.26.3",
|
|
53
|
+
"@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.26.3",
|
|
54
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.26.3",
|
|
55
|
+
"@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.26.3",
|
|
56
|
+
"@nanobpm/c8ctl-plugin-nano-win32-x64": "1.26.3"
|
|
57
57
|
}
|
|
58
58
|
}
|