feat(headless): add deterministic multi-session scheduler

This commit is contained in:
Erik 2026-07-27 07:51:49 +02:00
parent b299e3738e
commit 7e8acb74dd
6 changed files with 694 additions and 54 deletions

View file

@ -1,4 +1,3 @@
using System.Diagnostics;
using AcDream.Headless.Configuration;
using AcDream.Headless.Credentials;
using AcDream.Headless.Diagnostics;
@ -10,12 +9,10 @@ namespace AcDream.Headless.Hosting;
internal sealed class HeadlessProcessHost : IDisposable
{
private static readonly TimeSpan K1TickInterval =
TimeSpan.FromMilliseconds(15);
private readonly HeadlessSessionHost _session;
private readonly HeadlessSessionHost[] _sessions;
private readonly HeadlessProcessScheduler _scheduler;
private readonly HeadlessDiagnosticWriter _diagnostics;
private readonly string _sessionId;
private int _disposeIndex;
private bool _disposed;
internal HeadlessProcessHost(
@ -30,67 +27,97 @@ internal sealed class HeadlessProcessHost : IDisposable
ArgumentNullException.ThrowIfNull(paths);
ArgumentNullException.ThrowIfNull(standardInput);
ArgumentNullException.ThrowIfNull(diagnostics);
if (configuration.Sessions.Count != 1
|| configuration.Sessions[0] is not { } descriptor)
if (configuration.Sessions.Count == 0)
{
throw new HeadlessConfigurationException(
"Slice K1 run mode requires exactly one session.");
"Headless run mode requires at least one session.");
}
_sessionId = descriptor.Id;
_diagnostics = new HeadlessDiagnosticWriter(diagnostics);
var credentials = new HeadlessCredentialResolver(
standardInput,
paths.ConfigDirectory);
HeadlessCredentialSecret secret =
credentials.Resolve(descriptor.Id, descriptor.Credential);
var sessions = new List<HeadlessSessionHost>(
configuration.Sessions.Count);
try
{
_session = new HeadlessSessionHost(
descriptor,
secret,
_diagnostics,
sessionOperations,
foreach (HeadlessSessionDescriptor? candidate
in configuration.Sessions)
{
HeadlessSessionDescriptor descriptor = candidate
?? throw new HeadlessConfigurationException(
"Headless run mode cannot contain a null session.");
HeadlessCredentialSecret secret =
credentials.Resolve(
descriptor.Id,
descriptor.Credential);
try
{
sessions.Add(new HeadlessSessionHost(
descriptor,
secret,
_diagnostics,
sessionOperations,
timeProvider));
}
catch
{
secret.Dispose();
throw;
}
}
_sessions = sessions.ToArray();
_scheduler = new HeadlessProcessScheduler(
_sessions,
timeProvider);
_disposeIndex = _sessions.Length - 1;
}
catch
{
secret.Dispose();
for (int index = sessions.Count - 1; index >= 0; index--)
sessions[index].Dispose();
throw;
}
}
internal HeadlessSessionHost Session => _session;
internal HeadlessSessionHost Session => _sessions.Length == 1
? _sessions[0]
: throw new InvalidOperationException(
"The process owns more than one headless session.");
internal IReadOnlyList<HeadlessSessionHost> Sessions => _sessions;
internal HeadlessSchedulerSnapshot Scheduler =>
_scheduler.CaptureSnapshot();
internal async Task<HeadlessExitCode> RunAsync(
CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
RuntimeSessionStartResult started = _session.Start();
if (started.Status != RuntimeSessionStartStatus.Connected)
foreach (HeadlessSessionHost session in _sessions)
{
if (started.Error is { } error)
_diagnostics.Failure(_sessionId, "start", error);
return HeadlessExitCode.ConnectionError;
RuntimeSessionStartResult started = session.Start();
if (started.Status != RuntimeSessionStartStatus.Connected)
{
if (started.Error is { } error)
{
_diagnostics.Failure(
session.SessionId,
"start",
error);
}
return HeadlessExitCode.ConnectionError;
}
_diagnostics.Lifecycle(
session.SessionId,
"running",
session.Runtime);
}
_diagnostics.Lifecycle(_sessionId, "running", _session.Runtime);
long previous = Stopwatch.GetTimestamp();
try
{
while (!cancellationToken.IsCancellationRequested)
{
await Task.Delay(
K1TickInterval,
cancellationToken).ConfigureAwait(false);
long current = Stopwatch.GetTimestamp();
double deltaSeconds =
(current - previous) / (double)Stopwatch.Frequency;
previous = current;
_session.Tick(deltaSeconds);
if (_session.IsPolicyComplete)
break;
}
await _scheduler.RunAsync(cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException)
when (cancellationToken.IsCancellationRequested)
@ -98,7 +125,7 @@ internal sealed class HeadlessProcessHost : IDisposable
}
catch (Exception error)
{
_diagnostics.Failure(_sessionId, "tick", error);
_diagnostics.Failure("process", "tick", error);
return HeadlessExitCode.RuntimeError;
}
@ -109,7 +136,11 @@ internal sealed class HeadlessProcessHost : IDisposable
{
if (_disposed)
return;
_session.Dispose();
while (_disposeIndex >= 0)
{
_sessions[_disposeIndex].Dispose();
_disposeIndex--;
}
_disposed = true;
}
}