Skip to content

[RemoteChat] - Add authorization, host, and viewer lifecycle #1487

Description

@JoshuaRowePhantom

Part of #1483

Summary

Implement authenticated attach authorization, host dispatch, owner-authoritative non-optimistic queues, runtime and attachment leases, replay, and default final-viewer shutdown with five-second unexpected-loss grace; persisted Continue in background can retain the runtime.

Scope

Implement only commit 4 of the approved design represented by commit
0c6578641befad31d5a38003baf6c0f0fe64748a. Preserve the contracts below
exactly; local and remote behavior must remain compatible.

This is the host/runtime request-API implementation item. The complete audited design contains
28 request/options types: 22 public and 6 internal. This child owns the five non-wire internal
request types used by authorization, host dispatch, runtime termination/retention, and attachment.
It consumes, but does not redefine ownership of, the property-based protocol and persisted-intent
records from its dependencies.

API shape convention

  • APIs with multiple independent values use one property-based *Request or *Options record plus
    an optional CancellationToken. Semantically required members are required init; optional
    members have explicit defaults.
  • Constructors remain compact value objects or cohesive dependency sets. Existing framework
    overrides retain inherited signatures.
  • Protocol DTO/property records retain strict serializers, fixed discriminators, kebab-case JSON,
    unknown-member rejection, and all existing wire semantics.
  • There is no InterruptAgentSessionRequest. The client API remains exactly
    Task InterruptAsync(Guid commandId, CancellationToken ct = default).

Shared protocol state forms

The host snapshot/event path uses the exact property-based state forms below:

public readonly record struct Usage
{
    public Usage() { }
    public long? TotalInputTokenCount { get; init; } = null;
    public long? TotalOutputTokenCount { get; init; } = null;
    public long? TotalCacheReadTokenCount { get; init; } = null;
    public long? TotalCacheWriteTokenCount { get; init; } = null;
    public long? TotalReasoningTokenCount { get; init; } = null;
    public double? TotalSessionCostUsd { get; init; } = null;
}

public readonly record struct AgentInformation
{
    public AgentInformation() { }
    public required string AgentSessionId { get; init; }
    public required string AgentId { get; init; }
    public required string Name { get; init; }
    public required string DisplayName { get; init; }
    public required string Description { get; init; }
    public required bool AcceptsUserInput { get; init; }
    public string? CurrentModelId { get; init; } = null;
    public required AgentDefinition AgentDefinition { get; init; }
}

Usage permits null for a metric the provider did not report; otherwise counts and USD cost are
nonnegative. AgentInformation requires nonnull, nonblank identity/display strings, a null-or-
nonblank model id, and a complete nonnull definition. Authorization completes before runtime lookup,
snapshot construction, or definition serialization. Authorized viewers receive the same complete
definition; unauthorized peers receive no definition bytes, metadata, or existence signal.

Files

Files: peer identity provider, attach authorizer, listener, host, runtime registry, runtime and
attachment leases, replay buffer, transport composition registration.
Tests: listener, authorizer, host, registry, replay limits, multiple viewers, sanitized errors, and
no existence disclosure, including default final-viewer stop, persisted background retention,
five-second reconnect grace, attach/stop races, explicit-terminate precedence, complete graceful
cleanup, queue convergence, and reconnect snapshot/replay.

Detailed design

AgentSessionTransportListener - New

  • Namespace/project/file: Phantom.Workspaces.Services.AgentSessions;
    Phantom.Workspaces/Services/AgentSessions/AgentSessionTransportListener.cs.
  • Visibility/kind: public sealed class : ITransportListener.
  • Responsibility/lifetime/threading: transport dispatch adapter; per accepted channel it returns
    the attachment lease supplied by the host.
internal AgentSessionTransportListener(
    RemoteAgentSessionHost host,
    ITransportPeerIdentityProvider peerIdentityProvider);
public Task<IAsyncDisposable?> OnChannelOpenAsync(
    JsonElement request, IMessageChannel channel, CancellationToken ct = default);
