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);
+ }
+}