Files
kandev/apps/web/hooks/domains/session/use-session-resumption.ts
T
2026-09-13 21:57:10 +01:00

814 lines
26 KiB
TypeScript

/* eslint-disable max-lines -- session resumption coordinates one guarded lifecycle. */
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import {
isWebSocketRequestTimeoutError,
SESSION_ENTRY_REQUEST_TIMEOUT_MS,
SESSION_ENTRY_RETRY_DELAY_MS,
} from "@/lib/ws/client";
import { getWebSocketClient } from "@/lib/ws/connection";
import { launchSession } from "@/lib/services/session-launch-service";
import {
buildResumeRequest,
buildRestoreWorkspaceRequest,
} from "@/lib/services/session-launch-helpers";
import { useSessionRecoveryFeedback } from "./use-session-recovery-feedback";
import {
clearArchiveRecovery,
decideResumeAction,
isTaskArchivedConflict,
markSessionStarting,
resumeViaLaunch,
resumeWithSilentFallback,
TASK_ARCHIVED_KIND,
} from "./use-session-resumption-operations";
import type {
ResumptionState,
ResumeStateSetter,
SessionLike,
SessionRecoveryFailure,
SessionStatus,
TaskArchiveState,
} from "./use-session-resumption-operations";
import {
buildGuardedSetters,
isCurrentRequest,
type SessionRequestIdentity,
} from "./use-session-resumption-request-guard";
import { resolveRequestErrorMessage } from "@/lib/services/session-recovery-service";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import {
sessionId as toSessionId,
taskId as toTaskId,
type TaskSessionState,
} from "@/lib/types/http";
import { t } from "@/lib/i18n";
import {
useWorkspaceRestoration,
type WorkspaceRestorationResult,
} from "./use-workspace-restoration";
export type {
ResumptionState,
ResumeStateSetter,
ResumeStartingProjection,
SessionLike,
SessionRecoveryFailure,
SessionStatus,
TaskArchiveState,
} from "./use-session-resumption-operations";
export {
decideResumeAction,
markSessionStarting,
resumeWithSilentFallback,
} from "./use-session-resumption-operations";
type CheckAndResumeParams = {
taskId: string;
sessionId: string;
session: SessionLike;
setSessionStatus: (s: SessionStatus) => void;
setters: ResumeStateSetter;
/** True when the prevent-auto-start-on-open preference gates open-time resumes. */
preventAutoStart: boolean;
taskArchiveState: TaskArchiveState;
canContinue: () => boolean;
};
const TERMINAL_STATES = new Set<TaskSessionState>(["FAILED", "CANCELLED", "COMPLETED"]);
const MAX_SESSION_STATUS_ATTEMPTS = 2;
function sessionStatusFailure(
error: unknown,
): Extract<SessionRecoveryFailure, { outcome: "status_unavailable" }> {
return {
outcome: "status_unavailable",
kind: isWebSocketRequestTimeoutError(error) ? "timeout" : "request",
statusError: error instanceof Error && error.message ? error.message : t("common:unknownError"),
};
}
/** Apply permanent outcomes returned inside an otherwise successful status response. */
function applyStatusResponseOutcome(status: SessionStatus, setters: ResumeStateSetter): boolean {
if (status.error) {
setters.setRecoveryFailure?.(null);
setters.setResumptionState("error");
setters.setError(status.error);
setters.setNotice?.(null);
return true;
}
if (status.resume_reason === TASK_ARCHIVED_KIND) {
clearArchiveRecovery(setters);
return true;
}
return false;
}
type LiveSessionLike = (SessionLike & { state?: string }) | null;
/**
* Monotonic status-hydration guard: a `task.session.status` response must not
* downgrade a live STARTING/RUNNING/WAITING_FOR_INPUT session state (a stale
* response can race a newer `session.state_changed` WS event and leave the UI
* showing a stopped session while the agent runs — WAITING_FOR_INPUT means the
* agent is alive and awaiting the next prompt, so an older response claiming
* otherwise must not overwrite it), and must not overwrite a live TERMINAL
* state (FAILED/CANCELLED/COMPLETED — which determines the recovery
* affordances the UI shows) with an older or timestamp-less response. In both
* cases the incoming status is accepted only when its timestamp is newer than
* the live session's.
*/
function shouldApplyStatusState(status: SessionStatus, live: LiveSessionLike): boolean {
if (!status.state) return false;
const liveState = live?.state;
const liveIsProtected =
liveState === "STARTING" ||
liveState === "RUNNING" ||
liveState === "WAITING_FOR_INPUT" ||
TERMINAL_STATES.has(liveState as TaskSessionState);
if (!liveIsProtected) return true;
const liveUpdated = live?.updated_at ? Date.parse(live.updated_at) : Number.NaN;
const incomingUpdated = status.updated_at ? Date.parse(status.updated_at) : Number.NaN;
return (
Number.isFinite(liveUpdated) &&
Number.isFinite(incomingUpdated) &&
incomingUpdated > liveUpdated
);
}
/**
* Apply session status fields to local state (guarded by
* `shouldApplyStatusState` against stale downgrades).
*/
function applyStatusToState(
status: SessionStatus,
taskId: string,
sessionId: string,
session: SessionLike,
setters: ResumeStateSetter,
): void {
setters.setWorktreePath(status.worktree_path ?? null);
setters.setWorktreeBranch(status.worktree_branch ?? null);
if (!status.state) return;
const live = setters.getLiveSession?.(sessionId) ?? session;
if (!shouldApplyStatusState(status, live)) {
return; // stale or non-terminal status must not downgrade a running session
}
setters.setTaskSession({
id: toSessionId(sessionId),
task_id: toTaskId(taskId),
state: status.state as TaskSessionState,
started_at: session?.started_at ?? "",
updated_at: status.updated_at ?? session?.updated_at ?? "",
});
}
type RefreshSessionStatusParams = {
client: NonNullable<ReturnType<typeof getWebSocketClient>>;
taskId: string;
sessionId: string;
session: SessionLike;
setSessionStatus: (status: SessionStatus) => void;
setters: ResumeStateSetter;
canContinue: () => boolean;
};
function waitForSessionStatusRetry(canContinue: () => boolean): Promise<boolean> {
return new Promise((resolve) => {
const timer = window.setTimeout(() => resolve(canContinue()), SESSION_ENTRY_RETRY_DELAY_MS);
if (!canContinue()) {
window.clearTimeout(timer);
resolve(false);
}
});
}
async function requestSessionStatusWithRetry({
client,
taskId,
sessionId,
canContinue,
}: Omit<
RefreshSessionStatusParams,
"session" | "setSessionStatus" | "setters"
>): Promise<SessionStatus | null> {
for (let attempt = 0; attempt < MAX_SESSION_STATUS_ATTEMPTS; attempt += 1) {
if (!canContinue()) return null;
try {
return await client.request<SessionStatus>(
"task.session.status",
{ task_id: taskId, session_id: sessionId },
SESSION_ENTRY_REQUEST_TIMEOUT_MS,
);
} catch (error) {
const canRetry =
isWebSocketRequestTimeoutError(error) && attempt + 1 < MAX_SESSION_STATUS_ATTEMPTS;
if (!canRetry || !(await waitForSessionStatusRetry(canContinue))) throw error;
}
}
return null;
}
async function refreshSessionStatus({
client,
taskId,
sessionId,
session,
setSessionStatus,
setters,
canContinue,
}: RefreshSessionStatusParams): Promise<SessionStatus | null> {
if (!canContinue()) return null;
try {
const status = await requestSessionStatusWithRetry({
client,
taskId,
sessionId,
canContinue,
});
if (!status || !canContinue()) return null;
if (applyStatusResponseOutcome(status, setters)) return null;
setSessionStatus(status);
applyStatusToState(status, taskId, sessionId, session, setters);
// A status response confirming the agent is running must clear any
// stale resume-skipped marker (a delayed response can otherwise leave
// a Start button beside a running agent).
if (status.is_agent_running || status.state === "RUNNING") {
setters.setResumeSkipped?.(sessionId, false);
}
if (status.is_agent_running && setters.setAgentctlReady) {
setters.setAgentctlReady(sessionId);
}
return status;
} catch (err) {
if (isTaskArchivedConflict(err) || !canContinue()) {
clearArchiveRecovery(setters);
return null;
}
setters.setResumptionState("error");
setters.setError(null);
setters.setNotice?.(null);
setters.setRecoveryFailure?.(sessionStatusFailure(err));
return null;
}
}
/**
* Record the resume-skipped marker only while the live session row is not
* STARTING/RUNNING (checked here with typed live-store access so a stale
* status can never leave a Start button beside a running agent).
*/
function recordResumeSkipIfStopped(setters: ResumeStateSetter, sessionId: string): void {
const liveState = setters.getLiveSession?.(sessionId)?.state;
if (liveState !== "STARTING" && liveState !== "RUNNING") {
setters.setResumeSkipped?.(sessionId, true);
}
}
type ResumeActionParams = {
status: SessionStatus;
taskId: string;
sessionId: string;
session: SessionLike;
setters: ResumeStateSetter;
preventAutoStart: boolean;
canContinue: () => boolean;
};
async function performResumeAction({
status,
taskId,
sessionId,
session,
setters,
preventAutoStart,
canContinue,
}: ResumeActionParams): Promise<boolean> {
switch (decideResumeAction(status, preventAutoStart)) {
case "running":
setters.setResumptionState("running");
setters.setResumeSkipped?.(sessionId, false);
return false;
case "skip":
recordResumeSkipIfStopped(setters, sessionId);
setters.setResumptionState("idle");
return false;
case "resume":
return resumeWithSilentFallback(taskId, sessionId, session, setters, canContinue);
case "restore":
return resumeViaLaunch(buildRestoreWorkspaceRequest, {
taskId,
sessionId,
session,
setters,
canContinue,
});
default:
setters.setResumptionState("idle");
return false;
}
}
type ProcessResumeStatusParams = {
status: SessionStatus;
taskId: string;
sessionId: string;
session: SessionLike;
setters: ResumeStateSetter;
preventAutoStart: boolean;
canContinue: () => boolean;
};
async function processResumeStatus({
status,
taskId,
sessionId,
session,
setters,
preventAutoStart,
canContinue,
}: ProcessResumeStatusParams): Promise<boolean> {
if (applyStatusResponseOutcome(status, setters)) return false;
applyStatusToState(status, taskId, sessionId, session, setters);
if (status.is_agent_running && setters.setAgentctlReady) {
setters.setAgentctlReady(sessionId);
}
return performResumeAction({
status,
taskId,
sessionId,
session,
setters,
preventAutoStart,
canContinue,
});
}
// eslint-disable-next-line complexity -- status, restore, archive, and stale-request outcomes share one guarded transition.
async function checkAndResume({
taskId,
sessionId,
session,
setSessionStatus,
setters,
preventAutoStart,
taskArchiveState,
canContinue,
}: CheckAndResumeParams): Promise<void> {
const client = getWebSocketClient();
if (!client) return;
if (taskArchiveState !== false || !canContinue()) return;
setters.setResumptionState("checking");
setters.setError(null);
setters.setNotice?.(null);
setters.setRecoveryFailure?.(null);
let status: SessionStatus | null;
try {
status = await requestSessionStatusWithRetry({
client,
taskId,
sessionId,
canContinue,
});
} catch (err) {
if (isTaskArchivedConflict(err) || !canContinue()) {
clearArchiveRecovery(setters);
return;
}
setters.setResumptionState("error");
setters.setError(null);
setters.setNotice?.(null);
setters.setRecoveryFailure?.(sessionStatusFailure(err));
return;
}
if (!status || !canContinue()) return;
if (applyStatusResponseOutcome(status, setters)) return;
try {
setSessionStatus(status);
const resumed = await processResumeStatus({
status,
taskId,
sessionId,
session,
setters,
preventAutoStart,
canContinue,
});
if (resumed && canContinue()) {
await refreshSessionStatus({
client,
taskId,
sessionId,
session,
setSessionStatus,
setters,
canContinue,
});
}
} catch (err) {
if (isTaskArchivedConflict(err) || !canContinue()) {
clearArchiveRecovery(setters);
return;
}
setters.setResumptionState("error");
setters.setRecoveryFailure?.(null);
setters.setError(err instanceof Error ? err.message : t("common:unknownError"));
setters.setNotice?.(null);
}
}
interface UseSessionResumptionReturn {
resumptionState: ResumptionState;
sessionStatus: SessionStatus | null;
error: string | null;
notice: string | null;
recoveryFailure: SessionRecoveryFailure | null;
recoveryAttemptId: number;
taskSessionState: TaskSessionState | null;
worktreePath: string | null;
worktreeBranch: string | null;
resumeSession: () => Promise<boolean>;
retrySessionStatus: () => Promise<void>;
workspaceRestoration: WorkspaceRestorationResult;
}
/**
* Hook for handling session resumption on page reload.
* When a sessionId is provided (from URL), it checks the session status
* and automatically resumes if needed.
*/
type SessionResetAndCheckResult = {
sessionStatus: SessionStatus | null;
captureRequest: () => SessionRequestIdentity;
buildGuardedSettersFor: (capturedRequest: SessionRequestIdentity) => ResumeStateSetter;
retryStatus: () => Promise<void>;
};
type ResetAndCheckParams = {
taskId: string | null;
sessionId: string | null;
connectionStatus: string;
session: SessionLike;
setters: ResumeStateSetter;
preventAutoStart: boolean;
taskArchiveState: TaskArchiveState;
};
const getSessionRequestKey = (
taskId: string | null,
sessionId: string | null,
taskArchiveState: TaskArchiveState,
) => JSON.stringify([taskId, sessionId, taskArchiveState]);
/** Extracted effects: reset state on session/task change, auto-check/resume, and remote retry. */
// eslint-disable-next-line max-lines-per-function -- one hook owns the request identity and lifecycle effects.
function useSessionResetAndCheck({
taskId,
sessionId,
connectionStatus,
session,
setters,
preventAutoStart,
taskArchiveState,
}: ResetAndCheckParams): SessionResetAndCheckResult {
const requestKey = getSessionRequestKey(taskId, sessionId, taskArchiveState);
const [sessionStatusState, setSessionStatus] = useState<{
requestKey: string;
status: SessionStatus | null;
}>({ requestKey, status: null });
const sessionStatus =
sessionStatusState.requestKey === requestKey ? sessionStatusState.status : null;
const hasAttemptedResume = useRef(false);
const remoteStatusRetryCount = useRef(0);
const requestGenerationRef = useRef(0);
const activeRequestRef = useRef<SessionRequestIdentity>({ key: requestKey, generation: 0 });
// Publish the new identity during commit so callbacks from the previous
// request are rejected before passive effects or queued promise handlers run.
useLayoutEffect(() => {
requestGenerationRef.current += 1;
activeRequestRef.current = {
key: requestKey,
generation: requestGenerationRef.current,
};
}, [requestKey]);
// Reset all local state when session or task changes to prevent stale data
// from a previous session leaking into the new one (e.g. topbar branch).
useEffect(() => {
hasAttemptedResume.current = false;
remoteStatusRetryCount.current = 0;
setters.setResumptionState("idle");
setters.setError(null);
setters.setNotice?.(null);
setters.setRecoveryFailure?.(null);
setters.setWorktreePath(null);
setters.setWorktreeBranch(null);
}, [sessionId, taskId, taskArchiveState]); // eslint-disable-line react-hooks/exhaustive-deps -- intentional reset on dep change
// Check session status and auto-resume if needed
useEffect(() => {
if (
!taskId ||
!sessionId ||
connectionStatus !== "connected" ||
taskArchiveState !== false ||
hasAttemptedResume.current
)
return;
hasAttemptedResume.current = true;
const capturedRequest = activeRequestRef.current;
const guardedSetters = buildGuardedSetters(activeRequestRef, capturedRequest, setters);
const canContinue = () => isCurrentRequest(activeRequestRef.current, capturedRequest);
checkAndResume({
taskId,
sessionId,
session,
preventAutoStart,
taskArchiveState,
canContinue,
setSessionStatus: (s) => {
if (isCurrentRequest(activeRequestRef.current, capturedRequest)) {
setSessionStatus({ requestKey: capturedRequest.key, status: s });
}
},
setters: guardedSetters,
});
}, [taskId, sessionId, connectionStatus, session, preventAutoStart, taskArchiveState]); // eslint-disable-line react-hooks/exhaustive-deps
// Freshly created remote sessions may return status before runtime metadata is available.
// Retry a few times so topbar/tooltips can show remote details without manual refresh.
useEffect(() => {
if (!taskId || !sessionId || connectionStatus !== "connected" || taskArchiveState !== false)
return;
if (!sessionStatus?.is_remote_executor) return;
if (sessionStatus.remote_checked_at || sessionStatus.remote_status_error) return;
if (remoteStatusRetryCount.current >= 3) return;
const capturedRequest = activeRequestRef.current;
const timer = window.setTimeout(async () => {
if (!isCurrentRequest(activeRequestRef.current, capturedRequest)) return;
const client = getWebSocketClient();
if (!client) return;
remoteStatusRetryCount.current += 1;
try {
const nextStatus = await client.request<SessionStatus>(
"task.session.status",
{
task_id: taskId,
session_id: sessionId,
},
SESSION_ENTRY_REQUEST_TIMEOUT_MS,
);
if (isCurrentRequest(activeRequestRef.current, capturedRequest)) {
setSessionStatus({ requestKey: capturedRequest.key, status: nextStatus });
}
} catch {
// Best-effort refresh only.
}
}, 1500);
return () => window.clearTimeout(timer);
}, [taskId, sessionId, connectionStatus, sessionStatus, taskArchiveState]);
const retryStatus = useCallback(async () => {
if (!taskId || !sessionId || connectionStatus !== "connected" || taskArchiveState !== false) {
return;
}
const capturedRequest = activeRequestRef.current;
const guardedSetters = buildGuardedSetters(activeRequestRef, capturedRequest, setters);
const canContinue = () => isCurrentRequest(activeRequestRef.current, capturedRequest);
guardedSetters.setResumptionState("checking");
guardedSetters.setError(null);
guardedSetters.setNotice?.(null);
guardedSetters.setRecoveryFailure?.(null);
const client = getWebSocketClient();
if (!client) return;
const status = await refreshSessionStatus({
client,
taskId,
sessionId,
session,
setSessionStatus: (nextStatus) => {
if (canContinue()) {
setSessionStatus({ requestKey: capturedRequest.key, status: nextStatus });
}
},
setters: guardedSetters,
canContinue,
});
if (!status || !canContinue()) return;
guardedSetters.setError(null);
guardedSetters.setNotice?.(null);
guardedSetters.setRecoveryFailure?.(null);
guardedSetters.setResumptionState(
status.is_agent_running || status.state === "RUNNING" ? "running" : "idle",
);
}, [connectionStatus, session, sessionId, setters, taskArchiveState, taskId]);
return {
sessionStatus,
captureRequest: () => activeRequestRef.current,
buildGuardedSettersFor: (capturedRequest) =>
buildGuardedSetters(activeRequestRef, capturedRequest, setters),
retryStatus,
};
}
type ManualResumeResponse = Awaited<ReturnType<typeof launchSession>>;
function applyManualResumeResponse(
response: ManualResumeResponse,
taskId: string,
sessionId: string,
session: SessionLike,
setters: ResumeStateSetter,
): boolean {
if (!response.success) {
setters.setResumptionState("error");
setters.setRecoveryFailure?.(null);
setters.setError(response.error ?? t("task:failedToResumeSession"));
return false;
}
setters.setResumptionState("resumed");
setters.setNotice?.(null);
if (response.state) {
setters.setTaskSession({
id: toSessionId(sessionId),
task_id: toTaskId(taskId),
state: response.state as TaskSessionState,
started_at: session?.started_at ?? "",
updated_at: session?.updated_at ?? "",
});
}
// A STARTING response keeps the resume-skipped marker until a later RUNNING
// event confirms that the agent is active.
if (response.state === "RUNNING") setters.setResumeSkipped?.(sessionId, false);
if (response.worktree_path) setters.setWorktreePath(response.worktree_path);
if (response.worktree_branch) setters.setWorktreeBranch(response.worktree_branch);
return true;
}
function handleManualResumeError(
error: unknown,
setters: ResumeStateSetter,
canContinue: () => boolean,
): boolean {
if (isTaskArchivedConflict(error)) {
clearArchiveRecovery(setters);
return false;
}
if (!canContinue()) return false;
setters.setResumptionState("error");
setters.setRecoveryFailure?.(null);
setters.setError(resolveRequestErrorMessage(error, t));
return false;
}
type ManualResumeParams = {
taskId: string | null;
sessionId: string | null;
taskArchiveState: TaskArchiveState;
session: SessionLike;
captureRequest: () => SessionRequestIdentity;
buildGuardedSettersFor: (capturedRequest: SessionRequestIdentity) => ResumeStateSetter;
};
function useManualResumeSession({
taskId,
sessionId,
taskArchiveState,
session,
captureRequest,
buildGuardedSettersFor,
}: ManualResumeParams): () => Promise<boolean> {
return useCallback(async (): Promise<boolean> => {
if (!taskId || !sessionId || taskArchiveState !== false) return false;
const capturedRequest = captureRequest();
const canContinue = () => isCurrentRequest(captureRequest(), capturedRequest);
const guardedSetters = buildGuardedSettersFor(capturedRequest);
if (!canContinue()) return false;
const startingProjection = markSessionStarting(taskId, sessionId, session, guardedSetters);
guardedSetters.setResumptionState("resuming");
guardedSetters.setError(null);
guardedSetters.setNotice?.(null);
guardedSetters.setRecoveryFailure?.(null);
try {
const response = await launchSession(buildResumeRequest(taskId, sessionId).request);
if (!canContinue()) {
startingProjection?.rollback();
return false;
}
const resumed = applyManualResumeResponse(
response,
taskId,
sessionId,
session,
guardedSetters,
);
if (!resumed) startingProjection?.rollback();
return resumed;
} catch (error) {
const handled = handleManualResumeError(error, guardedSetters, canContinue);
if (!handled) startingProjection?.rollback();
return handled;
}
}, [taskId, sessionId, taskArchiveState, session, captureRequest, buildGuardedSettersFor]);
}
export type SessionResumptionOptions = {
onTaskArchiveConflict?: () => void;
};
export function useSessionResumption(
taskId: string | null,
sessionId: string | null,
taskArchiveState: TaskArchiveState = false,
options: SessionResumptionOptions = {},
): UseSessionResumptionReturn {
const [resumptionState, setResumptionStateRaw] = useState<ResumptionState>("idle");
const recoveryAttemptIdRef = useRef(0);
const recoveryAttemptActiveRef = useRef(false);
const setResumptionState = useCallback((nextState: ResumptionState) => {
const startsRecoveryAttempt = nextState === "checking" || nextState === "resuming";
if (startsRecoveryAttempt) {
if (!recoveryAttemptActiveRef.current) {
recoveryAttemptIdRef.current += 1;
recoveryAttemptActiveRef.current = true;
}
} else {
recoveryAttemptActiveRef.current = false;
}
setResumptionStateRaw(nextState);
}, []);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [recoveryFailure, setRecoveryFailure] = useState<SessionRecoveryFailure | null>(null);
const [worktreePath, setWorktreePath] = useState<string | null>(null);
const [worktreeBranch, setWorktreeBranch] = useState<string | null>(null);
const connectionStatus = useAppStore((state) => state.connection.status);
const preventAutoStartAgentOnOpen = useAppStore(
(state) => state.userSettings.preventAutoStartAgentOnOpen,
);
const session = useAppStore((state) =>
sessionId ? (state.taskSessions.items[sessionId] ?? null) : null,
);
const setTaskSession = useAppStore((state) => state.setTaskSession);
const setSessionAgentctlStatus = useAppStore((state) => state.setSessionAgentctlStatus);
const setResumeSkipped = useAppStore((state) => state.setResumeSkipped);
const storeApi = useAppStoreApi();
const workspaceRestoration = useWorkspaceRestoration(taskId, sessionId);
const setters: ResumeStateSetter = {
setResumptionState,
setError,
setNotice,
setWorktreePath,
setWorktreeBranch,
setTaskSession,
setTaskSessionUnscoped: setTaskSession,
setAgentctlReady: (sid: string) => setSessionAgentctlStatus(sid, { status: "ready" }),
setResumeSkipped,
getLiveSession: (sid: string) => storeApi.getState().taskSessions.items[sid] ?? null,
setRecoveryFailure,
workspaceRestoration: workspaceRestoration.callbacks ?? undefined,
onTaskArchiveConflict: options.onTaskArchiveConflict,
};
useSessionRecoveryFeedback(sessionId, session?.state, error, notice, setters);
const { sessionStatus, captureRequest, buildGuardedSettersFor, retryStatus } =
useSessionResetAndCheck({
taskId,
sessionId,
connectionStatus,
session,
setters,
preventAutoStart: preventAutoStartAgentOnOpen,
taskArchiveState,
});
const resumeSession = useManualResumeSession({
taskId,
sessionId,
taskArchiveState,
session,
captureRequest,
buildGuardedSettersFor,
});
return {
resumptionState,
sessionStatus,
error,
notice,
recoveryFailure,
recoveryAttemptId: recoveryAttemptIdRef.current,
taskSessionState: session?.state ?? null,
worktreePath,
worktreeBranch,
resumeSession,
retrySessionStatus: retryStatus,
workspaceRestoration,
};
}