Implement explicit session lifecycle and task-scoped worktrees
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
143
src/ui/server.ts
143
src/ui/server.ts
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user