303 lines
10 KiB
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)
|
|
}
|
|
}
|