fix: preserve recovered workspace path (#3167)

This commit is contained in:
Carlos Florêncio
2026-08-30 19:46:32 +00:00
committed by GitHub
parent 9279e5ea19
commit b2764dae7a
9 changed files with 469 additions and 16 deletions
@@ -2175,27 +2175,17 @@ func (e *Executor) injectHandoverIfNeeded(ctx context.Context, taskID, currentSe
// to the task root via filepath.Dir would diverge hot vs cold cwd and break
// resume with -32002 Resource not found.
//
// resp.WorktreePath here already mirrors what executor_standalone.go writes
// into metadata["worktree_path"] (= req.WorkspacePath from the env preparer),
// which is also what becomes cmd.Dir of the agent process. So persisting it
// as-is keeps a single source of truth.
// resp.WorkspacePath is the lifecycle execution's actual working directory and
// is therefore authoritative. WorktreePath remains a compatibility fallback
// for lifecycle responses that only expose the preparer's legacy metadata.
func computeWorkspacePath(req *LaunchAgentRequest, resp *LaunchAgentResponse) string {
// SSH materializes repositories on the remote host. RepositoryPath still
// identifies the source checkout on this host, so persisting it would make a
// later sibling attach to a different remote directory. The lifecycle
// response is the canonical remote task-directory handle.
if req.ExecutorType == string(models.ExecutorTypeSSH) && resp.WorkspacePath != "" {
if resp.WorkspacePath != "" {
return resp.WorkspacePath
}
if resp.WorktreePath != "" {
return resp.WorktreePath
}
if req.RepositoryPath != "" {
return req.RepositoryPath
}
// Quick-chat sessions have no worktree/repo but the lifecycle manager
// creates a workspace directory — use it as fallback.
return resp.WorkspacePath
return req.RepositoryPath
}
// persistTaskEnvironment creates or updates the task environment record after a successful launch.
@@ -52,6 +52,7 @@ func TestResolveTaskEnvWorkspacePath(t *testing.T) {
}
})
// @covers AC-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-002.3
t.Run("no worktree falls back to repository path", func(t *testing.T) {
req := &LaunchAgentRequest{RepositoryPath: "/repos/myrepo"}
resp := &LaunchAgentResponse{} // no WorktreePath
@@ -60,6 +61,15 @@ func TestResolveTaskEnvWorkspacePath(t *testing.T) {
}
})
// @covers AC-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-002.1
t.Run("recovered execution keeps lifecycle workspace", func(t *testing.T) {
req := &LaunchAgentRequest{RepositoryPath: "/source/kandev"}
resp := &LaunchAgentResponse{WorkspacePath: "/tasks/task-1/kandev"}
if got := computeWorkspacePath(req, resp); got != "/tasks/task-1/kandev" {
t.Fatalf("recovered workspace: want lifecycle workspace, got %q", got)
}
})
t.Run("SSH keeps the remote task directory over the host repository path", func(t *testing.T) {
req := &LaunchAgentRequest{
ExecutorType: string(models.ExecutorTypeSSH),
@@ -239,7 +239,10 @@ export function FileBrowserToolbar({
{displayPath ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="min-w-0 truncate text-xs font-medium text-muted-foreground">
<span
className="min-w-0 truncate text-xs font-medium text-muted-foreground"
data-testid="file-browser-workspace-path"
>
{displayPath}
</span>
</TooltipTrigger>
@@ -148,6 +148,71 @@ test.describe("Session resume (ACP mode)", () => {
// 10. The agent should respond to the new prompt
await session.expectChatResponseVisible("simple mock response", 1, { timeout: 30_000 });
});
// @covers AC-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-002.2
test("preserves the worktree Files path after backend restart", async ({
testPage,
apiClient,
seedData,
backend,
}) => {
test.setTimeout(120_000);
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Worktree Path Resume Task",
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
executor_profile_id: seedData.worktreeExecutorProfileId,
},
);
const sessionId = task.session_id;
if (!sessionId) throw new Error("createTaskWithAgent did not return a session_id");
const session = await openTaskSession(testPage, "Worktree Path Resume Task");
await session.waitForChatIdle({ timeout: 30_000 });
await waitForSessionState(apiClient, {
taskId: task.id,
sessionId,
expectedState: "WAITING_FOR_INPUT",
message: "Initial worktree session did not settle before restart",
timeout: 30_000,
});
const { sessions } = await apiClient.listTaskSessions(task.id);
const initialSession = sessions.find((candidate) => candidate.id === sessionId);
const expectedWorkspacePath = initialSession?.workspace_path ?? initialSession?.worktree_path;
if (!expectedWorkspacePath) throw new Error("Worktree session did not expose a workspace path");
const expectedDisplayPath = expectedWorkspacePath.replace(/^\/(?:Users|home)\/[^/]+\//, "~/");
await session.clickTab("Files");
const visibleWorkspacePath = session.files.getByTestId("file-browser-workspace-path");
await expect(visibleWorkspacePath).toHaveText(expectedDisplayPath, { timeout: 30_000 });
// Keeping Files active makes reload reconstruct the workspace-only execution
// before automatic session resume promotes and persists that execution.
await backend.restart();
await testPage.reload();
await expect(session.files).toBeVisible({ timeout: 30_000 });
await session.clickSessionChatTab();
await session.waitForLoad();
await session.waitForChatIdle({ timeout: 60_000 });
await expect(session.chat.getByText("Resumed agent Mock", { exact: false })).toBeVisible({
timeout: 15_000,
});
// Reload once promotion has persisted the task environment. The boot payload
// must project the promoted execution's canonical workspace, not stale UI state.
await testPage.reload();
await session.waitForLoad();
await session.clickTab("Files");
await expect(visibleWorkspacePath).toHaveText(expectedDisplayPath, { timeout: 30_000 });
});
});
// ---------------------------------------------------------------------------
@@ -0,0 +1,136 @@
---
created: 2026-08-30
status: done
requirements:
- REQ-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-001
- REQ-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-002
system_design:
- ../../specs/tasks/system-design/additional-session-workspace-reuse.md
legacy_specs: []
---
# Implementation Plan: Recovered Workspace Path Persistence
## Overview
Preserve a Worktree task's actual execution workspace when backend restart
recovery creates a workspace-only execution and later promotes it during agent
resume. The work starts with a focused failing precedence regression, applies
the smallest orchestrator correction, and then proves the user-visible Files
path through the existing restart E2E flow.
The confirmed failure is:
1. Restart recovery reconstructs an execution at the correct worktree path.
2. Workspace-only promotion returns that path as `WorkspacePath`, but the
recovered metadata has no `WorktreePath`.
3. `computeWorkspacePath` prefers the request's source `RepositoryPath` over
the response's actual `WorkspacePath`.
4. `persistTaskEnvironment` overwrites the correct environment path with the
source checkout.
5. Session reads prefer the environment path, so Files displays the source
checkout even though agentctl still runs in the worktree.
## Scope
### In scope
- Treat the lifecycle response's execution workspace as authoritative over
source repository input.
- Preserve legacy fallbacks for responses without an execution workspace.
- Add a focused Go regression that fails under the current precedence.
- Extend the existing session-restart E2E coverage to assert that the Files
path remains the original Worktree task path after automatic resume.
- Add only the stable selector needed for that UI assertion.
### Out of scope
- Database schema changes or startup migrations.
- Files layout, navigation, touch behavior, or responsive composition changes.
- Reconstructing missing worktree inventory from filesystem state.
- Changing the environment-first task-session API projection.
- Direct mutation of a live user's database as part of the code change.
## Technical approach
### Workspace-path precedence
Update `computeWorkspacePath` in
`apps/backend/internal/orchestrator/executor/executor_execute.go` so a non-empty
`LaunchAgentResponse.WorkspacePath` wins. Retain `WorktreePath` as the fallback
for legacy lifecycle responses and `LaunchAgentRequest.RepositoryPath` as the
last repository-backed compatibility fallback.
The response workspace is `AgentExecution.WorkspacePath`, which is the actual
agentctl working directory surfaced by `backendapp.lifecycleAdapter`. This
keeps fresh launch, workspace-only promotion, SSH recovery, and quick-chat
recovery on one authority rule.
### Regression boundaries
Add a subtest to
`apps/backend/internal/orchestrator/executor/executor_execute_workspace_path_test.go`
with a source repository request, an empty response `WorktreePath`, and a
correct response `WorkspacePath`. It must expect the response workspace and
fail before the production correction.
Extend
`apps/web/e2e/tests/session/session-resume.spec.ts` with a Worktree-executor
scenario that records the original session worktree, keeps Files active,
restarts the backend, waits for automatic resume, and asserts the visible Files
workspace path remains unchanged. Add a stable path selector in
`apps/web/components/task/file-browser-toolbar.tsx`; no presentation behavior
changes.
## Tests
- `AC-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-002.1`: the unit regression
proves recovered execution workspace precedence over the source checkout.
- `AC-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-002.2`: the restart E2E proves
the Files projection remains the canonical Worktree path after promotion.
- `AC-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-002.3`: existing repository
fallback coverage remains green when the response has neither workspace nor
worktree path.
## E2E tests
Add `preserves the worktree Files path after backend restart` to
`apps/web/e2e/tests/session/session-resume.spec.ts` in the `chromium` project.
It uses the existing `backend.restart()` fixture and session recovery page
object patterns. The shared Files component supplies the same normalized path
to desktop and phone; because this fix does not alter layout or interaction,
no new mobile-only scenario is required.
## Work orders
- [x] [Task 01: Preserve recovered workspace path](task-01-preserve-recovered-workspace-path.md)
## Verification results
- RED: the new `recovered execution keeps lifecycle workspace` subtest failed
because `computeWorkspacePath` returned `/source/kandev` instead of
`/tasks/task-1/kandev`.
- RED: Chromium collected the named restart scenario and failed on the absent
`file-browser-workspace-path` selector before the selector was added. A
DOM-coupled prototype already kept the path in the normal E2E timing; the Go
regression is the defect-specific precedence gate.
- `cd apps && pnpm install --frozen-lockfile` passed.
- `cd apps/backend && go test ./internal/orchestrator/executor -run '^TestResolveTaskEnvWorkspacePath$'`
passed with 7 tests.
- `cd apps/backend && go test ./internal/orchestrator/executor` passed with 527
tests.
- `cd apps/web && pnpm e2e:run tests/session/session-resume.spec.ts -- --grep 'preserves the worktree Files path after backend restart'`
collected and passed 1 Chromium test.
- `cd apps/web && pnpm exec prettier --check components/task/file-browser-toolbar.tsx e2e/tests/session/session-resume.spec.ts`
passed.
- `cd apps/web && pnpm run typecheck` passed.
- `python3 scripts/lint-spec-files.py --all` and `git diff --check` passed.
## Risks
- `WorkspacePath` and `WorktreePath` historically overlap. The test matrix must
preserve legacy responses that supply only `WorktreePath`.
- The E2E stimulus must open Files before restart so reload reconstructs the
workspace-only execution before automatic resume promotes it.
- Previously corrupted rows are repaired only after a successful resume under
the corrected binary and only while canonical worktree inventory remains.
@@ -0,0 +1,121 @@
---
id: "01-preserve-recovered-workspace-path"
title: "Preserve recovered workspace path"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-001
- REQ-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-002
acceptance_criteria:
- AC-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-002.1
- AC-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-002.2
- AC-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-002.3
system_design:
- ../../specs/tasks/system-design/additional-session-workspace-reuse.md
---
# Task 01: Preserve Recovered Workspace Path
## Summary
Make the lifecycle execution workspace authoritative when launch or resume
persistence selects the task environment path. Prove the correction with a
failing unit regression and the existing browser-level backend-restart flow.
## In scope
- Add the recovered workspace-only precedence case to
`TestResolveTaskEnvWorkspacePath` and confirm RED.
- Change `computeWorkspacePath` to prefer the lifecycle execution workspace,
while retaining worktree-only and source-repository fallbacks.
- Add a stable selector for the visible Files workspace path.
- Add a Worktree-executor restart/resume scenario to the existing session
resume Playwright specification.
- Record RED and final command results in this work order and `plan.md`.
## Out of scope
- Schema or data migrations.
- Lifecycle promotion redesign or new worktree metadata requirements.
- Frontend state derivation, layout, mobile composition, or copy changes.
- Filesystem-based repair of missing canonical worktree inventory.
- Manual repair of the reported live task.
## Acceptance
- A response with the recovered execution workspace and no worktree metadata
persists that workspace instead of the request's source repository.
- Existing worktree-only, SSH, quick-chat, and empty-response fallbacks retain
their intended paths.
- After backend restart and automatic resume, the Files path shown for a
Worktree task is unchanged from its original canonical worktree.
## Verification
```bash
cd apps && pnpm install --frozen-lockfile
cd apps/backend && go test ./internal/orchestrator/executor -run '^TestResolveTaskEnvWorkspacePath$'
cd apps/backend && go test ./internal/orchestrator/executor
cd apps/web && pnpm e2e:run tests/session/session-resume.spec.ts -- --grep 'preserves the worktree Files path after backend restart'
```
The new unit subtest and E2E assertion must fail for the diagnosed path mismatch
before the production correction and pass afterward. Confirm Playwright
collects and executes the named `chromium` test.
## Files likely touched
- `apps/backend/internal/orchestrator/executor/executor_execute.go`
- `apps/backend/internal/orchestrator/executor/executor_execute_workspace_path_test.go`
- `apps/web/components/task/file-browser-toolbar.tsx`
- `apps/web/e2e/tests/session/session-resume.spec.ts`
- `docs/plans/recovered-workspace-path-persistence/plan.md`
- `docs/plans/recovered-workspace-path-persistence/task-01-preserve-recovered-workspace-path.md`
## Dependencies
None.
## Risks
- An E2E test that reloads into Chat instead of Files may miss the
workspace-only reconstruction and fail to exercise the production sequence.
- Adding a selector must not change the Files toolbar's responsive structure or
accessible name.
## Parallelism
`sequential`
## Inputs
- `REQ-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-002` and its acceptance
criteria.
- `docs/specs/tasks/system-design/additional-session-workspace-reuse.md`.
- `docs/decisions/2026-08-08-task-owned-worktree-lifetime.md`.
- `computeWorkspacePath`, `backendapp.lifecycleAdapter.LaunchAgent`, and
`lifecycle.Manager.promoteWorkspaceExecution`.
- Existing restart patterns in
`apps/web/e2e/tests/session/session-resume.spec.ts`.
## Results
- Made `LaunchAgentResponse.WorkspacePath` authoritative, with
`WorktreePath` and `RepositoryPath` retained as legacy fallbacks.
- Added the defect-specific Go regression for a recovered execution that has
no worktree metadata and confirmed it failed before the production change.
- Added the Files path selector and a Chromium restart/resume test that checks
the original worktree path before restart and after promotion plus a fresh
boot-payload reload.
- The first E2E draft used a DOM relationship and stayed green before the
backend correction under normal fixture timing. The final E2E RED was the
missing stable selector; the Go test remains the direct regression for the
response-precedence defect.
- All verification commands in this work order passed. The executor package
reported 527 passing tests, and the named Chromium run reported 1 passing
test.
- No mobile-only test was added because the change does not alter Files layout,
controls, touch behavior, or responsive composition; both desktop and mobile
render the same normalized path component.
+1
View File
@@ -109,6 +109,7 @@ signals, and task-scoped scheduling contracts.
### System design
- [Additional Session Workspace Reuse](system-design/additional-session-workspace-reuse.md)
- [Attach Workspace Sources](system-design/attach-workspace-sources.md)
- [Quick Chat Agent Titles](system-design/quick-chat-agent-titles.md)
- [Quick Chat Session Resumption](system-design/quick-chat-session-resumption.md)
@@ -2,6 +2,7 @@
status: draft
system: tasks
created: 2026-08-19
updated: 2026-08-30
owners:
- kandev
---
@@ -34,6 +35,24 @@ preserving independent session runtime state.
reuse shall fail with a typed, recoverable API error without creating a
session or replacement workspace.
### REQ-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-002: Canonical Workspace Identity Continuity
**Intent:** Keep the task's effective workspace identity stable when Kandev
reconstructs or resumes its runtime.
#### Acceptance criteria
- **AC-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-002.1:** When Kandev recovers
or resumes a task with a ready canonical environment, the persisted and
projected workspace path shall remain the materialized workspace used by the
recovered runtime.
- **AC-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-002.2:** When the task reloads
after recovery, Files and later attached sessions shall resolve the same
canonical workspace; the repository's source checkout shall not replace it.
- **AC-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-002.3:** When no materialized
or recovered runtime workspace exists, a legacy repository-backed session
can continue to use its source checkout as a compatibility fallback.
## Migrated source detail
## Why
@@ -111,3 +130,4 @@ an optional session name remains best effort after a successful launch.
- A trusted filesystem read-only agent mode.
- Automatic workspace repair, reset, branch switching, or replacement during
session spawn.
- Reconstructing a missing physical worktree from filesystem guesses.
@@ -0,0 +1,107 @@
---
status: current
system: tasks
requirements:
- REQ-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-001
- REQ-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-002
created: 2026-08-30
owners:
- kandev
---
# Additional Session Workspace Reuse System Design
## Purpose and boundaries
The task system owns the canonical task environment, its physical worktree
inventory, and the execution lifecycle that reuses those resources. The
workspace system supplies repository and worktree resources, while the agent
runtime creates the process that consumes the resolved task workspace.
This design preserves one effective workspace identity across initial launch,
additional-session attachment, backend restart, workspace-only reconstruction,
and agent promotion or resume. It follows
[ADR-2026-08-08-task-owned-worktree-lifetime](../../../decisions/2026-08-08-task-owned-worktree-lifetime.md).
## Requirement mapping
| Requirement | Design section |
| --- | --- |
| `REQ-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-001` | [Canonical environment and inventory](#canonical-environment-and-inventory) |
| `REQ-TASKS-ADDITIONAL-SESSION-WORKSPACE-REUSE-002` | [Recovery and path authority](#recovery-and-path-authority) |
## Canonical environment and inventory
`task_environments` owns the task-level execution environment.
`task_environment_repos` owns the ordered physical repository and worktree
inventory. `task_sessions.task_environment_id` attaches a session to that
environment; an execution ID or repository source path is not workspace
identity.
The initial materializer publishes a ready environment and complete repository
inventory. Additional sessions validate and attach to that environment without
running repository preparation or creating another physical owner. Missing,
creating, inconsistent, or unsupported environments fail through the existing
typed reuse errors.
## Recovery and path authority
`task/service.GetWorkspaceInfoForSession` reconstructs the runtime workspace
from the canonical environment-repository inventory. For a single repository,
the effective workspace is its worktree. For multiple repositories, it is their
task root. The lifecycle manager uses that value as the recovered
`AgentExecution.WorkspacePath` and as the agentctl working directory.
The lifecycle adapter returns both the execution workspace and any optional
worktree metadata. Workspace-only reconstruction can legitimately return the
correct `WorkspacePath` without a `worktree_path` metadata key when the
execution is promoted to an agent execution.
The orchestrator persists an effective workspace using this authority order:
1. The lifecycle response's non-empty `WorkspacePath`, which is the actual
execution working directory.
2. The response's non-empty `WorktreePath` for legacy adapters that do not
expose an execution workspace.
3. The request's `RepositoryPath` only when no materialized runtime workspace
is available.
The source repository path is preparation input. It must not overwrite a
materialized task workspace reported by the lifecycle response.
## Persistence and projection
After successful launch or resume, the orchestrator refreshes
`task_environments.workspace_path` with the authoritative effective workspace.
No schema migration is required. A successful resume under this contract also
self-heals an environment path written incorrectly by the former fallback
order, provided the canonical worktree inventory remains valid.
Task-session reads project the linked environment workspace before the legacy
session-local path. The Files panel and other workspace consumers use that
effective `workspace_path`, with `worktree_path` retained as the primary
repository and compatibility fallback.
## Failure and recovery
- An empty execution workspace and empty worktree path preserve the existing
source-repository fallback for legacy or non-materialized local sessions.
- A recovered execution workspace always wins over source checkout input,
including when optional worktree metadata is absent.
- Missing or ambiguous physical inventory fails closed. This path does not
inspect the filesystem to guess a replacement owner.
- Persistence failure follows the existing launch/resume rollback and task
environment failure handling.
## Verification
The orchestrator unit boundary covers all workspace-path precedence cases,
including workspace-only recovery with a source repository present. A session
resume Playwright scenario uses the Worktree executor, restarts the backend,
allows automatic promotion/resume, and verifies that the Files path remains
the original worktree path.
The Files path component is shared by desktop and phone layouts. This repair
changes backend state normalization, not layout, navigation, scrolling, or
touch behavior, so the focused desktop recovery scenario plus existing shared
component coverage provides mobile parity.