fix(openclaw): recover legacy lite instances during upgrade (#178)

This commit is contained in:
Li-Day-Day
2026-08-10 13:00:04 +08:00
committed by GitHub
parent 624b4540af
commit a234f9c3af
10 changed files with 865 additions and 13 deletions
+4 -1
View File
@@ -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
+36 -1
View File
@@ -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
}
+67 -10
View File
@@ -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 会话稳定性上。
## 计划