diff --git a/src/servers/api/chat/task-restart.ts b/src/servers/api/chat/task-restart.ts index cdc32fc6..e945df50 100644 --- a/src/servers/api/chat/task-restart.ts +++ b/src/servers/api/chat/task-restart.ts @@ -33,6 +33,9 @@ const restarted = new Map(); export type RestartResult = { ok: boolean; command?: string; reason?: string }; +/** A command and the directory it meant something in. The second half is not optional in practice. */ +export type TaskCommand = { command: string; cwd: string | null }; + /** * The command that started a task, recovered by joining the transcript back to itself. * @@ -44,7 +47,7 @@ export type RestartResult = { ok: boolean; command?: string; reason?: string }; * `/claude-///tasks/.output`, so the slug and session name the * transcript exactly. Guessing among a member's transcripts would be both slower and occasionally wrong. */ -export async function commandForTask(who: ChatIdentity, taskId: string): Promise { +export async function commandForTask(who: ChatIdentity, taskId: string): Promise { const output = findTaskOutput(who, taskId); if (!output || !output.endsWith('.output')) return null; @@ -64,7 +67,10 @@ export async function commandForTask(who: ChatIdentity, taskId: string): Promise return null; } - const commands = new Map(); + // The cwd is read from the SAME entry as the tool call, not from anywhere else. `bun dev` means one + // thing in a project and something entirely different in the platform's own directory — see the note on + // `restartBackgroundTask`. + const commands = new Map(); let toolUseId: string | null = null; for (const line of text.split('\n')) { @@ -75,13 +81,16 @@ export async function commandForTask(who: ChatIdentity, taskId: string): Promise } catch { continue; } - const content = (entry as { message?: { content?: unknown } })?.message?.content; + const record = entry as { message?: { content?: unknown }; cwd?: unknown }; + const content = record?.message?.content; if (!Array.isArray(content)) continue; for (const block of content as Record[]) { if (block.type === 'tool_use' && block.name === 'Bash') { const command = (block.input as { command?: unknown } | undefined)?.command; - if (typeof block.id === 'string' && typeof command === 'string') commands.set(block.id, command); + if (typeof block.id === 'string' && typeof command === 'string') { + commands.set(block.id, { command, cwd: typeof record.cwd === 'string' ? record.cwd : null }); + } } if (block.type === 'tool_result') { const body = typeof block.content === 'string' ? block.content : JSON.stringify(block.content ?? ''); @@ -110,13 +119,30 @@ export async function restartBackgroundTask( const output = findTaskOutput(who, taskId); if (!output) return { ok: false, reason: 'no such task' }; - const command = await commandForTask(who, taskId); - if (!command) { + const recovered = await commandForTask(who, taskId); + if (!recovered) { // Honest about which half failed: the file is there, the command is not recoverable. That happens when // the transcript has been pruned, and no amount of retrying will change it. return { ok: false, reason: 'cannot tell what command started this job — its transcript is gone' }; } + /** + * No directory, no restart. + * + * This refuses rather than falling back to the server's own cwd, and the reason is that the fallback + * already happened: a `bun dev` belonging to a project in `~/dockers/officer-atelier` was respawned + * without a cwd, inherited the PLATFORM's directory, and ran the platform's own `bun dev`. A second + * server bound port 9000 alongside the real one — Bun allows it through SO_REUSEPORT — and requests + * were split between two instances at random. + * + * A command is only meaningful with the directory it was written for, and guessing produced the worst + * outcome available: not a failure, but a different program running successfully. + */ + const { command, cwd } = recovered; + if (!cwd) { + return { ok: false, reason: 'cannot tell which directory this job ran in — refusing to guess' }; + } + // Ours first, then anything else holding the file. A second click must replace the first restart, not // leave two dev servers fighting over a port. const previous = restarted.get(taskId); @@ -146,7 +172,7 @@ export async function restartBackgroundTask( // life rather than a new job. const fd = openSync(output, 'a'); const argv = who.osUser ? runAsArgv(who.osUser, ['sh', '-c', command]) : ['sh', '-c', command]; - const proc = Bun.spawn(argv, { stdout: fd, stderr: fd, stdin: 'ignore' }); + const proc = Bun.spawn(argv, { cwd, stdout: fd, stderr: fd, stdin: 'ignore' }); restarted.set(taskId, { proc }); void proc.exited.then((code) => {