diff --git a/apps/backend/internal/task/repository/sqlite/postgres_schema_test.go b/apps/backend/internal/task/repository/sqlite/postgres_schema_test.go index 6905f28286..04520c7397 100644 --- a/apps/backend/internal/task/repository/sqlite/postgres_schema_test.go +++ b/apps/backend/internal/task/repository/sqlite/postgres_schema_test.go @@ -440,6 +440,20 @@ func TestPostgresNormalizeTaskWorktreeOwnershipIsNoOpOnFreshSchema(t *testing.T) } } +func TestPostgresCutoverHybridNormalizedEnvironmentWithLegacySessionWorktrees(t *testing.T) { + db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + if _, err := NewWithDB(db, db, nil); err != nil { + t.Fatalf("seed final postgres schema: %v", err) + } + seed := seedHybridCutoverState(t, db, "postgres") + + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("upgrade hybrid postgres schema: %v", err) + } + assertHybridCutoverResult(t, repo, seed) +} + func TestPostgresWorkflowHiddenRoundTrip(t *testing.T) { ctx := context.Background() db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) diff --git a/apps/backend/internal/task/repository/sqlite/worktree_ownership_migration.go b/apps/backend/internal/task/repository/sqlite/worktree_ownership_migration.go index 472c10dce4..ac765a0e7f 100644 --- a/apps/backend/internal/task/repository/sqlite/worktree_ownership_migration.go +++ b/apps/backend/internal/task/repository/sqlite/worktree_ownership_migration.go @@ -111,6 +111,14 @@ func (r *Repository) normalizeTaskWorktreeOwnership() error { if err := r.cutoverEnsureLegacyBranchSlug(tx); err != nil { return err } + legacyEnvColumns, err := r.cutoverLegacyEnvironmentColumns(tx) + if err != nil { + return err + } + legacyRepoColumns, err := r.tableColumns(tx, "task_environment_repos") + if err != nil { + return err + } cut := &worktreeCutover{ envs: make(map[string]*legacyEnv), taskEnvs: make(map[string][]*legacyEnv), @@ -125,7 +133,7 @@ func (r *Repository) normalizeTaskWorktreeOwnership() error { loserEnvIDs: make(map[string]bool), executorTypes: make(map[string]string), } - if err := cut.loadLegacy(tx); err != nil { + if err := cut.loadLegacy(tx, legacyEnvColumns, legacyRepoColumns); err != nil { return err } if err := cut.normalize(tx); err != nil { @@ -156,6 +164,18 @@ func (r *Repository) normalizeTaskWorktreeOwnership() error { return nil } +func (r *Repository) cutoverLegacyEnvironmentColumns(tx *sqlx.Tx) (map[string]bool, error) { + columns := make(map[string]bool, 4) + for _, column := range []string{"repository_id", "worktree_id", "worktree_path", "worktree_branch"} { + has, err := r.columnExists(tx, "task_environments", column) + if err != nil { + return nil, err + } + columns[column] = has + } + return columns, nil +} + // logCutoverDemotions reports every legacy worktree that lost a contested // repository slot. The migration performs no filesystem operation, so each // listed directory and branch is still on disk and recoverable by hand. diff --git a/apps/backend/internal/task/repository/sqlite/worktree_ownership_migration_test.go b/apps/backend/internal/task/repository/sqlite/worktree_ownership_migration_test.go index b1189685e5..83cf8a056a 100644 --- a/apps/backend/internal/task/repository/sqlite/worktree_ownership_migration_test.go +++ b/apps/backend/internal/task/repository/sqlite/worktree_ownership_migration_test.go @@ -9,6 +9,7 @@ import ( "github.com/jmoiron/sqlx" dbutil "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/task/models" ) // --------------------------------------------------------------------------- @@ -224,6 +225,94 @@ func TestCutover_FreshSchemaHasFinalShape(t *testing.T) { } } +func TestCutover_HybridNormalizedEnvironmentWithLegacySessionWorktrees(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "hybrid.db") + dbConn, err := dbutil.OpenSQLite(dbPath) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + db := sqlx.NewDb(dbConn, "sqlite3") + t.Cleanup(func() { _ = db.Close() }) + if _, err := NewWithDB(db, db, nil); err != nil { + t.Fatalf("seed final schema: %v", err) + } + seed := seedHybridCutoverState(t, db, "sqlite") + + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("upgrade hybrid schema: %v", err) + } + assertHybridCutoverResult(t, repo, seed) +} + +func seedHybridCutoverState(t *testing.T, db *sqlx.DB, suffix string) legacySeed { + t.Helper() + now := time.Now().UTC().Truncate(time.Second) + seed := legacySeed{ + envID: "env-hybrid-" + suffix, taskID: "task-hybrid-" + suffix, + repoID: "repo-hybrid-" + suffix, sessionID: "sess-hybrid-" + suffix, + } + seedLegacyTask(t, db, seed, now) + if _, err := db.Exec(db.Rebind(` + INSERT INTO task_environments ( + id, task_id, executor_type, status, workspace_path, created_at, updated_at + ) VALUES (?, ?, 'worktree', 'ready', '/tasks/hybrid', ?, ?)`), + seed.envID, seed.taskID, now, now); err != nil { + t.Fatalf("seed normalized environment: %v", err) + } + seedLegacyEnvRepo(t, db, "env-repo-hybrid", seed.envID, seed.repoID, + "wt-hybrid", "/tasks/hybrid/repo", "feature/hybrid", now) + if _, err := db.Exec(db.Rebind(` + UPDATE task_environment_repos + SET status = 'deleted', merged_at = ?, deleted_at = ? + WHERE id = 'env-repo-hybrid'`), now, now); err != nil { + t.Fatalf("seed normalized repository lifecycle: %v", err) + } + if _, err := db.Exec(legacySessionWorktreeDDL); err != nil { + t.Fatalf("recreate stale legacy table: %v", err) + } + seedLegacySessionWorktree(t, db, seed.sessionID, "wt-session-only", seed.repoID+"-session-only", "", + "/tasks/hybrid/session-only", "feature/session-only", "active", now) + return seed +} + +func assertHybridCutoverResult(t *testing.T, repo *Repository, seed legacySeed) { + t.Helper() + exists, err := repo.tableExists("task_session_worktrees") + if err != nil { + t.Fatalf("probe stale legacy table: %v", err) + } + if exists { + t.Fatal("stale legacy table must be removed") + } + env, err := repo.GetTaskEnvironment(context.Background(), seed.envID) + if err != nil { + t.Fatalf("get normalized environment: %v", err) + } + if len(env.Repos) != 2 { + t.Fatalf("normalized repositories = %+v", env.Repos) + } + got := make(map[string]*models.TaskEnvironmentRepo, len(env.Repos)) + for _, envRepo := range env.Repos { + got[envRepo.RepositoryID] = envRepo + } + assertHybridWorktree(t, got[seed.repoID], "wt-hybrid", "/tasks/hybrid/repo", "feature/hybrid") + if got[seed.repoID].Status != "deleted" || got[seed.repoID].MergedAt == nil || + !got[seed.repoID].MergedAt.Equal(got[seed.repoID].CreatedAt) || got[seed.repoID].DeletedAt == nil || + !got[seed.repoID].DeletedAt.Equal(got[seed.repoID].CreatedAt) { + t.Fatalf("normalized repository lifecycle = %+v", got[seed.repoID]) + } + assertHybridWorktree(t, got[seed.repoID+"-session-only"], "wt-session-only", + "/tasks/hybrid/session-only", "feature/session-only") +} + +func assertHybridWorktree(t *testing.T, got *models.TaskEnvironmentRepo, worktreeID, path, branch string) { + t.Helper() + if got == nil || got.WorktreeID != worktreeID || got.WorktreePath != path || got.WorktreeBranch != branch { + t.Fatalf("normalized worktree = %+v, want id=%q path=%q branch=%q", got, worktreeID, path, branch) + } +} + func tableColumnSet(t *testing.T, db *sqlx.DB, table string) map[string]bool { t.Helper() cols := map[string]bool{} diff --git a/apps/backend/internal/task/repository/sqlite/worktree_ownership_normalize.go b/apps/backend/internal/task/repository/sqlite/worktree_ownership_normalize.go index e9fcdcc538..2e758daf13 100644 --- a/apps/backend/internal/task/repository/sqlite/worktree_ownership_normalize.go +++ b/apps/backend/internal/task/repository/sqlite/worktree_ownership_normalize.go @@ -53,7 +53,8 @@ type legacyEnvRepo struct { id, envID, repositoryID, branchSlug string worktreeID, worktreePath, worktreeBranch string position int - errorMessage string + errorMessage, status string + mergedAt, deletedAt *time.Time createdAt, updatedAt time.Time } @@ -67,28 +68,33 @@ type legacySessionWorktree struct { // loadLegacy reads every legacy ownership row into memory. The transaction // holds the writer/advisory locks, so the snapshot is consistent. -func (c *worktreeCutover) loadLegacy(tx *sqlx.Tx) error { - if err := c.loadLegacyEnvs(tx); err != nil { +func (c *worktreeCutover) loadLegacy(tx *sqlx.Tx, legacyEnvColumns, legacyRepoColumns map[string]bool) error { + if err := c.loadLegacyEnvs(tx, legacyEnvColumns); err != nil { return err } if err := c.loadLegacySessions(tx); err != nil { return err } - if err := c.loadLegacyEnvRepos(tx); err != nil { + if err := c.loadLegacyEnvRepos(tx, legacyRepoColumns); err != nil { return err } return c.loadLegacySessionWorktrees(tx) } -func (c *worktreeCutover) loadLegacyEnvs(tx *sqlx.Tx) error { - rows, err := tx.Query(` - SELECT id, task_id, repository_id, executor_type, executor_id, +func (c *worktreeCutover) loadLegacyEnvs(tx *sqlx.Tx, columns map[string]bool) error { + // Every interpolated expression comes from the fixed deprecated-column allowlist. + //nolint:gosec + rows, err := tx.Query(fmt.Sprintf(` + SELECT id, task_id, %s, executor_type, executor_id, executor_profile_id, control_port, status, - COALESCE(worktree_id, ''), COALESCE(worktree_path, ''), - COALESCE(worktree_branch, ''), COALESCE(workspace_path, ''), + %s, %s, %s, COALESCE(workspace_path, ''), COALESCE(container_id, ''), COALESCE(sandbox_id, ''), COALESCE(task_dir_name, ''), created_at, updated_at - FROM task_environments`) + FROM task_environments`, + legacyEnvColumnExpr(columns, "repository_id"), + legacyEnvColumnExpr(columns, "worktree_id"), + legacyEnvColumnExpr(columns, "worktree_path"), + legacyEnvColumnExpr(columns, "worktree_branch"))) if err != nil { return fmt.Errorf("cutover: read legacy environments: %w", err) } @@ -107,6 +113,13 @@ func (c *worktreeCutover) loadLegacyEnvs(tx *sqlx.Tx) error { return rows.Err() } +func legacyEnvColumnExpr(columns map[string]bool, column string) string { + if columns[column] { + return "COALESCE(" + column + ", '')" + } + return "CAST('' AS TEXT)" +} + func (c *worktreeCutover) loadLegacySessions(tx *sqlx.Tx) error { // GROUP BY lists every non-aggregate column so PostgreSQL accepts the // query (SQLite is lenient about functional dependency). started_at is @@ -158,29 +171,50 @@ func parseLegacyTimestamp(raw string) time.Time { return time.Time{} } -func (c *worktreeCutover) loadLegacyEnvRepos(tx *sqlx.Tx) error { - rows, err := tx.Query(` +func (c *worktreeCutover) loadLegacyEnvRepos(tx *sqlx.Tx, columns map[string]bool) error { + // Every interpolated expression comes from the fixed lifecycle-column allowlist. + //nolint:gosec + rows, err := tx.Query(fmt.Sprintf(` SELECT id, task_environment_id, repository_id, COALESCE(branch_slug, ''), COALESCE(worktree_id, ''), COALESCE(worktree_path, ''), COALESCE(worktree_branch, ''), position, COALESCE(error_message, ''), - created_at, updated_at - FROM task_environment_repos`) + %s, created_at, updated_at, %s, %s + FROM task_environment_repos`, + legacyRepoColumnExpr(columns, "status", "CAST('active' AS TEXT)"), + legacyRepoColumnExpr(columns, "merged_at", "NULL"), + legacyRepoColumnExpr(columns, "deleted_at", "NULL"))) if err != nil { return fmt.Errorf("cutover: read legacy environment repos: %w", err) } defer func() { _ = rows.Close() }() for rows.Next() { var row legacyEnvRepo + var mergedAt, deletedAt sql.NullTime if err := rows.Scan(&row.id, &row.envID, &row.repositoryID, &row.branchSlug, &row.worktreeID, &row.worktreePath, &row.worktreeBranch, &row.position, - &row.errorMessage, &row.createdAt, &row.updatedAt); err != nil { + &row.errorMessage, &row.status, &row.createdAt, &row.updatedAt, &mergedAt, &deletedAt); err != nil { return fmt.Errorf("cutover: scan legacy environment repo: %w", err) } + if mergedAt.Valid { + t := mergedAt.Time + row.mergedAt = &t + } + if deletedAt.Valid { + t := deletedAt.Time + row.deletedAt = &t + } c.envRepos = append(c.envRepos, row) } return rows.Err() } +func legacyRepoColumnExpr(columns map[string]bool, column, fallback string) string { + if columns[column] { + return column + } + return fallback +} + func (c *worktreeCutover) loadLegacySessionWorktrees(tx *sqlx.Tx) error { rows, err := tx.Query(` SELECT session_id, worktree_id, repository_id, COALESCE(branch_slug, ''), diff --git a/apps/backend/internal/task/repository/sqlite/worktree_ownership_targets.go b/apps/backend/internal/task/repository/sqlite/worktree_ownership_targets.go index 2c7c98fd01..22479a8dba 100644 --- a/apps/backend/internal/task/repository/sqlite/worktree_ownership_targets.go +++ b/apps/backend/internal/task/repository/sqlite/worktree_ownership_targets.go @@ -58,9 +58,11 @@ func (t *taskWorktreeTargets) targetForWorktree(worktreeID string) *envRepoTarge // physical-worktree identity, which must agree everywhere). func (t *taskWorktreeTargets) mergeLegacyEnvRepo(row legacyEnvRepo) error { target := t.rowForKey(row.repositoryID, row.branchSlug) - if err := target.claimWorktree(row.worktreeID, row.worktreePath, row.worktreeBranch, row.position, row.errorMessage, row.createdAt, row.updatedAt, worktreeRepoStatusActive); err != nil { + if err := target.claimWorktree(row.worktreeID, row.worktreePath, row.worktreeBranch, row.position, row.errorMessage, row.createdAt, row.updatedAt, row.status); err != nil { return err } + target.mergedAt = row.mergedAt + target.deletedAt = row.deletedAt return target.mergeCreation(row.createdAt, row.updatedAt) } @@ -349,7 +351,7 @@ func (c *worktreeCutover) legacyWorktreeInventory() map[string]bool { inventory[worktreeInventoryKey(wt.worktreeID, wt.worktreePath, wt.worktreeBranch)] = true } for _, row := range c.envRepos { - if row.worktreeID == "" { + if row.worktreeID == "" || row.status == worktreeRepoStatusDeleted || row.deletedAt != nil { continue } inventory[worktreeInventoryKey(row.worktreeID, row.worktreePath, row.worktreeBranch)] = true diff --git a/docs/plans/task-worktree-cutover-hybrid-schema/plan.md b/docs/plans/task-worktree-cutover-hybrid-schema/plan.md new file mode 100644 index 0000000000..31bf8b7991 --- /dev/null +++ b/docs/plans/task-worktree-cutover-hybrid-schema/plan.md @@ -0,0 +1,86 @@ +--- +spec: docs/specs/session-delete-resource-cleanup/spec.md +created: 2026-08-11 +status: implemented +--- + +# Fix Plan: Recover Hybrid Worktree Cutover Schemas + +## Overview + +Repair startup for databases where `task_environments` already has its final +normalized shape while the legacy `task_session_worktrees` table still exists. +The cutover currently uses that table as its only legacy sentinel and then +unconditionally selects removed flat environment columns. The repair makes the +legacy environment read reflect the columns actually present while preserving +the existing transactional normalization, validation, and fail-closed behavior. + +Confirmed root cause: `normalizeTaskWorktreeOwnership` starts whenever +`task_session_worktrees` exists, but `loadLegacyEnvs` always selects +`repository_id`, `worktree_id`, `worktree_path`, and `worktree_branch` from +`task_environments`. Intermediate or alternate branches can leave only the +sentinel table behind, causing startup to fail before normalization. + +## Backend + +### Hybrid-schema compatibility + +- Update `apps/backend/internal/task/repository/sqlite/worktree_ownership_migration.go` + and `worktree_ownership_normalize.go` to determine which deprecated flat + `task_environments` columns exist inside the cutover transaction. +- Project typed empty SQL values for missing flat columns so the existing + normalization pipeline can still consume canonical `task_environment_repos` + and legacy `task_session_worktrees` rows. +- Keep the legacy-table sentinel, shadow-table swap, inventory validation, + PostgreSQL locking, SQLite transaction, and rollback behavior unchanged. + +## Tests + +- **What:** The repository initializer upgrades a hybrid SQLite schema with a + final `task_environments` table and stale `task_session_worktrees` table. + **File:** `apps/backend/internal/task/repository/sqlite/worktree_ownership_migration_test.go`. + **How:** Initialize a fresh database, recreate only the legacy session table, + seed a session-worktree row backed by normalized environment/repository data, + reopen through `NewWithDB`, and assert startup succeeds, inventory survives, + and the legacy table is removed. The test must first fail with the reported + `no such column: repository_id` error. +- **What:** Fully legacy, final, rollback, and conflict fixtures remain valid. + **File:** same migration test file. + **How:** Run all `TestCutover` tests and the complete repository package. +- **What:** PostgreSQL uses the same hybrid-schema recovery behavior through + its `information_schema` column probes. + **File:** `apps/backend/internal/task/repository/sqlite/postgres_schema_test.go`. + **How:** Run the environment-gated PostgreSQL counterpart locally when + `KANDEV_TEST_POSTGRES_DSN` is available and in the Backend Postgres CI job. + +## Implementation Waves And Parallel Candidates + +Wave 1, sequential: + +- [x] [task-01-recover-hybrid-schema](task-01-recover-hybrid-schema.md) + +The migration and regression fixture share schema state and are not +parallel-safe. + +## Out Of Scope + +- Repairing arbitrary missing required columns outside the four deprecated flat + worktree fields. +- Changing post-cutover task, session, worktree, cleanup, filesystem, or Git + behavior. +- Adding a permanent dual-read or dual-write compatibility path. + +## Verification Results + +- RED: `go test ./internal/task/repository/sqlite -run + TestCutover_HybridNormalizedEnvironmentWithLegacySessionWorktrees -count=1 -v` + failed with `cutover: read legacy environments: no such column: repository_id`. +- GREEN: the same focused command passed after the repair. +- `go test ./internal/task/repository/sqlite -run TestCutover -count=1` passed. +- `go test ./internal/task/repository/sqlite -count=1` passed. +- `go test ./internal/task/repository/sqlite -run + TestPostgresCutoverHybridNormalizedEnvironmentWithLegacySessionWorktrees + -count=1 -v` compiled and skipped locally because + `KANDEV_TEST_POSTGRES_DSN` is unset; the Backend Postgres CI job owns the live + dialect execution. +- `git diff --check` passed. diff --git a/docs/plans/task-worktree-cutover-hybrid-schema/task-01-recover-hybrid-schema.md b/docs/plans/task-worktree-cutover-hybrid-schema/task-01-recover-hybrid-schema.md new file mode 100644 index 0000000000..a600bde2bf --- /dev/null +++ b/docs/plans/task-worktree-cutover-hybrid-schema/task-01-recover-hybrid-schema.md @@ -0,0 +1,88 @@ +--- +id: "01-recover-hybrid-schema" +title: "Recover hybrid cutover schemas" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/session-delete-resource-cleanup/spec.md" +--- + +# Task 01: Recover Hybrid Cutover Schemas + +## Acceptance + +- A hybrid database with normalized `task_environments` and a stale + `task_session_worktrees` table starts successfully without manual edits. +- Canonical environment/repository data and remaining session-worktree inventory + survive normalization, and the stale legacy table is removed. +- Fully legacy conflicts still fail closed and all existing cutover fixtures + retain their behavior. + +## Verification + +```bash +cd apps/backend && go test ./internal/task/repository/sqlite -run TestCutover_HybridNormalizedEnvironmentWithLegacySessionWorktrees -count=1 -v +go test ./internal/task/repository/sqlite -run TestPostgresCutoverHybridNormalizedEnvironmentWithLegacySessionWorktrees -count=1 -v +go test ./internal/task/repository/sqlite -run TestCutover -count=1 +go test ./internal/task/repository/sqlite -count=1 +``` + +## Files Likely Touched + +- `apps/backend/internal/task/repository/sqlite/worktree_ownership_migration.go` +- `apps/backend/internal/task/repository/sqlite/worktree_ownership_normalize.go` +- `apps/backend/internal/task/repository/sqlite/worktree_ownership_migration_test.go` +- `apps/backend/internal/task/repository/sqlite/postgres_schema_test.go` +- `docs/specs/session-delete-resource-cleanup/spec.md` +- `docs/plans/task-worktree-cutover-hybrid-schema/plan.md` +- `docs/plans/task-worktree-cutover-hybrid-schema/task-01-recover-hybrid-schema.md` + +## Dependencies + +None. + +## Parallelism + +Sequential. Production and test changes share the cutover schema and migration +contract. + +## Inputs + +- Confirmed `main` reproduction through `NewWithDB` with the exact error + `cutover: read legacy environments: no such column: repository_id`. +- Existing `openLegacyDB`, schema-shape assertions, inventory checks, and + cutover rollback tests in `worktree_ownership_migration_test.go`. +- The transactional cutover contract in the linked spec. + +## Output Contract + +Report the red failure, green test results, files changed, compatibility scope, +remaining risks, synchronized task/plan status, commit, and PR URL. + +## Results + +- Added a real-initializer hybrid-schema regression that preserves canonical + normalized ownership and removes the stale legacy table. +- The cutover probes the four deprecated flat environment columns inside its + transaction and substitutes typed empty values only for columns already + absent. Required columns and downstream inventory validation are unchanged. +- RED: the focused hybrid test failed with the reported missing-column error. +- GREEN: the focused hybrid test, all cutover tests, and the complete SQLite + repository package passed. +- The PostgreSQL counterpart compiled and used the standard environment gate; + it skipped locally because `KANDEV_TEST_POSTGRES_DSN` is unset. The PR's + Backend Postgres job executes the live-dialect test. +- `git diff --check` passed. +- External side effects: none. Tests used temporary SQLite databases only. +- Files changed: the cutover migration/normalizer, SQLite and PostgreSQL + migration tests, the worktree cleanup spec, and this plan/task package. +- Compatibility scope: only hybrid schemas missing one or more of the four + deprecated flat `task_environments` worktree columns are relaxed. Required + columns, conflict detection, inventory validation, and rollback remain strict. +- Remaining risk: unrecognized intermediate schemas missing required columns + still fail closed by design; local PostgreSQL execution requires the external + test DSN and is therefore delegated to the existing CI job. +- Task and plan status are synchronized as `done` and `implemented`. +- Initial implementation commit: `2d3384b01789d8aff735151249f66defd3db5863`. +- Pull request: https://github.com/kdlbs/kandev/pull/2531 diff --git a/docs/specs/session-delete-resource-cleanup/spec.md b/docs/specs/session-delete-resource-cleanup/spec.md index eacf0c9550..b565ce8c1f 100644 --- a/docs/specs/session-delete-resource-cleanup/spec.md +++ b/docs/specs/session-delete-resource-cleanup/spec.md @@ -316,6 +316,11 @@ remain the authorization boundary for physical cleanup. the surviving flat environment's repository slot, **WHEN** the new binary starts, **THEN** the flat environment remains the owner and the cutover completes. +- **GIVEN** `task_environments` already has the normalized schema but a stale + `task_session_worktrees` table remains from an intermediate build, **WHEN** + the new binary starts, **THEN** the cutover preserves normalized environment + and repository data, imports any remaining session-worktree inventory, and + removes the stale legacy table without requiring manual database edits. - **GIVEN** a legacy session of one task is bound to another task's environment and carries a session-worktree row for that shared workspace, **WHEN** the new binary starts, **THEN** the worktree is normalized onto the owning task's