feat: add temporal run engine integration
This commit is contained in:
@@ -35,6 +35,8 @@ export interface FakePhaseWaitOptions {
|
||||
timeoutMs?: number;
|
||||
pollIntervalMs?: number;
|
||||
stableMs?: number;
|
||||
signal?: AbortSignal;
|
||||
onPoll?: () => void;
|
||||
}
|
||||
|
||||
interface ArtifactWaitOptions extends FakePhaseWaitOptions {
|
||||
@@ -63,6 +65,7 @@ export type RunSingleFakePhaseInput = RunSingleFakePhaseBaseInput &
|
||||
({ sessions: SessionRuntime; adapter?: never } | { adapter: SessionAdapter; sessions?: never });
|
||||
|
||||
type CanonicalRunSingleFakePhaseInput = RunSingleFakePhaseBaseInput & {
|
||||
reserveSessionId?: () => string;
|
||||
sessions: SessionRuntime;
|
||||
};
|
||||
|
||||
@@ -81,11 +84,17 @@ const sendPromptRetryBudget = 2;
|
||||
const terminalRunStates = ["completed", "failed", "aborted"] as const;
|
||||
const phaseMutationRunStates = ["executing", "planning"] as const;
|
||||
|
||||
interface SessionIdReservable {
|
||||
reserveSessionId(): string;
|
||||
}
|
||||
|
||||
interface PhaseEntry {
|
||||
attempt: number;
|
||||
continueArtifactWait: boolean;
|
||||
continueValidation: boolean;
|
||||
artifactBaselineSignature?: string | undefined;
|
||||
promptId?: string;
|
||||
recordPromptEventOnReplay?: boolean;
|
||||
repairAttemptUsed: boolean;
|
||||
replayedOutcome?: ArtifactOutcome;
|
||||
resumedPrompt: boolean;
|
||||
@@ -106,8 +115,19 @@ function canonicalizeRunSingleFakePhaseInput(
|
||||
"sessions" in input && input.sessions !== undefined
|
||||
? input.sessions
|
||||
: new SessionManager({ db: input.db, adapter: input.adapter });
|
||||
const adapter = "adapter" in input ? input.adapter : undefined;
|
||||
const reserveSessionId =
|
||||
adapter !== undefined && isSessionIdReservable(adapter)
|
||||
? () => adapter.reserveSessionId()
|
||||
: undefined;
|
||||
|
||||
return { ...input, expectedArtifactPath, sessions, worktreeRoot };
|
||||
return {
|
||||
...input,
|
||||
expectedArtifactPath,
|
||||
...(reserveSessionId === undefined ? {} : { reserveSessionId }),
|
||||
sessions,
|
||||
worktreeRoot,
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalizePathAgainstWorktree(
|
||||
@@ -140,6 +160,15 @@ function canonicalizePossiblyMissingPath(path: string): string {
|
||||
return resolve(realpathSync(current), ...missingSegments);
|
||||
}
|
||||
|
||||
function isSessionIdReservable(
|
||||
adapter: SessionAdapter,
|
||||
): adapter is SessionAdapter & SessionIdReservable {
|
||||
return (
|
||||
"reserveSessionId" in adapter &&
|
||||
typeof (adapter as Partial<SessionIdReservable>).reserveSessionId === "function"
|
||||
);
|
||||
}
|
||||
|
||||
export async function runSingleFakePhase(
|
||||
rawInput: RunSingleFakePhaseInput,
|
||||
): Promise<RunSingleFakePhaseResult> {
|
||||
@@ -184,10 +213,14 @@ export async function runSingleFakePhase(
|
||||
} else if (phaseEntry.continueArtifactWait) {
|
||||
promptId = requirePhaseEntryPromptId(input, phaseEntry, "Artifact wait replay");
|
||||
promptDedupKeyForIdle = promptId;
|
||||
promptSend = { promptId, artifactBaselineSignature: undefined };
|
||||
if (phaseEntry.recordPromptEventOnReplay === true) {
|
||||
await recordPromptEventIfMissing(input, eventRepository, promptEventType, envelope);
|
||||
}
|
||||
promptSend = { promptId, artifactBaselineSignature: phaseEntry.artifactBaselineSignature };
|
||||
} else if (phaseEntry.continueValidation) {
|
||||
promptId = requirePhaseEntryPromptId(input, phaseEntry, "Artifact validation replay");
|
||||
promptDedupKeyForIdle = promptId;
|
||||
await recordPromptEventIfMissing(input, eventRepository, promptEventType, envelope);
|
||||
} else {
|
||||
try {
|
||||
promptSend = await sendPromptAndRecord(
|
||||
@@ -250,6 +283,10 @@ export async function runSingleFakePhase(
|
||||
await captureTranscript(input, handle);
|
||||
throw error;
|
||||
}
|
||||
if (isActivityCancelled(error)) {
|
||||
await captureTranscript(input, handle);
|
||||
throw error;
|
||||
}
|
||||
if (!isDevflowErrorWithCode(error, "artifact_timeout_exhausted")) {
|
||||
await failRunAndDisposeSession(
|
||||
input,
|
||||
@@ -415,6 +452,10 @@ export async function runSingleFakePhase(
|
||||
await captureTranscript(input, handle);
|
||||
throw repairError;
|
||||
}
|
||||
if (isActivityCancelled(repairError)) {
|
||||
await captureTranscript(input, handle);
|
||||
throw repairError;
|
||||
}
|
||||
if (!isDevflowErrorWithCode(repairError, "artifact_timeout_exhausted")) {
|
||||
await failRunAndDisposeSession(
|
||||
input,
|
||||
@@ -565,6 +606,10 @@ export async function runSingleFakePhase(
|
||||
await captureTranscript(input, handle);
|
||||
throw error;
|
||||
}
|
||||
if (isActivityCancelled(error)) {
|
||||
await captureTranscript(input, handle);
|
||||
throw error;
|
||||
}
|
||||
if (!isDevflowErrorWithCode(error, "artifact_timeout_exhausted")) {
|
||||
await failRunAndDisposeSession(
|
||||
input,
|
||||
@@ -711,13 +756,31 @@ async function enterInitialPhase(
|
||||
};
|
||||
}
|
||||
if (["CREATED", "BOOTSTRAPPING", "READY"].includes(session.state)) {
|
||||
const promptEventAlreadyRecorded = await promptEventExists(
|
||||
input,
|
||||
phaseStart.repairAttemptUsed ? "prompt.repaired" : "prompt.sent",
|
||||
envelope.dedupKey,
|
||||
);
|
||||
if (
|
||||
promptEventAlreadyRecorded &&
|
||||
(await artifactSignature(input.expectedArtifactPath)) !== undefined
|
||||
) {
|
||||
return {
|
||||
attempt: phase.attempts,
|
||||
continueArtifactWait: false,
|
||||
continueValidation: true,
|
||||
promptId: envelope.dedupKey,
|
||||
repairAttemptUsed: phaseStart.repairAttemptUsed,
|
||||
resumedPrompt: true,
|
||||
handle: { sessionId: session.id },
|
||||
};
|
||||
}
|
||||
return {
|
||||
attempt: phase.attempts,
|
||||
continueArtifactWait: false,
|
||||
continueValidation: false,
|
||||
repairAttemptUsed: phaseStart.repairAttemptUsed,
|
||||
resumedPrompt: false,
|
||||
handle: { sessionId: session.id },
|
||||
};
|
||||
}
|
||||
if (
|
||||
@@ -726,10 +789,29 @@ async function enterInitialPhase(
|
||||
session.expectedArtifactPath === input.expectedArtifactPath &&
|
||||
session.expectedSchema === input.expectedSchema
|
||||
) {
|
||||
if (
|
||||
!(await promptEventExists(
|
||||
input,
|
||||
phaseStart.repairAttemptUsed ? "prompt.repaired" : "prompt.sent",
|
||||
envelope.dedupKey,
|
||||
))
|
||||
) {
|
||||
return {
|
||||
attempt: phase.attempts,
|
||||
continueArtifactWait: true,
|
||||
continueValidation: false,
|
||||
artifactBaselineSignature: await artifactSignature(input.expectedArtifactPath),
|
||||
promptId: envelope.dedupKey,
|
||||
repairAttemptUsed: phaseStart.repairAttemptUsed,
|
||||
resumedPrompt: true,
|
||||
handle: { sessionId: session.id },
|
||||
};
|
||||
}
|
||||
return {
|
||||
attempt: phase.attempts,
|
||||
continueArtifactWait: false,
|
||||
continueArtifactWait: true,
|
||||
continueValidation: false,
|
||||
promptId: session.lastPromptHash,
|
||||
repairAttemptUsed: phaseStart.repairAttemptUsed,
|
||||
resumedPrompt: true,
|
||||
handle: { sessionId: session.id },
|
||||
@@ -764,11 +846,21 @@ async function enterInitialPhase(
|
||||
session.expectedArtifactPath === input.expectedArtifactPath &&
|
||||
session.expectedSchema === input.expectedSchema
|
||||
) {
|
||||
const currentPromptEventExists = await promptEventExists(
|
||||
input,
|
||||
phaseStart.repairAttemptUsed ? "prompt.repaired" : "prompt.sent",
|
||||
envelope.dedupKey,
|
||||
);
|
||||
const artifactWaitEventExists = await artifactExpectedEventExists(input, phase.attempts);
|
||||
return {
|
||||
attempt: phase.attempts,
|
||||
continueArtifactWait: true,
|
||||
continueValidation: false,
|
||||
...(currentPromptEventExists || !artifactWaitEventExists
|
||||
? {}
|
||||
: { artifactBaselineSignature: await artifactSignature(input.expectedArtifactPath) }),
|
||||
promptId: session.lastPromptHash,
|
||||
recordPromptEventOnReplay: !currentPromptEventExists && !artifactWaitEventExists,
|
||||
repairAttemptUsed: phaseStart.repairAttemptUsed,
|
||||
resumedPrompt: true,
|
||||
handle: { sessionId: session.id },
|
||||
@@ -1166,6 +1258,19 @@ async function failPhaseAndRequestGate(
|
||||
}
|
||||
|
||||
if (sessionId !== undefined) {
|
||||
await tx
|
||||
.insert(tuiSessions)
|
||||
.values({
|
||||
id: sessionId,
|
||||
runId: input.runId,
|
||||
roleId: input.roleId,
|
||||
backend: "fake",
|
||||
cwd: input.worktreeRoot,
|
||||
expectedArtifactPath: input.expectedArtifactPath,
|
||||
expectedSchema: input.expectedSchema,
|
||||
state: "FAILED_NEEDS_HUMAN",
|
||||
})
|
||||
.onConflictDoNothing({ target: tuiSessions.id });
|
||||
await tx
|
||||
.update(tuiSessions)
|
||||
.set({ state: "FAILED_NEEDS_HUMAN" })
|
||||
@@ -1437,15 +1542,22 @@ async function startSessionAndRecord(
|
||||
eventRepository: RunEventRepository,
|
||||
attempt: number,
|
||||
): Promise<SessionHandle> {
|
||||
const existingHandle = await resumeExistingSessionAndRecord(input, eventRepository, attempt);
|
||||
if (existingHandle !== undefined) {
|
||||
return existingHandle;
|
||||
const existingSession = await sessionForRole(input);
|
||||
if (
|
||||
existingSession !== undefined &&
|
||||
!["CREATED", "BOOTSTRAPPING"].includes(existingSession.state)
|
||||
) {
|
||||
const existingHandle = await resumeExistingSessionAndRecord(input, eventRepository, attempt);
|
||||
if (existingHandle !== undefined) {
|
||||
return existingHandle;
|
||||
}
|
||||
}
|
||||
|
||||
const sessionId = existingSession?.id ?? input.reserveSessionId?.() ?? randomUUID();
|
||||
let handle: SessionHandle | undefined;
|
||||
let sessionRowPersisted = false;
|
||||
try {
|
||||
handle = await input.sessions.start({
|
||||
sessionId,
|
||||
runId: input.runId,
|
||||
roleId: input.roleId,
|
||||
backend: "fake",
|
||||
@@ -1454,10 +1566,18 @@ async function startSessionAndRecord(
|
||||
expectedSchema: input.expectedSchema,
|
||||
});
|
||||
const startedHandle = handle;
|
||||
let sessionInsertConflicted = false;
|
||||
if (startedHandle.sessionId !== sessionId) {
|
||||
throw new DevflowError("Session adapter did not honor reserved session id", {
|
||||
class: "fatal",
|
||||
code: "internal_state_corruption",
|
||||
runId: input.runId,
|
||||
phaseId: input.phaseId,
|
||||
recoveryHint: `expected=${sessionId};actual=${startedHandle.sessionId}`,
|
||||
});
|
||||
}
|
||||
await input.db.transaction(async (tx) => {
|
||||
await assertRunCanMutatePhaseInTransaction(input, tx);
|
||||
const insertedSession = await tx
|
||||
await tx
|
||||
.insert(tuiSessions)
|
||||
.values({
|
||||
id: startedHandle.sessionId,
|
||||
@@ -1467,14 +1587,9 @@ async function startSessionAndRecord(
|
||||
cwd: input.worktreeRoot,
|
||||
expectedArtifactPath: input.expectedArtifactPath,
|
||||
expectedSchema: input.expectedSchema,
|
||||
state: "CREATED",
|
||||
state: "BOOTSTRAPPING",
|
||||
})
|
||||
.onConflictDoNothing({ target: [tuiSessions.runId, tuiSessions.roleId] })
|
||||
.returning({ id: tuiSessions.id });
|
||||
if (insertedSession[0] === undefined) {
|
||||
sessionInsertConflicted = true;
|
||||
return;
|
||||
}
|
||||
.onConflictDoNothing({ target: tuiSessions.id });
|
||||
await eventRepository.appendInTransaction(tx, {
|
||||
runId: input.runId,
|
||||
phaseId: input.phaseId,
|
||||
@@ -1498,21 +1613,6 @@ async function startSessionAndRecord(
|
||||
idempotencyKey: `session.ready:${startedHandle.sessionId}:0`,
|
||||
});
|
||||
});
|
||||
if (sessionInsertConflicted) {
|
||||
await input.sessions.dispose(startedHandle).catch(() => undefined);
|
||||
handle = undefined;
|
||||
const existingHandle = await resumeExistingSessionAndRecord(input, eventRepository, attempt);
|
||||
if (existingHandle !== undefined) {
|
||||
return existingHandle;
|
||||
}
|
||||
throw new DevflowError("Concurrent fake session insert conflicted without an existing row", {
|
||||
class: "fatal",
|
||||
code: "internal_state_corruption",
|
||||
runId: input.runId,
|
||||
phaseId: input.phaseId,
|
||||
});
|
||||
}
|
||||
sessionRowPersisted = true;
|
||||
return startedHandle;
|
||||
} catch (error) {
|
||||
if (handle !== undefined) {
|
||||
@@ -1531,19 +1631,35 @@ async function startSessionAndRecord(
|
||||
"session_start_failed",
|
||||
gateError.code,
|
||||
{ errorCode: error.code, recoveryHint: gateError.recoveryHint },
|
||||
sessionRowPersisted ? handle?.sessionId : undefined,
|
||||
sessionId,
|
||||
);
|
||||
throw gateError;
|
||||
}
|
||||
|
||||
await failPhaseAndRun(input, eventRepository, attempt, "session_start_failed");
|
||||
if (sessionRowPersisted && handle !== undefined) {
|
||||
await markSessionFailedNeedsHuman(input, eventRepository, handle.sessionId);
|
||||
await markSessionFailedNeedsHuman(input, eventRepository, sessionId);
|
||||
if (handle !== undefined) {
|
||||
await input.sessions.dispose(handle).catch(() => undefined);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function sessionForRole(input: CanonicalRunSingleFakePhaseInput): Promise<
|
||||
| {
|
||||
id: string;
|
||||
state: string;
|
||||
}
|
||||
| undefined
|
||||
> {
|
||||
const [session] = await input.db
|
||||
.select({ id: tuiSessions.id, state: tuiSessions.state })
|
||||
.from(tuiSessions)
|
||||
.where(and(eq(tuiSessions.runId, input.runId), eq(tuiSessions.roleId, input.roleId)))
|
||||
.limit(1);
|
||||
return session;
|
||||
}
|
||||
|
||||
async function resumeExistingSessionAndRecord(
|
||||
input: CanonicalRunSingleFakePhaseInput,
|
||||
eventRepository: RunEventRepository,
|
||||
@@ -1709,6 +1825,14 @@ async function sendPromptAndRecord(
|
||||
type: "prompt.sent" | "prompt.repaired",
|
||||
options: SendPromptAndRecordOptions = {},
|
||||
): Promise<PromptSendRecord> {
|
||||
await input.db.transaction(async (tx) => {
|
||||
await assertRunCanMutatePhaseInTransaction(input, tx);
|
||||
});
|
||||
|
||||
const artifactBaselineSignature =
|
||||
options.captureArtifactBaseline === false
|
||||
? undefined
|
||||
: await artifactSignature(input.expectedArtifactPath);
|
||||
await input.db.transaction(async (tx) => {
|
||||
await assertRunCanMutatePhaseInTransaction(input, tx);
|
||||
await tx
|
||||
@@ -1730,11 +1854,6 @@ async function sendPromptAndRecord(
|
||||
idempotencyKey: `session.busy:${handle.sessionId}:${envelope.dedupKey}`,
|
||||
});
|
||||
});
|
||||
|
||||
const artifactBaselineSignature =
|
||||
options.captureArtifactBaseline === false
|
||||
? undefined
|
||||
: await artifactSignature(input.expectedArtifactPath);
|
||||
const prompt = await sendPromptWithRetry(input.sessions, handle, envelope);
|
||||
await input.db.transaction(async (tx) => {
|
||||
await assertRunCanMutatePhaseInTransaction(input, tx);
|
||||
@@ -1750,6 +1869,66 @@ async function sendPromptAndRecord(
|
||||
return { promptId: prompt.promptId, artifactBaselineSignature };
|
||||
}
|
||||
|
||||
async function recordPromptEventIfMissing(
|
||||
input: CanonicalRunSingleFakePhaseInput,
|
||||
eventRepository: RunEventRepository,
|
||||
type: "prompt.sent" | "prompt.repaired",
|
||||
envelope: PromptEnvelope,
|
||||
): Promise<void> {
|
||||
await input.db.transaction(async (tx) => {
|
||||
await assertRunCanMutatePhaseInTransaction(input, tx);
|
||||
await eventRepository.appendInTransaction(tx, {
|
||||
runId: input.runId,
|
||||
phaseId: input.phaseId,
|
||||
type,
|
||||
payload: { roleId: input.roleId, dedupKey: envelope.dedupKey },
|
||||
idempotencyKey: `${type}:${envelope.dedupKey}`,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function promptEventExists(
|
||||
input: CanonicalRunSingleFakePhaseInput,
|
||||
type: "prompt.sent" | "prompt.repaired",
|
||||
dedupKey: string,
|
||||
): Promise<boolean> {
|
||||
const [event] = await input.db
|
||||
.select({ id: runEvents.id })
|
||||
.from(runEvents)
|
||||
.where(
|
||||
and(
|
||||
eq(runEvents.runId, input.runId),
|
||||
eq(runEvents.phaseId, input.phaseId),
|
||||
eq(runEvents.type, type),
|
||||
eq(runEvents.idempotencyKey, `${type}:${dedupKey}`),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return event !== undefined;
|
||||
}
|
||||
|
||||
async function artifactExpectedEventExists(
|
||||
input: CanonicalRunSingleFakePhaseInput,
|
||||
attempt: number,
|
||||
): Promise<boolean> {
|
||||
const [event] = await input.db
|
||||
.select({ id: runEvents.id })
|
||||
.from(runEvents)
|
||||
.where(
|
||||
and(
|
||||
eq(runEvents.runId, input.runId),
|
||||
eq(runEvents.phaseId, input.phaseId),
|
||||
eq(runEvents.type, "artifact.expected"),
|
||||
eq(
|
||||
runEvents.idempotencyKey,
|
||||
`artifact.expected:${input.phaseId}:${attempt}:${input.expectedArtifactPath}`,
|
||||
),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return event !== undefined;
|
||||
}
|
||||
|
||||
async function sendPromptWithRetry(
|
||||
sessions: SessionRuntime,
|
||||
handle: { sessionId: string },
|
||||
@@ -2163,6 +2342,19 @@ async function markSessionFailedNeedsHuman(
|
||||
eventRepository: RunEventRepository,
|
||||
sessionId: string,
|
||||
) {
|
||||
await input.db
|
||||
.insert(tuiSessions)
|
||||
.values({
|
||||
id: sessionId,
|
||||
runId: input.runId,
|
||||
roleId: input.roleId,
|
||||
backend: "fake",
|
||||
cwd: input.worktreeRoot,
|
||||
expectedArtifactPath: input.expectedArtifactPath,
|
||||
expectedSchema: input.expectedSchema,
|
||||
state: "FAILED_NEEDS_HUMAN",
|
||||
})
|
||||
.onConflictDoNothing({ target: tuiSessions.id });
|
||||
await input.db
|
||||
.update(tuiSessions)
|
||||
.set({ state: "FAILED_NEEDS_HUMAN" })
|
||||
@@ -2223,12 +2415,14 @@ async function waitForArtifact(path: string, options: ArtifactWaitOptions = {}):
|
||||
let stableSince: number | undefined;
|
||||
|
||||
while (Date.now() <= deadline) {
|
||||
throwIfAborted(options.signal);
|
||||
options.onPoll?.();
|
||||
try {
|
||||
const signature = await artifactSignature(path);
|
||||
if (signature === undefined || signature === ignoreInitialSignature) {
|
||||
lastSignature = undefined;
|
||||
stableSince = undefined;
|
||||
await sleep(pollIntervalMs);
|
||||
await sleep(pollIntervalMs, options.signal);
|
||||
continue;
|
||||
}
|
||||
if (lastSignature === signature) {
|
||||
@@ -2259,7 +2453,7 @@ async function waitForArtifact(path: string, options: ArtifactWaitOptions = {}):
|
||||
});
|
||||
}
|
||||
}
|
||||
await sleep(pollIntervalMs);
|
||||
await sleep(pollIntervalMs, options.signal);
|
||||
}
|
||||
|
||||
throw new DevflowError("Timed out waiting for fake phase artifact", {
|
||||
@@ -2427,6 +2621,10 @@ function isDevflowErrorWithCode(error: unknown, code: string): error is DevflowE
|
||||
return error instanceof DevflowError && error.code === code;
|
||||
}
|
||||
|
||||
function isActivityCancelled(error: unknown): error is DevflowError {
|
||||
return isDevflowErrorWithCode(error, "activity_cancelled");
|
||||
}
|
||||
|
||||
function isRunStateChanged(error: unknown): error is DevflowError {
|
||||
return isDevflowErrorWithCode(error, "run_state_changed");
|
||||
}
|
||||
@@ -2523,8 +2721,37 @@ async function captureTranscript(
|
||||
});
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
if (signal === undefined) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
throwIfAborted(signal);
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
const onAbort = () => {
|
||||
clearTimeout(timeout);
|
||||
reject(activityCancelledError(signal.reason));
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function throwIfAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) {
|
||||
throw activityCancelledError(signal.reason);
|
||||
}
|
||||
}
|
||||
|
||||
function activityCancelledError(cause: unknown): DevflowError {
|
||||
return new DevflowError("Activity was cancelled before artifact wait completed", {
|
||||
class: "recoverable",
|
||||
code: "activity_cancelled",
|
||||
cause,
|
||||
});
|
||||
}
|
||||
|
||||
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
||||
|
||||
Reference in New Issue
Block a user