Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 61 additions & 112 deletions packages/control-plane/src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { SessionIndexStore } from "./db/session-index";
import { UserScmTokenStore, DEFAULT_TOKEN_LIFETIME_MS } from "./db/user-scm-tokens";
import { UserStore, type ProviderIdentity } from "./db/user-store";
import { buildSessionInternalUrl, SessionInternalPaths } from "./session/contracts";
import { initializeSession, type SessionInitInput } from "./session/initialize";

import {
getValidModelOrDefault,
Expand Down Expand Up @@ -961,13 +962,6 @@ async function handleCreateSession(
}
}

// Generate session ID
const sessionId = generateId();

// Get Durable Object
const doId = env.SESSION.idFromName(sessionId);
const stub = env.SESSION.get(doId);

// Validate model and reasoning effort once for both DO init and D1 index
const model = getValidModelOrDefault(body.model);
const reasoningEffort =
Expand All @@ -981,61 +975,40 @@ async function handleCreateSession(
resolveSandboxSettings(env.DB, repoOwner, repoName),
]);

// Store session in D1 before initializing the SessionDO. SessionDO init starts
// sandbox warming, so D1 failures must fail before any sandbox can be spawned.
const now = Date.now();
const sessionStore = new SessionIndexStore(env.DB);
await sessionStore.create({
id: sessionId,
title: body.title || null,
const sessionId = generateId();

const input: SessionInitInput = {
sessionId,
repoOwner,
repoName,
repoId,
defaultBranch,
branch: body.branch,
title: body.title,
model,
reasoningEffort,
baseBranch: body.branch || defaultBranch || "main",
status: "created",
participantUserId: userId,
platformUserId: resolvedUserId,
scmLogin,
scmName,
scmEmail,
scmUserId,
scmTokenEncrypted,
scmRefreshTokenEncrypted,
scmTokenExpiresAt,
codeServerEnabled,
sandboxSettings,
spawnSource: body.spawnSource,
scmLogin: scmLogin || null,
userId: resolvedUserId,
createdAt: now,
updatedAt: now,
});

// Initialize session with user info and optional encrypted token
const initResponse = await stub.fetch(
internalRequest(
buildSessionInternalUrl(SessionInternalPaths.init),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionName: sessionId, // Pass the session name for WebSocket routing
repoOwner,
repoName,
repoId,
defaultBranch,
branch: body.branch,
title: body.title,
model,
reasoningEffort,
userId,
scmLogin,
scmName,
scmEmail,
scmTokenEncrypted,
scmRefreshTokenEncrypted,
scmTokenExpiresAt,
scmUserId,
codeServerEnabled,
sandboxSettings,
spawnSource: body.spawnSource,
}),
},
ctx
)
);
};

if (!initResponse.ok) {
try {
await initializeSession(env, input, ctx);
} catch (e) {
logger.error("Failed to initialize session", {
error: e instanceof Error ? e.message : String(e),
session_id: sessionId,
trace_id: ctx.trace_id,
});
return error("Failed to create session", 500);
}

Expand Down Expand Up @@ -1958,11 +1931,6 @@ async function handleSpawnChild(
return error("Child sessions must use the same repository as the parent", 403);
}

// Create child session (same pattern as handleCreateSession)
const childId = generateId();
const childDoId = env.SESSION.idFromName(childId);
const childStub = env.SESSION.get(childDoId);

// Validate explicit model from the agent; reject invalid names so the agent
// can self-correct instead of silently falling back to the default model.
const rawModel = body.model ?? spawnContext.model;
Expand All @@ -1976,6 +1944,7 @@ async function handleSpawnChild(
: spawnContext.reasoningEffort;

const childDepth = parentDepth + 1;
const childId = generateId();

logger.info("Spawning child session", {
event: "session.spawn_child",
Expand All @@ -1991,66 +1960,46 @@ async function handleSpawnChild(
resolveSandboxSettings(env.DB, spawnContext.repoOwner, spawnContext.repoName),
]);

// Initialize child DO
const initResponse = await childStub.fetch(
internalRequest(
buildSessionInternalUrl(SessionInternalPaths.init),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionName: childId,
repoOwner: spawnContext.repoOwner,
repoName: spawnContext.repoName,
repoId: spawnContext.repoId,
title: body.title,
model,
reasoningEffort,
userId: spawnContext.owner.userId,
scmLogin: spawnContext.owner.scmLogin,
scmName: spawnContext.owner.scmName,
scmEmail: spawnContext.owner.scmEmail,
scmTokenEncrypted: spawnContext.owner.scmAccessTokenEncrypted,
scmRefreshTokenEncrypted: spawnContext.owner.scmRefreshTokenEncrypted,
scmTokenExpiresAt: spawnContext.owner.scmTokenExpiresAt,
scmUserId: spawnContext.owner.scmUserId,
branch: spawnContext.baseBranch ?? "main",
parentSessionId: parentId,
spawnSource: "agent",
spawnDepth: childDepth,
codeServerEnabled: childCodeServerEnabled,
sandboxSettings: childSandboxSettings,
}),
},
ctx
)
);