public Task<IAsyncDisposable?> OnStreamOpenAsync(
    JsonElement request, Stream stream, CancellationToken ct = default);
public ValueTask DisposeAsync();

OnChannelOpenAsync returns null for a discriminator other than attach-agent-session. For a match,
it uses strict deserialization, obtains authenticated peer identity, and calls the host. Validation
or authorization failures write one sanitized terminal error and close; authorization occurs before
runtime lookup. OnStreamOpenAsync always returns null. Disposal stops accepting channels and
releases listener-owned active attachment leases. It does not directly dispose runtime leases, but
final attachment release applies the normal background policy; owner-host application shutdown then
calls RemoteAgentSessionHost.DisposeAsync, which fences and stops every remaining runtime
regardless of that policy.

Attach identity and authorization - New

  • Namespace/project/files: transport-authenticated
    TransportPeerIdentity.cs and ITransportPeerIdentityProvider.cs remain in
    Phantom.Workspaces.Transport; IAgentSessionAttachAuthorizer.cs is in
    Phantom.Workspaces/Services/AgentSessions.
  • Visibility/kind: public sealed record TransportPeerIdentity; two internal interfaces.
  • Ownership: each authenticated transport adapter records identity in a channel-keyed feature
    provider. Anonymous or ambiguous channels have no identity and fail closed.
public sealed record TransportPeerIdentity
{
    public required string AuthenticationScheme { get; init; }
    public required string StablePeerId { get; init; }
    public string? UserEntityId { get; init; } = null;
    public string? UserComputerProfileEntityId { get; init; } = null;
}

internal interface ITransportPeerIdentityProvider
{
    TransportPeerIdentity GetRequiredIdentity(IMessageChannel channel);
}

internal interface IAgentSessionAttachAuthorizer
{
    ValueTask<AgentSessionAuthorizationDecision> AuthorizeAsync(
        TransportPeerIdentity peer,
        AgentSessionAuthorizationRequest request,
        CancellationToken ct = default);
}

internal sealed record AgentSessionAuthorizationRequest
{
    public required string AgentSessionId { get; init; }
    public required string ExpectedOwningProfileEntityId { get; init; }
    public required long ExpectedOwnershipGeneration { get; init; }
    public required AgentSessionAuthorizationOperation Operation { get; init; }
    public string? ChildAgentId { get; init; } = null;
}

internal readonly record struct AgentSessionAuthorizationDecision
{
    public required bool IsAllowed { get; init; }
}
internal enum AgentSessionAuthorizationOperation
{
    Status, Open, Reconnect, Send, SetToolState, SetBackgroundPreference, Interrupt, Terminate, OpenSubagent,
    ModalResponse, Takeover
}

internal sealed class AgentSessionAttachAuthorizer : IAgentSessionAttachAuthorizer
{
    internal AgentSessionAttachAuthorizer(IDataAccessLayer dataAccessLayer);
}

TransportPeerIdentity rejects blank authentication scheme/stable peer id. Optional entity ids are
either null or nonblank canonical entity ids; these values are authenticated claims and are not
serialized by the attach protocol.

GetRequiredIdentity returns only transport-authenticated claims, never request fields, and throws a
sanitized unauthenticated error when absent. The concrete internal
AgentSessionAttachAuthorizer(IDataAccessLayer) loads the session's owning
user-computer-profile, resolves that profile's user, and allows attach/mutation only when:
(a) request owner and generation exactly match persistence; (b) peer.UserEntityId equals the
owning profile's user entity id; and (c) the authenticated transport's
UserComputerProfileEntityId, when present, resolves to that same user. Takeover additionally
requires the proposed new profile to resolve to the same user. Child attach additionally requires
the child id to be in the owning runtime's subagent registry. There is no caller-authored ACL or
request-supplied identity. Authorization runs for open/reconnect and every mutation. Denial is
indistinguishable from nonexistent session on the wire. Cancellation performs no mutation. The
authorizer is stateless and process-scoped.

Consumed protocol request values

The host consumes the exact strict property-based protocol values below from #1486:

