361 lines
11 KiB
Go
361 lines
11 KiB
Go
package agenttask
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
|
|
"iop/packages/go/agentguard"
|
|
)
|
|
|
|
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.selector.Select(ctx, SelectionRequest{Project: project, Work: 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
|
|
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 submission Submission
|
|
dispatchRequest := DispatchRequest{
|
|
Project: project, Work: work, Target: target, AdmissionRequest: admissionRequest,
|
|
Permit: admission.Permit, Workspace: *admission.Workspace,
|
|
IdempotencyKey: dispatchKey(projectID, workID, work.Attempt),
|
|
}
|
|
validation, invokeErr := agentguard.Invoke(
|
|
ctx,
|
|
admission.Permit,
|
|
admissionRequest,
|
|
func(invokeCtx context.Context, workspace agentguard.CanonicalWorkspace) error {
|
|
dispatchRequest.Workspace = workspace
|
|
var err error
|
|
submission, err = m.invoker.Invoke(invokeCtx, dispatchRequest)
|
|
return err
|
|
},
|
|
)
|
|
lease.Release()
|
|
if !validation.Allowed() {
|
|
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 {
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
m.blockWork(ctx, projectID, workID, WorkStateBlocked, Blocker{
|
|
Code: BlockerInvocationFailed, Message: invokeErr.Error(), Retryable: true,
|
|
})
|
|
return nil
|
|
}
|
|
if err := validateSubmission(projectID, 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 err := m.changeWork(ctx, projectID, workID, func(work *WorkRecord) error {
|
|
if err := transitionWork(work, WorkStateSubmitted); err != nil {
|
|
return err
|
|
}
|
|
work.Submission = &submission
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
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")
|
|
}
|
|
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(projectID ProjectID, work WorkRecord, submission Submission) error {
|
|
if submission.ProjectID != 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
|
|
}
|
|
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 {
|
|
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),
|
|
})
|
|
}
|