feat: add real tmux session manager

This commit is contained in:
chungyeong
2026-05-13 21:44:58 +09:00
parent aa3033771a
commit ef4c56e6b0
14 changed files with 3499 additions and 76 deletions

View File

@@ -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));