first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
# Local-to-Cloud Handoff — Product Spec
|
||||
Linear: [REMOTE-1486](https://linear.app/warpdotdev/issue/REMOTE-1486)
|
||||
## Summary
|
||||
Let a user mid-conversation in a local Oz agent send that conversation to a fresh cloud agent. The cloud agent picks up the conversation history, the workspace state (uncommitted git diffs and modified files), and the user's optional new prompt. The local agent stays usable and is unaffected.
|
||||
## Problem
|
||||
Today there's no first-class way to delegate "what I'm working on right now" to the cloud. Users have to retell the cloud agent the context, copy/paste plans, and manually push uncommitted changes. The existing cloud-to-cloud handoff (REMOTE-1290) covers continuation across cloud sandboxes, but the symmetric local→cloud transition is missing.
|
||||
## Goals
|
||||
- A user in a local Oz agent conversation can hand off to a cloud agent without leaving their flow — clicking the "Hand off to cloud" chip (or running `/move-to-cloud`) opens a split cloud-mode pane next to the local agent, where the user types a follow-up prompt and submits.
|
||||
- The cloud agent receives the local conversation's history (forked into a fresh cloud conversation) and the workspace's uncommitted state (git diffs + modified files the agent has touched).
|
||||
- The new pane's env selector defaults to whichever env contains the most touched repos.
|
||||
- Handoff does not interrupt the local conversation — the user can keep typing into it.
|
||||
- The cloud agent runs an Oz harness in V0; the design leaves room for third-party harnesses as a follow-up.
|
||||
## Non-goals
|
||||
- Third-party harnesses (Claude Code, Gemini, etc.). Extending `/move-to-cloud` to dispatch on the active conversation's harness is a follow-up that reuses most of the plumbing.
|
||||
- A symmetric cloud→local handoff. That's REMOTE-1290's existing rehydration target plus future work.
|
||||
- Multi-conversation / batch handoff. One conversation at a time.
|
||||
- Bidirectional sync after handoff. The cloud agent operates on a forked copy (different `conversation_id`); local edits after the handoff don't propagate to the cloud, and vice-versa.
|
||||
- A CLI surface in V0. The chip and slash command are UI-only entry points.
|
||||
- A redesign of the env selector or environment management UI.
|
||||
- Capturing system state outside the workspace (caches, daemons, env vars, MCP server state) — same scope as cloud→cloud handoff.
|
||||
## Figma
|
||||
None provided.
|
||||
## Behavior
|
||||
### Entry points
|
||||
1. A "Hand off to cloud" chip is added to the agent input footer's right slot whenever `FeatureFlag::OzHandoff && FeatureFlag::HandoffLocalCloud` are both enabled, in agent-view panes only, and not for session viewers (handoff is host-initiated). The chip uses the existing `bundled/svg/upload-cloud-01.svg` icon (cloud-with-upward-arrow); design may swap this for a bespoke icon as a follow-up.
|
||||
2. A slash command `/move-to-cloud [optional prompt]` is registered under the same flag gates plus the existing `AGENT_VIEW | ACTIVE_CONVERSATION | AI_ENABLED` availability rules. The name is harness-agnostic so the same command can dispatch to non-Oz harnesses as a follow-up.
|
||||
3. Both entry points dispatch `WorkspaceAction::OpenLocalToCloudHandoffPane`, which splits a fresh cloud-mode pane to the right of the active pane. The slash command pre-fills the new pane's prompt with whatever followed the command; the chip leaves it empty. The local pane stays in place and remains fully active throughout.
|
||||
4. Per-conversation eligibility is enforced by the click handler, not chip visibility. If the active conversation has a synced `server_conversation_token` and is non-empty, the new pane is seeded with handoff context (forked + snapshot uploaded on submit). Otherwise the new pane opens as an ordinary fresh cloud-mode pane (no fork, no snapshot) — the user clearly wanted a cloud-mode pane regardless.
|
||||
### Handoff pane
|
||||
5. The handoff pane is a regular cloud-mode pane (entered via `AgentViewEntryOrigin::CloudAgent`). It uses the existing cloud-mode input footer — model selector, env selector chip, prompt editor, voice/file inputs, send button — with no handoff-specific buttons. The pane intentionally does **not** opt into the new `CloudModeInputV2` UI even when that flag is on; V2 is for fresh cloud-mode runs only.
|
||||
6. There is no dedicated handoff banner UI in V0. Touched-repo derivation runs silently in the background (§9) and the env selector's default updates when derivation completes (§7). Per-repo `✓ / ⚠` overlap status is intentionally not surfaced; submission errors surface through the model's submission state but have no banner-style row.
|
||||
7. The pane's env selector layers a repo-aware default on top of the existing recency-based default: each env is scored by the number of touched repos it contains; highest score wins, ties broken by most-recently-used. When no env contains any touched repo, the existing default applies (`CloudAgentSettings.last_selected_environment_id` → most-recently-used → no-env). The user can override at any time by clicking the env selector chip.
|
||||
8. The send button follows the regular cloud-mode rules (prompt non-empty) plus a guard until touched-repo derivation completes. Closing the pane abandons the handoff with no side effects on the local conversation.
|
||||
### Touched-repo derivation
|
||||
9. The handoff pane opens immediately — it does not wait for any I/O. Touched-repo derivation runs asynchronously off the main thread; the pane chrome stays interactive throughout. Derivation:
|
||||
- Walks the most recent action results in the conversation (capped at `MAX_TOOL_CALLS_TO_SCAN = 500`): file paths from edit/read/grep/glob actions plus the `cwd` of every shell command.
|
||||
- For each path, walks up to the nearest `.git` directory. The set of distinct git roots is the touched-repo list.
|
||||
- For each git root, runs `git remote get-url origin` (best-effort) to parse a `<owner>/<repo>` for env-overlap matching. Branch and HEAD metadata are gathered later by the snapshot pipeline.
|
||||
- Modified files outside any `.git` are tracked separately as orphan files and uploaded as raw file contents during snapshotting.
|
||||
10. If the conversation has no touched repos, the handoff still proceeds; the cloud agent starts with a clean workspace.
|
||||
### Submitting
|
||||
11. When the user submits the prompt, the client (off the main thread):
|
||||
1. Builds the snapshot from each touched repo (git diff including binary patches, untracked files, branch / HEAD metadata) and each orphan file.
|
||||
2. Calls `POST /agent/handoff/upload-snapshot` to mint an `initial_snapshot_token` and presigned upload URLs scoped to `handoff/{initial_snapshot_token}/`.
|
||||
3. Uploads the artifacts in parallel to GCS.
|
||||
4. Calls `POST /agent/runs` (`SpawnAgentRequest`) with `fork_from_conversation_id` + `initial_snapshot_token` set. The server forks the source conversation, creates the new task, and binds the initial snapshot token to the new run's queued execution; the cloud sandbox reads the snapshot files directly from `handoff/{initial_snapshot_token}/`.
|
||||
5. The pane transitions into the live cloud-mode session through the same `AmbientAgentViewModel::spawn_agent_with_request` streaming path used for fresh cloud-mode runs (`WaitingForSession` → `SessionStarted`).
|
||||
12. Per-file upload failures are best-effort: each retries on transient errors with bounded backoff; failures past retry are logged but do not block the handoff. If every blob fails, the cloud agent is created without rehydration content and the failure is reported via `report_error!` for on-call. Failures of `upload-snapshot` or task-creation themselves are fatal: no cloud agent is created and the failure surfaces inline via `HandoffSubmissionState::Failed` so the user can retry.
|
||||
13. While the handoff is in flight the send button is disabled ("Starting…"). Closing the pane abandons the handoff (in-flight uploads abort). The local conversation is unaffected throughout — the user may keep typing, run other commands, etc.
|
||||
### Post-handoff state
|
||||
14. The local conversation continues normally with no "this was handed off" annotation in V0 — the handoff pane being open next to it is the discoverability surface.
|
||||
15. The new cloud agent has a *new* `conversation_id` (different from the local conversation's `server_conversation_token`); the local and cloud conversations diverge at the handoff point. It inherits the local conversation's task and message history up to the handoff, receives a `<system-message>`-wrapped rehydration prompt instructing it to apply the snapshot patches before answering, then handles the user's optional follow-up prompt. The new agent appears in the cloud agent management view and supports the standard reopen flows.
|
||||
### Pre-SessionStarted visualization in the handoff pane
|
||||
16. While the cloud agent's session is being established, the handoff pane shows the user's submitted prompt as a queued user-query indicator (REMOTE-1454's visual treatment, no Send-now / dismiss buttons), the warping "Setting up environment" indicator, and the collapsible "Running setup commands…" summary — the standard cloud-mode setup affordances.
|
||||
17. When the cloud agent's first turn arrives, the queued-prompt indicator is removed and the pane behaves like any live cloud-mode pane. If the run fails, is cancelled, or requires GitHub auth before the session connects, the queued-prompt indicator is torn down and the existing failure / cancel / auth UI is shown.
|
||||
### Edge cases and error states
|
||||
18. If a touched repo's local clone is unreadable, missing, or has a corrupt git state, that repo is captured as a `gather_failed` entry in the snapshot manifest and the rest of the snapshot proceeds. The rehydration prompt tells the cloud agent to fail the apply for that repo and report it.
|
||||
19. Modified files outside any `.git` are uploaded as raw file contents (the `kind: file` declaration form in the existing snapshot pipeline) and listed in the manifest with their original paths.
|
||||
20. If the user has no environments, the pane still works with no env selected; the cloud agent runs against the platform default image.
|
||||
21. If the user is at cloud agent capacity, the cloud agent is created in a queued state — the same behavior as `oz agent run-cloud`. The handoff pane shows the existing "queued / waiting for capacity" UI.
|
||||
22. If the local pane closes mid-handoff, in-flight uploads abort and no cloud agent is created. The user is not warned in V0 — handoffs are short and rarely interrupted.
|
||||
23. The handoff is per-conversation; running it twice produces two independent cloud agents, each forked from the same point.
|
||||
### Permissions and authorization
|
||||
24. Handoff requires the user to be logged in and have permission to create cloud agent runs in their workspace — same permissions as `oz agent run-cloud`. The chip is hidden from session viewers.
|
||||
25. The selected environment must be readable by the user; the dropdown only lists envs the user already has view access to (same scoping as the existing cloud-agent setup `EnvironmentSelector`).
|
||||
## Open questions
|
||||
- The chip uses an existing icon for V0; design may swap it for a bespoke handoff icon later.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Cloud Handoff Snapshot Upload — Tech Spec
|
||||
Part of the local-to-cloud Oz handoff feature ([REMOTE-1486](https://linear.app/warpdotdev/issue/REMOTE-1486)). Full feature behavior in `PRODUCT.md`; the orchestrator that wires this together lives in `TECH.md`.
|
||||
## Context
|
||||
The handoff flow stages the local agent's workspace into GCS *before* the cloud task exists, so the cloud sandbox can rehydrate from those files on its first turn. There's no `task_id` to scope the upload to at that point — only a server-minted initial snapshot token and a `handoff/{token}/` GCS prefix.
|
||||
The existing end-of-run snapshot pipeline (REMOTE-1332) is generic enough to reuse for the gather + upload phase, but the entry point and the URL-allocation step both need new variants that don't depend on a task. The `task_id` parameter that the existing helpers thread through purely for log context becomes a liability in the new entry point, so we drop it and re-extract a `task_id`-free upload helper that both paths share.
|
||||
This branch contains only the upload + server-contract pieces; nothing in-tree calls `upload_snapshot_for_handoff` yet, so the function and the unused `InitialSnapshotToken::as_str` accessor are gated with `#[allow(dead_code)]` until the parent stack branch wires them up.
|
||||
Relevant code:
|
||||
- `app/src/ai/agent_sdk/driver/snapshot.rs` — existing end-of-run pipeline (`run_pipeline`, `gather_snapshot_entries`, `upload_gathered_snapshot`, `apply_per_run_cap`, `upload_entry`, `repo_metadata`, `build_repo_patch`).
|
||||
- `app/src/server/server_api/ai.rs` — `AIClient` trait, `SpawnAgentRequest`, the existing `HarnessSupportClient::get_snapshot_upload_targets` URL allocation we don't reuse here.
|
||||
- `app/src/ai/ambient_agents/spawn.rs` — `SessionJoinInfo::from_task`, the cloud-mode session join helper we tighten for the new fork-via-handoff server contract.
|
||||
## Proposed changes
|
||||
### `upload_snapshot_for_handoff`
|
||||
A sibling entry point in `app/src/ai/agent_sdk/driver/snapshot.rs` that reuses the existing gather + upload internals but skips the JSONL declarations file and the cloud-side `run_declarations_script`:
|
||||
```rust path=null start=null
|
||||
pub(crate) async fn upload_snapshot_for_handoff(
|
||||
repo_paths: Vec<PathBuf>,
|
||||
orphan_file_paths: Vec<PathBuf>,
|
||||
client: Arc<dyn AIClient>,
|
||||
http: &http_client::Client,
|
||||
) -> Result<Option<InitialSnapshotToken>>;
|
||||
```
|
||||
Translates the input paths into the same internal `Vec<DeclarationEntry>` that `parse_declarations` produces today (repos → `EntryKind::Repo`, orphan files → `EntryKind::File`), then:
|
||||
1. Calls `gather_snapshot_entries` to build the manifest stubs and upload blobs.
|
||||
2. Applies the existing per-run cap (`MAX_SNAPSHOT_FILES_PER_RUN = 100`).
|
||||
3. Calls `AIClient::upload_local_handoff_snapshot` with the planned filenames + mime types to mint an initial snapshot token and presigned upload URLs scoped to `handoff/{token}/` (rather than going through `HarnessSupportClient::get_snapshot_upload_targets`, which requires a task).
|
||||
4. Builds the upload `target_map` by zipping the requested filenames with the positionally-aligned server `uploads` array; missing targets are marked `skipped` downstream.
|
||||
5. Routes the actual blob + manifest uploads through the new `upload_prepared_snapshot_files` helper.
|
||||
Returns:
|
||||
- `Ok(Some(initial_snapshot_token))` when a token was minted **and the `snapshot_state.json` manifest landed in GCS**. Individual blob uploads may still have failed; the manifest catalogues their status so the cloud side rehydrates against whatever did land, matching the cloud→cloud best-effort posture.
|
||||
- `Ok(None)` when the workspace was empty **or** when the manifest itself failed to upload. Without the manifest the snapshot prefix is unusable, so callers spawn the cloud agent without an initial snapshot token instead of pointing it at incomplete state.
|
||||
- `Err(_)` only for hard failures of `upload_local_handoff_snapshot` itself (auth, etc.).
|
||||
Manifest-upload failures (whether the manifest serialization aborted the pipeline or its presigned PUT failed) also route through `report_error!` so on-call alerting catches the silent regression.
|
||||
### Refactor: drop `task_id` from the existing helpers, extract `upload_prepared_snapshot_files`
|
||||
The existing `upload_snapshot_from_declarations_file`, `run_pipeline`, `gather_snapshot_entries`, `upload_gathered_snapshot`, `gather_repo` / `gather_file`, `apply_per_run_cap`, `fold_upload_results`, `upload_entry`, `parse_declarations`, and `read_and_parse_declarations` previously took `&AmbientAgentTaskId` only for log context. The new handoff entry point has no task at this stage, so each helper drops the parameter and the corresponding log lines lose the `(task X)` suffix. The outer `upload_snapshot_from_declarations` (which `AgentDriver` calls at end-of-run) still has a task id and passes it to `resolve_declarations_path` for the per-run JSONL file path; that's the only remaining task-aware helper.
|
||||
`upload_prepared_snapshot_files` is extracted out of `upload_gathered_snapshot` as a private helper. Both the existing `run_pipeline` path (declarations → server `get_snapshot_upload_targets` → upload) and the new `upload_snapshot_for_handoff` path (touched-workspace input → `upload_local_handoff_snapshot` → upload) terminate in the same blob + manifest upload logic.
|
||||
### Server contract: `upload_local_handoff_snapshot` + new types
|
||||
Adds to `app/src/server/server_api/ai.rs`:
|
||||
- `InitialSnapshotToken(String)` — opaque token the server returns from `upload_local_handoff_snapshot` and the client passes back via `SpawnAgentRequest.initial_snapshot_token`.
|
||||
- `UploadLocalHandoffSnapshotRequest { files: Vec<SnapshotUploadFileInfo> }` and `SnapshotUploadFileInfo { filename, mime_type }`.
|
||||
- `UploadLocalHandoffSnapshotResponse { initial_snapshot_token, expires_at, uploads: Vec<UploadTarget> }`; the response field deserializes from the public wire key `initial_snapshot_token`.
|
||||
- `AIClient::upload_local_handoff_snapshot(...)` trait method (POSTs to `agent/handoff/upload-snapshot`) and its `ServerApi` implementation.
|
||||
The server-side handler mints a UUID-v4 initial snapshot token, authorizes against the user, and generates URLs scoped to `handoff/{token}/` via the existing presigned-URL helper. No DB writes — discovery happens later by GCS prefix. Server-side details are covered in the parent feature's `TECH.md`.
|
||||
### `SpawnAgentRequest` field additions
|
||||
Two new optional fields on `SpawnAgentRequest`:
|
||||
- `fork_from_conversation_id: Option<String>` — instructs the server to fork the named conversation and use the resulting fork id as `task.AgentConversationID`. The actual fork is server-side; this field is the client's signal.
|
||||
- `initial_snapshot_token: Option<InitialSnapshotToken>` — references the GCS prefix uploaded above so the server can bind the token to the new run's queued execution and the cloud sandbox can list / download files from it on first turn. This serializes as the public API wire key `initial_snapshot_token`.
|
||||
Both fields use `#[serde(skip_serializing_if = "Option::is_none")]` so they're backwards-compatible against older server builds. Existing constructor sites in `agent_sdk/ambient.rs`, `agent_sdk/mcp_config_tests.rs`, `pane_group/pane/terminal_pane.rs`, `ambient_agents/spawn_tests.rs`, and `view/ambient_agent/model.rs::spawn_agent` set both to `None`; the parent stack branch's `submit_handoff` is the only call site that populates them.
|
||||
### `SessionJoinInfo::from_task` strictness
|
||||
`SessionJoinInfo::from_task` (`app/src/ai/ambient_agents/spawn.rs`) is rewritten to require a parseable `session_id` and return `None` otherwise. Previously a task with a `session_link` but no `session_id` returned a join info with `session_id: None`; that path is no longer actionable for the cloud-mode pane.
|
||||
The new behavior is needed because the GET task handler now overwrites `session_link` with a conversation link for tasks that have synced conversation data (e.g. the local-to-cloud handoff fork) — so a `session_link` alone is no longer a reliable signal that a real session exists. `session_link` falls back to `shared_session::join_link(&session_id)` when the server didn't provide one. Matching `spawn_tests.rs` test updates ship in this branch.
|
||||
## Testing and validation
|
||||
- `snapshot_tests.rs` is updated to drop the `&fake_task_id()` arguments from every helper call, keeping the existing `run_pipeline` coverage intact under the simplified signature.
|
||||
- `spawn_tests.rs` covers the new `SessionJoinInfo::from_task` invariants: `requires_session_id` (no session_id returns `None`), `prefers_server_session_link_when_session_id_is_present`, `constructs_link_from_session_id_when_link_missing`.
|
||||
- End-to-end coverage of `upload_snapshot_for_handoff` (mockito for the upload-snapshot endpoint, asserting manifest shape and per-blob upload outcomes) lands on the parent stack branch where the function actually has a caller.
|
||||
@@ -0,0 +1,219 @@
|
||||
# Local-to-Cloud Handoff — Tech Spec
|
||||
Product spec: `specs/REMOTE-1486/PRODUCT.md`
|
||||
Linear: [REMOTE-1486](https://linear.app/warpdotdev/issue/REMOTE-1486)
|
||||
Sub-specs (lower stack branches):
|
||||
- `TOUCHED_WORKSPACE_TECH.md` — touched-workspace discovery (path extraction, repo grouping, env-overlap pick).
|
||||
- `SNAPSHOT_UPLOAD_TECH.md` — handoff snapshot upload pipeline and the `SpawnAgentRequest` server-contract additions.
|
||||
## Context
|
||||
The product spec describes a chip + `/move-to-cloud` slash command that opens a split cloud-mode pane next to the local agent to hand off the active local Oz conversation to the cloud. The user types the follow-up prompt and submits inside the pane's existing cloud-mode input bar; the cloud agent runs in a fresh sandbox, gets a forked copy of the conversation history, and rehydrates from a workspace snapshot taken on the local machine.
|
||||
The pieces this builds on already exist:
|
||||
- **Cloud→cloud handoff and rehydration** (REMOTE-1290): `snapshots/{run_id}/{execution_id}/` GCS layout, the `<system-message>`-wrapped `UserQuery` rehydration prompt injected by `logic/ai/multi_agent/runtime/interceptors/input.go:433` via `ResolveHandoffRehydrationPrompt` in `../warp-server/logic/ai/ambient_agents/handoff_rehydration.go`. Server discovers snapshot files by GCS path convention (`ListSnapshotFiles` in `../warp-server/logic/ai/ambient_agents/attachment_storage.go:281`), no DB column needed.
|
||||
- **End-of-run snapshot pipeline** (REMOTE-1332): `app/src/ai/agent_sdk/driver/snapshot.rs` reads JSONL declarations and uploads patches + a `snapshot_state.json` manifest. The pipeline is generic over JSONL — it doesn't care who wrote the declarations or where the artifacts go.
|
||||
- **`task.AgentConversationID` is the load-bearing field**: `RunAgentRequest` already accepts `ConversationID *string` at `../warp-server/router/handlers/public_api/agent_webhooks.go:205`, persisted onto the new task as `AgentConversationID`. The cloud-side resume happens via the `--task-id` chain: the worker passes only `--task-id` (not `--conversation`); the embedded CLI's `--task-id` path fetches the task metadata, reads `conversation_id` off it, and resumes via `get_ai_conversation`. See section 8 for the full trace.
|
||||
- **Local fork**: `BlocklistAIHistoryModel::fork_conversation` at `app/src/ai/blocklist/history_model.rs:1016` already produces a forked AIConversation by copying tasks. We need a server-side analogue that operates on a `server_conversation_token`.
|
||||
- **EnvironmentSelector**: existing component at `app/src/ai/blocklist/agent_view/agent_input_footer/environment_selector.rs` reads `CloudAmbientAgentEnvironment::get_all` from `app/src/ai/cloud_environments/mod.rs:114`. Each env carries `github_repos: Vec<GithubRepo>` so overlap with our touched-repo set is computable client-side.
|
||||
- **Agent input footer chips**: rendered by `app/src/ai/blocklist/agent_view/agent_input_footer/chips.rs`. The chip system is data-driven via `ChipResult` and slot positions (left/right). We add a new chip kind here.
|
||||
- **Slash commands**: registered in `app/src/search/slash_command_menu/static_commands/commands.rs`. Commands flow through dispatch in `app/src/terminal/input/slash_commands/mod.rs`.
|
||||
## Diagram
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as User
|
||||
participant C as Local Warp Client
|
||||
participant LC as Local Conversation
|
||||
participant API as warp-server (public API)
|
||||
participant DB as Postgres
|
||||
participant GCS
|
||||
participant Disp as Dispatcher
|
||||
participant Wk as Worker
|
||||
participant Sand as New Cloud Sandbox
|
||||
U->>C: Click "Hand off to cloud" chip
|
||||
C->>C: Split a fresh cloud-mode pane next to the local pane
|
||||
C->>U: Show handoff pane (standard cloud-mode input)
|
||||
C->>LC: Walk action history → touched repos + orphan files (async)
|
||||
U->>C: Pick env (or accept default), type prompt, submit
|
||||
C->>C: Build declarations programmatically (no script)
|
||||
C->>API: POST /agent/handoff/upload-snapshot {files: [{filename, mime_type}]}
|
||||
API-->>C: {initial_snapshot_token, expires_at, uploads: [{filename, upload_url}]}
|
||||
par Snapshot uploads
|
||||
C->>GCS: PUT each file under handoff/{initial_snapshot_token}/...
|
||||
end
|
||||
C->>API: POST /agent/runs {fork_from_conversation_id, initial_snapshot_token, prompt, config}
|
||||
API->>DB: Create forked AI conversation (copy tasks from source)
|
||||
API->>DB: Create task with AgentConversationID = forked_conversation_id
|
||||
API->>DB: tx { INSERT task, INSERT QUEUED ai_run_executions (input.initial_snapshot_token=<token>) }
|
||||
API-->>C: {task_id, run_id}
|
||||
C->>C: Pane transitions into live cloud-mode session (queued-prompt + setup affordances)
|
||||
C->>U: User stays in the same pane; cloud agent's first turn streams in
|
||||
Note over LC: Local conversation continues, user can keep typing
|
||||
Disp->>DB: Pop queued task; create new execution (id=1) in PENDING
|
||||
Disp->>Wk: Assign task with task.AgentConversationID = <forked_id>
|
||||
Wk->>Sand: oz agent run --task-id <new_task_id> --sandboxed (no --conversation flag)
|
||||
Sand->>API: GET /agent/runs/<new_task_id> (fetch task metadata)
|
||||
API-->>Sand: AmbientAgentTask { conversation_id: <forked_id>, ... }
|
||||
Sand->>API: get_ai_conversation(<forked_id>) (resume via the --task-id→conversation_id chain)
|
||||
API-->>Sand: ConversationData (forked source's tasks/messages)
|
||||
Sand->>Sand: driver_options.resume = ResumeOptions::Oz(Historical{...})
|
||||
Sand->>API: GET /agent/runs/<new_task_id>/handoff/attachments
|
||||
API->>DB: GetActiveExecutionForRun → Input.InitialSnapshotToken=<token>
|
||||
API-->>Sand: presigned download URLs from handoff/<token>/
|
||||
Sand->>GCS: Download handoff snapshot files
|
||||
Sand->>API: StartFromAmbientRunPrompt (resolves rehydration message)
|
||||
API-->>Sand: <system-message>-wrapped rehydration UserQuery + user prompt
|
||||
Sand->>Sand: Apply patches via git apply, then handle user prompt
|
||||
```
|
||||
## Proposed changes
|
||||
### 1. Touched-repo derivation (client)
|
||||
See `TOUCHED_WORKSPACE_TECH.md` for the path-extraction walk, repo grouping, and env-overlap pick. The open path described in §2 calls `extract_paths_from_conversation` and `derive_touched_workspace` on chip click, and applies `pick_handoff_overlap_env` once derivation completes.
|
||||
### 2. Handoff pane: split-pane bootstrap
|
||||
There is no dedicated modal view. On chip click or `/move-to-cloud` activation, `Workspace::start_local_to_cloud_handoff` (in `app/src/workspace/view.rs`) drives the open path:
|
||||
1. Resolve the source conversation from the active session view's `BlocklistAIHistoryModel::active_conversation` (must be non-empty and have a `server_conversation_token`).
|
||||
2. Call `pane_group.add_ambient_agent_pane(ctx)` to split a new cloud-mode pane next to the active pane (mirrors `Workspace::open_network_log_pane`'s pattern but pre-mounts the cloud-mode chrome).
|
||||
3. Pre-fill the new pane's prompt editor when the slash command supplied an argument (slash command args do not flow through `PendingHandoff` itself).
|
||||
4. If the source conversation didn't resolve, return early — the new pane stays as an ordinary fresh cloud-mode pane with no handoff context. Non-eligible clicks are not surfaced as errors.
|
||||
5. Otherwise, seed `PendingHandoff` onto the new pane's `AmbientAgentViewModel` (see below) and `ctx.spawn` an async block that calls `extract_paths_from_conversation` and then `derive_touched_workspace(...)`. When derivation completes, apply `pick_handoff_overlap_env(...)` to the model's `environment_id` (the env selector's `ensure_default_selection` already runs first; the handoff-aware pick overrides on a real overlap match and is skipped on no-overlap).
|
||||
#### Handoff context on `AmbientAgentViewModel`
|
||||
Add a `pending_handoff: Option<PendingHandoff>` field on `AmbientAgentViewModel` (`app/src/terminal/view/ambient_agent/model.rs`):
|
||||
```rust path=null start=null
|
||||
pub(crate) struct PendingHandoff {
|
||||
pub(crate) source_conversation_id: ServerConversationToken,
|
||||
/// `None` until `derive_touched_workspace` completes.
|
||||
pub(crate) touched_workspace: Option<TouchedWorkspace>,
|
||||
/// Gates `submit_handoff` against double-submits and surfaces inline errors.
|
||||
pub(crate) submission_state: HandoffSubmissionState, // Idle | Starting | Failed(String)
|
||||
}
|
||||
```
|
||||
`is_local_to_cloud_handoff()` returns `pending_handoff.is_some()` and is the single source of truth for "this pane is in handoff mode". The new pane needs that predicate true from the moment it opens so the V2-input suppression and the submit-interception logic both fire before the spawn.
|
||||
#### Suppress `CloudModeInputV2` for handoff panes
|
||||
Update `Input::is_cloud_mode_input_v2_composing` (`app/src/terminal/input/agent.rs:65`) to also require `!ambient_agent_view_model.is_local_to_cloud_handoff()`. V2 is for fresh cloud-mode runs only; handoff stays on the existing input UI regardless of the flag's state.
|
||||
#### No banner UI in V0
|
||||
V0 ships with no dedicated handoff banner. `PendingHandoffChanged` triggers a `ctx.notify()` for future banner work; today the only user-visible effects of derivation completing are (a) the env selector's default updating to the overlap winner and (b) `submit_handoff` being unblocked. Submission errors surface inline via `HandoffSubmissionState::Failed` for future banner work to consume.
|
||||
### 3. Chip and slash command (client)
|
||||
- Add a new `AgentToolbarItemKind::HandoffToCloud` variant in `app/src/ai/blocklist/agent_view/agent_input_footer/toolbar_item.rs`. The chip is rendered with the `bundled/svg/upload-cloud-01.svg` icon. Visibility is gated only on `FeatureFlag::OzHandoff && FeatureFlag::HandoffLocalCloud`; conversation eligibility (synced token, non-empty, harness) is enforced via fall-through inside `Workspace::start_local_to_cloud_handoff` rather than at the visibility level. The chip is also hidden from session viewers (`available_to_session_viewer()` returns `!status.is_viewer()`).
|
||||
- Add the chip to `default_right()` (and `all_available()`) in the same file, gated on the same flags so the user-facing toolbar configurator picks it up.
|
||||
- The chip's on-click action emits `AgentInputFooterEvent::OpenHandoffPane { initial_prompt: None }`. The terminal `Input` subscriber forwards it to `WorkspaceAction::OpenLocalToCloudHandoffPane`.
|
||||
- Add `MOVE_TO_CLOUD` to `app/src/search/slash_command_menu/static_commands/commands.rs`:
|
||||
```rust path=null start=null
|
||||
pub static MOVE_TO_CLOUD: LazyLock<StaticCommand> = LazyLock::new(|| StaticCommand {
|
||||
name: "/move-to-cloud",
|
||||
description: "Hand off this conversation to a cloud agent",
|
||||
icon_path: "bundled/svg/upload-cloud-01.svg",
|
||||
availability: Availability::AGENT_VIEW
|
||||
| Availability::ACTIVE_CONVERSATION
|
||||
| Availability::AI_ENABLED,
|
||||
auto_enter_ai_mode: false,
|
||||
argument: Some(Argument::optional()
|
||||
.with_hint_text("<optional follow-up prompt>")
|
||||
.with_execute_on_selection()),
|
||||
});
|
||||
```
|
||||
Gate registration on the same flags inside `all_commands()` in that file.
|
||||
- Wire the slash command's execute path in `app/src/terminal/input/slash_commands/mod.rs` to dispatch `WorkspaceAction::OpenLocalToCloudHandoffPane { initial_prompt: argument.cloned().filter(|s| !s.is_empty()) }`. Like the chip, conversation eligibility is enforced in the workspace handler, so the slash command itself only checks the feature flags.
|
||||
### 4. Snapshot pipeline: local-mode entry point
|
||||
See `SNAPSHOT_UPLOAD_TECH.md` for `upload_snapshot_for_handoff`, the `POST /agent/handoff/upload-snapshot` endpoint contract, the `SpawnAgentRequest.fork_from_conversation_id` / `initial_snapshot_token` field additions, and the `task_id` refactor that lets the existing pipeline share its upload helper with the handoff entry point.
|
||||
### 5. Server-side conversation fork
|
||||
The existing fork mechanism is client-driven: `BlocklistAIHistoryModel::fork_conversation` (`app/src/ai/blocklist/history_model.rs:1016`) copies tasks locally, then the next request sends `forked_from_conversation_id` + `tasks` together; the server (`router/middleware/set_conversation_info.go:45`) mints a new UUID and records `forked_from_conversation_id` for telemetry only (no DB column persists it). That doesn't fit local→cloud: the cloud sandbox has no source-task in memory, and we need `task.AgentConversationID` to point at a materialized conversation at task-creation time so the local pane can fetch the fork immediately.
|
||||
We add a server-side helper that materializes the fork synchronously:
|
||||
```go path=null start=null
|
||||
// ForkConversation copies an existing conversation's GCS data and metadata into a
|
||||
// new conversation owned by `principal`. Returns the new conversation_id.
|
||||
func ForkConversation(
|
||||
ctx context.Context,
|
||||
db database.SqlQuerier,
|
||||
datastores types.Stores,
|
||||
sourceConversationID string,
|
||||
principal types.Principal,
|
||||
) (string, error)
|
||||
```
|
||||
Location: alongside `UpsertAIConversationMetadata` / `CreateThirdPartyAIConversation` in `../warp-server/logic/ai_conversation_object.go`. Steps:
|
||||
1. **Authorize.** `GetAIConversationObjectInfo(sourceConversationID)` + require `ViewAction` for `principal` (mirrors `CheckAndRecordConversationAccess` at `ai_conversation_object.go:603`); reject with `NotAuthorizedError` otherwise.
|
||||
2. **Require persisted source data.** `DoesConversationDataExist(ctx, sourceConversationID)` rejects unsynced conversations before task creation.
|
||||
3. **Read source metadata.** `AIConversationMetadataStore.GetUsageByConversationIDs([sourceConversationID])` so the fork inherits `title`, `working_directory`, `harness`, `latest_git_branch`.
|
||||
4. **Mint and copy.** `newID := uuid.NewString()`, then `CopyConversationDataInGCS(ctx, sourceConversationID, newID)` performs a server-side GCS copy of `{conversation_id}.pb`.
|
||||
5. **Insert metadata + WD object with `has_gcs_data = TRUE`.** `UpsertAIConversationMetadataWithHasGCSData(..., shouldCreateConversationObject: true)` inserts the metadata row, creates the `object_metadata` row owned by `principal`, and marks the fork as GCS-backed in one path.
|
||||
6. **Return `newID`** and emit a structured log line linking source→fork→principal.
|
||||
The server-side copy keeps conversation bytes inside GCS even for large conversations. No lineage column is persisted today; if one is added later, the helper can populate it.
|
||||
### 6. Server-side `RunAgentRequest` extensions
|
||||
Extend `RunAgentRequest` in `../warp-server/router/handlers/public_api/agent_webhooks.go:199` with two new fields:
|
||||
```go path=null start=null
|
||||
type RunAgentRequest struct {
|
||||
// existing fields...
|
||||
ForkFromConversationID *string `json:"fork_from_conversation_id,omitempty"`
|
||||
InitialSnapshotToken *string `json:"initial_snapshot_token,omitempty"`
|
||||
}
|
||||
```
|
||||
`enqueueAgentRun` is updated:
|
||||
- If `ForkFromConversationID` is set, call `ForkConversation(...)` to mint `<forked_id>`, then set `req.ConversationID = &<forked_id>` (overriding any caller value). Existing logic at `agent_webhooks.go:381` continues to set `task.AgentConversationID` from `req.ConversationID`.
|
||||
- If `InitialSnapshotToken` is set, plumb it through `NewTaskParams.InitialSnapshotToken` to `AddTask`. Inside the task transaction, the QUEUED `ai_run_executions` row is inserted with `Input.InitialSnapshotToken = &<initial_snapshot_token>`. Discovery (`handoff_rehydration.go::resolveHandoffSnapshotFilesForRun` and the `/handoff/attachments` handler) reads that field and resolves files at `handoff/{token}/` in place — there's no GCS move, no synthetic ENDED row, and no migration.
|
||||
- Both new fields are gated behind `local_to_cloud_handoff` (server-side flag, mirroring the client `HandoffLocalCloud`).
|
||||
- Authorization: user must have view access on `ForkFromConversationID` (existing AI conversation auth checks). The `InitialSnapshotToken` only authorizes uploads back to the prefix that minted it.
|
||||
- Error handling: fork failure aborts task creation. Per-blob upload failures during the `upload-snapshot` phase are best-effort (logged + reported via `report_error!`); the cloud agent rehydrates against whatever made it to GCS.
|
||||
### 7. Handoff submission
|
||||
The client API surface (`AIClient::upload_local_handoff_snapshot` and the `SpawnAgentRequest` field additions) is documented in `SNAPSHOT_UPLOAD_TECH.md`.
|
||||
The client-side submission lives in `AmbientAgentViewModel::submit_handoff`. It starts one `ctx.spawn`ed future that calls `upload_snapshot_for_handoff` to mint the initial snapshot token, gather repo patches and orphan-file contents, and upload everything to `handoff/{initial_snapshot_token}/`. The actual cloud-agent spawn happens after that future resolves so the existing streaming flow is reused unchanged.
|
||||
The agent config (env, model, worker_host, computer_use_enabled, harness) is intentionally read at spawn time. By then, the user has already picked an env via the pane's existing env selector chip; `build_default_spawn_config` reads everything else from the model + global preferences.
|
||||
Failures of the snapshot upload phase set `pending_handoff.submission_state = Failed(msg)`. V0 has no banner; the user retries by re-submitting from the same pane. Failures of the spawn itself surface via the model's existing cloud-mode error rendering.
|
||||
### 7a. Submit interception in the handoff pane (client)
|
||||
The handoff pane is a regular cloud-mode pane, so the user's submission flows through the existing input dispatch path. We intercept it when `AmbientAgentViewModel::is_local_to_cloud_handoff()` is true (i.e. `pending_handoff.is_some()`) so the snapshot upload runs *before* the spawn.
|
||||
#### `AmbientAgentViewModel::submit_handoff`
|
||||
```rust path=null start=null
|
||||
pub(crate) fn submit_handoff(
|
||||
&mut self,
|
||||
prompt: String,
|
||||
attachments: Vec<AttachmentInput>,
|
||||
ctx: &mut ModelContext<Self>,
|
||||
);
|
||||
```
|
||||
Flow:
|
||||
1. No-op if `pending_handoff` is absent, derivation hasn't completed (`touched_workspace.is_none()`), or `submission_state` is already `Starting`.
|
||||
2. Set `submission_state = Starting` and emit `PendingHandoffChanged`.
|
||||
3. `ctx.spawn` `upload_snapshot_for_handoff` with the model's `touched_workspace`.
|
||||
4. On success, build a `SpawnAgentRequest` with `fork_from_conversation_id` + `initial_snapshot_token` set and `config = Some(self.build_default_spawn_config(ctx))`, then call `self.spawn_agent_with_request(request, ctx)` — the same helper the regular `spawn_agent` path uses. This flips the model to `WaitingForSession` and emits `DispatchedAgent`.
|
||||
5. On failure, set `submission_state = Failed(msg)` so the user can retry.
|
||||
#### Wiring submit interception
|
||||
The submit dispatch in `Input::handle_input_action` (`app/src/terminal/input.rs`) routes through `submit_handoff` instead of `spawn_agent` when `model.is_local_to_cloud_handoff()` is true. `pending_handoff` is seeded by the chip / slash command's open path (§2) and is not cleared after the spawn — it stays so post-spawn flows that query `is_local_to_cloud_handoff()` (queued-prompt rendering, V2-input suppression) keep behaving consistently.
|
||||
#### DispatchedAgent + queued-prompt rendering
|
||||
`DispatchedAgent` (`app/src/terminal/view/ambient_agent/view_impl.rs`) renders the user's prompt via `insert_cloud_mode_queued_user_query_block` (REMOTE-1454's helper) when `is_local_to_cloud_handoff()` is true. The block is removed on the same transitions the non-oz harness path already handles in `handle_ambient_agent_event`: `Failed`, `Cancelled`, `NeedsGithubAuth`, `HarnessCommandStarted`. For the Oz handoff specifically, the first `AppendedExchange` also clears the block (the analogous "harness CLI started" transition for Oz). Each path calls `remove_pending_user_query_block(ctx)` (idempotent). The cloud agent's exchanges flow into the pane via the shared-session replication path that regular cloud-mode runs already use.
|
||||
### 8. How the conversation reaches the new sandbox (no worker or sandbox CLI changes)
|
||||
The only invariant the new task needs to satisfy is `task.AgentConversationID = <forked_id>`. From there, the existing `--task-id` chain plumbs the conversation into the cloud agent without any new client-side or worker-side changes:
|
||||
1. **Worker.** `oz-agent-worker/internal/common/task_utils.go::AugmentArgsForTask` passes only `--task-id <T>` (never `--conversation`). Pinned by `task_utils_test.go:152` ("does not forward --conversation even when AgentConversationID is set").
|
||||
2. **Embedded CLI.** Inside the sandbox, `setup_and_run_driver` (`app/src/ai/agent_sdk/mod.rs:545`) sees `args.task_id = Some(T)` and `args.conversation = None`. `build_driver_options_and_task` fetches the task via `get_ambient_agent_task(T)` (`mod.rs:1031-1051`); the returned `AmbientAgentTask.conversation_id` (= `task.AgentConversationID`) is merged into `resume_conversation_id`.
|
||||
3. **Resume.** `load_conversation_information(<forked_id>, HarnessKind::Oz)` (`mod.rs:1105`) calls `get_ai_conversation(<forked_id>)` and produces `ResumeOptions::Oz(ConversationRestorationInNewPaneType::Historical { conversation, ... })`. The terminal driver restores the conversation and the agent starts with the forked history visible.
|
||||
Our change wires the front of this chain: the client sends `POST /agent/runs` with `fork_from_conversation_id = <local_token>` (and deliberately does *not* set the existing `conversation_id` field, which has resume semantics rather than fork semantics). `enqueueAgentRun` calls `ForkConversation(<local_token>)` to mint `<forked_id>`, sets `req.ConversationID = &<forked_id>`, and the existing line `agent_webhooks.go:381` plumbs it onto `task.AgentConversationID`. Callers should set exactly one of `conversation_id` (resume) and `fork_from_conversation_id` (fork); both live on `SpawnAgentRequest` / `RunAgentRequest` and pick different branches inside `enqueueAgentRun`.
|
||||
### 9. Sandbox-side: rehydration prompt (no client-side changes)
|
||||
With the conversation-resume side covered above, the only remaining sandbox-side work is the rehydration prompt that tells the agent to apply the snapshot patches:
|
||||
- `fetch_and_download_handoff_snapshot_attachments` (`app/src/ai/agent_sdk/driver/attachments.rs:68`) calls `GET /agent/runs/:runId/handoff/attachments`. The server reads the active execution's `Input.InitialSnapshotToken` and lists files at `handoff/{token}/`; if the token is absent (post-first-execution retries, cloud→cloud handoffs) it falls back to the latest ENDED execution's `snapshots/{run_id}/{exec_id}/` upload.
|
||||
- The runtime's rehydration message construction (`logic/ai/multi_agent/runtime/interceptors/input.go:433` → `resolveHandoffRehydrationMessage`) shares the same two-rule discovery as the `/handoff/attachments` handler. It lists snapshot files at the resolved prefix and prepends the `<system-message>`-wrapped UserQuery to the runtime's first input.
|
||||
### 10. Feature flags
|
||||
Add `FeatureFlag::HandoffLocalCloud` in `crates/warp_features/src/lib.rs`. The chip, slash command, client API methods, and server endpoint behavior are all gated on `OzHandoff && HandoffLocalCloud`. Both flags must be enabled for the feature to function.
|
||||
On the server, mirror with a `local_to_cloud_handoff` flag in `config/features/features.go`. The server feature-flag check happens at the request handler level (returns 404 / `feature not available` when off). This mirrors `HandoffCloudCloudEnabled` which already exists.
|
||||
## Risks and mitigations
|
||||
- **Initial snapshot token expires before task creation.** The `initial_snapshot_token` is short-lived (15 min, matching presigned URL lifetime); a stalled handoff past expiry would fail with a "can't find files" error. *Mitigation:* the upload-snapshot endpoint returns the expiry timestamp so the pane can request a fresh token before the deadline; as a backstop, the task-creation handler returns a structured "initial snapshot token expired" error so the client can transparently retry.
|
||||
- **Fork on a very large conversation.** `ForkConversation` copies the source conversation object inside GCS. *Mitigation:* use the server-side `CopyConversationDataInGCS` path so bytes do not round-trip through the warp-server process.
|
||||
- **Source conversation isn't fully synced to GCS.** A `server_conversation_token` only proves the metadata row exists; the GCS data (`{conversation_id}.pb`) may still be in flight or never written. *Mitigation:* the fork helper checks `DoesConversationDataExist` before copying and returns a structured `SourceConversationNotPersisted` error; the pane surfaces it via `HandoffSubmissionState::Failed`.
|
||||
- **Unauthorized cross-user fork.** A caller could try to fork another user's conversation. *Mitigation:* `ForkConversation` step 1 requires `ViewAction` on the source via the existing `auth_types.For(ctx)` engine (same posture as `CheckAndRecordConversationAccess`); the new fork is owned by the requesting principal, not the source's owner.
|
||||
- **Local-only changes that aren't reproducible in cloud.** Private forks, submodules, large LFS files. `git diff --binary HEAD` and `git ls-files --others --exclude-standard` cover the common cases; submodules are not recursed (same as cloud→cloud). *Mitigation:* acceptable for V0; the rehydration prompt instructs the agent to report apply failures.
|
||||
- **Worker/server flag drift.** Client flag on, server flag off → endpoint 404. *Mitigation:* standard rollout sequencing (server first); the client surfaces the 404 as `HandoffSubmissionState::Failed`.
|
||||
- **Snapshot upload tail latency.** Pathological binary diffs hit the existing pipeline's cap (3 retries, exponential backoff, 2-min ceiling). *Mitigation:* same caps as cloud→cloud; the user sees the "Starting…" state for the duration and closing the pane aborts in-flight uploads.
|
||||
## Testing and validation
|
||||
Per-branch unit-test coverage (touched-repo helpers, snapshot pipeline, `SessionJoinInfo`) is documented in `TOUCHED_WORKSPACE_TECH.md` and `SNAPSHOT_UPLOAD_TECH.md`.
|
||||
### Server tests (`../warp-server`)
|
||||
- `agent_webhooks_test.go::TestHandoff_ForkAndInitialSnapshotToken`: end-to-end inside the test harness. Pre-creates a source conversation, calls the upload-snapshot endpoint, uploads test files, calls `POST /agent/runs` with both new fields, asserts that the new task has `AgentConversationID` pointing at a fresh forked conversation, that `handoff/{initial_snapshot_token}/` retains the uploaded files (no move), and that the QUEUED `ai_run_executions` row's `Input.InitialSnapshotToken` matches the token.
|
||||
- `agent_webhooks_test.go::TestHandoff_FlagOff`: with `local_to_cloud_handoff=false`, the request fails with the expected error and no task / no fork side effects.
|
||||
- `agent_webhooks_test.go::TestHandoff_InitialSnapshotTokenWithoutFiles`: `POST /agent/runs` with a `initial_snapshot_token` whose prefix is empty creates the task normally; `/handoff/attachments` returns an empty list at rehydration time.
|
||||
### Integration / manual
|
||||
- Starting a handoff with a touched repo containing uncommitted changes, opening the resulting cloud run, and confirming the agent's first turn applies the patches before answering. Verified via the cloud agent's tool calls (`git apply`, `git status`), not by the LLM's chat output.
|
||||
- After a successful handoff the local conversation accepts new user input and the local agent continues responding. The user can fork it locally too, run other commands, etc.
|
||||
- The cloud agent's conversation has a different `server_conversation_token` than the local one and that token appears in the cloud agent management view.
|
||||
- Toggling settings to verify chip availability under various states (no synced server token, `CloudConversations` disabled, etc.).
|
||||
- Manually break a touched repo's `.git` and confirm the manifest captures it as `gather_failed` and the rest of the snapshot proceeds.
|
||||
### Feature-flag rollout
|
||||
- Server flag (`local_to_cloud_handoff`) goes Dogfood first, end-to-end tested with a Warp engineer's local→cloud handoff against a staging worker.
|
||||
- Client flag (`HandoffLocalCloud`) follows once server flag is stable.
|
||||
- Promote together to Preview and Stable per the standard `promote-feature` skill.
|
||||
## Follow-ups
|
||||
- Extend `/move-to-cloud` to dispatch to non-Oz harnesses (Claude Code, Gemini, etc.). Most of the plumbing (touched-repo derivation, snapshot pipeline, upload-snapshot endpoint, server-side fork) is reusable; the differences are (a) the chip/command gating drops the Oz-only check and instead reads the active conversation's `harness_kind()` to pick the cloud-side resume strategy, (b) for Claude conversations the server handler must also upload the local Claude transcript envelope to the right GCS slot so the cloud Claude run resumes via REMOTE-1373's existing transcript rehydration path.
|
||||
- A CLI surface for handoff (e.g. `oz agent handoff --conversation <local-id> --env <id> --prompt "..."`). Opens up automation. Out of scope for V0; the public API surface is already CLI-friendly when we get there.
|
||||
- A "this conversation was handed off to <link>" indicator on the local conversation, persisted on the local conversation metadata. V0 only surfaces the link by auto-opening the new cloud-mode pane; the local pane has no persistent breadcrumb back to its handoff destination.
|
||||
- Multi-conversation handoff (batch operation) and "handoff with this exact context but a different prompt" (re-launch with the same uploaded snapshot). These would benefit from making the initial snapshot token re-usable across multiple `POST /agent/runs` calls before expiry.
|
||||
- Snapshot file size cap on the upload-snapshot endpoint. Today the size cap is implicit (presigned URL upload limits + the 100-file cap inherited from `MAX_SNAPSHOT_FILES_PER_RUN`). Worth surfacing more explicitly so the handoff pane can warn the user before they submit.
|
||||
- Banner UI surfacing touched-repo overlap status, derivation progress, and inline submission errors. V0 ships with no banner; the data is all available on `pending_handoff` but not visualized.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Touched-Workspace Discovery — Tech Spec
|
||||
Part of the local-to-cloud Oz handoff feature ([REMOTE-1486](https://linear.app/warpdotdev/issue/REMOTE-1486)). Full feature behavior in `PRODUCT.md`; the orchestrator and UI that consume this module live in `TECH.md`.
|
||||
## Context
|
||||
The handoff flow needs two pieces of derived state from the local conversation that the existing cloud-agent infra doesn't already produce: a list of git repos and orphan files the local agent has touched (consumed by the snapshot pipeline in the sibling branch), and a repo-aware default env pick for the new cloud-mode pane's env selector. Both derivations are pure / async and have no UI of their own — they're a library that the parent stack branch wires up.
|
||||
This branch contains only the library; nothing in-tree calls it yet, so the module is gated with `#![allow(dead_code)]` and the `sort_environments_by_recency` visibility bump is removed by the parent branch when the consumers land.
|
||||
Relevant code:
|
||||
- `app/src/ai/agent/conversation.rs` — `AIConversation::all_exchanges` and the per-exchange `working_directory` we walk.
|
||||
- `app/src/ai/agent/mod.rs` — `AIAgentAction` and `AIAgentActionType` variants we filter for write actions.
|
||||
- `app/src/ai/cloud_environments/mod.rs` — `CloudAmbientAgentEnvironment` and its `github_repos: Vec<GithubRepo>` field used for env-overlap matching.
|
||||
- `app/src/ai/blocklist/agent_view/agent_input_footer/environment_selector.rs` — `sort_environments_by_recency`, the existing recency-sort helper we now share with the env-overlap pick.
|
||||
## Proposed changes
|
||||
All new code lives in `app/src/ai/blocklist/handoff/touched_repos.rs`.
|
||||
### Path extraction
|
||||
```rust path=null start=null
|
||||
pub(crate) fn extract_paths_from_conversation(conversation: &AIConversation) -> Vec<PathBuf>;
|
||||
```
|
||||
Walks the conversation's exchanges newest-first, capped at `MAX_TOOL_CALLS_TO_SCAN = 500` action results. From each exchange we collect:
|
||||
- Every file path the agent **wrote to** via `RequestFileEdits` or `UploadArtifact`.
|
||||
- The per-exchange `working_directory`, so repos the agent only browsed via shell commands are still discovered.
|
||||
Read-only actions (`ReadFiles`, `Grep`, `FileGlob*`, `SearchCodebase`, `InsertCodeReviewComments`) are intentionally **not** walked: the handoff snapshot uploads orphan-file contents verbatim, so including read-only paths would let the agent leak something like `~/.ssh/id_rsa` into the cloud sandbox. Limiting the walk to writes keeps the snapshot to files the user knowingly let the agent author.
|
||||
Relative paths are resolved against the exchange's `working_directory`; paths with no resolvable cwd are dropped, as are empty entries.
|
||||
### Workspace derivation
|
||||
```rust path=null start=null
|
||||
pub(crate) async fn derive_touched_workspace(paths: Vec<PathBuf>) -> TouchedWorkspace;
|
||||
pub(crate) struct TouchedWorkspace {
|
||||
pub repos: Vec<TouchedRepo>,
|
||||
pub orphan_files: Vec<PathBuf>,
|
||||
}
|
||||
pub(crate) struct TouchedRepo {
|
||||
pub git_root: PathBuf,
|
||||
pub repo_id: Option<GithubRepo>,
|
||||
}
|
||||
```
|
||||
Walks each input path up to the nearest `.git` directory: paths with a `.git` ancestor go into a deduped set of git roots; paths without one are kept as orphan files (filtered to ones that exist and are regular files). For each unique git root, `git remote get-url origin` runs via `command::r#async::Command` (no per-call OS thread) with a 5-second timeout. The trimmed remote URL is parsed by `parse_github_repo` into `<owner>/<repo>` for env-overlap matching; non-GitHub remotes leave `repo_id = None`.
|
||||
Per-repo `branch` / `head_sha` metadata is **not** gathered here — the existing `repo_metadata` helper in the snapshot pipeline (sibling branch) does that during upload, keeping the rehydration prompt's plumbing unchanged.
|
||||
### Env-overlap pick
|
||||
```rust path=null start=null
|
||||
pub(crate) fn pick_handoff_overlap_env(
|
||||
workspace: &TouchedWorkspace,
|
||||
envs: Vec<CloudAmbientAgentEnvironment>,
|
||||
) -> Option<SyncId>;
|
||||
```
|
||||
Scores each env by the number of touched repos it contains (against the env's `github_repos`), picks the highest-scoring env, breaks ties by recency. Returns `None` when no env contains any touched repo so callers leave the existing env-selector default in place.
|
||||
Sorts `envs` internally via `sort_environments_by_recency` (the same helper the env selector uses) so ties resolve to the most-recently-used env. That helper is bumped from `fn` to `pub(crate) fn` and re-exported from `app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs` so this module can call it.
|
||||
## Testing and validation
|
||||
- `touched_repos_tests.rs` covers `find_git_root` against a temporary directory layout (file inside a repo, directory inside a repo, path outside any repo). `find_git_root` is the only helper that walks the real filesystem; covering it directly avoids fixturing `git` subprocess behavior.
|
||||
- `parse_github_repo` and `pick_handoff_overlap_env` are pure helpers exercised end-to-end by the handoff submit path on the parent stack branch — their correctness is enforced by their call sites there rather than by standalone tests in this branch.
|
||||
Reference in New Issue
Block a user