diff --git a/apps/backend/internal/task/repository/sqlite/session.go b/apps/backend/internal/task/repository/sqlite/session.go index a9484a409f..1df26bdfcb 100644 --- a/apps/backend/internal/task/repository/sqlite/session.go +++ b/apps/backend/internal/task/repository/sqlite/session.go @@ -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 diff --git a/apps/backend/internal/task/repository/sqlite/task_environment.go b/apps/backend/internal/task/repository/sqlite/task_environment.go index 885e78912c..8d8f98eccf 100644 --- a/apps/backend/internal/task/repository/sqlite/task_environment.go +++ b/apps/backend/internal/task/repository/sqlite/task_environment.go @@ -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 { diff --git a/apps/backend/internal/task/service/service_tasks.go b/apps/backend/internal/task/service/service_tasks.go index 7872a154bd..056e820c92 100644 --- a/apps/backend/internal/task/service/service_tasks.go +++ b/apps/backend/internal/task/service/service_tasks.go @@ -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 { diff --git a/apps/backend/internal/task/service/service_tasks_stop_test.go b/apps/backend/internal/task/service/service_tasks_stop_test.go index 5357f8cfbd..bf6431dce7 100644 --- a/apps/backend/internal/task/service/service_tasks_stop_test.go +++ b/apps/backend/internal/task/service/service_tasks_stop_test.go @@ -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) + } +} diff --git a/docs/specs/office/tasks.md b/docs/specs/office/tasks.md index 9abf99a8c4..306657e762 100644 --- a/docs/specs/office/tasks.md +++ b/docs/specs/office/tasks.md @@ -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. diff --git a/docs/specs/tasks/runtime-cleanup.md b/docs/specs/tasks/runtime-cleanup.md index e89a5f776c..a323d7be48 100644 --- a/docs/specs/tasks/runtime-cleanup.md +++ b/docs/specs/tasks/runtime-cleanup.md @@ -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