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