wip: Campaign LA LA2 probe mode + idle policy — INCOMPLETE, stopped mid-task
Agent was stopped for token budget. Landed here: probe flag through LiveSessionConnectOptions + the StartCore short-circuit, the mode field with JsonRequired-to-semantic-validation move, host exit-code mapping, and 34 passing tests including 3 new probe tests (agent last reported green before the stop). NOT DONE: the idle-policy unit tests (next step), full-suite verification, and the WSL run. Build/test state UNVERIFIED at this commit. Next session: finish idle policy tests, run Runtime+Headless Release suites Windows and WSL, then dispatch the Opus dual-lens review. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
498f1c1182
commit
c601942467
14 changed files with 830 additions and 35 deletions
|
|
@ -46,11 +46,33 @@ internal sealed record HeadlessSessionDescriptor
|
|||
[JsonRequired]
|
||||
public string Account { get; init; } = string.Empty;
|
||||
|
||||
[JsonRequired]
|
||||
public HeadlessCharacterSelector Character { get; init; } = new();
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA2: ABSENT (<see langword="null"/>) for normal play
|
||||
/// sessions; <see cref="HeadlessSessionMode.Probe"/> for the LA2 probe
|
||||
/// (connect → characterList → graceful disconnect, never EnterWorld) —
|
||||
/// the pinned launch-contract schema's <c>mode</c> field
|
||||
/// (<c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1/LA2).
|
||||
/// <see cref="Character"/>/<see cref="Policy"/> requiredness depends on
|
||||
/// this value, which is why their requiredness lives in
|
||||
/// <see cref="HeadlessConfigurationLoader"/>'s semantic validation rather
|
||||
/// than a <c>[JsonRequired]</c> attribute — that attribute fires during
|
||||
/// deserialization, before <see cref="Mode"/> can be inspected at all.
|
||||
/// </summary>
|
||||
public HeadlessSessionMode? Mode { get; init; }
|
||||
|
||||
[JsonRequired]
|
||||
public HeadlessBotPolicyDescriptor Policy { get; init; } = new();
|
||||
/// <summary>
|
||||
/// Required for play sessions (<see cref="Mode"/> absent); MUST be
|
||||
/// omitted for probe sessions (<see cref="HeadlessSessionMode.Probe"/>) —
|
||||
/// the pinned contract keeps the shape unambiguous by forbidding a probe
|
||||
/// session from also declaring a selector. Enforced by
|
||||
/// <see cref="HeadlessConfigurationLoader.ValidateSession"/>, not
|
||||
/// <c>[JsonRequired]</c> (see this record's own doc on <see cref="Mode"/>).
|
||||
/// </summary>
|
||||
public HeadlessCharacterSelector? Character { get; init; }
|
||||
|
||||
/// <summary>Same mode-dependent requiredness as <see cref="Character"/>:
|
||||
/// required for play sessions, forbidden for probe sessions.</summary>
|
||||
public HeadlessBotPolicyDescriptor? Policy { get; init; }
|
||||
|
||||
[JsonRequired]
|
||||
public HeadlessCredentialReference Credential { get; init; } = new();
|
||||
|
|
@ -140,6 +162,19 @@ internal sealed class HeadlessBotPolicyDescriptor
|
|||
public HeadlessBotPolicyRole? Role { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA2: see <see cref="HeadlessSessionDescriptor.Mode"/>.
|
||||
/// The pinned launch-contract schema defines exactly two states for a
|
||||
/// session — ABSENT (mapped to <see langword="null"/>, meaning "play") or
|
||||
/// the literal string <c>"probe"</c> — so <see cref="Probe"/> is the only
|
||||
/// member; there is no explicit "play" spelling.
|
||||
/// </summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<HeadlessSessionMode>))]
|
||||
internal enum HeadlessSessionMode
|
||||
{
|
||||
Probe,
|
||||
}
|
||||
|
||||
/// <summary>See <see cref="HeadlessBotPolicyDescriptor.Role"/>.</summary>
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<HeadlessBotPolicyRole>))]
|
||||
internal enum HeadlessBotPolicyRole
|
||||
|
|
|
|||
|
|
@ -182,6 +182,57 @@ internal static class HeadlessConfigurationLoader
|
|||
$"Session '{session.Id}' requires a non-empty account.");
|
||||
}
|
||||
|
||||
ValidateModeShape(session);
|
||||
|
||||
if (session.Credential is null
|
||||
|| string.IsNullOrWhiteSpace(session.Credential.Reference))
|
||||
{
|
||||
throw new HeadlessConfigurationException(
|
||||
$"Session '{session.Id}' requires a credential reference.");
|
||||
}
|
||||
|
||||
ValidateCharacterOptions(session);
|
||||
ValidateLaunchContractFields(session);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA2: mode-dependent requiredness for
|
||||
/// <see cref="HeadlessSessionDescriptor.Character"/>/
|
||||
/// <see cref="HeadlessSessionDescriptor.Policy"/> — this REPLACES the
|
||||
/// former `[JsonRequired]` attributes on both properties (which fired
|
||||
/// unconditionally at deserialize time, before a probe session's
|
||||
/// omission could ever be distinguished from a play session's mistake).
|
||||
/// A play session (mode absent) keeps EXACTLY today's strictness: a
|
||||
/// missing/malformed character selector or a missing policy id still
|
||||
/// fails load, just via <see cref="HeadlessConfigurationException"/>
|
||||
/// naming the field instead of a raw <see cref="JsonException"/> citing
|
||||
/// "missing required properties" — same exit code (3,
|
||||
/// <c>HeadlessExitCode.ConfigurationError</c>) either way, more specific
|
||||
/// text now (an accepted improvement, not a contract change). A probe
|
||||
/// session (mode "probe") must OMIT both fields entirely — the pinned
|
||||
/// contract keeps the shape unambiguous by rejecting a probe session
|
||||
/// that also declares a selector or a policy, rather than silently
|
||||
/// ignoring them.
|
||||
/// </summary>
|
||||
private static void ValidateModeShape(HeadlessSessionDescriptor session)
|
||||
{
|
||||
if (session.Mode == HeadlessSessionMode.Probe)
|
||||
{
|
||||
if (session.Character is not null)
|
||||
{
|
||||
throw new HeadlessConfigurationException(
|
||||
$"Session '{session.Id}' has mode \"probe\" and must omit "
|
||||
+ "'character' — a probe never selects a character.");
|
||||
}
|
||||
if (session.Policy is not null)
|
||||
{
|
||||
throw new HeadlessConfigurationException(
|
||||
$"Session '{session.Id}' has mode \"probe\" and must omit "
|
||||
+ "'policy' — a probe never drives a bot policy.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.Character is null)
|
||||
{
|
||||
throw new HeadlessConfigurationException(
|
||||
|
|
@ -206,16 +257,6 @@ internal static class HeadlessConfigurationLoader
|
|||
throw new HeadlessConfigurationException(
|
||||
$"Session '{session.Id}' requires a non-empty policy id.");
|
||||
}
|
||||
|
||||
if (session.Credential is null
|
||||
|| string.IsNullOrWhiteSpace(session.Credential.Reference))
|
||||
{
|
||||
throw new HeadlessConfigurationException(
|
||||
$"Session '{session.Id}' requires a credential reference.");
|
||||
}
|
||||
|
||||
ValidateCharacterOptions(session);
|
||||
ValidateLaunchContractFields(session);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -200,6 +200,22 @@ internal sealed class HeadlessProcessHost : IDisposable
|
|||
foreach (HeadlessSessionHost session in _sessions)
|
||||
{
|
||||
RuntimeSessionStartResult started = session.Start();
|
||||
// Campaign LA slice LA2: ProbeComplete is a SUCCESS variant, not
|
||||
// a connection failure — the session already connected, reported
|
||||
// its roster, and gracefully disconnected before EnterWorld (see
|
||||
// LiveSessionController's probe short-circuit). Continue to the
|
||||
// next configured session instead of returning ConnectionError,
|
||||
// so a probe session sharing a process with play sessions never
|
||||
// tears the others down. ProbeHeadlessBotPolicy already reports
|
||||
// IsComplete, so the scheduler below skips this session entirely.
|
||||
if (started.Status == RuntimeSessionStartStatus.ProbeComplete)
|
||||
{
|
||||
_diagnostics.Lifecycle(
|
||||
session.SessionId,
|
||||
"probed",
|
||||
session.Runtime);
|
||||
continue;
|
||||
}
|
||||
if (started.Status != RuntimeSessionStartStatus.Connected)
|
||||
{
|
||||
if (started.Error is { } error)
|
||||
|
|
|
|||
|
|
@ -319,7 +319,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 +366,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,
|
||||
|
|
@ -636,11 +647,18 @@ 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: a probe session that
|
||||
// never faulted reports reason "probe" here instead of
|
||||
// "disposed" — the pinned contract's exit event for a
|
||||
// successful probe.
|
||||
_statusWriter.Exited(
|
||||
_descriptor.Id,
|
||||
_faulted ? 1 : 0,
|
||||
_faulted ? "fault" : "disposed");
|
||||
_faulted
|
||||
? "fault"
|
||||
: _descriptor.Mode == HeadlessSessionMode.Probe
|
||||
? "probe"
|
||||
: "disposed");
|
||||
_disposeStage++;
|
||||
_disposed = true;
|
||||
break;
|
||||
|
|
@ -717,7 +735,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;
|
||||
|
|
@ -964,12 +983,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)
|
||||
|
|
@ -988,6 +1014,8 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
RuntimeSessionStartStatus.Deferred,
|
||||
LiveSessionStartStatus.Failed =>
|
||||
RuntimeSessionStartStatus.Failed,
|
||||
LiveSessionStartStatus.ProbeComplete =>
|
||||
RuntimeSessionStartStatus.ProbeComplete,
|
||||
_ => throw new ArgumentOutOfRangeException(
|
||||
nameof(result),
|
||||
result.Status,
|
||||
|
|
|
|||
|
|
@ -100,6 +100,21 @@ internal static class HeadlessBotPolicyFactory
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA2: the "idle" consumer policy id — the session enters
|
||||
/// world (unchanged <see cref="HeadlessSessionHost"/> start/select/EnterWorld
|
||||
/// path) and then does nothing actively: no chat, no movement, no combat.
|
||||
/// <see cref="IsComplete"/> is permanently <see langword="false"/>, so
|
||||
/// <see cref="HeadlessProcessScheduler"/> keeps ticking the session
|
||||
/// (harmlessly — <see cref="Tick"/> and every delta handler below are no-ops)
|
||||
/// until the process is stopped (SIGINT/cancellation) or disposed; teardown
|
||||
/// then rides <see cref="HeadlessSessionHost.Dispose"/>'s existing graceful
|
||||
/// stop/logout path — the same mechanism K4's endurance gate already proved.
|
||||
/// No <see cref="HeadlessBotPolicyDescriptor.Role"/> is required. This class
|
||||
/// predates LA2 (introduced at K1 as dev/test scaffolding); LA2 formalizes it
|
||||
/// as the documented headless "just sit in world" play policy and adds
|
||||
/// focused coverage in <c>HeadlessBotPolicyTests</c>.
|
||||
/// </summary>
|
||||
internal sealed class IdleHeadlessBotPolicy : IHeadlessBotPolicy
|
||||
{
|
||||
public bool IsComplete => false;
|
||||
|
|
@ -149,6 +164,70 @@ internal sealed class IdleHeadlessBotPolicy : IHeadlessBotPolicy
|
|||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA2: the policy substituted (never selected via
|
||||
/// <see cref="HeadlessBotPolicyFactory.Create"/> — a probe session's
|
||||
/// descriptor carries no <c>policy</c> id at all) for a
|
||||
/// <see cref="HeadlessSessionMode.Probe"/> session. <see cref="IsComplete"/>
|
||||
/// is <see langword="true"/> from construction, BEFORE
|
||||
/// <see cref="HeadlessSessionHost.Start"/> even runs, so
|
||||
/// <see cref="HeadlessProcessScheduler"/> never dispatches a tick to this
|
||||
/// session — a probe session's <see cref="AcDream.Runtime.Session.WorldSession"/>
|
||||
/// is already gracefully torn down by
|
||||
/// <see cref="AcDream.Runtime.Session.LiveSessionController"/>'s probe
|
||||
/// short-circuit by the time the scheduler would otherwise look at it, and a
|
||||
/// single-session probe process's <c>Run()</c> loop returns immediately
|
||||
/// instead of waiting for SIGINT.
|
||||
/// </summary>
|
||||
internal sealed class ProbeHeadlessBotPolicy : IHeadlessBotPolicy
|
||||
{
|
||||
public bool IsComplete => true;
|
||||
|
||||
public void Tick(
|
||||
IGameRuntimeView view,
|
||||
IGameRuntimeCommands commands)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(view);
|
||||
ArgumentNullException.ThrowIfNull(commands);
|
||||
}
|
||||
|
||||
public void OnLifecycle(in RuntimeLifecycleDelta delta)
|
||||
{
|
||||
}
|
||||
|
||||
public void OnCommand(in RuntimeCommandDelta delta)
|
||||
{
|
||||
}
|
||||
|
||||
public void OnEntity(in RuntimeEntityDelta delta)
|
||||
{
|
||||
}
|
||||
|
||||
public void OnInventory(in RuntimeInventoryDelta delta)
|
||||
{
|
||||
}
|
||||
|
||||
public void OnChat(in RuntimeChatDelta delta)
|
||||
{
|
||||
}
|
||||
|
||||
public void OnMovement(in RuntimeMovementDelta delta)
|
||||
{
|
||||
}
|
||||
|
||||
public void OnPortal(in RuntimePortalDelta delta)
|
||||
{
|
||||
}
|
||||
|
||||
public void OnCombat(in RuntimeCombatDelta delta)
|
||||
{
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Explicit connected-gate policy: wait for the local player, issue one
|
||||
/// harmless local-speech command and one lifestone recall, reconnect after
|
||||
|
|
|
|||
|
|
@ -27,6 +27,14 @@ public enum RuntimeSessionStartStatus
|
|||
Failed,
|
||||
Inactive,
|
||||
StaleGeneration,
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA2: mirrors
|
||||
/// <see cref="Session.LiveSessionStartStatus.ProbeComplete"/> — a probe
|
||||
/// session connected, reported its roster, and gracefully disconnected
|
||||
/// before EnterWorld. A SUCCESS outcome for the headless process host's
|
||||
/// exit-code mapping, not a failure.
|
||||
/// </summary>
|
||||
ProbeComplete,
|
||||
}
|
||||
|
||||
public readonly record struct RuntimeSessionStartResult(
|
||||
|
|
|
|||
|
|
@ -13,7 +13,23 @@ public sealed record LiveSessionConnectOptions(
|
|||
int Port,
|
||||
string User,
|
||||
string Password,
|
||||
LiveSessionCharacterSelector? Character = null);
|
||||
LiveSessionCharacterSelector? Character = null,
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA2: short-circuits
|
||||
/// <see cref="LiveSessionController"/>'s connect transaction right after
|
||||
/// the roster report (before <c>TrySelectCharacter</c>/
|
||||
/// <c>ApplySelectedCharacter</c>/<c>EnterWorld</c>) — connect, receive
|
||||
/// <c>CharacterList</c>, report the roster, gracefully disconnect via the
|
||||
/// same <c>StopCore</c> teardown the <see cref="LiveSessionStartStatus.NoCharacters"/>
|
||||
/// path already uses, and return
|
||||
/// <see cref="LiveSessionStartStatus.ProbeComplete"/>. The pinned launch
|
||||
/// contract (<c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1's
|
||||
/// <c>mode</c> field) requires a probe session to omit both
|
||||
/// <see cref="Character"/> and its policy entirely, but the controller
|
||||
/// itself does not enforce that pairing — the headless config loader
|
||||
/// does, before a <see cref="LiveSessionConnectOptions"/> is ever built.
|
||||
/// </summary>
|
||||
bool Probe = false);
|
||||
|
||||
public interface IRuntimeLiveSessionFramePhase
|
||||
{
|
||||
|
|
|
|||
|
|
@ -13,6 +13,14 @@ public enum LiveSessionStartStatus
|
|||
Connected,
|
||||
Deferred,
|
||||
Failed,
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA2: a <see cref="LiveSessionConnectOptions.Probe"/>
|
||||
/// session connected, received (and reported) the character roster, and
|
||||
/// gracefully disconnected BEFORE selection/EnterWorld — deliberately a
|
||||
/// SUCCESS variant of the <see cref="NoCharacters"/> early-exit shape
|
||||
/// (same <c>StopCore</c> teardown), not a failure.
|
||||
/// </summary>
|
||||
ProbeComplete,
|
||||
}
|
||||
|
||||
public readonly record struct LiveSessionOwnershipSnapshot(
|
||||
|
|
@ -647,6 +655,24 @@ public sealed class LiveSessionController
|
|||
return new LiveSessionStartResult(LiveSessionStartStatus.Deferred);
|
||||
}
|
||||
|
||||
// Campaign LA slice LA2: the probe short-circuit lands here —
|
||||
// right after the roster report, before TrySelectCharacter ever
|
||||
// runs — so a probe session never reaches selection,
|
||||
// ApplySelectedCharacter, or EnterWorld. This mirrors the
|
||||
// NoCharacters early-exit immediately below (same StopCore
|
||||
// teardown), the deliberate difference being the returned status
|
||||
// is a SUCCESS, not a failure. Non-probe callers (options.Probe
|
||||
// is false by default) fall straight through to the unchanged
|
||||
// selection/enter path below — byte-identical to pre-LA2
|
||||
// behavior.
|
||||
if (options.Probe)
|
||||
{
|
||||
Console.WriteLine(
|
||||
"live: probe complete — disconnecting before EnterWorld");
|
||||
StopCore();
|
||||
return new LiveSessionStartResult(LiveSessionStartStatus.ProbeComplete);
|
||||
}
|
||||
|
||||
if (characters is null
|
||||
|| !TrySelectCharacter(
|
||||
characters,
|
||||
|
|
|
|||
|
|
@ -316,6 +316,8 @@ public sealed class LiveSessionHost : IRuntimeSessionCommands
|
|||
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