fix: preserve inherited task worktrees (#1621)

This commit is contained in:
Carlos Florêncio
2026-07-08 17:11:50 +01:00
committed by GitHub
parent fd0ed44d59
commit b4824bd4c7
6 changed files with 470 additions and 2 deletions
@@ -906,6 +906,37 @@ func (r *Repository) HasActiveTaskSessionsByEnvironment(ctx context.Context, env
return err == nil, err
}
func (r *Repository) HasActiveTaskSessionsByTaskEnvironmentExcludingTask(ctx context.Context, taskEnvironmentID, taskID string) (bool, error) {
var exists int
err := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
SELECT 1 FROM task_sessions
WHERE task_environment_id = ?
AND task_id != ?
AND state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT')
LIMIT 1
`), taskEnvironmentID, taskID).Scan(&exists)
if err == sql.ErrNoRows {
return false, nil
}
return err == nil, err
}
func (r *Repository) FindActiveTaskSessionTaskIDByTaskEnvironmentExcludingTask(ctx context.Context, taskEnvironmentID, taskID string) (string, error) {
var borrowerTaskID string
err := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
SELECT task_id FROM task_sessions
WHERE task_environment_id = ?
AND task_id != ?
AND state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT')
ORDER BY updated_at DESC
LIMIT 1
`), taskEnvironmentID, taskID).Scan(&borrowerTaskID)
if err == sql.ErrNoRows {
return "", nil
}
return borrowerTaskID, err
}
func (r *Repository) HasActiveTaskSessionsByRepository(ctx context.Context, repositoryID string) (bool, error) {
var exists int
// Only sessions of live (non-archived) tasks count; archived tasks never
@@ -170,6 +170,23 @@ func (r *Repository) UpdateTaskEnvironment(ctx context.Context, env *models.Task
return nil
}
func (r *Repository) TransferTaskEnvironmentToTask(ctx context.Context, envID, taskID string) error {
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
UPDATE task_environments SET
task_id = ?,
updated_at = ?
WHERE id = ?
`), taskID, time.Now().UTC(), envID)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("task environment not found: %s", envID)
}
return nil
}
// DeleteTaskEnvironment deletes a task environment by ID.
// Per-repo rows are removed via ON DELETE CASCADE.
func (r *Repository) DeleteTaskEnvironment(ctx context.Context, id string) error {
@@ -53,6 +53,18 @@ type taskEnvironmentCleanup struct {
deleteRow bool
}
type taskEnvironmentSessionUsageChecker interface {
HasActiveTaskSessionsByTaskEnvironmentExcludingTask(ctx context.Context, taskEnvironmentID, taskID string) (bool, error)
}
type taskEnvironmentSessionBorrowerFinder interface {
FindActiveTaskSessionTaskIDByTaskEnvironmentExcludingTask(ctx context.Context, taskEnvironmentID, taskID string) (string, error)
}
type taskEnvironmentOwnerTransferer interface {
TransferTaskEnvironmentToTask(ctx context.Context, envID, taskID string) error
}
// Task operations
// isOfficeRequest returns true if the request should create an office task.
@@ -1050,6 +1062,14 @@ func (s *Service) deleteTaskWithReasonAndDBDelete(
worktrees := s.gatherWorktreesForDelete(ctx, id)
taskEnv := s.gatherTaskEnvironmentForCleanup(ctx, id)
if preserved, err := s.preserveTaskEnvironmentForActiveBorrower(ctx, id, taskEnv); err != nil {
return false, err
} else if preserved {
s.logger.Info("transferred borrowed task environment before task delete",
zap.String("task_id", id),
zap.String("env_id", taskEnvironmentID(taskEnv)),
zap.String("new_owner_task_id", taskEnv.TaskID))
}
// 3. Get active sessions for stopping agents (sync, fast)
// Must query before delete since DB records will be gone
@@ -1136,6 +1156,23 @@ func (s *Service) CleanupTaskResources(ctx context.Context, taskID string, delet
}
worktrees := s.gatherWorktreesForDelete(ctx, taskID)
taskEnv := s.gatherTaskEnvironmentForCleanup(ctx, taskID)
if deleteEnvRow {
preserved, err := s.preserveTaskEnvironmentForActiveBorrower(ctx, taskID, taskEnv)
if err != nil {
s.logger.Warn("skipping cascade cleanup because task environment could not be preserved for borrower",
zap.String("task_id", taskID),
zap.String("env_id", taskEnvironmentID(taskEnv)),
zap.Error(err))
return
}
if preserved {
deleteEnvRow = false
s.logger.Info("transferred borrowed task environment before cascade delete",
zap.String("task_id", taskID),
zap.String("env_id", taskEnvironmentID(taskEnv)),
zap.String("new_owner_task_id", taskEnv.TaskID))
}
}
envCleanup := taskEnvironmentCleanup{env: taskEnv, deleteRow: deleteEnvRow}
if len(sessions) == 0 && len(worktrees) == 0 && len(stopTargets) == 0 && taskEnv == nil {
return
@@ -1366,7 +1403,7 @@ func (s *Service) performTaskCleanup(
zap.String("task_id", taskID),
zap.Int("preserved_runtime_count", len(preserveExecutorRows)))
}
errs = append(errs, s.cleanupDestructiveTaskResources(ctx, taskID, worktrees, envCleanup, preserveExecutorRows)...)
errs = append(errs, s.cleanupDestructiveTaskResources(ctx, taskID, sessions, worktrees, envCleanup, preserveExecutorRows)...)
sessionIDs := cleanupSessionIDs(sessions, stopTargets)
for _, sessionID := range sessionIDs {
@@ -1467,15 +1504,31 @@ func (s *Service) cleanupQuickChatDirs(
func (s *Service) cleanupDestructiveTaskResources(
ctx context.Context,
taskID string,
sessions []*models.TaskSession,
worktrees []*worktree.Worktree,
envCleanup taskEnvironmentCleanup,
preserveExecutorRows map[string]struct{},
) []error {
var errs []error
if len(preserveExecutorRows) == 0 {
skipOwnedEnvironment, err := s.hasActiveOtherTaskSessionsForEnvironment(ctx, taskID, envCleanup.env)
if err != nil {
s.logger.Warn("skipping task environment cleanup after shared-environment ownership check failed",
zap.String("task_id", taskID),
zap.String("env_id", taskEnvironmentID(envCleanup.env)),
zap.Error(err))
errs = append(errs, fmt.Errorf("check task environment ownership %s: %w", taskEnvironmentID(envCleanup.env), err))
skipOwnedEnvironment = true
}
if skipOwnedEnvironment {
s.logger.Info("skipping task environment cleanup while another task still uses it",
zap.String("task_id", taskID),
zap.String("env_id", taskEnvironmentID(envCleanup.env)))
}
if len(preserveExecutorRows) == 0 && !skipOwnedEnvironment {
errs = append(errs, s.cleanupTaskEnvironment(ctx, taskID, envCleanup)...)
}
originalWorktreeCount := len(worktrees)
worktrees = s.filterOwnedWorktreesForTaskCleanup(ctx, taskID, sessions, worktrees, envCleanup.env, skipOwnedEnvironment)
worktrees = cleanupEligibleWorktrees(worktrees, envCleanup.env, preserveExecutorRows)
if len(worktrees) == 0 {
if originalWorktreeCount > 0 {
@@ -1499,6 +1552,135 @@ func (s *Service) cleanupDestructiveTaskResources(
return errs
}
func taskEnvironmentID(env *models.TaskEnvironment) string {
if env == nil {
return ""
}
return env.ID
}
func (s *Service) hasActiveOtherTaskSessionsForEnvironment(ctx context.Context, taskID string, env *models.TaskEnvironment) (bool, error) {
if env == nil || env.ID == "" || s.sessions == nil {
return false, nil
}
checker, ok := s.sessions.(taskEnvironmentSessionUsageChecker)
if !ok {
return false, nil
}
return checker.HasActiveTaskSessionsByTaskEnvironmentExcludingTask(ctx, env.ID, taskID)
}
func (s *Service) preserveTaskEnvironmentForActiveBorrower(ctx context.Context, taskID string, env *models.TaskEnvironment) (bool, error) {
if env == nil || env.ID == "" || s.sessions == nil {
return false, nil
}
finder, ok := s.sessions.(taskEnvironmentSessionBorrowerFinder)
if !ok {
return false, nil
}
borrowerTaskID, err := finder.FindActiveTaskSessionTaskIDByTaskEnvironmentExcludingTask(ctx, env.ID, taskID)
if err != nil {
return false, fmt.Errorf("find task environment borrower %s: %w", env.ID, err)
}
if borrowerTaskID == "" {
return false, nil
}
ownerTransfer, ok := s.taskEnvironments.(taskEnvironmentOwnerTransferer)
if !ok {
return false, fmt.Errorf("task environment repository cannot transfer borrowed environment %s", env.ID)
}
if err := ownerTransfer.TransferTaskEnvironmentToTask(ctx, env.ID, borrowerTaskID); err != nil {
return false, fmt.Errorf("transfer task environment %s to %s: %w", env.ID, borrowerTaskID, err)
}
env.TaskID = borrowerTaskID
return true, nil
}
func (s *Service) filterOwnedWorktreesForTaskCleanup(
ctx context.Context,
taskID string,
sessions []*models.TaskSession,
worktrees []*worktree.Worktree,
ownedEnv *models.TaskEnvironment,
skipOwnedEnvironment bool,
) []*worktree.Worktree {
if len(worktrees) == 0 {
return worktrees
}
bySession := make(map[string]*models.TaskSession, len(sessions))
for _, sess := range sessions {
if sess != nil && sess.ID != "" {
bySession[sess.ID] = sess
}
}
envCache := map[string]*models.TaskEnvironment{}
filtered := worktrees[:0]
for _, wt := range worktrees {
if wt == nil {
continue
}
sess, sessionLoaded := bySession[wt.SessionID]
if s.taskOwnsSessionWorktree(ctx, taskID, wt.SessionID, sess, sessionLoaded, ownedEnv, skipOwnedEnvironment, envCache) {
filtered = append(filtered, wt)
}
}
return filtered
}
func (s *Service) taskOwnsSessionWorktree(
ctx context.Context,
taskID string,
sessionID string,
session *models.TaskSession,
sessionLoaded bool,
ownedEnv *models.TaskEnvironment,
skipOwnedEnvironment bool,
envCache map[string]*models.TaskEnvironment,
) bool {
if session == nil {
if sessionID == "" {
return true
}
if !sessionLoaded {
s.logger.Warn("skipping task worktree cleanup because session ownership cannot be checked",
zap.String("task_id", taskID),
zap.String("session_id", sessionID))
}
return false
}
if session.TaskEnvironmentID == "" {
return true
}
if ownedEnv != nil && session.TaskEnvironmentID == ownedEnv.ID {
return !skipOwnedEnvironment
}
if s.taskEnvironments == nil {
s.logger.Warn("skipping task worktree cleanup because task environment ownership cannot be checked",
zap.String("task_id", taskID),
zap.String("session_id", session.ID),
zap.String("task_environment_id", session.TaskEnvironmentID))
return false
}
env, ok := envCache[session.TaskEnvironmentID]
if !ok {
var err error
env, err = s.taskEnvironments.GetTaskEnvironment(ctx, session.TaskEnvironmentID)
if err != nil {
s.logger.Warn("skipping task worktree cleanup because task environment lookup failed",
zap.String("task_id", taskID),
zap.String("session_id", session.ID),
zap.String("task_environment_id", session.TaskEnvironmentID),
zap.Error(err))
return false
}
envCache[session.TaskEnvironmentID] = env
}
if env == nil || env.TaskID != taskID {
return false
}
return true
}
func cleanupEligibleWorktrees(worktrees []*worktree.Worktree, env *models.TaskEnvironment, preserveExecutorRows map[string]struct{}) []*worktree.Worktree {
worktrees = excludeEnvironmentWorktree(worktrees, env)
if len(preserveExecutorRows) == 0 || len(worktrees) == 0 {
@@ -8,6 +8,7 @@ import (
"github.com/kandev/kandev/internal/agentruntime"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/repository"
"github.com/kandev/kandev/internal/worktree"
)
// --- isCleanableSessionState ---
@@ -266,3 +267,223 @@ func TestCleanupTaskResources_NonTerminalSessionStopFailureBlocksCleanup(t *test
t.Error("executor_running row must be preserved when stop fails for a non-terminal (RUNNING) session")
}
}
func TestCleanupTaskResources_SkipsBorrowedInheritedWorktree(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
seedParentChildWorkspace(t, repo, "ws-inherited", "wf-inherited", "parent-task", "child-task")
if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{
ID: "env-parent",
TaskID: "parent-task",
WorktreeID: "wt-parent",
WorktreePath: "/tmp/parent-worktree",
Status: models.TaskEnvironmentStatusReady,
}); err != nil {
t.Fatalf("create parent environment: %v", err)
}
if err := repo.CreateTaskSession(ctx, &models.TaskSession{
ID: "session-child",
TaskID: "child-task",
State: models.TaskSessionStateCancelled,
TaskEnvironmentID: "env-parent",
}); err != nil {
t.Fatalf("create child session: %v", err)
}
cleanup := &recordingWorktreeCleanup{
worktreesByTaskID: map[string][]*worktree.Worktree{
"child-task": {{
ID: "wt-parent",
TaskID: "child-task",
SessionID: "session-child",
Path: "/tmp/parent-worktree",
}},
},
}
svc.SetWorktreeCleanup(cleanup)
svc.setCleanupDoneForTestHook(make(chan struct{}, 1))
svc.CleanupTaskResources(ctx, "child-task", true)
waitForCleanupDone(t, svc)
if cleanedIDs := cleanup.cleanedIDs(); len(cleanedIDs) != 0 {
t.Fatalf("child cleanup must not clean inherited parent worktrees, got %#v", cleanedIDs)
}
}
func TestCleanupTaskResources_SkipsWorktreeWhenSessionOwnershipUnknown(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
seedParentChildWorkspace(t, repo, "ws-unknown", "wf-unknown", "parent-task", "child-task")
cleanup := &recordingWorktreeCleanup{
worktreesByTaskID: map[string][]*worktree.Worktree{
"child-task": {{
ID: "wt-unknown",
TaskID: "child-task",
SessionID: "missing-session",
Path: "/tmp/unknown-worktree",
}},
},
}
svc.SetWorktreeCleanup(cleanup)
svc.setCleanupDoneForTestHook(make(chan struct{}, 1))
svc.CleanupTaskResources(ctx, "child-task", true)
waitForCleanupDone(t, svc)
if cleanedIDs := cleanup.cleanedIDs(); len(cleanedIDs) != 0 {
t.Fatalf("cleanup must fail closed when session ownership is unknown, got %#v", cleanedIDs)
}
}
func TestCleanupTaskResources_PreservesOwnedEnvironmentWithActiveInheritedChild(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
seedParentChildWorkspace(t, repo, "ws-shared", "wf-shared", "parent-task", "child-task")
if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{
ID: "env-parent",
TaskID: "parent-task",
WorktreeID: "wt-parent",
WorktreePath: "/tmp/parent-worktree",
Status: models.TaskEnvironmentStatusReady,
}); err != nil {
t.Fatalf("create parent environment: %v", err)
}
if err := repo.CreateTaskSession(ctx, &models.TaskSession{
ID: "session-parent",
TaskID: "parent-task",
State: models.TaskSessionStateCancelled,
TaskEnvironmentID: "env-parent",
}); err != nil {
t.Fatalf("create parent session: %v", err)
}
if err := repo.CreateTaskSession(ctx, &models.TaskSession{
ID: "session-child",
TaskID: "child-task",
State: models.TaskSessionStateRunning,
TaskEnvironmentID: "env-parent",
}); err != nil {
t.Fatalf("create child session: %v", err)
}
destroyer := &stubDestroyer{}
svc.SetEnvironmentDestroyer(destroyer)
cleanup := &recordingWorktreeCleanup{
worktreesByTaskID: map[string][]*worktree.Worktree{
"parent-task": {{
ID: "wt-parent",
TaskID: "parent-task",
SessionID: "session-parent",
Path: "/tmp/parent-worktree",
}},
},
}
svc.SetWorktreeCleanup(cleanup)
svc.setCleanupDoneForTestHook(make(chan struct{}, 1))
svc.CleanupTaskResources(ctx, "parent-task", false)
waitForCleanupDone(t, svc)
if len(destroyer.worktreeCalls) != 0 {
t.Fatalf("parent cleanup must not destroy a worktree while an active child inherits it, got %#v", destroyer.worktreeCalls)
}
if cleanedIDs := cleanup.cleanedIDs(); len(cleanedIDs) != 0 {
t.Fatalf("parent cleanup must not batch-clean a shared inherited worktree, got %#v", cleanedIDs)
}
}
func TestDeleteTask_TransfersBorrowedEnvironmentBeforeDeletingOwner(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
seedParentChildWorkspace(t, repo, "ws-transfer", "wf-transfer", "parent-task", "child-task")
if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{
ID: "env-parent",
TaskID: "parent-task",
WorktreeID: "wt-parent",
WorktreePath: "/tmp/parent-worktree",
Status: models.TaskEnvironmentStatusReady,
}); err != nil {
t.Fatalf("create parent environment: %v", err)
}
if err := repo.CreateTaskSession(ctx, &models.TaskSession{
ID: "session-child",
TaskID: "child-task",
State: models.TaskSessionStateRunning,
TaskEnvironmentID: "env-parent",
}); err != nil {
t.Fatalf("create child session: %v", err)
}
svc.setCleanupDoneForTestHook(make(chan struct{}, 1))
if err := svc.DeleteTask(ctx, "parent-task"); err != nil {
t.Fatalf("delete parent task: %v", err)
}
env, err := repo.GetTaskEnvironment(ctx, "env-parent")
if err != nil {
t.Fatalf("borrowed environment should survive parent delete: %v", err)
}
if env.TaskID != "child-task" {
t.Fatalf("borrowed environment owner = %q, want child-task", env.TaskID)
}
}
func TestCleanupTaskResources_TransfersBorrowedEnvironmentBeforeCascadeDelete(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
seedParentChildWorkspace(t, repo, "ws-cascade-transfer", "wf-cascade-transfer", "parent-task", "child-task")
if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{
ID: "env-parent",
TaskID: "parent-task",
WorktreeID: "wt-parent",
WorktreePath: "/tmp/parent-worktree",
Status: models.TaskEnvironmentStatusReady,
}); err != nil {
t.Fatalf("create parent environment: %v", err)
}
if err := repo.CreateTaskSession(ctx, &models.TaskSession{
ID: "session-child",
TaskID: "child-task",
State: models.TaskSessionStateRunning,
TaskEnvironmentID: "env-parent",
}); err != nil {
t.Fatalf("create child session: %v", err)
}
svc.setCleanupDoneForTestHook(make(chan struct{}, 1))
svc.CleanupTaskResources(ctx, "parent-task", true)
waitForCleanupDone(t, svc)
if err := repo.DeleteTask(ctx, "parent-task"); err != nil {
t.Fatalf("delete parent task: %v", err)
}
env, err := repo.GetTaskEnvironment(ctx, "env-parent")
if err != nil {
t.Fatalf("borrowed environment should survive cascade owner delete: %v", err)
}
if env.TaskID != "child-task" {
t.Fatalf("borrowed environment owner = %q, want child-task", env.TaskID)
}
}
func seedParentChildWorkspace(t *testing.T, repo interface {
CreateWorkspace(context.Context, *models.Workspace) error
CreateWorkflow(context.Context, *models.Workflow) error
CreateTask(context.Context, *models.Task) error
}, wsID, wfID, parentID, childID string) {
t.Helper()
ctx := context.Background()
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: wsID, Name: "Workspace"}); err != nil {
t.Fatalf("create workspace: %v", err)
}
if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: wfID, WorkspaceID: wsID, Name: "Workflow"}); err != nil {
t.Fatalf("create workflow: %v", err)
}
if err := repo.CreateTask(ctx, &models.Task{ID: parentID, WorkspaceID: wsID, WorkflowID: wfID, WorkflowStepID: "step-1", Title: "Parent", Priority: "medium"}); err != nil {
t.Fatalf("create parent task: %v", err)
}
if err := repo.CreateTask(ctx, &models.Task{ID: childID, WorkspaceID: wsID, WorkflowID: wfID, WorkflowStepID: "step-1", ParentID: parentID, Title: "Child", Priority: "medium"}); err != nil {
t.Fatalf("create child task: %v", err)
}
}
+1
View File
@@ -343,6 +343,7 @@ An agent's run JWT also pins an `AllowedTaskIDs` scope. The runtime rejects muta
- **GIVEN** a coordinator creates a planning task and an implementation task with the implementation blocked-by the planner, **WHEN** the planner writes `spec` and `plan` documents up to the parent and completes, **THEN** the blocker resolves and the implementation task wakes; its prompt names the parent's available document keys so the agent fetches them via the task document tool.
- **GIVEN** a parent policy says children inherit the parent workspace and run sequentially, **WHEN** the coordinator creates child tasks, **THEN** the children reuse the parent materialized workspace and receive dependency edges that order their execution.
- **GIVEN** a child task inherits a parent materialized workspace, **WHEN** the child is archived or deleted, **THEN** the inherited parent workspace remains available to the parent and any other active child that still references it.
- **GIVEN** a user opens a Kanban task detail page, **WHEN** they create a subtask, **THEN** they can choose to inherit the parent task workspace or create a new workspace by selecting repositories, local folders, or a remote URL.
- **GIVEN** two tasks share a workspace group without dependency edges, **WHEN** the scheduler starts both, **THEN** they may run concurrently in the same materialized workspace.
- **GIVEN** a parent task has descendant tasks, **WHEN** the user deletes the parent, **THEN** Kandev cancels active descendant runs, deletes every descendant, releases their workspace memberships, and runs cleanup.
+16
View File
@@ -27,6 +27,13 @@ worktrees, or executor rows behind and the machine slowly runs out of memory.
- A cleanup path MUST NOT delete the last durable runtime handle for a task until
either the matching runtime execution has stopped successfully or the failure is
preserved for retry/diagnosis.
- Worktree and task-environment cleanup is ownership-aware. A task cleanup may
destroy only resources owned by that task's `TaskEnvironment`; borrowed or
inherited worktrees remain owned by the source task.
- A `TaskEnvironment` referenced by another active task session is shared for the
duration of that session. Cleanup can stop the current task's runtime rows, but
must defer destructive worktree/container/sandbox teardown until no other
active task session references the environment.
- Agent subprocess shutdown kills the whole agent process group when graceful
shutdown does not finish within the configured stop timeout.
- Agent subprocess shutdown does not treat the command leader exiting as
@@ -133,6 +140,8 @@ Allowed transitions:
confirmed, the runtime tracking row can still be removed because it no longer
identifies a live process. The resource cleanup error is logged and handled by
the resource-specific retry path.
- If cleanup cannot prove that a session worktree belongs to the task being
cleaned, destructive worktree deletion fails closed and skips that worktree.
- If an agentctl process exits unexpectedly, its owned agent subprocess group is
killed before agentctl shutdown completes.
- If the user sends Ctrl+C to a standalone Kandev process tree, agentctl does
@@ -191,6 +200,13 @@ Allowed transitions:
- **GIVEN** the `executors_running` query fails during archive cleanup, **WHEN**
cleanup evaluates destructive actions, **THEN** it logs the failure and does not
delete runtime tracking rows based on incomplete information.
- **GIVEN** a child task reuses its parent's `TaskEnvironment`, **WHEN** the child
is archived or deleted, **THEN** cleanup stops the child's runtime rows without
deleting the inherited parent worktree.
- **GIVEN** a parent task owns a `TaskEnvironment` that an active child session
still references, **WHEN** parent cleanup runs, **THEN** destructive
environment and worktree teardown is deferred until the child no longer holds
the environment.
## Out of Scope