@yagni-app/code-staging 1.1.6-staging.1439.1 → 1.1.6-staging.1445.1

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.
@@ -362,6 +362,9 @@ function formatSummaryForChat(result, costText) {
362
362
  const UNTRACKED_NOTICE = {
363
363
  disabled: "Run tracking is off for this workspace, so this /go run won't appear on the Work page.",
364
364
  unauthorized: "Run tracking is unauthorized. Run `yagni login` to refresh your token so runs appear on the Work page.",
365
+ // A refusal always arrives with the server's own sentence; this is the line
366
+ // for the rare body that carries none.
367
+ refused: "YAGNI declined to record this run, so it won't appear on the Work page. The pipeline ran anyway.",
365
368
  error: "Couldn't reach YAGNI to record this run, so it won't appear on the Work page. The pipeline ran anyway.",
366
369
  };
367
370
  /** Toast severity for an outcome: clean is good, round_cap warns, abort/fail are errors. */
@@ -737,9 +740,11 @@ export function registerGoCommand(pi, deps = {}) {
737
740
  }
738
741
  return { runId: started.runId, workItemId: started.workItemId };
739
742
  }
740
- // Recording skipped (flag off) or failed (unauthorized / unreachable):
741
- // tell the user the run won't show on the Work page rather than swallowing it.
742
- notify(UNTRACKED_NOTICE[started.reason], "warning");
743
+ // Recording skipped (flag off) or failed (unauthorized / refused /
744
+ // unreachable): tell the user the run won't show on the Work page
745
+ // rather than swallowing it. A refusal carries the server's own
746
+ // sentence, which says what to do about it, so it wins over our copy.
747
+ notify(started.message ?? UNTRACKED_NOTICE[started.reason], "warning");
743
748
  return {};
744
749
  };
745
750
  let resumeFrom;
