PR #3519
Sections
Review

fix: guard archived session recovery and preserve launch errors

main ← feature/investigate-archived-793 58 files +1240 −210 PR #3519 ↗

Block automatic resume and workspace restore for archived tasks, return a typed 409 conflict, and preserve launch failure causes for retry.

Why this change

Archived tasks still trigger automatic resume and workspace restore. The UI shows recovery banners for history that should stay read-only, and launch failures lose their cause so retry cannot show the real error.

What it does

Architecture, end to end

The frontend checks task.session.status before any resume. The backend gates archived tasks early and returns a typed conflict. The client clears recovery state and waits for unarchive.

flowchart LR
  Hook[useSessionResumption] --> Status[task.session.status]
  Status --> Gate[ensureTaskNotArchived]
  Gate -->|archived| Conflict[409 task_archived]
  Gate -->|active| Resume[resume / restore_workspace]
  Conflict --> Clear[clearArchiveRecovery]
  Clear --> Task[useTask sidebarArchivedTasks]
  Task --> Unarchive[Unarchive]
  Unarchive --> Refresh[refreshTask + re-check]
  Resume --> Executor[Executor stopSession]
  Executor --> Owner[onExecutionStopOwnerRegistration]

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 (s *Service) ensureTaskNotArchived(ctx context.Context, taskID string) error
Click for details →

The status path returns task_archived early and skips resumable checks so archived history never enters recovery UI.

Gate helper
func (s *Service) ensureTaskNotArchived(ctx context.Context, taskID string) error {
	task, err := s.repo.GetTask(ctx, taskID)
	if err != nil {
		return fmt.Errorf("failed to load task: %w", err)
	}
	if task == nil {
		return fmt.Errorf("failed to load task: task %s is nil", taskID)
	}
	if task.ArchivedAt != nil {
		return executor.ErrTaskArchived
	}
	return nil
}
Status early return
task, err := s.repo.GetTask(ctx, taskID)
if err != nil {
	return resp, fmt.Errorf("failed to load task: %w", err)
}
if task == nil {
	return resp, fmt.Errorf("failed to load task: task %s is nil", taskID)
}

resp.State = string(session.State)
resp.UpdatedAt = session.UpdatedAt.UTC().Format(time.RFC3339Nano)
resp.AgentProfileID = session.AgentProfileID
if task.ArchivedAt != nil {
	resp.ResumeReason = resumeReasonTaskArchived
	return resp, nil
}
s.populateExecutorStatusInfo(ctx, session, &resp)
func (s *Service) launchRestoreWorkspace(ctx context.Context, req *LaunchSessionRequest) (*LaunchSessionResponse, error)
Click for details →

Restore and manual recover reject archived tasks before they create a runtime or clear the resume token.

Restore guard
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.ensureTaskNotArchived(ctx, req.TaskID); err != nil {
	return nil, err
}

if err := s.agentManager.EnsureWorkspaceExecutionForSession(ctx, req.TaskID, req.SessionID); err != nil {
	return nil, fmt.Errorf("failed to restore workspace: %w", err)
}
Recover guard
if err := s.authorizeSessionPrompt(ctx, sessionID); err != nil {
	return nil, err
}
if err := s.authorizeTask(ctx, taskID); err != nil {
	return nil, err
}
if err := s.ensureTaskNotArchived(ctx, taskID); err != nil {
	return nil, err
}
if action == "runtime_retry" {
	if s.wasResumeAttempt(ctx, sessionID) {
		action = "resume"
	} else {
		action = "fresh_start"
	}
}
func taskArchivedConflictResponse(msg *ws.Message, err error) (*ws.Message, error)
Click for details →

The WS layer maps ErrTaskArchived to a conflict with kind task_archived so the client can clear recovery state without a generic error.

