diff --git a/electron/ipc/docker-pull.test.ts b/electron/ipc/docker-pull.test.ts new file mode 100644 index 000000000..14f6af52d --- /dev/null +++ b/electron/ipc/docker-pull.test.ts @@ -0,0 +1,100 @@ +/** + * Unit tests for the Docker image pre-pull resilience orchestrator. + * Pure logic — Docker is fully faked, no network/subprocess/timers. + */ + +import { describe, expect, it, vi } from 'vitest'; +import { ensureDockerImageAvailable } from './docker-pull.js'; + +const IMAGE = 'thunderockforge/forge-agent:latest'; + +/** Build a deps object with sensible fakes; override per test. */ +function makeDeps(over: { present?: boolean[]; pullCodes?: number[]; signal?: AbortSignal }) { + const present = [...(over.present ?? [])]; + const pullCodes = [...(over.pullCodes ?? [])]; + const status: string[] = []; + const pull = vi.fn(async () => (pullCodes.length ? (pullCodes.shift() as number) : -1)); + const delay = vi.fn(async () => {}); + const imagePresent = vi.fn(async () => (present.length ? (present.shift() as boolean) : false)); + return { + deps: { + imagePresent, + pull, + delay, + onStatus: (l: string) => status.push(l), + signal: over.signal ?? new AbortController().signal, + }, + status, + pull, + delay, + imagePresent, + }; +} + +describe('ensureDockerImageAvailable', () => { + it('skips the pull entirely when the image is already cached locally', async () => { + const { deps, pull } = makeDeps({ present: [true] }); + const res = await ensureDockerImageAvailable(IMAGE, deps); + expect(res).toEqual({ ok: true, usedLocal: true }); + expect(pull).not.toHaveBeenCalled(); + }); + + it('pulls once and succeeds when the image is missing', async () => { + const { deps, pull } = makeDeps({ present: [false], pullCodes: [0] }); + const res = await ensureDockerImageAvailable(IMAGE, deps); + expect(res).toEqual({ ok: true, usedLocal: false }); + expect(pull).toHaveBeenCalledTimes(1); + }); + + it('retries with backoff and succeeds on a later attempt', async () => { + const { deps, pull, delay, status } = makeDeps({ + present: [false], + pullCodes: [1, 0], // fail, then succeed + }); + const res = await ensureDockerImageAvailable(IMAGE, deps, { maxAttempts: 3 }); + expect(res).toEqual({ ok: true, usedLocal: false }); + expect(pull).toHaveBeenCalledTimes(2); + expect(delay).toHaveBeenCalledTimes(1); + expect(status.some((s) => /retry/i.test(s))).toBe(true); + }); + + it('gives up after maxAttempts when pulls keep failing and nothing is cached', async () => { + const { deps, pull } = makeDeps({ + present: [false, false], // initial check + final fallback check + pullCodes: [1, 1, 1], + }); + const res = await ensureDockerImageAvailable(IMAGE, deps, { maxAttempts: 3 }); + expect(res).toEqual({ ok: false, reason: 'pull-failed' }); + expect(pull).toHaveBeenCalledTimes(3); + }); + + it('falls back to a locally cached copy when pulls fail but the image is present', async () => { + const { deps } = makeDeps({ + present: [false, true], // missing up front, but present on the final fallback check + pullCodes: [1, 1, 1], + }); + const res = await ensureDockerImageAvailable(IMAGE, deps, { maxAttempts: 3 }); + expect(res).toEqual({ ok: true, usedLocal: true }); + }); + + it('returns cancelled without pulling when aborted before start', async () => { + const ac = new AbortController(); + ac.abort(); + const { deps, pull } = makeDeps({ present: [false], signal: ac.signal }); + const res = await ensureDockerImageAvailable(IMAGE, deps); + expect(res).toEqual({ ok: false, reason: 'cancelled' }); + expect(pull).not.toHaveBeenCalled(); + }); + + it('returns cancelled when aborted during a pull', async () => { + const ac = new AbortController(); + const { deps } = makeDeps({ present: [false], pullCodes: [-1], signal: ac.signal }); + // Abort as soon as the pull is attempted. + deps.pull = vi.fn(async () => { + ac.abort(); + return -1; + }); + const res = await ensureDockerImageAvailable(IMAGE, deps, { maxAttempts: 3 }); + expect(res).toEqual({ ok: false, reason: 'cancelled' }); + }); +}); diff --git a/electron/ipc/docker-pull.ts b/electron/ipc/docker-pull.ts new file mode 100644 index 000000000..bb1e48165 --- /dev/null +++ b/electron/ipc/docker-pull.ts @@ -0,0 +1,135 @@ +import { execFile, execFileSync, spawn as cpSpawn } from 'child_process'; + +/** Project images are built locally (forge-project:), never pulled from a registry. */ +export const PROJECT_IMAGE_PREFIX = 'forge-project:'; + +interface EnsureImageDeps { + /** Resolve true if an image with this tag is already present locally. */ + imagePresent: (image: string) => Promise; + /** Pull the image; resolve with the process exit code (0 = success). */ + pull: (image: string, signal: AbortSignal) => Promise; + /** Abortable sleep. */ + delay: (ms: number, signal: AbortSignal) => Promise; + /** Emit a human-friendly status line to the terminal. */ + onStatus: (line: string) => void; + signal: AbortSignal; +} + +interface EnsureImageOptions { + maxAttempts?: number; + /** Backoff before retry N (index 0 = wait before 2nd attempt). Last value reused. */ + backoffMs?: number[]; +} + +export type EnsureImageResult = + | { ok: true; usedLocal: boolean } + | { ok: false; reason: 'cancelled' | 'pull-failed' }; + +/** + * Ensure a registry image is available locally before `docker run`. + * + * Fast-paths when the image is already cached (no network). Otherwise pulls with + * bounded retries + backoff so a transient Docker Hub blip doesn't hard-fail the + * task, and falls back to any locally cached copy before giving up. + */ +export async function ensureDockerImageAvailable( + image: string, + deps: EnsureImageDeps, + opts: EnsureImageOptions = {}, +): Promise { + const maxAttempts = opts.maxAttempts ?? 3; + const backoff = opts.backoffMs ?? [2000, 4000]; + + if (deps.signal.aborted) return { ok: false, reason: 'cancelled' }; + + // Already cached — `docker run` will use it, no network round-trip needed. + if (await deps.imagePresent(image)) return { ok: true, usedLocal: true }; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + if (deps.signal.aborted) return { ok: false, reason: 'cancelled' }; + deps.onStatus( + attempt === 1 + ? `Pulling ${image} … (first run can take a few minutes)` + : `Retrying pull (attempt ${attempt}/${maxAttempts}) …`, + ); + + const code = await deps.pull(image, deps.signal).catch(() => -1); + if (deps.signal.aborted) return { ok: false, reason: 'cancelled' }; + if (code === 0) return { ok: true, usedLocal: false }; + + if (attempt < maxAttempts) { + const wait = backoff[Math.min(attempt - 1, backoff.length - 1)]; + deps.onStatus(`Pull failed — retrying in ${Math.round(wait / 1000)}s …`); + await deps.delay(wait, deps.signal); + } + } + + // Retries exhausted — use any locally cached copy rather than fail outright + // (e.g. a concurrent pull landed it, or an older image is good enough). + if (await deps.imagePresent(image)) return { ok: true, usedLocal: true }; + + return { ok: false, reason: 'pull-failed' }; +} + +/** + * Synchronous existence check, used on the spawn fast-path so a cached image + * still launches without deferring to an async tick. Bounded timeout; treats + * any failure (incl. a hung daemon) as "not present" so we fall back to a pull. + */ +export function dockerImagePresentSync(image: string): boolean { + try { + const out = execFileSync( + 'docker', + ['image', 'ls', '--filter', `reference=${image}`, '--format', '{{.ID}}'], + { encoding: 'utf8', timeout: 4000, stdio: ['ignore', 'pipe', 'ignore'] }, + ); + return !!out.trim(); + } catch { + return false; + } +} + +/** True if an image with this tag exists locally (existence only — no staleness check). */ +export function dockerImagePresentByTag(image: string): Promise { + return new Promise((resolve) => { + // `docker image ls --filter reference=` works around the containerd store + // breaking tag-based `docker image inspect`. + execFile( + 'docker', + ['image', 'ls', '--filter', `reference=${image}`, '--format', '{{.ID}}'], + { encoding: 'utf8', timeout: 5000 }, + (err, stdout) => resolve(!err && !!String(stdout).trim()), + ); + }); +} + +/** Stream `docker pull ` output to `onData`; resolve with the exit code (-1 on spawn error/abort). */ +export function pullDockerImage( + image: string, + onData: (text: string) => void, + signal: AbortSignal, +): Promise { + return new Promise((resolve) => { + const child = cpSpawn('docker', ['pull', image], { signal }); + child.stdout?.on('data', (d: Buffer) => onData(d.toString('utf8'))); + child.stderr?.on('data', (d: Buffer) => onData(d.toString('utf8'))); + child.on('error', () => resolve(-1)); // includes AbortError when signal fires + child.on('close', (code) => resolve(code ?? -1)); + }); +} + +/** Promise that resolves after `ms`, or immediately if the signal aborts. */ +export function delay(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal.aborted) return resolve(); + const onAbort = () => { + clearTimeout(timer); + resolve(); + }; + const timer = setTimeout(() => { + signal.removeEventListener('abort', onAbort); + resolve(); + }, ms); + signal.addEventListener('abort', onAbort, { once: true }); + }); +} diff --git a/electron/ipc/pty.test.ts b/electron/ipc/pty.test.ts index 995ecbbc6..86a6a7519 100644 --- a/electron/ipc/pty.test.ts +++ b/electron/ipc/pty.test.ts @@ -10,6 +10,12 @@ const { mockExecFileSync, mockExecFile, mockChildProcessSpawn, mockPtySpawn, moc if (command === 'which' && args?.[0] === 'nonexistent-binary-xyz') { throw new Error('not found'); } + // Docker image-presence fast-path: report the image as cached locally so + // spawn stays synchronous (no pull) by default. Tests exercising the pull + // path tag their image with "needs-pull" to force a cache miss. + if (command === 'docker' && args?.[0] === 'image' && args?.[1] === 'ls') { + return args?.[3]?.includes('needs-pull') ? '' : 'abc123def456\n'; + } return ''; }); @@ -1227,3 +1233,92 @@ describe('buildDockerCredentialMounts — read-only auth dir', () => { expect(warnMessages.some((m) => /\[docker-auth\].*Could not/.test(m))).toBe(true); }); }); + +describe('spawnAgent docker mode — image pull resilience', () => { + const flush = () => new Promise((resolve) => setImmediate(resolve)); + + // A docker pull child whose stdout/stderr/close handlers we can drive. + function fakePullChild(closeHandlers: ((code: number) => void)[]) { + return { + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + on: vi.fn((event: string, cb: (code: number) => void) => { + if (event === 'close') closeHandlers.push(cb); + }), + }; + } + + it('pulls a missing image first, then launches the container', async () => { + const image = 'registry.test/needs-pull-ok:latest'; + // Async presence check (and fallback) report the image absent. + mockExecFile.mockImplementation((_cmd: string, _args: string[], opts: unknown, cb: unknown) => { + const done = (typeof opts === 'function' ? opts : cb) as (e: unknown, out: string) => void; + done?.(null, ''); + }); + const closeHandlers: ((code: number) => void)[] = []; + mockChildProcessSpawn.mockImplementation(() => fakePullChild(closeHandlers)); + + spawnAgent(createMockWindow(), buildSpawnArgs({ dockerImage: image, agentId: nextAgentId() })); + + await flush(); + // A pull was started and the container has NOT launched yet. + expect(mockChildProcessSpawn).toHaveBeenCalledWith( + 'docker', + ['pull', image], + expect.anything(), + ); + expect(mockPtySpawn).not.toHaveBeenCalled(); + + closeHandlers[0]?.(0); // pull succeeds + await flush(); + expect(mockPtySpawn).toHaveBeenCalled(); // container launched after the pull + }); + + it('reports a friendly error (not a raw daemon dump) when the pull keeps failing', async () => { + vi.useFakeTimers(); + try { + const image = 'registry.test/needs-pull-fail:latest'; + mockExecFile.mockImplementation( + (_cmd: string, _args: string[], opts: unknown, cb: unknown) => { + const done = (typeof opts === 'function' ? opts : cb) as ( + e: unknown, + out: string, + ) => void; + done?.(null, ''); // never present + }, + ); + const closeHandlers: ((code: number) => void)[] = []; + mockChildProcessSpawn.mockImplementation(() => fakePullChild(closeHandlers)); + + const win = createMockWindow(); + spawnAgent( + win, + buildSpawnArgs({ + dockerImage: image, + agentId: nextAgentId(), + onOutput: { __CHANNEL_ID__: 'ch-pull-fail' }, + }), + ); + + // Drive three failing pull attempts through their backoff windows. + for (let i = 0; i < 3; i += 1) { + await vi.advanceTimersByTimeAsync(0); + expect(closeHandlers.length).toBe(i + 1); + closeHandlers[i](1); + await vi.advanceTimersByTimeAsync(5000); + } + await vi.advanceTimersByTimeAsync(0); + + const calls = vi.mocked(win.webContents.send).mock.calls as Array< + [string, { type?: string; data?: { exit_code?: number } }] + >; + // Never launched a container, and surfaced a clean Exit instead of hanging. + expect(mockPtySpawn).not.toHaveBeenCalled(); + const exit = calls.find(([, msg]) => msg?.type === 'Exit'); + expect(exit).toBeTruthy(); + expect(exit?.[1].data?.exit_code).toBe(1); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/electron/ipc/pty.ts b/electron/ipc/pty.ts index 5139daf19..9aaf7d1ae 100644 --- a/electron/ipc/pty.ts +++ b/electron/ipc/pty.ts @@ -8,6 +8,14 @@ import type { BrowserWindow } from 'electron'; import { RingBuffer } from '../remote/ring-buffer.js'; import { resolveUserShell } from '../user-shell.js'; import { ensureClaudeSandboxFiles, ensureSandboxExcludes } from './git.js'; +import { + ensureDockerImageAvailable, + dockerImagePresentByTag, + dockerImagePresentSync, + pullDockerImage, + delay as abortableDelay, + PROJECT_IMAGE_PREFIX, +} from './docker-pull.js'; import { debug as logDebug } from '../log.js'; const __filename = fileURLToPath(import.meta.url); @@ -28,6 +36,12 @@ interface PtySession { const sessions = new Map(); +/** Agents whose Docker image pull is in flight (no live PTY session yet). */ +const pendingPulls = new Map(); + +/** Images confirmed present locally this session — skip the per-spawn presence check. */ +const knownPresentImages = new Set(); + function sendToChannel(win: BrowserWindow, channelId: string, msg: unknown): void { if (!win.isDestroyed()) { win.webContents.send(`channel:${channelId}`, msg); @@ -199,6 +213,18 @@ export function spawnAgent( const command = args.command || resolveUserShell(); const cwd = args.cwd || process.env.HOME || '/'; + // A renderer reload while a Docker image pull is in flight has no live + // session to reattach to. Abort the stale pull (it resumes from cache next + // time) so its captured old channel goes silent, then fall through to a + // fresh spawn on the new channel. + if (args.attachExisting) { + const inflightPull = pendingPulls.get(args.agentId); + if (inflightPull) { + inflightPull.abort(); + pendingPulls.delete(args.agentId); + } + } + // Renderer reloads should reattach to still-running PTYs before validating // the launch command. The process already exists; a missing binary after // reload should not strand the live session on the old renderer channel. @@ -357,147 +383,221 @@ export function spawnAgent( spawnArgs = args.args; } - logDebug('pty', `spawn command ${args.agentId}`, { - taskId: args.taskId, - command: spawnCommand, - args: redactedSpawnArgs(spawnCommand, spawnArgs), - cwd, - dockerMode: args.dockerMode === true, - }); - - const proc = pty.spawn(spawnCommand, spawnArgs, { - name: 'xterm-256color', - cols: args.cols, - rows: args.rows, - cwd: args.dockerMode ? undefined : cwd, - env: args.dockerMode ? filteredEnv : spawnEnv, - }); + const launch = () => { + logDebug('pty', `spawn command ${args.agentId}`, { + taskId: args.taskId, + command: spawnCommand, + args: redactedSpawnArgs(spawnCommand, spawnArgs), + cwd, + dockerMode: args.dockerMode === true, + }); - const session: PtySession = { - proc, - channelId, - taskId: args.taskId, - agentId: args.agentId, - isShell: args.isShell ?? false, - flushTimer: null, - subscribers: new Set(), - scrollback: new RingBuffer(), - containerName, - }; - sessions.set(args.agentId, session); + const proc = pty.spawn(spawnCommand, spawnArgs, { + name: 'xterm-256color', + cols: args.cols, + rows: args.rows, + cwd: args.dockerMode ? undefined : cwd, + env: args.dockerMode ? filteredEnv : spawnEnv, + }); - // Batching strategy matching the Rust implementation - let batchChunks: Buffer[] = []; - let batchSize = 0; - let tailChunks: Buffer[] = []; - let tailSize = 0; + const session: PtySession = { + proc, + channelId, + taskId: args.taskId, + agentId: args.agentId, + isShell: args.isShell ?? false, + flushTimer: null, + subscribers: new Set(), + scrollback: new RingBuffer(), + containerName, + }; + sessions.set(args.agentId, session); - const send = (msg: unknown) => { - sendToChannel(win, session.channelId, msg); - }; + // Batching strategy matching the Rust implementation + let batchChunks: Buffer[] = []; + let batchSize = 0; + let tailChunks: Buffer[] = []; + let tailSize = 0; - // In Docker mode, write a diagnostic banner to the terminal so the user - // can see what command is being run (and debug when nothing else appears). - if (args.dockerMode) { - const image = args.dockerImage || DOCKER_DEFAULT_IMAGE; - const innerCmd = [command, ...args.args].join(' '); - const banner = - `\x1b[2m[docker] container: ${containerName}\r\n` + - `[docker] image: ${image}\r\n` + - `[docker] command: ${innerCmd}\r\n` + - `[docker] waiting for container to start…\x1b[0m\r\n\r\n`; - console.warn(`[docker] spawning container ${containerName} — image=${image} cmd=${innerCmd}`); - send({ type: 'Data', data: Buffer.from(banner, 'utf8').toString('base64') }); - } + const send = (msg: unknown) => { + sendToChannel(win, session.channelId, msg); + }; - const flush = () => { - if (batchSize === 0) return; - const batch = Buffer.concat(batchChunks); - const encoded = batch.toString('base64'); - send({ type: 'Data', data: encoded }); - session.scrollback.write(batch); - for (const sub of session.subscribers) { - sub(encoded); - } - batchChunks = []; - batchSize = 0; - if (session.flushTimer) { - clearTimeout(session.flushTimer); - session.flushTimer = null; + // In Docker mode, write a diagnostic banner to the terminal so the user + // can see what command is being run (and debug when nothing else appears). + if (args.dockerMode) { + const image = args.dockerImage || DOCKER_DEFAULT_IMAGE; + const innerCmd = [command, ...args.args].join(' '); + const banner = + `\x1b[2m[docker] container: ${containerName}\r\n` + + `[docker] image: ${image}\r\n` + + `[docker] command: ${innerCmd}\r\n` + + `[docker] waiting for container to start…\x1b[0m\r\n\r\n`; + console.warn(`[docker] spawning container ${containerName} — image=${image} cmd=${innerCmd}`); + send({ type: 'Data', data: Buffer.from(banner, 'utf8').toString('base64') }); } - }; - proc.onData((data: string) => { - const chunk = Buffer.from(data, 'utf8'); - - // Maintain tail buffer for exit diagnostics - tailChunks.push(chunk); - tailSize += chunk.length; - if (tailSize > TAIL_CAP) { - const combined = Buffer.concat(tailChunks); - const trimmed = combined.subarray(combined.length - TAIL_CAP); - tailChunks = [trimmed]; - tailSize = trimmed.length; - } + const flush = () => { + if (batchSize === 0) return; + const batch = Buffer.concat(batchChunks); + const encoded = batch.toString('base64'); + send({ type: 'Data', data: encoded }); + session.scrollback.write(batch); + for (const sub of session.subscribers) { + sub(encoded); + } + batchChunks = []; + batchSize = 0; + if (session.flushTimer) { + clearTimeout(session.flushTimer); + session.flushTimer = null; + } + }; - batchChunks.push(chunk); - batchSize += chunk.length; + proc.onData((data: string) => { + const chunk = Buffer.from(data, 'utf8'); + + // Maintain tail buffer for exit diagnostics + tailChunks.push(chunk); + tailSize += chunk.length; + if (tailSize > TAIL_CAP) { + const combined = Buffer.concat(tailChunks); + const trimmed = combined.subarray(combined.length - TAIL_CAP); + tailChunks = [trimmed]; + tailSize = trimmed.length; + } - // Flush large batches immediately - if (batchSize >= BATCH_MAX) { - flush(); - return; - } + batchChunks.push(chunk); + batchSize += chunk.length; - // Small read = likely interactive prompt, flush immediately - if (chunk.length < 1024) { - flush(); - return; - } + // Flush large batches immediately + if (batchSize >= BATCH_MAX) { + flush(); + return; + } - // Otherwise schedule flush on timer - if (!session.flushTimer) { - session.flushTimer = setTimeout(flush, BATCH_INTERVAL); - } - }); + // Small read = likely interactive prompt, flush immediately + if (chunk.length < 1024) { + flush(); + return; + } + + // Otherwise schedule flush on timer + if (!session.flushTimer) { + session.flushTimer = setTimeout(flush, BATCH_INTERVAL); + } + }); - proc.onExit(({ exitCode, signal }) => { - // If this session was replaced by a new spawn with the same agentId, - // skip cleanup — the new session owns the map entry now. - if (sessions.get(args.agentId) !== session) return; + proc.onExit(({ exitCode, signal }) => { + // If this session was replaced by a new spawn with the same agentId, + // skip cleanup — the new session owns the map entry now. + if (sessions.get(args.agentId) !== session) return; - if (containerName) { - console.warn( - `[docker] container ${containerName} exited — code=${exitCode} signal=${signal ?? 'none'}`, - ); - } + if (containerName) { + console.warn( + `[docker] container ${containerName} exited — code=${exitCode} signal=${signal ?? 'none'}`, + ); + } - // Flush any remaining buffered data - flush(); - - // Parse tail buffer into last N lines for exit diagnostics - const tailBuf = Buffer.concat(tailChunks); - const tailStr = tailBuf.toString('utf8'); - const lines = tailStr - .split('\n') - .map((l) => l.replace(/\r$/, '')) - .filter((l) => l.length > 0) - .slice(-MAX_LINES); - - send({ - type: 'Exit', - data: { - exit_code: exitCode, - signal: signal !== undefined ? String(signal) : null, - last_output: lines, - }, + // Flush any remaining buffered data + flush(); + + // Parse tail buffer into last N lines for exit diagnostics + const tailBuf = Buffer.concat(tailChunks); + const tailStr = tailBuf.toString('utf8'); + const lines = tailStr + .split('\n') + .map((l) => l.replace(/\r$/, '')) + .filter((l) => l.length > 0) + .slice(-MAX_LINES); + + send({ + type: 'Exit', + data: { + exit_code: exitCode, + signal: signal !== undefined ? String(signal) : null, + last_output: lines, + }, + }); + + emitPtyEvent('exit', args.agentId, { exitCode, signal }); + sessions.delete(args.agentId); }); - emitPtyEvent('exit', args.agentId, { exitCode, signal }); - sessions.delete(args.agentId); - }); + emitPtyEvent('spawn', args.agentId); + }; - emitPtyEvent('spawn', args.agentId); + const resolvedImage = args.dockerImage || DOCKER_DEFAULT_IMAGE; + + // Non-Docker tasks (and locally-built project images) spawn immediately. + // Registry images get a resilient pre-pull so a transient Docker Hub blip + // doesn't hard-fail the task with a cryptic `docker run` daemon error. + if (args.dockerMode && !resolvedImage.startsWith(PROJECT_IMAGE_PREFIX)) { + // Fast path: a cached image spawns synchronously (no async tick) — this is + // the common case and keeps task launch snappy. + if (knownPresentImages.has(resolvedImage) || dockerImagePresentSync(resolvedImage)) { + knownPresentImages.add(resolvedImage); + launch(); + return; + } + const ac = new AbortController(); + pendingPulls.set(args.agentId, ac); + const cleanup = () => { + if (pendingPulls.get(args.agentId) === ac) pendingPulls.delete(args.agentId); + }; + const sendData = (text: string) => + sendToChannel(win, channelId, { + type: 'Data', + data: Buffer.from(text, 'utf8').toString('base64'), + }); + void (async () => { + try { + const res = await ensureDockerImageAvailable(resolvedImage, { + imagePresent: dockerImagePresentByTag, + pull: (img, signal) => pullDockerImage(img, sendData, signal), + delay: abortableDelay, + onStatus: (line) => sendData(`\x1b[2m[docker] ${line}\x1b[0m\r\n`), + signal: ac.signal, + }); + // Killed or superseded by a reattach — another path owns the lifecycle. + if (ac.signal.aborted || (!res.ok && res.reason === 'cancelled')) { + cleanup(); + return; + } + if (!res.ok) { + cleanup(); + sendData( + `\r\n\x1b[31m[docker] Could not pull ${resolvedImage} after several attempts.\x1b[0m\r\n` + + `\x1b[2m[docker] Check your network / Docker Hub reachability, then retry the task.\x1b[0m\r\n`, + ); + sendToChannel(win, channelId, { + type: 'Exit', + data: { + exit_code: 1, + signal: null, + last_output: [`Failed to pull Docker image ${resolvedImage}`], + }, + }); + emitPtyEvent('exit', args.agentId, { exitCode: 1, signal: undefined }); + return; + } + knownPresentImages.add(resolvedImage); + launch(); + cleanup(); + } catch (err) { + cleanup(); + sendData(`\r\n\x1b[31m[docker] Error preparing image: ${String(err)}\x1b[0m\r\n`); + sendToChannel(win, channelId, { + type: 'Exit', + data: { exit_code: 1, signal: null, last_output: [] }, + }); + emitPtyEvent('exit', args.agentId, { exitCode: 1, signal: undefined }); + } + })(); + return; + } + + launch(); } export function writeToAgent(agentId: string, data: string): void { @@ -542,6 +642,14 @@ export function killAgent(agentId: string): void { stopDockerContainer(session.containerName); } session.proc.kill(); + return; + } + // No live session — a Docker image pull may still be in flight; abort it so + // it doesn't launch a container for a task the user already killed. + const pendingPull = pendingPulls.get(agentId); + if (pendingPull) { + pendingPull.abort(); + pendingPulls.delete(agentId); } } @@ -550,6 +658,8 @@ export function countRunningAgents(): number { } export function killAllAgents(): void { + for (const ac of pendingPulls.values()) ac.abort(); + pendingPulls.clear(); for (const [, session] of sessions) { if (session.flushTimer) clearTimeout(session.flushTimer); session.subscribers.clear();