@cat-factory/executor-harness 1.43.0 → 1.43.4
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/git.js +87 -3
- package/dist/pi.js +53 -2
- package/package.json +6 -6
- package/src/failure.ts +3 -0
- package/src/git.ts +120 -8
- package/src/pi.ts +69 -2
package/dist/git.js
CHANGED
|
@@ -91,6 +91,81 @@ export function isGitTimeoutKill(err, aborted) {
|
|
|
91
91
|
function gitSubcommand(args) {
|
|
92
92
|
return args.find((a) => a !== '' && !a.startsWith('-')) ?? 'command';
|
|
93
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Classify the common shapes of git's own stderr into an actionable remedy, else undefined
|
|
96
|
+
* (an unrecognized failure keeps just its raw stderr). This is the FIRST-WRAP-POINT for
|
|
97
|
+
* unavoidable third-party text (per the error-message initiative's I6): git's stderr is the
|
|
98
|
+
* only signal we get for a clone/push auth or access fault, so we match it ONCE here and
|
|
99
|
+
* APPEND a cause + fix, never rewrite the raw line. Host-neutral — the same remedy serves a
|
|
100
|
+
* GitHub-App installation token and a GitLab/GitHub PAT (local mode). Pure, so it is
|
|
101
|
+
* unit-tested over a fixed set of stderr strings.
|
|
102
|
+
*/
|
|
103
|
+
export function describeGitFailure(stderr) {
|
|
104
|
+
const s = stderr.toLowerCase();
|
|
105
|
+
// Rate-limit / abuse-detection first: the host returns these as a 403, which would
|
|
106
|
+
// otherwise fall into the write-access shape below and be mislabeled as a permission
|
|
107
|
+
// problem — but the fix is to wait, not to grant access.
|
|
108
|
+
if (/rate limit|secondary rate|abuse detection/i.test(stderr)) {
|
|
109
|
+
return ('The git host rate-limited this run (a primary/secondary rate limit or abuse-detection ' +
|
|
110
|
+
'trip). This is usually transient — wait a few minutes and retry. If it persists, reduce ' +
|
|
111
|
+
'the number of concurrent runs against this host.');
|
|
112
|
+
}
|
|
113
|
+
// Order matters: a 404 "repository not found" is sometimes GitHub's stand-in for "your
|
|
114
|
+
// token can't see this private repo", so it is checked before the generic auth shape.
|
|
115
|
+
if (/repository not found|remote:\s*not found|returned error:\s*404|fatal:\s*could not read from remote repository/i.test(stderr) &&
|
|
116
|
+
!/authentication failed|invalid username or password/i.test(s)) {
|
|
117
|
+
return ('The repository could not be found or is not visible to the credential used for this run. ' +
|
|
118
|
+
'It may have been deleted, renamed, or made private, or the GitHub App installation / access ' +
|
|
119
|
+
'token no longer has access to it. Confirm the repository still exists and that the connected ' +
|
|
120
|
+
'GitHub App (or, in local mode, the GITHUB_PAT) can see it, then retry.');
|
|
121
|
+
}
|
|
122
|
+
if (/authentication failed|invalid username or password|could not read username|could not read password|terminal prompts disabled|support for password authentication was removed|returned error:\s*401|http basic:\s*access denied/i.test(stderr)) {
|
|
123
|
+
return ('Git authentication was rejected — the credential this run used was refused. The GitHub App ' +
|
|
124
|
+
'installation token (or, in local mode, the GITHUB_PAT) is most likely expired, rotated, ' +
|
|
125
|
+
'revoked, or no longer installed on this repository. Reconnect the GitHub App for the ' +
|
|
126
|
+
'workspace (or regenerate the PAT with repo scope in local mode), then retry.');
|
|
127
|
+
}
|
|
128
|
+
if (/permission to .* denied|remote:\s*permission|protected branch|pre-receive hook declined|returned error:\s*403|http 403/i.test(stderr)) {
|
|
129
|
+
return ('Git authenticated but the credential lacks WRITE access to push to this repository. Grant ' +
|
|
130
|
+
'the connected GitHub App (or the local-mode PAT) write permission on the repo — and, if the ' +
|
|
131
|
+
'target branch is protected, the permission its branch-protection rule requires — then retry.');
|
|
132
|
+
}
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Classify a PR/MR-open REST failure by its HTTP status into an actionable remedy, else
|
|
137
|
+
* undefined (an unmapped status keeps just the raw `Failed to open … (HTTP n)` line). Like
|
|
138
|
+
* {@link describeGitFailure} this only APPENDS a cause + fix — the raw status line is
|
|
139
|
+
* load-bearing detail and stays. `provider` tailors the scope/permission wording (GitHub App
|
|
140
|
+
* Pull-requests permission / `repo` PAT scope vs GitLab `api` scope) and the noun (pull
|
|
141
|
+
* request vs merge request). Pure, so it is unit-tested per status.
|
|
142
|
+
*/
|
|
143
|
+
export function describePrOpenFailure(status, provider) {
|
|
144
|
+
const noun = provider === 'gitlab' ? 'merge request' : 'pull request';
|
|
145
|
+
if (status === 401) {
|
|
146
|
+
return (`The credential was rejected while opening the ${noun}. The GitHub App installation token ` +
|
|
147
|
+
'(or, in local mode, the GITHUB_PAT) is most likely expired, rotated, or revoked — reconnect ' +
|
|
148
|
+
'the GitHub App for the workspace (or regenerate the PAT), then retry.');
|
|
149
|
+
}
|
|
150
|
+
if (status === 403) {
|
|
151
|
+
const scope = provider === 'gitlab'
|
|
152
|
+
? 'the GitLab token needs the `api` scope and Developer+ access to the project'
|
|
153
|
+
: 'the GitHub App needs the "Pull requests: write" permission (or the PAT the `repo` scope) and write access to the repository';
|
|
154
|
+
return `The credential lacks permission to open a ${noun}: ${scope}. Grant it, then retry.`;
|
|
155
|
+
}
|
|
156
|
+
if (status === 404) {
|
|
157
|
+
return (`The repository could not be found while opening the ${noun} — it may have been deleted, ` +
|
|
158
|
+
'renamed, or made private, or the credential can no longer see it. Confirm the repo and the ' +
|
|
159
|
+
"credential's access to it, then retry.");
|
|
160
|
+
}
|
|
161
|
+
if (status === 422 || status === 400) {
|
|
162
|
+
return (`GitHub/GitLab rejected the ${noun} as invalid. Usually the head or base branch does not ` +
|
|
163
|
+
'exist, the two branches are identical (nothing to compare), or the base branch is protected ' +
|
|
164
|
+
'against direct PRs. Check the branch names and that the head has commits ahead of the base, ' +
|
|
165
|
+
'then retry.');
|
|
166
|
+
}
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
94
169
|
/**
|
|
95
170
|
* Wrap a git failure into a credential-scrubbed {@link HarnessFailure}('git') with an ACCURATE
|
|
96
171
|
* message. Three cases the old bare "Command failed: git …" collapsed together:
|
|
@@ -114,7 +189,12 @@ function gitFailure(err, args, aborted) {
|
|
|
114
189
|
const stderr = typeof e?.stderr === 'string' ? e.stderr : (e?.stderr?.toString() ?? '');
|
|
115
190
|
const base = e instanceof Error ? e.message : String(err);
|
|
116
191
|
const combined = stderr.trim() ? `${base}\n${stderr.trim()}` : base;
|
|
117
|
-
|
|
192
|
+
// Append a cause + fix for the recognized auth/access shapes, keeping the raw (scrubbed)
|
|
193
|
+
// stderr above it as the detail. The remedy is static text with no secrets, so it is added
|
|
194
|
+
// after redaction.
|
|
195
|
+
const remedy = describeGitFailure(combined);
|
|
196
|
+
const message = remedy ? `${redactSecrets(combined)}\n${remedy}` : redactSecrets(combined);
|
|
197
|
+
const failure = new HarnessFailure('git', message);
|
|
118
198
|
if (e?.stack)
|
|
119
199
|
failure.stack = redactSecrets(e.stack);
|
|
120
200
|
return failure;
|
|
@@ -834,7 +914,9 @@ export async function openPullRequest(opts) {
|
|
|
834
914
|
// the run with GitHub's opaque 422.
|
|
835
915
|
if (res.status === 422 && /no commits between/i.test(detail))
|
|
836
916
|
return null;
|
|
837
|
-
|
|
917
|
+
const remedy = describePrOpenFailure(res.status, 'github');
|
|
918
|
+
const base = redactSecrets(`Failed to open PR (HTTP ${res.status}): ${detail.slice(0, 300)}`);
|
|
919
|
+
throw new HarnessFailure('api', remedy ? `${base}\n${remedy}` : base);
|
|
838
920
|
}
|
|
839
921
|
const body = (await res.json());
|
|
840
922
|
if (!body.html_url)
|
|
@@ -881,7 +963,9 @@ async function openGitLabMergeRequest(opts) {
|
|
|
881
963
|
if (existing)
|
|
882
964
|
return existing;
|
|
883
965
|
}
|
|
884
|
-
|
|
966
|
+
const remedy = describePrOpenFailure(res.status, 'gitlab');
|
|
967
|
+
const base = redactSecrets(`Failed to open merge request (HTTP ${res.status}): ${detail.slice(0, 300)}`);
|
|
968
|
+
throw new HarnessFailure('api', remedy ? `${base}\n${remedy}` : base);
|
|
885
969
|
}
|
|
886
970
|
const body = (await res.json());
|
|
887
971
|
if (!body.web_url)
|
package/dist/pi.js
CHANGED
|
@@ -5,6 +5,7 @@ import { dirname, join } from 'node:path';
|
|
|
5
5
|
import { killChildProcess, spawnDetached } from './process.js';
|
|
6
6
|
import { pathExists } from './fs-utils.js';
|
|
7
7
|
import { redactSecrets } from './redact.js';
|
|
8
|
+
import { HarnessFailure } from './failure.js';
|
|
8
9
|
import { log } from './logger.js';
|
|
9
10
|
// Drives the Pi coding-agent CLI. Pi is pointed at the Worker's OpenAI-compatible
|
|
10
11
|
// proxy via a custom provider in ~/.pi/agent/models.json, authenticated with the
|
|
@@ -756,18 +757,37 @@ export function runPi(opts) {
|
|
|
756
757
|
const runError = terminalRunError(stdout);
|
|
757
758
|
if (runError) {
|
|
758
759
|
const scrubbed = redactSecrets(runError).slice(0, 1000);
|
|
759
|
-
|
|
760
|
+
const detail = tail ? `${scrubbed} Agent stderr: ${tail}` : scrubbed;
|
|
761
|
+
reject(piRunFailure(detail, runError));
|
|
760
762
|
}
|
|
761
763
|
else {
|
|
762
764
|
resolve({ ...summarizePiRun(stdout), ...(tail ? { stderrTail: tail } : {}) });
|
|
763
765
|
}
|
|
764
766
|
}
|
|
765
767
|
else {
|
|
766
|
-
|
|
768
|
+
// A non-zero exit is the OTHER way a proxy refusal can surface (Pi crashing rather
|
|
769
|
+
// than exiting 0 after exhausting retries), so classify it here too — otherwise a
|
|
770
|
+
// 401/402/429 that happens to crash Pi would read as a generic agent failure. Redact
|
|
771
|
+
// the transcript slice before it becomes the detail: unlike the exit-0 path above, the
|
|
772
|
+
// raw `stderr`/`stdout` here was previously interpolated unscrubbed.
|
|
773
|
+
const raw = (stderr || stdout).slice(-500);
|
|
774
|
+
reject(piRunFailure(`pi exited with code ${code}: ${redactSecrets(raw)}`, raw));
|
|
767
775
|
}
|
|
768
776
|
});
|
|
769
777
|
});
|
|
770
778
|
}
|
|
779
|
+
/**
|
|
780
|
+
* Build the rejection for a failed Pi run: if its terminal text points at the LLM proxy
|
|
781
|
+
* refusing every model call (auth/quota/rate-limit), stamp the structured `llm-upstream`
|
|
782
|
+
* cause with the remedy APPENDED after the detail (keeping the raw line first, matching the
|
|
783
|
+
* git/PR-open classifiers); otherwise a plain Error (→ the generic `agent` cause). Shared by
|
|
784
|
+
* both the exit-0 terminal-error path and the non-zero exit path so a proxy refusal is
|
|
785
|
+
* classified the same whether Pi survives it or crashes on it.
|
|
786
|
+
*/
|
|
787
|
+
function piRunFailure(detail, sourceText) {
|
|
788
|
+
const remedy = classifyLlmUpstreamError(sourceText);
|
|
789
|
+
return remedy ? new HarnessFailure('llm-upstream', `${detail}\n${remedy}`) : new Error(detail);
|
|
790
|
+
}
|
|
771
791
|
/** Parse Pi's LF-framed JSONL stdout into its event records, skipping noise. */
|
|
772
792
|
function parsePiEvents(stdout) {
|
|
773
793
|
const events = [];
|
|
@@ -813,6 +833,37 @@ export function terminalRunError(stdout) {
|
|
|
813
833
|
}
|
|
814
834
|
return undefined;
|
|
815
835
|
}
|
|
836
|
+
/**
|
|
837
|
+
* Classify a terminal run error whose text points at the LLM PROXY rejecting every model call
|
|
838
|
+
* (auth / quota / rate-limit) into an actionable remedy, else undefined. All model traffic goes
|
|
839
|
+
* through the Worker's OpenAI-compatible proxy, so a 401/402/429 surfaced in Pi's `finalError`
|
|
840
|
+
* means the leased provider key was refused, is out of credit, or was rate-limited — none of
|
|
841
|
+
* which is an agent bug. This is the first-wrap-point for Pi's own error text (per the
|
|
842
|
+
* error-message initiative's I6): we match it ONCE here and let the caller stamp the structured
|
|
843
|
+
* `llm-upstream` cause + this remedy. Pure, so it is unit-tested over fixed error strings.
|
|
844
|
+
*/
|
|
845
|
+
export function classifyLlmUpstreamError(finalError) {
|
|
846
|
+
const s = finalError.toLowerCase();
|
|
847
|
+
// 402 / quota / credit — check before the generic auth shape (a 402 body can also say
|
|
848
|
+
// "unauthorized"-ish things, but a payment/quota signal is the more actionable cause).
|
|
849
|
+
if (/\b402\b|payment required|insufficient (?:funds|quota|credit|balance)|out of (?:quota|credit)|quota exceeded|billing/i.test(finalError)) {
|
|
850
|
+
return ('The model provider rejected the run: the account is out of quota or credit (HTTP 402). ' +
|
|
851
|
+
'Top up or raise the limit with the provider, or switch to a provider key that has quota in ' +
|
|
852
|
+
'the workspace AI key pool (Configure AI), then retry.');
|
|
853
|
+
}
|
|
854
|
+
if (/\b429\b|too many requests|rate.?limit/i.test(finalError) &&
|
|
855
|
+
!/\b401\b|\b402\b|\b403\b/.test(finalError)) {
|
|
856
|
+
return ('The model provider rate-limited the run (HTTP 429) and the agent exhausted its automatic ' +
|
|
857
|
+
'retries. This is usually transient — wait a moment and retry. If it persists, reduce ' +
|
|
858
|
+
'concurrent runs or use a provider key / plan with a higher rate limit in the AI key pool.');
|
|
859
|
+
}
|
|
860
|
+
if (/\b401\b|\b403\b|unauthorized|forbidden|invalid api key|authentication|invalid.*token/i.test(s)) {
|
|
861
|
+
return ('The model provider rejected the run: the API credential was refused (HTTP 401/403). The ' +
|
|
862
|
+
'provider key in the workspace AI key pool is most likely invalid, revoked, or expired — ' +
|
|
863
|
+
're-enter it under the AI provider keys (Configure AI), then retry.');
|
|
864
|
+
}
|
|
865
|
+
return undefined;
|
|
866
|
+
}
|
|
816
867
|
/**
|
|
817
868
|
* Pi's assistant summary plus {@link PiRunStats}, derived from one pass over its
|
|
818
869
|
* output — the canonical close-of-run signal the harness uses both to report the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/executor-harness",
|
|
3
|
-
"version": "1.43.
|
|
3
|
+
"version": "1.43.4",
|
|
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",
|
|
@@ -22,12 +22,12 @@
|
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"@hono/node-server": "^2.0.8",
|
|
25
|
-
"@types/node": "^26.
|
|
26
|
-
"hono": "^4.12.
|
|
25
|
+
"@types/node": "^26.1.1",
|
|
26
|
+
"hono": "^4.12.29",
|
|
27
27
|
"typescript": "^6.0.3",
|
|
28
|
-
"vitest": "^4.1.
|
|
29
|
-
"@cat-factory/server": "0.
|
|
30
|
-
"@cat-factory/spend": "0.12.
|
|
28
|
+
"vitest": "^4.1.10",
|
|
29
|
+
"@cat-factory/server": "0.113.5",
|
|
30
|
+
"@cat-factory/spend": "0.12.26"
|
|
31
31
|
},
|
|
32
32
|
"scripts": {
|
|
33
33
|
"build": "tsc -p tsconfig.json",
|
package/src/failure.ts
CHANGED
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
* - `agent` — the agent ran but produced an unusable/failed result, or threw.
|
|
23
23
|
* - `git` — a git operation failed (clone/push/merge/PR).
|
|
24
24
|
* - `api` — an upstream API call failed (e.g. the GitHub/GitLab PR/MR REST call).
|
|
25
|
+
* - `llm-upstream` — the model provider rejected every call (auth/quota/rate-limit) and Pi
|
|
26
|
+
* exhausted its retries, so the run never produced a result.
|
|
25
27
|
* - `no-usable-output` — the agent finished but returned no usable report / structured output.
|
|
26
28
|
* - `no-changes` — a coding agent finished without producing any change to push.
|
|
27
29
|
*/
|
|
@@ -31,6 +33,7 @@ export type FailureCause =
|
|
|
31
33
|
| 'agent'
|
|
32
34
|
| 'git'
|
|
33
35
|
| 'api'
|
|
36
|
+
| 'llm-upstream'
|
|
34
37
|
| 'no-usable-output'
|
|
35
38
|
| 'no-changes'
|
|
36
39
|
|
package/src/git.ts
CHANGED
|
@@ -112,6 +112,113 @@ function gitSubcommand(args: string[]): string {
|
|
|
112
112
|
return args.find((a) => a !== '' && !a.startsWith('-')) ?? 'command'
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Classify the common shapes of git's own stderr into an actionable remedy, else undefined
|
|
117
|
+
* (an unrecognized failure keeps just its raw stderr). This is the FIRST-WRAP-POINT for
|
|
118
|
+
* unavoidable third-party text (per the error-message initiative's I6): git's stderr is the
|
|
119
|
+
* only signal we get for a clone/push auth or access fault, so we match it ONCE here and
|
|
120
|
+
* APPEND a cause + fix, never rewrite the raw line. Host-neutral — the same remedy serves a
|
|
121
|
+
* GitHub-App installation token and a GitLab/GitHub PAT (local mode). Pure, so it is
|
|
122
|
+
* unit-tested over a fixed set of stderr strings.
|
|
123
|
+
*/
|
|
124
|
+
export function describeGitFailure(stderr: string): string | undefined {
|
|
125
|
+
const s = stderr.toLowerCase()
|
|
126
|
+
// Rate-limit / abuse-detection first: the host returns these as a 403, which would
|
|
127
|
+
// otherwise fall into the write-access shape below and be mislabeled as a permission
|
|
128
|
+
// problem — but the fix is to wait, not to grant access.
|
|
129
|
+
if (/rate limit|secondary rate|abuse detection/i.test(stderr)) {
|
|
130
|
+
return (
|
|
131
|
+
'The git host rate-limited this run (a primary/secondary rate limit or abuse-detection ' +
|
|
132
|
+
'trip). This is usually transient — wait a few minutes and retry. If it persists, reduce ' +
|
|
133
|
+
'the number of concurrent runs against this host.'
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
// Order matters: a 404 "repository not found" is sometimes GitHub's stand-in for "your
|
|
137
|
+
// token can't see this private repo", so it is checked before the generic auth shape.
|
|
138
|
+
if (
|
|
139
|
+
/repository not found|remote:\s*not found|returned error:\s*404|fatal:\s*could not read from remote repository/i.test(
|
|
140
|
+
stderr,
|
|
141
|
+
) &&
|
|
142
|
+
!/authentication failed|invalid username or password/i.test(s)
|
|
143
|
+
) {
|
|
144
|
+
return (
|
|
145
|
+
'The repository could not be found or is not visible to the credential used for this run. ' +
|
|
146
|
+
'It may have been deleted, renamed, or made private, or the GitHub App installation / access ' +
|
|
147
|
+
'token no longer has access to it. Confirm the repository still exists and that the connected ' +
|
|
148
|
+
'GitHub App (or, in local mode, the GITHUB_PAT) can see it, then retry.'
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
if (
|
|
152
|
+
/authentication failed|invalid username or password|could not read username|could not read password|terminal prompts disabled|support for password authentication was removed|returned error:\s*401|http basic:\s*access denied/i.test(
|
|
153
|
+
stderr,
|
|
154
|
+
)
|
|
155
|
+
) {
|
|
156
|
+
return (
|
|
157
|
+
'Git authentication was rejected — the credential this run used was refused. The GitHub App ' +
|
|
158
|
+
'installation token (or, in local mode, the GITHUB_PAT) is most likely expired, rotated, ' +
|
|
159
|
+
'revoked, or no longer installed on this repository. Reconnect the GitHub App for the ' +
|
|
160
|
+
'workspace (or regenerate the PAT with repo scope in local mode), then retry.'
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
if (
|
|
164
|
+
/permission to .* denied|remote:\s*permission|protected branch|pre-receive hook declined|returned error:\s*403|http 403/i.test(
|
|
165
|
+
stderr,
|
|
166
|
+
)
|
|
167
|
+
) {
|
|
168
|
+
return (
|
|
169
|
+
'Git authenticated but the credential lacks WRITE access to push to this repository. Grant ' +
|
|
170
|
+
'the connected GitHub App (or the local-mode PAT) write permission on the repo — and, if the ' +
|
|
171
|
+
'target branch is protected, the permission its branch-protection rule requires — then retry.'
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
return undefined
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Classify a PR/MR-open REST failure by its HTTP status into an actionable remedy, else
|
|
179
|
+
* undefined (an unmapped status keeps just the raw `Failed to open … (HTTP n)` line). Like
|
|
180
|
+
* {@link describeGitFailure} this only APPENDS a cause + fix — the raw status line is
|
|
181
|
+
* load-bearing detail and stays. `provider` tailors the scope/permission wording (GitHub App
|
|
182
|
+
* Pull-requests permission / `repo` PAT scope vs GitLab `api` scope) and the noun (pull
|
|
183
|
+
* request vs merge request). Pure, so it is unit-tested per status.
|
|
184
|
+
*/
|
|
185
|
+
export function describePrOpenFailure(
|
|
186
|
+
status: number,
|
|
187
|
+
provider: 'github' | 'gitlab',
|
|
188
|
+
): string | undefined {
|
|
189
|
+
const noun = provider === 'gitlab' ? 'merge request' : 'pull request'
|
|
190
|
+
if (status === 401) {
|
|
191
|
+
return (
|
|
192
|
+
`The credential was rejected while opening the ${noun}. The GitHub App installation token ` +
|
|
193
|
+
'(or, in local mode, the GITHUB_PAT) is most likely expired, rotated, or revoked — reconnect ' +
|
|
194
|
+
'the GitHub App for the workspace (or regenerate the PAT), then retry.'
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
if (status === 403) {
|
|
198
|
+
const scope =
|
|
199
|
+
provider === 'gitlab'
|
|
200
|
+
? 'the GitLab token needs the `api` scope and Developer+ access to the project'
|
|
201
|
+
: 'the GitHub App needs the "Pull requests: write" permission (or the PAT the `repo` scope) and write access to the repository'
|
|
202
|
+
return `The credential lacks permission to open a ${noun}: ${scope}. Grant it, then retry.`
|
|
203
|
+
}
|
|
204
|
+
if (status === 404) {
|
|
205
|
+
return (
|
|
206
|
+
`The repository could not be found while opening the ${noun} — it may have been deleted, ` +
|
|
207
|
+
'renamed, or made private, or the credential can no longer see it. Confirm the repo and the ' +
|
|
208
|
+
"credential's access to it, then retry."
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
if (status === 422 || status === 400) {
|
|
212
|
+
return (
|
|
213
|
+
`GitHub/GitLab rejected the ${noun} as invalid. Usually the head or base branch does not ` +
|
|
214
|
+
'exist, the two branches are identical (nothing to compare), or the base branch is protected ' +
|
|
215
|
+
'against direct PRs. Check the branch names and that the head has commits ahead of the base, ' +
|
|
216
|
+
'then retry.'
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
return undefined
|
|
220
|
+
}
|
|
221
|
+
|
|
115
222
|
/**
|
|
116
223
|
* Wrap a git failure into a credential-scrubbed {@link HarnessFailure}('git') with an ACCURATE
|
|
117
224
|
* message. Three cases the old bare "Command failed: git …" collapsed together:
|
|
@@ -139,7 +246,12 @@ function gitFailure(err: unknown, args: string[], aborted: boolean): HarnessFail
|
|
|
139
246
|
const stderr = typeof e?.stderr === 'string' ? e.stderr : (e?.stderr?.toString() ?? '')
|
|
140
247
|
const base = e instanceof Error ? e.message : String(err)
|
|
141
248
|
const combined = stderr.trim() ? `${base}\n${stderr.trim()}` : base
|
|
142
|
-
|
|
249
|
+
// Append a cause + fix for the recognized auth/access shapes, keeping the raw (scrubbed)
|
|
250
|
+
// stderr above it as the detail. The remedy is static text with no secrets, so it is added
|
|
251
|
+
// after redaction.
|
|
252
|
+
const remedy = describeGitFailure(combined)
|
|
253
|
+
const message = remedy ? `${redactSecrets(combined)}\n${remedy}` : redactSecrets(combined)
|
|
254
|
+
const failure = new HarnessFailure('git', message)
|
|
143
255
|
if (e?.stack) failure.stack = redactSecrets(e.stack)
|
|
144
256
|
return failure
|
|
145
257
|
}
|
|
@@ -1017,10 +1129,9 @@ export async function openPullRequest(opts: OpenPullRequestOptions): Promise<str
|
|
|
1017
1129
|
// from base). Signal it with null so the caller records a clean no-op instead of failing
|
|
1018
1130
|
// the run with GitHub's opaque 422.
|
|
1019
1131
|
if (res.status === 422 && /no commits between/i.test(detail)) return null
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
)
|
|
1132
|
+
const remedy = describePrOpenFailure(res.status, 'github')
|
|
1133
|
+
const base = redactSecrets(`Failed to open PR (HTTP ${res.status}): ${detail.slice(0, 300)}`)
|
|
1134
|
+
throw new HarnessFailure('api', remedy ? `${base}\n${remedy}` : base)
|
|
1024
1135
|
}
|
|
1025
1136
|
const body = (await res.json()) as { html_url?: string }
|
|
1026
1137
|
if (!body.html_url) throw new HarnessFailure('api', 'GitHub did not return a PR url')
|
|
@@ -1075,10 +1186,11 @@ async function openGitLabMergeRequest(
|
|
|
1075
1186
|
const existing = await findOpenMergeRequestUrl(apiBase, project, opts)
|
|
1076
1187
|
if (existing) return existing
|
|
1077
1188
|
}
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1189
|
+
const remedy = describePrOpenFailure(res.status, 'gitlab')
|
|
1190
|
+
const base = redactSecrets(
|
|
1191
|
+
`Failed to open merge request (HTTP ${res.status}): ${detail.slice(0, 300)}`,
|
|
1081
1192
|
)
|
|
1193
|
+
throw new HarnessFailure('api', remedy ? `${base}\n${remedy}` : base)
|
|
1082
1194
|
}
|
|
1083
1195
|
const body = (await res.json()) as { web_url?: string }
|
|
1084
1196
|
if (!body.web_url) throw new HarnessFailure('api', 'GitLab did not return a merge request url')
|
package/src/pi.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { dirname, join } from 'node:path'
|
|
|
5
5
|
import { killChildProcess, spawnDetached } from './process.js'
|
|
6
6
|
import { pathExists } from './fs-utils.js'
|
|
7
7
|
import { redactSecrets } from './redact.js'
|
|
8
|
+
import { HarnessFailure } from './failure.js'
|
|
8
9
|
import { log } from './logger.js'
|
|
9
10
|
|
|
10
11
|
// Drives the Pi coding-agent CLI. Pi is pointed at the Worker's OpenAI-compatible
|
|
@@ -1061,17 +1062,37 @@ export function runPi(opts: {
|
|
|
1061
1062
|
const runError = terminalRunError(stdout)
|
|
1062
1063
|
if (runError) {
|
|
1063
1064
|
const scrubbed = redactSecrets(runError).slice(0, 1000)
|
|
1064
|
-
|
|
1065
|
+
const detail = tail ? `${scrubbed} Agent stderr: ${tail}` : scrubbed
|
|
1066
|
+
reject(piRunFailure(detail, runError))
|
|
1065
1067
|
} else {
|
|
1066
1068
|
resolve({ ...summarizePiRun(stdout), ...(tail ? { stderrTail: tail } : {}) })
|
|
1067
1069
|
}
|
|
1068
1070
|
} else {
|
|
1069
|
-
|
|
1071
|
+
// A non-zero exit is the OTHER way a proxy refusal can surface (Pi crashing rather
|
|
1072
|
+
// than exiting 0 after exhausting retries), so classify it here too — otherwise a
|
|
1073
|
+
// 401/402/429 that happens to crash Pi would read as a generic agent failure. Redact
|
|
1074
|
+
// the transcript slice before it becomes the detail: unlike the exit-0 path above, the
|
|
1075
|
+
// raw `stderr`/`stdout` here was previously interpolated unscrubbed.
|
|
1076
|
+
const raw = (stderr || stdout).slice(-500)
|
|
1077
|
+
reject(piRunFailure(`pi exited with code ${code}: ${redactSecrets(raw)}`, raw))
|
|
1070
1078
|
}
|
|
1071
1079
|
})
|
|
1072
1080
|
})
|
|
1073
1081
|
}
|
|
1074
1082
|
|
|
1083
|
+
/**
|
|
1084
|
+
* Build the rejection for a failed Pi run: if its terminal text points at the LLM proxy
|
|
1085
|
+
* refusing every model call (auth/quota/rate-limit), stamp the structured `llm-upstream`
|
|
1086
|
+
* cause with the remedy APPENDED after the detail (keeping the raw line first, matching the
|
|
1087
|
+
* git/PR-open classifiers); otherwise a plain Error (→ the generic `agent` cause). Shared by
|
|
1088
|
+
* both the exit-0 terminal-error path and the non-zero exit path so a proxy refusal is
|
|
1089
|
+
* classified the same whether Pi survives it or crashes on it.
|
|
1090
|
+
*/
|
|
1091
|
+
function piRunFailure(detail: string, sourceText: string): Error {
|
|
1092
|
+
const remedy = classifyLlmUpstreamError(sourceText)
|
|
1093
|
+
return remedy ? new HarnessFailure('llm-upstream', `${detail}\n${remedy}`) : new Error(detail)
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1075
1096
|
/** Parse Pi's LF-framed JSONL stdout into its event records, skipping noise. */
|
|
1076
1097
|
function parsePiEvents(stdout: string): Record<string, unknown>[] {
|
|
1077
1098
|
const events: Record<string, unknown>[] = []
|
|
@@ -1117,6 +1138,52 @@ export function terminalRunError(stdout: string): string | undefined {
|
|
|
1117
1138
|
return undefined
|
|
1118
1139
|
}
|
|
1119
1140
|
|
|
1141
|
+
/**
|
|
1142
|
+
* Classify a terminal run error whose text points at the LLM PROXY rejecting every model call
|
|
1143
|
+
* (auth / quota / rate-limit) into an actionable remedy, else undefined. All model traffic goes
|
|
1144
|
+
* through the Worker's OpenAI-compatible proxy, so a 401/402/429 surfaced in Pi's `finalError`
|
|
1145
|
+
* means the leased provider key was refused, is out of credit, or was rate-limited — none of
|
|
1146
|
+
* which is an agent bug. This is the first-wrap-point for Pi's own error text (per the
|
|
1147
|
+
* error-message initiative's I6): we match it ONCE here and let the caller stamp the structured
|
|
1148
|
+
* `llm-upstream` cause + this remedy. Pure, so it is unit-tested over fixed error strings.
|
|
1149
|
+
*/
|
|
1150
|
+
export function classifyLlmUpstreamError(finalError: string): string | undefined {
|
|
1151
|
+
const s = finalError.toLowerCase()
|
|
1152
|
+
// 402 / quota / credit — check before the generic auth shape (a 402 body can also say
|
|
1153
|
+
// "unauthorized"-ish things, but a payment/quota signal is the more actionable cause).
|
|
1154
|
+
if (
|
|
1155
|
+
/\b402\b|payment required|insufficient (?:funds|quota|credit|balance)|out of (?:quota|credit)|quota exceeded|billing/i.test(
|
|
1156
|
+
finalError,
|
|
1157
|
+
)
|
|
1158
|
+
) {
|
|
1159
|
+
return (
|
|
1160
|
+
'The model provider rejected the run: the account is out of quota or credit (HTTP 402). ' +
|
|
1161
|
+
'Top up or raise the limit with the provider, or switch to a provider key that has quota in ' +
|
|
1162
|
+
'the workspace AI key pool (Configure AI), then retry.'
|
|
1163
|
+
)
|
|
1164
|
+
}
|
|
1165
|
+
if (
|
|
1166
|
+
/\b429\b|too many requests|rate.?limit/i.test(finalError) &&
|
|
1167
|
+
!/\b401\b|\b402\b|\b403\b/.test(finalError)
|
|
1168
|
+
) {
|
|
1169
|
+
return (
|
|
1170
|
+
'The model provider rate-limited the run (HTTP 429) and the agent exhausted its automatic ' +
|
|
1171
|
+
'retries. This is usually transient — wait a moment and retry. If it persists, reduce ' +
|
|
1172
|
+
'concurrent runs or use a provider key / plan with a higher rate limit in the AI key pool.'
|
|
1173
|
+
)
|
|
1174
|
+
}
|
|
1175
|
+
if (
|
|
1176
|
+
/\b401\b|\b403\b|unauthorized|forbidden|invalid api key|authentication|invalid.*token/i.test(s)
|
|
1177
|
+
) {
|
|
1178
|
+
return (
|
|
1179
|
+
'The model provider rejected the run: the API credential was refused (HTTP 401/403). The ' +
|
|
1180
|
+
'provider key in the workspace AI key pool is most likely invalid, revoked, or expired — ' +
|
|
1181
|
+
're-enter it under the AI provider keys (Configure AI), then retry.'
|
|
1182
|
+
)
|
|
1183
|
+
}
|
|
1184
|
+
return undefined
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1120
1187
|
/**
|
|
1121
1188
|
* Pi's assistant summary plus {@link PiRunStats}, derived from one pass over its
|
|
1122
1189
|
* output — the canonical close-of-run signal the harness uses both to report the
|