Conflict helper
func taskArchivedConflictResponse(msg *ws.Message, err error) (*ws.Message, error) {
	if !errors.Is(err, executor.ErrTaskArchived) {
		return nil, nil
	}
	return ws.NewError(
		msg.ID,
		msg.Action,
		ws.ErrorCodeConflict,
		"Task is archived. Unarchive it before recovering this session.",
		map[string]interface{}{"kind": "task_archived"},
	)
}
Launch and recover wiring
resp, err := h.service.LaunchSession(ctx, &req)
if err != nil {
	if recoveryResponse, responseErr := taskArchivedConflictResponse(msg, err); recoveryResponse != nil || responseErr != nil {
		return recoveryResponse, responseErr
	}
	// ... benign teardown logging ...
	return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to launch session: "+err.Error(), nil)
}

resp, err := h.service.RecoverSession(ctx, req.TaskID, req.SessionID, req.Action)
if err != nil {
	if recoveryResponse, responseErr := taskArchivedConflictResponse(msg, err); recoveryResponse != nil || responseErr != nil {
		return recoveryResponse, responseErr
	}
	if recoveryResponse, responseErr := branchRecoveryConflictResponse(msg, err); recoveryResponse != nil || responseErr != nil {
		return recoveryResponse, responseErr
	}
}
func (e *Executor) stopSession(ctx context.Context, session *models.TaskSession, reason string, force bool) (SessionStopResult, error)
Click for details →

The detailed stop path now registers the execution owner so archive cascade can track and clean up the runtime.

Owner registration
e.logStop(session, executionID, reason, force)
if e.onExecutionStopOwnerRegistration != nil {
	e.onExecutionStopOwnerRegistration(session.ID, executionID, force)
}

changed, finalState, stateErr := e.transitionSessionState(
	ctx,
	session.TaskID,
	session.ID,
	models.TaskSessionStateCancelled,
	reason,
)
export function isTaskArchivedConflict(error: unknown): boolean
Click for details →

The client detects the typed conflict, clears recovery feedback, and preserves failure causes for the retry UI.

Conflict detection
const TASK_ARCHIVED_KIND = "task_archived";

export function isTaskArchivedConflict(error: unknown): boolean {
  return error instanceof WebSocketRequestError && error.details?.kind === TASK_ARCHIVED_KIND;
}

export function clearArchiveRecovery(setters: ResumeStateSetter): void {
  setters.setResumptionState("idle");
  setters.setError(null);
  setters.setNotice?.(null);
  setters.setRecoveryFailure?.(null);
  setters.onTaskArchiveConflict?.();
}
Silent fallback with archive check
export async function resumeWithSilentFallback(
  taskId: string,
  sessionId: string,
  session: SessionLike,
  setters: ResumeStateSetter,
  canContinue: () => boolean = () => true,
): Promise<boolean> {
  if (!canContinue()) return false;
  setters.setResumptionState("resuming");
  const context = { taskId, sessionId, session, setters, canContinue };
  const resumeAttempt = await tryLaunch(buildResumeRequest(taskId, sessionId).request, context);
  if (!canContinue()) return false;
  if (resumeAttempt.ok) return true;
  if (resumeAttempt.archived) {
    clearArchiveRecovery(setters);
    return false;
  }
  return restoreAfterResumeFailure(context, resumeAttempt);
}
function checkAndResume(params: CheckAndResumeParams): Promise<void>
Click for details →

The hook skips status and resume when the task is archived and re-checks after unarchive without reload.

Archived short-circuit
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");
  // ... request task.session.status ...
  if (status.resume_reason === TASK_ARCHIVED_KIND) {
    clearArchiveRecovery(setters);
    return;
  }
}
Task hook keeps archived tasks
return (
  Object.values(state.sidebarArchivedTasks.itemsByWorkspaceId)
    .flat()
    .find((item: Task) => item.id === taskId) ?? null
);
Unarchive triggers re-check
const resumption = useSessionResumption(
  task?.id ?? null,
  effectiveSessionId,
  task ? task.archived_at != null : null,
  { onTaskArchiveConflict: refreshTask },
);
Read the changes as a list