@@ -67,21 +67,27 @@ export interface RunStartResult extends RunSessionHandle {
67
67
  * workspace (the write endpoints 404 to hide themselves).
68
68
  * - `unauthorized` — 401/403: the device token is missing/expired or lacks the
69
69
  * `yagni_code:write` scope (re-run `yagni login`).
70
+ * - `refused` — 400: the server declined for a reason a person can act on
71
+ * and said so in one sentence (this repository belongs to
72
+ * no Team). The sentence rides `message` and is what the
73
+ * caller shows.
70
74
  * - `error` — anything else: an over-budget 402, a 5xx, a 2xx with an
71
75
  * unrecognized body, or an unreachable backend / thrown
72
76
  * network error.
73
77
  */
74
- export type RunTrackingFailure = "disabled" | "unauthorized" | "error";
78
+ export type RunTrackingFailure = "disabled" | "unauthorized" | "refused" | "error";
75
79
  /**
76
80
  * The outcome of opening a run. `tracked: true` carries the handle + gate
77
81
  * verdict; `tracked: false` carries the reason so `/go` can warn that this run
78
- * won't appear on the Work page. `start` never returns null and never throws.
82
+ * won't appear on the Work page, plus the server's own sentence when it sent
83
+ * one. `start` never returns null and never throws.
79
84
  */
80
85
  export type RunStartOutcome = ({
81
86
  tracked: true;
82
87
  } & RunStartResult) | {
83
88
  tracked: false;
84
89
  reason: RunTrackingFailure;
90
+ message?: string;
85
91
  };
86
92
  /** Arguments to open a run. */
87
93
  export interface StartArgs {
@@ -25,27 +25,45 @@ function failureReason(status) {
25
25
  return "disabled";
26
26
  if (status === 401 || status === 403)
27
27
  return "unauthorized";
28
+ if (status === 400)
29
+ return "refused";
28
30
  return "error";
29
31
  }
30
32
  /** Cap a debug body excerpt so a giant HTML/JSON error never floods the channel. */
31
33
  const MAX_DEBUG_BODY = 200;
34
+ /** Cap the sentence a refusal is allowed to print, so a stray body stays one line. */
35
+ const MAX_REFUSAL_MESSAGE = 240;
32
36
  /**
33
- * A token-free, capped `: <body>` suffix for a failed-response debug line. Reads
34
- * the body only on the `!res.ok` branch (where `post` never reads it otherwise,
35
- * so there is no double-consume with the ok-path `res.json()`), and swallows any
36
- * read error so observability never breaks the fail-soft contract.
37
+ * The server's one-sentence refusal, if the body is the shape our own routes
38
+ * send (`{ error }`). Anything else, including HTML from a proxy, reads as no
39
+ * sentence at all, so the caller falls back to its own copy. Newlines are
40
+ * folded so a notice is never a stack.
37
41
  */
38
- async function bodyTail(res) {
42
+ function refusalMessage(body) {
39
43
  try {
40
- const t = (await res.text()).trim();
41
- if (!t)
42
- return "";
43
- return `: ${t.length > MAX_DEBUG_BODY ? `${t.slice(0, MAX_DEBUG_BODY)} ...` : t}`;
44
+ const parsed = JSON.parse(body);
45
+ const text = typeof parsed.error === "string" ? parsed.error.trim() : "";
46
+ if (!text)
47
+ return undefined;
48
+ const oneLine = text.replace(/\s+/g, " ");
49
+ return oneLine.length > MAX_REFUSAL_MESSAGE
50
+ ? `${oneLine.slice(0, MAX_REFUSAL_MESSAGE)}...`
51
+ : oneLine;
44
52
  }
45
53
  catch {
46
- return "";
54
+ return undefined;
47
55
  }
48
56
  }
57
+ /**
58
+ * A token-free, capped `: <body>` suffix for a failed-response debug line, from
59
+ * a body already read once by the `!res.ok` branch.
60
+ */
61
+ function bodyTail(body) {
62
+ const t = body.trim();
63
+ if (!t)
64
+ return "";
65
+ return `: ${t.length > MAX_DEBUG_BODY ? `${t.slice(0, MAX_DEBUG_BODY)} ...` : t}`;
66
+ }
49
67
  export function makeRunSession(opts) {
50
68
  const f = opts.fetchImpl ?? fetch;
51
69
  let handle = null;
@@ -94,9 +112,21 @@ export function makeRunSession(opts) {
94
112
  ...(opts.random ? { random: opts.random } : {}),
95
113
  });
96
114
  if (!res.ok) {
115
+ // Read the body ONCE on the failure branch (the ok path reads
116
+ // `res.json()` instead, so there is no double-consume): the debug line
117
+ // and the refusal sentence both come from this one read, and a body
118
+ // that cannot be read costs neither.
119
+ let body = "";
120
+ try {
121
+ body = await res.text();
122
+ }
123
+ catch {
124
+ /* a body we cannot read is a body we do not quote */
125
+ }
97
126
  if (debugOn)
98
- debug(`[yagni-code] run-record ${path} -> HTTP ${res.status}${await bodyTail(res)}`);
99
- return { ok: false, status: res.status };
127
+ debug(`[yagni-code] run-record ${path} -> HTTP ${res.status}${bodyTail(body)}`);
128
+ const message = refusalMessage(body);
129
+ return { ok: false, status: res.status, ...(message ? { failureText: message } : {}) };
100
130
  }
101
131
  return { ok: true, data: (await res.json()) };
102
132
  }
@@ -119,8 +149,13 @@ export function makeRunSession(opts) {
119
149
  // deduped by the server (startCodeRun.findRunByIdempotencyKey) instead of
120
150
  // opening a second run + a second free-text work item.
121
151
  const r = await post("runs", { ...b, idempotencyKey: randomUUID() }, { retry: true });
122
- if (!r.ok)
123
- return { tracked: false, reason: failureReason(r.status) };
152
+ if (!r.ok) {
153
+ return {
154
+ tracked: false,
155
+ reason: failureReason(r.status),
156
+ ...(r.failureText ? { message: r.failureText } : {}),
157
+ };
158
+ }
124
159
  const data = r.data;
125
160
  if (typeof data.runId === "string" && typeof data.workItemId === "string") {
126
161
  handle = { runId: data.runId, workItemId: data.workItemId };
@@ -62,25 +62,65 @@ export declare function makeSandboxSpawnHook(manager: YagniSandboxManager): Bash
62
62
  * when the manager is not initialized (fail-open to plain execution — the
63
63
  * permission gate still ran; the sandbox simply is not active).
64
64
  *
65
- * Transient shell-resolution failures (isShellResolutionFailure — srt's 1s
66
- * `which` spawn timing out under load) are retried once after a short
67
- * backoff; a second failure surfaces the original error. Any other error
68
- * propagates immediately, no retry.
65
+ * Shell-resolution failures (isShellResolutionFailure — srt's 1s `which`
66
+ * spawn timing out under load) carry two distinct causes that srt collapses
67
+ * into one message, split here by evidence:
68
+ *
69
+ * - LOAD TIMEOUT (the common case): the attempt took ~1s (the srt hard
70
+ * timeout) — `which` itself failed to spawn because the machine is
71
+ * saturated. Self-heals (observed sick windows 15–22s, heals within
72
+ * ~75s, 10/10 across every recorded incident). Retried on an
73
+ * exponential ladder; a ladder that never heals surfaces the HONEST
74
+ * transient message (the raw `Shell '…' not found in PATH` is a lie
75
+ * for this cause — the shell is right there — and models flail on it).
76
+ * - GENUINE MISS (rare): the attempt failed fast (<500ms — `which`
77
+ * answers a real "no" in ~4ms, measured) or the shell is an absolute
78
+ * path that does not exist. The original error surfaces immediately,
79
+ * zero retries — for this cause the message is TRUE and retrying is
80
+ * dead time against a config problem.
81
+ *
82
+ * Any other error propagates immediately, no retry. Every ladder sleep is
83
+ * abort-aware (Ctrl+C / tool cancel kills the wait).
69
84
  */
70
85
  export interface PreWrapRetryOpts {
71
- /** Abort the backoff sleep (the tool call's signal). */
86
+ /** Abort the ladder sleeps (the tool call's signal). */
72
87
  signal?: AbortSignal;
73
- /** Backoff before the single retry. Tests pass a tiny value to stay fast. */
74
- backoffMs?: number;
75
88
  /** Instrumentation: fired once, with the retry's terminal outcome. */
76
89
  onRetry?: (outcome: "recovered" | "exhausted") => void;
90
+ /** Retry policy: the default ladder, or the legacy single 2s retry
91
+ * (background launches — a silent 35s stall on a returns-immediately
92
+ * call is worse than the error, and bg launches are the highest-load
93
+ * moments). */
94
+ retryPolicy?: "ladder" | "single";
95
+ /** Discriminator threshold: a signature failure whose wrap attempt took
96
+ * at least this many ms classifies as a load timeout; faster ones are
97
+ * genuine misses. Default SHELL_RESOLUTION_TRANSIENT_MIN_ELAPSED_MS —
98
+ * exported and seam-overridable; tests pass 0 to force the ladder path
99
+ * against instant-throwing fakes, session tests shrink the default. */
100
+ transientMinElapsedMs?: number;
77
101
  }
102
+ /** The honest failure text for an exhausted ladder: says what actually
103
+ * happened, what it means, and what to do. Exported so tests pin the exact
104
+ * string. Deliberately contains no EPERM / "Operation not permitted" /
105
+ * `[sandbox] ` markers — it must never match isSandboxDenialOutput and
106
+ * poison the escape-consent flow's session denial evidence. */
107
+ export declare const SHELL_RESOLUTION_TRANSIENT_MESSAGE: string;
108
+ /** The discriminator threshold: a signature failure whose wrap attempt
109
+ * took at least this many ms classifies as a load timeout; faster ones
110
+ * are genuine misses. The two causes are ~4ms (a real `which` "no") vs
111
+ * ~1000ms (srt's hard timeout) apart, so the midpoint has wide margins
112
+ * on both sides. Extracted per the coupled-rollout rule — the riskiest
113
+ * heuristic of the feature must be independently revertible. */
114
+ export declare const SHELL_RESOLUTION_TRANSIENT_MIN_ELAPSED_MS = 500;
115
+ export declare function _setShellResolutionRetryBackoffsForTest(ms: readonly number[] | null): void;
116
+ export declare function _setShellResolutionSingleRetryBackoffForTest(ms: number | null): void;
117
+ export declare function _setShellResolutionTransientMinElapsedForTest(ms: number | null): void;
78
118
  export declare function preWrappedCommand(manager: YagniSandboxManager, command: string, binShell?: string, opts?: PreWrapRetryOpts): Promise<string>;
79
- export declare function _setShellResolutionRetryBackoffForTest(ms: number | null): void;
80
- /** srt's transient shell-resolution failure: `Shell '<name>' not found in
81
- * PATH`, thrown from wrapCommandWithSandbox when which.js's 1s
82
- * spawnSync('which') times out under load. Self-healing after ~15–30s;
83
- * preWrappedCommand retries it once. */
119
+ /** srt's shell-resolution failure: `Shell '<name>' not found in PATH`,
120
+ * thrown from wrapCommandWithSandbox when which.js's 1s spawnSync('which')
121
+ * times out under load — or genuinely answers "no". preWrappedCommand
122
+ * splits the two by evidence (see its doc) and retries only the load
123
+ * timeout, on an exponential ladder. */
84
124
  export declare function isShellResolutionFailure(err: unknown): boolean;
85
125
  /**
86
126
  * Did this command output indicate the OS sandbox DENIED something? The
@@ -21,7 +21,7 @@
21
21
  * lessons (process-group kill, stdio release).
22
22
  */
23
23
  import { existsSync } from "node:fs";
24
- import { join } from "node:path";
24
+ import { isAbsolute, join } from "node:path";
25
25
  import { logEvent } from "../errorSink.js";
26
26
  import { scrubSecrets } from "../pipeline/scrubSecrets.js";
27
27
  function stripLeadingSafeEnvVars(command) {
@@ -115,73 +115,193 @@ export function makeSandboxSpawnHook(manager) {
115
115
  env: { ...ctx.env, ...extraEnv },
116
116
  });
117
117
  }
118
+ /** The honest failure text for an exhausted ladder: says what actually
119
+ * happened, what it means, and what to do. Exported so tests pin the exact
120
+ * string. Deliberately contains no EPERM / "Operation not permitted" /
121
+ * `[sandbox] ` markers — it must never match isSandboxDenialOutput and
122
+ * poison the escape-consent flow's session denial evidence. */
123
+ export const SHELL_RESOLUTION_TRANSIENT_MESSAGE = "Transient sandbox shell-resolution timeout — the machine is under load. " +
124
+ "It self-heals, typically within a minute; retrying the same command shortly should work. " +
125
+ "Re-running the same command is usually enough.";
126
+ /** The discriminator threshold: a signature failure whose wrap attempt
127
+ * took at least this many ms classifies as a load timeout; faster ones
128
+ * are genuine misses. The two causes are ~4ms (a real `which` "no") vs
129
+ * ~1000ms (srt's hard timeout) apart, so the midpoint has wide margins
130
+ * on both sides. Extracted per the coupled-rollout rule — the riskiest
131
+ * heuristic of the feature must be independently revertible. */
132
+ export const SHELL_RESOLUTION_TRANSIENT_MIN_ELAPSED_MS = 500;
133
+ /** Ladder backoffs between attempts after the first (2s/4s/8s/16s → 5
134
+ * attempts, ~30s of sleep + ~1s per failing `which`: ~35s worst case).
135
+ * Sized from the recorded incidents: multi-failure bursts prove sick
136
+ * windows of 15–22s, so the ladder absorbs the observed majority; the
137
+ * long tail falls through to the honest message. */
138
+ const SHELL_RESOLUTION_LADDER_BACKOFFS_MS = [2000, 4000, 8000, 16000];
139
+ /** Legacy single-retry backoff (retryPolicy "single"). */
140
+ const SHELL_RESOLUTION_RETRY_BACKOFF_MS = 2000;
141
+ /** Test seam for the ladder backoffs: session-layer tests (composition,
142
+ * user_bash) drive the ladder without per-call opts, so they shrink the
143
+ * module-level default instead of sleeping real seconds. Module-scoped
144
+ * (never a globalThis key — a leaked value there would silently shrink the
145
+ * production backoffs); restore in the same describe that sets it.
146
+ * Deliberately SEPARATE from the single-policy seam below: the two
147
+ * policies must not share a test override (a leaked ladder shrink would
148
+ * silently shrink the bg path's backoff). */
149
+ let shellRetryBackoffsForTest = null;
150
+ export function _setShellResolutionRetryBackoffsForTest(ms) {
151
+ shellRetryBackoffsForTest = ms;
152
+ }
153
+ function ladderBackoffs() {
154
+ return shellRetryBackoffsForTest ?? SHELL_RESOLUTION_LADDER_BACKOFFS_MS;
155
+ }
156
+ /** Test seam for the single-policy backoff (its own override — never the
157
+ * ladder's). Same module-scoped discipline. */
158
+ let shellRetrySingleBackoffMsForTest = null;
159
+ export function _setShellResolutionSingleRetryBackoffForTest(ms) {
160
+ shellRetrySingleBackoffMsForTest = ms;
161
+ }
162
+ /** Test seam for the discriminator threshold (session tests shrink it so
163
+ * their fakes need no real 600ms sleeps). Same module-scoped discipline. */
164
+ let shellTransientMinElapsedMsForTest = null;
165
+ export function _setShellResolutionTransientMinElapsedForTest(ms) {
166
+ shellTransientMinElapsedMsForTest = ms;
167
+ }
168
+ /** The candidate filenames `existsSync` should probe for `bin` on this
169
+ * platform: the bare name on POSIX, or each PATHEXT extension on Windows
170
+ * (the same names `where` would consider). */
171
+ function pathCandidates(bin) {
172
+ if (process.platform === "win32") {
173
+ const exts = (process.env.PATHEXT ?? ".EXE").split(";");
174
+ return exts.map((e) => bin + (e.startsWith(".") ? e : "." + e));
175
+ }
176
+ return [bin];
177
+ }
178
+ /** Is the binary findable in the current PATH? An in-process scan (the same
179
+ * insight as the whole fix: answer a filesystem question with a filesystem
180
+ * check, never a subprocess) — and the SAME PATH srt's own `whichSync` sees:
181
+ * its spawnSync inherits this process's env, and the shell resolution runs
182
+ * in the host before the sandbox child ever starts. LOOSE by design:
183
+ * existence only, no X_OK — a false positive (found but not executable)
184
+ * errs toward the ladder, i.e. toward retrying, the safe side. For bare
185
+ * names the scan is an approximation of `which` (simple joins, no PATH
186
+ * semantics beyond the separator), so a "no" is strong but not absolute —
187
+ * a genuinely-broken exotic PATH shape could false-negative and surface
188
+ * the original error without retrying, the same posture as the fast-fail
189
+ * branch. */
190
+ function foundOnPath(bin) {
191
+ if (bin.includes("/") || isAbsolute(bin))
192
+ return existsSync(bin);
193
+ const sep = process.platform === "win32" ? ";" : ":";
194
+ const dirs = (process.env.PATH ?? "").split(sep).filter(Boolean);
195
+ return dirs.some((dir) => pathCandidates(bin).some((name) => existsSync(join(dir, name))));
196
+ }
118
197
  export async function preWrappedCommand(manager, command, binShell, opts = {}) {
119
198
  if (!manager.initialized)
120
199
  return command;
121
- try {
122
- return await manager.wrapWithSandbox(command, binShell);
123
- }
124
- catch (err) {
125
- if (!isShellResolutionFailure(err))
126
- throw err;
127
- }
128
- // The failed attempt may have left per-command state behind (the wrap can
129
- // start helpers before it throws); clear it so the retry starts clean and
130
- // the exec path's cleanupAfterCommand never sees double state. Best-effort:
131
- // a cleanup throw must never swallow the original error or kill the retry.
132
- try {
133
- manager.cleanupAfterCommand();
134
- }
135
- catch (err) {
136
- // Best-effort cleanup failed — the retry still runs (the exec path
137
- // cleans up again); a fully silent swallow would hide a leaked-helper
138
- // state, so the failure gets a visible trail. The message rides the
139
- // line (the error class alone is always the literal "Error" for plain
140
- // throws) and is run through scrubSecrets at WRITE time so the line
141
- // is credential-scrubbed before any reader. That is pattern-scrub
142
- // only, not a general redactor — readSessionTrail adds the same
143
- // pattern pass at read time; the durable errors-*.jsonl stays
144
- // unredacted by the sink's documented design (local file, never
145
- // uploaded raw). Same posture as sandbox_persist_failed.
146
- logEvent({
147
- source: "sandbox",
148
- level: "warn",
149
- event: "shell_retry_cleanup_failed",
150
- fields: {
151
- error: scrubSecrets(err instanceof Error ? `${err.constructor.name}: ${err.message}` : String(err)),
152
- },
200
+ const transientMinElapsedMs = opts.transientMinElapsedMs ?? shellTransientMinElapsedMsForTest ?? SHELL_RESOLUTION_TRANSIENT_MIN_ELAPSED_MS;
201
+ /** Corroboration: can this shell failure be a REAL miss? An absolute or
202
+ * path-bearing binShell that does not exist, or a bare name nowhere on
203
+ * PATH, is a true "no" regardless of machine load — never the ladder,
204
+ * never the honest-transient message. */
205
+ const provablyAbsent = () => typeof binShell === "string" && binShell.length > 0 && !foundOnPath(binShell);
206
+ const isGenuineMiss = (elapsedMs) => provablyAbsent() || elapsedMs < transientMinElapsedMs;
207
+ const attempt = () => {
208
+ const startedAt = Date.now();
209
+ return manager.wrapWithSandbox(command, binShell).then((wrapped) => ({ wrapped, elapsedMs: Date.now() - startedAt }), (err) => ({ err, elapsedMs: Date.now() - startedAt }));
210
+ };
211
+ // Best-effort cleanup between attempts: the failed wrap may have left
212
+ // per-command state behind (it can start helpers before it throws); clear
213
+ // it so the retry starts clean and the exec path's cleanupAfterCommand
214
+ // never sees double state. A cleanup throw must never swallow the
215
+ // original error or kill the retry; a fully silent swallow would hide a
216
+ // leaked-helper state, so the failure gets a visible trail. The message
217
+ // rides the line (the error class alone is always the literal "Error" for
218
+ // plain throws) and is run through scrubSecrets at WRITE time so the line
219
+ // is credential-scrubbed before any reader. That is pattern-scrub only,
220
+ // not a general redactor — readSessionTrail adds the same pattern pass at
221
+ // read time; the durable errors-*.jsonl stays unredacted by the sink's
222
+ // documented design (local file, never uploaded raw). Same posture as
223
+ // sandbox_persist_failed.
224
+ const cleanup = () => {
225
+ try {
226
+ manager.cleanupAfterCommand();
227
+ }
228
+ catch (err) {
229
+ logEvent({
230
+ source: "sandbox",
231
+ level: "warn",
232
+ event: "shell_retry_cleanup_failed",
233
+ fields: {
234
+ error: scrubSecrets(err instanceof Error ? `${err.constructor.name}: ${err.message}` : String(err)),
235
+ },
236
+ });
237
+ }
238
+ };
239
+ const first = await attempt();
240
+ if (first.wrapped !== undefined)
241
+ return first.wrapped;
242
+ if (!isShellResolutionFailure(first.err))
243
+ throw first.err;
244
+ if (isGenuineMiss(first.elapsedMs)) {
245
+ logShellResolutionClassification({
246
+ classification: "genuine-miss",
247
+ elapsedMs: first.elapsedMs,
248
+ absoluteShell: typeof binShell === "string" && isAbsolute(binShell),
249
+ provablyAbsent: provablyAbsent(),
153
250
  });
251
+ throw first.err;
154
252
  }
155
- await sleep(effectiveBackoffMs(opts.backoffMs), opts.signal);
156
- try {
157
- const wrapped = await manager.wrapWithSandbox(command, binShell);
158
- opts.onRetry?.("recovered");
159
- return wrapped;
253
+ cleanup();
254
+ const backoffs = opts.retryPolicy === "single"
255
+ ? [shellRetrySingleBackoffMsForTest ?? SHELL_RESOLUTION_RETRY_BACKOFF_MS]
256
+ : ladderBackoffs();
257
+ let lastErr = first.err;
258
+ let lastWasSignature = true;
259
+ for (const backoffMs of backoffs) {
260
+ await sleep(backoffMs, opts.signal);
261
+ const next = await attempt();
262
+ if (next.wrapped !== undefined) {
263
+ opts.onRetry?.("recovered");
264
+ return next.wrapped;
265
+ }
266
+ if (!isShellResolutionFailure(next.err)) {
267
+ // An unrelated mid-ladder error propagates as-is and does NOT fire
268
+ // `exhausted` — the health signal stays clean (only the terminal
269
+ // same-signature failure reports exhausted). The failed attempt
270
+ // still gets its cleanup (uniform with every other failed attempt).
271
+ cleanup();
272
+ lastErr = next.err;
273
+ lastWasSignature = false;
274
+ break;
275
+ }
276
+ lastErr = next.err;
277
+ lastWasSignature = true;
278
+ cleanup();
160
279
  }
161
- catch (err) {
162
- // `exhausted` is the shell-resolution health signal — report it only
163
- // when the second failure is the SAME transient; an unrelated retry
164
- // error propagates as-is without polluting the metric.
165
- if (isShellResolutionFailure(err))
166
- opts.onRetry?.("exhausted");
167
- throw err;
280
+ if (lastWasSignature) {
281
+ opts.onRetry?.("exhausted");
282
+ // The honest message replaces the lie on the discriminated-timeout
283
+ // path only; a genuine miss (surfaced above) keeps the original —
284
+ // there the "not found in PATH" text is accurate. The ORIGINAL error
285
+ // rides as `cause` so the terminal classification (shell name,
286
+ // signature) survives for diagnostics from the thrown value alone.
287
+ throw new Error(SHELL_RESOLUTION_TRANSIENT_MESSAGE, { cause: lastErr });
168
288
  }
289
+ throw lastErr;
169
290
  }
170
- /** Default backoff before the single shell-resolution retry. The observed
171
- * upstream transient self-heals in ~15–30s; this is a bounded bridge, not
172
- * a guarantee — a still-failing second attempt surfaces the original error. */
173
- const SHELL_RESOLUTION_RETRY_BACKOFF_MS = 2000;
174
- /** Test seam for the backoff: session-layer tests (composition, user_bash)
175
- * drive the retry without a backoffMs opt, so they shrink the module-level
176
- * default instead of sleeping a real 2s. Module-scoped (never a globalThis
177
- * key — a leaked value there would silently shrink the production backoff);
178
- * restore in the same describe that sets it. */
179
- let shellRetryBackoffMsForTest = null;
180
- export function _setShellResolutionRetryBackoffForTest(ms) {
181
- shellRetryBackoffMsForTest = ms;
182
- }
183
- function effectiveBackoffMs(override) {
184
- return override ?? shellRetryBackoffMsForTest ?? SHELL_RESOLUTION_RETRY_BACKOFF_MS;
291
+ /** Sink line for the discriminator's classification — the auditability of
292
+ * the 500ms threshold: a mis-tuned threshold would otherwise look like "no
293
+ * retries happened" in prod. Fires only on the GENUINE-MISS classification
294
+ * (the rare path — one line per surfaced config problem); the load-timeout
295
+ * path is already counted by shell_resolution_retry's recovered/exhausted
296
+ * census. Closed-enum classification + elapsed ms + the corroboration
297
+ * booleans: no command or shell content. */
298
+ function logShellResolutionClassification(fields) {
299
+ logEvent({
300
+ source: "sandbox",
301
+ level: "info",
302
+ event: "shell_resolution_genuine_miss",
303
+ fields,
304
+ });
185
305
  }
186
306
  function sleep(ms, signal) {
187
307
  // The abort rejection carries the caller's own abort reason when the
@@ -219,10 +339,11 @@ function sleep(ms, signal) {
219
339
  signal?.addEventListener("abort", onAbort, { once: true });
220
340
  });
221
341
  }
222
- /** srt's transient shell-resolution failure: `Shell '<name>' not found in
223
- * PATH`, thrown from wrapCommandWithSandbox when which.js's 1s
224
- * spawnSync('which') times out under load. Self-healing after ~15–30s;
225
- * preWrappedCommand retries it once. */
342
+ /** srt's shell-resolution failure: `Shell '<name>' not found in PATH`,
343
+ * thrown from wrapCommandWithSandbox when which.js's 1s spawnSync('which')
344
+ * times out under load — or genuinely answers "no". preWrappedCommand
345
+ * splits the two by evidence (see its doc) and retries only the load
346
+ * timeout, on an exponential ladder. */
226
347
  export function isShellResolutionFailure(err) {
227
348
  return err instanceof Error && /^Shell '[^']*' not found in PATH$/.test(err.message);
228
349
  }
@@ -161,9 +161,14 @@ export function makeBashComposition(manager, settings, cwd, onShellResolutionRet
161
161
  let bgCommand = input.command;
162
162
  let bgEnv;
163
163
  if (useSandbox) {
164
+ // retryPolicy "single": the bg launch ack is returns-
165
+ // immediately, and bg launches are precisely the high-load
166
+ // moments — a silent 35s ladder stall on the ack is worse than
167
+ // the error, so the legacy one-quick-retry stands here.
164
168
  bgCommand = await preWrappedCommand(manager, input.command, binShell, {
165
169
  signal: signal ?? undefined,
166
170
  onRetry: onShellResolutionRetry,
171
+ retryPolicy: "single",
167
172
  });
168
173
  bgEnv = { ...process.env, ...sandboxSpawnEnv(manager) };
169
174
  manager.cleanupAfterCommand();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.1.6-staging.1439.1",
3
+ "version": "1.1.6-staging.1445.1",
4
4
  "description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
@@ -58,5 +58,5 @@
58
58
  "turndown": "^7.2.4",
59
59
  "typebox": "^1.3.15"
60
60
  },
61
- "yagniSourceSha": "aef6a54c459365b8fcb575ca230209ca0bd47f38"
61
+ "yagniSourceSha": "df1d49933fa6a71d3b18754a183b5e400a45053a"
62
62
  }