mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 10:59:32 +01:00
fix(agent): hold input during a guarded submit instead of refusing it
The input lease is held across the awaited tmux Enter, so WebSocket frames keep arriving while it is held. A tagged frame (seq N) was refused and its seq rolled back, but the browser's WebSocket path does not wait for ACKs: seq N+1 landed after the lease and moved the watermark past N, and the redelivered N was then dropped as a duplicate. The keystroke was lost. Input that arrives during the lease is now held and written in arrival order when the lease is released, whatever the submit's outcome. A WebSocket frame is applied and ACKed only once written; managed writes (writeViaMux) and named keys (send-key) wait for their turn the same way, and a held write that is still being sent holds everything behind it. A keystroke held before the guard runs still moves the input revision, so it still invalidates the Enter. The hold is capped per lease (256 inputs, 1 MiB): past it the WebSocket is closed with 1013 and nothing later from it is applied, so the browser resends every unACKed frame in order, and send-key answers 409 CONFLICT instead of logging a tmux failure as INTERNAL_ERROR. POST .../input keeps its 409 for the lease plus the held input's drain; its browser drainer is serialized. Docs: correct the api-reference paragraph that said these keystrokes are redelivered, and describe the capture fallback and the per-scope cursor cap from the previous two commits in api-reference, architecture-invariants and the changeset.
This commit is contained in:
1 parent
ecaddb40ae
commit
2aad93914e
9 files changed
+727
-100
No files matched your search
+144
-17
@@ -465,6 +465,17 @@ export interface ClaudeMessage {
|
||||
duration_ms?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A write that a guarded submit's lease could not hold, because the per-lease hold
|
||||
* queue (`Session.MAX_GUARDED_HELD_INPUTS`) was full. Nothing was written; retry it.
|
||||
*/
|
||||
export class GuardedInputBusyError extends Error {
|
||||
constructor() {
|
||||
super('A guarded submit is in progress on this session; nothing was written. Retry the input.');
|
||||
this.name = 'GuardedInputBusyError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a programmatic write leaves the keep-alive turn running: the nudge itself, or a bare
|
||||
* Enter (which submits nothing new). Anything else carrying an Enter starts a turn of its own.
|
||||
@@ -4218,7 +4229,7 @@ export class Session extends EventEmitter {
|
||||
prompt: string,
|
||||
options?: { model?: string; onProgress?: (info: { tokens?: number; status?: string }) => void }
|
||||
): Promise<{ result: string; cost: number }> {
|
||||
if (this._guardedSubmitInFlight) {
|
||||
if (this.guardedInputHeld) {
|
||||
this._guardedInputRevision++;
|
||||
throw new Error('Guarded submission in progress');
|
||||
}
|
||||
@@ -4738,6 +4749,19 @@ export class Session extends EventEmitter {
|
||||
private _guardedInputRevision = 0;
|
||||
private _guardedPendingWrites = 0;
|
||||
private _guardedSubmitInFlight = false;
|
||||
/**
|
||||
* Input that arrived while a guarded submit held the lease, in arrival order. Each
|
||||
* entry runs when the lease is released; an entry that returns a promise (a mux
|
||||
* write, a named key) is waited for before the next runs, so nothing overtakes it.
|
||||
*/
|
||||
private _guardedHeld: Array<{ run: () => void | Promise<unknown>; bytes: number }> = [];
|
||||
private _guardedHeldBytes = 0;
|
||||
/** True while the drain waits for a held asynchronous write to finish. */
|
||||
private _guardedHeldDraining = false;
|
||||
/** Held entries per lease. A lease lasts milliseconds; past this the caller backs off. */
|
||||
static readonly MAX_GUARDED_HELD_INPUTS = 256;
|
||||
/** Held bytes per lease (16 browser frames at the 64 KiB frame cap). */
|
||||
static readonly MAX_GUARDED_HELD_BYTES = 16 * 64 * 1024;
|
||||
/** Ephemeral identity, never persisted or based on a potentially reused pid alone. */
|
||||
get inspectionGeneration(): string {
|
||||
return `${this._inspectionInstance}:${this._guardedPaneRevision}:${this.owner ?? ''}`;
|
||||
@@ -4750,13 +4774,89 @@ export class Session extends EventEmitter {
|
||||
return this._guardedPendingWrites;
|
||||
}
|
||||
/**
|
||||
* True while a guarded submit holds the input lease. Every write path refuses for
|
||||
* that span (a few milliseconds), so a route that can report a refusal checks this
|
||||
* first instead of answering as if the bytes went out.
|
||||
* True while a guarded submit holds the input lease (a few milliseconds). Input that
|
||||
* arrives in that span is held and written after it (see {@link guardedInputHeld}).
|
||||
*/
|
||||
get guardedSubmitInFlight(): boolean {
|
||||
return this._guardedSubmitInFlight;
|
||||
}
|
||||
/**
|
||||
* True while input is being held behind a guarded submit: the lease itself, or held
|
||||
* input not yet written after it. A write that is not routed through
|
||||
* {@link holdForGuardedLease} must refuse in this window, or it overtakes held input.
|
||||
*/
|
||||
get guardedInputHeld(): boolean {
|
||||
return this._guardedSubmitInFlight || this._guardedHeld.length > 0 || this._guardedHeldDraining;
|
||||
}
|
||||
/**
|
||||
* Hold input behind a guarded submit's lease instead of refusing it.
|
||||
*
|
||||
* `free`: nothing is held, so the caller writes inline as it always did. `held`: `run`
|
||||
* is queued and runs, in arrival order with every other held input, once the lease is
|
||||
* released (whatever the submit's outcome). `full`: the per-lease cap is reached and
|
||||
* nothing was queued; the caller must stop delivering from that source until it can
|
||||
* resend in order. Refusing instead lost input: the browser's WebSocket path does not
|
||||
* wait for ACKs, so a refused seq N was overtaken by N+1 and its redelivery was then
|
||||
* rejected as a duplicate. `data`, when given, is observed as input on arrival, so a
|
||||
* keystroke held during the lease still moves the input revision at once.
|
||||
*/
|
||||
holdForGuardedLease(run: () => void | Promise<unknown>, bytes = 0, data?: string): 'free' | 'held' | 'full' {
|
||||
if (!this.guardedInputHeld) return 'free';
|
||||
if (data !== undefined) this._observeGuardedKeyboardInput(data);
|
||||
if (
|
||||
this._guardedHeld.length >= Session.MAX_GUARDED_HELD_INPUTS ||
|
||||
this._guardedHeldBytes + bytes > Session.MAX_GUARDED_HELD_BYTES
|
||||
)
|
||||
return 'full';
|
||||
this._guardedHeld.push({ run, bytes });
|
||||
this._guardedHeldBytes += bytes;
|
||||
return 'held';
|
||||
}
|
||||
/**
|
||||
* Run an asynchronous write now, or in its turn behind a guarded submit's lease. The
|
||||
* turn lasts until the write settles, so later held input waits for it. Resolves
|
||||
* `whenFull` without running anything when the hold queue is full.
|
||||
*/
|
||||
private _inGuardedTurn<T>(write: () => Promise<T>, whenFull: () => T): Promise<T> {
|
||||
if (!this.guardedInputHeld) return write();
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const held = this.holdForGuardedLease(() => {
|
||||
const running = write();
|
||||
running.then(resolve, reject);
|
||||
return running;
|
||||
});
|
||||
if (held === 'full') {
|
||||
try {
|
||||
resolve(whenFull());
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
/** Run held input in arrival order until it is all written or a new lease is taken. */
|
||||
private _drainGuardedHeld(): void {
|
||||
while (this._guardedHeld.length > 0 && !this._guardedSubmitInFlight && !this._guardedHeldDraining) {
|
||||
const entry = this._guardedHeld.shift()!;
|
||||
this._guardedHeldBytes -= entry.bytes;
|
||||
let running: void | Promise<unknown>;
|
||||
try {
|
||||
running = entry.run();
|
||||
} catch (err) {
|
||||
console.error(`[Session ${this.id}] held input failed after a guarded submit:`, getErrorMessage(err));
|
||||
continue;
|
||||
}
|
||||
if (running && typeof running.then === 'function') {
|
||||
this._guardedHeldDraining = true;
|
||||
void running
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
this._guardedHeldDraining = false;
|
||||
this._drainGuardedHeld();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
/** Expose only the path selection needed for attributed Codex evidence, never credentials. */
|
||||
getCodexHomeOverride(): string | undefined {
|
||||
return this._envOverrides?.CODEX_HOME || undefined;
|
||||
@@ -4773,7 +4873,7 @@ export class Session extends EventEmitter {
|
||||
this._closing ||
|
||||
this.paneLifecycleInFlight ||
|
||||
this._guardedPendingWrites ||
|
||||
(!allowLease && this._guardedSubmitInFlight) ||
|
||||
(!allowLease && this.guardedInputHeld) ||
|
||||
this.remote ||
|
||||
this.docker ||
|
||||
this.paneExit ||
|
||||
@@ -4808,7 +4908,7 @@ export class Session extends EventEmitter {
|
||||
/** Stage through ordinary delivery, after cancelling any older Enter retry timer. */
|
||||
async stageGuardedInput(message: string): Promise<boolean> {
|
||||
if (/[\r\n]/.test(message)) return false;
|
||||
if (this._guardedPendingWrites || this._guardedSubmitInFlight) {
|
||||
if (this._guardedPendingWrites || this.guardedInputHeld) {
|
||||
this._guardedInputRevision++;
|
||||
return false;
|
||||
}
|
||||
@@ -4839,18 +4939,29 @@ export class Session extends EventEmitter {
|
||||
return sent && dispatched;
|
||||
} finally {
|
||||
this._guardedSubmitInFlight = false;
|
||||
this._drainGuardedHeld();
|
||||
}
|
||||
}
|
||||
/** Lease/pending boundary for named keys whose bytes bypass the ordinary write methods. */
|
||||
/**
|
||||
* Lease/pending boundary for named keys whose bytes bypass the ordinary write methods.
|
||||
* During a guarded submit the operation waits for the lease like any other write;
|
||||
* only a full hold queue refuses it, with {@link GuardedInputBusyError}.
|
||||
*/
|
||||
async performAuxiliaryInput(operation: () => Promise<void>): Promise<void> {
|
||||
this._guardedInputRevision++;
|
||||
if (this._guardedSubmitInFlight) throw new Error('Guarded submission in progress');
|
||||
this._guardedPendingWrites++;
|
||||
try {
|
||||
await operation();
|
||||
} finally {
|
||||
this._guardedPendingWrites--;
|
||||
}
|
||||
return this._inGuardedTurn(
|
||||
async () => {
|
||||
this._guardedPendingWrites++;
|
||||
try {
|
||||
await operation();
|
||||
} finally {
|
||||
this._guardedPendingWrites--;
|
||||
}
|
||||
},
|
||||
() => {
|
||||
throw new GuardedInputBusyError();
|
||||
}
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Sends input directly to the PTY process.
|
||||
@@ -5040,7 +5151,17 @@ export class Session extends EventEmitter {
|
||||
*/
|
||||
async writeViaMux(data: string, options: SessionWriteOptions = {}): Promise<boolean> {
|
||||
this._observeGuardedKeyboardInput(data);
|
||||
if (this._guardedSubmitInFlight) return false;
|
||||
// A guarded submit's lease lasts for its Enter only: wait for it, in arrival order,
|
||||
// rather than refusing, so an approval answer or a programmatic prompt is not
|
||||
// dropped. Without a lease the write starts synchronously, so the pending count
|
||||
// rises before any await and no lease can be taken while it is in flight.
|
||||
return this._inGuardedTurn(
|
||||
() => this._writeViaMuxNow(data, options),
|
||||
() => false
|
||||
);
|
||||
}
|
||||
|
||||
private async _writeViaMuxNow(data: string, options: SessionWriteOptions): Promise<boolean> {
|
||||
this._guardedPendingWrites++;
|
||||
try {
|
||||
const submittedPrompt = this._trackSubmit(data, options);
|
||||
@@ -5090,7 +5211,13 @@ export class Session extends EventEmitter {
|
||||
? null
|
||||
: this._mux.capturePaneText?.(this._muxSession.muxName),
|
||||
sendEnter: () => {
|
||||
if (this._guardedSubmitInFlight) this._submitVerifier?.cancel();
|
||||
// A retry Enter is never held behind a guarded submit: once that submit's
|
||||
// Enter went out, a re-press would submit whatever is on the composer next.
|
||||
if (this.guardedInputHeld) {
|
||||
this._guardedInputRevision++;
|
||||
this._submitVerifier?.cancel();
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return this.writeViaMux('\r');
|
||||
},
|
||||
// ⚠ NO fallback glyph here, unlike the screen-reading probe elsewhere in this file.
|
||||
@@ -5261,7 +5388,7 @@ export class Session extends EventEmitter {
|
||||
|
||||
// Legacy method for sending input - wraps runPrompt
|
||||
async sendInput(input: string): Promise<void> {
|
||||
if (this._guardedSubmitInFlight) {
|
||||
if (this.guardedInputHeld) {
|
||||
this._guardedInputRevision++;
|
||||
throw new Error('Guarded submission in progress');
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user