fix(workspace): a new session shows in its workspace
This commit is contained in:
@@ -5,7 +5,9 @@
|
|||||||
* following `/clear` to a new session; a `run_id` that is not a dashboard pane
|
* following `/clear` to a new session; a `run_id` that is not a dashboard pane
|
||||||
* name, or names no run, changes nothing. A session a lane's pane holds is
|
* name, or names no run, changes nothing. A session a lane's pane holds is
|
||||||
* stamped with that lane (`sessions.lane_id`) — the workspace owns it — and a
|
* stamped with that lane (`sessions.lane_id`) — the workspace owns it — and a
|
||||||
* nested CLI's session, or one another lane already owns, is not.
|
* nested CLI's session, or one another lane already owns, is not. A new
|
||||||
|
* Claude pane is started with `--session-id`, so a hook without `run_id` is
|
||||||
|
* still stamped from the run that holds its session.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -198,4 +200,67 @@ describe("run ↔ session binding", () => {
|
|||||||
9
|
9
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("starts a new claude session with --session-id and records that id on the run", () => {
|
||||||
|
let argv = null;
|
||||||
|
tmux.__setExecImpl((args) => {
|
||||||
|
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
|
||||||
|
if (args[0] === "new-session") argv = args;
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
pty.spawnRun({ laneId: 78, cwd: ROOT, initialPrompt: "hi" });
|
||||||
|
tmux.__reset();
|
||||||
|
const at = argv.indexOf("--session-id");
|
||||||
|
assert.ok(at > 0, argv.join(" "));
|
||||||
|
const id = argv[at + 1];
|
||||||
|
assert.match(id, /^[0-9a-f-]{36}$/);
|
||||||
|
assert.equal(dashboardRuns.getRun("agenthub-lane-78").session_id, id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stamps the session a run was started holding, also from a hook with no run_id", async () => {
|
||||||
|
dashboardRuns.recordRun({
|
||||||
|
id: "agenthub-lane-10",
|
||||||
|
laneId: 10,
|
||||||
|
sessionId: "own-norun-0004",
|
||||||
|
mode: "interactive",
|
||||||
|
cwd: ROOT,
|
||||||
|
status: "running",
|
||||||
|
startedAt: Date.now(),
|
||||||
|
});
|
||||||
|
// An older hook handler that drops AGENTHUB_RUN_ID.
|
||||||
|
await postHook({
|
||||||
|
hook_type: "SessionStart",
|
||||||
|
data: { session_id: "own-norun-0004", cwd: ROOT, source: "startup" },
|
||||||
|
});
|
||||||
|
await postHook({
|
||||||
|
hook_type: "SessionStart",
|
||||||
|
data: { session_id: "own-stray-0005", cwd: ROOT, source: "startup" },
|
||||||
|
});
|
||||||
|
const laneOf = (id) => db.prepare("SELECT lane_id FROM sessions WHERE id = ?").get(id).lane_id;
|
||||||
|
assert.equal(laneOf("own-norun-0004"), 10);
|
||||||
|
// A session no run holds stays unowned.
|
||||||
|
assert.equal(laneOf("own-stray-0005"), null);
|
||||||
|
});
|
||||||
|
it("lists a session its run holds before anything stamped it (hooks went to another dashboard)", async () => {
|
||||||
|
// Another dashboard on the same DB took the hooks and stamped nothing.
|
||||||
|
db.prepare(
|
||||||
|
"INSERT INTO sessions (id, name, status, cwd) VALUES ('held-0006', 'x', 'active', ?)"
|
||||||
|
).run(ROOT);
|
||||||
|
dashboardRuns.recordRun({
|
||||||
|
id: "agenthub-lane-11-2",
|
||||||
|
laneId: 11,
|
||||||
|
sessionId: "held-0006",
|
||||||
|
mode: "interactive",
|
||||||
|
cwd: ROOT,
|
||||||
|
status: "running",
|
||||||
|
startedAt: Date.now(),
|
||||||
|
});
|
||||||
|
const ids = async (lane) =>
|
||||||
|
(await (await fetch(`${base}/api/sessions?lane=${lane}`)).json()).sessions.map((x) => x.id);
|
||||||
|
assert.ok((await ids(11)).includes("held-0006"));
|
||||||
|
assert.ok(!(await ids(12)).includes("held-0006"));
|
||||||
|
// An owner already stamped wins over the run record.
|
||||||
|
db.prepare("UPDATE sessions SET lane_id = 12 WHERE id = 'held-0006'").run();
|
||||||
|
assert.ok(!(await ids(11)).includes("held-0006"));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -181,6 +181,21 @@ function routerForSession(sessionId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sessionLaneStmt = db.prepare(`
|
||||||
|
SELECT lane_id FROM dashboard_runs
|
||||||
|
WHERE session_id = @sessionId AND lane_id IS NOT NULL
|
||||||
|
ORDER BY started_at DESC LIMIT 1
|
||||||
|
`);
|
||||||
|
|
||||||
|
/** The lane of the newest run that holds `sessionId`, or null. */
|
||||||
|
function laneForSession(sessionId) {
|
||||||
|
try {
|
||||||
|
return sessionLaneStmt.get({ sessionId })?.lane_id ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const runningStmt = db.prepare(
|
const runningStmt = db.prepare(
|
||||||
"SELECT id FROM dashboard_runs WHERE status IN ('running', 'spawning')"
|
"SELECT id FROM dashboard_runs WHERE status IN ('running', 'spawning')"
|
||||||
);
|
);
|
||||||
@@ -212,4 +227,12 @@ function reconcileOrphans(isAlive = () => false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { recordRun, patchRun, listRuns, getRun, reconcileOrphans, routerForSession };
|
module.exports = {
|
||||||
|
recordRun,
|
||||||
|
patchRun,
|
||||||
|
listRuns,
|
||||||
|
getRun,
|
||||||
|
reconcileOrphans,
|
||||||
|
routerForSession,
|
||||||
|
laneForSession,
|
||||||
|
};
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ function buildClaudeArgv({
|
|||||||
effort,
|
effort,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
forkSession = false,
|
forkSession = false,
|
||||||
|
newSessionId = null,
|
||||||
worktreeName = null,
|
worktreeName = null,
|
||||||
initialPrompt,
|
initialPrompt,
|
||||||
addDirs = [],
|
addDirs = [],
|
||||||
@@ -139,6 +140,7 @@ function buildClaudeArgv({
|
|||||||
if (model) argv.push("--model", model);
|
if (model) argv.push("--model", model);
|
||||||
if (effort && EFFORT_LEVELS.has(effort)) argv.push("--effort", effort);
|
if (effort && EFFORT_LEVELS.has(effort)) argv.push("--effort", effort);
|
||||||
if (resumeSessionId) argv.push("--resume", resumeSessionId);
|
if (resumeSessionId) argv.push("--resume", resumeSessionId);
|
||||||
|
else if (newSessionId) argv.push("--session-id", newSessionId);
|
||||||
// A fork resumes the history into a NEW session id; the original is left as
|
// A fork resumes the history into a NEW session id; the original is left as
|
||||||
// it was.
|
// it was.
|
||||||
if (resumeSessionId && forkSession) argv.push("--fork-session");
|
if (resumeSessionId && forkSession) argv.push("--fork-session");
|
||||||
@@ -357,6 +359,11 @@ function spawnRun(args) {
|
|||||||
tool
|
tool
|
||||||
);
|
);
|
||||||
const routerSpawn = routerForSpawn(tool, routerProfile);
|
const routerSpawn = routerForSpawn(tool, routerProfile);
|
||||||
|
// A new Claude session gets its id from us, so the run knows the session it
|
||||||
|
// holds from the start — a hook that lost AGENTHUB_RUN_ID (an older hook
|
||||||
|
// handler) still finds its pane by it (routes/hooks.js → bindRunSession).
|
||||||
|
const newSessionId =
|
||||||
|
entry.launcher === "claude" && !resumeSessionId ? require("crypto").randomUUID() : null;
|
||||||
const argv = buildArgv(
|
const argv = buildArgv(
|
||||||
{
|
{
|
||||||
addDirs,
|
addDirs,
|
||||||
@@ -370,6 +377,7 @@ function spawnRun(args) {
|
|||||||
approval,
|
approval,
|
||||||
resumeSessionId,
|
resumeSessionId,
|
||||||
forkSession: forkSession === true,
|
forkSession: forkSession === true,
|
||||||
|
newSessionId,
|
||||||
worktreeName: worktree === true ? `ah-${laneId}-${Date.now().toString(36)}` : null,
|
worktreeName: worktree === true ? `ah-${laneId}-${Date.now().toString(36)}` : null,
|
||||||
resumeLast,
|
resumeLast,
|
||||||
bypass: bypass === true,
|
bypass: bypass === true,
|
||||||
@@ -385,7 +393,7 @@ function spawnRun(args) {
|
|||||||
dashboardRuns.recordRun({
|
dashboardRuns.recordRun({
|
||||||
id,
|
id,
|
||||||
// A fork's own id arrives with its first hook (bindRunSession).
|
// A fork's own id arrives with its first hook (bindRunSession).
|
||||||
sessionId: (forkSession !== true && resumeSessionId) || null,
|
sessionId: newSessionId || (forkSession !== true && resumeSessionId) || null,
|
||||||
// `dashboard_runs.mode` is NOT NULL. Passing null made every insert throw
|
// `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
|
// 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
|
// persisted: publicRun then read its cwd/model/prompt/startedAt off a row
|
||||||
|
|||||||
+10
-1
@@ -1310,10 +1310,19 @@ const HOOK_TOOLS = { codex: codexHookInput, antigravity: antigravityHookInput };
|
|||||||
* The session the pane holds is also stamped with the pane's lane
|
* The session the pane holds is also stamped with the pane's lane
|
||||||
* (`sessions.lane_id`, first writer wins) — that is what makes it the
|
* (`sessions.lane_id`, first writer wins) — that is what makes it the
|
||||||
* workspace's own session rather than one that merely ran in the same folder.
|
* workspace's own session rather than one that merely ran in the same folder.
|
||||||
|
* A new Claude pane is started with `--session-id` (pty-run.js), so its run
|
||||||
|
* names the session before any hook: a hook without `run_id` is stamped from
|
||||||
|
* that run instead.
|
||||||
*/
|
*/
|
||||||
function bindRunSession(runId, sessionId, hookType, source, tool) {
|
function bindRunSession(runId, sessionId, hookType, source, tool) {
|
||||||
if (typeof runId !== "string" || !/^agenthub-lane-\d+(-[1-3])?$/.test(runId)) return;
|
|
||||||
if (typeof sessionId !== "string" || !sessionId) return;
|
if (typeof sessionId !== "string" || !sessionId) return;
|
||||||
|
if (typeof runId !== "string" || !/^agenthub-lane-\d+(-[1-3])?$/.test(runId)) {
|
||||||
|
// No pane named (a hook handler that predates AGENTHUB_RUN_ID): a run
|
||||||
|
// that was started holding this very session id still owns it.
|
||||||
|
const laneId = dashboardRuns.laneForSession(sessionId);
|
||||||
|
if (laneId != null) stmts.claimSessionLane.run(laneId, sessionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const row = dashboardRuns.getRun(runId);
|
const row = dashboardRuns.getRun(runId);
|
||||||
if (!row) return;
|
if (!row) return;
|
||||||
if (row.session_id !== sessionId) {
|
if (row.session_id !== sessionId) {
|
||||||
|
|||||||
@@ -161,8 +161,13 @@ router.get("/", (req, res) => {
|
|||||||
params.push(tool);
|
params.push(tool);
|
||||||
}
|
}
|
||||||
if (lane !== undefined) {
|
if (lane !== undefined) {
|
||||||
where.push("s.lane_id = ?");
|
// Also a session one of the lane's runs holds that nothing stamped yet:
|
||||||
params.push(Number(lane));
|
// two dashboards may share the DB and hooks reach only one of them, which
|
||||||
|
// may be an older build that never stamps (bindRunSession).
|
||||||
|
where.push(
|
||||||
|
"(s.lane_id = ? OR (s.lane_id IS NULL AND s.id IN (SELECT session_id FROM dashboard_runs WHERE lane_id = ?)))"
|
||||||
|
);
|
||||||
|
params.push(Number(lane), Number(lane));
|
||||||
}
|
}
|
||||||
// `archived=0|1` = only live / only archived; absent = both, as before.
|
// `archived=0|1` = only live / only archived; absent = both, as before.
|
||||||
if (req.query.archived === "0") where.push("s.archived_at IS NULL");
|
if (req.query.archived === "0") where.push("s.archived_at IS NULL");
|
||||||
|
|||||||
Reference in New Issue
Block a user