Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions context/workspace-runtime-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,10 @@ Workspace deletion is intentionally conservative.
- Only after a clean preflight may runtime sessions and shells be stopped.
- Only after runtime shutdown succeeds should destructive worktree and DB
teardown continue.
- A live worktree registration at the persisted path in the resolved repository
authorizes cleanup without an ownership marker; a same-repository replacement
is the workspace, but a symlink to another worktree is not.
(`internal/workspace/manager.go::gitDirOwnsCleanupWorktree`)
- A confirmed delete publishes workspace absence from the application workflow before presenter-specific navigation
or failure UI; releasing the initiating presenter must not suppress tombstones, hosted-session cleanup, or route invalidation
(`frontend/src/lib/components/terminal/workspace-runtime-workflow.ts::executeMutation`).
Expand Down
36 changes: 36 additions & 0 deletions docs/adr/0004-live-worktree-registration-authorizes-cleanup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# ADR 0004: Live Worktree Registration Authorizes Cleanup

## Status

Accepted

## Context

kenn-forge writes an ownership marker into linked-worktree metadata when it
creates a workspace. Workspaces created before that marker was introduced have
valid persisted records and live Git registrations but cannot be deleted or
retried because cleanup treats the missing marker as unproven ownership.

## Decision

An exact live linked-worktree registration in the repository resolved for the
persisted workspace is sufficient authority for cleanup. The ownership marker
is not required for workspace deletion or retry cleanup.

Cleanup will still preserve a checkout that belongs to another repository, a
standalone replacement clone, or a path no longer registered by the resolved
repository. A same-repository live registration at the exact persisted path is
treated as the workspace even if it appeared after cleanup planning.

Ownership markers remain part of worktree creation and rollback, where they
distinguish the generation kenn-forge just created from a replacement that may
have appeared during a failed setup.

## Consequences

- Pre-marker workspaces are deletable without migration or manual metadata
repair.
- A manually created linked worktree at the exact persisted workspace path in
the expected repository is treated as the workspace and can be deleted.
- Replacement repositories remain protected by repository identity; a
same-repository replacement at the persisted path is deleted.
74 changes: 30 additions & 44 deletions internal/server/workspacetest/workspace_clone_safety_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -358,7 +358,7 @@ func TestWorkspaceForceDeletePreservesForeignLinkedWorktreeAfterManagedPruneE2E(
)
}

