Files
BrowserSkill/packages/dsh-plugin-browserskill/src/queue.ts
T
BB-fat bc5d9610a3 fix(dsh-plugin-browserskill): serialize per-session commands (observation vs tool calls)
The daemon accepts only one unfinished command per session; the
thumbnail loop racing a model tool call produced 'session already has
an unfinished command' failures on both sides (found by the Phase 1-C
e2e: navigate/snapshot/click all errored while captures kept running).
Add a KeyedExecutor: every command for a session — tool calls and
observation captures alike — runs FIFO; queued tasks reject early on
abort, running tasks keep the runner's signal-driven kill.
2026-08-14 06:41:49 +00:00

63 lines
1.9 KiB
TypeScript

/**
* Per-key FIFO executor. The bsk daemon serializes commands per session (a
* second command while one is unfinished is rejected), so every plugin
* command — model-facing tool calls AND observation captures — funnels
* through one queue per session. A queued task rejects early if its abort
* signal fires before it starts; once running, cancellation is owned by the
* task's own signal handling (the runner kills the child).
*/
export class KeyedExecutor {
private readonly tails = new Map<string, Promise<void>>();
/** Run `fn` after every previously queued task for `key` settled. */
run<T>(key: string, fn: () => Promise<T>, signal?: AbortSignal): Promise<T> {
const previous = this.tails.get(key) ?? Promise.resolve();
const task = new Promise<T>((resolve, reject) => {
let settled = false;
const onAbort = () => {
if (!settled) {
settled = true;
reject(abortError());
}
};
signal?.addEventListener("abort", onAbort, { once: true });
void previous.then(() => {
if (settled) return;
if (signal?.aborted) {
settled = true;
reject(abortError());
return;
}
// Running now: cancellation flows through the task's own signal.
signal?.removeEventListener("abort", onAbort);
fn().then(
(value) => {
settled = true;
resolve(value);
},
(error: unknown) => {
settled = true;
reject(error);
},
);
});
});
// The stored tail never rejects, so one failure never strands the queue.
this.tails.set(
key,
task.then(
() => {},
() => {},
),
);
return task;
}
}
function abortError(): Error {
const error = new Error("tool call aborted");
error.name = "AbortError";
return error;
}