merge: Campaign LA LA2 - probe and idle review-closed

# Conflicts:
#	docs/plans/2026-08-14-launcher-campaign.md
#	src/AcDream.Headless/Hosting/HeadlessSessionHost.cs
This commit is contained in:
Erik 2026-08-14 17:30:07 +02:00
commit e01b2cd12f
16 changed files with 1361 additions and 69 deletions

View file

@ -120,6 +120,15 @@ internal sealed class HeadlessSessionHost : IDisposable
/// rework of the shared-stdout diagnostics writer.
/// </summary>
private readonly SessionStatusWriter _statusWriter;
/// <summary>
/// Campaign LA LA2 review fix: the actual result returned by the process
/// start attempt. A configured probe mode is only intent; terminal status
/// may claim <c>reason:"probe"</c> after this records
/// <see cref="RuntimeSessionStartStatus.ProbeComplete"/>. Any other
/// non-connected result maps to the same connection-error code returned by
/// <see cref="HeadlessProcessHost"/>.
/// </summary>
private RuntimeSessionStartStatus? _startOutcome;
/// <summary>Guards <see cref="Stop"/>'s <c>disconnected</c> status event
/// so a Stop() on a session that never actually reached Connected (e.g.
/// disposing a fresh, never-started host) does not report a spurious
@ -319,7 +328,7 @@ internal sealed class HeadlessSessionHost : IDisposable
// doc). Gating on role keeps two sessions
// writing the SAME field from ever racing —
// only one role ever writes it.
if (descriptor.Policy.Role
if (descriptor.Policy?.Role
== HeadlessBotPolicyRole.Recruit
&& gateCoordinator is not null)
{
@ -366,13 +375,24 @@ internal sealed class HeadlessSessionHost : IDisposable
hostLease = runtime.AcquireHostLease(
$"headless:{descriptor.Id}");
// Campaign LA slice LA2: a probe session's descriptor carries no
// `policy` at all (the loader rejects the opposite pairing) — a
// probe never reaches TrySelectCharacter/EnterWorld, so there is
// no policy id to switch on. ProbeHeadlessBotPolicy reports
// IsComplete unconditionally so HeadlessProcessScheduler treats
// this session as already finished the instant it is
// constructed, letting the scheduler's Run() loop return
// immediately for a probe-only process instead of waiting for
// SIGINT.
policy = policyOverride
?? HeadlessBotPolicyFactory.Create(
descriptor.Policy,
runtime,
() => _pendingConfirmation,
RespondToConfirmation,
gateCoordinator);
?? (descriptor.Mode == HeadlessSessionMode.Probe
? new ProbeHeadlessBotPolicy()
: HeadlessBotPolicyFactory.Create(
descriptor.Policy!,
runtime,
() => _pendingConfirmation,
RespondToConfirmation,
gateCoordinator));
policySubscription = runtime.Subscribe(policy);
diagnostics.Lifecycle(
descriptor.Id,
@ -454,7 +474,10 @@ internal sealed class HeadlessSessionHost : IDisposable
// Campaign LA slice LA1: "started" = session host start — the
// earliest point this session actually attempts to connect.
_statusWriter.Started(_descriptor.Id);
return Commands.Session.Start(Runtime.Generation);
RuntimeSessionStartResult result =
Commands.Session.Start(Runtime.Generation);
_startOutcome = result.Status;
return result;
}
internal RuntimeSessionStartResult Reconnect() =>
@ -637,11 +660,16 @@ internal sealed class HeadlessSessionHost : IDisposable
_stoppedGeneration);
// Campaign LA slice LA1: "exited" = terminal — the sole
// point every disposal path (graceful and post-
// quarantine) converges on.
// quarantine) converges on. LA2: only an actual
// ProbeComplete start outcome reports reason "probe";
// configured probe intent cannot turn a failed start into
// a successful terminal event.
(int exitCode, string exitReason) =
ResolveTerminalStatus();
_statusWriter.Exited(
_descriptor.Id,
_faulted ? 1 : 0,
_faulted ? "runtime-fault" : "graceful");
exitCode,
exitReason);
_disposeStage++;
_disposed = true;
break;
@ -652,6 +680,33 @@ internal sealed class HeadlessSessionHost : IDisposable
}
}
/// <summary>
/// Produces the same terminal classification the owning process host uses.
/// Descriptor mode never participates: only an observed ProbeComplete may
/// report a successful probe. The surrounding disposal stage and LA1's
/// terminal/idempotent <see cref="SessionStatusWriter"/> make this event
/// exact-once even when disposal is retried.
/// </summary>
private (int Code, string Reason) ResolveTerminalStatus()
{
if (_faulted)
{
return (
(int)HeadlessExitCode.RuntimeError,
"runtime-fault");
}
return _startOutcome switch
{
RuntimeSessionStartStatus.ProbeComplete =>
((int)HeadlessExitCode.Success, "probe"),
null or RuntimeSessionStartStatus.Connected =>
((int)HeadlessExitCode.Success, "graceful"),
_ =>
((int)HeadlessExitCode.ConnectionError, "connection-error"),
};
}
private RuntimeSessionStartResult StartCore(
RuntimeGenerationToken expectedGeneration,
bool reconnect)
@ -722,7 +777,8 @@ internal sealed class HeadlessSessionHost : IDisposable
_descriptor.Endpoint.Port,
_descriptor.Account,
password,
MapCharacterSelector(_descriptor.Character));
MapCharacterSelector(_descriptor.Character),
Probe: _descriptor.Mode == HeadlessSessionMode.Probe);
LiveSessionStartResult result = _liveSession.Start(options);
if (result.Selection is { } selection)
_accountName = selection.AccountName;
@ -969,12 +1025,19 @@ internal sealed class HeadlessSessionHost : IDisposable
return declared;
}
private static LiveSessionCharacterSelector MapCharacterSelector(
HeadlessCharacterSelector selector) =>
new(
selector.Index,
selector.Id,
selector.Name);
/// <summary>Campaign LA slice LA2: <see langword="null"/> for a probe
/// session (the loader guarantees <c>Character</c> is omitted whenever
/// <c>Mode</c> is <see cref="HeadlessSessionMode.Probe"/>) — a probe
/// never reaches <c>TrySelectCharacter</c>, so "no selector configured"
/// is the correct, harmless mapping.</summary>
private static LiveSessionCharacterSelector? MapCharacterSelector(
HeadlessCharacterSelector? selector) =>
selector is null
? null
: new(
selector.Index,
selector.Id,
selector.Name);
private RuntimeSessionStartResult Convert(
LiveSessionStartResult result)
@ -993,6 +1056,8 @@ internal sealed class HeadlessSessionHost : IDisposable
RuntimeSessionStartStatus.Deferred,
LiveSessionStartStatus.Failed =>
RuntimeSessionStartStatus.Failed,
LiveSessionStartStatus.ProbeComplete =>
RuntimeSessionStartStatus.ProbeComplete,
_ => throw new ArgumentOutOfRangeException(
nameof(result),
result.Status,