public readonly record struct RuntimeEpoch
{
    public required Guid Value { get; init; }
}

public readonly record struct ReplayCursor
{
    public required RuntimeEpoch Epoch { get; init; }
    public required long Sequence { get; init; }
}

public enum AgentSessionOpenIntent { Status, Start, Attach, StartOrAttach, Resume }
public enum AgentSessionRemoteStatus { Running, NotRunning, Unavailable }

public sealed record AgentSessionOpenRequest
{
    public required int ProtocolVersion { get; init; }
    public required string AgentSessionId { get; init; }
    public required string ExpectedOwningProfileEntityId { get; init; }
    public required long ExpectedOwnershipGeneration { get; init; }
    public required AgentSessionOpenIntent OpenIntent { get; init; }
    public required string AttachmentToken { get; init; }
    public ReplayCursor? ReplayCursor { get; init; } = null;
    public required IReadOnlyList<string> Capabilities { get; init; }
}

internal sealed record AgentSessionTakeoverRequest
{
    public required string AgentSessionId { get; init; }
    public required string ExpectedOwningProfileEntityId { get; init; }
    public required long ExpectedOwnershipGeneration { get; init; }
    public required string NewOwningProfileEntityId { get; init; }
    public required Guid CorrelationId { get; init; }
}

These retain version-1 kebab-case serialization, strict unknown-member rejection, and fixed protocol
semantics. They carry stable identity and intent only, never authenticated peer claims, compiled
policy, local paths, process handles, or credentials.

RemoteAgentSessionHost - New

  • Namespace/project/file: Phantom.Workspaces.Services.AgentSessions;
    Phantom.Workspaces/Services/AgentSessions/RemoteAgentSessionHost.cs.
  • Visibility/kind: internal sealed class.
  • Responsibility/lifetime/threading: application-layer coordinator between authorization,
    persistence/runtime hydration, the runtime registry, and a transport channel.
internal RemoteAgentSessionHost(
    IAgentSessionAttachAuthorizer authorizer,
    IRemoteAgentSessionRuntimeRegistry runtimeRegistry,
    IAgentSessionRuntimeContextFactory runtimeContextFactory);
internal Task<AgentSessionRemoteStatus> GetStatusAsync(
    TransportPeerIdentity peer,
    AgentSessionOpenRequest request,
    CancellationToken ct = default);
internal Task<RemoteAgentAttachmentLease> OpenAsync(
    OpenAgentSessionHostRequest request, CancellationToken ct = default);
internal Task TakeOverAsync(
    TransportPeerIdentity peer,
    AgentSessionTakeoverRequest request,
    CancellationToken ct = default);
internal ValueTask DisposeAsync();

internal sealed record OpenAgentSessionHostRequest
{
    public required TransportPeerIdentity Peer { get; init; }
    public required AgentSessionOpenRequest OpenRequest { get; init; }
    public required IMessageChannel Channel { get; init; }
}

GetStatusAsync accepts only OpenIntent.Status, authorizes before lookup, and exposes no other
runtime state. OpenAsync rejects Status, authorizes before lookup, validates owner/generation,
then atomically gets or starts the
runtime according to OpenIntent; Attach never starts, Start fails if already running, and
StartOrAttach/Resume reuse the matching runtime. It creates the attachment before taking a
snapshot so no delta is lost, then emits snapshot-or-replay. TakeOverAsync authorizes takeover,
fences mutations, awaits old runtime disposal, compare/exchanges owner/generation, and starts nothing
until persistence succeeds. A live unconfirmed lease returns takeover-blocked. Host disposal drains
attachments then runtime leases. No method accepts a compiled policy.

The ownership lease is a persisted compare/exchange record keyed by session and generation. It uses
the data service's authoritative timestamp, is renewed every 10 seconds, and expires after 30 seconds.
Each successful renewal returns the authoritative expiry instant. If renewal fails or is uncertain,
the owner retries once per second but fences the runtime no later than five seconds before the last
confirmed expiry; after fencing it accepts no mutation and performs graceful stop. It never assumes
renewal from a local clock or transient response. Orderly stop releases the lease only after
terminal/stopped persistence; a crashed host cannot renew it.
After expiry, restart/takeover first marks the abandoned epoch stopped, then creates a new epoch.
continue-in-background survives that recovery but is not an auto-resume instruction: only an
ordinary open or the existing auto-resume entity setting starts the replacement runtime.

