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:
commit
e01b2cd12f
16 changed files with 1361 additions and 69 deletions
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue