PR #3577
Sections
Review

fix: restore completed task workspace access

main ← feature/investigate-complete-6a5 87 files +2147 −412 PR #3577 ↗

Completed sessions now restore their retained workspace without starting an agent, so file browsing, Git views, and terminals work after the conversation ends.

Why this change

Opening a completed task rejected workspace creation with "session is terminal" and left the Files panel stuck on preparation. The same guard blocked every terminal session, even though retained workspace infrastructure remains usable.

What it does

Architecture, end to end

The frontend restores a completed session through a workspace-only launch. The backend admits the workspace without the terminal check, creates agentctl, and settles the UI through WebSocket events.

flowchart LR
  User[User opens completed task] --> Hook[useWorkspaceRestoration]
  Hook --> Launch[session.launch restore_workspace]
  Launch --> Admission[ensureWorkspaceSessionAdmitted]
  Admission --> Manager[Lifecycle Manager]
  Manager --> Agentctl[agentctl execution]
  Agentctl --> WS[session.agentctl_ready]
  WS --> Store[workspaceRestoration store]
  Store --> UI[File browser / Terminal / Changes]

Key code changes

Drag to pan. Use the + and − buttons to zoom. Click a node to open the full code. The arrows show how the parts interact.

drag to pan · +/− to zoom · click a node for details

func (m *Manager) ensureWorkspaceSessionAdmitted(ctx context.Context, taskID string, info *WorkspaceInfo) error
Click for details →

This new file admits a workspace-only execution. It checks archive, ownership, binding, and cleanup, but it does not reject terminal session state.

Admission
func (m *Manager) ensureWorkspaceSessionAdmitted(ctx context.Context, taskID string, info *WorkspaceInfo) error {
  if info == nil {
    return fmt.Errorf("workspace info is required")
  }
  if info.TaskArchived || info.WorkspaceOwnerArchived {
    return fmt.Errorf("%w: task workspace is archived", ErrSessionWorkspaceNotReady)
  }
  if m.executorProfileReader == nil || info.SessionID == "" {
    return nil
  }
  snapshot, err := m.readWorkspaceAdmission(ctx, info)
  if err != nil {
    return err
  }
  if err := validateWorkspaceAdmission(taskID, info, snapshot); err != nil {
    return err
  }
  if err := m.ensureWorkspaceTaskAdmission(ctx, snapshot.session); err != nil {
    return err
  }
  if err := m.ensureWorkspaceCleanupInactive(ctx, taskID, snapshot); err != nil {
    return err
  }
  latest, err := m.readWorkspaceAdmission(ctx, info)
  if err != nil {
    return fmt.Errorf("reverify workspace admission: %w", err)
  }
  return nil
}
Cached check
func (m *Manager) ensureCachedWorkspaceExecutionAdmitted(ctx context.Context, execution *AgentExecution) error {
  if execution == nil || m.executorProfileReader == nil || execution.SessionID == "" {
    return nil
  }
  info := &WorkspaceInfo{
    TaskID:            execution.TaskID,
    SessionID:         execution.SessionID,
    TaskEnvironmentID: execution.TaskEnvironmentID,
  }
  if m.workspaceInfoProvider != nil {
    resolved, err := m.workspaceInfoProvider.GetWorkspaceInfoForSession(ctx, execution.TaskID, execution.SessionID)
    if err != nil {
      return fmt.Errorf("verify cached workspace: %w", err)
    }
    info = resolved
  }
  return m.ensureWorkspaceSessionAdmitted(ctx, execution.TaskID, info)
}
func (m *Manager) GetOrEnsureExecution(ctx context.Context, sessionID string) (*AgentExecution, error)
Click for details →

Workspace paths now use the new admission. Agent paths keep the terminal guard. Cached executions re-verify through the workspace path.

Workspace path
if execution, exists := m.executionStore.GetBySessionID(sessionID); exists {
  if err := m.ensureCachedWorkspaceExecutionAdmitted(ctx, execution); err != nil {
    return nil, err
  }
  return execution, nil
}
Terminal guard stays for agents
// ErrSessionTerminal indicates that an agent-start operation reached a terminal
// session state. Workspace-only admission deliberately does not return this
// error because retained workspace infrastructure can remain usable after the
// agent conversation ends.
var ErrSessionTerminal = errors.New("session is terminal")
Environment route
check := m.environmentExecCheck
if check == nil {
  check = m.environmentAccessCheck
}
if check != nil {
  if err := check(ctx, taskEnvironmentID); err != nil {
    return nil, err
  }
}
if err := m.ensureWorkspaceSessionAdmitted(ctx, info.TaskID, info); err != nil {
  return nil, err
}
func (s *Service) launchRestoreWorkspace(ctx context.Context, req *LaunchSessionRequest) (*LaunchSessionResponse, error)
Click for details →

The orchestrator adds a workspace-only intent that restores agentctl without starting an agent or changing task state.

Intent
const (
  IntentPrepare          SessionIntent = "prepare"
  IntentStart            SessionIntent = "start"
  IntentStartCreated     SessionIntent = "start_created"
  IntentResume           SessionIntent = "resume"
  IntentWorkflowStep     SessionIntent = "workflow_step"
  IntentRestoreWorkspace SessionIntent = "restore_workspace"
)
Handler
func (s *Service) launchRestoreWorkspace(ctx context.Context, req *LaunchSessionRequest) (*LaunchSessionResponse, error) {
  if req.SessionID == "" {
    return nil, fmt.Errorf("session_id is required for workspace restore")
  }
  session, err := s.repo.GetTaskSession(ctx, req.SessionID)
  if err != nil {
    return nil, fmt.Errorf("session not found: %w", err)
  }
  if session.TaskID != req.TaskID {
    return nil, fmt.Errorf("session does not belong to task")
  }
  if err := s.agentManager.EnsureWorkspaceExecutionForSession(ctx, req.TaskID, req.SessionID); err != nil {
    return nil, fmt.Errorf("failed to restore workspace: %w", err)
  }
  agentExecutionID, _ := s.agentManager.GetExecutionIDForSession(ctx, req.SessionID)
  return &LaunchSessionResponse{
    Success:          true,
    TaskID:           req.TaskID,
    SessionID:        req.SessionID,
    AgentExecutionID: agentExecutionID,
    State:            string(session.State),
  }, nil
}
Auth
if intent != IntentRestoreWorkspace {
  if err := s.authorizeTaskPrompt(ctx, req.TaskID); err != nil {
    return nil, err
  }
}
export function useWorkspaceRestoration(taskId: string | null, sessionId: string | null, explicitEnvironmentId?: string | null)
Click for details →

The hook starts a restore attempt, calls the backend, and settles the attempt when agentctl becomes ready or fails.

Hook
export function useWorkspaceRestoration(
  taskId: string | null | undefined,
  sessionId: string | null | undefined,
  explicitEnvironmentId?: string | null,
): WorkspaceRestorationResult {
  const environmentKey = resolveWorkspaceRestorationKey(sessionId, environmentId);
  const attempt = useAppStore((state) =>
    environmentKey ? (state.workspaceRestoration?.byEnvironmentId?.[environmentKey] ?? null) : null,
  );
  const restore = useCallback(async (): Promise<boolean> => {
    if (!taskId || !sessionId || !environmentKey || !callbacks) return false;
    const nextAttempt = callbacks.begin(taskId, sessionId);
    if (!nextAttempt) return false;
    try {
      const response = await restoreSessionWorkspace(taskId, sessionId, t("task:failedToRestoreWorkspace"));
      const agentctlStatus = storeApi.getState().sessionAgentctl.itemsBySessionId[sessionId];
      if (settleWorkspaceRestoreResponse({ response, agentctlStatus, attempt: nextAttempt, callbacks, sessionId, t, bumpWorkspaceFilesRefresh }) === "failed") {
        return false;
      }
      if (!isCurrentWorkspaceAttempt(storeApi, nextAttempt)) return false;
      return true;
    } catch (error) {
      callbacks.fail(nextAttempt, error);
      return false;
    }
  }, [callbacks, environmentKey, sessionId, storeApi, t, taskId]);
  return { status: attempt?.status ?? null, attempt, restore, callbacks };
}
export function beginWorkspaceRestoration(state: WorkspaceRestorationState, input: WorkspaceRestorationInput)
Click for details →

The store keeps one attempt per environment, prevents duplicate pending attempts, and migrates from session fallback keys.

State
export type WorkspaceRestorationAttempt = {
  attemptId?: string;
  taskId: string;
  sessionId: string;
  environmentId: string;
  revision: number;
  status: WorkspaceRestorationStatus;
  details?: string;
};

export type WorkspaceRestorationState = {
  byEnvironmentId: Record<string, WorkspaceRestorationAttempt>;
};
Begin
export function beginWorkspaceRestoration(
  state: WorkspaceRestorationState,
  input: WorkspaceRestorationInput,
): WorkspaceRestorationAttempt | null {
  const current = state.byEnvironmentId[input.environmentId];
  if (current?.status === "pending" && current.taskId === input.taskId && current.sessionId === input.sessionId) {
    return null;
  }
  const attempt: WorkspaceRestorationAttempt = {
    attemptId: generateUUID(),
    ...input,
    revision: (current?.revision ?? 0) + 1,
    status: "pending",
  };
  state.byEnvironmentId[input.environmentId] = attempt;
  return attempt;
}
Key
export function resolveWorkspaceRestorationKey(
  _sessionId: string | null | undefined,
  environmentId?: string | null,
): string | null {
  const explicitEnvironmentId = environmentId?.trim();
  if (explicitEnvironmentId) return explicitEnvironmentId;
  return null;
}
export function WorkspaceUnavailable({ error, restoration, onRetry }: WorkspaceUnavailableProps)
Click for details →

The panel shows pending, failed, or unavailable state inside the workspace surface with Retry and bounded technical details.

Component
export function WorkspaceUnavailable({
  error,
  restoration,
  onRetry,
  retryDisabled = false,
  compact = false,
}: WorkspaceUnavailableProps) {
  const { t } = useTranslation();
  const isRestoring = restoration?.status === "pending";
  const hasRestoreError = restoration?.status === "error";
  const detail = getWorkspaceRestoreDetail(restoration, error);
  return (
    <div data-testid="workspace-unavailable" role="status" aria-label={t("task:workspaceUnavailable")} aria-busy={isRestoring || undefined}>
      <div className="text-sm font-medium">{t("task:workspaceUnavailable")}</div>
      <p className="mt-1 text-xs">{getWorkspaceRestoreMessage(isRestoring, hasRestoreError, t)}</p>
      {restoration && onRetry && (
        <Button data-testid="workspace-retry" disabled={retryDisabled} onClick={onRetry}>
          <IconRefresh className={isRestoring ? "h-3.5 w-3.5 animate-spin" : "h-3.5 w-3.5"} />
          {t("task:retry")}
        </Button>
      )}
      {detail && (
        <details className="mt-2 text-xs">
          <summary>{t("task:technicalDetails")}</summary>
          <pre className="max-h-48 overflow-y-auto whitespace-pre-wrap break-words">{detail}</pre>
        </details>
      )}
    </div>
  );
}
Load state
export function renderSessionOrLoadState({
  workspaceRestoration,
  onRestoreWorkspace,
  restoreWorkspaceDisabled,
}: RenderSessionOrLoadStateInput) {
  if (workspaceRestoration && workspaceRestoration.status !== "ready") {
    return (
      <WorkspaceUnavailable
        restoration={workspaceRestoration}
        onRetry={onRestoreWorkspace}
        retryDisabled={restoreWorkspaceDisabled}
      />
    );
  }
  if (isSessionFailed) {
    return <WorkspaceUnavailable error={sessionError} />;
  }
  if ((loadState === "loading" || isLoadingTree) && !tree) {
    return <PanelLoadingState label={t("task:loadingFiles")} />;
  }
  return null;
}
Read the changes as a list

Workspace admission without terminal check

apps/backend/internal/agent/runtime/lifecycle/manager_workspace_admission.go

This new file admits a workspace-only execution. It checks archive, ownership, binding, and cleanup, but it does not reject terminal session state.

Admission
func (m *Manager) ensureWorkspaceSessionAdmitted(ctx context.Context, taskID string, info *WorkspaceInfo) error {
  if info == nil {
    return fmt.Errorf("workspace info is required")
  }
  if info.TaskArchived || info.WorkspaceOwnerArchived {
    return fmt.Errorf("%w: task workspace is archived", ErrSessionWorkspaceNotReady)
  }
  if m.executorProfileReader == nil || info.SessionID == "" {
    return nil
  }
  snapshot, err := m.readWorkspaceAdmission(ctx, info)
  if err != nil {
    return err
  }
  if err := validateWorkspaceAdmission(taskID, info, snapshot); err != nil {
    return err
  }
  if err := m.ensureWorkspaceTaskAdmission(ctx, snapshot.session); err != nil {
    return err
  }
  if err := m.ensureWorkspaceCleanupInactive(ctx, taskID, snapshot); err != nil {
    return err
  }
  latest, err := m.readWorkspaceAdmission(ctx, info)
  if err != nil {
    return fmt.Errorf("reverify workspace admission: %w", err)
  }
  return nil
}
Cached check
func (m *Manager) ensureCachedWorkspaceExecutionAdmitted(ctx context.Context, execution *AgentExecution) error {
  if execution == nil || m.executorProfileReader == nil || execution.SessionID == "" {
    return nil
  }
  info := &WorkspaceInfo{
    TaskID:            execution.TaskID,
    SessionID:         execution.SessionID,
    TaskEnvironmentID: execution.TaskEnvironmentID,
  }
  if m.workspaceInfoProvider != nil {
    resolved, err := m.workspaceInfoProvider.GetWorkspaceInfoForSession(ctx, execution.TaskID, execution.SessionID)
    if err != nil {
      return fmt.Errorf("verify cached workspace: %w", err)
    }
    info = resolved
  }
  return m.ensureWorkspaceSessionAdmitted(ctx, execution.TaskID, info)
}

Split execution admission paths

apps/backend/internal/agent/runtime/lifecycle/manager_execution.go

Workspace paths now use the new admission. Agent paths keep the terminal guard. Cached executions re-verify through the workspace path.

Workspace path
if execution, exists := m.executionStore.GetBySessionID(sessionID); exists {
  if err := m.ensureCachedWorkspaceExecutionAdmitted(ctx, execution); err != nil {
    return nil, err
  }
  return execution, nil
}
Terminal guard stays for agents
// ErrSessionTerminal indicates that an agent-start operation reached a terminal
// session state. Workspace-only admission deliberately does not return this
// error because retained workspace infrastructure can remain usable after the
// agent conversation ends.
var ErrSessionTerminal = errors.New("session is terminal")
Environment route
check := m.environmentExecCheck
if check == nil {
  check = m.environmentAccessCheck
}
if check != nil {
  if err := check(ctx, taskEnvironmentID); err != nil {
    return nil, err
  }
}
if err := m.ensureWorkspaceSessionAdmitted(ctx, info.TaskID, info); err != nil {
  return nil, err
}

Restore workspace intent

apps/backend/internal/orchestrator/session_launch.go

The orchestrator adds a workspace-only intent that restores agentctl without starting an agent or changing task state.

Intent
const (
  IntentPrepare          SessionIntent = "prepare"
  IntentStart            SessionIntent = "start"
  IntentStartCreated     SessionIntent = "start_created"
  IntentResume           SessionIntent = "resume"
  IntentWorkflowStep     SessionIntent = "workflow_step"
  IntentRestoreWorkspace SessionIntent = "restore_workspace"
)
Handler
func (s *Service) launchRestoreWorkspace(ctx context.Context, req *LaunchSessionRequest) (*LaunchSessionResponse, error) {
  if req.SessionID == "" {
    return nil, fmt.Errorf("session_id is required for workspace restore")
  }
  session, err := s.repo.GetTaskSession(ctx, req.SessionID)
  if err != nil {
    return nil, fmt.Errorf("session not found: %w", err)
  }
  if session.TaskID != req.TaskID {
    return nil, fmt.Errorf("session does not belong to task")
  }
  if err := s.agentManager.EnsureWorkspaceExecutionForSession(ctx, req.TaskID, req.SessionID); err != nil {
    return nil, fmt.Errorf("failed to restore workspace: %w", err)
  }
  agentExecutionID, _ := s.agentManager.GetExecutionIDForSession(ctx, req.SessionID)
  return &LaunchSessionResponse{
    Success:          true,
    TaskID:           req.TaskID,
    SessionID:        req.SessionID,
    AgentExecutionID: agentExecutionID,
    State:            string(session.State),
  }, nil
}
Auth
if intent != IntentRestoreWorkspace {
  if err := s.authorizeTaskPrompt(ctx, req.TaskID); err != nil {
    return nil, err
  }
}

Frontend restoration hook

apps/web/hooks/domains/session/use-workspace-restoration.ts

The hook starts a restore attempt, calls the backend, and settles the attempt when agentctl becomes ready or fails.

Hook
export function useWorkspaceRestoration(
  taskId: string | null | undefined,
  sessionId: string | null | undefined,
  explicitEnvironmentId?: string | null,
): WorkspaceRestorationResult {
  const environmentKey = resolveWorkspaceRestorationKey(sessionId, environmentId);
  const attempt = useAppStore((state) =>
    environmentKey ? (state.workspaceRestoration?.byEnvironmentId?.[environmentKey] ?? null) : null,
  );
  const restore = useCallback(async (): Promise<boolean> => {
    if (!taskId || !sessionId || !environmentKey || !callbacks) return false;
    const nextAttempt = callbacks.begin(taskId, sessionId);
    if (!nextAttempt) return false;
    try {
      const response = await restoreSessionWorkspace(taskId, sessionId, t("task:failedToRestoreWorkspace"));
      const agentctlStatus = storeApi.getState().sessionAgentctl.itemsBySessionId[sessionId];
      if (settleWorkspaceRestoreResponse({ response, agentctlStatus, attempt: nextAttempt, callbacks, sessionId, t, bumpWorkspaceFilesRefresh }) === "failed") {
        return false;
      }
      if (!isCurrentWorkspaceAttempt(storeApi, nextAttempt)) return false;
      return true;
    } catch (error) {
      callbacks.fail(nextAttempt, error);
      return false;
    }
  }, [callbacks, environmentKey, sessionId, storeApi, t, taskId]);
  return { status: attempt?.status ?? null, attempt, restore, callbacks };
}

Environment-scoped restoration state

apps/web/lib/state/slices/session-runtime/workspace-restoration.ts

The store keeps one attempt per environment, prevents duplicate pending attempts, and migrates from session fallback keys.

State
export type WorkspaceRestorationAttempt = {
  attemptId?: string;
  taskId: string;
  sessionId: string;
  environmentId: string;
  revision: number;
  status: WorkspaceRestorationStatus;
  details?: string;
};

export type WorkspaceRestorationState = {
  byEnvironmentId: Record<string, WorkspaceRestorationAttempt>;
};
Begin
export function beginWorkspaceRestoration(
  state: WorkspaceRestorationState,
  input: WorkspaceRestorationInput,
): WorkspaceRestorationAttempt | null {
  const current = state.byEnvironmentId[input.environmentId];
  if (current?.status === "pending" && current.taskId === input.taskId && current.sessionId === input.sessionId) {
    return null;
  }
  const attempt: WorkspaceRestorationAttempt = {
    attemptId: generateUUID(),
    ...input,
    revision: (current?.revision ?? 0) + 1,
    status: "pending",
  };
  state.byEnvironmentId[input.environmentId] = attempt;
  return attempt;
}
Key
export function resolveWorkspaceRestorationKey(
  _sessionId: string | null | undefined,
  environmentId?: string | null,
): string | null {
  const explicitEnvironmentId = environmentId?.trim();
  if (explicitEnvironmentId) return explicitEnvironmentId;
  return null;
}

