@yagni-app/code-staging 1.1.6-staging.1438.1 → 1.1.6-staging.1443.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.
|
@@ -45,9 +45,7 @@ export const CHILD_HONESTY_PREAMBLE = "Never fabricate file paths, file contents
|
|
|
45
45
|
"rather than presenting a guess as a finding.";
|
|
46
46
|
/** Fill {ticket}/{previous}; an absent var renders as empty string. */
|
|
47
47
|
export function renderTemplate(tmpl, vars) {
|
|
48
|
-
return tmpl
|
|
49
|
-
.replace(/\{ticket\}/g, vars.ticket ?? "")
|
|
50
|
-
.replace(/\{previous\}/g, vars.previous ?? "");
|
|
48
|
+
return tmpl.replace(/\{(ticket|previous)\}/g, (_, key) => vars[key] ?? "");
|
|
51
49
|
}
|
|
52
50
|
/**
|
|
53
51
|
* Build the pi passthrough argv for one stage (from `--mode` onward). The
|
|
@@ -44,6 +44,10 @@ export const REQUIRED_LENSES = ["correctness"];
|
|
|
44
44
|
const READ_TOOLS = ["read", "grep", "find", "ls", "bash"];
|
|
45
45
|
/** Full write tools for the worker stages. */
|
|
46
46
|
const WRITE_TOOLS = ["read", "write", "edit", "bash", "grep", "find", "ls"];
|
|
47
|
+
const TICKET_CONTEXT = "Follow the approved plan and its explicit constraints, including approved answers. " +
|
|
48
|
+
"Prior summaries and review findings do not authorize expanding scope or publication " +
|
|
49
|
+
"permissions. Report conflicts requiring a scope change for approval.\n\n" +
|
|
50
|
+
"Ticket context and explicit constraints:\n{ticket}\n\n";
|
|
47
51
|
export const PIPELINE_V1 = [
|
|
48
52
|
{
|
|
49
53
|
id: "map",
|
|
@@ -71,7 +75,8 @@ export const PIPELINE_V1 = [
|
|
|
71
75
|
agent: "worker",
|
|
72
76
|
model: "standard",
|
|
73
77
|
tools: [...WRITE_TOOLS, "ask_yagni", "record_decision"],
|
|
74
|
-
taskTemplate:
|
|
78
|
+
taskTemplate: TICKET_CONTEXT +
|
|
79
|
+
"Implement the plan. Write the code against it, and call record_decision " +
|
|
75
80
|
"only for a consequential product-intent or architecture call the plan did " +
|
|
76
81
|
"not settle (never for taste, naming, or formatting).\n\nPlan:\n{previous}",
|
|
77
82
|
},
|
|
@@ -80,14 +85,16 @@ export const PIPELINE_V1 = [
|
|
|
80
85
|
agent: "reviewer",
|
|
81
86
|
model: "peak",
|
|
82
87
|
tools: [...READ_TOOLS, "ask_yagni", "review_business_match"],
|
|
83
|
-
taskTemplate:
|
|
88
|
+
taskTemplate: TICKET_CONTEXT +
|
|
89
|
+
"Review the implementation adversarially. Diff/summary of the change:\n{previous}",
|
|
84
90
|
},
|
|
85
91
|
{
|
|
86
92
|
id: "fix",
|
|
87
93
|
agent: "worker",
|
|
88
94
|
model: "standard",
|
|
89
95
|
tools: [...WRITE_TOOLS, "ask_yagni"],
|
|
90
|
-
taskTemplate:
|
|
96
|
+
taskTemplate: TICKET_CONTEXT +
|
|
97
|
+
"Reconcile ONLY the blocking review findings against the plan. Leave " +
|
|
91
98
|
"non-blocking findings alone.\n\nBlocking findings:\n{previous}",
|
|
92
99
|
},
|
|
93
100
|
];
|
|
@@ -197,7 +204,8 @@ export function builderStage(workstream, siblings = []) {
|
|
|
197
204
|
model: workstream.tier,
|
|
198
205
|
workstream: workstream.name,
|
|
199
206
|
personaClause: builderPersonaClause(workstream, siblings),
|
|
200
|
-
taskTemplate:
|
|
207
|
+
taskTemplate: TICKET_CONTEXT +
|
|
208
|
+
`Implement ONLY your workstream of the plan below: "${workstream.name}".\n\n` +
|
|
201
209
|
`Your workstream: ${workstream.task}\n\n` +
|
|
202
210
|
"Write the code against the plan, staying inside the files you own, and call " +
|
|
203
211
|
"record_decision only for a consequential product-intent or architecture call " +
|
|
@@ -216,7 +224,8 @@ export function synthesizerStage() {
|
|
|
216
224
|
...implement,
|
|
217
225
|
agent: "synthesizer",
|
|
218
226
|
model: "standard",
|
|
219
|
-
taskTemplate:
|
|
227
|
+
taskTemplate: TICKET_CONTEXT +
|
|
228
|
+
"Several builders just finished in parallel in this working tree. Reconcile the " +
|
|
220
229
|
"seams between their changes, address the checker findings and any out-of-claim " +
|
|
221
230
|
"edits below, and write ONE implement summary for the reviewers.\n\n{previous}",
|
|
222
231
|
};
|
|
@@ -236,7 +245,8 @@ export const SYNTHESIZER_CALLER_LABEL = `${IMPLEMENT_CALLER_PREFIX}synthesizer`;
|
|
|
236
245
|
export function synthesizerFixStage(findings) {
|
|
237
246
|
return {
|
|
238
247
|
...synthesizerStage(),
|
|
239
|
-
taskTemplate:
|
|
248
|
+
taskTemplate: TICKET_CONTEXT +
|
|
249
|
+
"Verification failed on the merged tree, and these findings do not sit inside any single " +
|
|
240
250
|
"workstream's files, so they are yours. Fix ONLY what is listed, then re-write the implement " +
|
|
241
251
|
`summary for the reviewers in the same format.\n\nFindings:\n${findings}`,
|
|
242
252
|
};
|
|
@@ -251,7 +261,8 @@ export function fixerStage(workstream, findings, siblings = []) {
|
|
|
251
261
|
return {
|
|
252
262
|
...base,
|
|
253
263
|
personaClause: builderPersonaClause(workstream, siblings),
|
|
254
|
-
taskTemplate:
|
|
264
|
+
taskTemplate: TICKET_CONTEXT +
|
|
265
|
+
"Verification failed. Fix ONLY the findings below" +
|
|
255
266
|
(workstream ? `, and only inside the files your workstream owns` : "") +
|
|
256
267
|
". Do not start a fresh pass over the plan and do not fix anything that is not " +
|
|
257
268
|
`listed.\n\nFindings:\n${findings}`,
|
|
@@ -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.1443.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": "8e549e888319699160837efefdfa8f1d835a84b5"
|
|
62
62
|
}
|