mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-12 01:41:39 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a9c204f2b |
No files matched your search
@@ -226,6 +226,18 @@ retry is scheduled. The reasons are:
|
||||
one with a reused `deliveryId`; the result warns older callers that this field no
|
||||
longer deduplicates input. The CLI does not generate a deduplication key.
|
||||
|
||||
The last recorded managed-compaction input refusal appears as `inputReadFailure` in the attempt
|
||||
record and `rig compact --state`: the preparation or compact stage, `composer`
|
||||
(the explicit composer hold) or `draft-aware` check, reader status, confidence,
|
||||
a fixed read-failure reason and cursor flags. An owed restore step reports it in
|
||||
`owedRestore.lastDelivery`. These fields contain no input text, suggestions,
|
||||
terminal frames or exception messages. Confidence and timeout fields are `null`
|
||||
when that reader does not supply them; a known composer read timeout reports
|
||||
`timedOut: true`. A generic `capture_unavailable` does not identify the failed
|
||||
capture operation. Older records lack this diagnostic. It neither changes the
|
||||
refusal nor retries an attempt; missing metadata cannot establish which check
|
||||
caused a historical refusal.
|
||||
|
||||
Clearing a draft, changing modes or restarting does not flush saved messages or
|
||||
callbacks. To try again, send a current request: it rechecks the recipient and input.
|
||||
A caller abort before paste cancels input; after a write may have started, the
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { InputReadDiagnostic } from "../domain/input-read-diagnostic.js";
|
||||
import { DeliveryGuardError, type SeatDeliveryGuard } from "../domain/seat-delivery-guard.js";
|
||||
import { writeFile as fsWriteFile, unlink as fsUnlink } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -77,7 +78,7 @@ export function defaultTmuxFileOps(): TmuxFileOps {
|
||||
|
||||
export type TmuxResult =
|
||||
| { ok: true; warnings?: string[]; stopPhase?: StopPhase }
|
||||
| { ok: false; code: string; message: string; stopPhase?: StopPhase };
|
||||
| { ok: false; code: string; message: string; stopPhase?: StopPhase; inputReadFailure?: InputReadDiagnostic };
|
||||
|
||||
/** Furthest external stop boundary reached; an attempted command can have an uncertain effect. */
|
||||
export type StopPhase = "none" | "detach_attempted" | "detached" | "kill_attempted";
|
||||
@@ -246,7 +247,7 @@ function isTmuxTransportAbsentError(err: unknown): boolean {
|
||||
}
|
||||
|
||||
function classifyWriteError(err: unknown): TmuxResult {
|
||||
if (err instanceof DeliveryGuardError) return { ok: false, code: err.code, message: err.message };
|
||||
if (err instanceof DeliveryGuardError) return { ok: false, code: err.code, message: err.message, ...(err.inputReadFailure ? { inputReadFailure: err.inputReadFailure } : {}) };
|
||||
if (!(err instanceof Error)) {
|
||||
return { ok: false, code: "unknown", message: String(err) };
|
||||
}
|
||||
@@ -600,7 +601,8 @@ export class TmuxAdapter {
|
||||
return guard.input(identity, () => write(pane, () => guard.checkInput(identity), bound.nodeId));
|
||||
});
|
||||
} catch (error) {
|
||||
return { ok: false, code: (error as { code?: string }).code ?? "guard_target_unknown", message: String((error as Error).message) };
|
||||
return { ok: false, code: (error as { code?: string }).code ?? "guard_target_unknown", message: String((error as Error).message),
|
||||
...(error instanceof DeliveryGuardError && error.inputReadFailure ? { inputReadFailure: error.inputReadFailure } : {}) };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { InputReadDiagnostic } from "./input-read-diagnostic.js";
|
||||
import type { TerminalActor, TerminalPolicyStore } from "./terminal-policy.js";
|
||||
import { TerminalPolicyRefusal } from "./terminal-policy.js";
|
||||
import { DeliveryGuardError } from "./seat-delivery-guard.js";
|
||||
@@ -425,6 +426,7 @@ type PendingPostCompactStage = "turn_boundary" | "restore_prompt" | "compliance_
|
||||
|
||||
/** Safe delivery diagnostics: never retain pane contents or an exception message. */
|
||||
export interface RestoreDeliveryAttempt {
|
||||
inputReadFailure?: InputReadDiagnostic;
|
||||
stage: PendingPostCompactStage;
|
||||
at: string;
|
||||
ok: boolean;
|
||||
@@ -461,6 +463,7 @@ export interface ObservedCompaction {
|
||||
detail: string;
|
||||
}
|
||||
export interface PreparationState {
|
||||
inputReadFailure?: InputReadDiagnostic & { stage: "preparation" | "compact" };
|
||||
/** The reading that held this preparation send; never includes draft text. */
|
||||
inputBox?: SendResult["heldInputBoxReading"];
|
||||
policyActor?: TerminalActor;
|
||||
@@ -565,6 +568,7 @@ interface PreparationFailureRun {
|
||||
}
|
||||
|
||||
interface CompactionRecord {
|
||||
inputReadFailure?: PreparationState["inputReadFailure"];
|
||||
preparationFailures?: PreparationFailureRun;
|
||||
inputBox?: PreparationState["inputBox"];
|
||||
/** The daemon process that last wrote it. */
|
||||
@@ -1000,6 +1004,7 @@ export class ClaudeCompactionEnforcer {
|
||||
} catch (error) {
|
||||
this.recordRestoreFailure(input.sessionName, stage, record, {
|
||||
ok: false, reason: error instanceof DeliveryGuardError ? error.code : "send_failed",
|
||||
...(error instanceof DeliveryGuardError && error.inputReadFailure ? { inputReadFailure: error.inputReadFailure } : {}),
|
||||
}, error instanceof Error ? error.name : typeof error);
|
||||
if (error instanceof DeliveryGuardError) return { triggered: false, reason: error.code === "typing_guard_enabled" ? "typing_guard_enabled" : "guard_target_unknown" };
|
||||
throw error;
|
||||
@@ -1027,7 +1032,7 @@ export class ClaudeCompactionEnforcer {
|
||||
private recordRestoreFailure(
|
||||
session: string, stage: PendingPostCompactStage | undefined,
|
||||
record: { file: string; record: CompactionRecord } | undefined,
|
||||
result: Pick<SendResult, "ok" | "reason" | "outcome" | "activity" | "heldInputBox">, errorClass?: string,
|
||||
result: Pick<SendResult, "ok" | "reason" | "outcome" | "activity" | "heldInputBox" | "inputReadFailure">, errorClass?: string,
|
||||
): void {
|
||||
if (!stage || !record || this.records.get(session) !== record || this.pendingPostCompactRestore.get(session) !== stage) return;
|
||||
const prior = record.record.lastDelivery;
|
||||
@@ -1042,6 +1047,7 @@ export class ClaudeCompactionEnforcer {
|
||||
const lastDelivery: RestoreDeliveryAttempt = {
|
||||
stage, at: new Date(this.now()).toISOString(), ok: result.ok, reason: result.reason ?? "send_failed",
|
||||
...(result.outcome ? { outcome: result.outcome } : {}),
|
||||
...(result.inputReadFailure ? { inputReadFailure: result.inputReadFailure } : {}),
|
||||
...(result.activity ? { activity: { state: result.activity.state, reason: result.activity.reason, evidenceSource: result.activity.evidenceSource } } : {}),
|
||||
...(errorClass ? { errorClass } : {}), failures: waiting ? 0 : (prior?.failures ?? 0) + 1,
|
||||
...(held ? { inputBox: held } : {}), ...(unreadable ? { unreadable } : {}),
|
||||
@@ -1598,7 +1604,7 @@ export class ClaudeCompactionEnforcer {
|
||||
if (error instanceof DeliveryGuardError) {
|
||||
const written = (error as { inputWritten?: boolean }).inputWritten === true;
|
||||
const attempted = (error as { inputAttempted?: boolean }).inputAttempted === true;
|
||||
prep = { ok: false, sent: written, reason: error.code,
|
||||
prep = { ok: false, sent: written, reason: error.code, inputReadFailure: error.inputReadFailure,
|
||||
inputPhase: written ? "written" as const : attempted ? "attempted" as const : "not_reached" as const };
|
||||
} else prep = null;
|
||||
}
|
||||
@@ -1611,7 +1617,8 @@ export class ClaudeCompactionEnforcer {
|
||||
// Neither a failed response nor its loss proves no paste/Enter. Never replay uncertainty.
|
||||
attempt.delivery = prep?.ok && prep.outcome !== "retained" ? "delivered" : notSent ? "not_sent" : "uncertain";
|
||||
attempt.inputBox = prep?.heldInputBoxReading;
|
||||
this.persist(input.sessionName, attempt.attemptId, { delivery: attempt.delivery, sends: attempt.sends, inputBox: attempt.inputBox });
|
||||
if (prep?.inputReadFailure) attempt.inputReadFailure = { stage: "preparation", ...prep.inputReadFailure };
|
||||
this.persist(input.sessionName, attempt.attemptId, { delivery: attempt.delivery, sends: attempt.sends, inputBox: attempt.inputBox, inputReadFailure: attempt.inputReadFailure });
|
||||
// The replacement request went out (or may have): the ended attempt's gate is used up.
|
||||
if (!notSent && attempt.afterEnded !== undefined && this.pendingPreCompactPrep.get(input.sessionName) === attempt) this.lastEnded.delete(input.sessionName);
|
||||
if (this.pendingPreCompactPrep.get(input.sessionName) !== attempt || attempt.controller.signal.aborted) return;
|
||||
@@ -1661,7 +1668,7 @@ export class ClaudeCompactionEnforcer {
|
||||
return this.sessionTransport.send(input.sessionName, composed.command,
|
||||
{ openrigNotice: true, allowDraftWhileIdle: true, beforeWrite, ...(attempt.deadlineAt !== null ? { waitForIdleMs: attempt.prepared ? idleWait : Math.max(1, Math.min(idleWait, this.compactDeadline(attempt)! - this.now())), readinessFromPaneOnly: true } : {}) });
|
||||
};
|
||||
let result: Pick<SendResult, "ok" | "reason" | "outcome" | "sent" | "inputPhase" | "activity" | "heldInputBox">;
|
||||
let result: Pick<SendResult, "ok" | "reason" | "outcome" | "sent" | "inputPhase" | "activity" | "heldInputBox" | "inputReadFailure">;
|
||||
let guardRefused = false;
|
||||
try {
|
||||
const guard = this.sessionTransport.deliveryGuard;
|
||||
@@ -1672,11 +1679,16 @@ export class ClaudeCompactionEnforcer {
|
||||
const attempted = (error as { inputAttempted?: boolean } | null)?.inputAttempted === true;
|
||||
guardRefused = error instanceof DeliveryGuardError && !written && !attempted;
|
||||
result = { ok: false, reason: error instanceof DeliveryGuardError ? error.code : "delivery_uncertain",
|
||||
...(error instanceof DeliveryGuardError && error.inputReadFailure ? { inputReadFailure: error.inputReadFailure } : {}),
|
||||
...(written ? { sent: true, inputPhase: "written" as const } : attempted ? { inputPhase: "attempted" as const } : {}) };
|
||||
} finally {
|
||||
delete attempt.compactInFlight;
|
||||
}
|
||||
if (!result.ok || result.outcome === "retained") {
|
||||
if (result.inputReadFailure) {
|
||||
attempt.inputReadFailure = { stage: "compact", ...result.inputReadFailure };
|
||||
this.persist(input.sessionName, attempt.attemptId, { inputReadFailure: attempt.inputReadFailure });
|
||||
}
|
||||
const phase = result.inputPhase;
|
||||
// A write that was issued, or text already in the input, is never "nothing sent".
|
||||
const writeAttempted = phase === "attempted" || phase === "written" || result.sent === true;
|
||||
@@ -1927,7 +1939,7 @@ export class ClaudeCompactionEnforcer {
|
||||
compact: "not_sent", sends: record.sends,
|
||||
deadlineAt: record.deadlineAt ?? (record.delivery === "not_sent" ? null : record.updatedAt + AUTO_PREP_WAIT_MS_DEFAULT),
|
||||
policyWasEnabled: true, preparationRoot: record.preparationRoot, controller: new AbortController(),
|
||||
compactInstruction: record.compactInstruction, inputBox: record.inputBox,
|
||||
compactInstruction: record.compactInstruction, inputBox: record.inputBox, inputReadFailure: record.inputReadFailure,
|
||||
// A held attempt comes back held, with its recorded hold (never stamped again) and its map's age and usage.
|
||||
...(held ? { prepared: true as const, preparedAt: record.preparedAt ?? record.updatedAt, preparedReason: record.preparedReason ?? "map_complete_seat_never_idle" } : {}),
|
||||
...(held ? { mapReadyAt: record.mapReadyAt ?? record.preparedAt ?? record.updatedAt } : record.mapReadyAt !== undefined ? { mapReadyAt: record.mapReadyAt } : {}),
|
||||
|
||||
@@ -95,7 +95,8 @@ export class DraftAwareDelivery {
|
||||
: guardError ? error.message : "The delivery attempt ended without a reliable write result. Repeating the request may duplicate input.";
|
||||
result = { ok: false, sessionName: session, outcome: "failed", inputPhase,
|
||||
...(inputWritten || inputPhase === "not_reached" ? { sent: inputWritten } : {}),
|
||||
reason: guardError ? error.code : "delivery_indeterminate", error: detail };
|
||||
reason: guardError ? error.code : "delivery_indeterminate", error: detail,
|
||||
...(guardError && error.inputReadFailure ? { inputReadFailure: error.inputReadFailure } : {}) };
|
||||
}
|
||||
if (inputWritten) result = { ...result, sent: true };
|
||||
const preInputRefusal = ["draft_input_busy", "draft_input_unknown", "draft_input_changed", "draft_wake_superseded",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/** Reporting only: never retain composer text, suggestions, frames, hints or exception messages. */
|
||||
export interface InputReadDiagnostic {
|
||||
check: "composer" | "draft-aware";
|
||||
status: "empty" | "text" | "typed" | "likely-suggestion" | "typed-with-suggestion" | "unknown" | "no-input-box";
|
||||
confidence: "high" | "medium" | "low" | "unknown" | null;
|
||||
reason: string;
|
||||
cursor: { present: boolean; valid: boolean | null; atEnd: boolean | null };
|
||||
/** null means this reader does not report whether a timeout occurred. */
|
||||
timedOut: boolean | null;
|
||||
}
|
||||
|
||||
const reasons = new Set(["capture_unavailable", "pane_in_mode", "invalid_cursor", "incomplete_frame",
|
||||
"ambiguous_composer", "unrecognized_composer", "read_timeout", "read_budget_spent", "cursor_moved",
|
||||
"cursor_unavailable", "cursor_outside", "unrecognized_input_box", "input_not_clear",
|
||||
"paste_controls", "paste_long", "paste_viewport"]);
|
||||
|
||||
/** Copy only fixed vocabulary and cursor flags. Unknown reader reasons remain content-free. */
|
||||
export function inputReadDiagnostic(check: InputReadDiagnostic["check"], reading: {
|
||||
state?: string; status?: string; confidence?: string; unknownReason?: string;
|
||||
cursor?: { x: number; y: number; width: number; height: number } | null;
|
||||
cursorAtEnd?: boolean; evidence?: string[];
|
||||
} | null, reason?: string): InputReadDiagnostic {
|
||||
const evidence = reading?.evidence ?? [];
|
||||
const timeout = evidence.includes("input box reads timed out");
|
||||
const derived = reason ?? reading?.unknownReason ?? (timeout ? "read_timeout"
|
||||
: evidence.includes("input box budget spent") ? "read_budget_spent"
|
||||
: evidence.includes("cursor moved during the capture") ? "cursor_moved"
|
||||
: evidence.includes("cursor unavailable") ? "cursor_unavailable"
|
||||
: evidence.some(e => e === "cursor outside the input box" || e === "cursor seen once outside the input box") ? "cursor_outside"
|
||||
: reading?.status === "no-input-box" ? "unrecognized_input_box"
|
||||
: reading?.status === "unknown" || !reading ? "capture_unavailable" : "input_not_clear");
|
||||
const status = reading?.status ?? reading?.state;
|
||||
const confidence = reading?.confidence;
|
||||
const cursor = reading?.cursor;
|
||||
return {
|
||||
check,
|
||||
status: ["empty", "text", "typed", "likely-suggestion", "typed-with-suggestion", "unknown", "no-input-box"].includes(status ?? "")
|
||||
? status as InputReadDiagnostic["status"] : "unknown",
|
||||
confidence: ["high", "medium", "low", "unknown"].includes(confidence ?? "")
|
||||
? confidence as InputReadDiagnostic["confidence"] : null,
|
||||
reason: reasons.has(derived) ? derived : "capture_unavailable",
|
||||
cursor: { present: !!cursor, valid: cursor ? [cursor.x, cursor.y, cursor.width, cursor.height].every(Number.isInteger)
|
||||
&& cursor.x >= 0 && cursor.y >= 0 && cursor.width > cursor.x && cursor.height > cursor.y : null,
|
||||
atEnd: typeof reading?.cursorAtEnd === "boolean" ? reading.cursorAtEnd : null },
|
||||
timedOut: timeout ? true : null,
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { inputReadDiagnostic, type InputReadDiagnostic } from "./input-read-diagnostic.js";
|
||||
import { hasOtherSeatHistory, isStoppedSeatPredecessor, resolveCurrentSessionAddress } from "./session-registry.js";
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { randomUUID } from "node:crypto";
|
||||
@@ -28,7 +29,7 @@ interface Lease {
|
||||
}
|
||||
|
||||
export class DeliveryGuardError extends Error {
|
||||
constructor(readonly code: string, message: string) { super(message); }
|
||||
constructor(readonly code: string, message: string, readonly inputReadFailure?: InputReadDiagnostic) { super(message); }
|
||||
|
||||
// Hono's error protocol also preserves this typed refusal on lifecycle routes.
|
||||
getResponse(): Response {
|
||||
@@ -440,14 +441,14 @@ export class SeatDeliveryGuard {
|
||||
this.checkInput(name);
|
||||
return observed;
|
||||
};
|
||||
const unknown = () => new DeliveryGuardError("draft_input_unknown", "The current input cannot be read. Automatic delivery is held.");
|
||||
const unknown = (reading: ComposerInput | null) => new DeliveryGuardError("draft_input_unknown", "The current input cannot be read. Automatic delivery is held.", inputReadDiagnostic("draft-aware", reading));
|
||||
let input = await read();
|
||||
// Claude may still be redrawing this lease's own paste: one bounded re-read before refusing its Enter.
|
||||
if (submit && lease.staged && (!input || input.state === "unknown")) { await this.sleep(OWN_INPUT_REREAD_MS); input = await read(); }
|
||||
if (!input || input.state === "unknown") {
|
||||
// No Enter was sent, so the paste stays there unsubmitted, and the next delivery may recognize it as OpenRig's.
|
||||
if (submit && lease.staged) this.leftovers.set(target.nodeId, { target: lease.target, humanEpoch: lease.humanEpoch, ...lease.staged });
|
||||
throw unknown();
|
||||
throw unknown(input);
|
||||
}
|
||||
if (leftover === "recognize" && !submit && this.ownsLeftover(target, input)) { lease.ownLeftover = "recognized"; return; }
|
||||
if (typeof leftover === "object" && lease.ownLeftover === "recognized") {
|
||||
@@ -466,7 +467,7 @@ export class SeatDeliveryGuard {
|
||||
lease.ownLeftover = "submitted";
|
||||
await this.sleep(OWN_INPUT_REREAD_MS);
|
||||
const after = await read();
|
||||
if (!after || after.state === "unknown") throw unknown();
|
||||
if (!after || after.state === "unknown") throw unknown(after);
|
||||
input = after;
|
||||
}
|
||||
}
|
||||
@@ -489,7 +490,7 @@ export class SeatDeliveryGuard {
|
||||
: uncertainty === "long"
|
||||
? "OpenRig couldn't confirm ownership of Codex's collapsed long paste. Send a shorter message; nothing was pasted. Turning this seat's typing guard off explicitly disables draft protection."
|
||||
: "OpenRig couldn't confirm the complete message would stay visible in this Codex viewport. Open a larger terminal or shorten the message, then retry this request; nothing was pasted.";
|
||||
throw new DeliveryGuardError("draft_input_unknown", message);
|
||||
throw new DeliveryGuardError("draft_input_unknown", message, inputReadDiagnostic("draft-aware", input, `paste_${uncertainty}`));
|
||||
}
|
||||
lease.inputFrame = input.frame;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { inputReadDiagnostic, type InputReadDiagnostic } from "./input-read-diagnostic.js";
|
||||
import { expectedConversation, sameConversation, resolveExpectedConversation, type ExpectedConversation } from "./expected-conversation.js";
|
||||
import type { ContextUsageStore } from "./context-usage-store.js";
|
||||
import { physicalSessionName } from "./session-registry.js";
|
||||
@@ -2176,6 +2177,7 @@ export interface SendResult {
|
||||
heldInputBox?: InputBoxAnnotation["status"];
|
||||
/** The existing preserve-input reading, without the draft or suggestion text. No extra capture. */
|
||||
heldInputBoxReading?: Pick<InputBoxAnnotation, "status" | "confidence" | "evidence" | "cursor">;
|
||||
inputReadFailure?: InputReadDiagnostic;
|
||||
}
|
||||
|
||||
/** One reading of a Claude Code or Codex input box: `exact` (the sent text, word boundaries kept), `ambiguous`
|
||||
@@ -2823,6 +2825,7 @@ export class SessionTransport {
|
||||
sent: written ? true : attempted ? undefined : false,
|
||||
inputPhase: written ? "written" : attempted ? "attempted" : "not_reached",
|
||||
reason: (error as { code?: string }).code ?? "expected_identity_unavailable", error: (error as Error).message,
|
||||
...(error instanceof DeliveryGuardError && error.inputReadFailure ? { inputReadFailure: error.inputReadFailure } : {}),
|
||||
...(receiptUnconfirmed ? { warning: "Conditional input receipt could not be finalized; inspect before retrying." } : {}) };
|
||||
}
|
||||
}
|
||||
@@ -2944,17 +2947,20 @@ export class SessionTransport {
|
||||
if ((error as { inputWritten?: boolean }).inputWritten === true) {
|
||||
// Only a throw before the Enter key reached the adapter positively means Enter was never sent.
|
||||
const enter = (error as { enterAttempted?: boolean }).enterAttempted === true ? "the submission (Enter) outcome is unconfirmed" : "Enter was not sent";
|
||||
return { ok: false, sessionName, sent: true, outcome: "failed", inputPhase: "written", reason: (error as { code?: string }).code ?? "submit_refused",
|
||||
return { ok: false, sessionName, sent: true, outcome: "failed", inputPhase: "written",
|
||||
...(error instanceof DeliveryGuardError && error.inputReadFailure ? { inputReadFailure: error.inputReadFailure } : {}), reason: (error as { code?: string }).code ?? "submit_refused",
|
||||
error: `Text was written to '${sessionName}' but ${enter}: ${(error as Error).message}` };
|
||||
}
|
||||
// A throw after the text write was issued: whether it landed is unknown, so it is not "nothing sent" either.
|
||||
if ((error as { inputAttempted?: boolean }).inputAttempted === true) {
|
||||
return { ok: false, sessionName, outcome: "failed", inputPhase: "attempted", reason: (error as { code?: string }).code ?? "delivery_indeterminate",
|
||||
return { ok: false, sessionName, outcome: "failed", inputPhase: "attempted",
|
||||
...(error instanceof DeliveryGuardError && error.inputReadFailure ? { inputReadFailure: error.inputReadFailure } : {}), reason: (error as { code?: string }).code ?? "delivery_indeterminate",
|
||||
error: `A text write to '${sessionName}' was issued, but its result was lost: ${(error as Error).message}` };
|
||||
}
|
||||
const noInputCode = error instanceof OutboxHandlerError && error.code === "retained_quota_full" ? "retained_quota_full" as const
|
||||
: error instanceof DeliveryGuardError && error.code === "terminal_seat" ? "terminal_seat" as const : undefined;
|
||||
return { ok: false, sessionName, sent: false, reason: (error as { code?: string }).code ?? "guard_unavailable", error: (error as Error).message,
|
||||
...(error instanceof DeliveryGuardError && error.inputReadFailure ? { inputReadFailure: error.inputReadFailure } : {}),
|
||||
...(noInputCode ? { noInputCode, inputPhase: "not_reached" as const } : {}) };
|
||||
}
|
||||
}
|
||||
@@ -3350,7 +3356,7 @@ export class SessionTransport {
|
||||
}
|
||||
if (!submitResult.ok && !enterIssued && submitResult.code === CALLER_CANCELLED && cancel?.aborted) return callerCancelled(sessionName);
|
||||
if (!submitResult.ok) {
|
||||
return { ok: false, sessionName, inputPhase: enterIssued ? "attempted" : "not_reached", ...(!enterIssued ? { sent: false } : {}), reason: submitResult.code?.startsWith("expected_identity_") ? submitResult.code : "submit_failed", outcome: "failed", error: enterIssued
|
||||
return { ok: false, sessionName, ...(submitResult.inputReadFailure ? { inputReadFailure: submitResult.inputReadFailure } : {}), inputPhase: enterIssued ? "attempted" : "not_reached", ...(!enterIssued ? { sent: false } : {}), reason: submitResult.code?.startsWith("expected_identity_") ? submitResult.code : "submit_failed", outcome: "failed", error: enterIssued
|
||||
? `submitOnly: Enter reported a failure for '${sessionName}'; submission is unconfirmed: ${submitResult.message}`
|
||||
: `submitOnly: Enter was not sent to '${sessionName}': ${submitResult.message}` };
|
||||
}
|
||||
@@ -3700,7 +3706,7 @@ export class SessionTransport {
|
||||
heldInputBoxReading = { status, confidence, evidence, cursor };
|
||||
}
|
||||
throw new DeliveryGuardError(box?.status === "typed" ? "draft_input_busy" : "draft_input_unknown",
|
||||
`Managed input waits for an empty composer (it reads ${box?.status ?? "unknown"}). Your draft was left untouched; inspect the pane before retrying.`);
|
||||
`Managed input waits for an empty composer (it reads ${box?.status ?? "unknown"}). Your draft was left untouched; inspect the pane before retrying.`, inputReadDiagnostic("composer", box));
|
||||
}
|
||||
if (!clear) pastedOverInputBox = box?.status ?? "unknown";
|
||||
} : undefined;
|
||||
@@ -3735,6 +3741,7 @@ export class SessionTransport {
|
||||
ok: false,
|
||||
sessionName,
|
||||
reason: policyRefusal ? textResult.code : "send_failed",
|
||||
...(textResult.inputReadFailure ? { inputReadFailure: textResult.inputReadFailure } : {}),
|
||||
outcome: "failed",
|
||||
// The adapter's draft checks run before the paste (only file/buffer preparation precedes them), and its
|
||||
// after-paste observation cannot fail the write, so a draft_* receipt proves the target input was never reached.
|
||||
@@ -3803,6 +3810,7 @@ export class SessionTransport {
|
||||
ok: false,
|
||||
sessionName,
|
||||
reason: policyRefusal ? submitResult.code : "submit_failed",
|
||||
...(submitResult.inputReadFailure ? { inputReadFailure: submitResult.inputReadFailure } : {}),
|
||||
outcome: "failed",
|
||||
inputPhase: "written",
|
||||
error: policyRefusal
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
// per poll, filling the seat's held quota. Real SessionTransport, real draft-aware delivery and outbox on an
|
||||
// in-memory database; the tmux adapter records commands instead of running them.
|
||||
import Database from "better-sqlite3";
|
||||
import { Hono } from "hono";
|
||||
import { inputReadDiagnostic } from "../src/domain/input-read-diagnostic.js";
|
||||
import { compactionRoutes } from "../src/routes/compaction.js";
|
||||
import stringWidth from "string-width";
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -83,7 +86,11 @@ async function fixture(opts: { pane: string | null; mode?: "off" | "draft-aware"
|
||||
const effects = () => ({ compactPastes: writes.filter((w) => w.startsWith("paste:/compact")).length, prepPastes: writes.filter((w) => w.startsWith("paste:") && !w.startsWith("paste:/compact")).length, enters: writes.filter((w) => w === "enter").length });
|
||||
const compactRows = (state?: string) => (db.prepare("SELECT outbox_id AS id, delivery_state AS state FROM outbox_entries WHERE body LIKE '/compact%'").all() as Array<{ id: string; state: string }>)
|
||||
.filter((r) => !state || r.state === state);
|
||||
return { db, e, transport, tmux, writes, compactRows, rows, effects, stageMap, body: () => body,
|
||||
return { db, e, transport, tmux, writes, compactRows, rows, effects, stageMap, recover: () => {
|
||||
const next = new ClaudeCompactionEnforcer({ resolveClaudeCompactionPolicy: () => policy } as any, transport,
|
||||
{ openrigHome: home, now: () => clock, resolveOccupantGeneration: () => "g1" });
|
||||
next.recoverSeat(input); return next;
|
||||
}, body: () => body,
|
||||
onStage: (fn: () => void) => { onStage = fn; }, activity: (s: string) => { activity = s; }, draft: (text: string) => { body = text; } };
|
||||
}
|
||||
|
||||
@@ -361,3 +368,95 @@ it("the managed idle wait treats only a recognized draft according to the ordina
|
||||
expect(await f.e.triggerManualCompact(input, { operatorInitiated: true, skipMap: true })).toMatchObject({ triggered: true });
|
||||
expect(f.effects()).toMatchObject({ prepPastes: 1, compactPastes: 1, enters: 2 });
|
||||
});
|
||||
|
||||
|
||||
it.each(["preparation", "compact"] as const)("records the %s refusal through the existing transport without content or input", async stage => {
|
||||
const f = await fixture({ pane: "%1", mode: stage === "preparation" ? "draft-aware" : "off" });
|
||||
const send = f.transport.send.bind(f.transport);
|
||||
vi.spyOn(f.transport, "send").mockImplementation(async (s, text, opts) => {
|
||||
if (stage === "preparation") {
|
||||
vi.mocked(f.tmux.captureComposerSnapshot).mockResolvedValue({ ...frame("PRIVATE_INPUT https://private.invalid/gateway"), inMode: true });
|
||||
} else if (text.startsWith("/compact")) {
|
||||
vi.spyOn(f.transport as any, "captureInputBox").mockResolvedValue({ status: "unknown", confidence: "unknown", cursor: null,
|
||||
evidence: ["input box reads timed out"], input: { text: "PRIVATE_INPUT" }, hint: "https://private.invalid/gateway" });
|
||||
return send(s, text, { ...opts, preserveInput: true });
|
||||
}
|
||||
return send(s, text, opts);
|
||||
});
|
||||
await f.e.triggerManualCompact(input, { operatorInitiated: true, skipMap: true });
|
||||
const state = f.e.getPreparationState(seat)!;
|
||||
const expected = stage === "preparation"
|
||||
? { stage, check: "draft-aware", status: "unknown", confidence: null, reason: "pane_in_mode", timedOut: null,
|
||||
cursor: { present: true, valid: true, atEnd: false } }
|
||||
: { stage, check: "composer", status: "unknown", confidence: "unknown", reason: "read_timeout", timedOut: true,
|
||||
cursor: { present: false, valid: null, atEnd: null } };
|
||||
expect(state).toMatchObject({ inputReadFailure: expected, compact: "not_sent" });
|
||||
expect(f.effects()).toEqual({ compactPastes: 0, prepPastes: stage === "preparation" ? 0 : 1, enters: stage === "preparation" ? 0 : 1 });
|
||||
const record = readFileSync(join(dirname(state.mapPath), COMPACTION_RECORD_FILE), "utf8");
|
||||
expect(JSON.parse(record)).toMatchObject({ inputReadFailure: expected });
|
||||
const app = new Hono();
|
||||
app.use("*", async (c, next) => { c.set("compactionEnforcer" as never, f.e as never); c.set("db" as never, f.db as never); await next(); });
|
||||
app.route("/api/compaction", compactionRoutes());
|
||||
const response = await app.request(`/api/compaction/state?session=${seat}`);
|
||||
const body = await response.text();
|
||||
expect(response.status).toBe(200);
|
||||
expect(JSON.parse(body)).toMatchObject({ preparation: { inputReadFailure: expected } });
|
||||
expect(record + body).not.toMatch(/PRIVATE_INPUT|private\.invalid/);
|
||||
});
|
||||
|
||||
it("distinguishes an opted-in composer timeout from the separate draft-aware check", async () => {
|
||||
const f = await fixture({ pane: "%1", mode: "off" });
|
||||
const send = f.transport.send.bind(f.transport);
|
||||
vi.spyOn(f.transport as any, "captureInputBox").mockResolvedValue({ status: "unknown", confidence: "unknown", cursor: null,
|
||||
evidence: ["input box reads timed out"], input: { text: "PRIVATE_INPUT" }, suggestion: { text: "PRIVATE_SUGGESTION" }, hint: "https://private.invalid/gateway" });
|
||||
vi.spyOn(f.transport, "send").mockImplementation((s, text, opts) => send(s, text, { ...opts, preserveInput: true }));
|
||||
await f.e.triggerManualCompact(input, { operatorInitiated: true, skipMap: true });
|
||||
const state = f.e.getPreparationState(seat)!;
|
||||
expect(state).toMatchObject({ delivery: "not_sent", inputReadFailure: { stage: "preparation", check: "composer", reason: "read_timeout", timedOut: true,
|
||||
status: "unknown", confidence: "unknown", cursor: { present: false, valid: null, atEnd: null } } });
|
||||
const record = readFileSync(join(dirname(state.mapPath), COMPACTION_RECORD_FILE), "utf8");
|
||||
expect(record).not.toMatch(/PRIVATE_INPUT|PRIVATE_SUGGESTION|private\.invalid/);
|
||||
expect(f.writes).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
it("retains a held completed map's compact diagnostic across record recovery", async () => {
|
||||
const f = await fixture({ pane: "%1", mode: "off" });
|
||||
await f.e.maybeAutoCompact(input); f.stageMap();
|
||||
const send = f.transport.send.bind(f.transport);
|
||||
vi.spyOn(f.transport as any, "captureInputBox").mockResolvedValue({ status: "unknown", confidence: "unknown", cursor: null, evidence: ["cursor unavailable"] });
|
||||
vi.spyOn(f.transport, "send").mockImplementation((s, text, opts) => send(s, text, { ...opts, preserveInput: true }));
|
||||
expect(await f.e.maybeAutoCompact(input)).toMatchObject({ triggered: false, reason: "prepared_awaiting_window" });
|
||||
const state = f.e.getPreparationState(seat)!;
|
||||
expect(state).toMatchObject({ prepared: true, compact: "not_sent", inputReadFailure: { stage: "compact", check: "composer", reason: "cursor_unavailable", timedOut: null } });
|
||||
const recordPath = join(dirname(state.mapPath), COMPACTION_RECORD_FILE);
|
||||
const record = JSON.parse(readFileSync(recordPath, "utf8"));
|
||||
// A new enforcer in this process deliberately ignores this process's live record; model a predecessor daemon.
|
||||
writeFileSync(recordPath, JSON.stringify({ ...record, daemon: "previous-daemon" }));
|
||||
expect(f.recover().getPreparationState(seat)?.inputReadFailure).toEqual(state.inputReadFailure);
|
||||
expect(f.effects()).toEqual({ compactPastes: 0, prepPastes: 1, enters: 1 });
|
||||
});
|
||||
|
||||
it("records the same diagnostic on an owed restore step without advancing it", async () => {
|
||||
const f = await fixture({ pane: "%1", mode: "off" });
|
||||
await f.e.triggerManualCompact(input, { operatorInitiated: true, skipMap: true });
|
||||
await f.e.maybeAutoCompact(input);
|
||||
expect(f.e.getOwedRestore(seat)?.stage).toBe("restore_prompt");
|
||||
const before = f.effects(), send = f.transport.send.bind(f.transport);
|
||||
vi.spyOn(f.transport as any, "captureInputBox").mockResolvedValue({ status: "unknown", confidence: "unknown", cursor: null, evidence: ["cursor unavailable"] });
|
||||
vi.spyOn(f.transport, "send").mockImplementation((s, text, opts) => send(s, text, { ...opts, preserveInput: true }));
|
||||
await f.e.maybeAutoCompact(input);
|
||||
expect(f.e.getOwedRestore(seat)).toMatchObject({ stage: "restore_prompt", lastDelivery: { stage: "restore_prompt", reason: "draft_input_unknown",
|
||||
inputReadFailure: { check: "composer", reason: "cursor_unavailable", timedOut: null } } });
|
||||
expect(f.effects()).toEqual(before);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ state: "unknown", unknownReason: "invalid_cursor", cursor: { x: -1, y: 0, width: 80, height: 24 }, reason: "invalid_cursor", valid: false },
|
||||
{ state: "unknown", unknownReason: "ambiguous_composer", reason: "ambiguous_composer", valid: null },
|
||||
{ state: "unknown", unknownReason: "PRIVATE_INPUT https://private.invalid/gateway", reason: "capture_unavailable", valid: null },
|
||||
])("diagnostic vocabulary excludes content and reports existing cursor validity ($reason)", ({ reason, valid, ...reading }) => {
|
||||
const safe = inputReadDiagnostic("draft-aware", { ...reading, rows: ["PRIVATE_INPUT"], suggestionRows: ["PRIVATE_SUGGESTION"], frame: "https://private.invalid/gateway" } as any);
|
||||
expect(safe).toMatchObject({ reason, cursor: { valid } });
|
||||
expect(JSON.stringify(safe)).not.toMatch(/PRIVATE_INPUT|PRIVATE_SUGGESTION|private\.invalid|rows|frame/);
|
||||
});
|
||||
Reference in new issue
Block a user