Files
probo/pkg/agentrun/handler.go
Bryan Frimin 3dfc833671 Replace supervisor with agentrun worker service
Move agent-run orchestration from the legacy supervisor path into the new
agentrun worker/service package and wire it through coredata, server,
policies, and GraphQL resolvers.

This consolidates run lifecycle handling around lease-aware workers and
aligns API surface with the new agent-run domain model so reviewers can
follow one coherent execution path.

Signed-off-by: Bryan Frimin <bryan@probo.com>
2026-06-08 15:27:50 +02:00

376 lines
10 KiB
Go

// 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 agentrun
import (
"context"
"encoding/json"
"errors"
"fmt"
"sync"
"time"
"unicode/utf8"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
"go.gearno.de/kit/worker"
"go.probo.inc/probo/pkg/agent"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/llm"
)
type handler struct {
pg *pg.Client
store *coredata.PGCheckpointer
registry agent.AgentRegistry
logger *log.Logger
leaseDuration time.Duration
shutdownCh chan struct{}
shutdownOnce sync.Once
}
var (
_ worker.Handler[coredata.AgentRun] = (*handler)(nil)
_ worker.StaleRecoverer = (*handler)(nil)
)
// Claim loads the next pending agent run, marks it RUNNING with a lease
// owned by this worker, and returns the row. When no work is available it
// returns worker.ErrNoTask so the kit can back off until the next tick.
func (h *handler) Claim(ctx context.Context) (coredata.AgentRun, error) {
var (
run = coredata.AgentRun{}
now = time.Now()
leaseExpiresAt = now.Add(h.leaseDuration)
)
if err := h.pg.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
if err := run.LoadNextPendingForUpdateSkipLocked(ctx, tx); err != nil {
return fmt.Errorf("cannot load next pending agent run: %w", err)
}
run.Status = coredata.AgentRunStatusRunning
run.StartedAt = &now
run.LeaseExpiresAt = &leaseExpiresAt
run.LeaseGeneration++
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 {
if errors.Is(err, coredata.ErrResourceNotFound) {
return coredata.AgentRun{}, worker.ErrNoTask
}
return coredata.AgentRun{}, err
}
return run, nil
}
// Process executes a single agent run. It spawns a heartbeat goroutine
// that renews the lease while the run is active, and a forwarder
// goroutine that converts the handler-level shutdown broadcast into a
// per-run ctx cancellation so the agent loop checkpoints cleanly at
// its next turn boundary.
//
// The returned error mirrors the run outcome so the worker kit's
// task metrics and OTel span status reflect actual agent failures.
// nil is returned for both successful runs and graceful exits
// (lease loss, infrastructure suspension) where the row state is
// already consistent.
func (h *handler) Process(ctx context.Context, run coredata.AgentRun) error {
runCtx, cancelRun := context.WithCancelCause(ctx)
defer cancelRun(nil)
leaseGeneration := run.LeaseGeneration
forwarderDone := make(chan struct{})
defer close(forwarderDone)
go func() {
select {
case <-h.shutdownCh:
cancelRun(agent.ErrSuspendForCheckpoint)
case <-forwarderDone:
}
}()
heartbeatCtx, cancelHeartbeat := context.WithCancel(ctx)
defer cancelHeartbeat()
go h.heartbeatLease(heartbeatCtx, run.ID.String(), leaseGeneration, cancelRun)
return h.executeRun(runCtx, &run, leaseGeneration)
}
// RecoverStale resets agent runs whose worker lease has expired back to
// PENDING so a fresh worker can pick them up on the next cycle.
func (h *handler) RecoverStale(ctx context.Context) error {
if err := h.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
return coredata.ResetStaleAgentRuns(ctx, conn)
},
); err != nil {
return fmt.Errorf("cannot reset stale agent runs: %w", err)
}
return nil
}
// signalShutdown closes the handler-level shutdown broadcast channel. All
// in-flight Process forwarder goroutines observe the close and propagate
// it to their per-run agent stop channels, letting agents checkpoint at
// the next turn boundary before Process returns.
func (h *handler) signalShutdown() {
h.shutdownOnce.Do(func() { close(h.shutdownCh) })
}
func (h *handler) heartbeatLease(
ctx context.Context,
runID string,
leaseGeneration int64,
cancelRun context.CancelCauseFunc,
) {
ticker := time.NewTicker(h.leaseDuration / 3)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
expiresAt := time.Now().Add(h.leaseDuration)
if err := h.pg.WithConn(
ctx,
func(ctx context.Context, conn pg.Querier) error {
rowsAffected, err := coredata.HeartbeatAgentRunLease(
ctx,
conn,
runID,
leaseGeneration,
expiresAt,
)
if err != nil {
return err
}
if rowsAffected == 0 {
return ErrLeaseLost
}
return nil
},
); err != nil {
h.logger.ErrorCtx(ctx, "cannot heartbeat agent run lease", log.Error(err))
if errors.Is(err, ErrLeaseLost) {
cancelRun(ErrLeaseLost)
} else {
cancelRun(fmt.Errorf("%w: %w", ErrHeartbeatFailed, err))
}
return
}
}
}
}
const (
// errorMessageMaxLen caps the error string persisted to the
// agent_runs.error_message column. Raw tool or LLM errors can embed
// URLs with credentials, response snippets containing PII, or partial
// records from failed DB lookups; the full context is logged while
// only a truncated summary is stored for caller-visible state.
errorMessageMaxLen = 512
)
func sanitizeError(err error) string {
msg := err.Error()
if len(msg) <= errorMessageMaxLen {
return msg
}
cut := errorMessageMaxLen
for cut > 0 && !utf8.RuneStart(msg[cut]) {
cut--
}
return msg[:cut] + "…"
}
type leasedCheckpointer struct {
store *coredata.PGCheckpointer
leaseGeneration int64
}
func (s leasedCheckpointer) Save(ctx context.Context, runID string, cp *agent.Checkpoint) error {
return s.store.SaveForLease(ctx, runID, cp, s.leaseGeneration)
}
func (s leasedCheckpointer) Load(ctx context.Context, runID string) (*agent.Checkpoint, error) {
return s.store.Load(ctx, runID)
}
func (h *handler) executeRun(
ctx context.Context,
run *coredata.AgentRun,
leaseGeneration int64,
) error {
runID := run.ID.String()
checkpointer := leasedCheckpointer{
store: h.store,
leaseGeneration: leaseGeneration,
}
var (
result *agent.Result
runErr error
)
if run.Checkpoint != nil {
h.logger.InfoCtx(ctx, "resuming agent run", log.String("run_id", runID))
result, runErr = agent.Restore(ctx, checkpointer, runID, h.registry)
} else {
h.logger.InfoCtx(ctx, "starting agent run", log.String("run_id", runID))
a, err := h.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.Run(
ctx,
inputMsgs,
agent.WithCheckpointer(checkpointer, runID),
)
}
}
}
// Heartbeat loss: another worker may have taken over. Do not commit
// any status — stale recovery will handle the row. Surface the cause
// so the worker kit logs and traces a failure for this attempt.
if cause := context.Cause(ctx); errors.Is(cause, ErrLeaseLost) || errors.Is(cause, ErrHeartbeatFailed) {
h.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 cause
}
// 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. This
// is not a failure from the worker kit's perspective.
if runErr != nil {
if _, ok := errors.AsType[*agent.SuspendedError](runErr); ok {
h.logger.InfoCtx(
context.WithoutCancel(ctx),
"agent run suspended by infrastructure; leaving for stale recovery",
log.String("run_id", runID),
)
return nil
}
}
now := time.Now()
run.UpdatedAt = now
run.StartedAt = nil
run.LeaseExpiresAt = nil
if runErr == nil {
run.Status = coredata.AgentRunStatusCompleted
if result != nil {
data, err := json.Marshal(result)
if err != nil {
h.logger.ErrorCtx(ctx, "cannot marshal agent run result", log.Error(err))
runErr = fmt.Errorf("cannot marshal agent run result: %w", err)
} else {
run.Result = data
}
}
}
if runErr != nil {
run.Status = coredata.AgentRunStatusFailed
run.Result = nil
h.logger.ErrorCtx(
context.WithoutCancel(ctx),
"agent run failed",
log.String("run_id", runID),
log.Error(runErr),
)
msg := sanitizeError(runErr)
run.ErrorMessage = &msg
}
commitCtx := context.WithoutCancel(ctx)
if err := h.pg.WithTx(
commitCtx,
func(ctx context.Context, tx pg.Tx) error {
rowsAffected, err := coredata.CommitAgentRunResult(ctx, tx, run, leaseGeneration)
if err != nil {
return err
}
if rowsAffected == 0 {
return ErrLeaseLost
}
if run.Status == coredata.AgentRunStatusCompleted {
if err := run.ClearCheckpoint(ctx, tx, coredata.NewNoScope()); err != nil {
return err
}
}
return nil
},
); err != nil {
if errors.Is(err, ErrLeaseLost) {
h.logger.WarnCtx(
commitCtx,
"agent run lost lease before commit; discarding stale completion",
log.String("run_id", runID),
)
return nil
}
h.logger.ErrorCtx(commitCtx, "cannot commit agent run status", log.Error(err))
return fmt.Errorf("cannot commit agent run status: %w", err)
}
return runErr
}