Add agent run supervisor worker
Poll-based supervisor that claims PENDING agent runs with FOR UPDATE SKIP LOCKED, runs them with lease-based heartbeat, and handles graceful shutdown. On infrastructure stop the row stays RUNNING so stale recovery resets it to PENDING on restart; Restore picks up from the last checkpoint. Heartbeat loss cancels execution without committing a terminal status. Signed-off-by: Aurélien Sibiril <81782+aureliensibiril@users.noreply.github.com>
This commit is contained in:
@@ -35,7 +35,7 @@ type (
|
|||||||
// Approval-interrupted checkpoints carry pending tool calls.
|
// Approval-interrupted checkpoints carry pending tool calls.
|
||||||
PendingToolCalls []llm.ToolCall `json:"pending_tool_calls,omitempty"`
|
PendingToolCalls []llm.ToolCall `json:"pending_tool_calls,omitempty"`
|
||||||
PendingApprovals []llm.ToolCall `json:"pending_approvals,omitempty"`
|
PendingApprovals []llm.ToolCall `json:"pending_approvals,omitempty"`
|
||||||
ApprovalInput map[string]ApprovalResult `json:"approval_input,omitempty"`
|
ApprovalInput map[string]ApprovalResult `json:"approval_input,omitempty"` // keyed by tool call ID
|
||||||
|
|
||||||
// Nested agent-as-tool suspension: one entry per suspended inner agent.
|
// Nested agent-as-tool suspension: one entry per suspended inner agent.
|
||||||
AllToolCalls []llm.ToolCall `json:"all_tool_calls,omitempty"`
|
AllToolCalls []llm.ToolCall `json:"all_tool_calls,omitempty"`
|
||||||
|
|||||||
@@ -60,9 +60,6 @@ func restoreCheckpoint(
|
|||||||
runID string,
|
runID string,
|
||||||
registry AgentRegistry,
|
registry AgentRegistry,
|
||||||
) (*Result, error) {
|
) (*Result, error) {
|
||||||
if cp.Version != CheckpointVersion {
|
|
||||||
return nil, fmt.Errorf("cannot restore: unsupported checkpoint version %d", cp.Version)
|
|
||||||
}
|
|
||||||
emitHook(agent, func(h RunHooks) { h.OnRunRestore(ctx, agent, cp) })
|
emitHook(agent, func(h RunHooks) { h.OnRunRestore(ctx, agent, cp) })
|
||||||
|
|
||||||
switch cp.Status {
|
switch cp.Status {
|
||||||
|
|||||||
@@ -374,6 +374,10 @@ func coreLoop(ctx context.Context, startAgent *Agent, inputMessages []llm.Messag
|
|||||||
return s.finishRun(ctx, nil, fmt.Errorf("cannot complete: %w", err))
|
return s.finishRun(ctx, nil, fmt.Errorf("cannot complete: %w", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if s.turns >= s.agent.maxTurns {
|
||||||
|
return s.finishRun(ctx, nil, &MaxTurnsExceededError{MaxTurns: s.agent.maxTurns})
|
||||||
|
}
|
||||||
|
|
||||||
if ch := stopSignalFrom(ctx); ch != nil {
|
if ch := stopSignalFrom(ctx); ch != nil {
|
||||||
select {
|
select {
|
||||||
case <-ch:
|
case <-ch:
|
||||||
@@ -383,6 +387,7 @@ func coreLoop(ctx context.Context, startAgent *Agent, inputMessages []llm.Messag
|
|||||||
if s.opts.checkpointStore != nil && s.opts.runID != "" {
|
if s.opts.checkpointStore != nil && s.opts.runID != "" {
|
||||||
if saveErr := s.opts.checkpointStore.Save(ctx, s.opts.runID, cp); saveErr != nil {
|
if saveErr := s.opts.checkpointStore.Save(ctx, s.opts.runID, cp); saveErr != nil {
|
||||||
s.logger.ErrorCtx(ctx, "cannot save suspension checkpoint", log.Error(saveErr))
|
s.logger.ErrorCtx(ctx, "cannot save suspension checkpoint", log.Error(saveErr))
|
||||||
|
se.Checkpoint = cp
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
se.Checkpoint = cp
|
se.Checkpoint = cp
|
||||||
@@ -393,10 +398,6 @@ func coreLoop(ctx context.Context, startAgent *Agent, inputMessages []llm.Messag
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.turns >= s.agent.maxTurns {
|
|
||||||
return s.finishRun(ctx, nil, &MaxTurnsExceededError{MaxTurns: s.agent.maxTurns})
|
|
||||||
}
|
|
||||||
|
|
||||||
fullMessages := buildFullMessages(s.systemPrompt, s.messages)
|
fullMessages := buildFullMessages(s.systemPrompt, s.messages)
|
||||||
|
|
||||||
var responseFormat *llm.ResponseFormat
|
var responseFormat *llm.ResponseFormat
|
||||||
|
|||||||
416
pkg/probo/agent_run_supervisor.go
Normal file
416
pkg/probo/agent_run_supervisor.go
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||||
|
//
|
||||||
|
// Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
// purpose with or without fee is hereby granted, provided that the above
|
||||||
|
// copyright notice and this permission notice appear in all copies.
|
||||||
|
//
|
||||||
|
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
package probo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.gearno.de/crypto/uuid"
|
||||||
|
"go.gearno.de/kit/log"
|
||||||
|
"go.gearno.de/kit/pg"
|
||||||
|
"go.probo.inc/probo/pkg/agent"
|
||||||
|
"go.probo.inc/probo/pkg/coredata"
|
||||||
|
"go.probo.inc/probo/pkg/llm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
AgentRunSupervisor struct {
|
||||||
|
pg *pg.Client
|
||||||
|
store *coredata.PGCheckpointStore
|
||||||
|
registry agent.AgentRegistry
|
||||||
|
logger *log.Logger
|
||||||
|
interval time.Duration
|
||||||
|
leaseDuration time.Duration
|
||||||
|
maxConcurrency int
|
||||||
|
workerID string
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
running map[string]*runHandle // runID -> handle
|
||||||
|
}
|
||||||
|
|
||||||
|
// runHandle wraps a stop channel with a sync.Once to prevent double-close panics.
|
||||||
|
runHandle struct {
|
||||||
|
stopCh chan struct{}
|
||||||
|
once sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
AgentRunSupervisorOption func(*AgentRunSupervisor)
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrAgentRunHeartbeatFailed = errors.New("agent run heartbeat failed")
|
||||||
|
ErrAgentRunLeaseLost = errors.New("agent run lease lost")
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *runHandle) stop() {
|
||||||
|
h.once.Do(func() { close(h.stopCh) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithAgentRunSupervisorInterval(d time.Duration) AgentRunSupervisorOption {
|
||||||
|
return func(s *AgentRunSupervisor) {
|
||||||
|
if d > 0 {
|
||||||
|
s.interval = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithAgentRunSupervisorLeaseDuration(d time.Duration) AgentRunSupervisorOption {
|
||||||
|
return func(s *AgentRunSupervisor) {
|
||||||
|
if d > 0 {
|
||||||
|
s.leaseDuration = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithAgentRunSupervisorMaxConcurrency(n int) AgentRunSupervisorOption {
|
||||||
|
return func(s *AgentRunSupervisor) {
|
||||||
|
if n > 0 {
|
||||||
|
s.maxConcurrency = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAgentRunSupervisor(
|
||||||
|
pgClient *pg.Client,
|
||||||
|
store *coredata.PGCheckpointStore,
|
||||||
|
registry agent.AgentRegistry,
|
||||||
|
logger *log.Logger,
|
||||||
|
opts ...AgentRunSupervisorOption,
|
||||||
|
) *AgentRunSupervisor {
|
||||||
|
s := &AgentRunSupervisor{
|
||||||
|
pg: pgClient,
|
||||||
|
store: store,
|
||||||
|
registry: registry,
|
||||||
|
logger: logger,
|
||||||
|
interval: 10 * time.Second,
|
||||||
|
leaseDuration: 5 * time.Minute,
|
||||||
|
maxConcurrency: 5,
|
||||||
|
workerID: uuid.MustNewV4().String(),
|
||||||
|
running: make(map[string]*runHandle),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, opt := range opts {
|
||||||
|
opt(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AgentRunSupervisor) Run(ctx context.Context) error {
|
||||||
|
var (
|
||||||
|
wg sync.WaitGroup
|
||||||
|
sem = make(chan struct{}, s.maxConcurrency)
|
||||||
|
ticker = time.NewTicker(s.interval)
|
||||||
|
)
|
||||||
|
defer ticker.Stop()
|
||||||
|
defer wg.Wait()
|
||||||
|
defer s.stopAll()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-ticker.C:
|
||||||
|
nonCancelableCtx := context.WithoutCancel(ctx)
|
||||||
|
s.recoverStaleRuns(nonCancelableCtx)
|
||||||
|
s.checkStopRequests(nonCancelableCtx)
|
||||||
|
|
||||||
|
for {
|
||||||
|
if err := s.processNext(ctx, sem, &wg); err != nil {
|
||||||
|
if !errors.Is(err, coredata.ErrResourceNotFound) {
|
||||||
|
s.logger.ErrorCtx(nonCancelableCtx, "cannot claim agent run", log.Error(err))
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AgentRunSupervisor) processNext(
|
||||||
|
ctx context.Context,
|
||||||
|
sem chan struct{},
|
||||||
|
wg *sync.WaitGroup,
|
||||||
|
) error {
|
||||||
|
select {
|
||||||
|
case sem <- struct{}{}:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
run = coredata.AgentRun{}
|
||||||
|
now = time.Now()
|
||||||
|
leaseOwner = s.workerID
|
||||||
|
leaseExpiresAt = now.Add(s.leaseDuration)
|
||||||
|
nonCancelableCtx = context.WithoutCancel(ctx)
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := s.pg.WithTx(
|
||||||
|
nonCancelableCtx,
|
||||||
|
func(ctx context.Context, tx pg.Tx) error {
|
||||||
|
if err := run.LoadNextPendingForUpdateSkipLocked(ctx, tx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
run.Status = coredata.AgentRunStatusRunning
|
||||||
|
run.StartedAt = &now
|
||||||
|
run.LeaseOwner = &leaseOwner
|
||||||
|
run.LeaseExpiresAt = &leaseExpiresAt
|
||||||
|
run.UpdatedAt = now
|
||||||
|
|
||||||
|
if err := run.Update(ctx, tx, coredata.NewNoScope()); err != nil {
|
||||||
|
return fmt.Errorf("cannot update agent run: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
); err != nil {
|
||||||
|
<-sem
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
handle := &runHandle{stopCh: make(chan struct{})}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.running[run.ID.String()] = handle
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func(run coredata.AgentRun) {
|
||||||
|
defer wg.Done()
|
||||||
|
defer func() { <-sem }()
|
||||||
|
defer func() {
|
||||||
|
s.mu.Lock()
|
||||||
|
delete(s.running, run.ID.String())
|
||||||
|
s.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
runCtx, cancelRun := context.WithCancelCause(nonCancelableCtx)
|
||||||
|
defer cancelRun(nil)
|
||||||
|
|
||||||
|
heartbeatCtx, cancelHeartbeat := context.WithCancel(nonCancelableCtx)
|
||||||
|
defer cancelHeartbeat()
|
||||||
|
go s.heartbeatLease(heartbeatCtx, run.ID.String(), cancelRun)
|
||||||
|
|
||||||
|
runCtx = agent.WithStopSignal(runCtx, handle.stopCh)
|
||||||
|
s.executeRun(runCtx, &run)
|
||||||
|
}(run)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AgentRunSupervisor) heartbeatLease(
|
||||||
|
ctx context.Context,
|
||||||
|
runID string,
|
||||||
|
cancelRun context.CancelCauseFunc,
|
||||||
|
) {
|
||||||
|
ticker := time.NewTicker(s.leaseDuration / 3)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
expiresAt := time.Now().Add(s.leaseDuration)
|
||||||
|
|
||||||
|
if err := s.pg.WithConn(
|
||||||
|
ctx,
|
||||||
|
func(ctx context.Context, conn pg.Querier) error {
|
||||||
|
rowsAffected, err := coredata.HeartbeatAgentRunLease(ctx, conn, runID, s.workerID, expiresAt)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if rowsAffected == 0 {
|
||||||
|
return ErrAgentRunLeaseLost
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
); err != nil {
|
||||||
|
s.logger.ErrorCtx(ctx, "cannot heartbeat agent run lease", log.Error(err))
|
||||||
|
if errors.Is(err, ErrAgentRunLeaseLost) {
|
||||||
|
cancelRun(ErrAgentRunLeaseLost)
|
||||||
|
} else {
|
||||||
|
cancelRun(fmt.Errorf("%w: %w", ErrAgentRunHeartbeatFailed, err))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AgentRunSupervisor) executeRun(ctx context.Context, run *coredata.AgentRun) {
|
||||||
|
runID := run.ID.String()
|
||||||
|
|
||||||
|
var (
|
||||||
|
result *agent.Result
|
||||||
|
runErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
if run.Checkpoint != nil {
|
||||||
|
// Resume from checkpoint.
|
||||||
|
s.logger.InfoCtx(ctx, "resuming agent run", log.String("run_id", runID))
|
||||||
|
result, runErr = agent.Restore(ctx, s.store, runID, s.registry)
|
||||||
|
} else {
|
||||||
|
// Start fresh.
|
||||||
|
s.logger.InfoCtx(ctx, "starting agent run", log.String("run_id", runID))
|
||||||
|
|
||||||
|
a, err := s.registry.Agent(run.StartAgentName)
|
||||||
|
if err != nil {
|
||||||
|
runErr = fmt.Errorf("cannot resolve agent %q: %w", run.StartAgentName, err)
|
||||||
|
} else {
|
||||||
|
var inputMsgs []llm.Message
|
||||||
|
if err := json.Unmarshal(run.InputMessages, &inputMsgs); err != nil {
|
||||||
|
runErr = fmt.Errorf("cannot unmarshal input messages: %w", err)
|
||||||
|
} else {
|
||||||
|
result, runErr = a.RunWithOpts(
|
||||||
|
ctx,
|
||||||
|
inputMsgs,
|
||||||
|
agent.WithCheckpointStore(s.store, runID),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heartbeat loss: another worker may have taken over. Do not commit
|
||||||
|
// any status — stale recovery will handle the row.
|
||||||
|
if cause := context.Cause(ctx); errors.Is(cause, ErrAgentRunLeaseLost) || errors.Is(cause, ErrAgentRunHeartbeatFailed) {
|
||||||
|
s.logger.WarnCtx(
|
||||||
|
context.WithoutCancel(ctx),
|
||||||
|
"agent run stopped after heartbeat failure; leaving status for stale recovery",
|
||||||
|
log.String("run_id", runID),
|
||||||
|
log.Error(cause),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Infrastructure-triggered suspension (graceful shutdown): leave the
|
||||||
|
// row as RUNNING so stale recovery resets it to PENDING on restart.
|
||||||
|
// The checkpoint was already saved by coreLoop before returning
|
||||||
|
// SuspendedError, so Restore will pick up where it left off.
|
||||||
|
if runErr != nil {
|
||||||
|
if _, ok := errors.AsType[*agent.SuspendedError](runErr); ok {
|
||||||
|
s.logger.InfoCtx(
|
||||||
|
context.WithoutCancel(ctx),
|
||||||
|
"agent run suspended by infrastructure; leaving for stale recovery",
|
||||||
|
log.String("run_id", runID),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update run status based on outcome.
|
||||||
|
now := time.Now()
|
||||||
|
run.UpdatedAt = now
|
||||||
|
run.StartedAt = nil
|
||||||
|
run.LeaseOwner = nil
|
||||||
|
run.LeaseExpiresAt = nil
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case runErr == nil:
|
||||||
|
run.Status = coredata.AgentRunStatusCompleted
|
||||||
|
if result != nil {
|
||||||
|
data, err := json.Marshal(result)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.ErrorCtx(ctx, "cannot marshal agent run result", log.Error(err))
|
||||||
|
} else {
|
||||||
|
run.Result = data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run.StopRequested = false
|
||||||
|
|
||||||
|
default:
|
||||||
|
run.Status = coredata.AgentRunStatusFailed
|
||||||
|
msg := runErr.Error()
|
||||||
|
run.ErrorMessage = &msg
|
||||||
|
}
|
||||||
|
|
||||||
|
commitCtx := context.WithoutCancel(ctx)
|
||||||
|
|
||||||
|
if err := s.pg.WithTx(
|
||||||
|
commitCtx,
|
||||||
|
func(ctx context.Context, tx pg.Tx) error {
|
||||||
|
if err := run.Update(ctx, tx, coredata.NewNoScope()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if run.Status == coredata.AgentRunStatusCompleted {
|
||||||
|
if err := run.ClearCheckpoint(ctx, tx, coredata.NewNoScope()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
); err != nil {
|
||||||
|
s.logger.ErrorCtx(commitCtx, "cannot commit agent run status", log.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AgentRunSupervisor) checkStopRequests(ctx context.Context) {
|
||||||
|
var ids []string
|
||||||
|
|
||||||
|
if err := s.pg.WithConn(
|
||||||
|
ctx,
|
||||||
|
func(ctx context.Context, conn pg.Querier) error {
|
||||||
|
var err error
|
||||||
|
ids, err = coredata.LoadRunningStopRequestedIDs(ctx, conn)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
); err != nil {
|
||||||
|
s.logger.ErrorCtx(ctx, "cannot check stop requests", log.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, id := range ids {
|
||||||
|
s.mu.Lock()
|
||||||
|
if h, ok := s.running[id]; ok {
|
||||||
|
h.stop()
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AgentRunSupervisor) stopAll() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
for _, h := range s.running {
|
||||||
|
h.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AgentRunSupervisor) recoverStaleRuns(ctx context.Context) {
|
||||||
|
if err := s.pg.WithConn(
|
||||||
|
ctx,
|
||||||
|
func(ctx context.Context, conn pg.Querier) error {
|
||||||
|
if err := coredata.ResetStaleAgentRuns(ctx, conn); err != nil {
|
||||||
|
return fmt.Errorf("cannot reset stale agent runs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
); err != nil {
|
||||||
|
s.logger.ErrorCtx(ctx, "cannot recover stale agent runs", log.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user