Archived gate in session status

apps/backend/internal/orchestrator/task_operations.go

The status path returns task_archived early and skips resumable checks so archived history never enters recovery UI.

Gate helper
func (s *Service) ensureTaskNotArchived(ctx context.Context, taskID string) error {
	task, err := s.repo.GetTask(ctx, taskID)
	if err != nil {
		return fmt.Errorf("failed to load task: %w", err)
	}
	if task == nil {
		return fmt.Errorf("failed to load task: task %s is nil", taskID)
	}
	if task.ArchivedAt != nil {
		return executor.ErrTaskArchived
	}
	return nil
}
Status early return
task, err := s.repo.GetTask(ctx, taskID)
if err != nil {
	return resp, fmt.Errorf("failed to load task: %w", err)
}
if task == nil {
	return resp, fmt.Errorf("failed to load task: task %s is nil", taskID)
}

resp.State = string(session.State)
resp.UpdatedAt = session.UpdatedAt.UTC().Format(time.RFC3339Nano)
resp.AgentProfileID = session.AgentProfileID
if task.ArchivedAt != nil {
	resp.ResumeReason = resumeReasonTaskArchived
	return resp, nil
}
s.populateExecutorStatusInfo(ctx, session, &resp)

Guard restore and recover paths

apps/backend/internal/orchestrator/session_launch.go

Restore and manual recover reject archived tasks before they create a runtime or clear the resume token.

Restore guard
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.ensureTaskNotArchived(ctx, req.TaskID); err != nil {
	return nil, err
}

if err := s.agentManager.EnsureWorkspaceExecutionForSession(ctx, req.TaskID, req.SessionID); err != nil {
	return nil, fmt.Errorf("failed to restore workspace: %w", err)
}
Recover guard
if err := s.authorizeSessionPrompt(ctx, sessionID); err != nil {
	return nil, err
}
if err := s.authorizeTask(ctx, taskID); err != nil {
	return nil, err
}
if err := s.ensureTaskNotArchived(ctx, taskID); err != nil {
	return nil, err
}
if action == "runtime_retry" {
	if s.wasResumeAttempt(ctx, sessionID) {
		action = "resume"
	} else {
		action = "fresh_start"
	}
}

Typed 409 conflict for archived tasks

apps/backend/internal/orchestrator/handlers/handlers.go

The WS layer maps ErrTaskArchived to a conflict with kind task_archived so the client can clear recovery state without a generic error.

Conflict helper
func taskArchivedConflictResponse(msg *ws.Message, err error) (*ws.Message, error) {
	if !errors.Is(err, executor.ErrTaskArchived) {
		return nil, nil
	}
	return ws.NewError(
		msg.ID,
		msg.Action,
		ws.ErrorCodeConflict,
		"Task is archived. Unarchive it before recovering this session.",
		map[string]interface{}{"kind": "task_archived"},
	)
}
Launch and recover wiring
resp, err := h.service.LaunchSession(ctx, &req)
if err != nil {
	if recoveryResponse, responseErr := taskArchivedConflictResponse(msg, err); recoveryResponse != nil || responseErr != nil {
		return recoveryResponse, responseErr
	}
	// ... benign teardown logging ...
	return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to launch session: "+err.Error(), nil)
}

resp, err := h.service.RecoverSession(ctx, req.TaskID, req.SessionID, req.Action)
if err != nil {
	if recoveryResponse, responseErr := taskArchivedConflictResponse(msg, err); recoveryResponse != nil || responseErr != nil {
		return recoveryResponse, responseErr
	}
	if recoveryResponse, responseErr := branchRecoveryConflictResponse(msg, err); recoveryResponse != nil || responseErr != nil {
		return recoveryResponse, responseErr
	}
}

Synchronous stop owns teardown

apps/backend/internal/orchestrator/executor/executor_interaction.go

The detailed stop path now registers the execution owner so archive cascade can track and clean up the runtime.

