feat: add minimal run engine

This commit is contained in:
chungyeong
2026-05-11 00:46:45 +09:00
parent 64efeabd33
commit 78ebd5ef78
26 changed files with 6045 additions and 209 deletions

17
apps/api/package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "@devflow/api",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsup src/index.ts --format esm --clean",
"typecheck": "tsc -p ../../tsconfig.typecheck.json --noEmit",
"test": "cd ../.. && vitest run --project apps/api"
},
"dependencies": {
"@devflow/core": "workspace:*",
"@devflow/db": "workspace:*",
"@devflow/run-engine": "workspace:*",
"@devflow/session": "workspace:*"
}
}

746
apps/api/src/index.test.ts Normal file
View File

@@ -0,0 +1,746 @@
import { execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import {
DevflowError,
type PromptEnvelope,
loadPersonaFiles,
loadTemplateFiles,
} from "@devflow/core";
import {
type DbClient,
agentPersonas,
approvalDecisions,
approvalRequests,
createDbClient,
runEvents,
runs,
tuiSessions,
workflowTemplates,
} from "@devflow/db";
import { DbRunEngine } from "@devflow/run-engine";
import { FakeSessionAdapter, type SessionHandle, SessionManager } from "@devflow/session";
import { and, eq, inArray } from "drizzle-orm";
import { afterEach, describe, expect, it } from "vitest";
import { startApi } from "./index.js";
const databaseUrl =
process.env.DATABASE_URL ?? "postgres://devflow:devflow@127.0.0.1:55432/devflow";
class ResumeFailsFakeSessionAdapter extends FakeSessionAdapter {
resumeAttempts = 0;
override async resume(_handle: SessionHandle): Promise<SessionHandle> {
this.resumeAttempts += 1;
throw new DevflowError("resume failed", {
class: "recoverable",
code: "pane_briefly_unresponsive",
recoveryHint: "resume failed",
});
}
}
class ResumeSucceedsAfterTwoFailuresFakeSessionAdapter extends FakeSessionAdapter {
resumeAttempts = 0;
override async resume(handle: SessionHandle): Promise<SessionHandle> {
this.resumeAttempts += 1;
if (this.resumeAttempts <= 2) {
throw new DevflowError("resume failed transiently", {
class: "recoverable",
code: "pane_briefly_unresponsive",
recoveryHint: "resume failed transiently",
});
}
return super.resume(handle);
}
}
class DelayedSendPromptFakeSessionAdapter extends FakeSessionAdapter {
readonly promptStarted = deferred<void>();
readonly releasePrompt = deferred<void>();
override async sendPrompt(
handle: SessionHandle,
envelope: PromptEnvelope,
): Promise<{ promptId: string }> {
this.promptStarted.resolve();
await this.releasePrompt.promise;
return super.sendPrompt(handle, envelope);
}
}
function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, reject, resolve };
}
function createGitRepo(): string {
const repoPath = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-repo-")));
execFileSync("git", ["init", "-b", "main"], { cwd: repoPath, stdio: "ignore" });
writeFileSync(join(repoPath, "README.md"), "# API fixture\n");
execFileSync("git", ["add", "README.md"], { cwd: repoPath, stdio: "ignore" });
execFileSync(
"git",
[
"-c",
"user.name=Devflow Test",
"-c",
"user.email=devflow@example.test",
"commit",
"-m",
"initial",
],
{ cwd: repoPath, stdio: "ignore" },
);
return repoPath;
}
async function seedDevelopmentRegistry(db: DbClient["db"]) {
const [templateEntry] = loadTemplateFiles(resolve("docs/schemas/templates")).filter(
(entry) => entry.name === "development" && entry.version === 1,
);
if (templateEntry === undefined) {
throw new Error("development@1 template fixture is missing");
}
await db
.insert(workflowTemplates)
.values({
name: templateEntry.name,
version: templateEntry.version,
hash: templateEntry.hash,
definition: templateEntry.definition,
})
.onConflictDoUpdate({
target: [workflowTemplates.name, workflowTemplates.version],
set: { hash: templateEntry.hash, definition: templateEntry.definition },
});
for (const personaEntry of loadPersonaFiles(resolve("docs/schemas/personas"))) {
await db
.insert(agentPersonas)
.values({
name: personaEntry.name,
version: personaEntry.version,
hash: personaEntry.hash,
definition: personaEntry.definition,
})
.onConflictDoNothing({ target: [agentPersonas.name, agentPersonas.version] });
}
}
async function waitForRunEventType(db: DbClient["db"], runId: string, type: string) {
const deadline = Date.now() + 2_000;
while (Date.now() < deadline) {
const [event] = await db
.select({ id: runEvents.id })
.from(runEvents)
.where(and(eq(runEvents.runId, runId), eq(runEvents.type, type)))
.limit(1);
if (event !== undefined) {
return;
}
await new Promise((resolveWait) => setTimeout(resolveWait, 10));
}
throw new Error(`timed out waiting for ${type}`);
}
describe("startApi", () => {
let client: DbClient | undefined;
const runIds: string[] = [];
const templateIds: string[] = [];
const tempRoots: string[] = [];
function createApiWorkspaceRoot(): string {
const workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-workspace-")));
tempRoots.push(workspaceRoot);
return workspaceRoot;
}
function startTestApi(options: Parameters<typeof startApi>[0] = {}) {
return startApi({ workspaceRoot: createApiWorkspaceRoot(), ...options });
}
afterEach(async () => {
if (client !== undefined) {
if (runIds.length > 0) {
const requests = await client.db
.select({ id: approvalRequests.id })
.from(approvalRequests)
.where(inArray(approvalRequests.runId, [...runIds]));
if (requests.length > 0) {
await client.db.delete(approvalDecisions).where(
inArray(
approvalDecisions.approvalRequestId,
requests.map((request) => request.id),
),
);
}
await client.db
.delete(approvalRequests)
.where(inArray(approvalRequests.runId, [...runIds]));
await client.db.delete(runs).where(inArray(runs.id, [...runIds]));
}
if (templateIds.length > 0) {
await client.db
.delete(workflowTemplates)
.where(inArray(workflowTemplates.id, [...templateIds]));
}
await client.close();
client = undefined;
}
for (const root of tempRoots.splice(0)) {
rmSync(root, { recursive: true, force: true });
}
runIds.length = 0;
templateIds.length = 0;
});
it("runs M4 restart recovery before startup completes", async () => {
client = createDbClient(databaseUrl);
const templateId = randomUUID();
const runId = randomUUID();
const sessionId = randomUUID();
const repoPath = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-repo-")));
const worktreeRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-worktree-")));
tempRoots.push(repoPath, worktreeRoot);
templateIds.push(templateId);
runIds.push(runId);
await client.db.insert(workflowTemplates).values({
id: templateId,
name: `api-startup-${templateId}`,
version: 1,
hash: "a".repeat(64),
definition: { name: "api-startup", version: 1, roles: [], phases: [], defaultGates: [] },
});
await client.db.insert(runs).values({
id: runId,
templateId,
templateHash: "a".repeat(64),
state: "executing",
repoPath,
baseBranch: "main",
worktreeRoot,
});
await client.db.insert(tuiSessions).values({
id: sessionId,
runId,
roleId: "spec_writer",
backend: "fake",
cwd: worktreeRoot,
state: "READY",
});
const result = await startTestApi({ dbClient: client, recoveryRunIds: [runId] });
try {
expect(result.recovery).toEqual({
failedSessionIds: [sessionId],
sweptRunIds: [runId],
});
expect(result.sessionRecovery).toEqual({
failedSessionIds: [],
recoveredSessionIds: [],
});
} finally {
await result.stop();
}
const [run] = await client.db
.select({ state: runs.state })
.from(runs)
.where(eq(runs.id, runId));
expect(run).toEqual({ state: "failed" });
const [session] = await client.db
.select({ state: tuiSessions.state })
.from(tuiSessions)
.where(eq(tuiSessions.id, sessionId));
expect(session).toEqual({ state: "FAILED_NEEDS_HUMAN" });
const events = await client.db
.select({ type: runEvents.type })
.from(runEvents)
.where(eq(runEvents.runId, runId))
.orderBy(runEvents.seq);
expect(events.map((event) => event.type)).toEqual(["run.failed", "session.failed"]);
});
it("holds the SessionManager singleton lock until stopped", async () => {
client = createDbClient(databaseUrl);
const recoveryRunIds = [randomUUID()];
const first = await startTestApi({ dbClient: client, recoveryRunIds });
try {
await expect(startTestApi({ dbClient: client, recoveryRunIds })).rejects.toMatchObject({
code: "session_manager_already_running",
});
} finally {
await first.stop();
}
});
it("hosts the M4 run engine behind the API startup boundary", async () => {
client = createDbClient(databaseUrl);
await seedDevelopmentRegistry(client.db);
const workspaceRoot = createApiWorkspaceRoot();
const repoPath = createGitRepo();
tempRoots.push(repoPath);
const api = await startApi({ dbClient: client, workspaceRoot, recoveryRunIds: [] });
try {
expect(api.engine).toBeInstanceOf(DbRunEngine);
const { runId } = await api.engine.startRun({
requirementsMd: "Start a fake development run through the API-owned engine.",
repoPath,
baseBranch: "main",
scenarios: { spec: "ok" },
});
runIds.push(runId);
const status = await api.engine.getStatus(runId);
expect(status.run.state).toBe("awaiting_approval");
expect(status.run.worktreeRoot).toBe(resolve(workspaceRoot, runId, "main"));
expect(status.approvals).toMatchObject([{ gateKey: "spec_approved", state: "pending" }]);
} finally {
await api.stop();
}
});
it("repairs missing terminal final reports during API startup", async () => {
client = createDbClient(databaseUrl);
await seedDevelopmentRegistry(client.db);
const workspaceRoot = createApiWorkspaceRoot();
const template = (
await client.db
.select({ hash: workflowTemplates.hash, id: workflowTemplates.id })
.from(workflowTemplates)
.where(eq(workflowTemplates.name, "development"))
.limit(1)
)[0];
if (template === undefined) {
throw new Error("development template missing");
}
const runId = randomUUID();
const repoPath = createGitRepo();
const worktreeRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-worktree-")));
tempRoots.push(repoPath, worktreeRoot);
runIds.push(runId);
await client.db.insert(runs).values({
id: runId,
templateId: template.id,
templateHash: template.hash,
state: "completed",
repoPath,
baseBranch: "main",
worktreeRoot,
endedAt: new Date(),
finalReportPath: null,
});
const api = await startApi({ dbClient: client, workspaceRoot, recoveryRunIds: [runId] });
try {
expect(api.finalReportRecovery).toEqual([runId]);
const [run] = await client.db
.select({ finalReportPath: runs.finalReportPath })
.from(runs)
.where(eq(runs.id, runId));
expect(run?.finalReportPath).toMatch(/\.report\.md$/);
} finally {
await api.stop();
}
});
it("does not sweep active runs when a second API instance fails the singleton lock", async () => {
client = createDbClient(databaseUrl);
const first = await startTestApi({ dbClient: client, recoveryRunIds: [] });
const templateId = randomUUID();
const runId = randomUUID();
const sessionId = randomUUID();
const repoPath = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-repo-")));
const worktreeRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-worktree-")));
tempRoots.push(repoPath, worktreeRoot);
templateIds.push(templateId);
runIds.push(runId);
try {
await client.db.insert(workflowTemplates).values({
id: templateId,
name: `api-lock-order-${templateId}`,
version: 1,
hash: "c".repeat(64),
definition: { name: "api-lock-order", version: 1, roles: [], phases: [] },
});
await client.db.insert(runs).values({
id: runId,
templateId,
templateHash: "c".repeat(64),
state: "executing",
repoPath,
baseBranch: "main",
worktreeRoot,
});
await client.db.insert(tuiSessions).values({
id: sessionId,
runId,
roleId: "spec_writer",
backend: "fake",
cwd: worktreeRoot,
state: "READY",
});
await expect(
startTestApi({ dbClient: client, recoveryRunIds: [runId] }),
).rejects.toMatchObject({
code: "session_manager_already_running",
});
const [run] = await client.db
.select({ state: runs.state })
.from(runs)
.where(eq(runs.id, runId));
expect(run).toEqual({ state: "executing" });
const [session] = await client.db
.select({ state: tuiSessions.state })
.from(tuiSessions)
.where(eq(tuiSessions.id, sessionId));
expect(session).toEqual({ state: "READY" });
const events = await client.db.select().from(runEvents).where(eq(runEvents.runId, runId));
expect(events).toEqual([]);
} finally {
await first.stop();
}
});
it("ignores terminal-run sessions during SessionManager startup recovery", async () => {
client = createDbClient(databaseUrl);
const templateId = randomUUID();
const runId = randomUUID();
const sessionId = randomUUID();
const repoPath = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-repo-")));
const worktreeRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-worktree-")));
tempRoots.push(repoPath, worktreeRoot);
templateIds.push(templateId);
runIds.push(runId);
const adapter = new FakeSessionAdapter({
sessionIdFactory: () => sessionId,
writeDelayMs: 0,
});
await adapter.start({
runId,
roleId: "spec_writer",
backend: "fake",
cwd: worktreeRoot,
});
await client.db.insert(workflowTemplates).values({
id: templateId,
name: `api-session-recovery-${templateId}`,
version: 1,
hash: "b".repeat(64),
definition: { name: "api-session-recovery", version: 1, roles: [], phases: [] },
});
await client.db.insert(runs).values({
id: runId,
templateId,
templateHash: "b".repeat(64),
state: "completed",
repoPath,
baseBranch: "main",
worktreeRoot,
});
await client.db.insert(tuiSessions).values({
id: sessionId,
runId,
roleId: "spec_writer",
backend: "fake",
cwd: worktreeRoot,
state: "READY",
});
const result = await startTestApi({
dbClient: client,
recoveryRunIds: [runId],
sessionAdapter: adapter,
});
try {
expect(result.recovery).toEqual({ failedSessionIds: [], sweptRunIds: [] });
expect(result.sessionRecovery).toEqual({
failedSessionIds: [],
recoveredSessionIds: [],
});
} finally {
await result.stop();
}
const [session] = await client.db
.select({ state: tuiSessions.state })
.from(tuiSessions)
.where(eq(tuiSessions.id, sessionId));
expect(session).toEqual({ state: "READY" });
const approvals = await client.db
.select()
.from(approvalRequests)
.where(eq(approvalRequests.runId, runId));
expect(approvals).toEqual([]);
const events = await client.db.select().from(runEvents).where(eq(runEvents.runId, runId));
expect(events).toEqual([]);
});
it("retries transient session resume failures during startup recovery", async () => {
client = createDbClient(databaseUrl);
const templateId = randomUUID();
const runId = randomUUID();
const sessionId = randomUUID();
const repoPath = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-repo-")));
const worktreeRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-worktree-")));
tempRoots.push(repoPath, worktreeRoot);
templateIds.push(templateId);
runIds.push(runId);
const adapter = new ResumeSucceedsAfterTwoFailuresFakeSessionAdapter({
sessionIdFactory: () => sessionId,
writeDelayMs: 0,
});
await adapter.start({
runId,
roleId: "spec_writer",
backend: "fake",
cwd: worktreeRoot,
});
await client.db.insert(workflowTemplates).values({
id: templateId,
name: `api-session-retry-${templateId}`,
version: 1,
hash: "e".repeat(64),
definition: { name: "api-session-retry", version: 1, roles: [], phases: [] },
});
await client.db.insert(runs).values({
id: runId,
templateId,
templateHash: "e".repeat(64),
state: "executing",
repoPath,
baseBranch: "main",
worktreeRoot,
});
await client.db.insert(tuiSessions).values({
id: sessionId,
runId,
roleId: "spec_writer",
backend: "fake",
cwd: worktreeRoot,
state: "READY",
});
const manager = new SessionManager({
dbClient: client,
adapter,
recoveryRunIds: [runId],
});
const recovery = await manager.initialize();
try {
expect(adapter.resumeAttempts).toBe(3);
expect(recovery).toEqual({ failedSessionIds: [], recoveredSessionIds: [sessionId] });
const approvals = await client.db
.select()
.from(approvalRequests)
.where(eq(approvalRequests.runId, runId));
expect(approvals).toEqual([]);
} finally {
await manager.shutdown();
}
});
it("pauses a non-terminal run when SessionManager startup recovery cannot resume a session", async () => {
client = createDbClient(databaseUrl);
const templateId = randomUUID();
const runId = randomUUID();
const sessionId = randomUUID();
const repoPath = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-repo-")));
const worktreeRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-worktree-")));
tempRoots.push(repoPath, worktreeRoot);
templateIds.push(templateId);
runIds.push(runId);
await client.db.insert(workflowTemplates).values({
id: templateId,
name: `api-session-recovery-failure-${templateId}`,
version: 1,
hash: "d".repeat(64),
definition: { name: "api-session-recovery-failure", version: 1, roles: [], phases: [] },
});
await client.db.insert(runs).values({
id: runId,
templateId,
templateHash: "d".repeat(64),
state: "executing",
repoPath,
baseBranch: "main",
worktreeRoot,
});
await client.db.insert(tuiSessions).values({
id: sessionId,
runId,
roleId: "spec_writer",
backend: "fake",
cwd: worktreeRoot,
state: "READY",
});
const adapter = new ResumeFailsFakeSessionAdapter();
const manager = new SessionManager({
dbClient: client,
adapter,
recoveryRunIds: [runId],
});
const recovery = await manager.initialize();
try {
expect(adapter.resumeAttempts).toBe(3);
expect(recovery).toEqual({ failedSessionIds: [sessionId], recoveredSessionIds: [] });
const [run] = await client.db
.select({ pausedFromState: runs.pausedFromState, state: runs.state })
.from(runs)
.where(eq(runs.id, runId));
expect(run).toEqual({ pausedFromState: "executing", state: "paused" });
const [session] = await client.db
.select({ recoveryAttempts: tuiSessions.recoveryAttempts, state: tuiSessions.state })
.from(tuiSessions)
.where(eq(tuiSessions.id, sessionId));
expect(session).toEqual({ recoveryAttempts: 1, state: "FAILED_NEEDS_HUMAN" });
const [approval] = await client.db
.select({
gateKey: approvalRequests.gateKey,
phaseId: approvalRequests.phaseId,
state: approvalRequests.state,
})
.from(approvalRequests)
.where(eq(approvalRequests.runId, runId));
expect(approval).toEqual({
gateKey: "session_recovery_required",
phaseId: null,
state: "pending",
});
const events = await client.db
.select({ type: runEvents.type })
.from(runEvents)
.where(eq(runEvents.runId, runId))
.orderBy(runEvents.seq);
expect(events.map((event) => event.type)).toEqual([
"session.failed",
"run.paused",
"approval.requested",
]);
} finally {
await manager.shutdown();
}
});
it("keeps the singleton lock while shutdown drains in-flight session operations", async () => {
client = createDbClient(databaseUrl);
const adapter = new DelayedSendPromptFakeSessionAdapter({ writeDelayMs: 0 });
const manager = new SessionManager({
dbClient: client,
adapter,
recoveryRunIds: [],
shutdownDrainMs: 5_000,
});
await manager.initialize();
const runId = randomUUID();
const cwd = realpathSync(mkdtempSync(join(tmpdir(), "devflow-api-session-")));
tempRoots.push(cwd);
const handle = await manager.start({
runId,
roleId: "spec_writer",
backend: "fake",
cwd,
});
const envelope: PromptEnvelope = {
uuid: randomUUID(),
runId,
roleId: "spec_writer",
phaseKey: "spec",
attempt: 0,
expectedArtifact: join(tmpdir(), `${randomUUID()}.json`),
expectedSchema: "dev/spec@1",
dedupKey: `dedup-${randomUUID()}`,
instructions: "Scenario: timeout",
};
const promptPromise = manager.sendPrompt(handle, envelope);
await adapter.promptStarted.promise;
const shutdownPromise = manager.shutdown();
await expect(
new SessionManager({
dbClient: client,
adapter: new FakeSessionAdapter(),
recoveryRunIds: [],
}).initialize(),
).rejects.toMatchObject({ code: "session_manager_already_running" });
adapter.releasePrompt.resolve(undefined);
await expect(promptPromise).resolves.toEqual({ promptId: envelope.dedupKey });
await shutdownPromise;
const nextManager = new SessionManager({
dbClient: client,
adapter: new FakeSessionAdapter(),
recoveryRunIds: [],
});
await expect(nextManager.initialize()).resolves.toEqual({
failedSessionIds: [],
recoveredSessionIds: [],
});
await nextManager.shutdown();
});
it("keeps the singleton lock while shutdown drains in-flight artifact polling", async () => {
client = createDbClient(databaseUrl);
await seedDevelopmentRegistry(client.db);
const workspaceRoot = createApiWorkspaceRoot();
const repoPath = createGitRepo();
tempRoots.push(repoPath);
const runId = randomUUID();
runIds.push(runId);
const api = await startApi({
dbClient: client,
workspaceRoot,
recoveryRunIds: [],
sessionAdapter: new FakeSessionAdapter({ writeDelayMs: 1_000 }),
});
const startPromise = api.engine.startRun({
runId,
requirementsMd: "Keep artifact polling in flight during shutdown.",
repoPath,
baseBranch: "main",
scenarios: { spec: "ok" },
});
await waitForRunEventType(client.db, runId, "artifact.expected");
const stopPromise = api.stop();
await expect(
new SessionManager({
dbClient: client,
adapter: new FakeSessionAdapter(),
recoveryRunIds: [],
}).initialize(),
).rejects.toMatchObject({ code: "session_manager_already_running" });
await expect(startPromise).resolves.toEqual({ runId });
await stopPromise;
const nextManager = new SessionManager({
dbClient: client,
adapter: new FakeSessionAdapter(),
recoveryRunIds: [],
});
await expect(nextManager.initialize()).resolves.toEqual({
failedSessionIds: [],
recoveredSessionIds: [],
});
await nextManager.shutdown();
});
});

