fix(org-import): migrate runtime schedules from removed predecessor on recreate #2007

Merged
hongming merged 1 commits from fix/schedule-migration-on-recreate into main 2026-05-29 09:28:59 +00:00
2 changed files with 143 additions and 0 deletions
@@ -548,6 +548,16 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
})
}
// internal#2006: migrate runtime-created schedules from a removed
// predecessor of the same agent (role+parent) onto this freshly-created
// workspace. Reconcile re-derives template-sourced state below, but
// schedules a user added at runtime (source='runtime', via the canvas/API)
// bind to the ephemeral workspace_id and would otherwise be abandoned on
// the removed row when an agent is recreated with a new id. Runs before the
// template upsert loop so a same-named template schedule still wins.
// Best-effort: never fails the import.
h.migrateRuntimeSchedulesFromRemovedPredecessor(ctx, id, role, ws.Name, parentID)
// Insert schedules if defined. Resolve each schedule's prompt body from
// either inline `prompt:` or `prompt_file:` (file ref relative to the
// workspace's files_dir). Inline wins; empty prompt after resolution is
@@ -687,6 +697,64 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, absX
return h.recurseChildrenForImport(ws, id, absX, absY, defaults, orgBaseDir, results, provisionSem)
}
// migrateRuntimeSchedulesFromRemovedPredecessor re-points runtime-created
// schedules (source='runtime') from the most-recent removed predecessor of the
// same agent onto newID. Recreating an agent mints a NEW workspace id (the
// ON CONFLICT in createWorkspaceTree only matches non-removed rows), so a
// schedule a user added at runtime would otherwise be abandoned on the removed
// row. Template-sourced schedules are NOT migrated — reconcile re-derives those
// from the org template (the upsert loop). The predecessor is matched by the
// stable `role` when present (survives the name auto-suffixing that yields
// "Agent (2)"), falling back to name+parent. Idempotent (skips names already on
// newID) and best-effort (logs, never errors the import). See internal#2006.
func (h *OrgHandler) migrateRuntimeSchedulesFromRemovedPredecessor(ctx context.Context, newID string, role interface{}, name string, parentID *string) {
var predID string
var err error
if role != nil {
err = db.DB.QueryRowContext(ctx, `
SELECT id FROM workspaces
WHERE status = 'removed' AND role = $1
AND parent_id IS NOT DISTINCT FROM $2
AND id <> $3
ORDER BY updated_at DESC NULLS LAST
LIMIT 1
`, role, parentID, newID).Scan(&predID)
} else {
err = db.DB.QueryRowContext(ctx, `
SELECT id FROM workspaces
WHERE status = 'removed' AND name = $1
AND parent_id IS NOT DISTINCT FROM $2
AND id <> $3
ORDER BY updated_at DESC NULLS LAST
LIMIT 1
`, name, parentID, newID).Scan(&predID)
}
if errors.Is(err, sql.ErrNoRows) {
return // first-time create — no predecessor to migrate from
}
if err != nil {
log.Printf("Org import: predecessor lookup for %q (new=%s) failed: %v — skipping schedule migration", name, newID, err)
return
}
res, err := db.DB.ExecContext(ctx, `
UPDATE workspace_schedules s
SET workspace_id = $1, updated_at = now()
WHERE s.workspace_id = $2
AND s.source = 'runtime'
AND NOT EXISTS (
SELECT 1 FROM workspace_schedules t
WHERE t.workspace_id = $1 AND t.name = s.name
)
`, newID, predID)
if err != nil {
log.Printf("Org import: schedule migration %s -> %s (%q) failed: %v", predID, newID, name, err)
return
}
if n, _ := res.RowsAffected(); n > 0 {
log.Printf("Org import: migrated %d runtime schedule(s) from removed predecessor %s to new workspace %s (%q)", n, predID, newID, name)
}
}
// lookupExistingChild returns the id of an existing workspace under
// (parent_id, name) if any, with idempotency-friendly semantics:
// - parent_id IS NOT DISTINCT FROM matches NULL too (root workspaces)
@@ -0,0 +1,75 @@
package handlers
import (
"context"
"database/sql"
"testing"
sqlmock "github.com/DATA-DOG/go-sqlmock"
)
// TestMigrateRuntimeSchedulesFromRemovedPredecessor verifies the happy path:
// a removed predecessor exists for the agent (matched by role), and its
// runtime-created schedules are re-pointed onto the freshly-created workspace.
// internal#2006 (recreate-orphans-schedules regression).
func TestMigrateRuntimeSchedulesFromRemovedPredecessor(t *testing.T) {
mock := setupTestDB(t)
h := &OrgHandler{}
// Predecessor lookup (role branch) returns the removed prior workspace.
mock.ExpectQuery(`SELECT id FROM workspaces`).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("old-removed-ws"))
// Re-point UPDATE migrates 2 runtime schedules.
mock.ExpectExec(`UPDATE workspace_schedules`).
WillReturnResult(sqlmock.NewResult(0, 2))
parent := "parent-1"
h.migrateRuntimeSchedulesFromRemovedPredecessor(
context.Background(), "new-ws", interface{}("code-reviewer"), "Code Reviewer (2)", &parent,
)
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sqlmock expectations: %v", err)
}
}
// TestMigrateRuntimeSchedules_NoPredecessor verifies the first-time-create path:
// no removed predecessor → the function returns after the lookup and MUST NOT
// run the re-point UPDATE (sqlmock errors on an unexpected query if it does).
func TestMigrateRuntimeSchedules_NoPredecessor(t *testing.T) {
mock := setupTestDB(t)
h := &OrgHandler{}
mock.ExpectQuery(`SELECT id FROM workspaces`).
WillReturnError(sql.ErrNoRows)
// No ExpectExec — an UPDATE here would be an unexpected query → test fails.
h.migrateRuntimeSchedulesFromRemovedPredecessor(
context.Background(), "new-ws", interface{}("researcher"), "Root-Cause Researcher", nil,
)
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sqlmock expectations: %v", err)
}
}
// TestMigrateRuntimeSchedules_NameFallback verifies the name-branch lookup is
// used when the agent has no stable role (role == nil), still followed by the
// re-point UPDATE.
func TestMigrateRuntimeSchedules_NameFallback(t *testing.T) {
mock := setupTestDB(t)
h := &OrgHandler{}
mock.ExpectQuery(`SELECT id FROM workspaces`).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("old-removed-ws"))
mock.ExpectExec(`UPDATE workspace_schedules`).
WillReturnResult(sqlmock.NewResult(0, 1))
h.migrateRuntimeSchedulesFromRemovedPredecessor(
context.Background(), "new-ws", nil, "Some Agent", nil,
)
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sqlmock expectations: %v", err)
}
}