fix(task): elect legacy worktree ownership (#2535)
Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
"go.uber.org/zap/zaptest/observer"
|
||||
|
||||
"github.com/kandev/kandev/internal/common/logger"
|
||||
dbutil "github.com/kandev/kandev/internal/db"
|
||||
)
|
||||
|
||||
// TestCutover_ElectsNewestWorktreeForContestedSlot proves that two live
|
||||
// sessions of the same task holding different worktrees for one repository
|
||||
// slot no longer abort the upgrade: the newest worktree owns the slot and the
|
||||
// older one is demoted to history.
|
||||
func TestCutover_ElectsNewestWorktreeForContestedSlot(t *testing.T) {
|
||||
db := openLegacyDB(t)
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
older := now.Add(-2 * time.Hour)
|
||||
if _, err := db.Exec(db.Rebind(`
|
||||
INSERT INTO tasks (id, workspace_id, title, created_at, updated_at)
|
||||
VALUES ('task-conflict', 'ws-1', 'legacy', ?, ?)`), older, now); err != nil {
|
||||
t.Fatalf("seed task: %v", err)
|
||||
}
|
||||
for _, s := range []string{"sess-c1", "sess-c2"} {
|
||||
if _, err := db.Exec(db.Rebind(`
|
||||
INSERT INTO task_sessions (id, task_id, state, started_at, updated_at)
|
||||
VALUES (?, 'task-conflict', 'RUNNING', ?, ?)`), s, older, now); err != nil {
|
||||
t.Fatalf("seed session: %v", err)
|
||||
}
|
||||
}
|
||||
seedLegacySessionWorktree(t, db, "sess-c1", "wt-c1", "repo-c", "", "/t/c1", "feature/c1", "active", older)
|
||||
seedLegacySessionWorktree(t, db, "sess-c2", "wt-c2", "repo-c", "", "/t/c2", "feature/c2", "active", now)
|
||||
|
||||
repo, err := NewWithDB(db, db, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("cutover: %v", err)
|
||||
}
|
||||
env, err := repo.GetTaskEnvironmentByTaskID(context.Background(), "task-conflict")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTaskEnvironmentByTaskID: %v", err)
|
||||
}
|
||||
if env == nil {
|
||||
t.Fatal("contested task must keep an environment")
|
||||
}
|
||||
if len(env.Repos) != 1 || env.Repos[0].WorktreeID != "wt-c2" {
|
||||
t.Fatalf("elected repository rows = %+v, want one wt-c2 row", env.Repos)
|
||||
}
|
||||
if legacyTableExists(t, db, "task_session_worktrees") {
|
||||
t.Fatal("legacy table must be dropped once the cutover completes")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCutover_FlatEnvironmentOutranksLiveSessionWorktree reproduces the
|
||||
// upgrade failure from issue #2505: a database with no canonical repository
|
||||
// row where a still-live session holds an older worktree for the slot the
|
||||
// surviving flat environment owns. The flat environment keeps the slot.
|
||||
func TestCutover_FlatEnvironmentOutranksLiveSessionWorktree(t *testing.T) {
|
||||
db := openLegacyDB(t)
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
seed := legacySeed{envID: "env-live", taskID: "task-live", repoID: "repo-live", sessionID: "sess-live"}
|
||||
seedLegacyTask(t, db, seed, now.Add(-3*time.Hour))
|
||||
if _, err := db.Exec(db.Rebind(`UPDATE task_sessions SET state = 'IDLE' WHERE id = ?`), seed.sessionID); err != nil {
|
||||
t.Fatalf("set session state: %v", err)
|
||||
}
|
||||
seedLegacyFlatEnv(t, db, seed, "wt-current", "/tasks/live/repo", "feature/current", now)
|
||||
seedLegacySessionWorktree(t, db, seed.sessionID, "wt-old", "repo-live", "", "/tasks/live/old", "feature/old", "active", now.Add(-3*time.Hour))
|
||||
|
||||
repo, err := NewWithDB(db, db, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("cutover: %v", err)
|
||||
}
|
||||
env, err := repo.GetTaskEnvironment(context.Background(), seed.envID)
|
||||
if err != nil {
|
||||
t.Fatalf("get task environment: %v", err)
|
||||
}
|
||||
if len(env.Repos) != 1 || env.Repos[0].WorktreeID != "wt-current" {
|
||||
t.Fatalf("repository rows = %+v, want one wt-current row", env.Repos)
|
||||
}
|
||||
session, err := repo.GetTaskSession(context.Background(), seed.sessionID)
|
||||
if err != nil {
|
||||
t.Fatalf("get task session: %v", err)
|
||||
}
|
||||
if session.TaskEnvironmentID != seed.envID {
|
||||
t.Fatalf("session environment = %q, want %q", session.TaskEnvironmentID, seed.envID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCutover_LiveSessionOutranksTerminalSessionForSlot proves the election
|
||||
// prefers a live session's worktree over a terminal session's newer one when
|
||||
// no canonical owner exists for the slot.
|
||||
func TestCutover_LiveSessionOutranksTerminalSessionForSlot(t *testing.T) {
|
||||
db := openLegacyDB(t)
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
if _, err := db.Exec(db.Rebind(`
|
||||
INSERT INTO tasks (id, workspace_id, title, created_at, updated_at)
|
||||
VALUES ('task-mixed', 'ws-1', 'legacy', ?, ?)`), now, now); err != nil {
|
||||
t.Fatalf("seed task: %v", err)
|
||||
}
|
||||
for _, session := range []struct{ id, state string }{{"sess-live", "WAITING_FOR_INPUT"}, {"sess-done", "COMPLETED"}} {
|
||||
if _, err := db.Exec(db.Rebind(`
|
||||
INSERT INTO task_sessions (id, task_id, state, started_at, updated_at)
|
||||
VALUES (?, 'task-mixed', ?, ?, ?)`), session.id, session.state, now, now); err != nil {
|
||||
t.Fatalf("seed session: %v", err)
|
||||
}
|
||||
}
|
||||
seedLegacySessionWorktree(t, db, "sess-live", "wt-live", "repo-mixed", "", "/t/live", "feature/live", "active", now.Add(-time.Hour))
|
||||
seedLegacySessionWorktree(t, db, "sess-done", "wt-done", "repo-mixed", "", "/t/done", "feature/done", "active", now)
|
||||
|
||||
repo, err := NewWithDB(db, db, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("cutover: %v", err)
|
||||
}
|
||||
env, err := repo.GetTaskEnvironmentByTaskID(context.Background(), "task-mixed")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTaskEnvironmentByTaskID: %v", err)
|
||||
}
|
||||
if env == nil || len(env.Repos) != 1 || env.Repos[0].WorktreeID != "wt-live" {
|
||||
t.Fatalf("elected repository rows = %+v, want one wt-live row", env)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCutover_IrreconcilableMetadataFailsClosed proves the cutover still
|
||||
// refuses to guess when two live sessions disagree about the path of the same
|
||||
// physical worktree and no canonical owner can settle it.
|
||||
func TestCutover_IrreconcilableMetadataFailsClosed(t *testing.T) {
|
||||
db := openLegacyDB(t)
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
if _, err := db.Exec(db.Rebind(`
|
||||
INSERT INTO tasks (id, workspace_id, title, created_at, updated_at)
|
||||
VALUES ('task-metadata', 'ws-1', 'legacy', ?, ?)`), now, now); err != nil {
|
||||
t.Fatalf("seed task: %v", err)
|
||||
}
|
||||
for _, s := range []string{"sess-m1", "sess-m2"} {
|
||||
if _, err := db.Exec(db.Rebind(`
|
||||
INSERT INTO task_sessions (id, task_id, state, started_at, updated_at)
|
||||
VALUES (?, 'task-metadata', 'RUNNING', ?, ?)`), s, now, now); err != nil {
|
||||
t.Fatalf("seed session: %v", err)
|
||||
}
|
||||
}
|
||||
seedLegacySessionWorktree(t, db, "sess-m1", "wt-m", "repo-m", "", "/t/one", "feature/m", "active", now)
|
||||
seedLegacySessionWorktree(t, db, "sess-m2", "wt-m", "repo-m", "", "/t/two", "feature/m", "active", now)
|
||||
|
||||
if _, err := NewWithDB(db, db, nil); err == nil {
|
||||
t.Fatal("expected cutover to fail on irreconcilable worktree metadata")
|
||||
} else if !strings.Contains(err.Error(), "conflict") {
|
||||
t.Fatalf("error must describe the conflict, got: %v", err)
|
||||
}
|
||||
// Pre-upgrade state must be intact.
|
||||
if !legacyTableExists(t, db, "task_session_worktrees") {
|
||||
t.Fatal("legacy table must survive a failed cutover")
|
||||
}
|
||||
envCols := tableColumnSet(t, db, "task_environments")
|
||||
if !envCols["worktree_id"] || !envCols["repository_id"] {
|
||||
t.Fatal("legacy flat columns must survive a failed cutover")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCutoverElection_RecordsDemotionDiagnostics proves every demoted legacy
|
||||
// worktree is recorded with the directory and branch support needs to find it
|
||||
// on disk, since the migration itself never touches the filesystem.
|
||||
func TestCutoverElection_RecordsDemotionDiagnostics(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
cut := &worktreeCutover{
|
||||
envs: map[string]*legacyEnv{},
|
||||
sessions: map[string]*legacySession{},
|
||||
sessionTasks: map[string]string{},
|
||||
sessionEnvIDs: map[string]string{},
|
||||
sessionWorktreeSuperseded: map[string]bool{},
|
||||
demotedWorktrees: map[string]bool{},
|
||||
authoritativeWorktreeIDs: map[string]bool{},
|
||||
tasks: map[string]*taskWorktreeTargets{},
|
||||
taskEnvIDs: map[string]string{},
|
||||
loserEnvIDs: map[string]bool{},
|
||||
executorTypes: map[string]string{},
|
||||
}
|
||||
for _, id := range []string{"sess-old", "sess-new"} {
|
||||
cut.sessions[id] = &legacySession{id: id, taskID: "task-1", state: "RUNNING"}
|
||||
cut.sessionTasks[id] = "task-1"
|
||||
}
|
||||
cut.sessionWts = []legacySessionWorktree{
|
||||
{sessionID: "sess-old", worktreeID: "wt-old", repositoryID: "repo-1",
|
||||
worktreePath: "/tasks/old", worktreeBranch: "feature/old",
|
||||
createdAt: now.Add(-time.Hour), updatedAt: now.Add(-time.Hour), status: "active"},
|
||||
{sessionID: "sess-new", worktreeID: "wt-new", repositoryID: "repo-1",
|
||||
worktreePath: "/tasks/new", worktreeBranch: "feature/new",
|
||||
createdAt: now, updatedAt: now, status: "active"},
|
||||
}
|
||||
|
||||
cut.electSlotWorktreeOwners()
|
||||
|
||||
if !cut.demotedWorktrees[sessionWorktreeKey("sess-old", "wt-old")] {
|
||||
t.Fatal("older worktree must be demoted")
|
||||
}
|
||||
if cut.demotedWorktrees[sessionWorktreeKey("sess-new", "wt-new")] {
|
||||
t.Fatal("elected worktree must not be demoted")
|
||||
}
|
||||
if len(cut.demotions) != 1 {
|
||||
t.Fatalf("demotions = %v, want exactly one entry", cut.demotions)
|
||||
}
|
||||
for _, want := range []string{"sess-old", "wt-old", "/tasks/old", "feature/old", "wt-new"} {
|
||||
if !strings.Contains(cut.demotions[0], want) {
|
||||
t.Fatalf("demotion %q must mention %q", cut.demotions[0], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCutoverElection_DoesNotLogRolledBackDemotions proves operators only see
|
||||
// demotion diagnostics after the ownership transaction commits successfully.
|
||||
func TestCutoverElection_DoesNotLogRolledBackDemotions(t *testing.T) {
|
||||
db := openLegacyDB(t)
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO tasks (id, workspace_id, title, created_at, updated_at)
|
||||
VALUES ('task-log', 'ws-1', 'legacy', ?, ?)`, now, now); err != nil {
|
||||
t.Fatalf("seed task: %v", err)
|
||||
}
|
||||
for _, sessionID := range []string{"sess-log-old", "sess-log-new"} {
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO task_sessions (id, task_id, state, started_at, updated_at)
|
||||
VALUES (?, 'task-log', 'RUNNING', ?, ?)`, sessionID, now, now); err != nil {
|
||||
t.Fatalf("seed session: %v", err)
|
||||
}
|
||||
}
|
||||
seedLegacySessionWorktree(t, db, "sess-log-old", "wt-log-old", "repo-log", "", "/tasks/old", "feature/old", "active", now.Add(-time.Hour))
|
||||
seedLegacySessionWorktree(t, db, "sess-log-new", "wt-log-new", "repo-log", "", "/tasks/new", "feature/new", "active", now)
|
||||
|
||||
core, logs := observer.New(zapcore.WarnLevel)
|
||||
log, err := logger.NewFromZap(zap.New(core))
|
||||
if err != nil {
|
||||
t.Fatalf("create observer logger: %v", err)
|
||||
}
|
||||
repo := &Repository{
|
||||
db: db, ro: db, log: log, migrate: dbutil.NewMigrateLogger(db, log),
|
||||
failCutoverAfter: "pre_swap",
|
||||
}
|
||||
if err := repo.initSchema(); err == nil {
|
||||
t.Fatal("expected injected cutover failure")
|
||||
}
|
||||
message := "cutover: duplicate legacy worktrees demoted to history"
|
||||
if entries := logs.FilterMessage(message).All(); len(entries) != 0 {
|
||||
t.Fatalf("rolled-back demotion logs = %d, want none", len(entries))
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/kandev/kandev/internal/db/dialect"
|
||||
)
|
||||
@@ -117,6 +118,7 @@ func (r *Repository) normalizeTaskWorktreeOwnership() error {
|
||||
sessionTasks: make(map[string]string),
|
||||
sessionEnvIDs: make(map[string]string),
|
||||
sessionWorktreeSuperseded: make(map[string]bool),
|
||||
demotedWorktrees: make(map[string]bool),
|
||||
authoritativeWorktreeIDs: make(map[string]bool),
|
||||
tasks: make(map[string]*taskWorktreeTargets),
|
||||
taskEnvIDs: make(map[string]string),
|
||||
@@ -150,9 +152,22 @@ func (r *Repository) normalizeTaskWorktreeOwnership() error {
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("cutover: commit: %w", err)
|
||||
}
|
||||
r.logCutoverDemotions(cut)
|
||||
return 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.
|
||||
func (r *Repository) logCutoverDemotions(c *worktreeCutover) {
|
||||
if len(c.demotions) == 0 || r.log == nil {
|
||||
return
|
||||
}
|
||||
r.log.Warn("cutover: duplicate legacy worktrees demoted to history",
|
||||
zap.Int("count", len(c.demotions)),
|
||||
zap.Strings("worktrees", c.demotions))
|
||||
}
|
||||
|
||||
// cutoverAcquireLocks serializes the cutover for the active database engine:
|
||||
// PostgreSQL takes a migration advisory lock plus exclusive locks on every
|
||||
// affected ownership table (aborting on lock timeout); SQLite relies on the
|
||||
|
||||
@@ -3,14 +3,12 @@ package sqlite
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
dbutil "github.com/kandev/kandev/internal/db"
|
||||
"github.com/kandev/kandev/internal/testutil"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -456,44 +454,6 @@ func TestCutover_PurgesPreviewCleanupJobs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCutover_ConflictingWorktreesFailClosed proves that two sessions of the
|
||||
// same task with different worktrees for the same repository slot abort the
|
||||
// cutover and leave the legacy database intact.
|
||||
func TestCutover_ConflictingWorktreesFailClosed(t *testing.T) {
|
||||
db := openLegacyDB(t)
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO tasks (id, workspace_id, title, created_at, updated_at)
|
||||
VALUES ('task-conflict', 'ws-1', 'legacy', ?, ?)`, now, now); err != nil {
|
||||
t.Fatalf("seed task: %v", err)
|
||||
}
|
||||
for _, s := range []string{"sess-c1", "sess-c2"} {
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO task_sessions (id, task_id, state, started_at, updated_at)
|
||||
VALUES (?, 'task-conflict', 'RUNNING', ?, ?)`, s, now, now); err != nil {
|
||||
t.Fatalf("seed session: %v", err)
|
||||
}
|
||||
}
|
||||
seedLegacySessionWorktree(t, db, "sess-c1", "wt-c1", "repo-c", "", "/t/c1", "feature/c1", "active", now)
|
||||
seedLegacySessionWorktree(t, db, "sess-c2", "wt-c2", "repo-c", "", "/t/c2", "feature/c2", "active", now)
|
||||
|
||||
_, err := NewWithDB(db, db, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected cutover to fail on conflicting worktree identities")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "conflict") {
|
||||
t.Fatalf("error must describe the conflict, got: %v", err)
|
||||
}
|
||||
// Pre-upgrade state must be intact.
|
||||
if !legacyTableExists(t, db, "task_session_worktrees") {
|
||||
t.Fatal("legacy table must survive a failed cutover")
|
||||
}
|
||||
envCols := tableColumnSet(t, db, "task_environments")
|
||||
if !envCols["worktree_id"] || !envCols["repository_id"] {
|
||||
t.Fatal("legacy flat columns must survive a failed cutover")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCutover_RollbackAtEveryFailpoint injects a failure at each cutover
|
||||
// step and proves the transaction restores the complete legacy schema and
|
||||
// data.
|
||||
@@ -703,35 +663,6 @@ func TestCutover_PreservesTerminalWorktreeOutsideFlatOwnerSlot(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestCutover_RejectsLiveWorktreeDifferentFromFlatOwner proves that a
|
||||
// non-terminal session with a different physical identity still fails closed.
|
||||
func TestCutover_RejectsLiveWorktreeDifferentFromFlatOwner(t *testing.T) {
|
||||
for _, state := range []string{"RUNNING", "WAITING_FOR_INPUT"} {
|
||||
t.Run(state, func(t *testing.T) {
|
||||
db := openLegacyDB(t)
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
seed := legacySeed{envID: "env-flat-live", taskID: "task-flat-live", repoID: "repo-flat-live", sessionID: "sess-flat-live"}
|
||||
seedLegacyTask(t, db, seed, now)
|
||||
if _, err := db.Exec(db.Rebind(`UPDATE task_sessions SET state = ? WHERE id = ?`), state, seed.sessionID); err != nil {
|
||||
t.Fatalf("set session state: %v", err)
|
||||
}
|
||||
seedLegacyFlatEnv(t, db, seed, "wt-flat-current", "/tasks/flat-live/repo", "feature/current", now)
|
||||
seedLegacySessionWorktree(t, db, seed.sessionID, "wt-flat-live", seed.repoID, "", "/tasks/flat-live/old", "feature/live", "active", now)
|
||||
|
||||
_, err := NewWithDB(db, db, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected live worktree conflict")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "conflict") {
|
||||
t.Fatalf("error must describe the conflict, got: %v", err)
|
||||
}
|
||||
if !legacyTableExists(t, db, "task_session_worktrees") {
|
||||
t.Fatal("legacy table must survive a failed cutover")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCutover_IgnoresDeletedHistoricalSessionConflict proves that a deleted
|
||||
// session reference cannot override an existing task-owned repository row.
|
||||
func TestCutover_IgnoresDeletedHistoricalSessionConflict(t *testing.T) {
|
||||
@@ -952,212 +883,3 @@ func linkSessionEnvironment(t *testing.T, db *sqlx.DB, sessionID, envID string)
|
||||
t.Fatalf("link session environment: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PostgreSQL coverage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func openLegacyPostgres(t *testing.T) *sqlx.DB {
|
||||
t.Helper()
|
||||
db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t))
|
||||
if _, err := NewWithDB(db, db, nil); err != nil {
|
||||
t.Fatalf("seed baseline postgres schema: %v", err)
|
||||
}
|
||||
rewindToLegacySchema(t, db)
|
||||
return db
|
||||
}
|
||||
|
||||
func TestCutoverPostgres_NormalizesLegacyFlatEnvironment(t *testing.T) {
|
||||
db := openLegacyPostgres(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
seed := legacySeed{envID: "env-pg1", taskID: "task-pg1", repoID: "repo-pg1", sessionID: "sess-pg1"}
|
||||
seedLegacyTask(t, db, seed, now)
|
||||
seedLegacySessionWorktree(t, db, "sess-pg1", "wt-pg1", "repo-pg1", "", "/tasks/pg1/kandev", "feature/pg", "active", now)
|
||||
seedLegacyFlatEnv(t, db, seed, "wt-pg1", "/tasks/pg1/kandev", "feature/pg", now)
|
||||
seedCleanupJob(t, db, "job-pg-session-delete", "task-pg1", "session_delete", "pending", now)
|
||||
seedCleanupJob(t, db, "job-pg-archive", "task-pg1", "archive", "pending", now)
|
||||
|
||||
repo, err := NewWithDB(db, db, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("postgres cutover: %v", err)
|
||||
}
|
||||
env, err := repo.GetTaskEnvironment(ctx, "env-pg1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTaskEnvironment: %v", err)
|
||||
}
|
||||
if len(env.Repos) != 1 || env.Repos[0].WorktreeID != "wt-pg1" || env.Repos[0].Status != "active" {
|
||||
t.Fatalf("normalized repos = %+v", env.Repos)
|
||||
}
|
||||
session, err := repo.GetTaskSession(ctx, "sess-pg1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTaskSession: %v", err)
|
||||
}
|
||||
if session.TaskEnvironmentID != "env-pg1" {
|
||||
t.Fatalf("session env = %q, want env-pg1", session.TaskEnvironmentID)
|
||||
}
|
||||
var tableCount int
|
||||
if err := db.Get(&tableCount, `
|
||||
SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_name = 'task_session_worktrees' AND table_schema = current_schema()`); err != nil {
|
||||
t.Fatalf("count legacy table: %v", err)
|
||||
}
|
||||
if tableCount != 0 {
|
||||
t.Fatal("task_session_worktrees must be dropped on postgres")
|
||||
}
|
||||
var jobCount int
|
||||
if err := db.Get(&jobCount, `SELECT COUNT(*) FROM task_resource_cleanup_jobs`); err != nil {
|
||||
t.Fatalf("count jobs: %v", err)
|
||||
}
|
||||
if jobCount != 1 {
|
||||
t.Fatalf("postgres cleanup jobs after cutover = %d, want 1", jobCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCutoverPostgres_SessionOnlyNormalization(t *testing.T) {
|
||||
db := openLegacyPostgres(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
seed := legacySeed{taskID: "task-pg2", sessionID: "sess-pg2"}
|
||||
seedLegacyTask(t, db, seed, now)
|
||||
seedLegacySessionWorktree(t, db, "sess-pg2", "wt-pg2", "repo-pg2", "", "/tasks/pg2/kandev", "feature/pg2", "active", now)
|
||||
|
||||
repo, err := NewWithDB(db, db, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("postgres cutover: %v", err)
|
||||
}
|
||||
env, err := repo.GetTaskEnvironmentByTaskID(ctx, "task-pg2")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTaskEnvironmentByTaskID: %v", err)
|
||||
}
|
||||
if env == nil || len(env.Repos) != 1 || env.Repos[0].WorktreeID != "wt-pg2" {
|
||||
t.Fatalf("normalized env = %+v", env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCutoverPostgres_ConflictingWorktreesFailClosed(t *testing.T) {
|
||||
db := openLegacyPostgres(t)
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
if _, err := db.Exec(db.Rebind(`
|
||||
INSERT INTO tasks (id, workspace_id, title, created_at, updated_at)
|
||||
VALUES (?, 'ws-1', 'legacy', ?, ?)`), "task-pg-conflict", now, now); err != nil {
|
||||
t.Fatalf("seed task: %v", err)
|
||||
}
|
||||
for _, s := range []string{"sess-pgc1", "sess-pgc2"} {
|
||||
if _, err := db.Exec(db.Rebind(`
|
||||
INSERT INTO task_sessions (id, task_id, state, started_at, updated_at)
|
||||
VALUES (?, 'task-pg-conflict', 'COMPLETED', ?, ?)`), s, now, now); err != nil {
|
||||
t.Fatalf("seed session: %v", err)
|
||||
}
|
||||
}
|
||||
seedLegacySessionWorktree(t, db, "sess-pgc1", "wt-pgc1", "repo-pgc", "", "/t/pgc1", "feature/a", "active", now)
|
||||
seedLegacySessionWorktree(t, db, "sess-pgc2", "wt-pgc2", "repo-pgc", "", "/t/pgc2", "feature/b", "active", now)
|
||||
|
||||
_, err := NewWithDB(db, db, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected postgres cutover to fail on conflicting worktrees")
|
||||
}
|
||||
var tableCount int
|
||||
if err := db.Get(&tableCount, `
|
||||
SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_name = 'task_session_worktrees' AND table_schema = current_schema()`); err != nil {
|
||||
t.Fatalf("count legacy table: %v", err)
|
||||
}
|
||||
if tableCount != 1 {
|
||||
t.Fatal("failed postgres cutover must leave the legacy table intact")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCutoverPostgres_ReplayIsNoOp proves a second boot skips the cutover.
|
||||
func TestCutoverPostgres_ReplayIsNoOp(t *testing.T) {
|
||||
db := openLegacyPostgres(t)
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
seed := legacySeed{envID: "env-pg3", taskID: "task-pg3", repoID: "repo-pg3", sessionID: "sess-pg3"}
|
||||
seedLegacyTask(t, db, seed, now)
|
||||
seedLegacySessionWorktree(t, db, "sess-pg3", "wt-pg3", "repo-pg3", "", "/tasks/pg3/kandev", "feature/pg3", "active", now)
|
||||
seedLegacyFlatEnv(t, db, seed, "wt-pg3", "/tasks/pg3/kandev", "feature/pg3", now)
|
||||
|
||||
if _, err := NewWithDB(db, db, nil); err != nil {
|
||||
t.Fatalf("first boot: %v", err)
|
||||
}
|
||||
if _, err := NewWithDB(db, db, nil); err != nil {
|
||||
t.Fatalf("second boot (replay): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCutoverPostgres_RollbackAtEveryFailpoint mirrors the SQLite failpoint
|
||||
// matrix on PostgreSQL.
|
||||
func TestCutoverPostgres_RollbackAtEveryFailpoint(t *testing.T) {
|
||||
for _, step := range []string{
|
||||
"create_shadow", "copy_envs", "backfill_repos", "link_sessions",
|
||||
"validate", "pre_swap", "drop_legacy", "swap", "post_swap",
|
||||
} {
|
||||
t.Run(step, func(t *testing.T) {
|
||||
db := openLegacyPostgres(t)
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
seed := legacySeed{envID: "env-pgr", taskID: "task-pgr", repoID: "repo-pgr", sessionID: "sess-pgr"}
|
||||
seedLegacyTask(t, db, seed, now)
|
||||
seedLegacySessionWorktree(t, db, "sess-pgr", "wt-pgr", "repo-pgr", "", "/tasks/pgr/kandev", "feature/pgr", "active", now)
|
||||
seedLegacyFlatEnv(t, db, seed, "wt-pgr", "/tasks/pgr/kandev", "feature/pgr", now)
|
||||
|
||||
repo := &Repository{db: db, ro: db, migrate: dbutil.NewMigrateLogger(db, nil), failCutoverAfter: step}
|
||||
err := repo.initSchema()
|
||||
if err == nil {
|
||||
t.Fatalf("expected injected failure at %s", step)
|
||||
}
|
||||
var tableCount int
|
||||
if err := db.Get(&tableCount, `
|
||||
SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_name = 'task_session_worktrees' AND table_schema = current_schema()`); err != nil {
|
||||
t.Fatalf("count legacy table: %v", err)
|
||||
}
|
||||
if tableCount != 1 {
|
||||
t.Fatalf("legacy table must survive rollback at %s", step)
|
||||
}
|
||||
var path string
|
||||
if err := db.Get(&path, db.Rebind(`
|
||||
SELECT worktree_path FROM task_session_worktrees WHERE worktree_id = ?`), "wt-pgr"); err != nil {
|
||||
t.Fatalf("legacy worktree row lost after rollback at %s: %v", step, err)
|
||||
}
|
||||
if path != "/tasks/pgr/kandev" {
|
||||
t.Fatalf("legacy worktree data changed after rollback at %s", step)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCutoverPostgres_FreshSchemaIsFinal proves a fresh PostgreSQL database
|
||||
// contains only the final schema.
|
||||
func TestCutoverPostgres_FreshSchemaIsFinal(t *testing.T) {
|
||||
db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t))
|
||||
if _, err := NewWithDB(db, db, nil); err != nil {
|
||||
t.Fatalf("init fresh postgres schema: %v", err)
|
||||
}
|
||||
var tableCount int
|
||||
if err := db.Get(&tableCount, `
|
||||
SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_name = 'task_session_worktrees' AND table_schema = current_schema()`); err != nil {
|
||||
t.Fatalf("count legacy table: %v", err)
|
||||
}
|
||||
if tableCount != 0 {
|
||||
t.Fatal("fresh postgres database must not contain task_session_worktrees")
|
||||
}
|
||||
// The environment FK must reference tasks and the repos FK must
|
||||
// reference task_environments after the swap.
|
||||
var fkCount int
|
||||
if err := db.Get(&fkCount, `
|
||||
SELECT COUNT(*) FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.referential_constraints rc
|
||||
ON rc.constraint_name = tc.constraint_name AND rc.constraint_schema = tc.constraint_schema
|
||||
JOIN information_schema.constraint_column_usage ccu
|
||||
ON ccu.constraint_name = rc.unique_constraint_name AND ccu.constraint_schema = rc.unique_constraint_schema
|
||||
WHERE tc.table_name = 'task_environment_repos'
|
||||
AND tc.constraint_type = 'FOREIGN KEY'
|
||||
AND ccu.table_name = 'task_environments'
|
||||
AND tc.table_schema = current_schema()`); err != nil {
|
||||
t.Fatalf("count env-repo FK: %v", err)
|
||||
}
|
||||
if fkCount != 1 {
|
||||
t.Fatalf("task_environment_repos FK to task_environments missing: %d", fkCount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ type worktreeCutover struct {
|
||||
sessionTasks map[string]string
|
||||
sessionEnvIDs map[string]string
|
||||
sessionWorktreeSuperseded map[string]bool
|
||||
demotedWorktrees map[string]bool
|
||||
demotions []string
|
||||
authoritativeWorktreeIDs map[string]bool
|
||||
envRepos []legacyEnvRepo
|
||||
sessionWts []legacySessionWorktree
|
||||
@@ -252,6 +254,7 @@ func (c *worktreeCutover) normalize(tx *sqlx.Tx) error {
|
||||
// worktree belongs to the environment the session resolves to, which is
|
||||
// not always an environment owned by the session's own task.
|
||||
c.linkSessions()
|
||||
c.electSlotWorktreeOwners()
|
||||
c.mergeSessionWorktrees()
|
||||
c.registerWorktreeIdentities()
|
||||
c.bindTargetsToEnvironments()
|
||||
@@ -326,6 +329,132 @@ func (c *worktreeCutover) mergeFlatEnvironmentFields() {
|
||||
}
|
||||
}
|
||||
|
||||
// worktreeSlot is one normalized repository slot — a (repository, branch
|
||||
// slug) pair of a single task — plus the legacy session rows competing to own
|
||||
// it.
|
||||
type worktreeSlot struct {
|
||||
targets *taskWorktreeTargets
|
||||
key string
|
||||
rows []legacySessionWorktree
|
||||
}
|
||||
|
||||
// electSlotWorktreeOwners resolves the one legacy shape the task-owned model
|
||||
// cannot represent: several distinct physical worktrees claiming the same
|
||||
// (repository, branch slug) slot of one task. The per-session model produced
|
||||
// these routinely — a handoff, a re-materialized workspace, or a second
|
||||
// session each minted their own worktree for the same repository — so failing
|
||||
// the upgrade would strand those databases on the previous binary.
|
||||
//
|
||||
// A canonical repository row or the surviving flat environment owns the slot
|
||||
// outright; otherwise the newest live session worktree wins. Every other row
|
||||
// is demoted to historical evidence: the migration touches no filesystem, so
|
||||
// the demoted directory and branch stay on disk, but the normalized schema
|
||||
// records exactly one owner per slot.
|
||||
func (c *worktreeCutover) electSlotWorktreeOwners() {
|
||||
slots, order := c.collectSlotContenders()
|
||||
for _, id := range order {
|
||||
slot := slots[id]
|
||||
winner := slot.currentOwner()
|
||||
if winner == "" {
|
||||
winner = c.bestSlotOwner(slot.rows)
|
||||
}
|
||||
for _, row := range slot.rows {
|
||||
if row.worktreeID == winner {
|
||||
continue
|
||||
}
|
||||
c.demoteSessionWorktree(row, winner)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// collectSlotContenders groups every legacy session worktree that still needs
|
||||
// a home by the slot it would claim, preserving row order so the election is
|
||||
// deterministic.
|
||||
func (c *worktreeCutover) collectSlotContenders() (map[string]*worktreeSlot, []string) {
|
||||
slots := make(map[string]*worktreeSlot)
|
||||
var order []string
|
||||
for _, wt := range c.sessionWts {
|
||||
if wt.worktreeID == "" || isLegacyDeletedWorktree(wt) {
|
||||
continue
|
||||
}
|
||||
taskID := c.sessionOwnerTaskID(wt.sessionID)
|
||||
targets := c.targetsForTask(taskID)
|
||||
if targets.targetForWorktree(wt.worktreeID) != nil {
|
||||
continue // a canonical source already owns this physical worktree
|
||||
}
|
||||
key := envRepoKey(wt.repositoryID, wt.branchSlug)
|
||||
id := taskID + "\x00" + key
|
||||
slot, ok := slots[id]
|
||||
if !ok {
|
||||
slot = &worktreeSlot{targets: targets, key: key}
|
||||
slots[id] = slot
|
||||
order = append(order, id)
|
||||
}
|
||||
slot.rows = append(slot.rows, wt)
|
||||
}
|
||||
return slots, order
|
||||
}
|
||||
|
||||
// currentOwner returns the physical worktree a canonical source already
|
||||
// homed on this slot, or "" when the slot is still unclaimed.
|
||||
func (s *worktreeSlot) currentOwner() string {
|
||||
target, ok := s.targets.byKey[s.key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return target.worktreeID
|
||||
}
|
||||
|
||||
// bestSlotOwner picks the worktree that keeps an otherwise unclaimed slot.
|
||||
func (c *worktreeCutover) bestSlotOwner(rows []legacySessionWorktree) string {
|
||||
best := rows[0]
|
||||
for _, row := range rows[1:] {
|
||||
if c.outranksSlotOwner(row, best) {
|
||||
best = row
|
||||
}
|
||||
}
|
||||
return best.worktreeID
|
||||
}
|
||||
|
||||
// outranksSlotOwner orders two competing rows: a live session's worktree
|
||||
// beats a terminal session's, then the most recently updated row wins, with
|
||||
// creation time and identity as stable tie-breakers.
|
||||
func (c *worktreeCutover) outranksSlotOwner(candidate, best legacySessionWorktree) bool {
|
||||
candidateHistorical := c.isHistoricalSessionRow(candidate)
|
||||
bestHistorical := c.isHistoricalSessionRow(best)
|
||||
if candidateHistorical != bestHistorical {
|
||||
return bestHistorical
|
||||
}
|
||||
if !candidate.updatedAt.Equal(best.updatedAt) {
|
||||
return candidate.updatedAt.After(best.updatedAt)
|
||||
}
|
||||
if !candidate.createdAt.Equal(best.createdAt) {
|
||||
return candidate.createdAt.After(best.createdAt)
|
||||
}
|
||||
return candidate.worktreeID > best.worktreeID
|
||||
}
|
||||
|
||||
// isHistoricalSessionRow reports whether the row's session is terminal (or
|
||||
// gone), which makes its worktree the weaker claim on a contested slot.
|
||||
func (c *worktreeCutover) isHistoricalSessionRow(wt legacySessionWorktree) bool {
|
||||
session, ok := c.sessions[wt.sessionID]
|
||||
return !ok || isLegacyHistoricalSession(session.state)
|
||||
}
|
||||
|
||||
// demoteSessionWorktree records a legacy row as historical evidence for a
|
||||
// slot another worktree owns, and keeps a human-readable line so the losing
|
||||
// directory can still be found on disk.
|
||||
func (c *worktreeCutover) demoteSessionWorktree(wt legacySessionWorktree, winner string) {
|
||||
key := sessionWorktreeKey(wt.sessionID, wt.worktreeID)
|
||||
if c.demotedWorktrees[key] {
|
||||
return
|
||||
}
|
||||
c.demotedWorktrees[key] = true
|
||||
c.demotions = append(c.demotions, fmt.Sprintf(
|
||||
"session %s worktree %s (repository %s, path %q, branch %q) superseded by worktree %s",
|
||||
wt.sessionID, wt.worktreeID, wt.repositoryID, wt.worktreePath, wt.worktreeBranch, winner))
|
||||
}
|
||||
|
||||
// mergeSessionWorktrees folds every legacy session-worktree row into the
|
||||
// targets of the task that owns the session's environment.
|
||||
func (c *worktreeCutover) mergeSessionWorktrees() {
|
||||
@@ -333,6 +462,9 @@ func (c *worktreeCutover) mergeSessionWorktrees() {
|
||||
if _, ok := c.sessions[wt.sessionID]; !ok {
|
||||
continue // already reported in load
|
||||
}
|
||||
if c.demotedWorktrees[sessionWorktreeKey(wt.sessionID, wt.worktreeID)] {
|
||||
continue // another physical worktree owns this repository slot
|
||||
}
|
||||
targets := c.targetsForTask(c.sessionOwnerTaskID(wt.sessionID))
|
||||
if err := targets.mergeSessionWorktree(wt, c.isSupersededSessionWorktree(wt)); err != nil {
|
||||
c.conflicts = append(c.conflicts, fmt.Sprintf(
|
||||
@@ -472,12 +604,15 @@ func (c *worktreeCutover) linkSessions() {
|
||||
}
|
||||
|
||||
// isSupersededSessionWorktree reports whether a legacy session row is no
|
||||
// longer an ownership source. Repository rows and the surviving flat
|
||||
// environment take precedence for the same physical identity regardless of
|
||||
// session state. Historical rows with no authoritative replacement remain
|
||||
// eligible for backfill.
|
||||
// longer an ownership source. Rows demoted by the slot election, repository
|
||||
// rows, and the surviving flat environment take precedence for the same
|
||||
// physical identity regardless of session state. Historical rows with no
|
||||
// authoritative replacement remain eligible for backfill.
|
||||
func (c *worktreeCutover) isSupersededSessionWorktree(wt legacySessionWorktree) bool {
|
||||
cacheKey := wt.sessionID + "\x00" + wt.worktreeID
|
||||
cacheKey := sessionWorktreeKey(wt.sessionID, wt.worktreeID)
|
||||
if c.demotedWorktrees[cacheKey] {
|
||||
return true
|
||||
}
|
||||
if superseded, ok := c.sessionWorktreeSuperseded[cacheKey]; ok {
|
||||
return superseded
|
||||
}
|
||||
@@ -566,6 +701,11 @@ func authoritativeWorktreeKey(taskID, worktreeID string) string {
|
||||
return taskID + "\x00" + worktreeID
|
||||
}
|
||||
|
||||
// sessionWorktreeKey identifies one legacy session-worktree row.
|
||||
func sessionWorktreeKey(sessionID, worktreeID string) string {
|
||||
return sessionID + "\x00" + worktreeID
|
||||
}
|
||||
|
||||
// registerWorktreeIdentities ensures every physical worktree is owned by
|
||||
// exactly one environment-repository key.
|
||||
func (c *worktreeCutover) registerWorktreeIdentities() {
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
dbutil "github.com/kandev/kandev/internal/db"
|
||||
"github.com/kandev/kandev/internal/testutil"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PostgreSQL coverage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func openLegacyPostgres(t *testing.T) *sqlx.DB {
|
||||
t.Helper()
|
||||
db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t))
|
||||
if _, err := NewWithDB(db, db, nil); err != nil {
|
||||
t.Fatalf("seed baseline postgres schema: %v", err)
|
||||
}
|
||||
rewindToLegacySchema(t, db)
|
||||
return db
|
||||
}
|
||||
|
||||
func TestCutoverPostgres_NormalizesLegacyFlatEnvironment(t *testing.T) {
|
||||
db := openLegacyPostgres(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
seed := legacySeed{envID: "env-pg1", taskID: "task-pg1", repoID: "repo-pg1", sessionID: "sess-pg1"}
|
||||
seedLegacyTask(t, db, seed, now)
|
||||
seedLegacySessionWorktree(t, db, "sess-pg1", "wt-pg1", "repo-pg1", "", "/tasks/pg1/kandev", "feature/pg", "active", now)
|
||||
seedLegacyFlatEnv(t, db, seed, "wt-pg1", "/tasks/pg1/kandev", "feature/pg", now)
|
||||
seedCleanupJob(t, db, "job-pg-session-delete", "task-pg1", "session_delete", "pending", now)
|
||||
seedCleanupJob(t, db, "job-pg-archive", "task-pg1", "archive", "pending", now)
|
||||
|
||||
repo, err := NewWithDB(db, db, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("postgres cutover: %v", err)
|
||||
}
|
||||
env, err := repo.GetTaskEnvironment(ctx, "env-pg1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTaskEnvironment: %v", err)
|
||||
}
|
||||
if len(env.Repos) != 1 || env.Repos[0].WorktreeID != "wt-pg1" || env.Repos[0].Status != "active" {
|
||||
t.Fatalf("normalized repos = %+v", env.Repos)
|
||||
}
|
||||
session, err := repo.GetTaskSession(ctx, "sess-pg1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTaskSession: %v", err)
|
||||
}
|
||||
if session.TaskEnvironmentID != "env-pg1" {
|
||||
t.Fatalf("session env = %q, want env-pg1", session.TaskEnvironmentID)
|
||||
}
|
||||
var tableCount int
|
||||
if err := db.Get(&tableCount, `
|
||||
SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_name = 'task_session_worktrees' AND table_schema = current_schema()`); err != nil {
|
||||
t.Fatalf("count legacy table: %v", err)
|
||||
}
|
||||
if tableCount != 0 {
|
||||
t.Fatal("task_session_worktrees must be dropped on postgres")
|
||||
}
|
||||
var jobCount int
|
||||
if err := db.Get(&jobCount, `SELECT COUNT(*) FROM task_resource_cleanup_jobs`); err != nil {
|
||||
t.Fatalf("count jobs: %v", err)
|
||||
}
|
||||
if jobCount != 1 {
|
||||
t.Fatalf("postgres cleanup jobs after cutover = %d, want 1", jobCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCutoverPostgres_SessionOnlyNormalization(t *testing.T) {
|
||||
db := openLegacyPostgres(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
seed := legacySeed{taskID: "task-pg2", sessionID: "sess-pg2"}
|
||||
seedLegacyTask(t, db, seed, now)
|
||||
seedLegacySessionWorktree(t, db, "sess-pg2", "wt-pg2", "repo-pg2", "", "/tasks/pg2/kandev", "feature/pg2", "active", now)
|
||||
|
||||
repo, err := NewWithDB(db, db, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("postgres cutover: %v", err)
|
||||
}
|
||||
env, err := repo.GetTaskEnvironmentByTaskID(ctx, "task-pg2")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTaskEnvironmentByTaskID: %v", err)
|
||||
}
|
||||
if env == nil || len(env.Repos) != 1 || env.Repos[0].WorktreeID != "wt-pg2" {
|
||||
t.Fatalf("normalized env = %+v", env)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCutoverPostgres_ElectsSingleSlotOwner mirrors the SQLite election: two
|
||||
// legacy worktrees contesting one repository slot resolve to a single owner
|
||||
// instead of aborting the upgrade.
|
||||
func TestCutoverPostgres_ElectsSingleSlotOwner(t *testing.T) {
|
||||
db := openLegacyPostgres(t)
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
older := now.Add(-2 * time.Hour)
|
||||
if _, err := db.Exec(db.Rebind(`
|
||||
INSERT INTO tasks (id, workspace_id, title, created_at, updated_at)
|
||||
VALUES (?, 'ws-1', 'legacy', ?, ?)`), "task-pg-conflict", older, now); err != nil {
|
||||
t.Fatalf("seed task: %v", err)
|
||||
}
|
||||
for _, s := range []string{"sess-pgc1", "sess-pgc2"} {
|
||||
if _, err := db.Exec(db.Rebind(`
|
||||
INSERT INTO task_sessions (id, task_id, state, started_at, updated_at)
|
||||
VALUES (?, 'task-pg-conflict', 'COMPLETED', ?, ?)`), s, older, now); err != nil {
|
||||
t.Fatalf("seed session: %v", err)
|
||||
}
|
||||
}
|
||||
seedLegacySessionWorktree(t, db, "sess-pgc1", "wt-pgc1", "repo-pgc", "", "/t/pgc1", "feature/a", "active", older)
|
||||
seedLegacySessionWorktree(t, db, "sess-pgc2", "wt-pgc2", "repo-pgc", "", "/t/pgc2", "feature/b", "active", now)
|
||||
|
||||
repo, err := NewWithDB(db, db, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("postgres cutover: %v", err)
|
||||
}
|
||||
env, err := repo.GetTaskEnvironmentByTaskID(context.Background(), "task-pg-conflict")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTaskEnvironmentByTaskID: %v", err)
|
||||
}
|
||||
if env == nil || len(env.Repos) != 1 || env.Repos[0].WorktreeID != "wt-pgc2" {
|
||||
t.Fatalf("elected postgres env = %+v, want one wt-pgc2 row", env)
|
||||
}
|
||||
var tableCount int
|
||||
if err := db.Get(&tableCount, `
|
||||
SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_name = 'task_session_worktrees' AND table_schema = current_schema()`); err != nil {
|
||||
t.Fatalf("count legacy table: %v", err)
|
||||
}
|
||||
if tableCount != 0 {
|
||||
t.Fatal("completed postgres cutover must drop the legacy table")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCutoverPostgres_IrreconcilableMetadataFailsClosed proves postgres still
|
||||
// rolls back when two live sessions disagree about one worktree's path.
|
||||
func TestCutoverPostgres_IrreconcilableMetadataFailsClosed(t *testing.T) {
|
||||
db := openLegacyPostgres(t)
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
if _, err := db.Exec(db.Rebind(`
|
||||
INSERT INTO tasks (id, workspace_id, title, created_at, updated_at)
|
||||
VALUES (?, 'ws-1', 'legacy', ?, ?)`), "task-pg-metadata", now, now); err != nil {
|
||||
t.Fatalf("seed task: %v", err)
|
||||
}
|
||||
for _, s := range []string{"sess-pgm1", "sess-pgm2"} {
|
||||
if _, err := db.Exec(db.Rebind(`
|
||||
INSERT INTO task_sessions (id, task_id, state, started_at, updated_at)
|
||||
VALUES (?, 'task-pg-metadata', 'RUNNING', ?, ?)`), s, now, now); err != nil {
|
||||
t.Fatalf("seed session: %v", err)
|
||||
}
|
||||
}
|
||||
seedLegacySessionWorktree(t, db, "sess-pgm1", "wt-pgm", "repo-pgm", "", "/t/one", "feature/m", "active", now)
|
||||
seedLegacySessionWorktree(t, db, "sess-pgm2", "wt-pgm", "repo-pgm", "", "/t/two", "feature/m", "active", now)
|
||||
|
||||
if _, err := NewWithDB(db, db, nil); err == nil {
|
||||
t.Fatal("expected postgres cutover to fail on irreconcilable metadata")
|
||||
}
|
||||
var tableCount int
|
||||
if err := db.Get(&tableCount, `
|
||||
SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_name = 'task_session_worktrees' AND table_schema = current_schema()`); err != nil {
|
||||
t.Fatalf("count legacy table: %v", err)
|
||||
}
|
||||
if tableCount != 1 {
|
||||
t.Fatal("failed postgres cutover must leave the legacy table intact")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCutoverPostgres_ReplayIsNoOp proves a second boot skips the cutover.
|
||||
func TestCutoverPostgres_ReplayIsNoOp(t *testing.T) {
|
||||
db := openLegacyPostgres(t)
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
seed := legacySeed{envID: "env-pg3", taskID: "task-pg3", repoID: "repo-pg3", sessionID: "sess-pg3"}
|
||||
seedLegacyTask(t, db, seed, now)
|
||||
seedLegacySessionWorktree(t, db, "sess-pg3", "wt-pg3", "repo-pg3", "", "/tasks/pg3/kandev", "feature/pg3", "active", now)
|
||||
seedLegacyFlatEnv(t, db, seed, "wt-pg3", "/tasks/pg3/kandev", "feature/pg3", now)
|
||||
|
||||
if _, err := NewWithDB(db, db, nil); err != nil {
|
||||
t.Fatalf("first boot: %v", err)
|
||||
}
|
||||
if _, err := NewWithDB(db, db, nil); err != nil {
|
||||
t.Fatalf("second boot (replay): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCutoverPostgres_RollbackAtEveryFailpoint mirrors the SQLite failpoint
|
||||
// matrix on PostgreSQL.
|
||||
func TestCutoverPostgres_RollbackAtEveryFailpoint(t *testing.T) {
|
||||
for _, step := range []string{
|
||||
"create_shadow", "copy_envs", "backfill_repos", "link_sessions",
|
||||
"validate", "pre_swap", "drop_legacy", "swap", "post_swap",
|
||||
} {
|
||||
t.Run(step, func(t *testing.T) {
|
||||
db := openLegacyPostgres(t)
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
seed := legacySeed{envID: "env-pgr", taskID: "task-pgr", repoID: "repo-pgr", sessionID: "sess-pgr"}
|
||||
seedLegacyTask(t, db, seed, now)
|
||||
seedLegacySessionWorktree(t, db, "sess-pgr", "wt-pgr", "repo-pgr", "", "/tasks/pgr/kandev", "feature/pgr", "active", now)
|
||||
seedLegacyFlatEnv(t, db, seed, "wt-pgr", "/tasks/pgr/kandev", "feature/pgr", now)
|
||||
|
||||
repo := &Repository{db: db, ro: db, migrate: dbutil.NewMigrateLogger(db, nil), failCutoverAfter: step}
|
||||
err := repo.initSchema()
|
||||
if err == nil {
|
||||
t.Fatalf("expected injected failure at %s", step)
|
||||
}
|
||||
var tableCount int
|
||||
if err := db.Get(&tableCount, `
|
||||
SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_name = 'task_session_worktrees' AND table_schema = current_schema()`); err != nil {
|
||||
t.Fatalf("count legacy table: %v", err)
|
||||
}
|
||||
if tableCount != 1 {
|
||||
t.Fatalf("legacy table must survive rollback at %s", step)
|
||||
}
|
||||
var path string
|
||||
if err := db.Get(&path, db.Rebind(`
|
||||
SELECT worktree_path FROM task_session_worktrees WHERE worktree_id = ?`), "wt-pgr"); err != nil {
|
||||
t.Fatalf("legacy worktree row lost after rollback at %s: %v", step, err)
|
||||
}
|
||||
if path != "/tasks/pgr/kandev" {
|
||||
t.Fatalf("legacy worktree data changed after rollback at %s", step)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCutoverPostgres_FreshSchemaIsFinal proves a fresh PostgreSQL database
|
||||
// contains only the final schema.
|
||||
func TestCutoverPostgres_FreshSchemaIsFinal(t *testing.T) {
|
||||
db := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t))
|
||||
if _, err := NewWithDB(db, db, nil); err != nil {
|
||||
t.Fatalf("init fresh postgres schema: %v", err)
|
||||
}
|
||||
var tableCount int
|
||||
if err := db.Get(&tableCount, `
|
||||
SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_name = 'task_session_worktrees' AND table_schema = current_schema()`); err != nil {
|
||||
t.Fatalf("count legacy table: %v", err)
|
||||
}
|
||||
if tableCount != 0 {
|
||||
t.Fatal("fresh postgres database must not contain task_session_worktrees")
|
||||
}
|
||||
// The environment FK must reference tasks and the repos FK must
|
||||
// reference task_environments after the swap.
|
||||
var fkCount int
|
||||
if err := db.Get(&fkCount, `
|
||||
SELECT COUNT(*) FROM information_schema.table_constraints tc
|
||||
JOIN information_schema.referential_constraints rc
|
||||
ON rc.constraint_name = tc.constraint_name AND rc.constraint_schema = tc.constraint_schema
|
||||
JOIN information_schema.constraint_column_usage ccu
|
||||
ON ccu.constraint_name = rc.unique_constraint_name AND ccu.constraint_schema = rc.unique_constraint_schema
|
||||
WHERE tc.table_name = 'task_environment_repos'
|
||||
AND tc.constraint_type = 'FOREIGN KEY'
|
||||
AND ccu.table_name = 'task_environments'
|
||||
AND tc.table_schema = current_schema()`); err != nil {
|
||||
t.Fatalf("count env-repo FK: %v", err)
|
||||
}
|
||||
if fkCount != 1 {
|
||||
t.Fatalf("task_environment_repos FK to task_environments missing: %d", fkCount)
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,16 @@ import (
|
||||
"github.com/kandev/kandev/internal/db/dialect"
|
||||
)
|
||||
|
||||
// envRepoKey is the normalized repository-slot key: one row per
|
||||
// (repository, branch slug) pair within a task environment.
|
||||
func envRepoKey(repositoryID, branchSlug string) string {
|
||||
return repositoryID + "\x00" + branchSlug
|
||||
}
|
||||
|
||||
// rowForKey returns (creating if needed) the normalized repo target for a
|
||||
// (repository, branch slug) key in this task.
|
||||
func (t *taskWorktreeTargets) rowForKey(repositoryID, branchSlug string) *envRepoTarget {
|
||||
key := repositoryID + "\x00" + branchSlug
|
||||
key := envRepoKey(repositoryID, branchSlug)
|
||||
if target, ok := t.byKey[key]; ok {
|
||||
return target
|
||||
}
|
||||
|
||||
@@ -120,15 +120,25 @@ environment across tasks. Such a borrowing task gains no environment of its
|
||||
own, and the shared physical worktree keeps exactly one owner.
|
||||
Legacy session rows marked `deleted` (or carrying `deleted_at`) and stale
|
||||
references from terminal sessions are historical evidence, not additional
|
||||
owners. A terminal reference bypasses validation when a higher-precedence
|
||||
task-owned source exists for the same repository and branch slot. This source
|
||||
can be a canonical repository row or the surviving flat environment row. The
|
||||
higher-precedence source remains the owner when the terminal reference carries
|
||||
a different physical `worktree_id`. A terminal-only reference without a
|
||||
higher-precedence owner still requires compatible identity, path, branch, and
|
||||
repository data. A non-terminal session with a different physical identity
|
||||
also requires compatible data. Unresolved ownership fails closed with a
|
||||
diagnostic.
|
||||
owners. A higher-precedence task-owned source for the same repository and
|
||||
branch slot, either a canonical repository row or the surviving flat
|
||||
environment row, remains the owner even when a terminal reference carries a
|
||||
different physical `worktree_id`.
|
||||
|
||||
The task-owned model holds exactly one physical worktree per (repository,
|
||||
branch slug) slot, while the legacy per-session model let one task accumulate
|
||||
several for the same slot through handoffs, re-materialized workspaces, and
|
||||
additional sessions. When no higher-precedence task-owned source exists, the
|
||||
upgrade elects one owner per contested slot instead of failing: a live
|
||||
session's worktree wins over a terminal session's, then the most recently
|
||||
updated row wins. Every other worktree for that slot becomes historical
|
||||
evidence and is logged with its identity, repository, path, and branch. No
|
||||
directory, registration, or branch is removed, so a demoted workspace remains
|
||||
on disk.
|
||||
|
||||
Irreconcilable data, including incompatible path or branch metadata for the
|
||||
same physical worktree identity with no higher-precedence owner or a worktree
|
||||
whose task has no resolvable environment, still fails closed with a diagnostic.
|
||||
|
||||
After backfill validation, the same upgrade drops `task_session_worktrees` and
|
||||
the deprecated flat worktree columns from `task_environments`. It also removes
|
||||
@@ -311,10 +321,17 @@ remain the authorization boundary for physical cleanup.
|
||||
binary starts, **THEN** the worktree is normalized onto the owning task's
|
||||
environment, both sessions keep that environment, the borrowing task gains no
|
||||
environment of its own, and the cutover completes.
|
||||
- **GIVEN** a non-terminal session references a worktree that conflicts with
|
||||
the canonical owner and cannot be reconciled, **WHEN** the new binary starts,
|
||||
**THEN** startup fails closed, the transaction rolls back, and the verified
|
||||
pre-upgrade backup remains the recovery source.
|
||||
- **GIVEN** several legacy sessions of one task registered different physical
|
||||
worktrees for the same repository and branch slot, **WHEN** the new binary
|
||||
starts, **THEN** the canonical owner — or the newest live session worktree
|
||||
when there is none — keeps the slot, the other worktrees are recorded as
|
||||
history and logged with their paths, no directory or branch is removed, and
|
||||
the cutover completes.
|
||||
- **GIVEN** a non-terminal session references a worktree whose path or branch
|
||||
metadata contradicts the same physical worktree's owner and cannot be
|
||||
reconciled, **WHEN** the new binary starts, **THEN** startup fails closed, the
|
||||
transaction rolls back, and the verified pre-upgrade backup remains the
|
||||
recovery source.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
|
||||
Reference in New Issue
Block a user