128
apps/api/src/index.ts Normal file
View File

@@ -0,0 +1,128 @@
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { type BackendConfig, getConfig } from "@devflow/core";
import { DevflowError } from "@devflow/core";
import { type DbClient, createDbClient } from "@devflow/db";
import { DbRunEngine, type RunEngine } from "@devflow/run-engine";
import {
FakeSessionAdapter,
type SessionAdapter,
SessionManager,
type SessionManagerRecoveryResult,
} from "@devflow/session";
import { recoverM4ApiStartup, startM4SessionManager } from "./startup.js";
export * from "./startup.js";
export interface StartApiOptions {
dbClient?: DbClient;
workspaceRoot?: string;
availableBackends?: readonly BackendConfig[];
recoveryRunIds?: readonly string[];
sessionAdapter?: SessionAdapter;
sessionManager?: SessionManager;
runEngine?: RunEngine;
}
export interface StartApiResult {
recovery: Awaited<ReturnType<typeof recoverM4ApiStartup>>;
sessionRecovery: SessionManagerRecoveryResult;
sessionManager: SessionManager;
engine: RunEngine;
finalReportRecovery: string[];
stop(): Promise<void>;
}
export async function startApi(options: StartApiOptions = {}): Promise<StartApiResult> {
const ownedClient = options.dbClient === undefined;
const config = ownedClient || options.workspaceRoot === undefined ? getConfig() : undefined;
const dbClient =
options.dbClient ?? createDbClient(config?.DATABASE_URL ?? getConfig().DATABASE_URL);
const sessionManager =
options.sessionManager ??
new SessionManager({
dbClient,
adapter: options.sessionAdapter ?? new FakeSessionAdapter(),
...(options.recoveryRunIds === undefined ? {} : { recoveryRunIds: options.recoveryRunIds }),
});
const engine =
options.runEngine ??
new DbRunEngine({
db: dbClient.db,
sessions: sessionManager,
workspaceRoot: options.workspaceRoot ?? config?.WORKSPACE_ROOT ?? getConfig().WORKSPACE_ROOT,
...(options.availableBackends === undefined
? config?.backends === undefined
? {}
: { availableBackends: config.backends }
: { availableBackends: options.availableBackends }),
});
try {
await sessionManager.acquireLock();
const recovery = await recoverM4ApiStartup(
dbClient.db,
options.recoveryRunIds === undefined ? {} : { runIds: options.recoveryRunIds },
);
const sessionRecovery = await startM4SessionManager(sessionManager);
const finalReportRecovery =
engine instanceof DbRunEngine
? await engine.recoverMissingFinalReports(
options.recoveryRunIds === undefined ? {} : { runIds: options.recoveryRunIds },
)
: [];
return {
engine,
finalReportRecovery,
recovery,
sessionRecovery,
sessionManager,
async stop() {
await sessionManager.shutdown();
if (ownedClient) {
await dbClient.close();
}
},
};
} catch (error) {
if (options.sessionManager === undefined) {
await sessionManager.shutdown().catch(() => undefined);
}
if (ownedClient) {
await dbClient.close();
}
throw error;
}
}
if (isDirectEntry(import.meta.url, process.argv)) {
startApi()
.then(async (api) => {
await waitForShutdownSignal();
await api.stop();
})
.catch((error: unknown) => {
console.error(error);
process.exitCode =
error instanceof DevflowError && error.code === "session_manager_already_running" ? 3 : 2;
});
}
function isDirectEntry(importMetaUrl: string, argv: readonly string[]): boolean {
const entry = argv[1];
return entry !== undefined && resolve(entry) === fileURLToPath(importMetaUrl);
}
function waitForShutdownSignal(): Promise<void> {
return new Promise((resolveSignal) => {
const resolveOnce = () => {
process.off("SIGINT", resolveOnce);
process.off("SIGTERM", resolveOnce);
resolveSignal();
};
process.once("SIGINT", resolveOnce);
process.once("SIGTERM", resolveOnce);
});
}

