@dungle-scrubs/harness-cli-normalizer 0.5.2 → 0.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +113 -2
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +8 -0
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/help.d.ts +2 -2
- package/dist/cli/help.d.ts.map +1 -1
- package/dist/cli/help.js +16 -0
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/inspect.d.ts.map +1 -1
- package/dist/cli/inspect.js +21 -0
- package/dist/cli/inspect.js.map +1 -1
- package/dist/cli/refuse.d.ts +7 -1
- package/dist/cli/refuse.d.ts.map +1 -1
- package/dist/cli/refuse.js +16 -5
- package/dist/cli/refuse.js.map +1 -1
- package/dist/cli/session-json.d.ts +25 -0
- package/dist/cli/session-json.d.ts.map +1 -0
- package/dist/cli/session-json.js +195 -0
- package/dist/cli/session-json.js.map +1 -0
- package/dist/cli/session.d.ts.map +1 -1
- package/dist/cli/session.js +93 -11
- package/dist/cli/session.js.map +1 -1
- package/dist/execution/failure.d.ts +2 -1
- package/dist/execution/failure.d.ts.map +1 -1
- package/dist/execution/failure.js +12 -1
- package/dist/execution/failure.js.map +1 -1
- package/dist/execution/open-session.d.ts +24 -3
- package/dist/execution/open-session.d.ts.map +1 -1
- package/dist/execution/open-session.js +106 -19
- package/dist/execution/open-session.js.map +1 -1
- package/dist/execution/stream-turn.d.ts.map +1 -1
- package/dist/execution/stream-turn.js +8 -5
- package/dist/execution/stream-turn.js.map +1 -1
- package/dist/interpretation/argv.d.ts +3 -0
- package/dist/interpretation/argv.d.ts.map +1 -1
- package/dist/interpretation/argv.js +5 -0
- package/dist/interpretation/argv.js.map +1 -1
- package/dist/interpretation/limits.d.ts +3 -0
- package/dist/interpretation/limits.d.ts.map +1 -1
- package/dist/interpretation/limits.js +12 -13
- package/dist/interpretation/limits.js.map +1 -1
- package/dist/knowledge/descriptor.d.ts +6 -1
- package/dist/knowledge/descriptor.d.ts.map +1 -1
- package/dist/knowledge/descriptor.js.map +1 -1
- package/dist/knowledge/index.d.ts +1 -1
- package/dist/knowledge/index.d.ts.map +1 -1
- package/dist/knowledge/index.js +1 -1
- package/dist/knowledge/index.js.map +1 -1
- package/dist/knowledge/matchers.d.ts +2 -1
- package/dist/knowledge/matchers.d.ts.map +1 -1
- package/dist/knowledge/matchers.js +10 -0
- package/dist/knowledge/matchers.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/args.ts +8 -0
- package/src/cli/help.ts +16 -0
- package/src/cli/inspect.ts +26 -0
- package/src/cli/refuse.ts +31 -12
- package/src/cli/session-json.ts +230 -0
- package/src/cli/session.ts +103 -12
- package/src/execution/failure.ts +16 -1
- package/src/execution/open-session.ts +132 -23
- package/src/execution/stream-turn.ts +8 -3
- package/src/interpretation/argv.ts +8 -0
- package/src/interpretation/limits.ts +17 -18
- package/src/knowledge/descriptor.ts +6 -1
- package/src/knowledge/index.ts +1 -0
- package/src/knowledge/matchers.ts +17 -1
package/src/cli/session.ts
CHANGED
|
@@ -9,6 +9,9 @@ import { createRenderState, renderEvent } from "./render.js";
|
|
|
9
9
|
import { resolveHarness } from "./resolve-harness.js";
|
|
10
10
|
|
|
11
11
|
export const session = async (harnessName: string, rawArgs: string[]): Promise<void> => {
|
|
12
|
+
// Decided before any refusal can fire: a refused --json session still owes
|
|
13
|
+
// the stream a failure and a terminal `closed` (RFC-01 rule 3).
|
|
14
|
+
const jsonMode = rawArgs.includes("--json");
|
|
12
15
|
// issue #44: the gate is the descriptor's sessionMode (claude stream-json,
|
|
13
16
|
// pi --mode rpc), not a hardcoded name list - a harness that grows a
|
|
14
17
|
// session mode is available the moment its descriptor declares one.
|
|
@@ -23,9 +26,8 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
|
|
|
23
26
|
supported,
|
|
24
27
|
detail: `session mode is available on ${supported.join(", ")}; ${harnessName} declares no persistent headless session`,
|
|
25
28
|
});
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
process.exitCode = 2;
|
|
29
|
+
const { refusalOf, refuse } = await import("./refuse.js");
|
|
30
|
+
refuse(refusalOf(err), jsonMode, "closed");
|
|
29
31
|
return;
|
|
30
32
|
}
|
|
31
33
|
|
|
@@ -42,6 +44,14 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
|
|
|
42
44
|
} catch (err) {
|
|
43
45
|
const message = err instanceof Error ? err.message : String(err);
|
|
44
46
|
process.stderr.write(`unknown flag: ${message}\n`);
|
|
47
|
+
if (jsonMode) {
|
|
48
|
+
const { writeFailurePair } = await import("./refuse.js");
|
|
49
|
+
const { failureFromRejected } = await import("../execution/failure.js");
|
|
50
|
+
writeFailurePair(
|
|
51
|
+
failureFromRejected({ issue: "invalid-option-value", detail: `unknown flag: ${message}` }),
|
|
52
|
+
"closed",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
45
55
|
process.exitCode = 2;
|
|
46
56
|
return;
|
|
47
57
|
}
|
|
@@ -53,6 +63,7 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
|
|
|
53
63
|
randomUUID();
|
|
54
64
|
const model = values.model as string | undefined;
|
|
55
65
|
const cwd = values.cwd as string | undefined;
|
|
66
|
+
const provider = values.provider as string | undefined;
|
|
56
67
|
|
|
57
68
|
// issue #44: same precedence as hcn run - arg > project > user >
|
|
58
69
|
// default-true. A behavior instruction, so it rides every send's
|
|
@@ -87,6 +98,17 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
|
|
|
87
98
|
: "default";
|
|
88
99
|
} catch (configErr) {
|
|
89
100
|
process.stderr.write(`config error: ${(configErr as Error).message}\n`);
|
|
101
|
+
if (jsonMode) {
|
|
102
|
+
const { writeFailurePair } = await import("./refuse.js");
|
|
103
|
+
const { failureFromRejected } = await import("../execution/failure.js");
|
|
104
|
+
writeFailurePair(
|
|
105
|
+
failureFromRejected({
|
|
106
|
+
issue: "invalid-option-value",
|
|
107
|
+
detail: `config error: ${(configErr as Error).message}`,
|
|
108
|
+
}),
|
|
109
|
+
"closed",
|
|
110
|
+
);
|
|
111
|
+
}
|
|
90
112
|
process.exitCode = 2;
|
|
91
113
|
return;
|
|
92
114
|
}
|
|
@@ -95,25 +117,92 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
|
|
|
95
117
|
// Validate sessionId shape? let openSession handle via assertUsableSessionId
|
|
96
118
|
delete (process.env as Record<string, string | undefined>).HERDR_ENV;
|
|
97
119
|
|
|
98
|
-
const
|
|
120
|
+
const wantJson = values.json === true;
|
|
121
|
+
// Opt-in per-turn inactivity budget. 0 disables; no default. A session turn
|
|
122
|
+
// can hang with the process alive, which no exit code reports.
|
|
123
|
+
const rawStall = values.stall as string | undefined;
|
|
124
|
+
let stallMs: number | undefined;
|
|
125
|
+
if (rawStall !== undefined) {
|
|
126
|
+
const seconds = Number(rawStall);
|
|
127
|
+
if (!Number.isFinite(seconds) || seconds < 0) {
|
|
128
|
+
process.stderr.write(`invalid --stall ${JSON.stringify(rawStall)}; expected seconds >= 0\n`);
|
|
129
|
+
if (jsonMode) {
|
|
130
|
+
const { writeFailurePair } = await import("./refuse.js");
|
|
131
|
+
const { failureFromRejected } = await import("../execution/failure.js");
|
|
132
|
+
writeFailurePair(
|
|
133
|
+
failureFromRejected({
|
|
134
|
+
issue: "invalid-option-value",
|
|
135
|
+
detail: `invalid --stall ${JSON.stringify(rawStall)}`,
|
|
136
|
+
}),
|
|
137
|
+
"closed",
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
process.exitCode = 2;
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (seconds > 0) stallMs = seconds * 1000;
|
|
144
|
+
}
|
|
145
|
+
const baseDeps = stallMs === undefined ? nodeRunnerDeps() : nodeRunnerDeps({ stallMs });
|
|
146
|
+
// Capture the runner's final exitCode/cause for the --json `closed` event.
|
|
147
|
+
const closeInfo = { exitCode: null as number | null, cause: "clean" };
|
|
148
|
+
const droppedIds: string[] = [];
|
|
149
|
+
const deps = wantJson
|
|
150
|
+
? {
|
|
151
|
+
...baseDeps,
|
|
152
|
+
log: (e: Record<string, unknown>) => {
|
|
153
|
+
if (e.event === "session_close") {
|
|
154
|
+
closeInfo.exitCode = (e.exitCode as number | null) ?? null;
|
|
155
|
+
closeInfo.cause = (e.cause as string) ?? "clean";
|
|
156
|
+
}
|
|
157
|
+
if (e.event === "sends_dropped" && Array.isArray(e.ids)) {
|
|
158
|
+
for (const id of e.ids as unknown[]) if (typeof id === "string") droppedIds.push(id);
|
|
159
|
+
}
|
|
160
|
+
baseDeps.log?.(e);
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
: baseDeps;
|
|
99
164
|
|
|
100
165
|
let handle: ReturnType<typeof openSession>;
|
|
101
166
|
try {
|
|
102
|
-
handle = openSession(h, { sessionId, model, cwd, escalateQuestions }, deps);
|
|
167
|
+
handle = openSession(h, { sessionId, model, cwd, escalateQuestions, provider }, deps);
|
|
103
168
|
} catch (err) {
|
|
104
169
|
if (err instanceof ArgvRefusalError) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
process.exitCode = 2;
|
|
170
|
+
const { refusalOf, refuse } = await import("./refuse.js");
|
|
171
|
+
refuse(refusalOf(err), jsonMode, "closed");
|
|
108
172
|
return;
|
|
109
173
|
}
|
|
174
|
+
// S002: the harness binary is missing or would not start. A transport
|
|
175
|
+
// failure, not a refusal - exit 1, and the stream is still owed its pair.
|
|
110
176
|
process.stderr.write(
|
|
111
177
|
`could not open session: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
112
178
|
);
|
|
179
|
+
if (jsonMode) {
|
|
180
|
+
const { writeFailurePair } = await import("./refuse.js");
|
|
181
|
+
const { failureFromTransport } = await import("../execution/failure.js");
|
|
182
|
+
writeFailurePair(
|
|
183
|
+
failureFromTransport(err instanceof Error ? err.message : String(err)),
|
|
184
|
+
"closed",
|
|
185
|
+
);
|
|
186
|
+
}
|
|
113
187
|
process.exitCode = 1;
|
|
114
188
|
return;
|
|
115
189
|
}
|
|
116
190
|
|
|
191
|
+
if (wantJson) {
|
|
192
|
+
const { runJsonSession } = await import("./session-json.js");
|
|
193
|
+
const { getVersion } = await import("./version.js");
|
|
194
|
+
process.exitCode = await runJsonSession({
|
|
195
|
+
handle,
|
|
196
|
+
sessionId,
|
|
197
|
+
harness: h.name,
|
|
198
|
+
hcnVersion: getVersion(),
|
|
199
|
+
escalateQuestions,
|
|
200
|
+
getCloseInfo: () => closeInfo,
|
|
201
|
+
getDroppedIds: () => droppedIds,
|
|
202
|
+
});
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
117
206
|
process.stdout.write(
|
|
118
207
|
`interactive ${h.name} session ${sessionId}\n(empty line or "exit" to quit)\n`,
|
|
119
208
|
);
|
|
@@ -136,6 +225,7 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
|
|
|
136
225
|
|
|
137
226
|
// Handle SIGINT to close session cleanly
|
|
138
227
|
let closing = false;
|
|
228
|
+
let sendCount = 0;
|
|
139
229
|
const doClose = async () => {
|
|
140
230
|
if (closing) return;
|
|
141
231
|
closing = true;
|
|
@@ -168,7 +258,7 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
|
|
|
168
258
|
const trimmed = line.trim();
|
|
169
259
|
if (trimmed === "" || trimmed === "exit") break;
|
|
170
260
|
|
|
171
|
-
const result = handle.send(line);
|
|
261
|
+
const result = handle.send({ id: `you-${++sendCount}`, text: line });
|
|
172
262
|
if (result.disposition === "queued") {
|
|
173
263
|
process.stderr.write(`disposition: queued (turn in progress)\n`);
|
|
174
264
|
}
|
|
@@ -209,9 +299,10 @@ export const session = async (harnessName: string, rawArgs: string[]): Promise<v
|
|
|
209
299
|
answer = a;
|
|
210
300
|
}
|
|
211
301
|
}
|
|
212
|
-
handle.send(
|
|
213
|
-
|
|
214
|
-
|
|
302
|
+
handle.send({
|
|
303
|
+
id: `you-${++sendCount}`,
|
|
304
|
+
text: `The user answered the question: "${q.question}" with: ${answer}. Continue accordingly.`,
|
|
305
|
+
});
|
|
215
306
|
// Drain the answer turn BEFORE prompting again - the pump's
|
|
216
307
|
// backpressure stalls the harness until the turn iterable is
|
|
217
308
|
// consumed (verified live: menu answered, you-prompt rendered, no
|
package/src/execution/failure.ts
CHANGED
|
@@ -13,7 +13,11 @@
|
|
|
13
13
|
* options or a different harness, not a different model.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
detectAuthFailureInLine,
|
|
18
|
+
detectTransportInLine,
|
|
19
|
+
detectUnavailableInLine,
|
|
20
|
+
} from "../interpretation/limits.js";
|
|
17
21
|
import type { RefusalIssue } from "../interpretation/refusal.js";
|
|
18
22
|
import type {
|
|
19
23
|
AuthFailureKind,
|
|
@@ -30,6 +34,7 @@ export const FAILURE_CLASSES = Object.freeze([
|
|
|
30
34
|
"budget",
|
|
31
35
|
"task",
|
|
32
36
|
"transport",
|
|
37
|
+
"unavailable",
|
|
33
38
|
"rejected",
|
|
34
39
|
"native",
|
|
35
40
|
"timeout",
|
|
@@ -76,6 +81,8 @@ const messageFor = (cls: FailureClass, detail?: string): string => {
|
|
|
76
81
|
return `Task failed${detail ? ` (${detail})` : ""} - surface to caller, do not auto-route`;
|
|
77
82
|
case "transport":
|
|
78
83
|
return `Transport failure${detail ? ` (${detail})` : ""} - retry or route to another provider`;
|
|
84
|
+
case "unavailable":
|
|
85
|
+
return `Provider cannot serve this route${detail ? ` (${detail})` : ""} - route to another model or provider`;
|
|
79
86
|
case "rejected":
|
|
80
87
|
return `Request rejected${detail ? ` (${detail})` : ""} - change options or harness`;
|
|
81
88
|
case "timeout":
|
|
@@ -144,6 +151,7 @@ export const failureFromTerminalError = (h: HarnessDescriptor, message: string):
|
|
|
144
151
|
const auth = detectAuthFailureInLine(h, message);
|
|
145
152
|
if (auth !== null) return failureFromAuth(auth);
|
|
146
153
|
if (detectTransportInLine(message)) return failureFromTransport(message);
|
|
154
|
+
if (detectUnavailableInLine(message)) return failureFromUnavailable(message);
|
|
147
155
|
return failureFromTask(message);
|
|
148
156
|
};
|
|
149
157
|
|
|
@@ -165,6 +173,12 @@ export const failureFromTransport = (detail?: string): FailureSummary => ({
|
|
|
165
173
|
message: messageFor("transport", detail),
|
|
166
174
|
});
|
|
167
175
|
|
|
176
|
+
export const failureFromUnavailable = (detail?: string): FailureSummary => ({
|
|
177
|
+
class: "unavailable",
|
|
178
|
+
retryable: true,
|
|
179
|
+
message: messageFor("unavailable", detail),
|
|
180
|
+
});
|
|
181
|
+
|
|
168
182
|
export const failureFromRejected = (opts: {
|
|
169
183
|
issue: RefusalIssue;
|
|
170
184
|
option?: import("../interpretation/refusal.js").RefusalOption;
|
|
@@ -194,6 +208,7 @@ const PRECEDENCE: Record<FailureClass, number> = {
|
|
|
194
208
|
"rate-limit": 2,
|
|
195
209
|
"usage-limit": 2,
|
|
196
210
|
quota: 2,
|
|
211
|
+
unavailable: 2,
|
|
197
212
|
budget: 3,
|
|
198
213
|
task: 3,
|
|
199
214
|
transport: 4,
|
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
import type { HarnessDescriptor, SessionInputContract } from "../knowledge/descriptor.js";
|
|
30
30
|
import { AsyncChannel } from "./channel.js";
|
|
31
31
|
import { decodeParsed, freshDecodeState } from "./decode.js";
|
|
32
|
-
import type { RunnerDeps, SpawnedProcess } from "./deps.js";
|
|
32
|
+
import type { RunnerDeps, SpawnedProcess, TimerHandle } from "./deps.js";
|
|
33
33
|
import type { ExitCause, HarnessEvent } from "./events.js";
|
|
34
34
|
import type { FailureSummary } from "./failure.js";
|
|
35
35
|
import {
|
|
@@ -50,15 +50,36 @@ export const CLOSE_GRACE_MS = 5_000;
|
|
|
50
50
|
const PRETURN_MAX = 256;
|
|
51
51
|
|
|
52
52
|
export interface SessionSendResult {
|
|
53
|
-
readonly disposition: "started" | "queued";
|
|
53
|
+
readonly disposition: "started" | "queued" | "rejected";
|
|
54
|
+
/** Present when rejected. `write-failed` is a broken stdin pipe, which is
|
|
55
|
+
* a different remedy from a session the caller already closed - the two
|
|
56
|
+
* must stay distinguishable. */
|
|
57
|
+
readonly reason?: "write-failed";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** One turn's event stream, tagged with the id of the send that opened it.
|
|
61
|
+
* `inputId` is present for every turn a consumer send opened, which today is
|
|
62
|
+
* every turn; a turn opened by anything else (none exists yet) omits it. */
|
|
63
|
+
export interface SessionTurn extends AsyncIterable<HarnessEvent> {
|
|
64
|
+
readonly inputId?: string;
|
|
65
|
+
/** `${sessionId}:turn-${n}`, matching the runner's turn_start log. */
|
|
66
|
+
readonly turnId?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A send's payload: the consumer's correlation id travels with the text
|
|
70
|
+
* from the moment it arrives to the turn it opens and, on death, to the
|
|
71
|
+
* loss report. */
|
|
72
|
+
export interface SessionInput {
|
|
73
|
+
readonly id: string;
|
|
74
|
+
readonly text: string;
|
|
54
75
|
}
|
|
55
76
|
|
|
56
77
|
export interface SessionHandle {
|
|
57
78
|
/** One inner iterable per turn, each ending in a turn-scoped `done`.
|
|
58
79
|
* Breaking out of THIS iterable closes the session; breaking out of a
|
|
59
80
|
* single turn's iterable only stops reading that turn. */
|
|
60
|
-
readonly turns: AsyncIterable<
|
|
61
|
-
send(
|
|
81
|
+
readonly turns: AsyncIterable<SessionTurn>;
|
|
82
|
+
send(input: SessionInput): SessionSendResult;
|
|
62
83
|
close(): Promise<void>;
|
|
63
84
|
}
|
|
64
85
|
|
|
@@ -72,6 +93,8 @@ export interface OpenSessionOptions {
|
|
|
72
93
|
* every send and arms block detection at turn end; false composes the
|
|
73
94
|
* no-ask instruction and disarms detection. */
|
|
74
95
|
readonly escalateQuestions?: boolean;
|
|
96
|
+
/** Provider selector (pi); refused on a harness without one. */
|
|
97
|
+
readonly provider?: string;
|
|
75
98
|
}
|
|
76
99
|
|
|
77
100
|
export class SessionClosedError extends Error {
|
|
@@ -99,6 +122,7 @@ export const openSession = (
|
|
|
99
122
|
const argv = buildSessionArgv(h, {
|
|
100
123
|
sessionId: opts.sessionId,
|
|
101
124
|
...(opts.model !== undefined ? { model: opts.model } : {}),
|
|
125
|
+
...(opts.provider !== undefined ? { provider: opts.provider } : {}),
|
|
102
126
|
});
|
|
103
127
|
let sessionInput: SessionInputContract;
|
|
104
128
|
try {
|
|
@@ -142,7 +166,7 @@ export const openSession = (
|
|
|
142
166
|
argv: redactArgv(argv),
|
|
143
167
|
});
|
|
144
168
|
|
|
145
|
-
const turnsChannel = new AsyncChannel<
|
|
169
|
+
const turnsChannel = new AsyncChannel<SessionTurn>();
|
|
146
170
|
const state = freshDecodeState(opts.sessionId);
|
|
147
171
|
const escalateQuestions = opts.escalateQuestions !== false;
|
|
148
172
|
const sessionInputMode = h.sessionMode;
|
|
@@ -150,7 +174,7 @@ export const openSession = (
|
|
|
150
174
|
let turnCounter = 0;
|
|
151
175
|
let activeTurn: AsyncChannel<HarnessEvent> | null = null;
|
|
152
176
|
let activeTurnId = "";
|
|
153
|
-
const pendingSends:
|
|
177
|
+
const pendingSends: SessionInput[] = [];
|
|
154
178
|
const preTurnEvents: HarnessEvent[] = [];
|
|
155
179
|
let dead = false;
|
|
156
180
|
let closing = false;
|
|
@@ -174,6 +198,42 @@ export const openSession = (
|
|
|
174
198
|
deps.clock.setTimeout(() => safeSignal("SIGKILL"), KILL_GRACE_MS);
|
|
175
199
|
};
|
|
176
200
|
|
|
201
|
+
// Per-turn inactivity budget. A session turn can hang with the process
|
|
202
|
+
// alive and the pipes open, which no exit code reports; without this the
|
|
203
|
+
// consumer waits forever. Armed at turn start, rearmed on any output
|
|
204
|
+
// chunk, disarmed at turn end and at exit - the same discipline
|
|
205
|
+
// streamTurn uses, scoped to the turn rather than the process.
|
|
206
|
+
let stallTimer: TimerHandle | null = null;
|
|
207
|
+
let stalled = false;
|
|
208
|
+
const disarmStall = (): void => {
|
|
209
|
+
if (stallTimer !== null) deps.clock.clearTimeout(stallTimer);
|
|
210
|
+
stallTimer = null;
|
|
211
|
+
};
|
|
212
|
+
const rearmStall = (): void => {
|
|
213
|
+
if (deps.stallMs === undefined || activeTurn === null) return;
|
|
214
|
+
disarmStall();
|
|
215
|
+
stallTimer = deps.clock.setTimeout(() => {
|
|
216
|
+
// The turn may have ended between the timer firing and this callback
|
|
217
|
+
// running. Without this guard a clean turn that finished near the
|
|
218
|
+
// budget would be reported as a stall and the child signalled.
|
|
219
|
+
if (activeTurn === null) return;
|
|
220
|
+
stalled = true;
|
|
221
|
+
log({
|
|
222
|
+
event: "stall",
|
|
223
|
+
sessionId: opts.sessionId,
|
|
224
|
+
turnId: activeTurnId,
|
|
225
|
+
harness: h.name,
|
|
226
|
+
reason: "inactivity",
|
|
227
|
+
budgetMs: deps.stallMs,
|
|
228
|
+
});
|
|
229
|
+
// The turn is owed its own terminal event before the process dies;
|
|
230
|
+
// the exit path then closes the session with the same cause.
|
|
231
|
+
void pushFailure(failureFromTransport("stalled: inactivity"));
|
|
232
|
+
endTurn({ kind: "done", exitCode: null, cause: "stall" });
|
|
233
|
+
escalate();
|
|
234
|
+
}, deps.stallMs);
|
|
235
|
+
};
|
|
236
|
+
|
|
177
237
|
const writeUser = (text: string): boolean => {
|
|
178
238
|
try {
|
|
179
239
|
stdin.write(
|
|
@@ -184,7 +244,13 @@ export const openSession = (
|
|
|
184
244
|
);
|
|
185
245
|
return true;
|
|
186
246
|
} catch {
|
|
187
|
-
|
|
247
|
+
// A broken stdin pipe ends the session: there is no way to drive the
|
|
248
|
+
// child any more. Surface it as its own event, stop accepting sends,
|
|
249
|
+
// and END the child - marking it dead here instead would suppress the
|
|
250
|
+
// very signal that stops it. The exit path then finalizes as usual.
|
|
251
|
+
void routeEvent({ kind: "error", message: "send failed: session stdin is gone" });
|
|
252
|
+
closing = true;
|
|
253
|
+
escalate();
|
|
188
254
|
return false;
|
|
189
255
|
}
|
|
190
256
|
};
|
|
@@ -195,19 +261,25 @@ export const openSession = (
|
|
|
195
261
|
return summary;
|
|
196
262
|
};
|
|
197
263
|
|
|
198
|
-
const startTurn = (): void => {
|
|
264
|
+
const startTurn = (inputId?: string): void => {
|
|
199
265
|
turnLimitSeen = false;
|
|
200
266
|
turnFailures = [];
|
|
201
267
|
turnAsked = false;
|
|
202
268
|
lastAssistantText = null;
|
|
203
269
|
activeTurn = new AsyncChannel<HarnessEvent>();
|
|
204
270
|
activeTurnId = `${opts.sessionId}:turn-${++turnCounter}`;
|
|
271
|
+
// Tag the turn with the id of the send that opened it, so the consumer
|
|
272
|
+
// correlates a queued input to its turn by reading the tag, not by
|
|
273
|
+
// shadowing the runner's delivery order.
|
|
274
|
+
(activeTurn as { inputId?: string; turnId?: string }).inputId = inputId;
|
|
275
|
+
(activeTurn as { inputId?: string; turnId?: string }).turnId = activeTurnId;
|
|
205
276
|
log({ event: "turn_start", sessionId: opts.sessionId, turnId: activeTurnId });
|
|
206
277
|
for (const held of preTurnEvents.splice(0)) {
|
|
207
278
|
if (held.kind === "failure") turnFailures.push(summaryOf(held));
|
|
208
279
|
activeTurn.push(held);
|
|
209
280
|
}
|
|
210
|
-
turnsChannel.push(activeTurn);
|
|
281
|
+
turnsChannel.push(activeTurn as SessionTurn);
|
|
282
|
+
rearmStall();
|
|
211
283
|
};
|
|
212
284
|
|
|
213
285
|
/** issue #44: at a turn boundary, scan the last assistant message for
|
|
@@ -246,6 +318,7 @@ export const openSession = (
|
|
|
246
318
|
|
|
247
319
|
const endTurn = (done: HarnessEvent & { kind: "done" }): void => {
|
|
248
320
|
if (activeTurn === null) return;
|
|
321
|
+
disarmStall();
|
|
249
322
|
// Asking is a successful turn: the session semantic is "blocked on
|
|
250
323
|
// answer, session alive" - the done stays TURN-scoped (exitCode null
|
|
251
324
|
// in sessions) and the caller answers with the next send().
|
|
@@ -271,7 +344,21 @@ export const openSession = (
|
|
|
271
344
|
// The boundary is the only legal delivery point for queued input.
|
|
272
345
|
if (dead || closing) return;
|
|
273
346
|
const next = pendingSends.shift();
|
|
274
|
-
if (next
|
|
347
|
+
if (next === undefined) return;
|
|
348
|
+
if (writeUser(next.text)) {
|
|
349
|
+
startTurn(next.id);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
// The queue was shifted but the write failed: report the id that was
|
|
353
|
+
// accepted as queued and never delivered, instead of dropping it.
|
|
354
|
+
log({
|
|
355
|
+
event: "sends_dropped",
|
|
356
|
+
sessionId: opts.sessionId,
|
|
357
|
+
count: 1,
|
|
358
|
+
ids: [next.id],
|
|
359
|
+
reason: "write-failed",
|
|
360
|
+
lengths: [next.text.length],
|
|
361
|
+
});
|
|
275
362
|
};
|
|
276
363
|
|
|
277
364
|
const routeEvent = (event: HarnessEvent): Promise<void> => {
|
|
@@ -434,6 +521,7 @@ export const openSession = (
|
|
|
434
521
|
}
|
|
435
522
|
};
|
|
436
523
|
for await (const chunk of proc.stdout) {
|
|
524
|
+
rearmStall();
|
|
437
525
|
for (const line of lines.push(chunk)) await handleLine(line);
|
|
438
526
|
}
|
|
439
527
|
const rest = lines.flush();
|
|
@@ -443,6 +531,7 @@ export const openSession = (
|
|
|
443
531
|
const pumpStderr = async (): Promise<void> => {
|
|
444
532
|
const lines = new LineBuffer();
|
|
445
533
|
for await (const chunk of proc.stderr) {
|
|
534
|
+
rearmStall();
|
|
446
535
|
for (const line of lines.push(chunk)) {
|
|
447
536
|
const limit = detectLimitInLine(h, line);
|
|
448
537
|
if (limit !== null) {
|
|
@@ -478,28 +567,34 @@ export const openSession = (
|
|
|
478
567
|
const finalize = (): void => {
|
|
479
568
|
if (finalized) return;
|
|
480
569
|
finalized = true;
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
570
|
+
// A stall killed the process on purpose, so the signal death it caused
|
|
571
|
+
// reports as "stall", not "killed".
|
|
572
|
+
const cause: ExitCause = stalled
|
|
573
|
+
? "stall"
|
|
574
|
+
: state.limitSeen
|
|
575
|
+
? "limit"
|
|
576
|
+
: exitCode === 0
|
|
577
|
+
? "clean"
|
|
578
|
+
: exitCode === null
|
|
579
|
+
? "killed"
|
|
580
|
+
: "crash";
|
|
488
581
|
if (pumpError !== null) {
|
|
489
582
|
void routeEvent({ kind: "error", message: `session pump failed: ${String(pumpError)}` });
|
|
490
583
|
}
|
|
491
584
|
if (pendingSends.length > 0) {
|
|
492
585
|
// "queued" was an accepted disposition - the loss must be visible to
|
|
493
586
|
// both the log and the consumer, never silent.
|
|
587
|
+
const droppedIds = pendingSends.map((s) => s.id);
|
|
494
588
|
void routeEvent({
|
|
495
589
|
kind: "error",
|
|
496
|
-
message: `${pendingSends.length} queued send(s) died with the session`,
|
|
590
|
+
message: `${pendingSends.length} queued send(s) died with the session: ${droppedIds.join(", ")}`,
|
|
497
591
|
});
|
|
498
592
|
log({
|
|
499
593
|
event: "sends_dropped",
|
|
500
594
|
sessionId: opts.sessionId,
|
|
501
595
|
count: pendingSends.length,
|
|
502
|
-
|
|
596
|
+
ids: droppedIds,
|
|
597
|
+
lengths: pendingSends.map((s) => s.text.length),
|
|
503
598
|
});
|
|
504
599
|
pendingSends.length = 0;
|
|
505
600
|
}
|
|
@@ -526,6 +621,8 @@ export const openSession = (
|
|
|
526
621
|
void proc.exited.then((code) => {
|
|
527
622
|
dead = true;
|
|
528
623
|
exitCode = code;
|
|
624
|
+
// The process is gone: a later fire would flip a finished turn to stall.
|
|
625
|
+
disarmStall();
|
|
529
626
|
// Pipes held open past exit (a grandchild) must not hang the session.
|
|
530
627
|
const pipeGrace = deps.clock.setTimeout(() => {
|
|
531
628
|
pipesOpenAtExit = true;
|
|
@@ -564,24 +661,36 @@ export const openSession = (
|
|
|
564
661
|
if (!closing && !dead) void close();
|
|
565
662
|
}
|
|
566
663
|
})(),
|
|
567
|
-
send(
|
|
664
|
+
send(input: SessionInput): SessionSendResult {
|
|
568
665
|
if (dead || closing) throw new SessionClosedError();
|
|
569
666
|
if (activeTurn !== null) {
|
|
570
|
-
pendingSends.push(
|
|
667
|
+
pendingSends.push(input);
|
|
571
668
|
log({
|
|
572
669
|
event: "send",
|
|
573
670
|
sessionId: opts.sessionId,
|
|
574
671
|
turnId: activeTurnId,
|
|
672
|
+
inputId: input.id,
|
|
575
673
|
disposition: "queued",
|
|
576
674
|
});
|
|
577
675
|
return { disposition: "queued" };
|
|
578
676
|
}
|
|
579
|
-
if (!writeUser(text))
|
|
580
|
-
|
|
677
|
+
if (!writeUser(input.text)) {
|
|
678
|
+
log({
|
|
679
|
+
event: "send",
|
|
680
|
+
sessionId: opts.sessionId,
|
|
681
|
+
turnId: activeTurnId,
|
|
682
|
+
inputId: input.id,
|
|
683
|
+
disposition: "rejected",
|
|
684
|
+
reason: "write-failed",
|
|
685
|
+
});
|
|
686
|
+
return { disposition: "rejected", reason: "write-failed" };
|
|
687
|
+
}
|
|
688
|
+
startTurn(input.id);
|
|
581
689
|
log({
|
|
582
690
|
event: "send",
|
|
583
691
|
sessionId: opts.sessionId,
|
|
584
692
|
turnId: activeTurnId,
|
|
693
|
+
inputId: input.id,
|
|
585
694
|
disposition: "started",
|
|
586
695
|
});
|
|
587
696
|
return { disposition: "started" };
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
detectAuthFailureInLine,
|
|
21
21
|
detectLimitInLine,
|
|
22
22
|
detectTransportInLine,
|
|
23
|
+
detectUnavailableInLine,
|
|
23
24
|
} from "../interpretation/limits.js";
|
|
24
25
|
import { composeEscalatedPrompt, detectQuestionBlock } from "../interpretation/question.js";
|
|
25
26
|
import { ArgvRefusalError } from "../interpretation/refusal.js";
|
|
@@ -39,6 +40,7 @@ import {
|
|
|
39
40
|
failureFromTerminalError,
|
|
40
41
|
failureFromTimeout,
|
|
41
42
|
failureFromTransport,
|
|
43
|
+
failureFromUnavailable,
|
|
42
44
|
reduceFailures,
|
|
43
45
|
} from "./failure.js";
|
|
44
46
|
import { LineBuffer } from "./lines.js";
|
|
@@ -595,12 +597,15 @@ export async function* streamTurn(
|
|
|
595
597
|
) {
|
|
596
598
|
const tailForNative = stderrTail.snapshot();
|
|
597
599
|
const transportLine = tailForNative.find((line) => detectTransportInLine(line));
|
|
600
|
+
const unavailableLine = tailForNative.find((line) => detectUnavailableInLine(line));
|
|
598
601
|
const f =
|
|
599
602
|
transportLine !== undefined
|
|
600
603
|
? failureFromTransport(transportLine)
|
|
601
|
-
:
|
|
602
|
-
?
|
|
603
|
-
:
|
|
604
|
+
: unavailableLine !== undefined
|
|
605
|
+
? failureFromUnavailable(unavailableLine)
|
|
606
|
+
: tailForNative.length > 0
|
|
607
|
+
? failureFromNative(exitCode, tailForNative)
|
|
608
|
+
: failureFromTransport(`nonzero exit ${exitCode}`);
|
|
604
609
|
failures.push(f);
|
|
605
610
|
// Need to emit this failure before done, even though queue is closed
|
|
606
611
|
yield { kind: "failure", ...f };
|
|
@@ -189,6 +189,9 @@ export const buildResumeArgv = (h: HarnessDescriptor, opts: ResumeOptions): stri
|
|
|
189
189
|
export interface SessionOptions {
|
|
190
190
|
readonly sessionId: string;
|
|
191
191
|
readonly model?: string;
|
|
192
|
+
/** Provider selector (pi). A harness with no provider selector refuses,
|
|
193
|
+
* the same way a one-shot turn does. */
|
|
194
|
+
readonly provider?: string;
|
|
192
195
|
}
|
|
193
196
|
|
|
194
197
|
export const buildSessionArgv = (h: HarnessDescriptor, opts: SessionOptions): string[] => {
|
|
@@ -223,6 +226,11 @@ export const buildSessionArgv = (h: HarnessDescriptor, opts: SessionOptions): st
|
|
|
223
226
|
}
|
|
224
227
|
argv.push(h.vocabulary.modelFlag, validated.id);
|
|
225
228
|
}
|
|
229
|
+
if (opts.provider !== undefined) {
|
|
230
|
+
// One dimension, rendered by the same code path a launch argv uses, so
|
|
231
|
+
// the flag spelling and the refusal (with supportedBy) stay identical.
|
|
232
|
+
argv.push(...renderTurnOptions(h, { provider: opts.provider } as TurnOptions, "launch"));
|
|
233
|
+
}
|
|
226
234
|
return argv;
|
|
227
235
|
};
|
|
228
236
|
|
|
@@ -23,9 +23,9 @@ import type {
|
|
|
23
23
|
HarnessDescriptor,
|
|
24
24
|
LimitCode,
|
|
25
25
|
LimitMatcher,
|
|
26
|
-
|
|
26
|
+
PhraseMatcher,
|
|
27
27
|
} from "../knowledge/descriptor.js";
|
|
28
|
-
import { SHARED_TRANSPORT_MATCHERS } from "../knowledge/matchers.js";
|
|
28
|
+
import { SHARED_TRANSPORT_MATCHERS, SHARED_UNAVAILABLE_MATCHERS } from "../knowledge/matchers.js";
|
|
29
29
|
|
|
30
30
|
/** Bottom-up batch scans stop after this many non-empty lines: the wall is
|
|
31
31
|
* virtually always the last thing a dying turn printed, and an unbounded
|
|
@@ -155,29 +155,28 @@ export const detectAuthFailureInLine = (
|
|
|
155
155
|
export const detectAuthFailure = (h: HarnessDescriptor, output: string): AuthFailureKind | null =>
|
|
156
156
|
scanTail(output, compileAuthMatchers(h.authMatchers));
|
|
157
157
|
|
|
158
|
-
const
|
|
159
|
-
ReadonlyArray<TransportMatcher>,
|
|
160
|
-
ReadonlyArray<readonly [RegExp, boolean]>
|
|
161
|
-
>();
|
|
158
|
+
const phraseCache = new WeakMap<ReadonlyArray<PhraseMatcher>, ReadonlyArray<RegExp>>();
|
|
162
159
|
|
|
163
|
-
const
|
|
164
|
-
matchers
|
|
165
|
-
): ReadonlyArray<readonly [RegExp, boolean]> => {
|
|
166
|
-
const cached = transportCache.get(matchers);
|
|
160
|
+
const compilePhraseMatchers = (matchers: ReadonlyArray<PhraseMatcher>): ReadonlyArray<RegExp> => {
|
|
161
|
+
const cached = phraseCache.get(matchers);
|
|
167
162
|
if (cached !== undefined) return cached;
|
|
168
163
|
if (matchers.length > MAX_MATCHERS_PER_KIND) {
|
|
169
164
|
throw new Error(`more than ${MAX_MATCHERS_PER_KIND} matchers per harness per kind`);
|
|
170
165
|
}
|
|
171
|
-
const compiled = matchers.map((m) =>
|
|
172
|
-
|
|
166
|
+
const compiled = matchers.map((m) => validateAndCompile(m.pattern, m.flags));
|
|
167
|
+
phraseCache.set(matchers, compiled);
|
|
173
168
|
return compiled;
|
|
174
169
|
};
|
|
175
170
|
|
|
176
|
-
|
|
177
|
-
const compiled = compileTransportMatchers(SHARED_TRANSPORT_MATCHERS);
|
|
171
|
+
const detectPhraseInLine = (matchers: ReadonlyArray<PhraseMatcher>, line: string): boolean => {
|
|
178
172
|
const windowed = line.slice(0, WINDOW).trim();
|
|
179
|
-
|
|
180
|
-
if (re.test(windowed)) return true;
|
|
181
|
-
}
|
|
182
|
-
return false;
|
|
173
|
+
return compilePhraseMatchers(matchers).some((re) => re.test(windowed));
|
|
183
174
|
};
|
|
175
|
+
|
|
176
|
+
/** A network or gateway fault between the harness and its provider. */
|
|
177
|
+
export const detectTransportInLine = (line: string): boolean =>
|
|
178
|
+
detectPhraseInLine(SHARED_TRANSPORT_MATCHERS, line);
|
|
179
|
+
|
|
180
|
+
/** A provider that answered but cannot serve the requested model. */
|
|
181
|
+
export const detectUnavailableInLine = (line: string): boolean =>
|
|
182
|
+
detectPhraseInLine(SHARED_UNAVAILABLE_MATCHERS, line);
|