994 lines
31 KiB
Go
994 lines
31 KiB
Go
package agenttask
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"reflect"
|
|
|
|
"iop/packages/go/agentguard"
|
|
"iop/packages/go/agentpolicy"
|
|
)
|
|
|
|
func (m *Manager) runWork(
|
|
ctx context.Context,
|
|
projectID ProjectID,
|
|
workID WorkUnitID,
|
|
) error {
|
|
for {
|
|
project, work, err := m.loadWork(ctx, projectID, workID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
switch work.State {
|
|
case WorkStateReviewing:
|
|
if work.Submission == nil {
|
|
m.blockWork(ctx, projectID, workID, WorkStateBlocked, Blocker{
|
|
Code: BlockerArtifactMismatch,
|
|
Message: "reviewing work has no durable submission identity",
|
|
})
|
|
return nil
|
|
}
|
|
rework, reviewErr := m.reviewSubmission(ctx, project, work, *work.Submission)
|
|
if reviewErr != nil || !rework {
|
|
return reviewErr
|
|
}
|
|
continue
|
|
case WorkStateReady:
|
|
default:
|
|
return nil
|
|
}
|
|
|
|
if err := m.changeWork(ctx, projectID, workID, func(work *WorkRecord) error {
|
|
return transitionWork(work, WorkStatePreparing)
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
project, work, err = m.loadWork(ctx, projectID, workID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
target, err := m.selectTarget(ctx, project, work)
|
|
if err != nil {
|
|
m.blockWork(ctx, projectID, workID, WorkStateBlocked, Blocker{
|
|
Code: BlockerSelectionFailed, Message: err.Error(), Retryable: true,
|
|
})
|
|
return nil
|
|
}
|
|
if err := validateTarget(project, target); err != nil {
|
|
m.blockWork(ctx, projectID, workID, WorkStateBlocked, Blocker{
|
|
Code: BlockerInvalidIdentity, Message: err.Error(),
|
|
})
|
|
return nil
|
|
}
|
|
isolation, err := m.isolation.Prepare(ctx, IsolationRequest{
|
|
Project: project, Work: work, Target: target,
|
|
IdempotencyKey: dispatchKey(projectID, workID, work.Attempt) + "/isolation",
|
|
})
|
|
if err != nil {
|
|
m.blockWork(ctx, projectID, workID, WorkStateBlocked, Blocker{
|
|
Code: BlockerIsolationFailed, Message: err.Error(), Retryable: true,
|
|
})
|
|
return nil
|
|
}
|
|
admissionRequest, isolationIdentity, err := validatePreparedIsolation(project, work, target, isolation)
|
|
if err != nil {
|
|
m.blockWork(ctx, projectID, workID, WorkStateBlocked, Blocker{
|
|
Code: BlockerIsolationFailed, Message: err.Error(),
|
|
})
|
|
return nil
|
|
}
|
|
admission := agentguard.Admit(admissionRequest)
|
|
if !admission.Allowed() {
|
|
detail := "workspace admission denied"
|
|
if admission.Blocker != nil {
|
|
detail = string(admission.Blocker.Code) + ": " + admission.Blocker.Message
|
|
}
|
|
m.blockWork(ctx, projectID, workID, WorkStateBlocked, Blocker{
|
|
Code: BlockerAdmissionFailed, Message: detail,
|
|
})
|
|
return nil
|
|
}
|
|
lease, err := m.scheduler.Acquire(ctx, DispatchCandidate{
|
|
ProjectID: projectID, WorkspaceID: project.WorkspaceID,
|
|
WorkUnitID: workID, AttemptID: work.AttemptID, Target: target,
|
|
})
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
_ = m.changeWork(context.WithoutCancel(ctx), projectID, workID, func(work *WorkRecord) error {
|
|
return transitionWork(work, WorkStateReady)
|
|
})
|
|
return ctx.Err()
|
|
}
|
|
m.blockWork(ctx, projectID, workID, WorkStateBlocked, Blocker{
|
|
Code: BlockerProviderCapacity, Message: err.Error(), Retryable: true,
|
|
})
|
|
return nil
|
|
}
|
|
err = m.changeWork(ctx, projectID, workID, func(work *WorkRecord) error {
|
|
if err := transitionWork(work, WorkStateDispatching); err != nil {
|
|
return err
|
|
}
|
|
work.Target = &target
|
|
work.Isolation = &isolationIdentity
|
|
if work.Locators == nil {
|
|
work.Locators = make(map[LocatorKind]LocatorRecord)
|
|
}
|
|
work.Locators[LocatorOverlay] = locatorForIsolation(project, *work, isolationIdentity)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
lease.Release()
|
|
return err
|
|
}
|
|
project, work, err = m.loadWork(ctx, projectID, workID)
|
|
if err != nil {
|
|
lease.Release()
|
|
return err
|
|
}
|
|
var cmdID CommandID
|
|
var wfRev WorkflowRevision
|
|
if project.Intent != nil {
|
|
cmdID = project.Intent.CommandID
|
|
wfRev = project.Intent.WorkflowRevision
|
|
}
|
|
m.emit(ctx, Event{
|
|
Type: EventDispatchStarted, ProjectID: projectID, WorkspaceID: project.WorkspaceID,
|
|
WorkUnitID: workID, CommandID: cmdID, WorkflowRevision: wfRev, AttemptID: work.AttemptID, Ordinal: work.DispatchOrdinal,
|
|
State: work.State, ProviderID: target.ProviderID, ProfileID: target.ProfileID,
|
|
WriteSetKind: work.Unit.WriteSetKind, IsolationMode: work.Unit.IsolationMode,
|
|
})
|
|
var invocation ProviderInvocation
|
|
var launch ProviderLaunch
|
|
confinementBinding := isolation.Confinement.Binding()
|
|
dispatchRequest := DispatchRequest{
|
|
Project: project, Work: work, Target: target, AdmissionRequest: admissionRequest,
|
|
Permit: admission.Permit, Workspace: *admission.Workspace,
|
|
Confinement: isolation.Confinement,
|
|
IdempotencyKey: dispatchKey(projectID, workID, work.Attempt),
|
|
}
|
|
validation, invokeErr := agentguard.Invoke(
|
|
ctx,
|
|
admission.Permit,
|
|
admissionRequest,
|
|
func(invokeCtx context.Context, workspace agentguard.CanonicalWorkspace) error {
|
|
if workspace.ConfinementRevision != isolation.Confinement.Revision() {
|
|
return fmt.Errorf(
|
|
"agenttask: permit confinement revision does not match the executable proof",
|
|
)
|
|
}
|
|
if err := isolation.Confinement.Validate(confinementBinding); err != nil {
|
|
return fmt.Errorf(
|
|
"agenttask: executable confinement proof became invalid before invocation: %w",
|
|
err,
|
|
)
|
|
}
|
|
dispatchRequest.Workspace = workspace
|
|
prepared, err := m.invoker.Prepare(invokeCtx, dispatchRequest)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if prepared == nil {
|
|
return fmt.Errorf("agenttask: provider returned a nil launch plan")
|
|
}
|
|
launch = prepared
|
|
command := launch.Command()
|
|
started, err := isolation.Confinement.Start(invokeCtx, command)
|
|
if err != nil {
|
|
return fmt.Errorf("agenttask: start provider through executable confinement: %w", err)
|
|
}
|
|
if err := validateStartedConfinement(started); err != nil {
|
|
if started != nil {
|
|
_ = started.Abort()
|
|
}
|
|
return err
|
|
}
|
|
invocation, err = launch.BindStarted(started)
|
|
if err != nil {
|
|
_ = started.Abort()
|
|
return fmt.Errorf("agenttask: bind confined provider child: %w", err)
|
|
}
|
|
if invocation == nil {
|
|
_ = started.Abort()
|
|
return fmt.Errorf("agenttask: provider bound a nil invocation handle")
|
|
}
|
|
return nil
|
|
},
|
|
)
|
|
if !validation.Allowed() {
|
|
lease.Release()
|
|
detail := "admission permit became invalid before invocation"
|
|
if validation.Blocker != nil {
|
|
detail = string(validation.Blocker.Code) + ": " + validation.Blocker.Message
|
|
}
|
|
m.blockWork(ctx, projectID, workID, WorkStateBlocked, Blocker{
|
|
Code: BlockerAdmissionFailed, Message: detail,
|
|
})
|
|
return nil
|
|
}
|
|
if invokeErr != nil {
|
|
lease.Release()
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
m.blockWork(ctx, projectID, workID, WorkStateBlocked, Blocker{
|
|
Code: BlockerInvocationFailed, Message: "provider launch failed before a normalized invocation was available", Retryable: true,
|
|
})
|
|
return nil
|
|
}
|
|
if launch == nil || invocation == nil {
|
|
lease.Release()
|
|
m.blockWork(ctx, projectID, workID, WorkStateBlocked, Blocker{
|
|
Code: BlockerInvocationFailed, Message: "provider launch did not bind an invocation handle",
|
|
})
|
|
return nil
|
|
}
|
|
invocationLocators := invocation.Locators()
|
|
if err := validateInvocationLocators(project, work, invocationLocators); err != nil {
|
|
_ = invocation.Cancel(context.WithoutCancel(ctx))
|
|
lease.Release()
|
|
m.blockWork(ctx, projectID, workID, WorkStateBlocked, Blocker{
|
|
Code: BlockerStaleCheckpoint, Message: err.Error(),
|
|
})
|
|
return nil
|
|
}
|
|
if err := m.changeWork(ctx, projectID, workID, func(work *WorkRecord) error {
|
|
for _, locator := range invocationLocators {
|
|
work.Locators[locator.Kind] = locator
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
_ = invocation.Cancel(context.WithoutCancel(ctx))
|
|
lease.Release()
|
|
return err
|
|
}
|
|
submission, invokeErr := invocation.Wait(ctx)
|
|
lease.Release()
|
|
if invokeErr != nil {
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
handled, continueWork, continuationErr := m.continueAfterInvocationFailure(
|
|
ctx, project, work, target, invocation,
|
|
)
|
|
if continuationErr != nil {
|
|
return continuationErr
|
|
}
|
|
if handled {
|
|
if continueWork {
|
|
continue
|
|
}
|
|
return nil
|
|
}
|
|
m.blockWork(ctx, projectID, workID, WorkStateBlocked, Blocker{
|
|
Code: BlockerInvocationFailed, Message: "provider invocation failed without a normalized failure observation", Retryable: true,
|
|
})
|
|
return nil
|
|
}
|
|
project, work, err = m.loadWork(ctx, projectID, workID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := validateSubmission(project, work, submission); err != nil {
|
|
m.blockWork(ctx, projectID, workID, WorkStateBlocked, Blocker{
|
|
Code: BlockerArtifactMismatch, Message: err.Error(),
|
|
})
|
|
return nil
|
|
}
|
|
if !submission.Ready {
|
|
m.blockWork(ctx, projectID, workID, WorkStateBlocked, Blocker{
|
|
Code: BlockerSubmissionIncomplete,
|
|
Message: "worker submission did not pass the provider-neutral completeness gate",
|
|
})
|
|
return nil
|
|
}
|
|
if blocker := m.gateSubmissionEvidence(ctx, project, work, submission); blocker != nil {
|
|
m.blockWork(ctx, projectID, workID, WorkStateBlocked, *blocker)
|
|
return nil
|
|
}
|
|
if err := m.changeWork(ctx, projectID, workID, func(work *WorkRecord) error {
|
|
if err := transitionWork(work, WorkStateSubmitted); err != nil {
|
|
return err
|
|
}
|
|
work.Submission = &submission
|
|
for _, locator := range submission.Locators {
|
|
work.Locators[locator.Kind] = locator
|
|
}
|
|
resetFailure(work, FailureStageDispatch)
|
|
work.ContinuationTarget = nil
|
|
return nil
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
m.emit(ctx, Event{
|
|
Type: EventSubmissionAccepted, ProjectID: projectID, WorkspaceID: project.WorkspaceID,
|
|
WorkUnitID: workID, CommandID: cmdID, WorkflowRevision: wfRev, AttemptID: work.AttemptID, Ordinal: work.DispatchOrdinal,
|
|
Detail: string(submission.ArtifactID),
|
|
})
|
|
if err := m.changeWork(ctx, projectID, workID, func(work *WorkRecord) error {
|
|
return transitionWork(work, WorkStateReviewing)
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
project, work, err = m.loadWork(ctx, projectID, workID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rework, err := m.reviewSubmission(ctx, project, work, submission)
|
|
if err != nil || !rework {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
// selectTarget preserves a policy-authorized continuation target across a
|
|
// crash or pre-invocation retry. New work still goes through the ordinary
|
|
// selector; no continuation target is inferred from a previous failure.
|
|
func (m *Manager) selectTarget(
|
|
ctx context.Context,
|
|
project ProjectRecord,
|
|
work WorkRecord,
|
|
) (ExecutionTarget, error) {
|
|
if work.ContinuationTarget != nil {
|
|
return *work.ContinuationTarget, nil
|
|
}
|
|
return m.selector.Select(ctx, SelectionRequest{Project: project, Work: work})
|
|
}
|
|
|
|
func (m *Manager) continueAfterInvocationFailure(
|
|
ctx context.Context,
|
|
project ProjectRecord,
|
|
work WorkRecord,
|
|
current ExecutionTarget,
|
|
invocation ProviderInvocation,
|
|
) (handled bool, continueWork bool, err error) {
|
|
observed, ok := invocation.(FailureObservedInvocation)
|
|
if !ok {
|
|
return false, false, nil
|
|
}
|
|
observation := agentpolicy.SanitizeAttemptObservation(
|
|
observed.FailureObservation(),
|
|
m.clock.Now(),
|
|
)
|
|
if !observationMatchesTarget(observation, current) {
|
|
observation.Quota = agentpolicy.CorruptQuotaObservation()
|
|
}
|
|
source, ok := m.selector.(FailureContinuationPolicySource)
|
|
if !ok {
|
|
return m.persistContinuation(
|
|
ctx,
|
|
project,
|
|
work,
|
|
current,
|
|
observation,
|
|
agentpolicy.ContinuationBlock,
|
|
nil,
|
|
Blocker{
|
|
Code: BlockerFailurePolicyUnavailable,
|
|
Message: "no declared failure continuation policy is available",
|
|
},
|
|
)
|
|
}
|
|
policy, policyErr := source.ContinuationPolicy(ctx, FailureContinuationPolicyRequest{
|
|
Project: project,
|
|
Work: work,
|
|
CurrentTarget: current,
|
|
Observation: observation,
|
|
})
|
|
if policyErr != nil {
|
|
return m.persistContinuation(
|
|
ctx,
|
|
project,
|
|
work,
|
|
current,
|
|
observation,
|
|
agentpolicy.ContinuationBlock,
|
|
nil,
|
|
Blocker{
|
|
Code: BlockerFailurePolicyDenied,
|
|
Message: "failure continuation policy rejected the observation",
|
|
},
|
|
)
|
|
}
|
|
request, candidateTargets, buildErr := m.continuationRequest(
|
|
project,
|
|
work,
|
|
current,
|
|
observation,
|
|
policy,
|
|
)
|
|
if buildErr != nil {
|
|
return m.persistContinuation(
|
|
ctx,
|
|
project,
|
|
work,
|
|
current,
|
|
observation,
|
|
agentpolicy.ContinuationBlock,
|
|
nil,
|
|
Blocker{
|
|
Code: BlockerFailurePolicyDenied,
|
|
Message: "failure continuation policy inputs are invalid",
|
|
},
|
|
)
|
|
}
|
|
decision, decisionErr := agentpolicy.DecideContinuation(request)
|
|
if decisionErr != nil {
|
|
return m.persistContinuation(
|
|
ctx,
|
|
project,
|
|
work,
|
|
current,
|
|
observation,
|
|
agentpolicy.ContinuationBlock,
|
|
nil,
|
|
Blocker{
|
|
Code: BlockerFailurePolicyDenied,
|
|
Message: "common failure continuation policy rejected its inputs",
|
|
},
|
|
)
|
|
}
|
|
switch decision.Action {
|
|
case agentpolicy.ContinuationBlock:
|
|
if decision.Blocker == "" {
|
|
return m.persistContinuation(
|
|
ctx,
|
|
project,
|
|
work,
|
|
current,
|
|
observation,
|
|
agentpolicy.ContinuationBlock,
|
|
nil,
|
|
Blocker{
|
|
Code: BlockerFailurePolicyDenied,
|
|
Message: "failure continuation policy returned an invalid block decision",
|
|
},
|
|
)
|
|
}
|
|
return m.persistContinuation(
|
|
ctx,
|
|
project,
|
|
work,
|
|
current,
|
|
observation,
|
|
decision.Action,
|
|
nil,
|
|
blockerFromContinuation(decision.Blocker),
|
|
)
|
|
case agentpolicy.ContinuationRetry:
|
|
if !targetMatchesIdentity(current, decision.Target) {
|
|
return m.persistContinuation(
|
|
ctx,
|
|
project,
|
|
work,
|
|
current,
|
|
observation,
|
|
agentpolicy.ContinuationBlock,
|
|
nil,
|
|
Blocker{
|
|
Code: BlockerFailurePolicyDenied,
|
|
Message: "failure continuation policy returned an invalid next target",
|
|
},
|
|
)
|
|
}
|
|
return m.persistContinuation(
|
|
ctx,
|
|
project,
|
|
work,
|
|
current,
|
|
observation,
|
|
decision.Action,
|
|
¤t,
|
|
Blocker{},
|
|
)
|
|
case agentpolicy.ContinuationFailover:
|
|
next, exists := candidateTargets[decision.Target]
|
|
if !exists || sameExecutionTarget(next, current) {
|
|
return m.persistContinuation(
|
|
ctx,
|
|
project,
|
|
work,
|
|
current,
|
|
observation,
|
|
agentpolicy.ContinuationBlock,
|
|
nil,
|
|
Blocker{
|
|
Code: BlockerFailurePolicyDenied,
|
|
Message: "common failure policy selected an unknown failover target",
|
|
},
|
|
)
|
|
}
|
|
return m.persistContinuation(
|
|
ctx,
|
|
project,
|
|
work,
|
|
current,
|
|
observation,
|
|
decision.Action,
|
|
&next,
|
|
Blocker{},
|
|
)
|
|
default:
|
|
return m.persistContinuation(
|
|
ctx,
|
|
project,
|
|
work,
|
|
current,
|
|
observation,
|
|
agentpolicy.ContinuationBlock,
|
|
nil,
|
|
Blocker{
|
|
Code: BlockerFailurePolicyDenied,
|
|
Message: "failure continuation policy returned an unsupported action",
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
func (m *Manager) continuationRequest(
|
|
project ProjectRecord,
|
|
work WorkRecord,
|
|
current ExecutionTarget,
|
|
observation agentpolicy.AttemptObservation,
|
|
source FailureContinuationPolicy,
|
|
) (agentpolicy.ContinuationRequest, map[agentpolicy.TargetIdentity]ExecutionTarget, error) {
|
|
currentIdentity := executionTargetIdentity(current)
|
|
used := make([]agentpolicy.TargetIdentity, 0, len(work.AttemptObservations))
|
|
seenUsed := make(map[agentpolicy.TargetIdentity]struct{}, len(work.AttemptObservations))
|
|
for _, record := range work.AttemptObservations {
|
|
identity := executionTargetIdentity(record.Target)
|
|
if _, duplicate := seenUsed[identity]; duplicate {
|
|
continue
|
|
}
|
|
seenUsed[identity] = struct{}{}
|
|
used = append(used, identity)
|
|
}
|
|
|
|
candidates := make([]agentpolicy.ContinuationCandidate, 0, len(source.Candidates))
|
|
candidateTargets := make(map[agentpolicy.TargetIdentity]ExecutionTarget, len(source.Candidates))
|
|
for _, candidate := range source.Candidates {
|
|
if err := validateTarget(project, candidate.Target); err != nil {
|
|
return agentpolicy.ContinuationRequest{}, nil, err
|
|
}
|
|
identity := executionTargetIdentity(candidate.Target)
|
|
if _, duplicate := candidateTargets[identity]; duplicate {
|
|
return agentpolicy.ContinuationRequest{}, nil, fmt.Errorf(
|
|
"agenttask: continuation policy repeats a candidate target",
|
|
)
|
|
}
|
|
quota := agentpolicy.SanitizeQuotaObservation(candidate.Quota)
|
|
if quota.Validity != agentpolicy.ObservationCorrupt &&
|
|
(quota.Adapter != candidate.Target.ProviderID ||
|
|
quota.Target != candidate.Target.ProfileID) {
|
|
quota = agentpolicy.CorruptQuotaObservation()
|
|
}
|
|
candidateTargets[identity] = candidate.Target
|
|
candidates = append(candidates, agentpolicy.ContinuationCandidate{
|
|
Target: identity,
|
|
Eligible: candidate.Eligible,
|
|
Quota: quota,
|
|
})
|
|
}
|
|
return agentpolicy.ContinuationRequest{
|
|
Policy: source.Policy,
|
|
Current: currentIdentity,
|
|
Candidates: candidates,
|
|
Used: used,
|
|
Budget: m.pendingFailureBudget(work),
|
|
Observation: observation,
|
|
}, candidateTargets, nil
|
|
}
|
|
|
|
func (m *Manager) persistContinuation(
|
|
ctx context.Context,
|
|
project ProjectRecord,
|
|
failedWork WorkRecord,
|
|
current ExecutionTarget,
|
|
observation agentpolicy.AttemptObservation,
|
|
action agentpolicy.ContinuationAction,
|
|
next *ExecutionTarget,
|
|
blocker Blocker,
|
|
) (handled bool, continueWork bool, err error) {
|
|
var outcomeBlocker Blocker
|
|
var nextAttempt AttemptID
|
|
continued := false
|
|
err = m.changeWork(ctx, project.ProjectID, failedWork.Unit.ID, func(work *WorkRecord) error {
|
|
if work.AttemptID != failedWork.AttemptID || work.State != WorkStateDispatching {
|
|
return fmt.Errorf("agenttask: failed work changed before continuation was persisted")
|
|
}
|
|
if err := appendAttemptObservation(work, current, observation); err != nil {
|
|
return err
|
|
}
|
|
if action == agentpolicy.ContinuationBlock {
|
|
outcomeBlocker = m.recordFailure(work, FailureStageDispatch, blocker)
|
|
if err := transitionWork(work, WorkStateBlocked); err != nil {
|
|
return err
|
|
}
|
|
work.Blocker = &outcomeBlocker
|
|
return nil
|
|
}
|
|
outcomeBlocker = m.recordFailure(work, FailureStageDispatch, Blocker{
|
|
Code: BlockerInvocationFailed,
|
|
Message: "provider execution failed; continuation was policy-authorized",
|
|
Retryable: true,
|
|
})
|
|
if outcomeBlocker.Code == BlockerFailureBudgetExhausted {
|
|
if err := transitionWork(work, WorkStateBlocked); err != nil {
|
|
return err
|
|
}
|
|
work.Blocker = &outcomeBlocker
|
|
return nil
|
|
}
|
|
if next == nil {
|
|
return fmt.Errorf("agenttask: continuation has no next target")
|
|
}
|
|
if err := transitionWork(work, WorkStateReady); err != nil {
|
|
return err
|
|
}
|
|
work.Attempt++
|
|
work.AttemptID = attemptID(work.Unit.ID, work.Attempt)
|
|
target := *next
|
|
work.Target = nil
|
|
work.ContinuationTarget = &target
|
|
work.Isolation = nil
|
|
work.Submission = nil
|
|
work.Review = nil
|
|
work.ChangeSet = nil
|
|
work.Integration = nil
|
|
work.Locators = make(map[LocatorKind]LocatorRecord)
|
|
work.Blocker = nil
|
|
nextAttempt = work.AttemptID
|
|
continued = true
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return true, false, err
|
|
}
|
|
if continued {
|
|
var commandID CommandID
|
|
var workflowRevision WorkflowRevision
|
|
if project.Intent != nil {
|
|
commandID = project.Intent.CommandID
|
|
workflowRevision = project.Intent.WorkflowRevision
|
|
}
|
|
m.emit(ctx, Event{
|
|
Type: EventFollowup, ProjectID: project.ProjectID, WorkspaceID: project.WorkspaceID,
|
|
WorkUnitID: failedWork.Unit.ID, CommandID: commandID, WorkflowRevision: workflowRevision,
|
|
AttemptID: nextAttempt, Ordinal: failedWork.DispatchOrdinal, Detail: string(action),
|
|
})
|
|
return true, true, nil
|
|
}
|
|
m.emit(ctx, Event{
|
|
Type: EventBlocked, ProjectID: project.ProjectID, WorkspaceID: project.WorkspaceID,
|
|
WorkUnitID: failedWork.Unit.ID, AttemptID: failedWork.AttemptID,
|
|
Ordinal: failedWork.DispatchOrdinal, State: WorkStateBlocked, Detail: string(outcomeBlocker.Code),
|
|
})
|
|
return true, false, nil
|
|
}
|
|
|
|
func (m *Manager) pendingFailureBudget(work WorkRecord) agentpolicy.FailureBudget {
|
|
budget := work.FailureBudgets[FailureStageDispatch]
|
|
limit := budget.Limit
|
|
if limit == 0 {
|
|
limit = m.config.MaxFailureAttempts
|
|
}
|
|
used := budget.Consecutive
|
|
if used < limit {
|
|
used++
|
|
}
|
|
return agentpolicy.FailureBudget{Used: used, Limit: limit}
|
|
}
|
|
|
|
func appendAttemptObservation(
|
|
work *WorkRecord,
|
|
target ExecutionTarget,
|
|
observation agentpolicy.AttemptObservation,
|
|
) error {
|
|
record := AttemptObservationRecord{
|
|
AttemptID: work.AttemptID,
|
|
Target: target,
|
|
Observation: observation.Clone(),
|
|
}
|
|
for _, existing := range work.AttemptObservations {
|
|
if existing.AttemptID != record.AttemptID {
|
|
continue
|
|
}
|
|
if reflect.DeepEqual(existing, record) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("agenttask: a different failure observation already exists for this attempt")
|
|
}
|
|
work.AttemptObservations = append(work.AttemptObservations, record)
|
|
return nil
|
|
}
|
|
|
|
func observationMatchesTarget(
|
|
observation agentpolicy.AttemptObservation,
|
|
target ExecutionTarget,
|
|
) bool {
|
|
if observation.Quota.Validity == agentpolicy.ObservationCorrupt {
|
|
return true
|
|
}
|
|
return observation.Quota.Adapter == target.ProviderID &&
|
|
observation.Quota.Target == target.ProfileID
|
|
}
|
|
|
|
func targetMatchesIdentity(
|
|
target ExecutionTarget,
|
|
identity agentpolicy.TargetIdentity,
|
|
) bool {
|
|
return target.ProviderID == identity.ProviderID &&
|
|
target.ModelID == identity.ModelID &&
|
|
target.ProfileID == identity.ProfileID &&
|
|
target.ProfileRevision == identity.ProfileRevision
|
|
}
|
|
|
|
func executionTargetIdentity(target ExecutionTarget) agentpolicy.TargetIdentity {
|
|
return agentpolicy.TargetIdentity{
|
|
ProviderID: target.ProviderID,
|
|
ModelID: target.ModelID,
|
|
ProfileID: target.ProfileID,
|
|
ProfileRevision: target.ProfileRevision,
|
|
}
|
|
}
|
|
|
|
func sameExecutionTarget(left, right ExecutionTarget) bool {
|
|
return left.ProviderID == right.ProviderID &&
|
|
left.ModelID == right.ModelID &&
|
|
left.ProfileID == right.ProfileID &&
|
|
left.ProfileRevision == right.ProfileRevision &&
|
|
left.ConfigRevision == right.ConfigRevision &&
|
|
left.Capacity == right.Capacity
|
|
}
|
|
|
|
func blockerFromContinuation(code agentpolicy.ContinuationBlockerCode) Blocker {
|
|
switch code {
|
|
case agentpolicy.ContinuationBlockerUnknownQuota:
|
|
return Blocker{Code: BlockerFailureObservationUnknown, Message: "quota observation is unknown"}
|
|
case agentpolicy.ContinuationBlockerStaleObservation:
|
|
return Blocker{Code: BlockerFailureObservationStale, Message: "quota observation is stale"}
|
|
case agentpolicy.ContinuationBlockerCorruptObservation:
|
|
return Blocker{Code: BlockerFailureObservationCorrupt, Message: "quota observation is corrupt"}
|
|
case agentpolicy.ContinuationBlockerUnknownFailure:
|
|
return Blocker{Code: BlockerFailureUnknown, Message: "runtime failure is unknown"}
|
|
case agentpolicy.ContinuationBlockerBudgetExhausted:
|
|
return Blocker{Code: BlockerFailureBudgetExhausted, Message: "failure budget is exhausted"}
|
|
case agentpolicy.ContinuationBlockerNoAlternate:
|
|
return Blocker{Code: BlockerNoEligibleFailover, Message: "no eligible unused failover target remains"}
|
|
default:
|
|
return Blocker{Code: BlockerFailurePolicyDenied, Message: "failure is not declared by policy"}
|
|
}
|
|
}
|
|
|
|
func validateStartedConfinement(started StartedConfinement) error {
|
|
if started == nil {
|
|
return fmt.Errorf("agenttask: executable confinement returned a nil started handle")
|
|
}
|
|
if started.Child() == nil {
|
|
return fmt.Errorf("agenttask: executable confinement returned a started handle without a child")
|
|
}
|
|
if started.Stdin() == nil || started.Stdout() == nil || started.Stderr() == nil {
|
|
return fmt.Errorf("agenttask: executable confinement returned incomplete child I/O")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateInvocationLocators(
|
|
project ProjectRecord,
|
|
work WorkRecord,
|
|
locators []LocatorRecord,
|
|
) error {
|
|
if len(locators) == 0 {
|
|
return fmt.Errorf("agenttask: provider invocation returned no durable process or session locator")
|
|
}
|
|
seen := make(map[LocatorKind]struct{}, len(locators))
|
|
for _, locator := range locators {
|
|
if locator.Kind != LocatorProcess && locator.Kind != LocatorSession {
|
|
return fmt.Errorf("agenttask: provider returned unsupported %q invocation locator", locator.Kind)
|
|
}
|
|
if _, duplicate := seen[locator.Kind]; duplicate {
|
|
return fmt.Errorf("agenttask: provider returned duplicate %q invocation locator", locator.Kind)
|
|
}
|
|
seen[locator.Kind] = struct{}{}
|
|
if err := validateLocator(project, work, locator); err != nil {
|
|
return err
|
|
}
|
|
if existing, ok := work.Locators[locator.Kind]; ok &&
|
|
!reflect.DeepEqual(existing, locator) {
|
|
return fmt.Errorf("agenttask: provider replaced the durable %q locator", locator.Kind)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateTarget(project ProjectRecord, target ExecutionTarget) error {
|
|
for field, value := range map[string]string{
|
|
"provider": target.ProviderID,
|
|
"model": target.ModelID,
|
|
"profile": target.ProfileID,
|
|
"profile_revision": target.ProfileRevision,
|
|
} {
|
|
if err := validateIdentity(field, value); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if project.Intent == nil || target.ConfigRevision != project.Intent.ConfigRevision {
|
|
return fmt.Errorf("agenttask: selected target config revision does not match manual start")
|
|
}
|
|
if target.Capacity <= 0 {
|
|
return fmt.Errorf("agenttask: selected target capacity must be positive")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validatePreparedIsolation(
|
|
project ProjectRecord,
|
|
work WorkRecord,
|
|
target ExecutionTarget,
|
|
prepared PreparedIsolation,
|
|
) (agentguard.AdmissionRequest, IsolationIdentity, error) {
|
|
if prepared.Grant == nil || prepared.Descriptor == nil ||
|
|
prepared.Confinement == nil {
|
|
return agentguard.AdmissionRequest{}, IsolationIdentity{},
|
|
fmt.Errorf("agenttask: isolation backend returned incomplete strict ports")
|
|
}
|
|
if project.Intent == nil {
|
|
return agentguard.AdmissionRequest{}, IsolationIdentity{},
|
|
fmt.Errorf("agenttask: project has no manual start intent")
|
|
}
|
|
if prepared.Grant.ProjectID != string(project.ProjectID) ||
|
|
prepared.Grant.WorkspaceID != string(project.WorkspaceID) ||
|
|
prepared.Grant.Revision != string(project.Intent.GrantRevision) {
|
|
return agentguard.AdmissionRequest{}, IsolationIdentity{},
|
|
fmt.Errorf("agenttask: workspace grant identity or revision mismatch")
|
|
}
|
|
if prepared.Descriptor.Mode != work.Unit.IsolationMode {
|
|
return agentguard.AdmissionRequest{}, IsolationIdentity{},
|
|
fmt.Errorf("agenttask: isolation mode differs from the workflow request")
|
|
}
|
|
if prepared.Profile.ProviderID != target.ProviderID ||
|
|
prepared.Profile.ModelID != target.ModelID ||
|
|
prepared.Profile.ProfileID != target.ProfileID ||
|
|
prepared.Profile.Revision != target.ProfileRevision {
|
|
return agentguard.AdmissionRequest{}, IsolationIdentity{},
|
|
fmt.Errorf("agenttask: admitted provider profile differs from selected target")
|
|
}
|
|
expectedConfinement := ConfinementBinding{
|
|
Revision: prepared.Descriptor.ConfinementRevision,
|
|
IsolationID: prepared.Descriptor.ID,
|
|
IsolationRevision: prepared.Descriptor.Revision,
|
|
PinnedBaseRevision: prepared.Descriptor.PinnedBaseRevision,
|
|
ConfigRevision: string(project.Intent.ConfigRevision),
|
|
GrantRevision: prepared.Grant.Revision,
|
|
ProfileRevision: prepared.Profile.Revision,
|
|
BaseRoot: prepared.Descriptor.BaseRoot,
|
|
TaskRoot: prepared.Descriptor.TaskRoot,
|
|
WorkingDir: prepared.Descriptor.WorkingDir,
|
|
WritableRoots: append([]string(nil), prepared.Descriptor.WritableRoots...),
|
|
}
|
|
actualConfinement := prepared.Confinement.Binding()
|
|
expectedConfinement.RuntimeRoot = actualConfinement.RuntimeRoot
|
|
expectedConfinement.SnapshotRoot = actualConfinement.SnapshotRoot
|
|
if prepared.Confinement.Revision() != expectedConfinement.Revision {
|
|
return agentguard.AdmissionRequest{}, IsolationIdentity{},
|
|
fmt.Errorf("agenttask: confinement proof revision differs from the isolation descriptor")
|
|
}
|
|
if err := prepared.Confinement.Validate(expectedConfinement); err != nil {
|
|
return agentguard.AdmissionRequest{}, IsolationIdentity{},
|
|
fmt.Errorf("agenttask: invalid executable confinement proof: %w", err)
|
|
}
|
|
identity := IsolationIdentity{
|
|
ID: prepared.Descriptor.ID, Revision: prepared.Descriptor.Revision,
|
|
Mode: prepared.Descriptor.Mode, PinnedBaseRevision: prepared.Descriptor.PinnedBaseRevision,
|
|
TaskRoot: prepared.Descriptor.TaskRoot,
|
|
}
|
|
for field, value := range map[string]string{
|
|
"isolation": identity.ID,
|
|
"isolation_revision": identity.Revision,
|
|
"pinned_base_revision": identity.PinnedBaseRevision,
|
|
} {
|
|
if err := validateIdentity(field, value); err != nil {
|
|
return agentguard.AdmissionRequest{}, IsolationIdentity{}, err
|
|
}
|
|
}
|
|
return agentguard.AdmissionRequest{
|
|
Grant: prepared.Grant, Isolation: prepared.Descriptor, Profile: prepared.Profile,
|
|
}, identity, nil
|
|
}
|
|
|
|
func validateSubmission(project ProjectRecord, work WorkRecord, submission Submission) error {
|
|
if submission.ProjectID != project.ProjectID || submission.WorkUnitID != work.Unit.ID ||
|
|
submission.AttemptID != work.AttemptID {
|
|
return fmt.Errorf("agenttask: submission durable identity mismatch")
|
|
}
|
|
if err := validateIdentity("artifact", string(submission.ArtifactID)); err != nil {
|
|
return err
|
|
}
|
|
seen := make(map[LocatorKind]struct{}, len(submission.Locators))
|
|
for _, locator := range submission.Locators {
|
|
if locator.Kind != LocatorProcess && locator.Kind != LocatorSession {
|
|
return fmt.Errorf("agenttask: provider returned unsupported %q submission locator", locator.Kind)
|
|
}
|
|
if _, duplicate := seen[locator.Kind]; duplicate {
|
|
return fmt.Errorf("agenttask: provider returned duplicate %q submission locator", locator.Kind)
|
|
}
|
|
seen[locator.Kind] = struct{}{}
|
|
if err := validateLocator(project, work, locator); err != nil {
|
|
return err
|
|
}
|
|
if existing, ok := work.Locators[locator.Kind]; ok &&
|
|
!reflect.DeepEqual(existing, locator) {
|
|
return fmt.Errorf("agenttask: submission replaced the durable %q locator", locator.Kind)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Manager) loadWork(
|
|
ctx context.Context,
|
|
projectID ProjectID,
|
|
workID WorkUnitID,
|
|
) (ProjectRecord, WorkRecord, error) {
|
|
state, err := m.load(ctx)
|
|
if err != nil {
|
|
return ProjectRecord{}, WorkRecord{}, err
|
|
}
|
|
project, ok := state.Projects[projectID]
|
|
if !ok {
|
|
return ProjectRecord{}, WorkRecord{}, fmt.Errorf("agenttask: project %q disappeared", projectID)
|
|
}
|
|
work, ok := project.Works[workID]
|
|
if !ok {
|
|
return ProjectRecord{}, WorkRecord{}, fmt.Errorf("agenttask: work %q disappeared", workID)
|
|
}
|
|
return project, work, nil
|
|
}
|
|
|
|
func (m *Manager) changeWork(
|
|
ctx context.Context,
|
|
projectID ProjectID,
|
|
workID WorkUnitID,
|
|
change func(*WorkRecord) error,
|
|
) error {
|
|
return m.mutate(ctx, func(state *ManagerState) error {
|
|
project, ok := state.Projects[projectID]
|
|
if !ok {
|
|
return fmt.Errorf("agenttask: project %q disappeared", projectID)
|
|
}
|
|
work, ok := project.Works[workID]
|
|
if !ok {
|
|
return fmt.Errorf("agenttask: work %q disappeared", workID)
|
|
}
|
|
if err := change(&work); err != nil {
|
|
return err
|
|
}
|
|
work.UpdatedAt = m.clock.Now()
|
|
project.Works[workID] = work
|
|
project.UpdatedAt = m.clock.Now()
|
|
state.Projects[projectID] = project
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (m *Manager) blockWork(
|
|
ctx context.Context,
|
|
projectID ProjectID,
|
|
workID WorkUnitID,
|
|
state WorkState,
|
|
blocker Blocker,
|
|
) {
|
|
_ = m.changeWork(ctx, projectID, workID, func(work *WorkRecord) error {
|
|
stage := failureStageForState(work.State)
|
|
blocker = m.recordFailure(work, stage, blocker)
|
|
if CanTransition(work.State, state) {
|
|
work.State = state
|
|
} else {
|
|
work.State = WorkStateBlocked
|
|
}
|
|
work.Blocker = &blocker
|
|
return nil
|
|
})
|
|
m.emit(ctx, Event{
|
|
Type: EventBlocked, ProjectID: projectID, WorkUnitID: workID,
|
|
State: state, Detail: string(blocker.Code),
|
|
})
|
|
}
|