Skip to content

Commit 6f17bc0

Browse files
committed
fix(server): make reopenSession idempotent and return 404 for missing
Reopen used a single UPDATE gated on status='closed' and threw a plain Error when nothing changed, collapsing "not found" and "already active" into a 500. After a daemon crash (e.g. SIGKILL mid-closeSession), the server session stays active while the daemon still thinks it owes a reopen; on restart the daemon entered a 5-min backoff loop hammering this endpoint. Split into SELECT + early-return on active + UPDATE, and throw HTTPException(404) for genuinely missing sessions.
1 parent 875aa18 commit 6f17bc0

3 files changed

Lines changed: 84 additions & 5 deletions

File tree

apps/web/server/agentSessionRepo.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { AgentSession, AgentSessionWithMachine, SessionUsageInput } from "@agent-kanban/shared";
22
import { signDelegation } from "@agent-kanban/shared";
3+
import { HTTPException } from "hono/http-exception";
34
import { getAgentPrivateKey } from "./agentRepo";
45
import { createAuth } from "./betterAuth";
56
import type { D1 } from "./db";
@@ -107,11 +108,10 @@ export async function closeSession(db: D1, sessionId: string): Promise<void> {
107108
}
108109

109110
export async function reopenSession(db: D1, sessionId: string): Promise<void> {
110-
const result = await db
111-
.prepare("UPDATE agent_sessions SET status = 'active', closed_at = NULL WHERE id = ? AND status = 'closed'")
112-
.bind(sessionId)
113-
.run();
114-
if (!result.meta.changes) throw new Error(`Session ${sessionId} not found or not closed`);
111+
const row = await db.prepare("SELECT status FROM agent_sessions WHERE id = ?").bind(sessionId).first<{ status: string }>();
112+
if (!row) throw new HTTPException(404, { message: `Session ${sessionId} not found` });
113+
if (row.status === "active") return;
114+
await db.prepare("UPDATE agent_sessions SET status = 'active', closed_at = NULL WHERE id = ?").bind(sessionId).run();
115115
}
116116

