iop/apps/agent/internal/taskloop/integration_test.go

303 lines
10 KiB
Go

package taskloop
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"iop/packages/go/agentconfig"
"iop/packages/go/agentguard"
"iop/packages/go/agentstate"
"iop/packages/go/agenttask"
"iop/packages/go/agentworkspace"
)
func TestIntegrationDelegatesCleanConflictRetentionAndQueueContinuation(t *testing.T) {
fixture := newLoopIntegrationFixture(t)
writeIntegrationFile(t, filepath.Join(fixture.baseRoot, "shared.txt"), "base\n")
writeIntegrationFile(t, filepath.Join(fixture.baseRoot, "other.txt"), "other base\n")
fixture.commit("base")
first := fixture.prepare("first", 1)
conflicting := fixture.prepare("conflicting", 2)
independent := fixture.prepare("independent", 3)
writeIntegrationFile(t, filepath.Join(first.view, "shared.txt"), "first\n")
writeIntegrationFile(t, filepath.Join(conflicting.view, "shared.txt"), "second\n")
writeIntegrationFile(t, filepath.Join(independent.view, "other.txt"), "other integrated\n")
firstSet := first.freeze()
conflictingSet := conflicting.freeze()
independentSet := independent.freeze()
integration, err := NewIntegration(fixture.backend, fixture.store, DefaultValidator())
if err != nil {
t.Fatalf("NewIntegration: %v", err)
}
firstResult := fixture.integrate(integration, first, firstSet)
if firstResult.Outcome != agenttask.IntegrationOutcomeIntegrated {
t.Fatalf("first result = %#v", firstResult)
}
conflictResult := fixture.integrate(integration, conflicting, conflictingSet)
if conflictResult.Outcome != agenttask.IntegrationOutcomeTerminalDeferred ||
!conflictResult.Retained ||
conflictResult.Blocker == nil ||
!strings.Contains(conflictResult.Blocker.Message, "conflict") {
t.Fatalf("conflict result = %#v", conflictResult)
}
independentResult := fixture.integrate(integration, independent, independentSet)
if independentResult.Outcome != agenttask.IntegrationOutcomeIntegrated {
t.Fatalf("independent result = %#v", independentResult)
}
assertIntegrationFile(t, filepath.Join(fixture.baseRoot, "shared.txt"), "first\n")
assertIntegrationFile(t, filepath.Join(fixture.baseRoot, "other.txt"), "other integrated\n")
if _, err := os.Stat(conflictingSet.Locator.Record); err != nil {
t.Fatalf("retained conflict record: %v", err)
}
}
func TestIntegrationRejectsUnavailableSharedOwner(t *testing.T) {
var integration *Integration
_, err := integration.Integrate(context.Background(), agenttask.IntegrationRequest{})
if err == nil || !strings.Contains(err.Error(), "shared integrator is unavailable") {
t.Fatalf("Integrate error = %v", err)
}
}
func TestIntegrationRequiresPostApplyValidator(t *testing.T) {
fixture := newLoopIntegrationFixture(t)
if _, err := NewIntegration(fixture.backend, fixture.store, nil); err == nil ||
!strings.Contains(err.Error(), "validator is required") {
t.Fatalf("NewIntegration(nil validator) error = %v", err)
}
}
func TestIntegrationValidationFailureRollsBackAndAllowsIndependentQueue(t *testing.T) {
fixture := newLoopIntegrationFixture(t)
writeIntegrationFile(t, filepath.Join(fixture.baseRoot, "a.txt"), "a base\n")
writeIntegrationFile(t, filepath.Join(fixture.baseRoot, "b.txt"), "b base\n")
fixture.commit("base")
rejected := fixture.prepare("rejected", 1)
independent := fixture.prepare("independent-after-rejection", 2)
writeIntegrationFile(t, filepath.Join(rejected.view, "a.txt"), "invalid whitespace \n")
writeIntegrationFile(t, filepath.Join(independent.view, "b.txt"), "b integrated\n")
rejectedSet := rejected.freeze()
independentSet := independent.freeze()
integration, err := NewIntegration(
fixture.backend,
fixture.store,
DefaultValidator(),
)
if err != nil {
t.Fatalf("NewIntegration: %v", err)
}
rejectedResult := fixture.integrate(integration, rejected, rejectedSet)
if rejectedResult.Outcome != agenttask.IntegrationOutcomeTerminalDeferred ||
!rejectedResult.Retained ||
rejectedResult.Blocker == nil ||
!strings.Contains(rejectedResult.Blocker.Message, "validation") {
t.Fatalf("rejected result = %#v", rejectedResult)
}
assertIntegrationFile(t, filepath.Join(fixture.baseRoot, "a.txt"), "a base\n")
independentResult := fixture.integrate(
integration,
independent,
independentSet,
)
if independentResult.Outcome != agenttask.IntegrationOutcomeIntegrated {
t.Fatalf("independent result = %#v", independentResult)
}
assertIntegrationFile(
t,
filepath.Join(fixture.baseRoot, "b.txt"),
"b integrated\n",
)
}
type loopIntegrationFixture struct {
t *testing.T
baseRoot string
backend *agentworkspace.Backend
store *agentstate.Store
}
type loopPreparedTask struct {
fixture *loopIntegrationFixture
request agenttask.IsolationRequest
prepared agenttask.PreparedIsolation
view string
artifact agenttask.ArtifactID
ordinal agenttask.DispatchOrdinal
}
func newLoopIntegrationFixture(t *testing.T) *loopIntegrationFixture {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git is required for integration adapter tests")
}
root, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatal(err)
}
baseRoot := filepath.Join(root, "workspace")
localRoot := filepath.Join(root, "runtime")
for _, directory := range []string{baseRoot, localRoot} {
if err := os.MkdirAll(directory, 0700); err != nil {
t.Fatal(err)
}
}
runIntegrationGit(t, baseRoot, "init", "-q")
runIntegrationGit(t, baseRoot, "config", "user.email", "taskloop@example.invalid")
runIntegrationGit(t, baseRoot, "config", "user.name", "Taskloop Fixture")
resolver := agentworkspace.InputResolverFunc(func(
_ context.Context,
request agenttask.IsolationRequest,
) (agentworkspace.ResolvedInputs, error) {
return agentworkspace.ResolvedInputs{
Grant: agentguard.WorkspaceGrant{
ProjectID: string(request.Project.ProjectID), WorkspaceID: string(request.Project.WorkspaceID),
Root: baseRoot, Revision: string(request.Project.Intent.GrantRevision),
},
Profile: agentguard.ProviderProfile{
ProviderID: request.Target.ProviderID, ModelID: request.Target.ModelID,
ProfileID: request.Target.ProfileID, Revision: request.Target.ProfileRevision,
Unattended: true, ApprovalBypass: true, WritableRootConfinement: true,
},
}, nil
})
backend, err := agentworkspace.NewBackend(agentworkspace.BackendConfig{
LocalRoot: localRoot,
Retention: agentconfig.RetentionPolicy{CompletedDays: 7, BlockedDays: 14},
}, resolver)
if err != nil {
t.Fatalf("NewBackend: %v", err)
}
store, err := agentstate.NewStore(filepath.Join(localRoot, "state", "manager.json"))
if err != nil {
t.Fatalf("NewStore: %v", err)
}
return &loopIntegrationFixture{
t: t, baseRoot: baseRoot, backend: backend, store: store,
}
}
func (fixture *loopIntegrationFixture) commit(message string) {
fixture.t.Helper()
runIntegrationGit(fixture.t, fixture.baseRoot, "add", "-A")
runIntegrationGit(fixture.t, fixture.baseRoot, "commit", "-q", "-m", message)
}
func (fixture *loopIntegrationFixture) prepare(
workID string,
ordinal agenttask.DispatchOrdinal,
) loopPreparedTask {
fixture.t.Helper()
projectID := agenttask.ProjectID("project")
workspaceID := agenttask.WorkspaceID("workspace")
request := agenttask.IsolationRequest{
Project: agenttask.ProjectRecord{
ProjectID: projectID, WorkspaceID: workspaceID,
Intent: &agenttask.StartIntent{
ProjectID: projectID, WorkspaceID: workspaceID,
ConfigRevision: "config-r1", GrantRevision: "grant-r1",
},
},
Work: agenttask.WorkRecord{
Unit: agenttask.WorkUnit{
ID: agenttask.WorkUnitID(workID), IsolationMode: agentguard.IsolationModeOverlay,
},
AttemptID: agenttask.AttemptID(workID + "#1"), DispatchOrdinal: ordinal,
},
Target: agenttask.ExecutionTarget{
ProviderID: "provider", ModelID: "model", ProfileID: "profile",
ProfileRevision: "profile-r1", ConfigRevision: "config-r1", Capacity: 3,
},
IdempotencyKey: "dispatch/" + workID + "/1/isolation",
}
prepared, err := fixture.backend.Prepare(context.Background(), request)
if err != nil {
fixture.t.Fatalf("Prepare(%s): %v", workID, err)
}
return loopPreparedTask{
fixture: fixture, request: request, prepared: prepared,
view: prepared.Descriptor.WorkingDir,
artifact: agenttask.ArtifactID("artifact-" + workID),
ordinal: ordinal,
}
}
func (task loopPreparedTask) freeze() agentworkspace.ChangeSet {
task.fixture.t.Helper()
changeSet, err := task.fixture.backend.Freeze(context.Background(), agentworkspace.FreezeRequest{
Descriptor: *task.prepared.Descriptor,
ArtifactID: task.artifact,
ValidationEvidence: []agentworkspace.ValidationEvidence{{
Name: "official-review", Result: "pass", Digest: "sha256:fixture",
}},
})
if err != nil {
task.fixture.t.Fatalf("Freeze(%s): %v", task.request.Work.Unit.ID, err)
}
return changeSet
}
func (fixture *loopIntegrationFixture) integrate(
integration *Integration,
task loopPreparedTask,
changeSet agentworkspace.ChangeSet,
) agenttask.IntegrationResult {
fixture.t.Helper()
work := task.request.Work
identity := changeSet.Identity()
work.ChangeSet = &identity
work.Isolation = &agenttask.IsolationIdentity{
ID: task.prepared.Descriptor.ID, Revision: task.prepared.Descriptor.Revision,
Mode: task.prepared.Descriptor.Mode,
PinnedBaseRevision: task.prepared.Descriptor.PinnedBaseRevision,
TaskRoot: task.prepared.Descriptor.TaskRoot,
}
result, err := integration.Integrate(context.Background(), agenttask.IntegrationRequest{
Project: task.request.Project, Work: work, ChangeSet: identity,
Ordinal: task.ordinal, Attempt: 1,
IdempotencyKey: fmt.Sprintf(
"integrate/%s/%s/1",
task.request.Work.Unit.ID,
changeSet.Revision,
),
})
if err != nil {
fixture.t.Fatalf("Integrate(%s): %v", task.request.Work.Unit.ID, err)
}
return result
}
func runIntegrationGit(t *testing.T, root string, arguments ...string) {
t.Helper()
command := exec.Command("git", arguments...)
command.Dir = root
if output, err := command.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", arguments, err, output)
}
}
func writeIntegrationFile(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func assertIntegrationFile(t *testing.T, path, expected string) {
t.Helper()
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
if string(content) != expected {
t.Fatalf("%s = %q, want %q", path, content, expected)
}
}