feat: add minimal run engine
This commit is contained in:
1098
packages/run-engine/src/engine.test.ts
Normal file
1098
packages/run-engine/src/engine.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
2336
packages/run-engine/src/engine.ts
Normal file
2336
packages/run-engine/src/engine.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -1,2 +1,3 @@
|
||||
export * from "./engine.js";
|
||||
export * from "./fake-phase-harness.js";
|
||||
export * from "./run-event-repository.js";
|
||||
|
||||
Reference in New Issue
Block a user