@dungle-scrubs/harness-cli-normalizer 0.1.2 → 0.3.0

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 (151) hide show
  1. package/README.md +144 -7
  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 +4 -0
  19. package/dist/cli/index.d.ts.map +1 -0
  20. package/dist/cli/index.js +170 -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 +2 -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 +2 -0
  67. package/dist/execution/stream-turn.d.ts.map +1 -1
  68. package/dist/execution/stream-turn.js +181 -16
  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/turn-options.d.ts +18 -0
  87. package/dist/interpretation/turn-options.d.ts.map +1 -0
  88. package/dist/interpretation/turn-options.js +417 -0
  89. package/dist/interpretation/turn-options.js.map +1 -0
  90. package/dist/interpretation/vocabulary.d.ts +5 -0
  91. package/dist/interpretation/vocabulary.d.ts.map +1 -1
  92. package/dist/interpretation/vocabulary.js +1 -1
  93. package/dist/interpretation/vocabulary.js.map +1 -1
  94. package/dist/knowledge/claude-code.d.ts +13 -1
  95. package/dist/knowledge/claude-code.d.ts.map +1 -1
  96. package/dist/knowledge/claude-code.js +35 -13
  97. package/dist/knowledge/claude-code.js.map +1 -1
  98. package/dist/knowledge/codex.d.ts.map +1 -1
  99. package/dist/knowledge/codex.js +25 -14
  100. package/dist/knowledge/codex.js.map +1 -1
  101. package/dist/knowledge/descriptor.d.ts +118 -14
  102. package/dist/knowledge/descriptor.d.ts.map +1 -1
  103. package/dist/knowledge/descriptor.js +58 -0
  104. package/dist/knowledge/descriptor.js.map +1 -1
  105. package/dist/knowledge/matchers.d.ts +3 -3
  106. package/dist/knowledge/matchers.d.ts.map +1 -1
  107. package/dist/knowledge/matchers.js +19 -6
  108. package/dist/knowledge/matchers.js.map +1 -1
  109. package/dist/knowledge/muse.d.ts +2 -2
  110. package/dist/knowledge/muse.d.ts.map +1 -1
  111. package/dist/knowledge/muse.js +22 -6
  112. package/dist/knowledge/muse.js.map +1 -1
  113. package/dist/knowledge/overrides.d.ts +7 -0
  114. package/dist/knowledge/overrides.d.ts.map +1 -1
  115. package/dist/knowledge/overrides.js +76 -0
  116. package/dist/knowledge/overrides.js.map +1 -1
  117. package/dist/knowledge/pi.d.ts.map +1 -1
  118. package/dist/knowledge/pi.js +25 -3
  119. package/dist/knowledge/pi.js.map +1 -1
  120. package/package.json +5 -2
  121. package/src/cli/args.ts +381 -0
  122. package/src/cli/check.ts +136 -0
  123. package/src/cli/exit-codes.ts +13 -0
  124. package/src/cli/help.ts +114 -0
  125. package/src/cli/index.ts +177 -0
  126. package/src/cli/inspect.ts +174 -0
  127. package/src/cli/ls.ts +14 -0
  128. package/src/cli/render.ts +62 -0
  129. package/src/cli/resolve-harness.ts +33 -0
  130. package/src/cli/run.ts +280 -0
  131. package/src/cli/session.ts +137 -0
  132. package/src/cli/version.ts +32 -0
  133. package/src/execution/decode.ts +32 -0
  134. package/src/execution/deps.ts +7 -1
  135. package/src/execution/events.ts +9 -2
  136. package/src/execution/failure.ts +159 -0
  137. package/src/execution/node-deps.ts +17 -0
  138. package/src/execution/stream-turn.ts +189 -17
  139. package/src/interpretation/argv.ts +85 -27
  140. package/src/interpretation/dimensions.ts +0 -6
  141. package/src/interpretation/limits.ts +97 -6
  142. package/src/interpretation/refusal.ts +116 -0
  143. package/src/interpretation/turn-options.ts +424 -0
  144. package/src/interpretation/vocabulary.ts +1 -1
  145. package/src/knowledge/claude-code.ts +35 -13
  146. package/src/knowledge/codex.ts +25 -14
  147. package/src/knowledge/descriptor.ts +138 -12
  148. package/src/knowledge/matchers.ts +22 -9
  149. package/src/knowledge/muse.ts +22 -6
  150. package/src/knowledge/overrides.ts +97 -0
  151. package/src/knowledge/pi.ts +25 -3