Workspace-local failure UI

apps/web/components/task/workspace-unavailable.tsx

The panel shows pending, failed, or unavailable state inside the workspace surface with Retry and bounded technical details.

Component
export function WorkspaceUnavailable({
  error,
  restoration,
  onRetry,
  retryDisabled = false,
  compact = false,
}: WorkspaceUnavailableProps) {
  const { t } = useTranslation();
  const isRestoring = restoration?.status === "pending";
  const hasRestoreError = restoration?.status === "error";
  const detail = getWorkspaceRestoreDetail(restoration, error);
  return (
    <div data-testid="workspace-unavailable" role="status" aria-label={t("task:workspaceUnavailable")} aria-busy={isRestoring || undefined}>
      <div className="text-sm font-medium">{t("task:workspaceUnavailable")}</div>
      <p className="mt-1 text-xs">{getWorkspaceRestoreMessage(isRestoring, hasRestoreError, t)}</p>
      {restoration && onRetry && (
        <Button data-testid="workspace-retry" disabled={retryDisabled} onClick={onRetry}>
          <IconRefresh className={isRestoring ? "h-3.5 w-3.5 animate-spin" : "h-3.5 w-3.5"} />
          {t("task:retry")}
        </Button>
      )}
      {detail && (
        <details className="mt-2 text-xs">
          <summary>{t("task:technicalDetails")}</summary>
          <pre className="max-h-48 overflow-y-auto whitespace-pre-wrap break-words">{detail}</pre>
        </details>
      )}
    </div>
  );
}
Load state
export function renderSessionOrLoadState({
  workspaceRestoration,
  onRestoreWorkspace,
  restoreWorkspaceDisabled,
}: RenderSessionOrLoadStateInput) {
  if (workspaceRestoration && workspaceRestoration.status !== "ready") {
    return (
      <WorkspaceUnavailable
        restoration={workspaceRestoration}
        onRetry={onRestoreWorkspace}
        retryDisabled={restoreWorkspaceDisabled}
      />
    );
  }
  if (isSessionFailed) {
    return <WorkspaceUnavailable error={sessionError} />;
  }
  if ((loadState === "loading" || isLoadingTree) && !tree) {
    return <PanelLoadingState label={t("task:loadingFiles")} />;
  }
  return null;
}

Data and storage

Workspace restoration is environment-scoped and survives backend restart. The attempt keeps provider identity for later Resume.

FieldTypeNotes
workspaceRestoration.byEnvironmentIdRecord<string, WorkspaceRestorationAttempt>one attempt per environment, keyed by environmentId
WorkspaceRestorationAttempt.statusenum pending | ready | errorpending blocks workspace, ready clears it, error shows Retry
WorkspaceRestorationAttempt.revisionintegermonotonic per environment, rejects late results
WorkspaceRestorationAttempt.detailsstringsanitized and bounded to 512 chars
WorkspaceInfo.ValidatedTaskEnvironmentGenerationint64fences workspace attach against ownership transfer

Risk

5 / 10 Medium
1 low5 medium10 high

Why this score

  • Relaxing the terminal guard could allow unintended agent starts if the split is wrong; the change keeps agent paths guarded and only workspace paths bypass it.
  • Workspace admission touches archive, binding, and cleanup races; missing a check could expose or recreate a stale workspace.
  • Frontend state is environment-scoped and migrates from session keys; a key mismatch could leave a spinner or hide an error.

Trade-offs and review notes

Where to look first

  1. Verify manager_workspace_admission.go checks archive, binding, generation, and cleanup and that agent paths still reject terminal sessions.
  2. Check session_launch.go IntentRestoreWorkspace does not require session.prompt permission and does not change task or session state.
  3. Confirm use-workspace-restoration.ts and workspace-restoration.ts prevent duplicate pending attempts and sanitize details.
  4. Review file-browser-load-state.tsx and workspace-unavailable.tsx for correct pending, error, and Retry handling on desktop and mobile.