Implement explicit session lifecycle and task-scoped worktrees

This commit is contained in:
2026-02-24 10:09:07 -05:00
parent 23ad28ad12
commit ca5fd3f096
21 changed files with 1201 additions and 45 deletions

View File

@@ -333,7 +333,7 @@ function buildActorPrompt(input: ActorExecutionInput): string {
},
events: [
{
type: "requirements_defined | tasks_planned | code_committed | task_blocked | validation_passed | validation_failed | branch_merged",
type: "requirements_defined | tasks_planned | code_committed | task_ready_for_review | task_blocked | validation_passed | validation_failed | branch_merged",
payload: {
summary: "optional",
details: {},
@@ -553,10 +553,12 @@ export async function createProviderRunRuntime(input: {
provider: RunProvider;
initialPrompt: string;
config: Readonly<AppConfig>;
projectPath: string;
}): Promise<ProviderRunRuntime> {
const sessionContext = await createSessionContext(input.provider, {
prompt: input.initialPrompt,
config: input.config,
workspaceRoot: input.projectPath,
});
return {

View File

@@ -25,6 +25,7 @@ const dom = {
runProvider: document.querySelector("#run-provider"),
runTopologyHint: document.querySelector("#run-topology-hint"),
runFlags: document.querySelector("#run-flags"),
runRuntimeContext: document.querySelector("#run-runtime-context"),
runValidationNodes: document.querySelector("#run-validation-nodes"),
killRun: document.querySelector("#kill-run"),
runStatus: document.querySelector("#run-status"),
@@ -111,6 +112,7 @@ const MANIFEST_EVENT_TRIGGERS = [
"requirements_defined",
"tasks_planned",
"code_committed",
"task_ready_for_review",
"task_blocked",
"validation_passed",
"validation_failed",
@@ -129,6 +131,7 @@ const LABEL_HELP_BY_CONTROL = Object.freeze({
"run-provider": "Choose which model provider backend handles provider-mode runs.",
"run-topology-hint": "Optional hint that nudges orchestration toward a topology strategy.",
"run-flags": "Optional JSON object passed in as initial run flags.",
"run-runtime-context": "Optional JSON object of template values injected into persona prompts (for example repo or ticket).",
"run-validation-nodes": "Optional comma-separated node IDs to simulate validation outcomes for.",
"events-limit": "Set how many recent runtime events are loaded per refresh.",
"cfg-webhook-url": "Webhook endpoint that receives runtime event notifications.",
@@ -1486,6 +1489,12 @@ async function startRun(event) {
return;
}
const runtimeContext = parseJsonSafe(dom.runRuntimeContext.value, {});
if (typeof runtimeContext !== "object" || Array.isArray(runtimeContext) || !runtimeContext) {
showRunStatus("Runtime Context Overrides must be a JSON object.", true);
return;
}
const manifestSelection = dom.runManifestSelect.value.trim();
const payload = {
@@ -1494,6 +1503,7 @@ async function startRun(event) {
provider: dom.runProvider.value,
topologyHint: dom.runTopologyHint.value.trim() || undefined,
initialFlags: flags,
runtimeContextOverrides: runtimeContext,
simulateValidationNodeIds: fromCsv(dom.runValidationNodes.value),
};

View File

@@ -75,6 +75,10 @@
Initial Flags (JSON)
<textarea id="run-flags" rows="3" placeholder='{"needs_bootstrap": true}'></textarea>
</label>
<label>
Runtime Context Overrides (JSON)
<textarea id="run-runtime-context" rows="3" placeholder='{"repo":"ai_ops","ticket":"AIOPS-123"}'></textarea>
</label>
<label>
Simulate Validation Nodes (CSV)
<input id="run-validation-nodes" type="text" placeholder="coder-1,qa-1" />

View File

@@ -3,11 +3,17 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { SchemaDrivenExecutionEngine } from "../agents/orchestration.js";
import { parseAgentManifest, type AgentManifest } from "../agents/manifest.js";
import { FileSystemProjectContextStore } from "../agents/project-context.js";
import type {
ActorExecutionResult,
ActorExecutor,
PipelineAggregateStatus,
} from "../agents/pipeline.js";
import {
FileSystemSessionMetadataStore,
SessionWorktreeManager,
type SessionMetadata,
} from "../agents/session-lifecycle.js";
import { loadConfig, type AppConfig } from "../config.js";
import { parseEnvFile } from "./env-store.js";
import {
@@ -240,6 +246,19 @@ async function loadRuntimeConfig(envPath: string): Promise<Readonly<AppConfig>>
});
}
function resolveRuntimePaths(input: {
workspaceRoot: string;
config: Readonly<AppConfig>;
}): {
stateRoot: string;
worktreeRoot: string;
} {
return {
stateRoot: resolve(input.workspaceRoot, input.config.orchestration.stateRoot),
worktreeRoot: resolve(input.workspaceRoot, input.config.provisioning.gitWorktree.rootDirectory),
};
}
async function writeRunMeta(input: {
stateRoot: string;
sessionId: string;
@@ -319,6 +338,92 @@ export class UiRunService {
this.envFilePath = resolve(this.workspaceRoot, input.envFilePath ?? ".env");
}
private async loadRuntime(): Promise<{
config: Readonly<AppConfig>;
stateRoot: string;
sessionStore: FileSystemSessionMetadataStore;
worktreeManager: SessionWorktreeManager;
}> {
const config = await loadRuntimeConfig(this.envFilePath);
const paths = resolveRuntimePaths({
workspaceRoot: this.workspaceRoot,
config,
});
return {
config,
stateRoot: paths.stateRoot,
sessionStore: new FileSystemSessionMetadataStore({
stateRoot: paths.stateRoot,
}),
worktreeManager: new SessionWorktreeManager({
worktreeRoot: paths.worktreeRoot,
baseRef: config.provisioning.gitWorktree.baseRef,
}),
};
}
async createSession(input: {
projectPath: string;
sessionId?: string;
}): Promise<SessionMetadata> {
const runtime = await this.loadRuntime();
const sessionId = input.sessionId?.trim() || toSessionId();
const baseWorkspacePath = runtime.worktreeManager.resolveBaseWorkspacePath(sessionId);
const session = await runtime.sessionStore.createSession({
sessionId,
projectPath: resolve(input.projectPath),
baseWorkspacePath,
});
await runtime.worktreeManager.initializeSessionBaseWorkspace({
sessionId: session.sessionId,
projectPath: session.projectPath,
baseWorkspacePath: session.baseWorkspacePath,
});
return session;
}
async listSessions(): Promise<SessionMetadata[]> {
const runtime = await this.loadRuntime();
return runtime.sessionStore.listSessions();
}
async readSession(sessionId: string): Promise<SessionMetadata | undefined> {
const runtime = await this.loadRuntime();
return runtime.sessionStore.readSession(sessionId);
}
async closeSession(input: {
sessionId: string;
mergeToProject?: boolean;
}): Promise<SessionMetadata> {
const runtime = await this.loadRuntime();
const session = await runtime.sessionStore.readSession(input.sessionId);
if (!session) {
throw new Error(`Session \"${input.sessionId}\" does not exist.`);
}
const sessionProjectContextStore = new FileSystemProjectContextStore({
filePath: runtime.sessionStore.getSessionProjectContextPath(session.sessionId),
});
const projectContext = await sessionProjectContextStore.readState();
const taskWorktreePaths = projectContext.taskQueue
.map((task) => task.worktreePath)
.filter((path): path is string => typeof path === "string" && path.trim().length > 0);
await runtime.worktreeManager.closeSession({
session,
taskWorktreePaths,
mergeBaseIntoProject: input.mergeToProject === true,
});
return runtime.sessionStore.updateSession(session.sessionId, {
sessionStatus: "closed",
});
}
listRuns(): RunRecord[] {
const output = [...this.runHistory.values()].sort((left, right) => {
return right.startedAt.localeCompare(left.startedAt);
@@ -331,11 +436,21 @@ export class UiRunService {
}
async startRun(input: StartRunInput): Promise<RunRecord> {
const config = await loadRuntimeConfig(this.envFilePath);
const runtime = await this.loadRuntime();
const config = runtime.config;
const manifest = parseAgentManifest(input.manifest);
const executionMode = input.executionMode ?? "mock";
const provider = input.provider ?? "codex";
const sessionId = input.sessionId?.trim() || toSessionId();
const session = input.sessionId?.trim()
? await runtime.sessionStore.readSession(sessionId)
: undefined;
if (input.sessionId?.trim() && !session) {
throw new Error(`Session \"${sessionId}\" does not exist.`);
}
if (session && session.sessionStatus === "closed") {
throw new Error(`Session \"${sessionId}\" is closed and cannot run new tasks.`);
}
const runId = randomUUID();
const controller = new AbortController();
@@ -359,6 +474,7 @@ export class UiRunService {
provider,
initialPrompt: input.prompt,
config,
projectPath: session?.baseWorkspacePath ?? this.workspaceRoot,
});
}
@@ -376,11 +492,20 @@ export class UiRunService {
actorExecutors,
settings: {
workspaceRoot: this.workspaceRoot,
stateRoot: config.orchestration.stateRoot,
projectContextPath: config.orchestration.projectContextPath,
stateRoot: runtime.stateRoot,
projectContextPath: session
? runtime.sessionStore.getSessionProjectContextPath(sessionId)
: resolve(this.workspaceRoot, config.orchestration.projectContextPath),
runtimeContext: {
ui_mode: executionMode,
run_provider: provider,
...(session
? {
session_id: sessionId,
project_path: session.projectPath,
base_workspace_path: session.baseWorkspacePath,
}
: {}),
...(input.runtimeContextOverrides ?? {}),
},
},
@@ -388,7 +513,7 @@ export class UiRunService {
});
await writeRunMeta({
stateRoot: config.orchestration.stateRoot,
stateRoot: runtime.stateRoot,
sessionId,
run: record,
});
@@ -404,6 +529,7 @@ export class UiRunService {
},
},
signal: controller.signal,
...(session ? { sessionMetadata: session } : {}),
});
const completedRecord = this.runHistory.get(runId);
@@ -419,7 +545,7 @@ export class UiRunService {
this.runHistory.set(runId, next);
await writeRunMeta({
stateRoot: config.orchestration.stateRoot,
stateRoot: runtime.stateRoot,
sessionId,
run: next,
});
@@ -439,7 +565,7 @@ export class UiRunService {
this.runHistory.set(runId, next);
await writeRunMeta({
stateRoot: config.orchestration.stateRoot,
stateRoot: runtime.stateRoot,
sessionId,
run: next,
});

View File

@@ -23,6 +23,14 @@ type StartRunRequest = {
provider?: RunProvider;
};
type CreateSessionRequest = {
projectPath: string;
};
type CloseSessionRequest = {
mergeToProject?: boolean;
};
function parsePort(value: string | undefined): number {
const parsed = Number(value ?? "4317");
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
@@ -102,6 +110,13 @@ function ensureProvider(value: unknown): RunProvider {
return value === "claude" ? "claude" : "codex";
}
function ensureNonEmptyString(value: unknown, field: string): string {
if (typeof value !== "string" || value.trim().length === 0) {
throw new Error(`Field "${field}" is required.`);
}
return value.trim();
}
async function readRuntimePaths(configStore: UiConfigStore, workspaceRoot: string): Promise<{
stateRoot: string;
runtimeEventLogPath: string;
@@ -299,6 +314,20 @@ async function handleApiRequest(input: {
}
if (pathname === "/api/sessions") {
if (method === "POST") {
const body = await parseJsonBody<CreateSessionRequest>(request);
const projectPath = ensureNonEmptyString(body.projectPath, "projectPath");
const session = await runService.createSession({
projectPath,
});
sendJson(response, 201, {
ok: true,
session,
});
return true;
}
if (method !== "GET") {
methodNotAllowed(response);
return true;
@@ -309,10 +338,12 @@ async function handleApiRequest(input: {
stateRoot,
runtimeEventLogPath,
});
const metadata = await runService.listSessions();
sendJson(response, 200, {
ok: true,
sessions,
sessionMetadata: metadata,
runs: runService.listRuns(),
});
return true;
@@ -362,6 +393,118 @@ async function handleApiRequest(input: {
return true;
}
if (pathname.startsWith("/api/sessions/") && pathname.endsWith("/run")) {
if (method !== "POST") {
methodNotAllowed(response);
return true;
}
const sessionId = toRelativePathFromApi(pathname.slice("/api/sessions/".length, -"/run".length));
if (!sessionId) {
sendJson(response, 400, {
ok: false,
error: "Session id is required.",
});
return true;
}
const body = await parseJsonBody<StartRunRequest>(request);
if (typeof body.prompt !== "string" || body.prompt.trim().length === 0) {
sendJson(response, 400, {
ok: false,
error: 'Field "prompt" is required.',
});
return true;
}
const manifestSource = (() => {
if (body.manifest !== undefined) {
return body.manifest;
}
if (typeof body.manifestPath === "string" && body.manifestPath.trim().length > 0) {
return undefined;
}
return undefined;
})();
const resolvedManifest = manifestSource ?? (() => {
if (!body.manifestPath) {
return undefined;
}
return body.manifestPath;
})();
let manifest: unknown;
if (typeof resolvedManifest === "string") {
manifest = (await manifestStore.read(resolvedManifest)).source;
} else if (resolvedManifest !== undefined) {
manifest = resolvedManifest;
}
if (!manifest) {
sendJson(response, 400, {
ok: false,
error: "A manifest or manifestPath is required to start a run.",
});
return true;
}
const record = await runService.startRun({
prompt: body.prompt,
manifest,
manifestPath: body.manifestPath,
sessionId,
topologyHint: body.topologyHint,
initialFlags: ensureBooleanRecord(body.initialFlags),
runtimeContextOverrides: ensureRuntimeContext(body.runtimeContextOverrides),
simulateValidationNodeIds: ensureStringArray(body.simulateValidationNodeIds),
executionMode: ensureExecutionMode(body.executionMode),
provider: ensureProvider(body.provider),
});
sendJson(response, 202, {
ok: true,
run: record,
});
return true;
}
if (pathname.startsWith("/api/sessions/") && pathname.endsWith("/close")) {
if (method !== "POST") {
methodNotAllowed(response);
return true;
}
const sessionId = toRelativePathFromApi(pathname.slice("/api/sessions/".length, -"/close".length));
if (!sessionId) {
sendJson(response, 400, {
ok: false,
error: "Session id is required.",
});
return true;
}
let body: CloseSessionRequest = {};
try {
body = await parseJsonBody<CloseSessionRequest>(request);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message !== "Request body is required.") {
throw error;
}
}
const session = await runService.closeSession({
sessionId,
mergeToProject: body.mergeToProject === true,
});
sendJson(response, 200, {
ok: true,
session,
});
return true;
}
if (pathname === "/api/runs") {
if (method === "GET") {
sendJson(response, 200, {