@cat-factory/executor-harness 1.31.4 → 1.31.8

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/dist/agent.js CHANGED
@@ -533,6 +533,24 @@ async function runCodingMode(job, opts) {
533
533
  ...(job.repo.provider ? { provider: job.repo.provider } : {}),
534
534
  signal: opts.signal,
535
535
  });
536
+ // `null` ⇒ the branch has nothing ahead of base, so there was no PR to open (a resumed
537
+ // branch whose earlier PR already merged). Record it as a clean no-op rather than a push,
538
+ // mirroring the no-changes outcome — the `runCodingAgent` guard normally catches this, so
539
+ // this is the belt-and-suspenders path when the ahead-of-base check couldn't determine it.
540
+ if (prUrl === null) {
541
+ if (job.noChangesIsError === false) {
542
+ return { pushed: false, branch: pushBranch, summary, stats, ...(usage ? { usage } : {}) };
543
+ }
544
+ return {
545
+ pushed: false,
546
+ branch: pushBranch,
547
+ summary,
548
+ stats,
549
+ error: noChangesReason('the work branch has no commits ahead of its base (nothing to open a PR for)', stats, stderrTail),
550
+ failureCause: 'no-changes',
551
+ ...(usage ? { usage } : {}),
552
+ };
553
+ }
536
554
  return { pushed: true, prUrl, branch: pushBranch, summary, stats, ...(usage ? { usage } : {}) };
537
555
  }
538
556
  return { pushed: true, branch: pushBranch, summary, stats, ...(usage ? { usage } : {}) };
@@ -1,6 +1,6 @@
1
1
  import { mkdir } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
- import { branchHasCommitsSince, cloneExistingBranch, cloneRepo, commitTrackedEdits, createBranch, excludeFromGit, headCommit, listUntrackedFiles, prepareExistingCheckout, pushBranch, refreshFromBaseIfClean, remoteBranchExists, } from './git.js';
3
+ import { branchAheadOfBase, branchHasCommitsSince, cloneExistingBranch, cloneRepo, commitTrackedEdits, createBranch, excludeFromGit, headCommit, listUntrackedFiles, prepareExistingCheckout, pushBranch, refreshFromBaseIfClean, remoteBranchExists, } from './git.js';
4
4
  import { FOLLOW_UPS_FILENAME, FollowUpTailer } from './follow-ups.js';
5
5
  import { acquireRepoCheckout, agentNeverActed, agentOutputTail, runAgentInWorkspace, } from './pi-workspace.js';
6
6
  import { log } from './logger.js';
@@ -238,7 +238,24 @@ export async function runCodingAgent(spec, opts = {}) {
238
238
  files: leftover.slice(0, 20),
239
239
  });
240
240
  }