func TestWorkspaceForceDeleteRejectsSameRepoReplacementAfterManagedPruneE2E(
func TestWorkspaceForceDeleteRemovesSameRepoReplacementAfterManagedPruneE2E(
t *testing.T,
) {
t.Parallel()
Expand Down Expand Up @@ -394,23 +394,14 @@ func TestWorkspaceForceDeleteRejectsSameRepoReplacementAfterManagedPruneE2E(
)
require.NoError(err)
require.Equal(
http.StatusConflict, deleteResp.StatusCode(), string(deleteResp.Body),
http.StatusNoContent, deleteResp.StatusCode(), string(deleteResp.Body),
)

stored, err := fixture.database.GetWorkspace(ctx, ws.Id)
require.NoError(err)
require.NotNil(stored)
assert.Equal(ws.Id, stored.ID)
require.FileExists(filepath.Join(worktreePath, ".git"))
contents, err := os.ReadFile(filepath.Join(worktreePath, "base.txt"))
require.NoError(err)
assert.Equal("foreign dirty data\n", string(contents))
assert.Equal(foreignHead, testGitSHA(t, worktreePath, "HEAD"))
assert.Contains(
workspaceGitOutput(t, worktreePath, "status", "--porcelain"),
"M base.txt",
)
assert.Contains(
assert.Nil(stored)
require.NoDirExists(worktreePath)
assert.NotContains(
workspaceGitOutput(t, fixture.bare, "worktree", "list", "--porcelain"),
worktreePath,
)
Expand All @@ -420,7 +411,7 @@ func TestWorkspaceForceDeleteRejectsSameRepoReplacementAfterManagedPruneE2E(
)
}

func TestWorkspaceForceDeleteRejectsPreMarkerWorkspaceAfterUpgradeE2E(
func TestWorkspaceForceDeleteRemovesPreMarkerWorkspaceAfterUpgradeE2E(
t *testing.T,
) {
t.Parallel()
Expand Down Expand Up @@ -450,26 +441,18 @@ func TestWorkspaceForceDeleteRejectsPreMarkerWorkspaceAfterUpgradeE2E(
)
require.NoError(err)
require.Equal(
http.StatusConflict, deleteResp.StatusCode(), string(deleteResp.Body),
http.StatusNoContent, deleteResp.StatusCode(), string(deleteResp.Body),
)

stored, err := fixture.database.GetWorkspace(ctx, ws.Id)
require.NoError(err)
require.NotNil(stored)
assert.Equal(ws.Id, stored.ID)
require.FileExists(filepath.Join(worktreePath, ".git"))
assert.Equal(
"kenn-forge/pr-1",
workspaceGitOutput(t, worktreePath, "branch", "--show-current"),
)
assert.Contains(
assert.Nil(stored)
require.NoDirExists(worktreePath)
assert.NotContains(
workspaceGitOutput(t, fixture.bare, "worktree", "list", "--porcelain"),
worktreePath,
)
assert.Equal(
testGitSHA(t, worktreePath, "HEAD"),
testGitSHA(t, fixture.bare, "refs/heads/kenn-forge/pr-1"),
)
requireGitRefMissing(t, fixture.bare, "refs/heads/kenn-forge/pr-1")
}

func TestWorkspaceForceDeleteRetainsLockedWorktreeE2E(t *testing.T) {
Expand Down Expand Up @@ -528,7 +511,7 @@ func TestWorkspaceForceDeleteRetainsLockedWorktreeE2E(t *testing.T) {
assert.Nil(stored)
}

func TestWorkspaceForceDeleteRejectsSymlinkToSameRepoWorktreeE2E(
func TestWorkspaceForceDeleteForgetsSymlinkToSameRepoWorktreeE2E(
t *testing.T,
) {
t.Parallel()
Expand Down Expand Up @@ -561,13 +544,12 @@ func TestWorkspaceForceDeleteRejectsSymlinkToSameRepoWorktreeE2E(
)
require.NoError(err)
require.Equal(
http.StatusConflict, deleteResp.StatusCode(), string(deleteResp.Body),
http.StatusNoContent, deleteResp.StatusCode(), string(deleteResp.Body),
)

stored, err := fixture.database.GetWorkspace(ctx, ws.Id)
require.NoError(err)
require.NotNil(stored)
assert.Equal(ws.Id, stored.ID)
assert.Nil(stored)
pathInfo, err := os.Lstat(worktreePath)
require.NoError(err)
assert.NotZero(pathInfo.Mode() & os.ModeSymlink)
Expand Down Expand Up @@ -644,7 +626,7 @@ func TestWorkspaceCreateRejectsSymlinkedReusableWorktreeE2E(t *testing.T) {
assert.ErrorIs(err, os.ErrNotExist)
}

func TestWorkspaceRetryRejectsPreMarkerWorkspaceAfterUpgradeE2E(
func TestWorkspaceRetryAcceptsPreMarkerWorkspaceAfterUpgradeE2E(
t *testing.T,
) {
t.Parallel()
Expand All @@ -656,6 +638,7 @@ func TestWorkspaceRetryRejectsPreMarkerWorkspaceAfterUpgradeE2E(
ctx := t.Context()

ws := createReadyWorkspace(t, ctx, fixture.client)
branch := workspaceGitOutput(t, ws.WorktreePath, "branch", "--show-current")
metadataDir := workspaceGitOutput(
t, ws.WorktreePath,
"rev-parse", "--path-format=absolute", "--git-dir",
Expand All @@ -669,20 +652,23 @@ func TestWorkspaceRetryRejectsPreMarkerWorkspaceAfterUpgradeE2E(
retryResp, err := fixture.client.HTTP.RetryWorkspaceWithResponse(ctx, ws.Id)
require.NoError(err)
require.Equal(
http.StatusConflict, retryResp.StatusCode(), string(retryResp.Body),
http.StatusAccepted, retryResp.StatusCode(), string(retryResp.Body),
)

stored, err := fixture.database.GetWorkspace(ctx, ws.Id)
require.NoError(err)
require.NotNil(stored)
assert.Equal("error", stored.Status)
require.NotNil(stored.ErrorMessage)
assert.NotEmpty(*stored.ErrorMessage)
require.FileExists(filepath.Join(ws.WorktreePath, ".git"))
assert.Contains(
workspaceGitOutput(t, fixture.bare, "worktree", "list", "--porcelain"),
ws.WorktreePath,
ready := waitForWorkspaceReady(t, ctx, fixture.client, ws.Id)
assert.Equal(branch, workspaceGitOutput(
t, ready.WorktreePath, "branch", "--show-current",
))
require.FileExists(filepath.Join(ready.WorktreePath, ".git"))
newMetadataDir := workspaceGitOutput(
t, ready.WorktreePath,
"rev-parse", "--path-format=absolute", "--git-dir",
)
marker, err := os.ReadFile(filepath.Join(
newMetadataDir, "kenn-forge-workspace-id",
))
require.NoError(err)
assert.Equal(ws.Id+"\n", string(marker))
}

func TestWorkspaceRetryCleansStalePreMarkerRegistrationE2E(t *testing.T) {
Expand Down
28 changes: 9 additions & 19 deletions internal/workspace/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -3375,6 +3375,13 @@ func worktreeRegistrationMetadataDir(
func gitDirHasStaleWorktreeRegistration(
ctx context.Context, gitDir, worktreePath string,
) (bool, error) {
info, err := os.Lstat(worktreePath)
if err == nil && info.Mode()&os.ModeSymlink != 0 {
return false, nil
}
if err != nil && !errors.Is(err, os.ErrNotExist) {
return false, fmt.Errorf("stat workspace path: %w", err)
}
tracked, err := gitDirTracksWorktreePath(ctx, gitDir, worktreePath)
if err != nil || !tracked {
return false, err
Expand All @@ -3396,18 +3403,7 @@ func gitDirHasLiveWorktree(
if err != nil {
return false, fmt.Errorf("stat workspace path: %w", err)
}
isDirectory := info.IsDir()
if info.Mode()&os.ModeSymlink != 0 {
targetInfo, targetErr := os.Stat(worktreePath)
if errors.Is(targetErr, os.ErrNotExist) {
return false, nil
}
if targetErr != nil {
return false, fmt.Errorf("stat workspace symlink target: %w", targetErr)
}
isDirectory = targetInfo.IsDir()
}
if !isDirectory {
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return false, nil
}
isRoot, err := worktreePathIsRoot(ctx, worktreePath)
Expand Down Expand Up @@ -3486,13 +3482,7 @@ func gitDirOwnsCleanupWorktree(
if err != nil || owned {
return owned, err
}
live, err := gitDirHasLiveWorktree(ctx, gitDir, worktreePath)
if err != nil || !live {
return false, err
}
return false, fmt.Errorf(
"%w: %s", ErrWorkspaceOwnershipUnproven, worktreePath,
)
return gitDirHasLiveWorktree(ctx, gitDir, worktreePath)
}

func gitDirOwnsLinkedWorktree(
Expand Down
Loading
Loading