diff --git a/docs/plans/2026-08-14-launcher-campaign.md b/docs/plans/2026-08-14-launcher-campaign.md
index c904885e..ae9586e1 100644
--- a/docs/plans/2026-08-14-launcher-campaign.md
+++ b/docs/plans/2026-08-14-launcher-campaign.md
@@ -478,7 +478,7 @@ LA6 adds CH-regression scrutiny; LA0 adds guard-integrity scrutiny.
|---|---|---|---|---|
| LA0 | **DONE 2026-08-14** | `cb6502c8`, `a49e92df` | Opus dual-lens PASS; all 6 findings CLOSED in narrow re-review | Byte-identity proven; Linux CI lanes restored; Platform BCL-only self-guard added; K0 guard untouched |
| LA1 | implemented; Opus review in flight | `db9ad53c` (mixed — see `e1322a06`) | review in flight | Runtime 1630 / Headless 126 / App 5025+3skip / Core.Net 905 green; Runtime+Headless green on WSL; shared fixture parsed by BOTH host readers |
-| LA2 | — | | | |
+| LA2 | implementation complete; automated gates **GREEN**; Opus dual-lens review pending | `c6019424` + completion (this commit) | pending | Probe is roster-before-selection with graceful pre-world teardown and exit 0; normal play remains strict selector + `idle` policy. Release build green; Runtime 1,632/1,632 and Headless 141/141 on both Windows and Ubuntu/WSL |
| LA3 | review FIX FIRST; fix round in flight | `37d74e44` + fixes pending | Opus 2026-08-14: 12 findings — 1 CRITICAL (`"paths": {}` breaks App loader), probe composition owed, Stop→SIGKILL hazard, 0600 temp window | Contract text now COMMITTED into LA1 section (review process note); cross-assembly loader test owed at LA1+LA3 merge; CI lane addition at merge |
| LA4 | — | | | |
| LA5 | — | | | |
diff --git a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs
index fbdbdbe9..1ad3a913 100644
--- a/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs
+++ b/src/AcDream.Headless/Configuration/HeadlessConfiguration.cs
@@ -167,9 +167,11 @@ internal sealed class HeadlessBotPolicyDescriptor
/// The pinned launch-contract schema defines exactly two states for a
/// session — ABSENT (mapped to , meaning "play") or
/// the literal string "probe" — so is the only
-/// member; there is no explicit "play" spelling.
+/// member; there is no explicit "play" spelling. This deliberately uses
+/// 's global camel-case,
+/// string-only enum converter; a per-enum converter with its default options
+/// would accidentally accept numeric 0 as a second probe spelling.
///
-[JsonConverter(typeof(JsonStringEnumConverter))]
internal enum HeadlessSessionMode
{
Probe,
diff --git a/tests/AcDream.Headless.Tests/HeadlessBotPolicyTests.cs b/tests/AcDream.Headless.Tests/HeadlessBotPolicyTests.cs
new file mode 100644
index 00000000..2ea2f73a
--- /dev/null
+++ b/tests/AcDream.Headless.Tests/HeadlessBotPolicyTests.cs
@@ -0,0 +1,77 @@
+using System.Reflection;
+using AcDream.Headless.Policies;
+using AcDream.Runtime;
+
+namespace AcDream.Headless.Tests;
+
+public sealed class HeadlessBotPolicyTests
+{
+ ///
+ /// Campaign LA slice LA2: idle is a deliberately passive,
+ /// non-terminal policy. It must neither inspect Runtime state nor reach
+ /// any command surface, and no event can make it complete on its own.
+ /// The process scheduler therefore keeps the play session alive until
+ /// external cancellation/stop drives the host's ordinary teardown path.
+ ///
+ [Fact]
+ public void IdlePolicyIsPassiveAndNeverCompletesAutonomously()
+ {
+ var policy = new IdleHeadlessBotPolicy();
+ IGameRuntimeView view = CreateNoTouchProxy(
+ out InvocationCountingProxy viewCalls);
+ IGameRuntimeCommands commands =
+ CreateNoTouchProxy(
+ out InvocationCountingProxy commandCalls);
+
+ for (int index = 0; index < 3; index++)
+ policy.Tick(view, commands);
+
+ RuntimeLifecycleDelta lifecycle = default;
+ RuntimeCommandDelta command = default;
+ RuntimeEntityDelta entity = default;
+ RuntimeInventoryDelta inventory = default;
+ RuntimeChatDelta chat = default;
+ RuntimeMovementDelta movement = default;
+ RuntimePortalDelta portal = default;
+ RuntimeCombatDelta combat = default;
+ policy.OnLifecycle(in lifecycle);
+ policy.OnCommand(in command);
+ policy.OnEntity(in entity);
+ policy.OnInventory(in inventory);
+ policy.OnChat(in chat);
+ policy.OnMovement(in movement);
+ policy.OnPortal(in portal);
+ policy.OnCombat(in combat);
+
+ Assert.False(policy.IsComplete);
+ Assert.Equal(0, viewCalls.InvocationCount);
+ Assert.Equal(0, commandCalls.InvocationCount);
+
+ policy.Dispose();
+ policy.Dispose();
+ Assert.False(policy.IsComplete);
+ }
+
+ private static T CreateNoTouchProxy(
+ out InvocationCountingProxy proxy)
+ where T : class
+ {
+ T value = DispatchProxy.Create();
+ proxy = (InvocationCountingProxy)(object)value;
+ return value;
+ }
+
+ public class InvocationCountingProxy : DispatchProxy
+ {
+ public int InvocationCount { get; private set; }
+
+ protected override object? Invoke(
+ MethodInfo? targetMethod,
+ object?[]? args)
+ {
+ InvocationCount++;
+ throw new InvalidOperationException(
+ $"Idle policy unexpectedly invoked {targetMethod?.Name}.");
+ }
+ }
+}
diff --git a/tests/AcDream.Headless.Tests/HeadlessConfigurationLoaderTests.cs b/tests/AcDream.Headless.Tests/HeadlessConfigurationLoaderTests.cs
index 9c21273c..6902fccc 100644
--- a/tests/AcDream.Headless.Tests/HeadlessConfigurationLoaderTests.cs
+++ b/tests/AcDream.Headless.Tests/HeadlessConfigurationLoaderTests.cs
@@ -237,6 +237,38 @@ public sealed class HeadlessConfigurationLoaderTests
Assert.Null(session.Policy);
}
+ ///
+ /// The pinned v1 contract has one named mode value: "probe".
+ /// In particular, the enum's underlying numeric zero must not become an
+ /// accidental second spelling through an enum converter configured to
+ /// allow integers.
+ ///
+ [Theory]
+ [InlineData("\"play\"")]
+ [InlineData("0")]
+ public void SessionModeRejectsEveryValueOtherThanTheNamedProbeMode(
+ string modeJson)
+ {
+ using TemporaryConfiguration file = TemporaryConfiguration.Create(
+ $$"""
+ {
+ "version": 1,
+ "sessions": [
+ {
+ "id": "unsupported-mode",
+ "endpoint": { "host": "127.0.0.1", "port": 9000 },
+ "account": "account",
+ "mode": {{modeJson}},
+ "credential": { "provider": "environment", "reference": "PROBE_PASSWORD" }
+ }
+ ]
+ }
+ """);
+
+ Assert.Throws(
+ () => HeadlessConfigurationLoader.Load(file.Path));
+ }
+
[Fact]
public void ProbeSessionDeclaringCharacterFailsLoadNamingTheField()
{
diff --git a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs
index 332a9933..f568feb2 100644
--- a/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs
+++ b/tests/AcDream.Headless.Tests/HeadlessSessionHostTests.cs
@@ -1,5 +1,6 @@
using System.Buffers.Binary;
using System.Collections.Immutable;
+using System.Diagnostics;
using System.Net;
using System.Numerics;
using System.Reflection;
@@ -315,41 +316,122 @@ public sealed class HeadlessSessionHostTests
Assert.False(playSession.IsFaulted);
}
+ ///
+ /// Campaign LA slice LA2: the configured idle policy follows the
+ /// normal play shape (selector + policy, mode absent), enters world, and
+ /// remains non-terminal through real scheduler turns until cancellation.
+ /// Cancellation stops the process loop; the owning host's ordinary
+ /// disposal transaction then performs graceful session teardown. Status
+ /// events must describe those boundaries truthfully and remain exactly
+ /// once even when disposal is repeated.
+ ///
[Fact]
- public async Task ProcessHostRunsUntilCancellationAndReturnsStableExitCode()
+ public async Task IdlePolicyEntersWorldRunsUntilCancellationAndConvergesExactlyOnce()
{
- var configuration = new HeadlessConfiguration
+ string statusPath = Path.Combine(
+ Path.GetTempPath(),
+ $"acdream-headless-idle-status-{Guid.NewGuid():N}.jsonl");
+ try
{
- Version = 1,
- Sessions =
- [
- Descriptor(
- HeadlessCredentialProviderKind.StandardInput,
- "stdin-bot"),
- ],
- };
- HeadlessPathSet paths = HeadlessPathSet.Resolve(
- new HeadlessPathOverrides());
- using var diagnostics = new StringWriter();
- var operations = new FixtureSessionOperations();
- using var host = new HeadlessProcessHost(
- configuration,
- paths,
- new System.IO.StringReader(
- "process-password" + Environment.NewLine),
- diagnostics,
- operations);
- using var cancellation = new CancellationTokenSource();
- cancellation.Cancel();
+ var configuration = new HeadlessConfiguration
+ {
+ Version = 1,
+ Sessions =
+ [
+ Descriptor(
+ HeadlessCredentialProviderKind.StandardInput,
+ "stdin-bot",
+ statusFile: statusPath),
+ ],
+ };
+ HeadlessPathSet paths = HeadlessPathSet.Resolve(
+ new HeadlessPathOverrides());
+ using var diagnostics = new StringWriter();
+ var operations = new FixtureSessionOperations();
+ using var host = new HeadlessProcessHost(
+ configuration,
+ paths,
+ new System.IO.StringReader(
+ "process-password" + Environment.NewLine),
+ diagnostics,
+ operations);
+ using var cancellation = new CancellationTokenSource();
- HeadlessExitCode result =
- await host.RunAsync(cancellation.Token);
+ Task run = host.RunAsync(cancellation.Token);
+ var timeout = Stopwatch.StartNew();
+ while (operations.TickCallCount < 3
+ && !run.IsCompleted
+ && timeout.Elapsed < TimeSpan.FromSeconds(10))
+ {
+ await Task.Delay(5);
+ }
- Assert.Equal(HeadlessExitCode.Success, result);
- Assert.True(host.Session.Runtime.Session.IsInWorld);
- Assert.DoesNotContain(
- "process-password",
- diagnostics.ToString());
+ Assert.True(
+ operations.TickCallCount >= 3,
+ $"Expected at least 3 idle scheduler turns, observed {operations.TickCallCount}.");
+ Assert.False(run.IsCompleted);
+ Assert.Equal(1, operations.EnterWorldCallCount);
+ Assert.Equal("Headless", host.Session.ActiveCharacterName);
+ Assert.True(host.Session.Runtime.Session.IsInWorld);
+ Assert.False(host.Session.IsPolicyComplete);
+ Assert.Equal(
+ ["started", "connected", "characterList", "enteredWorld"],
+ ReadStatusEventNames(statusPath));
+
+ cancellation.Cancel();
+ HeadlessExitCode result = await run.WaitAsync(
+ TimeSpan.FromSeconds(10));
+
+ Assert.Equal(HeadlessExitCode.Success, result);
+ // RunAsync owns scheduling, not the host lifetime. The session
+ // remains honestly connected until its owner disposes it.
+ Assert.True(host.Session.Runtime.Session.IsInWorld);
+ Assert.Equal(
+ ["started", "connected", "characterList", "enteredWorld"],
+ ReadStatusEventNames(statusPath));
+
+ host.Dispose();
+ host.Dispose();
+
+ Assert.True(host.Session.Runtime.CaptureOwnership().IsConverged);
+ Assert.Equal(1, operations.DisposedSessionCount);
+ string[] lines = File.ReadAllLines(statusPath);
+ string[] eventNames = ReadStatusEventNames(statusPath);
+ Assert.Equal(
+ [
+ "started", "connected", "characterList", "enteredWorld",
+ "disconnected", "exited",
+ ],
+ eventNames);
+
+ using JsonDocument disconnected = JsonDocument.Parse(
+ lines[Array.IndexOf(eventNames, "disconnected")]);
+ Assert.Equal(
+ "stopped",
+ disconnected.RootElement.GetProperty("reason").GetString());
+
+ using JsonDocument exited = JsonDocument.Parse(
+ lines[Array.IndexOf(eventNames, "exited")]);
+ JsonElement exit = exited.RootElement;
+ Assert.Equal(0, exit.GetProperty("code").GetInt32());
+ string? exitReason = exit.GetProperty("reason").GetString();
+ Assert.False(string.IsNullOrWhiteSpace(exitReason));
+ Assert.NotEqual("fault", exitReason);
+ Assert.NotEqual("probe", exitReason);
+ Assert.DoesNotContain(
+ "process-password",
+ File.ReadAllText(statusPath),
+ StringComparison.Ordinal);
+ Assert.DoesNotContain(
+ "process-password",
+ diagnostics.ToString(),
+ StringComparison.Ordinal);
+ }
+ finally
+ {
+ if (File.Exists(statusPath))
+ File.Delete(statusPath);
+ }
}
[Fact]
@@ -2276,6 +2358,15 @@ public sealed class HeadlessSessionHostTests
StatusFile = statusFile,
};
+ private static string[] ReadStatusEventNames(string path) =>
+ File.ReadAllLines(path)
+ .Select(static line =>
+ {
+ using JsonDocument document = JsonDocument.Parse(line);
+ return document.RootElement.GetProperty("e").GetString()!;
+ })
+ .ToArray();
+
private static void HydrateGroundedPlayer(GameRuntime runtime)
{
const uint player = 0x50000002u;
@@ -2917,11 +3008,17 @@ public sealed class HeadlessSessionHostTests
private sealed class FixtureSessionOperations : ILiveSessionOperations
{
+ private int _enterWorldCallCount;
+ private int _tickCallCount;
+
public List Sessions { get; } = [];
public int CreatedSessionCount { get; private set; }
public int DisposedSessionCount { get; private set; }
public string? LastUser { get; private set; }
public string? LastPassword { get; private set; }
+ public int EnterWorldCallCount =>
+ Volatile.Read(ref _enterWorldCallCount);
+ public int TickCallCount => Volatile.Read(ref _tickCallCount);
public IPEndPoint ResolveEndpoint(string host, int port) =>
new(IPAddress.Loopback, port);
@@ -2963,19 +3060,16 @@ public sealed class HeadlessSessionHostTests
true,
true);
- /// Campaign LA slice LA2: lets a probe test assert the
- /// live-session controller never reached EnterWorld.
- public int EnterWorldCallCount { get; private set; }
-
public void EnterWorld(
WorldSession session,
int activeCharacterIndex)
{
- EnterWorldCallCount++;
+ Interlocked.Increment(ref _enterWorldCallCount);
}
public void Tick(WorldSession session)
{
+ Interlocked.Increment(ref _tickCallCount);
}
public void DisposeSession(WorldSession session)