117117
export async function updateSessionUsage(db: D1, sessionId: string, usage: SessionUsageInput): Promise<void> {

tests/agent-redesign.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,29 @@ describe("session lifecycle", () => {
280280
agentId = agent.id;
281281
});
282282

283+
it("createSession auto-creates agentHost when it does not exist yet", async () => {
284+
// Use a fresh machine with no pre-seeded agentHost to exercise the create-if-not-exists branch
285+
const { upsertMachine } = await import("../apps/web/server/machineRepo");
286+
const freshMachine = await upsertMachine(env.DB, userId, {
287+
name: "fresh-machine-no-host",
288+
os: "test",
289+
version: "1.0",
290+
runtimes: [],
291+
device_id: "test-device-no-host",
292+
});
293+
const freshAgent = await createTestAgent(env.DB, userId, {
294+
name: "FreshAgent",
295+
username: "fresh-agent-no-host",
296+
runtime: "claude",
297+
});
298+
const { createSession, getSession } = await import("../apps/web/server/agentSessionRepo");
299+
const { publicKey } = await createSessionKeypair();
300+
const freshSessionId = randomUUID();
301+
await createSession(env.DB, env, freshAgent.id, freshMachine.id, freshSessionId, publicKey, userId);
302+
const session = await getSession(env.DB, freshSessionId);
303+
expect(session!.status).toBe("active");
304+
});
305+
283306
it("close session sets status and closed_at", async () => {
284307
const { createSession, closeSession, getSession } = await import("../apps/web/server/agentSessionRepo");
285308
const { publicKey } = await createSessionKeypair();

tests/routes.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -729,6 +729,62 @@ describe("routes", () => {
729729
expect(res.status).toBe(200);
730730
});
731731

732+
it("POST /api/agents/:agentId/sessions/:sessionId/reopen returns 404 for nonexistent session", async () => {
733+
const nonexistentSessionId = randomUUID();
734+
const res = await apiRequest("POST", `/api/agents/${agentId}/sessions/${nonexistentSessionId}/reopen`, {}, apiKey);
735+
expect(res.status).toBe(404);
736+
});
737+
738+
it("POST /api/agents/:agentId/sessions/:sessionId/reopen is idempotent when session is already active", async () => {
739+
// Create a fresh session that starts active (status='active', closed_at=NULL)
740+
const freshSessionId = randomUUID();
741+
const freshKeypair = await crypto.subtle.generateKey({ name: "Ed25519" } as any, true, ["sign", "verify"]);
742+
const freshPubJwk = await crypto.subtle.exportKey("jwk", (freshKeypair as any).publicKey);
743+
await apiRequest("POST", `/api/agents/${agentId}/sessions`, { session_id: freshSessionId, session_public_key: freshPubJwk.x! }, apiKey);
744+
745+
// Inject a sentinel closed_at while keeping status='active'. This state is not reachable
746+
// via the public API — it exists solely to discriminate the no-op path from an erroneous
747+
// UPDATE: if reopen runs the UPDATE it would set closed_at to NULL, failing the assertion;
748+
// if it correctly skips the UPDATE the sentinel value survives unchanged.
749+
const sentinelClosedAt = "2000-01-01T00:00:00.000Z";
750+
await env.DB.prepare("UPDATE agent_sessions SET closed_at = ? WHERE id = ?").bind(sentinelClosedAt, freshSessionId).run();
751+
752+
const res = await apiRequest("POST", `/api/agents/${agentId}/sessions/${freshSessionId}/reopen`, {}, apiKey);
753+
expect(res.status).toBe(200);
754+
755+
const row = await env.DB.prepare("SELECT status, closed_at FROM agent_sessions WHERE id = ?")
756+
.bind(freshSessionId)
757+
.first<{ status: string; closed_at: string | null }>();
758+
expect(row?.status).toBe("active");
759+
// The sentinel must survive — proves the UPDATE branch was skipped entirely
760+
expect(row?.closed_at).toBe(sentinelClosedAt);
761+
});
762+
763+
it("POST /api/agents/:agentId/sessions/:sessionId/reopen clears closed_at after close", async () => {
764+
// Create a session, close it, then reopen and verify closed_at is cleared
765+
const freshSessionId = randomUUID();
766+
const freshKeypair = await crypto.subtle.generateKey({ name: "Ed25519" } as any, true, ["sign", "verify"]);
767+
const freshPubJwk = await crypto.subtle.exportKey("jwk", (freshKeypair as any).publicKey);
768+
await apiRequest("POST", `/api/agents/${agentId}/sessions`, { session_id: freshSessionId, session_public_key: freshPubJwk.x! }, apiKey);
769+
770+
await apiRequest("DELETE", `/api/agents/${agentId}/sessions/${freshSessionId}`, undefined, apiKey);
771+
772+
const closedRow = await env.DB.prepare("SELECT status, closed_at FROM agent_sessions WHERE id = ?")
773+
.bind(freshSessionId)
774+
.first<{ status: string; closed_at: string | null }>();
775+
expect(closedRow?.status).toBe("closed");
776+
expect(closedRow?.closed_at).not.toBeNull();
777+
778+
const res = await apiRequest("POST", `/api/agents/${agentId}/sessions/${freshSessionId}/reopen`, {}, apiKey);
779+
expect(res.status).toBe(200);
780+
781+
const reopenedRow = await env.DB.prepare("SELECT status, closed_at FROM agent_sessions WHERE id = ?")
782+
.bind(freshSessionId)
783+
.first<{ status: string; closed_at: string | null }>();
784+
expect(reopenedRow?.status).toBe("active");
785+
expect(reopenedRow?.closed_at).toBeNull();
786+
});
787+
732788
// ─── Agent PATCH/DELETE ───
733789

734790
it("PATCH /api/agents/:id returns 404 for nonexistent agent", async () => {

0 commit comments

Comments
 (0)