feat: add real tmux session manager
This commit is contained in:
@@ -22,6 +22,7 @@ import {
|
||||
tuiSessions,
|
||||
} from "@devflow/db";
|
||||
import {
|
||||
type ProbeResult,
|
||||
type SessionAdapter,
|
||||
type SessionHandle,
|
||||
SessionManager,
|
||||
@@ -253,7 +254,6 @@ export async function runSingleFakePhase(
|
||||
throw gateError;
|
||||
}
|
||||
await failRunAndDisposeSession(input, eventRepository, attempt, "prompt_send_failed", handle);
|
||||
await captureTranscript(input, handle);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -295,7 +295,6 @@ export async function runSingleFakePhase(
|
||||
"artifact_validation_failed",
|
||||
handle,
|
||||
);
|
||||
await captureTranscript(input, handle);
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -332,7 +331,6 @@ export async function runSingleFakePhase(
|
||||
"artifact_timeout_recovery_failed",
|
||||
handle,
|
||||
);
|
||||
await captureTranscript(input, handle);
|
||||
throw recoveryError;
|
||||
}
|
||||
if (!recovered) {
|
||||
@@ -387,7 +385,6 @@ export async function runSingleFakePhase(
|
||||
"stale_artifact_remove_failed",
|
||||
handle,
|
||||
);
|
||||
await captureTranscript(input, handle);
|
||||
throw error;
|
||||
}
|
||||
const timeoutRepairEnvelope = buildEnvelope(
|
||||
@@ -417,7 +414,6 @@ export async function runSingleFakePhase(
|
||||
"prompt_send_failed",
|
||||
handle,
|
||||
);
|
||||
await captureTranscript(input, handle);
|
||||
throw repairError;
|
||||
}
|
||||
const gateError = toHumanRequiredRecoveryError(repairError);
|
||||
@@ -464,7 +460,6 @@ export async function runSingleFakePhase(
|
||||
"artifact_repair_failed",
|
||||
handle,
|
||||
);
|
||||
await captureTranscript(input, handle);
|
||||
throw repairError;
|
||||
}
|
||||
await failPhaseAndRequestGate(
|
||||
@@ -542,7 +537,6 @@ export async function runSingleFakePhase(
|
||||
"stale_artifact_remove_failed",
|
||||
handle,
|
||||
);
|
||||
await captureTranscript(input, handle);
|
||||
throw error;
|
||||
}
|
||||
const repairEnvelope = buildEnvelope(
|
||||
@@ -572,7 +566,6 @@ export async function runSingleFakePhase(
|
||||
"prompt_send_failed",
|
||||
handle,
|
||||
);
|
||||
await captureTranscript(input, handle);
|
||||
throw error;
|
||||
}
|
||||
const gateError = toHumanRequiredRecoveryError(error);
|
||||
@@ -618,7 +611,6 @@ export async function runSingleFakePhase(
|
||||
"artifact_repair_failed",
|
||||
handle,
|
||||
);
|
||||
await captureTranscript(input, handle);
|
||||
throw error;
|
||||
}
|
||||
await failPhaseAndRequestGate(
|
||||
@@ -640,8 +632,10 @@ export async function runSingleFakePhase(
|
||||
}
|
||||
}
|
||||
|
||||
let transcript: Awaited<ReturnType<typeof captureTranscript>> | undefined;
|
||||
if (outcome.validation.ok) {
|
||||
if (input.workflowApprovalGateKey !== undefined) {
|
||||
transcript = await captureTranscript(input, handle);
|
||||
await requestWorkflowApproval(
|
||||
input,
|
||||
eventRepository,
|
||||
@@ -685,7 +679,7 @@ export async function runSingleFakePhase(
|
||||
});
|
||||
}
|
||||
|
||||
const transcript = await captureTranscript(input, handle);
|
||||
transcript ??= await captureTranscript(input, handle);
|
||||
|
||||
return {
|
||||
sessionId: handle.sessionId,
|
||||
@@ -1307,6 +1301,16 @@ async function failPhaseAndRun(
|
||||
attempt: number,
|
||||
reason: string,
|
||||
) {
|
||||
const sessionIdsToDispose = await markPhaseAndRunFailed(input, eventRepository, attempt, reason);
|
||||
await disposeSessionIds(input, sessionIdsToDispose);
|
||||
}
|
||||
|
||||
async function markPhaseAndRunFailed(
|
||||
input: CanonicalRunSingleFakePhaseInput,
|
||||
eventRepository: RunEventRepository,
|
||||
attempt: number,
|
||||
reason: string,
|
||||
): Promise<string[]> {
|
||||
let sessionIdsToDispose: string[] = [];
|
||||
await input.db.transaction(async (tx) => {
|
||||
await tx.execute(sql`SELECT 1 FROM ${runs} WHERE ${runs.id} = ${input.runId} FOR UPDATE`);
|
||||
@@ -1348,7 +1352,7 @@ async function failPhaseAndRun(
|
||||
input.runId,
|
||||
);
|
||||
});
|
||||
await disposeSessionIds(input.sessions, sessionIdsToDispose);
|
||||
return sessionIdsToDispose;
|
||||
}
|
||||
|
||||
async function failRunAndDisposeSession(
|
||||
@@ -1356,10 +1360,33 @@ async function failRunAndDisposeSession(
|
||||
eventRepository: RunEventRepository,
|
||||
attempt: number,
|
||||
reason: string,
|
||||
handle: { sessionId: string },
|
||||
handle: SessionHandle,
|
||||
) {
|
||||
await failPhaseAndRun(input, eventRepository, attempt, reason);
|
||||
await input.sessions.dispose(handle).catch(() => undefined);
|
||||
const sessionIdsToDispose = await markPhaseAndRunFailed(input, eventRepository, attempt, reason);
|
||||
let captureError: unknown;
|
||||
try {
|
||||
await captureTranscript(input, handle);
|
||||
} catch (error) {
|
||||
captureError = error;
|
||||
}
|
||||
|
||||
let disposeError: unknown;
|
||||
try {
|
||||
await disposeSessionIds(input, sessionIdsToDispose);
|
||||
if (!sessionIdsToDispose.includes(handle.sessionId)) {
|
||||
await input.sessions.dispose(await sessionHandleForId(input.db, handle.sessionId, handle));
|
||||
}
|
||||
} catch (error) {
|
||||
disposeError = error;
|
||||
}
|
||||
|
||||
if (captureError !== undefined) {
|
||||
throw captureError;
|
||||
}
|
||||
|
||||
if (disposeError !== undefined) {
|
||||
throw disposeError;
|
||||
}
|
||||
}
|
||||
|
||||
async function completePhaseAndRun(
|
||||
@@ -1615,11 +1642,19 @@ async function startSessionAndRecord(
|
||||
});
|
||||
return startedHandle;
|
||||
} catch (error) {
|
||||
let disposeError: unknown;
|
||||
if (handle !== undefined) {
|
||||
await input.sessions.dispose(handle);
|
||||
try {
|
||||
await input.sessions.dispose(handle);
|
||||
} catch (cleanupError) {
|
||||
disposeError = cleanupError;
|
||||
}
|
||||
}
|
||||
|
||||
if (isRunStateChanged(error)) {
|
||||
if (disposeError !== undefined) {
|
||||
throw disposeError;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (shouldCreateHumanGate(error)) {
|
||||
@@ -1633,13 +1668,16 @@ async function startSessionAndRecord(
|
||||
{ errorCode: error.code, recoveryHint: gateError.recoveryHint },
|
||||
sessionId,
|
||||
);
|
||||
if (disposeError !== undefined) {
|
||||
throw disposeError;
|
||||
}
|
||||
throw gateError;
|
||||
}
|
||||
|
||||
await failPhaseAndRun(input, eventRepository, attempt, "session_start_failed");
|
||||
await markSessionFailedNeedsHuman(input, eventRepository, sessionId);
|
||||
if (handle !== undefined) {
|
||||
await input.sessions.dispose(handle).catch(() => undefined);
|
||||
if (disposeError !== undefined) {
|
||||
throw disposeError;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -2230,7 +2268,7 @@ async function recoverFromArtifactTimeout(
|
||||
sessionId: string,
|
||||
): Promise<boolean> {
|
||||
const probe = await probeWithTypedError(input.sessions, { sessionId });
|
||||
if (!probe.alive || !probe.paneActive) {
|
||||
if (!probe.alive || !probe.paneActive || isBackendReadinessUnknown(probe)) {
|
||||
return false;
|
||||
}
|
||||
await setSessionStateIfRunActive(input, sessionId, "RESUMING");
|
||||
@@ -2263,6 +2301,10 @@ async function recoverFromArtifactTimeout(
|
||||
return true;
|
||||
}
|
||||
|
||||
function isBackendReadinessUnknown(probe: ProbeResult): boolean {
|
||||
return probe.hint === "tmux_liveness_only";
|
||||
}
|
||||
|
||||
async function setSessionStateIfRunActive(
|
||||
input: CanonicalRunSingleFakePhaseInput,
|
||||
sessionId: string,
|
||||
@@ -2397,12 +2439,19 @@ async function markAllSessionsFailedInTransaction(
|
||||
return sessions.map((session) => session.id);
|
||||
}
|
||||
|
||||
async function disposeSessionIds(sessions: SessionRuntime, sessionIds: readonly string[]) {
|
||||
await Promise.all(
|
||||
[...new Set(sessionIds)].map((sessionId) =>
|
||||
sessions.dispose({ sessionId }).catch(() => undefined),
|
||||
),
|
||||
);
|
||||
async function disposeSessionIds(
|
||||
input: CanonicalRunSingleFakePhaseInput,
|
||||
sessionIds: readonly string[],
|
||||
) {
|
||||
if (sessionIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const handles = await sessionHandlesFromDb(input.db, sessionIds);
|
||||
const results = await Promise.allSettled(handles.map((handle) => input.sessions.dispose(handle)));
|
||||
const failed = results.find((result) => result.status === "rejected");
|
||||
if (failed !== undefined) {
|
||||
throw failed.reason;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForArtifact(path: string, options: ArtifactWaitOptions = {}): Promise<void> {
|
||||
@@ -2702,7 +2751,13 @@ async function captureTranscript(
|
||||
) {
|
||||
const sink = input.transcriptSink ?? new TuiTranscriptRepository(input.db);
|
||||
const [session] = await input.db
|
||||
.select({ lastCaptureSeq: tuiSessions.lastCaptureSeq })
|
||||
.select({
|
||||
id: tuiSessions.id,
|
||||
lastCaptureSeq: tuiSessions.lastCaptureSeq,
|
||||
lastKnownPanePid: tuiSessions.lastKnownPanePid,
|
||||
tmuxSession: tuiSessions.tmuxSession,
|
||||
tmuxWindow: tuiSessions.tmuxWindow,
|
||||
})
|
||||
.from(tuiSessions)
|
||||
.where(eq(tuiSessions.id, handle.sessionId));
|
||||
if (session === undefined) {
|
||||
@@ -2715,12 +2770,51 @@ async function captureTranscript(
|
||||
}
|
||||
return captureAndPersistTranscript({
|
||||
adapter: input.sessions,
|
||||
handle,
|
||||
handle: sessionHandleFromRow(session),
|
||||
fromSeq: session.lastCaptureSeq,
|
||||
sink,
|
||||
});
|
||||
}
|
||||
|
||||
async function sessionHandlesFromDb(
|
||||
db: DbClient["db"],
|
||||
sessionIds: readonly string[],
|
||||
): Promise<SessionHandle[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: tuiSessions.id,
|
||||
lastKnownPanePid: tuiSessions.lastKnownPanePid,
|
||||
tmuxSession: tuiSessions.tmuxSession,
|
||||
tmuxWindow: tuiSessions.tmuxWindow,
|
||||
})
|
||||
.from(tuiSessions)
|
||||
.where(inArray(tuiSessions.id, [...new Set(sessionIds)]));
|
||||
return rows.map((row) => sessionHandleFromRow(row));
|
||||
}
|
||||
|
||||
async function sessionHandleForId(
|
||||
db: DbClient["db"],
|
||||
sessionId: string,
|
||||
fallback: SessionHandle,
|
||||
): Promise<SessionHandle> {
|
||||
const [handle] = await sessionHandlesFromDb(db, [sessionId]);
|
||||
return handle ?? fallback;
|
||||
}
|
||||
|
||||
function sessionHandleFromRow(session: {
|
||||
id: string;
|
||||
lastKnownPanePid: number | null;
|
||||
tmuxSession: string | null;
|
||||
tmuxWindow: string | null;
|
||||
}): SessionHandle {
|
||||
return {
|
||||
sessionId: session.id,
|
||||
...(session.lastKnownPanePid === null ? {} : { pid: session.lastKnownPanePid }),
|
||||
...(session.tmuxSession === null ? {} : { tmuxSession: session.tmuxSession }),
|
||||
...(session.tmuxWindow === null ? {} : { tmuxWindow: session.tmuxWindow }),
|
||||
};
|
||||
}
|
||||
|
||||
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
if (signal === undefined) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
Reference in New Issue
Block a user