Runtime and attachment storage - New

  • Namespace/project/files: Phantom.Workspaces.Services.AgentSessions;
    IRemoteAgentSessionRuntimeRegistry.cs, RemoteAgentSessionRuntimeRegistry.cs,
    RemoteAgentSessionLease.cs, RemoteAgentAttachmentLease.cs, AgentSessionReplayBuffer.cs.
  • Visibility/kind: registry interface/implementation and leases are internal; epoch/cursor value
    types are public protocol values.
  • Ownership: the process-scoped registry owns one runtime lease per
    (AgentSessionId, OwnershipGeneration). The lease owns execution resources; its lifecycle gate
    also tracks logical attachment count and the persisted background preference.
internal interface IRemoteAgentSessionRuntimeRegistry
{
    ValueTask<RemoteAgentSessionLease?> TryGetAsync(
        string sessionId, long ownershipGeneration, CancellationToken ct = default);
    ValueTask<RemoteAgentSessionLease> GetOrStartAsync(
        PersistedAgentSessionRuntimeIntent intent,
        Func<CancellationToken, Task<RemoteAgentSessionLease>> startAsync,
        CancellationToken ct = default);
    ValueTask<bool> TryTerminateAsync(
        TerminateAgentSessionRuntimeRequest request, CancellationToken ct = default);
    ValueTask SetContinueInBackgroundAsync(
        UpdateAgentSessionRuntimeRetentionRequest request, CancellationToken ct = default);
}

internal sealed record TerminateAgentSessionRuntimeRequest
{
    public required string SessionId { get; init; }
    public required long OwnershipGeneration { get; init; }
    public required RuntimeEpoch Epoch { get; init; }
}

internal sealed record UpdateAgentSessionRuntimeRetentionRequest
{
    public required string SessionId { get; init; }
    public required long OwnershipGeneration { get; init; }
    public required RuntimeEpoch Epoch { get; init; }
    public required bool ContinueInBackground { get; init; }
}

internal sealed class RemoteAgentSessionRuntimeRegistry
    : IRemoteAgentSessionRuntimeRegistry, IAsyncDisposable
{
    internal RemoteAgentSessionRuntimeRegistry(TimeProvider timeProvider);
    public ValueTask DisposeAsync();
}

internal sealed class RemoteAgentSessionLease : IAsyncDisposable
{
    internal RuntimeEpoch Epoch { get; }
    internal IAgentChat Chat { get; }
    internal AgentSessionReplayBuffer Replay { get; }
    internal RemoteAgentAttachmentLease Attach(AttachRemoteAgentSessionRequest request);
    internal ValueTask SetContinueInBackgroundAsync(
        bool continueInBackground, CancellationToken ct = default);
    public ValueTask DisposeAsync();
}

internal sealed record AttachRemoteAgentSessionRequest
{
    public required string AttachmentToken { get; init; }
    public required IMessageChannel Channel { get; init; }
    public ReplayCursor? Cursor { get; init; } = null;
}

internal sealed class RemoteAgentAttachmentLease : IAsyncDisposable
{
    internal ReplayCursor Cursor { get; }
    internal ValueTask PublishAsync(
        AgentSessionServerEvent value, CancellationToken ct = default);
    public ValueTask DisposeAsync();
}

internal sealed class AgentSessionReplayBuffer
{
    internal long HighWaterMark { get; }
    internal AgentSessionServerFrame Append(
        Guid correlationId, AgentSessionServerEvent value);
    internal ReplayReadResult ReadAfter(ReplayCursor cursor);
}

internal readonly record struct ReplayReadResult
{
    public required bool IsCovered { get; init; }
    public required IReadOnlyList<AgentSessionServerFrame> Frames { get; init; }
}

GetOrStartAsync is single-flight and returns the existing matching lease; a failed/cancelled factory
is removed. TryTerminateAsync succeeds only on exact generation+epoch, marks the registry entry
fenced in place first, then
performs graceful stop once; explicit terminate always wins over attach, reconnect, grace, or
preference changes. Runtime stop rejects new mutations, interrupts an active turn when necessary,
unsubscribes the local event bridge, disposes AgentChat, component transports, MXC/process-executor
leases, wrappers, stdio MCP transports, and contained children, persists terminal/stopped state,
emits terminal once before writable channels close, and only then removes the registry entry. A
fenced entry rejects attach/start so no replacement epoch can appear before cleanup completes.

Attach subscribes before snapshot capture and increments the logical viewer count under the same
lifecycle gate. Explicit attachment disposal decrements immediately. Unexpected channel loss marks
the attachment token disconnected and starts one TimeProvider-driven five-second timer; reconnect
with that token cancels the timer and keeps the count unchanged. Timer expiry releases the viewer.
When a release changes the count to zero and ContinueInBackground is false, graceful stop starts.
SetContinueInBackgroundAsync validates the exact generation/epoch, persists first, then updates the
runtime and emits SessionRetentionChangedEvent; setting false at zero viewers starts stop in the
same serialized transition. Attach, release, grace expiry, and preference updates emit an ordered
SessionRetentionChangedEvent whenever either authoritative value changes; snapshots carry both
values. A later attach cannot cancel a fenced stop.

Named initializer examples

The child-owned request records are constructed by name, never with transposable positional values:

var authorizationRequest = new AgentSessionAuthorizationRequest
{
    AgentSessionId = agentSessionId,
    ExpectedOwningProfileEntityId = owningProfileEntityId,
    ExpectedOwnershipGeneration = ownershipGeneration,
    Operation = AgentSessionAuthorizationOperation.Open,
};

var hostRequest = new OpenAgentSessionHostRequest
{
    Peer = peer,
    OpenRequest = openRequest,
    Channel = channel,
};

var terminateRequest = new TerminateAgentSessionRuntimeRequest
{
    SessionId = sessionId,
    OwnershipGeneration = ownershipGeneration,
    Epoch = epoch,
};

var retentionRequest = new UpdateAgentSessionRuntimeRetentionRequest
{
    SessionId = sessionId,
    OwnershipGeneration = ownershipGeneration,
    Epoch = epoch,
    ContinueInBackground = true,
};

var attachRequest = new AttachRemoteAgentSessionRequest
{
    AttachmentToken = attachmentToken,
    Channel = channel,
    Cursor = cursor,
};

The strict protocol open descriptor consumed here is likewise property-based:

var openRequest = new AgentSessionOpenRequest
{
    ProtocolVersion = 1,
    AgentSessionId = agentSessionId,
    ExpectedOwningProfileEntityId = owningProfileEntityId,
    ExpectedOwnershipGeneration = ownershipGeneration,
    OpenIntent = AgentSessionOpenIntent.Attach,
    AttachmentToken = attachmentToken,
    Capabilities = capabilities,
};

AgentSessionReplayBuffer retains the newest 4,096 events subject to an 8 MiB serialized-size cap
and a 15-minute age cap. Append and cursor reads are locked and sequence-monotonic. A reconnect gets
replay only when epoch matches and every sequence after its cursor is retained; otherwise it gets one
new snapshot whose sequence is the current high-water mark. Snapshot generation and event append use
the runtime's serialized scheduler, preventing snapshot/delta gaps.

Lifecycle

  • An owning runtime has a host-owned RemoteAgentSessionLease; each attached GUI has an independent RemoteAgentAttachmentLease. The runtime serializes attachment-count, retention-preference, and termination transitions under one lifecycle gate.
  • continue-in-background is an explicit persisted per-session preference, defaults to false, and is copied into every runtime snapshot. When the last viewer detaches and the value is false, the owner gracefully stops the runtime. When it is true, the runtime may continue with zero viewers until explicit termination, takeover, owner-host shutdown, or runtime-lease expiry.
  • Explicit detach, proxy disposal, UI tab close, and graceful viewer-application shutdown release that viewer immediately. Unexpected channel/transport loss instead reserves that logical attachment for a five-second reconnect grace period; reconnect with the same attachment token cancels expiry without changing the logical viewer count. When the grace expires, the attachment is released and the last-viewer rule runs.
  • A new attach racing with last-viewer stop is serialized by the lifecycle gate. If attachment reservation wins, stop is cancelled and attach receives the existing epoch. If fencing wins, that epoch accepts no attach or mutation; Attach returns the indistinguishable not-found result and StartOrAttach may create a fresh epoch only after terminal persistence and registry removal complete.
  • Multiple viewers are independent: removing any non-final viewer never stops the runtime. Setting continue-in-background to true preserves a zero-viewer runtime; setting it to false while viewers remain changes persistence and the snapshot but does not interrupt the run; setting it to false when the viewer count is already zero starts graceful stop immediately.
  • Owner-host application shutdown and runtime-lease expiry stop every runtime regardless of the preference. Viewer-application shutdown is only a graceful detach. A host crash cannot emit a terminal frame, but process containment kills owned children and recovery records the interrupted epoch as stopped before any new epoch starts; the persisted preference remains unchanged and does not itself imply auto-resume.
  • Graceful stop first fences the epoch so it accepts no new mutations, cancels/interrupts an active turn if needed, disposes AgentChat, component transports, MXC/process-executor leases, wrappers, stdio MCP transports, and contained child sessions/trees, persists the terminal completion and stopped state, then removes the registry entry. It emits exactly one session-terminal event after persistence and before closing attachment channels where the transport remains writable.
  • Interrupt cancels only the active run; terminate ends the owning runtime. These are distinct protocol verbs and UI actions.

Security and failure semantics

  • attach-agent-session authenticates the transport peer and authorizes that peer for the requested owning profile and session before revealing whether the session exists.
  • Attach authorization is evaluated on every initial attach, reconnect, child-subagent attach, mutation verb, and takeover request. Existing execution-target reachability alone is not sufficient proof of session access.
  • MXC does not authorize attach, and transport authorization does not sandbox processes.
  • A required containment compile, handoff, wrapper, or launch failure fails closed. No layer retries with a null policy or direct uncontained launch.
  • Host-local details remain in protected logs. Wire errors contain only a stable error code, safe operation category, retryability, user-safe message, and correlation id. They exclude policy JSON, grants, local paths, environment values, command arguments, stderr containing secrets, native handles, and credentials.
  • operation-error terminates the affected operation without necessarily terminating the owning session. A fatal runtime error additionally emits session-terminal.
  • MXC is a preview dependency and must not be described as a production-grade security boundary.

Tests

AgentSessionTransportListenerTests (Phantom.Workspaces.Tests)

  • OnChannelOpenAsync_OtherType_ReturnsNull.
  • OnChannelOpenAsync_ValidAttach_ReturnsAttachmentLease.
  • OnChannelOpenAsync_MalformedRequest_WritesSanitizedTerminalError.
  • OnChannelOpenAsync_UnauthenticatedChannel_DoesNotLookupSession.
  • OnChannelOpenAsync_UnauthorizedPeer_DoesNotRevealSessionExistence.
  • OnStreamOpenAsync_AnyRequest_ReturnsNull.
  • DisposeAsync_ActiveAttachments_ReleasesViewersThenHostStopsAllRuntimes.

Property/request-shape coverage:

  • Usage_DefaultInitialization_AllOptionalMetricsAreNull.
  • Usage_NamedInitializer_PreservesExactMetricTypes.
  • AgentInformation_RequiredInitProperties_AreMarkedRequired.
  • AgentInformation_NamedInitializer_PreservesAllFields.
  • TransportPeerIdentity_BlankAuthenticatedIdentity_RejectsInitialization.
  • OpenAgentSessionHostRequest_NamedInitializer_MapsPeerOpenRequestAndChannel.
  • RuntimeRequestTypes_RequiredMembersDefaultsAndNamedInitializers_MapToLifecycleOperations.
  • AgentSessionAuthorizationRequest_RequiredMembersDefaultsAndNamedInitializer_MapToAuthorization.

RemoteAgentSessionHostTests:

  • OpenAsync_Start_CreatesOneRuntimeAndSnapshot.
  • OpenAsync_AttachMissingRuntime_ReturnsIndistinguishableNotFound.
  • OpenAsync_StartOrAttachConcurrent_CreatesOneRuntime.
  • GetStatusAsync_AuthorizedPeer_ReturnsRunningOrNotRunningWithoutAttaching.
  • GetStatusAsync_UnauthorizedPeer_ReturnsUnavailableWithoutRuntimeLookup.
  • OpenAsync_ReconnectCoveredCursor_ReplaysWithoutSnapshotOrRelaunch.
  • OpenAsync_ReconnectExpiredCursor_SendsSnapshotWithoutRelaunch.
  • OpenAsync_ChildSubagent_ReauthorizesMembership.
  • Command_DuplicateIdSamePayload_ReturnsCachedResultWithoutMutation.
  • Command_DuplicateIdDifferentPayload_ReturnsConflict.
  • QueueCommand_StaleExpectedRevision_ReturnsConflictWithoutMutation.
  • QueueCommand_InvalidQueueOrItem_ReturnsRejectedWithoutDelta.
  • SetToolEnabledCommand_EachMutation_ReauthorizesAndBroadcastsToolsEvent.
  • QueueCommand_Applied_BroadcastsOneOrderedDeltaToEveryViewer.
  • QueueCommand_Applied_TaskCompletesAfterAuthoritativeRevisionApplied.
  • QueueConsumption_CurrentRun_BroadcastsOwnerRevisionAfterEnqueue.
  • Command_EachMutation_ReauthorizesPeer.
  • TakeOverAsync_ConfirmedOldTermination_AdvancesGenerationThenStartsNewEpoch.
  • TakeOverAsync_UnconfirmedLiveLease_FailsClosed.
  • OwnershipLease_RenewalEveryTenSeconds_ExtendsThirtySecondExpiry.
  • OwnershipLease_RenewalUncertain_FencesBeforeSafetyDeadline.
  • OwnershipLease_HostCrashExpires_RecoveryMarksOldEpochStoppedBeforeRestart.
  • DisposeAsync_HostShutdown_DisposesAllRuntimeTrees.
  • OpenAsync_AttachRacesLastViewerStop_WinnerDeterminesExistingOrFreshEpoch.
  • OpenAsync_ReconnectAfterGrace_ReturnsNotFoundWithoutStartingRuntime.
  • OpenAsync_UnauthorizedPeer_DoesNotSerializeDefinitionOrSessionMetadata.
  • SetContinueInBackgroundAsync_ZeroViewersFalse_StopsImmediately.

RemoteAgentSessionRuntimeRegistryTests:

  • GetOrStartAsync_ConcurrentCallers_StartsFactoryOnce.
  • GetOrStartAsync_CancelledFactory_RemovesFailedEntry.
  • TryGetAsync_WrongGeneration_ReturnsNull.
  • TryTerminateAsync_ExactEpoch_FencesThenDisposesOnce.
  • TryTerminateAsync_StaleEpoch_ReturnsFalse.
  • Attach_MultipleViewers_UsesIndependentAttachmentLeases.
  • AttachmentDispose_NonFinalViewer_DoesNotDisposeRuntime.
  • AttachmentDispose_LastViewer_DefaultPolicy_DisposesRuntime.
  • AttachmentDispose_LastViewer_BackgroundEnabled_PreservesRuntime.
  • TransportLoss_ReconnectWithinFiveSeconds_ReusesAttachmentAndEpoch.
  • TransportLoss_GraceExpiresAsLastViewer_DefaultPolicy_DisposesRuntimeAndChildren.
  • TransportLoss_GraceExpiresAsLastViewer_BackgroundEnabled_PreservesRuntimeAndChildren.
  • SetContinueInBackground_TrueWithViewers_PersistsWithoutStopping.
  • SetContinueInBackground_FalseWithViewers_PersistsWithoutStopping.
  • SetContinueInBackground_FalseAtZeroViewers_StopsImmediately.
  • TryTerminateAsync_ConcurrentAttachOrPreferenceChange_TerminateWins.
  • RuntimeDispose_ActiveAttachments_DisposesChildrenPersistsThenEmitsOneTerminal.
  • RuntimeDispose_ActiveTurn_FencesInterruptsPersistsTerminalThenClosesChannels.
  • HostCrash_Restart_RecordsInterruptedEpochStoppedAndPreservesPreference.
  • ReplayBuffer_Over4096Events_DropsOldest.
  • ReplayBuffer_Over8MiB_DropsOldest.
  • ReplayBuffer_Over15Minutes_ForcesSnapshotFallback.

