feat(session): add SessionRunner abstraction for async subagent delegation

PR1 of async subagent delegation stack (Issue #5887).

This commit introduces the SessionRunner namespace which provides a unified
interface for executing session loops:

- runOnce(): Synchronous execution wrapping SessionPrompt.prompt (implemented)
- runBackground(): Fire-and-forget execution for async delegation (scaffold only)
- Options/RunResult types for future background execution
- State tracking infrastructure for active background runs

No behavior change - this is purely an abstraction layer.

Next: PR2 will implement runBackground() with lifecycle events.
This commit is contained in:
Enzo 2025-12-22 16:30:20 -05:00
parent 855fd07d22
commit c5cd7c4f2c
2 changed files with 144 additions and 0 deletions

View file

@ -0,0 +1,79 @@
import z from "zod"
import { Identifier } from "../id/id"
import { Log } from "../util/log"
import { SessionPrompt } from "./prompt"
import { MessageV2 } from "./message-v2"
import { Instance } from "../project/instance"
import { fn } from "@/util/fn"
export namespace SessionRunner {
const log = Log.create({ service: "session.runner" })
export const Options = z
.object({
model: z.object({
providerID: z.string(),
modelID: z.string(),
}),
agent: z.string(),
tools: z.record(z.string(), z.boolean()).optional(),
origin: z
.object({
parentSessionID: Identifier.schema("session").optional(),
parentMessageID: Identifier.schema("message").optional(),
description: z.string().optional(),
command: z.string().optional(),
})
.optional(),
timeoutMs: z.number().optional(),
maxSteps: z.number().optional(),
})
.meta({ ref: "SessionRunnerOptions" })
export type Options = z.infer<typeof Options>
export const RunResult = z
.object({
sessionID: Identifier.schema("session"),
message: MessageV2.WithParts,
success: z.boolean(),
error: z.string().optional(),
})
.meta({ ref: "SessionRunnerResult" })
export type RunResult = z.infer<typeof RunResult>
const state = Instance.state(() => ({
active: {} as Record<
string,
{
startedAt: number
options: Options
promise: Promise<RunResult>
}
>,
}))
export function isRunning(id: string): boolean {
return id in state().active
}
export function listActive(): string[] {
return Object.keys(state().active)
}
export const runOnce = fn(SessionPrompt.PromptInput, async (input): Promise<MessageV2.WithParts> => {
log.info("runOnce", { sessionID: input.sessionID, agent: input.agent })
return SessionPrompt.prompt(input)
})
export function runBackground(_id: string, _options: Options): void {
throw new Error("SessionRunner.runBackground not yet implemented")
}
export function cancelBackground(_id: string): boolean {
throw new Error("SessionRunner.cancelBackground not yet implemented")
}
export async function waitFor(_id: string): Promise<RunResult> {
throw new Error("SessionRunner.waitFor not yet implemented")
}
}

View file

@ -0,0 +1,65 @@
import { describe, expect, test } from "bun:test"
import { SessionRunner } from "../../src/session/runner"
describe("SessionRunner", () => {
describe("Options schema", () => {
test("validates valid options", () => {
const valid = {
model: { providerID: "anthropic", modelID: "claude-3-5-sonnet" },
agent: "code",
}
expect(SessionRunner.Options.safeParse(valid).success).toBe(true)
})
test("validates options with tools", () => {
const opts = {
model: { providerID: "anthropic", modelID: "claude-3-5-sonnet" },
agent: "code",
tools: { bash: true, read: true, write: false },
}
expect(SessionRunner.Options.safeParse(opts).success).toBe(true)
})
test("validates options with timeout", () => {
const opts = {
model: { providerID: "openai", modelID: "gpt-4" },
agent: "general",
timeoutMs: 30000,
maxSteps: 10,
}
expect(SessionRunner.Options.safeParse(opts).success).toBe(true)
})
test("rejects missing model", () => {
expect(SessionRunner.Options.safeParse({ agent: "code" }).success).toBe(false)
})
test("rejects missing agent", () => {
const opts = { model: { providerID: "anthropic", modelID: "claude-3-5-sonnet" } }
expect(SessionRunner.Options.safeParse(opts).success).toBe(false)
})
test("rejects invalid model structure", () => {
const opts = { model: { providerID: "anthropic" }, agent: "code" }
expect(SessionRunner.Options.safeParse(opts).success).toBe(false)
})
})
describe("stub methods", () => {
test("runBackground throws", () => {
const opts: SessionRunner.Options = {
model: { providerID: "anthropic", modelID: "claude-3-5-sonnet" },
agent: "code",
}
expect(() => SessionRunner.runBackground("session_123", opts)).toThrow("not yet implemented")
})
test("cancelBackground throws", () => {
expect(() => SessionRunner.cancelBackground("session_123")).toThrow("not yet implemented")
})
test("waitFor throws", async () => {
await expect(SessionRunner.waitFor("session_123")).rejects.toThrow("not yet implemented")
})
})
})