fix(openclaw): recover legacy lite instances during upgrade (#178)
This commit is contained in:
+4
-1
@@ -1,12 +1,15 @@
|
||||
.git
|
||||
.gitignore
|
||||
.codex
|
||||
.DS_Store
|
||||
node_modules
|
||||
frontend/node_modules
|
||||
frontend/dist
|
||||
backend/bin
|
||||
backend/.cache
|
||||
backend/.codex
|
||||
backend/.tmp
|
||||
dev_docs
|
||||
docs
|
||||
scripts
|
||||
*.log
|
||||
*.log
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
@@ -909,6 +910,9 @@ func (s *instanceService) Start(instanceID int) error {
|
||||
}
|
||||
|
||||
if runtimeType, ok := v2RuntimeTypeForInstance(instance); ok {
|
||||
if err := s.prepareV2InstanceStart(ctx, instance); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.startV2Instance(ctx, instance, runtimeType)
|
||||
}
|
||||
|
||||
@@ -1435,6 +1439,37 @@ func (s *instanceService) startV2Instance(ctx context.Context, instance *models.
|
||||
return nil
|
||||
}
|
||||
|
||||
// prepareV2InstanceStart makes an explicit start retry idempotent. A failed or
|
||||
// older binding belongs to the previous gateway attempt and must not be allowed
|
||||
// to overwrite the new runtime generation during scheduler reconciliation.
|
||||
func (s *instanceService) prepareV2InstanceStart(ctx context.Context, instance *models.Instance) error {
|
||||
if s.bindingRepo != nil {
|
||||
binding, err := s.bindingRepo.GetByInstanceID(ctx, instance.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get v2 runtime binding before start: %w", err)
|
||||
}
|
||||
if binding != nil {
|
||||
if binding.Generation > instance.RuntimeGeneration {
|
||||
return fmt.Errorf("v2 runtime binding generation %d is newer than instance generation %d", binding.Generation, instance.RuntimeGeneration)
|
||||
}
|
||||
state := strings.ToLower(strings.TrimSpace(binding.State))
|
||||
if binding.Generation == instance.RuntimeGeneration && (state == "running" || state == "ready" || state == "healthy") {
|
||||
return fmt.Errorf("instance is already running")
|
||||
}
|
||||
if err := s.cleanupV2GatewayBinding(ctx, instance); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
runtimeType, runtimeTypeOK := NormalizeV2RuntimeType(instance.Type)
|
||||
if runtimeTypeOK && runtimeType == RuntimeTypeOpenClaw && instance.WorkspacePath != nil {
|
||||
if _, err := quarantineCorruptLegacyOpenClawTaskState(strings.TrimSpace(*instance.WorkspacePath), instance.RuntimeGeneration, instance.RuntimeErrorMessage); err != nil {
|
||||
return fmt.Errorf("failed to quarantine corrupt legacy OpenClaw task state: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *instanceService) stopV2Instance(ctx context.Context, instance *models.Instance) error {
|
||||
if err := s.instanceRepo.UpdateRuntimeState(ctx, instance.ID, "stopped", instance.RuntimeGeneration, nil); err != nil {
|
||||
return fmt.Errorf("failed to mark v2 instance stopped: %w", err)
|
||||
@@ -1490,7 +1525,7 @@ func (s *instanceService) cleanupV2GatewayBinding(ctx context.Context, instance
|
||||
} else if pod == nil {
|
||||
return fmt.Errorf("runtime pod %d is not available for v2 cleanup", binding.RuntimePodID)
|
||||
} else if pod != nil && pod.AgentEndpoint != nil && strings.TrimSpace(*pod.AgentEndpoint) != "" && s.agentClient != nil && binding.GatewayID != "" {
|
||||
if err := s.agentClient.DeleteGateway(ctx, strings.TrimSpace(*pod.AgentEndpoint), binding.GatewayID); err != nil {
|
||||
if err := s.agentClient.DeleteGateway(ctx, strings.TrimSpace(*pod.AgentEndpoint), binding.GatewayID); err != nil && !errors.Is(err, ErrRuntimeAgentNotFound) {
|
||||
return fmt.Errorf("failed to delete v2 gateway: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,6 +317,96 @@ func TestInstanceServiceStartV2MarksCreatingWithNextGeneration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceServiceStartV2CleansFailedBindingBeforeNextGeneration(t *testing.T) {
|
||||
workspacePath := "/workspaces/openclaw/user-45/instance-78"
|
||||
instanceRepo := newV2LifecycleInstanceRepo()
|
||||
instanceRepo.byID[78] = &models.Instance{
|
||||
ID: 78,
|
||||
UserID: 45,
|
||||
Type: "openclaw",
|
||||
RuntimeType: "gateway",
|
||||
Status: "error",
|
||||
WorkspacePath: &workspacePath,
|
||||
RuntimeGeneration: 4,
|
||||
}
|
||||
endpoint := "http://agent.local:19090"
|
||||
podRepo := &fakeRuntimePodRepo{pods: map[int64]*models.RuntimePod{
|
||||
9: {ID: 9, AgentEndpoint: &endpoint},
|
||||
}}
|
||||
bindingRepo := newFakeRuntimeBindingRepo()
|
||||
bindingRepo.bindings[78] = &models.InstanceRuntimeBinding{
|
||||
InstanceID: 78,
|
||||
RuntimePodID: 9,
|
||||
GatewayID: "gw-78-4",
|
||||
GatewayPort: 20000,
|
||||
State: "error",
|
||||
Generation: 4,
|
||||
}
|
||||
agent := &fakeRuntimeAgentClient{}
|
||||
service := &instanceService{
|
||||
instanceRepo: instanceRepo,
|
||||
runtimePodRepo: podRepo,
|
||||
bindingRepo: bindingRepo,
|
||||
agentClient: agent,
|
||||
}
|
||||
|
||||
if err := service.Start(78); err != nil {
|
||||
t.Fatalf("Start returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(agent.deleteRequests) != 1 || agent.deleteRequests[0].gatewayID != "gw-78-4" {
|
||||
t.Fatalf("delete gateway requests = %#v", agent.deleteRequests)
|
||||
}
|
||||
if bindingRepo.deleteAndReleaseCalls[78] != 1 || bindingRepo.bindings[78] != nil {
|
||||
t.Fatalf("failed binding was not removed: calls=%d binding=%+v", bindingRepo.deleteAndReleaseCalls[78], bindingRepo.bindings[78])
|
||||
}
|
||||
state := instanceRepo.runtimeStates[78]
|
||||
if state.status != "creating" || state.generation != 5 {
|
||||
t.Fatalf("runtime state = %#v, want creating generation 5", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceServiceStartV2QuarantinesReportedCorruptLegacyTaskDatabase(t *testing.T) {
|
||||
workspacePath := t.TempDir()
|
||||
legacyTasksRoot := path.Join(workspacePath, "home", ".openclaw", "tasks")
|
||||
if err := os.MkdirAll(legacyTasksRoot, 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacyDatabase := path.Join(legacyTasksRoot, "runs.sqlite")
|
||||
if err := os.WriteFile(legacyDatabase, []byte("corrupt"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtimeError := "SQLITE_CORRUPT: database disk image is malformed"
|
||||
instanceRepo := newV2LifecycleInstanceRepo()
|
||||
instanceRepo.byID[79] = &models.Instance{
|
||||
ID: 79,
|
||||
UserID: 45,
|
||||
Type: "openclaw",
|
||||
RuntimeType: "gateway",
|
||||
Status: "error",
|
||||
WorkspacePath: &workspacePath,
|
||||
RuntimeGeneration: 8,
|
||||
RuntimeErrorMessage: &runtimeError,
|
||||
}
|
||||
service := &instanceService{instanceRepo: instanceRepo}
|
||||
|
||||
if err := service.Start(79); err != nil {
|
||||
t.Fatalf("Start returned error: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(legacyDatabase); !os.IsNotExist(err) {
|
||||
t.Fatalf("legacy corrupt database still exists: %v", err)
|
||||
}
|
||||
quarantineEntries, err := os.ReadDir(path.Join(workspacePath, "home", ".openclaw", "quarantine"))
|
||||
if err != nil || len(quarantineEntries) != 1 {
|
||||
t.Fatalf("quarantine entries = %#v, err=%v", quarantineEntries, err)
|
||||
}
|
||||
state := instanceRepo.runtimeStates[79]
|
||||
if state.status != "creating" || state.generation != 9 {
|
||||
t.Fatalf("runtime state = %#v, want creating generation 9", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceServiceStopV2DeletesGatewayBindingAndReleasesSlot(t *testing.T) {
|
||||
workspacePath := "/workspaces/openclaw/user-45/instance-88"
|
||||
instanceRepo := newV2LifecycleInstanceRepo()
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ensureOpenClawPluginLayoutCompatibility bridges the project-local npm layout
|
||||
// used by newer OpenClaw releases to the global npm layout used by older
|
||||
// releases. Existing global packages are authoritative and are never replaced.
|
||||
func ensureOpenClawPluginLayoutCompatibility(workspacePath string, uid, gid int) error {
|
||||
openClawHome := filepath.Join(workspacePath, "home", ".openclaw")
|
||||
npmRoot := filepath.Join(openClawHome, "npm")
|
||||
projectsRoot := filepath.Join(npmRoot, "projects")
|
||||
globalRoot := filepath.Join(npmRoot, "node_modules")
|
||||
|
||||
projects, err := os.ReadDir(projectsRoot)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("read OpenClaw npm projects: %w", err)
|
||||
}
|
||||
|
||||
globalInfo, err := os.Lstat(globalRoot)
|
||||
globalRootMissing := errors.Is(err, os.ErrNotExist)
|
||||
if err == nil {
|
||||
if globalInfo.Mode()&os.ModeSymlink != 0 {
|
||||
// A whole-directory compatibility link is already configured.
|
||||
return nil
|
||||
}
|
||||
if !globalInfo.IsDir() {
|
||||
return fmt.Errorf("OpenClaw global node_modules is not a directory")
|
||||
}
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("inspect OpenClaw global node_modules: %w", err)
|
||||
}
|
||||
|
||||
packages := map[string][]string{}
|
||||
for _, project := range projects {
|
||||
if !project.IsDir() {
|
||||
continue
|
||||
}
|
||||
projectModules := filepath.Join(projectsRoot, project.Name(), "node_modules")
|
||||
if err := collectOpenClawProjectPackages(projectModules, packages); err != nil {
|
||||
return fmt.Errorf("inspect OpenClaw npm project %q: %w", project.Name(), err)
|
||||
}
|
||||
}
|
||||
if len(packages) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(globalRoot, 0o755); err != nil {
|
||||
return fmt.Errorf("create OpenClaw global node_modules: %w", err)
|
||||
}
|
||||
if globalRootMissing {
|
||||
if err := chownOpenClawCompatibilityPath(globalRoot, uid, gid, false); err != nil {
|
||||
return fmt.Errorf("set OpenClaw global node_modules ownership: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
packageNames := make([]string, 0, len(packages))
|
||||
for packageName := range packages {
|
||||
packageNames = append(packageNames, packageName)
|
||||
}
|
||||
sort.Strings(packageNames)
|
||||
for _, packageName := range packageNames {
|
||||
linkPath := filepath.Join(globalRoot, filepath.FromSlash(packageName))
|
||||
exists, err := openClawCompatibilityPathExists(linkPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
continue
|
||||
}
|
||||
targets := packages[packageName]
|
||||
if len(targets) != 1 {
|
||||
return fmt.Errorf("OpenClaw plugin %q exists in multiple npm projects; cannot choose a rollback-compatible target", packageName)
|
||||
}
|
||||
if err := createOpenClawCompatibilityLink(linkPath, targets[0], globalRoot, uid, gid); err != nil {
|
||||
return fmt.Errorf("link OpenClaw plugin %q: %w", packageName, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func collectOpenClawProjectPackages(nodeModulesRoot string, packages map[string][]string) error {
|
||||
entries, err := os.ReadDir(nodeModulesRoot)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if strings.HasPrefix(name, ".") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(name, "@") && entry.IsDir() {
|
||||
scopeRoot := filepath.Join(nodeModulesRoot, name)
|
||||
scopedEntries, err := os.ReadDir(scopeRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, scopedEntry := range scopedEntries {
|
||||
if strings.HasPrefix(scopedEntry.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
packageName := name + "/" + scopedEntry.Name()
|
||||
packagePath := filepath.Join(scopeRoot, scopedEntry.Name())
|
||||
plugin, err := isOpenClawPluginPackage(packagePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if plugin {
|
||||
packages[packageName] = append(packages[packageName], packagePath)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
packagePath := filepath.Join(nodeModulesRoot, name)
|
||||
plugin, err := isOpenClawPluginPackage(packagePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if plugin {
|
||||
packages[name] = append(packages[name], packagePath)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isOpenClawPluginPackage(packagePath string) (bool, error) {
|
||||
manifestPath := filepath.Join(packagePath, "openclaw.plugin.json")
|
||||
info, err := os.Stat(manifestPath)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("inspect OpenClaw plugin manifest %q: %w", manifestPath, err)
|
||||
}
|
||||
return !info.IsDir(), nil
|
||||
}
|
||||
|
||||
func openClawCompatibilityPathExists(path string) (bool, error) {
|
||||
_, err := os.Lstat(path)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("inspect OpenClaw compatibility path %q: %w", path, err)
|
||||
}
|
||||
|
||||
func createOpenClawCompatibilityLink(linkPath, targetPath, globalRoot string, uid, gid int) error {
|
||||
linkParent := filepath.Dir(linkPath)
|
||||
if linkParent != globalRoot {
|
||||
parentInfo, err := os.Lstat(linkParent)
|
||||
if err == nil && parentInfo.Mode()&os.ModeSymlink != 0 {
|
||||
// Preserve an existing whole-scope link rather than writing through it.
|
||||
return nil
|
||||
}
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
parentMissing := errors.Is(err, os.ErrNotExist)
|
||||
if err := os.MkdirAll(linkParent, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if parentMissing {
|
||||
if err := chownOpenClawCompatibilityPath(linkParent, uid, gid, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
relativeTarget, err := filepath.Rel(linkParent, targetPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Symlink(relativeTarget, linkPath); err != nil {
|
||||
if errors.Is(err, os.ErrExist) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return chownOpenClawCompatibilityPath(linkPath, uid, gid, true)
|
||||
}
|
||||
|
||||
func chownOpenClawCompatibilityPath(path string, uid, gid int, symlink bool) error {
|
||||
if runtime.GOOS == "windows" || uid <= 0 || gid <= 0 {
|
||||
return nil
|
||||
}
|
||||
var err error
|
||||
if symlink {
|
||||
err = os.Lchown(path, uid, gid)
|
||||
} else {
|
||||
err = os.Chown(path, uid, gid)
|
||||
}
|
||||
// NFS root-squash can reject chown even though the link is readable and the
|
||||
// parent directory is usable. Ownership is therefore best-effort only.
|
||||
if errors.Is(err, os.ErrPermission) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// quarantineCorruptLegacyOpenClawTaskState isolates only the optional 5.4 task
|
||||
// history database after the runtime has explicitly reported SQLite corruption.
|
||||
// The files stay inside the instance workspace so an operator can recover them.
|
||||
func quarantineCorruptLegacyOpenClawTaskState(workspacePath string, generation int, runtimeError *string) (bool, error) {
|
||||
if strings.TrimSpace(workspacePath) == "" || runtimeError == nil || !isSQLiteCorruptionError(*runtimeError) {
|
||||
return false, nil
|
||||
}
|
||||
openClawHome := filepath.Join(workspacePath, "home", ".openclaw")
|
||||
legacyTasksRoot := filepath.Join(openClawHome, "tasks")
|
||||
fileNames := []string{"runs.sqlite", "runs.sqlite-wal", "runs.sqlite-shm"}
|
||||
existing := make([]string, 0, len(fileNames))
|
||||
for _, fileName := range fileNames {
|
||||
path := filepath.Join(legacyTasksRoot, fileName)
|
||||
if _, err := os.Lstat(path); err == nil {
|
||||
existing = append(existing, fileName)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return false, fmt.Errorf("inspect legacy task database %q: %w", path, err)
|
||||
}
|
||||
}
|
||||
if len(existing) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
quarantineRoot := filepath.Join(openClawHome, "quarantine")
|
||||
baseName := "legacy-tasks-generation-" + strconv.Itoa(generation)
|
||||
quarantinePath, err := nextAvailableQuarantinePath(quarantineRoot, baseName)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := os.MkdirAll(quarantinePath, 0o750); err != nil {
|
||||
return false, fmt.Errorf("create quarantine directory: %w", err)
|
||||
}
|
||||
|
||||
moved := make([]string, 0, len(existing))
|
||||
for _, fileName := range existing {
|
||||
source := filepath.Join(legacyTasksRoot, fileName)
|
||||
destination := filepath.Join(quarantinePath, fileName)
|
||||
if err := os.Rename(source, destination); err != nil {
|
||||
var rollbackErrs []error
|
||||
for index := len(moved) - 1; index >= 0; index-- {
|
||||
movedName := moved[index]
|
||||
if rollbackErr := os.Rename(filepath.Join(quarantinePath, movedName), filepath.Join(legacyTasksRoot, movedName)); rollbackErr != nil {
|
||||
rollbackErrs = append(rollbackErrs, rollbackErr)
|
||||
}
|
||||
}
|
||||
_ = os.Remove(quarantinePath)
|
||||
return false, errors.Join(append([]error{fmt.Errorf("move %s to quarantine: %w", fileName, err)}, rollbackErrs...)...)
|
||||
}
|
||||
moved = append(moved, fileName)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func isSQLiteCorruptionError(message string) bool {
|
||||
normalized := strings.ToLower(message)
|
||||
for _, marker := range []string{
|
||||
"database disk image is malformed",
|
||||
"database is corrupt",
|
||||
"database corruption",
|
||||
"file is not a database",
|
||||
"malformed database schema",
|
||||
"sqlite_corrupt",
|
||||
} {
|
||||
if strings.Contains(normalized, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func nextAvailableQuarantinePath(root, baseName string) (string, error) {
|
||||
for suffix := 0; suffix < 1000; suffix++ {
|
||||
name := baseName
|
||||
if suffix > 0 {
|
||||
name += "-" + strconv.Itoa(suffix)
|
||||
}
|
||||
candidate := filepath.Join(root, name)
|
||||
_, err := os.Lstat(candidate)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return candidate, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("inspect quarantine path: %w", err)
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("too many quarantine directories for generation")
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnsureOpenClawPluginLayoutCompatibilityLeavesLegacyLayoutUntouched(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
globalRoot := filepath.Join(workspace, "home", ".openclaw", "npm", "node_modules")
|
||||
defaultsPackage := filepath.Join(workspace, "defaults", "legacy-plugin")
|
||||
if err := os.MkdirAll(defaultsPackage, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(globalRoot, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacyLink := filepath.Join(globalRoot, "legacy-plugin")
|
||||
if err := os.Symlink(defaultsPackage, legacyLink); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := ensureOpenClawPluginLayoutCompatibility(workspace, 0, 0); err != nil {
|
||||
t.Fatalf("ensureOpenClawPluginLayoutCompatibility returned error: %v", err)
|
||||
}
|
||||
|
||||
info, err := os.Lstat(legacyLink)
|
||||
if err != nil || info.Mode()&os.ModeSymlink == 0 {
|
||||
t.Fatalf("legacy plugin link changed: info=%v err=%v", info, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureOpenClawPluginLayoutCompatibilityLinksProjectPackages(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
npmRoot := filepath.Join(workspace, "home", ".openclaw", "npm")
|
||||
projectModules := filepath.Join(npmRoot, "projects", "openclaw-feishu", "node_modules")
|
||||
feishuPackage := filepath.Join(projectModules, "@openclaw", "feishu")
|
||||
dingtalkPackage := filepath.Join(projectModules, "dingtalk-connector")
|
||||
for _, dir := range []string{feishuPackage, dingtalkPackage} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "openclaw.plugin.json"), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
globalRoot := filepath.Join(npmRoot, "node_modules")
|
||||
legacyPackage := filepath.Join(globalRoot, "dingtalk-connector")
|
||||
if err := os.MkdirAll(legacyPackage, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := ensureOpenClawPluginLayoutCompatibility(workspace, 0, 0); err != nil {
|
||||
t.Fatalf("ensureOpenClawPluginLayoutCompatibility returned error: %v", err)
|
||||
}
|
||||
|
||||
feishuLink := filepath.Join(globalRoot, "@openclaw", "feishu")
|
||||
info, err := os.Lstat(feishuLink)
|
||||
if err != nil || info.Mode()&os.ModeSymlink == 0 {
|
||||
t.Fatalf("scoped compatibility link missing: info=%v err=%v", info, err)
|
||||
}
|
||||
resolved, err := filepath.EvalSymlinks(feishuLink)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantResolved, err := filepath.EvalSymlinks(feishuPackage)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resolved != wantResolved {
|
||||
t.Fatalf("compatibility link resolves to %q, want %q", resolved, wantResolved)
|
||||
}
|
||||
legacyInfo, err := os.Lstat(legacyPackage)
|
||||
if err != nil || !legacyInfo.IsDir() || legacyInfo.Mode()&os.ModeSymlink != 0 {
|
||||
t.Fatalf("existing legacy package was replaced: info=%v err=%v", legacyInfo, err)
|
||||
}
|
||||
|
||||
// The operation is intentionally idempotent for retries and scheduler loops.
|
||||
if err := ensureOpenClawPluginLayoutCompatibility(workspace, 0, 0); err != nil {
|
||||
t.Fatalf("second compatibility pass returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureOpenClawPluginLayoutCompatibilityRejectsAmbiguousPackage(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
projectsRoot := filepath.Join(workspace, "home", ".openclaw", "npm", "projects")
|
||||
for _, project := range []string{"project-a", "project-b"} {
|
||||
packagePath := filepath.Join(projectsRoot, project, "node_modules", "same-plugin")
|
||||
if err := os.MkdirAll(packagePath, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(packagePath, "openclaw.plugin.json"), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
err := ensureOpenClawPluginLayoutCompatibility(workspace, 0, 0)
|
||||
if err == nil {
|
||||
t.Fatal("expected ambiguous plugin error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureOpenClawPluginLayoutCompatibilityIgnoresDuplicateTransitiveDependencies(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
projectsRoot := filepath.Join(workspace, "home", ".openclaw", "npm", "projects")
|
||||
for _, project := range []string{"plugin-a", "plugin-b"} {
|
||||
dependencyPath := filepath.Join(projectsRoot, project, "node_modules", "asynckit")
|
||||
if err := os.MkdirAll(dependencyPath, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dependencyPath, "package.json"), []byte(`{"name":"asynckit"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := ensureOpenClawPluginLayoutCompatibility(workspace, 0, 0); err != nil {
|
||||
t.Fatalf("duplicate transitive dependency blocked gateway preparation: %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(workspace, "home", ".openclaw", "npm", "node_modules", "asynckit")); !os.IsNotExist(err) {
|
||||
t.Fatalf("transitive dependency received a legacy plugin link: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuarantineCorruptLegacyOpenClawTaskStateMovesRecoverableDatabaseSet(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
tasksRoot := filepath.Join(workspace, "home", ".openclaw", "tasks")
|
||||
if err := os.MkdirAll(tasksRoot, 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, fileName := range []string{"runs.sqlite", "runs.sqlite-wal", "runs.sqlite-shm"} {
|
||||
if err := os.WriteFile(filepath.Join(tasksRoot, fileName), []byte(fileName), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
errorMessage := "startup migration failed: database disk image is malformed"
|
||||
|
||||
moved, err := quarantineCorruptLegacyOpenClawTaskState(workspace, 34, &errorMessage)
|
||||
if err != nil {
|
||||
t.Fatalf("quarantineCorruptLegacyOpenClawTaskState returned error: %v", err)
|
||||
}
|
||||
if !moved {
|
||||
t.Fatal("expected corrupt legacy task database to be quarantined")
|
||||
}
|
||||
quarantineRoot := filepath.Join(workspace, "home", ".openclaw", "quarantine")
|
||||
entries, err := os.ReadDir(quarantineRoot)
|
||||
if err != nil || len(entries) != 1 || !strings.HasPrefix(entries[0].Name(), "legacy-tasks-generation-34") {
|
||||
t.Fatalf("quarantine entries = %#v, err=%v", entries, err)
|
||||
}
|
||||
for _, fileName := range []string{"runs.sqlite", "runs.sqlite-wal", "runs.sqlite-shm"} {
|
||||
if _, err := os.Stat(filepath.Join(tasksRoot, fileName)); !os.IsNotExist(err) {
|
||||
t.Fatalf("legacy file %q still exists: %v", fileName, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(quarantineRoot, entries[0].Name(), fileName)); err != nil {
|
||||
t.Fatalf("quarantined file %q missing: %v", fileName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuarantineCorruptLegacyOpenClawTaskStateIgnoresNonCorruptionFailure(t *testing.T) {
|
||||
workspace := t.TempDir()
|
||||
tasksRoot := filepath.Join(workspace, "home", ".openclaw", "tasks")
|
||||
if err := os.MkdirAll(tasksRoot, 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
databasePath := filepath.Join(tasksRoot, "runs.sqlite")
|
||||
if err := os.WriteFile(databasePath, []byte("healthy-or-unknown"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
errorMessage := "gateway health check timed out"
|
||||
|
||||
moved, err := quarantineCorruptLegacyOpenClawTaskState(workspace, 7, &errorMessage)
|
||||
if err != nil || moved {
|
||||
t.Fatalf("non-corruption result = moved %v, err %v", moved, err)
|
||||
}
|
||||
if _, err := os.Stat(databasePath); err != nil {
|
||||
t.Fatalf("non-corrupt database changed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,10 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrRuntimeAgentConflict = errors.New("runtime agent conflict")
|
||||
var (
|
||||
ErrRuntimeAgentConflict = errors.New("runtime agent conflict")
|
||||
ErrRuntimeAgentNotFound = errors.New("runtime agent resource not found")
|
||||
)
|
||||
|
||||
type RuntimeAgentClient interface {
|
||||
Health(ctx context.Context, endpoint string) error
|
||||
@@ -142,6 +145,9 @@ func (c *runtimeAgentHTTPClient) do(ctx context.Context, method, endpoint, path
|
||||
if resp.StatusCode == http.StatusConflict {
|
||||
return fmt.Errorf("%w: %s", ErrRuntimeAgentConflict, string(msg))
|
||||
}
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return fmt.Errorf("%w: %s", ErrRuntimeAgentNotFound, string(msg))
|
||||
}
|
||||
return fmt.Errorf("runtime agent status %d: %s", resp.StatusCode, string(msg))
|
||||
}
|
||||
if out == nil {
|
||||
|
||||
@@ -247,6 +247,20 @@ func TestRuntimeAgentClientNonConflictErrorIncludesStatusAndBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeAgentClientDeleteGatewayReturnsNotFoundSentinel(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte("gateway not found"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewRuntimeAgentClientWithHTTPClient("token", server.Client())
|
||||
err := client.DeleteGateway(context.Background(), server.URL, "gw-missing")
|
||||
if !errors.Is(err, ErrRuntimeAgentNotFound) {
|
||||
t.Fatalf("DeleteGateway error = %v, want ErrRuntimeAgentNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func runtimeAgentIntPtr(v int) *int {
|
||||
return &v
|
||||
}
|
||||
|
||||
@@ -542,10 +542,19 @@ func (s *RuntimeScheduler) reconcile(ctx context.Context) error {
|
||||
continue
|
||||
}
|
||||
if binding != nil {
|
||||
if err := s.syncInstanceStateFromBinding(ctx, instance, binding); err != nil {
|
||||
errs = append(errs, fmt.Errorf("sync desired instance %d from binding: %w", instance.ID, err))
|
||||
stale, staleErr := s.cleanupStaleInstanceBinding(ctx, instance, binding)
|
||||
if staleErr != nil {
|
||||
errs = append(errs, fmt.Errorf("clean stale running binding for desired instance %d: %w", instance.ID, staleErr))
|
||||
continue
|
||||
}
|
||||
if stale {
|
||||
binding = nil
|
||||
} else if err := s.syncInstanceStateFromBinding(ctx, instance, binding); err != nil {
|
||||
errs = append(errs, fmt.Errorf("sync desired instance %d from binding: %w", instance.ID, err))
|
||||
continue
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
continue
|
||||
}
|
||||
binding, err = s.bindingRepo.GetByInstanceID(ctx, instance.ID)
|
||||
if err != nil {
|
||||
@@ -553,10 +562,19 @@ func (s *RuntimeScheduler) reconcile(ctx context.Context) error {
|
||||
continue
|
||||
}
|
||||
if binding != nil {
|
||||
if err := s.syncInstanceStateFromBinding(ctx, instance, binding); err != nil {
|
||||
errs = append(errs, fmt.Errorf("sync desired instance %d from binding: %w", instance.ID, err))
|
||||
stale, staleErr := s.cleanupStaleInstanceBinding(ctx, instance, binding)
|
||||
if staleErr != nil {
|
||||
errs = append(errs, fmt.Errorf("clean stale binding for desired instance %d: %w", instance.ID, staleErr))
|
||||
continue
|
||||
}
|
||||
if stale {
|
||||
binding = nil
|
||||
} else if err := s.syncInstanceStateFromBinding(ctx, instance, binding); err != nil {
|
||||
errs = append(errs, fmt.Errorf("sync desired instance %d from binding: %w", instance.ID, err))
|
||||
continue
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
continue
|
||||
}
|
||||
if assignErr := s.assignInstance(ctx, instance); assignErr != nil {
|
||||
if errors.Is(assignErr, errRuntimeScaleOutPending) || errors.Is(assignErr, errRuntimeGatewayStartPending) {
|
||||
@@ -615,10 +633,16 @@ func (s *RuntimeScheduler) reconcileCreatingInstance(ctx context.Context, instan
|
||||
return []error{fmt.Errorf("get binding for creating instance %d: %w", instance.ID, err)}
|
||||
}
|
||||
if binding != nil {
|
||||
if err := s.syncInstanceStateFromBinding(ctx, instance, binding); err != nil {
|
||||
return []error{fmt.Errorf("sync creating instance %d from binding: %w", instance.ID, err)}
|
||||
stale, staleErr := s.cleanupStaleInstanceBinding(ctx, instance, binding)
|
||||
if staleErr != nil {
|
||||
return []error{fmt.Errorf("clean stale binding for creating instance %d: %w", instance.ID, staleErr)}
|
||||
}
|
||||
if !stale {
|
||||
if err := s.syncInstanceStateFromBinding(ctx, instance, binding); err != nil {
|
||||
return []error{fmt.Errorf("sync creating instance %d from binding: %w", instance.ID, err)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := s.assignInstance(ctx, instance); err != nil {
|
||||
if errors.Is(err, errRuntimeScaleOutPending) || errors.Is(err, errRuntimeGatewayStartPending) {
|
||||
@@ -631,6 +655,33 @@ func (s *RuntimeScheduler) reconcileCreatingInstance(ctx context.Context, instan
|
||||
return nil
|
||||
}
|
||||
|
||||
// cleanupStaleInstanceBinding removes only a binding from an older runtime
|
||||
// generation. A newer binding may have been created after the instance snapshot
|
||||
// was read and must never be removed by this reconciliation pass.
|
||||
func (s *RuntimeScheduler) cleanupStaleInstanceBinding(ctx context.Context, instance models.Instance, binding *models.InstanceRuntimeBinding) (bool, error) {
|
||||
if binding == nil || binding.Generation >= instance.RuntimeGeneration {
|
||||
return false, nil
|
||||
}
|
||||
if s.bindingRepo == nil {
|
||||
return false, fmt.Errorf("runtime binding repository is not configured")
|
||||
}
|
||||
if s.podRepo != nil && s.agentClient != nil && strings.TrimSpace(binding.GatewayID) != "" {
|
||||
pod, err := s.podRepo.GetByID(ctx, binding.RuntimePodID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("get runtime pod %d: %w", binding.RuntimePodID, err)
|
||||
}
|
||||
if pod != nil && pod.AgentEndpoint != nil && strings.TrimSpace(*pod.AgentEndpoint) != "" {
|
||||
if err := s.agentClient.DeleteGateway(ctx, strings.TrimSpace(*pod.AgentEndpoint), binding.GatewayID); err != nil && !errors.Is(err, ErrRuntimeAgentNotFound) {
|
||||
return false, fmt.Errorf("delete stale gateway %q: %w", binding.GatewayID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := s.bindingRepo.DeleteByInstanceIDAndReleaseSlot(ctx, instance.ID, binding.RuntimePodID); err != nil {
|
||||
return false, fmt.Errorf("delete stale binding and release slot: %w", err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (s *RuntimeScheduler) syncInstanceStateFromBinding(ctx context.Context, instance models.Instance, binding *models.InstanceRuntimeBinding) error {
|
||||
if s == nil || s.instanceRepo == nil || binding == nil {
|
||||
return nil
|
||||
@@ -1010,6 +1061,11 @@ func (s *RuntimeScheduler) prepareGatewayStartExcludingPorts(
|
||||
return nil, fmt.Errorf("build runtime gateway environment: %w", err)
|
||||
}
|
||||
uid, gid := runtimeGatewayLinuxIDs(instance.ID, environment)
|
||||
if runtimeType == RuntimeTypeOpenClaw {
|
||||
if err := ensureOpenClawPluginLayoutCompatibility(workspacePath, uid, gid); err != nil {
|
||||
return nil, fmt.Errorf("prepare OpenClaw workspace compatibility: %w", err)
|
||||
}
|
||||
}
|
||||
reservedBinding, err := s.reserveGatewayPortExcludingPorts(ctx, instance, runtimeType, pod, workspacePath, excludedPorts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1319,7 +1375,8 @@ func isRecoverableRuntimeSchedulingError(instance models.Instance) bool {
|
||||
}
|
||||
message := strings.TrimSpace(*instance.RuntimeErrorMessage)
|
||||
return message == fmt.Sprintf("no schedulable %s runtime pod", runtimeType) ||
|
||||
strings.Contains(message, fmt.Sprintf("no schedulable %s runtime pod:", runtimeType))
|
||||
strings.Contains(message, fmt.Sprintf("no schedulable %s runtime pod:", runtimeType)) ||
|
||||
message == "gateway start failed: exit status 1"
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
|
||||
@@ -124,6 +124,118 @@ func TestRuntimeSchedulerAssignsCreatingInstanceToReadyPod(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeSchedulerReplacesOlderGenerationBindingForCreatingInstance(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
endpoint := "http://agent.runtime"
|
||||
workspacePath := "/workspaces/openclaw/user-45/instance-18"
|
||||
instanceRepo := newFakeRuntimeInstanceRepo()
|
||||
podRepo := &fakeRuntimePodRepo{
|
||||
pods: map[int64]*models.RuntimePod{
|
||||
9: {ID: 9, RuntimeType: RuntimeTypeOpenClaw, AgentEndpoint: &endpoint, State: "ready", Capacity: 2},
|
||||
},
|
||||
schedulable: []models.RuntimePod{
|
||||
{ID: 9, RuntimeType: RuntimeTypeOpenClaw, AgentEndpoint: &endpoint, State: "ready", Capacity: 2},
|
||||
},
|
||||
}
|
||||
bindingRepo := newFakeRuntimeBindingRepo()
|
||||
bindingRepo.bindings[18] = &models.InstanceRuntimeBinding{
|
||||
InstanceID: 18,
|
||||
RuntimePodID: 9,
|
||||
RuntimeType: RuntimeTypeOpenClaw,
|
||||
GatewayID: "gw-18-3",
|
||||
GatewayPort: RuntimeGatewayPortStart,
|
||||
State: "error",
|
||||
Generation: 3,
|
||||
}
|
||||
agent := &fakeRuntimeAgentClient{createResponse: &RuntimeAgentCreateGatewayResponse{
|
||||
GatewayID: "gw-18-4",
|
||||
Status: "running",
|
||||
}}
|
||||
scheduler := NewRuntimeScheduler(
|
||||
instanceRepo,
|
||||
podRepo,
|
||||
bindingRepo,
|
||||
&fakeRuntimeRolloutRepo{},
|
||||
agent,
|
||||
&fakeRuntimeEventService{},
|
||||
nil,
|
||||
&fakeRuntimeDeploymentService{},
|
||||
time.Second,
|
||||
)
|
||||
instance := models.Instance{
|
||||
ID: 18,
|
||||
UserID: 45,
|
||||
Type: RuntimeTypeOpenClaw,
|
||||
RuntimeType: RuntimeBackendGateway,
|
||||
InstanceMode: InstanceModeLite,
|
||||
Status: "creating",
|
||||
WorkspacePath: &workspacePath,
|
||||
RuntimeGeneration: 4,
|
||||
MemoryGB: 2,
|
||||
DiskGB: 8,
|
||||
}
|
||||
|
||||
if errs := scheduler.reconcileCreatingInstance(ctx, instance); len(errs) != 0 {
|
||||
t.Fatalf("reconcileCreatingInstance errors = %v", errs)
|
||||
}
|
||||
|
||||
if len(agent.deleteRequests) != 1 || agent.deleteRequests[0].gatewayID != "gw-18-3" {
|
||||
t.Fatalf("stale gateway delete requests = %#v", agent.deleteRequests)
|
||||
}
|
||||
if len(agent.createRequests) != 1 || agent.createRequests[0].req.Generation != 4 {
|
||||
t.Fatalf("new gateway create requests = %#v", agent.createRequests)
|
||||
}
|
||||
binding := bindingRepo.bindings[18]
|
||||
if binding == nil || binding.Generation != 4 || binding.State != "running" {
|
||||
t.Fatalf("replacement binding = %+v, want running generation 4", binding)
|
||||
}
|
||||
state := instanceRepo.runtimeStates[18]
|
||||
if state.status != "running" || state.generation != 4 {
|
||||
t.Fatalf("instance runtime state = %+v, want running generation 4", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeSchedulerDoesNotDeleteNewerGenerationBinding(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
instanceRepo := newFakeRuntimeInstanceRepo()
|
||||
bindingRepo := newFakeRuntimeBindingRepo()
|
||||
bindingRepo.bindings[19] = &models.InstanceRuntimeBinding{
|
||||
InstanceID: 19,
|
||||
RuntimePodID: 9,
|
||||
GatewayID: "gw-19-5",
|
||||
State: "running",
|
||||
Generation: 5,
|
||||
}
|
||||
agent := &fakeRuntimeAgentClient{}
|
||||
scheduler := NewRuntimeScheduler(
|
||||
instanceRepo,
|
||||
&fakeRuntimePodRepo{},
|
||||
bindingRepo,
|
||||
&fakeRuntimeRolloutRepo{},
|
||||
agent,
|
||||
&fakeRuntimeEventService{},
|
||||
nil,
|
||||
&fakeRuntimeDeploymentService{},
|
||||
time.Second,
|
||||
)
|
||||
|
||||
if errs := scheduler.reconcileCreatingInstance(ctx, models.Instance{
|
||||
ID: 19,
|
||||
Status: "creating",
|
||||
RuntimeGeneration: 4,
|
||||
}); len(errs) != 0 {
|
||||
t.Fatalf("reconcileCreatingInstance errors = %v", errs)
|
||||
}
|
||||
|
||||
if len(agent.deleteRequests) != 0 || bindingRepo.deleteAndReleaseCalls[19] != 0 {
|
||||
t.Fatalf("newer binding was deleted: agent=%#v calls=%d", agent.deleteRequests, bindingRepo.deleteAndReleaseCalls[19])
|
||||
}
|
||||
state := instanceRepo.runtimeStates[19]
|
||||
if state.status != "running" || state.generation != 5 {
|
||||
t.Fatalf("instance runtime state = %+v, want running generation 5", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeSchedulerSkipsAgentRejectedGatewayPort(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
endpoint := "http://agent.runtime"
|
||||
@@ -1219,6 +1331,60 @@ func TestRuntimeSchedulerReconcileRetriesRecoverableNoSchedulableError(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeSchedulerReconcileRetriesUnboundGatewayStartFailureAfterRuntimeUpgrade(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
endpoint := "http://agent.runtime"
|
||||
workspacePath := "/workspaces/openclaw/user-46/instance-970"
|
||||
errorMessage := "gateway start failed: exit status 1"
|
||||
instanceRepo := newFakeRuntimeInstanceRepo()
|
||||
instanceRepo.desiredRunning = []models.Instance{{
|
||||
ID: 970,
|
||||
UserID: 46,
|
||||
Type: RuntimeTypeOpenClaw,
|
||||
RuntimeType: RuntimeBackendGateway,
|
||||
InstanceMode: InstanceModeLite,
|
||||
Status: "error",
|
||||
RuntimeErrorMessage: &errorMessage,
|
||||
MemoryGB: 1,
|
||||
DiskGB: 1,
|
||||
WorkspacePath: &workspacePath,
|
||||
RuntimeGeneration: 26,
|
||||
}}
|
||||
podRepo := &fakeRuntimePodRepo{
|
||||
pods: map[int64]*models.RuntimePod{
|
||||
51: {ID: 51, RuntimeType: RuntimeTypeOpenClaw, AgentEndpoint: &endpoint, State: "ready", Capacity: 100},
|
||||
},
|
||||
schedulable: []models.RuntimePod{
|
||||
{ID: 51, RuntimeType: RuntimeTypeOpenClaw, AgentEndpoint: &endpoint, State: "ready", Capacity: 100},
|
||||
},
|
||||
}
|
||||
agent := &fakeRuntimeAgentClient{
|
||||
createResponse: &RuntimeAgentCreateGatewayResponse{GatewayID: "gw-970-26", Port: 20000, Status: "running"},
|
||||
}
|
||||
scheduler := NewRuntimeScheduler(
|
||||
instanceRepo,
|
||||
podRepo,
|
||||
newFakeRuntimeBindingRepo(),
|
||||
&fakeRuntimeRolloutRepo{},
|
||||
agent,
|
||||
NewRuntimeEventService(nil),
|
||||
nil,
|
||||
&fakeRuntimeDeploymentService{},
|
||||
time.Second,
|
||||
)
|
||||
|
||||
if err := scheduler.reconcile(ctx); err != nil {
|
||||
t.Fatalf("reconcile returned error: %v", err)
|
||||
}
|
||||
if got := len(agent.createRequests); got != 1 {
|
||||
t.Fatalf("CreateGateway calls = %d, want one retry after the runtime upgrade", got)
|
||||
}
|
||||
state := instanceRepo.runtimeStates[970]
|
||||
if state.status != "running" || state.generation != 26 || state.message != nil {
|
||||
t.Fatalf("runtime state = %+v, want recovered generation 26 without an error", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeSchedulerReconcileSkipsNonRecoverableErrorInstance(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
endpoint := "http://agent.runtime"
|
||||
|
||||
@@ -81,6 +81,7 @@ flowchart LR
|
||||
- 相关改动已部署到 172.16.1.12 环境,并完成一轮自动化回归。
|
||||
- 当前自动化测试已覆盖普通实例 Lite / Pro 模式、Share Link 基础行为、管理端模式展示以及 Team Lite 创建链路。
|
||||
- Lite 批量创建调度已改为可配置并发,容量为 100 的部署允许单 Pod 同时接收 100 个异步 gateway 创建请求;控制面会按 runtime 原子预留端口(OpenClaw 为三端口组,Hermes 为单端口),再调用 runtime agent,并在端口冲突时改用下一组。
|
||||
- OpenClaw Lite 已补充 5.4 与 7.1 workspace 兼容:启动前保留旧版全局插件布局,并为新版 `npm/projects/*/node_modules` 中缺失的全局包创建幂等软链接;显式重试会清理旧 generation binding,且仅在运行时明确报告 SQLite 损坏时,将可选的旧版任务数据库移入实例内 quarantine 后继续启动。
|
||||
- 联调中发现,部分 Lite runtime / agent 能力仍需补齐,主要集中在 Team 任务消费和 Hermes Lite 会话稳定性上。
|
||||
|
||||
## 计划
|
||||
|
||||
Reference in New Issue
Block a user