From 75707874ea9fc046f50304299fe79c35d28f9453 Mon Sep 17 00:00:00 2001 From: Hoang Snowy Date: Thu, 22 Oct 2026 20:05:17 +0700 Subject: [PATCH] feat(runner): the computer doesn't idle-sleep while the AI is working on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On the lab the paired laptop idled to sleep at 08:41 with two tasks mid-run; Autopilot and both runs sat still until 20:00. While the runner has a CLI run or a preview in flight it now resets the system idle timer every 30 s (SetThreadExecutionState, non-continuous — never holds the display, never blocks a sleep the person asks for, and stops as soon as the work ends). Windows only; REMOTE_AGENT_KEEP_AWAKE=off opts out. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018WAdQ7Rm24xss5QDSvXtJB --- README.md | 3 +- src/AgentOs.RemoteAgent/KeepAwake.cs | 62 +++++++++++++++++++ src/AgentOs.RemoteAgent/PreviewHost.cs | 11 +++- src/AgentOs.RemoteAgent/Program.cs | 6 +- tests/AgentOs.Tests/AgentOs.Tests.csproj | 1 + .../AgentOs.Tests/Sessions/KeepAwakeTests.cs | 34 ++++++++++ 6 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 src/AgentOs.RemoteAgent/KeepAwake.cs create mode 100644 tests/AgentOs.Tests/Sessions/KeepAwakeTests.cs diff --git a/README.md b/README.md index c2aa81e..9714430 100644 --- a/README.md +++ b/README.md @@ -101,7 +101,8 @@ the next run uses it, no restart. Keys can also be seeded from the environment Connect this computer** shows one command with a one-time code — run it on that computer, in the helper's folder (`dotnet run --project src/AgentOs.RemoteAgent -- pair
` from a checkout). The page turns *Connected* by itself; later starts need no arguments. Connecting the same computer again (same -name) retires its old offline entry and token. Board tasks set to *On my computer* then +name) retires its old offline entry and token. While the AI works on it (a task, a test, a preview), the computer doesn't idle-sleep; +`REMOTE_AGENT_KEEP_AWAKE=off` turns that off. Board tasks set to *On my computer* then clone, edit, build, test and push in a private per-task folder (`src/AgentOs.RemoteAgent/ScopedAutonomy.cs`). **Try the app** (Board → a project → *Try the app*) runs the latest default branch on that same computer and opens it diff --git a/src/AgentOs.RemoteAgent/KeepAwake.cs b/src/AgentOs.RemoteAgent/KeepAwake.cs new file mode 100644 index 0000000..311fbc1 --- /dev/null +++ b/src/AgentOs.RemoteAgent/KeepAwake.cs @@ -0,0 +1,62 @@ +// "Use your own computer" means a laptop that idles to sleep: on the lab the machine slept from 08:41 to 20:00 with two +// tasks mid-run, and Autopilot sat still for eleven hours. While this runner has work in flight (a CLI run or a preview) +// it resets the system idle timer every 30 s, so the computer doesn't idle-sleep under the agent. It never prevents a +// sleep the person asks for (lid, Start › Sleep), never keeps the display on, and does nothing once the work is done. +// Windows only; REMOTE_AGENT_KEEP_AWAKE=off opts out. + +using System.Runtime.InteropServices; + +namespace AgentOs.RemoteAgent; + +/// Keeps the computer from idle-sleeping while work is in flight. +internal sealed partial class KeepAwake : IDisposable +{ + private const uint SystemRequired = 0x00000001; + + private readonly Timer? _timer; + private int _active; + + public KeepAwake(bool enabled) + { + if (enabled && OperatingSystem.IsWindows()) + { + _timer = new Timer(_ => Tick(), null, TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30)); + } + } + + /// True when the machine setting allows it (anything but "off"). + public static bool Allowed(string? setting) => !string.Equals(setting?.Trim(), "off", StringComparison.OrdinalIgnoreCase); + + /// Work in flight right now. + public int Active => Volatile.Read(ref _active); + + /// Mark one piece of work as started; dispose the result when it ends. + public IDisposable Hold() + { + Interlocked.Increment(ref _active); + Tick(); + return new Lease(this); + } + + private void Tick() + { + // A non-continuous call only resets the idle timer once — repeated while work is in flight, never after. + if (_timer is not null && Volatile.Read(ref _active) > 0) { _ = SetThreadExecutionState(SystemRequired); } + } + + public void Dispose() => _timer?.Dispose(); + + private sealed class Lease(KeepAwake owner) : IDisposable + { + private int _done; + + public void Dispose() + { + if (Interlocked.Exchange(ref _done, 1) == 0) { Interlocked.Decrement(ref owner._active); } + } + } + + [LibraryImport("kernel32.dll")] + [DefaultDllImportSearchPaths(DllImportSearchPath.System32)] + private static partial uint SetThreadExecutionState(uint flags); +} diff --git a/src/AgentOs.RemoteAgent/PreviewHost.cs b/src/AgentOs.RemoteAgent/PreviewHost.cs index ce87008..1f8d84f 100644 --- a/src/AgentOs.RemoteAgent/PreviewHost.cs +++ b/src/AgentOs.RemoteAgent/PreviewHost.cs @@ -22,7 +22,7 @@ namespace AgentOs.RemoteAgent; internal sealed record PreviewStart(string PreviewId, string Owner, string Repo, string Branch, string Command, string Url, int ReadyTimeoutSeconds); /// Runs "Try the app" previews on this computer. -internal sealed class PreviewHost(string root, Func report) +internal sealed class PreviewHost(string root, Func report, KeepAwake? awake = null) { private const int TailLines = 30; private static readonly TimeSpan GitTimeout = TimeSpan.FromMinutes(5); @@ -38,6 +38,7 @@ private sealed class Running(string id, string repoKey, string dir) public string Dir { get; } = dir; public Process? Process { get; set; } public ProcessJob Job { get; } = ProcessJob.Create(); + public IDisposable? Awake { get; set; } public CancellationTokenSource Stop { get; } = new(); public Queue Tail { get; } = new(); } @@ -66,7 +67,10 @@ public void Start(PreviewStart start) } var repoKey = $"{start.Owner}/{start.Repo}"; if (_idByRepo.TryGetValue(repoKey, out var previous)) { Stop(previous); } - var run = new Running(start.PreviewId, repoKey, Path.Join(Path.GetFullPath(root), $"{start.Owner}-{start.Repo}")); + var run = new Running(start.PreviewId, repoKey, Path.Join(Path.GetFullPath(root), $"{start.Owner}-{start.Repo}")) + { + Awake = awake?.Hold(), // a running preview keeps the computer from idle-sleeping (KeepAwake.cs) + }; _byId[run.Id] = run; _idByRepo[repoKey] = run.Id; _ = Task.Run(() => RunAsync(run, start)); @@ -187,7 +191,7 @@ private async Task RunAsync(Running run, PreviewStart start) if (_byId.TryRemove(run.Id, out _)) { _idByRepo.TryRemove(new KeyValuePair(run.RepoKey, run.Id)); - run.Job.Dispose(); // whatever it left running in the background goes with it + Kill(run); // whatever it left running in the background goes with it await report(run.Id, "Stopped", $"The app ended (exit {process.ExitCode}).\n{TailOf(run)}").ConfigureAwait(false); } } @@ -325,6 +329,7 @@ private static void WritePidFile(string dir, Process process) private static void Kill(Running run) { run.Job.Dispose(); // the app's whole tree, background children included + run.Awake?.Dispose(); try { File.Delete(Path.Join(run.Dir, "preview.pid")); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { _ = ex; } } diff --git a/src/AgentOs.RemoteAgent/Program.cs b/src/AgentOs.RemoteAgent/Program.cs index 6207078..8169ff8 100644 --- a/src/AgentOs.RemoteAgent/Program.cs +++ b/src/AgentOs.RemoteAgent/Program.cs @@ -34,6 +34,8 @@ // (default 2); their own lane, so they never queue behind a long work run // REMOTE_AGENT_DATA test data the agents may read: "NAME=C:\path;OTHER=/data/x" → env vars of each scoped run, // names (not paths) told to the agent, read-only to it (see TestData in ScopedAutonomy.cs) +// REMOTE_AGENT_KEEP_AWAKE "off" lets the computer idle-sleep mid-run (default: while a run or preview is in flight, +// the idle timer is reset so the machine doesn't sleep under the agent — see KeepAwake.cs) // REMOTE_AGENT_PREVIEWS "off" refuses "Try the app" previews on this machine (default: allowed — the member clicks // Try the app after seeing the project's command); REMOTE_AGENT_PREVIEWROOT moves their folder // @@ -113,6 +115,7 @@ // 15-minute coding runs they outlived the server's deadline — the ticket split fell back to the raw draft list. var maxAnswers = int.TryParse(Environment.GetEnvironmentVariable("REMOTE_AGENT_MAX_ANSWERS"), out var ma) && ma > 0 ? ma : 2; var answerSlots = new SemaphoreSlim(maxAnswers, maxAnswers); +using var keepAwake = new AgentOs.RemoteAgent.KeepAwake(AgentOs.RemoteAgent.KeepAwake.Allowed(Environment.GetEnvironmentVariable("REMOTE_AGENT_KEEP_AWAKE"))); // M3 — full-prompt LLM dispatch connection.On("Execute", request => @@ -123,6 +126,7 @@ _ = Task.Run(async () => { await lane.WaitAsync().ConfigureAwait(false); + using var awake = keepAwake.Hold(); try { // Stopped while it waited for a slot: never start it. @@ -170,7 +174,7 @@ var previewRoot = Environment.GetEnvironmentVariable("REMOTE_AGENT_PREVIEWROOT") ?? AgentOs.RemoteAgent.PreviewHost.DefaultRoot(); var previewsOff = string.Equals(Environment.GetEnvironmentVariable("REMOTE_AGENT_PREVIEWS"), "off", StringComparison.OrdinalIgnoreCase); AgentOs.RemoteAgent.PreviewHost.KillLeftovers(previewRoot); -var previews = new AgentOs.RemoteAgent.PreviewHost(previewRoot, ReportPreviewAsync); +var previews = new AgentOs.RemoteAgent.PreviewHost(previewRoot, ReportPreviewAsync, keepAwake); AppDomain.CurrentDomain.ProcessExit += (_, _) => previews.StopAll(); Console.CancelKeyPress += (_, _) => previews.StopAll(); diff --git a/tests/AgentOs.Tests/AgentOs.Tests.csproj b/tests/AgentOs.Tests/AgentOs.Tests.csproj index d478f07..010ee0e 100644 --- a/tests/AgentOs.Tests/AgentOs.Tests.csproj +++ b/tests/AgentOs.Tests/AgentOs.Tests.csproj @@ -56,6 +56,7 @@ + diff --git a/tests/AgentOs.Tests/Sessions/KeepAwakeTests.cs b/tests/AgentOs.Tests/Sessions/KeepAwakeTests.cs new file mode 100644 index 0000000..ac4b506 --- /dev/null +++ b/tests/AgentOs.Tests/Sessions/KeepAwakeTests.cs @@ -0,0 +1,34 @@ +// The runner keeps the computer from idle-sleeping only while work is in flight, and lets go when it ends. + +using AgentOs.RemoteAgent; +using Shouldly; +using Xunit; + +namespace AgentOs.Tests.Sessions; + +public class KeepAwakeTests +{ + [Theory] + [InlineData(null, true)] + [InlineData("", true)] + [InlineData("on", true)] + [InlineData("off", false)] + [InlineData(" OFF ", false)] + public void Allowed_OnlyOffOptsOut(string? setting, bool expected) => KeepAwake.Allowed(setting).ShouldBe(expected); + + [Fact] + public void Hold_CountsWorkInFlight_AndReleasesOnce() + { + using var awake = new KeepAwake(enabled: true); + var first = awake.Hold(); + var second = awake.Hold(); + awake.Active.ShouldBe(2); + + first.Dispose(); + first.Dispose(); // a double release must not under-count + awake.Active.ShouldBe(1); + + second.Dispose(); + awake.Active.ShouldBe(0); + } +}