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

592 lines
18 KiB
Go

package agenttask
import (
"context"
"encoding/json"
"errors"
"fmt"
"reflect"
"strings"
"testing"
"time"
"iop/packages/go/agentpolicy"
"iop/packages/go/agentprovider/cli/status"
"iop/packages/go/agentruntime"
)
var continuationTestNow = time.Date(2026, 7, 28, 0, 0, 0, 0, time.UTC)
func TestFailureContinuationUsesCommonPolicyAndSkipsUsedCandidate(t *testing.T) {
current := continuationTarget("profile", "profile-r1")
firstAlternate := continuationTarget("backup-one", "profile-r2")
secondAlternate := continuationTarget("backup-two", "profile-r3")
policy := FailureContinuationPolicy{
Policy: agentpolicy.FailurePolicy{
FailoverCodes: []agentruntime.FailureCode{agentruntime.FailureCodeQuotaExhausted},
},
Candidates: []FailureContinuationCandidate{
continuationCandidate(firstAlternate, agentpolicy.QuotaStateAvailable),
continuationCandidate(secondAlternate, agentpolicy.QuotaStateNotApplicable),
},
}
selector := &continuationTestSelector{initial: current, policy: policy}
invoker := &continuationTestInvoker{
observations: []agentpolicy.AttemptObservation{
continuationObservationForTarget(
current,
agentpolicy.QuotaStateExhausted,
agentruntime.FailureCodeQuotaExhausted,
false,
),
continuationObservationForTarget(
firstAlternate,
agentpolicy.QuotaStateExhausted,
agentruntime.FailureCodeQuotaExhausted,
false,
),
},
failures: 2,
}
snapshot := testSnapshot("project", "workspace", testUnit("work", WriteSetUnknown))
harness := newHarness(t, map[ProjectID]ProjectWorkflowSnapshot{"project": snapshot}, 1)
harness.manager.selector = selector
harness.manager.invoker = invoker
harness.start("project", "workspace", nil)
if err := harness.manager.Reconcile(context.Background()); err != nil {
t.Fatalf("Reconcile: %v", err)
}
if len(invoker.requests) != 3 {
t.Fatalf("invocations = %d, want 3", len(invoker.requests))
}
wantTargets := []ExecutionTarget{current, firstAlternate, secondAlternate}
for index, want := range wantTargets {
if got := invoker.requests[index].Target; !sameExecutionTarget(got, want) {
t.Fatalf("target %d = %#v, want %#v", index, got, want)
}
}
if len(selector.requests) != 2 {
t.Fatalf("policy-source requests = %d, want 2", len(selector.requests))
}
state := harness.store.snapshot()
work := state.Projects["project"].Works["work"]
if work.State != WorkStateCompleted || work.Attempt != 3 ||
work.ContinuationTarget != nil || len(work.AttemptObservations) != 2 {
t.Fatalf("work = %#v", work)
}
if !sameExecutionTarget(work.AttemptObservations[0].Target, current) ||
!sameExecutionTarget(work.AttemptObservations[1].Target, firstAlternate) {
t.Fatalf("attempt target history = %#v", work.AttemptObservations)
}
}
func TestFailureContinuationMalformedObservationBecomesTypedBlocker(t *testing.T) {
current := continuationTarget("profile", "profile-r1")
observation := continuationObservationForTarget(
current,
agentpolicy.QuotaStateAvailable,
agentruntime.FailureCodeUnavailable,
true,
)
observation.Quota.SnapshotID = "token=provider-secret\n"
observation.Quota.Reasons = []string{"authorization=Bearer provider-secret"}
snapshot := testSnapshot("project", "workspace", testUnit("work", WriteSetUnknown))
harness := newHarness(t, map[ProjectID]ProjectWorkflowSnapshot{"project": snapshot}, 1)
selector := &continuationTestSelector{
initial: current,
policy: FailureContinuationPolicy{Policy: agentpolicy.FailurePolicy{
RetryableCodes: []agentruntime.FailureCode{agentruntime.FailureCodeUnavailable},
}},
}
harness.manager.selector = selector
harness.manager.invoker = &continuationTestInvoker{
observations: []agentpolicy.AttemptObservation{observation},
failures: 1,
}
harness.start("project", "workspace", nil)
if err := harness.manager.Reconcile(context.Background()); err != nil {
t.Fatalf("Reconcile: %v", err)
}
state := harness.store.snapshot()
work := state.Projects["project"].Works["work"]
if work.State != WorkStateBlocked || work.Blocker == nil ||
work.Blocker.Code != BlockerFailureObservationCorrupt {
t.Fatalf("work = %#v", work)
}
if len(work.AttemptObservations) != 1 {
t.Fatalf("attempt observations = %#v", work.AttemptObservations)
}
got := work.AttemptObservations[0].Observation
if got.Quota.Validity != agentpolicy.ObservationCorrupt ||
got.Quota.SnapshotID != "" || len(got.Quota.Reasons) != 0 {
t.Fatalf("durable observation = %#v", got)
}
encoded, err := json.Marshal(state)
if err != nil {
t.Fatalf("Marshal state: %v", err)
}
if strings.Contains(string(encoded), "provider-secret") ||
strings.Contains(string(encoded), "authorization") {
t.Fatalf("durable state retained unsafe evidence: %s", encoded)
}
}
func TestFailureContinuationProjectionTamperBecomesTypedBlocker(t *testing.T) {
tests := []struct {
name string
state agentpolicy.QuotaState
mutate func(*agentpolicy.QuotaObservation)
}{
{
name: "state",
state: agentpolicy.QuotaStateAvailable,
mutate: func(observation *agentpolicy.QuotaObservation) {
observation.State = agentpolicy.QuotaStateExhausted
},
},
{
name: "target",
state: agentpolicy.QuotaStateAvailable,
mutate: func(observation *agentpolicy.QuotaObservation) {
observation.Target = "other-profile"
},
},
{
name: "snapshot id",
state: agentpolicy.QuotaStateAvailable,
mutate: func(observation *agentpolicy.QuotaObservation) {
observation.SnapshotID = "quota-" + strings.Repeat("0", 64)
},
},
{
name: "adapter",
state: agentpolicy.QuotaStateAvailable,
mutate: func(observation *agentpolicy.QuotaObservation) {
observation.Adapter = "other-provider"
},
},
{
name: "checked time",
state: agentpolicy.QuotaStateAvailable,
mutate: func(observation *agentpolicy.QuotaObservation) {
observation.CheckedAt = observation.CheckedAt.Add(-time.Second)
},
},
{
name: "validity",
state: agentpolicy.QuotaStateAvailable,
mutate: func(observation *agentpolicy.QuotaObservation) {
observation.Validity = agentpolicy.ObservationStale
},
},
{
name: "reason",
state: agentpolicy.QuotaStateUnknown,
mutate: func(observation *agentpolicy.QuotaObservation) {
observation.Reasons[0] = "checker_error"
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
current := continuationTarget("profile", "profile-r1")
observation := continuationObservationForTarget(
current,
test.state,
agentruntime.FailureCodeUnavailable,
true,
)
test.mutate(&observation.Quota)
snapshot := testSnapshot("project", "workspace", testUnit("work", WriteSetUnknown))
harness := newHarness(t, map[ProjectID]ProjectWorkflowSnapshot{"project": snapshot}, 1)
harness.manager.selector = &continuationTestSelector{
initial: current,
policy: FailureContinuationPolicy{Policy: agentpolicy.FailurePolicy{
RetryableCodes: []agentruntime.FailureCode{agentruntime.FailureCodeUnavailable},
}},
}
invoker := &continuationTestInvoker{
observations: []agentpolicy.AttemptObservation{observation},
failures: 1,
}
harness.manager.invoker = invoker
harness.start("project", "workspace", nil)
if err := harness.manager.Reconcile(context.Background()); err != nil {
t.Fatalf("Reconcile: %v", err)
}
if len(invoker.requests) != 1 {
t.Fatalf("invocations = %d, want exactly one", len(invoker.requests))
}
work := harness.store.snapshot().Projects["project"].Works["work"]
if work.State != WorkStateBlocked || work.Blocker == nil ||
work.Blocker.Code != BlockerFailureObservationCorrupt {
t.Fatalf("work = %#v", work)
}
if len(work.AttemptObservations) != 1 ||
!reflect.DeepEqual(
work.AttemptObservations[0].Observation.Quota,
agentpolicy.CorruptQuotaObservation(),
) {
t.Fatalf("durable observations = %#v", work.AttemptObservations)
}
})
}
}
func TestFailureContinuationUnknownAndBudgetBlockWork(t *testing.T) {
tests := []struct {
name string
observation agentpolicy.AttemptObservation
policy agentpolicy.FailurePolicy
maxFailures uint32
wantBlocker BlockerCode
}{
{
name: "unknown policy failure becomes typed blocker",
observation: continuationObservationForTarget(
continuationTarget("profile", "profile-r1"),
agentpolicy.QuotaStateAvailable,
agentruntime.FailureCodeUnknown,
false,
),
policy: agentpolicy.FailurePolicy{
RetryableCodes: []agentruntime.FailureCode{agentruntime.FailureCodeUnavailable},
},
wantBlocker: BlockerFailureUnknown,
},
{
name: "manager failure budget blocks a declared retry",
observation: continuationObservationForTarget(
continuationTarget("profile", "profile-r1"),
agentpolicy.QuotaStateNotApplicable,
agentruntime.FailureCodeUnavailable,
true,
),
policy: agentpolicy.FailurePolicy{
RetryableCodes: []agentruntime.FailureCode{agentruntime.FailureCodeUnavailable},
},
maxFailures: 1,
wantBlocker: BlockerFailureBudgetExhausted,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
current := continuationTarget("profile", "profile-r1")
snapshot := testSnapshot("project", "workspace", testUnit("work", WriteSetUnknown))
harness := newHarness(t, map[ProjectID]ProjectWorkflowSnapshot{"project": snapshot}, 1)
if test.maxFailures != 0 {
harness.manager.config.MaxFailureAttempts = test.maxFailures
}
selector := &continuationTestSelector{
initial: current,
policy: FailureContinuationPolicy{Policy: test.policy},
}
invoker := &continuationTestInvoker{
observations: []agentpolicy.AttemptObservation{test.observation},
failures: 1,
}
harness.manager.selector = selector
harness.manager.invoker = invoker
harness.start("project", "workspace", nil)
if err := harness.manager.Reconcile(context.Background()); err != nil {
t.Fatalf("Reconcile: %v", err)
}
if len(invoker.requests) != 1 {
t.Fatalf("invocations = %d, want 1", len(invoker.requests))
}
work := harness.store.snapshot().Projects["project"].Works["work"]
if work.State != WorkStateBlocked || work.Blocker == nil ||
work.Blocker.Code != test.wantBlocker {
t.Fatalf("work = %#v", work)
}
if len(work.AttemptObservations) != 1 {
t.Fatalf("attempt observations = %#v", work.AttemptObservations)
}
})
}
}
func TestFailureWithoutObservationRedactsProviderError(t *testing.T) {
current := continuationTarget("profile", "profile-r1")
snapshot := testSnapshot("project", "workspace", testUnit("work", WriteSetUnknown))
harness := newHarness(t, map[ProjectID]ProjectWorkflowSnapshot{"project": snapshot}, 1)
invoker := &unobservedFailureInvoker{
owner: &continuationTestInvoker{
observations: []agentpolicy.AttemptObservation{
continuationObservationForTarget(
current,
agentpolicy.QuotaStateAvailable,
agentruntime.FailureCodeUnavailable,
true,
),
},
failures: 1,
},
}
harness.manager.selector = &continuationTestSelector{initial: current}
harness.manager.invoker = invoker
harness.start("project", "workspace", nil)
if err := harness.manager.Reconcile(context.Background()); err != nil {
t.Fatalf("Reconcile: %v", err)
}
state := harness.store.snapshot()
work := state.Projects["project"].Works["work"]
if work.State != WorkStateBlocked || work.Blocker == nil || work.Blocker.Code != BlockerInvocationFailed {
t.Fatalf("work = %#v", work)
}
encoded, err := json.Marshal(state)
if err != nil {
t.Fatalf("Marshal state: %v", err)
}
if strings.Contains(string(encoded), "credential=provider-secret") {
t.Fatalf("durable state retained provider error: %s", encoded)
}
}
type continuationTestSelector struct {
initial ExecutionTarget
policy FailureContinuationPolicy
policies []FailureContinuationPolicy
requests []FailureContinuationPolicyRequest
}
func (s *continuationTestSelector) Select(context.Context, SelectionRequest) (ExecutionTarget, error) {
return s.initial, nil
}
func (s *continuationTestSelector) ContinuationPolicy(
_ context.Context,
request FailureContinuationPolicyRequest,
) (FailureContinuationPolicy, error) {
s.requests = append(s.requests, request)
if len(s.policies) == 0 {
return s.policy, nil
}
policy := s.policies[0]
s.policies = s.policies[1:]
return policy, nil
}
type unobservedFailureInvoker struct {
owner *continuationTestInvoker
}
func (i *unobservedFailureInvoker) Prepare(
ctx context.Context,
request DispatchRequest,
) (ProviderLaunch, error) {
launch, err := i.owner.Prepare(ctx, request)
if err != nil {
return nil, err
}
return unobservedFailureLaunch{ProviderLaunch: launch}, nil
}
type unobservedFailureLaunch struct {
ProviderLaunch
}
func (l unobservedFailureLaunch) BindStarted(
started StartedConfinement,
) (ProviderInvocation, error) {
invocation, err := l.ProviderLaunch.BindStarted(started)
if err != nil {
return nil, err
}
return unobservedFailureInvocation{ProviderInvocation: invocation}, nil
}
type unobservedFailureInvocation struct {
ProviderInvocation
}
type continuationTestInvoker struct {
observations []agentpolicy.AttemptObservation
failures int
requests []DispatchRequest
}
func (i *continuationTestInvoker) Prepare(
_ context.Context,
request DispatchRequest,
) (ProviderLaunch, error) {
i.requests = append(i.requests, request)
return &continuationTestLaunch{
owner: i,
request: request,
index: len(i.requests),
}, nil
}
type continuationTestLaunch struct {
owner *continuationTestInvoker
request DispatchRequest
index int
}
func (continuationTestLaunch) Command() ConfinementCommand {
return ConfinementCommand{Name: "true"}
}
func (l *continuationTestLaunch) BindStarted(
started StartedConfinement,
) (ProviderInvocation, error) {
if started == nil || started.Child() == nil {
return nil, errors.New("missing confined started handle")
}
locator := LocatorRecord{
Kind: LocatorProcess,
Opaque: fmt.Sprintf("continuation-process-%d", l.index),
Revision: "process-r1",
ProjectID: l.request.Project.ProjectID,
WorkspaceID: l.request.Project.WorkspaceID,
WorkUnitID: l.request.Work.Unit.ID,
AttemptID: l.request.Work.AttemptID,
}
return &continuationTestInvocation{
request: l.request,
locators: []LocatorRecord{locator},
observation: l.owner.observation(l.index),
fail: l.index <= l.owner.failures,
started: started,
}, nil
}
func (i *continuationTestInvoker) observation(index int) agentpolicy.AttemptObservation {
if index <= 0 || index > len(i.observations) {
return agentpolicy.AttemptObservation{}
}
return i.observations[index-1].Clone()
}
type continuationTestInvocation struct {
request DispatchRequest
locators []LocatorRecord
observation agentpolicy.AttemptObservation
fail bool
started StartedConfinement
}
func (i *continuationTestInvocation) Locators() []LocatorRecord {
return append([]LocatorRecord(nil), i.locators...)
}
func (i *continuationTestInvocation) Wait(context.Context) (Submission, error) {
if i.started != nil {
if stdin := i.started.Stdin(); stdin != nil {
_ = stdin.Close()
}
if child := i.started.Child(); child != nil {
_ = child.Wait()
}
if stdout := i.started.Stdout(); stdout != nil {
_ = stdout.Close()
}
if stderr := i.started.Stderr(); stderr != nil {
_ = stderr.Close()
}
}
if i.fail {
return Submission{}, errors.New("credential=provider-secret")
}
return Submission{
ProjectID: i.request.Project.ProjectID,
WorkUnitID: i.request.Work.Unit.ID,
AttemptID: i.request.Work.AttemptID,
ArtifactID: ArtifactID("artifact-" + string(i.request.Work.AttemptID)),
Ready: true,
Locators: append([]LocatorRecord(nil), i.locators...),
}, nil
}
func (i *continuationTestInvocation) Cancel(context.Context) error {
if i.started != nil {
_ = i.started.Abort()
}
return nil
}
func (i *continuationTestInvocation) FailureObservation() agentpolicy.AttemptObservation {
return i.observation.Clone()
}
func continuationTarget(profile, revision string) ExecutionTarget {
return ExecutionTarget{
ProviderID: "provider",
ModelID: "model",
ProfileID: profile,
ProfileRevision: revision,
ConfigRevision: "config-r1",
Capacity: 1,
}
}
func continuationObservationForTarget(
target ExecutionTarget,
state agentpolicy.QuotaState,
code agentruntime.FailureCode,
retryable bool,
) agentpolicy.AttemptObservation {
return agentpolicy.NormalizeAttemptObservation(
continuationQuotaSnapshot(target, state),
&agentruntime.Failure{Code: code, Retryable: retryable},
continuationTestNow,
0,
)
}
func continuationQuotaSnapshot(
target ExecutionTarget,
state agentpolicy.QuotaState,
) status.QuotaSnapshot {
var requiredCaps []string
var usage *status.UsageStatus
switch state {
case agentpolicy.QuotaStateAvailable:
requiredCaps = []string{"overall"}
usage = &status.UsageStatus{DailyLimit: "50%"}
case agentpolicy.QuotaStateExhausted:
requiredCaps = []string{"overall"}
usage = &status.UsageStatus{DailyLimit: "0%"}
case agentpolicy.QuotaStateUnknown:
requiredCaps = []string{"overall"}
usage = &status.UsageStatus{DailyLimit: "not-a-percent"}
case agentpolicy.QuotaStateNotApplicable:
requiredCaps = nil
default:
panic("unsupported quota state")
}
return status.NormalizeQuotaSnapshot(
target.ProviderID,
target.ProfileID,
requiredCaps,
continuationTestNow,
usage,
nil,
)
}
func continuationCandidate(
target ExecutionTarget,
state agentpolicy.QuotaState,
) FailureContinuationCandidate {
return FailureContinuationCandidate{
Target: target,
Eligible: true,
Quota: agentpolicy.NormalizeQuotaObservation(
continuationQuotaSnapshot(target, state),
continuationTestNow,
time.Minute,
),
}
}