@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 +18 -0
- package/dist/coding-agent.js +19 -2
- package/dist/git.js +123 -16
- package/package.json +3 -3
- package/src/agent.ts +22 -0
- package/src/coding-agent.ts +19 -1
- package/src/git.ts +144 -15
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 } : {}) };
|
package/dist/coding-agent.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
30
|
-
"@cat-factory/spend": "0.10.
|
|
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 } : {}) }
|
package/src/coding-agent.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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)}`),
|