iop/packages/go/agentworkspace/integrator_test.go
toki 3c48879c44 feat(agent-runtime): 독립 Agent CLI 런타임 기반을 구현한다
독립 호스트에서 안전한 작업 실행과 복구를 제공하기 위해 런타임 설정, 정책, 상태 저장소, 워크스페이스 격리 및 AgentTask 오케스트레이션을 확장한다.
2026-07-29 13:12:21 +09:00

996 lines
32 KiB
Go

package agentworkspace_test
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"path/filepath"
"reflect"
"runtime"
"sort"
"strings"
"sync/atomic"
"testing"
"iop/packages/go/agentconfig"
"iop/packages/go/agentguard"
"iop/packages/go/agentstate"
"iop/packages/go/agenttask"
"iop/packages/go/agentworkspace"
)
func TestFreezeChangeSetCapturesContentModeSymlinkAndDeletion(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("mode and symlink operations require Unix filesystem semantics")
}
fixture := newIntegrationFixture(t)
writeFixtureFile(t, filepath.Join(fixture.baseRoot, "modified.txt"), "base\n", 0o644)
writeFixtureFile(t, filepath.Join(fixture.baseRoot, "deleted.txt"), "delete\n", 0o644)
writeFixtureFile(t, filepath.Join(fixture.baseRoot, "mode.sh"), "#!/bin/sh\n", 0o644)
if err := os.Symlink("modified.txt", filepath.Join(fixture.baseRoot, "link")); err != nil {
t.Fatalf("Symlink: %v", err)
}
fixture.commit("base")
task := fixture.prepare("freeze", 1)
writeFixtureFile(t, filepath.Join(task.view, "modified.txt"), "reviewed\n", 0o644)
writeFixtureFile(t, filepath.Join(task.view, "added.txt"), "added\n", 0o644)
if err := os.Remove(filepath.Join(task.view, "deleted.txt")); err != nil {
t.Fatalf("remove deleted fixture: %v", err)
}
if err := os.Chmod(filepath.Join(task.view, "mode.sh"), 0o755); err != nil {
t.Fatalf("chmod fixture: %v", err)
}
if err := os.Remove(filepath.Join(task.view, "link")); err != nil {
t.Fatalf("remove link fixture: %v", err)
}
if err := os.Symlink("added.txt", filepath.Join(task.view, "link")); err != nil {
t.Fatalf("replace link fixture: %v", err)
}
changeSet := task.freeze()
operations := make(map[string]agentworkspace.ChangeOperation)
for _, operation := range changeSet.Operations {
operations[operation.Path] = operation
}
for path, want := range map[string]agentworkspace.ChangeOperationKind{
"added.txt": agentworkspace.ChangeOperationAdd,
"deleted.txt": agentworkspace.ChangeOperationDelete,
"link": agentworkspace.ChangeOperationModify,
"mode.sh": agentworkspace.ChangeOperationModify,
"modified.txt": agentworkspace.ChangeOperationModify,
} {
if operation, ok := operations[path]; !ok || operation.Kind != want {
t.Fatalf("operation %s = %#v, want %s", path, operation, want)
}
}
if operations["link"].Result == nil ||
operations["link"].Result.Kind != agentworkspace.SnapshotEntrySymlink ||
operations["link"].Result.SymlinkTarget != "added.txt" {
t.Fatalf("symlink operation = %#v", operations["link"])
}
if operations["mode.sh"].Result == nil ||
os.FileMode(operations["mode.sh"].Result.Mode).Perm() != 0o755 {
t.Fatalf("mode operation = %#v", operations["mode.sh"])
}
if changeSet.Identity().ArtifactID != task.artifactID ||
!strings.HasPrefix(changeSet.Revision, "sha256:") {
t.Fatalf("change-set identity = %#v", changeSet.Identity())
}
replayed := task.freeze()
if !reflect.DeepEqual(replayed, changeSet) {
t.Fatalf("idempotent freeze changed record\nfirst=%#v\nsecond=%#v", changeSet, replayed)
}
}
func TestSerialIntegratorAppliesDisjointSetsAndReplaysAfterRestart(t *testing.T) {
fixture := newIntegrationFixture(t)
writeFixtureFile(t, filepath.Join(fixture.baseRoot, "a.txt"), "a base\n", 0o644)
writeFixtureFile(t, filepath.Join(fixture.baseRoot, "b.txt"), "b base\n", 0o644)
fixture.commit("base")
first := fixture.prepare("first", 1)
second := fixture.prepare("second", 2)
writeFixtureFile(t, filepath.Join(first.view, "a.txt"), "a integrated\n", 0o644)
writeFixtureFile(t, filepath.Join(second.view, "b.txt"), "b integrated\n", 0o644)
firstChangeSet := first.freeze()
secondChangeSet := second.freeze()
var validations atomic.Int32
integrator := fixture.integrator(func(
_ context.Context,
_ agentworkspace.ValidationRequest,
) error {
validations.Add(1)
return nil
})
firstResult := integrateTask(t, integrator, first, firstChangeSet, 1)
secondResult := integrateTask(t, integrator, second, secondChangeSet, 1)
if firstResult.Outcome != agenttask.IntegrationOutcomeIntegrated ||
secondResult.Outcome != agenttask.IntegrationOutcomeIntegrated {
t.Fatalf("integration outcomes = %s/%s", firstResult.Outcome, secondResult.Outcome)
}
assertFixtureContent(t, filepath.Join(fixture.baseRoot, "a.txt"), "a integrated\n")
assertFixtureContent(t, filepath.Join(fixture.baseRoot, "b.txt"), "b integrated\n")
managerState, managerRevision, err := fixture.store.Load(context.Background())
if err != nil {
t.Fatalf("Load manager state: %v", err)
}
managerState.NextOrdinal = 99
if _, err := fixture.store.CompareAndSwap(
context.Background(),
managerRevision,
managerState,
); err != nil {
t.Fatalf("manager CAS after integration: %v", err)
}
reopenedStore, err := agentstate.NewStore(fixture.statePath)
if err != nil {
t.Fatalf("NewStore(reopen): %v", err)
}
restarted, err := agentworkspace.NewIntegrator(agentworkspace.IntegratorConfig{
Backend: fixture.backend,
Store: reopenedStore,
Validator: func(
_ context.Context,
_ agentworkspace.ValidationRequest,
) error {
validations.Add(1)
return nil
},
})
if err != nil {
t.Fatalf("NewIntegrator(restart): %v", err)
}
replayed := integrateTask(t, restarted, second, secondChangeSet, 1)
if replayed.Outcome != agenttask.IntegrationOutcomeIntegrated ||
validations.Load() != 2 {
t.Fatalf(
"restart replay outcome/validations = %s/%d",
replayed.Outcome,
validations.Load(),
)
}
}
func TestSerialIntegratorThreeWayMergesManagedPredecessor(t *testing.T) {
fixture := newIntegrationFixture(t)
writeFixtureFile(
t,
filepath.Join(fixture.baseRoot, "shared.txt"),
"first base\nmiddle\nlast base\n",
0o644,
)
fixture.commit("base")
first := fixture.prepare("first", 1)
second := fixture.prepare("second", 2)
writeFixtureFile(
t,
filepath.Join(first.view, "shared.txt"),
"first integrated\nmiddle\nlast base\n",
0o644,
)
writeFixtureFile(
t,
filepath.Join(second.view, "shared.txt"),
"first base\nmiddle\nlast integrated\n",
0o644,
)
firstChangeSet := first.freeze()
secondChangeSet := second.freeze()
integrator := fixture.integrator(func(
_ context.Context,
_ agentworkspace.ValidationRequest,
) error {
return nil
})
if result := integrateTask(t, integrator, first, firstChangeSet, 1); result.Outcome != agenttask.IntegrationOutcomeIntegrated {
t.Fatalf("first outcome = %s", result.Outcome)
}
if result := integrateTask(t, integrator, second, secondChangeSet, 1); result.Outcome != agenttask.IntegrationOutcomeIntegrated {
t.Fatalf("second outcome = %#v", result)
}
assertFixtureContent(
t,
filepath.Join(fixture.baseRoot, "shared.txt"),
"first integrated\nmiddle\nlast integrated\n",
)
}
func TestSerialIntegratorRetainsConflictAndAdvancesIndependentChangeSet(t *testing.T) {
fixture := newIntegrationFixture(t)
writeFixtureFile(t, filepath.Join(fixture.baseRoot, "shared.txt"), "base\n", 0o644)
writeFixtureFile(t, filepath.Join(fixture.baseRoot, "other.txt"), "other base\n", 0o644)
fixture.commit("base")
first := fixture.prepare("first", 1)
conflicting := fixture.prepare("conflicting", 2)
independent := fixture.prepare("independent", 3)
writeFixtureFile(t, filepath.Join(first.view, "shared.txt"), "first\n", 0o644)
writeFixtureFile(t, filepath.Join(conflicting.view, "shared.txt"), "second\n", 0o644)
writeFixtureFile(t, filepath.Join(independent.view, "other.txt"), "other integrated\n", 0o644)
firstChangeSet := first.freeze()
conflictingChangeSet := conflicting.freeze()
independentChangeSet := independent.freeze()
integrator := fixture.integrator(func(
_ context.Context,
_ agentworkspace.ValidationRequest,
) error {
return nil
})
if result := integrateTask(t, integrator, first, firstChangeSet, 1); result.Outcome != agenttask.IntegrationOutcomeIntegrated {
t.Fatalf("first outcome = %s", result.Outcome)
}
beforeConflict := workspaceDigest(t, fixture.baseRoot)
conflictResult := integrateTask(t, integrator, conflicting, conflictingChangeSet, 1)
if conflictResult.Outcome != agenttask.IntegrationOutcomeTerminalDeferred ||
!conflictResult.Retained ||
conflictResult.Blocker == nil ||
!strings.Contains(conflictResult.Blocker.Message, "conflict") {
t.Fatalf("conflict result = %#v", conflictResult)
}
if afterConflict := workspaceDigest(t, fixture.baseRoot); afterConflict != beforeConflict {
t.Fatalf("conflict changed canonical digest: %s != %s", afterConflict, beforeConflict)
}
if _, err := os.Stat(conflictingChangeSet.Locator.Record); err != nil {
t.Fatalf("retained change set: %v", err)
}
independentResult := integrateTask(
t,
integrator,
independent,
independentChangeSet,
1,
)
if independentResult.Outcome != agenttask.IntegrationOutcomeIntegrated {
t.Fatalf("independent outcome = %s", independentResult.Outcome)
}
assertFixtureContent(t, filepath.Join(fixture.baseRoot, "shared.txt"), "first\n")
assertFixtureContent(t, filepath.Join(fixture.baseRoot, "other.txt"), "other integrated\n")
}
func TestSerialIntegratorRejectsUnmanagedDriftWithoutMutation(t *testing.T) {
fixture := newIntegrationFixture(t)
writeFixtureFile(t, filepath.Join(fixture.baseRoot, "target.txt"), "base\n", 0o644)
writeFixtureFile(t, filepath.Join(fixture.baseRoot, "unrelated.txt"), "stable\n", 0o644)
fixture.commit("base")
task := fixture.prepare("drift", 1)
writeFixtureFile(t, filepath.Join(task.view, "target.txt"), "integrated\n", 0o644)
changeSet := task.freeze()
writeFixtureFile(t, filepath.Join(fixture.baseRoot, "unrelated.txt"), "external drift\n", 0o644)
before := workspaceDigest(t, fixture.baseRoot)
var validations atomic.Int32
integrator := fixture.integrator(func(
_ context.Context,
_ agentworkspace.ValidationRequest,
) error {
validations.Add(1)
return nil
})
result := integrateTask(t, integrator, task, changeSet, 1)
if result.Outcome != agenttask.IntegrationOutcomeTerminalDeferred ||
result.Blocker == nil ||
!strings.Contains(result.Blocker.Message, "unmanaged base drift") {
t.Fatalf("drift result = %#v", result)
}
if after := workspaceDigest(t, fixture.baseRoot); after != before {
t.Fatalf("drift rejection changed canonical digest: %s != %s", after, before)
}
if validations.Load() != 0 {
t.Fatalf("validator calls = %d, want 0", validations.Load())
}
assertFixtureContent(t, filepath.Join(fixture.baseRoot, "target.txt"), "base\n")
}
func TestSerialIntegratorRollsBackFailedValidationAndReplaysTerminalResult(t *testing.T) {
fixture := newIntegrationFixture(t)
writeFixtureFile(t, filepath.Join(fixture.baseRoot, "modified.txt"), "base\n", 0o644)
writeFixtureFile(t, filepath.Join(fixture.baseRoot, "deleted.txt"), "keep\n", 0o644)
fixture.commit("base")
task := fixture.prepare("validation", 1)
writeFixtureFile(t, filepath.Join(task.view, "modified.txt"), "candidate\n", 0o755)
writeFixtureFile(t, filepath.Join(task.view, "added.txt"), "candidate\n", 0o644)
if err := os.Remove(filepath.Join(task.view, "deleted.txt")); err != nil {
t.Fatalf("remove task file: %v", err)
}
changeSet := task.freeze()
before := workspaceDigest(t, fixture.baseRoot)
var validations atomic.Int32
integrator := fixture.integrator(func(
_ context.Context,
_ agentworkspace.ValidationRequest,
) error {
validations.Add(1)
return errorsForTest("fixture validation failure")
})
result := integrateTask(t, integrator, task, changeSet, 1)
if result.Outcome != agenttask.IntegrationOutcomeTerminalDeferred ||
result.BeforeRevision == "" ||
result.AfterRevision != result.BeforeRevision ||
result.Blocker == nil ||
!strings.Contains(result.Blocker.Message, "validation failed") {
t.Fatalf("validation result = %#v", result)
}
if after := workspaceDigest(t, fixture.baseRoot); after != before {
t.Fatalf("validation rollback digest = %s, want %s", after, before)
}
replayed := integrateTask(t, integrator, task, changeSet, 1)
if replayed.Outcome != agenttask.IntegrationOutcomeTerminalDeferred ||
validations.Load() != 1 {
t.Fatalf(
"terminal replay outcome/validations = %s/%d",
replayed.Outcome,
validations.Load(),
)
}
assertFixtureContent(t, filepath.Join(fixture.baseRoot, "modified.txt"), "base\n")
assertFixtureContent(t, filepath.Join(fixture.baseRoot, "deleted.txt"), "keep\n")
if _, err := os.Stat(filepath.Join(fixture.baseRoot, "added.txt")); !os.IsNotExist(err) {
t.Fatalf("rolled-back addition exists: %v", err)
}
}
func TestSerialIntegratorRestartRecoversInterruptedApplyByRollback(t *testing.T) {
fixture := newIntegrationFixture(t)
writeFixtureFile(t, filepath.Join(fixture.baseRoot, "target.txt"), "base\n", 0o644)
fixture.commit("base")
task := fixture.prepare("restart", 1)
writeFixtureFile(t, filepath.Join(task.view, "target.txt"), "candidate\n", 0o644)
changeSet := task.freeze()
before := workspaceDigest(t, fixture.baseRoot)
faultStore := &failNthIntegrationStore{delegate: fixture.store, failAt: 2}
var validations atomic.Int32
integrator, err := agentworkspace.NewIntegrator(agentworkspace.IntegratorConfig{
Backend: fixture.backend,
Store: faultStore,
Validator: func(
_ context.Context,
_ agentworkspace.ValidationRequest,
) error {
validations.Add(1)
return nil
},
})
if err != nil {
t.Fatalf("NewIntegrator: %v", err)
}
request := integrationRequest(task, changeSet, 1)
if _, err := integrator.Integrate(context.Background(), request); !errorsIsRevisionConflict(err) {
t.Fatalf("interrupted Integrate error = %v", err)
}
if after := workspaceDigest(t, fixture.baseRoot); after != before {
t.Fatalf("interrupted apply rollback digest = %s, want %s", after, before)
}
restarted := fixture.integrator(func(
_ context.Context,
_ agentworkspace.ValidationRequest,
) error {
validations.Add(1)
return nil
})
result, err := restarted.Integrate(context.Background(), request)
if err != nil {
t.Fatalf("restart Integrate: %v", err)
}
if result.Outcome != agenttask.IntegrationOutcomeTerminalDeferred ||
result.BeforeRevision != result.AfterRevision ||
result.Blocker == nil ||
!strings.Contains(result.Blocker.Message, "restart recovered") {
t.Fatalf("restart recovery result = %#v", result)
}
if validations.Load() != 1 {
t.Fatalf("validator calls = %d, want 1", validations.Load())
}
}
func TestSerialIntegratorPreservesManagedDescendantAcrossDirectoryDeletion(t *testing.T) {
fixture := newIntegrationFixture(t)
writeFixtureFile(t, filepath.Join(fixture.baseRoot, "tree", "keep.txt"), "keep\n", 0o644)
fixture.commit("base")
first := fixture.prepare("first", 1)
second := fixture.prepare("second", 2)
// The managed predecessor adds an independent descendant under tree/.
writeFixtureFile(t, filepath.Join(first.view, "tree", "new.txt"), "new\n", 0o644)
// The later change set deletes the whole tree/ directory it saw in its base.
if err := os.RemoveAll(filepath.Join(second.view, "tree")); err != nil {
t.Fatalf("remove tree fixture: %v", err)
}
firstChangeSet := first.freeze()
secondChangeSet := second.freeze()
integrator := fixture.integrator(func(
_ context.Context,
_ agentworkspace.ValidationRequest,
) error {
return nil
})
if result := integrateTask(t, integrator, first, firstChangeSet, 1); result.Outcome != agenttask.IntegrationOutcomeIntegrated {
t.Fatalf("first outcome = %s", result.Outcome)
}
assertFixtureContent(t, filepath.Join(fixture.baseRoot, "tree", "new.txt"), "new\n")
if result := integrateTask(t, integrator, second, secondChangeSet, 1); result.Outcome != agenttask.IntegrationOutcomeIntegrated {
t.Fatalf("second outcome = %#v", result)
}
// The predecessor's independent descendant must survive the directory delete.
assertFixtureContent(t, filepath.Join(fixture.baseRoot, "tree", "new.txt"), "new\n")
// The change set's own descendant is deleted as requested.
if _, err := os.Stat(filepath.Join(fixture.baseRoot, "tree", "keep.txt")); !os.IsNotExist(err) {
t.Fatalf("owned descendant was not deleted: %v", err)
}
// The container is preserved because it still holds independent content.
if info, err := os.Stat(filepath.Join(fixture.baseRoot, "tree")); err != nil || !info.IsDir() {
t.Fatalf("preserved container = %#v, err=%v", info, err)
}
}
func TestSerialIntegratorReplacesNonEmptyDirectoryByType(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink replacement requires Unix filesystem semantics")
}
testCases := []struct {
name string
prepare func(*testing.T, string)
assert func(*testing.T, string)
}{
{
name: "regular",
prepare: func(t *testing.T, path string) {
writeFixtureFile(t, path, "flat\n", 0o644)
},
assert: func(t *testing.T, path string) {
info, err := os.Lstat(path)
if err != nil || !info.Mode().IsRegular() {
t.Fatalf("regular replacement = %#v, err=%v", info, err)
}
assertFixtureContent(t, path, "flat\n")
},
},
{
name: "symlink",
prepare: func(t *testing.T, path string) {
if err := os.Symlink("replacement.txt", path); err != nil {
t.Fatalf("create replacement symlink: %v", err)
}
},
assert: func(t *testing.T, path string) {
info, err := os.Lstat(path)
if err != nil || info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("symlink replacement = %#v, err=%v", info, err)
}
target, err := os.Readlink(path)
if err != nil || target != "replacement.txt" {
t.Fatalf("symlink target = %q, err=%v", target, err)
}
},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
fixture := newIntegrationFixture(t)
writeFixtureFile(
t,
filepath.Join(fixture.baseRoot, "tree", "child.txt"),
"base\n",
0o644,
)
writeFixtureFile(
t,
filepath.Join(fixture.baseRoot, "replacement.txt"),
"replacement target\n",
0o644,
)
fixture.commit("base")
task := fixture.prepare("replace-tree-"+testCase.name, 1)
if err := os.RemoveAll(filepath.Join(task.view, "tree")); err != nil {
t.Fatalf("remove task tree: %v", err)
}
testCase.prepare(t, filepath.Join(task.view, "tree"))
changeSet := task.freeze()
var validations atomic.Int32
integrator := fixture.integrator(func(
_ context.Context,
request agentworkspace.ValidationRequest,
) error {
validations.Add(1)
testCase.assert(t, filepath.Join(request.ValidationRoot, "tree"))
return nil
})
result := integrateTask(t, integrator, task, changeSet, 1)
if result.Outcome != agenttask.IntegrationOutcomeIntegrated {
t.Fatalf("integration result = %#v", result)
}
if validations.Load() != 1 {
t.Fatalf("validator calls = %d, want 1", validations.Load())
}
testCase.assert(t, filepath.Join(fixture.baseRoot, "tree"))
if _, err := os.Lstat(filepath.Join(fixture.baseRoot, "tree", "child.txt")); err == nil {
t.Fatal("owned descendant was not removed")
}
})
}
}
func TestSerialIntegratorValidationMutationDoesNotEscapeCandidateView(t *testing.T) {
fixture := newIntegrationFixture(t)
writeFixtureFile(t, filepath.Join(fixture.baseRoot, "target.txt"), "base\n", 0o644)
writeFixtureFile(t, filepath.Join(fixture.baseRoot, "unrelated.txt"), "stable\n", 0o644)
fixture.commit("base")
task := fixture.prepare("mutate", 1)
writeFixtureFile(t, filepath.Join(task.view, "target.txt"), "integrated\n", 0o644)
changeSet := task.freeze()
before := workspaceDigest(t, fixture.baseRoot)
var seenValidationRoot string
integrator := fixture.integrator(func(
_ context.Context,
request agentworkspace.ValidationRequest,
) error {
seenValidationRoot = request.ValidationRoot
// A misbehaving validator writes outside the change-set write set and
// then fails; the mutation must never reach the canonical workspace.
for name, content := range map[string]string{
"escape.txt": "leak\n",
"unrelated.txt": "mutated\n",
} {
if err := os.WriteFile(
filepath.Join(request.ValidationRoot, name),
[]byte(content),
0o644,
); err != nil {
t.Fatalf("validator side-effect write %s: %v", name, err)
}
}
return errorsForTest("validation failure after side effects")
})
result := integrateTask(t, integrator, task, changeSet, 1)
if result.Outcome != agenttask.IntegrationOutcomeTerminalDeferred ||
result.BeforeRevision == "" ||
result.AfterRevision != result.BeforeRevision ||
result.Blocker == nil ||
!strings.Contains(result.Blocker.Message, "validation failed") {
t.Fatalf("validation result = %#v", result)
}
if seenValidationRoot == "" || seenValidationRoot == fixture.baseRoot {
t.Fatalf(
"validation root = %q, want a candidate outside %q",
seenValidationRoot,
fixture.baseRoot,
)
}
if after := workspaceDigest(t, fixture.baseRoot); after != before {
t.Fatalf("validator side effect changed canonical digest: %s != %s", after, before)
}
if _, err := os.Stat(filepath.Join(fixture.baseRoot, "escape.txt")); !os.IsNotExist(err) {
t.Fatalf("validator escape survived in canonical: %v", err)
}
assertFixtureContent(t, filepath.Join(fixture.baseRoot, "unrelated.txt"), "stable\n")
assertFixtureContent(t, filepath.Join(fixture.baseRoot, "target.txt"), "base\n")
}
func TestSerialIntegratorRevisedChangeSetAfterConflictAndLaterAdvance(t *testing.T) {
fixture := newIntegrationFixture(t)
writeFixtureFile(
t,
filepath.Join(fixture.baseRoot, "shared.txt"),
"line1\nline2\nline3\n",
0o644,
)
fixture.commit("base")
predecessor := fixture.prepare("predecessor", 1)
work := fixture.prepare("rework", 2)
writeFixtureFile(
t,
filepath.Join(predecessor.view, "shared.txt"),
"LINE1\nline2\nline3\n",
0o644,
)
predecessorChangeSet := predecessor.freeze()
integrator := fixture.integrator(func(
_ context.Context,
request agentworkspace.ValidationRequest,
) error {
if request.ValidationRoot == "" || request.ValidationRoot == request.CanonicalRoot {
t.Errorf(
"validation root %q must be a candidate outside %q",
request.ValidationRoot,
request.CanonicalRoot,
)
}
return nil
})
if result := integrateTask(t, integrator, predecessor, predecessorChangeSet, 1); result.Outcome != agenttask.IntegrationOutcomeIntegrated {
t.Fatalf("predecessor outcome = %s", result.Outcome)
}
// The first attempt conflicts with the managed head on line 1 and is retained.
writeFixtureFile(
t,
filepath.Join(work.view, "shared.txt"),
"XXXX\nline2\nline3\n",
0o644,
)
firstChangeSet := work.freeze()
conflict := integrateTask(t, integrator, work, firstChangeSet, 1)
if conflict.Outcome != agenttask.IntegrationOutcomeTerminalDeferred ||
!conflict.Retained ||
conflict.Blocker == nil ||
!strings.Contains(conflict.Blocker.Message, "conflict") {
t.Fatalf("conflict result = %#v", conflict)
}
// The reworked immutable change set edits an independent line and merges
// cleanly at the next integration attempt without replaying the first.
writeFixtureFile(
t,
filepath.Join(work.view, "shared.txt"),
"line1\nline2\nLINE3\n",
0o644,
)
revisedChangeSet := work.freeze()
if revisedChangeSet.Identity() == firstChangeSet.Identity() {
t.Fatalf("revised change set kept the retained identity")
}
revised := integrateTask(t, integrator, work, revisedChangeSet, 2)
if revised.Outcome != agenttask.IntegrationOutcomeIntegrated {
t.Fatalf("revised outcome = %#v", revised)
}
assertFixtureContent(
t,
filepath.Join(fixture.baseRoot, "shared.txt"),
"LINE1\nline2\nLINE3\n",
)
if _, err := os.Stat(firstChangeSet.Locator.Record); err != nil {
t.Fatalf("retained first attempt change set is missing: %v", err)
}
}
type integrationFixture struct {
t *testing.T
baseRoot string
localRoot string
statePath string
backend *agentworkspace.Backend
store *agentstate.Store
}
type preparedTask struct {
fixture *integrationFixture
request agenttask.IsolationRequest
prepared agenttask.PreparedIsolation
view string
artifactID agenttask.ArtifactID
ordinal agenttask.DispatchOrdinal
}
type failNthIntegrationStore struct {
delegate agentworkspace.IntegrationRecordStore
failAt int
calls int
}
func (store *failNthIntegrationStore) LoadIntegrationRecord(
ctx context.Context,
key string,
) ([]byte, string, bool, error) {
return store.delegate.LoadIntegrationRecord(ctx, key)
}
func (store *failNthIntegrationStore) CompareAndSwapIntegrationRecord(
ctx context.Context,
key string,
expected string,
payload []byte,
) (string, error) {
store.calls++
if store.calls == store.failAt {
return "", agenttask.ErrRevisionConflict
}
return store.delegate.CompareAndSwapIntegrationRecord(
ctx,
key,
expected,
payload,
)
}
func newIntegrationFixture(t *testing.T) *integrationFixture {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git is required for change-set integration tests")
}
root, err := filepath.EvalSymlinks(t.TempDir())
if err != nil {
t.Fatalf("EvalSymlinks: %v", err)
}
baseRoot := filepath.Join(root, "workspace")
localRoot := filepath.Join(root, "runtime")
for _, directory := range []string{baseRoot, localRoot} {
if err := os.MkdirAll(directory, 0o700); err != nil {
t.Fatalf("MkdirAll %s: %v", directory, err)
}
}
gitFixtureRun(t, baseRoot, "init", "-q")
gitFixtureRun(t, baseRoot, "config", "user.email", "integration@example.invalid")
gitFixtureRun(t, baseRoot, "config", "user.name", "Integration 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)
}
statePath := filepath.Join(localRoot, "state", "manager.json")
store, err := agentstate.NewStore(statePath)
if err != nil {
t.Fatalf("NewStore: %v", err)
}
return &integrationFixture{
t: t, baseRoot: baseRoot, localRoot: localRoot,
statePath: statePath, backend: backend, store: store,
}
}
func (fixture *integrationFixture) commit(message string) {
fixture.t.Helper()
gitFixtureRun(fixture.t, fixture.baseRoot, "add", "-A")
gitFixtureRun(fixture.t, fixture.baseRoot, "commit", "-q", "-m", message)
}
func (fixture *integrationFixture) prepare(
workID string,
ordinal agenttask.DispatchOrdinal,
) preparedTask {
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 preparedTask{
fixture: fixture, request: request, prepared: prepared,
view: prepared.Descriptor.WorkingDir,
artifactID: agenttask.ArtifactID("artifact-" + workID),
ordinal: ordinal,
}
}
func (task preparedTask) freeze() agentworkspace.ChangeSet {
task.fixture.t.Helper()
changeSet, err := task.fixture.backend.Freeze(
context.Background(),
agentworkspace.FreezeRequest{
Descriptor: *task.prepared.Descriptor,
ArtifactID: task.artifactID,
ValidationEvidence: []agentworkspace.ValidationEvidence{{
Name: "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 *integrationFixture) integrator(
validator agentworkspace.ValidationFunc,
) *agentworkspace.SerialIntegrator {
fixture.t.Helper()
integrator, err := agentworkspace.NewIntegrator(agentworkspace.IntegratorConfig{
Backend: fixture.backend, Store: fixture.store, Validator: validator,
})
if err != nil {
fixture.t.Fatalf("NewIntegrator: %v", err)
}
return integrator
}
func integrateTask(
t *testing.T,
integrator *agentworkspace.SerialIntegrator,
task preparedTask,
changeSet agentworkspace.ChangeSet,
attempt agenttask.IntegrationAttempt,
) agenttask.IntegrationResult {
t.Helper()
result, err := integrator.Integrate(
context.Background(),
integrationRequest(task, changeSet, attempt),
)
if err != nil {
t.Fatalf("Integrate(%s): %v", task.request.Work.Unit.ID, err)
}
return result
}
func integrationRequest(
task preparedTask,
changeSet agentworkspace.ChangeSet,
attempt agenttask.IntegrationAttempt,
) agenttask.IntegrationRequest {
work := task.request.Work
work.ChangeSet = pointer(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,
}
return agenttask.IntegrationRequest{
Project: task.request.Project,
Work: work, ChangeSet: changeSet.Identity(),
Ordinal: task.ordinal, Attempt: attempt,
IdempotencyKey: fmt.Sprintf(
"integrate/%s/%s/%d",
task.request.Work.Unit.ID,
changeSet.Revision,
attempt,
),
}
}
func pointer[T any](value T) *T { return &value }
type fixtureError string
func (err fixtureError) Error() string { return string(err) }
func errorsForTest(message string) error { return fixtureError(message) }
func errorsIsRevisionConflict(err error) bool {
return err == agenttask.ErrRevisionConflict
}
func workspaceDigest(t *testing.T, root string) string {
t.Helper()
hash := sha256.New()
var paths []string
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if path == root {
return nil
}
relative, err := filepath.Rel(root, path)
if err != nil {
return err
}
if relative == ".git" {
return filepath.SkipDir
}
paths = append(paths, path)
if entry.IsDir() {
return nil
}
return nil
})
if err != nil {
t.Fatalf("WalkDir: %v", err)
}
sort.Strings(paths)
for _, path := range paths {
relative, _ := filepath.Rel(root, path)
info, err := os.Lstat(path)
if err != nil {
t.Fatalf("Lstat %s: %v", path, err)
}
_, _ = io.WriteString(hash, filepath.ToSlash(relative))
_, _ = io.WriteString(hash, fmt.Sprintf("\x00%d\x00", uint32(info.Mode())))
switch {
case info.Mode().IsRegular():
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile %s: %v", path, err)
}
_, _ = hash.Write(content)
case info.Mode()&os.ModeSymlink != 0:
target, err := os.Readlink(path)
if err != nil {
t.Fatalf("Readlink %s: %v", path, err)
}
_, _ = io.WriteString(hash, target)
}
}
return hex.EncodeToString(hash.Sum(nil))
}
func writeFixtureFile(t *testing.T, path, content string, mode fs.FileMode) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatalf("MkdirAll %s: %v", filepath.Dir(path), err)
}
if err := os.WriteFile(path, []byte(content), mode); err != nil {
t.Fatalf("WriteFile %s: %v", path, err)
}
if err := os.Chmod(path, mode); err != nil {
t.Fatalf("Chmod %s: %v", path, err)
}
}
func assertFixtureContent(t *testing.T, path, want string) {
t.Helper()
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("ReadFile %s: %v", path, err)
}
if string(content) != want {
t.Fatalf("content %s = %q, want %q", path, content, want)
}
}
func gitFixtureRun(t *testing.T, root string, args ...string) {
t.Helper()
command := exec.Command("git", append([]string{"-C", root}, args...)...)
command.Env = append(os.Environ(), "LC_ALL=C")
if output, err := command.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, output)
}
}