Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <address> <code>` 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
Expand Down
62 changes: 62 additions & 0 deletions src/AgentOs.RemoteAgent/KeepAwake.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>Keeps the computer from idle-sleeping while work is in flight.</summary>
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));
}
}

/// <summary>True when the machine setting allows it (anything but "off").</summary>
public static bool Allowed(string? setting) => !string.Equals(setting?.Trim(), "off", StringComparison.OrdinalIgnoreCase);

/// <summary>Work in flight right now.</summary>
public int Active => Volatile.Read(ref _active);

/// <summary>Mark one piece of work as started; dispose the result when it ends.</summary>
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);
}
11 changes: 8 additions & 3 deletions src/AgentOs.RemoteAgent/PreviewHost.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);

/// <summary>Runs "Try the app" previews on this computer.</summary>
internal sealed class PreviewHost(string root, Func<string, string, string?, Task> report)
internal sealed class PreviewHost(string root, Func<string, string, string?, Task> report, KeepAwake? awake = null)
{
private const int TailLines = 30;
private static readonly TimeSpan GitTimeout = TimeSpan.FromMinutes(5);
Expand All @@ -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<string> Tail { get; } = new();
}
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -187,7 +191,7 @@ private async Task RunAsync(Running run, PreviewStart start)
if (_byId.TryRemove(run.Id, out _))
{
_idByRepo.TryRemove(new KeyValuePair<string, string>(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);
}
}
Expand Down Expand Up @@ -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; }
}
Expand Down
6 changes: 5 additions & 1 deletion src/AgentOs.RemoteAgent/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
//
Expand Down Expand Up @@ -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<RemoteExecRequest>("Execute", request =>
Expand All @@ -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.
Expand Down Expand Up @@ -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();

Expand Down
1 change: 1 addition & 0 deletions tests/AgentOs.Tests/AgentOs.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
<Compile Include="..\..\src\AgentOs.RemoteAgent\CliProgress.cs" Link="Linked\CliProgress.cs" />
<Compile Include="..\..\src\AgentOs.RemoteAgent\PreviewHost.cs" Link="Linked\PreviewHost.cs" />
<Compile Include="..\..\src\AgentOs.RemoteAgent\ProcessJob.cs" Link="Linked\ProcessJob.cs" />
<Compile Include="..\..\src\AgentOs.RemoteAgent\KeepAwake.cs" Link="Linked\KeepAwake.cs" />
</ItemGroup>

</Project>
34 changes: 34 additions & 0 deletions tests/AgentOs.Tests/Sessions/KeepAwakeTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading