Skip to content
Open
Show file tree
Hide file tree
Changes from 71 commits
Commits
Show all changes
78 commits
Select commit Hold shift + click to select a range
73db666
Plan the hand-off of an ended session end to end (#2132)
alexeyzimarev Oct 30, 2026
d2edd15
Fence the hand-off takeover by its lease and keep its outcome by sess…
alexeyzimarev Oct 30, 2026
899141b
Keep a cut-short hand-off takeover's progress and wait for its succes…
alexeyzimarev Oct 30, 2026
0b0cbe6
Mark a hand-off takeover cut short by a restart as interrupted (#2132)
alexeyzimarev Oct 30, 2026
89c90f2
Send the hand-off takeover begin as one request record (#2132)
alexeyzimarev Oct 30, 2026
1aaa104
Name the hand-off begin request's wire fields (#2132)
alexeyzimarev Oct 30, 2026
c7b18c0
Merge remote-tracking branch 'origin/main' into capacitor/agent-965b4…
alexeyzimarev Oct 30, 2026
dec7b8f
Carry a hand-off block on the daemon's launch command (#2132)
alexeyzimarev Oct 30, 2026
335fe3c
Build the first message of a hand-off from its source (#2132)
alexeyzimarev Oct 30, 2026
9579966
Resolve a hand-off's starting code over local control (#2132)
alexeyzimarev Oct 30, 2026
2ec9fa0
Report hand-off probe git failures as fetch errors (#2132)
alexeyzimarev Oct 30, 2026
9c9c858
Take over only a source the server reports ended (#2132)
alexeyzimarev Oct 30, 2026
e86ee65
Launch a hand-off in its resolved worktree with its source's context …
alexeyzimarev Oct 30, 2026
e948f14
Title a text-less hand-off after the session it continues (#2132)
alexeyzimarev Oct 30, 2026
c9687db
Shorten a hand-off's title like any derived title (#2132)
alexeyzimarev Oct 30, 2026
cfea4ed
Carry an ended session's work over under the server's lease (#2132)
alexeyzimarev Oct 30, 2026
b589fd5
Give a hand-off push command only for a plain, quoted branch (#2132)
alexeyzimarev Oct 30, 2026
8ccdc21
Retry a hand-off begin that never reached the server (#2132)
alexeyzimarev Oct 30, 2026
ed12cc9
Keep a hand-off takeover's outcome when it finishes during shutdown (…
alexeyzimarev Oct 30, 2026
bd35077
Refuse a hand-off of a session this daemon runs before begin (#2132)
alexeyzimarev Oct 30, 2026
f6538c9
Report a hand-off lease lost to a failed renewal as a lost reservatio…
alexeyzimarev Oct 30, 2026
3426e11
Refuse a malformed or scratch hand-off launch before consent (#2132)
alexeyzimarev Oct 30, 2026
46c8ad7
Check at most 200 of a hand-off's session commits (#2132)
alexeyzimarev Oct 30, 2026
dd9a355
Store a hand-off outcome's detail as a code the app can word (#2132)
alexeyzimarev Oct 30, 2026
b8350a8
Recreate the hand-off outcome directory owner-only as daemon start do…
alexeyzimarev Oct 30, 2026
560f32e
Store the interrupted hand-off outcome before its test daemon starts …
alexeyzimarev Oct 30, 2026
1fd0756
End a hand-off lease on its own deadline during shutdown (#2132)
alexeyzimarev Oct 30, 2026
80681c6
Ask the server whether it can hand off a session (#2132)
alexeyzimarev Oct 30, 2026
e14165c
Share the launcher's agent and permission chips as one control (#2132)
alexeyzimarev Oct 30, 2026
9b8f730
Wrap the launcher's attach button with the chips (#2132)
alexeyzimarev Oct 30, 2026
d92bd9b
Decide whether and from where an ended session can be handed off (#2132)
alexeyzimarev Oct 30, 2026
87a7f02
Hand a failed hand-off's draft back without moving the user (#2132)
alexeyzimarev Oct 30, 2026
20fa093
Keep a hand-off draft safe when its composer is gone (#2132)
alexeyzimarev Oct 30, 2026
57776e3
Observe a hand-off launch fault for the command's whole life (#2132)
alexeyzimarev Oct 30, 2026
bd0ab9d
Hand off an ended session from its composer (#2132)
alexeyzimarev Oct 30, 2026
fd2241f
Re-check a hand-off when its ended session's row changes (#2132)
alexeyzimarev Oct 30, 2026
2ff0244
Show what a hand-off carried over in the new session (#2132)
alexeyzimarev Oct 30, 2026
025a00b
Fix the hand-off outcome card's polling, size and wording (#2132)
alexeyzimarev Oct 30, 2026
9004f0f
Let a pinned session's unknown access level reach a hand-off (#2132)
alexeyzimarev Oct 30, 2026
576c8fb
Reopen a failed hand-off's source by pinning it first (#2132)
alexeyzimarev Oct 30, 2026
03e47c0
Show a hand-off's error, reason and progress on their own lines (#2132)
alexeyzimarev Oct 30, 2026
20db7c3
Bound and scope the server's launch-features read (#2132)
alexeyzimarev Oct 30, 2026
268d9d1
Refresh the hand-off composer from a below-full row in its test (#2132)
alexeyzimarev Oct 30, 2026
1291e48
Say why a below-full ended session can't be handed off (#2132)
alexeyzimarev Oct 30, 2026
3a68355
Clear a hand-off outcome card when its session id changes (#2132)
alexeyzimarev Oct 30, 2026
6550d99
Ask a signed-out user to sign in before a hand-off (#2132)
alexeyzimarev Oct 30, 2026
9029b67
Fold and cap server labels on the hand-off outcome card (#2132)
alexeyzimarev Oct 30, 2026
4a06bb5
Fold a hand-off's title in its not-started notice (#2132)
alexeyzimarev Oct 30, 2026
03b15db
Add the hand-off launch flag to the DaemonConnect golden fixture (#2132)
alexeyzimarev Oct 31, 2026
90bb081
Merge commit '03b15db67' into capacitor/agent-965b4395443c41
alexeyzimarev Oct 31, 2026
aba5d0b
Merge main into the hand-off daemon branch
alexeyzimarev Oct 31, 2026
8b86f68
Merge main into the hand-off app branch
alexeyzimarev Oct 31, 2026
9e03daf
Stop hand-off preparation when its launch is cancelled
alexeyzimarev Oct 31, 2026
99bace6
Finish a hand-off's takeover after a daemon restart
alexeyzimarev Oct 31, 2026
f0b3b66
Merge the hand-off daemon branch into the app branch
alexeyzimarev Oct 31, 2026
1dd8ef9
Keep a continued successor from taking over the hand-off source
alexeyzimarev Oct 31, 2026
bc98b1d
Run one hand-off takeover per successor at a time
alexeyzimarev Oct 31, 2026
4773170
Merge the hand-off daemon branch into the app branch
alexeyzimarev Oct 31, 2026
93504a8
Leave out a successor's held work items when a hand-off reruns
alexeyzimarev Oct 31, 2026
74f1ce6
Merge the hand-off daemon branch into the app branch
alexeyzimarev Oct 31, 2026
40e0a8c
Keep a hand-off rerun from overriding the successor's own choices
alexeyzimarev Oct 31, 2026
d8a03b7
Renew a hand-off lease within the lease the server advertises
alexeyzimarev Oct 31, 2026
18ced6a
Merge the hand-off daemon branch into the app branch
alexeyzimarev Oct 31, 2026
5a5ec3c
Read a plan-gated successor as holding no work items in a hand-off
alexeyzimarev Oct 31, 2026
0ede723
Merge the hand-off daemon branch into the app branch
alexeyzimarev Oct 31, 2026
35720cf
Retry only hand-off writes ordered after the last one a rerun finds made
alexeyzimarev Oct 31, 2026
ccbfb4f
Merge the hand-off daemon branch into the app branch
alexeyzimarev Oct 31, 2026
e65cf5c
Adopt a hand-off's plans in the order they were declared
alexeyzimarev Oct 31, 2026
f7c1fe7
Rerun a resumed successor's takeover only when it reads interrupted
alexeyzimarev Oct 31, 2026
1bea4be
Read a hand-off begin cut off by a dropped connection as unreachable
alexeyzimarev Oct 31, 2026
49bba59
Merge the hand-off daemon branch into the app branch
alexeyzimarev Oct 31, 2026
cfe5643
Clear a hand-off source from the resume entry once its takeover settles
alexeyzimarev Oct 31, 2026
2a16403
Merge the hand-off daemon branch into the app branch
alexeyzimarev Oct 31, 2026
fe4c877
Keep a hand-off source until the takeover's outcome is saved
alexeyzimarev Oct 31, 2026
786438d
Merge the hand-off daemon branch into the app branch
alexeyzimarev Oct 31, 2026
ff76a2b
Merge main into the hand-off app branch
alexeyzimarev Oct 31, 2026
d13c5e9
Keep the hand-off composer in step with its server and daemon (#2132)
alexeyzimarev Nov 1, 2026
59236c9
Keep every failed hand-off prompt for its source session (#2132)
alexeyzimarev Nov 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
258 changes: 258 additions & 0 deletions docs/superpowers/plans/2026-10-30-handoff-end-to-end.md

Large diffs are not rendered by default.

159 changes: 159 additions & 0 deletions docs/superpowers/plans/2026-10-30-handoff-server.md

Large diffs are not rendered by default.

40 changes: 34 additions & 6 deletions src/Capacitor.App/App.axaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ internal static TelemetryStartup DesktopTelemetry(
// last, since its transitions are what the feed subscribes to.
ServerPermissionFeed? _permissionFeed;
SessionAttentionTracker? _attention;
HandoffDrafts? _handoffDrafts;
BackgroundCommandActivity? _commands;
SessionAccessService? _sessionAccess;
PullRequestToneCache? _pullRequestTones;
Expand Down Expand Up @@ -426,7 +427,7 @@ await QuiesceAppAsync(_wizardAuth, _historyImport, _localInstall,
await HandleStartupFailureAsync(
desktop, ex, _service, _shutdown,
[_tray, _trayVm, _promptCoordinator, _consent, _desktopNotifications, _notificationSink,
_notificationSessions, _notificationSettings, _permissionFeed, _attention, _commands, _permissions, _accounts, _sessionAccess,
_notificationSessions, _notificationSettings, _permissionFeed, _attention, _handoffDrafts, _commands, _permissions, _accounts, _sessionAccess,
_pullRequestTones, _launchApprovals, _webWorkspace, _activity, _home, _localServer, _localStack, _rail, _pause, _staleDaemonNudge, _restartPending, _expectedRestart],
_lifecycle, _lane, _localUpgrade, _localSearch, _menuBar, _coordinator);
await DisposeServerClientsAsync(); // after _home above
Expand All @@ -449,6 +450,7 @@ await HandleStartupFailureAsync(
_notificationSettings = null;
_permissionFeed = null;
_attention = null;
_handoffDrafts = null;
_commands = null;
_permissions = null;
_accounts = null;
Expand Down Expand Up @@ -1034,7 +1036,8 @@ void BuildDaemonGraph(
onUnauthorized: serverLane.ParkSignedOut);
var sessions = new ServerSessionsService(
serverLane, ServerSessionsService.HttpFetch(sessionHttp, profiles), ServerSessionsService.HttpOwners(sessionHttp, profiles),
_time, onUnauthorized: serverLane.ParkSignedOut, fetchSummary: ServerSearchHttp.Summary(sessionHttp, profiles));
_time, onUnauthorized: serverLane.ParkSignedOut, fetchSummary: ServerSearchHttp.Summary(sessionHttp, profiles),
fetchHandoffContext: ServerSearchHttp.HandoffContext(sessionHttp, profiles));
var repoIdentity = new RepoIdentityResolver();
var directory = new AgentDirectory(
service, remoteAgents, serverLane, repoIdentity, GitRepository.ResolveMainRepoRoot,
Expand Down Expand Up @@ -1189,6 +1192,7 @@ void BuildDaemonGraph(
var transcripts = new LocalTranscripts();
var readings = new AccountReadings(accounts.Snapshot, remoteAgents.DrawableDaemonAccounts, UsageClock.Create(_time));
_usageClock = readings.Clock;
var handoffOutcomes = new HandoffOutcomeReader(service, ops, _time);
WorkspaceViewModel BuildWorkspace(string agentId) => new(
agentId, service, actions, attachFactory, () => new XtermTerminalSurface(80, 24, PtyDumpPath), _time, workspaceOpener, permissions,
workContext, ops, uploader,
Expand All @@ -1199,7 +1203,8 @@ void BuildDaemonGraph(
resumeLedger: id => ResumeLedgerStore.For(_daemonStore, service.DaemonName).TryRead(id),
pullRequestSnapshots: pullRequestSnapshots, transcripts: transcripts,
pullRequestLinksChanged: pullRequestTones.Refresh, readings: readings, senders: senders,
appSubject: appSubject, notifier: notifier, serverLane: serverLane.Status, workContextLayout: workContextLayout);
appSubject: appSubject, notifier: notifier, serverLane: serverLane.Status, workContextLayout: workContextLayout,
handoffOutcomes: handoffOutcomes);
// The origin lookup below and this call are two reads of a cache the directory's own
// background recompute mutates, so the row can be gone by the time this runs: no row, no
// host, and the click opens nothing.
Expand All @@ -1211,8 +1216,31 @@ void BuildDaemonGraph(
signInCompleted: serverClients.SignInCompleted, plans: plans, drafts: drafts, inputSending: attention.ReplySending,
pullRequestSnapshots: pullRequestSnapshots, pullRequestLinksChanged: pullRequestTones.Refresh, readSubagent: readSubagent,
readings: readings, senders: senders, remoteAgents: remoteAgents, notifier: notifier, appSubject: appSubject,
serverSessions: sessions, workContextLayout: workContextLayout)
serverSessions: sessions, workContextLayout: workContextLayout, handoffOutcomes: handoffOutcomes)
: null;
// One for the app's life: a failed hand-off's draft outlives the composer that sent it.
var handoffDrafts = new HandoffDrafts(serverLane.LaunchFailures, directory, _time);
_handoffDrafts = handoffDrafts;
var checkouts = new LocalCheckoutFinder(repoIdentity);
var knownRepos = new RepoPathStore(_config, _time);
async Task<IReadOnlyList<string>> HandoffCandidatesAsync(CancellationToken ct) {
var remembered = (await _appState.LoadAsync()).HarnessByRepo?.Keys ?? [];
var known = await knownRepos.GetSortedPathsAsync();
var agents = service.Agents.Items.Select(a => a.RepoPath).OfType<string>().Where(p => p.Length > 0);
return [.. remembered, .. known, .. agents];
}
MainWindowViewModel? OpenWindow() => _coordinator?.Window?.DataContext as MainWindowViewModel;
HandoffComposerViewModel BuildHandoff(AgentRow row) => new(
HandoffComposerViewModel.SourceOf(row), service, launch, sessions, ops, checkouts, HandoffCandidatesAsync,
handoffDrafts, serverLane.Status, uploader, directory,
navigationGeneration: () => OpenWindow()?.NavigationGeneration ?? 0,
openSessionIfCurrent: (agentId, origin, generation) => OpenWindow()?.OpenSessionIfCurrent(agentId, origin, generation),
closeFailedLaunch: agentId => OpenWindow()?.CloseFailedLaunch(agentId),
// Unconditional: the failed launch's close has just moved the navigation generation on.
reopenSource: (sessionId, presentation) =>
OpenWindow() is { } window ? window.ReopenServerSessionAsync(sessionId, presentation) : Task.FromResult(false),
notifier: notifier,
modelCatalog: modelCatalog.Catalog, shutdown: _shutdown.Token);
// A session the server lists with no daemon behind it: read-only, so no terminal surface.
RemoteSessionViewModel? BuildServer(string sessionId) =>
directory.Rows.Lookup($"server:{sessionId}") is { HasValue: true, Value: var row }
Expand All @@ -1222,7 +1250,7 @@ void BuildDaemonGraph(
signInCompleted: serverClients.SignInCompleted, plans: plans, drafts: drafts, inputSending: attention.ReplySending,
pullRequestSnapshots: pullRequestSnapshots, pullRequestLinksChanged: pullRequestTones.Refresh, readSubagent: readSubagent,
readings: readings, remoteAgents: remoteAgents, notifier: notifier, appSubject: appSubject, serverSessions: sessions,
workContextLayout: workContextLayout)
workContextLayout: workContextLayout, handoff: BuildHandoff, handoffOutcomes: handoffOutcomes)
: null;

_coordinator = new MainWindowCoordinator(
Expand Down Expand Up @@ -2880,7 +2908,7 @@ await QuiesceAppAsync(_wizardAuth, _historyImport, _localInstall, _lifecycle, _l
await Dispatcher.UIThread.InvokeAsync(async () => {
IDisposable?[] ui = [
_tray, _trayVm, _promptCoordinator, _consent, _desktopNotifications, _notificationSink,
_notificationSessions, _notificationSettings, _permissionFeed, _attention, _commands, _permissions, _accounts, _sessionAccess,
_notificationSessions, _notificationSettings, _permissionFeed, _attention, _handoffDrafts, _commands, _permissions, _accounts, _sessionAccess,
_pullRequestTones, _launchApprovals, _webWorkspace, _activity, _home, _localServer, _localStack, _rail, _pause, _staleDaemonNudge, _restartPending, _expectedRestart,
];
if (shutdown) {
Expand Down
6 changes: 6 additions & 0 deletions src/Capacitor.App/Services/HandoffContextFetch.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
using Capacitor.Remote.Models;

namespace Capacitor.App.Services;

/// Unavailable is a session the server hides from the caller or does not hold; Context is set only on Ok.
public sealed record HandoffContextFetch(ServerReadOutcome Outcome, HandoffContextDto? Context);
10 changes: 10 additions & 0 deletions src/Capacitor.App/Services/HandoffDraft.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using Capacitor.App.ViewModels;
using Capacitor.Cli.Core;

namespace Capacitor.App.Services;

/// A hand-off composer's contents as the chips showed them: PermissionMode and CodexPosture are the
/// chip values, not what the launch sent. Error is the failure that handed the draft back.
public sealed record HandoffDraft(
string Text, IReadOnlyList<StagedAttachment> Attachments, string Vendor, string Model, string? Effort,
string? PermissionMode, CodexLaunchPosture? CodexPosture, string? Error);
121 changes: 121 additions & 0 deletions src/Capacitor.App/Services/HandoffDrafts.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
using System.Reactive.Disposables;
using System.Reactive.Disposables.Fluent;
using System.Reactive.Linq;
using System.Reactive.Subjects;
using Capacitor.App.ViewModels;
using DynamicData;
using ReactiveUI.Reactive;

namespace Capacitor.App.Services;

/// Hand-off drafts awaiting their launch's verdict, for the app's lifetime: the composer that sent
/// one is gone once the new session opens, and a failure must hand the draft back to the source
/// session's next composer. Failures are buffered from construction, because one can arrive before
/// the launch call returns and the hold that would match it is made.
public sealed class HandoffDrafts : IDisposable {
static readonly TimeSpan PendingTtl = TimeSpan.FromMinutes(10);
static readonly TimeSpan RecentFailureTtl = TimeSpan.FromSeconds(30);

sealed record Held(string SourceSessionId, HandoffDraft Draft, DateTimeOffset At, Action<string>? OnFailed);

readonly Lock _lock = new();
readonly Dictionary<string, Held> _pending = new(StringComparer.Ordinal);
readonly Dictionary<string, (string Reason, DateTimeOffset At)> _recentFailures = new(StringComparer.Ordinal);
readonly Dictionary<string, HandoffDraft> _failed = new(StringComparer.Ordinal);
readonly IAgentDirectory? _directory;
readonly TimeProvider _time;
readonly CompositeDisposable _subscriptions = new();
readonly Subject<string> _failedFor = new();

public HandoffDrafts(IObservable<LaunchFailure> failures, IAgentDirectory? directory, TimeProvider time) {
_directory = directory;
_time = time;
// Settled on the lane's thread, so a hold made before the UI hop still sees the failure.
failures
.Select(Settle)
.Where(onFailed => onFailed is not null)
.ObserveOn(RxSchedulers.MainThreadScheduler)
.Subscribe(onFailed => onFailed!())
.DisposeWith(_subscriptions);
directory?.Rows.Connect()
.Subscribe(changes => {
foreach (var change in changes)
if (change.Reason is ChangeReason.Add or ChangeReason.Update
&& change.Current is { IsPlaceholder: false, Origin: AgentOrigin.Local } row)
Confirm(row.Id);
})
.DisposeWith(_subscriptions);
}

/// onFailed runs on the UI thread, with the worded failure, when a failure for the agent arrives
/// after this hold.
public HandoffHold Hold(string sourceSessionId, string agentId, HandoffDraft draft, Action<string>? onFailed = null) {
var id = Key(agentId);
if (RowExists(id)) return HandoffHold.AlreadyStarted;
lock (_lock) {
var now = _time.GetUtcNow();
if (_recentFailures.Remove(id, out var failure) && now - failure.At <= RecentFailureTtl) {
Comment thread
alexeyzimarev marked this conversation as resolved.
Resolved
_failed[sourceSessionId] = draft with { Error = HandoffFailureText.For(failure.Reason) };
return HandoffHold.AlreadyFailed;
}
_pending[id] = new Held(sourceSessionId, draft, now, onFailed);
}
// The row can land between the first check and the registration.
if (!RowExists(id)) return HandoffHold.Pending;
Confirm(id);
return HandoffHold.AlreadyStarted;
}

/// The source session of a draft a later failure handed back, on the UI thread, after the
/// hold's onFailed. Whichever composer calls TakeFailed first gets the draft.
public IObservable<string> FailedFor => _failedFor;

/// A draft whose composer is gone before its launch settled, for the source's next composer.
public void HoldFailed(string sourceSessionId, HandoffDraft draft) {
lock (_lock) _failed[sourceSessionId] = draft;
}
Comment thread
alexeyzimarev marked this conversation as resolved.
Resolved

public HandoffDraft? TakeFailed(string sourceSessionId) {
lock (_lock) return _failed.Remove(sourceSessionId, out var draft) ? draft : null;
}

public void Confirm(string agentId) {
var id = Key(agentId);
lock (_lock) {
Comment thread
alexeyzimarev marked this conversation as resolved.
Resolved
_pending.Remove(id);
_recentFailures.Remove(id);
}
}

Action? Settle(LaunchFailure failure) {
var id = Key(failure.AgentId);
lock (_lock) {
var now = _time.GetUtcNow();
if (_pending.Remove(id, out var held) && now - held.At <= PendingTtl) {
var worded = HandoffFailureText.For(failure.Reason);
_failed[held.SourceSessionId] = held.Draft with { Error = worded };
return () => {
_directory?.RemovePlaceholder(id);
held.OnFailed?.Invoke(worded);
_failedFor.OnNext(held.SourceSessionId);
};
}
_recentFailures[id] = (failure.Reason, now);
foreach (var stale in _recentFailures.Where(kv => now - kv.Value.At > RecentFailureTtl).Select(kv => kv.Key).ToList())
_recentFailures.Remove(stale);
foreach (var stale in _pending.Where(kv => now - kv.Value.At > PendingTtl).Select(kv => kv.Key).ToList())
_pending.Remove(stale);
return null;
}
}

bool RowExists(string id) =>
_directory?.Rows.Items.Any(r => r is { IsPlaceholder: false, Origin: AgentOrigin.Local } && Key(r.Id) == id) == true;

static string Key(string agentId) => AgentIds.Normalize(agentId) ?? agentId;

public void Dispose() {
_subscriptions.Dispose();
_failedFor.Dispose();
}
}
5 changes: 5 additions & 0 deletions src/Capacitor.App/Services/HandoffHold.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
namespace Capacitor.App.Services;

/// What holding a hand-off draft found. AlreadyFailed: a failure for the agent arrived before the
/// hold, and the draft waits in TakeFailed. AlreadyStarted: the agent's row is already listed.
public enum HandoffHold { Pending, AlreadyFailed, AlreadyStarted }
62 changes: 62 additions & 0 deletions src/Capacitor.App/Services/HandoffOutcomeReader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using System.Reactive.Linq;
using Capacitor.Cli.Core.Handoff;
using Capacitor.Cli.Core.LocalIpc;

namespace Capacitor.App.Services;

/// Follows the daemon's stored hand-off outcome for a session id. The daemon keeps it by the
/// successor's id, so the answer is the same whether the view is the live agent or the server row
/// the successor became.
public sealed class HandoffOutcomeReader(IDaemonClientService daemon, ILocalControlOps ops, TimeProvider time) {
internal static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(2);
internal static readonly TimeSpan NullWindow = TimeSpan.FromMinutes(2);

/// Emits each outcome that differs in value from the last: every read deserializes fresh lists,
/// so reference equality would re-emit each poll. An answer of null (nothing stored yet, or a
/// failed read) is retried for <see cref="NullWindow"/>; a running outcome is followed without limit.
/// Completes on a terminal state or when the window lapses, and emits nothing for a daemon
/// without handoff/1.
public IObservable<HandoffOutcomeDto> Watch(string sessionId) =>
daemon.Status
.SkipWhile(status => status.State != AttachState.Connected)
.Take(1)
.SelectMany(status => status.Capabilities?.Contains(HandoffWire.Capability) == true
? Poll(sessionId)
Comment thread
alexeyzimarev marked this conversation as resolved.
Resolved
: Observable.Empty<HandoffOutcomeDto>());

/// The chat card's stream for one session id, inside a Switch over ids: it opens with null so a
/// card from the previous id is cleared rather than carried over.
public static IObservable<HandoffOutcomeDto?> CardFor(HandoffOutcomeReader reader, string? sessionId) =>
sessionId is null
? Observable.Return<HandoffOutcomeDto?>(null)
: reader.Watch(sessionId).Select(outcome => (HandoffOutcomeDto?)outcome).StartWith((HandoffOutcomeDto?)null);

IObservable<HandoffOutcomeDto> Poll(string sessionId) =>
Observable.Create<HandoffOutcomeDto>(async (observer, ct) => {
var started = time.GetTimestamp();
var seen = false;
HandoffOutcomeDto? last = null;
try {
while (!ct.IsCancellationRequested) {
var outcome = await Task.Run(() => ops.ReadHandoffOutcomeAsync(sessionId, ct), ct).ConfigureAwait(false);
if (outcome is not null) {
seen = true;
if (!Same(outcome, last)) observer.OnNext(outcome);
last = outcome;
if (outcome.State != "running") break;
Comment thread
alexeyzimarev marked this conversation as resolved.
Resolved
} else if (!seen && time.GetElapsedTime(started) >= NullWindow) {
break;
}
await Task.Delay(PollInterval, time, ct).ConfigureAwait(false);
}
observer.OnCompleted();
} catch (OperationCanceledException) when (ct.IsCancellationRequested) {
} catch (Exception ex) {
observer.OnError(ex);
}
});

internal static bool Same(HandoffOutcomeDto a, HandoffOutcomeDto? b) =>
b is not null && a.SourceSessionId == b.SourceSessionId && a.SourceTitle == b.SourceTitle
&& a.State == b.State && a.Detail == b.Detail && a.Items.SequenceEqual(b.Items);
}
Loading
Loading