fix(runs): persist tmux-backed runs so a resumed session shows its own info
`dashboard_runs.mode` is NOT NULL and `recordRun` swallows insert errors by design, so pty-run.js passing `mode: null` meant NO tmux-backed run was ever persisted — the newest row in a live database dated from before pty-run.js replaced run-spawner.js. `publicRun` reads cwd, model, prompt preview, started_at and session_id off that row and the handle itself is recomputed from tmux, so the runs modal rendered a live row with no cwd, no model, no prompt and a start date of Jan 1 1970. The same missing row is why a resumed session kept its separate `external` badge: the client dedupes an external session against the session ids carried by run rows, and no run row ever carried one. - pty-run.js records `mode: "interactive"` — every run here is one pane. - dashboard-runs.js still never fails a live run on a db error, but warns instead of swallowing silently; that silence hid this for six weeks. - RunHistory renders `startedAt: 0` as "-" rather than dating a row with no known start time to the epoch. Also closes the other half of the same lie: adopting a pane that is already running an agent now throws ERUNLIVE (409 from both the lane start action and POST /api/run) when the request carried a prompt or a resumeSessionId. That argv cannot be typed over a live agent, so answering 200 reported a resume that never spawned. A start asking for nothing but a pane still adopts.
This commit is contained in:
+6
-1
@@ -555,7 +555,12 @@ failures return `500` with `error.stderr`.
|
||||
|
||||
`start` returns `409 ERUNLIVE` when the lane's recorded run is still `spawning`
|
||||
or `running`: overwriting `run_id` would orphan that child, and a later `reset`
|
||||
would then `git clean -fd` a directory the orphan is still writing into.
|
||||
would then `git clean -fd` a directory the orphan is still writing into. It
|
||||
returns the same code when the lane's tmux pane is running an agent and the
|
||||
request carried a `prompt` or `resumeSessionId` — `pty-run.js` will not type
|
||||
over a live agent, and adopting the pane silently would answer 200 for a resume
|
||||
that never spawned. `POST /api/run` maps that code to `409` too; a start with
|
||||
neither field still adopts the pane.
|
||||
|
||||
Besides the stage a skill declares explicitly, every lane also carries a
|
||||
server-inferred `detected_stage`: `server/lib/stage-detect.js` matches each
|
||||
|
||||
@@ -335,4 +335,91 @@ describe("pty-run", () => {
|
||||
);
|
||||
assert.ok(!call.some((a) => String(a).startsWith("--dangerously-bypass")));
|
||||
});
|
||||
|
||||
// `dashboard_runs.mode` is NOT NULL and recordRun swallows insert errors, so a
|
||||
// null there persisted nothing at all while every call still looked fine: the
|
||||
// runs modal rendered a row with no cwd, model, prompt or start time, and a
|
||||
// resumed session kept its separate `external` row because no run row ever
|
||||
// carried its session id. These assert the row actually lands.
|
||||
it("refuses to swallow a resume or a prompt when the pane already runs an agent", () => {
|
||||
// Adopting silently here answered 200 while spawning no `--resume` at all:
|
||||
// the dashboard reported a resumed session that never happened.
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "display-message") return "claude\n";
|
||||
return ""; // has-session succeeds → pane is busy with a live agent
|
||||
});
|
||||
assert.throws(
|
||||
() => pty.spawnRun({ laneId: 51, cwd: "/tmp/repo", resumeSessionId: "abc12345" }),
|
||||
(err) => err.code === "ERUNLIVE"
|
||||
);
|
||||
assert.throws(
|
||||
() => pty.spawnRun({ laneId: 51, cwd: "/tmp/repo", initialPrompt: "do the thing" }),
|
||||
(err) => err.code === "ERUNLIVE"
|
||||
);
|
||||
});
|
||||
|
||||
it("still adopts a busy pane when the request asked for nothing but a pane", () => {
|
||||
const calls = [];
|
||||
tmux.__setExecImpl((args) => {
|
||||
calls.push(args);
|
||||
if (args[0] === "display-message") return "claude\n";
|
||||
return "";
|
||||
});
|
||||
const handle = pty.spawnRun({ laneId: 52, cwd: "/tmp/repo" });
|
||||
assert.equal(handle.id, "agenthub-lane-52");
|
||||
assert.ok(!calls.some((c) => c[0] === "new-session"));
|
||||
assert.ok(!calls.some((c) => c[0] === "send-keys"));
|
||||
});
|
||||
|
||||
describe("run persistence", () => {
|
||||
const dashboardRuns = require("../lib/dashboard-runs");
|
||||
|
||||
it("spawnRun persists the run so the runs list has cwd, model and prompt", () => {
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "has-session") {
|
||||
const e = new Error("gone");
|
||||
e.status = 1;
|
||||
throw e;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
pty.spawnRun({
|
||||
laneId: 71,
|
||||
cwd: "/tmp/persisted",
|
||||
model: "claude-opus-5",
|
||||
initialPrompt: "ship it",
|
||||
});
|
||||
|
||||
const row = dashboardRuns.getRun("agenthub-lane-71");
|
||||
assert.ok(row, "expected a dashboard_runs row for the spawned run");
|
||||
assert.equal(row.cwd, "/tmp/persisted");
|
||||
assert.equal(row.model, "claude-opus-5");
|
||||
assert.equal(row.prompt_preview, "ship it");
|
||||
assert.equal(row.lane_id, 71);
|
||||
assert.ok(row.started_at, "started_at must be set, or the UI dates the row to the epoch");
|
||||
assert.ok(row.mode, "mode is NOT NULL — a null here silently drops the whole insert");
|
||||
});
|
||||
|
||||
it("a resumed run carries the session id, which is what dedupes the external row", () => {
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "has-session") {
|
||||
const e = new Error("gone");
|
||||
e.status = 1;
|
||||
throw e;
|
||||
}
|
||||
return "";
|
||||
});
|
||||
const sessionId = "c2055094-09cf-4735-9712-09e5cdf7023f";
|
||||
const handle = pty.spawnRun({
|
||||
laneId: 72,
|
||||
cwd: "/tmp/resumed",
|
||||
resumeSessionId: sessionId,
|
||||
});
|
||||
|
||||
// The UI reads sessionId off the run list; an external session whose id is
|
||||
// already on a run row is not repeated as its own `external` row.
|
||||
assert.equal(handle.sessionId, sessionId);
|
||||
assert.equal(dashboardRuns.getRun("agenthub-lane-72").resume_session_id, sessionId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,8 +91,12 @@ function recordRun(handle) {
|
||||
lane_id: typeof handle.laneId === "number" ? handle.laneId : null,
|
||||
tool: handle.tool === "codex" ? "codex" : "claude",
|
||||
});
|
||||
} catch {
|
||||
/* persistence is best-effort */
|
||||
} catch (err) {
|
||||
// Best-effort by design (a live run must never fail because sqlite did),
|
||||
// but not silent: a swallowed insert leaves the runs modal showing rows with
|
||||
// no cwd, model, prompt or start time, which reads as a UI bug rather than a
|
||||
// persistence one. Say it once, here, where the cause is known.
|
||||
console.warn(`[dashboard-runs] failed to record run ${handle?.id}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+19
-1
@@ -200,7 +200,13 @@ function spawnRun(args) {
|
||||
dashboardRuns.recordRun({
|
||||
id,
|
||||
sessionId: resumeSessionId || null,
|
||||
mode: null,
|
||||
// `dashboard_runs.mode` is NOT NULL. Passing null made every insert throw
|
||||
// inside recordRun's best-effort catch, so no tmux-backed run was ever
|
||||
// persisted: publicRun then read its cwd/model/prompt/startedAt off a row
|
||||
// that did not exist (a blank row dated Jan 1 1970 in the runs modal), and
|
||||
// session_id never landed either, so a resumed session stayed listed as a
|
||||
// separate `external` row. Every run here is one interactive pane.
|
||||
mode: "interactive",
|
||||
cwd,
|
||||
tool: tool === "codex" ? "codex" : "claude",
|
||||
model: model || null,
|
||||
@@ -238,6 +244,18 @@ function spawnRun(args) {
|
||||
// Pane is running something (a live `claude`, an editor, a build): adopt
|
||||
// silently, same convention as this repo's server port-adoption logic — no
|
||||
// error, no duplicate session. Attaching shows the user what is running.
|
||||
//
|
||||
// Adopting is only honest when the request asked for nothing more than a
|
||||
// pane. A Resume or an initial prompt cannot be delivered into a pane that is
|
||||
// already running an agent (typing over it is what the shell-pane branch
|
||||
// above exists to avoid), so answering 200 there reports work that never
|
||||
// happened: the user sees "resumed" while no `--resume` was ever spawned.
|
||||
else if (resumeSessionId || initialPrompt) {
|
||||
throw makeErr(
|
||||
"ERUNLIVE",
|
||||
`lane ${laneId}'s pane is already running \`${tmux.paneCommand(id) || "a process"}\` — stop it before resuming a session or sending a prompt`
|
||||
);
|
||||
}
|
||||
|
||||
return getRun(id);
|
||||
}
|
||||
|
||||
@@ -898,7 +898,9 @@ function sendLifecycleError(res, err) {
|
||||
if (["ENOTMANAGED", "EOUTSIDEROOT", "ENOTWORKTREE", "EEXPECT"].includes(err.code)) {
|
||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
if (["ESTALE", "EUNPUSHED"].includes(err.code)) {
|
||||
// ERUNLIVE: the lane's pane is busy with a live agent, so a resume/prompt
|
||||
// could not be delivered — a conflict, not a bad request or a server fault.
|
||||
if (["ESTALE", "EUNPUSHED", "ERUNLIVE"].includes(err.code)) {
|
||||
return res.status(409).json({
|
||||
error: {
|
||||
code: err.code,
|
||||
|
||||
@@ -256,6 +256,10 @@ router.post("/", (req, res) => {
|
||||
});
|
||||
return res.status(201).json(handle);
|
||||
} catch (err) {
|
||||
// A busy pane is a conflict with live state, not a malformed request.
|
||||
if (err.code === "ERUNLIVE") {
|
||||
return res.status(409).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
if (err.code && err.code.startsWith("E")) {
|
||||
return res.status(400).json({ error: { code: err.code, message: err.message } });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user