14
apps/api/src/startup.ts Normal file
View File

@@ -0,0 +1,14 @@
import type { DbClient } from "@devflow/db";
import { type M4ProcessRestartSweepOptions, sweepM4ProcessRestart } from "@devflow/run-engine";
import type { SessionManager } from "@devflow/session";
export async function recoverM4ApiStartup(
db: DbClient["db"],
options: M4ProcessRestartSweepOptions = {},
) {
return sweepM4ProcessRestart(db, options);
}
export async function startM4SessionManager(sessionManager: SessionManager) {
return sessionManager.recoverSessions();
}

15
apps/api/tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"types": ["node", "vitest"]
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../../packages/core" },
{ "path": "../../packages/db" },
{ "path": "../../packages/run-engine" },
{ "path": "../../packages/session" }
]
}

View File

@@ -0,0 +1,15 @@
name: fake-devflow-agent
version: 1
backend: fake
capabilities:
- spec_write
- phase_planning
- task_dag_planning
- code_edit
- test_first_development
- command_execute
- final_report_compose
maxRiskLevel: high
promptConfig:
instructionsPrelude: "Use the fake backend fixture protocol."
modelConfig: {}

View File

@@ -0,0 +1,35 @@
name: development
version: 1
roles:
- id: spec_writer
requiredCapabilities:
- spec_write
preferredBackends:
- fake
- id: phase_planner
requiredCapabilities:
- phase_planning
preferredBackends:
- fake
phases:
- key: spec
title: Development Specification
risk: low
roles:
- spec_writer
expectedArtifact:
path: artifacts/spec.json
schema: dev/spec@1
gates:
- spec_approved
- key: phase_plan
title: Phase Plan
risk: low
roles:
- phase_planner
expectedArtifact:
path: artifacts/phase-plan.json
schema: dev/phase-plan@1
gates:
- phase_plan_approved
defaultGates: []

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -136,6 +136,18 @@ class StartFailsFakeAdapter extends FakeSessionAdapter {
}
}
class ResumeFailsFakeAdapter extends FakeSessionAdapter {
resumeAttempts = 0;
override async resume(_handle: SessionHandle): Promise<SessionHandle> {
this.resumeAttempts += 1;
throw new DevflowError("transient resume failure", {
class: "recoverable",
code: "pane_briefly_unresponsive",
});
}
}
class PromptWritesArtifactBeforeReturnFakeAdapter extends FakeSessionAdapter {
override async sendPrompt(
handle: SessionHandle,
@@ -334,7 +346,7 @@ describe("runSingleFakePhase", () => {
worktreeRoot,
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 100 },
}),
).rejects.toMatchObject({ code: "internal_state_corruption" });
).rejects.toMatchObject({ code: "run_state_changed" });
const [run] = await db.select({ state: runs.state }).from(runs).where(eq(runs.id, runId));
expect(run).toEqual({ state: runState });
@@ -578,6 +590,209 @@ describe("runSingleFakePhase", () => {
expect(approvals).toEqual([]);
});
it("moves a successful workflow-gated phase from busy to waiting for approval without an idle event", async () => {
const { db, phaseId, runId } = await createRunAndPhase();
const worktreeRoot = realpathSync(
mkdtempSync(join(tmpdir(), "devflow-fake-phase-workflow-gate-")),
);
tempRoots.push(worktreeRoot);
const expectedArtifactPath = join(worktreeRoot, "artifacts", "spec.json");
const sessionId = randomUUID();
await runSingleFakePhase({
adapter: new FakeSessionAdapter({ sessionIdFactory: () => sessionId, writeDelayMs: 0 }),
db,
expectedArtifactPath,
expectedSchema: "dev/spec@1",
instructions: "Scenario: ok\nWrite the development specification.",
phaseId,
phaseKey: "implement",
roleId: "implementer",
runId,
worktreeRoot,
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 },
workflowApprovalGateKey: "spec_approved",
uuidFactory: () => "00000000-0000-4000-8000-000000000024",
});
const [run] = await db
.select({ currentPhaseId: runs.currentPhaseId, state: runs.state })
.from(runs)
.where(eq(runs.id, runId));
expect(run).toEqual({ currentPhaseId: phaseId, state: "awaiting_approval" });
const [phase] = await db
.select({ state: runPhases.state })
.from(runPhases)
.where(eq(runPhases.id, phaseId));
expect(phase).toEqual({ state: "awaiting_approval" });
const [session] = await db
.select({ state: tuiSessions.state })
.from(tuiSessions)
.where(eq(tuiSessions.id, sessionId));
expect(session).toEqual({ state: "WAITING_FOR_APPROVAL" });
const [approval] = await db
.select({ gateKey: approvalRequests.gateKey, state: approvalRequests.state })
.from(approvalRequests)
.where(eq(approvalRequests.runId, runId));
expect(approval).toEqual({ gateKey: "spec_approved", state: "pending" });
const events = await db
.select({ type: runEvents.type })
.from(runEvents)
.where(eq(runEvents.runId, runId))
.orderBy(runEvents.seq);
expect(events.map((event) => event.type)).toEqual([
"phase.started",
"session.created",
"session.ready",
"session.busy",
"prompt.sent",
"artifact.expected",
"artifact.validated",
"approval.requested",
]);
});
it("does not mark a timeout-repaired workflow-gated phase idle before approval", async () => {
const { db, phaseId, runId } = await createRunAndPhase();
const worktreeRoot = realpathSync(
mkdtempSync(join(tmpdir(), "devflow-fake-phase-workflow-timeout-repair-")),
);
tempRoots.push(worktreeRoot);
const expectedArtifactPath = join(worktreeRoot, "artifacts", "spec.json");
const sessionId = randomUUID();
await runSingleFakePhase({
adapter: new FakeSessionAdapter({ sessionIdFactory: () => sessionId, writeDelayMs: 0 }),
db,
expectedArtifactPath,
expectedSchema: "dev/spec@1",
instructions: "Scenario: timeout\nWrite the development specification.",
phaseId,
phaseKey: "implement",
roleId: "implementer",
runId,
worktreeRoot,
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 10 },
workflowApprovalGateKey: "spec_approved",
uuidFactory: () => "00000000-0000-4000-8000-000000000036",
});
const events = await db
.select({ type: runEvents.type })
.from(runEvents)
.where(eq(runEvents.runId, runId))
.orderBy(runEvents.seq);
expect(events.map((event) => event.type)).toEqual([
"phase.started",
"session.created",
"session.ready",
"session.busy",
"prompt.sent",
"artifact.expected",
"artifact.timeout",
"session.recovered",
"phase.started",
"session.busy",
"prompt.repaired",
"artifact.expected",
"artifact.validated",
"approval.requested",
]);
});
it("does not mark a replayed valid artifact idle before requesting workflow approval", async () => {
const { db, phaseId, runId } = await createRunAndPhase("executing", "validating", 1);
await recordPhaseStarted(db, runId, phaseId);
const worktreeRoot = realpathSync(
mkdtempSync(join(tmpdir(), "devflow-fake-phase-workflow-gate-replay-")),
);
tempRoots.push(worktreeRoot);
const expectedArtifactPath = join(worktreeRoot, "artifacts", "spec.json");
const instructions = "Scenario: ok\nWrite the development specification.";
const promptHash = hash({
attempt: 1,
expectedArtifact: expectedArtifactPath,
expectedSchema: "dev/spec@1",
instructions,
phaseKey: "implement",
roleId: "implementer",
runId,
});
const sessionId = randomUUID();
const artifactId = randomUUID();
const artifactHash = hash({ replay: "valid-workflow-gate" });
await db.insert(tuiSessions).values({
id: sessionId,
runId,
roleId: "implementer",
backend: "fake",
cwd: worktreeRoot,
expectedArtifactPath,
expectedSchema: "dev/spec@1",
lastPromptHash: promptHash,
lastPromptAt: new Date(Date.now() - 1000),
state: "BUSY",
});
await db.insert(artifacts).values({
id: artifactId,
runId,
phaseId,
path: expectedArtifactPath,
schemaId: "dev/spec@1",
hash: artifactHash,
valid: true,
});
await db.insert(runEvents).values({
runId,
phaseId,
seq: 2n,
type: "artifact.validated",
payload: {
artifactId,
hash: artifactHash,
path: expectedArtifactPath,
schemaId: "dev/spec@1",
},
idempotencyKey: `artifact.validated:${phaseId}:${expectedArtifactPath}:${artifactHash}`,
});
await runSingleFakePhase({
adapter: new FakeSessionAdapter({ sessionIdFactory: () => sessionId, writeDelayMs: 0 }),
db,
expectedArtifactPath,
expectedSchema: "dev/spec@1",
instructions,
phaseId,
phaseKey: "implement",
roleId: "implementer",
runId,
worktreeRoot,
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 },
workflowApprovalGateKey: "spec_approved",
});
const [session] = await db
.select({ state: tuiSessions.state })
.from(tuiSessions)
.where(eq(tuiSessions.id, sessionId));
expect(session).toEqual({ state: "WAITING_FOR_APPROVAL" });
const events = await db
.select({ type: runEvents.type })
.from(runEvents)
.where(eq(runEvents.runId, runId))
.orderBy(runEvents.seq);
expect(events.map((event) => event.type)).toEqual([
"phase.started",
"artifact.validated",
"approval.requested",
]);
});
it("resumes a running phase when prompt delivery succeeded before prompt.sent was recorded", async () => {
const { db, phaseId, runId } = await createRunAndPhase("executing", "running", 1);
await recordPhaseStarted(db, runId, phaseId);
@@ -662,6 +877,53 @@ describe("runSingleFakePhase", () => {
]);
});
it("requests a human gate when existing session resume exhausts retries", async () => {
const { db, phaseId, runId } = await createRunAndPhase();
const worktreeRoot = realpathSync(
mkdtempSync(join(tmpdir(), "devflow-fake-phase-resume-fails-")),
);
tempRoots.push(worktreeRoot);
const expectedArtifactPath = join(worktreeRoot, "artifacts", "spec.json");
const sessionId = randomUUID();
await db.insert(tuiSessions).values({
id: sessionId,
runId,
roleId: "implementer",
backend: "fake",
cwd: worktreeRoot,
expectedArtifactPath,
expectedSchema: "dev/spec@1",
state: "READY",
});
const adapter = new ResumeFailsFakeAdapter();
await expect(
runSingleFakePhase({
adapter,
db,
expectedArtifactPath,
expectedSchema: "dev/spec@1",
instructions: "Scenario: ok\nExisting session resume fails.",
phaseId,
phaseKey: "implement",
roleId: "implementer",
runId,
worktreeRoot,
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 },
uuidFactory: () => "00000000-0000-4000-8000-000000000039",
}),
).rejects.toMatchObject({ code: "prompt_send_exhausted" });
expect(adapter.resumeAttempts).toBe(3);
const [run] = await db.select({ state: runs.state }).from(runs).where(eq(runs.id, runId));
expect(run).toEqual({ state: "paused" });
const [approval] = await db
.select({ gateKey: approvalRequests.gateKey, state: approvalRequests.state })
.from(approvalRequests)
.where(eq(approvalRequests.runId, runId));
expect(approval).toEqual({ gateKey: "prompt_send_exhausted", state: "pending" });
});
it("resumes a running phase when the crash happened before session creation", async () => {
const { db, phaseId, runId } = await createRunAndPhase("executing", "running", 1);
await recordPhaseStarted(db, runId, phaseId);
@@ -898,6 +1160,65 @@ describe("runSingleFakePhase", () => {
expect(run?.state).toBe("executing");
});
it("reuses an idle role session when a later running phase has not sent its prompt yet", async () => {
const { db, phaseId, runId } = await createRunAndPhase("executing", "running", 1);
await recordPhaseStarted(db, runId, phaseId);
const worktreeRoot = realpathSync(
mkdtempSync(join(tmpdir(), "devflow-fake-phase-reuse-idle-session-")),
);
tempRoots.push(worktreeRoot);
const previousArtifactPath = join(worktreeRoot, "artifacts", "previous-spec.json");
const expectedArtifactPath = join(worktreeRoot, "artifacts", "next-spec.json");
const adapter = new FakeSessionAdapter({ writeDelayMs: 0 });
const sessionHandle = await adapter.start({
backend: "fake",
cwd: worktreeRoot,
expectedArtifactPath: previousArtifactPath,
expectedSchema: "dev/spec@1",
roleId: "implementer",
runId,
});
await db.insert(tuiSessions).values({
id: sessionHandle.sessionId,
runId,
roleId: "implementer",
backend: "fake",
cwd: worktreeRoot,
expectedArtifactPath: previousArtifactPath,
expectedSchema: "dev/spec@1",
state: "READY",
});
await runSingleFakePhase({
adapter,
db,
expectedArtifactPath,
expectedSchema: "dev/spec@1",
instructions: "Scenario: ok\nWrite the development specification.",
phaseId,
phaseKey: "implement",
roleId: "implementer",
runId,
worktreeRoot,
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 },
});
const [session] = await db
.select({
expectedArtifactPath: tuiSessions.expectedArtifactPath,
expectedSchema: tuiSessions.expectedSchema,
state: tuiSessions.state,
})
.from(tuiSessions)
.where(eq(tuiSessions.id, sessionHandle.sessionId));
expect(session).toEqual({
expectedArtifactPath,
expectedSchema: "dev/spec@1",
state: "READY",
});
await expectRunCompleted(db, runId);
});
it("replays an invalid validating artifact and uses the one repair attempt", async () => {
const { db, phaseId, runId } = await createRunAndPhase("executing", "validating", 1);
await recordPhaseStarted(db, runId, phaseId);
@@ -1674,7 +1995,7 @@ describe("runSingleFakePhase", () => {
worktreeRoot,
wait: { pollIntervalMs: 1, stableMs: 0, timeoutMs: 500 },
}),
).rejects.toMatchObject({ code: "internal_state_corruption" });
).rejects.toMatchObject({ code: "run_state_changed" });
const events = await db
.select({ type: runEvents.type })

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +1,3 @@
export * from "./engine.js";
export * from "./fake-phase-harness.js";
export * from "./run-event-repository.js";

View File

@@ -12,9 +12,7 @@
"test": "cd ../.. && vitest run --project packages/session"
},
"dependencies": {
"@devflow/core": "workspace:*"
},
"devDependencies": {
"@devflow/core": "workspace:*",
"@devflow/db": "workspace:*"
}
}

