implement local cloud handoff#9455
Conversation
This stack of pull requests is managed by Graphite. Learn more about stacking. |
|
I'm starting a first review of this pull request. I completed the review and posted feedback on this pull request. Comment I completed the review and posted feedback on this pull request. Comment I completed the review and posted feedback on this pull request. Comment You can view the conversation on Warp. I completed the review and posted feedback on this pull request. Comment Powered by Oz |
There was a problem hiding this comment.
Overview
This PR adds client-side local-to-cloud handoff entry points, touched-workspace derivation, snapshot prep/upload plumbing, and spawn-request fields for forking a local conversation into a cloud agent.
Concerns
- Orphan file collection currently stages any readable absolute file outside a git repo based on conversation action paths, which can upload local files unrelated to the workspace.
- Handoff snapshot upload targets are matched by response order even though the response includes filenames, so reordered responses can corrupt the staged snapshot.
- Submitting before touched-workspace derivation completes drops the user's prompt and attachments because the input has already been cleared.
- Prep/upload failures are only logged/stored in unused handoff state, leaving the user without an inline error or retry prompt.
Verdict
Found: 1 critical, 3 important, 0 suggestions
Request changes
Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).
Powered by Oz
50d01b3 to
0dd168b
Compare
0dd168b to
8582656
Compare
973da8c to
70f0742
Compare
There was a problem hiding this comment.
Overview
This PR adds local-to-cloud handoff entry points, touched-workspace derivation, snapshot prep/upload plumbing, and spawn-request fields for forking a local conversation into a cloud agent.
Concerns
- Handoff snapshot upload targets are matched to requested files by response order instead of by the returned filename, so an out-of-order server response can upload blobs under the wrong object names and break rehydration.
- Submitting before touched-workspace derivation completes, or while a submission is already starting, returns after the input layer has already cleared the prompt and attachments, silently dropping user input.
Security
- The touched-workspace scan treats every exchange working directory as touched, so handoff can upload uncommitted local repo changes even when the agent never touched that repo. Limit cwd inclusion to actual shell-command actions to keep the upload scope aligned with user expectations.
Verdict
Found: 0 critical, 3 important, 0 suggestions
Request changes
Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).
Powered by Oz
There was a problem hiding this comment.
Overview
This PR adds the client-side local-to-cloud Oz handoff flow, including the handoff entry points, touched-workspace derivation, snapshot prep/upload, and spawn-request wiring for forked cloud runs.
Concerns
- Orphan-file collection can upload arbitrary local files referenced in the conversation, including files outside the workspace such as secrets under the home directory.
- Handoff snapshot upload targets are matched by response order instead of the response filename, which can swap or skip uploaded blobs if the server returns URLs in a different order.
- Submitting before touched-workspace derivation completes clears the input and then no-ops, dropping the user prompt without starting a run.
- Fatal prep/upload failures only update an internal state that is not rendered by this PR, so users get no visible error and the cleared prompt is not restored.
Verdict
Found: 1 critical, 3 important, 0 suggestions
Request changes
Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).
Powered by Oz
I don't need oz to approve my PR
There was a problem hiding this comment.
Overview
This PR adds client-side local-to-cloud handoff entry points, touched-workspace derivation, handoff snapshot upload prep, and spawn request plumbing for forking a local conversation into a cloud run.
Concerns
- Handoff prep failures restore only the prompt even though pending attachments were already cleared from the input, so retrying loses user-selected attachments.
- The touched-path scan walks action messages oldest-to-newest within the newest exchange, so a long exchange can exhaust the 500-action cap before reaching its latest edits.
- The submit guard can silently no-op while touched-workspace derivation is still running; the user sees an enabled editor/send path but no feedback.
Security
- The handoff snapshot path logs the server conversation token for correlation; prefer a redacted or hashed correlation value because this token is also used in conversation/debug links.
Verdict
Found: 0 critical, 3 important, 1 suggestions
Request changes
Comment /oz-review on this pull request to retrigger a review (up to 3 times on the same pull request).
Powered by Oz
70f0742 to
275dd8b
Compare
48ed3c0 to
9311573
Compare
9311573 to
eea298a
Compare
275dd8b to
a44fdd6
Compare
a44fdd6 to
cbef010
Compare
eea298a to
3e9b09c
Compare
cbef010 to
b2d5235
Compare
3e9b09c to
305a73a
Compare
| /// `/oz-cloud-handoff` slash command) that fork the active local Oz | ||
| /// conversation into a fresh cloud agent run with the current workspace | ||
| /// snapshot attached. Requires `OzHandoff` to also be enabled. | ||
| LocalToCloudHandoff, |
There was a problem hiding this comment.
ya didn't wanna just copy the convention HandoffLocalCloud lol
| }); | ||
|
|
||
| pub static OZ_CLOUD_HANDOFF: LazyLock<StaticCommand> = LazyLock::new(|| StaticCommand { | ||
| name: "/oz-cloud-handoff", |
There was a problem hiding this comment.
i think we can do better with this slash command name lol
| ) { | ||
| let declarations_path = resolve_declarations_path(Some(task_id)); | ||
| let _ = upload_snapshot_from_declarations_file(&declarations_path, client, task_id).await; | ||
| let log_label = format!("task {task_id}"); |
There was a problem hiding this comment.
is there a meaningful reason this 'log label' was factored out? Is it because there is no task id for the pre handoff snapshot
| } | ||
| } | ||
|
|
||
| Ok(Some(prep_token)) |
There was a problem hiding this comment.
can we make prep token typed
| path: &Path, | ||
| client: Arc<dyn HarnessSupportClient>, | ||
| task_id: &AmbientAgentTaskId, | ||
| log_label: &str, |
There was a problem hiding this comment.
this log label piping is kinda ugly and not sure if the task Id actually serves any meaningful purpose in the logs
| let mut cursor: Option<&Path> = if path.is_dir() { | ||
| Some(path) | ||
| } else { | ||
| path.parent() | ||
| }; | ||
| while let Some(dir) = cursor { | ||
| let candidate = dir.join(".git"); | ||
| if candidate.exists() { | ||
| return Some(dir.to_path_buf()); | ||
| } | ||
| cursor = dir.parent(); |
There was a problem hiding this comment.
these arefs calls that shouldn't happen on the main thread
| async fn gather_repo_metadata(git_root: PathBuf) -> TouchedRepo { | ||
| let remote_url = git_string(&git_root, &["remote", "get-url", "origin"]).await; | ||
| let repo_id = remote_url.as_deref().and_then(parse_github_repo); | ||
| TouchedRepo { git_root, repo_id } | ||
| } | ||
|
|
||
| /// Run `git <args>` in `repo_dir`, returning the trimmed stdout if the exit status is 0 | ||
| /// and the output decodes as UTF-8. Caps each invocation at [`GIT_COMMAND_TIMEOUT`] so a | ||
| /// stalled git process can't pin the modal's loading state forever. | ||
| async fn git_string(repo_dir: &Path, args: &[&str]) -> Option<String> { | ||
| match git_string_inner(repo_dir, args).await { | ||
| Ok(s) => { | ||
| let trimmed = s.trim().to_string(); | ||
| if trimmed.is_empty() { | ||
| None | ||
| } else { | ||
| Some(trimmed) | ||
| } | ||
| } | ||
| Err(_) => None, | ||
| } | ||
| } | ||
|
|
||
| async fn git_string_inner(repo_dir: &Path, args: &[&str]) -> Result<String> { |
There was a problem hiding this comment.
this feels like an unnecessary amount of abstraction for running a single command to get the remote origin URL
| // Paths are kept absolute when they look absolute, and resolved against the | ||
| // exchange's `working_directory` when they don't. Empty entries are dropped. |
There was a problem hiding this comment.
? "look absolute" just means has a leading '/' no?
| // call). Covers `RunShellCommand` cwds without walking action results. | ||
| if let Some(cwd) = cwd { | ||
| let cwd_path = PathBuf::from(cwd); | ||
| if cwd_path.is_absolute() && seen.insert(cwd_path.clone()) { |
There was a problem hiding this comment.
does is_absolute() require a syscall? if so we should refactor this to first collect paths from the conversation, then do validation (or any syscall-requiring work) async
| pub referenced_attachments: Vec<String>, | ||
| /// When set, instructs the server to fork the named conversation and use the resulting | ||
| /// fork id as `task.AgentConversationID`. Mutually exclusive with the existing | ||
| /// `conversation_id` field (resume semantics) on the server. Used by the local-to-cloud |
There was a problem hiding this comment.
where is the conversation_id field? I don't see one?
zachbai
left a comment
There was a problem hiding this comment.
I would like to split this PR up between the touchedrepos stuff, snapshotting behavior, and the actual UI - there is a little too much going on at the same time right now

Description
This PR implements local -> cloud handoff flow in the client. This includes a snapshotting flow (similar to the cloud -> cloud snapshotting), adding a cloud mode entrypoint for handing off a local conversation, and then actual handing off the conversation succesfully.
Don't over-index on the UI too much — in a follow-up PR I'll add the conversation into the cloud mode pane and also re-use the full cloud mode setup v2 UI.
The associated server PR for this client PR is here: https://github.com/warpdotdev/warp-server/pull/10777
Testing
demo: https://www.loom.com/share/a6caa2c974e34b49b2b038a8019c062c
Agent Mode