c8ctl-plugin-nano 1.35.5 → 1.36.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 (3) hide show
  1. package/README.md +30 -5
  2. package/c8ctl-plugin.js +211 -20
  3. package/package.json +8 -8
package/README.md CHANGED
@@ -485,7 +485,7 @@ stdin payload as `task`:
485
485
  Element templates emit flat dotpath header keys (strings); the plugin expands
486
486
  them into a nested object and coerces `"true"/"false"` → bool and numeric
487
487
  strings → int. The normalized shape is
488
- `{ schemaVersion, repository{provider,url,ref,depth,submodules,authRef}, branch{base,create,push}, setup{commands,env,secretRefs}, task{prompt,promptFile,maxIterations,timeoutMs,allowPr,prBase} }`.
488
+ `{ schemaVersion, repository{provider,url,ref,sha,depth,singleBranch,filter,baseRef,baseSha,cloneTimeoutMs,submodules,authRef}, branch{base,create,push}, setup{commands,env,secretRefs}, task{prompt,promptFile,maxIterations,timeoutMs,allowPr,prBase} }`.
489
489
 
490
490
  **Prompt = base + optional verbatim append.** The agent's prompt resolves to
491
491
  `task.prompt` (typically a model header filled at deploy time), falling back to a
@@ -531,8 +531,33 @@ the harness:
531
531
  1. resolve the optional repo credential (`repository.authRef`, or `GITHUB_TOKEN`
532
532
  for GitHub) — absent ⇒ anonymous clone;
