fix(skills): fall back to copy when link publication fails
Creating the staged symlink usually succeeds, so the link strategy really fails at publish time: renameSkillPathNoReplace has no atomic no-clobber primitive for a symlink source and refuses it whenever the kernel flag is unavailable (NFS, FUSE, overlayfs). Gating the copy fallback on staging alone therefore left every non-universal Agent unconfigured on exactly the filesystems the fallback exists to support. Retry the whole target transaction as a direct copy after a failure in any phase, but only when the failed attempt fully restored the originals. The converter also re-adds the replacement backups the link plan deliberately skips for destinations already pointing at canonical, which a copy must replace and no-replace publication would otherwise reject with EEXIST.
This commit is contained in:
1 parent
b4129c467d
commit
7924e84fb6
2 files changed
+325
-39
No files matched your search
+121
-39
@@ -188,6 +188,11 @@ const (
|
||||
skillSetupBackupReplace = "same-name Skill"
|
||||
)
|
||||
|
||||
// errSkillSetupStateUncertain marks a failure whose rollback could not fully
|
||||
// restore the Agent directory. The target is left in a partially modified
|
||||
// state, so no automatic retry may run over it.
|
||||
var errSkillSetupStateUncertain = errors.New("Skill setup 状态不确定")
|
||||
|
||||
func newSkillSetupCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "setup",
|
||||
@@ -2010,7 +2015,7 @@ func backupSkillSetupTarget(home string, planned []skillSetupBackup, out io.Writ
|
||||
backup, err := skillSetupBackupAndRemove(home, item.Path)
|
||||
if err != nil {
|
||||
if restoreErr := restoreSkillSetupTarget(nil, backups); restoreErr != nil {
|
||||
return nil, errors.Join(err, fmt.Errorf("恢复已备份 Skill 失败: %w", restoreErr))
|
||||
return nil, errors.Join(err, fmt.Errorf("恢复已备份 Skill 失败: %w", restoreErr), errSkillSetupStateUncertain)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
@@ -2036,7 +2041,7 @@ func publishSkillSetupTarget(staged []skillSetupStagedDir, backups []skillSetupB
|
||||
if err != nil {
|
||||
publishErr := fmt.Errorf("发布 Skill 失败 %s: %w", item.dest, err)
|
||||
if restoreErr := restoreSkillSetupTarget(published, backups); restoreErr != nil {
|
||||
return errors.Join(publishErr, fmt.Errorf("Skill setup 回滚不完整: %w", restoreErr))
|
||||
return errors.Join(publishErr, fmt.Errorf("Skill setup 回滚不完整: %w", restoreErr), errSkillSetupStateUncertain)
|
||||
}
|
||||
return publishErr
|
||||
}
|
||||
@@ -2045,6 +2050,90 @@ func publishSkillSetupTarget(staged []skillSetupStagedDir, backups []skillSetupB
|
||||
return nil
|
||||
}
|
||||
|
||||
// skillSetupAttemptPhase identifies which stage of a single target transaction
|
||||
// failed so the caller can keep its per-phase diagnostics.
|
||||
type skillSetupAttemptPhase int
|
||||
|
||||
const (
|
||||
skillSetupAttemptDone skillSetupAttemptPhase = iota
|
||||
skillSetupAttemptStage
|
||||
skillSetupAttemptBackup
|
||||
skillSetupAttemptPublish
|
||||
)
|
||||
|
||||
// skillSetupCopyFallbackTarget converts a canonical-link target into a
|
||||
// direct-copy target. buildSkillSetupPlan omits the replacement backup for a
|
||||
// destination that already points at canonical, because linking would leave it
|
||||
// untouched. A copy must replace that destination instead, and
|
||||
// PublishSkillPathNoReplace refuses to overwrite it, so those paths are
|
||||
// re-added before the retry.
|
||||
func skillSetupCopyFallbackTarget(plan *skillSetupPlan, target skillSetupTargetPlan) (skillSetupTargetPlan, error) {
|
||||
fallback := target
|
||||
fallback.LinkCanonical = false
|
||||
fallback.Backups = append([]skillSetupBackup(nil), target.Backups...)
|
||||
seen := make(map[string]bool, len(fallback.Backups))
|
||||
for _, backup := range fallback.Backups {
|
||||
seen[backup.Path] = true
|
||||
}
|
||||
replacements := []string{target.Destination}
|
||||
if plan.Mode == skillSetupModeMulti {
|
||||
replacements = make([]string, 0, len(plan.MultiSkillNames))
|
||||
for _, name := range plan.MultiSkillNames {
|
||||
replacements = append(replacements, filepath.Join(target.Destination, name))
|
||||
}
|
||||
}
|
||||
for _, path := range replacements {
|
||||
if seen[path] {
|
||||
continue
|
||||
}
|
||||
if _, err := skillSetupLstat(path); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return skillSetupTargetPlan{}, fmt.Errorf("检查将被替换的 Skill 失败 %s: %w", path, err)
|
||||
}
|
||||
seen[path] = true
|
||||
fallback.Backups = append(fallback.Backups, skillSetupBackup{Path: path, Reason: skillSetupBackupReplace})
|
||||
}
|
||||
sort.Slice(fallback.Backups, func(i, j int) bool { return fallback.Backups[i].Path < fallback.Backups[j].Path })
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
// runSkillSetupTargetAttempt performs one complete stage → backup → publish
|
||||
// transaction for a single Agent target. On failure it reports the phase that
|
||||
// failed; unless the error wraps errSkillSetupStateUncertain the original
|
||||
// directories are restored, so the caller may retry with another strategy.
|
||||
func runSkillSetupTargetAttempt(
|
||||
plan *skillSetupPlan,
|
||||
target skillSetupTargetPlan,
|
||||
home string,
|
||||
out, errOut io.Writer,
|
||||
) ([]skillSetupStagedDir, skillSetupAttemptPhase, error) {
|
||||
stageRoot, staged, stageErr := stageSkillSetupTarget(plan, target)
|
||||
if stageErr != nil {
|
||||
return nil, skillSetupAttemptStage, stageErr
|
||||
}
|
||||
backups, backupErr := backupSkillSetupTarget(home, target.Backups, out)
|
||||
if backupErr != nil {
|
||||
if cleanupErr := skillSetupRemoveAll(stageRoot); cleanupErr != nil {
|
||||
backupErr = errors.Join(backupErr, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr))
|
||||
}
|
||||
return nil, skillSetupAttemptBackup, backupErr
|
||||
}
|
||||
publishErr := publishSkillSetupTarget(staged, backups)
|
||||
cleanupErr := skillSetupRemoveAll(stageRoot)
|
||||
if publishErr != nil {
|
||||
if cleanupErr != nil {
|
||||
publishErr = errors.Join(publishErr, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr))
|
||||
}
|
||||
return nil, skillSetupAttemptPublish, publishErr
|
||||
}
|
||||
if cleanupErr != nil {
|
||||
fmt.Fprintf(errOut, " ⚠️ Skill staging 清理失败 %s: %v\n", stageRoot, cleanupErr)
|
||||
}
|
||||
return staged, skillSetupAttemptDone, nil
|
||||
}
|
||||
|
||||
func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (installed, skipped int, err error) {
|
||||
home, homeErr := skillSetupUserHomeDir()
|
||||
hasCanonicalDependents := false
|
||||
@@ -2086,49 +2175,42 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install
|
||||
continue
|
||||
}
|
||||
|
||||
stageRoot, staged, stageErr := stageSkillSetupTarget(plan, target)
|
||||
if stageErr != nil && target.LinkCanonical {
|
||||
staged, phase, attemptErr := runSkillSetupTargetAttempt(plan, target, home, out, errOut)
|
||||
// A canonical link can fail either while staging (no symlink support) or
|
||||
// while publishing (the no-replace rename fallback used on NFS, FUSE and
|
||||
// overlayfs refuses a symlink source). Both mean links are unusable here,
|
||||
// so retry the whole transaction as a direct copy — but only when the
|
||||
// failed attempt left the original directories intact.
|
||||
if attemptErr != nil && target.LinkCanonical && !errors.Is(attemptErr, errSkillSetupStateUncertain) {
|
||||
fmt.Fprintf(errOut, " ℹ️ %s 无法使用共享安装方式,正在自动改用兼容安装\n", target.Destination)
|
||||
fallback := target
|
||||
fallback.LinkCanonical = false
|
||||
stageRoot, staged, stageErr = stageSkillSetupTarget(plan, fallback)
|
||||
fallback, fallbackErr := skillSetupCopyFallbackTarget(plan, target)
|
||||
if fallbackErr != nil {
|
||||
attemptErr = errors.Join(attemptErr, fallbackErr)
|
||||
} else {
|
||||
staged, phase, attemptErr = runSkillSetupTargetAttempt(plan, fallback, home, out, errOut)
|
||||
}
|
||||
}
|
||||
if stageErr != nil {
|
||||
if isCanonical && hasCanonicalDependents {
|
||||
return installed, skipped + perTarget, fmt.Errorf("canonical Skill staging 失败 %s: %w", target.Destination, stageErr)
|
||||
if attemptErr != nil {
|
||||
switch phase {
|
||||
case skillSetupAttemptBackup:
|
||||
if isCanonical && hasCanonicalDependents {
|
||||
return installed, skipped + perTarget, fmt.Errorf("canonical Skill 备份失败,已执行回滚 %s: %w", target.Destination, attemptErr)
|
||||
}
|
||||
fmt.Fprintf(errOut, " ✗ Skill 备份失败,已执行回滚,跳过整个 Agent 目标 %s: %v\n", target.Destination, attemptErr)
|
||||
case skillSetupAttemptPublish:
|
||||
if isCanonical && hasCanonicalDependents {
|
||||
return installed, skipped + perTarget, fmt.Errorf("canonical Skill 发布失败,已执行回滚 %s: %w", target.Destination, attemptErr)
|
||||
}
|
||||
fmt.Fprintf(errOut, " ✗ Skill 发布失败,已执行回滚,跳过整个 Agent 目标 %s: %v\n", target.Destination, attemptErr)
|
||||
default:
|
||||
if isCanonical && hasCanonicalDependents {
|
||||
return installed, skipped + perTarget, fmt.Errorf("canonical Skill staging 失败 %s: %w", target.Destination, attemptErr)
|
||||
}
|
||||
fmt.Fprintf(errOut, " ✗ Skill staging 失败,保留原集合 %s: %v\n", target.Destination, attemptErr)
|
||||
}
|
||||
fmt.Fprintf(errOut, " ✗ Skill staging 失败,保留原集合 %s: %v\n", target.Destination, stageErr)
|
||||
skipped += perTarget
|
||||
continue
|
||||
}
|
||||
backups, backupErr := backupSkillSetupTarget(home, target.Backups, out)
|
||||
if backupErr != nil {
|
||||
if cleanupErr := skillSetupRemoveAll(stageRoot); cleanupErr != nil {
|
||||
backupErr = errors.Join(backupErr, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr))
|
||||
}
|
||||
if isCanonical && hasCanonicalDependents {
|
||||
return installed, skipped + perTarget, fmt.Errorf("canonical Skill 备份失败,已执行回滚 %s: %w", target.Destination, backupErr)
|
||||
}
|
||||
fmt.Fprintf(errOut, " ✗ Skill 备份失败,已执行回滚,跳过整个 Agent 目标 %s: %v\n", target.Destination, backupErr)
|
||||
skipped += perTarget
|
||||
continue
|
||||
}
|
||||
publishErr := publishSkillSetupTarget(staged, backups)
|
||||
cleanupErr := skillSetupRemoveAll(stageRoot)
|
||||
if publishErr != nil {
|
||||
if cleanupErr != nil {
|
||||
publishErr = errors.Join(publishErr, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr))
|
||||
}
|
||||
if isCanonical && hasCanonicalDependents {
|
||||
return installed, skipped + perTarget, fmt.Errorf("canonical Skill 发布失败,已执行回滚 %s: %w", target.Destination, publishErr)
|
||||
}
|
||||
fmt.Fprintf(errOut, " ✗ Skill 发布失败,已执行回滚,跳过整个 Agent 目标 %s: %v\n", target.Destination, publishErr)
|
||||
skipped += perTarget
|
||||
continue
|
||||
}
|
||||
if cleanupErr != nil {
|
||||
fmt.Fprintf(errOut, " ⚠️ Skill staging 清理失败 %s: %v\n", stageRoot, cleanupErr)
|
||||
}
|
||||
for _, item := range staged {
|
||||
fmt.Fprintf(out, " ✓ %s\n", item.dest)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user