[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
127
infrastructure/ai/shared/sessionExecutionQueue.ts
Normal file
127
infrastructure/ai/shared/sessionExecutionQueue.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Per-session execution queue for tool calls that target the same terminal
|
||||
* session.
|
||||
*
|
||||
* Background — issue #1101 problem 3:
|
||||
*
|
||||
* Vercel AI SDK dispatches every tool_use block emitted in one assistant
|
||||
* turn through `Promise.all(toolCalls.map(execute))`, so an LLM that asks
|
||||
* for three commands "at once" sends three simultaneous `bridge.aiExec()`
|
||||
* calls at the underlying PTY. The main-process session mutex
|
||||
* (`mcpServerBridge.reserveSessionExecution`) only lets one through and
|
||||
* rejects the rest with `{ ok: false, error: "Session already has another
|
||||
* command in progress..." }`. The LLM then sees two synthetic errors plus
|
||||
* one real result for a turn it expected to be all-or-nothing — and the
|
||||
* Anthropic API has occasionally rejected the resulting trace with a
|
||||
* `tool_use ids were found without tool_result blocks` 400.
|
||||
*
|
||||
* The cleanest fix is to never let those calls race in the first place:
|
||||
* serialize at the renderer-side tool execute boundary so the bridge sees
|
||||
* one command per session at a time. The bridge mutex stays as
|
||||
* defense-in-depth for non-LLM IPC paths (terminal_start, MCP, etc.).
|
||||
*
|
||||
* The queue exposes both a high-level `chainBySessionKey(key, task)` for
|
||||
* simple "run this when it's our turn" callers, and a lower-level
|
||||
* `reserveSessionSlot(key)` for callers that need to do non-blocking work
|
||||
* (e.g. await an approval prompt) *while* their queue slot is held — so
|
||||
* the queue order matches the LLM's emission order independent of when
|
||||
* each call's approval lands.
|
||||
*/
|
||||
|
||||
const queues = new Map<string, Promise<unknown>>();
|
||||
|
||||
/**
|
||||
* A reserved slot in a session's execution queue. The slot is added to
|
||||
* the queue tail synchronously when {@link reserveSessionSlot} is called,
|
||||
* so call order is fixed at reservation time — regardless of how long
|
||||
* each caller spends on pre-work (approval prompts, abort checks, etc.)
|
||||
* before they actually start.
|
||||
*
|
||||
* Lifecycle:
|
||||
* 1. `reserveSessionSlot(key)` — synchronously snaps a place in line.
|
||||
* 2. caller does whatever pre-work they want, in parallel with siblings.
|
||||
* 3. `await slot.ready` — blocks until the previous slot has released.
|
||||
* 4. caller does the serialized work.
|
||||
* 5. `slot.release()` — frees the next slot. Idempotent.
|
||||
*
|
||||
* The slot **must** be released exactly once (typically from a `finally`)
|
||||
* even if the caller decides to skip the serialized work — otherwise
|
||||
* subsequent slots queued behind it never start.
|
||||
*/
|
||||
export interface SessionExecutionSlot {
|
||||
/** Resolves when this slot is at the head of its queue. */
|
||||
readonly ready: Promise<void>;
|
||||
/** Releases this slot. Safe to call multiple times. */
|
||||
release(): void;
|
||||
}
|
||||
|
||||
export function reserveSessionSlot(key: string): SessionExecutionSlot {
|
||||
const prev = queues.get(key) ?? Promise.resolve();
|
||||
|
||||
let resolveDone!: () => void;
|
||||
const done = new Promise<void>((r) => {
|
||||
resolveDone = r;
|
||||
});
|
||||
|
||||
// The new tail of this key's queue: previous tail → our `done`.
|
||||
// Wrap in a non-rejecting chain so a thrown task never poisons later
|
||||
// callers waiting on this tail.
|
||||
const tail: Promise<unknown> = prev.then(() => done).catch(() => undefined);
|
||||
queues.set(key, tail);
|
||||
|
||||
// Best-effort cleanup once we're the last in line — keeps the map
|
||||
// from growing without bound across many short-lived sessions. A
|
||||
// later caller that arrived between `queues.set` and this finally
|
||||
// will already have replaced the tail; we only clear when we're
|
||||
// still it.
|
||||
void tail.finally(() => {
|
||||
if (queues.get(key) === tail) {
|
||||
queues.delete(key);
|
||||
}
|
||||
});
|
||||
|
||||
let released = false;
|
||||
return {
|
||||
ready: prev.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
),
|
||||
release(): void {
|
||||
if (released) return;
|
||||
released = true;
|
||||
resolveDone();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `task` after every previously-reserved slot with the same `key`
|
||||
* has released. Returns the task's resolved value (or rejects if the
|
||||
* task throws). A failure in one task does not poison the queue head
|
||||
* for subsequent callers — the chain only waits on settlement, not
|
||||
* success.
|
||||
*
|
||||
* For callers that need to interleave pre-work with the queue wait
|
||||
* (e.g. approval prompts that should run in parallel even though the
|
||||
* actual command must run serially), use {@link reserveSessionSlot}
|
||||
* directly.
|
||||
*/
|
||||
export async function chainBySessionKey<T>(key: string, task: () => Promise<T>): Promise<T> {
|
||||
const slot = reserveSessionSlot(key);
|
||||
try {
|
||||
await slot.ready;
|
||||
return await task();
|
||||
} finally {
|
||||
slot.release();
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only: inspect the live queue. */
|
||||
export function getSessionExecutionQueueSizeForTests(): number {
|
||||
return queues.size;
|
||||
}
|
||||
|
||||
/** Test-only: drop all queued work. */
|
||||
export function resetSessionExecutionQueueForTests(): void {
|
||||
queues.clear();
|
||||
}
|
||||
Reference in New Issue
Block a user