Implement explicit session lifecycle and task-scoped worktrees
This commit is contained in:
@@ -614,6 +614,7 @@ test("runs parallel topology blocks concurrently and routes via domain-event edg
|
||||
projectContextPatch: {
|
||||
enqueueTasks: [
|
||||
{
|
||||
taskId: "task-integrate",
|
||||
id: "task-integrate",
|
||||
title: "Integrate feature branches",
|
||||
status: "pending",
|
||||
|
||||
@@ -28,6 +28,7 @@ test("project context store reads defaults and applies domain patches", async ()
|
||||
},
|
||||
enqueueTasks: [
|
||||
{
|
||||
taskId: "task-1",
|
||||
id: "task-1",
|
||||
title: "Build parser",
|
||||
status: "pending",
|
||||
@@ -38,11 +39,13 @@ test("project context store reads defaults and applies domain patches", async ()
|
||||
const updated = await store.patchState({
|
||||
upsertTasks: [
|
||||
{
|
||||
taskId: "task-1",
|
||||
id: "task-1",
|
||||
title: "Build parser",
|
||||
status: "in_progress",
|
||||
},
|
||||
{
|
||||
taskId: "task-2",
|
||||
id: "task-2",
|
||||
title: "Add tests",
|
||||
status: "pending",
|
||||
@@ -70,6 +73,7 @@ test("project context parser merges missing root keys with defaults", async () =
|
||||
{
|
||||
taskQueue: [
|
||||
{
|
||||
taskId: "task-1",
|
||||
id: "task-1",
|
||||
title: "Migrate",
|
||||
status: "pending",
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, writeFile } from "node:fs/promises";
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, mkdir, stat, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { UiRunService, readRunMetaBySession } from "../src/ui/run-service.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
async function waitForTerminalRun(
|
||||
runService: UiRunService,
|
||||
runId: string,
|
||||
@@ -94,3 +98,89 @@ test("run service persists failure when pipeline summary is failure", async () =
|
||||
});
|
||||
assert.equal(persisted?.status, "failure");
|
||||
});
|
||||
|
||||
test("run service creates, runs, and closes explicit sessions", async () => {
|
||||
const workspaceRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-run-service-session-"));
|
||||
const stateRoot = resolve(workspaceRoot, "state");
|
||||
const envPath = resolve(workspaceRoot, ".env");
|
||||
const projectPath = resolve(workspaceRoot, "project");
|
||||
|
||||
await mkdir(projectPath, { recursive: true });
|
||||
await execFileAsync("git", ["init", projectPath], { encoding: "utf8" });
|
||||
await execFileAsync("git", ["-C", projectPath, "config", "user.name", "AI Ops"], { encoding: "utf8" });
|
||||
await execFileAsync("git", ["-C", projectPath, "config", "user.email", "ai-ops@example.local"], { encoding: "utf8" });
|
||||
await writeFile(resolve(projectPath, "README.md"), "# project\n", "utf8");
|
||||
await execFileAsync("git", ["-C", projectPath, "add", "README.md"], { encoding: "utf8" });
|
||||
await execFileAsync("git", ["-C", projectPath, "commit", "-m", "initial"], { encoding: "utf8" });
|
||||
|
||||
await writeFile(
|
||||
envPath,
|
||||
[
|
||||
`AGENT_STATE_ROOT=${stateRoot}`,
|
||||
"AGENT_WORKTREE_ROOT=.ai_ops/worktrees",
|
||||
"AGENT_WORKTREE_BASE_REF=HEAD",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const runService = new UiRunService({
|
||||
workspaceRoot,
|
||||
envFilePath: ".env",
|
||||
});
|
||||
|
||||
const createdSession = await runService.createSession({
|
||||
projectPath,
|
||||
});
|
||||
assert.equal(createdSession.sessionStatus, "active");
|
||||
|
||||
const manifest = {
|
||||
schemaVersion: "1",
|
||||
topologies: ["sequential"],
|
||||
personas: [
|
||||
{
|
||||
id: "writer",
|
||||
displayName: "Writer",
|
||||
systemPromptTemplate: "Write draft",
|
||||
toolClearance: {
|
||||
allowlist: ["read_file", "write_file"],
|
||||
banlist: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
relationships: [],
|
||||
topologyConstraints: {
|
||||
maxDepth: 1,
|
||||
maxRetries: 0,
|
||||
},
|
||||
pipeline: {
|
||||
entryNodeId: "write-node",
|
||||
nodes: [
|
||||
{
|
||||
id: "write-node",
|
||||
actorId: "writer-actor",
|
||||
personaId: "writer",
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
};
|
||||
|
||||
const started = await runService.startRun({
|
||||
prompt: "complete task",
|
||||
manifest,
|
||||
sessionId: createdSession.sessionId,
|
||||
executionMode: "mock",
|
||||
});
|
||||
|
||||
const terminalStatus = await waitForTerminalRun(runService, started.runId);
|
||||
assert.equal(terminalStatus, "success");
|
||||
|
||||
const closed = await runService.closeSession({
|
||||
sessionId: createdSession.sessionId,
|
||||
});
|
||||
assert.equal(closed.sessionStatus, "closed");
|
||||
|
||||
await assert.rejects(() => stat(createdSession.baseWorkspacePath), {
|
||||
code: "ENOENT",
|
||||
});
|
||||
});
|
||||
|
||||
116
tests/session-lifecycle.test.ts
Normal file
116
tests/session-lifecycle.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, mkdir, readFile, writeFile, stat } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import {
|
||||
FileSystemSessionMetadataStore,
|
||||
SessionWorktreeManager,
|
||||
type SessionMetadata,
|
||||
} from "../src/agents/session-lifecycle.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
async function git(args: string[]): Promise<string> {
|
||||
const { stdout } = await execFileAsync("git", args, {
|
||||
encoding: "utf8",
|
||||
});
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
test("session metadata store persists and updates session metadata", async () => {
|
||||
const stateRoot = await mkdtemp(resolve(tmpdir(), "ai-ops-session-store-"));
|
||||
const store = new FileSystemSessionMetadataStore({ stateRoot });
|
||||
|
||||
const created = await store.createSession({
|
||||
sessionId: "session-abc",
|
||||
projectPath: resolve(stateRoot, "project"),
|
||||
baseWorkspacePath: resolve(stateRoot, "worktrees", "session-abc", "base"),
|
||||
});
|
||||
|
||||
assert.equal(created.sessionStatus, "active");
|
||||
assert.equal(created.sessionId, "session-abc");
|
||||
|
||||
const listed = await store.listSessions();
|
||||
assert.equal(listed.length, 1);
|
||||
assert.equal(listed[0]?.sessionId, "session-abc");
|
||||
|
||||
const updated = await store.updateSession("session-abc", {
|
||||
sessionStatus: "closed",
|
||||
});
|
||||
assert.equal(updated.sessionStatus, "closed");
|
||||
|
||||
const readBack = await store.readSession("session-abc");
|
||||
assert.equal(readBack?.sessionStatus, "closed");
|
||||
});
|
||||
|
||||
test("session worktree manager provisions and merges task worktrees", async () => {
|
||||
const root = await mkdtemp(resolve(tmpdir(), "ai-ops-session-worktree-"));
|
||||
const projectPath = resolve(root, "project");
|
||||
const worktreeRoot = resolve(root, "worktrees");
|
||||
|
||||
await mkdir(projectPath, { recursive: true });
|
||||
await git(["init", projectPath]);
|
||||
await git(["-C", projectPath, "config", "user.name", "AI Ops"]);
|
||||
await git(["-C", projectPath, "config", "user.email", "ai-ops@example.local"]);
|
||||
await writeFile(resolve(projectPath, "README.md"), "# project\n", "utf8");
|
||||
await git(["-C", projectPath, "add", "README.md"]);
|
||||
await git(["-C", projectPath, "commit", "-m", "initial commit"]);
|
||||
|
||||
const manager = new SessionWorktreeManager({
|
||||
worktreeRoot,
|
||||
baseRef: "HEAD",
|
||||
});
|
||||
|
||||
const sessionId = "session-1";
|
||||
const baseWorkspacePath = manager.resolveBaseWorkspacePath(sessionId);
|
||||
|
||||
await manager.initializeSessionBaseWorkspace({
|
||||
sessionId,
|
||||
projectPath,
|
||||
baseWorkspacePath,
|
||||
});
|
||||
|
||||
const baseStats = await stat(baseWorkspacePath);
|
||||
assert.equal(baseStats.isDirectory(), true);
|
||||
|
||||
const taskWorktreePath = (
|
||||
await manager.ensureTaskWorktree({
|
||||
sessionId,
|
||||
taskId: "task-1",
|
||||
baseWorkspacePath,
|
||||
})
|
||||
).taskWorktreePath;
|
||||
|
||||
await writeFile(resolve(taskWorktreePath, "feature.txt"), "task output\n", "utf8");
|
||||
|
||||
await manager.mergeTaskIntoBase({
|
||||
taskId: "task-1",
|
||||
baseWorkspacePath,
|
||||
taskWorktreePath,
|
||||
});
|
||||
|
||||
const mergedFile = await readFile(resolve(baseWorkspacePath, "feature.txt"), "utf8");
|
||||
assert.equal(mergedFile, "task output\n");
|
||||
|
||||
const session: SessionMetadata = {
|
||||
sessionId,
|
||||
projectPath,
|
||||
baseWorkspacePath,
|
||||
sessionStatus: "active",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await manager.closeSession({
|
||||
session,
|
||||
taskWorktreePaths: [],
|
||||
mergeBaseIntoProject: false,
|
||||
});
|
||||
|
||||
await assert.rejects(() => stat(baseWorkspacePath), {
|
||||
code: "ENOENT",
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user