@@ -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
@@ -79,6 +89,8 @@ export interface TurnRunOptions extends LaunchOptions {
79
89
  readonly resume?: string;
80
90
  /** Working directory for the spawned harness. */
81
91
  readonly cwd?: string;
92
+ /** Per-call environment, merged over parent; "" deletes. */
93
+ readonly env?: Readonly<Record<string, string>>;
82
94
  }
83
95
 
84
96
  export async function* streamTurn(
@@ -88,18 +100,87 @@ export async function* streamTurn(
88
100
  ): AsyncIterable<HarnessEvent> {
89
101
  const turnId = deps.turnId ?? `turn-${++turnCounter}`;
90
102
  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
103
 
104
+ // Validate env before building argv so an invalid env is a refusal, not a spawn
105
+ if (opts.env !== undefined) {
106
+ for (const [k, v] of Object.entries(opts.env)) {
107
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(k) || k.includes("\0") || v.includes("\0")) {
108
+ const refusal = new ArgvRefusalError({
109
+ issue: "invalid-env",
110
+ harness: h.name,
111
+ supported: ["env keys must match /^[A-Za-z_][A-Za-z0-9_]*$/ and no NUL"],
112
+ detail: k,
113
+ });
114
+ const failure = failureFromRejected({
115
+ issue: refusal.issue,
116
+ option: undefined,
117
+ supported: refusal.supported,
118
+ detail: k,
119
+ });
120
+ log({
121
+ event: "rejected",
122
+ turnId,
123
+ harness: h.name,
124
+ issue: refusal.issue,
125
+ supported: refusal.supported,
126
+ argv: redactArgv([], opts.prompt),
127
+ });
128
+ yield { kind: "failure", ...failure };
129
+ yield { kind: "done", exitCode: null, cause: "failed", failure };
130
+ return;
131
+ }
132
+ }
133
+ }
134
+
135
+ let argv: string[];
136
+ let granularity: import("../knowledge/descriptor.js").StreamingGranularity;
137
+ try {
138
+ argv =
139
+ opts.resume === undefined
140
+ ? buildLaunchArgv(h, opts)
141
+ : buildResumeArgv(h, { ...opts, sessionId: opts.resume });
142
+ granularity = streamingGranularityOf(h, argv);
143
+ } catch (e) {
144
+ if (e instanceof ArgvRefusalError) {
145
+ const failure = failureFromRejected({
146
+ issue: e.issue,
147
+ option: e.option,
148
+ facet: e.facet,
149
+ supported: e.supported,
150
+ detail: e.message,
151
+ });
152
+ // No process spawned on a refusal - log rejected instead of spawn
153
+ let argvForLog: string[] = [];
154
+ try {
155
+ argvForLog = redactArgv([], opts.prompt);
156
+ } catch {}
157
+ log({
158
+ event: "rejected",
159
+ turnId,
160
+ harness: h.name,
161
+ issue: e.issue,
162
+ option: e.option,
163
+ facet: e.facet,
164
+ supported: e.supported,
165
+ argv: argvForLog,
166
+ });
167
+ yield { kind: "failure", ...failure };
168
+ yield { kind: "done", exitCode: null, cause: "failed", failure };
169
+ return;
170
+ }
171
+ throw e;
172
+ }
173
+
174
+ const matcherOverrides = matcherOverridesOf.get(h);
175
+ const envKeys = opts.env ? Object.keys(opts.env) : undefined;
97
176
  log({
98
177
  event: "spawn",
99
178
  turnId,
100
179
  harness: h.name,
101
180
  argv: redactArgv(argv, opts.prompt),
102
181
  granularity,
182
+ ...(matcherOverrides ? { matcherOverrides } : {}),
183
+ ...(envKeys?.length ? { envKeys } : {}),
103
184
  });
104
185
 
105
186
  let proc: SpawnedProcess;
@@ -107,11 +188,12 @@ export async function* streamTurn(
107
188
  proc = deps.spawn(argv, {
108
189
  stdin: stdinPolicyOf(h) === "close-required" ? "close" : "inherit",
109
190
  ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}),
191
+ ...(opts.env !== undefined ? { env: opts.env } : {}),
110
192
  });
111
193
  } 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().
194
+ // Spawn failure is a transport failure, not merely a crash
114
195
  const message = cause instanceof Error ? cause.message : String(cause);
196
+ const failure = failureFromTransport(`spawn failed: ${message}`);
115
197
  log({
116
198
  event: "exit",
117
199
  turnId,
@@ -121,7 +203,8 @@ export async function* streamTurn(
121
203
  spawnError: message,
122
204
  });
123
205
  yield { kind: "error", message: `spawn failed: ${message}` };
124
- yield { kind: "done", exitCode: 127, cause: "crash" };
206
+ yield { kind: "failure", ...failure };
207
+ yield { kind: "done", exitCode: 127, cause: "failed", failure };
125
208
  return;
126
209
  }
