1502 lines
42 KiB
Go
1502 lines
42 KiB
Go
package bootstrap
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"iop/apps/agent/internal/clientprocess"
|
|
"iop/apps/agent/internal/host"
|
|
"iop/apps/agent/internal/localcontrol"
|
|
"iop/apps/agent/internal/projectlog"
|
|
"iop/apps/agent/internal/taskloop"
|
|
"iop/packages/go/agentconfig"
|
|
"iop/packages/go/agenttask"
|
|
"iop/packages/go/agentworkspace"
|
|
iop "iop/proto/gen/iop"
|
|
)
|
|
|
|
type testComponent struct {
|
|
name string
|
|
trace *traceLog
|
|
start error
|
|
stop error
|
|
once sync.Once
|
|
}
|
|
|
|
type traceLog struct {
|
|
mu sync.Mutex
|
|
events []string
|
|
}
|
|
|
|
func (l *traceLog) add(event string) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
l.events = append(l.events, event)
|
|
}
|
|
|
|
func (l *traceLog) snapshot() []string {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
return append([]string(nil), l.events...)
|
|
}
|
|
|
|
func (c *testComponent) Name() string { return c.name }
|
|
|
|
func (c *testComponent) Start(ctx context.Context) error {
|
|
c.trace.add("start:" + c.name)
|
|
return c.start
|
|
}
|
|
|
|
func (c *testComponent) Stop(ctx context.Context) error {
|
|
c.trace.add("stop:" + c.name)
|
|
return c.stop
|
|
}
|
|
|
|
// nilComponent is a host.Component that is nil, used to test nil rejection.
|
|
var nilComponent host.Component = nil
|
|
|
|
// typedNilComponent is a *testComponent pointer that is nil but held in a
|
|
// host.Component interface. It is not equal to nil as an interface value,
|
|
// so the bootstrap layer must detect it via reflection.
|
|
var typedNilComponent host.Component = (*testComponent)(nil)
|
|
|
|
func TestNewModuleRejectsInvalidDependencies(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
give []host.Component
|
|
err string
|
|
}{
|
|
{
|
|
name: "empty component list",
|
|
give: nil,
|
|
err: "at least one component is required",
|
|
},
|
|
{
|
|
name: "nil component",
|
|
give: []host.Component{nilComponent, &testComponent{name: "a"}},
|
|
err: "component at index 0 is nil",
|
|
},
|
|
{
|
|
name: "typed-nil component",
|
|
give: []host.Component{typedNilComponent, &testComponent{name: "a"}},
|
|
err: "component at index 0 is nil",
|
|
},
|
|
{
|
|
name: "duplicate namer names",
|
|
give: []host.Component{
|
|
&testComponent{name: "same"},
|
|
&testComponent{name: "same"},
|
|
},
|
|
err: "duplicate component name",
|
|
},
|
|
{
|
|
name: "unique names succeed",
|
|
give: []host.Component{
|
|
&testComponent{name: "alpha"},
|
|
&testComponent{name: "beta"},
|
|
},
|
|
err: "",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
_, err := NewModule(tt.give...)
|
|
if tt.err == "" {
|
|
if err != nil {
|
|
t.Fatalf("NewModule() unexpected error: %v", err)
|
|
}
|
|
return
|
|
}
|
|
if err == nil {
|
|
t.Fatalf("NewModule() expected error containing %q, got nil", tt.err)
|
|
}
|
|
if got := err.Error(); !contains(got, tt.err) {
|
|
t.Fatalf("NewModule() error = %q, want substring %q", got, tt.err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestModuleRunDelegatesLifecycle(t *testing.T) {
|
|
trace := &traceLog{}
|
|
components := []*testComponent{
|
|
{name: "first", trace: trace},
|
|
{name: "second", trace: trace},
|
|
{name: "third", trace: trace},
|
|
}
|
|
comps := make([]host.Component, len(components))
|
|
for i, c := range components {
|
|
comps[i] = c
|
|
}
|
|
|
|
m, err := NewModule(comps...)
|
|
if err != nil {
|
|
t.Fatalf("NewModule() error = %v", err)
|
|
}
|
|
|
|
if err := m.Run(context.Background()); err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
|
|
// Verify components started in declared order.
|
|
if got, want := trace.snapshot(), []string{
|
|
"start:first", "start:second", "start:third",
|
|
}; !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("trace = %v, want %v", got, want)
|
|
}
|
|
|
|
// Verify status reflects running state.
|
|
status := m.Status()
|
|
if !reflect.DeepEqual(status.Started, []string{"first", "second", "third"}) {
|
|
t.Fatalf("Status().Started = %v, want [first second third]", status.Started)
|
|
}
|
|
|
|
// Close should stop in reverse order.
|
|
if err := m.Close(context.Background()); err != nil {
|
|
t.Fatalf("Close() error = %v", err)
|
|
}
|
|
|
|
if got, want := trace.snapshot(), []string{
|
|
"start:first", "start:second", "start:third",
|
|
"stop:third", "stop:second", "stop:first",
|
|
}; !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("trace = %v, want %v", got, want)
|
|
}
|
|
|
|
// Idempotent Close.
|
|
if err := m.Close(context.Background()); err != nil {
|
|
t.Fatalf("second Close() error = %v, want nil", err)
|
|
}
|
|
}
|
|
|
|
func TestModuleStartupFailureRollsBack(t *testing.T) {
|
|
trace := &traceLog{}
|
|
launchErr := errors.New("launch failed")
|
|
rollbackErr := errors.New("rollback failed")
|
|
|
|
first := &testComponent{name: "first", trace: trace, stop: rollbackErr}
|
|
second := &testComponent{name: "second", trace: trace, start: launchErr}
|
|
comps := []host.Component{first, second}
|
|
|
|
m, err := NewModule(comps...)
|
|
if err != nil {
|
|
t.Fatalf("NewModule() error = %v", err)
|
|
}
|
|
|
|
err = m.Run(context.Background())
|
|
if !errors.Is(err, launchErr) {
|
|
t.Fatalf("Run() error = %v, want launch error identity", err)
|
|
}
|
|
if !errors.Is(err, rollbackErr) {
|
|
t.Fatalf("Run() error = %v, want rollback error identity", err)
|
|
}
|
|
|
|
// First component should have been rolled back.
|
|
if got, want := trace.snapshot(), []string{"start:first", "start:second", "stop:first"}; !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("trace = %v, want %v", got, want)
|
|
}
|
|
|
|
// Status should record failure details.
|
|
status := m.Status()
|
|
if status.Failed != "second" {
|
|
t.Fatalf("Status().Failed = %q, want second", status.Failed)
|
|
}
|
|
if !errors.Is(status.LaunchErr, launchErr) {
|
|
t.Fatalf("Status().LaunchErr = %v, want launch error identity", status.LaunchErr)
|
|
}
|
|
if !errors.Is(status.StopErr, rollbackErr) {
|
|
t.Fatalf("Status().StopErr = %v, want rollback error identity", status.StopErr)
|
|
}
|
|
if !status.Stopped {
|
|
t.Fatal("Status().Stopped = false after completed rollback")
|
|
}
|
|
|
|
// Second Run should be rejected since the host is already stopped.
|
|
err = m.Run(context.Background())
|
|
if err == nil {
|
|
t.Fatalf("second Run() returned nil, want error")
|
|
}
|
|
|
|
// Close after failed startup should be safe (idempotent).
|
|
if err := m.Close(context.Background()); err != nil {
|
|
t.Fatalf("Close() after failed Run() error = %v, want nil", err)
|
|
}
|
|
}
|
|
|
|
func TestNewModuleDuplicateNameMatchesSentinel(t *testing.T) {
|
|
first := &testComponent{name: "dup"}
|
|
second := &testComponent{name: "dup"}
|
|
|
|
_, err := NewModule(first, second)
|
|
if err == nil {
|
|
t.Fatalf("NewModule() returned nil, want duplicate error")
|
|
}
|
|
if !errors.Is(err, ErrDuplicateName) {
|
|
t.Fatalf("NewModule() error = %v, want errors.Is(err, ErrDuplicateName) to be true", err)
|
|
}
|
|
}
|
|
|
|
// nonNamerComponent does not implement host.Namer, so bootstrap must fall
|
|
// back to the synthetic name. Its Start/Stop still work through the
|
|
// resolvedComponent wrapper.
|
|
type nonNamerComponent struct {
|
|
trace *traceLog
|
|
start error
|
|
stop error
|
|
}
|
|
|
|
func (c *nonNamerComponent) Start(ctx context.Context) error {
|
|
c.trace.add("start:nonNamer")
|
|
return c.start
|
|
}
|
|
|
|
func (c *nonNamerComponent) Stop(ctx context.Context) error {
|
|
c.trace.add("stop:nonNamer")
|
|
return c.stop
|
|
}
|
|
|
|
// duplicateFallbackNamer implements host.Namer but returns a name that
|
|
// would collide with the synthetic fallback used by bootstrap for
|
|
// non-Namer components at the same index.
|
|
type duplicateFallbackNamer struct {
|
|
trace *traceLog
|
|
}
|
|
|
|
func (c *duplicateFallbackNamer) Name() string { return "component-x" }
|
|
|
|
func (c *duplicateFallbackNamer) Start(ctx context.Context) error {
|
|
c.trace.add("start:duplicateFallbackNamer")
|
|
return nil
|
|
}
|
|
|
|
func (c *duplicateFallbackNamer) Stop(ctx context.Context) error {
|
|
c.trace.add("stop:duplicateFallbackNamer")
|
|
return nil
|
|
}
|
|
|
|
func TestModulePreservesResolvedComponentNames(t *testing.T) {
|
|
trace := &traceLog{}
|
|
|
|
// nonNamerComp does not implement host.Namer, so bootstrap assigns
|
|
// "component-x" via its fallback. The host must see the same name.
|
|
nonNamerComp := &nonNamerComponent{trace: trace}
|
|
// dupFallbackComp explicitly names itself "component-x", which is the
|
|
// same fallback bootstrap assigns to nonNamerComp at index 0. This
|
|
// must be rejected at construction as a duplicate.
|
|
dupFallbackComp := &duplicateFallbackNamer{trace: trace}
|
|
|
|
_, err := NewModule(nonNamerComp, dupFallbackComp)
|
|
if err == nil {
|
|
t.Fatalf("NewModule() returned nil, want duplicate error")
|
|
}
|
|
if !errors.Is(err, ErrDuplicateName) {
|
|
t.Fatalf("NewModule() error = %v, want errors.Is(err, ErrDuplicateName)", err)
|
|
}
|
|
|
|
// Now verify a nonNamer component at index 0 coexists with a Namer
|
|
// component at index 1 that resolves to a distinct name, and that
|
|
// Status().Started reflects the preserved names.
|
|
trace2 := &traceLog{}
|
|
first := &nonNamerComponent{trace: trace2}
|
|
second := &testComponent{name: "beta", trace: trace2}
|
|
|
|
m, err := NewModule(first, second)
|
|
if err != nil {
|
|
t.Fatalf("NewModule() error = %v", err)
|
|
}
|
|
|
|
if err := m.Run(context.Background()); err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
|
|
status := m.Status()
|
|
if len(status.Started) != 2 {
|
|
t.Fatalf("Status().Started = %v, want 2 entries", status.Started)
|
|
}
|
|
if status.Started[0] != "component-x" {
|
|
t.Fatalf("Status().Started[0] = %q, want %q (bootstrap fallback preserved)", status.Started[0], "component-x")
|
|
}
|
|
if status.Started[1] != "beta" {
|
|
t.Fatalf("Status().Started[1] = %q, want %q", status.Started[1], "beta")
|
|
}
|
|
|
|
if err := m.Close(context.Background()); err != nil {
|
|
t.Fatalf("Close() error = %v", err)
|
|
}
|
|
|
|
expected := []string{
|
|
"start:nonNamer", "start:beta",
|
|
"stop:beta", "stop:nonNamer",
|
|
}
|
|
if got := trace2.snapshot(); !reflect.DeepEqual(got, expected) {
|
|
t.Fatalf("trace = %v, want %v", got, expected)
|
|
}
|
|
}
|
|
|
|
func contains(s, substr string) bool {
|
|
for i := 0; i+len(substr) <= len(s); i++ {
|
|
if s[i:i+len(substr)] == substr {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
type changingNamerComponent struct {
|
|
mu sync.Mutex
|
|
callCount int
|
|
trace *traceLog
|
|
}
|
|
|
|
func (c *changingNamerComponent) Name() string {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.callCount++
|
|
if c.callCount == 1 {
|
|
return "first-name"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (c *changingNamerComponent) Start(ctx context.Context) error {
|
|
c.trace.add("start:changingNamer")
|
|
return nil
|
|
}
|
|
|
|
func (c *changingNamerComponent) Stop(ctx context.Context) error {
|
|
c.trace.add("stop:changingNamer")
|
|
return nil
|
|
}
|
|
|
|
func TestModuleResolvesComponentNameExactlyOnce(t *testing.T) {
|
|
trace := &traceLog{}
|
|
comp := &changingNamerComponent{trace: trace}
|
|
|
|
m, err := NewModule(comp)
|
|
if err != nil {
|
|
t.Fatalf("NewModule() error = %v", err)
|
|
}
|
|
|
|
comp.mu.Lock()
|
|
calls := comp.callCount
|
|
comp.mu.Unlock()
|
|
|
|
if calls != 1 {
|
|
t.Fatalf("Name() call count = %d, want 1", calls)
|
|
}
|
|
|
|
if err := m.Run(context.Background()); err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
|
|
status := m.Status()
|
|
if !reflect.DeepEqual(status.Started, []string{"first-name"}) {
|
|
t.Fatalf("Status().Started = %v, want [first-name]", status.Started)
|
|
}
|
|
|
|
if err := m.Close(context.Background()); err != nil {
|
|
t.Fatalf("Close() error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestNewDaemonModuleCompositionAndLifecycle(t *testing.T) {
|
|
stateRoot := t.TempDir()
|
|
_ = os.Chmod(stateRoot, 0700)
|
|
overlayRoot := filepath.Join(stateRoot, "overlays")
|
|
logRoot := filepath.Join(stateRoot, "logs")
|
|
|
|
globalConfig := `version: "1"
|
|
defaults:
|
|
default_profile: p1
|
|
isolation:
|
|
default_mode: overlay
|
|
retention:
|
|
completed_days: 14
|
|
`
|
|
localConfig := fmt.Sprintf(`version: "1"
|
|
device:
|
|
state_root: %s
|
|
overlay_root: %s
|
|
log_root: %s
|
|
projects:
|
|
proj-1:
|
|
workspace: %s
|
|
enabled: true
|
|
selected_milestone: m1
|
|
clients:
|
|
flutter:
|
|
executable: /bin/echo
|
|
working_directory: /tmp
|
|
launch_on_start: false
|
|
`, stateRoot, overlayRoot, logRoot, filepath.Join(stateRoot, "ws1"))
|
|
|
|
snapshot, err := agentconfig.LoadRuntimeConfigBytes([]byte(globalConfig), []byte(localConfig))
|
|
if err != nil {
|
|
t.Fatalf("LoadRuntimeConfigBytes error = %v", err)
|
|
}
|
|
|
|
backend := &fakeProcessBackend{}
|
|
daemon, err := NewDaemonModule(
|
|
context.Background(),
|
|
snapshot,
|
|
WithProcessBackend(backend),
|
|
WithTaskCatalog(bootstrapTestCatalog(snapshot)),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewDaemonModule() error = %v", err)
|
|
}
|
|
|
|
if daemon.StateStore == nil {
|
|
t.Error("expected StateStore to be initialized")
|
|
}
|
|
if daemon.ClientManager == nil {
|
|
t.Error("expected ClientManager to be initialized")
|
|
}
|
|
if daemon.TaskRuntime == nil {
|
|
t.Error("expected TaskRuntime to be initialized")
|
|
}
|
|
if len(daemon.ProjectStores) != 1 || daemon.ProjectStores["proj-1"] == nil {
|
|
t.Errorf("expected ProjectStore for proj-1, got %v", daemon.ProjectStores)
|
|
}
|
|
if daemon.ControlServer == nil {
|
|
t.Error("expected ControlServer to be initialized")
|
|
}
|
|
if daemon.Ledger == nil {
|
|
t.Error("expected Ledger to be initialized")
|
|
}
|
|
|
|
ctx := context.Background()
|
|
if err := daemon.Run(ctx); err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
|
|
status := daemon.Status()
|
|
expectedStarted := []string{"project-log", "task-runtime", "client-process", "local-control"}
|
|
if !reflect.DeepEqual(status.Started, expectedStarted) {
|
|
t.Fatalf("Status().Started = %v, want %v", status.Started, expectedStarted)
|
|
}
|
|
|
|
socketPath := daemon.ControlServer.Path()
|
|
if _, err := os.Lstat(socketPath); err != nil {
|
|
t.Errorf("expected Unix socket file at %s, got %v", socketPath, err)
|
|
}
|
|
|
|
if err := daemon.Close(ctx); err != nil {
|
|
t.Fatalf("Close() error = %v", err)
|
|
}
|
|
|
|
if _, err := os.Lstat(socketPath); !os.IsNotExist(err) {
|
|
t.Errorf("expected Unix socket file to be removed after Close(), got err = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDaemonClientExitIsolation(t *testing.T) {
|
|
stateRoot := t.TempDir()
|
|
_ = os.Chmod(stateRoot, 0700)
|
|
overlayRoot := filepath.Join(stateRoot, "overlays")
|
|
logRoot := filepath.Join(stateRoot, "logs")
|
|
|
|
globalConfig := `version: "1"
|
|
defaults:
|
|
default_profile: p1
|
|
isolation:
|
|
default_mode: overlay
|
|
retention:
|
|
completed_days: 14
|
|
`
|
|
localConfig := fmt.Sprintf(`version: "1"
|
|
device:
|
|
state_root: %s
|
|
overlay_root: %s
|
|
log_root: %s
|
|
clients:
|
|
flutter:
|
|
executable: /bin/echo
|
|
working_directory: /tmp
|
|
launch_on_start: false
|
|
`, stateRoot, overlayRoot, logRoot)
|
|
|
|
snapshot, err := agentconfig.LoadRuntimeConfigBytes([]byte(globalConfig), []byte(localConfig))
|
|
if err != nil {
|
|
t.Fatalf("LoadRuntimeConfigBytes error = %v", err)
|
|
}
|
|
|
|
backend := &fakeProcessBackend{}
|
|
daemon, err := NewDaemonModule(
|
|
context.Background(),
|
|
snapshot,
|
|
WithProcessBackend(backend),
|
|
WithTaskCatalog(bootstrapTestCatalog(snapshot)),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewDaemonModule() error = %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
if err := daemon.Run(ctx); err != nil {
|
|
t.Fatalf("Run() error = %v", err)
|
|
}
|
|
defer daemon.Close(ctx)
|
|
|
|
res, err := daemon.ClientManager.Start(ctx, clientprocess.ClientFlutter, "cmd-1")
|
|
if err != nil {
|
|
t.Fatalf("Start client error = %v", err)
|
|
}
|
|
if res.Record.State != clientprocess.StateStarting && res.Record.State != clientprocess.StateConnected {
|
|
t.Fatalf("unexpected client state: %v", res.Record.State)
|
|
}
|
|
|
|
proc := backend.lastProcess
|
|
if proc != nil {
|
|
_ = proc.Abort()
|
|
}
|
|
|
|
status := daemon.Status()
|
|
if status.Stopped {
|
|
t.Fatal("host stopped unexpectedly after client process exited")
|
|
}
|
|
if len(status.Started) != 4 {
|
|
t.Fatalf("host components lost after client exit: %v", status.Started)
|
|
}
|
|
}
|
|
|
|
type fakeProcessBackend struct {
|
|
mu sync.Mutex
|
|
lastProcess *fakeOwnedProcess
|
|
}
|
|
|
|
func (b *fakeProcessBackend) Start(ctx context.Context, kind clientprocess.ClientKind, spec agentconfig.ClientProcessSpec) (clientprocess.OwnedProcess, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
p := &fakeOwnedProcess{
|
|
identity: clientprocess.ProcessIdentity{PID: 1234, StartToken: "token-1234"},
|
|
done: make(chan struct{}),
|
|
}
|
|
b.lastProcess = p
|
|
return p, nil
|
|
}
|
|
|
|
func (b *fakeProcessBackend) Inspect(ctx context.Context, identity clientprocess.ProcessIdentity) (clientprocess.IdentityObservation, error) {
|
|
return clientprocess.IdentityObservation{State: clientprocess.IdentityLive}, nil
|
|
}
|
|
|
|
func (b *fakeProcessBackend) Signal(ctx context.Context, identity clientprocess.ProcessIdentity, sig os.Signal) error {
|
|
return nil
|
|
}
|
|
|
|
func (b *fakeProcessBackend) Kill(ctx context.Context, identity clientprocess.ProcessIdentity) error {
|
|
return nil
|
|
}
|
|
|
|
func (b *fakeProcessBackend) Focus(ctx context.Context, kind clientprocess.ClientKind, spec agentconfig.ClientProcessSpec) error {
|
|
return nil
|
|
}
|
|
|
|
type fakeOwnedProcess struct {
|
|
identity clientprocess.ProcessIdentity
|
|
done chan struct{}
|
|
once sync.Once
|
|
}
|
|
|
|
func (p *fakeOwnedProcess) Identity() clientprocess.ProcessIdentity { return p.identity }
|
|
func (p *fakeOwnedProcess) Wait() error {
|
|
<-p.done
|
|
return nil
|
|
}
|
|
func (p *fakeOwnedProcess) Abort() error {
|
|
p.once.Do(func() { close(p.done) })
|
|
return nil
|
|
}
|
|
|
|
func bootstrapTestCatalog(snapshot agentconfig.RuntimeSnapshot) agentconfig.Catalog {
|
|
cfg := snapshot.Config()
|
|
profileID := cfg.Defaults.DefaultProfile
|
|
if profileID == "" {
|
|
profileID = "p1"
|
|
}
|
|
providerID := cfg.Selection.Default.Provider
|
|
if providerID == "" {
|
|
providerID = "test-provider"
|
|
}
|
|
modelID := cfg.Selection.Default.Model
|
|
if modelID == "" {
|
|
modelID = "test-model"
|
|
}
|
|
capabilities := []string{
|
|
"approval_bypass", "run", "unattended", "writable_root_confinement",
|
|
}
|
|
return agentconfig.Catalog{
|
|
Version: agentconfig.SchemaVersion,
|
|
Providers: []agentconfig.Provider{{
|
|
ID: providerID, Command: "provider-must-not-run-in-tests",
|
|
VersionProbe: agentconfig.CommandProbe{Args: []string{"--version"}},
|
|
Authentication: agentconfig.AuthenticationProbe{Args: []string{"auth", "status"}},
|
|
Capabilities: capabilities,
|
|
}},
|
|
Models: []agentconfig.Model{{
|
|
ID: modelID, Provider: providerID, Target: modelID,
|
|
}},
|
|
Profiles: []agentconfig.Profile{{
|
|
ID: profileID, Provider: providerID, Model: modelID,
|
|
Args: []string{"--model", "{{model}}"}, Capabilities: capabilities,
|
|
}},
|
|
}
|
|
}
|
|
|
|
func TestDaemonTaskRuntimeIsAuthoritativeForLocalControl(t *testing.T) {
|
|
stateRoot := t.TempDir()
|
|
_ = os.Chmod(stateRoot, 0700)
|
|
overlayRoot := filepath.Join(stateRoot, "overlays")
|
|
logRoot := filepath.Join(stateRoot, "logs")
|
|
workspace := filepath.Join(stateRoot, "ws1")
|
|
taskRoot := filepath.Join(workspace, "agent-task", "m-m1", "1_fixture")
|
|
if err := os.MkdirAll(taskRoot, 0700); err != nil {
|
|
t.Fatalf("create task fixture: %v", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(taskRoot, "PLAN-test.md"), []byte(`# Plan
|
|
|
|
## Modified Files Summary
|
|
|
|
| File | Purpose |
|
|
|------|---------|
|
|
| `+"`README.md`"+` | Exercise local control. |
|
|
`), 0600); err != nil {
|
|
t.Fatalf("write PLAN fixture: %v", err)
|
|
}
|
|
if err := os.WriteFile(
|
|
filepath.Join(taskRoot, "CODE_REVIEW-test.md"),
|
|
[]byte("# Code Review Reference\n"),
|
|
0600,
|
|
); err != nil {
|
|
t.Fatalf("write review fixture: %v", err)
|
|
}
|
|
|
|
globalConfig := `version: "1"
|
|
defaults:
|
|
default_profile: p1
|
|
isolation:
|
|
default_mode: overlay
|
|
retention:
|
|
completed_days: 14
|
|
`
|
|
localConfig := fmt.Sprintf(`version: "1"
|
|
device:
|
|
state_root: %s
|
|
overlay_root: %s
|
|
log_root: %s
|
|
projects:
|
|
proj-1:
|
|
workspace: %s
|
|
enabled: true
|
|
selected_milestone: m1
|
|
clients:
|
|
flutter:
|
|
executable: /bin/echo
|
|
working_directory: /tmp
|
|
launch_on_start: false
|
|
`, stateRoot, overlayRoot, logRoot, workspace)
|
|
|
|
snapshot, err := agentconfig.LoadRuntimeConfigBytes([]byte(globalConfig), []byte(localConfig))
|
|
if err != nil {
|
|
t.Fatalf("LoadRuntimeConfigBytes error = %v", err)
|
|
}
|
|
|
|
backend := &fakeProcessBackend{}
|
|
daemon, err := NewDaemonModule(
|
|
context.Background(),
|
|
snapshot,
|
|
WithProcessBackend(backend),
|
|
WithTaskCatalog(bootstrapTestCatalog(snapshot)),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewDaemonModule() error = %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
bridge := &daemonStateBridge{
|
|
snapshot: snapshot,
|
|
manager: daemon.ClientManager,
|
|
task: daemon.TaskRuntime,
|
|
}
|
|
before, err := bridge.ProjectStatus(ctx, "proj-1")
|
|
if err != nil || before.State != "observed" {
|
|
t.Fatalf("ProjectStatus before start = %#v, err = %v", before, err)
|
|
}
|
|
blockers, err := bridge.BlockerList(ctx, "proj-1")
|
|
if err != nil || blockers.Summary != "0 blocker(s)" {
|
|
t.Fatalf("BlockerList = %#v, err = %v", blockers, err)
|
|
}
|
|
workflow, err := daemon.TaskRuntime.WorkflowSnapshot(ctx, "proj-1")
|
|
if err != nil {
|
|
t.Fatalf("WorkflowSnapshot error = %v", err)
|
|
}
|
|
|
|
svc, err := localcontrol.NewService(bridge, bridge, daemon.Ledger)
|
|
if err != nil {
|
|
t.Fatalf("NewService error = %v", err)
|
|
}
|
|
statusRequest := &iop.AgentLocalEnvelope{
|
|
ProtocolVersion: localcontrol.ProtocolVersion,
|
|
Kind: iop.AgentLocalKind_AGENT_LOCAL_KIND_REQUEST,
|
|
MessageId: "msg-status-1",
|
|
Operation: localcontrol.OperationProjectStatus,
|
|
Payload: &iop.AgentLocalEnvelope_Request{
|
|
Request: &iop.AgentLocalRequest{
|
|
Payload: &iop.AgentLocalRequest_Read{
|
|
Read: &iop.AgentLocalReadRequest{
|
|
ProjectId: "proj-1",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
startRequest := &iop.AgentLocalEnvelope{
|
|
ProtocolVersion: localcontrol.ProtocolVersion,
|
|
Kind: iop.AgentLocalKind_AGENT_LOCAL_KIND_REQUEST,
|
|
MessageId: "msg-start-1",
|
|
Operation: localcontrol.OperationProjectStart,
|
|
Payload: &iop.AgentLocalEnvelope_Request{
|
|
Request: &iop.AgentLocalRequest{
|
|
CommandId: "cmd-start-1",
|
|
Payload: &iop.AgentLocalRequest_Project{
|
|
Project: &iop.AgentLocalProjectRequest{
|
|
ProjectId: "proj-1",
|
|
WorkspaceId: string(workflow.WorkspaceID),
|
|
MilestoneId: "m1",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
statusResponse := svc.Handle(ctx, true, statusRequest)
|
|
if statusResponse.GetKind() != iop.AgentLocalKind_AGENT_LOCAL_KIND_RESPONSE ||
|
|
statusResponse.GetResponse().GetSnapshot().GetState() != "observed" {
|
|
t.Fatalf("status response = %#v", statusResponse)
|
|
}
|
|
startResponse := svc.Handle(ctx, true, startRequest)
|
|
if startResponse.GetKind() != iop.AgentLocalKind_AGENT_LOCAL_KIND_RESPONSE ||
|
|
startResponse.GetResponse().GetMutation().GetState() != "started" {
|
|
t.Fatalf("start response = %#v", startResponse)
|
|
}
|
|
after, err := bridge.ProjectStatus(ctx, "proj-1")
|
|
if err != nil || after.State != "started" {
|
|
t.Fatalf("ProjectStatus after start = %#v, err = %v", after, err)
|
|
}
|
|
cmd := localcontrol.ProjectCommand{
|
|
ProjectID: "proj-1", WorkspaceID: string(workflow.WorkspaceID), MilestoneID: "m1",
|
|
}
|
|
stopped, err := bridge.StopProject(ctx, cmd)
|
|
if err != nil || stopped.State != "stopped" {
|
|
t.Fatalf("StopProject = %#v, err = %v", stopped, err)
|
|
}
|
|
resumed, err := bridge.ResumeProject(ctx, cmd)
|
|
if err != nil || resumed.State != "started" {
|
|
t.Fatalf("ResumeProject = %#v, err = %v", resumed, err)
|
|
}
|
|
|
|
events, _, errReplay := daemon.Ledger.Replay(ctx, "iop-agent-daemon", 0)
|
|
if errReplay != nil {
|
|
t.Fatalf("Ledger.Replay error = %v", errReplay)
|
|
}
|
|
if len(events) != 1 {
|
|
t.Fatalf("expected 1 retained local-control mutation event, got %d", len(events))
|
|
}
|
|
}
|
|
|
|
func TestDaemonFakeProviderPersistedLifecycleRollbackAndRestart(t *testing.T) {
|
|
fixture := createDaemonLifecycleFixture(t)
|
|
provider := &daemonLifecycleProvider{}
|
|
reviewer := &daemonLifecycleReviewer{}
|
|
validator := &daemonLifecycleValidator{}
|
|
ctx := context.Background()
|
|
|
|
daemon := newLifecycleDaemon(t, ctx, fixture, provider, reviewer, validator)
|
|
if err := daemon.Run(ctx); err != nil {
|
|
t.Fatalf("Run: %v", err)
|
|
}
|
|
bridge := &daemonStateBridge{
|
|
snapshot: fixture.snapshot,
|
|
manager: daemon.ClientManager,
|
|
task: daemon.TaskRuntime,
|
|
}
|
|
workflow, err := daemon.TaskRuntime.WorkflowSnapshot(ctx, "project")
|
|
if err != nil {
|
|
t.Fatalf("WorkflowSnapshot: %v", err)
|
|
}
|
|
started, err := bridge.StartProject(ctx, localcontrol.ProjectCommand{
|
|
ProjectID: "project",
|
|
WorkspaceID: string(workflow.WorkspaceID),
|
|
MilestoneID: "m1",
|
|
})
|
|
if err != nil || (started.State != "started" && started.State != "running") {
|
|
t.Fatalf("StartProject = %#v, err = %v", started, err)
|
|
}
|
|
waitDaemonLifecycle(t, daemon.TaskRuntime)
|
|
if err := daemon.TaskRuntime.Reconcile(ctx); err != nil {
|
|
t.Fatalf("terminal Reconcile: %v", err)
|
|
}
|
|
assertDaemonLifecycle(t, fixture, daemon, bridge, provider, reviewer, validator)
|
|
if err := daemon.Close(ctx); err != nil {
|
|
t.Fatalf("Close: %v", err)
|
|
}
|
|
|
|
restarted := newLifecycleDaemon(t, ctx, fixture, provider, reviewer, validator)
|
|
if err := restarted.Run(ctx); err != nil {
|
|
t.Fatalf("Run(restart): %v", err)
|
|
}
|
|
waitDaemonLifecycle(t, restarted.TaskRuntime)
|
|
if err := restarted.TaskRuntime.Reconcile(ctx); err != nil {
|
|
t.Fatalf("restart terminal Reconcile: %v", err)
|
|
}
|
|
restartedBridge := &daemonStateBridge{
|
|
snapshot: fixture.snapshot,
|
|
manager: restarted.ClientManager,
|
|
task: restarted.TaskRuntime,
|
|
}
|
|
blockers, err := restartedBridge.BlockerList(ctx, "project")
|
|
if err != nil || len(blockers.Entries) != 1 {
|
|
t.Fatalf("restart blockers = %#v, err = %v", blockers, err)
|
|
}
|
|
if provider.Count() != 2 || reviewer.Count() != 2 {
|
|
t.Fatalf(
|
|
"restart duplicated work: dispatches=%d reviews=%d",
|
|
provider.Count(),
|
|
reviewer.Count(),
|
|
)
|
|
}
|
|
if err := restarted.Close(ctx); err != nil {
|
|
t.Fatalf("Close(restart): %v", err)
|
|
}
|
|
}
|
|
|
|
type daemonLifecycleFixture struct {
|
|
snapshot agentconfig.RuntimeSnapshot
|
|
catalog agentconfig.Catalog
|
|
workspace string
|
|
logRoot string
|
|
}
|
|
|
|
func createDaemonLifecycleFixture(t *testing.T) daemonLifecycleFixture {
|
|
t.Helper()
|
|
root, err := filepath.EvalSymlinks(t.TempDir())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
workspace := filepath.Join(root, "workspace")
|
|
stateRoot := filepath.Join(root, "state")
|
|
logRoot := filepath.Join(root, "logs")
|
|
for _, directory := range []string{workspace, stateRoot, logRoot} {
|
|
if err := os.MkdirAll(directory, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
writeDaemonLifecycleFile(t, filepath.Join(workspace, "rejected.txt"), "base rejected\n")
|
|
writeDaemonLifecycleFile(t, filepath.Join(workspace, "sibling.txt"), "base sibling\n")
|
|
createDaemonLifecycleTask(t, workspace, "1_rejected", "rejected.txt")
|
|
createDaemonLifecycleTask(t, workspace, "2_sibling", "sibling.txt")
|
|
runDaemonLifecycleGit(t, workspace, "init", "-q")
|
|
runDaemonLifecycleGit(t, workspace, "config", "user.email", "daemon@example.invalid")
|
|
runDaemonLifecycleGit(t, workspace, "config", "user.name", "Daemon Fixture")
|
|
runDaemonLifecycleGit(t, workspace, "add", "-A")
|
|
runDaemonLifecycleGit(t, workspace, "commit", "-q", "-m", "base")
|
|
|
|
global := `version: "1"
|
|
defaults:
|
|
default_profile: p1
|
|
selection:
|
|
timezone: UTC
|
|
default:
|
|
provider: test-provider
|
|
model: test-model
|
|
profile: p1
|
|
isolation:
|
|
default_mode: overlay
|
|
retention:
|
|
completed_days: 14
|
|
blocked_days: 14
|
|
`
|
|
local := fmt.Sprintf(`version: "1"
|
|
device:
|
|
state_root: %s
|
|
overlay_root: %s
|
|
log_root: %s
|
|
projects:
|
|
project:
|
|
workspace: %s
|
|
enabled: true
|
|
selected_milestone: m1
|
|
`, stateRoot, filepath.Join(root, "overlays"), logRoot, workspace)
|
|
snapshot, err := agentconfig.LoadRuntimeConfigBytes([]byte(global), []byte(local))
|
|
if err != nil {
|
|
t.Fatalf("LoadRuntimeConfigBytes: %v", err)
|
|
}
|
|
return daemonLifecycleFixture{
|
|
snapshot: snapshot,
|
|
catalog: bootstrapTestCatalog(snapshot),
|
|
workspace: workspace,
|
|
logRoot: logRoot,
|
|
}
|
|
}
|
|
|
|
func createDaemonLifecycleTask(
|
|
t *testing.T,
|
|
workspace, directory, target string,
|
|
) {
|
|
t.Helper()
|
|
taskRoot := filepath.Join(workspace, "agent-task", "m-m1", directory)
|
|
if err := os.MkdirAll(taskRoot, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
reviewRelative := filepath.ToSlash(
|
|
filepath.Join("agent-task", "m-m1", directory, "CODE_REVIEW-test.md"),
|
|
)
|
|
writeDaemonLifecycleFile(t, filepath.Join(taskRoot, "PLAN-test.md"), fmt.Sprintf(`# Plan
|
|
|
|
## Modified Files Summary
|
|
|
|
| File | Purpose |
|
|
|------|---------|
|
|
| %s | Exercise provider output. |
|
|
| %s | Record implementation and review. |
|
|
`, "`"+target+"`", "`"+reviewRelative+"`"))
|
|
writeDaemonLifecycleFile(t, filepath.Join(taskRoot, "CODE_REVIEW-test.md"), `# Code Review Reference
|
|
|
|
## Implementation Notes
|
|
|
|
Deterministic daemon evidence is complete.
|
|
`)
|
|
}
|
|
|
|
func writeDaemonLifecycleFile(t *testing.T, path, content string) {
|
|
t.Helper()
|
|
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func runDaemonLifecycleGit(t *testing.T, root string, args ...string) {
|
|
t.Helper()
|
|
command := exec.Command("git", append([]string{"-C", root}, args...)...)
|
|
if output, err := command.CombinedOutput(); err != nil {
|
|
t.Fatalf("git %v: %v: %s", args, err, output)
|
|
}
|
|
}
|
|
|
|
func newLifecycleDaemon(
|
|
t *testing.T,
|
|
ctx context.Context,
|
|
fixture daemonLifecycleFixture,
|
|
provider *daemonLifecycleProvider,
|
|
reviewer *daemonLifecycleReviewer,
|
|
validator *daemonLifecycleValidator,
|
|
) *DaemonModule {
|
|
t.Helper()
|
|
daemon, err := NewDaemonModule(
|
|
ctx,
|
|
fixture.snapshot,
|
|
WithTaskCatalog(fixture.catalog),
|
|
WithTaskRuntimePorts(provider, reviewer, validator.Validate, nil),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewDaemonModule: %v", err)
|
|
}
|
|
return daemon
|
|
}
|
|
|
|
func waitDaemonLifecycle(t *testing.T, runtime *taskloop.Runtime) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(10 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
view, err := runtime.ProjectStatus(context.Background(), "project")
|
|
if err == nil && len(view.Works) == 2 {
|
|
terminal := true
|
|
for _, work := range view.Works {
|
|
terminal = terminal && work.State.Terminal()
|
|
}
|
|
if terminal {
|
|
return
|
|
}
|
|
}
|
|
time.Sleep(25 * time.Millisecond)
|
|
}
|
|
t.Fatal("daemon lifecycle did not reach terminal projections")
|
|
}
|
|
|
|
func assertDaemonLifecycle(
|
|
t *testing.T,
|
|
fixture daemonLifecycleFixture,
|
|
daemon *DaemonModule,
|
|
bridge *daemonStateBridge,
|
|
provider *daemonLifecycleProvider,
|
|
reviewer *daemonLifecycleReviewer,
|
|
validator *daemonLifecycleValidator,
|
|
) {
|
|
t.Helper()
|
|
if provider.Count() != 2 || reviewer.Count() != 2 || validator.Count() != 2 {
|
|
t.Fatalf(
|
|
"lifecycle counts dispatch=%d review=%d validation=%d",
|
|
provider.Count(),
|
|
reviewer.Count(),
|
|
validator.Count(),
|
|
)
|
|
}
|
|
state, _, err := daemon.StateStore.Load(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
project := state.Projects["project"]
|
|
rejected := project.Works["1"]
|
|
sibling := project.Works["2"]
|
|
if rejected.State != agenttask.WorkStateTerminalDeferred ||
|
|
rejected.Review == nil ||
|
|
rejected.Review.Verdict != agenttask.ReviewVerdictPass ||
|
|
rejected.ChangeSet == nil ||
|
|
rejected.Blocker == nil ||
|
|
!strings.Contains(rejected.Blocker.Message, "validation") {
|
|
t.Fatalf("rejected work = %#v", rejected)
|
|
}
|
|
if sibling.State != agenttask.WorkStateCompleted ||
|
|
sibling.Review == nil ||
|
|
sibling.Review.Verdict != agenttask.ReviewVerdictPass ||
|
|
sibling.ChangeSet == nil ||
|
|
!sibling.CompletionVerified {
|
|
t.Fatalf("sibling work = %#v", sibling)
|
|
}
|
|
rejectedProjection, err := bridge.IntegrationStatus(
|
|
context.Background(),
|
|
"project",
|
|
"1",
|
|
)
|
|
if err != nil ||
|
|
rejectedProjection.State != string(agenttask.WorkStateTerminalDeferred) ||
|
|
rejectedProjection.Summary != string(agenttask.IntegrationOutcomeTerminalDeferred) {
|
|
t.Fatalf("rejected projection = %#v, err = %v", rejectedProjection, err)
|
|
}
|
|
blockers, err := bridge.BlockerList(context.Background(), "project")
|
|
if err != nil || len(blockers.Entries) != 1 {
|
|
t.Fatalf("blocker projection = %#v, err = %v", blockers, err)
|
|
}
|
|
assertDaemonLifecycleFile(t, filepath.Join(fixture.workspace, "rejected.txt"), "base rejected\n")
|
|
assertDaemonLifecycleFile(t, filepath.Join(fixture.workspace, "sibling.txt"), "integrated 2\n")
|
|
assertDaemonTerminalArchives(t, fixture.logRoot)
|
|
}
|
|
|
|
func assertDaemonLifecycleFile(t *testing.T, path, want string) {
|
|
t.Helper()
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(content) != want {
|
|
t.Fatalf("%s = %q, want %q", path, content, want)
|
|
}
|
|
}
|
|
|
|
func assertDaemonTerminalArchives(t *testing.T, root string) {
|
|
t.Helper()
|
|
manifests := 0
|
|
timelines := make(map[agenttask.WorkUnitID]bool)
|
|
if err := filepath.Walk(root, func(path string, info os.FileInfo, walkErr error) error {
|
|
if walkErr != nil {
|
|
return walkErr
|
|
}
|
|
if info.IsDir() {
|
|
return nil
|
|
}
|
|
switch {
|
|
case strings.HasSuffix(path, ".manifest.json"):
|
|
manifests++
|
|
case strings.HasSuffix(path, ".timeline.jsonl"):
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var last projectlog.ProjectLogRecord
|
|
var prior uint64
|
|
for index, line := range strings.Split(strings.TrimSpace(string(content)), "\n") {
|
|
var record projectlog.ProjectLogRecord
|
|
if err := json.Unmarshal([]byte(line), &record); err != nil {
|
|
return err
|
|
}
|
|
if index > 0 && record.Sequence != prior+1 {
|
|
return errors.New("daemon project log sequence is not monotonic")
|
|
}
|
|
prior = record.Sequence
|
|
last = record
|
|
}
|
|
if !last.Terminal {
|
|
return errors.New("daemon project log timeline is not terminal")
|
|
}
|
|
timelines[last.WorkUnitID] = true
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if manifests != 2 || !timelines["1"] || !timelines["2"] {
|
|
t.Fatalf("archive evidence manifests=%d timelines=%#v", manifests, timelines)
|
|
}
|
|
}
|
|
|
|
type daemonLifecycleProvider struct {
|
|
mu sync.Mutex
|
|
requests []agenttask.DispatchRequest
|
|
}
|
|
|
|
func (provider *daemonLifecycleProvider) Prepare(
|
|
_ context.Context,
|
|
request agenttask.DispatchRequest,
|
|
) (agenttask.ProviderLaunch, error) {
|
|
if request.Confinement == nil || request.Permit == nil {
|
|
return nil, errors.New("daemon fake requires admitted confinement")
|
|
}
|
|
binding := request.Confinement.Binding()
|
|
if err := request.Confinement.Validate(binding); err != nil {
|
|
return nil, err
|
|
}
|
|
provider.mu.Lock()
|
|
provider.requests = append(provider.requests, request)
|
|
provider.mu.Unlock()
|
|
return &daemonLifecycleLaunch{request: request, root: binding.WorkingDir}, nil
|
|
}
|
|
|
|
func (provider *daemonLifecycleProvider) Count() int {
|
|
provider.mu.Lock()
|
|
defer provider.mu.Unlock()
|
|
return len(provider.requests)
|
|
}
|
|
|
|
type daemonLifecycleLaunch struct {
|
|
request agenttask.DispatchRequest
|
|
root string
|
|
}
|
|
|
|
func (launch *daemonLifecycleLaunch) Command() agenttask.ConfinementCommand {
|
|
return agenttask.ConfinementCommand{Name: "true"}
|
|
}
|
|
|
|
func (launch *daemonLifecycleLaunch) BindStarted(
|
|
started agenttask.StartedConfinement,
|
|
) (agenttask.ProviderInvocation, error) {
|
|
if started == nil || started.Child() == nil || started.Child().Process == nil {
|
|
return nil, errors.New("daemon fake child is incomplete")
|
|
}
|
|
opaque := fmt.Sprintf("daemon-fake-%d", started.Child().Process.Pid)
|
|
return &daemonLifecycleInvocation{
|
|
request: launch.request,
|
|
root: launch.root,
|
|
started: started,
|
|
locator: agenttask.LocatorRecord{
|
|
Kind: agenttask.LocatorProcess,
|
|
Opaque: opaque,
|
|
Revision: daemonLifecycleDigest("process", opaque),
|
|
ProjectID: launch.request.Project.ProjectID,
|
|
WorkspaceID: launch.request.Project.WorkspaceID,
|
|
WorkUnitID: launch.request.Work.Unit.ID,
|
|
AttemptID: launch.request.Work.AttemptID,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
type daemonLifecycleInvocation struct {
|
|
request agenttask.DispatchRequest
|
|
root string
|
|
started agenttask.StartedConfinement
|
|
locator agenttask.LocatorRecord
|
|
}
|
|
|
|
func (invocation *daemonLifecycleInvocation) Locators() []agenttask.LocatorRecord {
|
|
return []agenttask.LocatorRecord{invocation.locator}
|
|
}
|
|
|
|
func (invocation *daemonLifecycleInvocation) Wait(
|
|
context.Context,
|
|
) (agenttask.Submission, error) {
|
|
if err := daemonLifecycleWait(invocation.started); err != nil {
|
|
return agenttask.Submission{}, err
|
|
}
|
|
target := ""
|
|
for _, candidate := range invocation.request.Work.Unit.DeclaredWriteSet {
|
|
if !strings.HasPrefix(candidate, "agent-task/") {
|
|
target = candidate
|
|
break
|
|
}
|
|
}
|
|
if target == "" {
|
|
return agenttask.Submission{}, errors.New("daemon fake target is missing")
|
|
}
|
|
if err := os.WriteFile(
|
|
filepath.Join(invocation.root, filepath.FromSlash(target)),
|
|
[]byte("integrated "+string(invocation.request.Work.Unit.ID)+"\n"),
|
|
0o600,
|
|
); err != nil {
|
|
return agenttask.Submission{}, err
|
|
}
|
|
return agenttask.Submission{
|
|
ProjectID: invocation.request.Project.ProjectID,
|
|
WorkUnitID: invocation.request.Work.Unit.ID,
|
|
AttemptID: invocation.request.Work.AttemptID,
|
|
ArtifactID: agenttask.ArtifactID(daemonLifecycleDigest(
|
|
"artifact",
|
|
string(invocation.request.Work.Unit.ID),
|
|
string(invocation.request.Work.AttemptID),
|
|
)),
|
|
Ready: true,
|
|
Locators: invocation.Locators(),
|
|
}, nil
|
|
}
|
|
|
|
func (invocation *daemonLifecycleInvocation) Cancel(context.Context) error {
|
|
return invocation.started.Abort()
|
|
}
|
|
|
|
func daemonLifecycleWait(started agenttask.StartedConfinement) error {
|
|
if stdin := started.Stdin(); stdin != nil {
|
|
_ = stdin.Close()
|
|
}
|
|
if stdout := started.Stdout(); stdout != nil {
|
|
_, _ = io.Copy(io.Discard, stdout)
|
|
_ = stdout.Close()
|
|
}
|
|
if stderr := started.Stderr(); stderr != nil {
|
|
_, _ = io.Copy(io.Discard, stderr)
|
|
_ = stderr.Close()
|
|
}
|
|
return started.Child().Wait()
|
|
}
|
|
|
|
func daemonLifecycleDigest(parts ...string) string {
|
|
hash := sha256.New()
|
|
for _, part := range parts {
|
|
_, _ = fmt.Fprintf(hash, "%d:", len(part))
|
|
_, _ = hash.Write([]byte(part))
|
|
}
|
|
return "sha256:" + hex.EncodeToString(hash.Sum(nil))
|
|
}
|
|
|
|
type daemonLifecycleReviewer struct {
|
|
mu sync.Mutex
|
|
count int
|
|
}
|
|
|
|
func (reviewer *daemonLifecycleReviewer) ExecuteReview(
|
|
_ context.Context,
|
|
_ agenttask.ReviewRequest,
|
|
root, _, reviewRelative string,
|
|
) error {
|
|
reviewer.mu.Lock()
|
|
reviewer.count++
|
|
reviewer.mu.Unlock()
|
|
file, err := os.OpenFile(
|
|
filepath.Join(root, filepath.FromSlash(reviewRelative)),
|
|
os.O_APPEND|os.O_WRONLY,
|
|
0,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
_, err = io.WriteString(file, "\n## Code Review Result\n\nOverall Verdict: PASS\n")
|
|
return err
|
|
}
|
|
|
|
func (reviewer *daemonLifecycleReviewer) Count() int {
|
|
reviewer.mu.Lock()
|
|
defer reviewer.mu.Unlock()
|
|
return reviewer.count
|
|
}
|
|
|
|
type daemonLifecycleValidator struct {
|
|
mu sync.Mutex
|
|
count int
|
|
}
|
|
|
|
func (validator *daemonLifecycleValidator) Validate(
|
|
_ context.Context,
|
|
request agentworkspace.ValidationRequest,
|
|
) error {
|
|
validator.mu.Lock()
|
|
validator.count++
|
|
validator.mu.Unlock()
|
|
if request.ValidationRoot == request.CanonicalRoot {
|
|
return errors.New("daemon validator did not receive an isolated candidate")
|
|
}
|
|
content, err := os.ReadFile(filepath.Join(request.ValidationRoot, "rejected.txt"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if string(content) != "base rejected\n" {
|
|
return errors.New("daemon fake validator rejected candidate")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (validator *daemonLifecycleValidator) Count() int {
|
|
validator.mu.Lock()
|
|
defer validator.mu.Unlock()
|
|
return validator.count
|
|
}
|
|
|
|
type partialFailBackend struct {
|
|
mu sync.Mutex
|
|
failKind clientprocess.ClientKind
|
|
failErr error
|
|
cleanupErr error
|
|
processes map[clientprocess.ClientKind]*fakeOwnedProcess
|
|
}
|
|
|
|
func newPartialFailBackend(failKind clientprocess.ClientKind, failErr error, cleanupErr error) *partialFailBackend {
|
|
return &partialFailBackend{
|
|
failKind: failKind,
|
|
failErr: failErr,
|
|
cleanupErr: cleanupErr,
|
|
processes: make(map[clientprocess.ClientKind]*fakeOwnedProcess),
|
|
}
|
|
}
|
|
|
|
func (b *partialFailBackend) Start(ctx context.Context, kind clientprocess.ClientKind, spec agentconfig.ClientProcessSpec) (clientprocess.OwnedProcess, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
if kind == b.failKind {
|
|
return nil, b.failErr
|
|
}
|
|
p := &fakeOwnedProcess{
|
|
identity: clientprocess.ProcessIdentity{PID: 1234, StartToken: "token-1234"},
|
|
done: make(chan struct{}),
|
|
}
|
|
b.processes[kind] = p
|
|
return p, nil
|
|
}
|
|
|
|
func (b *partialFailBackend) Inspect(ctx context.Context, identity clientprocess.ProcessIdentity) (clientprocess.IdentityObservation, error) {
|
|
return clientprocess.IdentityObservation{State: clientprocess.IdentityLive}, nil
|
|
}
|
|
|
|
func (b *partialFailBackend) Signal(ctx context.Context, identity clientprocess.ProcessIdentity, sig os.Signal) error {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
for _, p := range b.processes {
|
|
if p.identity == identity {
|
|
p.Abort()
|
|
}
|
|
}
|
|
return b.cleanupErr
|
|
}
|
|
|
|
func (b *partialFailBackend) Kill(ctx context.Context, identity clientprocess.ProcessIdentity) error {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
for _, p := range b.processes {
|
|
if p.identity == identity {
|
|
p.Abort()
|
|
}
|
|
}
|
|
return b.cleanupErr
|
|
}
|
|
|
|
func (b *partialFailBackend) Focus(ctx context.Context, kind clientprocess.ClientKind, spec agentconfig.ClientProcessSpec) error {
|
|
return nil
|
|
}
|
|
|
|
func TestDaemonPartialClientLaunchFailureRollsBack(t *testing.T) {
|
|
stateRoot := t.TempDir()
|
|
_ = os.Chmod(stateRoot, 0700)
|
|
overlayRoot := filepath.Join(stateRoot, "overlays")
|
|
logRoot := filepath.Join(stateRoot, "logs")
|
|
|
|
globalConfig := `version: "1"
|
|
defaults:
|
|
default_profile: p1
|
|
isolation:
|
|
default_mode: overlay
|
|
retention:
|
|
completed_days: 14
|
|
`
|
|
localConfig := fmt.Sprintf(`version: "1"
|
|
device:
|
|
state_root: %s
|
|
overlay_root: %s
|
|
log_root: %s
|
|
clients:
|
|
flutter:
|
|
executable: /bin/echo
|
|
working_directory: /tmp
|
|
launch_on_start: true
|
|
unity:
|
|
executable: /bin/echo
|
|
working_directory: /tmp
|
|
launch_on_start: true
|
|
`, stateRoot, overlayRoot, logRoot)
|
|
|
|
snapshot, err := agentconfig.LoadRuntimeConfigBytes([]byte(globalConfig), []byte(localConfig))
|
|
if err != nil {
|
|
t.Fatalf("LoadRuntimeConfigBytes error = %v", err)
|
|
}
|
|
|
|
launchErr := errors.New("unity process launch failed")
|
|
cleanupErr := errors.New("flutter process cleanup failed")
|
|
backend := newPartialFailBackend(clientprocess.ClientUnity, launchErr, cleanupErr)
|
|
|
|
daemon, err := NewDaemonModule(
|
|
context.Background(),
|
|
snapshot,
|
|
WithProcessBackend(backend),
|
|
WithTaskCatalog(bootstrapTestCatalog(snapshot)),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewDaemonModule() error = %v", err)
|
|
}
|
|
|
|
ctx := context.Background()
|
|
runErr := daemon.Run(ctx)
|
|
if runErr == nil {
|
|
t.Fatal("expected daemon.Run to return error on partial launch failure, got nil")
|
|
}
|
|
|
|
if !errors.Is(runErr, launchErr) {
|
|
t.Fatalf("daemon.Run error = %v, want errors.Is launchErr", runErr)
|
|
}
|
|
|
|
if !errors.Is(runErr, cleanupErr) {
|
|
t.Fatalf("daemon.Run error = %v, want errors.Is cleanupErr", runErr)
|
|
}
|
|
|
|
backend.mu.Lock()
|
|
flutterProc := backend.processes[clientprocess.ClientFlutter]
|
|
backend.mu.Unlock()
|
|
|
|
if flutterProc == nil {
|
|
t.Fatal("expected flutter process to have been launched initially")
|
|
}
|
|
|
|
select {
|
|
case <-flutterProc.done:
|
|
// Succeeded: flutter process was aborted/closed during rollback
|
|
default:
|
|
t.Fatal("expected flutter process to be aborted/reaped on partial startup rollback")
|
|
}
|
|
|
|
status := daemon.Status()
|
|
if !status.Stopped {
|
|
t.Fatal("expected host status to record stopped/failed state")
|
|
}
|
|
|
|
socketPath := daemon.ControlServer.Path()
|
|
if _, err := os.Lstat(socketPath); !os.IsNotExist(err) {
|
|
t.Errorf("expected local control socket to be removed after rollback, got %v", err)
|
|
}
|
|
}
|