if (!initResponse.ok) {
return error("Failed to create child session", 500);
}

// Store in D1 index
const now = Date.now();
await sessionStore.create({
id: childId,
title: body.title,
const input: SessionInitInput = {
sessionId: childId,
repoOwner: spawnContext.repoOwner,
repoName: spawnContext.repoName,
repoId: spawnContext.repoId,
branch: spawnContext.baseBranch ?? "main",
title: body.title,
model,
reasoningEffort,
baseBranch: spawnContext.baseBranch ?? "main",
status: "created",
participantUserId: spawnContext.owner.userId,
platformUserId: parentUserId,
scmLogin: spawnContext.owner.scmLogin,
scmName: spawnContext.owner.scmName,
scmEmail: spawnContext.owner.scmEmail,
scmUserId: spawnContext.owner.scmUserId,
scmTokenEncrypted: spawnContext.owner.scmAccessTokenEncrypted,
scmRefreshTokenEncrypted: spawnContext.owner.scmRefreshTokenEncrypted,
scmTokenExpiresAt: spawnContext.owner.scmTokenExpiresAt,
codeServerEnabled: childCodeServerEnabled,
sandboxSettings: childSandboxSettings,
parentSessionId: parentId,
spawnSource: "agent",
spawnDepth: childDepth,
scmLogin: spawnContext.owner.scmLogin || null,
userId: parentUserId,
createdAt: now,
updatedAt: now,
});
};

try {
await initializeSession(env, input, ctx);
} catch (e) {
logger.error("Failed to initialize child session", {
error: e instanceof Error ? e.message : String(e),
parent_id: parentId,
child_id: childId,
trace_id: ctx.trace_id,
});
return error("Failed to create child session", 500);
}

// Enqueue the prompt on the child DO
const childDoId = env.SESSION.idFromName(childId);
const childStub = env.SESSION.get(childDoId);
let promptResponse: Response;
try {
promptResponse = await childStub.fetch(
Expand Down
6 changes: 5 additions & 1 deletion packages/control-plane/src/session/contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@ import { SessionInternalPaths } from "./contracts";
describe("session internal endpoint contracts", () => {
it("uses contract constants in internal route wiring and router for known endpoints", () => {
const routerSource = readFileSync(new URL("../router.ts", import.meta.url), "utf8");
const initializeSource = readFileSync(new URL("./initialize.ts", import.meta.url), "utf8");
const routesSource = readFileSync(new URL("./http/routes.ts", import.meta.url), "utf8");
const durableObjectSource = readFileSync(
new URL("./durable-object.ts", import.meta.url),
"utf8"
);

// Endpoints used directly in router.ts
const routerEndpointKeys: Array<keyof typeof SessionInternalPaths> = [
"verifySandboxToken",
"init",
"state",
"prompt",
"stop",
Expand All @@ -37,6 +38,9 @@ describe("session internal endpoint contracts", () => {
expect(routerSource).toContain(`SessionInternalPaths.${endpointKey}`);
}

// "init" is used in session/initialize.ts (extracted from router)
expect(initializeSource).toContain("SessionInternalPaths.init");

for (const endpointKey of Object.keys(SessionInternalPaths) as Array<
keyof typeof SessionInternalPaths
>) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ import { getValidModelOrDefault, isValidModel } from "../../../utils/models";

const TERMINAL_STATUSES = new Set<SessionStatus>(["completed", "archived", "cancelled", "failed"]);

/**
* Request body for the /internal/init endpoint.
* The router constructs this from SessionInitInput — see session/initialize.ts.
* Note: `userId` here is the participantUserId from SessionInitInput.
*/
interface InitRequest {
sessionName: string;
repoOwner: string;
Expand Down
Loading
Loading