From e7eec86f28a7d009241f3e882556eafa70d69090 Mon Sep 17 00:00:00 2001 From: Canitas Date: Sun, 18 Oct 2026 02:31:40 -0700 Subject: [PATCH] fix: end a run stopped while the operator is asked permitted() awaited the operator's answer with no way to stop, and abort() awaits the run, so a run stopped while a question went unanswered never ended. The loop now races the answer against the run's stop: a stop ends the wait and records the call as refused because the run stopped. Found by Sol reading loop.ts and by measuring stops at each point of a run. --- packages/core/src/loop.ts | 29 ++++++++++++++++++++----- packages/core/tests/permissions.test.ts | 22 +++++++++++++++++++ 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/packages/core/src/loop.ts b/packages/core/src/loop.ts index 81329a5b1..849bde764 100644 --- a/packages/core/src/loop.ts +++ b/packages/core/src/loop.ts @@ -188,6 +188,24 @@ export const modelContext = (history: readonly HistoryMessage[]): Message[] => h * Runs one tool call: an unknown or inactive tool, invalid arguments or an * exception become a failed call the model sees. */ +/** The operator's answer as a decision and its reason; none when the run was stopped before they answered. */ +function operatorDecision(answer: "once" | "session" | "deny" | undefined): { decision: "allow" | "deny"; reason: string } { + if (answer === undefined) return { decision: "deny", reason: "The run was stopped before the operator answered." }; + if (answer === "deny") return { decision: "deny", reason: "The operator refused it." }; + return { decision: "allow", reason: answer === "session" ? "The operator allowed it for this session." : "The operator allowed it once." }; +} + +/** What `promise` gives, or nothing once `signal` stops first; the promise itself is left to settle on its own. */ +function untilStopped(promise: Promise, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve(undefined); + return new Promise((resolve, reject) => { + const stop = (): void => { resolve(undefined); }; + signal.addEventListener("abort", stop, { once: true }); + promise.then((value) => { signal.removeEventListener("abort", stop); resolve(value); }, + (error: unknown) => { signal.removeEventListener("abort", stop); reject(error); }); + }); +} + async function execute(tools: readonly EngineTool[], call: ToolCall, signal: AbortSignal): Promise { const tool = tools.find((candidate) => candidate.name === call.name); if (tool === undefined) return { text: `Tool "${call.name}" not found.`, isError: true }; @@ -454,7 +472,7 @@ export function createLoop(options: LoopOptions): LoopSession { * (`judge`), and the operator answers what they leave to them, once or for * the session. Every decision is recorded before the call runs, or instead. */ - async function permitted(run: number, call: ToolCall, offered: readonly EngineTool[]): Promise { + async function permitted(run: number, call: ToolCall, offered: readonly EngineTool[], signal: AbortSignal): Promise { const permissions = options.permissions; const access = offered.find((tool) => tool.name === call.name)?.access ?? "run"; const key = `${call.name}\n${JSON.stringify(call.arguments)}`; @@ -466,11 +484,10 @@ export function createLoop(options: LoopOptions): LoopSession { by = "session"; reason = "The operator allowed it for this session."; } else if (verdict.decision === "ask") { - const answer = await permissions?.ask?.(call, verdict.reason) ?? "deny"; - decision = answer === "deny" ? "deny" : "allow"; + // A stop while the operator is asked ends the wait: the run does not wait for an answer that no longer matters. + const answer = await untilStopped(permissions?.ask?.(call, verdict.reason) ?? Promise.resolve("deny" as const), signal); + ({ decision, reason } = operatorDecision(answer)); by = "operator"; - reason = answer === "deny" ? "The operator refused it." : answer === "session" ? "The operator allowed it for this session." - : "The operator allowed it once."; if (answer === "session") allowedForSession.add(key); } writer.append({ type: "permission", run, callId: call.id, decision, by, reason }); @@ -527,7 +544,7 @@ export function createLoop(options: LoopOptions): LoopSession { let output: ToolOutput; if (message.stopReason === "length") output = truncatedCall(call); else if (signal.aborted) output = stoppedCall; - else if (!await permitted(run, call, offered)) output = refusedCall(call); + else if (!await permitted(run, call, offered, signal)) output = signal.aborted ? stoppedCall : refusedCall(call); else output = await hooked(run, call, signal, offered); const result = toolResult(call, output); writer.append({ type: "tool_result", run, message: result }); diff --git a/packages/core/tests/permissions.test.ts b/packages/core/tests/permissions.test.ts index 9b3ca4989..5f9827b32 100644 --- a/packages/core/tests/permissions.test.ts +++ b/packages/core/tests/permissions.test.ts @@ -251,6 +251,28 @@ describe("the boundary every call passes", () => { expect(entries(created.file).some(({ type }) => type === "call_intent")).toBe(false); }); + test("stopped while the operator has not answered, the run ends without waiting for the answer, and the call does not run", async () => { + const faux = fauxProvider(); + faux.setResponses([write("a.txt"), "done"].map(scriptedReply)); + const models = createModels(); + models.setProvider(faux.provider); + const cwd = folder("tesota-core-permissions-cwd-"); + let asked: () => void = () => undefined; + const asking = new Promise((resolve) => { asked = resolve; }); + // The question is never answered, as when no one is there to see it. + const created = createSession({ cwd, folder: folder("tesota-core-sessions-"), models, model: faux.getModel(), verifier: accepts, + permissions: { mode: () => "ask", rules: [], ask: () => { asked(); return new Promise(() => undefined); } } }); + open.push(created); + const run = created.prompt("go"); + await asking; + await created.abort(); + await run; + expect(created.outcome()).toBe("aborted"); + expect(existsSync(join(cwd, "a.txt"))).toBe(false); + expect(entries(created.file)).toContainEqual(expect.objectContaining({ type: "permission", decision: "deny", + reason: "The run was stopped before the operator answered." })); + }); + test("with no one to ask, a call that would ask is refused; read only refuses writes; the floor holds in full access", async () => { const alone = session([write("a.txt"), "done"], "ask"); await alone.created.prompt("go");