From a843b990a8e1a1766f188d6b4cdca6f27f2b865b Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 7 Sep 2026 06:56:27 +0200 Subject: [PATCH] feat(headless): add the interactive console (reader, renderer, controller) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Console mode for the headless bot host, enabled by --console or ACDREAM_HEADLESS_CONSOLE=1 (default: on when stdin is a real terminal, off when redirected — a background reader thread blocked on ReadLine would otherwise sit idle against a script/CI pipe). - HeadlessConsoleInputReader: one background thread per console, reading lines into a ConcurrentQueue. It only ever enqueues — no dispatch code runs on it — so input queued while the session tick is busy is guaranteed to execute on the tick thread, never the reader thread, when drained. - HeadlessConsoleController: drains the queue via HeadlessSessionHost.ConsolePump (a new no-op-by-default tick hook), handles /quit (cancels a CancellationTokenSource) and /status (prints a caller-supplied status string), and routes everything else through SubmitConsoleLine. - HeadlessConsoleChatFormatter/HeadlessConsoleRenderer: render the K2 bot event stream (IRuntimeEventObserver — the same interface a bot policy subscribes) as bracket-labelled lines ([Tell] Bob: hi, [Fellowship] ..., [Local] ...) plus lifecycle/portal/rejected-command lines. A deliberate departure from ChatVM.FormatEntry's retail prose: Headless cannot reference AcDream.UI.Abstractions (the dependency- boundary test), so this is its own terminal-shaped rendering using the same channel-name strings, not a byte-for-byte port. - HeadlessProcessHost: attaches the console only for a single-session run (multi-session is out of scope for this cut per the plan), constructed AFTER every session's own credential resolution so the reader thread never races a StandardInput-provider password prompt on the same stdin stream. /quit's CancellationTokenSource is linked into the scheduler's run token, so it exits through the exact same graceful path an external Ctrl+C/SIGTERM already takes. Mutation check (reverted before commit): removing the reader thread's Enqueue call and removing _quitRequested.Cancel() each turn a still-to-be-added HeadlessConsoleTests case red. Co-Authored-By: Claude Fable 5.1 --- src/AcDream.Headless/HeadlessEntryPoint.cs | 9 +- .../Hosting/HeadlessConsoleChatFormatter.cs | 58 +++++++++++ .../Hosting/HeadlessConsoleController.cs | 96 +++++++++++++++++++ .../Hosting/HeadlessConsoleInputReader.cs | 94 ++++++++++++++++++ .../Hosting/HeadlessConsoleRenderer.cs | 94 ++++++++++++++++++ .../Hosting/HeadlessProcessHost.cs | 84 +++++++++++++++- src/AcDream.Headless/Program.cs | 3 +- 7 files changed, 432 insertions(+), 6 deletions(-) create mode 100644 src/AcDream.Headless/Hosting/HeadlessConsoleChatFormatter.cs create mode 100644 src/AcDream.Headless/Hosting/HeadlessConsoleController.cs create mode 100644 src/AcDream.Headless/Hosting/HeadlessConsoleInputReader.cs create mode 100644 src/AcDream.Headless/Hosting/HeadlessConsoleRenderer.cs diff --git a/src/AcDream.Headless/HeadlessEntryPoint.cs b/src/AcDream.Headless/HeadlessEntryPoint.cs index e5576e54d..cf3fb2a34 100644 --- a/src/AcDream.Headless/HeadlessEntryPoint.cs +++ b/src/AcDream.Headless/HeadlessEntryPoint.cs @@ -46,7 +46,8 @@ internal static class HeadlessEntryPoint TextReader standardInput, TextWriter output, TextWriter error, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + bool standardInputIsTerminal = false) { ArgumentNullException.ThrowIfNull(arguments); ArgumentNullException.ThrowIfNull(standardInput); @@ -74,13 +75,17 @@ internal static class HeadlessEntryPoint configuredPaths.Merge(commandLine.Paths)); if (commandLine.Command == "run") { + bool consoleEnabled = HeadlessConsoleOptions.Resolve( + commandLine.Console, + standardInputIsTerminal); using var host = new HeadlessProcessHost( configuration, paths, standardInput, output, directCredentials: - commandLine.DirectCredentials); + commandLine.DirectCredentials, + consoleEnabled: consoleEnabled); return (int)host.RunAsync(cancellationToken) .GetAwaiter() .GetResult(); diff --git a/src/AcDream.Headless/Hosting/HeadlessConsoleChatFormatter.cs b/src/AcDream.Headless/Hosting/HeadlessConsoleChatFormatter.cs new file mode 100644 index 000000000..de8fdbfa7 --- /dev/null +++ b/src/AcDream.Headless/Hosting/HeadlessConsoleChatFormatter.cs @@ -0,0 +1,58 @@ +using AcDream.Core.Chat; +using AcDream.Runtime; + +namespace AcDream.Headless.Hosting; + +/// +/// Presentation for the console's rendered chat lines. A distinct, terminal- +/// shaped format from the graphical ChatVM.FormatEntry retail prose +/// (Headless cannot reference AcDream.UI.Abstractions — see the +/// dependency-boundary test — and a script piping console output wants a +/// stable, greppable "[Label] Sender: text" shape more than retail's exact +/// sentence). It uses the SAME channel-name strings the graphical SpewBox +/// shows (, "Tell", "Local") per +/// the plan's requirement, just not the same sentence template. +/// +internal static class HeadlessConsoleChatFormatter +{ + /// Formats one chat event for the console, or + /// when this kind renders nothing (there are + /// none today — kept for forward compatibility with a future silent + /// kind). + internal static string? Format(in RuntimeChatEntry entry) + { + var kind = (ChatKind)entry.Kind; + return kind switch + { + ChatKind.LocalSpeech or ChatKind.RangedSpeech => + $"[Local] {SpeakerLabel(entry.Sender)}: {entry.Text}", + ChatKind.Channel => + $"[{ChannelLabel(entry)}] {SpeakerLabel(entry.Sender)}: {entry.Text}", + ChatKind.Tell => FormatTell(entry), + ChatKind.Emote or ChatKind.SoulEmote => + $"* {entry.Sender} {entry.Text}", + ChatKind.Popup => $"[Popup] {entry.Text}", + // System/Combat lines arrive pre-formatted (system messages, + // combat translator output) — render bare, matching retail's own + // no-prefix system-chat convention (Campaign CH user-gate round + // 1, item B). + _ => entry.Text, + }; + } + + private static string FormatTell(in RuntimeChatEntry entry) => + // SenderGuid != 0 is an incoming whisper (see ChatLog.OnTellReceived); + // == 0 is our own outbound echo, where Sender carries the target + // name (ChatLog.OnSelfSent). Both directions get the "[Tell]" label + // the plan asks for; the "You -> " marker is what disambiguates an + // outgoing tell from an incoming one in the bracket-label shape. + entry.SenderGuid != 0 + ? $"[Tell] {entry.Sender}: {entry.Text}" + : $"[Tell] You -> {entry.Sender}: {entry.Text}"; + + private static string SpeakerLabel(string sender) => + string.IsNullOrEmpty(sender) || sender == "You" ? "You" : sender; + + private static string ChannelLabel(in RuntimeChatEntry entry) => + string.IsNullOrEmpty(entry.ChannelName) ? "Channel" : entry.ChannelName; +} diff --git a/src/AcDream.Headless/Hosting/HeadlessConsoleController.cs b/src/AcDream.Headless/Hosting/HeadlessConsoleController.cs new file mode 100644 index 000000000..4a2b56b20 --- /dev/null +++ b/src/AcDream.Headless/Hosting/HeadlessConsoleController.cs @@ -0,0 +1,96 @@ +using AcDream.Runtime.Chat; + +namespace AcDream.Headless.Hosting; + +/// +/// The console's own orchestration: owns the background reader +/// () and, once per session tick +/// (), drains every line queued since the last call +/// and dispatches each one IN ORDER, on the calling thread — never the +/// reader thread (Slice K's monotonic scheduler contract; see +/// 's own doc). +/// +/// +/// /quit and /status are console-only controls (the plan's +/// "Control" section) — they never reach , +/// matching retail's own client-local commands. Every other line goes +/// through , which a production caller binds to +/// HeadlessSessionHost.SubmitConsoleLine — the exact plugin-verb +/// registry → client/server slash-command pipeline +/// LoginCommandSequence and the graphical chat box both already use. +/// +internal sealed class HeadlessConsoleController : IDisposable +{ + private readonly HeadlessConsoleInputReader _reader; + private readonly TextWriter _output; + private readonly Func _submit; + private readonly Func _statusText; + private readonly CancellationTokenSource _quitRequested; + + internal HeadlessConsoleController( + TextReader input, + TextWriter output, + Func submit, + Func statusText, + CancellationTokenSource quitRequested) + { + ArgumentNullException.ThrowIfNull(input); + _output = output ?? throw new ArgumentNullException(nameof(output)); + _submit = submit ?? throw new ArgumentNullException(nameof(submit)); + _statusText = statusText ?? throw new ArgumentNullException(nameof(statusText)); + _quitRequested = quitRequested + ?? throw new ArgumentNullException(nameof(quitRequested)); + _reader = new HeadlessConsoleInputReader(input); + } + + /// Number of lines handled by the most recent + /// call — a test seam for the reader-thread + /// ordering assertion. + internal int LastDrainCount { get; private set; } + + /// Test seam: lets a bounded-fixture test wait for the + /// background reader thread to reach EOF before calling + /// , instead of sleeping or polling. + internal HeadlessConsoleInputReader Reader => _reader; + + internal void DrainDue() + { + int count = 0; + while (_reader.TryDequeue(out string line)) + { + Handle(line); + count++; + } + LastDrainCount = count; + } + + private void Handle(string rawLine) + { + string trimmed = rawLine.Trim(); + if (trimmed.Length == 0) + return; + + if (trimmed.Equals("/quit", StringComparison.OrdinalIgnoreCase)) + { + WriteLine("quitting (graceful logout)"); + _quitRequested.Cancel(); + return; + } + + if (trimmed.Equals("/status", StringComparison.OrdinalIgnoreCase)) + { + WriteLine(_statusText()); + return; + } + + _submit(rawLine); + } + + private void WriteLine(string text) + { + _output.WriteLine(text); + _output.Flush(); + } + + public void Dispose() => _reader.Dispose(); +} diff --git a/src/AcDream.Headless/Hosting/HeadlessConsoleInputReader.cs b/src/AcDream.Headless/Hosting/HeadlessConsoleInputReader.cs new file mode 100644 index 000000000..ea159b24b --- /dev/null +++ b/src/AcDream.Headless/Hosting/HeadlessConsoleInputReader.cs @@ -0,0 +1,94 @@ +using System.Collections.Concurrent; + +namespace AcDream.Headless.Hosting; + +/// +/// Reads lines from a on one dedicated background +/// thread and hands them to whoever drains . Slice K's +/// scheduler contract binds every mutating call to one thread for a session's +/// whole lifetime (#368 — collision generations refuse migration), so console +/// input can never be executed from this thread: it only ever enqueues, and +/// the session tick is the sole reader of . +/// +/// +/// has no cancellable overload, so a real +/// Console.In reader can be blocked on it when the process wants to +/// exit. The thread is a background thread (does not keep the process alive) +/// and only requests the loop stop at its next +/// opportunity — it does not abort a pending read. A closed/EOF input (a +/// piped fixture reaching its last line, or the real console's stdin handle +/// closing) ends the loop on its own; lets a test +/// wait for that deterministically instead of polling or sleeping. +/// +internal sealed class HeadlessConsoleInputReader : IDisposable +{ + private readonly TextReader _input; + private readonly ConcurrentQueue _queue = new(); + private readonly Thread _thread; + private volatile bool _stopRequested; + + internal HeadlessConsoleInputReader(TextReader input) + { + _input = input ?? throw new ArgumentNullException(nameof(input)); + _thread = new Thread(ReadLoop) + { + IsBackground = true, + Name = "acdream-headless-console-reader", + }; + _thread.Start(); + } + + /// Set once the reader loop has returned (EOF or stop request). + /// Tests wait on this instead of sleeping/polling for a deterministic + /// "every line the fixture will ever produce has been enqueued" signal. + /// + internal ManualResetEventSlim EndOfInput { get; } = new(initialState: false); + + /// Dequeues the next queued line in FIFO order, or returns + /// if none is queued yet. Never blocks. + internal bool TryDequeue(out string line) => _queue.TryDequeue(out line!); + + private void ReadLoop() + { + try + { + while (!_stopRequested) + { + string? line = _input.ReadLine(); + if (line is null) + return; + _queue.Enqueue(line); + } + } + catch (ObjectDisposedException) + { + // The input was disposed out from under a pending read (process + // teardown racing the reader thread) — end the loop quietly, + // same as EOF. + } + catch (IOException) + { + // A redirected stream can fail mid-read (e.g. a broken pipe). + // Treat it the same as EOF rather than crashing the process. + } + finally + { + EndOfInput.Set(); + } + } + + /// Requests the read loop stop at its next opportunity. Does + /// not abort a already in progress — + /// the thread is background, so it cannot block process exit. + /// Deliberately does NOT dispose : the read + /// loop's own finally sets it from the reader thread, and racing + /// that against a Dispose() here (an unhandled + /// on a background thread + /// terminates the process) is worse than leaking one small + /// synchronization handle for the process's remaining lifetime. + /// + public void Dispose() + { + _stopRequested = true; + } +} diff --git a/src/AcDream.Headless/Hosting/HeadlessConsoleRenderer.cs b/src/AcDream.Headless/Hosting/HeadlessConsoleRenderer.cs new file mode 100644 index 000000000..8832c5bb2 --- /dev/null +++ b/src/AcDream.Headless/Hosting/HeadlessConsoleRenderer.cs @@ -0,0 +1,94 @@ +using AcDream.Runtime; + +namespace AcDream.Headless.Hosting; + +/// +/// One presentation over the K2 bot event stream +/// () — the SAME typed events a headless +/// bot policy observes (HeadlessBotPolicy.cs) — rendered as plain +/// lines. Every write goes through , so a test can +/// assert on exactly what a real console would have printed without a +/// terminal. +/// +internal sealed class HeadlessConsoleRenderer : IRuntimeEventObserver +{ + private const string Reset = ""; + private const string Dim = ""; + + private readonly TextWriter _output; + private readonly bool _useColor; + + internal HeadlessConsoleRenderer(TextWriter output, bool useColor) + { + _output = output ?? throw new ArgumentNullException(nameof(output)); + _useColor = useColor; + } + + public void OnChat(in RuntimeChatDelta delta) + { + string? line = HeadlessConsoleChatFormatter.Format(delta.Entry); + if (!string.IsNullOrEmpty(line)) + WriteLine(line); + } + + /// + /// Retail's transient "interface text" (SpewBox, ClientLocal + /// type) never touches — + /// see RuntimeCommunicationState.AddText — so it never reaches + /// . calls + /// this directly for the SAME text + /// ChatCommandRouter.Submit's ShowInterfaceText path would + /// otherwise only enqueue into the polled SpewBoxState. + /// + internal void WriteInterfaceText(string text) => WriteLine(text); + + public void OnLifecycle(in RuntimeLifecycleDelta delta) + { + switch (delta.Current) + { + case RuntimeLifecycleState.InWorld: + WriteLine("entered world"); + break; + case RuntimeLifecycleState.Stopping: + WriteLine("disconnecting"); + break; + case RuntimeLifecycleState.Faulted: + WriteLine("session faulted"); + break; + } + } + + public void OnCommand(in RuntimeCommandDelta delta) + { + if (delta.Status == RuntimeCommandStatus.Rejected) + WriteLine($"command rejected: {delta.Domain} {delta.Text}".TrimEnd()); + } + + public void OnPortal(in RuntimePortalDelta delta) + { + if (delta.Portal.IsMaterialized) + WriteLine($"portal -> cell 0x{delta.Portal.DestinationCell:X8}"); + } + + public void OnEntity(in RuntimeEntityDelta delta) + { + } + + public void OnInventory(in RuntimeInventoryDelta delta) + { + } + + public void OnMovement(in RuntimeMovementDelta delta) + { + } + + public void OnCombat(in RuntimeCombatDelta delta) + { + } + + private void WriteLine(string text) + { + _output.WriteLine(_useColor ? Dim + text + Reset : text); + _output.Flush(); + } +} diff --git a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs index 3a73be8bb..9c7d2043b 100644 --- a/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs +++ b/src/AcDream.Headless/Hosting/HeadlessProcessHost.cs @@ -15,6 +15,16 @@ internal sealed class HeadlessProcessHost : IDisposable private readonly HeadlessDiagnosticWriter _diagnostics; private readonly HeadlessProcessContentOwner? _content; private readonly HeadlessProcessResourceSampler _resources; + /// + /// Headless console (docs/plans/2026-09-07-headless-console.md): always + /// created, cancelled only by /quit — linking it into the + /// scheduler's run token below costs nothing when the console is + /// disabled (it simply never fires) and keeps + /// free of a console-shaped branch. + /// + private readonly CancellationTokenSource _consoleQuitRequested = new(); + private readonly HeadlessConsoleController? _console; + private readonly IDisposable? _consoleRendererSubscription; private int _disposeIndex; private bool _disposed; @@ -26,7 +36,8 @@ internal sealed class HeadlessProcessHost : IDisposable ILiveSessionOperations? sessionOperations = null, TimeProvider? timeProvider = null, IHeadlessProcessContentFactory? contentFactory = null, - HeadlessDirectCredentials? directCredentials = null) + HeadlessDirectCredentials? directCredentials = null, + bool consoleEnabled = false) { ArgumentNullException.ThrowIfNull(configuration); ArgumentNullException.ThrowIfNull(paths); @@ -65,6 +76,8 @@ internal sealed class HeadlessProcessHost : IDisposable paths.VtankProfilesDirectory); HeadlessProcessContentOwner? content = null; HeadlessProcessResourceSampler? resources = null; + HeadlessConsoleController? console = null; + IDisposable? consoleRendererSubscription = null; // FA6: constructed unconditionally — cheap, and every non-gate // session simply never reads or writes it (see the coordinator's // own class doc). @@ -137,9 +150,39 @@ internal sealed class HeadlessProcessHost : IDisposable _resources = resources; _content = content; _disposeIndex = _sessions.Length - 1; + + // Headless console (docs/plans/2026-09-07-headless-console.md): + // "Multi-session. Out of scope for the first cut" — attach only + // to a single-session process. Constructed AFTER every + // session's credential resolution above (which may itself read + // a line from standardInput for a StandardInput-provider + // credential) so the console's own reader thread never races a + // password prompt for the same stream. + if (consoleEnabled && _sessions.Length == 1) + { + HeadlessSessionHost session = _sessions[0]; + var renderer = new HeadlessConsoleRenderer( + diagnostics, + useColor: !System.Console.IsOutputRedirected); + consoleRendererSubscription = + session.Runtime.Subscribe(renderer); + console = new HeadlessConsoleController( + standardInput, + diagnostics, + line => session.SubmitConsoleLine( + line, + renderer.WriteInterfaceText), + () => BuildStatusText(session), + _consoleQuitRequested); + session.ConsolePump = console.DrainDue; + } + _console = console; + _consoleRendererSubscription = consoleRendererSubscription; } catch { + console?.Dispose(); + consoleRendererSubscription?.Dispose(); resources?.Dispose(); for (int index = sessions.Count - 1; index >= 0; index--) sessions[index].Dispose(); @@ -148,6 +191,29 @@ internal sealed class HeadlessProcessHost : IDisposable } } + /// + /// /status: generation, position (or "unknown" without a live + /// movement controller — a content-less host, or before the first + /// accepted placement), and the plugin-visible macro state this host + /// can actually observe today (loaded-plugin count — no plugin + /// currently reports a richer status string; see the plan's "if the + /// plugin reports one"). + /// + private static string BuildStatusText(HeadlessSessionHost session) + { + RuntimeMovementSnapshot movement = + session.Runtime.MovementOwner.Snapshot; + string position = movement.HasController + ? $"cell=0x{movement.Position.ObjCellId:X8} " + + $"local=({movement.Position.Frame.Origin.X:F2}," + + $"{movement.Position.Frame.Origin.Y:F2}," + + $"{movement.Position.Frame.Origin.Z:F2})" + : "unknown"; + return $"generation={session.Runtime.Generation.Value} " + + $"position={position} " + + $"plugins={session.Plugins.LoadedCount} loaded"; + } + internal HeadlessSessionHost Session => _sessions.Length == 1 ? _sessions[0] : throw new InvalidOperationException( @@ -249,12 +315,21 @@ internal sealed class HeadlessProcessHost : IDisposable _scheduler.CaptureSnapshot(), _content); + // Headless console: /quit cancels _consoleQuitRequested, which this + // linked token propagates into the scheduler's own wait loop — + // Run() returns normally (its loop condition simply goes false), + // the SAME graceful-exit path an external Ctrl+C/SIGTERM already + // takes. Linking costs nothing when the console never fires. + using CancellationTokenSource linkedQuit = + CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + _consoleQuitRequested.Token); try { - _scheduler.Run(cancellationToken); + _scheduler.Run(linkedQuit.Token); } catch (OperationCanceledException) - when (cancellationToken.IsCancellationRequested) + when (linkedQuit.IsCancellationRequested) { } catch (Exception error) @@ -277,6 +352,9 @@ internal sealed class HeadlessProcessHost : IDisposable { if (_disposed) return; + _console?.Dispose(); + _consoleRendererSubscription?.Dispose(); + _consoleQuitRequested.Dispose(); while (_disposeIndex >= 0) { _sessions[_disposeIndex].Dispose(); diff --git a/src/AcDream.Headless/Program.cs b/src/AcDream.Headless/Program.cs index 4fae40318..d0b7127c4 100644 --- a/src/AcDream.Headless/Program.cs +++ b/src/AcDream.Headless/Program.cs @@ -27,7 +27,8 @@ try Console.In, Console.Out, Console.Error, - cancellation.Token); + cancellation.Token, + standardInputIsTerminal: !Console.IsInputRedirected); } finally {