Repository navigation
Hand off an ended session from the desktop app #2205
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
alexeyzimarev
wants to merge
78
commits into
main
Choose a base branch
from
capacitor/handoff-slice2-app
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+4,480
−714
Open
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 d2edd15
Fence the hand-off takeover by its lease and keep its outcome by sess…
alexeyzimarev 899141b
Keep a cut-short hand-off takeover's progress and wait for its succes…
alexeyzimarev 0b0cbe6
Mark a hand-off takeover cut short by a restart as interrupted (#2132)
alexeyzimarev 89c90f2
Send the hand-off takeover begin as one request record (#2132)
alexeyzimarev 1aaa104
Name the hand-off begin request's wire fields (#2132)
alexeyzimarev c7b18c0
Merge remote-tracking branch 'origin/main' into capacitor/agent-965b4…
alexeyzimarev dec7b8f
Carry a hand-off block on the daemon's launch command (#2132)
alexeyzimarev 335fe3c
Build the first message of a hand-off from its source (#2132)
alexeyzimarev 9579966
Resolve a hand-off's starting code over local control (#2132)
alexeyzimarev 2ec9fa0
Report hand-off probe git failures as fetch errors (#2132)
alexeyzimarev 9c9c858
Take over only a source the server reports ended (#2132)
alexeyzimarev e86ee65
Launch a hand-off in its resolved worktree with its source's context …
alexeyzimarev e948f14
Title a text-less hand-off after the session it continues (#2132)
alexeyzimarev c9687db
Shorten a hand-off's title like any derived title (#2132)
alexeyzimarev cfea4ed
Carry an ended session's work over under the server's lease (#2132)
alexeyzimarev b589fd5
Give a hand-off push command only for a plain, quoted branch (#2132)
alexeyzimarev 8ccdc21
Retry a hand-off begin that never reached the server (#2132)
alexeyzimarev ed12cc9
Keep a hand-off takeover's outcome when it finishes during shutdown (…
alexeyzimarev bd35077
Refuse a hand-off of a session this daemon runs before begin (#2132)
alexeyzimarev f6538c9
Report a hand-off lease lost to a failed renewal as a lost reservatio…
alexeyzimarev 3426e11
Refuse a malformed or scratch hand-off launch before consent (#2132)
alexeyzimarev 46c8ad7
Check at most 200 of a hand-off's session commits (#2132)
alexeyzimarev dd9a355
Store a hand-off outcome's detail as a code the app can word (#2132)
alexeyzimarev b8350a8
Recreate the hand-off outcome directory owner-only as daemon start do…
alexeyzimarev 560f32e
Store the interrupted hand-off outcome before its test daemon starts …
alexeyzimarev 1fd0756
End a hand-off lease on its own deadline during shutdown (#2132)
alexeyzimarev 80681c6
Ask the server whether it can hand off a session (#2132)
alexeyzimarev e14165c
Share the launcher's agent and permission chips as one control (#2132)
alexeyzimarev 9b8f730
Wrap the launcher's attach button with the chips (#2132)
alexeyzimarev d92bd9b
Decide whether and from where an ended session can be handed off (#2132)
alexeyzimarev 87a7f02
Hand a failed hand-off's draft back without moving the user (#2132)
alexeyzimarev 20fa093
Keep a hand-off draft safe when its composer is gone (#2132)
alexeyzimarev 57776e3
Observe a hand-off launch fault for the command's whole life (#2132)
alexeyzimarev bd0ab9d
Hand off an ended session from its composer (#2132)
alexeyzimarev fd2241f
Re-check a hand-off when its ended session's row changes (#2132)
alexeyzimarev 2ff0244
Show what a hand-off carried over in the new session (#2132)
alexeyzimarev 025a00b
Fix the hand-off outcome card's polling, size and wording (#2132)
alexeyzimarev 9004f0f
Let a pinned session's unknown access level reach a hand-off (#2132)
alexeyzimarev 576c8fb
Reopen a failed hand-off's source by pinning it first (#2132)
alexeyzimarev 03e47c0
Show a hand-off's error, reason and progress on their own lines (#2132)
alexeyzimarev 20db7c3
Bound and scope the server's launch-features read (#2132)
alexeyzimarev 268d9d1
Refresh the hand-off composer from a below-full row in its test (#2132)
alexeyzimarev 1291e48
Say why a below-full ended session can't be handed off (#2132)
alexeyzimarev 3a68355
Clear a hand-off outcome card when its session id changes (#2132)
alexeyzimarev 6550d99
Ask a signed-out user to sign in before a hand-off (#2132)
alexeyzimarev 9029b67
Fold and cap server labels on the hand-off outcome card (#2132)
alexeyzimarev 4a06bb5
Fold a hand-off's title in its not-started notice (#2132)
alexeyzimarev 03b15db
Add the hand-off launch flag to the DaemonConnect golden fixture (#2132)
alexeyzimarev 90bb081
Merge commit '03b15db67' into capacitor/agent-965b4395443c41
alexeyzimarev aba5d0b
Merge main into the hand-off daemon branch
alexeyzimarev 8b86f68
Merge main into the hand-off app branch
alexeyzimarev 9e03daf
Stop hand-off preparation when its launch is cancelled
alexeyzimarev 99bace6
Finish a hand-off's takeover after a daemon restart
alexeyzimarev f0b3b66
Merge the hand-off daemon branch into the app branch
alexeyzimarev 1dd8ef9
Keep a continued successor from taking over the hand-off source
alexeyzimarev bc98b1d
Run one hand-off takeover per successor at a time
alexeyzimarev 4773170
Merge the hand-off daemon branch into the app branch
alexeyzimarev 93504a8
Leave out a successor's held work items when a hand-off reruns
alexeyzimarev 74f1ce6
Merge the hand-off daemon branch into the app branch
alexeyzimarev 40e0a8c
Keep a hand-off rerun from overriding the successor's own choices
alexeyzimarev d8a03b7
Renew a hand-off lease within the lease the server advertises
alexeyzimarev 18ced6a
Merge the hand-off daemon branch into the app branch
alexeyzimarev 5a5ec3c
Read a plan-gated successor as holding no work items in a hand-off
alexeyzimarev 0ede723
Merge the hand-off daemon branch into the app branch
alexeyzimarev 35720cf
Retry only hand-off writes ordered after the last one a rerun finds made
alexeyzimarev ccbfb4f
Merge the hand-off daemon branch into the app branch
alexeyzimarev e65cf5c
Adopt a hand-off's plans in the order they were declared
alexeyzimarev f7c1fe7
Rerun a resumed successor's takeover only when it reads interrupted
alexeyzimarev 1bea4be
Read a hand-off begin cut off by a dropped connection as unreachable
alexeyzimarev 49bba59
Merge the hand-off daemon branch into the app branch
alexeyzimarev cfe5643
Clear a hand-off source from the resume entry once its takeover settles
alexeyzimarev 2a16403
Merge the hand-off daemon branch into the app branch
alexeyzimarev fe4c877
Keep a hand-off source until the takeover's outcome is saved
alexeyzimarev 786438d
Merge the hand-off daemon branch into the app branch
alexeyzimarev ff76a2b
Merge main into the hand-off app branch
alexeyzimarev d13c5e9
Keep the hand-off composer in step with its server and daemon (#2132)
alexeyzimarev 59236c9
Keep every failed hand-off prompt for its source session (#2132)
alexeyzimarev File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
258 changes: 258 additions & 0 deletions
258
docs/superpowers/plans/2026-10-30-handoff-end-to-end.md
Large diffs are not rendered by default.
Oops, something went wrong.
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) { | ||
| _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; | ||
| } | ||
|
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) { | ||
|
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(); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
|
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; | ||
|
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); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.