@dungle-scrubs/harness-cli-normalizer 0.1.3 → 0.3.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.
Files changed (154) hide show
  1. package/README.md +156 -13
  2. package/dist/cli/args.d.ts +74 -0
  3. package/dist/cli/args.d.ts.map +1 -0
  4. package/dist/cli/args.js +339 -0
  5. package/dist/cli/args.js.map +1 -0
  6. package/dist/cli/check.d.ts +2 -0
  7. package/dist/cli/check.d.ts.map +1 -0
  8. package/dist/cli/check.js +110 -0
  9. package/dist/cli/check.js.map +1 -0
  10. package/dist/cli/exit-codes.d.ts +7 -0
  11. package/dist/cli/exit-codes.d.ts.map +1 -0
  12. package/dist/cli/exit-codes.js +11 -0
  13. package/dist/cli/exit-codes.js.map +1 -0
  14. package/dist/cli/help.d.ts +7 -0
  15. package/dist/cli/help.d.ts.map +1 -0
  16. package/dist/cli/help.js +110 -0
  17. package/dist/cli/help.js.map +1 -0
  18. package/dist/cli/index.d.ts +6 -0
  19. package/dist/cli/index.d.ts.map +1 -0
  20. package/dist/cli/index.js +186 -0
  21. package/dist/cli/index.js.map +1 -0
  22. package/dist/cli/inspect.d.ts +3 -0
  23. package/dist/cli/inspect.d.ts.map +1 -0
  24. package/dist/cli/inspect.js +170 -0
  25. package/dist/cli/inspect.js.map +1 -0
  26. package/dist/cli/ls.d.ts +2 -0
  27. package/dist/cli/ls.d.ts.map +1 -0
  28. package/dist/cli/ls.js +10 -0
  29. package/dist/cli/ls.js.map +1 -0
  30. package/dist/cli/render.d.ts +8 -0
  31. package/dist/cli/render.d.ts.map +1 -0
  32. package/dist/cli/render.js +55 -0
  33. package/dist/cli/render.js.map +1 -0
  34. package/dist/cli/resolve-harness.d.ts +4 -0
  35. package/dist/cli/resolve-harness.d.ts.map +1 -0
  36. package/dist/cli/resolve-harness.js +31 -0
  37. package/dist/cli/resolve-harness.js.map +1 -0
  38. package/dist/cli/run.d.ts +2 -0
  39. package/dist/cli/run.d.ts.map +1 -0
  40. package/dist/cli/run.js +280 -0
  41. package/dist/cli/run.js.map +1 -0
  42. package/dist/cli/session.d.ts +2 -0
  43. package/dist/cli/session.d.ts.map +1 -0
  44. package/dist/cli/session.js +128 -0
  45. package/dist/cli/session.js.map +1 -0
  46. package/dist/cli/version.d.ts +2 -0
  47. package/dist/cli/version.d.ts.map +1 -0
  48. package/dist/cli/version.js +34 -0
  49. package/dist/cli/version.js.map +1 -0
  50. package/dist/cli.js +3 -0
  51. package/dist/execution/decode.d.ts.map +1 -1
  52. package/dist/execution/decode.js +32 -0
  53. package/dist/execution/decode.js.map +1 -1
  54. package/dist/execution/deps.d.ts +7 -1
  55. package/dist/execution/deps.d.ts.map +1 -1
  56. package/dist/execution/events.d.ts +6 -2
  57. package/dist/execution/events.d.ts.map +1 -1
  58. package/dist/execution/events.js.map +1 -1
  59. package/dist/execution/failure.d.ts +45 -0
  60. package/dist/execution/failure.d.ts.map +1 -0
  61. package/dist/execution/failure.js +121 -0
  62. package/dist/execution/failure.js.map +1 -0
  63. package/dist/execution/node-deps.d.ts.map +1 -1
  64. package/dist/execution/node-deps.js +19 -0
  65. package/dist/execution/node-deps.js.map +1 -1
  66. package/dist/execution/stream-turn.d.ts +5 -1
  67. package/dist/execution/stream-turn.d.ts.map +1 -1
  68. package/dist/execution/stream-turn.js +188 -24
  69. package/dist/execution/stream-turn.js.map +1 -1
  70. package/dist/interpretation/argv.d.ts +16 -4
  71. package/dist/interpretation/argv.d.ts.map +1 -1
  72. package/dist/interpretation/argv.js +68 -19
  73. package/dist/interpretation/argv.js.map +1 -1
  74. package/dist/interpretation/dimensions.d.ts +0 -2
  75. package/dist/interpretation/dimensions.d.ts.map +1 -1
  76. package/dist/interpretation/dimensions.js +0 -2
  77. package/dist/interpretation/dimensions.js.map +1 -1
  78. package/dist/interpretation/limits.d.ts +10 -1
  79. package/dist/interpretation/limits.d.ts.map +1 -1
  80. package/dist/interpretation/limits.js +84 -5
  81. package/dist/interpretation/limits.js.map +1 -1
  82. package/dist/interpretation/refusal.d.ts +31 -0
  83. package/dist/interpretation/refusal.d.ts.map +1 -0
  84. package/dist/interpretation/refusal.js +87 -0
  85. package/dist/interpretation/refusal.js.map +1 -0
  86. package/dist/interpretation/store.js +1 -1
  87. package/dist/interpretation/turn-options.d.ts +18 -0
  88. package/dist/interpretation/turn-options.d.ts.map +1 -0
  89. package/dist/interpretation/turn-options.js +417 -0
  90. package/dist/interpretation/turn-options.js.map +1 -0
  91. package/dist/interpretation/vocabulary.d.ts +5 -0
  92. package/dist/interpretation/vocabulary.d.ts.map +1 -1
  93. package/dist/interpretation/vocabulary.js +1 -1
  94. package/dist/interpretation/vocabulary.js.map +1 -1
  95. package/dist/knowledge/claude-code.d.ts +13 -1
  96. package/dist/knowledge/claude-code.d.ts.map +1 -1
  97. package/dist/knowledge/claude-code.js +35 -13
  98. package/dist/knowledge/claude-code.js.map +1 -1
  99. package/dist/knowledge/codex.d.ts.map +1 -1
  100. package/dist/knowledge/codex.js +25 -14
  101. package/dist/knowledge/codex.js.map +1 -1
  102. package/dist/knowledge/descriptor.d.ts +118 -14
  103. package/dist/knowledge/descriptor.d.ts.map +1 -1
  104. package/dist/knowledge/descriptor.js +58 -0
  105. package/dist/knowledge/descriptor.js.map +1 -1
  106. package/dist/knowledge/matchers.d.ts +3 -3
  107. package/dist/knowledge/matchers.d.ts.map +1 -1
  108. package/dist/knowledge/matchers.js +19 -6
  109. package/dist/knowledge/matchers.js.map +1 -1
  110. package/dist/knowledge/muse.d.ts +2 -2
  111. package/dist/knowledge/muse.d.ts.map +1 -1
  112. package/dist/knowledge/muse.js +25 -6
  113. package/dist/knowledge/muse.js.map +1 -1
  114. package/dist/knowledge/overrides.d.ts +7 -0
  115. package/dist/knowledge/overrides.d.ts.map +1 -1
  116. package/dist/knowledge/overrides.js +76 -0
  117. package/dist/knowledge/overrides.js.map +1 -1
  118. package/dist/knowledge/pi.d.ts +1 -1
  119. package/dist/knowledge/pi.d.ts.map +1 -1
  120. package/dist/knowledge/pi.js +33 -9
  121. package/dist/knowledge/pi.js.map +1 -1
  122. package/package.json +5 -2
  123. package/src/cli/args.ts +381 -0
  124. package/src/cli/check.ts +136 -0
  125. package/src/cli/exit-codes.ts +13 -0
  126. package/src/cli/help.ts +114 -0
  127. package/src/cli/index.ts +190 -0
  128. package/src/cli/inspect.ts +174 -0
  129. package/src/cli/ls.ts +14 -0
  130. package/src/cli/render.ts +62 -0
  131. package/src/cli/resolve-harness.ts +33 -0
  132. package/src/cli/run.ts +280 -0
  133. package/src/cli/session.ts +137 -0
  134. package/src/cli/version.ts +32 -0
  135. package/src/execution/decode.ts +32 -0
  136. package/src/execution/deps.ts +7 -1
  137. package/src/execution/events.ts +9 -2
  138. package/src/execution/failure.ts +159 -0
  139. package/src/execution/node-deps.ts +17 -0
  140. package/src/execution/stream-turn.ts +192 -21
  141. package/src/interpretation/argv.ts +85 -27
  142. package/src/interpretation/dimensions.ts +0 -6
  143. package/src/interpretation/limits.ts +97 -6
  144. package/src/interpretation/refusal.ts +116 -0
  145. package/src/interpretation/store.ts +1 -1
  146. package/src/interpretation/turn-options.ts +424 -0
  147. package/src/interpretation/vocabulary.ts +1 -1
  148. package/src/knowledge/claude-code.ts +35 -13
  149. package/src/knowledge/codex.ts +25 -14
  150. package/src/knowledge/descriptor.ts +138 -12
  151. package/src/knowledge/matchers.ts +22 -9
  152. package/src/knowledge/muse.ts +25 -6
  153. package/src/knowledge/overrides.ts +97 -0
  154. package/src/knowledge/pi.ts +33 -9
