@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 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
- const failure = new HarnessFailure('git', redactSecrets(combined));
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
- throw new HarnessFailure('api', redactSecrets(`Failed to open PR (HTTP ${res.status}): ${detail.slice(0, 300)}`));
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
- throw new HarnessFailure('api', redactSecrets(`Failed to open merge request (HTTP ${res.status}): ${detail.slice(0, 300)}`));
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
- reject(new Error(tail ? `${scrubbed} Agent stderr: ${tail}` : scrubbed));
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
- reject(new Error(`pi exited with code ${code}: ${(stderr || stdout).slice(-500)}`));
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.0",
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.0.0",
26
- "hono": "^4.12.27",
25
+ "@types/node": "^26.1.1",
26
+ "hono": "^4.12.29",
27
27
  "typescript": "^6.0.3",
28
- "vitest": "^4.1.9",
29
- "@cat-factory/server": "0.108.0",
30
- "@cat-factory/spend": "0.12.10"
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
- const failure = new HarnessFailure('git', redactSecrets(combined))
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
- throw new HarnessFailure(
1021
- 'api',
1022
- redactSecrets(`Failed to open PR (HTTP ${res.status}): ${detail.slice(0, 300)}`),
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
- throw new HarnessFailure(
1079
- 'api',
1080
- redactSecrets(`Failed to open merge request (HTTP ${res.status}): ${detail.slice(0, 300)}`),
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
- reject(new Error(tail ? `${scrubbed} Agent stderr: ${tail}` : scrubbed))
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
- reject(new Error(`pi exited with code ${code}: ${(stderr || stdout).slice(-500)}`))
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