按 Fasthei 复审意见修并发状态机边界: - 成功/失败回填都加 WHERE deployment_id=? AND LOWER(status)='pending', 让 Pending->running / Pending->failed 成为 own-or-nothing 转换。 - 成功但 0 行(用户在 AM 启动窗口内 stop/delete/cancel):重读记录记日志, 删除 AM 刚起的 orphan runtime,绝不把记录改回 running。 - 失败仅在我方拥有 Pending->failed 转换时才 revoke model token;用户已 stop/delete 时交给对应路径(delete 已 revoke、stop 有意保留 key),避免重复/遗漏。 - 补竞态测试 StoppedDuringStart_NoResurrect:AM 启动被 hold 到记录置 stopped 后才返回, 断言记录保持 stopped、不回填 runtime_id、orphan runtime 被删除。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
139 lines
5.6 KiB
Go
139 lines
5.6 KiB
Go
package controller
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/heicode/manager/model"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// startTemplateAgentAsync must NOT block the caller on the (slow) AM start, and
|
|
// must flip the pre-persisted Pending record to running once AM responds — this
|
|
// is the core of the 504 fix (deploy returns immediately; status lands later).
|
|
func TestStartTemplateAgentAsync_PendingToRunning(t *testing.T) {
|
|
setupResourceControllerTestDB(t)
|
|
require.NoError(t, model.DB.AutoMigrate(&model.AgentDeployment{}))
|
|
|
|
// Slow AM mock — simulates the ~30s start the handler must not block on.
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
time.Sleep(150 * time.Millisecond)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"success":true,"data":{"namespace":"rt-async-1","access_info":{"domain":"https://async.agents.example"},"status":"running"}}`))
|
|
}))
|
|
defer srv.Close()
|
|
t.Setenv("AGENT_RUNTIME_BASE_URL", srv.URL)
|
|
|
|
// What HeicodeDeployAgent persists before returning.
|
|
require.NoError(t, model.DB.Create(&model.AgentDeployment{
|
|
DeploymentID: "dep_async_run",
|
|
UserID: "22",
|
|
TemplateID: "architect",
|
|
Status: "Pending",
|
|
}).Error)
|
|
|
|
start := time.Now()
|
|
startTemplateAgentAsync("dep_async_run", 0, amStartArgs{ManagerDeploymentID: "dep_async_run", UserID: "22", TemplateKey: "architect"})
|
|
require.Less(t, time.Since(start), 50*time.Millisecond, "must not block on the AM start call")
|
|
|
|
var got model.AgentDeployment
|
|
require.Eventually(t, func() bool {
|
|
model.DB.Where("deployment_id = ?", "dep_async_run").First(&got)
|
|
return got.Status == "running"
|
|
}, 3*time.Second, 20*time.Millisecond, "Pending should flip to running after async AM start")
|
|
require.Equal(t, "rt-async-1", got.RuntimeDeploymentID)
|
|
require.Equal(t, "https://async.agents.example", got.Subdomain)
|
|
}
|
|
|
|
// On AM failure the record must be marked failed (with a reason), not left
|
|
// stuck on Pending — so the client surfaces a failure instead of a hang.
|
|
func TestStartTemplateAgentAsync_FailureMarksFailed(t *testing.T) {
|
|
setupResourceControllerTestDB(t)
|
|
require.NoError(t, model.DB.AutoMigrate(&model.AgentDeployment{}))
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_, _ = w.Write([]byte(`{"success":false,"message":"AM boom"}`))
|
|
}))
|
|
defer srv.Close()
|
|
t.Setenv("AGENT_RUNTIME_BASE_URL", srv.URL)
|
|
|
|
require.NoError(t, model.DB.Create(&model.AgentDeployment{
|
|
DeploymentID: "dep_async_fail",
|
|
UserID: "22",
|
|
TemplateID: "architect",
|
|
Status: "Pending",
|
|
}).Error)
|
|
|
|
startTemplateAgentAsync("dep_async_fail", 0, amStartArgs{ManagerDeploymentID: "dep_async_fail", UserID: "22", TemplateKey: "architect"})
|
|
|
|
var got model.AgentDeployment
|
|
require.Eventually(t, func() bool {
|
|
model.DB.Where("deployment_id = ?", "dep_async_fail").First(&got)
|
|
return got.Status == "failed"
|
|
}, 3*time.Second, 20*time.Millisecond, "AM failure should mark the record failed")
|
|
require.NotEmpty(t, got.FailureReason)
|
|
}
|
|
|
|
// Concurrency boundary: if the user stops the agent while AM is still starting,
|
|
// the async goroutine must NOT resurrect it to running, and must delete the
|
|
// runtime AM just started (orphan cleanup). The AM start is held until the test
|
|
// has marked the record stopped, so the conditional update always runs after.
|
|
func TestStartTemplateAgentAsync_StoppedDuringStart_NoResurrect(t *testing.T) {
|
|
setupResourceControllerTestDB(t)
|
|
require.NoError(t, model.DB.AutoMigrate(&model.AgentDeployment{}))
|
|
|
|
release := make(chan struct{})
|
|
var mu sync.Mutex
|
|
var deletedRuntime string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case r.Method == http.MethodPost && r.URL.Path == "/agents":
|
|
<-release // block the start until the test marks the row stopped
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"success":true,"data":{"namespace":"rt-race-1","access_info":{"domain":"https://race.agents.example"},"status":"running"}}`))
|
|
case r.Method == http.MethodDelete && strings.HasPrefix(r.URL.Path, "/agents/"):
|
|
mu.Lock()
|
|
deletedRuntime = strings.TrimPrefix(r.URL.Path, "/agents/")
|
|
mu.Unlock()
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"success":true}`))
|
|
default:
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
t.Setenv("AGENT_RUNTIME_BASE_URL", srv.URL)
|
|
|
|
require.NoError(t, model.DB.Create(&model.AgentDeployment{
|
|
DeploymentID: "dep_race_stop",
|
|
UserID: "22",
|
|
TemplateID: "architect",
|
|
Status: "Pending",
|
|
}).Error)
|
|
|
|
startTemplateAgentAsync("dep_race_stop", 0, amStartArgs{ManagerDeploymentID: "dep_race_stop", UserID: "22", TemplateKey: "architect"})
|
|
|
|
// User stops the agent while AM is still starting. Done before releasing AM, so
|
|
// the goroutine's Pending-guarded update is guaranteed to see "stopped".
|
|
require.NoError(t, model.DB.Model(&model.AgentDeployment{}).
|
|
Where("deployment_id = ?", "dep_race_stop").
|
|
Updates(map[string]any{"status": "stopped"}).Error)
|
|
close(release)
|
|
|
|
require.Eventually(t, func() bool {
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
return deletedRuntime == "rt-race-1"
|
|
}, 3*time.Second, 20*time.Millisecond, "orphan runtime must be deleted in AM")
|
|
|
|
var row model.AgentDeployment
|
|
require.NoError(t, model.DB.Where("deployment_id = ?", "dep_race_stop").First(&row).Error)
|
|
require.Equal(t, "stopped", row.Status, "stopped record must not be flipped back to running")
|
|
require.Empty(t, row.RuntimeDeploymentID, "runtime id must not be backfilled onto a stopped record")
|
|
}
|