feat(agent): fall back to the container backend when pi-native fails

Dev starts on pi-native and keeps pi-agent available for a failed turn. Latency tuning stays deferred.
This commit is contained in:
weichen
2026-09-21 15:29:33 +00:00
parent f3b1209207
commit 127cfccccc
8 changed files with 124 additions and 8 deletions
+4 -1
View File
@@ -46,7 +46,10 @@ func NewBackend(name string, cfg *config.Config) Backend {
case "pi-agent":
inner = &PiAgentBackend{cfg: cfg}
case "pi-native":
inner = &PiNativeBackend{cfg: cfg}
inner = &fallbackBackend{
primary: NewPiNativeBackend(cfg),
fallback: &PiAgentBackend{cfg: cfg},
}
default:
inner = &StubBackend{}
}
+46
View File
@@ -0,0 +1,46 @@
package agent
import (
"context"
"strings"
)
// fallbackBackend runs pi-native first. A failed turn is retried on the container pi-agent.
type fallbackBackend struct {
primary Backend
fallback Backend
}
func (f *fallbackBackend) Name() string {
if f.primary == nil {
return "pi-native"
}
return f.primary.Name()
}
func (f *fallbackBackend) Execute(ctx context.Context, req Request) (*Result, error) {
res, err := f.primary.Execute(ctx, req)
if ctx.Err() != nil || succeeded(res, err) || f.fallback == nil {
return res, err
}
fb, fbErr := f.fallback.Execute(ctx, req)
if !succeeded(fb, fbErr) {
return res, err
}
note := "pi-native failed; served by pi-agent"
if res != nil && strings.TrimSpace(res.ErrorReason) != "" {
note += ": " + strings.TrimSpace(res.ErrorReason)
} else if err != nil {
note += ": " + err.Error()
}
if strings.TrimSpace(fb.Logs) == "" {
fb.Logs = note
} else {
fb.Logs = note + "\n" + fb.Logs
}
return fb, nil
}
func succeeded(res *Result, err error) bool {
return err == nil && res != nil && res.Success
}
+63
View File
@@ -0,0 +1,63 @@
package agent
import (
"context"
"errors"
"strings"
"testing"
)
type scriptBackend struct {
name string
res *Result
err error
calls int
}
func (s *scriptBackend) Name() string { return s.name }
func (s *scriptBackend) Execute(ctx context.Context, req Request) (*Result, error) {
s.calls++
return s.res, s.err
}
func TestFallbackKeepsNativeSuccess(t *testing.T) {
primary := &scriptBackend{name: "pi-native", res: &Result{Success: true, Logs: "native"}}
spare := &scriptBackend{name: "pi-agent", res: &Result{Success: true}}
got, err := (&fallbackBackend{primary: primary, fallback: spare}).Execute(context.Background(), Request{})
if err != nil || !got.Success || got.Logs != "native" || spare.calls != 0 {
t.Fatalf("got %#v err=%v spare=%d", got, err, spare.calls)
}
}
func TestFallbackUsesContainerAfterNativeFailure(t *testing.T) {
primary := &scriptBackend{name: "pi-native", res: &Result{Success: false, ErrorReason: "missing target deliverable"}}
spare := &scriptBackend{name: "pi-agent", res: &Result{Success: true, Logs: "container"}}
got, err := (&fallbackBackend{primary: primary, fallback: spare}).Execute(context.Background(), Request{})
if err != nil || !got.Success || spare.calls != 1 {
t.Fatalf("got %#v err=%v calls=%d", got, err, spare.calls)
}
if !strings.Contains(got.Logs, "served by pi-agent") || !strings.Contains(got.Logs, "missing target deliverable") {
t.Fatalf("logs %q", got.Logs)
}
}
func TestFallbackReturnsNativeWhenContainerAlsoFails(t *testing.T) {
primary := &scriptBackend{name: "pi-native", err: errors.New("native down")}
spare := &scriptBackend{name: "pi-agent", res: &Result{Success: false, ErrorReason: "runner down"}}
got, err := (&fallbackBackend{primary: primary, fallback: spare}).Execute(context.Background(), Request{})
if err == nil || err.Error() != "native down" || got != nil || spare.calls != 1 {
t.Fatalf("got %#v err=%v", got, err)
}
}
func TestFallbackStopsWhenContextCancelled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
primary := &scriptBackend{name: "pi-native", err: context.Canceled}
spare := &scriptBackend{name: "pi-agent", res: &Result{Success: true}}
_, err := (&fallbackBackend{primary: primary, fallback: spare}).Execute(ctx, Request{})
if !errors.Is(err, context.Canceled) || spare.calls != 0 {
t.Fatalf("err=%v spare=%d", err, spare.calls)
}
}