Owner registration
e.logStop(session, executionID, reason, force)
if e.onExecutionStopOwnerRegistration != nil {
	e.onExecutionStopOwnerRegistration(session.ID, executionID, force)
}

changed, finalState, stateErr := e.transitionSessionState(
	ctx,
	session.TaskID,
	session.ID,
	models.TaskSessionStateCancelled,
	reason,
)

Client archive-aware recovery

apps/web/hooks/domains/session/use-session-resumption-operations.ts

The client detects the typed conflict, clears recovery feedback, and preserves failure causes for the retry UI.

Conflict detection
const TASK_ARCHIVED_KIND = "task_archived";

export function isTaskArchivedConflict(error: unknown): boolean {
  return error instanceof WebSocketRequestError && error.details?.kind === TASK_ARCHIVED_KIND;
}

export function clearArchiveRecovery(setters: ResumeStateSetter): void {
  setters.setResumptionState("idle");
  setters.setError(null);
  setters.setNotice?.(null);
  setters.setRecoveryFailure?.(null);
  setters.onTaskArchiveConflict?.();
}
Silent fallback with archive check
export async function resumeWithSilentFallback(
  taskId: string,
  sessionId: string,
  session: SessionLike,
  setters: ResumeStateSetter,
  canContinue: () => boolean = () => true,
): Promise<boolean> {
  if (!canContinue()) return false;
  setters.setResumptionState("resuming");
  const context = { taskId, sessionId, session, setters, canContinue };
  const resumeAttempt = await tryLaunch(buildResumeRequest(taskId, sessionId).request, context);
  if (!canContinue()) return false;
  if (resumeAttempt.ok) return true;
  if (resumeAttempt.archived) {
    clearArchiveRecovery(setters);
    return false;
  }
  return restoreAfterResumeFailure(context, resumeAttempt);
}

Hook guards and unarchive refresh

apps/web/hooks/domains/session/use-session-resumption.ts

The hook skips status and resume when the task is archived and re-checks after unarchive without reload.

Archived short-circuit
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");
  // ... request task.session.status ...
  if (status.resume_reason === TASK_ARCHIVED_KIND) {
    clearArchiveRecovery(setters);
    return;
  }
}
Task hook keeps archived tasks
return (
  Object.values(state.sidebarArchivedTasks.itemsByWorkspaceId)
    .flat()
    .find((item: Task) => item.id === taskId) ?? null
);
Unarchive triggers re-check
const resumption = useSessionResumption(
  task?.id ?? null,
  effectiveSessionId,
  task ? task.archived_at != null : null,
  { onTaskArchiveConflict: refreshTask },
);

Data and storage

Archived state is a task-level flag. The backend returns a typed reason, the client clears recovery, and the token stays intact.

FieldTypeNotes
task.archived_attimestamp | nullset by ArchiveTask, cleared by Unarchive
resumeReasonTaskArchivedstring = "task_archived"GetTaskSessionStatus returns this when archived
executor.ErrTaskArchivedsentinel errormapped to 409 kind task_archived
executors_running.resume_tokenstringkept intact when archived reject happens
SessionRecoveryFailureunion { workspace_read_only | recovery_failed }preserves resumeError and restoreError for details

Risk

4 / 10 Medium
1 low5 medium10 high

Why this score

  • Change is scoped to archived tasks; active tasks keep the same resume and restore path.
  • Backend gates are covered by new unit tests and frontend archive tests; E2E covers desktop and mobile.
  • Rollback is low cost: unarchive restores the task and re-checks status without data migration.

Trade-offs and review notes

Where to look first

  1. Verify ensureTaskNotArchived is called before any token clear or runtime create in RecoverSession and launchRestoreWorkspace.
  2. Check that taskArchivedConflictResponse runs before branchRecoveryConflictResponse and generic error logging.
  3. Confirm useSessionResumption clears recovery on task_archived and does not start fallback restore.
  4. Review useTask archived lookup and the onTaskArchiveConflict refresh after unarchive.