127
210
 
@@ -148,21 +231,62 @@ export async function* streamTurn(
148
231
  }, KILL_GRACE_MS);
149
232
  };
150
233
 
234
+ const failures: FailureSummary[] = [];
235
+ const pushFailure = async (f: FailureSummary): Promise<void> => {
236
+ failures.push(f);
237
+ await queue.push({ kind: "failure", ...f });
238
+ };
239
+
151
240
  let watchdog: number | null = null;
241
+ let turnDeadline: number | null = null;
242
+ let watchdogReason: "inactivity" | "turn-deadline" | null = null;
152
243
  const disarm = (): void => {
153
244
  if (watchdog !== null) deps.clock.clearTimeout(watchdog);
154
245
  watchdog = null;
246
+ if (turnDeadline !== null) deps.clock.clearTimeout(turnDeadline);
247
+ turnDeadline = null;
155
248
  };
156
249
  const rearm = (): void => {
157
- if (granularity !== "none" || deps.stallMs === undefined) return;
158
- disarm();
250
+ if (deps.stallMs === undefined) return;
251
+ if (watchdog !== null) deps.clock.clearTimeout(watchdog);
159
252
  watchdog = deps.clock.setTimeout(() => {
160
253
  killedByWatchdog = true;
161
- log({ event: "stall", turnId, harness: h.name, stallMs: deps.stallMs });
254
+ watchdogReason = "inactivity";
255
+ log({
256
+ event: "stall",
257
+ turnId,
258
+ harness: h.name,
259
+ reason: "inactivity",
260
+ budgetMs: deps.stallMs,
261
+ });
262
+ // Disarm the other budget
263
+ if (turnDeadline !== null) {
264
+ deps.clock.clearTimeout(turnDeadline);
265
+ turnDeadline = null;
266
+ }
162
267
  escalate();
163
268
  }, deps.stallMs);
164
269
  };
270
+ // Arm both budgets
165
271
  rearm();
272
+ if (deps.turnTimeoutMs !== undefined) {
273
+ turnDeadline = deps.clock.setTimeout(() => {
274
+ killedByWatchdog = true;
275
+ watchdogReason = "turn-deadline";
276
+ log({
277
+ event: "stall",
278
+ turnId,
279
+ harness: h.name,
280
+ reason: "turn-deadline",
281
+ budgetMs: deps.turnTimeoutMs,
282
+ });
283
+ if (watchdog !== null) {
284
+ deps.clock.clearTimeout(watchdog);
285
+ watchdog = null;
286
+ }
287
+ escalate();
288
+ }, deps.turnTimeoutMs);
289
+ }
166
290
 
167
291
  let pipeGrace: number | null = null;
