From bbb035b5a4bb209af27399f48e50d75ed739d9cc Mon Sep 17 00:00:00 2001 From: Neil Date: Mon, 9 Nov 2026 18:59:48 -0800 Subject: [PATCH] fix(cloud): review fixes for Apply changes again and late continue (staged+unstaged re-apply, untracked files never lost, no archive with leftovers, atomic writes, per-transfer lock and worktree check, after-restart offer; one continue loop per pane, late reopen verifies session or offers Continue) --- .../cloud-transfer-apply.test.ts | 90 ++++++++++++- .../hosted-sandbox/cloud-transfer-apply.ts | 48 +++++-- .../hosted-sandbox/cloud-transfer-pending.ts | 80 ++++++++++++ .../hosted-sandbox/cloud-transfer-tar.test.ts | 3 +- src/main/hosted-sandbox/cloud-transfer-tar.ts | 69 ++++++++-- src/main/hosted-sandbox/cloud-transfer.ts | 101 +++++++-------- .../api/orca-cloud-workspace-bridge.ts | 1 + .../orca-cloud/CloudMoveToComputerView.tsx | 3 +- .../terminal-pane/input/pane-user-input.ts | 25 +++- .../terminal-pane/input/wake-input-buffer.ts | 8 +- .../pty-connection/input/pty-input-forward.ts | 2 +- src/renderer/src/i18n/locales/en.json | 14 +- .../resume/interrupted-turn-continue-offer.ts | 35 +++++ .../resume/interrupted-turn-continue.test.ts | 89 +++++++++++-- .../resume/interrupted-turn-continue.ts | 81 +++++++++++- .../src/lib/orca-cloud-session-events.ts | 3 + .../src/lib/orca-cloud-transfer-flow.ts | 90 +++---------- .../src/lib/orca-cloud-transfer-reapply.ts | 122 ++++++++++++++++++ .../hosted-sandbox/cloud-transfer-api.ts | 13 ++ 19 files changed, 714 insertions(+), 163 deletions(-) create mode 100644 src/main/hosted-sandbox/cloud-transfer-pending.ts create mode 100644 src/renderer/src/lib/agent-session/resume/interrupted-turn-continue-offer.ts create mode 100644 src/renderer/src/lib/orca-cloud-transfer-reapply.ts diff --git a/src/main/hosted-sandbox/cloud-transfer-apply.test.ts b/src/main/hosted-sandbox/cloud-transfer-apply.test.ts index c6846ddd0c6..e16ef106abe 100644 --- a/src/main/hosted-sandbox/cloud-transfer-apply.test.ts +++ b/src/main/hosted-sandbox/cloud-transfer-apply.test.ts @@ -11,7 +11,7 @@ import { loadPersistedTransfer, persistTransfer } from './cloud-transfer-apply' -import { sweepCloudTransfers } from './cloud-transfer' +import { listPendingCloudTransfers, sweepCloudTransfers } from './cloud-transfer' const git = (cwd: string, ...args: string[]) => execFileSync('git', ['-c', 'user.email=t@t', '-c', 'user.name=t', ...args], { @@ -91,9 +91,13 @@ describe('Apply changes again', () => { it('reports none for an empty patch and parses git apply conflict paths', async () => { const { repo, dir } = setup() - expect((await applyPatchIdempotent(repo, join(dir, 'staged.patch'), true, join(dir, 'u'))).outcome).toBe('none') expect( - conflictPathsFromGitApply('error: patch failed: src/x.ts:12\nerror: src/x.ts: patch does not apply\nerror: gone.txt: does not exist in working directory\n') + (await applyPatchIdempotent(repo, join(dir, 'staged.patch'), true, join(dir, 'u'))).outcome + ).toBe('none') + expect( + conflictPathsFromGitApply( + 'error: patch failed: src/x.ts:12\nerror: src/x.ts: patch does not apply\nerror: gone.txt: does not exist in working directory\n' + ) ).toEqual(['src/x.ts', 'gone.txt']) }) @@ -107,4 +111,84 @@ describe('Apply changes again', () => { expect(existsSync(entry.dir)).toBe(true) expect(await loadPersistedTransfer(join(root, 'nope'))).toBeNull() }) + + it('re-applies safely when staged and unstaged changes touch the same file', async () => { + const { repo, dir, entry, homes } = setup() + // Cloud side: a.txt staged as "one staged", then edited further (unstaged) to "one staged + more". + writeFileSync(join(repo, 'a.txt'), 'one staged\n') + git(repo, 'add', 'a.txt') + writeFileSync(join(dir, 'staged.patch'), git(repo, 'diff', '--cached', '--binary')) + writeFileSync(join(repo, 'a.txt'), 'one staged\nmore\n') + writeFileSync(join(dir, 'unstaged.patch'), git(repo, 'diff', '--binary')) + git(repo, 'reset', '-q', '--hard') + const first = await applyTransferToWorktree(entry, repo, homes) + expect(first).toMatchObject({ + appliedStaged: true, + appliedUnstaged: true, + leftoverPatchDir: null + }) + expect(readFileSync(join(repo, 'a.txt'), 'utf8')).toBe('one staged\nmore\n') + // A retry (lost reply, or "Apply changes again") must recognise both as already there. + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'staged.patch'), git(repo, 'diff', '--cached', '--binary')) + writeFileSync(join(dir, 'unstaged.patch'), git(repo, 'diff', '--binary')) + const again = await applyTransferToWorktree(entry, repo, homes) + expect(again).toMatchObject({ + appliedStaged: true, + appliedUnstaged: true, + leftoverPatchDir: null, + conflicts: [] + }) + expect(git(repo, 'diff', '--cached', '--name-only').trim()).toBe('a.txt') + expect(readFileSync(join(repo, 'a.txt'), 'utf8')).toBe('one staged\nmore\n') + }) + + it('keeps untracked files it could not write (a different file is here) and names them', async () => { + const { root, repo, dir, entry, homes } = setup() + const src = join(root, 'untracked-src') + mkdirSync(join(src, 'notes'), { recursive: true }) + writeFileSync(join(src, 'notes', 'todo.md'), 'cloud todo\n') + writeFileSync(join(src, 'new.txt'), 'cloud new\n') + execFileSync('tar', ['-czf', join(dir, 'untracked.tgz'), '-C', src, 'notes', 'new.txt'], { + env: { ...process.env, COPYFILE_DISABLE: '1' } + }) + mkdirSync(join(repo, 'notes')) + writeFileSync(join(repo, 'notes', 'todo.md'), 'my local todo\n') + const result = await applyTransferToWorktree(entry, repo, homes) + expect(readFileSync(join(repo, 'notes', 'todo.md'), 'utf8')).toBe('my local todo\n') + expect(readFileSync(join(repo, 'new.txt'), 'utf8')).toBe('cloud new\n') + expect(result.skippedUntracked).toEqual(['notes/todo.md']) + expect(result.leftoverPatchDir).toBe(join(dir, 'unapplied')) + expect(readFileSync(join(dir, 'unapplied', 'untracked-not-written.txt'), 'utf8')).toBe( + 'notes/todo.md\n' + ) + expect(existsSync(join(dir, 'unapplied', 'untracked.tgz'))).toBe(true) + // No temp files left behind next to the written ones. + expect(execFileSync('ls', ['-A', join(repo, 'notes')], { encoding: 'utf8' }).trim()).toBe( + 'todo.md' + ) + // Once the local file is moved aside, Apply changes again writes it; identical files count as done. + writeFileSync(join(repo, 'notes', 'todo.md'), 'cloud todo\n') + const again = await applyTransferToWorktree(entry, repo, homes) + expect(again.skippedUntracked).toEqual([]) + expect(again.leftoverPatchDir).toBeNull() + }) + + it('lists transfers left part-applied, for the after-restart offer', async () => { + const { root, repo, entry } = setup() + const transfers = join(root, 'transfers') + const id = '11111111-2222-3333-4444-555555555555' + const dir = join(transfers, id) + mkdirSync(dir, { recursive: true }) + await persistTransfer({ ...entry, dir }, repo) + mkdirSync(join(transfers, 'not-a-transfer')) + expect(await listPendingCloudTransfers(transfers)).toEqual([ + { + transferId: id, + workspaceId: 'ws-1', + worktreePath: repo, + leftoverDir: join(dir, 'unapplied') + } + ]) + }) }) diff --git a/src/main/hosted-sandbox/cloud-transfer-apply.ts b/src/main/hosted-sandbox/cloud-transfer-apply.ts index cb5abe2acc4..fdd3bfbe56d 100644 --- a/src/main/hosted-sandbox/cloud-transfer-apply.ts +++ b/src/main/hosted-sandbox/cloud-transfer-apply.ts @@ -44,7 +44,12 @@ export async function loadPersistedTransfer(dir: string): Promise() for (const line of stderr.split('\n')) { const failed = /^error: patch failed: (.+?):\d+$/.exec(line.trim()) - const other = /^error: (.+?): (?:does not exist in (?:index|working directory)|already exists in (?:index|working directory)|patch does not apply)$/.exec( - line.trim() - ) + const other = + /^error: (.+?): (?:does not exist in (?:index|working directory)|already exists in (?:index|working directory)|patch does not apply|does not match index)$/.exec( + line.trim() + ) const path = failed?.[1] ?? other?.[1] if (path) { paths.add(path.slice(0, 1024)) @@ -94,7 +100,10 @@ export async function applyPatchIdempotent( } const flags = ['--whitespace=nowarn', ...(index ? ['--index'] : [])] const saved = join(leftovers, patch.split('/').at(-1)!) - if ((await gitOk(worktreePath, ['apply', '--check', '-R', ...flags, patch])).ok) { + // "Already applied" for the staged patch is judged on the index alone (--cached): the worktree may + // also carry the unstaged patch on the same files, which --index would read as "does not match". + const reverse = ['--whitespace=nowarn', ...(index ? ['--cached'] : [])] + if ((await gitOk(worktreePath, ['apply', '--check', '-R', ...reverse, patch])).ok) { await rm(saved, { force: true }) return { outcome: 'already', conflicts: [] } } @@ -117,8 +126,18 @@ export async function applyTransferToWorktree( ): Promise { await persistTransfer(entry, worktreePath) const leftovers = join(entry.dir, 'unapplied') - const staged = await applyPatchIdempotent(worktreePath, join(entry.dir, 'staged.patch'), true, leftovers) - const unstaged = await applyPatchIdempotent(worktreePath, join(entry.dir, 'unstaged.patch'), false, leftovers) + const staged = await applyPatchIdempotent( + worktreePath, + join(entry.dir, 'staged.patch'), + true, + leftovers + ) + const unstaged = await applyPatchIdempotent( + worktreePath, + join(entry.dir, 'unstaged.patch'), + false, + leftovers + ) let skippedUntracked: string[] = [] if ((await fileSize(join(entry.dir, 'untracked.tgz'))) > 0) { // Inflate with a cap (no gzip bombs), then extract without clobbering anything already there. @@ -128,8 +147,21 @@ export async function applyTransferToWorktree( maxBytes: CLOUD_TRANSFER_MAX_BYTES, noClobber: true }) - skippedUntracked = extracted.skipped.slice(0, 200) await rm(untrackedTar, { force: true }) + // Files that differ from what is here were not written: keep the archive and name them, so + // nothing is lost and the folder (and "Apply changes again") stays. + skippedUntracked = [...extracted.existing, ...extracted.skipped].slice(0, 200) + if (extracted.existing.length > 0) { + await mkdir(leftovers, { recursive: true }) + await cp(join(entry.dir, 'untracked.tgz'), join(leftovers, 'untracked.tgz')) + await writeFile( + join(leftovers, 'untracked-not-written.txt'), + `${extracted.existing.join('\n')}\n` + ) + } else { + await rm(join(leftovers, 'untracked.tgz'), { force: true }) + await rm(join(leftovers, 'untracked-not-written.txt'), { force: true }) + } } const claudeSessions = await installSessions( join(entry.dir, 'claude'), diff --git a/src/main/hosted-sandbox/cloud-transfer-pending.ts b/src/main/hosted-sandbox/cloud-transfer-pending.ts new file mode 100644 index 00000000000..e98a36fa7d1 --- /dev/null +++ b/src/main/hosted-sandbox/cloud-transfer-pending.ts @@ -0,0 +1,80 @@ +// "Move to this computer": transfer folders left on disk. Prepared transfers live only in memory, so +// a folder found at startup is abandoned unless an apply was attempted on it (then it is kept, and +// offered for "Apply changes again", for 30 days). +import { readdir, readFile, rm, stat } from 'node:fs/promises' +import { join } from 'node:path' +import type { CloudTransferPending } from '../../shared/hosted-sandbox/cloud-transfer-api' +import { + PersistedTransferSchema, + TRANSFER_STATE_FILE, + loadPersistedTransfer +} from './cloud-transfer-apply' + +const LEFTOVER_TTL_MS = 30 * 86_400_000 + +/** + * Prepared transfers live only in memory, so anything on disk at startup is abandoned: removed, + * except saved patches that did not apply (the user's work), which are kept for 30 days. + */ +export async function sweepCloudTransfers(root: string, now: number): Promise { + let removed = 0 + for (const entry of await readdir(root, { withFileTypes: true }).catch(() => [])) { + if (!entry.isDirectory()) { + continue + } + const dir = join(root, entry.name) + const leftovers = await stat(join(dir, 'unapplied')).catch(() => null) + if (leftovers && now - leftovers.mtimeMs < LEFTOVER_TTL_MS) { + continue + } + // An apply was attempted but did not finish: keep it so "Apply changes again" still works. + if (await appliedAttemptWithin(dir, now)) { + continue + } + await rm(dir, { recursive: true, force: true }) + removed++ + } + return removed +} + +export async function persistedWorktreePath(dir: string): Promise { + try { + const parsed = PersistedTransferSchema.safeParse( + JSON.parse(await readFile(join(dir, TRANSFER_STATE_FILE), 'utf8')) + ) + return parsed.success ? (parsed.data.worktreePath ?? null) : null + } catch { + return null + } +} + +async function appliedAttemptWithin(dir: string, now: number): Promise { + const info = await stat(join(dir, TRANSFER_STATE_FILE)).catch(() => null) + if (!info || now - info.mtimeMs >= LEFTOVER_TTL_MS) { + return false + } + return (await persistedWorktreePath(dir)) !== null +} + +/** Transfers whose apply was attempted but left something behind (shown after a restart). */ +export async function listPendingCloudTransfers(root: string): Promise { + const pending: CloudTransferPending[] = [] + for (const entry of await readdir(root, { withFileTypes: true }).catch(() => [])) { + if (!entry.isDirectory() || !/^[0-9a-f-]{36}$/i.test(entry.name)) { + continue + } + const dir = join(root, entry.name) + const transfer = await loadPersistedTransfer(dir) + const worktreePath = await persistedWorktreePath(dir) + if (!transfer || !worktreePath) { + continue + } + pending.push({ + transferId: entry.name, + workspaceId: transfer.workspaceId, + worktreePath, + leftoverDir: join(dir, 'unapplied') + }) + } + return pending +} diff --git a/src/main/hosted-sandbox/cloud-transfer-tar.test.ts b/src/main/hosted-sandbox/cloud-transfer-tar.test.ts index 244042abd18..c5c23c9cbed 100644 --- a/src/main/hosted-sandbox/cloud-transfer-tar.test.ts +++ b/src/main/hosted-sandbox/cloud-transfer-tar.test.ts @@ -105,7 +105,8 @@ describe('safe extraction of VM archives', () => { const result = await safeExtractTar(archive, dest, { maxBytes: 1024, noClobber: true }) expect(existsSync(join(outside, 'pwned'))).toBe(false) expect(readFileSync(join(dest, 'keep.txt'), 'utf8')).toBe('mine') - expect(result.skipped).toEqual(['linked/pwned', 'keep.txt']) + expect(result.skipped).toEqual(['linked/pwned']) + expect(result.existing).toEqual(['keep.txt']) }) it('inflates gzip only up to the cap (gzip bomb)', async () => { diff --git a/src/main/hosted-sandbox/cloud-transfer-tar.ts b/src/main/hosted-sandbox/cloud-transfer-tar.ts index 1e8620df185..5138f5ecb7d 100644 --- a/src/main/hosted-sandbox/cloud-transfer-tar.ts +++ b/src/main/hosted-sandbox/cloud-transfer-tar.ts @@ -3,7 +3,8 @@ // or climbs out of the destination are skipped; total bytes and entry counts are capped, and gzip is // inflated with a byte cap (no gzip bombs). import { createReadStream, createWriteStream } from 'node:fs' -import { lstat, mkdir, open, realpath, rm } from 'node:fs/promises' +import { link, lstat, mkdir, open, readFile, realpath, rename, rm, unlink } from 'node:fs/promises' +import { randomBytes } from 'node:crypto' import { dirname, isAbsolute, join, normalize, sep } from 'node:path' import { pipeline } from 'node:stream/promises' import { Transform } from 'node:stream' @@ -11,7 +12,17 @@ import { createGunzip } from 'node:zlib' const BLOCK = 512 -export type TarExtractResult = { files: number; dirs: number; bytes: number; skipped: string[] } +export type TarExtractResult = { + files: number + dirs: number + bytes: number + /** Entries refused for safety (links, devices, escapes, symlinked ancestors). */ + skipped: string[] + /** `noClobber`: a different file was already at the path; the transferred one was not written. */ + existing: string[] + /** `noClobber`: the same content was already at the path (an earlier attempt wrote it). */ + identical: number +} /** True when any existing ancestor of `relative` under `root` (or the entry itself) is a symlink. */ async function throughSymlink(root: string, relative: string): Promise { @@ -100,7 +111,14 @@ export async function safeExtractTar( await mkdir(destination, { recursive: true }) const root = await realpath(destination) const handle = await open(tarPath, 'r') - const result: TarExtractResult = { files: 0, dirs: 0, bytes: 0, skipped: [] } + const result: TarExtractResult = { + files: 0, + dirs: 0, + bytes: 0, + skipped: [], + existing: [], + identical: 0 + } const header = Buffer.alloc(BLOCK) let offset = 0 let longName: string | null = null @@ -173,14 +191,17 @@ export async function safeExtractTar( await mkdir(dirname(target), { recursive: true }) // Never write through a symlink an earlier step (or the user) left at the path. const existing = await lstat(target).catch(() => null) - if (existing && (options.noClobber || !existing.isFile())) { + if (existing && !existing.isFile()) { result.skipped.push(rawName.slice(0, 200)) offset += padded continue } // Keep the executable bit (scripts); never setuid/setgid or group/world-writable. const executable = (readOctal(header, 100, 8) & 0o111) !== 0 - const out = await open(target, 'w', executable ? 0o755 : 0o644) + // Written to a fresh temp name first ('wx': never an existing path), then put in place, so a + // failure mid-write never leaves a partial file at the real path. + const temp = `${target}.orca-transfer-${randomBytes(6).toString('hex')}` + const out = await open(temp, 'wx', executable ? 0o755 : 0o644) try { let remaining = size let position = offset @@ -195,11 +216,37 @@ export async function safeExtractTar( remaining -= read.bytesRead position += read.bytesRead } - } finally { - await out.close() + } catch (error) { + await out.close().catch(() => undefined) + await unlink(temp).catch(() => undefined) + throw error + } + await out.close() + offset += padded + if (existing && options.noClobber) { + if (existing.size === size && (await sameContent(temp, target))) { + result.identical++ + } else { + result.existing.push(relative) + } + await unlink(temp) + continue + } + if (options.noClobber) { + // link() fails if something appeared at the path meanwhile: atomic no-clobber. + try { + await link(temp, target) + } catch { + result.existing.push(relative) + await unlink(temp) + continue + } + await unlink(temp) + } else { + await rename(temp, target) } result.files++ - offset += padded + continue } } finally { await handle.close() @@ -227,3 +274,9 @@ export async function gunzipCapped( } return total } + +async function sameContent(a: string, b: string): Promise { + // Only reached for files of equal size; transfers are capped, so reading both is bounded. + const [left, right] = await Promise.all([readFile(a), readFile(b)]) + return left.equals(right) +} diff --git a/src/main/hosted-sandbox/cloud-transfer.ts b/src/main/hosted-sandbox/cloud-transfer.ts index 00c35282976..74173090f31 100644 --- a/src/main/hosted-sandbox/cloud-transfer.ts +++ b/src/main/hosted-sandbox/cloud-transfer.ts @@ -3,7 +3,7 @@ // in the VM, downloads and imports the branch; finish (after the window created the local worktree // through its ordinary create path) applies changes and installs remapped agent sessions. import { randomUUID } from 'node:crypto' -import { mkdir, readdir, readFile, rm, stat } from 'node:fs/promises' +import { mkdir, rm, stat } from 'node:fs/promises' import { homedir } from 'node:os' import { join } from 'node:path' import { ipcMain, type BrowserWindow } from 'electron' @@ -14,15 +14,19 @@ import { } from '../../shared/hosted-sandbox/cloud-transfer-pack' import { safeExtractTar } from './cloud-transfer-tar' import { - PersistedTransferSchema, - TRANSFER_STATE_FILE, applyTransferToWorktree, loadPersistedTransfer, persistTransfer } from './cloud-transfer-apply' +import { + listPendingCloudTransfers, + persistedWorktreePath, + sweepCloudTransfers +} from './cloud-transfer-pending' import { ORCA_CLOUD_TRANSFER_CHANNELS as CH, type CloudTransferFinished, + type CloudTransferPending, type CloudTransferPhase, type CloudTransferPrepared, type CloudTransferProgressEvent, @@ -41,6 +45,7 @@ import { createHostedRuntimeEnvironmentCall } from './hosted-runtime-environment import { findCloudRuntime } from './hosted-workspace-service' export { importBundle, installSessions, pickCloudWorktree } from './cloud-transfer-steps' +export { listPendingCloudTransfers, sweepCloudTransfers } from './cloud-transfer-pending' export function installCloudTransferHandlers(deps: { userDataPath: string @@ -50,6 +55,13 @@ export function installCloudTransferHandlers(deps: { codexHome?: string }): () => void { const prepared = new Map() + // One apply at a time per transfer (finish and "Apply changes again" share it). + const applying = new Set() + const busy = (): CloudTransferResult => ({ + ok: false, + code: 'busy', + message: 'Changes from this move are already being applied.' + }) const claudeHome = deps.claudeHome ?? process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude') const codexHome = deps.codexHome ?? process.env.CODEX_HOME ?? join(homedir(), '.codex') const emit = (event: Omit): void => { @@ -78,6 +90,7 @@ export function installCloudTransferHandlers(deps: { ipcMain.removeHandler(CH.prepare) ipcMain.removeHandler(CH.finish) ipcMain.removeHandler(CH.reapply) + ipcMain.removeHandler(CH.listPending) ipcMain.handle( CH.prepare, async ( @@ -191,9 +204,16 @@ export function installCloudTransferHandlers(deps: { } const step = (phase: CloudTransferPhase): void => emit({ workspaceId: entry.workspaceId, transferId: args.transferId, phase }) + if (applying.has(args.transferId)) { + return busy() + } + applying.add(args.transferId) try { step('applying') - const finished = await applyTransferToWorktree(entry, args.worktreePath, { claudeHome, codexHome }) + const finished = await applyTransferToWorktree(entry, args.worktreePath, { + claudeHome, + codexHome + }) // Kept in memory while something is left to apply, so "Apply changes again" can retry. if (!finished.leftoverPatchDir) { prepared.delete(args.transferId) @@ -202,6 +222,8 @@ export function installCloudTransferHandlers(deps: { return { ok: true, value: finished } } catch (error) { return fail(entry.workspaceId, args.transferId, error) + } finally { + applying.delete(args.transferId) } } ) @@ -216,16 +238,27 @@ export function installCloudTransferHandlers(deps: { if (!/^[0-9a-f-]{36}$/i.test(args.transferId)) { return { ok: false, code: 'not_found', message: 'That transfer is no longer available.' } } - const entry = - prepared.get(args.transferId) ?? - (await loadPersistedTransfer(join(deps.userDataPath, 'cloud-transfers', args.transferId))) - const worktreeOk = await stat(args.worktreePath).then((s) => s.isDirectory(), () => false) - if (!entry || !worktreeOk) { + const dir = join(deps.userDataPath, 'cloud-transfers', args.transferId) + const entry = prepared.get(args.transferId) ?? (await loadPersistedTransfer(dir)) + const recorded = await persistedWorktreePath(dir) + const worktreeOk = await stat(args.worktreePath).then( + (s) => s.isDirectory(), + () => false + ) + // Only the worktree this transfer was first applied to: never another one the window names. + if (!entry || !worktreeOk || (recorded !== null && recorded !== args.worktreePath)) { return { ok: false, code: 'not_found', message: 'That transfer is no longer available.' } } + if (applying.has(args.transferId)) { + return busy() + } + applying.add(args.transferId) try { emit({ workspaceId: entry.workspaceId, transferId: args.transferId, phase: 'applying' }) - const finished = await applyTransferToWorktree(entry, args.worktreePath, { claudeHome, codexHome }) + const finished = await applyTransferToWorktree(entry, args.worktreePath, { + claudeHome, + codexHome + }) if (finished.leftoverPatchDir) { prepared.set(args.transferId, entry) } else { @@ -235,54 +268,18 @@ export function installCloudTransferHandlers(deps: { return { ok: true, value: finished } } catch (error) { return fail(entry.workspaceId, args.transferId, error) + } finally { + applying.delete(args.transferId) } } ) + ipcMain.handle(CH.listPending, async (): Promise => + listPendingCloudTransfers(join(deps.userDataPath, 'cloud-transfers')) + ) return () => { ipcMain.removeHandler(CH.prepare) ipcMain.removeHandler(CH.finish) ipcMain.removeHandler(CH.reapply) - } -} - -const LEFTOVER_TTL_MS = 30 * 86_400_000 - -/** - * Prepared transfers live only in memory, so anything on disk at startup is abandoned: removed, - * except saved patches that did not apply (the user's work), which are kept for 30 days. - */ -export async function sweepCloudTransfers(root: string, now: number): Promise { - let removed = 0 - for (const entry of await readdir(root, { withFileTypes: true }).catch(() => [])) { - if (!entry.isDirectory()) { - continue - } - const dir = join(root, entry.name) - const leftovers = await stat(join(dir, 'unapplied')).catch(() => null) - if (leftovers && now - leftovers.mtimeMs < LEFTOVER_TTL_MS) { - continue - } - // An apply was attempted but did not finish: keep it so "Apply changes again" still works. - if (await appliedAttemptWithin(dir, now)) { - continue - } - await rm(dir, { recursive: true, force: true }) - removed++ - } - return removed -} - -async function appliedAttemptWithin(dir: string, now: number): Promise { - const info = await stat(join(dir, TRANSFER_STATE_FILE)).catch(() => null) - if (!info || now - info.mtimeMs >= LEFTOVER_TTL_MS) { - return false - } - try { - const parsed = PersistedTransferSchema.safeParse( - JSON.parse(await readFile(join(dir, TRANSFER_STATE_FILE), 'utf8')) - ) - return parsed.success && parsed.data.worktreePath !== undefined - } catch { - return false + ipcMain.removeHandler(CH.listPending) } } diff --git a/src/preload/api/orca-cloud-workspace-bridge.ts b/src/preload/api/orca-cloud-workspace-bridge.ts index ac64d50bc9f..0f68fb7ba5b 100644 --- a/src/preload/api/orca-cloud-workspace-bridge.ts +++ b/src/preload/api/orca-cloud-workspace-bridge.ts @@ -34,6 +34,7 @@ export const orcaCloudWorkspaceApi = { }, prepareTransferToLocal: (args) => ipcRenderer.invoke(TRANSFER.prepare, args), reapplyTransferToLocal: (args) => ipcRenderer.invoke(TRANSFER.reapply, args), + listPendingTransfers: () => ipcRenderer.invoke(TRANSFER.listPending), finishTransferToLocal: (args) => ipcRenderer.invoke(TRANSFER.finish, args), onTransferProgress: (callback) => { const listener = (_event: Electron.IpcRendererEvent, event: CloudTransferProgressEvent): void => diff --git a/src/renderer/src/components/orca-cloud/CloudMoveToComputerView.tsx b/src/renderer/src/components/orca-cloud/CloudMoveToComputerView.tsx index 6597b993f44..7fda294e8e6 100644 --- a/src/renderer/src/components/orca-cloud/CloudMoveToComputerView.tsx +++ b/src/renderer/src/components/orca-cloud/CloudMoveToComputerView.tsx @@ -83,7 +83,8 @@ export function CloudMoveToComputerView({ }, { key: 'changes', - node: known + node: + known && known.uncommittedChanges > 0 ? t( 'orcaCloudMove.confirm.changesCount', '{{count}} uncommitted changes, plus untracked files', diff --git a/src/renderer/src/components/terminal-pane/input/pane-user-input.ts b/src/renderer/src/components/terminal-pane/input/pane-user-input.ts index a63e05d8a03..1f0b80b059a 100644 --- a/src/renderer/src/components/terminal-pane/input/pane-user-input.ts +++ b/src/renderer/src/components/terminal-pane/input/pane-user-input.ts @@ -2,11 +2,25 @@ // leaves those panes alone. Entries older than the memory window are pruned when a wake settles. const lastUserInputByPane = new Map() +const lastUserInputByWorkspace = new Map() const USER_INPUT_MEMORY_MS = 30 * 60_000 /** Any person's input reached this pane (layout leaf). Called for every admitted keystroke. */ -export function noteUserInputForPane(paneId: string, at = Date.now()): void { +export function noteUserInputForPane( + paneId: string, + at = Date.now(), + workspaceId?: string | null +): void { lastUserInputByPane.set(paneId, at) + if (workspaceId) { + lastUserInputByWorkspace.set(workspaceId, at) + } +} + +/** True when a person's input reached any pane of this workspace since `since`. */ +export function workspaceHadUserInputSince(workspaceId: string, since: number): boolean { + const at = lastUserInputByWorkspace.get(workspaceId) + return at !== undefined && at >= since } /** True when a person's input reached this pane since `since` (wake typing included). */ @@ -16,13 +30,16 @@ export function paneHadUserInputSince(paneId: string, since: number): boolean { } export function pruneUserInput(now = Date.now()): void { - for (const [paneId, at] of lastUserInputByPane) { - if (now - at > USER_INPUT_MEMORY_MS) { - lastUserInputByPane.delete(paneId) + for (const map of [lastUserInputByPane, lastUserInputByWorkspace]) { + for (const [key, at] of map) { + if (now - at > USER_INPUT_MEMORY_MS) { + map.delete(key) + } } } } export function resetPaneUserInputForTests(): void { lastUserInputByPane.clear() + lastUserInputByWorkspace.clear() } diff --git a/src/renderer/src/components/terminal-pane/input/wake-input-buffer.ts b/src/renderer/src/components/terminal-pane/input/wake-input-buffer.ts index 56b74c53000..3d414c8f5fa 100644 --- a/src/renderer/src/components/terminal-pane/input/wake-input-buffer.ts +++ b/src/renderer/src/components/terminal-pane/input/wake-input-buffer.ts @@ -12,7 +12,11 @@ import { applyWakeInput } from './wake-input-text' import { noteUserInputForPane, pruneUserInput, resetPaneUserInputForTests } from './pane-user-input' export { WAKE_INPUT_CAP, applyWakeInput } from './wake-input-text' -export { noteUserInputForPane, paneHadUserInputSince } from './pane-user-input' +export { + noteUserInputForPane, + paneHadUserInputSince, + workspaceHadUserInputSince +} from './pane-user-input' const FLUSH_POLL_MS = 100 /** A wake nobody settles stops holding typing after this, and the typing is dropped. */ export const WAKE_INPUT_MAX_HOLD_MS = 90_000 @@ -156,7 +160,7 @@ function pump(now = Date.now()): void { } try { pane.deliver(text) - noteUserInputForPane(paneId, now) + noteUserInputForPane(paneId, now, workspaceId) for (const listener of deliveryListeners) { listener({ workspaceId, paneId, submitted }) } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/input/pty-input-forward.ts b/src/renderer/src/components/terminal-pane/pty-connection/input/pty-input-forward.ts index 9e73f05b0b8..1eea4f134ee 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/input/pty-input-forward.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/input/pty-input-forward.ts @@ -186,7 +186,7 @@ export function installPtyInputForward(session: ConnectPanePtySession): void { session.pane.terminal, (data, wasUserInput) => { if (wasUserInput) { - noteUserInputForPane(wakeInputKey) + noteUserInputForPane(wakeInputKey, Date.now(), session.deps.worktreeId) } // Typing into a workspace whose server is waking is accepted now and delivered, in order, // once this pane is attached to the woken terminal. diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 5673969bf81..3b472b349b0 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -26709,6 +26709,18 @@ "failedLocalNext": "Use Move again from the sandbox panel to bring them over. Your files are safe in the cloud workspace.", "conflicts": "Changed here since: {{files}}", "failedLocalReapply": "Apply changes again to bring them over. Nothing you changed here is overwritten, and your files are safe in the cloud workspace.", - "reapply": "Apply changes again" + "reapply": "Apply changes again", + "pending": { + "title": "Some changes from a move weren’t applied", + "detail": "They are saved on this computer. Nothing you changed here is overwritten.", + "stillLeft": "Some changes still conflict with edits made here. They stay saved.", + "done": "All changes applied" + } + }, + "orcaCloud": { + "continueOffer": { + "title": "An agent was interrupted by the restart", + "action": "Continue" + } } } diff --git a/src/renderer/src/lib/agent-session/resume/interrupted-turn-continue-offer.ts b/src/renderer/src/lib/agent-session/resume/interrupted-turn-continue-offer.ts new file mode 100644 index 00000000000..d85fecc4e34 --- /dev/null +++ b/src/renderer/src/lib/agent-session/resume/interrupted-turn-continue-offer.ts @@ -0,0 +1,35 @@ +// A reopened pane the continue logic won't type into on its own (a late reopen whose session or +// activity can't be confirmed): one click sends `continue`, once per interrupted turn. +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { getPtyIdForPaneKey } from '@/hooks/pane-key-pty-id' +import { sendBracketedPasteToRunningAgent } from '@/lib/agent-session/messaging/paste/agent-paste-draft' +import { INTERRUPTED_TURN_CONTINUE_PROMPT } from '../../../../../shared/agent-launch/startup/tui-agent-resume-startup' +import { + isInterruptedTurnContinued, + markInterruptedTurnContinued +} from '@/components/terminal-pane/pty-connection/startup/cold-restore-resume-startup' + +export function offerInterruptedTurnContinue(paneKey: string, turnKey: string): void { + toast(translate('orcaCloud.continueOffer.title', 'An agent was interrupted by the restart'), { + id: `orca-continue-offer-${turnKey}`, + duration: 60_000, + action: { + label: translate('orcaCloud.continueOffer.action', 'Continue'), + onClick: () => { + const ptyId = getPtyIdForPaneKey(paneKey) + if (!ptyId || isInterruptedTurnContinued(turnKey)) { + return + } + void sendBracketedPasteToRunningAgent({ + ptyId, + content: INTERRUPTED_TURN_CONTINUE_PROMPT + }).then((sent) => { + if (sent) { + markInterruptedTurnContinued(turnKey) + } + }) + } + } + }) +} diff --git a/src/renderer/src/lib/agent-session/resume/interrupted-turn-continue.test.ts b/src/renderer/src/lib/agent-session/resume/interrupted-turn-continue.test.ts index 5825abfe2fd..e9dc4263768 100644 --- a/src/renderer/src/lib/agent-session/resume/interrupted-turn-continue.test.ts +++ b/src/renderer/src/lib/agent-session/resume/interrupted-turn-continue.test.ts @@ -145,25 +145,98 @@ describe('interrupted turn continue', () => { expect(await continueInterruptedTurns('ws', never.deps)).toBe(0) }) - it('waits for a closed pane to be reopened (agent view reopened after the restart), then continues', async () => { - state.agentStatusByPaneKey = { 'tab-a:1': working('tab-a:1', 'claude') } + /** The pane opens 10 min after the restart; then the relaunched agent reports `report`. */ + async function lateReopen(report: Record, extra: Record = {}) { + state.agentStatusByPaneKey = { + 'tab-a:1': working('tab-a:1', 'claude', { + providerSession: { id: 'sess-1' }, + terminalHandle: 'old' + }) + } armInterruptedTurnsForHostRestart('ws', NOW) - // The pane is closed for 10 minutes (well past the 90s relaunch window), then reopened. let clock = NOW - const send = vi.fn(async (_args: { ptyId: string; prompt: string }) => true) + const reopenAt = NOW + 10 * 60_000 + const submit = vi.fn(async (_args: { ptyId: string; content: string }) => true) + const offer = vi.fn() const sent = await continueInterruptedTurns('ws', { now: () => clock, sleep: async (ms: number) => { clock += ms + if (clock >= reopenAt + 3_000) { + state.agentStatusByPaneKey = { + 'tab-a:1': working('tab-a:1', 'claude', { + updatedAt: reopenAt + 1_000, + terminalHandle: 'new', + ...report + }) + } + } }, - ptyIdFor: () => (clock - NOW >= 10 * 60_000 ? 'pty-reopened' : null), + ptyIdFor: () => (clock >= reopenAt ? 'pty-reopened' : null), + send: vi.fn(async () => false), + submit, + agentInForeground: async () => true, + offer, + ...extra + }) + return { sent, submit, offer } + } + + it('continues a pane reopened long after the restart when the same session came back', async () => { + const { sent, submit, offer } = await lateReopen({ + providerSession: { id: 'sess-1' }, + state: 'done' + }) + expect(sent).toBe(1) + expect(submit).toHaveBeenCalledTimes(1) + expect(submit.mock.calls[0]![0]).toMatchObject({ ptyId: 'pty-reopened', content: 'continue' }) + expect(offer).not.toHaveBeenCalled() + }) + + it('offers Continue instead of typing when a late reopen shows a different session', async () => { + const { sent, submit, offer } = await lateReopen({ + providerSession: { id: 'sess-NEW' }, + state: 'done' + }) + expect(sent).toBe(0) + expect(submit).not.toHaveBeenCalled() + expect(offer).toHaveBeenCalledTimes(1) + }) + + it('offers Continue instead of typing when the person was active in the workspace since', async () => { + const { noteUserInputForPane } = + await import('@/components/terminal-pane/input/wake-input-buffer') + noteUserInputForPane('some-other-pane', NOW + 60_000, 'ws') + const { sent, submit, offer } = await lateReopen({ + providerSession: { id: 'sess-1' }, + state: 'done' + }) + expect(sent).toBe(0) + expect(submit).not.toHaveBeenCalled() + expect(offer).toHaveBeenCalledTimes(1) + }) + + it('a re-arm stops the older wait loop, so continue is never sent twice', async () => { + state.agentStatusByPaneKey = { 'tab-a:1': working('tab-a:1', 'claude') } + armInterruptedTurnsForHostRestart('ws', NOW) + let clock = NOW + const send = vi.fn(async () => true) + const first = continueInterruptedTurns('ws', { + now: () => clock, + sleep: async (ms: number) => { + clock += ms + // A second restart arms the pane again while the first loop is still waiting. + if (clock === NOW + 5_000) { + armInterruptedTurnsForHostRestart('ws', clock) + } + }, + ptyIdFor: () => (clock >= NOW + 20_000 ? 'pty' : null), send, submit: vi.fn(async () => true), agentInForeground: async () => true }) - expect(sent).toBe(1) - expect(send).toHaveBeenCalledTimes(1) - expect(send.mock.calls[0]![0]).toMatchObject({ ptyId: 'pty-reopened', prompt: 'continue' }) + expect(await first).toBe(0) + expect(send).not.toHaveBeenCalled() }) it('gives up on a pane that is never reopened while the turn is fresh', async () => { diff --git a/src/renderer/src/lib/agent-session/resume/interrupted-turn-continue.ts b/src/renderer/src/lib/agent-session/resume/interrupted-turn-continue.ts index a2cfee56739..ccaff26d425 100644 --- a/src/renderer/src/lib/agent-session/resume/interrupted-turn-continue.ts +++ b/src/renderer/src/lib/agent-session/resume/interrupted-turn-continue.ts @@ -6,6 +6,7 @@ // a sleep: those are the restarts that cut a working turn. (A pane's sleeping record is no signal // here: it can outlive the wake that resumed the agent.) import { useAppStore } from '@/store' +import { offerInterruptedTurnContinue } from './interrupted-turn-continue-offer' import { getPtyIdForPaneKey } from '@/hooks/pane-key-pty-id' import { sendFollowupPromptWhenAgentReady } from '@/lib/agent-session/messaging/paste/agent-followup-delivery' import { sendBracketedPasteToRunningAgent } from '@/lib/agent-session/messaging/paste/agent-paste-draft' @@ -19,7 +20,10 @@ import { isInterruptedTurnContinued, markInterruptedTurnContinued } from '@/components/terminal-pane/pty-connection/startup/cold-restore-resume-startup' -import { paneHadUserInputSince } from '@/components/terminal-pane/input/wake-input-buffer' +import { + paneHadUserInputSince, + workspaceHadUserInputSince +} from '@/components/terminal-pane/input/wake-input-buffer' import { inspectRuntimeTerminalProcess } from '@/runtime/hosted-terminals/inspection/runtime-terminal-process-inspection' import { isAgentForegroundWrapperProcess, @@ -27,6 +31,9 @@ import { } from '../../../../../shared/agent-status/process/agent-process-recognition' type ArmedTurn = { + workspaceId: string + /** The agent session that was working: a late relaunch must resume this one, not a new one. */ + providerSessionId: string | null paneKey: string agent: TuiAgent turnKey: string @@ -35,6 +42,10 @@ type ArmedTurn = { terminalHandle: string | null /** The pane's PTY when armed (the relaunch may reuse the id; it is only a hint). */ ptyIdAtArm: string | null + /** This pane's arm generation; a newer arm or a disarm makes this loop stale. */ + generation?: number + /** Set by continueInterruptedTurns: the restart this turn was armed for has settled. */ + settledAt?: number } /** How long after its pane is open a relaunched agent may take to come up. */ @@ -50,6 +61,14 @@ const CLOSED_PANE_POLL_MS = 5_000 const SETTLE_AFTER_REPORT_MS = 2_000 const armedByWorkspace = new Map() +/** Bumped by every arm/disarm of a pane: an older wait loop for that pane stops at its next step. */ +const paneGeneration = new Map() +const bumpPane = (paneKey: string): number => { + const next = (paneGeneration.get(paneKey) ?? 0) + 1 + paneGeneration.set(paneKey, next) + return next +} +/** A relaunch first seen this long after the restart is "late": the person reopened the pane. */ /** Dev builds only: what the continue logic decided, for e2e drivers (window.__orcaContinueTrace). */ const devTrace: string[] | null = import.meta.env?.DEV && typeof window !== 'undefined' ? [] : null @@ -70,6 +89,7 @@ type StatusEntry = { tabId?: string paneKey: string terminalHandle?: string + providerSession?: { id?: string } } function paneWorkspaceId(entry: StatusEntry, tabWorkspace: Map): string | null { @@ -102,6 +122,8 @@ export function armInterruptedTurnsForHostRestart(workspaceId: string, now = Dat continue } armed.push({ + workspaceId, + providerSessionId: entry.providerSession?.id ?? null, paneKey: entry.paneKey, agent, turnKey: interruptedTurnKey(entry, entry.paneKey), @@ -113,6 +135,12 @@ export function armInterruptedTurnsForHostRestart(workspaceId: string, now = Dat trace( `arm ${workspaceId.slice(-12)} ${armed.map((turn) => `${turn.agent}:${turn.paneKey.slice(-4)}`).join(',')}` ) + for (const turn of armedByWorkspace.get(workspaceId) ?? []) { + bumpPane(turn.paneKey) + } + for (const turn of armed) { + turn.generation = bumpPane(turn.paneKey) + } if (armed.length > 0) { armedByWorkspace.set(workspaceId, armed) } else { @@ -123,6 +151,9 @@ export function armInterruptedTurnsForHostRestart(workspaceId: string, now = Dat /** The restart failed or was abandoned: forget what was armed. */ export function disarmInterruptedTurns(workspaceId: string): void { + for (const turn of armedByWorkspace.get(workspaceId) ?? []) { + bumpPane(turn.paneKey) + } armedByWorkspace.delete(workspaceId) } @@ -159,9 +190,18 @@ async function continueOne(turn: ArmedTurn, deps: ContinueDeps): Promise + turn.generation !== undefined && paneGeneration.get(turn.paneKey) !== turn.generation + const userTookOver = (): boolean => + isInterruptedTurnContinued(turn.turnKey) || + paneHadUserInputSince(leafOf(turn.paneKey), turn.armedAt) while (deps.now() < Math.min(freshUntil, relaunchDeadline ?? freshUntil)) { + if (stale()) { + return false + } if ( continuedOnItsOwn(turn.paneKey, turn.armedAt) || // A cold-restore resume already carried `continue`, or a person typed into the pane since. @@ -178,6 +218,20 @@ async function continueOne(turn: ArmedTurn, deps: ContinueDeps): Promise RELAUNCH_WAIT_MS) { + if (!relaunchReport(turn)) { + // Can't tell which session came back until the relaunched agent reports. + await deps.sleep(POLL_MS) + continue + } + if (!lateContinueAllowed(turn)) { + deps.offer?.(turn) + return false + } + } // The relaunched agent already reported (its hooks only run inside the agent), so its TUI is // up and reading input: submit once it has settled and owns the terminal. Some agents (Codex) // resume idle and never emit the paste-readiness signal the generic path waits for. @@ -189,7 +243,8 @@ async function continueOne(turn: ArmedTurn, deps: ContinueDeps): Promise= SETTLE_AFTER_REPORT_MS && (await deps.agentInForeground(ptyId, turn.agent)) && - !isInterruptedTurnContinued(turn.turnKey) && + !stale() && + !userTookOver() && (await deps.submit({ ptyId, content: INTERRUPTED_TURN_CONTINUE_PROMPT @@ -199,7 +254,7 @@ async function continueOne(turn: ArmedTurn, deps: ContinueDeps): Promise number sleep: (ms: number) => Promise @@ -225,6 +290,8 @@ type ContinueDeps = { submit: typeof sendBracketedPasteToRunningAgent /** The relaunched agent (not a shell) is the terminal's foreground process. */ agentInForeground: (ptyId: string, agent: TuiAgent) => Promise + /** Late reopen that may not be typed into: offer "Continue" instead. */ + offer?: (turn: ArmedTurn) => void } async function agentOwnsTerminal(ptyId: string, agent: TuiAgent): Promise { @@ -245,7 +312,8 @@ const defaultDeps: ContinueDeps = { ptyIdFor: getPtyIdForPaneKey, send: sendFollowupPromptWhenAgentReady, submit: sendBracketedPasteToRunningAgent, - agentInForeground: agentOwnsTerminal + agentInForeground: agentOwnsTerminal, + offer: (turn) => offerInterruptedTurnContinue(turn.paneKey, turn.turnKey) } /** The host is back: send `continue` to every armed agent once its relaunched TUI reads input. */ @@ -259,6 +327,10 @@ export async function continueInterruptedTurns( return 0 } trace(`continue ${workspaceId.slice(-12)} ${armed.length}`) + const settledAt = deps.now() + for (const turn of armed) { + turn.settledAt = settledAt + } const results = await Promise.all( armed.map((turn) => continueOne(turn, deps) @@ -275,4 +347,5 @@ export async function continueInterruptedTurns( export function resetInterruptedTurnContinueForTests(): void { armedByWorkspace.clear() sentTurns.clear() + paneGeneration.clear() } diff --git a/src/renderer/src/lib/orca-cloud-session-events.ts b/src/renderer/src/lib/orca-cloud-session-events.ts index 7dc95395c61..6d76dcc3237 100644 --- a/src/renderer/src/lib/orca-cloud-session-events.ts +++ b/src/renderer/src/lib/orca-cloud-session-events.ts @@ -1,4 +1,5 @@ // Window side of Orca Cloud's session lifecycle: cap warnings, rollovers and idle sleep from main. +import { offerPendingCloudTransfers } from '@/lib/orca-cloud-transfer-flow' import { armInterruptedTurnsForHostRestart, continueInterruptedTurns, @@ -46,6 +47,8 @@ function restartNow(workspaceId: string): Promise { } export function installOrcaCloudSessionEvents(): () => void { + // Moves left part-applied before a restart: offer "Apply changes again" once the window is up. + void offerPendingCloudTransfers().catch(() => 0) const api = window.api?.ephemeralVm if (!api?.onCloudSessionWarning || !api.onCloudIdleSleepRequested) { return () => {} diff --git a/src/renderer/src/lib/orca-cloud-transfer-flow.ts b/src/renderer/src/lib/orca-cloud-transfer-flow.ts index 0af556119ee..33abb1374a5 100644 --- a/src/renderer/src/lib/orca-cloud-transfer-flow.ts +++ b/src/renderer/src/lib/orca-cloud-transfer-flow.ts @@ -32,6 +32,21 @@ export const MOVE_TIMEOUT_MS = 15 * 60_000 let inFlightRun: number | null = null let runCounter = 0 +/** Shared with "Apply changes again": one move or re-apply at a time. */ +export function claimMoveRun(): number | null { + if (inFlightRun !== null) { + return null + } + inFlightRun = ++runCounter + return inFlightRun +} + +export function releaseMoveRun(run: number): void { + if (inFlightRun === run) { + inFlightRun = null + } +} + export async function runOrcaCloudTransferToLocal( workspaceId: string, name: string, @@ -178,6 +193,8 @@ export async function runOrcaCloudTransferToLocal( } noteCloudWorkspaceMoved(workspaceId, Date.now(), worktree.id) const hidden = isHidden() + // Never archive while something is left to apply: the cloud copy is the other place it lives. + const archiveNow = archiveAfter && !finished.value.leftoverPatchDir setCloudMoveState({ stage: 'done', workspaceId, @@ -186,14 +203,14 @@ export async function runOrcaCloudTransferToLocal( prepared: prepared.value, finished: finished.value, elapsedMs: Date.now() - startedAt, - archivedAfter: archiveAfter, + archivedAfter: archiveNow, hidden }) // Hidden: the user kept working elsewhere; the notice offers Open instead of jumping there. if (!hidden) { activateAndRevealWorktree(worktree.id) } - if (archiveAfter) { + if (archiveNow) { options.onArchive?.(workspaceId, name) } } catch (error) { @@ -221,71 +238,4 @@ export async function runOrcaCloudTransferToLocal( } } -/** - * "Apply changes again": re-applies the saved changes and chats onto the SAME local worktree after - * a partial or failed apply (never creates another one). Main skips what is already there and never - * forces a patch over edits made here since; whatever still conflicts stays saved and is reported. - */ -export async function reapplyOrcaCloudTransfer(args: { - workspaceId: string - name: string - localWorktreeId: string - prepared: CloudTransferPrepared - worktreePath: string - archiveAfter: boolean -}): Promise { - const api = window.api?.orcaCloudWorkspace - if (!api?.reapplyTransferToLocal || inFlightRun !== null) { - return - } - const run = ++runCounter - inFlightRun = run - const startedAt = Date.now() - const base = { workspaceId: args.workspaceId, name: args.name, archiveAfter: args.archiveAfter } - setCloudMoveState({ - ...base, - stage: 'running', - step: 'applying', - skipped: ['waking', 'packing', 'downloading', 'creating'], - startedAt, - bytes: null, - totalBytes: null, - hidden: false - }) - try { - const result = await api.reapplyTransferToLocal({ - transferId: args.prepared.transferId, - worktreePath: args.worktreePath - }) - if (!result.ok) { - throw new Error(result.message) - } - noteCloudWorkspaceMoved(args.workspaceId, Date.now(), args.localWorktreeId) - setCloudMoveState({ - stage: 'done', - workspaceId: args.workspaceId, - name: args.name, - localWorktreeId: args.localWorktreeId, - prepared: args.prepared, - finished: result.value, - elapsedMs: Date.now() - startedAt, - archivedAfter: false, - hidden: false - }) - } catch (error) { - console.error('[orca-cloud] apply changes again failed', error) - setCloudMoveState({ - ...base, - stage: 'failed', - step: 'applying', - timedOut: false, - localWorktreeId: args.localWorktreeId, - retry: { prepared: args.prepared, worktreePath: args.worktreePath }, - hidden: false - }) - } finally { - if (inFlightRun === run) { - inFlightRun = null - } - } -} +export { offerPendingCloudTransfers, reapplyOrcaCloudTransfer } from './orca-cloud-transfer-reapply' diff --git a/src/renderer/src/lib/orca-cloud-transfer-reapply.ts b/src/renderer/src/lib/orca-cloud-transfer-reapply.ts new file mode 100644 index 00000000000..12f45767972 --- /dev/null +++ b/src/renderer/src/lib/orca-cloud-transfer-reapply.ts @@ -0,0 +1,122 @@ +// "Apply changes again" for "Move to this computer": re-applies a move's saved changes onto the SAME +// local worktree after a partial or failed apply, and offers it again after an app restart. +import { toast } from 'sonner' +import { translate } from '@/i18n/i18n' +import { noteCloudWorkspaceMoved, setCloudMoveState } from '@/lib/orca-cloud/orca-cloud-move-store' +import type { CloudTransferPrepared } from '../../../shared/hosted-sandbox/cloud-transfer-api' +import { claimMoveRun, releaseMoveRun } from './orca-cloud-transfer-flow' + +/** + * "Apply changes again": re-applies the saved changes and chats onto the SAME local worktree after + * a partial or failed apply (never creates another one). Main skips what is already there and never + * forces a patch over edits made here since; whatever still conflicts stays saved and is reported. + */ +export async function reapplyOrcaCloudTransfer(args: { + workspaceId: string + name: string + localWorktreeId: string + prepared: CloudTransferPrepared + worktreePath: string + archiveAfter: boolean +}): Promise { + const api = window.api?.orcaCloudWorkspace + const run = api?.reapplyTransferToLocal ? claimMoveRun() : null + if (!api || run === null) { + return + } + const startedAt = Date.now() + const base = { workspaceId: args.workspaceId, name: args.name, archiveAfter: args.archiveAfter } + setCloudMoveState({ + ...base, + stage: 'running', + step: 'applying', + skipped: ['waking', 'packing', 'downloading', 'creating'], + startedAt, + bytes: null, + totalBytes: null, + hidden: false + }) + try { + const result = await api.reapplyTransferToLocal({ + transferId: args.prepared.transferId, + worktreePath: args.worktreePath + }) + if (!result.ok) { + throw new Error(result.message) + } + noteCloudWorkspaceMoved(args.workspaceId, Date.now(), args.localWorktreeId) + setCloudMoveState({ + stage: 'done', + workspaceId: args.workspaceId, + name: args.name, + localWorktreeId: args.localWorktreeId, + prepared: args.prepared, + finished: result.value, + elapsedMs: Date.now() - startedAt, + archivedAfter: false, + hidden: false + }) + } catch (error) { + console.error('[orca-cloud] apply changes again failed', error) + setCloudMoveState({ + ...base, + stage: 'failed', + step: 'applying', + timedOut: false, + localWorktreeId: args.localWorktreeId, + retry: { prepared: args.prepared, worktreePath: args.worktreePath }, + hidden: false + }) + } finally { + releaseMoveRun(run) + } +} + +/** + * After an app restart: moves whose changes were not all applied are kept on disk. Offer + * "Apply changes again" for each (same local worktree; never a new one). + */ +export async function offerPendingCloudTransfers(): Promise { + const api = window.api?.orcaCloudWorkspace + if (!api?.listPendingTransfers || !api.reapplyTransferToLocal) { + return 0 + } + const pending = await api.listPendingTransfers().catch(() => []) + for (const transfer of pending) { + toast.warning( + translate('orcaCloudMove.pending.title', 'Some changes from a move weren’t applied'), + { + id: `orca-cloud-move-pending-${transfer.transferId}`, + description: translate( + 'orcaCloudMove.pending.detail', + 'They are saved on this computer. Nothing you changed here is overwritten.' + ), + duration: Infinity, + action: { + label: translate('orcaCloudMove.reapply', 'Apply changes again'), + onClick: () => + void api + .reapplyTransferToLocal({ + transferId: transfer.transferId, + worktreePath: transfer.worktreePath + }) + .then((result) => { + if (!result.ok) { + toast.error(result.message) + } else if (result.value.leftoverPatchDir) { + toast.warning( + translate( + 'orcaCloudMove.pending.stillLeft', + 'Some changes still conflict with edits made here. They stay saved.' + ) + ) + } else { + toast.success(translate('orcaCloudMove.pending.done', 'All changes applied')) + } + }) + } + } + ) + } + return pending.length +} diff --git a/src/shared/hosted-sandbox/cloud-transfer-api.ts b/src/shared/hosted-sandbox/cloud-transfer-api.ts index 00d6a50d301..3e9081524b8 100644 --- a/src/shared/hosted-sandbox/cloud-transfer-api.ts +++ b/src/shared/hosted-sandbox/cloud-transfer-api.ts @@ -52,6 +52,16 @@ export type CloudTransferFinished = { skippedUntracked?: string[] } +/** A move whose changes were not all applied; kept on disk so "Apply changes again" can retry. */ +export type CloudTransferPending = { + transferId: string + /** The cloud workspace it was moved from. */ + workspaceId: string + /** The local worktree it was applied to. */ + worktreePath: string + leftoverDir: string +} + export type CloudTransferResult = | { ok: true; value: T } | { ok: false; code: string; message: string } @@ -70,6 +80,8 @@ export type OrcaCloudTransferApi = { transferId: string worktreePath: string }) => Promise> + /** Moves with changes still to apply, kept across app restarts. */ + listPendingTransfers: () => Promise onTransferProgress: (callback: (event: CloudTransferProgressEvent) => void) => () => void } @@ -77,5 +89,6 @@ export const ORCA_CLOUD_TRANSFER_CHANNELS = { prepare: 'orcaCloudWorkspace:prepareTransferToLocal', finish: 'orcaCloudWorkspace:finishTransferToLocal', reapply: 'orcaCloudWorkspace:reapplyTransferToLocal', + listPending: 'orcaCloudWorkspace:listPendingTransfers', progress: 'orcaCloudWorkspace:transferProgress' } as const