@dungle-scrubs/harness-cli-normalizer 0.5.3 → 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 +111 -0
- 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/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/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/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/open-session.ts +132 -23
- package/src/interpretation/argv.ts +8 -0
|
@@ -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" };
|
|
@@ -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
|
|