View File

@@ -1,3 +1,4 @@
export * from "./adapter.js";
export * from "./fake.js";
export * from "./manager.js";
export * from "./transcript.js";

View File

@@ -0,0 +1,410 @@
import { DevflowError, type PromptEnvelope } from "@devflow/core";
import {
type DbClient,
RunEventRepository,
approvalRequests,
runs,
tuiSessions,
} from "@devflow/db";
import { and, eq, inArray, ne, notInArray, sql } from "drizzle-orm";
import type {
ProbeResult,
SessionAdapter,
SessionHandle,
StartInput,
TranscriptChunk,
} from "./adapter.js";
type Database = DbClient["db"];
interface AdvisoryLockClient {
query<T extends Record<string, unknown> = Record<string, unknown>>(
text: string,
values?: readonly unknown[],
): Promise<{ rows: T[] }>;
release(): void;
}
export interface SessionRuntime {
trackOperation<T>(operation: Promise<T>): Promise<T>;
start(input: StartInput): Promise<SessionHandle>;
sendPrompt(handle: SessionHandle, envelope: PromptEnvelope): Promise<{ promptId: string }>;
probe(handle: SessionHandle): Promise<ProbeResult>;
resume(handle: SessionHandle): Promise<SessionHandle>;
rebootstrap(handle: SessionHandle): Promise<SessionHandle>;
capture(handle: SessionHandle, fromSeq: bigint): AsyncIterable<TranscriptChunk>;
dispose(handle: SessionHandle): Promise<void>;
}
export interface SessionManagerOptions {
adapter: SessionAdapter;
db?: Database;
dbClient?: DbClient;
recoveryRunIds?: readonly string[];
shutdownDrainMs?: number;
}
export interface SessionManagerRecoveryResult {
recoveredSessionIds: string[];
failedSessionIds: string[];
}
export class SessionManager implements SessionRuntime {
private readonly adapter: SessionAdapter;
private readonly db: Database | undefined;
private readonly dbClient: DbClient | undefined;
private readonly recoveryRunIds: readonly string[] | undefined;
private readonly shutdownDrainMs: number;
private readonly handles = new Map<string, SessionHandle>();
private readonly inFlight = new Set<Promise<unknown>>();
private lockClient: AdvisoryLockClient | undefined;
private draining = false;
constructor(options: SessionManagerOptions) {
this.adapter = options.adapter;
this.db = options.dbClient?.db ?? options.db;
this.dbClient = options.dbClient;
this.recoveryRunIds = options.recoveryRunIds;
this.shutdownDrainMs = options.shutdownDrainMs ?? 30_000;
}
async initialize(): Promise<SessionManagerRecoveryResult> {
await this.acquireLock();
try {
return await this.recoverSessions();
} catch (error) {
await this.shutdown();
throw error;
}
}
async acquireLock(): Promise<void> {
if (this.dbClient === undefined) {
throw new DevflowError("SessionManager requires a DbClient for singleton startup", {
class: "fatal",
code: "internal_state_corruption",
});
}
if (this.lockClient !== undefined) {
return;
}
const client = (await this.dbClient.pool.connect()) as AdvisoryLockClient;
const result = await client.query<{ acquired: boolean }>(
"SELECT pg_try_advisory_lock(hashtext($1)) AS acquired",
["devflow:session-manager"],
);
if (result.rows[0]?.acquired !== true) {
client.release();
throw new DevflowError("another session manager is running", {
class: "human_required",
code: "session_manager_already_running",
recoveryHint: "exit_code=3",
});
}
this.lockClient = client;
}
async shutdown(): Promise<void> {
this.draining = true;
await this.waitForInFlight();
const client = this.lockClient;
this.lockClient = undefined;
this.handles.clear();
if (client !== undefined) {
try {
await client.query("SELECT pg_advisory_unlock(hashtext($1))", ["devflow:session-manager"]);
} finally {
client.release();
}
}
}
trackOperation<T>(operation: Promise<T>): Promise<T> {
return this.track(operation);
}
async start(input: StartInput): Promise<SessionHandle> {
this.assertAcceptingPrompts();
const handle = await this.track(this.adapter.start(input));
this.handles.set(handle.sessionId, handle);
return handle;
}
async sendPrompt(handle: SessionHandle, envelope: PromptEnvelope): Promise<{ promptId: string }> {
this.assertAcceptingPrompts();
return this.track(this.adapter.sendPrompt(this.handleFor(handle), envelope));
}
async probe(handle: SessionHandle): Promise<ProbeResult> {
return this.track(this.adapter.probe(this.handleFor(handle)));
}
async resume(handle: SessionHandle): Promise<SessionHandle> {
this.assertAcceptingPrompts();
const resumed = await this.track(this.adapter.resume(this.handleFor(handle)));
this.handles.set(resumed.sessionId, resumed);
return resumed;
}
async rebootstrap(handle: SessionHandle): Promise<SessionHandle> {
this.assertAcceptingPrompts();
const rebootstrapped = await this.track(this.adapter.rebootstrap(this.handleFor(handle)));
this.handles.set(rebootstrapped.sessionId, rebootstrapped);
return rebootstrapped;
}
async *capture(handle: SessionHandle, fromSeq: bigint): AsyncIterable<TranscriptChunk> {
const finishTracking = this.beginTrackedOperation();
try {
for await (const chunk of this.adapter.capture(this.handleFor(handle), fromSeq)) {
yield chunk;
}
} finally {
finishTracking();
}
}
async dispose(handle: SessionHandle): Promise<void> {
const resolvedHandle = this.handleFor(handle);
await this.track(this.adapter.dispose(resolvedHandle));
this.handles.delete(resolvedHandle.sessionId);
}
async recoverSessions(): Promise<SessionManagerRecoveryResult> {
if (this.db === undefined) {
return { recoveredSessionIds: [], failedSessionIds: [] };
}
const sessionRows = await this.db
.select({
id: tuiSessions.id,
runId: tuiSessions.runId,
roleId: tuiSessions.roleId,
backend: tuiSessions.backend,
cwd: tuiSessions.cwd,
lastKnownPanePid: tuiSessions.lastKnownPanePid,
recoveryAttempts: tuiSessions.recoveryAttempts,
state: tuiSessions.state,
tmuxSession: tuiSessions.tmuxSession,
tmuxWindow: tuiSessions.tmuxWindow,
})
.from(tuiSessions)
.innerJoin(runs, eq(tuiSessions.runId, runs.id))
.where(
this.recoveryRunIds === undefined
? and(
ne(tuiSessions.state, "FAILED_NEEDS_HUMAN"),
notInArray(runs.state, [...terminalRunStates]),
)
: and(
ne(tuiSessions.state, "FAILED_NEEDS_HUMAN"),
notInArray(runs.state, [...terminalRunStates]),
inArray(tuiSessions.runId, [...this.recoveryRunIds]),
),
);
const recoveredSessionIds: string[] = [];
const failedSessionIds: string[] = [];
for (const session of sessionRows) {
const handle = compactHandle(
session.id,
session.lastKnownPanePid,
session.tmuxSession,
session.tmuxWindow,
);
try {
const resumed = await this.resumeWithRetry(handle);
this.handles.set(resumed.sessionId, resumed);
recoveredSessionIds.push(resumed.sessionId);
} catch (error) {
await this.markRecoveryFailed(session, error);
failedSessionIds.push(session.id);
}
}
return { recoveredSessionIds, failedSessionIds };
}
private async markRecoveryFailed(
session: {
id: string;
runId: string;
roleId: string;
backend: string;
cwd: string;
recoveryAttempts: number;
},
error: unknown,
): Promise<void> {
if (this.db === undefined) {
return;
}
const eventRepository = new RunEventRepository(this.db);
const recoveryAttempts = session.recoveryAttempts + 1;
const gateKey = "session_recovery_required";
const approvalIdempotencyKey = `${session.runId}:${gateKey}:${session.id}:${recoveryAttempts}`;
const pauseCause = `session_recovery_failed:${session.id}:${recoveryAttempts}`;
await this.db.transaction(async (tx) => {
await tx.execute(sql`SELECT 1 FROM ${runs} WHERE ${runs.id} = ${session.runId} FOR UPDATE`);
const [run] = await tx
.select({ state: runs.state })
.from(runs)
.where(eq(runs.id, session.runId))
.limit(1);
await tx
.update(tuiSessions)
.set({ state: "FAILED_NEEDS_HUMAN", recoveryAttempts })
.where(eq(tuiSessions.id, session.id));
await eventRepository.appendInTransaction(tx, {
runId: session.runId,
type: "session.failed",
payload: { sessionId: session.id, roleId: session.roleId },
idempotencyKey: `session.failed:${session.id}`,
});
if (run === undefined || isTerminalRunState(run.state)) {
return;
}
const inserted = await tx
.insert(approvalRequests)
.values({
runId: session.runId,
gateKey,
state: "pending",
idempotencyKey: approvalIdempotencyKey,
payload: {
sessionId: session.id,
roleId: session.roleId,
backend: session.backend,
cwd: session.cwd,
recoveryHint: recoveryHintFor(error),
},
})
.onConflictDoNothing({ target: approvalRequests.idempotencyKey })
.returning({ id: approvalRequests.id, idempotencyKey: approvalRequests.idempotencyKey });
if (run.state !== "paused") {
await tx
.update(runs)
.set({ state: "paused", pausedFromState: run.state, updatedAt: new Date() })
.where(eq(runs.id, session.runId));
await eventRepository.appendInTransaction(tx, {
runId: session.runId,
type: "run.paused",
payload: { cause: pauseCause, pausedFromState: run.state },
idempotencyKey: `run.paused:${session.runId}:${pauseCause}`,
});
}
const request =
inserted[0] ??
(
await tx
.select({ id: approvalRequests.id, idempotencyKey: approvalRequests.idempotencyKey })
.from(approvalRequests)
.where(eq(approvalRequests.idempotencyKey, approvalIdempotencyKey))
.limit(1)
)[0];
if (request !== undefined) {
await eventRepository.appendInTransaction(tx, {
runId: session.runId,
type: "approval.requested",
payload: {
approvalRequestId: request.id,
approvalIdempotencyKey: request.idempotencyKey,
gateKey,
},
idempotencyKey: `approval.requested:${request.idempotencyKey}`,
});
}
});
}
private async resumeWithRetry(handle: SessionHandle): Promise<SessionHandle> {
let lastError: unknown;
for (let attempt = 0; attempt <= 2; attempt += 1) {
try {
return await this.track(this.adapter.resume(handle));
} catch (error) {
lastError = error;
if (!(error instanceof DevflowError) || error.class !== "recoverable") {
throw error;
}
}
}
throw lastError;
}
private async track<T>(operation: Promise<T>): Promise<T> {
const tracked = operation.finally(() => {
this.inFlight.delete(tracked);
});
this.inFlight.add(tracked);
return tracked;
}
private beginTrackedOperation(): () => void {
let finishOperation!: () => void;
const tracked = new Promise<void>((resolve) => {
finishOperation = resolve;
}).finally(() => {
this.inFlight.delete(tracked);
});
this.inFlight.add(tracked);
return finishOperation;
}
private async waitForInFlight(): Promise<void> {
if (this.inFlight.size === 0) {
return;
}
await Promise.race([
Promise.allSettled([...this.inFlight]),
new Promise((resolveWait) => setTimeout(resolveWait, this.shutdownDrainMs)),
]);
}
private handleFor(handle: SessionHandle): SessionHandle {
return this.handles.get(handle.sessionId) ?? handle;
}
private assertAcceptingPrompts(): void {
if (this.draining) {
throw new DevflowError("SessionManager is draining", {
class: "human_required",
code: "session_manager_draining",
});
}
}
}
const terminalRunStates = ["completed", "failed", "aborted"] as const;
function isTerminalRunState(state: string): state is (typeof terminalRunStates)[number] {
return terminalRunStates.includes(state as (typeof terminalRunStates)[number]);
}
function compactHandle(
sessionId: string,
pid: number | null,
tmuxSession: string | null,
tmuxWindow: string | null,
): SessionHandle {
return {
sessionId,
...(pid === null ? {} : { pid }),
...(tmuxSession === null ? {} : { tmuxSession }),
...(tmuxWindow === null ? {} : { tmuxWindow }),
};
}
function recoveryHintFor(error: unknown): string {
if (error instanceof DevflowError && error.recoveryHint !== undefined) {
return error.recoveryHint;
}
if (error instanceof Error) {
return error.message;
}
return "session resume failed";
}