AgentSessionAttachAuthorizerTests:

  • AuthorizeAsync_OwnerPeerAllowed_ReturnsAllow.
  • AuthorizeAsync_UnrelatedPeerDenied_ReturnsIndistinguishableDenial.
  • AuthorizeAsync_MutationAfterAclChange_DeniesPreviouslyAttachedPeer.
  • AuthorizeAsync_TakeoverWithoutOwnerPermission_Denies.
  • AuthorizeAsync_Cancelled_PerformsNoRuntimeLookupOrMutation.

Considered / Background

Option A — Extend ChatClientOverTransport

Add session attach, tools, subagents, modals, lifecycle, and replay to the existing chat-client
protocol.

Pros: reuses an existing adapter and framing.

Cons: overloads IChatClient, mixes per-run and whole-session lifetimes, and makes reconnect and
independent owning-runtime lifetime difficult.

Option B — Dedicated attach-agent-session protocol (chosen)

Add a session-oriented listener/client pair over existing message channels. Keep
ChatClientOverTransport focused on the lower-level remote chat-client topology.

Pros: clean ownership, authorization, replay, lifecycle, and error boundaries; supports multiple
viewers and subagent channels.

Cons: adds a second protocol vocabulary and new proxy abstraction.

Option C — Entity-store mirror plus thin control channel

Read persisted history/tools/subagents through entity observation and use a channel only for live
state and control.

Pros: less data on the control channel.

Cons: creates two ordering domains and cannot reliably reconstruct unpersisted streaming, queue,
modal, and lifecycle state.

The dedicated protocol keeps per-run IChatClient lifetime distinct from persistent AgentChat
lifetime. Separate runtime and attachment leases make default final-viewer shutdown compatible with
a five-second reconnect grace. Reconnect reuses attachment token, epoch, and cursor and never
reconstructs the runtime. Takeover transfers persisted intent only; host-local confinement state is
recompiled. Wire failures remain sanitized while detailed diagnostics stay in protected host logs.

Implementation commit mapping

This issue is Commit 4 — Add authorization, host, and viewer lifecycle of the approved ten-commit
plan.

  • Files: property-based peer identity/authorization/host/runtime request records, peer identity
    provider, attach authorizer, listener, host, runtime registry, runtime and attachment leases,
    replay buffer, and transport composition registration.
  • Tests: required-member/default/named-initializer mapping for all five non-wire internal request
    types, listener, authorizer, host, registry, replay limits, multiple viewers, sanitized errors, and
    no existence disclosure, including default final-viewer stop, persisted background retention,
    five-second reconnect grace, attach/stop races, explicit-terminate precedence, complete graceful
    cleanup, queue convergence, and reconnect snapshot/replay.
  • Dependencies: design commits 1 and 3, represented by [RemoteChat] - Persist and hydrate runtime intent #1484 and [RemoteChat] - Add strict protocol and proxy state #1486.

Hierarchy and dependencies

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

bugSomething isn't workingverified-locallyImplementation has been verified locally

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions