@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.
- package/dist/extension/pipeline/goCommand.js +8 -3
- package/dist/extension/pipeline/runSession.d.ts +8 -2
- package/dist/extension/pipeline/runSession.js +49 -14
- package/dist/extension/sandbox/bash.d.ts +52 -12
- package/dist/extension/sandbox/bash.js +185 -64
- package/dist/extension/sandbox/session.js +5 -0
- package/package.json +2 -2
|
@@ -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 /
|
|
741
|
-
// tell the user the run won't show on the Work page
|
|
742
|
-
|
|
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
|
|
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
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* so
|
|
36
|
-
*
|
|
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
|
-
|
|
42
|
+
function refusalMessage(body) {
|
|
39
43
|
try {
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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}${
|
|
99
|
-
|
|
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 {
|
|
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
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
/**
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
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.
|
|
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": "
|
|
61
|
+
"yagniSourceSha": "df1d49933fa6a71d3b18754a183b5e400a45053a"
|
|
62
62
|
}
|