View File

@@ -6,5 +6,5 @@
"types": ["node", "vitest"]
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../core" }]
"references": [{ "path": "../core" }, { "path": "../db" }]
}

16
pnpm-lock.yaml generated
View File

@@ -58,6 +58,21 @@ importers:
specifier: 2.1.8
version: 2.1.8(@types/node@22.10.2)
apps/api:
dependencies:
'@devflow/core':
specifier: workspace:*
version: link:../../packages/core
'@devflow/db':
specifier: workspace:*
version: link:../../packages/db
'@devflow/run-engine':
specifier: workspace:*
version: link:../../packages/run-engine
'@devflow/session':
specifier: workspace:*
version: link:../../packages/session
apps/cli:
dependencies:
commander:
@@ -120,7 +135,6 @@ importers:
'@devflow/core':
specifier: workspace:*
version: link:../core
devDependencies:
'@devflow/db':
specifier: workspace:*
version: link:../db

View File

@@ -0,0 +1,24 @@
{
"phases": [
{
"key": "spec",
"title": "Colliding planned phase",
"objective": "Prove planned phases cannot reuse template phase keys.",
"roles": ["spec_writer"],
"expectedArtifact": {
"path": "artifacts/colliding-spec.json",
"schema": "dev/spec@1"
},
"gates": [],
"tasks": [
{
"id": "TASK-1",
"title": "Colliding task",
"role": "spec_writer",
"writeSet": ["src/**"],
"dependsOn": []
}
]
}
]
}

View File

@@ -0,0 +1,24 @@
{
"phases": [
{
"key": "mixed-unbound",
"title": "Mixed bound and unbound planned phase",
"objective": "Prove every planned phase role is validated before insertion.",
"roles": ["spec_writer", "missing_role"],
"expectedArtifact": {
"path": "artifacts/mixed-unbound-spec.json",
"schema": "dev/spec@1"
},
"gates": [],
"tasks": [
{
"id": "TASK-1",
"title": "Impossible mixed-role task",
"role": "missing_role",
"writeSet": ["src/**"],
"dependsOn": []
}
]
}
]
}

View File

@@ -0,0 +1,24 @@
{
"phases": [
{
"key": "implement",
"title": "Implement requested change",
"objective": "Use the fake development run to prove the engine path.",
"roles": ["spec_writer"],
"expectedArtifact": {
"path": "artifacts/implementation-spec.json",
"schema": "dev/spec@1"
},
"gates": [],
"tasks": [
{
"id": "TASK-1",
"title": "Apply implementation",
"role": "spec_writer",
"writeSet": ["src/**"],
"dependsOn": []
}
]
}
]
}

View File

@@ -0,0 +1,11 @@
{
"phases": [
{
"key": "documentation-note",
"title": "Documentation Note",
"objective": "Record that this planned phase has no artifact and should be skipped.",
"roles": ["phase_planner"],
"gates": []
}
]
}

View File

@@ -0,0 +1,44 @@
{
"phases": [
{
"key": "implement-a",
"title": "Implement first fake change",
"objective": "First planned phase for replay serialization tests.",
"roles": ["spec_writer"],
"expectedArtifact": {
"path": "artifacts/implementation-a-spec.json",
"schema": "dev/spec@1"
},
"gates": [],
"tasks": [
{
"id": "TASK-A",
"title": "Apply first implementation",
"role": "spec_writer",
"writeSet": ["src/a/**"],
"dependsOn": []
}
]
},
{
"key": "implement-b",
"title": "Implement second fake change",
"objective": "Second planned phase for replay serialization tests.",
"roles": ["spec_writer"],
"expectedArtifact": {
"path": "artifacts/implementation-b-spec.json",
"schema": "dev/spec@1"
},
"gates": [],
"tasks": [
{
"id": "TASK-B",
"title": "Apply second implementation",
"role": "spec_writer",
"writeSet": ["src/b/**"],
"dependsOn": ["TASK-A"]
}
]
}
]
}

View File

@@ -0,0 +1,24 @@
{
"phases": [
{
"key": "unbound",
"title": "Unbound planned phase",
"objective": "Prove approval-triggered advancement fails terminally on invalid plan roles.",
"roles": ["missing_role"],
"expectedArtifact": {
"path": "artifacts/unbound-spec.json",
"schema": "dev/spec@1"
},
"gates": [],
"tasks": [
{
"id": "TASK-1",
"title": "Impossible task",
"role": "missing_role",
"writeSet": ["src/**"],
"dependsOn": []
}
]
}
]
}

View File

@@ -0,0 +1,24 @@
{
"phases": [
{
"key": "unknown-schema",
"title": "Unknown schema planned phase",
"objective": "Prove fatal planned-phase failures clean up every session.",
"roles": ["spec_writer"],
"expectedArtifact": {
"path": "artifacts/unknown-schema.json",
"schema": "dev/unknown-schema@1"
},
"gates": [],
"tasks": [
{
"id": "TASK-1",
"title": "Write unknown schema artifact",
"role": "spec_writer",
"writeSet": ["src/**"],
"dependsOn": []
}
]
}
]
}

View File

@@ -10,6 +10,7 @@
{ "path": "./packages/db" },
{ "path": "./packages/run-engine" },
{ "path": "./packages/session" },
{ "path": "./apps/api" },
{ "path": "./apps/cli" }
]
}

View File

@@ -27,5 +27,6 @@ export default defineWorkspace([
nodeProject("packages/core", ["packages/core/src/**/*.test.ts"]),
nodeProject("packages/session", ["packages/session/src/**/*.test.ts"]),
nodeProject("packages/run-engine", ["packages/run-engine/src/**/*.test.ts"]),
nodeProject("apps/api", ["apps/api/src/**/*.test.ts"]),
nodeProject("apps/cli", ["apps/cli/src/**/*.test.ts"]),
]);