Files
gstack/test/helpers/pty/session.ts
garrytan a1b21407eb refactor(pty): one runPtySession loop drives observation, counting and floor
test/helpers/pty/session.ts owns launch -> start -> (poll -> tick)* ->
timeout and the failure contract the three runners each hand-rolled: the
run's own error wins over capture and close errors, close always runs, owned
fixture cleanup runs last (also when launch fails). Each runner now supplies a
PtySessionPlan: its boot/command step, poll cadence (2s observation/floor
sleep; counting's output wake + 250ms coalesce), tick policy (permission
handling, native identity, terminal rules stay per runner because they differ)
and capture hooks. The runner bodies are decomposed into top-level steps so no
function exceeds 150 lines; behavior is unchanged and the fake-driver cases
from the first W4 commit pass unmodified.

The counting capture step and the native completion-summary predicate are now
named functions (countingCapture, isNativeCompletionSummary), so
plan-create-prepublication and plan-count-completion call them directly
instead of executing sliced source. The two harnesses that still execute a
sliced runner body with injected boundaries (eng-seeded-completion-ai,
plan-floor-permission) pass the PtyDriver seam instead of overriding
Date/Bun.sleep.
2026-09-30 06:15:24 +00:00

94 lines
3.7 KiB
TypeScript

/**
* The one PTY session loop (runPtySession) and its launch seam (PtyDriver).
* Owns launch, the poll/deadline loop, failure capture and close/cleanup
* ordering; each runner in pty/runners/ supplies its policy as a PtySessionPlan
* (start, poll, tick, timeout, capture hooks). New runner: build a plan and
* `return runPtySession(plan)`; tests pass the fake driver from fake-session.ts.
* Size ratchet (c) binds this module. Moved from the three runner loops of the
* former claude-pty-runner.ts. Tests import through claude-pty-runner.ts.
*/
import { launchClaudePty, type ClaudePtyOptions, type ClaudePtySession } from './launch';
/**
* The runners' launch seam: how a session starts, and the clock the runner
* loop reads. Omitted in production (real launcher, Date.now,
* performance.now, Bun.sleep); tests pass the fake driver from
* test/helpers/pty/fake-session.ts.
*/
export interface PtyDriver {
launch(opts: ClaudePtyOptions): Promise<ClaudePtySession>;
/** Wall clock, Date.now() semantics. */
now(): number;
/** Monotonic clock, performance.now() semantics. */
monotonic(): number;
sleep(ms: number): Promise<void>;
}
export const realPtyDriver: PtyDriver = {
launch: opts => launchClaudePty(opts),
now: () => Date.now(),
monotonic: () => performance.now(),
sleep: ms => Bun.sleep(ms),
};
/** One poll step: finish with a result, keep polling, or end the loop as a timeout. */
export type PtyStep<R> = { done: R } | 'continue' | 'break';
export interface PtySessionPlan<R> {
driver: PtyDriver;
launch: ClaudePtyOptions;
/** Owned fixture cleanup; runs after close, and when launch itself fails. */
cleanup?: () => void;
/** Boot and command submission. A returned result ends the run before polling. */
start(session: ClaudePtySession): Promise<R | undefined>;
/** Wait for the next poll. False means the deadline passed: the run times out. */
poll(session: ClaudePtySession): Promise<boolean>;
tick(session: ClaudePtySession): Promise<PtyStep<R>>;
timeout(session: ClaudePtySession): R;
/** Snapshot a thrown run before close; the original error is rethrown. */
onError?(session: ClaudePtySession, error: unknown): void;
/** Always runs before close. A throw here fails an otherwise successful run. */
beforeClose?(session: ClaudePtySession, failure: { error: unknown } | undefined): void;
/** Close failed after a successful run; the close error is rethrown. */
onCloseError?(session: ClaudePtySession, error: unknown): void;
}
/**
* launch → start → (poll → tick)* → timeout, with one failure contract: a
* run's own error wins over capture and close errors, close always runs, and
* owned cleanup runs last.
*/
export async function runPtySession<R>(plan: PtySessionPlan<R>): Promise<R> {
let session: ClaudePtySession;
try {
session = await plan.driver.launch(plan.launch);
} catch (error) {
plan.cleanup?.();
throw error;
}
let failure: { error: unknown } | undefined;
try {
const early = await plan.start(session);
if (early !== undefined) return early;
while (await plan.poll(session)) {
const step = await plan.tick(session);
if (step === 'break') break;
if (step !== 'continue') return step.done;
}
return plan.timeout(session);
} catch (error) {
failure = { error };
plan.onError?.(session, error);
throw error;
} finally {
try {
try { plan.beforeClose?.(session, failure); }
catch (error) { if (!failure) { failure = { error }; throw error; } }
finally {
try { await session.close(); }
catch (error) { if (!failure) { plan.onCloseError?.(session, error); throw error; } }
}
} finally { plan.cleanup?.(); }
}
}