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

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";