docs: Campaign LA — pinned launch-contract schema COMMITTED into plan LA1
The LA3 Opus review process note was right: the contract both sides implement lived only in orchestrator prompts, which is exactly the drift mode the pin exists to prevent (and it produced the paths-key CRITICAL). The schema, field rules, probe-mode discriminator, and status vocabulary are now a binding plan section; amendments change this text first, implementations second. Ledger: LA3 fix round dispatched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
0bcc7ba3a3
commit
db9ad53c1c
38 changed files with 2397 additions and 40 deletions
|
|
@ -72,6 +72,38 @@ internal sealed record HeadlessSessionDescriptor
|
|||
/// legal no-ops.
|
||||
/// </summary>
|
||||
public Dictionary<string, bool>? CharacterOptions { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: plugin ids to load from the standard plugins
|
||||
/// directory (<c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1).
|
||||
/// Absent means load every discovered plugin (today's dev behavior);
|
||||
/// LA5 wires this into an actual allow-list filter. Parsed and carried
|
||||
/// here now so the session-config shape is stable before LA5 lands.
|
||||
/// </summary>
|
||||
public List<string>? Plugins { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: ordered chat-typed strings run once the
|
||||
/// session enters world. LA6 wires actual execution; parsed and carried
|
||||
/// here now.
|
||||
/// </summary>
|
||||
public List<string>? LoginCommands { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: inter-command delay for
|
||||
/// <see cref="LoginCommands"/>, in milliseconds. Matches the pinned
|
||||
/// launch-contract default (500 ms) when the field is absent from the
|
||||
/// document.
|
||||
/// </summary>
|
||||
public int LoginCommandDelayMs { get; init; } = 500;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: absolute path for this session's status-event
|
||||
/// JSONL stream (<c>docs/superpowers/specs/2026-08-14-launcher-campaign-design.md</c>
|
||||
/// §6). Absent means no <see cref="AcDream.Runtime.Session.SessionStatusWriter"/>
|
||||
/// is constructed for this session.
|
||||
/// </summary>
|
||||
public string? StatusFile { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class HeadlessEndpointDescriptor
|
||||
|
|
|
|||
|
|
@ -215,6 +215,42 @@ internal static class HeadlessConfigurationLoader
|
|||
}
|
||||
|
||||
ValidateCharacterOptions(session);
|
||||
ValidateLaunchContractFields(session);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: validates the four new optional per-session
|
||||
/// fields shared with the App session-config reader (see
|
||||
/// <c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1's pinned
|
||||
/// contract). All four stay optional; only their SHAPE is checked here
|
||||
/// — parsing/executing <c>plugins</c>/<c>loginCommands</c> is LA5/LA6.
|
||||
/// </summary>
|
||||
private static void ValidateLaunchContractFields(HeadlessSessionDescriptor session)
|
||||
{
|
||||
if (session.Plugins is { } plugins)
|
||||
{
|
||||
foreach (string? plugin in plugins)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(plugin))
|
||||
{
|
||||
throw new HeadlessConfigurationException(
|
||||
$"Session '{session.Id}' plugins entries must be non-empty strings.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (session.LoginCommandDelayMs < 0)
|
||||
{
|
||||
throw new HeadlessConfigurationException(
|
||||
$"Session '{session.Id}' loginCommandDelayMs must be non-negative.");
|
||||
}
|
||||
|
||||
if (session.StatusFile is not null
|
||||
&& string.IsNullOrWhiteSpace(session.StatusFile))
|
||||
{
|
||||
throw new HeadlessConfigurationException(
|
||||
$"Session '{session.Id}' statusFile must be a non-empty path when present.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
|
|
@ -113,6 +113,19 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
private readonly HeadlessCredentialSecret _credential;
|
||||
private readonly HeadlessDiagnosticWriter _diagnostics;
|
||||
/// <summary>
|
||||
/// Campaign LA slice LA1: a SEPARATE per-session sink from
|
||||
/// <see cref="_diagnostics"/> — a no-op instance when
|
||||
/// <see cref="HeadlessSessionDescriptor.StatusFile"/> was not configured.
|
||||
/// See <see cref="SessionStatusWriter"/>'s own doc for why this is not a
|
||||
/// rework of the shared-stdout diagnostics writer.
|
||||
/// </summary>
|
||||
private readonly SessionStatusWriter _statusWriter;
|
||||
/// <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
|
||||
/// disconnect.</summary>
|
||||
private bool _hasConnected;
|
||||
/// <summary>
|
||||
/// Campaign OP slice OP7 (2026-08-11), D8: the parsed
|
||||
/// <c>characterOptions</c> block — empty when the config omitted it.
|
||||
/// Parsed once at construction; <see cref="HeadlessConfigurationLoader"/>
|
||||
|
|
@ -271,6 +284,10 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
var commands = new DirectGameRuntimeCommandAdapter(
|
||||
runtime,
|
||||
bridge);
|
||||
// Campaign LA slice LA1: no-op instance when
|
||||
// descriptor.StatusFile is unset — every call site below stays
|
||||
// unconditional.
|
||||
var statusWriter = new SessionStatusWriter(descriptor.StatusFile);
|
||||
var liveSession = new LiveSessionHost(
|
||||
runtime.Session,
|
||||
new LiveSessionHostBindings(
|
||||
|
|
@ -318,14 +335,25 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
descriptor.Id,
|
||||
$"connecting:{host}:{port}:{user}",
|
||||
runtime.Generation.Value),
|
||||
() => diagnostics.Message(
|
||||
() =>
|
||||
{
|
||||
diagnostics.Message(
|
||||
descriptor.Id,
|
||||
"connected",
|
||||
runtime.Generation.Value);
|
||||
statusWriter.Connected(descriptor.Id);
|
||||
_hasConnected = true;
|
||||
},
|
||||
roster => statusWriter.CharacterList(descriptor.Id, roster),
|
||||
selection => statusWriter.EnteredWorld(
|
||||
descriptor.Id,
|
||||
"connected",
|
||||
runtime.Generation.Value)));
|
||||
selection.CharacterId,
|
||||
selection.CharacterName)));
|
||||
|
||||
Runtime = runtime;
|
||||
Commands = commands;
|
||||
_liveSession = liveSession;
|
||||
_statusWriter = statusWriter;
|
||||
_localPlayerFrame =
|
||||
runtime.CreateLocalPlayerFrameController(
|
||||
new HeadlessLocalPlayerFrameHost(
|
||||
|
|
@ -421,8 +449,13 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
_pendingConfirmation = null;
|
||||
}
|
||||
|
||||
internal RuntimeSessionStartResult Start() =>
|
||||
Commands.Session.Start(Runtime.Generation);
|
||||
internal RuntimeSessionStartResult Start()
|
||||
{
|
||||
// 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);
|
||||
}
|
||||
|
||||
internal RuntimeSessionStartResult Reconnect() =>
|
||||
Commands.Session.Reconnect(Runtime.Generation);
|
||||
|
|
@ -470,6 +503,15 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
// (possibly disposed) WorldSession in the window between this Stop
|
||||
// and the next CreateEventRoute call.
|
||||
_currentSession = null;
|
||||
// Campaign LA slice LA1: only report a disconnect for a session that
|
||||
// actually reached Connected — a Stop() on a never-started or
|
||||
// never-connected host (e.g. immediate Dispose()) is not a real
|
||||
// disconnect.
|
||||
if (_hasConnected)
|
||||
{
|
||||
_hasConnected = false;
|
||||
_statusWriter.Disconnected(_descriptor.Id, "stopped");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
|
@ -592,6 +634,13 @@ internal sealed class HeadlessSessionHost : IDisposable
|
|||
_descriptor.Id,
|
||||
"disposed",
|
||||
_stoppedGeneration);
|
||||
// Campaign LA slice LA1: "exited" = terminal — the sole
|
||||
// point every disposal path (graceful and post-
|
||||
// quarantine) converges on.
|
||||
_statusWriter.Exited(
|
||||
_descriptor.Id,
|
||||
_faulted ? 1 : 0,
|
||||
_faulted ? "fault" : "disposed");
|
||||
_disposeStage++;
|
||||
_disposed = true;
|
||||
break;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue