334 lines
8.7 KiB
Go
334 lines
8.7 KiB
Go
package agenttask
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type ManagerConfig struct {
|
|
OwnerID string
|
|
LeaseDuration time.Duration
|
|
MaxReworkAttempts uint32
|
|
StateWriteAttempts int
|
|
}
|
|
|
|
type Manager struct {
|
|
config ManagerConfig
|
|
clock Clock
|
|
store StateStore
|
|
workflow WorkflowAdapter
|
|
selector Selector
|
|
isolation IsolationBackend
|
|
invoker ProviderInvoker
|
|
reviewer Reviewer
|
|
integrator Integrator
|
|
events EventSink
|
|
scheduler *Scheduler
|
|
|
|
reconcileMu sync.Mutex
|
|
activeMu sync.Mutex
|
|
activeRuns map[ProjectID]context.CancelFunc
|
|
}
|
|
|
|
func NewManager(
|
|
config ManagerConfig,
|
|
clock Clock,
|
|
store StateStore,
|
|
workflow WorkflowAdapter,
|
|
selector Selector,
|
|
isolation IsolationBackend,
|
|
invoker ProviderInvoker,
|
|
reviewer Reviewer,
|
|
integrator Integrator,
|
|
events EventSink,
|
|
) (*Manager, error) {
|
|
if err := validateIdentity("manager_owner", config.OwnerID); err != nil {
|
|
return nil, err
|
|
}
|
|
if store == nil || workflow == nil || selector == nil || isolation == nil ||
|
|
invoker == nil || reviewer == nil || integrator == nil {
|
|
return nil, errors.New("agenttask: every execution port is required; unsafe fallback is disabled")
|
|
}
|
|
if clock == nil {
|
|
clock = systemClock{}
|
|
}
|
|
if events == nil {
|
|
events = nopEventSink{}
|
|
}
|
|
if config.LeaseDuration <= 0 {
|
|
config.LeaseDuration = 30 * time.Second
|
|
}
|
|
if config.MaxReworkAttempts == 0 {
|
|
config.MaxReworkAttempts = 3
|
|
}
|
|
if config.StateWriteAttempts <= 0 {
|
|
config.StateWriteAttempts = 32
|
|
}
|
|
return &Manager{
|
|
config: config,
|
|
clock: clock,
|
|
store: store,
|
|
workflow: workflow,
|
|
selector: selector,
|
|
isolation: isolation,
|
|
invoker: invoker,
|
|
reviewer: reviewer,
|
|
integrator: integrator,
|
|
events: events,
|
|
scheduler: NewScheduler(),
|
|
activeRuns: make(map[ProjectID]context.CancelFunc),
|
|
}, nil
|
|
}
|
|
|
|
func (m *Manager) StartProject(ctx context.Context, req StartRequest) error {
|
|
if err := validateStartRequest(req); err != nil {
|
|
return err
|
|
}
|
|
autoResume := true
|
|
if req.AutoResumeInterrupted != nil {
|
|
autoResume = *req.AutoResumeInterrupted
|
|
}
|
|
intent := StartIntent{
|
|
CommandID: req.CommandID,
|
|
ProjectID: req.ProjectID,
|
|
WorkspaceID: req.WorkspaceID,
|
|
MilestoneID: req.MilestoneID,
|
|
WorkflowRevision: req.WorkflowRevision,
|
|
ConfigRevision: req.ConfigRevision,
|
|
GrantRevision: req.GrantRevision,
|
|
AutoResumeInterrupted: autoResume,
|
|
StartedAt: m.clock.Now(),
|
|
}
|
|
err := m.mutate(ctx, func(state *ManagerState) error {
|
|
if previous, ok := state.Commands[req.CommandID]; ok {
|
|
if sameCommandIntent(previous.Intent, intent) {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("agenttask: command %q was already used with different immutable input", req.CommandID)
|
|
}
|
|
project := state.Projects[req.ProjectID]
|
|
if project.ProjectID != "" && project.WorkspaceID != "" &&
|
|
project.WorkspaceID != req.WorkspaceID {
|
|
return fmt.Errorf("agenttask: project %q workspace identity changed", req.ProjectID)
|
|
}
|
|
project.ProjectID = req.ProjectID
|
|
project.WorkspaceID = req.WorkspaceID
|
|
project.Status = ProjectStatusStarted
|
|
project.Intent = &intent
|
|
project.Blocker = nil
|
|
project.UpdatedAt = m.clock.Now()
|
|
if project.Works == nil {
|
|
project.Works = make(map[WorkUnitID]WorkRecord)
|
|
}
|
|
state.Commands[req.CommandID] = CommandRecord{Intent: intent}
|
|
state.Projects[req.ProjectID] = project
|
|
return nil
|
|
})
|
|
if err == nil {
|
|
m.emit(ctx, Event{
|
|
Type: EventManualStart,
|
|
ProjectID: req.ProjectID,
|
|
WorkspaceID: req.WorkspaceID,
|
|
CommandID: req.CommandID,
|
|
WorkflowRevision: req.WorkflowRevision,
|
|
Detail: string(req.MilestoneID),
|
|
})
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (m *Manager) StopProject(ctx context.Context, projectID ProjectID) error {
|
|
if err := validateIdentity("project", string(projectID)); err != nil {
|
|
return err
|
|
}
|
|
m.activeMu.Lock()
|
|
cancel := m.activeRuns[projectID]
|
|
m.activeMu.Unlock()
|
|
if cancel != nil {
|
|
cancel()
|
|
}
|
|
var commandID CommandID
|
|
var workflowRev WorkflowRevision
|
|
err := m.mutate(ctx, func(state *ManagerState) error {
|
|
project, ok := state.Projects[projectID]
|
|
if !ok {
|
|
return fmt.Errorf("agenttask: project %q is not registered in manager state", projectID)
|
|
}
|
|
if project.Intent != nil {
|
|
commandID = project.Intent.CommandID
|
|
workflowRev = project.Intent.WorkflowRevision
|
|
}
|
|
project.Status = ProjectStatusStopped
|
|
project.Lease = nil
|
|
project.UpdatedAt = m.clock.Now()
|
|
for id, work := range project.Works {
|
|
if !work.State.Terminal() {
|
|
preStop := work.State
|
|
if preStop != WorkStateStopped {
|
|
work.ResumeStage = preStop
|
|
}
|
|
if err := transitionWork(&work, WorkStateStopped); err != nil {
|
|
return err
|
|
}
|
|
work.UpdatedAt = m.clock.Now()
|
|
project.Works[id] = work
|
|
}
|
|
}
|
|
state.Projects[projectID] = project
|
|
return nil
|
|
})
|
|
if err == nil {
|
|
m.emit(ctx, Event{
|
|
Type: EventStopped,
|
|
ProjectID: projectID,
|
|
CommandID: commandID,
|
|
WorkflowRevision: workflowRev,
|
|
})
|
|
}
|
|
return err
|
|
}
|
|
|
|
func mutateDecision[T any](
|
|
m *Manager,
|
|
ctx context.Context,
|
|
change func(*ManagerState) (T, error),
|
|
) (T, error) {
|
|
var zero T
|
|
var conflict error
|
|
for range m.config.StateWriteAttempts {
|
|
state, revision, err := m.store.Load(ctx)
|
|
if err != nil {
|
|
return zero, err
|
|
}
|
|
next := cloneState(state)
|
|
if next.SchemaVersion != currentSchemaVersion {
|
|
return zero, fmt.Errorf("agenttask: unsupported state schema %d", next.SchemaVersion)
|
|
}
|
|
result, err := change(&next)
|
|
if err != nil {
|
|
return zero, err
|
|
}
|
|
_, err = m.store.CompareAndSwap(ctx, revision, next)
|
|
if errors.Is(err, ErrRevisionConflict) {
|
|
conflict = err
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return zero, err
|
|
}
|
|
return result, nil
|
|
}
|
|
return zero, fmt.Errorf("agenttask: state CAS retry budget exhausted: %w", conflict)
|
|
}
|
|
|
|
func (m *Manager) mutate(
|
|
ctx context.Context,
|
|
change func(*ManagerState) error,
|
|
) error {
|
|
_, err := mutateDecision(m, ctx, func(state *ManagerState) (struct{}, error) {
|
|
return struct{}{}, change(state)
|
|
})
|
|
return err
|
|
}
|
|
|
|
func (m *Manager) load(ctx context.Context) (ManagerState, error) {
|
|
state, _, err := m.store.Load(ctx)
|
|
if err != nil {
|
|
return ManagerState{}, err
|
|
}
|
|
state = cloneState(state)
|
|
if state.SchemaVersion != currentSchemaVersion {
|
|
return ManagerState{}, fmt.Errorf("agenttask: unsupported state schema %d", state.SchemaVersion)
|
|
}
|
|
return state, nil
|
|
}
|
|
|
|
func durableIdentity(domain string, components ...string) string {
|
|
var sb strings.Builder
|
|
sb.WriteString(domain)
|
|
for _, c := range components {
|
|
sb.WriteString("/")
|
|
sb.WriteString(strconv.Itoa(len(c)))
|
|
sb.WriteString(":")
|
|
sb.WriteString(c)
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
func (m *Manager) emit(ctx context.Context, event Event) {
|
|
if event.EventID == "" {
|
|
event.EventID = durableIdentity(
|
|
"event-v1",
|
|
string(event.Type),
|
|
string(event.ProjectID),
|
|
string(event.WorkspaceID),
|
|
string(event.WorkUnitID),
|
|
string(event.CommandID),
|
|
string(event.WorkflowRevision),
|
|
string(event.AttemptID),
|
|
strconv.FormatUint(uint64(event.Ordinal), 10),
|
|
string(event.ChangeSetID),
|
|
event.ChangeSetRevision,
|
|
strconv.FormatUint(uint64(event.IntegrationAttempt), 10),
|
|
string(event.State),
|
|
event.Detail,
|
|
)
|
|
}
|
|
event.Timestamp = m.clock.Now()
|
|
_ = m.events.Emit(ctx, event)
|
|
}
|
|
|
|
func sameCommandIntent(left, right StartIntent) bool {
|
|
left.StartedAt = time.Time{}
|
|
right.StartedAt = time.Time{}
|
|
return reflect.DeepEqual(left, right)
|
|
}
|
|
|
|
func (m *Manager) beginProjectRun(
|
|
parent context.Context,
|
|
projectID ProjectID,
|
|
) (context.Context, func()) {
|
|
ctx, cancel := context.WithCancel(parent)
|
|
m.activeMu.Lock()
|
|
if previous := m.activeRuns[projectID]; previous != nil {
|
|
previous()
|
|
}
|
|
m.activeRuns[projectID] = cancel
|
|
m.activeMu.Unlock()
|
|
return ctx, func() {
|
|
cancel()
|
|
m.activeMu.Lock()
|
|
delete(m.activeRuns, projectID)
|
|
m.activeMu.Unlock()
|
|
}
|
|
}
|
|
|
|
func attemptID(workID WorkUnitID, attempt uint32) AttemptID {
|
|
return AttemptID(durableIdentity("attempt-v1", string(workID), strconv.FormatUint(uint64(attempt), 10)))
|
|
}
|
|
|
|
func dispatchKey(projectID ProjectID, workID WorkUnitID, attempt uint32) string {
|
|
return durableIdentity("dispatch-v1", string(projectID), string(workID), strconv.FormatUint(uint64(attempt), 10))
|
|
}
|
|
|
|
func reviewKey(projectID ProjectID, workID WorkUnitID, attempt uint32, artifact ArtifactID) string {
|
|
return durableIdentity("review-v1", string(projectID), string(workID), strconv.FormatUint(uint64(attempt), 10), string(artifact))
|
|
}
|
|
|
|
func integrationKey(
|
|
projectID ProjectID,
|
|
workID WorkUnitID,
|
|
changeSet ChangeSetIdentity,
|
|
attempt IntegrationAttempt,
|
|
) string {
|
|
return durableIdentity(
|
|
"integrate-v1",
|
|
string(projectID), string(workID), string(changeSet.ID), string(changeSet.Revision), strconv.FormatUint(uint64(attempt), 10),
|
|
)
|
|
}
|