@@ -0,0 +1,159 @@
1
+ /**
2
+ * failure: typed taxonomy for every way a turn can fail, and the reduction
3
+ * that collapses many observations into one self-sufficient summary on `done`.
4
+ *
5
+ * Provider-unavailable vs work-verdict is the load-bearing split: a provider-
6
+ * unavailable failure (rate-limit, usage-limit, quota, auth, transport) reached
7
+ * no verdict on the work, so routing the same work elsewhere is safe. A
8
+ * work-verdict failure (task, budget) means the model did the work and the
9
+ * work is wrong or incomplete - the router must NOT auto-route it elsewhere,
10
+ * or it would hide a model error behind a retry. `rejected` is the library
11
+ * refusing to build the call at all (unexpressible option, bad value); it is
12
+ * non-retryable across the whole model chain because the remedy is different
13
+ * options or a different harness, not a different model.
14
+ */
15
+
16
+ import type { RefusalIssue } from "../interpretation/refusal.js";
17
+ import type {
18
+ AuthFailureKind,
19
+ DiscoveryFacet,
20
+ LimitCode,
21
+ TurnOptionKey,
22
+ } from "../knowledge/descriptor.js";
23
+
24
+ export const FAILURE_CLASSES = Object.freeze([
25
+ "rate-limit",
26
+ "usage-limit",
27
+ "quota",
28
+ "auth",
29
+ "budget",
30
+ "task",
31
+ "transport",
32
+ "rejected",
33
+ ] as const);
34
+ export type FailureClass = (typeof FAILURE_CLASSES)[number];
35
+
36
+ export interface FailureSummary {
37
+ readonly class: FailureClass;
38
+ readonly retryable: boolean;
39
+ readonly message: string;
40
+ readonly code?: LimitCode;
41
+ readonly authKind?: AuthFailureKind;
42
+ readonly resetsAt?: number;
43
+ readonly issue?: RefusalIssue;
44
+ readonly option?: TurnOptionKey;
45
+ readonly facet?: DiscoveryFacet;
46
+ readonly supported?: readonly string[];
47
+ }
48
+
49
+ export const retryableOf = (cls: FailureClass): boolean =>
50
+ cls !== "task" && cls !== "budget" && cls !== "rejected";
51
+
52
+ const messageFor = (cls: FailureClass, detail?: string): string => {
53
+ switch (cls) {
54
+ case "rate-limit":
55
+ return `Rate limit hit${detail ? ` (${detail})` : ""} - retry after backoff or route to another provider`;
56
+ case "usage-limit":
57
+ return `Usage limit reached${detail ? ` (${detail})` : ""} - wait for reset or route to another provider`;
58
+ case "quota":
59
+ return `Quota exceeded${detail ? ` (${detail})` : ""} - replenish quota or route to another provider`;
60
+ case "auth":
61
+ return `Authentication failed${detail ? ` (${detail})` : ""} - re-authenticate this provider`;
62
+ case "budget":
63
+ return `Step budget exhausted${detail ? ` (${detail})` : ""} - raise the step cap and retry on the same model`;
64
+ case "task":
65
+ return `Task failed${detail ? ` (${detail})` : ""} - surface to caller, do not auto-route`;
66
+ case "transport":
67
+ return `Transport failure${detail ? ` (${detail})` : ""} - retry or route to another provider`;
68
+ case "rejected":
69
+ return `Request rejected${detail ? ` (${detail})` : ""} - change options or harness`;
70
+ default:
71
+ return `Failure${detail ? ` (${detail})` : ""}`;
72
+ }
73
+ };
74
+
75
+ export const failureFromLimit = (code: LimitCode): FailureSummary => {
76
+ const cls: FailureClass =
77
+ code === "rate-limit"
78
+ ? "rate-limit"
79
+ : code === "credits" || code === "quota"
80
+ ? "quota"
81
+ : "usage-limit";
82
+ return {
83
+ class: cls,
84
+ retryable: retryableOf(cls),
85
+ message: messageFor(cls, code),
86
+ code: code as LimitCode,
87
+ };
88
+ };
89
+
90
+ export const failureFromAuth = (kind: AuthFailureKind): FailureSummary => ({
91
+ class: "auth",
92
+ retryable: true,
93
+ message: messageFor("auth", kind),
94
+ authKind: kind,
95
+ });
96
+
97
+ export const failureFromTask = (detail?: string): FailureSummary => ({
98
+ class: "task",
99
+ retryable: false,
100
+ message: messageFor("task", detail),
101
+ });
102
+
103
+ export const failureFromBudget = (detail?: string): FailureSummary => ({
104
+ class: "budget",
105
+ retryable: false,
106
+ message: messageFor("budget", detail),
107
+ });
108
+
109
+ export const failureFromTransport = (detail?: string): FailureSummary => ({
110
+ class: "transport",
111
+ retryable: true,
112
+ message: messageFor("transport", detail),
113
+ });
114
+
115
+ export const failureFromRejected = (opts: {
116
+ issue: RefusalIssue;
117
+ option?: TurnOptionKey;
118
+ facet?: DiscoveryFacet;
119
+ supported?: readonly string[];
120
+ detail?: string;
121
+ }): FailureSummary => ({
122
+ class: "rejected",
123
+ retryable: false,
124
+ message: messageFor("rejected", opts.detail ?? opts.issue),
125
+ issue: opts.issue,
126
+ option: opts.option,
127
+ facet: opts.facet,
128
+ supported: opts.supported,
129
+ });
130
+
131
+ /** Precedence for reduction: lower number = higher priority (wins). */
132
+ const PRECEDENCE: Record<FailureClass, number> = {
133
+ auth: 1,
134
+ "rate-limit": 2,
135
+ "usage-limit": 2,
136
+ quota: 2,
137
+ budget: 3,
138
+ task: 3,
139
+ transport: 4,
140
+ rejected: 0, // rejected is separate, not reduced with others? But per spec, rejected is non-retryable and stands alone
141
+ };
142
+
143
+ export const reduceFailures = (failures: readonly FailureSummary[]): FailureSummary | undefined => {
144
+ if (failures.length === 0) return undefined;
145
+ if (failures.length === 1) return failures[0];
146
+ // Sort by precedence, then by earliest (stable). Lower precedence number wins.
147
+ let best = failures[0]!;
148
+ let bestPrec = PRECEDENCE[best.class] ?? 99;
149
+ for (let i = 1; i < failures.length; i++) {
150
+ const cur = failures[i]!;
151
+ const curPrec = PRECEDENCE[cur.class] ?? 99;
152
+ if (curPrec < bestPrec) {
153
+ best = cur;
154
+ bestPrec = curPrec;
155
+ }
156
+ // ties keep earliest (do nothing)
157
+ }
158
+ return best;
159
+ };
@@ -91,8 +91,25 @@ export const disposableOutputStream = (
91
91
  const realSpawn = (argv: readonly string[], opts: SpawnOptions): SpawnedProcess => {
92
92
  const [bin, ...args] = argv;
93
93
  if (bin === undefined) throw new Error("empty argv");
94
+ const env =
95
+ opts.env === undefined
96
+ ? undefined
97
+ : (() => {
98
+ const merged: Record<string, string | undefined> = { ...process.env };
99
+ for (const [k, v] of Object.entries(opts.env)) {
100
+ if (v === "") delete merged[k];
101
+ else merged[k] = v;
102
+ }
103
+ // Filter out undefined to satisfy Node's env type (string -> string)
104
+ const out: Record<string, string> = {};
105
+ for (const [k, v] of Object.entries(merged)) {
106
+ if (v !== undefined) out[k] = v;
107
+ }
108
+ return out;
109
+ })();
94
110
  const child = nodeSpawn(bin, args, {
95
111
  ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
112
+ ...(env !== undefined ? { env } : {}),
96
113
  stdio: [
97
114
  opts.stdin === "pipe" ? "pipe" : opts.stdin === "close" ? "ignore" : "inherit",
98
115
  "pipe",
@@ -17,11 +17,21 @@ import {
17
17
  } from "../interpretation/argv.js";
18
18
  import { stdinPolicyOf } from "../interpretation/dimensions.js";
19
19
  import { detectAuthFailureInLine, detectLimitInLine } from "../interpretation/limits.js";
20
+ import { ArgvRefusalError } from "../interpretation/refusal.js";
20
21
  import type { HarnessDescriptor } from "../knowledge/descriptor.js";
22
+ import { matcherOverridesOf } from "../knowledge/overrides.js";
21
23
  import { AsyncChannel } from "./channel.js";
22
24
  import { decodeLine, freshDecodeState } from "./decode.js";
23
25
  import type { RunnerDeps, SpawnedProcess } from "./deps.js";
24
26
  import type { ExitCause, HarnessEvent } from "./events.js";
27
+ import type { FailureSummary } from "./failure.js";
28
+ import {
29
+ failureFromAuth,
30
+ failureFromLimit,
31
+ failureFromRejected,
32
+ failureFromTransport,
33
+ reduceFailures,
34
+ } from "./failure.js";
25
35
  import { LineBuffer } from "./lines.js";
26
36
 
27
37
  /** Fallback correlation when the host does not mint turn ids: monotonic per
@@ -45,13 +55,18 @@ const SECRETISH = /(sk-[A-Za-z0-9_-]{8,}|(?:token|key|secret|password)=\S+)/i;
45
55
 
46
56
  /** Redact by POSITION, not shape: the prompt is a known argv slot and is
47
57
  * masked wholesale (content never reaches a log line - v1 D-005); every
48
- * other token is kept unless it is secret-shaped. */
49
- export const redactArgv = (argv: readonly string[], prompt?: string): string[] =>
50
- argv.map((token) => {
51
- if (prompt !== undefined && token === prompt) return `[prompt:${prompt.length}ch]`;
58
+ * other token is kept unless it is secret-shaped. Only the prompt's
59
+ * positional slot is masked, so a one-word prompt that equals a flag
60
+ * value does not cause that flag value to be masked. */
61
+ export const redactArgv = (argv: readonly string[], prompt?: string): string[] => {
62
+ const promptIndex = prompt !== undefined ? argv.lastIndexOf(prompt) : -1;
63
+ const promptLabel = prompt !== undefined ? `[prompt:${prompt.length}ch]` : "";
64
+ return argv.map((token, index) => {
65
+ if (index === promptIndex) return promptLabel;
52
66
  if (SECRETISH.test(token)) return "[redacted]";
53
67
  return token;
54
68
  });
69
+ };
55
70
 
56
71
  /** Bounded tail of unmatched stderr - the crash context a nonzero exit is
57
72
  * explained by (v1 kept the turn's output slice for exactly this). Shared
@@ -79,6 +94,8 @@ export interface TurnRunOptions extends LaunchOptions {
79
94
  readonly resume?: string;
80
95
  /** Working directory for the spawned harness. */
81
96
  readonly cwd?: string;
97
+ /** Per-call environment, merged over parent; "" deletes. */
98
+ readonly env?: Readonly<Record<string, string>>;
82
99
  }
83
100
 
84
101
  export async function* streamTurn(
@@ -88,18 +105,87 @@ export async function* streamTurn(
88
105
  ): AsyncIterable<HarnessEvent> {
89
106
  const turnId = deps.turnId ?? `turn-${++turnCounter}`;
90
107
  const log = deps.log ?? (() => {});
91
- const argv =
92
- opts.resume === undefined
93
- ? buildLaunchArgv(h, opts)
94
- : buildResumeArgv(h, { ...opts, sessionId: opts.resume });
95
- const granularity = streamingGranularityOf(h, argv);
96
108
 
109
+ // Validate env before building argv so an invalid env is a refusal, not a spawn
110
+ if (opts.env !== undefined) {
111
+ for (const [k, v] of Object.entries(opts.env)) {
112
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k) || k.includes("\0") || v.includes("\0")) {
113
+ const refusal = new ArgvRefusalError({
114
+ issue: "invalid-env",
115
+ harness: h.name,
116
+ supported: ["env keys must match /^[A-Za-z_][A-Za-z0-9_]*$/ and no NUL"],
117
+ detail: k,
118
+ });
119
+ const failure = failureFromRejected({
120
+ issue: refusal.issue,
121
+ option: undefined,
122
+ supported: refusal.supported,
123
+ detail: k,
124
+ });
125
+ log({
126
+ event: "rejected",
127
+ turnId,
128
+ harness: h.name,
129
+ issue: refusal.issue,
130
+ supported: refusal.supported,
131
+ argv: redactArgv([], opts.prompt),
132
+ });
133
+ yield { kind: "failure", ...failure };
134
+ yield { kind: "done", exitCode: null, cause: "failed", failure };
135
+ return;
136
+ }
137
+ }
138
+ }
139
+
140
+ let argv: string[];
141
+ let granularity: import("../knowledge/descriptor.js").StreamingGranularity;
142
+ try {
143
+ argv =
144
+ opts.resume === undefined
145
+ ? buildLaunchArgv(h, opts)
146
+ : buildResumeArgv(h, { ...opts, sessionId: opts.resume });
147
+ granularity = streamingGranularityOf(h, argv);
148
+ } catch (e) {
149
+ if (e instanceof ArgvRefusalError) {
150
+ const failure = failureFromRejected({
151
+ issue: e.issue,
152
+ option: e.option,
153
+ facet: e.facet,
154
+ supported: e.supported,
155
+ detail: e.message,
156
+ });
157
+ // No process spawned on a refusal - log rejected instead of spawn
158
+ let argvForLog: string[] = [];
159
+ try {
160
+ argvForLog = redactArgv([], opts.prompt);
161
+ } catch {}
162
+ log({
163
+ event: "rejected",
164
+ turnId,
165
+ harness: h.name,
166
+ issue: e.issue,
167
+ option: e.option,
168
+ facet: e.facet,
169
+ supported: e.supported,
170
+ argv: argvForLog,
171
+ });
172
+ yield { kind: "failure", ...failure };
173
+ yield { kind: "done", exitCode: null, cause: "failed", failure };
174
+ return;
175
+ }
176
+ throw e;
177
+ }
178
+
179
+ const matcherOverrides = matcherOverridesOf.get(h);
180
+ const envKeys = opts.env ? Object.keys(opts.env) : undefined;
97
181
  log({
98
182
  event: "spawn",
99
183
  turnId,
100
184
  harness: h.name,
101
185
  argv: redactArgv(argv, opts.prompt),
102
186
  granularity,
187
+ ...(matcherOverrides ? { matcherOverrides } : {}),
188
+ ...(envKeys?.length ? { envKeys } : {}),
103
189
  });
104
190
 
105
191
  let proc: SpawnedProcess;
@@ -107,11 +193,12 @@ export async function* streamTurn(
107
193
  proc = deps.spawn(argv, {
108
194
  stdin: stdinPolicyOf(h) === "close-required" ? "close" : "inherit",
109
195
  ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
196
+ ...(opts.env !== undefined ? { env: opts.env } : {}),
110
197
  });
111
198
  } catch (cause) {
112
- // A spawn that cannot start still honors the contract: error, done,
113
- // and a closed exit log - never a throw out of the first next().
199
+ // Spawn failure is a transport failure, not merely a crash
114
200
  const message = cause instanceof Error ? cause.message : String(cause);
201
+ const failure = failureFromTransport(`spawn failed: ${message}`);
115
202
  log({
116
203
  event: "exit",
117
204
  turnId,
@@ -121,7 +208,8 @@ export async function* streamTurn(
121
208
  spawnError: message,
122
209
  });
123
210
  yield { kind: "error", message: `spawn failed: ${message}` };
124
- yield { kind: "done", exitCode: 127, cause: "crash" };
211
+ yield { kind: "failure", ...failure };
212
+ yield { kind: "done", exitCode: 127, cause: "failed", failure };
125
213
  return;
126
214
  }
127
215
 
@@ -148,21 +236,62 @@ export async function* streamTurn(
148
236
  }, KILL_GRACE_MS);
149
237
  };
150
238
 
239
+ const failures: FailureSummary[] = [];
240
+ const pushFailure = async (f: FailureSummary): Promise<void> => {
241
+ failures.push(f);
242
+ await queue.push({ kind: "failure", ...f });
243
+ };
244
+
151
245
  let watchdog: number | null = null;
246
+ let turnDeadline: number | null = null;
247
+ let watchdogReason: "inactivity" | "turn-deadline" | null = null;
152
248
  const disarm = (): void => {
153
249
  if (watchdog !== null) deps.clock.clearTimeout(watchdog);
154
250
  watchdog = null;
251
+ if (turnDeadline !== null) deps.clock.clearTimeout(turnDeadline);
252
+ turnDeadline = null;
155
253
  };
156
254
  const rearm = (): void => {
157
- if (granularity !== "none" || deps.stallMs === undefined) return;
158
- disarm();
255
+ if (deps.stallMs === undefined) return;
256
+ if (watchdog !== null) deps.clock.clearTimeout(watchdog);
159
257
  watchdog = deps.clock.setTimeout(() => {
160
258
  killedByWatchdog = true;
161
- log({ event: "stall", turnId, harness: h.name, stallMs: deps.stallMs });
259
+ watchdogReason = "inactivity";
260
+ log({
261
+ event: "stall",
262
+ turnId,
263
+ harness: h.name,
264
+ reason: "inactivity",
265
+ budgetMs: deps.stallMs,
266
+ });
267
+ // Disarm the other budget
268
+ if (turnDeadline !== null) {
269
+ deps.clock.clearTimeout(turnDeadline);
270
+ turnDeadline = null;
271
+ }
162
272
  escalate();
163
273
  }, deps.stallMs);
164
274
  };
275
+ // Arm both budgets
165
276
  rearm();
277
+ if (deps.turnTimeoutMs !== undefined) {
278
+ turnDeadline = deps.clock.setTimeout(() => {
279
+ killedByWatchdog = true;
280
+ watchdogReason = "turn-deadline";
281
+ log({
282
+ event: "stall",
283
+ turnId,
284
+ harness: h.name,
285
+ reason: "turn-deadline",
286
+ budgetMs: deps.turnTimeoutMs,
287
+ });
288
+ if (watchdog !== null) {
289
+ deps.clock.clearTimeout(watchdog);
290
+ watchdog = null;
291
+ }
292
+ escalate();
293
+ }, deps.turnTimeoutMs);
294
+ }
166
295
 
167
296
  let pipeGrace: number | null = null;
168
297
  void proc.exited.then((code) => {
@@ -186,16 +315,26 @@ export async function* streamTurn(
186
315
  const lines = new LineBuffer();
187
316
  for await (const chunk of proc.stdout) {
188
317
  if (cancelled) break;
189
- rearm();
318
+ // Any output chunk rearms the inactivity budget, but not the wall-clock deadline
319
+ if (deps.stallMs !== undefined) rearm();
190
320
  for (const line of lines.push(chunk)) {
191
321
  for (const event of decodeLine(h, line, state, opts.model ?? "")) {
322
+ if ((event as unknown as { kind: string }).kind === "failure") {
323
+ // Directly from decode's rate_limit_event handling - track for reduction
324
+ failures.push(event as unknown as FailureSummary);
325
+ }
192
326
  await queue.push(event);
193
327
  }
194
328
  }
195
329
  }
196
330
  const rest = lines.flush();
197
331
  if (rest !== null && !cancelled) {
198
- for (const event of decodeLine(h, rest, state, opts.model ?? "")) await queue.push(event);
332
+ for (const event of decodeLine(h, rest, state, opts.model ?? "")) {
333
+ if ((event as unknown as { kind: string }).kind === "failure") {
334
+ failures.push(event as unknown as FailureSummary);
335
+ }
336
+ await queue.push(event);
337
+ }
199
338
  }
200
339
  };
201
340
 
@@ -203,20 +342,26 @@ export async function* streamTurn(
203
342
  const lines = new LineBuffer();
204
343
  for await (const chunk of proc.stderr) {
205
344
  if (cancelled) break;
206
- rearm();
345
+ if (deps.stallMs !== undefined) rearm();
207
346
  for (const line of lines.push(chunk)) {
208
347
  const limit = detectLimitInLine(h, line);
209
348
  if (limit !== null) {
210
349
  state.limitSeen = true;
350
+ const failure = failureFromLimit(limit);
351
+ // Emit both limit (for 0.1.3 compat) and failure
211
352
  await queue.push({
212
353
  kind: "limit",
213
354
  code: limit,
214
355
  message: `limit wall detected (${limit})`,
215
356
  });
357
+ await pushFailure(failure);
216
358
  continue;
217
359
  }
218
360
  const auth = detectAuthFailureInLine(h, line);
219
361
  if (auth !== null) {
362
+ const failure = failureFromAuth(auth);
363
+ await pushFailure(failure);
364
+ // Emit error alongside failure for 0.1.3 compat
220
365
  await queue.push({ kind: "error", message: `auth wall: ${auth}` });
221
366
  continue;
222
367
  }
@@ -240,6 +385,7 @@ export async function* streamTurn(
240
385
  issue: "read-failed",
241
386
  });
242
387
  await queue.push({ kind: "error", message: pumpFailureMessage(stream, cause) });
388
+ await pushFailure(failureFromTransport(pumpFailureMessage(stream, cause)));
243
389
  escalate();
244
390
  await proc.exited;
245
391
  proc.disposeOutput();
@@ -254,7 +400,27 @@ export async function* streamTurn(
254
400
  try {
255
401
  for await (const event of queue) yield event;
256
402
 
257
- const cause: ExitCause = state.limitSeen
403
+ // Post-queue failure sources: nonzero exit with no other failure is transport
404
+ if (
405
+ failures.length === 0 &&
406
+ exitCode !== 0 &&
407
+ exitCode !== null &&
408
+ !killedByWatchdog &&
409
+ !state.limitSeen
410
+ ) {
411
+ const f = failureFromTransport(`nonzero exit ${exitCode}`);
412
+ failures.push(f);
413
+ // Need to emit this failure before done, even though queue is closed
414
+ yield { kind: "failure", ...f };
415
+ }
416
+ // Stall watchdog also implies a transport failure if not already present
417
+ if (killedByWatchdog && failures.length === 0) {
418
+ const f = failureFromTransport(`stalled: ${watchdogReason ?? "inactivity"}`);
419
+ failures.push(f);
420
+ yield { kind: "failure", ...f };
421
+ }
422
+
423
+ let cause: ExitCause = state.limitSeen
258
424
  ? "limit"
259
425
  : killedByWatchdog && exitCode !== 0
260
426
  ? "stall"
@@ -263,6 +429,8 @@ export async function* streamTurn(
263
429
  : exitCode === null
264
430
  ? "killed"
265
431
  : "crash";
432
+ const reduced = reduceFailures(failures);
433
+ if (reduced && cause === "clean") cause = "failed";
266
434
  const tail = stderrTail.snapshot();
267
435
  log({
268
436
  event: "exit",
@@ -271,7 +439,10 @@ export async function* streamTurn(
271
439
  exitCode,
272
440
  cause,
273
441
  ...(pipesOpenAtExit ? { pipesOpenAtExit } : {}),
274
- ...(cause === "crash" || cause === "stall" || cause === "killed" ? { stderrTail: tail } : {}),
442
+ ...(cause === "crash" || cause === "stall" || cause === "killed" || cause === "failed"
443
+ ? { stderrTail: tail }
444
+ : {}),
445
+ ...(reduced ? { failure: reduced } : {}),
275
446
  });
276
447
  // A failure with captured stderr surfaces as a stream-level error, not
277
448
  // only in the exit log - so a crash from the real adapter's async spawn
@@ -280,7 +451,7 @@ export async function* streamTurn(
280
451
  yield { kind: "error", message: tail.join("\n").slice(0, 4096) };
281
452
  }
282
453
  terminalEventReached = true;
283
- yield { kind: "done", exitCode, cause };
454
+ yield { kind: "done", exitCode, cause, ...(reduced ? { failure: reduced } : {}) };
284
455
  } finally {
285
456
  const abandoned = !terminalEventReached;
286
457
  cancelled = true;