Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
76 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
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
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 @@ -1035,7 +1037,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 @@ -1190,6 +1193,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 @@ -1200,7 +1204,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 @@ -1212,8 +1217,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 @@ -1223,7 +1251,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 @@ -2889,7 +2917,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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep early failures until the launch call settles

If LaunchFailed arrives before StartAsync returns but the hub invocation takes more than 30 seconds, this age check discards the buffered failure and registers the already-failed launch as pending. The caller then adds and opens a placeholder, while the source draft is never restored because that failure broadcast is not repeated. Since the launch invocation has no matching 30-second timeout, retain an early failure until its Hold consumes it or use a lifetime at least as long as the launch can remain in flight.

Useful? React with πŸ‘Β / πŸ‘Ž.

_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 on lines +74 to +76

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Notify composers when storing a post-disposal failure

If the user navigates away during an upload or launch and reopens the source before that operation fails, the new composer can finish InitializeAsync before this method stores the draft. HoldFailed updates the dictionary without publishing FailedFor, so that already-open composer never takes the returned message or attachments; they remain hidden until the workspace is recreated again. Emit the same source-session notification used by delayed launch failures after storing the draft.

Useful? React with πŸ‘Β / πŸ‘Ž.


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) {
_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 on lines +21 to +24

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Retry outcome discovery after a capable daemon reconnects

When the workspace opens while the first connected daemon lacks handoff/1, Take(1) consumes that status and the empty branch completes the watcher permanently. Updating or restarting the daemon with hand-off support therefore never reads its persisted outcome, so the continuation card remains absent until the workspace is recreated; keep observing connection/capability transitions until a capable daemon is available.

Useful? React with πŸ‘Β / πŸ‘Ž.

: 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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Continue polling resumable interrupted outcomes

When the daemon restarts during a takeover, HandoffOutcomeStore converts the persisted running outcome to interrupted, but StartHandoffTakeover may later resume that same successor and replace the outcome with running and then done. This unconditional break treats the intermediate interrupted state as terminal, so a watcher that reads it first permanently leaves the card saying the hand-off was cut short even after the resumed takeover completes; keep polling this resumable state.

Useful? React with πŸ‘Β / πŸ‘Ž.

} 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