From 46a37643808e6bd83a049502adeb5e54e30c91ac Mon Sep 17 00:00:00 2001 From: skulitom Date: Fri, 9 Oct 2026 23:26:01 +0100 Subject: [PATCH 1/6] Fix: a desktop action cut short by its deadline answers with a bare "A task was canceled." DesktopLease stops a desktop action a second before its caller gives up. An action that doesn't size its work by timeoutMs, or one still waiting for the desktop behind an earlier action, was cancelled, and the pipe handler turned the exception into "TaskCanceledException: A task was canceled.". The reply arrived in time but didn't say the request ran out of time, or whether anything had run. HandleAsync now answers the deadline's own cancellation with errorCode timed_out, the time the request had, and started: false ("Nothing was done") for a request that never got the desktop, or started: true ("Part of it may have run") for one stopped partway. A stopping seat still cancels as before. A quick check ("agent deadline replies") covers both cases, the desktop staying usable afterwards, and the stopping seat. Reported in ANODE-BUGS.md as B-023. Co-Authored-By: Claude Opus 5.5 --- src/Anode/Cli/AgentChecks.cs | 56 +++++++++++++++++++++++++++ src/Anode/Cli/SelfTest.cs | 1 + src/Anode/Core/Agents/DesktopLease.cs | 26 ++++++++++++- 3 files changed, 81 insertions(+), 2 deletions(-) diff --git a/src/Anode/Cli/AgentChecks.cs b/src/Anode/Cli/AgentChecks.cs index 0850607..8c075a1 100644 --- a/src/Anode/Cli/AgentChecks.cs +++ b/src/Anode/Cli/AgentChecks.cs @@ -337,6 +337,62 @@ async Task Hold(JsonObject request, CancellationToken cancel) return "an action queued behind another of its agent's runs in the time its caller has left, and stops before it"; } + /// + /// A desktop action its deadline cuts short answers timed_out with the time it had and whether it started, + /// not a bare "A task was canceled.": one still queued did nothing. A stopping seat still cancels. + /// + public static async Task DeadlineReplies() + { + var entered = Signal(); var finish = Signal(); + var lease = new DesktopLease(); + async Task Dispatch(JsonObject request, CancellationToken cancel) + { + if (request.Str("text") == "first") + { + entered.TrySetResult(); + await finish.Task; + return JsonLine.Ok(); + } + await Task.Delay(Timeout.Infinite, cancel); // An action that doesn't size its work by its time. + return JsonLine.Ok(); + } + string token = (await lease.HandleAsync(Lease("A", "acquire"), Dispatch)).Obj("result")!.Str("leaseToken")!; + Task first = lease.HandleAsync(Input("A", token, "first"), Dispatch); + JsonObject queued; + try + { + await entered.Task.WaitAsync(TimeSpan.FromSeconds(2)); + var waiting = Input("A", token, "queued"); + waiting["timeoutMs"] = 800; + queued = await lease.HandleAsync(waiting, Dispatch).WaitAsync(TimeSpan.FromSeconds(5)); + } + finally { finish.TrySetResult(); } + Require((await first.WaitAsync(TimeSpan.FromSeconds(2))).Bool("ok") == true, "the action in front failed"); + Require(queued.Bool("ok") == false && queued.Str("errorCode") == "timed_out" && queued.Bool("started") == false + && queued.Str("error") is { } never && never.Contains("800 ms") && never.Contains("Nothing was done"), + "a request that ran out of time in the queue did not say so: " + queued.ToJsonString()); + + var held = Input("A", token, "held"); + held["timeoutMs"] = 1500; + var stopped = await lease.HandleAsync(held, Dispatch).WaitAsync(TimeSpan.FromSeconds(5)); + Require(stopped.Bool("ok") == false && stopped.Str("errorCode") == "timed_out" && stopped.Bool("started") == true + && stopped.Str("error") is { } partway && partway.Contains("1.5 s") && partway.Contains("may have run"), + "an action stopped partway did not say so: " + stopped.ToJsonString()); + Require((await lease.HandleAsync(Input("A", token, "first"), Dispatch).WaitAsync(TimeSpan.FromSeconds(2))).Bool("ok") == true, + "a timed-out action kept the desktop from the next one"); + + using var stopping = new CancellationTokenSource(); + Task during = lease.HandleAsync(Input("A", token, "held"), Dispatch, stopping.Token); + stopping.Cancel(); + try + { + var reply = await during.WaitAsync(TimeSpan.FromSeconds(2)); + throw new InvalidOperationException("a stopping seat's cancellation was answered as a timeout: " + reply.ToJsonString()); + } + catch (OperationCanceledException) { } + return "an action cut short by its deadline answers timed_out with its time, and says nothing was done when it never started"; + } + public static async Task Line() { long now = 0; diff --git a/src/Anode/Cli/SelfTest.cs b/src/Anode/Cli/SelfTest.cs index 05435d4..6935b6e 100644 --- a/src/Anode/Cli/SelfTest.cs +++ b/src/Anode/Cli/SelfTest.cs @@ -144,6 +144,7 @@ public static int Run(string[] args) Check(results, "MCP lease client process", () => McpChecks.LeaseClientProcess().GetAwaiter().GetResult()); Check(results, "agent queue and disconnect", () => AgentChecks.QueueAndDisconnect().GetAwaiter().GetResult()); Check(results, "agent queue time", () => AgentChecks.QueueTime().GetAwaiter().GetResult()); + Check(results, "agent deadline replies", () => AgentChecks.DeadlineReplies().GetAwaiter().GetResult()); Check(results, "agent line", () => AgentChecks.Line().GetAwaiter().GetResult()); Check(results, "agent names", () => AgentChecks.Names().GetAwaiter().GetResult()); Check(results, "MCP ownership", () => AgentChecks.McpOwnership().GetAwaiter().GetResult()); diff --git a/src/Anode/Core/Agents/DesktopLease.cs b/src/Anode/Core/Agents/DesktopLease.cs index 963764f..d6608c1 100644 --- a/src/Anode/Core/Agents/DesktopLease.cs +++ b/src/Anode/Core/Agents/DesktopLease.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Globalization; using System.Text.Json.Nodes; using Anode.Core.Bridge; @@ -204,6 +205,22 @@ private JsonObject Manage(JsonObject request) /// internal static int StopAfterMs(int timeoutMs) => Math.Max(1, timeoutMs - Math.Min(1000, timeoutMs / 8)); + /// + /// The failure for a desktop action its deadline cut short. It names the time the request had and says whether + /// the action had started: one still waiting for the desktop did nothing, one stopped partway may have done some. + /// + internal static JsonObject TimedOut(int timeoutMs, bool started) + { + string time = timeoutMs < 1000 ? $"{timeoutMs} ms" : (timeoutMs / 1000.0).ToString("0.#", CultureInfo.InvariantCulture) + " s"; + var failure = JsonLine.Fail(started + ? $"The desktop action ran out of its {time} and was stopped. Part of it may have run: observe the desktop " + + "before acting again, and don't repeat it unseen." + : $"The request ran out of its {time} while it waited behind an earlier desktop action, so it never started. Nothing was done."); + failure["errorCode"] = "timed_out"; + failure["started"] = started; + return failure; + } + public async Task HandleAsync(JsonObject request, Func> dispatch, CancellationToken cancel = default) { @@ -216,9 +233,13 @@ public async Task HandleAsync(JsonObject request, if (!AgentAccess.RequiresLease(op)) return await dispatch(request, cancel).ConfigureAwait(false); lock (_state) { if (Check(request) is { } error) return error; } + int timeoutMs = request.Int("timeoutMs") ?? 60000; using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancel); - deadline.CancelAfter(StopAfterMs(request.Int("timeoutMs") ?? 60000)); - await _interaction.WaitAsync(deadline.Token).ConfigureAwait(false); + deadline.CancelAfter(StopAfterMs(timeoutMs)); + // Only the deadline's own cancellation is a timeout; a stopping seat still cancels as before. + bool OutOfTime() => deadline.IsCancellationRequested && !cancel.IsCancellationRequested; + try { await _interaction.WaitAsync(deadline.Token).ConfigureAwait(false); } + catch (OperationCanceledException) when (OutOfTime()) { return TimedOut(timeoutMs, started: false); } bool admitted = false; try { @@ -239,6 +260,7 @@ public async Task HandleAsync(JsonObject request, lock (_state) { if (_line.Count > 0) response["waitingAgents"] = _line.Count; } return response; } + catch (OperationCanceledException) when (OutOfTime()) { return TimedOut(timeoutMs, admitted); } finally { try { if (admitted) lock (_state) { _active = false; Expire(); } } From 557d2a9e1a550f64be79096276f13ba3c97e2f62 Mon Sep 17 00:00:00 2001 From: skulitom Date: Fri, 9 Oct 2026 23:29:46 +0100 Subject: [PATCH 2/6] Docs: the timed_out reply in PROTOCOL, TROUBLESHOOTING and the changelog (B-023) Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +++++ docs/PROTOCOL.md | 4 ++++ docs/TROUBLESHOOTING.md | 9 ++++++--- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index efc74e6..9aa764a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- **A desktop action that runs out of time says so.** An action stopped at its deadline, such as + `seat_wait` queued behind a long `seat_type`, answered with a bare "TaskCanceledException: A task + was canceled.", which said neither that time ran out nor whether anything ran. It now fails with + `errorCode: "timed_out"` and the time the request had, and `started: false` and "Nothing was + done" when it was still waiting for the desktop, or `started: true` when it was stopped partway. - **`set_value` checks that the value took.** On a Chrome drop-down list (``), `seat_element` `set_value` reported `performed` while the list kept its old choice, so an agent could submit a form with the wrong option. Anode now reads the control back: if it still shows its old value 1.5 @@ -35,6 +30,11 @@ reconnected. The daemon now forwards the time a request has left, the seat takes its own waits off as well, and a desktop operation is stopped a second before its time runs out, so one queued behind another answers, or fails, in time. +- **A desktop action that runs out of time says so.** An action stopped at its deadline, such as + `seat_wait` queued behind a long `seat_type`, answered with a bare "TaskCanceledException: A task + was canceled.", which said neither that time ran out nor whether anything ran. It now fails with + `errorCode: "timed_out"` and the time the request had, and `started: false` and "Nothing was + done" when it was still waiting for the desktop, or `started: true` when it was stopped partway. - **Typing keeps pace with the program.** `seat_type` and `anode type` sent characters as fast as Windows took them, so a busy program, such as Chrome editing a long field, could drop and reorder them while the call still reported success. Characters now go out 15 ms apart by default, timed From 2793ea0dfff0ba56af8ef981912fa6ed49714945 Mon Sep 17 00:00:00 2001 From: skulitom Date: Fri, 9 Oct 2026 23:32:46 +0100 Subject: [PATCH 4/6] Changelog: an example of the timeout that happens in practice Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d772022..589a2c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,11 +30,12 @@ reconnected. The daemon now forwards the time a request has left, the seat takes its own waits off as well, and a desktop operation is stopped a second before its time runs out, so one queued behind another answers, or fails, in time. -- **A desktop action that runs out of time says so.** An action stopped at its deadline, such as - `seat_wait` queued behind a long `seat_type`, answered with a bare "TaskCanceledException: A task - was canceled.", which said neither that time ran out nor whether anything ran. It now fails with - `errorCode: "timed_out"` and the time the request had, and `started: false` and "Nothing was - done" when it was still waiting for the desktop, or `started: true` when it was stopped partway. +- **A desktop action that runs out of time says so.** An action stopped at its deadline, such as a + `seat_wait` left with less time than its wait after queueing behind a long display change, answered + with a bare "TaskCanceledException: A task was canceled.", which said neither that time ran out nor + whether anything ran. It now fails with `errorCode: "timed_out"` and the time the request had, + and `started: false` and "Nothing was done" when it was still waiting for the desktop, or + `started: true` when it was stopped partway. - **Typing keeps pace with the program.** `seat_type` and `anode type` sent characters as fast as Windows took them, so a busy program, such as Chrome editing a long field, could drop and reorder them while the call still reported success. Characters now go out 15 ms apart by default, timed From 8704adcf52713465a96e8671f64f08109353779d Mon Sep 17 00:00:00 2001 From: skulitom Date: Fri, 9 Oct 2026 23:41:20 +0100 Subject: [PATCH 5/6] Review fixes: word a timeout by what the action does; one signal for unsent requests From the second review of this PR: - A read-only action stopped partway (seat_wait, observe, screenshot) now says nothing changed and it is safe to repeat, from the tools' read-only hints; a cut-short exec.start points at seat_job action=list; only the rest says "Part of it may have run". - A request whose time runs out before it is sent at all (in JsonPipeClient's queue, or the daemon's forwarding with almost no time left), the usual "never ran" case through the daemon, also answers errorCode timed_out with started: false. - The seat host logs each timeout again, through the lease's record line. - The check counts dispatches, so "Nothing was done" is proven, and covers the read-only and exec.start wording and the log lines; the transport checks require the unsent requests' errorCode and started. - PROTOCOL: "once started" for operations that bound their own work. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 ++- docs/PROTOCOL.md | 8 +++++--- docs/TROUBLESHOOTING.md | 3 ++- src/Anode/Cli/AgentChecks.cs | 18 ++++++++++++++++-- src/Anode/Cli/TransportChecks.cs | 6 ++++-- src/Anode/Core/Agents/DesktopLease.cs | 24 +++++++++++++++--------- src/Anode/Core/Bridge/Pipes.cs | 17 ++++++++++++++--- src/Anode/Mcp/Tools.cs | 3 +++ 8 files changed, 61 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 589a2c6..a4ccb66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,7 +35,8 @@ with a bare "TaskCanceledException: A task was canceled.", which said neither that time ran out nor whether anything ran. It now fails with `errorCode: "timed_out"` and the time the request had, and `started: false` and "Nothing was done" when it was still waiting for the desktop, or - `started: true` when it was stopped partway. + `started: true` when it was stopped partway; a read-only action says that nothing changed. A request + whose time ran out before it was even sent also answers `timed_out` with `started: false`. - **Typing keeps pace with the program.** `seat_type` and `anode type` sent characters as fast as Windows took them, so a busy program, such as Chrome editing a long field, could drop and reorder them while the call still reported success. Characters now go out 15 ms apart by default, timed diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 1fd363a..4c4ff4c 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -27,9 +27,11 @@ operations on its one connection to the seat host (waits under 100 ms aren't cou host takes its own waits off as well. A desktop operation is stopped a second (or an eighth of a shorter time) before that time runs out, so even one cut short answers before its caller gives up. It fails with `errorCode: "timed_out"`, and `started` says how far it got: `false` when it was still -waiting behind an earlier desktop operation and did nothing, `true` when it was stopped partway and -may have done some of its work. Operations that bound their own work by the time, such as -`input.text`, report their own failure instead. +waiting for the desktop behind an earlier operation and did nothing, `true` when it was stopped +partway and may have done some of its work (one that only reads, such as `desktop.wait`, changed +nothing even then). A request whose time runs out before it is sent at all, in a client's queue or +the daemon's, fails the same way with `started: false`. Once started, operations that bound their own +work by the time, such as `input.text`, report their own failure instead. Limits derived from the time, such as how much one `input.text` may type, shrink with it. Desktop operations additionally require an `agentId` and live `leaseToken`, acquired through diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 630bef2..a20592d 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -43,7 +43,8 @@ with an ordinary terminal. Only `anode setup` needs administrator approval. The timed-out command may have executed. Check the seat before repeating it. A desktop action that the seat stopped at its deadline says how far it got: `timed_out` with `started: false` did nothing, -since it was still waiting behind an earlier action; with `started: true` it was stopped partway. +since it was still waiting behind an earlier action; with `started: true` it was stopped partway (one +that only reads, such as `seat_wait`, changed nothing). Run `anode start` to reconnect the seat host if the daemon reports it is not ready; Anode can recover that connection without waiting for the viewer to reconnect. `anode kill` remains available to stop the seat. diff --git a/src/Anode/Cli/AgentChecks.cs b/src/Anode/Cli/AgentChecks.cs index 8c075a1..d0e1a93 100644 --- a/src/Anode/Cli/AgentChecks.cs +++ b/src/Anode/Cli/AgentChecks.cs @@ -344,9 +344,12 @@ async Task Hold(JsonObject request, CancellationToken cancel) public static async Task DeadlineReplies() { var entered = Signal(); var finish = Signal(); - var lease = new DesktopLease(); + var record = new System.Collections.Concurrent.ConcurrentQueue(); + var lease = new DesktopLease(record: record.Enqueue); + int dispatched = 0; async Task Dispatch(JsonObject request, CancellationToken cancel) { + Interlocked.Increment(ref dispatched); if (request.Str("text") == "first") { entered.TrySetResult(); @@ -368,6 +371,7 @@ async Task Dispatch(JsonObject request, CancellationToken cancel) } finally { finish.TrySetResult(); } Require((await first.WaitAsync(TimeSpan.FromSeconds(2))).Bool("ok") == true, "the action in front failed"); + Require(dispatched == 1, $"a request that never got the desktop was dispatched ({dispatched} dispatches)"); Require(queued.Bool("ok") == false && queued.Str("errorCode") == "timed_out" && queued.Bool("started") == false && queued.Str("error") is { } never && never.Contains("800 ms") && never.Contains("Nothing was done"), "a request that ran out of time in the queue did not say so: " + queued.ToJsonString()); @@ -378,6 +382,16 @@ async Task Dispatch(JsonObject request, CancellationToken cancel) Require(stopped.Bool("ok") == false && stopped.Str("errorCode") == "timed_out" && stopped.Bool("started") == true && stopped.Str("error") is { } partway && partway.Contains("1.5 s") && partway.Contains("may have run"), "an action stopped partway did not say so: " + stopped.ToJsonString()); + var look = new JsonObject { ["op"] = "desktop.wait", ["agentId"] = "A", ["leaseToken"] = token, ["windowId"] = "w_1", ["textContains"] = "never", ["timeoutMs"] = 1000 }; + var read = await lease.HandleAsync(look, Dispatch).WaitAsync(TimeSpan.FromSeconds(5)); + Require(read.Str("errorCode") == "timed_out" && read.Bool("started") == true && read.Str("error") is { } safe + && safe.Contains("nothing changed") && !safe.Contains("may have run"), "a read-only action stopped partway was said to have acted: " + read.ToJsonString()); + var start = new JsonObject { ["op"] = "exec.start", ["agentId"] = "A", ["leaseToken"] = token, ["path"] = "unused-test-command", ["timeoutMs"] = 1000 }; + var started = await lease.HandleAsync(start, Dispatch).WaitAsync(TimeSpan.FromSeconds(5)); + Require(started.Str("errorCode") == "timed_out" && started.Bool("started") == true && started.Str("error") is { } job + && job.Contains("seat_job action=list"), "a command start cut short didn't point at the job list: " + started.ToJsonString()); + Require(record.Any(line => line == "'input.text' ran out of its 800 ms before it got the desktop") + && record.Any(line => line == "'input.text' ran out of its 1.5 s and was stopped"), "a timeout left no log line: " + string.Join(" | ", record)); Require((await lease.HandleAsync(Input("A", token, "first"), Dispatch).WaitAsync(TimeSpan.FromSeconds(2))).Bool("ok") == true, "a timed-out action kept the desktop from the next one"); @@ -390,7 +404,7 @@ async Task Dispatch(JsonObject request, CancellationToken cancel) throw new InvalidOperationException("a stopping seat's cancellation was answered as a timeout: " + reply.ToJsonString()); } catch (OperationCanceledException) { } - return "an action cut short by its deadline answers timed_out with its time, and says nothing was done when it never started"; + return "an action cut short by its deadline answers timed_out with its time and how far it got: nothing done, nothing changed, a job to find, or input that may have run"; } public static async Task Line() diff --git a/src/Anode/Cli/TransportChecks.cs b/src/Anode/Cli/TransportChecks.cs index d138e22..27bb5a4 100644 --- a/src/Anode/Cli/TransportChecks.cs +++ b/src/Anode/Cli/TransportChecks.cs @@ -44,7 +44,8 @@ public static async Task QueueDeadline() { await entered.Task.WaitAsync(TimeSpan.FromSeconds(3)); var queued = await client.RequestAsync("queued", timeoutMs: 100).WaitAsync(TimeSpan.FromSeconds(2)); - Require(queued.Bool("ok") == false && queued.Str("error")!.Contains("timed out"), "queued request did not time out"); + Require(queued.Bool("ok") == false && queued.Str("error")!.Contains("timed out") + && queued.Str("errorCode") == "timed_out" && queued.Bool("started") == false, "queued request did not time out as never sent: " + queued.ToJsonString()); Require(!first.IsCompleted && client.IsConnected, "queue timeout interrupted the active request"); } finally { release.TrySetResult(); } @@ -109,7 +110,8 @@ public static async Task ForwardTimeLeft() finally { release.TrySetResult(); } var unsent = await starved.WaitAsync(TimeSpan.FromSeconds(3)); Require((await first.WaitAsync(TimeSpan.FromSeconds(3))).Bool("ok") == true && unsent.Bool("ok") == false - && !given.ContainsKey("starved") && seat.IsConnected, "a request with almost no time left was sent, or cost the connection: " + unsent.ToJsonString()); + && !given.ContainsKey("starved") && seat.IsConnected && unsent.Str("errorCode") == "timed_out" && unsent.Bool("started") == false, + "a request with almost no time left was sent, cost the connection, or didn't say it never started: " + unsent.ToJsonString()); return "the daemon forwards each request with its caller's time left, so time queued behind another isn't counted twice"; } diff --git a/src/Anode/Core/Agents/DesktopLease.cs b/src/Anode/Core/Agents/DesktopLease.cs index d6608c1..3a5303c 100644 --- a/src/Anode/Core/Agents/DesktopLease.cs +++ b/src/Anode/Core/Agents/DesktopLease.cs @@ -37,7 +37,7 @@ private sealed class Waiter(string agent, string? name, long seen) private long _clientStarted; private bool _active; - /// Receives a line each time the desktop changes hands, for the log. + /// Receives a line for the log each time the desktop changes hands or an action runs out of time. public DesktopLease(Action? invalidateReferences = null, Func? cancelJobs = null, Func? milliseconds = null, Action? record = null, Func? clientAlive = null) { @@ -207,15 +207,21 @@ private JsonObject Manage(JsonObject request) /// /// The failure for a desktop action its deadline cut short. It names the time the request had and says whether - /// the action had started: one still waiting for the desktop did nothing, one stopped partway may have done some. + /// the action had started: one still waiting for the desktop did nothing, one stopped partway may have done some, + /// unless it only reads. /// - internal static JsonObject TimedOut(int timeoutMs, bool started) + private JsonObject TimedOut(string op, int timeoutMs, bool started) { string time = timeoutMs < 1000 ? $"{timeoutMs} ms" : (timeoutMs / 1000.0).ToString("0.#", CultureInfo.InvariantCulture) + " s"; - var failure = JsonLine.Fail(started - ? $"The desktop action ran out of its {time} and was stopped. Part of it may have run: observe the desktop " - + "before acting again, and don't repeat it unseen." - : $"The request ran out of its {time} while it waited behind an earlier desktop action, so it never started. Nothing was done."); + string message = !started + ? $"The request ran out of its {time} while it waited behind an earlier desktop action, so it never started. Nothing was done." + : $"The desktop action ran out of its {time} and was stopped. " + (Mcp.Tools.ReadsOnly(op) + ? "It only reads the desktop, so nothing changed, and it is safe to repeat." + : op == "exec.start" + ? "The command may have started: look for it with seat_job action=list before starting it again." + : "Part of it may have run: observe the desktop before acting again, and don't repeat it unseen."); + _record($"'{op}' ran out of its {time}" + (started ? " and was stopped" : " before it got the desktop")); + var failure = JsonLine.Fail(message); failure["errorCode"] = "timed_out"; failure["started"] = started; return failure; @@ -239,7 +245,7 @@ public async Task HandleAsync(JsonObject request, // Only the deadline's own cancellation is a timeout; a stopping seat still cancels as before. bool OutOfTime() => deadline.IsCancellationRequested && !cancel.IsCancellationRequested; try { await _interaction.WaitAsync(deadline.Token).ConfigureAwait(false); } - catch (OperationCanceledException) when (OutOfTime()) { return TimedOut(timeoutMs, started: false); } + catch (OperationCanceledException) when (OutOfTime()) { return TimedOut(op, timeoutMs, started: false); } bool admitted = false; try { @@ -260,7 +266,7 @@ public async Task HandleAsync(JsonObject request, lock (_state) { if (_line.Count > 0) response["waitingAgents"] = _line.Count; } return response; } - catch (OperationCanceledException) when (OutOfTime()) { return TimedOut(timeoutMs, admitted); } + catch (OperationCanceledException) when (OutOfTime()) { return TimedOut(op, timeoutMs, admitted); } finally { try { if (admitted) lock (_state) { _active = false; Expire(); } } diff --git a/src/Anode/Core/Bridge/Pipes.cs b/src/Anode/Core/Bridge/Pipes.cs index f40e7e7..d9ea7c8 100644 --- a/src/Anode/Core/Bridge/Pipes.cs +++ b/src/Anode/Core/Bridge/Pipes.cs @@ -296,8 +296,7 @@ public async Task RequestAsync(string op, JsonObject? args = null, i { long left = timeoutMs - (Environment.TickCount64 - started); // With almost no time left, any reply would come too late, and a late reply costs the connection. - if (deadline.IsCancellationRequested || left < Math.Min(100, timeoutMs / 8)) - return JsonLine.Fail($"'{op}' timed out after {timeoutMs} ms while it waited; nothing was sent"); + if (deadline.IsCancellationRequested || left < Math.Min(100, timeoutMs / 8)) return NotSent(op, timeoutMs); request["timeoutMs"] = TimeLeft(timeoutMs, timeoutMs - left); } sent = true; @@ -315,7 +314,7 @@ public async Task RequestAsync(string op, JsonObject? args = null, i // the command: it may already have changed something in the seat. if (sent) Close(); cancel.ThrowIfCancellationRequested(); - return JsonLine.Fail($"'{op}' timed out after {timeoutMs} ms"); + return sent ? JsonLine.Fail($"'{op}' timed out after {timeoutMs} ms") : NotSent(op, timeoutMs); } catch (IOException ex) { @@ -338,6 +337,18 @@ public async Task RequestAsync(string op, JsonObject? args = null, i /// ordinary cost of passing a request on and aren't counted, so limits derived from the time, such as how much /// one call may type, don't depend on clock ticks. /// + /// + /// A request whose time ran out before it went out. It carries the seat host's timed_out failure for an + /// action that never got the desktop, so a caller can tell that nothing ran. + /// + private static JsonObject NotSent(string op, int timeoutMs) + { + var failure = JsonLine.Fail($"'{op}' timed out after {timeoutMs} ms while it waited; nothing was sent"); + failure["errorCode"] = "timed_out"; + failure["started"] = false; + return failure; + } + internal static int TimeLeft(int timeoutMs, long waitedMs) => waitedMs < Math.Min(100, timeoutMs / 16) ? timeoutMs : (int)Math.Max(1, timeoutMs - waitedMs); diff --git a/src/Anode/Mcp/Tools.cs b/src/Anode/Mcp/Tools.cs index 2098805..24602a7 100644 --- a/src/Anode/Mcp/Tools.cs +++ b/src/Anode/Mcp/Tools.cs @@ -446,6 +446,9 @@ public static JsonArray Definitions() /// internal static bool ActsOnScreen(string op) => op.StartsWith("input.", StringComparison.Ordinal) || op is "gamepad.set" or "gamepad.tap"; + /// Whether an operation only reads, as its tool's read-only hint says: stopping it partway changed nothing. + internal static bool ReadsOnly(string op) => All.Any(tool => tool.Op == op && tool.Effect == Effect.ReadOnly); + public static bool TryResolve(string name, out string op, out bool startsDaemon) { foreach (var tool in All) From 429254fe00b5b200bc091b164637b919f86d237e Mon Sep 17 00:00:00 2001 From: skulitom Date: Fri, 9 Oct 2026 23:46:52 +0100 Subject: [PATCH 6/6] Review fixes: a sent request that times out never claims it didn't start; NotSent's doc in its place Co-Authored-By: Claude Opus 5.5 --- src/Anode/Cli/TransportChecks.cs | 2 ++ src/Anode/Core/Bridge/Pipes.cs | 10 +++++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/Anode/Cli/TransportChecks.cs b/src/Anode/Cli/TransportChecks.cs index 27bb5a4..012a2f0 100644 --- a/src/Anode/Cli/TransportChecks.cs +++ b/src/Anode/Cli/TransportChecks.cs @@ -128,6 +128,8 @@ public static async Task LateReply() ?? throw new InvalidOperationException("could not connect to the test pipe"); var timedOut = await client.RequestAsync("slow", timeoutMs: 100); Require(timedOut.Bool("ok") == false && !client.IsConnected, "timed-out connection stayed reusable"); + // It was sent, so it may have run: it must not claim, as an unsent request does, that it never started. + Require(timedOut["started"] is null, "a request timed out after it was sent claimed it never started: " + timedOut.ToJsonString()); var next = await client.RequestAsync("next", timeoutMs: 4000); Require(next.Bool("ok") == false, "a late reply was accepted as the next result"); using var fresh = await JsonPipeClient.TryConnectAsync(name, 4000) diff --git a/src/Anode/Core/Bridge/Pipes.cs b/src/Anode/Core/Bridge/Pipes.cs index d9ea7c8..6575f68 100644 --- a/src/Anode/Core/Bridge/Pipes.cs +++ b/src/Anode/Core/Bridge/Pipes.cs @@ -332,11 +332,6 @@ public async Task RequestAsync(string op, JsonObject? args = null, i } } - /// - /// What is left of a request's time after a wait. Waits under 100 ms (a sixteenth of a shorter time) are the - /// ordinary cost of passing a request on and aren't counted, so limits derived from the time, such as how much - /// one call may type, don't depend on clock ticks. - /// /// /// A request whose time ran out before it went out. It carries the seat host's timed_out failure for an /// action that never got the desktop, so a caller can tell that nothing ran. @@ -349,6 +344,11 @@ private static JsonObject NotSent(string op, int timeoutMs) return failure; } + /// + /// What is left of a request's time after a wait. Waits under 100 ms (a sixteenth of a shorter time) are the + /// ordinary cost of passing a request on and aren't counted, so limits derived from the time, such as how much + /// one call may type, don't depend on clock ticks. + /// internal static int TimeLeft(int timeoutMs, long waitedMs) => waitedMs < Math.Min(100, timeoutMs / 16) ? timeoutMs : (int)Math.Max(1, timeoutMs - waitedMs);