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:
Erik 2026-08-14 16:33:00 +02:00
parent 498f1c1182
commit c601942467
14 changed files with 830 additions and 35 deletions

View file

@ -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,