533
533
  2. `git clone` (honouring `depth`/`submodules`, and `repository.ref`/`branch.base`
534
- as the checkout target) into a throwaway workspace under
535
- `<state>/agent-runs/run-*`;
534
+ as the checkout target). **`ref` is always a branch/tag name** (there is no hex
535
+ heuristic, so a legitimately hex-named branch like `deadbeef` is cloned via
536
+ `--branch`, never misread as a commit); to pin a **raw commit** use the
537
+ dedicated **`repository.sha`** field, which clones `ref`/`branch.base` (if any)
538
+ then fetches + checks the commit out as a detached HEAD. The clone lands in a
539
+ throwaway workspace under `<state>/agent-runs/run-*`. For a **huge monorepo**
540
+ the clone envelope can be
541
+ scoped so it finishes inside the clone timeout: **`singleBranch`** adds
542
+ `--single-branch` (fetch only `ref`, not every branch — a plain `clone --branch`
543
+ still pulls all branches/history); **`filter`** (e.g. `"blob:none"`) adds
544
+ `--filter=<spec>` for a partial/treeless clone (full commit graph, lazy blobs —
545
+ so `merge-base`/`git diff base...head` still work); **`baseRef`**/**`baseSha`**
546
+ additionally `git fetch` the base (respecting `depth`/`filter`) so a
547
+ single-branch/shallow clone can still diff `base...head` (exported as
548
+ `AGENT_REPO_BASE`). **`baseRef`** is always treated as a branch/tag name (even
549
+ one that looks hex-like) and is mapped into `refs/remotes/origin/<baseRef>`;
550
+ **`baseSha`** is the field for a raw commit SHA (fetched by id, exposed as the
551
+ SHA itself). `baseRef` and `baseSha` are **mutually exclusive** — setting both
552
+ is ambiguous, so the base fetch is skipped and a non-fatal `baseFetchError`
553
+ records the misconfiguration. A `--depth 1 --single-branch` of only the head
554
+ otherwise has NO
555
+ base and NO merge-base, so a naive `git diff main` fails. A failed base fetch
556
+ is **non-fatal** — the head clone still succeeds and
557
+ the failure is logged. **`cloneTimeoutMs`** overrides the clone/fetch timeout
558
+ per envelope (default 120s, or the `--clone-timeout` worker flag) as a backstop
559
+ for repos big enough to approach the cap even when shallow; a timeout is now
560
+ reported *as a timeout* rather than an opaque `exit 128`;
536
561
  3. create `branch.create` (if set) off that target;
537
562
  4. set a **committer identity** on the workspace, preferring the operator's own
538
563
  (`GIT_AUTHOR_*` env → global `git config user.name/email` → the
@@ -540,8 +565,8 @@ the harness:
540
565
  none resolve — so autonomous commits are authored by the human running the
541
566
  fleet (who has signed any CLA/DCO), not an anonymous bot;
542
567
  5. run the harness **in the workspace** (`cwd`), with `AGENT_WORKSPACE`,
543
- `AGENT_REPO_URL`, `AGENT_REPO_BRANCH`, `AGENT_REPO_REF` exported and the job
544
- envelope on stdin;
568
+ `AGENT_REPO_URL`, `AGENT_REPO_BRANCH`, `AGENT_REPO_REF`, `AGENT_REPO_BASE`
569
+ exported and the job envelope on stdin;
545
570
  6. on success, enumerate new commits, `git push` the branch when `branch.push`
546
571
  (default true), and — when `task.allowPr` — **reconcile the PR the agent
547
572
  opened** for the branch (`gh pr list --head <branch>`; `openedBy` reports the
package/c8ctl-plugin.js CHANGED
@@ -2294,7 +2294,25 @@ function normalizeTaskEnvelope(customHeaders, variables, opts = {}) {
2294
2294
  provider: (str(repo.provider) || 'github').toLowerCase(),
2295
2295
  url: str(repo.url),
2296
2296
  ref: str(repo.ref),
2297
+ // Dedicated field for a raw commit SHA to check out (detached), mirroring
2298
+ // baseSha/baseRef. `ref` is ALWAYS a branch/tag name — there is no hex
2299
+ // heuristic — so a legitimately hex-named branch (e.g. `deadbeef`) is
2300
+ // never misread as a commit; pin a commit via `sha` instead.
2301
+ sha: str(repo.sha),
2297
2302
  depth: coerceInt(repo.depth, undefined),
2303
+ // Scope the fetch to just `ref` (independent of depth) for callers that want
2304
+ // full history of one branch but not every branch of a huge monorepo.
2305
+ singleBranch: coerceBool(repo.singleBranch, false),
2306
+ // Partial/treeless clone spec (e.g. "blob:none"): full commit graph with
2307
+ // lazy blob fetch, so `merge-base` / `git diff base...head` still work.
2308
+ filter: str(repo.filter),
2309
+ // When a base branch/sha is supplied we additionally fetch it so a
2310
+ // single-branch/shallow clone can still diff `base...head`.
2311
+ baseRef: str(repo.baseRef),
2312
+ baseSha: str(repo.baseSha),
2313
+ // Per-envelope override of the clone/fetch timeout (ms) — a backstop for
2314
+ // repos big enough to approach the default 120s cap even when shallow.
2315
+ cloneTimeoutMs: coerceInt(repo.cloneTimeoutMs, undefined),
2298
2316
  submodules: coerceBool(repo.submodules, false),
2299
2317
  authRef: str(repo.authRef),
2300
2318
  };
@@ -2790,13 +2808,98 @@ function redactToken(text, token) {
2790
2808
  return s.replace(/(https?:\/\/)[^@/\s]+@/gi, '$1');
2791
2809
  }
2792
2810
 
2811
+ // Build an operator-actionable diagnostic from one or more runGit results.
2812
+ // git splits its output unpredictably across stdout/stderr, so preferring one
2813
+ // stream (`stderr || stdout`) can drop the only useful line — the root of the
2814
+ // "stub reason"/"opaque exit 128" incidents. Combine BOTH streams of every
2815
+ // command, redact the token, and always append status/signal context (from the
2816
+ // last, i.e. failing, command) so an empty-output failure still says something.
2817
+ function gitErrorDetail(results, token, limit = 500) {
2818
+ const list = Array.isArray(results) ? results : [results];
2819
+ const body = redactToken(
2820
+ list
2821
+ .flatMap((r) => [r?.stderr, r?.stdout])
2822
+ .map((s) => String(s ?? '').trim())
2823
+ .filter(Boolean)
2824
+ .join('\n'),
2825
+ token,
2826
+ ).trim().slice(0, limit);
2827
+ const last = list[list.length - 1] || {};
2828
+ const ctx = [];
2829
+ if (last.status != null) ctx.push(`exit ${last.status}`);
2830
+ if (last.signal) ctx.push(`signal ${last.signal}`);
2831
+ const ctxStr = ctx.length ? `(${ctx.join(', ')})` : '';
2832
+ return [body, ctxStr].filter(Boolean).join(' ') || 'unknown error';
2833
+ }
2834
+
2793
2835
  function runGit(args, { cwd, env, timeoutMs = 120_000 } = {}) {
2794
2836
  try {
2795
2837
  const r = spawnSync('git', args, { cwd, env, encoding: 'utf8', timeout: timeoutMs });
2796
- return { status: r.status ?? (r.signal ? 128 : null), stdout: r.stdout || '', stderr: r.stderr || '', signal: r.signal || null };
2838
+ // spawnSync does not throw on timeout it returns with `error.code` set to
2839
+ // 'ETIMEDOUT' and the child SIGTERM-killed (signal set, status null). Surface
2840
+ // that as `timedOut` so callers can report a timeout instead of an
2841
+ // uninformative "exit 128" (ties to #89).
2842
+ const timedOut = !!(r.error && r.error.code === 'ETIMEDOUT');
2843
+ // A non-timeout spawn failure (e.g. ENOENT when `git` is missing, EACCES)
2844
+ // comes back via `r.error` with empty stdout/stderr; discarding
2845
+ // `r.error.message` leaves callers reporting an empty/"unknown error"
2846
+ // detail. Fold the spawn error (code + message) into stderr so
2847
+ // `gitErrorDetail` still surfaces something actionable.
2848
+ let stderr = r.stderr || '';
2849
+ if (r.error && !timedOut) {
2850
+ const spawnMsg = [r.error.code, r.error.message].filter(Boolean).join(': ');
2851
+ stderr = [stderr.trim(), spawnMsg].filter(Boolean).join('\n');
2852
+ }
2853
+ return { status: r.status ?? (r.signal ? 128 : null), stdout: r.stdout || '', stderr, signal: r.signal || null, timedOut, timeoutMs };
2797
2854
  } catch (err) {
2798
- return { status: null, stdout: '', stderr: err.message, signal: null };
2855
+ return { status: null, stdout: '', stderr: err.message, signal: null, timedOut: false, timeoutMs };
2856
+ }
2857
+ }
2858
+
2859
+ // Bound a git output string without decapitating it: a hard `.slice(0, max)`
2860
+ // drops the tail, but the real reason can live at either end of a long
2861
+ // multiline git error (the `fatal:` up top, wrapping/hint detail below). Keep a
2862
+ // head+tail window joined by an elision marker so both survive.
2863
+ function boundGitOutput(text, max = 500) {
2864
+ const s = String(text ?? '').trim();
2865
+ if (s.length <= max) return s;
2866
+ const marker = ' […] ';
2867
+ // When max is too small to fit even the elision marker the head+tail budget
2868
+ // goes negative, making the slices behave unexpectedly and return MORE than
2869
+ // max — so hard-cap to max (never below zero) in that degenerate case.
2870
+ if (max <= marker.length) return s.slice(0, Math.max(0, max));
2871
+ const budget = max - marker.length;
2872
+ const head = Math.ceil(budget * 0.6);
2873
+ const tail = budget - head;
2874
+ return `${s.slice(0, head)}${marker}${s.slice(s.length - tail)}`;
2875
+ }
2876
+
2877
+ // Build an informative, token-redacted failure reason from a runGit result.
2878
+ // Two things the old `stderr || stdout`.slice(0,500) message threw away:
2879
+ // 1. On a timeout Node SIGTERM-kills git (status→128, signal='SIGTERM') — say
2880
+ // so, and after how long, instead of surfacing only git's flushed
2881
+ // "Cloning into '…'..." progress line as if it were the failure.
2882
+ // 2. `stderr || stdout` let a non-empty-but-useless stderr mask a real reason
2883
+ // on stdout — capture BOTH streams (stderr then stdout) so either survives.
2884
+ function describeGitFailure(action, result, { token, timeoutMs } = {}) {
2885
+ const combined = boundGitOutput(
2886
+ redactToken([result && result.stderr, result && result.stdout].filter(Boolean).join('\n'), token),
2887
+ );
2888
+ // spawnSync's timeout kill lands as SIGTERM (its default killSignal); runGit
2889
+ // maps the null status to 128. Only SIGTERM means "timed out" — any OTHER
2890
+ // signal (e.g. SIGKILL from an OOM kill) is a distinct termination that we
2891
+ // must not misreport as a timeout.
2892
+ const signal = result && result.signal;
2893
+ if (signal === 'SIGTERM') {
2894
+ const secs = timeoutMs ? Math.round(timeoutMs / 1000) : null;
2895
+ const dur = secs ? ` after ${secs}s` : '';
2896
+ return `${action} timed out${dur} (SIGTERM)${combined ? `; last output: ${combined}` : ''}`;
2799
2897
  }
2898
+ if (signal) {
2899
+ return `${action} terminated by signal ${signal}${combined ? `: ${combined}` : ''}`;
2900
+ }
2901
+ const exit = result && result.status != null ? result.status : '?';
2902
+ return `${action} failed (exit ${exit})${combined ? `: ${combined}` : ''}`;
2800
2903
  }
2801
2904
 
2802
2905
  // Write a GIT_ASKPASS helper that echoes $GIT_TOKEN, so the token reaches git
@@ -3069,30 +3172,108 @@ function provisionRepo({ envelope, token, runDir, timeoutMs = 120_000 }) {
3069
3172
  gitEnv.GIT_CONFIG_GLOBAL = devNull;
3070
3173
  }
3071
3174
 
3072
- const target = repo.ref || envelope.branch?.base || '';
3073
- // `git clone --branch` accepts a branch or tag name but NOT a raw commit SHA.
3074
- // For a SHA we clone the default branch, then fetch + check it out below.
3075
- const isSha = !!target && /^[0-9a-f]{7,40}$/i.test(target);
3175
+ const branchName = repo.ref || envelope.branch?.base || '';
3176
+ // A raw commit is requested ONLY via the dedicated `repository.sha` field
3177
+ // the sole unambiguous way to pin a commit. `ref` (→ `branchName`) is ALWAYS a
3178
+ // branch/tag name and is passed to `git clone --branch`; there is no hex
3179
+ // heuristic, so a legitimately hex-named branch like `deadbeef` is cloned as a
3180
+ // branch, not misread as a SHA. When a `sha` is given we clone `branchName`
3181
+ // (if any — the branch that should contain it) then fetch + detach onto it.
3182
+ const commitSha = repo.sha || '';
3183
+ // `sha` pins a raw commit and is passed to `git fetch origin <sha>` /
3184
+ // `git checkout --detach <sha>`. Validate it is a hex commit id (7–40 chars)
3185
+ // before use: this fails a misconfigured envelope fast and, because a hex id
3186
+ // can never start with `-`, forecloses a value being (mis)parsed as a git
3187
+ // option.
3188
+ if (commitSha && !/^[0-9a-f]{7,40}$/i.test(commitSha)) {
3189
+ throw new ProvisionError(`invalid repository.sha ${JSON.stringify(commitSha)} — expected a hex commit id (7–40 chars)`);
3190
+ }
3191
+ const isSha = !!commitSha;
3192
+ // Per-envelope timeout override (backstop for giant monorepos that approach the
3193
+ // default cap even when shallow); falls back to the caller-supplied timeout.
3194
+ const effectiveTimeoutMs = (repo.cloneTimeoutMs && repo.cloneTimeoutMs > 0) ? repo.cloneTimeoutMs : timeoutMs;
3076
3195
  const cloneArgs = [...credArgs(), 'clone', '--no-tags'];
3077
3196
  if (repo.depth && repo.depth > 0) cloneArgs.push('--depth', String(repo.depth));
3197
+ // `--single-branch` restricts the fetch to just `ref` — a plain `clone --branch`
3198
+ // still downloads every branch and all history. `--depth` implies this, but
3199
+ // honor it independently for a full-history single-branch clone.
3200
+ if (repo.singleBranch) cloneArgs.push('--single-branch');
3201
+ // Partial (blob-filtered) clone: full commit graph, lazy blobs — best fit for
3202
+ // reviewing a PR on a monorepo where a full checkout blows the timeout.
3203
+ if (repo.filter) cloneArgs.push(`--filter=${repo.filter}`);
3078
3204
  if (repo.submodules) cloneArgs.push('--recurse-submodules');
3079
- if (target && !isSha) cloneArgs.push('--branch', target);
3205
+ if (branchName) cloneArgs.push('--branch', branchName);
3080
3206
  const remote = authUrl(repo.url, repo.provider || 'github', !!token);
3081
3207
  cloneArgs.push(remote, workspaceDir);
3082
3208
 
3083
- const clone = runGit(cloneArgs, { env: gitEnv, timeoutMs });
3209
+ const clone = runGit(cloneArgs, { env: gitEnv, timeoutMs: effectiveTimeoutMs });
3084
3210
  if (clone.status !== 0) {
3085
- throw new ProvisionError(`git clone failed: ${redactToken(clone.stderr || clone.stdout, token).trim().slice(0, 500) || `exit ${clone.status}`}`);
3211
+ if (clone.timedOut) {
3212
+ // Preserve whatever git managed to print before SIGTERM (plus exit/signal
3213
+ // context) so a timeout is still diagnosable, not an opaque wall-clock hit.
3214
+ const detail = gitErrorDetail(clone, token);
3215
+ const detailNote = detail && detail !== 'unknown error' ? ` — last git output: ${detail}` : '';
3216
+ throw new ProvisionError(`git clone timed out after ${effectiveTimeoutMs}ms — the repo may be too large, or the network stalled; raise the timeout (repository.cloneTimeoutMs in the envelope, or the worker's --clone-timeout flag) or scope the clone with filter/singleBranch/depth${detailNote}`);
3217
+ }
3218
+ throw new ProvisionError(`git clone failed: ${gitErrorDetail(clone, token)}`);
3086
3219
  }
3087
3220
 
3088
3221
  if (isSha) {
3089
- // The SHA may not be present under a shallow clone of the default branch —
3090
- // fetch it explicitly (best effort), then check it out (detached HEAD).
3091
- const fetch = runGit([...credArgs(), 'fetch', '--no-tags', 'origin', target], { cwd: workspaceDir, env: gitEnv, timeoutMs });
3092
- const co = runGit(['checkout', '--detach', target], { cwd: workspaceDir, env: gitEnv });
3222
+ // The SHA may not be present under a shallow clone of the branch — fetch it
3223
+ // explicitly (best effort), then check it out (detached HEAD).
3224
+ const fetch = runGit([...credArgs(), 'fetch', '--no-tags', 'origin', commitSha], { cwd: workspaceDir, env: gitEnv, timeoutMs: effectiveTimeoutMs });
3225
+ const co = runGit(['checkout', '--detach', commitSha], { cwd: workspaceDir, env: gitEnv });
3093
3226
  if (co.status !== 0) {
3094
- const why = redactToken(co.stderr || fetch.stderr, token).trim().slice(0, 300);
3095
- throw new ProvisionError(`git checkout ${target} failed: ${why || `exit ${co.status}`}`);
3227
+ // Combine the fetch + checkout output (the real reason often lives in the
3228
+ // fetch), and annotate a fetch timeout explicitly so a slow `git fetch
3229
+ // origin <sha>` is not misread as an opaque checkout failure.
3230
+ const fetchNote = fetch.timedOut ? ` (preceding git fetch origin ${commitSha} timed out after ${effectiveTimeoutMs}ms)` : '';
3231
+ throw new ProvisionError(`git checkout ${commitSha} failed: ${gitErrorDetail([fetch, co], token, 300)}${fetchNote}`);
3232
+ }
3233
+ }
3234
+
3235
+ // Optional base fetch: with a single-branch/shallow clone the head has no base
3236
+ // and no merge-base, so a naive `git diff <base>` fails. When a base branch or
3237
+ // sha is supplied, fetch it (respecting depth/filter) into a remote-tracking
3238
+ // ref so the harness can compute `git diff origin/<base>...HEAD`. Best-effort:
3239
+ // a failed base fetch is recorded, not fatal (the head clone still succeeded).
3240
+ let base = '';
3241
+ let baseFetchError;
3242
+ // `baseRef` (branch/tag) and `baseSha` (raw commit) are mutually exclusive — a
3243
+ // caller picks one. If BOTH are set the envelope is ambiguous, so rather than
3244
+ // silently preferring one (a surprising `base...head` diff), skip the base
3245
+ // fetch and record a non-fatal diagnostic so the misconfiguration is visible.
3246
+ if (repo.baseSha && repo.baseRef) {
3247
+ baseFetchError = `ambiguous base: both baseRef (${repo.baseRef}) and baseSha (${repo.baseSha}) set — provide only one`;
3248
+ } else {
3249
+ const baseTarget = repo.baseSha || repo.baseRef;
3250
+ if (baseTarget) {
3251
+ const isBaseSha = !!repo.baseSha;
3252
+ const fetchArgs = [...credArgs(), 'fetch', '--no-tags'];
3253
+ if (repo.depth && repo.depth > 0) fetchArgs.push('--depth', String(repo.depth));
3254
+ if (repo.filter) fetchArgs.push(`--filter=${repo.filter}`);
3255
+ if (isBaseSha) {
3256
+ // A raw sha can't be mapped to a stable name — fetch it (updates FETCH_HEAD)
3257
+ // and expose the sha itself as the diff base.
3258
+ fetchArgs.push('origin', baseTarget);
3259
+ base = baseTarget;
3260
+ } else {
3261
+ // Map the branch onto refs/remotes/origin/<baseRef> so `origin/<base>`
3262
+ // resolves for the reviewer even on a single-branch clone.
3263
+ fetchArgs.push('origin', `+${baseTarget}:refs/remotes/origin/${baseTarget}`);
3264
+ base = `origin/${baseTarget}`;
3265
+ }
3266
+ const bf = runGit(fetchArgs, { cwd: workspaceDir, env: gitEnv, timeoutMs: effectiveTimeoutMs });
3267
+ if (bf.status !== 0) {
3268
+ // Name the base target (branch vs sha) so the warning is actionable when
3269
+ // multiple refs are in play, and preserve git's output/context in both the
3270
+ // timeout and non-timeout paths.
3271
+ const baseLabel = `${isBaseSha ? 'sha' : 'branch'} ${baseTarget}`;
3272
+ base = '';
3273
+ baseFetchError = bf.timedOut
3274
+ ? `base fetch (${baseLabel}) timed out after ${effectiveTimeoutMs}ms: ${gitErrorDetail(bf, token, 300)}`
3275
+ : `base fetch (${baseLabel}) failed: ${gitErrorDetail(bf, token, 300)}`;
3276
+ }
3096
3277
  }
3097
3278
  }
3098
3279
 
@@ -3127,8 +3308,8 @@ function provisionRepo({ envelope, token, runDir, timeoutMs = 120_000 }) {
3127
3308
  // finalizeGit skips the push/PR reconcile instead of pushing a bogus ref.
3128
3309
  let workingBranch = null;
3129
3310
  if (envelope.branch?.create) {
3130
- const cb = runGit(['checkout', '-B', envelope.branch.create], { cwd: workspaceDir, env: gitEnv });
3131
- if (cb.status !== 0) throw new ProvisionError(`git checkout -B ${envelope.branch.create} failed: ${redactToken(cb.stderr, token).trim().slice(0, 300)}`);
3311
+ const cb = runGit(['checkout', '-B', envelope.branch.create], { cwd: workspaceDir, env: gitEnv, timeoutMs });
3312
+ if (cb.status !== 0) throw new ProvisionError(describeGitFailure(`git checkout -B ${envelope.branch.create}`, cb, { token, timeoutMs }));
3132
3313
  workingBranch = envelope.branch.create;
3133
3314
  } else {
3134
3315
  const head = runGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: workspaceDir, env: gitEnv });
@@ -3139,7 +3320,7 @@ function provisionRepo({ envelope, token, runDir, timeoutMs = 120_000 }) {
3139
3320
  // `git rev-parse HEAD` on an unborn branch (freshly cloned empty repo) exits
3140
3321
  // non-zero and echoes the literal "HEAD" on stdout — treat that as "no base
3141
3322
  // commit" (empty startSha) rather than a bogus revision.
3142
- return { workspaceDir, gitEnv, committer, startSha: sha.status === 0 ? (sha.stdout || '').trim() : '', workingBranch, detached: !workingBranch, ref: target || '', remote: redactToken(repo.url, token) };
3323
+ return { workspaceDir, gitEnv, committer, startSha: sha.status === 0 ? (sha.stdout || '').trim() : '', workingBranch, detached: !workingBranch, ref: commitSha || branchName || '', base, baseFetchError, remote: redactToken(repo.url, token) };
3143
3324
  }
3144
3325
 
3145
3326
  // Look up a PR for this branch (2a does NOT open it — the harness does, driven
@@ -3278,9 +3459,10 @@ function finalizeGit({ workspaceDir, gitEnv, startSha, workingBranch, envelope,
3278
3459
  if (!workingBranch) {
3279
3460
  out.detached = true; // clone landed on a tag/sha ⇒ no branch to push
3280
3461
  } else if (coerceBool(envelope.branch?.push, true) && out.commits.length > 0) {
3281
- const push = runGit([...credArgs(), 'push', '--set-upstream', 'origin', workingBranch], { cwd: workspaceDir, env: gitEnv });
3462
+ const pushTimeoutMs = 120_000; // matches runGit's default; surfaced in a timeout reason
3463
+ const push = runGit([...credArgs(), 'push', '--set-upstream', 'origin', workingBranch], { cwd: workspaceDir, env: gitEnv, timeoutMs: pushTimeoutMs });
3282
3464
  if (push.status === 0) out.pushed = true;
3283
- else out.pushError = redactToken(push.stderr || push.stdout, token).trim().slice(0, 300) || `push exit ${push.status}`;
3465
+ else out.pushError = describeGitFailure('git push', push, { token, timeoutMs: pushTimeoutMs });
3284
3466
  }
3285
3467
 
3286
3468
  if (workingBranch && envelope.task?.allowPr) {
@@ -4670,12 +4852,19 @@ async function workAgent(req, flags) {
4670
4852
  runDir = mkdtempSync(join(agentRunsRoot(), 'run-'));
4671
4853
  liveRunDirs.add(runDir);
4672
4854
  provisioned = provisionRepo({ envelope, token: repoToken, runDir, timeoutMs: cloneTimeoutMs });
4855
+ if (provisioned.baseFetchError) {
4856
+ logger.warn(`[${jobType}] job ${job.jobKey} base fetch failed — ${provisioned.baseFetchError}; base...head diffs may be unavailable`);
4857
+ }
4673
4858
  cwd = provisioned.workspaceDir;
4674
4859
  extraEnv = {
4675
4860
  AGENT_WORKSPACE: provisioned.workspaceDir,
4676
4861
  AGENT_REPO_URL: provisioned.remote,
4677
4862
  AGENT_REPO_BRANCH: provisioned.workingBranch || '',
4678
4863
  AGENT_REPO_REF: provisioned.ref || '',
4864
+ // The fetched base ref (e.g. `origin/main` or a base sha) for
4865
+ // computing `git diff <base>...HEAD`; empty when none was requested
4866
+ // or the base fetch failed (see provisioned.baseFetchError).
4867
+ AGENT_REPO_BASE: provisioned.base || '',
4679
4868
  // Pin the harness's commit identity to the resolved (placeholder-
4680
4869
  // sanitized) committer so the agent's own `git commit` can't be
4681
4870
  // hijacked by a placeholder GIT_AUTHOR_* inherited from process.env
@@ -8388,6 +8577,8 @@ export {
8388
8577
  startLockExtender,
8389
8578
  provisionRepo,
8390
8579
  finalizeGit,
8580
+ describeGitFailure,
8581
+ boundGitOutput,
8391
8582
  reconcileAgentPr,
8392
8583
  resolveCommitterIdentity,
8393
8584
  isPlaceholderEmail,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.35.5",
3
+ "version": "1.36.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",
@@ -57,12 +57,12 @@
57
57
  },
58
58
  "optionalDependencies": {
59
59
  "node-pty": "^1.0.0",
60
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.35.5",
61
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.35.5",
62
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.35.5",
63
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.35.5",
64
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.35.5",
65
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.35.5",
66
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.35.5"
60
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.36.0",
61
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.36.0",
62
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.36.0",
63
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.36.0",
64
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.36.0",
65
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.36.0",
66
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.36.0"
67
67
  }
68
68
  }