mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-12 01:41:39 -10:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a9c204f2b | ||
|
|
f06edfa410 |
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,7 +3,6 @@ import { performance } from "node:perf_hooks";
|
||||
import { Worker } from "node:worker_threads";
|
||||
import type { MiddlewareHandler } from "hono";
|
||||
|
||||
export const SLOW_OPERATION_BARRIER_TIMEOUT_MS = 250;
|
||||
export const SLOW_OPERATION_THRESHOLD_MS = 250;
|
||||
export const SLOW_OPERATION_ROTATION_BYTES = 1024 * 1024;
|
||||
export const SLOW_OPERATION_ROTATION_COUNT = 3;
|
||||
@@ -89,7 +88,6 @@ interface SlowOpRecorderOptions {
|
||||
maxBytes?: number;
|
||||
rotationCount?: number;
|
||||
slowThresholdMs?: number;
|
||||
barrierTimeoutMs?: number;
|
||||
}
|
||||
|
||||
interface Span {
|
||||
@@ -141,13 +139,7 @@ const WORKER_SOURCE = String.raw`
|
||||
ok = false;
|
||||
error = caught instanceof Error ? caught.message : String(caught);
|
||||
}
|
||||
if (message.signal) {
|
||||
const state = new Int32Array(message.signal);
|
||||
Atomics.store(state, 0, ok ? 1 : 2);
|
||||
Atomics.notify(state, 0);
|
||||
} else if (message.id) {
|
||||
parentPort.postMessage({ id: message.id, ok, error });
|
||||
}
|
||||
if (message.id) parentPort.postMessage({ id: message.id, ok, error });
|
||||
});
|
||||
`;
|
||||
|
||||
@@ -157,7 +149,6 @@ export class SlowOpRecorder implements SlowOperationInstrumentation {
|
||||
private readonly maxBytes: number;
|
||||
private readonly rotationCount: number;
|
||||
private readonly slowThresholdMs: number;
|
||||
private readonly barrierTimeoutMs: number;
|
||||
private readonly pending = new Map<string, { resolve: () => void; reject: (error: unknown) => void }>();
|
||||
private degraded: SlowOperationSnapshot = { healthy: true };
|
||||
private degradedHandler?: (snapshot: Required<Pick<SlowOperationSnapshot, "reason" | "site">>) => void;
|
||||
@@ -181,7 +172,6 @@ export class SlowOpRecorder implements SlowOperationInstrumentation {
|
||||
this.maxBytes = options.maxBytes ?? SLOW_OPERATION_ROTATION_BYTES;
|
||||
this.rotationCount = options.rotationCount ?? SLOW_OPERATION_ROTATION_COUNT;
|
||||
this.slowThresholdMs = options.slowThresholdMs ?? SLOW_OPERATION_THRESHOLD_MS;
|
||||
this.barrierTimeoutMs = options.barrierTimeoutMs ?? SLOW_OPERATION_BARRIER_TIMEOUT_MS;
|
||||
this.worker = new Worker(WORKER_SOURCE, { eval: true, execArgv: [] });
|
||||
this.worker.unref();
|
||||
this.worker.on("message", (message: { id?: string; ok?: boolean }) => {
|
||||
@@ -220,27 +210,10 @@ export class SlowOpRecorder implements SlowOperationInstrumentation {
|
||||
|
||||
beginSyncSpan(site: string): Span {
|
||||
const span: Span = { spanId: randomUUID(), site, startedAt: performance.now() };
|
||||
const signal = new SharedArrayBuffer(Int32Array.BYTES_PER_ELEMENT);
|
||||
const state = new Int32Array(signal);
|
||||
try {
|
||||
this.worker.postMessage({
|
||||
type: "append",
|
||||
signal,
|
||||
logPath: this.logPath,
|
||||
maxBytes: this.maxBytes,
|
||||
rotationCount: this.rotationCount,
|
||||
record: { v: 1, ts: new Date().toISOString(), spanId: span.spanId, phase: "begin", site },
|
||||
});
|
||||
} catch {
|
||||
// Synchronous postMessage failure means the Worker is gone: degrade
|
||||
// through the terminal transition but still return the span so the
|
||||
// wrapped operation runs to completion (its value/error stays exact).
|
||||
this.handleTerminalFailure("recorder_post_failed", site);
|
||||
return span;
|
||||
}
|
||||
const wait = Atomics.wait(state, 0, 0, this.barrierTimeoutMs);
|
||||
if (wait === "timed-out") this.markDegraded("begin_barrier_timeout", site);
|
||||
else if (Atomics.load(state, 0) !== 1) this.markDegraded("begin_barrier_failed", site);
|
||||
// Queue before the operation without waiting on worker startup or fsync. The
|
||||
// worker can write even while the main thread is wedged; only flush/close
|
||||
// acknowledge durability. A crash before the worker appends can lose begin.
|
||||
this.appendAsync({ v: 1, ts: new Date().toISOString(), spanId: span.spanId, phase: "begin", site });
|
||||
return span;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { execFile } from "node:child_process";
|
||||
import type { TmuxAdapter } from "../adapters/tmux.js";
|
||||
import { runSyncSite } from "./sync-site-wrap.js";
|
||||
import { runAsyncSite } from "./sync-site-wrap.js";
|
||||
|
||||
export interface TmuxOptionDefaultsDeps {
|
||||
tmuxAdapter: TmuxAdapter;
|
||||
@@ -17,11 +17,11 @@ export interface TmuxOptionDefaultsDeps {
|
||||
*/
|
||||
platform?: NodeJS.Platform;
|
||||
/**
|
||||
* OPR.0.4.6.02 S1 — cheap `command -v <bin>` probe for the linux
|
||||
* copy-command fallback chain. Defaults to a real shell probe; injectable
|
||||
* OPR.0.4.6.02 S1 — asynchronous `command -v <bin>` probe for the linux
|
||||
* copy-command fallback chain. Defaults to a bounded asynchronous shell probe; injectable
|
||||
* for deterministic tests.
|
||||
*/
|
||||
hasCommand?: (bin: string) => boolean;
|
||||
hasCommand?: (bin: string) => boolean | Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -44,7 +44,7 @@ export class TmuxOptionDefaultsApplier {
|
||||
private tmuxAdapter: TmuxAdapter;
|
||||
private readTmuxOptionDefaults: () => { statusBar: boolean };
|
||||
private platform: NodeJS.Platform;
|
||||
private hasCommand: (bin: string) => boolean;
|
||||
private hasCommand: (bin: string) => boolean | Promise<boolean>;
|
||||
private serverDefaultsAsserted = false;
|
||||
|
||||
constructor(deps: TmuxOptionDefaultsDeps) {
|
||||
@@ -120,7 +120,14 @@ export class TmuxOptionDefaultsApplier {
|
||||
warnings.push(`tmux "set-clipboard" server option not set: ${clip.message}`);
|
||||
}
|
||||
|
||||
const copyCommand = resolveCopyCommand(this.platform, this.hasCommand);
|
||||
let copyCommand: string | null;
|
||||
try {
|
||||
copyCommand = await resolveCopyCommand(this.platform, this.hasCommand);
|
||||
} catch {
|
||||
this.serverDefaultsAsserted = false;
|
||||
warnings.push("Clipboard lookup interrupted (250 ms limit); copy-command left unchanged (OSC 52 if unset). Will retry on the next fresh session.");
|
||||
return;
|
||||
}
|
||||
if (copyCommand !== null) {
|
||||
const cc = await this.tmuxAdapter.setServerOption("copy-command", copyCommand);
|
||||
if (!cc.ok) {
|
||||
@@ -141,14 +148,14 @@ export class TmuxOptionDefaultsApplier {
|
||||
* Pure: platform + a `command -v` probe in, the command string (or null)
|
||||
* out. One table, no side effects — the applier decides whether to write.
|
||||
*/
|
||||
export function resolveCopyCommand(
|
||||
export async function resolveCopyCommand(
|
||||
platform: NodeJS.Platform,
|
||||
hasCommand: (bin: string) => boolean,
|
||||
): string | null {
|
||||
hasCommand: (bin: string) => boolean | Promise<boolean>,
|
||||
): Promise<string | null> {
|
||||
if (platform === "darwin") return "pbcopy";
|
||||
if (platform === "linux") {
|
||||
if (hasCommand("wl-copy")) return "wl-copy";
|
||||
if (hasCommand("xclip")) return "xclip -selection clipboard -i";
|
||||
if (await hasCommand("wl-copy")) return "wl-copy";
|
||||
if (await hasCommand("xclip")) return "xclip -selection clipboard -i";
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
@@ -156,16 +163,18 @@ export function resolveCopyCommand(
|
||||
|
||||
/**
|
||||
* OPR.0.4.6.02 S1 — default `command -v <bin>` probe (POSIX shell builtin).
|
||||
* Returns true when the binary resolves on PATH. Cheap + best-effort; any
|
||||
* failure (spawn error, non-shell env) reads as absent.
|
||||
* Returns true when the binary resolves on PATH. A killed probe rejects so
|
||||
* the applier can warn and retry; other failures read as absent.
|
||||
*/
|
||||
function defaultHasCommand(bin: string): boolean {
|
||||
async function defaultHasCommand(bin: string): Promise<boolean> {
|
||||
try {
|
||||
const r = runSyncSite("tmux_options.command_v", () =>
|
||||
spawnSync(`command -v ${bin}`, { shell: true, stdio: "ignore" })
|
||||
);
|
||||
return r.status === 0;
|
||||
} catch {
|
||||
return await runAsyncSite("tmux_options.command_v", () => new Promise<boolean>((resolve, reject) => {
|
||||
execFile("/bin/sh", ["-c", 'command -v "$1"', "--", bin], {
|
||||
timeout: 250, killSignal: "SIGKILL", maxBuffer: 1024,
|
||||
}, error => error?.killed ? reject(error) : resolve(!error));
|
||||
}));
|
||||
} catch (error) {
|
||||
if ((error as { killed?: boolean } | null)?.killed) throw error;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type { Worker } from "node:worker_threads";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { Hono } from "hono";
|
||||
import { createDaemon } from "../src/startup.js";
|
||||
import { createSlowOpRequestMiddleware } from "../src/domain/slow-op-recorder.js";
|
||||
@@ -67,12 +67,37 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("SlowOpRecorder locked instrumentation contract", () => {
|
||||
it("returns promptly while begin acknowledgements are delayed", async () => {
|
||||
const mod = await loadRecorderModule();
|
||||
if (!mod) throw new Error("recorder module missing");
|
||||
const logPath = path.join(tempDir(), "pending.jsonl");
|
||||
const recorder = new mod.SlowOpRecorder({ logPath });
|
||||
const worker: Worker = (recorder as any).worker;
|
||||
const post = worker.postMessage;
|
||||
const held: unknown[] = [];
|
||||
worker.postMessage = message => {
|
||||
if (message?.record?.phase === "begin") held.push(message);
|
||||
else post.call(worker, message);
|
||||
};
|
||||
try {
|
||||
const start = performance.now();
|
||||
const values = [1, 2, 3].map(value => recorder.runSync("test.delayed.begin", () => value));
|
||||
const elapsedMs = performance.now() - start;
|
||||
expect(values).toEqual([1, 2, 3]);
|
||||
expect(held).toHaveLength(3);
|
||||
expect(elapsedMs).toBeLessThan(100);
|
||||
} finally {
|
||||
worker.postMessage = post;
|
||||
for (const message of held) post.call(worker, message);
|
||||
await recorder.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("pins the absolute boundary, rotation, filename, and secret-safe record contract", async () => {
|
||||
const mod = await loadRecorderModule();
|
||||
expect(mod, "slow-op-recorder production module is missing").not.toBeNull();
|
||||
if (!mod) return;
|
||||
|
||||
expect(mod.SLOW_OPERATION_BARRIER_TIMEOUT_MS).toBe(250);
|
||||
expect(mod.SLOW_OPERATION_THRESHOLD_MS).toBe(250);
|
||||
expect(mod.SLOW_OPERATION_ROTATION_BYTES).toBe(1024 * 1024);
|
||||
expect(mod.SLOW_OPERATION_ROTATION_COUNT).toBe(3);
|
||||
@@ -85,9 +110,6 @@ describe("SlowOpRecorder locked instrumentation contract", () => {
|
||||
process.env.OPENRIG_TEST_SECRET = secret;
|
||||
try {
|
||||
const value = recorder.runSync("test.sync.boundary", () => {
|
||||
const records = fs.readFileSync(logPath, "utf8");
|
||||
expect(records).toContain('"phase":"begin"');
|
||||
expect(records).toContain('"site":"test.sync.boundary"');
|
||||
return 41;
|
||||
});
|
||||
expect(value).toBe(41);
|
||||
@@ -107,88 +129,34 @@ describe("SlowOpRecorder locked instrumentation contract", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves a durable open span after the begin barrier and exposes fail-visible degradation", async () => {
|
||||
it("flushes a queued open span and exposes acknowledged write failure in health", async () => {
|
||||
const mod = await loadRecorderModule();
|
||||
expect(mod, "slow-op-recorder production module is missing").not.toBeNull();
|
||||
if (!mod) return;
|
||||
|
||||
if (!mod) throw new Error("recorder module missing");
|
||||
const dir = tempDir();
|
||||
const logPath = path.join(dir, "slow-operations.jsonl");
|
||||
const logPath = path.join(dir, "open.jsonl");
|
||||
const recorder = new mod.SlowOpRecorder({ logPath });
|
||||
const span = recorder.beginSyncSpan("test.sync.wedge");
|
||||
expect(span).toBeTruthy();
|
||||
const records = await readRecords(logPath);
|
||||
expect(records).toHaveLength(1);
|
||||
expect(records[0]).toMatchObject({ phase: "begin", site: "test.sync.wedge" });
|
||||
expect(recorder.snapshot()).toMatchObject({ healthy: true });
|
||||
recorder.beginSyncSpan("test.sync.wedge");
|
||||
await recorder.flush();
|
||||
expect(await readRecords(logPath)).toMatchObject([{ phase: "begin", site: "test.sync.wedge" }]);
|
||||
await recorder.close();
|
||||
|
||||
const degraded = new mod.SlowOpRecorder({
|
||||
logPath: path.join(dir, "degraded.jsonl"),
|
||||
barrierTimeoutMs: 0,
|
||||
});
|
||||
const degraded = new mod.SlowOpRecorder({ logPath: dir });
|
||||
const oldNoKernel = process.env.OPENRIG_NO_KERNEL;
|
||||
process.env.OPENRIG_NO_KERNEL = "1";
|
||||
const daemon = await createTestDaemon({
|
||||
dbPath: ":memory:",
|
||||
tmuxExec: async () => "",
|
||||
cmuxExec: async () => "",
|
||||
slowOpRecorder: degraded,
|
||||
} as never);
|
||||
const daemon = await createTestDaemon({ dbPath: ":memory:", tmuxExec: async () => "", cmuxExec: async () => "", slowOpRecorder: degraded } as never);
|
||||
try {
|
||||
const worker: Worker = (degraded as any).worker;
|
||||
const postMessage = worker.postMessage;
|
||||
let intercepted = 0;
|
||||
let withheldSignal: SharedArrayBuffer | undefined;
|
||||
try {
|
||||
worker.postMessage = (message, transferList) => {
|
||||
if (message?.type === "append"
|
||||
&& message.record?.phase === "begin"
|
||||
&& message.record?.site === "test.sync.degraded"
|
||||
&& message.signal instanceof SharedArrayBuffer) {
|
||||
intercepted++;
|
||||
const { signal, ...withoutSignal } = message;
|
||||
withheldSignal = signal;
|
||||
// Append through the real worker, but withhold this acknowledgement.
|
||||
return postMessage.call(worker, withoutSignal, transferList);
|
||||
}
|
||||
return postMessage.call(worker, message, transferList);
|
||||
};
|
||||
expect(degraded.runSync("test.sync.degraded", () => "continued")).toBe("continued");
|
||||
} finally {
|
||||
worker.postMessage = postMessage;
|
||||
}
|
||||
expect(intercepted).toBe(1);
|
||||
expect(worker.postMessage).toBe(postMessage);
|
||||
expect(withheldSignal).toBeInstanceOf(SharedArrayBuffer);
|
||||
expect(new Int32Array(withheldSignal!)[0]).toBe(0);
|
||||
expect(degraded.snapshot()).toMatchObject({
|
||||
healthy: false,
|
||||
reason: "begin_barrier_timeout",
|
||||
site: "test.sync.degraded",
|
||||
});
|
||||
|
||||
const healthResponse = await daemon.app.request("/healthz");
|
||||
expect(await healthResponse.json()).toMatchObject({
|
||||
status: "ok",
|
||||
slowOperations: {
|
||||
healthy: false,
|
||||
reason: "begin_barrier_timeout",
|
||||
site: "test.sync.degraded",
|
||||
},
|
||||
});
|
||||
expect(degraded.runSync("test.sync.degraded", () => "continued")).toBe("continued");
|
||||
await expect(degraded.flush()).rejects.toBeInstanceOf(mod.SlowOpRecorderWriteError);
|
||||
expect(degraded.snapshot()).toMatchObject({ healthy: false, reason: "recorder_write_failed", site: "recorder.worker" });
|
||||
const response = await daemon.app.request("/healthz");
|
||||
expect(await response.json()).toMatchObject({ status: "ok", slowOperations: { healthy: false, reason: "recorder_write_failed" } });
|
||||
const observations = daemon.deps.streamStore!.list();
|
||||
expect(observations).toHaveLength(1);
|
||||
expect(observations[0]!.body).toContain("begin_barrier_timeout");
|
||||
expect(observations[0]!.body).toContain("test.sync.degraded");
|
||||
await degraded.flush();
|
||||
const degradedRecords = await readRecords(path.join(dir, "degraded.jsonl"));
|
||||
expect(degradedRecords.filter((record) => record.phase === "begin"
|
||||
&& record.site === "test.sync.degraded")).toHaveLength(1);
|
||||
expect(observations[0]!.body).toContain("recorder_write_failed");
|
||||
} finally {
|
||||
daemon.eventLoopMonitor.stop();
|
||||
daemon.db.close();
|
||||
await degraded.close();
|
||||
await degraded.close().catch(() => {});
|
||||
if (oldNoKernel === undefined) delete process.env.OPENRIG_NO_KERNEL;
|
||||
else process.env.OPENRIG_NO_KERNEL = oldNoKernel;
|
||||
}
|
||||
@@ -211,20 +179,17 @@ describe("SlowOpRecorder locked instrumentation contract", () => {
|
||||
});
|
||||
expect(value).toBe("continued");
|
||||
expect(ran).toBe(true);
|
||||
expect(recorder.snapshot()).toEqual({
|
||||
healthy: false,
|
||||
reason: "begin_barrier_failed",
|
||||
site: "test.sync.observer_failure",
|
||||
});
|
||||
await expect(recorder.flush()).rejects.toBeInstanceOf(mod.SlowOpRecorderWriteError);
|
||||
expect(recorder.snapshot()).toEqual({ healthy: false, reason: "recorder_write_failed", site: "recorder.worker" });
|
||||
} finally {
|
||||
// This recorder's log path is intentionally unwritable (to force the
|
||||
// begin-barrier failure above), so close() now correctly rejects on the
|
||||
// asynchronously acknowledged write failure above), so close() now correctly rejects on the
|
||||
// latched lost-write durability — tolerate it in teardown.
|
||||
await recorder.close().catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
it("durably records an open span before a main-thread sync wedge is killed", async () => {
|
||||
it("writes a queued begin while the main thread is wedged, before a later kill", async () => {
|
||||
const dir = tempDir();
|
||||
const logPath = path.join(dir, "crash.jsonl");
|
||||
fs.writeFileSync(logPath, "", { mode: 0o600 });
|
||||
@@ -278,6 +243,7 @@ describe("SlowOpRecorder locked instrumentation contract", () => {
|
||||
});
|
||||
});
|
||||
expect(callbackEvidence).toEqual({ isMainThread: true, threadId: 0 });
|
||||
await vi.waitFor(async () => expect(await readRecords(logPath)).toMatchObject([{ phase: "begin", site: "test.sync.crash" }]));
|
||||
child.kill("SIGKILL");
|
||||
await once(child, "exit");
|
||||
|
||||
@@ -351,7 +317,7 @@ describe("SlowOpRecorder locked instrumentation contract", () => {
|
||||
expect(JSON.stringify(requests)).not.toContain("must-not-appear");
|
||||
});
|
||||
|
||||
it("wraps all nine accepted sync calls without moving them off the main thread", () => {
|
||||
it("keeps the accepted slow-operation site labels", () => {
|
||||
const srcRoot = path.resolve(import.meta.dirname, "../src");
|
||||
for (const [relativePath, sites] of expectedSites) {
|
||||
const source = fs.readFileSync(path.join(srcRoot, relativePath), "utf8");
|
||||
@@ -496,7 +462,7 @@ describe("SlowOpRecorder locked instrumentation contract", () => {
|
||||
expect((await app.request("/definitely-missing")).status).toBe(404);
|
||||
});
|
||||
|
||||
it("drives the real tmux command-v spawnSync site through a hermetic daemon", async () => {
|
||||
it("drives the asynchronous tmux command-v site through a hermetic daemon", async () => {
|
||||
const mod = await loadRecorderModule();
|
||||
expect(mod, "slow-op-recorder production module is missing").not.toBeNull();
|
||||
if (!mod) return;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TmuxAdapter } from "../src/adapters/tmux.js";
|
||||
import { TmuxOptionDefaultsApplier } from "../src/domain/tmux-option-defaults.js";
|
||||
|
||||
const execute = vi.hoisted(() => vi.fn());
|
||||
vi.mock("node:child_process", () => ({ execFile: execute }));
|
||||
beforeEach(() => { execute.mockReset(); });
|
||||
|
||||
describe("clipboard discovery deadline", () => {
|
||||
it.each([null, "custom-copy"])("warns and retries a killed default probe, preserving %j", async initial => {
|
||||
let copyCommand = initial;
|
||||
const setServerOption = vi.fn(async (name: string, value: string) => {
|
||||
if (name === "copy-command") copyCommand = value;
|
||||
return { ok: true };
|
||||
});
|
||||
const adapter = { setSessionOption: async () => ({ ok: true }), setServerOption } as unknown as TmuxAdapter;
|
||||
execute.mockImplementationOnce((_file, _args, options, callback) => {
|
||||
expect(options).toMatchObject({ timeout: 250, killSignal: "SIGKILL" });
|
||||
callback(Object.assign(new Error("probe deadline"), { killed: true }));
|
||||
}).mockImplementationOnce((_file, _args, _options, callback) => callback(null));
|
||||
const applier = new TmuxOptionDefaultsApplier({ tmuxAdapter: adapter, platform: "linux" });
|
||||
const warnings = await applier.applyToFreshSession("first@test");
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]).toMatch(/clipboard.*250 ms/i);
|
||||
expect(warnings[0]).toMatch(/retry/i);
|
||||
expect(copyCommand).toBe(initial);
|
||||
expect(execute).toHaveBeenCalledTimes(1);
|
||||
expect(await applier.applyToFreshSession("next@test")).toEqual([]);
|
||||
expect(copyCommand).toBe("wl-copy");
|
||||
expect(execute).toHaveBeenCalledTimes(2);
|
||||
expect(await applier.applyToFreshSession("later@test")).toEqual([]);
|
||||
expect(execute).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("memoizes confirmed absence without a deadline warning", async () => {
|
||||
execute.mockImplementation((_file, _args, _options, callback) => callback(Object.assign(new Error("absent"), { code: 1 })));
|
||||
const setServerOption = vi.fn(async () => ({ ok: true }));
|
||||
const adapter = { setSessionOption: async () => ({ ok: true }), setServerOption } as unknown as TmuxAdapter;
|
||||
const applier = new TmuxOptionDefaultsApplier({ tmuxAdapter: adapter, platform: "linux" });
|
||||
expect(await applier.applyToFreshSession("first@test")).toEqual([]);
|
||||
expect(await applier.applyToFreshSession("next@test")).toEqual([]);
|
||||
expect(execute).toHaveBeenCalledTimes(2);
|
||||
expect(setServerOption).not.toHaveBeenCalledWith("copy-command", expect.anything());
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,24 @@ function mockAdapter(overrides?: {
|
||||
}
|
||||
|
||||
describe("TmuxOptionDefaultsApplier", () => {
|
||||
it("awaits an asynchronous clipboard lookup without holding unrelated work", async () => {
|
||||
const { adapter, setServerOption } = mockAdapter();
|
||||
let release!: (present: boolean) => void;
|
||||
const pending = new Promise<boolean>(resolve => { release = resolve; });
|
||||
const probe = vi.fn((bin: string) => bin === "wl-copy" ? pending : Promise.resolve(true));
|
||||
let finished = false;
|
||||
const applier = new TmuxOptionDefaultsApplier({ tmuxAdapter: adapter, platform: "linux", hasCommand: probe });
|
||||
const applied = applier.applyToFreshSession("async-clipboard@test").then(value => { finished = true; return value; });
|
||||
try {
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
expect(finished).toBe(false);
|
||||
expect(setServerOption).not.toHaveBeenCalledWith("copy-command", expect.anything());
|
||||
} finally { release(false); }
|
||||
expect(await applied).toEqual([]);
|
||||
expect(probe.mock.calls.map(([bin]) => bin)).toEqual(["wl-copy", "xclip"]);
|
||||
expect(setServerOption).toHaveBeenCalledWith("copy-command", "xclip -selection clipboard -i");
|
||||
});
|
||||
|
||||
it("sets mouse on + status off (default) on the given session via SESSION scope only", async () => {
|
||||
const { adapter, setSessionOption, setServerOption } = mockAdapter();
|
||||
// default reader (omitted) → statusBar false.
|
||||
@@ -109,20 +127,20 @@ describe("TmuxOptionDefaultsApplier", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCopyCommand (pure per-platform table)", () => {
|
||||
it("darwin → pbcopy", () => {
|
||||
expect(resolveCopyCommand("darwin", () => false)).toBe("pbcopy");
|
||||
describe("resolveCopyCommand (pure per-platform table)", async () => {
|
||||
it("darwin → pbcopy", async () => {
|
||||
expect(await resolveCopyCommand("darwin", () => false)).toBe("pbcopy");
|
||||
});
|
||||
it("linux → wl-copy when present", () => {
|
||||
expect(resolveCopyCommand("linux", (b) => b === "wl-copy")).toBe("wl-copy");
|
||||
it("linux → wl-copy when present", async () => {
|
||||
expect(await resolveCopyCommand("linux", (b) => b === "wl-copy")).toBe("wl-copy");
|
||||
});
|
||||
it("linux → xclip when wl-copy absent but xclip present", () => {
|
||||
expect(resolveCopyCommand("linux", (b) => b === "xclip")).toBe("xclip -selection clipboard -i");
|
||||
it("linux → xclip when wl-copy absent but xclip present", async () => {
|
||||
expect(await resolveCopyCommand("linux", (b) => b === "xclip")).toBe("xclip -selection clipboard -i");
|
||||
});
|
||||
it("linux → null when neither present (OSC 52 fallback)", () => {
|
||||
expect(resolveCopyCommand("linux", () => false)).toBeNull();
|
||||
it("linux → null when neither present (OSC 52 fallback)", async () => {
|
||||
expect(await resolveCopyCommand("linux", () => false)).toBeNull();
|
||||
});
|
||||
it("other platforms → null", () => {
|
||||
expect(resolveCopyCommand("win32", () => true)).toBeNull();
|
||||
it("other platforms → null", async () => {
|
||||
expect(await resolveCopyCommand("win32", () => true)).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user