mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
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)
This commit is contained in:
@@ -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')
|
||||
}
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -44,7 +44,12 @@ export async function loadPersistedTransfer(dir: string): Promise<Prepared | nul
|
||||
JSON.parse(await readFile(join(dir, TRANSFER_STATE_FILE), 'utf8'))
|
||||
)
|
||||
return parsed.success
|
||||
? { dir, manifest: parsed.data.manifest, repoPath: parsed.data.repoPath, workspaceId: parsed.data.workspaceId }
|
||||
? {
|
||||
dir,
|
||||
manifest: parsed.data.manifest,
|
||||
repoPath: parsed.data.repoPath,
|
||||
workspaceId: parsed.data.workspaceId
|
||||
}
|
||||
: null
|
||||
} catch {
|
||||
return null
|
||||
@@ -68,9 +73,10 @@ export function conflictPathsFromGitApply(stderr: string): string[] {
|
||||
const paths = new Set<string>()
|
||||
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<CloudTransferFinished> {
|
||||
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'),
|
||||
|
||||
@@ -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<number> {
|
||||
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<string | null> {
|
||||
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<boolean> {
|
||||
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<CloudTransferPending[]> {
|
||||
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
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<boolean> {
|
||||
@@ -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<boolean> {
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -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<string, Prepared>()
|
||||
// One apply at a time per transfer (finish and "Apply changes again" share it).
|
||||
const applying = new Set<string>()
|
||||
const busy = <T>(): CloudTransferResult<T> => ({
|
||||
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<CloudTransferProgressEvent, 'at'>): 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<CloudTransferPending[]> =>
|
||||
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<number> {
|
||||
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<boolean> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -2,11 +2,25 @@
|
||||
// leaves those panes alone. Entries older than the memory window are pruned when a wake settles.
|
||||
|
||||
const lastUserInputByPane = new Map<string, number>()
|
||||
const lastUserInputByWorkspace = new Map<string, number>()
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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<string, unknown>, extra: Record<string, unknown> = {}) {
|
||||
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 () => {
|
||||
|
||||
@@ -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<string, ArmedTurn[]>()
|
||||
/** Bumped by every arm/disarm of a pane: an older wait loop for that pane stops at its next step. */
|
||||
const paneGeneration = new Map<string, number>()
|
||||
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, string>): 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<boolean
|
||||
return false
|
||||
}
|
||||
const freshUntil = turn.armedAt + AGENT_STATUS_STALE_AFTER_MS
|
||||
const settledAt = turn.settledAt ?? deps.now()
|
||||
// Starts when the pane is (re)opened: only then can its agent relaunch and read input.
|
||||
let relaunchDeadline: number | null = null
|
||||
const stale = (): boolean =>
|
||||
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<boolean
|
||||
continue
|
||||
}
|
||||
relaunchDeadline ??= deps.now() + RELAUNCH_WAIT_MS
|
||||
// Reopened long after the restart settled: the person may only want to read. Continue only the
|
||||
// same agent session, and only if nobody has done anything in the workspace since; otherwise
|
||||
// leave an offer instead of typing into it.
|
||||
if (deps.now() - settledAt > 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<boolean
|
||||
if (
|
||||
deps.now() - reported.updatedAt >= 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<boolean
|
||||
markInterruptedTurnContinued(turn.turnKey)
|
||||
return true
|
||||
}
|
||||
} else {
|
||||
} else if (!stale() && !userTookOver()) {
|
||||
const sent = await deps.send({
|
||||
ptyId,
|
||||
agent: turn.agent,
|
||||
@@ -217,6 +272,16 @@ async function continueOne(turn: ArmedTurn, deps: ContinueDeps): Promise<boolean
|
||||
return false
|
||||
}
|
||||
|
||||
/** A late relaunch: the same agent session (resume, not a new chat) and no activity since. */
|
||||
function lateContinueAllowed(turn: ArmedTurn): boolean {
|
||||
if (workspaceHadUserInputSince(turn.workspaceId, turn.armedAt)) {
|
||||
return false
|
||||
}
|
||||
const report = relaunchReport(turn)
|
||||
const sessionId = report?.providerSession?.id ?? null
|
||||
return turn.providerSessionId !== null && sessionId === turn.providerSessionId
|
||||
}
|
||||
|
||||
type ContinueDeps = {
|
||||
now: () => number
|
||||
sleep: (ms: number) => Promise<void>
|
||||
@@ -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<boolean>
|
||||
/** Late reopen that may not be typed into: offer "Continue" instead. */
|
||||
offer?: (turn: ArmedTurn) => void
|
||||
}
|
||||
|
||||
async function agentOwnsTerminal(ptyId: string, agent: TuiAgent): Promise<boolean> {
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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<HostedSessionRestartResult> {
|
||||
}
|
||||
|
||||
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 () => {}
|
||||
|
||||
@@ -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<void> {
|
||||
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'
|
||||
|
||||
@@ -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<void> {
|
||||
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<number> {
|
||||
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
|
||||
}
|
||||
@@ -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<T> =
|
||||
| { ok: true; value: T }
|
||||
| { ok: false; code: string; message: string }
|
||||
@@ -70,6 +80,8 @@ export type OrcaCloudTransferApi = {
|
||||
transferId: string
|
||||
worktreePath: string
|
||||
}) => Promise<CloudTransferResult<CloudTransferFinished>>
|
||||
/** Moves with changes still to apply, kept across app restarts. */
|
||||
listPendingTransfers: () => Promise<CloudTransferPending[]>
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user