241
- const hasWork = resumed || (await branchHasCommitsSince(dir, baseSha, signal));
241
+ // A fresh run produced work iff the branch advanced past its pre-run tip. A RESUMED
242
+ // run already carries prior work — UNLESS that branch turns out to have nothing ahead
243
+ // of the PR base (e.g. its earlier PR was merged with a merge commit, leaving the
244
+ // branch reachable from base and its best-effort delete skipped). Opening a PR for such
245
+ // a branch fails with GitHub's opaque 422 "No commits between ...", so a CONFIRMED-empty
246
+ // resumed branch is a no-op, not work. `undefined` (couldn't determine) keeps the prior
247
+ // resume-is-work behaviour; the PR-open path then no-ops on the 422 as a backstop.
248
+ const advancedThisPass = await branchHasCommitsSince(dir, baseSha, signal);
249
+ let hasWork = advancedThisPass || resumed;
250
+ if (resumed && !advancedThisPass) {
251
+ const ahead = await branchAheadOfBase(dir, spec.repo.baseBranch, spec.ghToken, signal);
252
+ if (ahead === false) {
253
+ logger.info('coding-agent: resumed branch has no commits ahead of base — no-op', {
254
+ base: spec.repo.baseBranch,
255
+ });
256
+ hasWork = false;
257
+ }
258
+ }
242
259
  if (!hasWork) {
243
260
  logger.info('coding-agent: no changes produced', { ...stats });
244
261
  outcome = {
package/dist/git.js CHANGED
@@ -38,15 +38,82 @@ const GIT_EMAIL = 'cat-factory[bot]@users.noreply.github.com';
38
38
  const GIT_TIMEOUT_MARGIN_MS = 3 * 60_000;
39
39
  const GIT_TIMEOUT_FLOOR_MS = 60_000;
40
40
  const GIT_TIMEOUT_MS = Math.max(GIT_TIMEOUT_FLOOR_MS, loadRunnerLimits().inactivityMs - GIT_TIMEOUT_MARGIN_MS);
41
- /** Wrap an error so its message/stack carry no credentials. */
42
- function redactError(err) {
43
- if (err instanceof Error) {
44
- const redacted = new Error(redactSecrets(err.message));
45
- if (err.stack)
46
- redacted.stack = redactSecrets(err.stack);
47
- return redacted;
41
+ // Config prefixed to EVERY git invocation to force fully non-interactive authentication.
42
+ //
43
+ // WHY: in native local mode the harness runs as a plain host process, so `git` inherits the
44
+ // developer's host git config. On Windows that config has `credential.helper=manager` (Git
45
+ // Credential Manager), and git consults its credential helpers BEFORE ever reaching
46
+ // `GIT_ASKPASS`. GCM then pops up an interactive OS auth dialog on clone/fetch/push — which in
47
+ // an autonomous, non-interactive run either steals focus with a stray window or, when the
48
+ // dialog is modal, blocks the git process until it hits GIT_TIMEOUT_MS and is killed (the
49
+ // classic "git push hung for ~7 minutes then failed" symptom).
50
+ //
51
+ // Emptying the helper list (`credential.helper=` with no value RESETS the multi-valued config,
52
+ // dropping the system/global/local helpers) removes GCM from the chain, so git falls back to
53
+ // the harness's own askpass helper — which returns the per-job PAT we already hold (see
54
+ // `authEnv`). `credential.interactive=false` is belt-and-suspenders for any backend that still
55
+ // runs. The token is never in argv; only this non-secret config is.
56
+ const NON_INTERACTIVE_CREDENTIAL_ARGS = [
57
+ '-c',
58
+ 'credential.helper=',
59
+ '-c',
60
+ 'credential.interactive=false',
61
+ ];
62
+ /**
63
+ * Env applied to git commands that DON'T carry {@link authEnv} (local ops like config/checkout/
64
+ * rev-parse). Keeps them from ever going interactive too — `GIT_TERMINAL_PROMPT=0` blocks the
65
+ * terminal prompt and `GCM_INTERACTIVE=never` blocks a Git Credential Manager popup even if a
66
+ * helper somehow survives. `authEnv` sets the same pair for the network ops.
67
+ */
68
+ function nonInteractiveGitEnv() {
69
+ return { ...process.env, GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never' };
70
+ }
71
+ /**
72
+ * Whether `err` is a per-command TIMEOUT kill (the child exceeded `execFile`'s `timeout`, so
73
+ * Node killed it with `killSignal` and set `killed=true`) — as opposed to a normal non-zero
74
+ * exit or a watchdog/caller abort. `aborted` is the caller signal's state: an abort ALSO
75
+ * kills the child, but it's the outer watchdog's story (recorded via `killReason` upstream),
76
+ * so it must NOT be reported here as a git timeout. Pure, so the classification is unit-tested.
77
+ */
78
+ export function isGitTimeoutKill(err, aborted) {
79
+ if (aborted)
80
+ return false;
81
+ const e = err;
82
+ if (e?.name === 'AbortError')
83
+ return false;
84
+ return e?.killed === true && e?.signal != null;
85
+ }
86
+ /** The first non-flag token of a git argv (the subcommand — `push`/`clone`/…), for messages. */
87
+ function gitSubcommand(args) {
88
+ return args.find((a) => a !== '' && !a.startsWith('-')) ?? 'command';
89
+ }
90
+ /**
91
+ * Wrap a git failure into a credential-scrubbed {@link HarnessFailure}('git') with an ACCURATE
92
+ * message. Three cases the old bare "Command failed: git …" collapsed together:
93
+ * - a per-command timeout kill → say it STALLED (and name the usual causes) instead of a blank
94
+ * "Command failed", so a hung push/clone reads as a timeout rather than a mystery rejection;
95
+ * - a real non-zero exit → fold in git's `stderr` (execFile puts the actual reason THERE, not
96
+ * on `.message`, which is only "Command failed: <cmd>"), so the surfaced error has content;
97
+ * - anything else → the scrubbed message.
98
+ */
99
+ function gitFailure(err, args, aborted) {
100
+ const e = err;
101
+ if (isGitTimeoutKill(err, aborted)) {
102
+ const failure = new HarnessFailure('git', redactSecrets(`git ${gitSubcommand(args)} timed out after ${Math.round(GIT_TIMEOUT_MS / 1000)}s with no ` +
103
+ 'progress — the operation stalled. Likely a very large clone/push, a slow or blocked ' +
104
+ 'network, or an interactive credential prompt (e.g. a Git Credential Manager popup) that ' +
105
+ 'a non-interactive run cannot answer.'));
106
+ if (e?.stack)
107
+ failure.stack = redactSecrets(e.stack);
108
+ return failure;
48
109
  }
49
- return new Error(redactSecrets(String(err)));
110
+ const stderr = typeof e?.stderr === 'string' ? e.stderr : (e?.stderr?.toString() ?? '');
111
+ const base = e instanceof Error ? e.message : String(err);
112
+ const combined = stderr.trim() ? `${base}\n${stderr.trim()}` : base;
113
+ const failure = new HarnessFailure('git', redactSecrets(combined));
114
+ if (e?.stack)
115
+ failure.stack = redactSecrets(e.stack);
116
+ return failure;
50
117
  }
51
118
  /**
52
119
  * Build the remote URL git uses. Only the username (`x-access-token`) is embedded
@@ -89,8 +156,11 @@ async function authEnv(ghToken) {
89
156
  ...process.env,
90
157
  GIT_ASKPASS: await ensureAskpass(),
91
158
  GIT_ASKPASS_TOKEN: ghToken,
92
- // Never fall back to an interactive prompt (which would hang the job).
159
+ // Never fall back to an interactive prompt / GUI credential dialog (which would hang the
160
+ // job or steal focus). Paired with the emptied credential helper in the git argv, this is
161
+ // what keeps a native-mode run from ever surfacing a Git Credential Manager popup.
93
162
  GIT_TERMINAL_PROMPT: '0',
163
+ GCM_INTERACTIVE: 'never',
94
164
  };
95
165
  }
96
166
  /**
@@ -100,12 +170,16 @@ async function authEnv(ghToken) {
100
170
  * and stack scrubbed of credentials.
101
171
  */
102
172
  async function git(args, opts = {}) {
173
+ // Force non-interactive auth on EVERY git op: empty the credential-helper list (drops the
174
+ // host's Git Credential Manager, whose popup otherwise steals focus or hangs the command)
175
+ // and, for ops without the auth env, still block a terminal/GCM prompt. See the notes on
176
+ // NON_INTERACTIVE_CREDENTIAL_ARGS / authEnv above.
103
177
  try {
104
- const { stdout } = await exec('git', args, {
178
+ const { stdout } = await exec('git', [...NON_INTERACTIVE_CREDENTIAL_ARGS, ...args], {
105
179
  ...(opts.cwd ? { cwd: opts.cwd } : {}),
106
180
  maxBuffer: 16 * 1024 * 1024,
107
181
  timeout: GIT_TIMEOUT_MS,
108
- ...(opts.env ? { env: opts.env } : {}),
182
+ env: opts.env ?? nonInteractiveGitEnv(),
109
183
  ...(opts.signal ? { signal: opts.signal } : {}),
110
184
  });
111
185
  return stdout;
@@ -114,11 +188,7 @@ async function git(args, opts = {}) {
114
188
  // Tag the failure as `git` so the registry's catch records the real cause instead of
115
189
  // the generic `agent`. A watchdog abort still wins: `describeFailure` keys off
116
190
  // `killReason` first, so an abort during a git op keeps the timeout message/cause.
117
- const redacted = redactError(err);
118
- const failure = new HarnessFailure('git', redacted.message);
119
- if (redacted.stack)
120
- failure.stack = redacted.stack;
121
- throw failure;
191
+ throw gitFailure(err, args, opts.signal?.aborted === true);
122
192
  }
123
193
  }
124
194
  /** Clone `repo`'s base branch (shallow by default) into `dir` and set commit identity. */
@@ -325,6 +395,36 @@ export async function excludeFromGit(dir, pattern, signal) {
325
395
  export async function branchHasCommitsSince(dir, baseSha, signal) {
326
396
  return (await headCommit(dir, signal)) !== baseSha;
327
397
  }
398
+ /**
399
+ * Whether the checked-out branch carries at least one commit the PR base does NOT — i.e.
400
+ * `git rev-list --count <base>..HEAD > 0`. A resume clone is single-branch, so it has no
401
+ * `origin/<base>` tracking ref; this fetches the base into a dedicated local ref first and
402
+ * diffs HEAD against it.
403
+ *
404
+ * Tri-state on purpose:
405
+ * - `true` — confirmed ≥1 commit ahead (there is something to open a PR for).
406
+ * - `false` — confirmed 0 commits ahead (the branch is reachable from base, e.g. its earlier
407
+ * PR was merged with a merge commit and the best-effort branch delete was skipped).
408
+ * - `undefined` — could not determine (fetch / rev-list error); the caller keeps its prior
409
+ * behaviour rather than wrongly dropping a resumed branch that has real work.
410
+ *
411
+ * Used by the resume path to avoid declaring a merged/empty branch as work and then failing
412
+ * the run with GitHub's opaque 422 "No commits between <base> and <branch>".
413
+ */
414
+ export async function branchAheadOfBase(dir, baseBranch, ghToken, signal) {
415
+ try {
416
+ await git(['fetch', 'origin', `+refs/heads/${baseBranch}:refs/cat-factory/base`], {
417
+ cwd: dir,
418
+ signal,
419
+ env: await authEnv(ghToken),
420
+ });
421
+ const count = (await git(['rev-list', '--count', 'refs/cat-factory/base..HEAD'], { cwd: dir, signal })).trim();
422
+ return Number(count) > 0;
423
+ }
424
+ catch {
425
+ return undefined;
426
+ }
427
+ }
328
428
  /**
329
429
  * Whether the checked-out branch has a real, examinable diff against
330
430
  * `origin/<baseBranch>` — i.e. the base branch's remote-tracking ref exists (so the
@@ -679,6 +779,13 @@ export async function openPullRequest(opts) {
679
779
  if (existing)
680
780
  return existing;
681
781
  }
782
+ // The head branch has nothing ahead of base ("No commits between <base> and <head>").
783
+ // That is not an API failure — there is simply nothing to open a PR for (e.g. a resumed
784
+ // branch whose earlier PR was merged with a merge commit, leaving the branch reachable
785
+ // from base). Signal it with null so the caller records a clean no-op instead of failing
786
+ // the run with GitHub's opaque 422.
787
+ if (res.status === 422 && /no commits between/i.test(detail))
788
+ return null;
682
789
  throw new HarnessFailure('api', redactSecrets(`Failed to open PR (HTTP ${res.status}): ${detail.slice(0, 300)}`));
683
790
  }
684
791
  const body = (await res.json());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.31.4",
3
+ "version": "1.31.8",
4
4
  "description": "Container payload: a thin TypeScript wrapper that runs the Pi coding agent against a cloned repo and opens a PR. Runs in the Cloudflare Container (and, in local native mode, as a host process); carries no secrets.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,8 +26,8 @@
26
26
  "hono": "^4.12.27",
27
27
  "typescript": "^6.0.3",
28
28
  "vitest": "^4.1.9",
29
- "@cat-factory/server": "0.66.1",
30
- "@cat-factory/spend": "0.10.69"
29
+ "@cat-factory/server": "0.66.4",
30
+ "@cat-factory/spend": "0.10.70"
31
31
  },
32
32
  "scripts": {
33
33
  "build": "tsc -p tsconfig.json",
package/src/agent.ts CHANGED
@@ -626,6 +626,28 @@ async function runCodingMode(job: AgentJob, opts: RunOptions): Promise<AgentResu
626
626
  ...(job.repo.provider ? { provider: job.repo.provider } : {}),
627
627
  signal: opts.signal,
628
628
  })
629
+ // `null` ⇒ the branch has nothing ahead of base, so there was no PR to open (a resumed
630
+ // branch whose earlier PR already merged). Record it as a clean no-op rather than a push,
631
+ // mirroring the no-changes outcome — the `runCodingAgent` guard normally catches this, so
632
+ // this is the belt-and-suspenders path when the ahead-of-base check couldn't determine it.
633
+ if (prUrl === null) {
634
+ if (job.noChangesIsError === false) {
635
+ return { pushed: false, branch: pushBranch, summary, stats, ...(usage ? { usage } : {}) }
636
+ }
637
+ return {
638
+ pushed: false,
639
+ branch: pushBranch,
640
+ summary,
641
+ stats,
642
+ error: noChangesReason(
643
+ 'the work branch has no commits ahead of its base (nothing to open a PR for)',
644
+ stats,
645
+ stderrTail,
646
+ ),
647
+ failureCause: 'no-changes',
648
+ ...(usage ? { usage } : {}),
649
+ }
650
+ }
629
651
  return { pushed: true, prUrl, branch: pushBranch, summary, stats, ...(usage ? { usage } : {}) }
630
652
  }
631
653
  return { pushed: true, branch: pushBranch, summary, stats, ...(usage ? { usage } : {}) }
@@ -2,6 +2,7 @@ import { mkdir } from 'node:fs/promises'
2
2
  import { join } from 'node:path'
3
3
  import type { HarnessAuthFields, RepoSpec } from './job.js'
4
4
  import {
5
+ branchAheadOfBase,
5
6
  branchHasCommitsSince,
6
7
  cloneExistingBranch,
7
8
  cloneRepo,
@@ -343,7 +344,24 @@ export async function runCodingAgent(
343
344
  })
344
345
  }
345
346
 
346
- const hasWork = resumed || (await branchHasCommitsSince(dir, baseSha, signal))
347
+ // A fresh run produced work iff the branch advanced past its pre-run tip. A RESUMED
348
+ // run already carries prior work — UNLESS that branch turns out to have nothing ahead
349
+ // of the PR base (e.g. its earlier PR was merged with a merge commit, leaving the
350
+ // branch reachable from base and its best-effort delete skipped). Opening a PR for such
351
+ // a branch fails with GitHub's opaque 422 "No commits between ...", so a CONFIRMED-empty
352
+ // resumed branch is a no-op, not work. `undefined` (couldn't determine) keeps the prior
353
+ // resume-is-work behaviour; the PR-open path then no-ops on the 422 as a backstop.
354
+ const advancedThisPass = await branchHasCommitsSince(dir, baseSha, signal)
355
+ let hasWork = advancedThisPass || resumed
356
+ if (resumed && !advancedThisPass) {
357
+ const ahead = await branchAheadOfBase(dir, spec.repo.baseBranch, spec.ghToken, signal)
358
+ if (ahead === false) {
359
+ logger.info('coding-agent: resumed branch has no commits ahead of base — no-op', {
360
+ base: spec.repo.baseBranch,
361
+ })
362
+ hasWork = false
363
+ }
364
+ }
347
365
  if (!hasWork) {
348
366
  logger.info('coding-agent: no changes produced', { ...stats })
349
367
  outcome = {
package/src/git.ts CHANGED
@@ -48,14 +48,96 @@ const GIT_TIMEOUT_MS = Math.max(
48
48
  loadRunnerLimits().inactivityMs - GIT_TIMEOUT_MARGIN_MS,
49
49
  )
50
50
 
51
- /** Wrap an error so its message/stack carry no credentials. */
52
- function redactError(err: unknown): Error {
53
- if (err instanceof Error) {
54
- const redacted = new Error(redactSecrets(err.message))
55
- if (err.stack) redacted.stack = redactSecrets(err.stack)
56
- return redacted
51
+ // Config prefixed to EVERY git invocation to force fully non-interactive authentication.
52
+ //
53
+ // WHY: in native local mode the harness runs as a plain host process, so `git` inherits the
54
+ // developer's host git config. On Windows that config has `credential.helper=manager` (Git
55
+ // Credential Manager), and git consults its credential helpers BEFORE ever reaching
56
+ // `GIT_ASKPASS`. GCM then pops up an interactive OS auth dialog on clone/fetch/push — which in
57
+ // an autonomous, non-interactive run either steals focus with a stray window or, when the
58
+ // dialog is modal, blocks the git process until it hits GIT_TIMEOUT_MS and is killed (the
59
+ // classic "git push hung for ~7 minutes then failed" symptom).
60
+ //
61
+ // Emptying the helper list (`credential.helper=` with no value RESETS the multi-valued config,
62
+ // dropping the system/global/local helpers) removes GCM from the chain, so git falls back to
63
+ // the harness's own askpass helper — which returns the per-job PAT we already hold (see
64
+ // `authEnv`). `credential.interactive=false` is belt-and-suspenders for any backend that still
65
+ // runs. The token is never in argv; only this non-secret config is.
66
+ const NON_INTERACTIVE_CREDENTIAL_ARGS = [
67
+ '-c',
68
+ 'credential.helper=',
69
+ '-c',
70
+ 'credential.interactive=false',
71
+ ]
72
+
73
+ /**
74
+ * Env applied to git commands that DON'T carry {@link authEnv} (local ops like config/checkout/
75
+ * rev-parse). Keeps them from ever going interactive too — `GIT_TERMINAL_PROMPT=0` blocks the
76
+ * terminal prompt and `GCM_INTERACTIVE=never` blocks a Git Credential Manager popup even if a
77
+ * helper somehow survives. `authEnv` sets the same pair for the network ops.
78
+ */
79
+ function nonInteractiveGitEnv(): NodeJS.ProcessEnv {
80
+ return { ...process.env, GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never' }
81
+ }
82
+
83
+ /** The shape `execFile` decorates its rejection with — the bits we read to classify a failure. */
84
+ type ExecError = Error & {
85
+ killed?: boolean
86
+ signal?: NodeJS.Signals | null
87
+ code?: number | string | null
88
+ stderr?: string | Buffer
89
+ stdout?: string | Buffer
90
+ }
91
+
92
+ /**
93
+ * Whether `err` is a per-command TIMEOUT kill (the child exceeded `execFile`'s `timeout`, so
94
+ * Node killed it with `killSignal` and set `killed=true`) — as opposed to a normal non-zero
95
+ * exit or a watchdog/caller abort. `aborted` is the caller signal's state: an abort ALSO
96
+ * kills the child, but it's the outer watchdog's story (recorded via `killReason` upstream),
97
+ * so it must NOT be reported here as a git timeout. Pure, so the classification is unit-tested.
98
+ */
99
+ export function isGitTimeoutKill(err: unknown, aborted: boolean): boolean {
100
+ if (aborted) return false
101
+ const e = err as ExecError
102
+ if (e?.name === 'AbortError') return false
103
+ return e?.killed === true && e?.signal != null
104
+ }
105
+
106
+ /** The first non-flag token of a git argv (the subcommand — `push`/`clone`/…), for messages. */
107
+ function gitSubcommand(args: string[]): string {
108
+ return args.find((a) => a !== '' && !a.startsWith('-')) ?? 'command'
109
+ }
110
+
111
+ /**
112
+ * Wrap a git failure into a credential-scrubbed {@link HarnessFailure}('git') with an ACCURATE
113
+ * message. Three cases the old bare "Command failed: git …" collapsed together:
114
+ * - a per-command timeout kill → say it STALLED (and name the usual causes) instead of a blank
115
+ * "Command failed", so a hung push/clone reads as a timeout rather than a mystery rejection;
116
+ * - a real non-zero exit → fold in git's `stderr` (execFile puts the actual reason THERE, not
117
+ * on `.message`, which is only "Command failed: <cmd>"), so the surfaced error has content;
118
+ * - anything else → the scrubbed message.
119
+ */
120
+ function gitFailure(err: unknown, args: string[], aborted: boolean): HarnessFailure {
121
+ const e = err as ExecError
122
+ if (isGitTimeoutKill(err, aborted)) {
123
+ const failure = new HarnessFailure(
124
+ 'git',
125
+ redactSecrets(
126
+ `git ${gitSubcommand(args)} timed out after ${Math.round(GIT_TIMEOUT_MS / 1000)}s with no ` +
127
+ 'progress — the operation stalled. Likely a very large clone/push, a slow or blocked ' +
128
+ 'network, or an interactive credential prompt (e.g. a Git Credential Manager popup) that ' +
129
+ 'a non-interactive run cannot answer.',
130
+ ),
131
+ )
132
+ if (e?.stack) failure.stack = redactSecrets(e.stack)
133
+ return failure
57
134
  }
58
- return new Error(redactSecrets(String(err)))
135
+ const stderr = typeof e?.stderr === 'string' ? e.stderr : (e?.stderr?.toString() ?? '')
136
+ const base = e instanceof Error ? e.message : String(err)
137
+ const combined = stderr.trim() ? `${base}\n${stderr.trim()}` : base
138
+ const failure = new HarnessFailure('git', redactSecrets(combined))
139
+ if (e?.stack) failure.stack = redactSecrets(e.stack)
140
+ return failure
59
141
  }
60
142
 
61
143
  /**
@@ -102,8 +184,11 @@ async function authEnv(ghToken: string): Promise<NodeJS.ProcessEnv> {
102
184
  ...process.env,
103
185
  GIT_ASKPASS: await ensureAskpass(),
104
186
  GIT_ASKPASS_TOKEN: ghToken,
105
- // Never fall back to an interactive prompt (which would hang the job).
187
+ // Never fall back to an interactive prompt / GUI credential dialog (which would hang the
188
+ // job or steal focus). Paired with the emptied credential helper in the git argv, this is
189
+ // what keeps a native-mode run from ever surfacing a Git Credential Manager popup.
106
190
  GIT_TERMINAL_PROMPT: '0',
191
+ GCM_INTERACTIVE: 'never',
107
192
  }
108
193
  }
109
194
 
@@ -117,12 +202,16 @@ async function git(
117
202
  args: string[],
118
203
  opts: { cwd?: string; signal?: AbortSignal; env?: NodeJS.ProcessEnv } = {},
119
204
  ): Promise<string> {
205
+ // Force non-interactive auth on EVERY git op: empty the credential-helper list (drops the
206
+ // host's Git Credential Manager, whose popup otherwise steals focus or hangs the command)
207
+ // and, for ops without the auth env, still block a terminal/GCM prompt. See the notes on
208
+ // NON_INTERACTIVE_CREDENTIAL_ARGS / authEnv above.
120
209
  try {
121
- const { stdout } = await exec('git', args, {
210
+ const { stdout } = await exec('git', [...NON_INTERACTIVE_CREDENTIAL_ARGS, ...args], {
122
211
  ...(opts.cwd ? { cwd: opts.cwd } : {}),
123
212
  maxBuffer: 16 * 1024 * 1024,
124
213
  timeout: GIT_TIMEOUT_MS,
125
- ...(opts.env ? { env: opts.env } : {}),
214
+ env: opts.env ?? nonInteractiveGitEnv(),
126
215
  ...(opts.signal ? { signal: opts.signal } : {}),
127
216
  })
128
217
  return stdout
@@ -130,10 +219,7 @@ async function git(
130
219
  // Tag the failure as `git` so the registry's catch records the real cause instead of
131
220
  // the generic `agent`. A watchdog abort still wins: `describeFailure` keys off
132
221
  // `killReason` first, so an abort during a git op keeps the timeout message/cause.
133
- const redacted = redactError(err)
134
- const failure = new HarnessFailure('git', redacted.message)
135
- if (redacted.stack) failure.stack = redacted.stack
136
- throw failure
222
+ throw gitFailure(err, args, opts.signal?.aborted === true)
137
223
  }
138
224
  }
139
225
 
@@ -409,6 +495,43 @@ export async function branchHasCommitsSince(
409
495
  return (await headCommit(dir, signal)) !== baseSha
410
496
  }
411
497
 
498
+ /**
499
+ * Whether the checked-out branch carries at least one commit the PR base does NOT — i.e.
500
+ * `git rev-list --count <base>..HEAD > 0`. A resume clone is single-branch, so it has no
501
+ * `origin/<base>` tracking ref; this fetches the base into a dedicated local ref first and
502
+ * diffs HEAD against it.
503
+ *
504
+ * Tri-state on purpose:
505
+ * - `true` — confirmed ≥1 commit ahead (there is something to open a PR for).
506
+ * - `false` — confirmed 0 commits ahead (the branch is reachable from base, e.g. its earlier
507
+ * PR was merged with a merge commit and the best-effort branch delete was skipped).
508
+ * - `undefined` — could not determine (fetch / rev-list error); the caller keeps its prior
509
+ * behaviour rather than wrongly dropping a resumed branch that has real work.
510
+ *
511
+ * Used by the resume path to avoid declaring a merged/empty branch as work and then failing
512
+ * the run with GitHub's opaque 422 "No commits between <base> and <branch>".
513
+ */
514
+ export async function branchAheadOfBase(
515
+ dir: string,
516
+ baseBranch: string,
517
+ ghToken: string,
518
+ signal?: AbortSignal,
519
+ ): Promise<boolean | undefined> {
520
+ try {
521
+ await git(['fetch', 'origin', `+refs/heads/${baseBranch}:refs/cat-factory/base`], {
522
+ cwd: dir,
523
+ signal,
524
+ env: await authEnv(ghToken),
525
+ })
526
+ const count = (
527
+ await git(['rev-list', '--count', 'refs/cat-factory/base..HEAD'], { cwd: dir, signal })
528
+ ).trim()
529
+ return Number(count) > 0
530
+ } catch {
531
+ return undefined
532
+ }
533
+ }
534
+
412
535
  /**
413
536
  * Whether the checked-out branch has a real, examinable diff against
414
537
  * `origin/<baseBranch>` — i.e. the base branch's remote-tracking ref exists (so the
@@ -790,7 +913,7 @@ function backoffMs(attempt: number): number {
790
913
  * GitLab whose host isn't named `gitlab.*` still opens an MR instead of being misrouted to
791
914
  * GitHub's API. The GitHub path is unchanged.
792
915
  */
793
- export async function openPullRequest(opts: OpenPullRequestOptions): Promise<string> {
916
+ export async function openPullRequest(opts: OpenPullRequestOptions): Promise<string | null> {
794
917
  const provider = opts.provider ?? (opts.cloneUrl ? inferVcsProvider(opts.cloneUrl) : 'github')
795
918
  if (provider === 'gitlab') {
796
919
  if (!opts.cloneUrl) {
@@ -831,6 +954,12 @@ export async function openPullRequest(opts: OpenPullRequestOptions): Promise<str
831
954
  const existing = await findOpenPullRequestUrl(opts)
832
955
  if (existing) return existing
833
956
  }
957
+ // The head branch has nothing ahead of base ("No commits between <base> and <head>").
958
+ // That is not an API failure — there is simply nothing to open a PR for (e.g. a resumed
959
+ // branch whose earlier PR was merged with a merge commit, leaving the branch reachable
960
+ // from base). Signal it with null so the caller records a clean no-op instead of failing
961
+ // the run with GitHub's opaque 422.
962
+ if (res.status === 422 && /no commits between/i.test(detail)) return null
834
963
  throw new HarnessFailure(
835
964
  'api',
836
965
  redactSecrets(`Failed to open PR (HTTP ${res.status}): ${detail.slice(0, 300)}`),