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 },
);