Skip to content

Commit af0bf41

Browse files
committed
fix(deepagent-code): enforce WSL2 validation boundary
1 parent 02d8111 commit af0bf41

23 files changed

Lines changed: 492 additions & 134 deletions

packages/core/src/deepagent/failure-triage.ts

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { analyzeErrors, type ErrorPattern } from "./diagnosis"
2-
import type { ValidationResult } from "./round-state"
2+
import type { ValidationFailureKind, ValidationResult } from "./round-state"
33

44
/**
55
* T2 (S1-v3.4): failure triage — the "fixability × progress" three-light classifier.
@@ -38,12 +38,14 @@ export type TriageResult = {
3838
readonly reason: string // human-readable; flows into needs_human body / fold label
3939
}
4040

41-
// Exit codes that indicate the command/environment, not the code, failed.
42-
// 127 = command not found, 126 = not executable, 124 = timeout (GNU coreutils convention).
43-
const ENV_EXIT_CODES = new Set([124, 126, 127])
44-
// 128 + signal: 137 = SIGKILL (OOM), 139 = SIGSEGV, 134 = SIGABRT — when these come from the
45-
// toolchain itself they are environment crashes, not user-code assertions.
46-
const SIGNAL_EXIT_CODES = new Set([134, 137, 139])
41+
const ENV_FAILURE_KINDS = new Set<ValidationFailureKind>([
42+
"shell_bootstrap_failed",
43+
"unsupported_platform",
44+
"unsupported_dialect",
45+
"timeout",
46+
"signal",
47+
"output_unavailable",
48+
])
4749

4850
// Output signatures of environment / dependency / network / resource problems (not fixable by editing code).
4951
const ENV_OUTPUT =
@@ -66,8 +68,8 @@ const dominantCategory = (patterns: ErrorPattern[]): string | null => {
6668
return [...patterns].sort((a, b) => b.count - a.count || a.category.localeCompare(b.category))[0]!.category
6769
}
6870

69-
const hasEnvExitCode = (failed: readonly ValidationResult[]): number | undefined =>
70-
failed.find((f) => ENV_EXIT_CODES.has(f.exit_code) || SIGNAL_EXIT_CODES.has(f.exit_code))?.exit_code
71+
const environmentFailure = (failed: readonly ValidationResult[]): ValidationResult | undefined =>
72+
failed.find((result) => ENV_FAILURE_KINDS.has(result.kind))
7173

7274
/**
7375
* Classify a failing round into a tier (+ yellow substate). Pure function; all signals are passed in.
@@ -80,12 +82,12 @@ export const classifyFailure = (input: TriageInput): TriageResult => {
8082
const combined = [...input.failed.map((f) => f.output), input.errorOutput ?? ""].join("\n")
8183

8284
// ── 🔴 RED: not auto-fixable (any one hit → immediate exit, no budget burn) ──
83-
const envExit = hasEnvExitCode(input.failed)
84-
if (envExit !== undefined) {
85+
const transportFailure = environmentFailure(input.failed)
86+
if (transportFailure) {
8587
return {
8688
tier: "not_auto_fixable",
8789
category,
88-
reason: `command/environment failure (exit ${envExit}) — not auto-fixable`,
90+
reason: `validation runner failure (${transportFailure.kind}, exit ${transportFailure.exit_code}) — not auto-fixable`,
8991
}
9092
}
9193
if (ENV_OUTPUT.test(combined)) {

packages/core/src/deepagent/goal-loop.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Effect, Schema } from "effect"
2+
import type { ValidationResult } from "./round-state"
23
import { randomUUID } from "node:crypto"
34
import type { DocumentStore } from "./document-store"
45
import {
@@ -153,7 +154,9 @@ export class InvalidGoalError extends Schema.TaggedErrorClass<InvalidGoalError>(
153154
*/
154155
export type GraderPorts = {
155156
/** Run the given validation commands; `pass` iff ALL succeeded. */
156-
readonly runTests: (commands: readonly string[]) => Effect.Effect<{ readonly pass: boolean }>
157+
readonly runTests: (
158+
commands: readonly string[],
159+
) => Effect.Effect<{ readonly pass: boolean; readonly results?: readonly ValidationResult[] }>
157160
/**
158161
* Highest diagnostic severity currently present, or null when there are none. `checked` MUST be false
159162
* when the port could not actually compute diagnostics (LSP crashed/timed out, no client covered the
@@ -197,7 +200,10 @@ const evaluateOne = (
197200
Effect.gen(function* () {
198201
switch (criterion.kind) {
199202
case "tests_pass": {
200-
const { pass } = yield* ports.runTests(criterion.commands)
203+
const { pass, results } = yield* ports.runTests(criterion.commands)
204+
const runnerFailure = results?.find((result) => result.kind !== "command_exit")
205+
if (runnerFailure)
206+
return `tests_pass: validation runner failed (${runnerFailure.kind}) for [${runnerFailure.command}]`
201207
return pass ? null : `tests_pass: one or more of [${criterion.commands.join(", ")}] failed`
202208
}
203209
case "no_diagnostics": {

packages/core/src/deepagent/round-state.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
11
import type { ActivationStage, AgentMode, RoundDecision, RunPhase } from "./mode"
22

3+
export type ValidationFailureKind =
4+
| "command_exit"
5+
| "shell_bootstrap_failed"
6+
| "unsupported_platform"
7+
| "unsupported_dialect"
8+
| "timeout"
9+
| "signal"
10+
| "output_unavailable"
11+
312
export type ValidationResult = {
413
readonly command: string
514
readonly passed: boolean
6-
// T1 (S1-v3.4): the raw process exit code, carried through for failure triage.
7-
// 127 = command not found, 126 = not executable, 124 = timeout, 137 = OOM/SIGKILL, etc.
8-
// These are the green/red dividing signals classifyFailure() needs; `passed` is still
9-
// exactly `exit_code === 0`, so existing assertions are unaffected.
15+
// `kind` is authoritative. `exit_code` is the process code for command_exit and a diagnostic
16+
// compatibility value for runner failures; classifiers must never infer transport failures from
17+
// an exit code alone because user commands are allowed to return 124/126/127.
18+
readonly kind: ValidationFailureKind
1019
readonly exit_code: number
1120
readonly output: string
1221
readonly duration_ms: number
@@ -92,7 +101,10 @@ const stageForDecision = (decision: RoundDecision, current: ActivationStage): Ac
92101
// on exit_code (not output text) for the same reason validationFingerprint is — output carries volatile
93102
// noise (durations/timestamps) that must not make identical evidence look distinct.
94103
const candidateEvidenceKey = (c: CandidateRef): string =>
95-
`${c.round}|${c.status}|${[...c.validations].map((v) => `${v.command}=${v.exit_code}`).sort().join(",")}`
104+
`${c.round}|${c.status}|${[...c.validations]
105+
.map((v) => `${v.command}=${v.kind}:${v.exit_code}`)
106+
.sort()
107+
.join(",")}`
96108

97109
export const addCandidate = (state: RoundState, candidate: CandidateRef): RoundState => {
98110
// STALE-REHARVEST DEDUPE (single append site; covers BOTH the request-prep path and the micro-round

packages/core/src/deepagent/validation.ts

Lines changed: 112 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,40 @@
1-
import type { ValidationResult } from "./round-state"
1+
import type { ValidationFailureKind, ValidationResult } from "./round-state"
2+
3+
export type ValidationCommandSource = "package_script" | "builtin" | "agents_md" | "user"
4+
export type ValidationScriptDialect = "posix"
5+
6+
export type ValidationCommand =
7+
| {
8+
readonly id: string
9+
readonly source: ValidationCommandSource
10+
readonly transport: "argv"
11+
readonly executable: string
12+
readonly args: readonly string[]
13+
readonly display: string
14+
}
15+
| {
16+
readonly id: string
17+
readonly source: ValidationCommandSource
18+
readonly transport: ValidationScriptDialect
19+
readonly script: string
20+
readonly display: string
21+
}
22+
23+
export type ValidationCommandInput = string | ValidationCommand
224

325
export type ValidationPlan = {
4-
readonly commands: readonly string[]
26+
readonly commands: readonly ValidationCommand[]
527
readonly timeout_ms: number
628
readonly failFast: boolean
729
}
830

931
export type ValidationConfig = {
1032
readonly cwd: string
11-
readonly commands: readonly string[]
33+
readonly commands: readonly ValidationCommandInput[]
1234
readonly timeout_ms?: number
1335
}
1436

15-
export const inferValidationCommands = (context: {
37+
export const inferValidationPlan = (context: {
1638
readonly cwd: string
1739
readonly packageJson?: { scripts?: Record<string, string> }
1840
readonly agentsMd?: string
@@ -21,40 +43,105 @@ export const inferValidationCommands = (context: {
2143
// The package-script runner for this workspace (e.g. "npm run", "bun run"). Defaults to npm.
2244
// P2-7: single inference impl; the deepagent-code production path passes "bun run".
2345
readonly runner?: string
24-
}): string[] => {
25-
const commands: string[] = []
46+
}): ValidationCommand[] => {
47+
const commands: ValidationCommand[] = []
2648
const run = context.runner ?? "npm run"
27-
const runnerBin = run.split(/\s+/)[0] ?? "npm" // "bun"/"npm" for the bare typecheck fallback
49+
const runner = run.trim().split(/\s+/).filter(Boolean)
50+
const runnerBin = runner[0] ?? "npm"
51+
const packageScript = (name: string): ValidationCommand => ({
52+
id: `package:${name}`,
53+
source: "package_script",
54+
transport: "argv",
55+
executable: runnerBin,
56+
args: [...runner.slice(1), name],
57+
display: `${run} ${name}`,
58+
})
2859

2960
if (context.packageJson?.scripts) {
3061
const scripts = context.packageJson.scripts
31-
if (scripts.typecheck) commands.push(`${run} typecheck`)
32-
else if (scripts["type-check"]) commands.push(`${run} type-check`)
33-
else if (context.hasTypeScript) commands.push(runnerBin === "bun" ? "bun typecheck" : "npx tsc --noEmit")
62+
if (scripts.typecheck) commands.push(packageScript("typecheck"))
63+
else if (scripts["type-check"]) commands.push(packageScript("type-check"))
64+
else if (context.hasTypeScript)
65+
commands.push(
66+
runnerBin === "bun"
67+
? {
68+
id: "builtin:typecheck",
69+
source: "builtin",
70+
transport: "argv",
71+
executable: "bun",
72+
args: ["typecheck"],
73+
display: "bun typecheck",
74+
}
75+
: {
76+
id: "builtin:typecheck",
77+
source: "builtin",
78+
transport: "argv",
79+
executable: "npx",
80+
args: ["tsc", "--noEmit"],
81+
display: "npx tsc --noEmit",
82+
},
83+
)
3484

35-
if (scripts.lint) commands.push(`${run} lint`)
85+
if (scripts.lint) commands.push(packageScript("lint"))
3686
// P1-3: the test command is part of the micro-round validation gate — a failing test means
3787
// "not done". Only added when a test script actually exists (no blind test runs).
38-
if (scripts.test) commands.push(`${run} test`)
39-
if (scripts.build && !scripts.test) commands.push(`${run} build`)
88+
if (scripts.test) commands.push(packageScript("test"))
89+
if (scripts.build && !scripts.test) commands.push(packageScript("build"))
4090
} else if (context.hasTypeScript) {
41-
commands.push("npx tsc --noEmit")
91+
commands.push({
92+
id: "builtin:typecheck",
93+
source: "builtin",
94+
transport: "argv",
95+
executable: "npx",
96+
args: ["tsc", "--noEmit"],
97+
display: "npx tsc --noEmit",
98+
})
4299
}
43100

44101
if (context.hasPython) {
45-
commands.push("python -m py_compile *.py")
102+
commands.push({
103+
id: "builtin:python-compile",
104+
source: "builtin",
105+
transport: "argv",
106+
executable: "python",
107+
args: ["-m", "compileall", "-q", "."],
108+
display: "python -m compileall -q .",
109+
})
46110
}
47111

48112
if (context.agentsMd) {
49113
const inferredFromAgents = extractCommandsFromAgentsMd(context.agentsMd)
50-
for (const cmd of inferredFromAgents) {
51-
if (!commands.includes(cmd)) commands.push(cmd)
52-
}
114+
for (const cmd of inferredFromAgents)
115+
if (!commands.some((item) => item.display === cmd))
116+
commands.push({
117+
id: `agents:${commands.length}`,
118+
source: "agents_md",
119+
transport: "posix",
120+
script: cmd,
121+
display: cmd,
122+
})
53123
}
54124

55125
return commands
56126
}
57127

128+
export const inferValidationCommands = (context: Parameters<typeof inferValidationPlan>[0]): string[] =>
129+
inferValidationPlan(context).map((command) => command.display)
130+
131+
export const normalizeValidationCommand = (command: ValidationCommandInput): ValidationCommand =>
132+
typeof command === "string"
133+
? {
134+
id: `user:${command}`,
135+
source: "user",
136+
transport: "posix",
137+
script: command,
138+
display: command,
139+
}
140+
: command
141+
142+
export const validationCommandDisplay = (command: ValidationCommandInput): string =>
143+
normalizeValidationCommand(command).display
144+
58145
// P2-7: the single AGENTS.md command extractor (was duplicated in workspace-context with a
59146
// drifting regex). Matches both "`cmd` - typecheck" list items and "run `cmd` to typecheck" prose.
60147
export const extractCommandsFromAgentsMd = (content: string): string[] => {
@@ -70,7 +157,10 @@ export const extractCommandsFromAgentsMd = (content: string): string[] => {
70157
}
71158

72159
export const buildValidationPlan = (config: ValidationConfig): ValidationPlan => ({
73-
commands: config.commands.length > 0 ? config.commands : ["echo 'no validation commands configured'"],
160+
commands:
161+
config.commands.length > 0
162+
? config.commands.map(normalizeValidationCommand)
163+
: [normalizeValidationCommand("echo 'no validation commands configured'")],
74164
timeout_ms: config.timeout_ms ?? 60_000,
75165
failFast: true,
76166
})
@@ -80,9 +170,11 @@ export const parseValidationOutput = (
80170
exitCode: number,
81171
output: string,
82172
duration_ms: number,
173+
kind: ValidationFailureKind = "command_exit",
83174
): ValidationResult => ({
84175
command,
85-
passed: exitCode === 0,
176+
passed: kind === "command_exit" && exitCode === 0,
177+
kind,
86178
exit_code: exitCode,
87179
output: output.slice(-4000),
88180
duration_ms,

packages/core/test/deepagent/failure-triage.test.ts

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@ import type { ValidationResult } from "../../src/deepagent/round-state"
55
// T2 (S1-v3.4): classifyFailure — fixability × progress, priority RED > YELLOW > GREEN.
66

77
const vr = (over: Partial<ValidationResult> = {}): ValidationResult => ({
8-
command: "tsc",
9-
passed: false,
10-
exit_code: 1,
11-
output: "",
12-
duration_ms: 1,
13-
...over,
8+
command: over.command ?? "tsc",
9+
passed: over.passed ?? false,
10+
kind: over.kind ?? "command_exit",
11+
exit_code: over.exit_code ?? 1,
12+
output: over.output ?? "",
13+
duration_ms: over.duration_ms ?? 1,
1414
})
1515

1616
const base = {
@@ -25,16 +25,25 @@ const base = {
2525

2626
describe("failure-triage.classifyFailure", () => {
2727
describe("🔴 not_auto_fixable (environment)", () => {
28-
it("exit 127 (command not found) → red", () => {
29-
const r = FailureTriage.classifyFailure({ ...base, failed: [vr({ exit_code: 127 })] })
28+
it("typed shell bootstrap failure → red", () => {
29+
const r = FailureTriage.classifyFailure({
30+
...base,
31+
failed: [vr({ kind: "shell_bootstrap_failed", exit_code: -1 })],
32+
})
3033
expect(r.tier).toBe("not_auto_fixable")
31-
expect(r.reason).toMatch(/exit 127/)
34+
expect(r.reason).toMatch(/shell_bootstrap_failed/)
3235
})
33-
for (const code of [126, 124, 137, 139, 134]) {
34-
it(`exit ${code} → red`, () => {
35-
expect(FailureTriage.classifyFailure({ ...base, failed: [vr({ exit_code: code })] }).tier).toBe(
36-
"not_auto_fixable",
37-
)
36+
it("a command that deliberately exits 127 is not red because of the number alone", () => {
37+
const r = FailureTriage.classifyFailure({
38+
...base,
39+
failed: [vr({ kind: "command_exit", exit_code: 127, output: "application-specific status" })],
40+
})
41+
expect(r.tier).not.toBe("not_auto_fixable")
42+
expect(r.reason).not.toMatch(/exit 127/)
43+
})
44+
for (const kind of ["unsupported_platform", "unsupported_dialect", "timeout", "signal"] as const) {
45+
it(`${kind} → red`, () => {
46+
expect(FailureTriage.classifyFailure({ ...base, failed: [vr({ kind })] }).tier).toBe("not_auto_fixable")
3847
})
3948
}
4049
for (const sig of [
@@ -150,13 +159,14 @@ describe("failure-triage.classifyFailure", () => {
150159
})
151160

152161
describe("priority", () => {
153-
it("red beats yellow: env exit code wins even when stagnant", () => {
162+
it("a command exit 127 does not beat yellow merely because it is 127", () => {
154163
const r = FailureTriage.classifyFailure({
155164
...base,
156165
stagnant: true,
157166
failed: [vr({ exit_code: 127, output: "error TS2322: Type X is not assignable" })],
158167
})
159-
expect(r.tier).toBe("not_auto_fixable")
168+
expect(r.tier).toBe("needs_narrowing")
169+
expect(r.substate).toBe("stall")
160170
})
161171
it("yellow beats green: stall on a fixable category is not green", () => {
162172
const r = FailureTriage.classifyFailure({

0 commit comments

Comments
 (0)