fix: preserve inherited task worktrees (#1621)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user