168
292
  void proc.exited.then((code) => {
@@ -186,16 +310,26 @@ export async function* streamTurn(
186
310
  const lines = new LineBuffer();
187
311
  for await (const chunk of proc.stdout) {
188
312
  if (cancelled) break;
189
- rearm();
313
+ // Any output chunk rearms the inactivity budget, but not the wall-clock deadline
314
+ if (deps.stallMs !== undefined) rearm();
190
315
  for (const line of lines.push(chunk)) {
191
316
  for (const event of decodeLine(h, line, state, opts.model ?? "")) {
317
+ if ((event as unknown as { kind: string }).kind === "failure") {
318
+ // Directly from decode's rate_limit_event handling - track for reduction
319
+ failures.push(event as unknown as FailureSummary);
320
+ }
192
321
  await queue.push(event);
193
322
  }
194
323
  }
195
324
  }
196
325
  const rest = lines.flush();
197
326
  if (rest !== null && !cancelled) {
198
- for (const event of decodeLine(h, rest, state, opts.model ?? "")) await queue.push(event);
327
+ for (const event of decodeLine(h, rest, state, opts.model ?? "")) {
328
+ if ((event as unknown as { kind: string }).kind === "failure") {
329
+ failures.push(event as unknown as FailureSummary);
330
+ }
331
+ await queue.push(event);
332
+ }
199
333
  }
200
334
  };
201
335
 
@@ -203,20 +337,27 @@ export async function* streamTurn(
203
337
  const lines = new LineBuffer();
204
338
  for await (const chunk of proc.stderr) {
205
339
  if (cancelled) break;
206
- rearm();
340
+ if (deps.stallMs !== undefined) rearm();
207
341
  for (const line of lines.push(chunk)) {
208
342
  const limit = detectLimitInLine(h, line);
209
343
  if (limit !== null) {
210
344
  state.limitSeen = true;
345
+ const failure = failureFromLimit(limit);
346
+ // Emit both limit (for 0.1.3 compat) and failure
211
347
  await queue.push({
212
348
  kind: "limit",
213
349
  code: limit,
214
350
  message: `limit wall detected (${limit})`,
215
351
  });
352
+ await pushFailure(failure);
216
353
  continue;
217
354
  }
218
355
  const auth = detectAuthFailureInLine(h, line);
219
356
  if (auth !== null) {
357
+ const failure = failureFromAuth(auth);
358
+ await pushFailure(failure);
359
+ // Also emit error for compat? The old code emitted error, but now we emit failure.
360
+ // Keep error for 0.1.3 compat as well
220
361
  await queue.push({ kind: "error", message: `auth wall: ${auth}` });
221
362
  continue;
222
363
  }
@@ -240,6 +381,7 @@ export async function* streamTurn(
240
381
  issue: "read-failed",
241
382
  });
242
383
  await queue.push({ kind: "error", message: pumpFailureMessage(stream, cause) });
384
+ await pushFailure(failureFromTransport(pumpFailureMessage(stream, cause)));
243
385
  escalate();
244
386
  await proc.exited;
245
387
  proc.disposeOutput();
@@ -254,7 +396,27 @@ export async function* streamTurn(
254
396
  try {
255
397
  for await (const event of queue) yield event;
256
398
 
257
- const cause: ExitCause = state.limitSeen
399
+ // Post-queue failure sources: nonzero exit with no other failure is transport
400
+ if (
401
+ failures.length === 0 &&
402
+ exitCode !== 0 &&
403
+ exitCode !== null &&
404
+ !killedByWatchdog &&
405
+ !state.limitSeen
406
+ ) {
407
+ const f = failureFromTransport(`nonzero exit ${exitCode}`);
408
+ failures.push(f);
409
+ // Need to emit this failure before done, even though queue is closed
410
+ yield { kind: "failure", ...f };
411
+ }
412
+ // Stall watchdog also implies a transport failure if not already present
413
+ if (killedByWatchdog && failures.length === 0) {
414
+ const f = failureFromTransport(`stalled: ${watchdogReason ?? "inactivity"}`);
415
+ failures.push(f);
416
+ yield { kind: "failure", ...f };
417
+ }
418
+
419
+ let cause: ExitCause = state.limitSeen
258
420
  ? "limit"
259
421
  : killedByWatchdog && exitCode !== 0
260
422
  ? "stall"
@@ -263,6 +425,13 @@ export async function* streamTurn(
263
425
  : exitCode === null
264
426
  ? "killed"
265
427
  : "crash";
428
+ const reduced = reduceFailures(failures);
429
+ if (reduced && cause === "clean") cause = "failed";
430
+ // Also handle pi stopReason error at exit 0 yielding failed - if we have a failure and exit 0, ensure cause is failed
431
+ if (reduced && exitCode === 0 && cause !== "limit" && cause !== "stall") {
432
+ // If we have any failure at exit 0, the cause should be failed, not clean
433
+ if (cause === "clean") cause = "failed";
434
+ }
266
435
  const tail = stderrTail.snapshot();
267
436
  log({
268
437
  event: "exit",
@@ -271,7 +440,10 @@ export async function* streamTurn(
271
440
  exitCode,
272
441
  cause,
273
442
  ...(pipesOpenAtExit ? { pipesOpenAtExit } : {}),
274
- ...(cause === "crash" || cause === "stall" || cause === "killed" ? { stderrTail: tail } : {}),
443
+ ...(cause === "crash" || cause === "stall" || cause === "killed" || cause === "failed"
444
+ ? { stderrTail: tail }
445
+ : {}),
446
+ ...(reduced ? { failure: reduced } : {}),
275
447
  });
276
448
  // A failure with captured stderr surfaces as a stream-level error, not
277
449
  // only in the exit log - so a crash from the real adapter's async spawn
@@ -280,7 +452,7 @@ export async function* streamTurn(
280
452
  yield { kind: "error", message: tail.join("\n").slice(0, 4096) };
281
453
  }
282
454
  terminalEventReached = true;
283
- yield { kind: "done", exitCode, cause };
455
+ yield { kind: "done", exitCode, cause, ...(reduced ? { failure: reduced } : {}) };
284
456
  } finally {
285
457
  const abandoned = !terminalEventReached;
286
458
  cancelled = true;