a restart runs the job in its own directory, or not at all
Restarting a `bun dev` belonging to a project in ~/dockers/officer-atelier started the PLATFORM's dev server instead. The respawn passed no cwd, so it inherited the server's own working directory, where `bun dev` means something else entirely. The result was not an error. `bun --watch src/server.tsx` came up and bound port 9000 ALONGSIDE the running platform — Bun allows that through SO_REUSEPORT — so requests were split at random between two instances of the platform. Confirmed on the machine by ancestry: `sh -c bun dev` → `bun dev` → `bun --watch src/server.tsx`, with two listeners on 9000. Stray killed; the real server was unaffected. The information was there all along: every transcript entry carries `cwd`, and the entry holding the tool call is the one that says where it ran. `commandForTask` returns both now. And when there is no cwd it REFUSES, rather than falling back to the server's own. The fallback is what produced this, and it produced the worst outcome available — not a failure, but a different program running successfully. A command is only meaningful with the directory it was written for. Verified with a job whose transcript says it ran in /tmp/cwd-probe-app: the restart reports that directory and the respawned process prints it, reading a file that exists only there. tsgo clean, frontend builds, 898 pass / 7 fail unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,9 @@ const restarted = new Map<string, { proc: Bun.Subprocess }>();
|
||||
|
||||
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 };
|
||||
* `<tmp>/claude-<uid>/<project-slug>/<session-uuid>/tasks/<id>.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<string | null> {
|
||||
export async function commandForTask(who: ChatIdentity, taskId: string): Promise<TaskCommand | null> {
|
||||
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<string, string>();
|
||||
// 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<string, TaskCommand>();
|
||||
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<string, unknown>[]) {
|
||||
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) => {
|
||||
|
||||
Reference in New Issue
Block a user