From d8eefc571898efcc21304b843ac13e1aebcccb96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sat, 12 Sep 2026 16:26:35 +0100 Subject: [PATCH] fix: restore completed task workspace access (#3577) --- .github/docker/ci-base/Dockerfile | 1 + .../e2e-tests-workflow-contract_test.py | 14 + .github/workflows/backend-tests.yml | 5 + .github/workflows/e2e-tests.yml | 5 + .github/workflows/frontend-tests.yml | 5 + .../runtime/lifecycle/manager_execution.go | 226 +++++++++++----- .../lifecycle/manager_execution_test.go | 17 +- .../agent/runtime/lifecycle/manager_launch.go | 20 +- .../runtime/lifecycle/manager_passthrough.go | 13 + .../runtime/lifecycle/manager_startup.go | 10 + .../lifecycle/manager_workspace_admission.go | 223 ++++++++++++++++ .../manager_workspace_restore_test.go | 225 ++++++++++++++++ .../internal/agent/runtime/lifecycle/types.go | 29 ++- .../orchestrator/handlers/handlers.go | 12 +- .../internal/orchestrator/session_launch.go | 24 +- .../orchestrator/session_launch_test.go | 17 ++ .../internal/orchestrator/task_operations.go | 5 + .../orchestrator/task_operations_test.go | 29 +++ .../internal/task/service/service_turns.go | 15 +- .../components/task/changes-panel-body.tsx | 36 ++- .../components/task/changes-panel-data.tsx | 109 ++++++-- .../task/ensure-session-error.test.tsx | 20 ++ .../task/file-browser-content-area.tsx | 76 ++++++ apps/web/components/task/file-browser-data.ts | 78 ++++++ .../task/file-browser-load-state.test.tsx | 28 ++ .../task/file-browser-load-state.tsx | 16 ++ .../components/task/file-browser-parts.tsx | 36 +-- .../task/file-browser-responsive.test.tsx | 7 +- ...ile-browser-search-context-action.test.tsx | 2 +- apps/web/components/task/file-browser.tsx | 114 ++------ .../components/task/passthrough-terminal.tsx | 52 +++- apps/web/components/task/shell-terminal.tsx | 38 ++- .../components/task/task-changes-panel.tsx | 227 +++++++++++----- .../task/use-passthrough-terminal.test.ts | 9 + .../task/workspace-unavailable.test.tsx | 43 ++- .../components/task/workspace-unavailable.tsx | 64 ++++- apps/web/e2e/README.md | 4 + apps/web/e2e/fixtures/kubernetes-tools.ts | 3 + .../session/completed-session-resume.spec.ts | 69 ++--- ...completed-workspace-restoration-helpers.ts | 138 ++++++++++ .../completed-workspace-restoration.spec.ts | 119 +++++++++ .../mobile-completed-session-resume.spec.ts | 52 ++-- ...le-completed-workspace-restoration.spec.ts | 139 ++++++++++ .../use-session-resumption-operations.ts | 184 ++++++++----- .../use-session-resumption-request-guard.ts | 13 + .../session/use-session-resumption.test.ts | 175 ++++++++++++- .../domains/session/use-session-resumption.ts | 14 +- .../session/use-workspace-restoration.test.ts | 244 ++++++++++++++++++ .../session/use-workspace-restoration.ts | 150 +++++++++++ .../lib/services/session-recovery-service.ts | 5 +- apps/web/lib/state/default-state.ts | 1 + apps/web/lib/state/slices/index.ts | 6 + .../migrate-env-keyed-data.test.ts | 53 ++++ .../session-runtime/purge-session.test.ts | 47 ++-- .../session-runtime/session-runtime-slice.ts | 63 +++++ .../lib/state/slices/session-runtime/types.ts | 14 + .../workspace-restoration.test.ts | 99 +++++++ .../session-runtime/workspace-restoration.ts | 166 ++++++++++++ apps/web/lib/state/store-reexports.ts | 8 +- .../web/lib/ws/handlers/agent-session.test.ts | 62 +++++ apps/web/lib/ws/handlers/agent-session.ts | 61 +++++ apps/web/src/locales/en/task.json | 3 + apps/web/src/locales/pseudo/task.json | 3 + apps/web/src/locales/pt-pt/task.json | 3 + apps/web/src/locales/zh-cn/task.json | 3 + apps/web/src/locales/zh-hk/agents.json | 8 +- apps/web/src/locales/zh-hk/task.json | 3 + apps/web/src/locales/zh-tw/agents.json | 8 +- apps/web/src/locales/zh-tw/task.json | 3 + .../completed-workspace-restoration/plan.md | 215 +++++++++++++++ .../task-01-workspace-admission.md | 145 +++++++++++ .../task-02-workspace-feedback.md | 143 ++++++++++ .../task-03-workspace-e2e.md | 148 +++++++++++ docs/plans/task-completion/plan.md | 8 + docs/public/workflow-tips.md | 8 + docs/specs/tasks/README.md | 4 +- .../tasks/requirements/task-completion.md | 57 ++++ .../tasks/system-design/task-completion.md | 160 ++++++++++++ 78 files changed, 4180 insertions(+), 481 deletions(-) create mode 100644 apps/backend/internal/agent/runtime/lifecycle/manager_workspace_admission.go create mode 100644 apps/backend/internal/agent/runtime/lifecycle/manager_workspace_restore_test.go create mode 100644 apps/web/components/task/file-browser-content-area.tsx create mode 100644 apps/web/components/task/file-browser-data.ts create mode 100644 apps/web/e2e/tests/session/completed-workspace-restoration-helpers.ts create mode 100644 apps/web/e2e/tests/session/completed-workspace-restoration.spec.ts create mode 100644 apps/web/e2e/tests/session/mobile-completed-workspace-restoration.spec.ts create mode 100644 apps/web/hooks/domains/session/use-workspace-restoration.test.ts create mode 100644 apps/web/hooks/domains/session/use-workspace-restoration.ts create mode 100644 apps/web/lib/state/slices/session-runtime/workspace-restoration.test.ts create mode 100644 apps/web/lib/state/slices/session-runtime/workspace-restoration.ts create mode 100644 docs/plans/completed-workspace-restoration/plan.md create mode 100644 docs/plans/completed-workspace-restoration/task-01-workspace-admission.md create mode 100644 docs/plans/completed-workspace-restoration/task-02-workspace-feedback.md create mode 100644 docs/plans/completed-workspace-restoration/task-03-workspace-e2e.md diff --git a/.github/docker/ci-base/Dockerfile b/.github/docker/ci-base/Dockerfile index a1a3bd528f..8d3e3ead2b 100644 --- a/.github/docker/ci-base/Dockerfile +++ b/.github/docker/ci-base/Dockerfile @@ -32,6 +32,7 @@ FROM mcr.microsoft.com/playwright:v1.61.1-noble AS runtime ENV DEBIAN_FRONTEND=noninteractive \ PNPM_HOME=/pnpm \ + COREPACK_HOME=/root/.cache/node/corepack \ PATH=/pnpm:$PATH # --- System packages ------------------------------------------------------- diff --git a/.github/scripts/e2e-tests-workflow-contract_test.py b/.github/scripts/e2e-tests-workflow-contract_test.py index 047bd2e6ad..381008520b 100644 --- a/.github/scripts/e2e-tests-workflow-contract_test.py +++ b/.github/scripts/e2e-tests-workflow-contract_test.py @@ -13,6 +13,8 @@ REPO_ROOT = Path(__file__).resolve().parents[2] DOCKERFILE = REPO_ROOT / ".github" / "docker" / "ci-base" / "Dockerfile" IMAGE_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "ci-base-image.yml" E2E_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "e2e-tests.yml" +BACKEND_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "backend-tests.yml" +FRONTEND_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "frontend-tests.yml" LINT_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "lint-action-pinning.yml" IMAGE_DIGEST_RESOLVER = REPO_ROOT / ".github" / "scripts" / "resolve-image-digest.sh" VALID_IMAGE_INDEX = ( @@ -154,6 +156,18 @@ cat "${FAKE_DOCKER_MANIFEST}" self.assertIn("type=gha,scope=runtime", workflow) self.assertIn("desktop-latest", workflow) + def test_container_jobs_use_the_baked_corepack_cache(self) -> None: + dockerfile = DOCKERFILE.read_text(encoding="utf-8") + + self.assertIn("COREPACK_HOME=/root/.cache/node/corepack", dockerfile) + for workflow_path in (E2E_WORKFLOW, BACKEND_WORKFLOW, FRONTEND_WORKFLOW): + workflow = workflow_path.read_text(encoding="utf-8") + self.assertIn( + "COREPACK_HOME: /root/.cache/node/corepack", + workflow, + workflow_path.name, + ) + def test_desktop_job_uses_image_without_live_bootstrap_downloads(self) -> None: workflow = E2E_WORKFLOW.read_text(encoding="utf-8") desktop_job = job_block(workflow, "desktop-e2e", "e2e-report") diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index 84a334716a..e3d01d1539 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -32,6 +32,11 @@ permissions: contents: read packages: read +env: + # Container jobs may override HOME. Keep Corepack pointed at the pnpm cache + # prepared in the CI image. + COREPACK_HOME: /root/.cache/node/corepack + jobs: runner_plan: name: Plan external runner allocation diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index b5b47e882f..1a2bc42736 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -38,6 +38,11 @@ permissions: # pull the kandev-ci image from GHCR via GITHUB_TOKEN. packages: read +env: + # Container jobs may override HOME. Keep Corepack pointed at the pnpm cache + # prepared in the CI image so setup does not require an npm registry request. + COREPACK_HOME: /root/.cache/node/corepack + jobs: runner_plan: name: Plan external runner allocation diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml index ded873fdc3..a8bb877ed2 100644 --- a/.github/workflows/frontend-tests.yml +++ b/.github/workflows/frontend-tests.yml @@ -28,6 +28,11 @@ permissions: contents: read packages: read +env: + # Container jobs may override HOME. Keep Corepack pointed at the pnpm cache + # prepared in the CI image. + COREPACK_HOME: /root/.cache/node/corepack + jobs: runner_plan: name: Plan external runner allocation diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_execution.go b/apps/backend/internal/agent/runtime/lifecycle/manager_execution.go index 0232771888..3e724971bc 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_execution.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_execution.go @@ -27,11 +27,10 @@ import ( // have a resolved workspace path (typically while worktree preparation is in progress). var ErrSessionWorkspaceNotReady = errors.New("session workspace not ready") -// ErrSessionTerminal indicates the task session has reached a terminal state -// (cancelled/completed/failed) and no execution can be created for it. User-facing -// workspace handlers treat this like ErrSessionWorkspaceNotReady: a graceful -// not-ready envelope rather than an ERROR-logged failure, since a terminal session -// will never recover an execution. +// 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") type executionAdmissionPurpose uint8 @@ -104,7 +103,7 @@ func (m *Manager) GetOrEnsureExecution(ctx context.Context, sessionID string) (* // Fast path: execution already in memory if execution, exists := m.executionStore.GetBySessionID(sessionID); exists { - if err := m.ensureLaunchSessionStillActive(ctx, sessionID, executionAdmissionWorkspaceOnly); err != nil { + if err := m.ensureCachedWorkspaceExecutionAdmitted(ctx, execution); err != nil { return nil, err } return execution, nil @@ -137,15 +136,21 @@ func (m *Manager) GetOrEnsureExecutionForEnvironment(ctx context.Context, taskEn return nil, fmt.Errorf("task_environment_id is required") } // Per-user scoping (opt-in auth) — before the cache short-circuit so a - // cached execution cannot be reached by a non-owner. - if check := m.environmentAccessCheck; check != nil { + // cached execution cannot be reached by a non-owner. An environment route + // hands the caller a shell, so it requires session.exec when that scoped + // checker is available. + check := m.environmentExecCheck + if check == nil { + check = m.environmentAccessCheck + } + if check != nil { if err := check(ctx, taskEnvironmentID); err != nil { return nil, err } } if execution, exists := m.executionStore.GetByTaskEnvironmentID(taskEnvironmentID); exists { - if err := m.ensureLaunchSessionStillActive(ctx, execution.SessionID, executionAdmissionWorkspaceOnly); err != nil { + if err := m.ensureCachedWorkspaceExecutionAdmitted(ctx, execution); err != nil { return nil, err } return execution, nil @@ -176,10 +181,8 @@ func (m *Manager) GetOrEnsureExecutionForEnvironment(ctx context.Context, taskEn if info.SessionID == "" { return nil, fmt.Errorf("task environment %s has no task session", taskEnvironmentID) } - if check := m.execAccessCheck(); check != nil { - if err := check(ctx, info.SessionID); err != nil { - return nil, err - } + if err := m.ensureWorkspaceSessionAdmitted(ctx, info.TaskID, info); err != nil { + return nil, err } // Share the sessionID-keyed bucket so we deduplicate against any concurrent @@ -187,13 +190,13 @@ func (m *Manager) GetOrEnsureExecutionForEnvironment(ctx context.Context, taskEn // the same session. value, err := m.doCoalescedExecution(ctx, info.SessionID, func(sharedCtx context.Context) (interface{}, error) { if execution, exists := m.executionStore.GetBySessionID(info.SessionID); exists { - if err := m.ensureLaunchSessionStillActive(sharedCtx, info.SessionID, executionAdmissionWorkspaceOnly); err != nil { + if err := m.ensureWorkspaceSessionAdmitted(sharedCtx, info.TaskID, info); err != nil { return nil, err } return execution, nil } if execution, exists := m.executionStore.GetByTaskEnvironmentID(taskEnvironmentID); exists { - if err := m.ensureLaunchSessionStillActive(sharedCtx, execution.SessionID, executionAdmissionWorkspaceOnly); err != nil { + if err := m.ensureWorkspaceSessionAdmitted(sharedCtx, info.TaskID, info); err != nil { return nil, err } return execution, nil @@ -234,7 +237,7 @@ func (m *Manager) EnsureWorkspaceExecutionForSession(ctx context.Context, taskID // Fast path: execution already in memory if execution, exists := m.executionStore.GetBySessionID(sessionID); exists { - if err := m.ensureLaunchSessionStillActive(ctx, sessionID, executionAdmissionWorkspaceOnly); err != nil { + if err := m.ensureCachedWorkspaceExecutionAdmitted(ctx, execution); err != nil { return nil, err } return execution, nil @@ -308,6 +311,9 @@ func (m *Manager) ensureWorkspaceExecutionLocked(ctx context.Context, taskID, se // Double-check after acquiring the slot — a peer in the same group may have // finished while we were waiting. if execution, exists := m.executionStore.GetBySessionID(sessionID); exists { + if err := m.ensureCachedWorkspaceExecutionAdmitted(ctx, execution); err != nil { + return nil, err + } return execution, nil } @@ -329,10 +335,13 @@ func (m *Manager) ensureWorkspaceExecutionLocked(ctx context.Context, taskID, se } else if info.TaskID == "" || info.TaskID != taskID { return nil, fmt.Errorf("session %s is not owned by task %s", sessionID, taskID) } + if err := m.ensureWorkspaceSessionAdmitted(ctx, taskID, info); err != nil { + return nil, err + } if info.TaskEnvironmentID != "" { if execution, exists := m.executionStore.GetByTaskEnvironmentID(info.TaskEnvironmentID); exists { - if err := m.ensureLaunchSessionStillActive(ctx, execution.SessionID, executionAdmissionWorkspaceOnly); err != nil { + if err := m.ensureWorkspaceSessionAdmitted(ctx, taskID, info); err != nil { return nil, err } m.logger.Info("reusing existing execution for task environment", @@ -500,6 +509,9 @@ func (m *Manager) EnsurePassthroughExecution(ctx context.Context, sessionID stri return nil, err } } + if err := m.ensureLaunchSessionStillActive(ctx, sessionID, executionAdmissionAgent); err != nil { + return nil, err + } // Check if execution already exists with a running passthrough process. // PassthroughProcessID is not cleared on exit, so a stale ID can point at // a dead process; verify the runner still has it before short-circuiting, @@ -547,9 +559,9 @@ func (m *Manager) resumeExistingExecution(ctx context.Context, sessionID string, // cleared the execution store. The two are distinguished by applyResumeIntent, // which decides fresh launch vs resume. // -// Terminal sessions are rejected by the guard at the top of createExecution, the -// same one every other creation path goes through, so this recovery path never -// spawns a runtime for a session that ended before the restart. +// Terminal sessions are rejected by the agent-launch admission below, so this +// recovery path never spawns a passthrough agent for a session that ended +// before the restart. func (m *Manager) createExecutionFromSessionInfo(ctx context.Context, sessionID string) (*AgentExecution, error) { if m.workspaceInfoProvider == nil { return nil, fmt.Errorf("cannot restore session %s: workspace info provider not configured", sessionID) @@ -568,6 +580,9 @@ func (m *Manager) createExecutionFromSessionInfo(ctx context.Context, sessionID if info.TaskID == "" { return nil, fmt.Errorf("session %s has no associated task ID", sessionID) } + if err := m.ensureLaunchSessionStillActive(ctx, sessionID, executionAdmissionAgent); err != nil { + return nil, err + } // Verify this session should use passthrough mode profileInfo, err := m.verifyPassthroughEnabled(ctx, sessionID, workspaceExecutionProfileID(info)) @@ -596,7 +611,7 @@ func (m *Manager) createExecutionFromSessionInfo(ctx context.Context, sessionID zap.String("session_id", sessionID), zap.String("workspace_path", info.WorkspacePath)) - execution, err := m.createExecution(ctx, info.TaskID, info) + execution, err := m.createAgentExecution(ctx, info.TaskID, info) if err != nil { return nil, fmt.Errorf("create execution for session %s: %w", sessionID, err) } @@ -661,21 +676,69 @@ func (m *Manager) verifyPassthroughEnabled(ctx context.Context, sessionID, profi return profileInfo, nil } -// createExecution creates an agentctl execution. +// createExecution creates workspace-only agentctl infrastructure. // The agent subprocess is NOT started - call ConfigureAgent + Start explicitly. func (m *Manager) createExecution(ctx context.Context, taskID string, info *WorkspaceInfo) (*AgentExecution, error) { + return m.createExecutionWithMode(ctx, taskID, info, false) +} + +// createAgentExecution creates infrastructure for a path that will start an +// agent, retaining the stricter terminal-session admission at every boundary. +func (m *Manager) createAgentExecution(ctx context.Context, taskID string, info *WorkspaceInfo) (*AgentExecution, error) { + return m.createExecutionWithMode(ctx, taskID, info, true) +} + +type executionCreationInputs struct { + runtime ExecutorBackend + executionID string + preparation *executionCreatePreparation +} + +func (m *Manager) prepareExecutionCreation( + ctx context.Context, + taskID string, + info *WorkspaceInfo, + agentLaunch bool, +) (*executionCreationInputs, error) { if info == nil { return nil, fmt.Errorf("workspace info is required") } - // A terminal session can never gain an execution, so reject it before - // reconciling the workspace, taking an activity lease, or creating a - // runtime instance. User-facing panels (terminal, git, files) reconnect on - // a timer; without this every retry paid for a full instance creation that - // the post-creation check below tore straight back down. That check stays — - // it guards the session that terminalizes *during* creation. - if err := m.ensureLaunchSessionStillActive(ctx, info.SessionID, executionAdmissionWorkspaceOnly); err != nil { + if err := m.ensureExecutionAdmission(ctx, taskID, info, agentLaunch); err != nil { return nil, err } + if err := m.reconcileExecutionWorkspace(ctx, taskID, info); err != nil { + return nil, err + } + rt, err := m.getExecutorBackend(info.ExecutorType) + if err != nil { + return nil, fmt.Errorf("no runtime configured: %w", err) + } + executionID := uuid.New().String() + preparation, err := m.prepareExecutionCreateRequest(ctx, taskID, info, executionID) + if err != nil { + return nil, err + } + return &executionCreationInputs{ + runtime: rt, + executionID: executionID, + preparation: preparation, + }, nil +} + +func (m *Manager) createExecutionWithMode( + ctx context.Context, + taskID string, + info *WorkspaceInfo, + agentLaunch bool, +) (*AgentExecution, error) { + if info == nil { + return nil, fmt.Errorf("workspace info is required") + } + if agentLaunch { + if err := m.ensureLaunchSessionStillActive(ctx, info.SessionID, executionAdmissionAgent); err != nil { + return nil, err + } + } recoveryAdmission, err := m.admitWorkspaceRecovery(ctx, info) if err != nil { return nil, err @@ -692,7 +755,8 @@ func (m *Manager) createExecution(ctx context.Context, taskID string, info *Work } }() } - if err := m.reconcileExecutionWorkspace(operationCtx, taskID, info); err != nil { + inputs, err := m.prepareExecutionCreation(operationCtx, taskID, info, agentLaunch) + if err != nil { return nil, err } activityLease, err := m.acquireActivity(operationCtx, activity.KindExecutionStarting) @@ -702,80 +766,121 @@ func (m *Manager) createExecution(ctx context.Context, taskID string, info *Work defer activityLease.Release() activityLease.SetKind(activity.KindExecutionPreparing) - // Select runtime based on executor type; falls back to standalone if empty/unavailable - rt, err := m.getExecutorBackend(info.ExecutorType) - if err != nil { - return nil, fmt.Errorf("no runtime configured: %w", err) - } - - executionID := uuid.New().String() - preparation, err := m.prepareExecutionCreateRequest(operationCtx, taskID, info, executionID) - if err != nil { - return nil, err - } launchCtx, launchCancel := withLaunchPhaseTimeout(operationCtx) defer launchCancel() - if err := resumeRemoteInstancePreflight(launchCtx, rt, preparation.request); err != nil { + if err := resumeRemoteInstancePreflight(launchCtx, inputs.runtime, inputs.preparation.request); err != nil { return nil, err } - runtimeInstance, err := rt.CreateInstance(launchCtx, preparation.request) + runtimeInstance, err := inputs.runtime.CreateInstance(launchCtx, inputs.preparation.request) if err != nil { return nil, fmt.Errorf("failed to create execution: %w", err) } - execution := m.initializeCreatedExecution(operationCtx, taskID, info, executionID, rt, runtimeInstance, preparation) + execution := m.initializeCreatedExecution( + operationCtx, + taskID, + info, + inputs.executionID, + inputs.runtime, + runtimeInstance, + inputs.preparation, + ) - if err := m.ensureLaunchSessionStillActive(operationCtx, info.SessionID, executionAdmissionWorkspaceOnly); err != nil { - m.rollbackLaunchExecution(operationCtx, rt, runtimeInstance, execution, "session ended during runtime creation") + if err := m.ensureExecutionAdmission(operationCtx, taskID, info, agentLaunch); err != nil { + m.rollbackLaunchExecution(operationCtx, inputs.runtime, runtimeInstance, execution, "session ended during runtime creation") return nil, err } - if addErr := m.executionStore.Add(execution); addErr != nil { + return m.registerAndPublishCreatedExecution( + operationCtx, + taskID, + info, + agentLaunch, + inputs, + runtimeInstance, + execution, + ) +} + +func (m *Manager) registerAndPublishCreatedExecution( + ctx context.Context, + taskID string, + info *WorkspaceInfo, + agentLaunch bool, + inputs *executionCreationInputs, + runtimeInstance *ExecutorInstance, + execution *AgentExecution, +) (*AgentExecution, error) { + // Cleanup can stop and remove an execution as soon as it is registered. + // Keep the final admission, registration, persistence, and publication as + // one lifecycle transition so cleanup cannot observe a half-created + // workspace execution and tear it down underneath its first subscriber. + execution.remoteInstanceLifecycleMu.Lock() + if err := m.executionStore.Add(execution); err != nil { + execution.remoteInstanceLifecycleMu.Unlock() // Lost a race: another path created an execution for this session // between our check and our Add. Roll back the runtime instance we // just spawned (otherwise its subprocess is orphaned) and return the // winner so the caller observes a single execution per session. - if errors.Is(addErr, ErrExecutionAlreadyExistsForSession) { - m.rollbackRacedExecution(operationCtx, rt, runtimeInstance, execution) + if errors.Is(err, ErrExecutionAlreadyExistsForSession) { + m.rollbackRacedExecution(ctx, inputs.runtime, runtimeInstance, execution) if existing, ok := m.executionStore.GetBySessionID(info.SessionID); ok { return existing, nil } } - return nil, fmt.Errorf("failed to register execution: %w", addErr) + return nil, fmt.Errorf("failed to register execution: %w", err) } isKubernetes := execution.RuntimeName == agentruntime.RuntimeKubernetes var createdRuntimeSecrets map[string]bool + var err error if isKubernetes { - createdRuntimeSecrets, err = m.persistRequiredKubernetesRuntimeSecrets(operationCtx, runtimeInstance, execution) + createdRuntimeSecrets, err = m.persistRequiredKubernetesRuntimeSecrets(ctx, runtimeInstance, execution) if err != nil { - m.rollbackRegisteredLaunch(rt, runtimeInstance, execution, "Kubernetes runtime secret persistence failed") + execution.remoteInstanceLifecycleMu.Unlock() + m.rollbackRegisteredLaunch(inputs.runtime, runtimeInstance, execution, "Kubernetes runtime secret persistence failed") return nil, err } } // Persist before the final session read so concurrent deletion cleanup can // inventory this execution even if it started between Add and validation. - if err := m.persistExecutorRunningResult(operationCtx, execution); err != nil { - secretCleanupErr := m.deleteCreatedRuntimeSecrets(operationCtx, execution, createdRuntimeSecrets) - m.rollbackRegisteredLaunchAfterPersistFailure(rt, runtimeInstance, execution) + if err := m.persistExecutorRunningResult(ctx, execution); err != nil { + secretCleanupErr := m.deleteCreatedRuntimeSecrets(ctx, execution, createdRuntimeSecrets) + execution.remoteInstanceLifecycleMu.Unlock() + m.rollbackRegisteredLaunchAfterPersistFailure(inputs.runtime, runtimeInstance, execution) return nil, errors.Join(fmt.Errorf("persist execution registration: %w", err), secretCleanupErr) } - if err := m.ensureLaunchSessionStillActive(operationCtx, info.SessionID, executionAdmissionWorkspaceOnly); err != nil { + if err := m.ensureExecutionAdmission(ctx, taskID, info, agentLaunch); err != nil { + execution.remoteInstanceLifecycleMu.Unlock() if errors.Is(err, errTaskCleanupActive) { - m.rollbackRegisteredLaunchForTaskCleanup(rt, runtimeInstance, execution) + m.rollbackRegisteredLaunchForTaskCleanup(inputs.runtime, runtimeInstance, execution) } else { - m.rollbackRegisteredLaunch(rt, runtimeInstance, execution, "session ended during execution registration") + m.rollbackRegisteredLaunch(inputs.runtime, runtimeInstance, execution, "session ended during execution registration") } return nil, err } - if err := m.publishCreatedExecution(operationCtx, runtimeInstance, execution, executionID, taskID); err != nil { - m.rollbackRegisteredLaunch(rt, runtimeInstance, execution, "runtime secret persistence failed") + if err := m.publishCreatedExecution(ctx, runtimeInstance, execution, inputs.executionID, taskID); err != nil { + execution.remoteInstanceLifecycleMu.Unlock() + m.rollbackRegisteredLaunch(inputs.runtime, runtimeInstance, execution, "runtime secret persistence failed") return nil, err } + execution.remoteInstanceLifecycleMu.Unlock() return execution, nil } +func (m *Manager) ensureExecutionAdmission( + ctx context.Context, + taskID string, + info *WorkspaceInfo, + agentLaunch bool, +) error { + if agentLaunch { + return m.ensureLaunchSessionStillActive(ctx, info.SessionID, executionAdmissionAgent) + } + return m.ensureWorkspaceSessionAdmitted(ctx, taskID, info) +} + type executionCreatePreparation struct { request *ExecutorCreateRequest profileInfo *AgentProfileInfo @@ -1048,6 +1153,7 @@ func (m *Manager) reconcileWorkspaceWorktrees(ctx context.Context, taskID string } if _, err := m.worktreeMgr.Create(ctx, worktree.CreateRequest{ TaskID: taskID, SessionID: info.SessionID, RepositoryID: repository.RepositoryID, + TaskEnvironmentID: info.TaskEnvironmentID, ReuseRequired: info.TaskEnvironmentID != "", RepositoryPath: repository.RepositoryPath, BaseBranch: repository.BaseBranch, FallbackBaseBranch: repository.DefaultBranch, CheckoutBranch: repository.CheckoutBranch, WorktreeID: repository.WorktreeID, TaskDirName: info.TaskDirName, WorkspaceID: info.WorkspaceID, diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_execution_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_execution_test.go index 635b31d32e..812dcda05b 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_execution_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_execution_test.go @@ -370,9 +370,15 @@ func TestCreateExecutionRollsBackWhenRegistrationCannotPersist(t *testing.T) { }, }, }) - mgr.SetExecutorProfileReader(&fakeExecutorProfileReader{session: &models.TaskSession{ - ID: sessionID, TaskID: "task-create-persist-failure", State: models.TaskSessionStateStarting, - }}) + mgr.SetExecutorProfileReader(&fakeExecutorProfileReader{ + session: &models.TaskSession{ + ID: sessionID, TaskID: "task-create-persist-failure", State: models.TaskSessionStateStarting, + }, + env: &models.TaskEnvironment{ + ID: "env-create-persist-failure", TaskID: "task-create-persist-failure", + Status: models.TaskEnvironmentStatusStopped, + }, + }) writer := &launchRegistrationWriter{ upserted: make(chan struct{}), upsertErr: errors.New("database is locked"), @@ -410,6 +416,9 @@ func newTerminalSessionManager(t *testing.T, state models.TaskSessionState) (*Ma }) mgr.SetExecutorProfileReader(&fakeExecutorProfileReader{session: &models.TaskSession{ ID: terminalSessionID, TaskID: terminalTaskID, State: state, + }, env: &models.TaskEnvironment{ + ID: terminalEnvironmentID, TaskID: terminalTaskID, + Status: models.TaskEnvironmentStatusStopped, }}) return mgr, backend } @@ -1071,7 +1080,7 @@ func TestCreateExecutionRecoversRepositoryEnvironmentAndSSHApprovals(t *testing. mgr.SetExecutorProfileReader(reader) execution, err := mgr.createExecution(context.Background(), "task-1", &WorkspaceInfo{ - SessionID: "session-1", WorkspaceID: "workspace-1", AgentProfileID: "agent-profile", ExecutionProfileID: "agent-profile", + TaskID: "task-1", SessionID: "session-1", WorkspaceID: "workspace-1", AgentProfileID: "agent-profile", ExecutionProfileID: "agent-profile", ExecutorProfileID: "executor-profile", AgentID: "auggie", WorkspacePath: "/workspace/task-1", }) diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go index d42cf22fb2..16a81560d6 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go @@ -1441,16 +1441,6 @@ func (m *Manager) promoteWorkspaceExecution(ctx context.Context, execution *Agen if execution.AgentCommand != "" { return nil, nil } - // Workspace-only executions can be created from a session row that stores - // the task assignee. The launch request carries the acting Office identity, - // so refresh it before the execution starts emitting events. - if req.AgentProfileID != "" { - execution.OfficeAgentProfileID = req.AgentProfileID - // Persist the acting identity while the workspace-only execution is - // being promoted, so a restart before the first stream event can - // restore the same attribution. - m.persistExecutorRunning(context.WithoutCancel(sharedCtx), execution) - } agentTypeName, profileInfo, err := m.resolveAgentProfile(sharedCtx, req) if err != nil { return nil, err @@ -1467,6 +1457,9 @@ func (m *Manager) promoteWorkspaceExecution(ctx context.Context, execution *Agen if err != nil { return nil, err } + if err := m.ensureLaunchSessionStillActive(sharedCtx, req.SessionID, executionAdmissionAgent); err != nil { + return nil, err + } execution.AgentCommand = cmds.initial execution.ContinueCommand = cmds.continue_ execution.AgentArgs = cmds.args @@ -1489,6 +1482,13 @@ func (m *Manager) promoteWorkspaceExecution(ctx context.Context, execution *Agen return nil, err } } + // Workspace-only executions can be created from a session row that stores + // the task assignee. The launch request carries the acting Office identity, + // so persist it only after the final agent-launch admission succeeds. + if req.AgentProfileID != "" { + execution.OfficeAgentProfileID = req.AgentProfileID + m.persistExecutorRunning(context.WithoutCancel(sharedCtx), execution) + } m.logger.Info("promoted workspace-only execution to agent execution", zap.String("execution_id", execution.ID), zap.String("session_id", req.SessionID), diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough.go b/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough.go index 31fbd0c821..d3a26abf28 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_passthrough.go @@ -803,6 +803,11 @@ func (m *Manager) startPassthroughSession(ctx context.Context, execution *AgentE return err } + execution.remoteInstanceLifecycleMu.Lock() + defer execution.remoteInstanceLifecycleMu.Unlock() + if err := m.ensureLaunchSessionStillActive(ctx, execution.SessionID, executionAdmissionAgent); err != nil { + return err + } processInfo, err := m.startInteractiveProcess(ctx, execution, pt, env, cmd, rt.StripEnv) if err != nil { return err @@ -1021,11 +1026,16 @@ func (m *Manager) passthroughProcessMatches(execution *AgentExecution, processID // delayed exit recovery. expectedProcessID is set by exit recovery so an old // callback cannot replace a process installed by a workflow reset. func (m *Manager) resumePassthroughSession(ctx context.Context, sessionID, expectedProcessID string) error { + if err := m.ensureLaunchSessionStillActive(ctx, sessionID, executionAdmissionAgent); err != nil { + return err + } execution, exists := m.executionStore.GetBySessionID(sessionID) if !exists { return fmt.Errorf("%w: %s", ErrNoExecutionForSession, sessionID) } + execution.remoteInstanceLifecycleMu.Lock() + defer execution.remoteInstanceLifecycleMu.Unlock() execution.passthroughLifecycleMu.Lock() defer execution.passthroughLifecycleMu.Unlock() if expectedProcessID != "" && execution.PassthroughProcessID != expectedProcessID { @@ -1071,6 +1081,9 @@ func (m *Manager) resumePassthroughSession(ctx context.Context, sessionID, expec // to a process it doesn't know about yet. startReq := buildInteractiveStartRequest(sessionID, execution, resolved.pt, env, cmd, resolved.rt.StripEnv, true) + if err := m.ensureLaunchSessionStillActive(ctx, sessionID, executionAdmissionAgent); err != nil { + return err + } processInfo, err := interactiveRunner.Start(ctx, startReq) if err != nil { return fmt.Errorf("failed to start passthrough session: %w", err) diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_startup.go b/apps/backend/internal/agent/runtime/lifecycle/manager_startup.go index 4cf8e120b8..4b5ac0d1c7 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_startup.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_startup.go @@ -107,6 +107,9 @@ func (m *Manager) startAgentProcess(ctx context.Context, executionID string) (re defer func() { retErr = wrapBootstrapFailure(execution, retErr) }() + if err := m.ensureLaunchSessionStillActive(ctx, execution.SessionID, executionAdmissionAgent); err != nil { + return err + } activityClaim, err := m.ensureExecutionActivity(ctx, executionID, activity.KindExecutionPreparing) if err != nil { return err @@ -179,6 +182,11 @@ func (m *Manager) startAgentProcess(ctx context.Context, executionID string) (re taskDescription := getTaskDescriptionFromMetadata(execution) approvalPolicy, agentDisplayName := m.resolveApprovalPolicyAndDisplayName(operationCtx, execution) + execution.remoteInstanceLifecycleMu.Lock() + if err := m.ensureLaunchSessionStillActive(operationCtx, execution.SessionID, executionAdmissionAgent); err != nil { + execution.remoteInstanceLifecycleMu.Unlock() + return err + } var bootCommand string if reuseExisting { // Agent subprocess is already running inside the remote executor. @@ -198,6 +206,7 @@ func (m *Manager) startAgentProcess(ctx context.Context, executionID string) (re var err error bootCommand, err = m.configureAndStartAgent(operationCtx, execution, approvalPolicy) if err != nil { + execution.remoteInstanceLifecycleMu.Unlock() return err } @@ -206,6 +215,7 @@ func (m *Manager) startAgentProcess(ctx context.Context, executionID string) (re zap.String("task_id", execution.TaskID), zap.String("command", bootCommand)) } + execution.remoteInstanceLifecycleMu.Unlock() return m.initializeAgentSession(operationCtx, execution, bootCommand, agentDisplayName, taskDescription, approvalPolicy) } diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_admission.go b/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_admission.go new file mode 100644 index 0000000000..b14a71763c --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_admission.go @@ -0,0 +1,223 @@ +package lifecycle + +import ( + "context" + "fmt" + + "github.com/kandev/kandev/internal/task/models" +) + +type workspaceAdmissionSnapshot struct { + session *models.TaskSession + env *models.TaskEnvironment +} + +// ensureWorkspaceSessionAdmitted validates the durable identity that permits +// a workspace-only execution. Terminal session state is intentionally not part +// of this rule. Cleanup, ownership, archive, and binding changes are. +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) + } + if err := validateWorkspaceAdmission(taskID, info, latest); err != nil { + return fmt.Errorf("reverify workspace admission: %w", err) + } + if err := m.ensureWorkspaceTaskAdmission(ctx, latest.session); err != nil { + return fmt.Errorf("reverify workspace admission: %w", err) + } + return nil +} + +func (m *Manager) readWorkspaceAdmission(ctx context.Context, info *WorkspaceInfo) (*workspaceAdmissionSnapshot, error) { + session, err := m.executorProfileReader.GetTaskSession(ctx, info.SessionID) + if err != nil { + return nil, fmt.Errorf("%w: verify workspace admission: %w", ErrSessionWorkspaceNotReady, err) + } + if session == nil { + return nil, fmt.Errorf("%w: verify workspace admission: session %q not found", ErrSessionWorkspaceNotReady, info.SessionID) + } + snapshot := &workspaceAdmissionSnapshot{session: session} + if info.TaskEnvironmentID == "" { + return snapshot, nil + } + env, err := m.executorProfileReader.GetTaskEnvironment(ctx, info.TaskEnvironmentID) + if err != nil { + return nil, fmt.Errorf("%w: verify workspace environment: %w", ErrSessionWorkspaceNotReady, err) + } + if env == nil { + return nil, fmt.Errorf("%w: task environment %q not found", ErrSessionWorkspaceNotReady, info.TaskEnvironmentID) + } + snapshot.env = env + return snapshot, nil +} + +func validateWorkspaceAdmission(taskID string, info *WorkspaceInfo, snapshot *workspaceAdmissionSnapshot) error { + if snapshot == nil || snapshot.session == nil { + return fmt.Errorf("verify workspace admission: session is unavailable") + } + session := snapshot.session + if err := validateWorkspaceSessionIdentity(taskID, info, session); err != nil { + return err + } + if info.TaskEnvironmentID == "" { + return validateUnboundWorkspaceSession(info, session) + } + return validateBoundWorkspaceEnvironment(info, session, snapshot.env) +} + +func validateWorkspaceSessionIdentity(taskID string, info *WorkspaceInfo, session *models.TaskSession) error { + if session.ID == "" || session.ID != info.SessionID { + return fmt.Errorf("%w: verify workspace admission: session identity changed", ErrSessionWorkspaceNotReady) + } + if taskID == "" || session.TaskID == "" || session.TaskID != taskID { + return fmt.Errorf("%w: verify workspace admission: session belongs to another task", ErrSessionWorkspaceNotReady) + } + if info.TaskID == "" || session.TaskID != info.TaskID { + return fmt.Errorf("%w: verify workspace admission: session task binding changed", ErrSessionWorkspaceNotReady) + } + return nil +} + +func validateUnboundWorkspaceSession(info *WorkspaceInfo, session *models.TaskSession) error { + if session.TaskEnvironmentID != "" { + return fmt.Errorf("%w: session environment binding is unavailable", ErrSessionWorkspaceNotReady) + } + return nil +} + +func validateBoundWorkspaceEnvironment( + info *WorkspaceInfo, + session *models.TaskSession, + env *models.TaskEnvironment, +) error { + if err := validateWorkspaceEnvironmentBinding(info, session, env); err != nil { + return err + } + if info.ValidatedTaskEnvironmentID == "" { + info.ValidatedTaskEnvironmentID = env.ID + } + if env.ID != info.ValidatedTaskEnvironmentID { + return fmt.Errorf("%w: task environment identity changed", ErrSessionWorkspaceNotReady) + } + if info.ValidatedExecutorType == "" && env.ExecutorType != "" { + info.ValidatedExecutorType = env.ExecutorType + } + if info.ValidatedExecutorType != "" && env.ExecutorType != info.ValidatedExecutorType { + return fmt.Errorf("%w: task environment executor changed", ErrSessionWorkspaceNotReady) + } + if info.ValidatedTaskEnvironmentGeneration == 0 { + info.ValidatedTaskEnvironmentGeneration = env.OwnershipGeneration + } + if info.ValidatedTaskEnvironmentGeneration != 0 && + env.OwnershipGeneration != info.ValidatedTaskEnvironmentGeneration { + return fmt.Errorf("%w: task environment ownership changed", ErrSessionWorkspaceNotReady) + } + switch env.Status { + case "", models.TaskEnvironmentStatusReady, models.TaskEnvironmentStatusStopped: + return nil + default: + return fmt.Errorf("%w: task environment is not attachable", ErrSessionWorkspaceNotReady) + } +} + +func validateWorkspaceEnvironmentBinding( + info *WorkspaceInfo, + session *models.TaskSession, + env *models.TaskEnvironment, +) error { + if session.TaskEnvironmentID != "" && session.TaskEnvironmentID != info.TaskEnvironmentID { + return fmt.Errorf("%w: verify workspace admission: session environment binding changed", ErrSessionWorkspaceNotReady) + } + if env == nil || env.ID != info.TaskEnvironmentID || env.TaskID == "" { + return fmt.Errorf("%w: task environment ownership is unavailable", ErrSessionWorkspaceNotReady) + } + return nil +} + +func appendWorkspaceCleanupTaskID(taskIDs []string, candidate string) []string { + if candidate == "" { + return taskIDs + } + for _, existing := range taskIDs { + if existing == candidate { + return taskIDs + } + } + return append(taskIDs, candidate) +} + +func workspaceCleanupTaskIDs(taskID string, snapshot *workspaceAdmissionSnapshot) []string { + taskIDs := make([]string, 0, 3) + taskIDs = appendWorkspaceCleanupTaskID(taskIDs, taskID) + taskIDs = appendWorkspaceCleanupTaskID(taskIDs, snapshot.session.TaskID) + if snapshot.env != nil && snapshot.env.TaskID != snapshot.session.TaskID { + taskIDs = appendWorkspaceCleanupTaskID(taskIDs, snapshot.env.TaskID) + } + return taskIDs +} + +func (m *Manager) ensureWorkspaceCleanupInactive(ctx context.Context, taskID string, snapshot *workspaceAdmissionSnapshot) error { + if snapshot == nil || snapshot.session == nil { + return fmt.Errorf("verify workspace cleanup: session is unavailable") + } + for _, cleanupTaskID := range workspaceCleanupTaskIDs(taskID, snapshot) { + active, err := m.executorProfileReader.HasActiveTaskResourceCleanupJob(ctx, cleanupTaskID) + if err != nil { + return fmt.Errorf("verify workspace cleanup: %w", err) + } + if active { + return fmt.Errorf("verify workspace cleanup: %w for task %q", errTaskCleanupActive, cleanupTaskID) + } + } + return nil +} + +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) + } + if resolved == nil { + return fmt.Errorf("verify cached workspace: session %q not found", execution.SessionID) + } + if execution.TaskEnvironmentID != "" && resolved.TaskEnvironmentID != execution.TaskEnvironmentID { + return fmt.Errorf("%w: cached execution environment changed", ErrSessionWorkspaceNotReady) + } + info = resolved + } + return m.ensureWorkspaceSessionAdmitted(ctx, execution.TaskID, info) +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_restore_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_restore_test.go new file mode 100644 index 0000000000..457313f38b --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_restore_test.go @@ -0,0 +1,225 @@ +package lifecycle + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/kandev/kandev/internal/task/models" +) + +func TestAgentStartsStillRejectTerminalSessions(t *testing.T) { + mgr := newTestManager(t) + mgr.SetExecutorProfileReader(&fakeExecutorProfileReader{session: &models.TaskSession{ + ID: terminalSessionID, TaskID: terminalTaskID, State: models.TaskSessionStateCompleted, + }}) + execution := &AgentExecution{ + ID: "execution-terminal-agent", + SessionID: terminalSessionID, + TaskID: terminalTaskID, + AgentCommand: "agent-command", + } + if err := mgr.executionStore.Add(execution); err != nil { + t.Fatalf("add execution: %v", err) + } + + if err := mgr.StartAgentProcess(context.Background(), execution.ID); !errors.Is(err, ErrSessionTerminal) { + t.Fatalf("StartAgentProcess() error = %v, want ErrSessionTerminal", err) + } +} + +func TestWorkspacePromotionStillRejectsTerminalSessions(t *testing.T) { + mgr := newTestManager(t) + mgr.SetExecutorProfileReader(&fakeExecutorProfileReader{session: &models.TaskSession{ + ID: terminalSessionID, TaskID: terminalTaskID, State: models.TaskSessionStateCompleted, + }}) + execution := &AgentExecution{ID: "execution-terminal-promotion", SessionID: terminalSessionID} + req := &LaunchRequest{SessionID: terminalSessionID, AgentProfileID: "profile-1"} + + if err := mgr.promoteWorkspaceExecution(context.Background(), execution, req); !errors.Is(err, ErrSessionTerminal) { + t.Fatalf("promoteWorkspaceExecution() error = %v, want ErrSessionTerminal", err) + } + if execution.AgentCommand != "" { + t.Fatalf("terminal promotion configured agent command %q", execution.AgentCommand) + } +} + +func TestPassthroughReconnectStillRejectsTerminalSessions(t *testing.T) { + mgr := newTestManager(t) + mgr.SetExecutorProfileReader(&fakeExecutorProfileReader{session: &models.TaskSession{ + ID: terminalSessionID, TaskID: terminalTaskID, State: models.TaskSessionStateCompleted, + }}) + + if _, err := mgr.EnsurePassthroughExecution(context.Background(), terminalSessionID); !errors.Is(err, ErrSessionTerminal) { + t.Fatalf("EnsurePassthroughExecution() error = %v, want ErrSessionTerminal", err) + } +} + +func TestWorkspaceRestoreRejectsActiveCleanupWithoutMutation(t *testing.T) { + entryPoints := []struct { + name string + call func(*Manager) error + }{ + { + name: "environment", + call: func(m *Manager) error { + _, err := m.GetOrEnsureExecutionForEnvironment(context.Background(), terminalEnvironmentID) + return err + }, + }, + { + name: "session", + call: func(m *Manager) error { + _, err := m.GetOrEnsureExecution(context.Background(), terminalSessionID) + return err + }, + }, + { + name: "explicit workspace session", + call: func(m *Manager) error { + _, err := m.EnsureWorkspaceExecutionForSession( + context.Background(), terminalTaskID, terminalSessionID, + ) + return err + }, + }, + } + + for _, entryPoint := range entryPoints { + t.Run(entryPoint.name, func(t *testing.T) { + mgr, backend := newTerminalSessionManager(t, models.TaskSessionStateCompleted) + mgr.SetExecutorProfileReader(&fakeExecutorProfileReader{ + session: &models.TaskSession{ + ID: terminalSessionID, TaskID: terminalTaskID, + State: models.TaskSessionStateCompleted, + }, + env: &models.TaskEnvironment{ + ID: terminalEnvironmentID, TaskID: terminalTaskID, + Status: models.TaskEnvironmentStatusStopped, + }, + cleanupActive: true, + }) + + if err := entryPoint.call(mgr); !errors.Is(err, errTaskCleanupActive) { + t.Fatalf("workspace restore error = %v, want errTaskCleanupActive", err) + } + if got := backend.createCount.Load(); got != 0 { + t.Fatalf("CreateInstance calls = %d, want 0", got) + } + if got := backend.stopCount.Load(); got != 0 { + t.Fatalf("StopInstance calls = %d, want 0", got) + } + }) + } +} + +func TestWorkspaceRestoreRejectsAdmissionChangesWithoutMutation(t *testing.T) { + tests := []struct { + name string + info func(*WorkspaceInfo) + env func(*models.TaskEnvironment) + wantErr error + wantInText string + }{ + { + name: "archived task", + info: func(info *WorkspaceInfo) { info.TaskArchived = true }, + wantErr: ErrSessionWorkspaceNotReady, + }, + { + name: "archived workspace owner", + info: func(info *WorkspaceInfo) { info.WorkspaceOwnerArchived = true }, + wantErr: ErrSessionWorkspaceNotReady, + }, + { + name: "environment ownership changed", + info: func(info *WorkspaceInfo) { info.ValidatedTaskEnvironmentGeneration = 3 }, + env: func(env *models.TaskEnvironment) { env.OwnershipGeneration = 4 }, + wantErr: ErrSessionWorkspaceNotReady, + }, + { + name: "environment is still materializing", + env: func(env *models.TaskEnvironment) { env.Status = models.TaskEnvironmentStatusCreating }, + wantErr: ErrSessionWorkspaceNotReady, + }, + { + name: "session environment binding changed", + info: func(info *WorkspaceInfo) { info.TaskEnvironmentID = "env-other" }, + wantErr: ErrSessionWorkspaceNotReady, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + info := &WorkspaceInfo{ + TaskID: terminalTaskID, SessionID: terminalSessionID, + TaskEnvironmentID: terminalEnvironmentID, WorkspacePath: "/workspace/task", + } + if tc.info != nil { + tc.info(info) + } + env := &models.TaskEnvironment{ + ID: terminalEnvironmentID, TaskID: terminalTaskID, + Status: models.TaskEnvironmentStatusStopped, OwnershipGeneration: 3, + } + if tc.env != nil { + tc.env(env) + } + provider := &mockWorkspaceInfoProvider{infos: map[string]*WorkspaceInfo{terminalSessionID: info}} + mgr, backend := newEnvironmentExecutionTestManager(t, provider) + mgr.SetExecutorProfileReader(&fakeExecutorProfileReader{ + session: &models.TaskSession{ + ID: terminalSessionID, TaskID: terminalTaskID, + TaskEnvironmentID: terminalEnvironmentID, State: models.TaskSessionStateCompleted, + }, + env: env, + }) + + _, err := mgr.EnsureWorkspaceExecutionForSession(context.Background(), terminalTaskID, terminalSessionID) + if !errors.Is(err, tc.wantErr) { + t.Fatalf("workspace restore error = %v, want %v", err, tc.wantErr) + } + if tc.wantInText != "" && !strings.Contains(err.Error(), tc.wantInText) { + t.Fatalf("workspace restore error = %v, want text %q", err, tc.wantInText) + } + if got := backend.createCount.Load(); got != 0 { + t.Fatalf("CreateInstance calls = %d, want 0", got) + } + }) + } +} + +func TestCachedWorkspaceRestoreRechecksProviderAdmission(t *testing.T) { + provider := &mockWorkspaceInfoProvider{infos: map[string]*WorkspaceInfo{ + terminalSessionID: { + TaskID: terminalTaskID, SessionID: terminalSessionID, + TaskEnvironmentID: terminalEnvironmentID, TaskArchived: true, + }, + }} + mgr, backend := newEnvironmentExecutionTestManager(t, provider) + mgr.SetExecutorProfileReader(&fakeExecutorProfileReader{ + session: &models.TaskSession{ + ID: terminalSessionID, TaskID: terminalTaskID, + TaskEnvironmentID: terminalEnvironmentID, State: models.TaskSessionStateCompleted, + }, + env: &models.TaskEnvironment{ + ID: terminalEnvironmentID, TaskID: terminalTaskID, + Status: models.TaskEnvironmentStatusStopped, + }, + }) + if err := mgr.executionStore.Add(&AgentExecution{ + ID: "execution-cached-archived", SessionID: terminalSessionID, + TaskID: terminalTaskID, TaskEnvironmentID: terminalEnvironmentID, + }); err != nil { + t.Fatalf("add cached execution: %v", err) + } + + _, err := mgr.GetOrEnsureExecution(context.Background(), terminalSessionID) + if !errors.Is(err, ErrSessionWorkspaceNotReady) { + t.Fatalf("cached workspace restore error = %v, want ErrSessionWorkspaceNotReady", err) + } + if got := backend.createCount.Load(); got != 0 { + t.Fatalf("CreateInstance calls = %d, want 0", got) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/types.go b/apps/backend/internal/agent/runtime/lifecycle/types.go index a84a4f693d..7e6ad9fa29 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/types.go +++ b/apps/backend/internal/agent/runtime/lifecycle/types.go @@ -1155,16 +1155,25 @@ type WorkspaceInfo struct { // is missing or no longer matches the selected environment. ValidatedTaskEnvironmentID string ValidatedExecutorType string - WorkspacePath string // Path to the workspace/repository - WorkspaceFolders []WorkspaceFolderSpec - WorkspaceRepositories []WorkspaceRepositorySpec - TaskDirName string - WorkspaceID string - AgentProfileID string // Stable Office agent identity (or the execution profile for legacy sessions) - ExecutionProfileID string // Concrete CLI profile selected for this execution - ExecutorProfileID string // Concrete executor profile selected for this execution - AgentID string // Agent type ID (e.g., "auggie", "codex") - required for runtime creation - ACPSessionID string // Agent's session ID for conversation resumption (from session metadata) + // ValidatedTaskEnvironmentGeneration fences workspace attachment against a + // concurrent environment ownership transfer. A zero value is retained for + // legacy callers that do not project the generation. + ValidatedTaskEnvironmentGeneration int64 + // TaskArchived and WorkspaceOwnerArchived are projected by the task service + // so lifecycle callers cannot restore an archived task through a cached or + // direct workspace entry point. + TaskArchived bool + WorkspaceOwnerArchived bool + WorkspacePath string // Path to the workspace/repository + WorkspaceFolders []WorkspaceFolderSpec + WorkspaceRepositories []WorkspaceRepositorySpec + TaskDirName string + WorkspaceID string + AgentProfileID string // Stable Office agent identity (or the execution profile for legacy sessions) + ExecutionProfileID string // Concrete CLI profile selected for this execution + ExecutorProfileID string // Concrete executor profile selected for this execution + AgentID string // Agent type ID (e.g., "auggie", "codex") - required for runtime creation + ACPSessionID string // Agent's session ID for conversation resumption (from session metadata) // SessionMode is the persisted session permission mode (e.g. "acceptEdits") // from session metadata, declared via the set_session_mode workflow action or // a user toggle. Applied as a mode override at ACP session init so a fresh diff --git a/apps/backend/internal/orchestrator/handlers/handlers.go b/apps/backend/internal/orchestrator/handlers/handlers.go index 23c97bfad9..c9a7a86336 100644 --- a/apps/backend/internal/orchestrator/handlers/handlers.go +++ b/apps/backend/internal/orchestrator/handlers/handlers.go @@ -5,6 +5,7 @@ import ( "context" "errors" + "github.com/kandev/kandev/internal/agent/runtime/routingerr" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/orchestrator" "github.com/kandev/kandev/internal/orchestrator/dto" @@ -137,6 +138,7 @@ func (h *Handlers) wsLaunchSession(ctx context.Context, msg *ws.Message) (*ws.Me resp, err := h.service.LaunchSession(ctx, &req) if err != nil { + intent := orchestrator.ResolveIntent(&req) if recoveryResponse, responseErr := taskArchivedConflictResponse(msg, err); recoveryResponse != nil || responseErr != nil { return recoveryResponse, responseErr } @@ -147,15 +149,19 @@ func (h *Handlers) wsLaunchSession(ctx context.Context, msg *ws.Message) (*ws.Me if orchestrator.IsBenignLaunchTeardownErr(err) { h.logger.Warn("session launch aborted during shutdown", zap.String("task_id", req.TaskID), - zap.String("intent", string(orchestrator.ResolveIntent(&req))), + zap.String("intent", string(intent)), zap.String("error", err.Error())) } else { h.logger.Error("failed to launch session", zap.String("task_id", req.TaskID), - zap.String("intent", string(orchestrator.ResolveIntent(&req))), + zap.String("intent", string(intent)), zap.Error(err)) } - return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to launch session: "+err.Error(), nil) + publicErr := err + if intent == orchestrator.IntentRestoreWorkspace { + publicErr = routingerr.SanitizeError(err) + } + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to launch session: "+publicErr.Error(), nil) } return ws.NewResponse(msg.ID, msg.Action, resp) } diff --git a/apps/backend/internal/orchestrator/session_launch.go b/apps/backend/internal/orchestrator/session_launch.go index ddee442939..3ed29d8485 100644 --- a/apps/backend/internal/orchestrator/session_launch.go +++ b/apps/backend/internal/orchestrator/session_launch.go @@ -159,11 +159,18 @@ func IsBenignLaunchTeardownErr(err error) bool { // LaunchSession is the unified entry point for all session operations. func (s *Service) LaunchSession(ctx context.Context, req *LaunchSessionRequest) (*LaunchSessionResponse, error) { + req.Prompt = strings.TrimSpace(req.Prompt) + intent := ResolveIntent(req) // Every intent funnels through here. SessionID is empty when creating, so // that case is carried by the task check alone. // Launching a session starts an agent turn: session.prompt. - if err := s.authorizeTaskPrompt(ctx, req.TaskID); err != nil { - return nil, err + // Workspace restoration only opens retained infrastructure. It must not + // require permission to start or resume an agent; lifecycle applies the + // session.exec check at the execution boundary. + if intent != IntentRestoreWorkspace { + if err := s.authorizeTaskPrompt(ctx, req.TaskID); err != nil { + return nil, err + } } // Existing-session launches must also prove that the supplied session and // task belong together; independent reach checks do not establish that @@ -174,9 +181,6 @@ func (s *Service) LaunchSession(ctx context.Context, req *LaunchSessionRequest) if err := s.claimLaunchAttachments(ctx, req); err != nil { return nil, fmt.Errorf("claim launch attachments: %w", err) } - intent := ResolveIntent(req) - req.Prompt = strings.TrimSpace(req.Prompt) - switch intent { case IntentPrepare: return s.launchPrepare(ctx, req) @@ -411,12 +415,14 @@ func (s *Service) launchRestoreWorkspace(ctx context.Context, req *LaunchSession 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) resp := &LaunchSessionResponse{ - Success: true, - TaskID: req.TaskID, - SessionID: req.SessionID, - State: string(session.State), + Success: true, + TaskID: req.TaskID, + SessionID: req.SessionID, + AgentExecutionID: agentExecutionID, + State: string(session.State), } if len(session.Worktrees) > 0 { wt := session.Worktrees[0] diff --git a/apps/backend/internal/orchestrator/session_launch_test.go b/apps/backend/internal/orchestrator/session_launch_test.go index a37e0941ee..c0fd5e4f57 100644 --- a/apps/backend/internal/orchestrator/session_launch_test.go +++ b/apps/backend/internal/orchestrator/session_launch_test.go @@ -349,6 +349,23 @@ func TestLaunchRestoreWorkspace_Success(t *testing.T) { } } +func TestLaunchSessionTrimsPromptBeforeInferringResume(t *testing.T) { + repo := setupTestRepo(t) + seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateCompleted) + svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), &mockAgentManager{ + repoForExecutionLookup: repo, + }) + + _, err := svc.LaunchSession(context.Background(), &LaunchSessionRequest{ + TaskID: "task1", + SessionID: "session1", + Prompt: " \n\t", + }) + if err == nil || !strings.Contains(err.Error(), "session is completed and cannot be resumed") { + t.Fatalf("LaunchSession error = %v, want completed-session resume rejection", err) + } +} + // --- launchPrepare passthrough upgrade --- func TestIsPassthroughProfile(t *testing.T) { diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index cc177bb4d7..57d33fa209 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -3493,6 +3493,11 @@ func (s *Service) populateEnvironmentWorkspaceInfo(ctx context.Context, session if session.TaskEnvironmentID != "" && env.ID != session.TaskEnvironmentID { return } + // WorkspacePath is the canonical task-root identity. A repository-less + // environment is still restorable, so do not require a repo row here. + if resp.WorktreePath == nil && env.WorkspacePath != "" { + resp.WorktreePath = &env.WorkspacePath + } if len(env.Repos) == 0 { return } diff --git a/apps/backend/internal/orchestrator/task_operations_test.go b/apps/backend/internal/orchestrator/task_operations_test.go index 0277d7b9f1..1fa626f5ea 100644 --- a/apps/backend/internal/orchestrator/task_operations_test.go +++ b/apps/backend/internal/orchestrator/task_operations_test.go @@ -7543,6 +7543,35 @@ func TestGetTaskSessionStatus_NeedsWorkspaceRestore_TerminalWithoutWorktree(t *t } } +func TestGetTaskSessionStatus_NeedsWorkspaceRestore_RepositorylessEnvironment(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + + seedTaskAndSession(t, repo, "task1", "session1", models.TaskSessionStateCompleted) + if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ + ID: "env1", TaskID: "task1", ExecutorType: "local", + WorkspacePath: "/tmp/task1", Status: models.TaskEnvironmentStatusReady, + }); err != nil { + t.Fatalf("CreateTaskEnvironment: %v", err) + } + + taskRepo := newMockTaskRepo() + agentMgr := &mockAgentManager{repoForExecutionLookup: repo} + svc := createTestServiceWithAgent(repo, newMockStepGetter(), taskRepo, agentMgr) + svc.executor = executor.NewExecutor(agentMgr, repo, testLogger(), executor.ExecutorConfig{}) + + resp, err := svc.GetTaskSessionStatus(ctx, "task1", "session1") + if err != nil { + t.Fatalf("GetTaskSessionStatus returned error: %v", err) + } + if !resp.NeedsWorkspaceRestore { + t.Fatal("expected NeedsWorkspaceRestore=true for a repository-less retained environment") + } + if resp.WorktreePath == nil || *resp.WorktreePath != "/tmp/task1" { + t.Fatalf("WorktreePath = %v, want canonical workspace path", resp.WorktreePath) + } +} + // sessionUpdatingAgentManager wraps mockAgentManager to update session state // when StartAgentProcess is called, simulating the agent initialization flow. type sessionUpdatingAgentManager struct { diff --git a/apps/backend/internal/task/service/service_turns.go b/apps/backend/internal/task/service/service_turns.go index 15b0536058..e0e4c7e846 100644 --- a/apps/backend/internal/task/service/service_turns.go +++ b/apps/backend/internal/task/service/service_turns.go @@ -899,12 +899,24 @@ func (s *Service) GetWorkspaceInfoForSession(ctx context.Context, taskID, sessio applyTaskEnvironmentToWorkspaceInfo(info, taskEnv) info.ValidatedTaskEnvironmentID = taskEnv.ID info.ValidatedExecutorType = taskEnv.ExecutorType + info.ValidatedTaskEnvironmentGeneration = taskEnv.OwnershipGeneration if info.ExecutorType == "" { info.ExecutorType = taskEnv.ExecutorType } info.TaskDirName = taskEnv.TaskDirName + if taskEnv.TaskID != "" && taskEnv.TaskID != taskID { + owner, ownerErr := s.tasks.GetTask(ctx, taskEnv.TaskID) + if ownerErr != nil { + return nil, fmt.Errorf("get workspace owner task: %w", ownerErr) + } + info.WorkspaceOwnerArchived = owner != nil && owner.ArchivedAt != nil + } } - if err := s.populateWorkspaceRepositorySpecs(ctx, taskID, session.Worktrees, info); err != nil { + workspaceInventory := session.Worktrees + if taskEnv != nil { + workspaceInventory = taskEnv.Repos + } + if err := s.populateWorkspaceRepositorySpecs(ctx, taskID, workspaceInventory, info); err != nil { return nil, err } @@ -1004,6 +1016,7 @@ func (s *Service) populateWorkspaceRepositorySpecs(ctx context.Context, taskID s return fmt.Errorf("get workspace task: %w", err) } else if task != nil { info.WorkspaceID = task.WorkspaceID + info.TaskArchived = task.ArchivedAt != nil } worktreesByIdentity := make(map[workspaceWorktreeKey]*models.TaskEnvironmentRepo, len(sessionWorktrees)) for _, worktree := range sessionWorktrees { diff --git a/apps/web/components/task/changes-panel-body.tsx b/apps/web/components/task/changes-panel-body.tsx index 6f34dd8310..a1dcd9e759 100644 --- a/apps/web/components/task/changes-panel-body.tsx +++ b/apps/web/components/task/changes-panel-body.tsx @@ -16,6 +16,7 @@ import { import type { ChangesPanelBodyProps } from "./changes-panel-data"; import { useTranslation } from "react-i18next"; import { IconAlertTriangle } from "@tabler/icons-react"; +import { WorkspaceUnavailable } from "./workspace-unavailable"; function ComparisonTargetNotice({ comparisonTargets, @@ -47,7 +48,9 @@ function ComparisonTargetNotice({ function ChangesPanelDialogsSection({ dialogs, isLoading, -}: Pick) { + workspaceBlocked, +}: Pick & { workspaceBlocked: boolean }) { + if (workspaceBlocked) return null; return ( <>
- + {workspaceBlocked && !props.hasAnything ? ( + + ) : ( + <> + {workspaceBlocked && ( + + )} + + + )}
- + ); } diff --git a/apps/web/components/task/changes-panel-data.tsx b/apps/web/components/task/changes-panel-data.tsx index c57db07222..fca7e05057 100644 --- a/apps/web/components/task/changes-panel-data.tsx +++ b/apps/web/components/task/changes-panel-data.tsx @@ -55,6 +55,8 @@ import { } from "./use-remote-contribution-resolution"; import { useRemoteContributionResolution } from "./use-remote-contribution-resolution"; import { useTranslation } from "react-i18next"; +import { useWorkspaceRestoration } from "@/hooks/domains/session/use-workspace-restoration"; +import type { WorkspaceRestorationAttempt } from "@/lib/state/slices/session-runtime/workspace-restoration"; function useChangesPanelStoreData() { const { t } = useTranslation(); @@ -156,6 +158,9 @@ export type ChangesPanelBodyProps = { pullBehind: number; }>; prByRepo?: Record; + workspaceRestoration?: WorkspaceRestorationAttempt | null; + onRestoreWorkspace?: () => void; + restoreWorkspaceDisabled?: boolean; }; function usePerRepoCallbacks( @@ -368,6 +373,7 @@ export function useChangesPanelData() { const { t } = useTranslation(); const { activeTaskId, activeSessionId, baseBranch, gitCredentialDisplay } = useChangesPanelStoreData(); + const workspaceRestoration = useWorkspaceRestoration(activeTaskId, activeSessionId); const baseBranchByRepo = useBaseBranchByRepo(activeTaskId); const git = useSessionGit(activeSessionId); const { toast } = useToast(); @@ -453,6 +459,7 @@ export function useChangesPanelData() { walkthroughRequestReady, resolution, resolutionTarget, + workspaceRestoration, pushDisabled: remoteActionPolicy.pushDisabled, pullDisabled: remoteActionPolicy.pullDisabled, pullDisabledReason, @@ -467,11 +474,80 @@ type ChangesPanelCallbacks = { onOpenReview?: () => void; }; +type ChangesPanelWorkspaceActions = Pick< + ChangesPanelBodyProps, + | "onRevertCommit" + | "onStageAll" + | "onUnstageAll" + | "onStage" + | "onUnstage" + | "onBulkStage" + | "onBulkUnstage" + | "onBulkDiscard" + | "onPush" + | "onForcePush" + | "onRepoStageAll" + | "onRepoUnstageAll" + | "onRepoCommit" + | "onRepoPush" + | "onRepoCreatePR" +>; + +function buildChangesPanelWorkspaceActions( + data: ReturnType, + workspaceBlocked: boolean, +): ChangesPanelWorkspaceActions { + const { git, gitHandlers, localDialogs, repoCallbacks } = data; + if (workspaceBlocked) { + return { + onRevertCommit: undefined, + onStageAll: () => undefined, + onUnstageAll: () => undefined, + onStage: async () => undefined, + onUnstage: async () => undefined, + onBulkStage: () => undefined, + onBulkUnstage: () => undefined, + onBulkDiscard: () => undefined, + onPush: () => undefined, + onForcePush: () => undefined, + onRepoStageAll: undefined, + onRepoUnstageAll: undefined, + onRepoCommit: undefined, + onRepoPush: undefined, + onRepoCreatePR: undefined, + }; + } + return { + onRevertCommit: gitHandlers.handleRevertCommit, + onStageAll: git.stageAll, + onUnstageAll: git.unstageAll, + onStage: (path, repo) => git.stageFile([path], repo).then(() => undefined), + onUnstage: (path, repo) => git.unstageFile([path], repo).then(() => undefined), + onBulkStage: (paths) => { + git.stageFile(paths).catch(() => undefined); + }, + onBulkUnstage: (paths) => { + git.unstageFile(paths).catch(() => undefined); + }, + onBulkDiscard: localDialogs.handleBulkDiscardClick, + onPush: () => gitHandlers.handlePush(), + onForcePush: () => gitHandlers.handleForcePush(), + onRepoStageAll: repoCallbacks.onRepoStageAll, + onRepoUnstageAll: repoCallbacks.onRepoUnstageAll, + onRepoCommit: repoCallbacks.onRepoCommit, + onRepoPush: repoCallbacks.onRepoPush, + onRepoCreatePR: repoCallbacks.onRepoCreatePR, + }; +} + export function buildChangesPanelBodyProps( data: ReturnType, callbacks: ChangesPanelCallbacks, ): ChangesPanelBodyProps { - const { git, gitHandlers, localDialogs, repoCallbacks, staged } = data; + const { git, staged } = data; + const workspaceBlocked = + data.workspaceRestoration.status !== null && data.workspaceRestoration.status !== "ready"; + const workspaceActions = buildChangesPanelWorkspaceActions(data, workspaceBlocked); return { hasAnything: git.hasAnything || data.hasPRFiles || data.hasPRCommits, hasUnstaged: git.hasUnstaged, @@ -483,10 +559,10 @@ export function buildChangesPanelBodyProps( resolution: data.resolution, resolutionTarget: data.resolutionTarget, providerPRNumber: data.selectedPR?.pr_number, - pushDisabled: data.pushDisabled, - pullDisabled: data.pullDisabled, - canPush: git.canPush, - canCreatePR: git.canCreatePR, + pushDisabled: data.pushDisabled || workspaceBlocked, + pullDisabled: data.pullDisabled || workspaceBlocked, + canPush: git.canPush && !workspaceBlocked, + canCreatePR: git.canCreatePR && !workspaceBlocked, existingPrUrl: data.existingPrUrl, unstagedFiles: data.unstagedFiles, stagedFiles: data.stagedFiles, @@ -506,31 +582,16 @@ export function buildChangesPanelBodyProps( onOpenDiffFile: callbacks.onOpenDiffFile, onEditFile: callbacks.onEditFile, onOpenCommitDetail: callbacks.onOpenCommitDetail, - onRevertCommit: gitHandlers.handleRevertCommit, onOpenReview: callbacks.onOpenReview, - onStageAll: git.stageAll, - onUnstageAll: git.unstageAll, - onStage: (path, repo) => git.stageFile([path], repo).then(() => undefined), - onUnstage: (path, repo) => git.unstageFile([path], repo).then(() => undefined), - onBulkStage: (paths) => { - git.stageFile(paths).catch(() => undefined); - }, - onBulkUnstage: (paths) => { - git.unstageFile(paths).catch(() => undefined); - }, - onBulkDiscard: localDialogs.handleBulkDiscardClick, - onPush: () => gitHandlers.handlePush(), - onForcePush: () => gitHandlers.handleForcePush(), + ...workspaceActions, stagedFileCount: staged.stagedFileCount, stagedAdditions: staged.stagedAdditions, stagedDeletions: staged.stagedDeletions, - onRepoStageAll: repoCallbacks.onRepoStageAll, - onRepoUnstageAll: repoCallbacks.onRepoUnstageAll, - onRepoCommit: repoCallbacks.onRepoCommit, - onRepoPush: repoCallbacks.onRepoPush, - onRepoCreatePR: repoCallbacks.onRepoCreatePR, repoDisplayName: data.repoDisplayName, perRepoStatus: git.perRepoStatus, prByRepo: data.prByRepo, + workspaceRestoration: data.workspaceRestoration.attempt, + onRestoreWorkspace: () => void data.workspaceRestoration.restore(), + restoreWorkspaceDisabled: data.workspaceRestoration.status === "pending", }; } diff --git a/apps/web/components/task/ensure-session-error.test.tsx b/apps/web/components/task/ensure-session-error.test.tsx index 58569842ad..a3ffd0dfce 100644 --- a/apps/web/components/task/ensure-session-error.test.tsx +++ b/apps/web/components/task/ensure-session-error.test.tsx @@ -174,6 +174,26 @@ describe("EnsureSessionErrorBanner", () => { expect(screen.getByText("Resume attempt")).toBeTruthy(); expect(screen.getByText(RESUME_FAILURE_DETAIL)).toBeTruthy(); }); + + it("renders page-wide feedback when automatic recovery and workspace restoration fail", () => { + render( + {}} + recoveryFailure={{ + outcome: "recovery_failed", + resumeError: RESUME_FAILURE_DETAIL, + restoreError: RESTORE_FAILURE_DETAIL, + }} + />, + ); + + expect(screen.getByTestId("session-recovery-error")).toBeTruthy(); + expect(screen.getByText("Session recovery failed")).toBeTruthy(); + expect(screen.getByText("The session could not be recovered.")).toBeTruthy(); + expect(screen.queryByTestId("session-recovery-notice")).toBeNull(); + }); }); describe("EnsureSessionErrorEmptyState", () => { diff --git a/apps/web/components/task/file-browser-content-area.tsx b/apps/web/components/task/file-browser-content-area.tsx new file mode 100644 index 0000000000..2099d0aa5e --- /dev/null +++ b/apps/web/components/task/file-browser-content-area.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useTranslation } from "react-i18next"; +import { renderSessionOrLoadState } from "./file-browser-load-state"; +import { + FileTreeView, + SearchResultsList, + type FileBrowserContentAreaProps, +} from "./file-browser-parts"; +import { WorkspaceUnavailable } from "./workspace-unavailable"; + +export function FileBrowserContentArea(props: FileBrowserContentAreaProps) { + const { t } = useTranslation(); + const workspaceBlocked = Boolean( + props.workspaceRestoration && props.workspaceRestoration.status !== "ready", + ); + if (workspaceBlocked) { + const notice = ( + + ); + if (props.isSearchActive && props.searchResults !== null) { + return ( +
+ {notice} + +
+ ); + } + if (props.tree) { + return ( +
+ {notice} + +
+ ); + } + return notice; + } + if (props.isSearchActive && props.searchResults !== null) { + return ( + + ); + } + const loadStateResult = renderSessionOrLoadState({ + isSessionFailed: props.isSessionFailed, + sessionError: props.sessionError, + loadState: props.loadState, + isLoadingTree: props.isLoadingTree, + tree: props.tree, + loadError: props.loadError, + onRetry: props.onRetry, + workspaceRestoration: props.workspaceRestoration, + onRestoreWorkspace: props.onRestoreWorkspace, + restoreWorkspaceDisabled: props.restoreWorkspaceDisabled, + }); + if (loadStateResult) return loadStateResult; + if (props.tree) return ; + return
{t("task:noFilesFound")}
; +} diff --git a/apps/web/components/task/file-browser-data.ts b/apps/web/components/task/file-browser-data.ts new file mode 100644 index 0000000000..4290879c7e --- /dev/null +++ b/apps/web/components/task/file-browser-data.ts @@ -0,0 +1,78 @@ +"use client"; + +import { useMemo } from "react"; +import { useSession } from "@/hooks/domains/session/use-session"; +import { useWorkspaceRestoration } from "@/hooks/domains/session/use-workspace-restoration"; +import { useRepository } from "@/hooks/domains/workspace/use-repository"; +import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status"; +import { useAppStore } from "@/components/state-provider"; +import { useOpenSessionFolder } from "@/hooks/use-open-session-folder"; +import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; +import { useFileBrowserSearch, useFileBrowserTree } from "./file-browser-hooks"; +import { getFileBrowserSessionWorkspacePath, resolveFileBrowserPaths } from "./file-browser-path"; + +export function getFileBrowserResetKey({ + sessionId, + environmentId, + worktreeCount, + workspaceFilesRefresh, +}: { + sessionId: string; + environmentId?: string | null; + worktreeCount: number; + workspaceFilesRefresh: number; +}) { + return `${environmentId ?? sessionId}:${worktreeCount}:${workspaceFilesRefresh}`; +} + +function useFileBrowserResetKey(sessionId: string, environmentId?: string | null) { + const worktreeCount = useAppStore( + (state) => state.sessionWorktreesBySessionId.itemsBySessionId[sessionId]?.length ?? 0, + ); + const workspaceFilesRefresh = useAppStore( + (state) => state.workspaceFilesRefresh.bySessionId[sessionId] ?? 0, + ); + return getFileBrowserResetKey({ + sessionId, + environmentId, + worktreeCount, + workspaceFilesRefresh, + }); +} + +export function useFileBrowserData(sessionId: string, environmentId: string | null | undefined) { + const { session, isFailed: isSessionFailed, errorMessage: sessionError } = useSession(sessionId); + const workspaceRestoration = useWorkspaceRestoration(session?.task_id, sessionId, environmentId); + const repository = useRepository(session?.repository_id ?? null); + const gitStatus = useSessionGitStatus(sessionId); + const { open: openFolder } = useOpenSessionFolder(sessionId); + const { copied, copy: copyPath } = useCopyToClipboard(1000); + const search = useFileBrowserSearch(sessionId); + const resetKey = useFileBrowserResetKey(sessionId, environmentId); + const treeState = useFileBrowserTree(sessionId, resetKey); + const isTreeLoaded = !treeState.isLoadingTree && treeState.tree !== null; + const fileStatuses = useMemo( + () => + new Map(Object.entries(gitStatus?.files ?? {}).map(([path, info]) => [path, info.status])), + [gitStatus?.files], + ); + const paths = resolveFileBrowserPaths({ + sessionWorktreePath: getFileBrowserSessionWorkspacePath(session), + repositoryLocalPath: repository?.local_path, + treePath: treeState.tree?.path, + treeLoaded: isTreeLoaded, + }); + return { + isSessionFailed, + sessionError, + openFolder, + copied, + copyPath, + search, + treeState, + workspaceRestoration, + isTreeLoaded, + fileStatuses, + ...paths, + }; +} diff --git a/apps/web/components/task/file-browser-load-state.test.tsx b/apps/web/components/task/file-browser-load-state.test.tsx index 8bb0b42a83..36f139e779 100644 --- a/apps/web/components/task/file-browser-load-state.test.tsx +++ b/apps/web/components/task/file-browser-load-state.test.tsx @@ -24,4 +24,32 @@ describe("renderSessionOrLoadState", () => { expect(screen.getByText("raw environment preparation failure")).toBeTruthy(); expect(screen.queryByText("Session failed")).toBeNull(); }); + + it("surfaces workspace restoration failures instead of waiting forever", () => { + const onRestoreWorkspace = () => {}; + const result = renderSessionOrLoadState({ + isSessionFailed: false, + sessionError: null, + loadState: "waiting", + isLoadingTree: true, + tree: null, + loadError: null, + onRetry: () => {}, + workspaceRestoration: { + taskId: "task-1", + sessionId: "session-1", + environmentId: "environment-1", + revision: 1, + status: "error", + details: "workspace restore failed", + }, + onRestoreWorkspace, + }); + + render(<>{result}); + + expect(screen.getByText("Couldn't reconnect to this task's workspace.")).toBeTruthy(); + expect(screen.queryByTestId("file-tree-waiting")).toBeNull(); + expect(screen.getByTestId("workspace-retry")).toBeTruthy(); + }); }); diff --git a/apps/web/components/task/file-browser-load-state.tsx b/apps/web/components/task/file-browser-load-state.tsx index c6624af03f..4f70d72947 100644 --- a/apps/web/components/task/file-browser-load-state.tsx +++ b/apps/web/components/task/file-browser-load-state.tsx @@ -2,6 +2,7 @@ import { PanelLoadingState } from "@/components/panel-loading-state"; import type { FileTreeNode } from "@/lib/types/backend"; +import type { WorkspaceRestorationAttempt } from "@/lib/state/slices/session-runtime/workspace-restoration"; import { WorkspaceUnavailable } from "./workspace-unavailable"; import { t } from "@/lib/i18n"; @@ -13,6 +14,9 @@ type RenderSessionOrLoadStateInput = { tree: FileTreeNode | null; loadError: string | null; onRetry: () => void; + workspaceRestoration?: WorkspaceRestorationAttempt | null; + onRestoreWorkspace?: () => void; + restoreWorkspaceDisabled?: boolean; }; export function renderSessionOrLoadState({ @@ -23,7 +27,19 @@ export function renderSessionOrLoadState({ tree, loadError, onRetry, + workspaceRestoration, + onRestoreWorkspace, + restoreWorkspaceDisabled, }: RenderSessionOrLoadStateInput) { + if (workspaceRestoration && workspaceRestoration.status !== "ready") { + return ( + + ); + } if (isSessionFailed) { return ; } diff --git a/apps/web/components/task/file-browser-parts.tsx b/apps/web/components/task/file-browser-parts.tsx index c09a02f14e..7ab64c5256 100644 --- a/apps/web/components/task/file-browser-parts.tsx +++ b/apps/web/components/task/file-browser-parts.tsx @@ -24,10 +24,10 @@ import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-a import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import type { FileTreeNode } from "@/lib/types/backend"; import type { FileInfo } from "@/lib/state/store"; +import type { WorkspaceRestorationAttempt } from "@/lib/state/slices/session-runtime/workspace-restoration"; import type { FileBrowserRow } from "./file-browser-hooks"; import { areTreeNodeRowPropsEqual, type TreeNodeRowProps } from "./file-tree-row-props"; import { InlineFileInput } from "./inline-file-input"; -import { renderSessionOrLoadState } from "./file-browser-load-state"; import { FileContextMenu, useFileDeleteAction, @@ -412,7 +412,7 @@ export function SearchResultsList({ export { FileBrowserToolbar } from "./file-browser-toolbar"; -type FileBrowserContentAreaProps = { +export type FileBrowserContentAreaProps = { isSearchActive: boolean; searchResults: string[] | null; isSessionFailed: boolean; @@ -438,6 +438,9 @@ type FileBrowserContentAreaProps = { onCreateFileSubmit: (parentPath: string, name: string) => void; onCancelCreate: () => void; onRetry: () => void; + workspaceRestoration?: WorkspaceRestorationAttempt | null; + onRestoreWorkspace?: () => void; + restoreWorkspaceDisabled?: boolean; setTree: React.Dispatch>; isSelectedFn?: (path: string) => boolean; onSelect?: (path: string, e: React.MouseEvent) => boolean; @@ -506,7 +509,7 @@ function scheduleVirtualRowReveal(reveal: () => void): () => void { }; } -function FileTreeView(props: FileBrowserContentAreaProps) { +export function FileTreeView(props: FileBrowserContentAreaProps) { if (!props.tree) return null; return ; } @@ -602,30 +605,3 @@ function VirtualizedFileTreeView(props: FileBrowserContentAreaProps) { ); } - -export function FileBrowserContentArea(props: FileBrowserContentAreaProps) { - const { t } = useTranslation(); - if (props.isSearchActive && props.searchResults !== null) { - return ( - - ); - } - const loadStateResult = renderSessionOrLoadState({ - isSessionFailed: props.isSessionFailed, - sessionError: props.sessionError, - loadState: props.loadState, - isLoadingTree: props.isLoadingTree, - tree: props.tree, - loadError: props.loadError, - onRetry: props.onRetry, - }); - if (loadStateResult) return loadStateResult; - if (props.tree) return ; - return
{t("task:noFilesFound")}
; -} diff --git a/apps/web/components/task/file-browser-responsive.test.tsx b/apps/web/components/task/file-browser-responsive.test.tsx index dcb5061fb1..f0242c2a0a 100644 --- a/apps/web/components/task/file-browser-responsive.test.tsx +++ b/apps/web/components/task/file-browser-responsive.test.tsx @@ -27,6 +27,9 @@ vi.mock("@/components/state-provider", () => ({ sessionWorktreesBySessionId: { itemsBySessionId: {} }, workspaceFilesRefresh: { bySessionId: {} }, }), + useAppStoreApi: () => ({ + getState: () => ({ workspaceRestoration: { byEnvironmentId: {} } }), + }), })); vi.mock("@/hooks/use-open-session-folder", () => ({ useOpenSessionFolder: () => ({ open: vi.fn() }), @@ -80,8 +83,8 @@ vi.mock("./file-tree-editor-menu", () => ({ FileTreeEditorProvider: ({ children }: { children: ReactNode }) => <>{children}, })); vi.mock("./file-context-menu", () => ({ useFileDeleteAction: () => null })); -vi.mock("./file-browser-parts", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("./file-browser-content-area", async () => { + const actual = await vi.importActual("./file-browser-parts"); return { ...actual, FileBrowserContentArea: ({ diff --git a/apps/web/components/task/file-browser-search-context-action.test.tsx b/apps/web/components/task/file-browser-search-context-action.test.tsx index 61d5f6cd61..828317691b 100644 --- a/apps/web/components/task/file-browser-search-context-action.test.tsx +++ b/apps/web/components/task/file-browser-search-context-action.test.tsx @@ -36,7 +36,7 @@ vi.mock("./file-context-menu", () => ({ getGitStatusTextClass: () => "", })); -import { FileBrowserContentArea } from "./file-browser-parts"; +import { FileBrowserContentArea } from "./file-browser-content-area"; const SEARCH_PATH = "src/components/chat-input.tsx"; diff --git a/apps/web/components/task/file-browser.tsx b/apps/web/components/task/file-browser.tsx index acb3fb98d7..21754fdcbd 100644 --- a/apps/web/components/task/file-browser.tsx +++ b/apps/web/components/task/file-browser.tsx @@ -11,12 +11,6 @@ import React, { } from "react"; import { ScrollArea } from "@kandev/ui/scroll-area"; import type { FileTreeNode, OpenFileTab } from "@/lib/types/backend"; -import { useSession } from "@/hooks/domains/session/use-session"; -import { useRepository } from "@/hooks/domains/workspace/use-repository"; -import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status"; -import { useAppStore } from "@/components/state-provider"; -import { useOpenSessionFolder } from "@/hooks/use-open-session-folder"; -import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; import { useToast } from "@/components/toast-provider"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; @@ -28,18 +22,17 @@ import { FileBrowserHeader } from "./file-browser-header"; import { insertNodeInTree, removeNodeFromTree, - FileBrowserContentArea, shouldShowFileTreeTouchActions, } from "./file-browser-parts"; +import { FileBrowserContentArea } from "./file-browser-content-area"; import { - useFileBrowserSearch, useFileBrowserTree, useScrollPersistence, loadNodeChildren, toggleFolderExpand, fetchAndOpenFile, } from "./file-browser-hooks"; -import { getFileBrowserSessionWorkspacePath, resolveFileBrowserPaths } from "./file-browser-path"; +import { useFileBrowserData } from "./file-browser-data"; import { useFileUploadEntryPoints } from "./use-file-upload-entry-points"; import { FileUploadStatusList } from "./file-upload-status-list"; import { FileTreeEditorProvider } from "./file-tree-editor-menu"; @@ -389,71 +382,7 @@ function useKeyboardShortcuts( }, [containerRef, clearSelection, selectAll]); } -export function getFileBrowserResetKey({ - sessionId, - environmentId, - worktreeCount, - workspaceFilesRefresh, -}: { - sessionId: string; - environmentId?: string | null; - worktreeCount: number; - workspaceFilesRefresh: number; -}) { - return `${environmentId ?? sessionId}:${worktreeCount}:${workspaceFilesRefresh}`; -} - -function useFileBrowserResetKey(sessionId: string, environmentId?: string | null) { - // Worktree count participates in the tree's reset key so an add_branch_to_task - // call that materializes a sibling worktree forces a fresh tree load. - const worktreeCount = useAppStore( - (state) => state.sessionWorktreesBySessionId.itemsBySessionId[sessionId]?.length ?? 0, - ); - const workspaceFilesRefresh = useAppStore( - (state) => state.workspaceFilesRefresh.bySessionId[sessionId] ?? 0, - ); - return getFileBrowserResetKey({ - sessionId, - environmentId, - worktreeCount, - workspaceFilesRefresh, - }); -} - -function useFileBrowserData(sessionId: string, environmentId: string | null | undefined) { - const { session, isFailed: isSessionFailed, errorMessage: sessionError } = useSession(sessionId); - const repository = useRepository(session?.repository_id ?? null); - const gitStatus = useSessionGitStatus(sessionId); - const { open: openFolder } = useOpenSessionFolder(sessionId); - const { copied, copy: copyPath } = useCopyToClipboard(1000); - const search = useFileBrowserSearch(sessionId); - const resetKey = useFileBrowserResetKey(sessionId, environmentId); - const treeState = useFileBrowserTree(sessionId, resetKey); - const isTreeLoaded = !treeState.isLoadingTree && treeState.tree !== null; - const fileStatuses = useMemo( - () => - new Map(Object.entries(gitStatus?.files ?? {}).map(([path, info]) => [path, info.status])), - [gitStatus?.files], - ); - const paths = resolveFileBrowserPaths({ - sessionWorktreePath: getFileBrowserSessionWorkspacePath(session), - repositoryLocalPath: repository?.local_path, - treePath: treeState.tree?.path, - treeLoaded: isTreeLoaded, - }); - return { - isSessionFailed, - sessionError, - openFolder, - copied, - copyPath, - search, - treeState, - isTreeLoaded, - fileStatuses, - ...paths, - }; -} +export { getFileBrowserResetKey } from "./file-browser-data"; function useFileBrowserViewModel({ sessionId, @@ -491,6 +420,7 @@ function FileBrowserTreeContent({ multiSelect, dnd, activeFilePath, + workspaceBlocked, onDeleteFile, onRenameFile, onDownloadFile, @@ -502,9 +432,11 @@ function FileBrowserTreeContent({ handlers: ReturnType; multiSelect: ReturnType["multiSelect"]; dnd: ReturnType["dnd"]; + workspaceBlocked: boolean; showTouchActions: boolean; }) { - const { search, isSessionFailed, sessionError, treeState, fileStatuses } = data; + const { search, isSessionFailed, sessionError, treeState, fileStatuses, workspaceRestoration } = + data; const scrollViewportRef = useRef(null); return ( undefined : handlers.handleCreateFileSubmit} onCancelCreate={handlers.handleCancelCreate} onRetry={() => void treeState.loadTree({ resetRetry: true })} + workspaceRestoration={workspaceRestoration.attempt} + onRestoreWorkspace={() => void workspaceRestoration.restore()} + restoreWorkspaceDisabled={workspaceRestoration.status === "pending"} setTree={treeState.setTree} isSelectedFn={multiSelect.isSelected} onSelect={multiSelect.handleClick} isDragging={dnd.isDragging} dragOverPath={dnd.dragOverPath} - onDragStart={dnd.handleDragStart} - onDragEnd={dnd.handleDragEnd} - onDragOver={dnd.handleDragOver} - onDragLeave={dnd.handleDragLeave} - onDrop={dnd.handleDrop} + onDragStart={workspaceBlocked ? undefined : dnd.handleDragStart} + onDragEnd={workspaceBlocked ? undefined : dnd.handleDragEnd} + onDragOver={workspaceBlocked ? undefined : dnd.handleDragOver} + onDragLeave={workspaceBlocked ? undefined : dnd.handleDragLeave} + onDrop={workspaceBlocked ? undefined : dnd.handleDrop} selectedCount={multiSelect.selectedPaths.size} selectedPaths={multiSelect.selectedPaths} /> @@ -590,6 +525,8 @@ export function FileBrowser({ containerRef, }); const { openFolder, copied, copyPath, search, treeState, fullPath, displayPath } = data; + const workspaceBlocked = + data.workspaceRestoration.status !== null && data.workspaceRestoration.status !== "ready"; const { openPicker, uploads, elements } = useFileUploadEntryPoints(sessionId); const handleToolbarUpload = useCallback( (mode: "files" | "folder") => openPicker(mode, handlers.activeFolderPath ?? ""), @@ -612,12 +549,12 @@ export function FileBrowser({ copied={copied} expandedPathsSize={treeState.expandedPaths.size} onCopyPath={copyPath} - onStartCreate={onCreateFile ? handlers.handleStartCreate : undefined} + onStartCreate={!workspaceBlocked && onCreateFile ? handlers.handleStartCreate : undefined} onOpenFolder={openFolder} onCollapseAll={treeState.collapseAll} - showCreateButton={Boolean(onCreateFile)} - onUploadFiles={sessionId ? handleToolbarUpload : undefined} - onAddSources={onAddSources} + showCreateButton={!workspaceBlocked && Boolean(onCreateFile)} + onUploadFiles={!workspaceBlocked && sessionId ? handleToolbarUpload : undefined} + onAddSources={workspaceBlocked ? undefined : onAddSources} addSourcesButtonRef={addSourcesButtonRef} addSourcesDisabledReason={addSourcesDisabledReason} /> @@ -629,6 +566,7 @@ export function FileBrowser({ multiSelect={multiSelect} dnd={dnd} activeFilePath={activeFilePath} + workspaceBlocked={workspaceBlocked} onDeleteFile={onDeleteFile} onRenameFile={onRenameFile} onDownloadFile={onDownloadFile} diff --git a/apps/web/components/task/passthrough-terminal.tsx b/apps/web/components/task/passthrough-terminal.tsx index 033a229131..85bef38037 100644 --- a/apps/web/components/task/passthrough-terminal.tsx +++ b/apps/web/components/task/passthrough-terminal.tsx @@ -33,6 +33,11 @@ import { useTerminalTheme } from "./use-terminal-theme"; import { useTranslation } from "react-i18next"; import { useClarificationEscapeGuard } from "@/hooks/use-clarification-escape-guard"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; +import { + useWorkspaceRestoration, + type WorkspaceRestorationResult, +} from "@/hooks/domains/session/use-workspace-restoration"; +import { WorkspaceUnavailable } from "./workspace-unavailable"; type BaseProps = { autoFocus?: boolean; @@ -140,13 +145,13 @@ export function computeCanConnect( connectionID: string | null | undefined, sessionId: string | null | undefined, environmentEnded = false, + workspaceReady = false, ): boolean { if (!connectionID) return false; if (mode === "agent") return Boolean(sessionId); - // The env handler refuses a shell for a dead environment, and refuses it - // identically on every attempt. Connecting anyway only restarts the retry - // timer, so stop before opening the socket at all. - return !environmentEnded; + // The env handler refuses a shell for a dead environment until the workspace + // admission flow has recreated or reattached its retained execution. + return workspaceReady || !environmentEnded; } export function resolveTouchScrollEnabled( @@ -224,8 +229,9 @@ export function computeTerminalPaneState( mode: "agent" | "shell", environmentEnded: boolean, isConnected: boolean, + workspaceReady = false, ): "connected" | "connecting" | "ended" { - if (mode === "shell" && environmentEnded) return "ended"; + if (mode === "shell" && environmentEnded && !workspaceReady) return "ended"; return isConnected ? "connected" : "connecting"; } @@ -426,9 +432,17 @@ export function PassthroughTerminal(props: PassthroughTerminalProps) { const sessionId = mode === "agent" ? (props.sessionId ?? storeSessionId) : environmentSessionId; const { session } = useSession(sessionId); const taskId = session?.task_id ?? null; + const workspaceRestoration = useWorkspaceRestoration( + mode === "shell" ? taskId : null, + mode === "shell" ? sessionId : null, + environmentId, + ); + const workspaceReady = mode === "shell" && workspaceRestoration.status === "ready"; const connectionID = mode === "agent" ? sessionId : environmentId; const environmentEnded = useEnvironmentEnded(environmentId); - const canConnect = computeCanConnect(mode, connectionID, sessionId, environmentEnded); + const canConnect = + computeCanConnect(mode, connectionID, sessionId, environmentEnded, workspaceReady) && + (mode !== "shell" || !workspaceRestoration.status || workspaceRestoration.status === "ready"); const wsBaseUrl = useWsBaseUrl(); const connection = usePassthroughConnection({ connectionID, @@ -438,7 +452,12 @@ export function PassthroughTerminal(props: PassthroughTerminalProps) { attachAddonRef, wsRef, }); - const paneState = computeTerminalPaneState(mode, environmentEnded, connection.isConnected); + const paneState = computeTerminalPaneState( + mode, + environmentEnded, + connection.isConnected, + workspaceReady, + ); const effectiveProps: PassthroughTerminalProps = { ...props, enableTouchScroll: resolveTouchScrollEnabled(mode, isFinePointer, props.enableTouchScroll), @@ -469,7 +488,11 @@ export function PassthroughTerminal(props: PassthroughTerminalProps) {
- + ); } @@ -481,11 +504,24 @@ export function PassthroughTerminal(props: PassthroughTerminalProps) { function TerminalPaneOverlay({ paneState, mode, + workspaceRestoration, }: { paneState: ReturnType; mode: "agent" | "shell"; + workspaceRestoration?: WorkspaceRestorationResult; }) { const { t } = useTranslation(); + if (mode === "shell" && workspaceRestoration?.status && workspaceRestoration.status !== "ready") { + return ( +
+ void workspaceRestoration.restore()} + retryDisabled={workspaceRestoration.status === "pending"} + /> +
+ ); + } if (paneState === "connected") return null; if (paneState === "ended") { return ( diff --git a/apps/web/components/task/shell-terminal.tsx b/apps/web/components/task/shell-terminal.tsx index 69104dcb6c..81310136e3 100644 --- a/apps/web/components/task/shell-terminal.tsx +++ b/apps/web/components/task/shell-terminal.tsx @@ -25,6 +25,7 @@ import { suppressIOSKeyboardAssists } from "@/lib/terminal/suppress-ios-keyboard import { sendShellInput } from "@/lib/terminal/send-shell-input"; import { WorkspaceUnavailable } from "./workspace-unavailable"; import { useTranslation } from "react-i18next"; +import { useWorkspaceRestoration } from "@/hooks/domains/session/use-workspace-restoration"; import { t } from "@/lib/i18n"; type ShellTerminalProps = { @@ -388,21 +389,30 @@ function useShellSessionState(propSessionId: string | undefined, isReadOnlyMode: ); const agentctlStatus = useSessionAgentctl(isReadOnlyMode ? null : sessionId); const taskId = session?.task_id ?? null; + const workspaceRestoration = useWorkspaceRestoration(taskId, isReadOnlyMode ? null : sessionId); const isSessionFailed = !isReadOnlyMode && isFailed; const shellOutput = useAppStore((state) => { if (!sessionId || isReadOnlyMode) return ""; const envKey = state.environmentIdBySessionId[sessionId] ?? sessionId; return state.shell.outputs[envKey] || ""; }); - const canSubscribe = Boolean(sessionId && isActive && !isReadOnlyMode && !agentctlStatus.isError); + const canSubscribe = Boolean( + sessionId && + isActive && + !isReadOnlyMode && + !agentctlStatus.isError && + (!workspaceRestoration.status || workspaceRestoration.status === "ready"), + ); return { sessionId, taskId, + isActive, isSessionFailed, errorMessage, shellOutput, canSubscribe, agentctlStatusKey: agentctlStatus.status, + workspaceRestoration, }; } @@ -433,7 +443,12 @@ export function ShellTerminal({ shellOutput, canSubscribe, agentctlStatusKey, + workspaceRestoration, } = useShellSessionState(propSessionId, isReadOnlyMode); + const workspaceBlocked = + !isReadOnlyMode && + workspaceRestoration.status !== null && + workspaceRestoration.status !== "ready"; useReadOnlyOutputSync({ xtermRef, isReadOnlyMode, @@ -482,10 +497,16 @@ export function ShellTerminal({ onClose: search.close, }); - useShellInputHandler({ xtermRef, onDataDisposableRef, isReadOnlyMode, taskId, sessionId }); + useShellInputHandler({ + xtermRef, + onDataDisposableRef, + isReadOnlyMode: isReadOnlyMode || workspaceBlocked, + taskId, + sessionId, + }); useShellTerminalKeyHandler({ xtermRef, - isReadOnlyMode, + isReadOnlyMode: isReadOnlyMode || workspaceBlocked, sessionId, send, onFindInPanel: search.open, @@ -524,7 +545,7 @@ export function ShellTerminal({ ); } - if (isSessionFailed) { + if (isSessionFailed && workspaceRestoration.status === null) { return ; } return ( @@ -536,6 +557,15 @@ export function ShellTerminal({ >
{searchBar} + {workspaceBlocked && ( +
+ void workspaceRestoration.restore()} + retryDisabled={workspaceRestoration.status === "pending"} + /> +
+ )}
); } diff --git a/apps/web/components/task/task-changes-panel.tsx b/apps/web/components/task/task-changes-panel.tsx index 9580d7ae6f..11c0810dcb 100644 --- a/apps/web/components/task/task-changes-panel.tsx +++ b/apps/web/components/task/task-changes-panel.tsx @@ -34,6 +34,11 @@ import type { SelectedDiff } from "./task-layout"; import { useIsTaskArchived, ArchivedPanelPlaceholder } from "./task-archived-context"; import { useTranslation } from "react-i18next"; import type { GitChangeLayer } from "@/lib/state/slices/session-runtime/types"; +import { + useWorkspaceRestoration, + type WorkspaceRestorationResult, +} from "@/hooks/domains/session/use-workspace-restoration"; +import { WorkspaceUnavailable } from "./workspace-unavailable"; type TaskChangesPanelProps = { mode?: "all" | "file"; @@ -188,12 +193,16 @@ function persistAutoMarkSetting(checked: boolean) { updateUserSettings(payload, { cache: "no-store" }).catch(() => {}); } -function useWalkthroughRequest(activeSessionId: string | null | undefined, allFiles: ReviewFile[]) { +function useWalkthroughRequest( + activeSessionId: string | null | undefined, + allFiles: ReviewFile[], + workspaceBlocked = false, +) { const activeTaskId = useAppStore((s) => s.tasks.activeTaskId); return useRequestChangesWalkthrough({ taskId: activeTaskId, sessionId: activeSessionId, - ready: allFiles.length > 0, + ready: allFiles.length > 0 && !workspaceBlocked, }); } @@ -222,20 +231,56 @@ function useChangesPRPresentation(opts: { return { selectedFileKey, blockChangesForPR }; } +function useFixCommentsRequest( + activeSessionId: string | null | undefined, + workspaceBlocked: boolean, +) { + const { t } = useTranslation(); + const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); + const getPendingComments = useCommentsStore((s) => s.getPendingComments); + const markCommentsSent = useCommentsStore((s) => s.markCommentsSent); + const { toast } = useToast(); + + return useCallback(() => { + if (workspaceBlocked || !activeSessionId || !activeTaskId) return; + const comments = getPendingComments().filter(isDiffComment); + if (comments.length === 0) return; + const markdown = formatReviewCommentsAsMarkdown(comments); + if (!markdown) return; + const client = getWebSocketClient(); + if (client) + client + .request("message.add", { + task_id: activeTaskId, + session_id: activeSessionId, + client_message_id: generateUUID(), + content: markdown, + }) + .catch(() => toast({ title: t("task:failedToSendComments"), variant: "error" })); + markCommentsSent(comments.map((c) => c.id)); + }, [ + activeSessionId, + activeTaskId, + getPendingComments, + markCommentsSent, + t, + toast, + workspaceBlocked, + ]); +} + function useChangesActions( activeSessionId: string | null | undefined, allFiles: ReviewFile[], defaultWordWrap = DEFAULT_DIFF_WORD_WRAP, + workspaceBlocked = false, ) { const { t } = useTranslation(); - const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); const autoMarkOnScroll = useAppStore((s) => s.userSettings.reviewAutoMarkOnScroll); const setUserSettings = useAppStore((state) => state.setUserSettings); const userSettings = useAppStore((state) => state.userSettings); const { discard } = useGitOperations(activeSessionId ?? null); const { markReviewed, markUnreviewed } = useSessionFileReviews(activeSessionId ?? null); - const getPendingComments = useCommentsStore((s) => s.getPendingComments); - const markCommentsSent = useCommentsStore((s) => s.markCommentsSent); const { toast } = useToast(); const [splitView, setSplitView] = useState( @@ -263,6 +308,7 @@ function useChangesActions( const handleDiscard = useCallback( async (key: string) => { + if (workspaceBlocked) return; const { repositoryName, path } = discardTargetFromReviewFileKey(key); try { const result = await discard([path], repositoryName || undefined); @@ -283,7 +329,7 @@ function useChangesActions( }); } }, - [discard, toast], + [discard, toast, workspaceBlocked], ); const handleToggleAutoMark = useCallback( @@ -295,25 +341,7 @@ function useChangesActions( [setUserSettings, userSettings], ); - const handleFixComments = useCallback(() => { - if (!activeSessionId || !activeTaskId) return; - const allPending = getPendingComments(); - const comments = allPending.filter(isDiffComment); - if (comments.length === 0) return; - const markdown = formatReviewCommentsAsMarkdown(comments); - if (!markdown) return; - const client = getWebSocketClient(); - if (client) - client - .request("message.add", { - task_id: activeTaskId, - session_id: activeSessionId, - client_message_id: generateUUID(), - content: markdown, - }) - .catch(() => toast({ title: t("task:failedToSendComments"), variant: "error" })); - markCommentsSent(comments.map((c) => c.id)); - }, [activeSessionId, activeTaskId, getPendingComments, markCommentsSent, toast]); + const handleFixComments = useFixCommentsRequest(activeSessionId, workspaceBlocked); return { splitView, @@ -328,6 +356,70 @@ function useChangesActions( }; } +function useTaskChangesWorkspaceRestoration( + activeSessionId: string | null | undefined, +): WorkspaceRestorationResult { + const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); + return useWorkspaceRestoration(activeTaskId, activeSessionId); +} + +function useTaskChangesPanelActions( + view: ReturnType, + wordWrap: boolean, + workspaceRestoration: WorkspaceRestorationResult, +) { + const workspaceBlocked = + workspaceRestoration.status !== null && workspaceRestoration.status !== "ready"; + const actions = useChangesActions( + view.activeSessionId, + view.allFiles, + wordWrap, + workspaceBlocked, + ); + const handleRequestWalkthrough = useWalkthroughRequest( + view.activeSessionId, + view.allFiles, + workspaceBlocked, + ); + return { actions, handleRequestWalkthrough }; +} + +function ChangesPanelHeader({ + mode, + view, + actions, + visible, + onRequestWalkthrough, +}: { + mode: "all" | "file"; + view: ReturnType; + actions: ReturnType; + visible: ReturnType; + onRequestWalkthrough: ReturnType; +}) { + return ( + + ); +} + const TaskChangesPanel = memo(function TaskChangesPanel({ mode = "all", filePath, @@ -346,10 +438,14 @@ const TaskChangesPanel = memo(function TaskChangesPanel({ const handleOpenFile = onOpenFileProp ?? panelOpenFile; const view = useChangesView(selectedDiff, onClearSelected, sourceFilter, prKey); + const workspaceRestoration = useTaskChangesWorkspaceRestoration(view.activeSessionId); + const { actions, handleRequestWalkthrough } = useTaskChangesPanelActions( + view, + wordWrapProp, + workspaceRestoration, + ); const usesPRDiff = sourceFilter === "all" || sourceFilter === "pr"; const relevantPRLoading = usesPRDiff && view.prDiffLoading; - const actions = useChangesActions(view.activeSessionId, view.allFiles, wordWrapProp); - const handleRequestWalkthrough = useWalkthroughRequest(view.activeSessionId, view.allFiles); const fileTarget = { filePath, fileRepositoryName, prKey, changeLayer }; const visible = useVisibleDiffState({ allFiles: view.allFiles, @@ -384,24 +480,12 @@ const TaskChangesPanel = memo(function TaskChangesPanel({ return ( - @@ -425,6 +509,7 @@ const TaskChangesPanel = memo(function TaskChangesPanel({ onOpenFile={handleOpenFile} onPreviewMarkdown={openFileInMarkdownPreview} fileRefs={visible.visibleFileRefs} + workspaceRestoration={workspaceRestoration} /> @@ -446,6 +531,7 @@ function ChangesPanelContent({ onOpenFile, onPreviewMarkdown, fileRefs, + workspaceRestoration, }: { isLoading: boolean; files: ReviewFile[]; @@ -460,8 +546,19 @@ function ChangesPanelContent({ onOpenFile: (path: string, repo?: string) => void; onPreviewMarkdown?: (path: string, repo?: string) => void; fileRefs: Map>; + workspaceRestoration: WorkspaceRestorationResult; }) { const { t } = useTranslation(); + const workspaceBlocked = workspaceRestoration.status && workspaceRestoration.status !== "ready"; + if (workspaceBlocked && files.length === 0) { + return ( + void workspaceRestoration.restore()} + retryDisabled={workspaceRestoration.status === "pending"} + /> + ); + } if (isLoading && files.length === 0) { return (
@@ -478,21 +575,31 @@ function ChangesPanelContent({ } if (!activeSessionId) return null; return ( - + <> + {workspaceBlocked && ( + void workspaceRestoration.restore()} + retryDisabled={workspaceRestoration.status === "pending"} + compact + /> + )} + + ); } diff --git a/apps/web/components/task/use-passthrough-terminal.test.ts b/apps/web/components/task/use-passthrough-terminal.test.ts index 82dc7b4053..c6452fa6ee 100644 --- a/apps/web/components/task/use-passthrough-terminal.test.ts +++ b/apps/web/components/task/use-passthrough-terminal.test.ts @@ -207,6 +207,10 @@ describe("computeCanConnect on ended sessions", () => { expect(computeCanConnect("shell", "env-1", "session-1", true)).toBe(false); }); + it("connects a shell terminal after workspace restoration", () => { + expect(computeCanConnect("shell", "env-1", "session-1", true, true)).toBe(true); + }); + it("still connects a shell terminal for a live environment", () => { expect(computeCanConnect("shell", "env-1", "session-1", false)).toBe(true); }); @@ -278,6 +282,11 @@ describe("computeTerminalPaneState", () => { expect(computeTerminalPaneState("shell", true, true)).toBe("ended"); }); + it("reports a restored shell as connected or connecting instead of ended", () => { + expect(computeTerminalPaneState("shell", true, false, true)).toBe("connecting"); + expect(computeTerminalPaneState("shell", true, true, true)).toBe("connected"); + }); + it("reports connecting only while a live session is not yet attached", () => { expect(computeTerminalPaneState("shell", false, false)).toBe("connecting"); expect(computeTerminalPaneState("shell", false, true)).toBe("connected"); diff --git a/apps/web/components/task/workspace-unavailable.test.tsx b/apps/web/components/task/workspace-unavailable.test.tsx index 5c80510ec8..669cf8bf91 100644 --- a/apps/web/components/task/workspace-unavailable.test.tsx +++ b/apps/web/components/task/workspace-unavailable.test.tsx @@ -1,5 +1,5 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { WorkspaceUnavailable } from "./workspace-unavailable"; afterEach(cleanup); @@ -22,4 +22,45 @@ describe("WorkspaceUnavailable", () => { expect(error.className).toContain("max-h-48"); expect(error.className).toContain("overflow-y-auto"); }); + + it("shows a retry action and scoped diagnostics for workspace restoration", () => { + const onRetry = vi.fn(); + render( + , + ); + + expect(screen.getByText("Couldn't reconnect to this task's workspace.")).toBeTruthy(); + fireEvent.click(screen.getByTestId("workspace-retry")); + expect(onRetry).toHaveBeenCalledTimes(1); + expect(screen.getByText("Technical details")).toBeTruthy(); + }); + + it("disables retry while restoration is pending", () => { + render( + , + ); + + expect(screen.getByText("Reconnecting to this workspace...")).toBeTruthy(); + expect(screen.getByTestId("workspace-retry")).toHaveProperty("disabled", true); + }); }); diff --git a/apps/web/components/task/workspace-unavailable.tsx b/apps/web/components/task/workspace-unavailable.tsx index 34d0ebaea9..3a9a5fc52a 100644 --- a/apps/web/components/task/workspace-unavailable.tsx +++ b/apps/web/components/task/workspace-unavailable.tsx @@ -1,20 +1,56 @@ "use client"; -import { IconAlertCircle, IconChevronDown } from "@tabler/icons-react"; +import { IconAlertCircle, IconChevronDown, IconRefresh } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; import { useTranslation } from "react-i18next"; +import type { WorkspaceRestorationAttempt } from "@/lib/state/slices/session-runtime/workspace-restoration"; +import { sanitizeWorkspaceRestorationDetails } from "@/lib/state/slices/session-runtime/workspace-restoration"; type WorkspaceUnavailableProps = { error?: string | null; + restoration?: WorkspaceRestorationAttempt | null; + onRetry?: () => void; + retryDisabled?: boolean; + compact?: boolean; }; -export function WorkspaceUnavailable({ error }: WorkspaceUnavailableProps) { +function getWorkspaceRestoreMessage( + isRestoring: boolean, + hasRestoreError: boolean, + t: ReturnType["t"], +) { + if (isRestoring) return t("task:workspaceRestorePending"); + if (hasRestoreError) return t("task:workspaceRestoreFailed"); + return t("task:thisSessionDidNotFinishSetting"); +} + +function getWorkspaceRestoreDetail( + restoration: WorkspaceRestorationAttempt | null | undefined, + error: string | null | undefined, +) { + if (restoration?.details) return restoration.details; + if (error) return sanitizeWorkspaceRestorationDetails(error); + return null; +} + +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 (

- {t("task:thisSessionDidNotFinishSetting")} + {getWorkspaceRestoreMessage(isRestoring, hasRestoreError, t)}

- {error && ( + {restoration && onRetry && ( + + )} + {detail && (
- +
-                {error}
+                {detail}
               
)} diff --git a/apps/web/e2e/README.md b/apps/web/e2e/README.md index b249dab741..da983ec347 100644 --- a/apps/web/e2e/README.md +++ b/apps/web/e2e/README.md @@ -175,6 +175,10 @@ The compatibility fixture uses the same exact-name ownership marker, narrow teardown, foreign-image refusal, and credential-redacted diagnostics as the full suite. Unsupported version selectors fail before provisioning. +The compatibility runtime image is the immutable `kandev-ci:runtime-latest` +image used by CI. It includes the lifecycle tools required by the backend, so +the fixture does not resolve Ubuntu packages during every matrix job. + ### Remote-executor fixture contracts Host-only `file://` fixtures are not reachable from an SSH or Docker target. Use a disposable provider-shaped HTTP Git fixture with a target-side URL rewrite; if the spec also uses host-local `GitHelper` or LSP paths, materialize a local clone at the expected temporary path. Verify both the remote checkout and every host-local fixture consumer. diff --git a/apps/web/e2e/fixtures/kubernetes-tools.ts b/apps/web/e2e/fixtures/kubernetes-tools.ts index 4d20807302..703d11eae5 100644 --- a/apps/web/e2e/fixtures/kubernetes-tools.ts +++ b/apps/web/e2e/fixtures/kubernetes-tools.ts @@ -210,6 +210,9 @@ function requireBuildArtifacts(): void { function buildRuntimeImage(tag: string): void { requireBuildArtifacts(); const context = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-kubernetes-e2e-image-")); + // The pinned CI runtime already contains the lifecycle tools used by the + // backend. Reusing it keeps compatibility jobs independent of live package + // mirror resolution on each hosted runner. const dockerfile = `FROM ${KUBERNETES_E2E_BASE_IMAGE} COPY kandev /usr/local/bin/kandev COPY agentctl-linux-amd64 /usr/local/bin/agentctl-linux-amd64 diff --git a/apps/web/e2e/tests/session/completed-session-resume.spec.ts b/apps/web/e2e/tests/session/completed-session-resume.spec.ts index b3b76ea358..d5f0c3ef21 100644 --- a/apps/web/e2e/tests/session/completed-session-resume.spec.ts +++ b/apps/web/e2e/tests/session/completed-session-resume.spec.ts @@ -1,46 +1,31 @@ import { test, expect } from "../../fixtures/test-base"; -import type { SeedData } from "../../fixtures/test-base"; -import type { ApiClient } from "../../helpers/api-client"; import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions"; -import { waitForSessionDone } from "../../helpers/session"; import { SessionPage } from "../../pages/session-page"; - -async function seedCompletedConversation(apiClient: ApiClient, seedData: SeedData) { - const task = await apiClient.createTaskWithAgent( - seedData.workspaceId, - `Completed conversation ${Date.now()}`, - seedData.agentProfileId, - { - description: "/e2e:simple-message", - workflow_id: seedData.workflowId, - workflow_step_id: seedData.startStepId, - repository_ids: [seedData.repositoryId], - }, - ); - if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id"); - await waitForSessionDone(apiClient, task.id, task.session_id, "Waiting for initial conversation"); - await apiClient.seedTaskSession(task.id, { - state: "COMPLETED", - sessionId: task.session_id, - agentProfileId: seedData.agentProfileId, - repositoryId: seedData.repositoryId, - completedAt: new Date().toISOString(), - }); - await apiClient.updateTaskState(task.id, "COMPLETED"); - return task; -} +import { + restartAndAssertColdWorkspace, + RETAINED_WORKSPACE_CONTENT, + RETAINED_WORKSPACE_FILE, + seedCompletedConversation, +} from "./completed-workspace-restoration-helpers"; test.describe("Completed conversation resume", () => { test("resumes the selected completed conversation and sends a follow-up in place", async ({ testPage, apiClient, seedData, + backend, }) => { test.setTimeout(180_000); - const task = await seedCompletedConversation(apiClient, seedData); + const task = await seedCompletedConversation( + apiClient, + seedData, + `Completed conversation ${Date.now()}`, + ); + if (!task.session_id) throw new Error("completed task has no session_id"); const before = await apiClient.listTaskSessions(task.id); const primary = before.sessions.find((session) => session.is_primary); expect(primary?.id).toBe(task.session_id); + await restartAndAssertColdWorkspace(backend, apiClient, task.id, task.session_id); await testPage.goto(`/t/${task.id}`); const session = new SessionPage(testPage); @@ -50,15 +35,39 @@ test.describe("Completed conversation resume", () => { await expect(session.completedSessionResumeButton()).toBeVisible(); await expect(session.completedSessionNewAgentButton()).toBeVisible(); + await session.clickTab("Files"); + const fileNode = session.fileTreeNode(RETAINED_WORKSPACE_FILE); + await expect(fileNode).toBeVisible({ timeout: 60_000 }); + await fileNode.click(); + const viewer = testPage.locator(".monaco-editor:visible").first(); + await expect(viewer).toBeVisible({ timeout: 15_000 }); + await expect( + viewer.locator(".view-lines").filter({ hasText: RETAINED_WORKSPACE_CONTENT }), + ).toBeVisible(); + await session.clickSessionChatTab(); + // Opening historical work is passive. Reloading must not launch a new // execution or replace the selected session. await testPage.reload(); - await session.waitForLoad(); + await session.showSessionContext(); await expect(session.completedSessionBanner()).toBeVisible({ timeout: 30_000 }); + await session.clickTab("Files"); + await expect(session.fileTreeNode(RETAINED_WORKSPACE_FILE)).toBeVisible({ timeout: 60_000 }); + await session.clickSessionChatTab(); const afterReload = await apiClient.listTaskSessions(task.id); expect(afterReload.sessions).toHaveLength(before.sessions.length); expect(afterReload.sessions.find((item) => item.is_primary)?.id).toBe(task.session_id); + // Recreate the cold runtime after workspace restoration. The next open + // must persist provider identity before explicit Resume uses it. + await backend.restart(); + await testPage.reload(); + await session.showSessionContext(); + await session.clickTab("Files"); + await expect(session.fileTreeNode(RETAINED_WORKSPACE_FILE)).toBeVisible({ timeout: 60_000 }); + await session.clickSessionChatTab(); + await expect(session.completedSessionBanner()).toBeVisible({ timeout: 30_000 }); + await session.completedSessionResumeButton().click(); await expect(session.completedSessionBanner()).toHaveCount(0, { timeout: 60_000 }); await expect diff --git a/apps/web/e2e/tests/session/completed-workspace-restoration-helpers.ts b/apps/web/e2e/tests/session/completed-workspace-restoration-helpers.ts new file mode 100644 index 0000000000..14e1f96402 --- /dev/null +++ b/apps/web/e2e/tests/session/completed-workspace-restoration-helpers.ts @@ -0,0 +1,138 @@ +import { expect, type Page } from "@playwright/test"; +import type { BackendContext } from "../../fixtures/backend"; +import type { SeedData } from "../../fixtures/test-base"; +import type { ApiClient } from "../../helpers/api-client"; +import { waitForSessionDone } from "../../helpers/session"; + +export const RETAINED_WORKSPACE_FILE = "walkthrough_base.txt"; +export const RETAINED_WORKSPACE_CONTENT = "WALKTHROUGH_UNCHANGED"; + +type GatewayFrame = { + id?: string; + type?: string; + action?: string; + payload?: Record; +}; + +type SocketMessage = string | Buffer; + +function parseGatewayFrame(value: string): GatewayFrame | null { + try { + const frame = JSON.parse(value) as unknown; + return typeof frame === "object" && frame !== null ? (frame as GatewayFrame) : null; + } catch { + return null; + } +} + +function restoreFailureFrame(id: string, message: string): string { + return JSON.stringify({ + id, + type: "error", + action: "session.launch", + payload: { code: "INTERNAL_ERROR", message }, + }); +} + +function isTargetWorkspaceRestore( + frame: GatewayFrame | null, + taskId: string, + sessionId: string, +): frame is GatewayFrame & { id: string; payload: Record } { + return ( + frame?.type === "request" && + typeof frame.id === "string" && + frame.action === "session.launch" && + frame.payload?.intent === "restore_workspace" && + frame.payload.task_id === taskId && + frame.payload.session_id === sessionId + ); +} + +/** Fail one automatic restore request, then forward all later requests normally. */ +export async function failNextWorkspaceRestore( + page: Page, + taskId: string, + sessionId: string, + failureMessage = "workspace restore failed for e2e", +): Promise<{ wasConsumed: () => boolean }> { + let pending = true; + + await page.routeWebSocket(/\/ws$/, (socket) => { + const server = socket.connectToServer(); + socket.onMessage((message: SocketMessage) => { + if (typeof message !== "string") { + server.send(message); + return; + } + + const forwarded: string[] = []; + for (const part of message.split("\n")) { + const frame = parseGatewayFrame(part.trim()); + if (pending && isTargetWorkspaceRestore(frame, taskId, sessionId)) { + pending = false; + socket.send(restoreFailureFrame(frame.id, failureMessage)); + continue; + } + if (part.trim()) forwarded.push(part); + } + if (forwarded.length > 0) server.send(forwarded.join("\n")); + }); + server.onMessage((message: SocketMessage) => socket.send(message)); + }); + + return { wasConsumed: () => !pending }; +} + +export async function seedCompletedConversation( + apiClient: ApiClient, + seedData: SeedData, + title: string, +) { + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + title, + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + executor_profile_id: seedData.worktreeExecutorProfileId, + }, + ); + if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id"); + await waitForSessionDone(apiClient, task.id, task.session_id, "Waiting for initial conversation"); + await apiClient.seedTaskSession(task.id, { + state: "COMPLETED", + sessionId: task.session_id, + agentProfileId: seedData.agentProfileId, + repositoryId: seedData.repositoryId, + completedAt: new Date().toISOString(), + }); + await apiClient.updateTaskState(task.id, "COMPLETED"); + return task; +} + +/** Restart the managed worker and prove the retained environment is cold but restorable. */ +export async function restartAndAssertColdWorkspace( + backend: BackendContext, + apiClient: ApiClient, + taskId: string, + sessionId: string, +): Promise { + await backend.restart(); + + const environment = await apiClient.getTaskEnvironment(taskId); + expect(environment, "completed task must retain its workspace environment").not.toBeNull(); + expect(environment?.status).toBe("ready"); + + const status = await apiClient.wsRequest<{ + state: string; + is_agent_running: boolean; + needs_workspace_restore: boolean; + }>("task.session.status", { task_id: taskId, session_id: sessionId }); + expect(status.state).toBe("COMPLETED"); + expect(status.is_agent_running).toBe(false); + expect(status.needs_workspace_restore).toBe(true); +} diff --git a/apps/web/e2e/tests/session/completed-workspace-restoration.spec.ts b/apps/web/e2e/tests/session/completed-workspace-restoration.spec.ts new file mode 100644 index 0000000000..9025dfbe41 --- /dev/null +++ b/apps/web/e2e/tests/session/completed-workspace-restoration.spec.ts @@ -0,0 +1,119 @@ +import { test, expect } from "../../fixtures/test-base"; +import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions"; +import { SessionPage } from "../../pages/session-page"; +import { + failNextWorkspaceRestore, + restartAndAssertColdWorkspace, + RETAINED_WORKSPACE_CONTENT, + RETAINED_WORKSPACE_FILE, + seedCompletedConversation, +} from "./completed-workspace-restoration-helpers"; + +test.describe("Completed workspace restoration", () => { + test.describe.configure({ retries: 1 }); + + test("restores a cold workspace and recovers a bounded failure", async ({ + testPage, + apiClient, + seedData, + backend, + prCapture, + }) => { + test.setTimeout(240_000); + const task = await seedCompletedConversation( + apiClient, + seedData, + `Completed workspace restoration ${Date.now()}`, + ); + if (!task.session_id) throw new Error("completed task has no session_id"); + + const beforeSessions = await apiClient.listTaskSessions(task.id); + const beforeMessages = await apiClient.listSessionMessages(task.session_id); + await restartAndAssertColdWorkspace(backend, apiClient, task.id, task.session_id); + + const failure = await failNextWorkspaceRestore( + testPage, + task.id, + task.session_id, + "workspace restore failed for e2e", + ); + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await expect(session.completedSessionBanner()).toBeVisible({ timeout: 30_000 }); + + await session.clickTab("Files"); + const workspaceUnavailable = session.files.getByTestId("workspace-unavailable"); + await expect(workspaceUnavailable).toBeVisible({ timeout: 30_000 }); + await expect(session.files.getByTestId("file-tree-waiting")).toHaveCount(0); + await expect(session.recoveryError()).toHaveCount(0); + expect(failure.wasConsumed()).toBe(true); + + await workspaceUnavailable.getByText("Technical details", { exact: true }).click(); + await expect(workspaceUnavailable.locator("pre")).toContainText( + "workspace restore failed for e2e", + ); + + const retry = workspaceUnavailable.getByTestId("workspace-retry"); + const retryBox = await retry.boundingBox(); + expect(retryBox, "workspace retry has no rendered hitbox").not.toBeNull(); + expect(retryBox?.height ?? 0).toBeGreaterThanOrEqual(32); + await retry.click(); + + const fileNode = session.fileTreeNode(RETAINED_WORKSPACE_FILE); + await expect(fileNode).toBeVisible({ timeout: 60_000 }); + await fileNode.click(); + const viewer = testPage.locator(".monaco-editor:visible").first(); + await expect(viewer).toBeVisible({ timeout: 15_000 }); + await expect( + viewer.locator(".view-lines").filter({ hasText: RETAINED_WORKSPACE_CONTENT }), + ).toBeVisible(); + + await session.clickTab("Changes"); + await expect(session.changes).toBeVisible(); + await expect(session.changes.getByTestId("workspace-unavailable")).toHaveCount(0); + + await session.clickTab("Terminal"); + await expect(session.terminal).toBeVisible(); + await session.typeInTerminal("printf 'RESTORED_WORKSPACE_SHELL\\n'"); + await session.expectTerminalHasText("RESTORED_WORKSPACE_SHELL"); + + const workspaceStatus = await apiClient.wsRequest<{ + state: string; + is_agent_running: boolean; + }>("task.session.status", { task_id: task.id, session_id: task.session_id }); + expect(workspaceStatus.state).toBe("COMPLETED"); + expect(workspaceStatus.is_agent_running).toBe(false); + + const afterWorkspace = await apiClient.listTaskSessions(task.id); + const afterWorkspaceMessages = await apiClient.listSessionMessages(task.session_id); + expect(afterWorkspace.sessions).toHaveLength(beforeSessions.sessions.length); + expect(afterWorkspace.sessions.find((item) => item.id === task.session_id)?.state).toBe( + "COMPLETED", + ); + expect(afterWorkspaceMessages.messages).toHaveLength(beforeMessages.messages.length); + expect(await apiClient.getTask(task.id)).toMatchObject({ + id: task.id, + state: "COMPLETED", + primary_session_id: task.session_id, + }); + await expect(session.recoveryError()).toHaveCount(0); + + await prCapture.screenshot("completed-workspace-restoration-desktop", { + caption: "Desktop completed task with restored workspace panels", + }); + + await testPage.reload(); + await session.showSessionContext(); + await session.clickTab("Files"); + await expect(session.fileTreeNode(RETAINED_WORKSPACE_FILE)).toBeVisible({ timeout: 60_000 }); + await session.clickSessionChatTab(); + await expect(session.completedSessionBanner()).toBeVisible({ timeout: 30_000 }); + + const finalSessions = await apiClient.listTaskSessions(task.id); + const finalMessages = await apiClient.listSessionMessages(task.session_id); + expect(finalSessions.sessions).toHaveLength(beforeSessions.sessions.length); + expect(finalMessages.messages).toHaveLength(beforeMessages.messages.length); + await assertNoDocumentHorizontalOverflow(testPage, "completed workspace restoration"); + }); +}); diff --git a/apps/web/e2e/tests/session/mobile-completed-session-resume.spec.ts b/apps/web/e2e/tests/session/mobile-completed-session-resume.spec.ts index 3894feb903..62fcb5fd06 100644 --- a/apps/web/e2e/tests/session/mobile-completed-session-resume.spec.ts +++ b/apps/web/e2e/tests/session/mobile-completed-session-resume.spec.ts @@ -1,48 +1,50 @@ import { test, expect } from "../../fixtures/test-base"; import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions"; -import { waitForSessionDone } from "../../helpers/session"; import { SessionPage } from "../../pages/session-page"; +import { + restartAndAssertColdWorkspace, + RETAINED_WORKSPACE_CONTENT, + RETAINED_WORKSPACE_FILE, + seedCompletedConversation, +} from "./completed-workspace-restoration-helpers"; test.describe("Completed conversation resume on mobile", () => { test("resumes in place with touch-sized actions and no overflow", async ({ testPage, apiClient, seedData, + backend, }) => { test.setTimeout(180_000); - const task = await apiClient.createTaskWithAgent( - seedData.workspaceId, - `Mobile completed conversation ${Date.now()}`, - seedData.agentProfileId, - { - description: "/e2e:simple-message", - workflow_id: seedData.workflowId, - workflow_step_id: seedData.startStepId, - repository_ids: [seedData.repositoryId], - }, - ); - if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id"); - await waitForSessionDone( + const task = await seedCompletedConversation( apiClient, - task.id, - task.session_id, - "Waiting for initial conversation", + seedData, + `Mobile completed conversation ${Date.now()}`, ); - await apiClient.seedTaskSession(task.id, { - state: "COMPLETED", - sessionId: task.session_id, - agentProfileId: seedData.agentProfileId, - repositoryId: seedData.repositoryId, - completedAt: new Date().toISOString(), - }); - await apiClient.updateTaskState(task.id, "COMPLETED"); + if (!task.session_id) throw new Error("completed task has no session_id"); const before = await apiClient.listTaskSessions(task.id); + await restartAndAssertColdWorkspace(backend, apiClient, task.id, task.session_id); await testPage.goto(`/t/${task.id}`); const session = new SessionPage(testPage); await session.waitForLoad(); await expect(session.completedSessionBanner()).toBeVisible({ timeout: 30_000 }); + await testPage.getByRole("button", { name: "Files" }).tap(); + const fileNode = session.fileTreeNode(RETAINED_WORKSPACE_FILE); + await expect(fileNode).toBeVisible({ timeout: 60_000 }); + await fileNode.tap(); + const viewer = testPage.getByTestId("mobile-file-viewer-panel"); + await expect(viewer).toBeVisible({ timeout: 15_000 }); + await expect( + viewer + .getByTestId("mobile-file-viewer-content") + .locator(".cm-line") + .filter({ hasText: RETAINED_WORKSPACE_CONTENT }), + ).toBeVisible(); + await viewer.getByRole("button", { name: "Close" }).tap(); + await testPage.getByRole("button", { name: "Chat" }).tap(); + const resume = session.completedSessionResumeButton(); const newAgent = session.completedSessionNewAgentButton(); await expect(resume).toBeVisible(); diff --git a/apps/web/e2e/tests/session/mobile-completed-workspace-restoration.spec.ts b/apps/web/e2e/tests/session/mobile-completed-workspace-restoration.spec.ts new file mode 100644 index 0000000000..cb509e4d63 --- /dev/null +++ b/apps/web/e2e/tests/session/mobile-completed-workspace-restoration.spec.ts @@ -0,0 +1,139 @@ +import { test, expect } from "../../fixtures/test-base"; +import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions"; +import { + focusTerminalForTyping, + readTerminalBuffer, + switchToTerminalPanel, + waitForShellReady, +} from "../terminal/mobile-terminal-helpers"; +import { SessionPage } from "../../pages/session-page"; +import { + failNextWorkspaceRestore, + restartAndAssertColdWorkspace, + RETAINED_WORKSPACE_CONTENT, + RETAINED_WORKSPACE_FILE, + seedCompletedConversation, +} from "./completed-workspace-restoration-helpers"; + +test.describe("Completed workspace restoration on mobile", () => { + test.describe.configure({ retries: 1 }); + + test("restores Files, Changes, and Terminal after a cold restart", async ({ + testPage, + apiClient, + seedData, + backend, + prCapture, + }) => { + test.setTimeout(240_000); + const task = await seedCompletedConversation( + apiClient, + seedData, + `Mobile completed workspace restoration ${Date.now()}`, + ); + if (!task.session_id) throw new Error("completed task has no session_id"); + + const beforeSessions = await apiClient.listTaskSessions(task.id); + const beforeMessages = await apiClient.listSessionMessages(task.session_id); + await restartAndAssertColdWorkspace(backend, apiClient, task.id, task.session_id); + + const failure = await failNextWorkspaceRestore( + testPage, + task.id, + task.session_id, + "workspace restore failed for mobile e2e", + ); + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await expect(session.completedSessionBanner()).toBeVisible({ timeout: 30_000 }); + + await testPage.getByRole("button", { name: "Files" }).tap(); + const workspaceUnavailable = testPage.getByTestId("workspace-unavailable"); + await expect(workspaceUnavailable).toBeVisible({ timeout: 30_000 }); + await expect(testPage.getByTestId("file-tree-waiting")).toHaveCount(0); + await expect(session.recoveryError()).toHaveCount(0); + expect(failure.wasConsumed()).toBe(true); + + await workspaceUnavailable.getByText("Technical details", { exact: true }).tap(); + await expect(workspaceUnavailable.locator("pre")).toContainText( + "workspace restore failed for mobile e2e", + ); + + const retry = workspaceUnavailable.getByTestId("workspace-retry"); + const retryBox = await retry.boundingBox(); + expect(retryBox, "mobile workspace retry has no rendered hitbox").not.toBeNull(); + expect(retryBox?.height ?? 0).toBeGreaterThanOrEqual(44); + await retry.tap(); + + const fileNode = session.fileTreeNode(RETAINED_WORKSPACE_FILE); + await expect(fileNode).toBeVisible({ timeout: 60_000 }); + await fileNode.tap(); + const viewer = testPage.getByTestId("mobile-file-viewer-panel"); + await expect(viewer).toBeVisible({ timeout: 15_000 }); + await expect( + viewer + .getByTestId("mobile-file-viewer-content") + .locator(".cm-line") + .filter({ hasText: RETAINED_WORKSPACE_CONTENT }), + ).toBeVisible(); + + await viewer.getByRole("button", { name: "Close" }).tap(); + await expect(viewer).toHaveCount(0); + + await testPage.getByRole("button", { name: "Changes" }).tap(); + const changes = testPage.getByTestId("mobile-changes-panel"); + await expect(changes).toBeVisible(); + await expect(changes.getByTestId("workspace-unavailable")).toHaveCount(0); + + await switchToTerminalPanel(testPage); + await waitForShellReady(testPage, 60_000); + await focusTerminalForTyping(testPage); + await testPage.keyboard.type("printf 'MOBILE_RESTORED_WORKSPACE_SHELL\\n'"); + await testPage.keyboard.press("Enter"); + await expect + .poll(() => readTerminalBuffer(testPage), { + timeout: 15_000, + message: "Waiting for the restored mobile shell command", + }) + .toContain("MOBILE_RESTORED_WORKSPACE_SHELL"); + + const workspaceStatus = await apiClient.wsRequest<{ + state: string; + is_agent_running: boolean; + }>("task.session.status", { task_id: task.id, session_id: task.session_id }); + expect(workspaceStatus.state).toBe("COMPLETED"); + expect(workspaceStatus.is_agent_running).toBe(false); + + const afterWorkspace = await apiClient.listTaskSessions(task.id); + const afterWorkspaceMessages = await apiClient.listSessionMessages(task.session_id); + expect(afterWorkspace.sessions).toHaveLength(beforeSessions.sessions.length); + expect(afterWorkspace.sessions.find((item) => item.id === task.session_id)?.state).toBe( + "COMPLETED", + ); + expect(afterWorkspaceMessages.messages).toHaveLength(beforeMessages.messages.length); + expect(await apiClient.getTask(task.id)).toMatchObject({ + id: task.id, + state: "COMPLETED", + primary_session_id: task.session_id, + }); + await expect(session.recoveryError()).toHaveCount(0); + + await prCapture.screenshot("completed-workspace-restoration-mobile", { + caption: "Mobile completed task with restored workspace controls", + }); + + await testPage.reload(); + await testPage.getByRole("button", { name: "Chat" }).tap(); + await session.waitForLoad(); + await testPage.getByRole("button", { name: "Files" }).tap(); + await expect(session.fileTreeNode(RETAINED_WORKSPACE_FILE)).toBeVisible({ timeout: 60_000 }); + await expect(testPage.getByTestId("workspace-unavailable")).toHaveCount(0); + + const finalSessions = await apiClient.listTaskSessions(task.id); + const finalMessages = await apiClient.listSessionMessages(task.session_id); + expect(finalSessions.sessions).toHaveLength(beforeSessions.sessions.length); + expect(finalMessages.messages).toHaveLength(beforeMessages.messages.length); + await assertNoDocumentHorizontalOverflow(testPage, "mobile completed workspace restoration"); + }); +}); diff --git a/apps/web/hooks/domains/session/use-session-resumption-operations.ts b/apps/web/hooks/domains/session/use-session-resumption-operations.ts index f5d43b6260..3d81928a83 100644 --- a/apps/web/hooks/domains/session/use-session-resumption-operations.ts +++ b/apps/web/hooks/domains/session/use-session-resumption-operations.ts @@ -13,6 +13,8 @@ import { import { isLaunchStateRegression } from "@/lib/session-state"; import { t } from "@/lib/i18n"; import { WebSocketRequestError } from "@/lib/ws/client"; +import type { WorkspaceRestorationCallbacks } from "./use-workspace-restoration"; +import type { WorkspaceRestorationAttempt } from "@/lib/state/slices/session-runtime/workspace-restoration"; export type TaskArchiveState = boolean | null; @@ -86,6 +88,8 @@ export type ResumeStateSetter = { getLiveSession?: (sessionId: string) => SessionLike; /** Retains the causes from automatic resume and workspace-restore attempts. */ setRecoveryFailure?: (failure: SessionRecoveryFailure | null) => void; + /** Publishes restore_workspace outcomes separately from agent recovery. */ + workspaceRestoration?: WorkspaceRestorationCallbacks; /** Refreshes the owning task after a typed archive conflict. */ onTaskArchiveConflict?: () => void; }; @@ -240,46 +244,109 @@ type ResumeLaunchContext = { canContinue: () => boolean; }; +function beginWorkspaceRestore( + request: LaunchSessionRequest, + context: ResumeLaunchContext, +): WorkspaceRestorationAttempt | null { + if (request.intent !== "restore_workspace") return null; + return context.setters.workspaceRestoration?.begin(context.taskId, context.sessionId) ?? null; +} + +function settleWorkspaceRestoreFailure( + attempt: WorkspaceRestorationAttempt | null, + error: unknown, + context: ResumeLaunchContext, +): void { + if (!attempt) return; + context.setters.workspaceRestoration?.fail(attempt, error); + context.setters.setResumptionState("error"); + context.setters.setError(null); + context.setters.setNotice?.(null); + context.setters.setRecoveryFailure?.(null); +} + +function isWorkspaceRestoreRequest(request: LaunchSessionRequest): boolean { + return request.intent === "restore_workspace"; +} + +function getLaunchResponseError(response: ResumeResponse, request: LaunchSessionRequest): Error { + if (response.error) return new Error(response.error); + if (isWorkspaceRestoreRequest(request)) return new Error(t("task:failedToRestoreWorkspace")); + return new Error(t("task:failedToResumeSession")); +} + +function toLaunchError(error: unknown): Error { + return error instanceof Error ? error : new Error(t("common:unknownError")); +} + +function applyLaunchSuccess( + response: ResumeResponse, + request: LaunchSessionRequest, + context: ResumeLaunchContext, +): LaunchAttempt { + applyResumeResponse( + response, + context.taskId, + context.sessionId, + context.session, + context.setters, + ); + if (!isWorkspaceRestoreRequest(request)) return { ok: true }; + // restore_workspace only admits the retained workspace. Agentctl readiness + // is settled by the matching session.agentctl_ready event so a later health + // failure cannot be hidden by an optimistic response. + return { ok: true }; +} + +function applyLaunchFailure( + response: ResumeResponse, + request: LaunchSessionRequest, + workspaceAttempt: WorkspaceRestorationAttempt | null, + context: ResumeLaunchContext, +): LaunchAttempt { + const error = getLaunchResponseError(response, request); + if (isWorkspaceRestoreRequest(request)) { + settleWorkspaceRestoreFailure(workspaceAttempt, error, context); + } + return { ok: false, error }; +} + +function handleLaunchException( + error: unknown, + request: LaunchSessionRequest, + workspaceAttempt: WorkspaceRestorationAttempt | null, + context: ResumeLaunchContext, +): LaunchAttempt { + const archived = isTaskArchivedConflict(error); + if (archived) { + if (workspaceAttempt) context.setters.workspaceRestoration?.clear(workspaceAttempt); + return { ok: false, error: toLaunchError(error), archived: true }; + } + if (isWorkspaceRestoreRequest(request)) { + settleWorkspaceRestoreFailure(workspaceAttempt, error, context); + } + return { ok: false, error: toLaunchError(error), archived: false }; +} + /** Launch a session via a request builder and apply the response. */ async function resumeViaLaunch( buildRequest: (taskId: string, sessionId: string) => { request: LaunchSessionRequest }, context: ResumeLaunchContext, ): Promise { - const { taskId, sessionId, session, setters, canContinue } = context; + const { taskId, sessionId, setters, canContinue } = context; if (!canContinue()) return false; setters.setResumptionState("resuming"); setters.setRecoveryFailure?.(null); const { request } = buildRequest(taskId, sessionId); - let launchResp: Awaited>; - try { - launchResp = await launchSession(request); - } catch (error) { - if (isTaskArchivedConflict(error)) { - clearArchiveRecovery(setters); - return false; - } - throw error; - } + const launchAttempt = await tryLaunch(request, context); if (!canContinue()) return false; - const ok = applyResumeResponse( - { - success: launchResp.success, - state: launchResp.state, - worktree_path: launchResp.worktree_path, - worktree_branch: launchResp.worktree_branch, - }, - taskId, - sessionId, - session, - setters, - ); - // restore_workspace's whole purpose is to bring up agentctl HTTP for an - // otherwise-idle session. A successful response means the workspace and - // agentctl are ready even when a reconnect dropped the readiness event. - if (ok && request.intent === "restore_workspace" && setters.setAgentctlReady) { - setters.setAgentctlReady(sessionId); + if (launchAttempt.ok) return true; + if (launchAttempt.archived) { + clearArchiveRecovery(setters); + return false; } - return ok; + if (isWorkspaceRestoreRequest(request)) return false; + throw launchAttempt.error; } async function restoreAfterResumeFailure( @@ -354,49 +421,33 @@ async function tryLaunch( request: LaunchSessionRequest, context: ResumeLaunchContext, ): Promise { - const { sessionId, session, setters, canContinue } = context; + const { canContinue, setters } = context; if (!canContinue()) return { ok: false, error: new Error() }; + const workspaceAttempt = beginWorkspaceRestore(request, context); + if (isWorkspaceRestoreRequest(request) && setters.workspaceRestoration && !workspaceAttempt) { + return { ok: false, error: new Error(t("task:workspaceRestoreInProgress")) }; + } try { const resp = await launchSession(request); - if (!canContinue()) return { ok: false, error: new Error() }; - if (!resp.success) { - return { - ok: false, - error: new Error( - resp.error ?? - (request.intent === "restore_workspace" - ? t("task:failedToRestoreWorkspace") - : t("task:failedToResumeSession")), - ), - }; + if (!canContinue()) { + if (workspaceAttempt) setters.workspaceRestoration?.clear(workspaceAttempt); + return { ok: false, error: new Error() }; } - applyResumeResponse( - { - success: true, - state: resp.state, - worktree_path: resp.worktree_path, - worktree_branch: resp.worktree_branch, - }, - context.taskId, - sessionId, - session, - setters, - ); - if (request.intent === "restore_workspace" && setters.setAgentctlReady) { - setters.setAgentctlReady(sessionId); - } - return { ok: true }; + if (!resp.success) return applyLaunchFailure(resp, request, workspaceAttempt, context); + return applyLaunchSuccess(resp, request, context); } catch (err) { console.error("[tryLaunch] session launch failed", { intent: request.intent, - sessionId, + sessionId: context.sessionId, err, }); - return { - ok: false, - error: err instanceof Error ? err : new Error(t("common:unknownError")), - archived: isTaskArchivedConflict(err), - }; + if (!canContinue()) { + // A rejected stale request must not strand its pending workspace row. + // The attempt carries its own task/session/environment/revision fence. + if (workspaceAttempt) setters.workspaceRestoration?.clear(workspaceAttempt); + return { ok: false, error: toLaunchError(err) }; + } + return handleLaunchException(err, request, workspaceAttempt, context); } } @@ -405,8 +456,11 @@ export type ResumeAction = "running" | "skip" | "resume" | "restore" | "idle"; export function decideResumeAction(status: SessionStatus, preventAutoStart: boolean): ResumeAction { if (status.is_agent_running) return "running"; // Completed sessions remain passive until the user explicitly chooses the - // completed-chat Resume action. Open-time recovery must not revive them. - if (status.state === "COMPLETED") return "idle"; + // completed-chat Resume action. Workspace recovery is separate and does not + // revive the agent conversation. + if (status.state === "COMPLETED") { + return status.needs_workspace_restore ? "restore" : "idle"; + } if (preventAutoStart && status.needs_resume && status.is_resumable) return "skip"; if (status.needs_resume && status.is_resumable) return "resume"; if (status.needs_workspace_restore) return "restore"; diff --git a/apps/web/hooks/domains/session/use-session-resumption-request-guard.ts b/apps/web/hooks/domains/session/use-session-resumption-request-guard.ts index d0f800028d..2acdb0ee10 100644 --- a/apps/web/hooks/domains/session/use-session-resumption-request-guard.ts +++ b/apps/web/hooks/domains/session/use-session-resumption-request-guard.ts @@ -54,6 +54,19 @@ export function buildGuardedSetters( setRecoveryFailure: (failure) => { if (guard()) setters.setRecoveryFailure?.(failure); }, + workspaceRestoration: setters.workspaceRestoration + ? { + begin: (taskId, sessionId) => + guard() ? (setters.workspaceRestoration?.begin(taskId, sessionId) ?? null) : null, + complete: (attempt) => + guard() ? (setters.workspaceRestoration?.complete(attempt) ?? false) : false, + fail: (attempt, error) => + guard() ? (setters.workspaceRestoration?.fail(attempt, error) ?? false) : false, + // clearWorkspaceRestoration validates the attempt identity itself. + // Allow stale request cleanup to remove only its own matching row. + clear: (attempt) => setters.workspaceRestoration?.clear(attempt) ?? false, + } + : undefined, onTaskArchiveConflict: () => { if (guard()) setters.onTaskArchiveConflict?.(); }, diff --git a/apps/web/hooks/domains/session/use-session-resumption.test.ts b/apps/web/hooks/domains/session/use-session-resumption.test.ts index e97658570d..7a1206ea03 100644 --- a/apps/web/hooks/domains/session/use-session-resumption.test.ts +++ b/apps/web/hooks/domains/session/use-session-resumption.test.ts @@ -2,6 +2,9 @@ import { act, renderHook, waitFor } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach } from "vitest"; +import { buildRestoreWorkspaceRequest } from "@/lib/services/session-launch-helpers"; +import { sanitizeWorkspaceRestorationDetails } from "@/lib/state/slices/session-runtime/workspace-restoration"; +import { createAppStore } from "@/lib/state/store"; const mockRequest = vi.fn(); const mockSetTaskSession = vi.fn(); @@ -43,12 +46,15 @@ const RESUME_TRANSPORT_ERROR = "Resume transport failed"; const WORKSPACE_RESTORE_ERROR = "Workspace restore failed"; import { + decideResumeAction, resumeWithSilentFallback, useSessionResumption, type ResumeStateSetter, type ResumptionState, type SessionRecoveryFailure, } from "./use-session-resumption"; +import { resumeViaLaunch } from "./use-session-resumption-operations"; +import { buildGuardedSetters } from "./use-session-resumption-request-guard"; type SetterCalls = { resumptionStates: ResumptionState[]; @@ -332,7 +338,7 @@ describe("resumeWithSilentFallback", () => { }); }); - it("seeds agentctl ready when restore_workspace fallback succeeds", async () => { + it("does not optimistically seed agentctl ready after restore_workspace succeeds", async () => { mockRequest .mockResolvedValueOnce({ success: false, task_id: TASK_ID, state: FAILED_STATE }) .mockResolvedValueOnce({ @@ -347,8 +353,85 @@ describe("resumeWithSilentFallback", () => { await resumeWithSilentFallback(TASK_ID, SESSION_ID, null, setters); - expect(setAgentctlReady).toHaveBeenCalledTimes(1); - expect(setAgentctlReady).toHaveBeenCalledWith(SESSION_ID); + expect(setAgentctlReady).not.toHaveBeenCalled(); + }); + + it("keeps workspace-only restore failures out of global recovery feedback", async () => { + mockRequest.mockResolvedValueOnce({ + success: false, + task_id: TASK_ID, + session_id: SESSION_ID, + state: FAILED_STATE, + error: WORKSPACE_RESTORE_ERROR, + }); + const { setters, calls } = createSetters(); + const attempt = { + taskId: TASK_ID, + sessionId: SESSION_ID, + environmentId: "environment-1", + revision: 1, + status: "pending" as const, + }; + const fail = vi.fn(() => true); + const complete = vi.fn(() => true); + setters.workspaceRestoration = { + begin: () => attempt, + complete, + fail, + clear: vi.fn(() => true), + }; + + const restored = await resumeViaLaunch(buildRestoreWorkspaceRequest, { + taskId: TASK_ID, + sessionId: SESSION_ID, + session: null, + setters, + canContinue: () => true, + }); + + expect(restored).toBe(false); + expect(fail).toHaveBeenCalledWith(attempt, expect.any(Error)); + expect(complete).not.toHaveBeenCalled(); + expect(calls.errors.at(-1)).toBeNull(); + expect(calls.recoveryFailures.at(-1)).toBeNull(); + }); + + it("clears a stale workspace attempt when restore rejects after navigation", async () => { + let rejectRestore: ((error: Error) => void) | undefined; + mockRequest.mockReturnValueOnce( + new Promise((_, reject) => { + rejectRestore = reject; + }), + ); + const { setters } = createSetters(); + const attempt = { + taskId: TASK_ID, + sessionId: SESSION_ID, + environmentId: "environment-1", + revision: 1, + status: "pending" as const, + }; + const clear = vi.fn(() => true); + setters.workspaceRestoration = { + begin: () => attempt, + complete: vi.fn(() => true), + fail: vi.fn(() => true), + clear, + }; + let current = true; + const restorePromise = resumeViaLaunch(buildRestoreWorkspaceRequest, { + taskId: TASK_ID, + sessionId: SESSION_ID, + session: null, + setters, + canContinue: () => current, + }); + + current = false; + rejectRestore?.(new Error("late restore failure")); + + expect(await restorePromise).toBe(false); + expect(clear).toHaveBeenCalledWith(attempt); }); it("does not seed agentctl ready when resume succeeds (new execution will emit its own events)", async () => { @@ -710,6 +793,23 @@ describe("useSessionResumption monotonic terminal hydration", () => { }); describe("useSessionResumption completed-session admission", () => { + it("selects workspace restoration for a completed session without selecting agent resume", () => { + const action = decideResumeAction( + { + session_id: SESSION_ID, + task_id: TASK_ID, + state: "COMPLETED", + is_agent_running: false, + is_resumable: true, + needs_resume: true, + needs_workspace_restore: true, + }, + false, + ); + + expect(action).toBe("restore"); + }); + it.each([false, true])( "does not auto-resume a completed session when preventAutoStart is %s", async (preventAutoStart) => { @@ -783,6 +883,7 @@ describe("useSessionResumption resume-skipped clearing on running status", () => }); }); +// eslint-disable-next-line max-lines-per-function -- navigation and response races share one harness. describe("useSessionResumption stale-callback guard after navigation", () => { beforeEach(() => { vi.clearAllMocks(); @@ -833,4 +934,72 @@ describe("useSessionResumption stale-callback guard after navigation", () => { expect(mockSetResumeSkipped).not.toHaveBeenCalled(); expect(mockSetTaskSession).not.toHaveBeenCalled(); }); + + it.each([ + ["success", { success: true, state: "WAITING_FOR_INPUT" }], + ["rejection", new Error("obsolete restore rejected")], + ] as const)( + "cleans up an obsolete automatic restore when navigation A to B to A receives a %s response", + async (_outcome, response) => { + const workspaceStore = createAppStore(); + const { setters } = createSetters(); + setters.workspaceRestoration = { + begin: (taskId, sessionId) => + workspaceStore.getState().beginWorkspaceRestoration(taskId, sessionId, "env-a"), + complete: (attempt) => workspaceStore.getState().completeWorkspaceRestoration(attempt), + fail: (attempt, details) => + workspaceStore + .getState() + .failWorkspaceRestoration(attempt, sanitizeWorkspaceRestorationDetails(details)), + clear: (attempt) => workspaceStore.getState().clearWorkspaceRestoration(attempt), + }; + const activeRequestRef = { + current: { key: "task-a/session-a", generation: 1 }, + }; + const capturedRequest = activeRequestRef.current; + const guardedSetters = buildGuardedSetters(activeRequestRef, capturedRequest, setters); + const launchResponse = Promise.withResolvers(); + mockRequest.mockReturnValueOnce(launchResponse.promise); + + const restore = resumeViaLaunch(buildRestoreWorkspaceRequest, { + taskId: "task-a", + sessionId: "session-a", + session: null, + setters: guardedSetters, + canContinue: () => + activeRequestRef.current.key === capturedRequest.key && + activeRequestRef.current.generation === capturedRequest.generation, + }); + + await waitFor(() => { + expect( + workspaceStore.getState().workspaceRestoration.byEnvironmentId["env-a"], + ).toMatchObject({ + taskId: "task-a", + sessionId: "session-a", + status: "pending", + }); + }); + + // Navigate to B before A's workspace request settles. + activeRequestRef.current = { key: "task-b/session-b", generation: 2 }; + if (response instanceof Error) launchResponse.reject(response); + else launchResponse.resolve(response); + await expect(restore).resolves.toBe(false); + + // Returning to A must not find the obsolete pending attempt. + activeRequestRef.current = { key: "task-a/session-a", generation: 3 }; + expect( + workspaceStore.getState().workspaceRestoration.byEnvironmentId["env-a"], + ).toBeUndefined(); + const retry = workspaceStore + .getState() + .beginWorkspaceRestoration("task-a", "session-a", "env-a"); + expect(retry).toMatchObject({ + taskId: "task-a", + sessionId: "session-a", + status: "pending", + }); + }, + ); }); diff --git a/apps/web/hooks/domains/session/use-session-resumption.ts b/apps/web/hooks/domains/session/use-session-resumption.ts index b681e6fa68..6bbf3f690d 100644 --- a/apps/web/hooks/domains/session/use-session-resumption.ts +++ b/apps/web/hooks/domains/session/use-session-resumption.ts @@ -35,6 +35,10 @@ import { type TaskSessionState, } from "@/lib/types/http"; import { t } from "@/lib/i18n"; +import { + useWorkspaceRestoration, + type WorkspaceRestorationResult, +} from "./use-workspace-restoration"; export type { ResumptionState, @@ -45,7 +49,11 @@ export type { SessionStatus, TaskArchiveState, } from "./use-session-resumption-operations"; -export { markSessionStarting, resumeWithSilentFallback } from "./use-session-resumption-operations"; +export { + decideResumeAction, + markSessionStarting, + resumeWithSilentFallback, +} from "./use-session-resumption-operations"; type CheckAndResumeParams = { taskId: string; sessionId: string; @@ -327,6 +335,7 @@ interface UseSessionResumptionReturn { worktreePath: string | null; worktreeBranch: string | null; resumeSession: () => Promise; + workspaceRestoration: WorkspaceRestorationResult; } /** @@ -598,6 +607,7 @@ export function useSessionResumption( const setSessionAgentctlStatus = useAppStore((state) => state.setSessionAgentctlStatus); const setResumeSkipped = useAppStore((state) => state.setResumeSkipped); const storeApi = useAppStoreApi(); + const workspaceRestoration = useWorkspaceRestoration(taskId, sessionId); const setters: ResumeStateSetter = { setResumptionState, @@ -611,6 +621,7 @@ export function useSessionResumption( setResumeSkipped, getLiveSession: (sid: string) => storeApi.getState().taskSessions.items[sid] ?? null, setRecoveryFailure, + workspaceRestoration: workspaceRestoration.callbacks ?? undefined, onTaskArchiveConflict: options.onTaskArchiveConflict, }; @@ -645,5 +656,6 @@ export function useSessionResumption( worktreePath, worktreeBranch, resumeSession, + workspaceRestoration, }; } diff --git a/apps/web/hooks/domains/session/use-workspace-restoration.test.ts b/apps/web/hooks/domains/session/use-workspace-restoration.test.ts new file mode 100644 index 0000000000..f48609474a --- /dev/null +++ b/apps/web/hooks/domains/session/use-workspace-restoration.test.ts @@ -0,0 +1,244 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { restoreSessionWorkspace } from "@/lib/services/session-recovery-service"; +import { + beginWorkspaceRestoration as beginAttempt, + clearWorkspaceRestoration as clearAttempt, + completeWorkspaceRestoration as completeAttempt, + failWorkspaceRestoration as failAttempt, + type WorkspaceRestorationState, +} from "@/lib/state/slices/session-runtime/workspace-restoration"; +import { useWorkspaceRestoration } from "./use-workspace-restoration"; + +vi.mock("@/lib/services/session-recovery-service", () => ({ + restoreSessionWorkspace: vi.fn(), +})); + +const mockRestoreSessionWorkspace = vi.mocked(restoreSessionWorkspace); +const taskId = "task-1"; +const sessionId = "session-1"; +const environmentId = "environment-1"; +const agentExecutionId = "execution-1"; + +let workspaceRestoration: WorkspaceRestorationState; +let state: Record; +const bumpWorkspaceFilesRefresh = vi.fn(); + +function resetStore() { + workspaceRestoration = { byEnvironmentId: {} }; + state = { + environmentIdBySessionId: { [sessionId]: environmentId }, + taskSessions: { items: { [sessionId]: { task_environment_id: environmentId } } }, + sessionAgentctl: { itemsBySessionId: {} }, + get workspaceRestoration() { + return workspaceRestoration; + }, + beginWorkspaceRestoration: ( + nextTaskId: string, + nextSessionId: string, + nextEnvironmentId: string, + ) => + beginAttempt(workspaceRestoration, { + taskId: nextTaskId, + sessionId: nextSessionId, + environmentId: nextEnvironmentId, + }), + completeWorkspaceRestoration: (attempt: Parameters[1]) => + completeAttempt(workspaceRestoration, attempt), + failWorkspaceRestoration: (attempt: Parameters[1], details: string) => + failAttempt(workspaceRestoration, attempt, details), + clearWorkspaceRestoration: (attempt: Parameters[1]) => + clearAttempt(workspaceRestoration, attempt), + bumpWorkspaceFilesRefresh, + }; +} + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (value: Record) => unknown) => selector(state), + useAppStoreApi: () => ({ getState: () => state }), +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})); + +beforeEach(() => { + vi.clearAllMocks(); + resetStore(); +}); + +describe("useWorkspaceRestoration attempt lifecycle", () => { + it("settles a failed restore before workspace consumers become ready", async () => { + mockRestoreSessionWorkspace.mockRejectedValueOnce(new Error("backend\u0000failure")); + const { result, rerender } = renderHook(() => + useWorkspaceRestoration(taskId, sessionId, environmentId), + ); + + await act(async () => { + expect(await result.current.restore()).toBe(false); + }); + rerender(); + + expect(result.current.status).toBe("error"); + expect(result.current.attempt?.details).toBe("backendfailure"); + expect(bumpWorkspaceFilesRefresh).not.toHaveBeenCalled(); + }); + + it("retries a failed restore, clears the error, and refreshes workspace consumers", async () => { + mockRestoreSessionWorkspace + .mockRejectedValueOnce(new Error("first failure")) + .mockResolvedValueOnce({ success: true, agent_execution_id: agentExecutionId } as never); + const { result, rerender } = renderHook(() => + useWorkspaceRestoration(taskId, sessionId, environmentId), + ); + + await act(async () => { + await result.current.restore(); + }); + rerender(); + expect(result.current.status).toBe("error"); + + state.sessionAgentctl = { + itemsBySessionId: { [sessionId]: { status: "ready", agentExecutionId } }, + }; + + await act(async () => { + expect(await result.current.restore()).toBe(true); + }); + rerender(); + + expect(result.current.status).toBe("ready"); + expect(result.current.attempt?.details).toBeUndefined(); + expect(mockRestoreSessionWorkspace).toHaveBeenCalledTimes(2); + expect(bumpWorkspaceFilesRefresh).toHaveBeenCalledWith(sessionId); + }); +}); + +// eslint-disable-next-line max-lines-per-function -- concurrency and mapping races share one harness. +describe("useWorkspaceRestoration concurrency guards", () => { + it("rejects a duplicate retry while the first restore is pending", async () => { + let resolveRestore: (() => void) | undefined; + mockRestoreSessionWorkspace.mockReturnValueOnce( + new Promise((resolve) => { + resolveRestore = () => resolve({ success: true } as never); + }), + ); + const { result, rerender } = renderHook(() => + useWorkspaceRestoration(taskId, sessionId, environmentId), + ); + + let firstRestore: Promise | undefined; + await act(async () => { + firstRestore = result.current.restore(); + }); + rerender(); + expect(result.current.status).toBe("pending"); + + await act(async () => { + expect(await result.current.restore()).toBe(false); + }); + expect(mockRestoreSessionWorkspace).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveRestore?.(); + expect(await firstRestore).toBe(true); + }); + rerender(); + expect(result.current.status).toBe("pending"); + }); + + it("settles a fallback-key attempt after its environment mapping is registered", async () => { + let resolveRestore: (() => void) | undefined; + mockRestoreSessionWorkspace.mockReturnValueOnce( + new Promise((resolve) => { + resolveRestore = () => + resolve({ success: true, agent_execution_id: agentExecutionId } as never); + }), + ); + const { result, rerender } = renderHook( + ({ currentEnvironmentId }) => + useWorkspaceRestoration(taskId, sessionId, currentEnvironmentId), + { initialProps: { currentEnvironmentId: sessionId } }, + ); + + let restorePromise: Promise | undefined; + await act(async () => { + restorePromise = result.current.restore(); + }); + const fallbackAttempt = workspaceRestoration.byEnvironmentId[sessionId]; + expect(fallbackAttempt).toMatchObject({ + taskId, + sessionId, + environmentId: sessionId, + status: "pending", + }); + + workspaceRestoration.byEnvironmentId[environmentId] = { + ...fallbackAttempt!, + environmentId, + }; + delete workspaceRestoration.byEnvironmentId[sessionId]; + state.sessionAgentctl = { + itemsBySessionId: { [sessionId]: { status: "ready", agentExecutionId } }, + }; + rerender({ currentEnvironmentId: environmentId }); + + await act(async () => { + resolveRestore?.(); + expect(await restorePromise).toBe(true); + }); + expect(workspaceRestoration.byEnvironmentId[environmentId]).toMatchObject({ + environmentId, + status: "ready", + }); + }); + + it("does not let a restore from the previous task settle the current attempt", async () => { + const restores: Array<() => void> = []; + mockRestoreSessionWorkspace.mockImplementation( + () => + new Promise((resolve) => + restores.push(() => + resolve({ success: true, agent_execution_id: agentExecutionId } as never), + ), + ), + ); + const { result, rerender } = renderHook( + ({ currentTaskId }) => useWorkspaceRestoration(currentTaskId, sessionId, environmentId), + { initialProps: { currentTaskId: taskId } }, + ); + + let firstRestore: Promise | undefined; + await act(async () => { + firstRestore = result.current.restore(); + }); + rerender({ currentTaskId: "task-2" }); + let secondRestore: Promise | undefined; + await act(async () => { + secondRestore = result.current.restore(); + }); + rerender({ currentTaskId: "task-2" }); + expect(result.current.status).toBe("pending"); + + await act(async () => { + restores[0]?.(); + expect(await firstRestore).toBe(false); + }); + rerender({ currentTaskId: "task-2" }); + expect(result.current.attempt?.taskId).toBe("task-2"); + expect(result.current.status).toBe("pending"); + + state.sessionAgentctl = { + itemsBySessionId: { [sessionId]: { status: "ready", agentExecutionId } }, + }; + await act(async () => { + restores[1]?.(); + expect(await secondRestore).toBe(true); + }); + rerender({ currentTaskId: "task-2" }); + expect(result.current.status).toBe("ready"); + expect(result.current.attempt?.taskId).toBe("task-2"); + }); +}); diff --git a/apps/web/hooks/domains/session/use-workspace-restoration.ts b/apps/web/hooks/domains/session/use-workspace-restoration.ts new file mode 100644 index 0000000000..058215304a --- /dev/null +++ b/apps/web/hooks/domains/session/use-workspace-restoration.ts @@ -0,0 +1,150 @@ +import { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { useAppStore, useAppStoreApi } from "@/components/state-provider"; +import { restoreSessionWorkspace } from "@/lib/services/session-recovery-service"; +import type { SessionAgentctlStatus } from "@/lib/state/slices/session/types"; +import { + isWorkspaceRestorationAttemptCurrent, + resolveWorkspaceRestorationKey, + sanitizeWorkspaceRestorationDetails, + type WorkspaceRestorationAttempt, + type WorkspaceRestorationStatus, +} from "@/lib/state/slices/session-runtime/workspace-restoration"; + +export type WorkspaceRestorationCallbacks = { + begin: (taskId: string, sessionId: string) => WorkspaceRestorationAttempt | null; + complete: (attempt: WorkspaceRestorationAttempt) => boolean; + fail: (attempt: WorkspaceRestorationAttempt, error: unknown) => boolean; + clear: (attempt: WorkspaceRestorationAttempt) => boolean; +}; + +export type WorkspaceRestorationResult = { + status: WorkspaceRestorationStatus | null; + attempt: WorkspaceRestorationAttempt | null; + restore: () => Promise; + callbacks: WorkspaceRestorationCallbacks | null; +}; + +type WorkspaceRestoreResponse = Awaited>; + +function settleWorkspaceRestoreResponse({ + response, + agentctlStatus, + attempt, + callbacks, + sessionId, + t, + bumpWorkspaceFilesRefresh, +}: { + response: WorkspaceRestoreResponse; + agentctlStatus: SessionAgentctlStatus | undefined; + attempt: WorkspaceRestorationAttempt; + callbacks: WorkspaceRestorationCallbacks; + sessionId: string; + t: (key: string) => string; + bumpWorkspaceFilesRefresh?: (sessionId: string) => void; +}): "pending" | "ready" | "failed" { + const responseExecutionId = response.agent_execution_id?.trim(); + if (!responseExecutionId || agentctlStatus?.agentExecutionId !== responseExecutionId) { + return "pending"; + } + if (agentctlStatus.status === "error") { + callbacks.fail(attempt, agentctlStatus.errorMessage || t("task:failedToRestoreWorkspace")); + return "failed"; + } + if (agentctlStatus.status !== "ready") return "pending"; + if (!callbacks.complete(attempt)) return "failed"; + bumpWorkspaceFilesRefresh?.(sessionId); + return "ready"; +} + +function isCurrentWorkspaceAttempt( + storeApi: ReturnType, + attempt: WorkspaceRestorationAttempt, +): boolean { + return isWorkspaceRestorationAttemptCurrent(storeApi.getState().workspaceRestoration, attempt); +} + +export function useWorkspaceRestoration( + taskId: string | null | undefined, + sessionId: string | null | undefined, + explicitEnvironmentId?: string | null, +): WorkspaceRestorationResult { + const { t } = useTranslation(); + const storeApi = useAppStoreApi(); + const sessionEnvironmentId = useAppStore((state) => + sessionId + ? (state.environmentIdBySessionId?.[sessionId] ?? + state.taskSessions?.items?.[sessionId]?.task_environment_id ?? + null) + : null, + ); + const environmentId = explicitEnvironmentId ?? sessionEnvironmentId; + const environmentKey = resolveWorkspaceRestorationKey(sessionId, environmentId); + const attempt = useAppStore((state) => + environmentKey ? (state.workspaceRestoration?.byEnvironmentId?.[environmentKey] ?? null) : null, + ); + const beginAction = useAppStore((state) => state.beginWorkspaceRestoration); + const completeAction = useAppStore((state) => state.completeWorkspaceRestoration); + const failAction = useAppStore((state) => state.failWorkspaceRestoration); + const clearAction = useAppStore((state) => state.clearWorkspaceRestoration); + const bumpWorkspaceFilesRefresh = useAppStore((state) => state.bumpWorkspaceFilesRefresh); + + const callbacks = useMemo( + () => + beginAction + ? { + begin: (nextTaskId, nextSessionId) => { + if (!environmentKey) return null; + return beginAction(nextTaskId, nextSessionId, environmentKey); + }, + complete: (nextAttempt) => completeAction?.(nextAttempt) ?? false, + fail: (nextAttempt, error) => + failAction?.(nextAttempt, sanitizeWorkspaceRestorationDetails(error)) ?? false, + clear: (nextAttempt) => clearAction?.(nextAttempt) ?? false, + } + : null, + [beginAction, clearAction, completeAction, environmentKey, failAction], + ); + + const restore = useCallback(async (): Promise => { + 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; + // The response admits the workspace. A matching agentctl_ready event + // settles the attempt when readiness is still in flight. + return true; + } catch (error) { + callbacks.fail(nextAttempt, error); + return false; + } + }, [bumpWorkspaceFilesRefresh, callbacks, environmentKey, sessionId, storeApi, t, taskId]); + + return { + status: attempt?.status ?? null, + attempt, + restore, + callbacks, + }; +} diff --git a/apps/web/lib/services/session-recovery-service.ts b/apps/web/lib/services/session-recovery-service.ts index 8caa455d27..f073b49054 100644 --- a/apps/web/lib/services/session-recovery-service.ts +++ b/apps/web/lib/services/session-recovery-service.ts @@ -1,5 +1,5 @@ import { buildRestoreWorkspaceRequest } from "./session-launch-helpers"; -import { launchSession } from "./session-launch-service"; +import { launchSession, type LaunchSessionResponse } from "./session-launch-service"; import { getWebSocketClient } from "@/lib/ws/connection"; import { WebSocketRequestError, type WebSocketRequestErrorDetails } from "@/lib/ws/client"; @@ -74,9 +74,10 @@ export async function restoreSessionWorkspace( taskId: string, sessionId: string, failureMessage: string, -): Promise { +): Promise { const { request } = buildRestoreWorkspaceRequest(taskId, sessionId); const response = await launchSession(request); const failure = responseFailure(response, failureMessage); if (failure) throw failure; + return response; } diff --git a/apps/web/lib/state/default-state.ts b/apps/web/lib/state/default-state.ts index b1c5717211..c89e8f2ba1 100644 --- a/apps/web/lib/state/default-state.ts +++ b/apps/web/lib/state/default-state.ts @@ -87,6 +87,7 @@ export const defaultState = { promptUsage: defaultSessionRuntimeState.promptUsage, sessionPollMode: defaultSessionRuntimeState.sessionPollMode, embeddedVscodeSupport: defaultSessionRuntimeState.embeddedVscodeSupport, + workspaceRestoration: defaultSessionRuntimeState.workspaceRestoration, githubStatus: defaultGitHubState.githubStatus, githubAppRegistrations: defaultGitHubState.githubAppRegistrations, taskPRs: defaultGitHubState.taskPRs, diff --git a/apps/web/lib/state/slices/index.ts b/apps/web/lib/state/slices/index.ts index 1bc9250edc..0ba6645d98 100644 --- a/apps/web/lib/state/slices/index.ts +++ b/apps/web/lib/state/slices/index.ts @@ -141,6 +141,12 @@ export type { QueuedMessage, QueueState, } from "./session/types"; +export type { + WorkspaceRestorationAttempt, + WorkspaceRestorationInput, + WorkspaceRestorationState, + WorkspaceRestorationStatus, +} from "./session-runtime/workspace-restoration"; export type { TerminalState, ShellState, diff --git a/apps/web/lib/state/slices/session-runtime/migrate-env-keyed-data.test.ts b/apps/web/lib/state/slices/session-runtime/migrate-env-keyed-data.test.ts index 264fae29c3..b019dbdd34 100644 --- a/apps/web/lib/state/slices/session-runtime/migrate-env-keyed-data.test.ts +++ b/apps/web/lib/state/slices/session-runtime/migrate-env-keyed-data.test.ts @@ -41,6 +41,7 @@ function commit(sha: string): SessionCommit { }; } +// eslint-disable-next-line max-lines-per-function -- migration cases share one state fixture. describe("registerSessionEnvironment — migrateEnvKeyedData", () => { it("setUserShells writes directly under the environmentId key (no session translation)", () => { const store = makeStore(); @@ -142,6 +143,58 @@ describe("registerSessionEnvironment — migrateEnvKeyedData", () => { expect(state.sessionCommits.byEnvironmentId["env-3"]).toBeUndefined(); expect(state.sessionCommits.byEnvironmentId["sess-3"]).toBeUndefined(); }); + + it("migrates workspace restoration feedback with the environment mapping", () => { + const store = makeStore(); + const attempt = store.getState().beginWorkspaceRestoration("task-3", "sess-3", "sess-3"); + expect(attempt).not.toBeNull(); + + store.getState().registerSessionEnvironment("sess-3", "env-3"); + + const state = store.getState(); + expect(state.workspaceRestoration.byEnvironmentId["env-3"]).toMatchObject({ + taskId: "task-3", + sessionId: "sess-3", + environmentId: "env-3", + status: "pending", + }); + expect(state.workspaceRestoration.byEnvironmentId["env-3"]?.attemptId).toBe(attempt?.attemptId); + expect(state.workspaceRestoration.byEnvironmentId["sess-3"]).toBeUndefined(); + + expect(store.getState().completeWorkspaceRestoration(attempt!)).toBe(true); + expect(store.getState().workspaceRestoration.byEnvironmentId["env-3"]).toMatchObject({ + status: "ready", + }); + const newerAttempt = store.getState().beginWorkspaceRestoration("task-3", "sess-3", "env-3"); + expect(newerAttempt).toMatchObject({ + taskId: "task-3", + sessionId: "sess-3", + environmentId: "env-3", + revision: 2, + status: "pending", + }); + expect(store.getState().failWorkspaceRestoration(attempt!, "stale result")).toBe(false); + expect(store.getState().workspaceRestoration.byEnvironmentId["env-3"]).toMatchObject({ + revision: 2, + status: "pending", + }); + }); + + it("settles and clears a migrated failure with its original attempt token", () => { + const store = makeStore(); + const attempt = store.getState().beginWorkspaceRestoration("task-4", "sess-4", "sess-4"); + expect(attempt).not.toBeNull(); + + store.getState().registerSessionEnvironment("sess-4", "env-4"); + + expect(store.getState().failWorkspaceRestoration(attempt!, "restore failed")).toBe(true); + expect(store.getState().workspaceRestoration.byEnvironmentId["env-4"]).toMatchObject({ + status: "error", + details: "restore failed", + }); + expect(store.getState().clearWorkspaceRestoration(attempt!)).toBe(true); + expect(store.getState().workspaceRestoration.byEnvironmentId["env-4"]).toBeUndefined(); + }); }); describe("setGitStatus", () => { diff --git a/apps/web/lib/state/slices/session-runtime/purge-session.test.ts b/apps/web/lib/state/slices/session-runtime/purge-session.test.ts index 8172b0f351..e9c31b0706 100644 --- a/apps/web/lib/state/slices/session-runtime/purge-session.test.ts +++ b/apps/web/lib/state/slices/session-runtime/purge-session.test.ts @@ -8,6 +8,9 @@ function makeStore() { return create()(immer(createSessionRuntimeSlice)); } +const SESSION_ID = "session-1"; +const SHARED_ENVIRONMENT_ID = "env-shared"; + describe("purgeSessionRuntimeState", () => { let store: ReturnType; @@ -17,56 +20,62 @@ describe("purgeSessionRuntimeState", () => { it("drops per-session maps, process output, and env-scoped buffers", () => { const s = store.getState(); - s.registerSessionEnvironment("session-1", "env-1"); - s.appendShellOutput("session-1", "shell noise"); - s.setShellStatus("session-1", { available: true }); - s.setContextWindow("session-1", { + s.registerSessionEnvironment(SESSION_ID, "env-1"); + s.appendShellOutput(SESSION_ID, "shell noise"); + s.setShellStatus(SESSION_ID, { available: true }); + s.setContextWindow(SESSION_ID, { size: 1, used: 1, remaining: 0, efficiency: 1, compactionCount: 0, }); - s.setSessionTodos("session-1", [{ description: "do", status: "pending" }]); + s.setSessionTodos(SESSION_ID, [{ description: "do", status: "pending" }]); + s.beginWorkspaceRestoration("task-1", SESSION_ID, "env-1"); s.upsertProcessStatus({ processId: "proc-1", - sessionId: "session-1", + sessionId: SESSION_ID, kind: "dev", status: "running", }); s.appendProcessOutput("proc-1", "process noise"); store.setState((draft) => { - purgeSessionRuntimeState(draft, "session-1"); + purgeSessionRuntimeState(draft, SESSION_ID); }); const after = store.getState(); - expect(after.environmentIdBySessionId["session-1"]).toBeUndefined(); - expect(after.contextWindow.bySessionId["session-1"]).toBeUndefined(); - expect(after.sessionTodos.bySessionId["session-1"]).toBeUndefined(); - expect(after.processes.processIdsBySessionId["session-1"]).toBeUndefined(); - expect(after.processes.devProcessBySessionId["session-1"]).toBeUndefined(); + expect(after.environmentIdBySessionId[SESSION_ID]).toBeUndefined(); + expect(after.contextWindow.bySessionId[SESSION_ID]).toBeUndefined(); + expect(after.sessionTodos.bySessionId[SESSION_ID]).toBeUndefined(); + expect(after.processes.processIdsBySessionId[SESSION_ID]).toBeUndefined(); + expect(after.processes.devProcessBySessionId[SESSION_ID]).toBeUndefined(); expect(after.processes.processesById["proc-1"]).toBeUndefined(); expect(after.processes.outputsByProcessId["proc-1"]).toBeUndefined(); // env-scoped buffers gone because no other session references env-1. expect(after.shell.outputs["env-1"]).toBeUndefined(); expect(after.shell.statuses["env-1"]).toBeUndefined(); + expect(after.workspaceRestoration.byEnvironmentId["env-1"]).toBeUndefined(); }); it("retains env-scoped buffers while another session shares the environment", () => { const s = store.getState(); - s.registerSessionEnvironment("session-1", "env-shared"); - s.registerSessionEnvironment("session-2", "env-shared"); - s.appendShellOutput("session-1", "shared shell"); + s.registerSessionEnvironment(SESSION_ID, SHARED_ENVIRONMENT_ID); + s.registerSessionEnvironment("session-2", SHARED_ENVIRONMENT_ID); + s.appendShellOutput(SESSION_ID, "shared shell"); + s.beginWorkspaceRestoration("task-1", SESSION_ID, SHARED_ENVIRONMENT_ID); store.setState((draft) => { - purgeSessionRuntimeState(draft, "session-1"); + purgeSessionRuntimeState(draft, SESSION_ID); }); const after = store.getState(); - expect(after.environmentIdBySessionId["session-1"]).toBeUndefined(); - expect(after.environmentIdBySessionId["session-2"]).toBe("env-shared"); + expect(after.environmentIdBySessionId[SESSION_ID]).toBeUndefined(); + expect(after.environmentIdBySessionId["session-2"]).toBe(SHARED_ENVIRONMENT_ID); // session-2 still uses env-shared, so its shell output must survive. - expect(after.shell.outputs["env-shared"]).toBe("shared shell"); + expect(after.shell.outputs[SHARED_ENVIRONMENT_ID]).toBe("shared shell"); + expect(after.workspaceRestoration.byEnvironmentId[SHARED_ENVIRONMENT_ID]).toMatchObject({ + status: "pending", + }); }); }); diff --git a/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts b/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts index e94ebc307a..e3b197d54c 100644 --- a/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts +++ b/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts @@ -2,6 +2,12 @@ import type { StateCreator } from "zustand"; import type { SessionRuntimeSlice, SessionRuntimeSliceState, SessionPollMode } from "./types"; import { normalizeGitStatusEntry } from "./git-status-normalizer"; import { applyGitStatus } from "./git-status-state"; +import { + beginWorkspaceRestoration, + clearWorkspaceRestoration, + completeWorkspaceRestoration, + failWorkspaceRestoration, +} from "./workspace-restoration"; const maxProcessOutputBytes = 2 * 1024 * 1024; // Shell + terminal streams are unbounded over a session's lifetime; cap them at @@ -77,6 +83,7 @@ function purgeEnvScopedRuntime(state: SessionRuntimeSliceState, envKey: string) delete state.userShells.dismissedByEnvironmentId[envKey]; delete state.userShells.loading[envKey]; delete state.userShells.loaded[envKey]; + delete state.workspaceRestoration.byEnvironmentId[envKey]; } /** Drop all runtime state tied to a removed session so closed/replaced sessions @@ -117,6 +124,7 @@ export const defaultSessionRuntimeState: SessionRuntimeSliceState = { sessionPollMode: { bySessionId: {} }, embeddedVscodeSupport: { bySessionId: {} }, workspaceFilesRefresh: { bySessionId: {} }, + workspaceRestoration: { byEnvironmentId: {} }, }; type ImmerSet = Parameters[0]; @@ -342,6 +350,16 @@ export function migrateEnvKeyedData( migrate(draft.userShells.dismissedByEnvironmentId); migrate(draft.userShells.loading); migrate(draft.userShells.loaded); + const workspaceAttempt = draft.workspaceRestoration.byEnvironmentId[sessionId]; + if (workspaceAttempt) { + if (!(environmentId in draft.workspaceRestoration.byEnvironmentId)) { + draft.workspaceRestoration.byEnvironmentId[environmentId] = { + ...workspaceAttempt, + environmentId, + }; + } + delete draft.workspaceRestoration.byEnvironmentId[sessionId]; + } } function buildContextWindowActions(set: ImmerSet) { @@ -360,6 +378,50 @@ function buildContextWindowActions(set: ImmerSet) { }; } +function buildWorkspaceRestorationActions(set: ImmerSet) { + return { + beginWorkspaceRestoration: (taskId: string, sessionId: string, environmentId: string) => { + let attempt: ReturnType = null; + set((draft) => { + attempt = beginWorkspaceRestoration(draft.workspaceRestoration, { + taskId, + sessionId, + environmentId, + }); + }); + return attempt; + }, + completeWorkspaceRestoration: ( + attempt: Parameters[0], + ) => { + let completed = false; + set((draft) => { + completed = completeWorkspaceRestoration(draft.workspaceRestoration, attempt); + }); + return completed; + }, + failWorkspaceRestoration: ( + attempt: Parameters[0], + details: string, + ) => { + let failed = false; + set((draft) => { + failed = failWorkspaceRestoration(draft.workspaceRestoration, attempt, details); + }); + return failed; + }, + clearWorkspaceRestoration: ( + attempt: Parameters[0], + ) => { + let cleared = false; + set((draft) => { + cleared = clearWorkspaceRestoration(draft.workspaceRestoration, attempt); + }); + return cleared; + }, + }; +} + export const createSessionRuntimeSlice: StateCreator< SessionRuntimeSlice, [["zustand/immer", never]], @@ -412,6 +474,7 @@ export const createSessionRuntimeSlice: StateCreator< draft.environmentIdBySessionId[sessionId] = environmentId; migrateEnvKeyedData(draft, sessionId, environmentId); }), + ...buildWorkspaceRestorationActions(set), ...buildContextWindowActions(set), ...buildSessionCommitActions(set), setAvailableCommands: (sessionId, commands) => diff --git a/apps/web/lib/state/slices/session-runtime/types.ts b/apps/web/lib/state/slices/session-runtime/types.ts index aac6d9fd76..5fc53361ba 100644 --- a/apps/web/lib/state/slices/session-runtime/types.ts +++ b/apps/web/lib/state/slices/session-runtime/types.ts @@ -1,3 +1,8 @@ +import type { + WorkspaceRestorationAttempt, + WorkspaceRestorationState, +} from "./workspace-restoration"; + export type TerminalState = { terminals: Array<{ id: string; output: string[] }>; }; @@ -461,6 +466,7 @@ export type SessionRuntimeSliceState = { sessionPollMode: SessionPollModeState; embeddedVscodeSupport: EmbeddedVscodeSupportState; workspaceFilesRefresh: { bySessionId: Record }; + workspaceRestoration: WorkspaceRestorationState; }; export type SessionRuntimeSliceActions = { @@ -542,6 +548,14 @@ export type SessionRuntimeSliceActions = { ) => void; setSessionPollMode: (sessionId: string, mode: SessionPollMode) => void; setEmbeddedVscodeSupport: (sessionId: string, supported: boolean) => void; + beginWorkspaceRestoration: ( + taskId: string, + sessionId: string, + environmentId: string, + ) => WorkspaceRestorationAttempt | null; + completeWorkspaceRestoration: (attempt: WorkspaceRestorationAttempt) => boolean; + failWorkspaceRestoration: (attempt: WorkspaceRestorationAttempt, details: string) => boolean; + clearWorkspaceRestoration: (attempt: WorkspaceRestorationAttempt) => boolean; }; export type SessionRuntimeSlice = SessionRuntimeSliceState & SessionRuntimeSliceActions; diff --git a/apps/web/lib/state/slices/session-runtime/workspace-restoration.test.ts b/apps/web/lib/state/slices/session-runtime/workspace-restoration.test.ts new file mode 100644 index 0000000000..0f78413878 --- /dev/null +++ b/apps/web/lib/state/slices/session-runtime/workspace-restoration.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { + beginWorkspaceRestoration, + clearWorkspaceRestoration, + completeWorkspaceRestoration, + failWorkspaceRestoration, + sanitizeWorkspaceRestorationDetails, + type WorkspaceRestorationState, +} from "./workspace-restoration"; + +const INPUT = { taskId: "task-1", sessionId: "session-1", environmentId: "env-1" }; + +function makeState(): WorkspaceRestorationState { + return { byEnvironmentId: {} }; +} + +describe("workspace restoration attempt state", () => { + it("rejects a duplicate pending attempt for the same workspace identity", () => { + const state = makeState(); + + const first = beginWorkspaceRestoration(state, INPUT); + const duplicate = beginWorkspaceRestoration(state, INPUT); + + expect(first).toMatchObject({ ...INPUT, revision: 1, status: "pending" }); + expect(duplicate).toBeNull(); + }); + + it("supersedes a pending attempt from another session in the same environment", () => { + const state = makeState(); + const first = beginWorkspaceRestoration(state, INPUT); + + const second = beginWorkspaceRestoration(state, { + ...INPUT, + sessionId: "session-2", + }); + + expect(second).toMatchObject({ sessionId: "session-2", revision: 2, status: "pending" }); + expect(first).not.toBeNull(); + expect(failWorkspaceRestoration(state, first!, "stale")).toBe(false); + expect(state.byEnvironmentId[INPUT.environmentId]).toMatchObject({ + sessionId: "session-2", + status: "pending", + }); + }); + + it("accepts success and clears the matching failure details", () => { + const state = makeState(); + const attempt = beginWorkspaceRestoration(state, INPUT)!; + + expect(failWorkspaceRestoration(state, attempt, "backend detail")).toBe(true); + expect(state.byEnvironmentId[INPUT.environmentId]).toMatchObject({ + status: "error", + details: "backend detail", + }); + expect(completeWorkspaceRestoration(state, attempt)).toBe(true); + expect(state.byEnvironmentId[INPUT.environmentId]).toMatchObject({ + status: "ready", + }); + expect(state.byEnvironmentId[INPUT.environmentId].details).toBeUndefined(); + }); + + it("does not let an old result clear a newer attempt", () => { + const state = makeState(); + const first = beginWorkspaceRestoration(state, INPUT)!; + expect(failWorkspaceRestoration(state, first, "first attempt failed")).toBe(true); + const second = beginWorkspaceRestoration(state, INPUT)!; + expect(second.revision).toBe(first.revision + 1); + + expect(clearWorkspaceRestoration(state, first)).toBe(false); + expect(state.byEnvironmentId[INPUT.environmentId]).toMatchObject({ + revision: second.revision, + status: "pending", + }); + }); + + it("removes only the matching attempt", () => { + const state = makeState(); + const attempt = beginWorkspaceRestoration(state, INPUT)!; + + expect(clearWorkspaceRestoration(state, attempt)).toBe(true); + expect(state.byEnvironmentId[INPUT.environmentId]).toBeUndefined(); + }); + + it("strips control characters and bounds diagnostics", () => { + const details = sanitizeWorkspaceRestorationDetails( + new Error(`before\u0000${"x".repeat(600)}`), + ); + + expect(details).toHaveLength(512); + expect(details).not.toContain("\u0000"); + }); + + it("does not leave a trailing high surrogate in bounded diagnostics", () => { + const details = sanitizeWorkspaceRestorationDetails("x".repeat(511) + "😀"); + + expect(details).toHaveLength(511); + expect(details).toBe("x".repeat(511)); + }); +}); diff --git a/apps/web/lib/state/slices/session-runtime/workspace-restoration.ts b/apps/web/lib/state/slices/session-runtime/workspace-restoration.ts new file mode 100644 index 0000000000..32eb8a8f05 --- /dev/null +++ b/apps/web/lib/state/slices/session-runtime/workspace-restoration.ts @@ -0,0 +1,166 @@ +import { generateUUID } from "@/lib/utils"; + +export type WorkspaceRestorationStatus = "pending" | "ready" | "error"; + +export type WorkspaceRestorationAttempt = { + /** Stable identity that survives migration from a session fallback key. */ + attemptId?: string; + taskId: string; + sessionId: string; + environmentId: string; + revision: number; + status: WorkspaceRestorationStatus; + details?: string; +}; + +export type WorkspaceRestorationState = { + byEnvironmentId: Record; +}; + +export type WorkspaceRestorationActions = { + beginWorkspaceRestoration: ( + taskId: string, + sessionId: string, + environmentId: string, + ) => WorkspaceRestorationAttempt | null; + completeWorkspaceRestoration: (attempt: WorkspaceRestorationAttempt) => boolean; + failWorkspaceRestoration: (attempt: WorkspaceRestorationAttempt, details: string) => boolean; + clearWorkspaceRestoration: (attempt: WorkspaceRestorationAttempt) => boolean; +}; + +export type WorkspaceRestorationInput = { + taskId: string; + sessionId: string; + environmentId: string; +}; + +export function resolveWorkspaceRestorationKey( + _sessionId: string | null | undefined, + environmentId?: string | null, +): string | null { + const explicitEnvironmentId = environmentId?.trim(); + if (explicitEnvironmentId) return explicitEnvironmentId; + // Workspace state is environment-scoped. Waiting for the canonical mapping + // prevents a late mapping from moving an in-flight attempt away from the + // key selected by a restore callback. + return null; +} + +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; +} + +function matchesAttemptFields( + current: WorkspaceRestorationAttempt | undefined, + attempt: WorkspaceRestorationAttempt, +): boolean { + if (!current) return false; + if ( + attempt.attemptId !== undefined && + current.attemptId !== undefined && + current.attemptId !== attempt.attemptId + ) { + return false; + } + return ( + current.revision === attempt.revision && + current.taskId === attempt.taskId && + current.sessionId === attempt.sessionId + ); +} + +function findMatchingWorkspaceRestorationAttempt( + state: WorkspaceRestorationState, + attempt: WorkspaceRestorationAttempt, +): [key: string, current: WorkspaceRestorationAttempt] | null { + const directMatch = state.byEnvironmentId[attempt.environmentId]; + if (matchesAttemptFields(directMatch, attempt)) { + return [attempt.environmentId, directMatch]; + } + + for (const [key, current] of Object.entries(state.byEnvironmentId)) { + if (matchesAttemptFields(current, attempt)) { + return [key, current]; + } + } + return null; +} + +export function isWorkspaceRestorationAttemptCurrent( + state: WorkspaceRestorationState, + attempt: WorkspaceRestorationAttempt, +): boolean { + return findMatchingWorkspaceRestorationAttempt(state, attempt) !== null; +} + +export function completeWorkspaceRestoration( + state: WorkspaceRestorationState, + attempt: WorkspaceRestorationAttempt, +): boolean { + const match = findMatchingWorkspaceRestorationAttempt(state, attempt); + if (!match) return false; + const [key, current] = match; + state.byEnvironmentId[key] = { + ...current, + status: "ready", + details: undefined, + }; + return true; +} + +export function failWorkspaceRestoration( + state: WorkspaceRestorationState, + attempt: WorkspaceRestorationAttempt, + details: string, +): boolean { + const match = findMatchingWorkspaceRestorationAttempt(state, attempt); + if (!match) return false; + const [key, current] = match; + state.byEnvironmentId[key] = { + ...current, + status: "error", + details, + }; + return true; +} + +export function clearWorkspaceRestoration( + state: WorkspaceRestorationState, + attempt: WorkspaceRestorationAttempt, +): boolean { + const match = findMatchingWorkspaceRestorationAttempt(state, attempt); + if (!match) return false; + const [key] = match; + delete state.byEnvironmentId[key]; + return true; +} + +/** Keep backend diagnostics safe and bounded before they reach a disclosure. */ +export function sanitizeWorkspaceRestorationDetails(error: unknown): string { + let message = String(error ?? ""); + if (error instanceof Error) message = error.message; + if (typeof error === "string") message = error; + return message + .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "") + .slice(0, 512) + .replace(/[\uD800-\uDBFF]$/u, "") + .trim(); +} diff --git a/apps/web/lib/state/store-reexports.ts b/apps/web/lib/state/store-reexports.ts index 019d9f66af..97da63e696 100644 --- a/apps/web/lib/state/store-reexports.ts +++ b/apps/web/lib/state/store-reexports.ts @@ -4,7 +4,13 @@ import type { SessionRuntimeSlice, SessionSliceActions } from "./slices"; export type WorkspaceSourceStoreState = Pick< SessionRuntimeSlice, - "workspaceFilesRefresh" | "bumpWorkspaceFilesRefresh" + | "workspaceFilesRefresh" + | "bumpWorkspaceFilesRefresh" + | "workspaceRestoration" + | "beginWorkspaceRestoration" + | "completeWorkspaceRestoration" + | "failWorkspaceRestoration" + | "clearWorkspaceRestoration" > & Pick; diff --git a/apps/web/lib/ws/handlers/agent-session.test.ts b/apps/web/lib/ws/handlers/agent-session.test.ts index cc47eafb49..33cb96487f 100644 --- a/apps/web/lib/ws/handlers/agent-session.test.ts +++ b/apps/web/lib/ws/handlers/agent-session.test.ts @@ -1467,6 +1467,68 @@ describe("session.state_changed → agentctl ready fallback", () => { ); }); + it("settles the matching workspace restore when agentctl becomes ready", () => { + const attempt = { + taskId: "t-1", + sessionId: "s-1", + environmentId: "env-1", + revision: 2, + status: "pending", + }; + const completeWorkspaceRestoration = vi.fn(() => true); + const bumpWorkspaceFilesRefresh = vi.fn(); + const store = makeStore({ + environmentIdBySessionId: { "s-1": "env-1" }, + workspaceRestoration: { byEnvironmentId: { "env-1": attempt } }, + completeWorkspaceRestoration, + bumpWorkspaceFilesRefresh, + }); + const handler = registerTaskSessionHandlers(store)["session.agentctl_ready"]!; + + handler({ + id: "m", + type: "notification", + action: "session.agentctl_ready", + timestamp: TS, + payload: { task_id: "t-1", session_id: "s-1", task_environment_id: "env-1" }, + }); + + expect(completeWorkspaceRestoration).toHaveBeenCalledWith(attempt); + expect(bumpWorkspaceFilesRefresh).toHaveBeenCalledWith("s-1"); + }); + + it("settles the matching workspace restore with sanitized agentctl error details", () => { + const attempt = { + taskId: "t-1", + sessionId: "s-1", + environmentId: "env-1", + revision: 2, + status: "pending", + }; + const failWorkspaceRestoration = vi.fn(); + const store = makeStore({ + environmentIdBySessionId: { "s-1": "env-1" }, + workspaceRestoration: { byEnvironmentId: { "env-1": attempt } }, + failWorkspaceRestoration, + }); + const handler = registerTaskSessionHandlers(store)["session.agentctl_error"]!; + + handler({ + id: "m", + type: "notification", + action: "session.agentctl_error", + timestamp: TS, + payload: { + task_id: "t-1", + session_id: "s-1", + task_environment_id: "env-1", + error_message: "backend\u0000failure", + }, + }); + + expect(failWorkspaceRestoration).toHaveBeenCalledWith(attempt, "backendfailure"); + }); + it("preserves the primary worktree when a sibling agentctl_ready arrives", () => { const upsertTaskSessionFromEvent = vi.fn(); const setTaskSession = vi.fn(); diff --git a/apps/web/lib/ws/handlers/agent-session.ts b/apps/web/lib/ws/handlers/agent-session.ts index de8e6d3ba0..c179473ada 100644 --- a/apps/web/lib/ws/handlers/agent-session.ts +++ b/apps/web/lib/ws/handlers/agent-session.ts @@ -15,6 +15,7 @@ import { import type { QueueStatusChangedPayload, TaskSessionActivityChangedPayload, + TaskSessionAgentctlPayload, } from "@/lib/types/backend"; import { syncKanbanPrimarySessionState } from "@/lib/ws/handlers/agent-session-kanban-sync"; import { parseContextWindowEntry } from "@/lib/state/slices/session-runtime/context-window"; @@ -22,6 +23,10 @@ import { ROUTE_SESSION_FIELDS } from "@/lib/ws/handlers/agent-session-route-fiel import { t } from "@/lib/i18n"; import { maybeMarkQuickChatUnseenIdle } from "@/lib/ws/handlers/quick-chat-unseen"; import { readLastAgentError } from "@/lib/session-last-agent-error"; +import { + sanitizeWorkspaceRestorationDetails, + type WorkspaceRestorationAttempt, +} from "@/lib/state/slices/session-runtime/workspace-restoration"; import { applyForegroundActivity, applyCancellationPending } from "./session-activity"; const debug = createDebugLogger("session:state"); @@ -541,6 +546,59 @@ function handleAgentctlReady(store: StoreApi, payload: any): void { } } +function resolveWorkspaceEventEnvironmentId( + state: AppState, + sessionId: string, + payloadEnvironmentId: string, +): string | null { + const mappedEnvironmentId = state.environmentIdBySessionId?.[sessionId]; + if (mappedEnvironmentId && payloadEnvironmentId && mappedEnvironmentId !== payloadEnvironmentId) { + return null; + } + return payloadEnvironmentId || mappedEnvironmentId || null; +} + +function workspaceRestorationTarget( + store: StoreApi, + payload: TaskSessionAgentctlPayload, +): { sessionId: string; attempt: WorkspaceRestorationAttempt } | null { + const sessionId = payload.session_id?.trim() ?? ""; + if (!sessionId) return null; + const state = store.getState(); + const environmentId = resolveWorkspaceEventEnvironmentId( + state, + sessionId, + payload.task_environment_id?.trim() ?? "", + ); + if (!environmentId) return null; + const attempt = state.workspaceRestoration?.byEnvironmentId?.[environmentId]; + if (!attempt || attempt.status !== "pending" || attempt.sessionId !== sessionId) return null; + return { sessionId, attempt }; +} + +/** Settle a workspace-only restore after the backend proves agentctl health. */ +function settleWorkspaceRestorationFromAgentctl( + store: StoreApi, + payload: TaskSessionAgentctlPayload, + status: "ready" | "error", +): void { + const target = workspaceRestorationTarget(store, payload); + if (!target) return; + const state = store.getState(); + + if (status === "ready") { + if (state.completeWorkspaceRestoration?.(target.attempt)) { + state.bumpWorkspaceFilesRefresh?.(target.sessionId); + } + return; + } + + const detail = sanitizeWorkspaceRestorationDetails( + payload.error_message || t("task:failedToRestoreWorkspace"), + ); + state.failWorkspaceRestoration?.(target.attempt, detail); +} + interface SessionFailureContext { taskId: TaskId; sessionId: SessionId; @@ -795,6 +853,7 @@ function handleQueueStatusChangedMessage( } /** Registers the task-session WebSocket handlers (state, messages, workspace sources, queue). */ +// eslint-disable-next-line max-lines-per-function -- session events remain one ordered registry. export function registerTaskSessionHandlers(store: StoreApi): WsHandlers { return { "message.queue.status_changed": (message) => @@ -895,6 +954,7 @@ export function registerTaskSessionHandlers(store: StoreApi): WsHandle updatedAt: message.timestamp, }); syncEnvFromAgentctlPayload(store, payload); + settleWorkspaceRestorationFromAgentctl(store, payload, "ready"); handleAgentctlReady(store, payload); }, "session.agentctl_error": (message) => { @@ -906,6 +966,7 @@ export function registerTaskSessionHandlers(store: StoreApi): WsHandle errorMessage: payload.error_message, updatedAt: message.timestamp, }); + settleWorkspaceRestorationFromAgentctl(store, payload, "error"); }, "session.workspace_sources.updated": (message) => handleWorkspaceSourcesUpdated(store, message.payload, message.timestamp), diff --git a/apps/web/src/locales/en/task.json b/apps/web/src/locales/en/task.json index 7947876e54..7bfa5d6d41 100644 --- a/apps/web/src/locales/en/task.json +++ b/apps/web/src/locales/en/task.json @@ -1819,6 +1819,9 @@ "workspaceModeLabel": "mode: {{mode}}", "workspaceRepository": "Workspace repository", "workspaceRequiresConfiguration": "Workspace requires configuration", + "workspaceRestoreFailed": "Couldn't reconnect to this task's workspace.", + "workspaceRestoreInProgress": "Reconnecting to this workspace...", + "workspaceRestorePending": "Reconnecting to this workspace...", "workspaceUnavailable": "Workspace unavailable", "unknownTaskFallback": "(unknown task)", "worktree": "Worktree", diff --git a/apps/web/src/locales/pseudo/task.json b/apps/web/src/locales/pseudo/task.json index e49c0996f7..b5766cd66e 100644 --- a/apps/web/src/locales/pseudo/task.json +++ b/apps/web/src/locales/pseudo/task.json @@ -1819,6 +1819,9 @@ "workspaceModeLabel": "ḿōďē: {{mode}}", "workspaceRepository": "Ŵōŕķśƥàćē ŕēƥōśĩţōŕŷ", "workspaceRequiresConfiguration": "Ŵōŕķśƥàćē ŕēqũĩŕēś ćōńƒĩĝũŕàţĩōń", + "workspaceRestoreFailed": "Ćōũĺďń'ţ ŕēćōńńēćţ ţō ţĥĩś ţàśķ'ś ŵōŕķśƥàćē.", + "workspaceRestoreInProgress": "Ŕēćōńńēćţĩńĝ ţō ţĥĩś ŵōŕķśƥàćē...", + "workspaceRestorePending": "Ŕēćōńńēćţĩńĝ ţō ţĥĩś ŵōŕķśƥàćē...", "workspaceUnavailable": "Ŵōŕķśƥàćē ũńàvàĩĺàƀĺē", "unknownTaskFallback": "(ũńķńōŵń ţàśķ)", "worktree": "Ŵōŕķţŕēē", diff --git a/apps/web/src/locales/pt-pt/task.json b/apps/web/src/locales/pt-pt/task.json index f9cb9f0561..5d4aecaa62 100644 --- a/apps/web/src/locales/pt-pt/task.json +++ b/apps/web/src/locales/pt-pt/task.json @@ -1762,6 +1762,9 @@ "workspaceModeLabel": "modo: {{mode}}", "workspaceRepository": "Repositório da área de trabalho", "workspaceRequiresConfiguration": "A área de trabalho requer configuração", + "workspaceRestoreFailed": "Não foi possível restabelecer a ligação à área de trabalho desta tarefa.", + "workspaceRestoreInProgress": "A restabelecer a ligação à área de trabalho...", + "workspaceRestorePending": "A restabelecer a ligação à área de trabalho...", "workspaceUnavailable": "Área de trabalho indisponível", "unknownTaskFallback": "(tarefa desconhecida)", "worktree": "Worktree", diff --git a/apps/web/src/locales/zh-cn/task.json b/apps/web/src/locales/zh-cn/task.json index 6f19ad6d1a..c9fa26ab7f 100644 --- a/apps/web/src/locales/zh-cn/task.json +++ b/apps/web/src/locales/zh-cn/task.json @@ -1761,6 +1761,9 @@ "workspaceModeLabel": "模式:{{mode}}", "workspaceRepository": "工作区仓库", "workspaceRequiresConfiguration": "工作区需要配置", + "workspaceRestoreFailed": "无法重新连接到此任务的工作区。", + "workspaceRestoreInProgress": "正在重新连接到此工作区...", + "workspaceRestorePending": "正在重新连接到此工作区...", "workspaceUnavailable": "工作区不可用", "unknownTaskFallback": "(未知任务)", "worktree": "工作树", diff --git a/apps/web/src/locales/zh-hk/agents.json b/apps/web/src/locales/zh-hk/agents.json index d6f5915191..db9d733976 100644 --- a/apps/web/src/locales/zh-hk/agents.json +++ b/apps/web/src/locales/zh-hk/agents.json @@ -162,10 +162,10 @@ "profileNameRequiredDescription": "儲存前請輸入設定名稱。", "profileNameRequiredTitle": "設定名稱為必填項", "profileNotFound": "未找到設定。", - "profileExternalChangeTitle": "設定檔已在其他位置變更", - "profileExternalChangeDescription": "你編輯時此設定檔已發生變更。你的草稿已保留。請放棄草稿以載入最新儲存值,然後再儲存。", - "profileExternalChangeDiscard": "載入最新設定檔", - "profileExternalChangeInvalidReason": "請先載入最新設定檔,然後再儲存。", + "profileExternalChangeTitle": "設定已在其他位置更改", + "profileExternalChangeDescription": "你編輯時此設定已發生更改。你的草稿已保留。請放棄草稿以載入最新儲存的值,然後再儲存。", + "profileExternalChangeDiscard": "載入最新設定", + "profileExternalChangeInvalidReason": "請先載入最新設定,然後再儲存。", "profileSettings": "代理程式設定檔", "promptTokenHint": "<0>{{token}} 將替換為你的任務描述或後續訊息。", "ptyConnectionError": "連接錯誤", diff --git a/apps/web/src/locales/zh-hk/task.json b/apps/web/src/locales/zh-hk/task.json index 990ba38785..3f7aaf8d4f 100644 --- a/apps/web/src/locales/zh-hk/task.json +++ b/apps/web/src/locales/zh-hk/task.json @@ -1761,6 +1761,9 @@ "workspaceModeLabel": "模式:{{mode}}", "workspaceRepository": "工作區儲存庫", "workspaceRequiresConfiguration": "工作區需要設定", + "workspaceRestoreFailed": "無法重新連接到此任務的工作區。", + "workspaceRestoreInProgress": "正在重新連接到此工作區...", + "workspaceRestorePending": "正在重新連接到此工作區...", "workspaceUnavailable": "工作區不可用", "unknownTaskFallback": "(未知任務)", "worktree": "工作樹", diff --git a/apps/web/src/locales/zh-tw/agents.json b/apps/web/src/locales/zh-tw/agents.json index 082077b008..cbc02be843 100644 --- a/apps/web/src/locales/zh-tw/agents.json +++ b/apps/web/src/locales/zh-tw/agents.json @@ -162,10 +162,10 @@ "profileNameRequiredDescription": "儲存前請輸入設定名稱。", "profileNameRequiredTitle": "設定名稱為必填項", "profileNotFound": "未找到設定。", - "profileExternalChangeTitle": "設定檔已在其他位置變更", - "profileExternalChangeDescription": "你編輯時此設定檔已發生變更。你的草稿已保留。請放棄草稿以載入最新儲存值,然後再儲存。", - "profileExternalChangeDiscard": "載入最新設定檔", - "profileExternalChangeInvalidReason": "請先載入最新設定檔,然後再儲存。", + "profileExternalChangeTitle": "設定已在其他位置更改", + "profileExternalChangeDescription": "你編輯時此設定已發生更改。你的草稿已保留。請放棄草稿以載入最新儲存的值,然後再儲存。", + "profileExternalChangeDiscard": "載入最新設定", + "profileExternalChangeInvalidReason": "請先載入最新設定,然後再儲存。", "profileSettings": "代理程式設定檔", "promptTokenHint": "<0>{{token}} 將替換為你的任務描述或後續訊息。", "ptyConnectionError": "連線錯誤", diff --git a/apps/web/src/locales/zh-tw/task.json b/apps/web/src/locales/zh-tw/task.json index 900064f995..8e0899ec9b 100644 --- a/apps/web/src/locales/zh-tw/task.json +++ b/apps/web/src/locales/zh-tw/task.json @@ -1761,6 +1761,9 @@ "workspaceModeLabel": "模式:{{mode}}", "workspaceRepository": "工作區儲存庫", "workspaceRequiresConfiguration": "工作區需要設定", + "workspaceRestoreFailed": "無法重新連線到此任務的工作區。", + "workspaceRestoreInProgress": "正在重新連線到此工作區...", + "workspaceRestorePending": "正在重新連線到此工作區...", "workspaceUnavailable": "工作區不可用", "unknownTaskFallback": "(未知任務)", "worktree": "工作樹", diff --git a/docs/plans/completed-workspace-restoration/plan.md b/docs/plans/completed-workspace-restoration/plan.md new file mode 100644 index 0000000000..d12b3792b7 --- /dev/null +++ b/docs/plans/completed-workspace-restoration/plan.md @@ -0,0 +1,215 @@ +--- +created: 2026-09-10 +status: complete +requirements: + - REQ-TASKS-COMPLETION-002 + - REQ-TASKS-COMPLETION-003 +system_design: + - ../../specs/tasks/system-design/task-completion.md +legacy_specs: [] +--- + +# Fix Plan: Workspace restoration after conversation completion + +## Overview + +Opening completed work should restore its workspace without resuming its agent. +Genuine workspace failures should appear in the affected workspace panel. +Implement runtime admission first, scoped feedback second, and browser coverage +third. All work orders are sequential; no delegation is authorized. + +The task system owns this repair because it controls session completion and +workspace restoration admission. Extend the existing task-completion requirement +and design rather than create a separate UI specification. Existing task-owned +workspace and generation-fencing ADRs supply the rationale; no new ADR is needed. + +## Evidence and requirement conformance + +Baseline: `272ddfca8a81065db8e30c6230044fe90f757356` on `origin/main`, including +PR #3564. This task branch was fast-forwarded to that baseline before authoring. +Recheck the base and relevant contracts before implementation if main advances. + +The reported task is `76910417-48da-498a-8e95-1ee2c309ab9d`. Its session +`859abeec-e739-4a3a-a04d-a484c9815f6c` completed at 2026-09-09 20:41:23 UTC. +Read-only session metadata confirms COMPLETED. Retained backend logs show +`restore_workspace` failures at 2026-09-10 12:19:51 and 12:20:07 Europe/Lisbon: +`verify session before registering execution: ... is COMPLETED: session is terminal`. +The screenshot shows the same rejection above a completed transcript and a +Files panel stuck on preparation. No live task was restarted or repaired. + +`launchRestoreWorkspace` explicitly accepts terminal sessions, but +`createExecution` calls `ensureLaunchSessionStillActive` before creation and +again around registration. That guard rejects every terminal session. Reuse of +an already-live runtime bypasses creation, which explains why tests or visits +with a warm runtime can miss the defect. `describeEnsureError` then assigns a +session-start title to a restore failure, and `task-page-inner.tsx` renders it +above the entire layout. + +The existing terminal-session lifecycle tests pin the conflicting policy. The +correction must replace only the workspace expectations while preserving the +actual agent-launch and cleanup guards. +PR #3564's explicit `AllowCompletedSessionResume` is not a workspace permission. + +Classification: the passive state invariant already exists in +`AC-TASKS-COMPLETION-002.3`; workspace availability and failure placement were +missing. Add `REQ-TASKS-COMPLETION-003` with ten criteria and extend the existing +system design. Preserve all `002` explicit-resume criteria and its prior package. + +Smallest deterministic regression: a real canonical retained workspace, +COMPLETED session, and empty execution store; call +`EnsureWorkspaceExecutionForSession`. The implementation now admits the +workspace without returning `ErrSessionTerminal`, while the agent-start paths +still reject terminal sessions. The permanent regression covers all three +workspace ensure entry points and FAILED, CANCELLED, and COMPLETED states. + +## Scope + +### In scope + +- Restore retained workspaces for completed sessions and preserve the endpoint's + workspace-only behavior for FAILED/CANCELLED sessions. +- Admission, authorization, cleanup/ownership races, shared runtime reuse, + retained provider context, and explicit Resume compatibility. +- Workspace-local failure state, Retry, Details, loading settlement, and + desktop/mobile behavior across Files, Changes, and workspace terminals. +- Focused regression tests and public recovery documentation at implementation. + +### Out of scope + +- Automatic agent resume, task reopening, queue policy changes, new sessions, + revised FAILED/CANCELLED message admission, or Office scheduling changes. +- New schema, runtime flags, executors, or global launch-error redesign. +- New workspace creation, branch replacement, archive/unarchive policy changes, + zero-session task access, deployment, or mutation of the reported live task. + +## Technical approach + +Follow the Workspace admission, Workspace runtime continuity, and Workspace +failure feedback sections of the [system design](../../specs/tasks/system-design/task-completion.md). + +1. In lifecycle `manager_execution.go`, separate workspace admission from the + agent launch guard in `manager_launch.go`. Retain the existing cleanup-aware + registration and shared singleflight. Audit `persistence.go`, workspace + inventory reconciliation, and resume promotion for state/context continuity. +2. Keep `session.launch` with `intent: restore_workspace`. Classify failures by + request intent; preserve safe backend error categories and sanitization. + Reconcile Git handlers' `session_terminal` short-circuit with workspace + eligibility instead of treating every historical conversation as unavailable. +3. Share environment-scoped restore attempt state through existing frontend + domain/store infrastructure. Route it to workspace content, not the global + session banner. Extend `WorkspaceUnavailable` and settle panel loading state. + Preserve real agent recovery feedback and archive handling. +4. Exercise cold restoration and failure/retry in managed desktop/mobile E2E. + Extend completed-conversation tests to Resume after workspace restoration. + +## Tests + +The following evidence was run for the implementation. Criterion suffixes in +this table use `AC-TASKS-COMPLETION-`. + +| Criteria | Regression evidence | +| --- | --- | +| `003.1`, `003.2`, `003.4` | lifecycle `manager_execution_test.go`: `TestEnsureExecutionAllowsTerminalWorkspaceWithoutStartingAgent` covers COMPLETED/FAILED/CANCELLED and all three ensure entry points; `manager_workspace_restore_test.go` keeps `TestAgentStartsStillRejectTerminalSessions`, `TestWorkspacePromotionStillRejectsTerminalSessions`, and `TestPassthroughReconnectStillRejectsTerminalSessions` for agent-only rejection | +| `003.3`, `002.2`, `002.3`, `002.4`, `002.8`, `002.13` | desktop `completed-workspace-restoration.spec.ts` and `completed-session-resume.spec.ts` restore the workspace, restart the backend, and then resume the same conversation without changing task/session identity | +| `003.5`, `003.6` | lifecycle `manager_workspace_restore_test.go`: `TestWorkspaceRestoreRejectsActiveCleanupWithoutMutation`, `TestWorkspaceRestoreRejectsAdmissionChangesWithoutMutation`, and `TestCachedWorkspaceRestoreRechecksProviderAdmission`; existing coalescing and deduplication tests remain in the focused lifecycle block | +| `003.7` | existing lifecycle workspace-validation and materialization tests plus `TestWorkspaceRestoreRejectsAdmissionChangesWithoutMutation`; the cold E2E uses an existing retained worktree and does not create a replacement | +| `003.1`, `003.5`, `003.8` | orchestrator `session_launch_test.go`: `TestLaunchRestoreWorkspace*`; existing Git/files/shell/terminal handler tests; the desktop and mobile E2E failure/retry path | +| `003.8`, `003.9` | `use-workspace-restoration.test.ts`, `file-browser-load-state.test.tsx`, `ensure-session-error.test.tsx`, `workspace-unavailable.test.tsx`, and resumption/recovery hook tests | +| `003.10`, `002.1`, `002.3` | desktop/mobile E2E; rendered phone controls, disclosure geometry, passive state preservation, and no horizontal overflow | + +## E2E tests + +- Add `apps/web/e2e/tests/session/completed-workspace-restoration.spec.ts`, + project `chromium`: materialize a real checkout, complete the session, stop + the isolated runtime via fixture restart, then open Files and a known file. + Verify Git content and a shell command, no error banner, unchanged completed + session/task, and no additional agent turn. Reload and repeat file access. +- Add `apps/web/e2e/tests/session/mobile-completed-workspace-restoration.spec.ts`, + project `mobile-chrome`: use shipped Files navigation and file-viewer Back; + verify the same content/state result and terminal access through mobile controls. +- Both new specs inject one bounded workspace failure, assert local feedback + instead of endless preparation, open Details, then Retry to real success. + Keep Chat usable and verify no unintended Resume request. Inspect phone + screenshots, touch hitboxes, disclosure containment, and horizontal overflow. +- Extend existing `completed-session-resume.spec.ts` and + `mobile-completed-session-resume.spec.ts`: restore workspace first, then click + Resume, send one follow-up, and preserve conversation/task identity. + +Happy paths must cross real backend workspace creation. Existing completed-chat +fixtures leave a warm execution and do not prove this regression. Use +`backend.restart()` with the worker's database and checkout retained; establish +the completed/no-live-runtime precondition before navigation. Do not use a +recovery-clicking page helper as evidence of passive restoration. + +## Work orders + +- [x] [Task 01: Restore retained workspace access](task-01-workspace-admission.md) (complete) +- [x] [Task 02: Scope workspace failure feedback](task-02-workspace-feedback.md) (complete) +- [x] [Task 03: Prove completed workspace recovery](task-03-workspace-e2e.md) (complete) + +## Verification results + +Implementation and regression tests completed on 2026-09-10: + +- Task 01 backend block: lifecycle 91 tests, orchestrator 125 tests, + orchestrator/executor 105 tests, orchestrator/handlers 3 tests, and + agent/handlers 200 tests passed with `-race`. +- Task 02 frontend blocks: 58 and 63 tests passed; the extracted file-browser + tests added 8 more passing tests. Web typecheck and full web lint passed. +- `rtk pnpm run i18n:check` and `rtk pnpm run i18n:ratchet` passed. The i18n + check reported the existing 140 orphaned catalog entries. +- Task 03 managed E2E: desktop 2/2 and mobile 2/2 passed after cold backend + restart. The mobile capture was inspected for terminal output, touch + controls, containment, and overflow; the temporary capture directory was + removed. +- Public docs validation passed: 62 tests and 46 pages. Specification + validation passed: 36 tests and all specification files. +- `rtk git diff --check` passed. Targeted E2E-sleep lint passed for all four + changed session specs. The repository-wide E2E-sleep lint still reports its + pre-existing unrelated baseline and is not used as a task gate. +- Review-fixup validation passed: the impacted frontend suite ran 120 tests, + backend normal suites ran 5,395 tests across three packages, and the + lifecycle race block ran 81 tests. Web typecheck, full lint, E2E build, and + both desktop/mobile cold-runtime flows passed again. +- PR #3577 follow-up aligned the workflow DTO and WebSocket mapper with the + current `main` contract after the merge-result check exposed a stale + duplicate field deletion. Local typecheck, lint, and mapper tests passed. +- The subsequent frontend failure was isolated to an incomplete existing test + mock after the store API became a workspace-restoration dependency. The + responsive File Browser mock now supplies that API, and its three tests pass. +- PR fixup removed duplicate terminal-side workspace launches. The page-level + resumption owner still admits retained workspaces, while archived unarchive + recovery keeps its single Resume request and ended sessions retain their + terminal explanation. The affected desktop E2E checks passed 4/4 and the + passthrough unit suite passed 31/31 locally. +- PR review follow-up added identity-fenced cleanup for obsolete automatic + restores and migration-aware workspace settlement. Deferred navigation tests + cover success and rejection cleanup with the shared app store, while + fallback-to-environment migration tests cover complete, fail, clear, and + protection against a newer attempt. The focused frontend block passed 58/58 + tests, changed-file lint passed, and web typecheck passed. + +Design validation before implementation on 2026-09-10: + +- `rtk python3 scripts/lint-spec-files.test.py`: passed, 36 tests. +- `rtk python3 scripts/lint-spec-files.py --all`: all specification files passed. +- `rtk git diff --check`: passed. +- Work-order references, source paths, and pending status checked. New test/helper + files are explicitly marked as implementation targets, not existing evidence. +- The historical design-package commit remains `ad695e1d797c7d81d348618bcfa77bcc523faea0`. + +## Risks + +- Relaxing the agent guard globally would allow unintended starts. Change only + workspace admission and retain the explicit Resume permission from PR #3564. +- Cleanup checks must protect registration and cache reuse, not only initial + lookup. Shared environment ownership and stale execution callbacks matter. +- A workspace-only row could overwrite the provider resume token. Test the + full restore/restart/Resume sequence, not only immediate file access. +- Current reconciliation can create worktrees. Keep passive restoration + attach-only and preserve separately authorized unarchive recovery. +- Moving a banner without propagating workspace failure would leave spinners + and hidden errors. Test content consumers and cached stale data explicitly. +- New localized copy requires en, pt-pt, zh-cn, generated zh-hk/zh-tw, and pseudo. +- Completed-session tests from PR #3564 stay in the verification set. Do not + reset their previous package status or claim old counts prove this repair. diff --git a/docs/plans/completed-workspace-restoration/task-01-workspace-admission.md b/docs/plans/completed-workspace-restoration/task-01-workspace-admission.md new file mode 100644 index 0000000000..2446820d58 --- /dev/null +++ b/docs/plans/completed-workspace-restoration/task-01-workspace-admission.md @@ -0,0 +1,145 @@ +--- +id: "01-workspace-admission" +title: "Restore retained workspace access" +status: complete +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-TASKS-COMPLETION-002 + - REQ-TASKS-COMPLETION-003 +acceptance_criteria: + - AC-TASKS-COMPLETION-002.2 + - AC-TASKS-COMPLETION-002.3 + - AC-TASKS-COMPLETION-002.4 + - AC-TASKS-COMPLETION-002.8 + - AC-TASKS-COMPLETION-002.13 + - AC-TASKS-COMPLETION-003.1 + - AC-TASKS-COMPLETION-003.2 + - AC-TASKS-COMPLETION-003.3 + - AC-TASKS-COMPLETION-003.4 + - AC-TASKS-COMPLETION-003.5 + - AC-TASKS-COMPLETION-003.6 + - AC-TASKS-COMPLETION-003.7 +system_design: + - ../../specs/tasks/system-design/task-completion.md +--- + +# Task 01: Restore retained workspace access + +## Summary + +Allow workspace infrastructure for retained environments independently of agent +session completion. Preserve explicit Resume, permissions, cleanup ownership, +and provider conversation data. + +## In scope + +- Add the workspace admission check at all creation/registration boundaries + and keep agent launch/promotion guards unchanged. Validate cache reuse too. +- Apply it to all three workspace ensure entry points. Retain common + singleflight and cleanup-aware registration; re-read current ownership and + roll back only the losing attempt's resources. +- Preserve attach-only retained inventory validation, including mixed valid + and missing required repositories. Do not broaden branch recreation. +- Audit workspace persistence and agentctl events for session/task mutation, + empty provider token writes, stale callbacks, and subsequent explicit Resume. +- Reconcile handler terminal-state failure mapping with actual workspace + eligibility. Keep bounded, sanitized errors and operation-accurate logs. + +## Out of scope + +Frontend composition, new schemas, automatic agent recovery, archive policy, +or provider protocol changes. + +## Acceptance + +- All workspace entry points restore eligible terminal sessions without agent + startup; missing/foreign/archived/cleanup-owned resources fail without mutation. +- Concurrent restore, cleanup, and Resume preserve one winning execution and + current ownership. Existing live siblings and their agent processes survive. +- Restoring and persisting workspace access preserves provider conversation + identity for later explicit Resume, including after backend restart. + +## TDD entry + +Use `TestEnsureExecutionAllowsTerminalWorkspaceWithoutStartingAgent` with real +retained inventory and the existing manager/runtime fakes. Assert workspace +success first; the baseline returns `ErrSessionTerminal`. Retain agent-launch, +promotion, and passthrough rejection tests. The focused lifecycle block +preserves the existing coalescing, cleanup, and workspace-validation coverage, +while the managed E2E crosses the real lifecycle manager and verifies the later +explicit Resume. + +## Verification + +Run the complete block from the repository root. These commands deliberately +cover both new workspace tests and the existing explicit-resume regressions. + +```bash +(cd apps/backend && rtk go test -race ./internal/agent/runtime/lifecycle -run 'Test.*(WorkspaceRestore|EnsureExecution|EnsureWorkspaceExecution|GetOrEnsureExecution|CreateExecution|Coalesced|LaunchSession|TerminalSession|PersistExecutor)' -count=1) +(cd apps/backend && rtk go test -race ./internal/orchestrator -run 'Test.*(CompletedWorkspace|CompletedSession|LaunchRestoreWorkspace|RecoverSession|Startup|Reclaim)' -count=1) +(cd apps/backend && rtk go test -race ./internal/orchestrator/executor -run 'Test.*(Resume|TerminalSessionState)' -count=1) +(cd apps/backend && rtk go test -race ./internal/orchestrator/handlers -run 'Test.*(Launch|Recover|Workspace)' -count=1) +(cd apps/backend && rtk go test -race ./internal/agent/handlers -run 'Test.*(Workspace|Git|File|Shell|Terminal)' -count=1) +rtk git diff --check +``` + +## Files likely touched + +- `apps/backend/internal/agent/runtime/lifecycle/manager_execution.go` +- `apps/backend/internal/agent/runtime/lifecycle/manager_launch.go` +- `apps/backend/internal/agent/runtime/lifecycle/persistence.go` +- `apps/backend/internal/agent/runtime/lifecycle/types.go` only if admission + identity must carry additional existing ownership metadata. +- Corresponding `manager_execution_test.go`, `manager_launch_test.go`, and `persistence_test.go`. +- `apps/backend/internal/orchestrator/session_launch.go` and `session_launch_test.go`. +- `apps/backend/internal/orchestrator/completed_workspace_restore_test.go` (new). +- `apps/backend/internal/orchestrator/handlers/handlers.go` and its tests. +- `apps/backend/internal/agent/handlers/git_handlers.go` and `git_handlers_test.go`. +- `apps/backend/internal/orchestrator/executor/executor_resume.go` only for + restoration/Resume coordination if the existing path needs adaptation. + +## Dependencies + +None. Baseline includes merged PR #3564. Read scoped backend guidance before +implementation; do not replace that PR's explicit completed-resume permission. + +## Risks + +Authorization must precede cache hits. Cleanup admission must remain durable. +Registration and rollback must retain current execution identity. Do not hold +lifecycle locks while waiting for callbacks that acquire the same locks. +If a schema change becomes necessary, revise the design and add the required +SQLite/PostgreSQL conformance checks before proceeding. + +## Parallelism + +`sequential` + +## Inputs + +- [Requirements](../../specs/tasks/requirements/task-completion.md), IDs above. +- [Design](../../specs/tasks/system-design/task-completion.md), Workspace sections. +- [Existing explicit-resume work](../task-completion/task-04-session-resume.md). +- Existing lifecycle execution, persistence, and cleanup race tests; `/tdd`. + +## Results + +Implemented workspace-specific admission for retained terminal sessions. The +workspace path now validates current session, task-environment, ownership, +archive, cleanup, and provider identity before cache reuse or resource +creation. Agent start, promotion, and passthrough reconnect retain the strict +terminal-session guard. + +Verification passed: + +- Lifecycle block: 91 tests passed with `-race`. +- Orchestrator block: 125 tests passed with `-race`. +- Orchestrator/executor block: 105 tests passed with `-race`. +- Orchestrator/handlers block: 3 tests passed with `-race`. +- Agent/handlers block: 200 tests passed with `-race`. +- `rtk git diff --check` passed. + +The managed desktop and mobile cold-runtime checks in Task 03 also exercise +the real retained-workspace path and subsequent explicit Resume. diff --git a/docs/plans/completed-workspace-restoration/task-02-workspace-feedback.md b/docs/plans/completed-workspace-restoration/task-02-workspace-feedback.md new file mode 100644 index 0000000000..88c3e04f24 --- /dev/null +++ b/docs/plans/completed-workspace-restoration/task-02-workspace-feedback.md @@ -0,0 +1,143 @@ +--- +id: "02-workspace-feedback" +title: "Scope workspace failure feedback" +status: complete +wave: 2 +depends_on: + - 01-workspace-admission +plan: "plan.md" +requirements: + - REQ-TASKS-COMPLETION-002 + - REQ-TASKS-COMPLETION-003 +acceptance_criteria: + - AC-TASKS-COMPLETION-002.1 + - AC-TASKS-COMPLETION-002.3 + - AC-TASKS-COMPLETION-002.9 + - AC-TASKS-COMPLETION-003.8 + - AC-TASKS-COMPLETION-003.9 + - AC-TASKS-COMPLETION-003.10 +system_design: + - ../../specs/tasks/system-design/task-completion.md +--- + +# Task 02: Scope workspace failure feedback + +## Summary + +Represent workspace restoration independently from agent recovery. Show a +compact actionable failure in affected workspace panels, keeping Chat usable. + +## In scope + +- Preserve operation identity at request creation; route workspace outcomes + through one shared environment-scoped attempt state with stale-result guards. +- Make Files, Changes, and workspace terminals consume the same pending/error + state. Replace indefinite preparation after failure; retain cached content + with a stale/unavailable notice when applicable. +- Exclude only workspace failures from top-level session feedback. Keep actual + ensure/start/resume errors and completed-session Resume/New Agent intact. +- Extend `WorkspaceUnavailable` with restore wording, valid Retry, and collapsed + sanitized Details. Retry calls restore only, disables while pending, and + clears only the matching failure after success. No per-panel polling loops. +- Use the existing mobile Files navigation and file viewer. Keep errors inline, + phone/coarse-pointer hitboxes at least 44 px, dense desktop controls, wrapping + details, one panel scroll owner, and safe-area navigation. +- Localize new copy in all required catalogs and generate Traditional Chinese + and pseudo through repository scripts. Keep technical detail out of titles. + +## Out of scope + +Global redesign of launch errors, new mobile navigation, persisted error history, +new runtime flags, or changes to explicit Resume policy. + +## Acceptance + +- Workspace failures settle loading and appear inside visible workspace content, + never as a global session-start banner. Chat remains usable. +- Retry succeeds through workspace restoration without resuming the agent; + duplicate actions and stale cross-environment/attempt results are rejected. +- Desktop and mobile share state/actions; phone geometry and disclosure behavior + meet the design. Genuine agent recovery feedback is still visible. + +## TDD entry + +Add `use-workspace-restoration.test.ts` for failed-before-readiness, retry, +success clearing, task switches, and late ready/error results. In +`file-browser-load-state.test.tsx`, prove a restore failure replaces waiting. +Extend `ensure-session-error.test.tsx` or the owning page test to prove a restore +failure is excluded while a real session-start failure remains visible. +Do not use a missing new selector as the only failing assertion. + +## Verification + +Run from the repository root. Install once before the first package command +if this worktree has not been bootstrapped. + +```bash +(cd apps && rtk pnpm install --frozen-lockfile) +(cd apps/web && rtk pnpm test hooks/domains/session/use-workspace-restoration.test.ts hooks/domains/session/use-session-resumption.test.ts hooks/domains/session/use-session-recovery-actions.test.ts components/task/file-browser-load-state.test.tsx components/task/ensure-session-error.test.tsx components/task/chat/session-stopped-banner.test.tsx) +(cd apps/web && rtk pnpm test lib/state/slices/session-runtime/workspace-restoration.test.ts lib/state/slices/session-runtime/migrate-env-keyed-data.test.ts lib/state/slices/session-runtime/purge-session.test.ts components/task/workspace-unavailable.test.tsx components/task/task-changes-panel.test.ts components/task/task-changes-panel-layers.test.ts) +(cd apps/web && rtk pnpm run typecheck) +(cd apps/web && rtk pnpm run i18n:check) +(cd apps/web && rtk pnpm run i18n:ratchet) +rtk git diff --check +``` + +If implementation extracts another state/helper test suite, add its exact path +to this command before completion. Run rendered desktop/mobile checks in Task 03; +unit tests alone do not complete the package's visual verification. + +## Files likely touched + +- `apps/web/hooks/domains/session/use-workspace-restoration.ts` and `.test.ts` (new). +- `apps/web/hooks/domains/session/use-session-resumption.ts` and `use-session-resumption-operations.ts`. +- `apps/web/hooks/domains/session/use-session-recovery-actions.ts` and corresponding tests. +- `apps/web/lib/services/session-launch-service.ts` if operation/error typing needs extension. +- `apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts` and `types.ts`. +- `apps/web/lib/state/slices/session-runtime/workspace-restoration.ts` and `.test.ts` (new pure state helpers). +- `apps/web/lib/state/slices/session-runtime/migrate-env-keyed-data.test.ts` and `purge-session.test.ts`. +- `apps/web/components/task/task-page-inner.tsx`, `ensure-session-error.tsx` and tests. +- `apps/web/components/task/workspace-unavailable.tsx` and `workspace-unavailable.test.tsx` (new). +- `apps/web/components/task/file-browser-load-state.tsx` and `.test.tsx`. +- `apps/web/components/task/task-files-panel.tsx`, `task-changes-panel.tsx`, and workspace terminal content boundaries. +- `apps/web/components/task/mobile/session-mobile-layout.tsx`, `mobile-changes-panel.tsx`, and `mobile-terminal-pane.tsx` where required for shared feedback. +- `apps/web/src/locales/{en,pt-pt,zh-cn,zh-hk,zh-tw,pseudo}/task.json`. + +## Dependencies + +Task 01. Read scoped web guidance and `/mobile-parity` before implementation. + +## Risks + +Do not route errors by English substring, clear another attempt, hide all errors +on completed tasks, or mistake stale cached content for live readiness. Existing +workspace terminal permissions are unchanged; do not label access read-only. + +## Parallelism + +`sequential` + +## Inputs + +- [Requirements](../../specs/tasks/requirements/task-completion.md), IDs above. +- [Design](../../specs/tasks/system-design/task-completion.md), Workspace failure feedback. +- `WorkspaceUnavailable` and the curated `task-layout.tsx` mobile composition. +- Existing recovery hooks, Files loading tests, and `/tdd`. + +## Results + +Implemented environment-scoped workspace restoration state and shared it across +Files, Changes, and workspace terminals. Failures now settle the affected +surface, expose bounded technical details, and provide a mobile-safe Retry +without replacing Chat or genuine agent recovery feedback. + +Verification passed: + +- Primary frontend block: 6 files and 58 tests passed. +- State and panel block: 6 files and 63 tests passed. +- Additional extracted file-browser coverage: 3 files and 8 tests passed. +- `rtk pnpm run typecheck` passed. +- `rtk pnpm run lint` passed with zero warnings. +- `rtk pnpm run i18n:check` and `rtk pnpm run i18n:ratchet` passed. The i18n + check reported the existing 140 orphaned catalog entries. +- Targeted E2E-sleep lint passed for all four changed session specs. diff --git a/docs/plans/completed-workspace-restoration/task-03-workspace-e2e.md b/docs/plans/completed-workspace-restoration/task-03-workspace-e2e.md new file mode 100644 index 0000000000..ba4df0191b --- /dev/null +++ b/docs/plans/completed-workspace-restoration/task-03-workspace-e2e.md @@ -0,0 +1,148 @@ +--- +id: "03-workspace-e2e" +title: "Prove completed workspace recovery" +status: complete +wave: 3 +depends_on: + - 02-workspace-feedback +plan: "plan.md" +requirements: + - REQ-TASKS-COMPLETION-002 + - REQ-TASKS-COMPLETION-003 +acceptance_criteria: + - AC-TASKS-COMPLETION-002.1 + - AC-TASKS-COMPLETION-002.2 + - AC-TASKS-COMPLETION-002.3 + - AC-TASKS-COMPLETION-002.4 + - AC-TASKS-COMPLETION-003.1 + - AC-TASKS-COMPLETION-003.2 + - AC-TASKS-COMPLETION-003.3 + - AC-TASKS-COMPLETION-003.8 + - AC-TASKS-COMPLETION-003.9 + - AC-TASKS-COMPLETION-003.10 +system_design: + - ../../specs/tasks/system-design/task-completion.md +--- + +# Task 03: Prove completed workspace recovery + +## Summary + +Prove passive workspace access and failure recovery on desktop and phone using +the managed runtime. Verify subsequent explicit Resume still continues the +same conversation, and document the shipped distinction for users. + +## In scope + +- Create the two restoration specs named in the plan. Share seeding and + controlled-error helpers in `completed-workspace-restoration-helpers.ts`. +- Seed a real checkout and conversation, mark it completed, then restart only + the isolated worker backend to remove its live execution. Prove this + precondition; do not let a warm fixture hide the terminal admission bug. +- Open through the shipped UI and read a known file, inspect Git state, and run + a harmless shell command. Assert session/task state and agent-turn counts + before any Resume, including after page reload. +- Inject one restore failure with existing WebSocket interception patterns, + verify local Details/Retry and loading settlement, then restore through the + real backend. Register transport waits before actions; restore fixture state. +- Extend both existing completed-session Resume specs to follow workspace + restoration. Preserve their prior message-count, ownership, and task-state + assertions. Include a restart between workspace restoration and explicit + Resume in desktop coverage to exercise persisted provider identity. +- Exercise mobile Files navigation, viewer Back, Changes, and terminal controls + with `.tap()`. Check actual 44 px action bounds, containment, keyboard access, + no document overflow, and inspect a phone screenshot. Include desktop at its + legal narrow width and breakpoint checks around the changed responsive rules. +- Update `docs/public/workflow-tips.md` with a short explanation of opening a + completed workspace versus Resume. Link existing Git/terminal guidance rather + than duplicate it. Synchronize actual results in this package and add a + follow-up link in the existing task-completion package without rewriting its + historical verification counts. + +## Out of scope + +Full E2E suites, generic QA/review tasks, live-instance mutation, screenshots of +the user's task, deployment, or CI/PR monitoring. + +## Acceptance + +- Both viewports show a working retained workspace with no agent startup or + top-level restoration error; local failure/retry recovers through the backend. +- Explicit Resume afterward yields one follow-up in the original conversation + and preserves completed task state, primary ownership, and session count. +- Rendered mobile evidence, exact test counts, docs/spec checks, and cleanup + results are recorded; public instructions match the implemented behavior. + +## Verification + +Run the whole block from the repository root after Task 02's dependency install. +Managed E2E rebuilds production assets; run the two projects separately. Never +overlap full suites or override the memory-aware worker limits. + +```bash +(cd apps/web && rtk pnpm e2e:run --project chromium tests/session/completed-workspace-restoration.spec.ts tests/session/completed-session-resume.spec.ts) +(cd apps/web && rtk pnpm e2e:run --project mobile-chrome tests/session/mobile-completed-workspace-restoration.spec.ts tests/session/mobile-completed-session-resume.spec.ts) +rtk node --test scripts/validate-public-docs.test.mjs +rtk node scripts/validate-public-docs.mjs +rtk python3 scripts/lint-spec-files.test.py +rtk python3 scripts/lint-spec-files.py --all +rtk git diff --check +rtk git status --short -- docs/plans/completed-workspace-restoration +``` + +Confirm each project discovers the new and existing specs. Task 01 supplies the +initial behavioral RED; preserve that evidence. E2E after integration proves +the real cold runtime path, not a second selector-only RED. + +## Files likely touched + +- `apps/web/e2e/tests/session/completed-workspace-restoration.spec.ts` (new). +- `apps/web/e2e/tests/session/mobile-completed-workspace-restoration.spec.ts` (new). +- `apps/web/e2e/tests/session/completed-workspace-restoration-helpers.ts` (new). +- `apps/web/e2e/tests/session/completed-session-resume.spec.ts`. +- `apps/web/e2e/tests/session/mobile-completed-session-resume.spec.ts`. +- `apps/web/e2e/pages/session-page.ts` only for reusable shipped-control helpers. +- `docs/public/workflow-tips.md`. +- This plan/work orders and the companion task-completion plan links. + +## Dependencies + +Tasks 01 and 02. Follow `/e2e`, its fixture-state and UI-state references, +`/mobile-parity`, and `/docs-maintainer`. + +## Risks + +Fixture restart preserves data but changes runtime liveness. Require causal +backend evidence before navigation. Do not use `waitForChatIdle` recovery helpers +to prove passive behavior. Reset interception and dispose temporary checkout +files even after assertions fail. A skipped test or undiscovered project is not +passing evidence. + +## Parallelism + +`sequential` + +## Inputs + +- [Requirements](../../specs/tasks/requirements/task-completion.md), IDs above. +- [Design](../../specs/tasks/system-design/task-completion.md), workspace and mobile sections. +- Existing completed-session specs, `backend.restart`, session page objects, + and `e2e/helpers/archived-session-recovery.ts` interception patterns. + +## Results + +Implemented cold-runtime desktop and mobile coverage, shared the retained +worktree fixture setup with the existing completed-session Resume specs, and +added public recovery guidance. + +Verification passed: + +- Chromium block discovered and passed 2/2 tests. +- Mobile Chromium block discovered and passed 2/2 tests. +- The mobile capture was inspected. Terminal output was readable, touch + controls were contained, and the page had no horizontal overflow. +- Public docs validation passed 62 tests and 46 pages. +- Specification validation passed 36 tests and all specification files. +- `rtk git diff --check` passed. +- Temporary `.pr-assets` capture output was removed after inspection. No live + task or persistent runtime state was changed. diff --git a/docs/plans/task-completion/plan.md b/docs/plans/task-completion/plan.md index fe87b7a85d..70ebee76b2 100644 --- a/docs/plans/task-completion/plan.md +++ b/docs/plans/task-completion/plan.md @@ -126,6 +126,14 @@ worker per shard, run desktop/mobile separately, and do not increase timeouts. ## Work orders +The completed delivery below covers explicit conversation Resume. The later +[workspace restoration package](../completed-workspace-restoration/plan.md) +adds `REQ-TASKS-COMPLETION-003` and passive workspace access. References here to +passive opening not launching an execution mean an **agent** execution; they +do not prohibit workspace-only infrastructure. Existing completion status and +recorded test counts remain historical evidence for PR #3564, not verification +of the later workspace fix. + - [x] [Task 01: Persist completion settings](task-01-persistence.md) (done) - [x] [Task 02: Carry portable completion settings](task-02-portable-contracts.md) (done) - [x] [Task 03: Apply configured task completion](task-03-runtime-completion.md) (done) diff --git a/docs/public/workflow-tips.md b/docs/public/workflow-tips.md index 30cbf2358f..ee758e174c 100644 --- a/docs/public/workflow-tips.md +++ b/docs/public/workflow-tips.md @@ -164,6 +164,14 @@ preserved. Opening or reloading a completed task does not start its agent. If the task has a completed conversation, select **Resume** to continue that same conversation. The previous messages and provider context remain available, and the task keeps its completed state and workflow step. Select **New Agent** when you want a separate conversation. A follow-up does not run the completed step's workflow actions again. +### Open a completed workspace + +Kandev restores a retained workspace when you open a completed task. This action restores Files, Changes, and supported workspace terminals. It does not resume the agent. It does not send a prompt. + +If workspace restoration fails, the affected panel shows **Workspace unavailable**. Select **Retry** to restore workspace access. Select **Technical details** to view bounded diagnostic information. The chat remains available while the workspace is unavailable. + +Select **Resume** in the completed conversation when you want the agent to continue. Workspace restoration and **Resume** are separate actions. + Pull candidates are selected by board position, then priority, queue time, creation time, and ID. A candidate that cannot be moved is skipped. Pulling runs for every limited step; a feeder is only needed for overflow created outside the destination step. ### Complete prompts while editing diff --git a/docs/specs/tasks/README.md b/docs/specs/tasks/README.md index 3a3b3039f6..e8ba60318c 100644 --- a/docs/specs/tasks/README.md +++ b/docs/specs/tasks/README.md @@ -36,7 +36,7 @@ signals, and task-scoped scheduling contracts. ### Requirements -- [Task completion and conversation follow-ups](requirements/task-completion.md) +- [Task completion, conversation follow-ups, and workspace access](requirements/task-completion.md) - [Agent-Generated Task Titles](requirements/agent-generated-titles.md) - [Additional Session Workspace Reuse](requirements/additional-session-workspace-reuse.md) @@ -140,7 +140,7 @@ signals, and task-scoped scheduling contracts. ### System design -- [Task completion and conversation follow-ups](system-design/task-completion.md) +- [Task completion, conversation follow-ups, and workspace access](system-design/task-completion.md) - [Additional Session Workspace Reuse](system-design/additional-session-workspace-reuse.md) - [Environment-Owned Git Status](system-design/environment-owned-git-status.md) diff --git a/docs/specs/tasks/requirements/task-completion.md b/docs/specs/tasks/requirements/task-completion.md index 91be1d6403..ac34e9e7c0 100644 --- a/docs/specs/tasks/requirements/task-completion.md +++ b/docs/specs/tasks/requirements/task-completion.md @@ -2,6 +2,7 @@ status: draft system: tasks created: 2026-09-09 +updated: 2026-09-10 owners: - kandev --- @@ -112,6 +113,52 @@ does not need to reopen merely because its agent answers a question. conversation and workspace identity. Concurrent archive, delete, or explicit stop shall prevent a stale resume from resurrecting work. +### REQ-TASKS-COMPLETION-003: Workspace access after conversation completion + +**Intent:** Let users inspect and use a retained task workspace without resuming +its agent conversation. The task system owns admission and recovery; workspace +ownership remains governed by the canonical task environment. + +#### Acceptance criteria + +- **AC-TASKS-COMPLETION-003.1:** When a completed session has a retained, + accessible task workspace, opening the task shall restore file browsing, + file contents, Git views, and supported workspace terminals without requiring + Resume. This shall also work after its previous runtime has stopped. +- **AC-TASKS-COMPLETION-003.2:** Workspace restoration shall preserve session + state, completion time, transcript, task state, workflow position, primary + selection, and agent ownership. It shall not start an agent, dispatch a + prompt, or replay workflow actions. +- **AC-TASKS-COMPLETION-003.3:** Explicit Resume shall remain available under + `REQ-TASKS-COMPLETION-002` after workspace restoration. Restoration shall + preserve the provider identity needed to continue the same conversation, + including across another backend restart. +- **AC-TASKS-COMPLETION-003.4:** A FAILED or CANCELLED conversation with a + retained, accessible workspace shall permit workspace-only restoration. + Its existing agent recovery and message-admission rules shall remain unchanged. +- **AC-TASKS-COMPLETION-003.5:** Restoration shall enforce current access and + ownership permissions. An archived or deleted task, an invalid session + reference, or cleanup that has claimed the environment shall prevent stale + restoration from recreating resources or exposing the workspace. +- **AC-TASKS-COMPLETION-003.6:** Concurrent workspace requests and explicit + Resume shall not leave duplicate runtimes or stop a newer execution. A + historical session sharing an environment shall not take over its live agent. +- **AC-TASKS-COMPLETION-003.7:** When retained workspace inventory is missing + or unsafe, passive restoration shall report its unavailability. It shall not + replace a branch, discard changes, or silently create a replacement workspace. +- **AC-TASKS-COMPLETION-003.8:** A workspace restoration failure shall appear + within the affected workspace surface with a concise cause and expandable, + bounded technical details. It shall not appear as a page-wide session-start + failure, fail the conversation, or leave an indefinite preparation indicator. +- **AC-TASKS-COMPLETION-003.9:** A recoverable failure shall offer workspace + Retry. An in-progress attempt shall prevent duplicate retries. Failure shall + not trigger an unbounded retry loop; success shall clear matching feedback. + A late result shall not replace feedback for a different environment or attempt. +- **AC-TASKS-COMPLETION-003.10:** Desktop and mobile shall expose the same + workspace results and recovery actions. Phone controls shall have at least + 44-pixel touch targets. Expanded details shall remain within the workspace + surface, preserve keyboard access, and cause no horizontal page overflow. + ## Compatibility and exclusions This contract replaces name-based workflow completion and the permanent @@ -124,3 +171,13 @@ Automatic profile reuse remains governed by [workflow profile sessions](workflow-profile-session-lifecycle.md). Prompt admission during startup follows [resume prompt queue](resume-prompt-queue.md). Provider restoration uses [agent recovery](../../agents/requirements/agent-resume-runtime-recovery.md). + +Workspace restoration does not imply read-only access: existing edit and shell +permissions remain authoritative. This contract does not add workspace access +for tasks with no sessions, change archive/unarchive recovery, introduce a new +executor, or change physical workspace ownership. + +## Implementation plans + +- [Task completion and follow-ups](../../../plans/task-completion/plan.md) +- [Workspace restoration after completion](../../../plans/completed-workspace-restoration/plan.md) diff --git a/docs/specs/tasks/system-design/task-completion.md b/docs/specs/tasks/system-design/task-completion.md index bf78c181bb..60edaad258 100644 --- a/docs/specs/tasks/system-design/task-completion.md +++ b/docs/specs/tasks/system-design/task-completion.md @@ -4,6 +4,8 @@ system: tasks requirements: - REQ-TASKS-COMPLETION-001 - REQ-TASKS-COMPLETION-002 + - REQ-TASKS-COMPLETION-003 +updated: 2026-09-10 --- # Task Completion System Design @@ -19,6 +21,7 @@ their current owners and authorization checks. | --- | --- | | `REQ-TASKS-COMPLETION-001` | Step contract, Migration, Entry and notification | | `REQ-TASKS-COMPLETION-002` | Explicit resume, Follow-up ownership, Cleanup and races, User interface | +| `REQ-TASKS-COMPLETION-003` | Workspace admission, Workspace runtime continuity, Workspace failure feedback | ## Step contract @@ -237,6 +240,147 @@ An inline setting fits this short choice. Both viewports share draft and save logic. Preserve keyboard focus, one settings scroll region, no horizontal page overflow, and access to Save changes above safe-area insets. +## Workspace admission + +Task completion, conversation availability, and workspace access have separate +admission rules. `launchRestoreWorkspace` is a workspace operation. It does not +receive `AllowCompletedSessionResume`, transition the session to STARTING, or +call agent configuration/start. Explicit Resume retains the preceding design. + +The lifecycle manager's `createExecution` builds workspace infrastructure only. +Replace its calls to `ensureLaunchSessionStillActive` with a workspace-specific +admission check. Keep the active-session check on agent launch and promotion. +Do not remove COMPLETED from global terminal-state helpers or introduce a +client-controlled bypass flag. + +Workspace admission resolves the session's canonical `TaskEnvironment` and +checks the current task, session binding, environment identity, owner generation, +archive state, and cleanup admission. COMPLETED, FAILED, or CANCELLED alone is +not proof that the task has relinquished its workspace. Missing records, +database errors, invalid ownership, or an admitted cleanup fail closed. +Reuse the existing task cleanup barrier and generation-aware repository helpers; +do not replace them with a process-local mutex or a path-exists check. + +Keep authorization before cache reuse as well as before creation. Retain +`session.exec` and environment access checks for execution-capable surfaces; +viewing conversation history does not grant shell or workspace access. +The environment's actual owner controls destructive cleanup. A borrowing +session cannot authorize changes to another owner's resources. + +Use the same admission rule for `EnsureWorkspaceExecutionForSession`, +`GetOrEnsureExecution`, and `GetOrEnsureExecutionForEnvironment`. The session +route remains a lookup handle; it must resolve to the same canonical environment. +An existing environment execution can be reused without replacing its session +binding, running agent, or workflow owner. + +For workspace creation, capture admission identity before external operations, +revalidate after runtime creation, persist registration through the existing +cleanup-aware writer, then revalidate before readiness publication. If cleanup, +deletion, ownership transfer, or a newer execution invalidates the claim, roll +back only the runtime created by that attempt. Never tear down a winning sibling +or resumed execution. A completed session present at admission differs from a +concurrent stop or cancellation of the attempt; preserve the existing lifecycle +and cancellation guards rather than inferring cancellation from state alone. + +Repository validation uses canonical inventory. Every repository required by +the environment's existing validation contract must pass; one healthy repository +does not excuse another missing or unsafe required repository. Keep the current +handling of historical deleted repository rows. Restoration attaches retained +workspaces and starts access infrastructure; it does not invoke branch replacement +or silently rematerialize an absent checkout. Audit +`reconcileExecutionWorkspace` and `reconcileWorkspaceWorktrees` accordingly. +Explicit unarchive or branch recovery keeps its separate authority. + +## Workspace runtime continuity + +Retain the common session-keyed singleflight and duplicate-registration rollback +used by session and environment entry points. Coordinate restoration with the +existing explicit-resume lifecycle guard. If Resume wins, workspace consumers +use its execution; if restoration wins, Resume may use the established promotion +or replacement path. Only Resume can start the agent. Do not add another lock +order or an environment-only singleflight bucket that races session requests. + +`publishCreatedExecution` emits agentctl Starting before Ready/Error. Preserve +that ordering and workspace stream attachment. These events update workspace +readiness, not task completion, session state, turns, or queue dispatch. +Failure before registration must still reach the initiating request's workspace +error state; waiting for an agentctl error event cannot cover that failure. + +Workspace registration can replace a stopped `executors_running` row. Preserve +recoverable provider identity and metadata before doing so. Do not store a +workspace runtime's empty provider fields over the completed conversation's +resume data. Prove restore, restart, then explicit Resume uses the same provider +conversation. Existing session metadata and persistence helpers remain the +source; no schema migration or new durable execution-purpose field is planned. + +Startup may release terminal-session runtime resources under existing cleanup +policy. The next authorized workspace access must be able to restore them +without reviving the agent. Old stop, reclaim, and readiness callbacks must not +alter a newer execution. Retain idle resource reclamation; the fix does not keep +every completed task's runtime permanently alive. + +## Workspace failure feedback + +Carry the known operation (`restore_workspace`) through the frontend request +boundary. Do not infer it by parsing the English error message. Workspace +attempt state is shared by consumers of the same environment and includes +task/environment identity, an attempt revision, pending/ready/error status, a +safe cause, bounded diagnostic detail, and retry eligibility. Reuse environment +state and request guards; do not persist this transient feedback as an agent +launch failure or create independent retry loops in each panel. + +Keep this state in the existing `session-runtime` slice beside environment-keyed +Git and shell state. Add pure attempt-state helpers and actions, with defaults, +environment mapping, and purge behavior covered by tests. The domain hook owns +request coordination. Do not treat a session ID as proof of environment identity +when its canonical mapping is unavailable, or let purging a historical session +clear a newer attempt owned by another session in the shared environment. + +`useSessionResumption` and `use-session-resumption-operations` publish restore +results to that workspace state. `task-page-inner.tsx` excludes workspace-only +failures from `EnsureSessionErrorBanner` and `SessionRecoveryFeedback` above the +layout. Genuine session ensure/start/resume failures retain their existing +presentation and precedence. Do not hide errors globally for completed tasks. + +Extend `WorkspaceUnavailable` for a restoration cause and Retry, rather than +using its setup-failure wording for all cases. Render the shared feedback at +workspace content boundaries, including Files, Changes, and workspace terminals. +On a failed initial load, replace the preparation indicator. If cached content +exists, keep it visible with an inline stale/unavailable notice; do not present +stale data as a successful live refresh. The chat's completed banner, Resume, +and New Agent remain independent. Failure in agent Resume remains chat-owned. + +Use a neutral icon, compact title, one short cause, and inline Retry/Details. +Suggested localized copy is "Workspace unavailable" and "Couldn't reconnect +to this task's workspace." Details are collapsed by default and must already +be sanitized and bounded before rendering. A disclosure is not permission to +expose credentials, raw command output, or private paths. Classify workspace +restoration failures correctly in backend logs instead of labelling every +terminal-state rejection as shutdown. Use existing structured diagnostics with +task/session/environment identity and outcome; no new metrics are required. + +Retry repeats only workspace restoration and clears matching feedback only +after success. Pending disables duplicate actions. Permission/archive/deleted +or invalid-inventory failures do not retry on a timer. Preserve existing explicit +recovery options where applicable. Task switches and newer attempts invalidate +late results. Readiness reconciliation after reconnect must not overwrite a +newer error with an old ready event. + +### Workspace mobile composition + +The curated exemplar is `task-layout.tsx` and its dedicated +`mobile/session-mobile-layout.tsx`: phone navigation selects one workspace +panel. The Files panel opens `mobile-file-viewer-panel.tsx` for file content. +Place the error inline in that selected panel. This is a persistent content +failure with a short action, so it needs neither a global banner nor a new drawer. + +Desktop keeps compact actions within its workspace pane. Phone/coarse-pointer +Retry and Details have at least 44 px hit areas; apply those dimensions only +to the appropriate responsive variants. Details wrap in the panel's single +vertical scroll region. Preserve the mobile layout's dynamic viewport and +safe-area navigation, keyboard focus, file-viewer back action, and saved desktop +layout. Shared state and retry behavior remain identical across viewports. + ## Verification and observability Use barrier-based Go tests for resume/stop, reclaim/resume, stale receipt/new @@ -254,9 +398,25 @@ Assert a second agent response, unchanged session count and history, and no task reopen or duplicate completion. Include a retired non-primary session and active clarification regression. Run against freshly built production assets. +For workspace restoration, use a real retained checkout and a completed session +with no live execution. Prove passive Files/Git/terminal access, unchanged +conversation state, and later explicit Resume on desktop and phone. Test all +three terminal states, shared environments, cache reuse, missing mixed repository +inventory, permissions, cleanup races, and provider identity preservation at the +appropriate unit/integration boundary. Inject one bounded restore failure for +the UI recovery test; do not mock successful workspace restoration in the happy +path. Exact regression names and commands belong to the linked fix package. + ## Related decisions - [Separate task completion from conversation availability](../../../decisions/2026-09-09-task-completion-conversation-availability.md) - [Replayable migrations](../../../decisions/0027-replayable-schema-migrations.md) - [Profile-switch session policy](../../../decisions/2026-08-31-workflow-profile-session-switch-policy.md) - [Current-turn clarification ownership](../../../decisions/2026-08-14-current-turn-clarification-ownership.md) +- [Task-owned worktree lifetime](../../../decisions/2026-08-08-task-owned-worktree-lifetime.md) +- [Environment ownership generations](../../../decisions/2026-09-04-generation-fenced-task-environment-ownership.md) + +## Implementation plans + +- [Task completion and follow-ups](../../../plans/task-completion/plan.md) +- [Workspace restoration after completion](../../../plans/completed-workspace-restoration/plan.md)