github.com/anthropics/anthropic-sdk-go@v1.70.1/lib/environments/worker.go (about)

     1  package environments
     2  
     3  import (
     4  	"cmp"
     5  	"context"
     6  	"encoding/base64"
     7  	"encoding/json"
     8  	"errors"
     9  	"fmt"
    10  	"log/slog"
    11  	"os"
    12  	"strings"
    13  	"time"
    14  
    15  	anthropic "github.com/anthropics/anthropic-sdk-go"
    16  	"github.com/anthropics/anthropic-sdk-go/internal/sendwindow"
    17  	"github.com/anthropics/anthropic-sdk-go/internal/stainlessheader"
    18  	"github.com/anthropics/anthropic-sdk-go/option"
    19  	"github.com/anthropics/anthropic-sdk-go/packages/param"
    20  	"github.com/anthropics/anthropic-sdk-go/tools/agenttoolset"
    21  )
    22  
    23  const (
    24  	heartbeatDefault    = 30 * time.Second
    25  	heartbeatFloor      = 1 * time.Second
    26  	noHeartbeatSentinel = "NO_HEARTBEAT"
    27  )
    28  
    29  // EnvironmentWorkerOptions configures an [EnvironmentWorker].
    30  type EnvironmentWorkerOptions struct {
    31  	// EnvironmentID is the self-hosted environment to poll for work. Required
    32  	// by [EnvironmentWorker.Run]; unused by [EnvironmentWorker.HandleItem] (which
    33  	// takes the work item's environment via [HandleItemOptions]).
    34  	EnvironmentID string
    35  
    36  	// EnvironmentKey is the worker's standing credential: polling always
    37  	// uses it, and per-session calls fall back to it when a work item's
    38  	// secret doesn't yield a sessions token. Required by
    39  	// [EnvironmentWorker.Run]; for [EnvironmentWorker.HandleItem] see
    40  	// [HandleItemOptions].
    41  	EnvironmentKey string
    42  
    43  	// WorkerID is a stable identifier reported back to the server for
    44  	// observability. Defaults to "<os.Hostname()>-<random hex>" — forwarded
    45  	// to [WorkPoller].
    46  	WorkerID string
    47  
    48  	// Workdir is the base directory for the per-session
    49  	// [agenttoolset.AgentToolContext]. When empty it defaults to the process
    50  	// working directory captured when [NewEnvironmentWorker] is called.
    51  	Workdir string
    52  
    53  	// Deprecated: no longer supported and slated for removal. The file tools
    54  	// are always confined to Workdir plus the session's memory-store folders,
    55  	// so a true value is not reinterpreted: [EnvironmentWorker.Run] and
    56  	// [EnvironmentWorker.HandleItem] return
    57  	// [agenttoolset.ErrUnrestrictedPathsUnsupported] instead.
    58  	UnrestrictedPaths bool
    59  
    60  	// MaxFileBytes is forwarded to the per-session
    61  	// [agenttoolset.AgentToolContext], capping the size of files the read and
    62  	// edit tools load into memory. Zero uses the built-in 256 KiB default; a
    63  	// positive value sets a custom cap; a negative value disables the cap (use
    64  	// only when the sandbox can absorb arbitrarily large files).
    65  	MaxFileBytes int64
    66  
    67  	// Tools, if non-nil, is exposed to every claimed session as-is. Ignored
    68  	// when ToolsFunc is set. When both Tools and ToolsFunc are nil the worker
    69  	// uses agenttoolset.BetaAgentToolset20260401(env) — the standard
    70  	// agent_toolset_20260401 set bound to the per-session AgentToolContext. Tool
    71  	// lifetime is the caller's responsibility; the worker never closes tools it
    72  	// was given via Tools.
    73  	Tools []anthropic.BetaTool
    74  
    75  	// ToolsFunc, if non-nil, is invoked once per claimed session with that
    76  	// session's [agenttoolset.AgentToolContext] — use it to bind
    77  	// agenttoolset.BetaAgentToolset20260401 (or any tool that needs the workdir) to the
    78  	// right session. The worker calls [agenttoolset.CloseAll] on the result
    79  	// after the session finishes.
    80  	ToolsFunc func(env *agenttoolset.AgentToolContext) []anthropic.BetaTool
    81  
    82  	// MaxIdle is forwarded to the per-session
    83  	// [github.com/anthropics/anthropic-sdk-go.SessionToolRunner].
    84  	MaxIdle *time.Duration
    85  
    86  	// MemorySyncInterval is how often the session's attached memory stores
    87  	// are synced back while it runs — checked after each dispatched tool
    88  	// call, plus one final sync when the session ends cleanly. A session
    89  	// that ends on an error or cancel instead gets a push-only
    90  	// [SessionMemoryStores.FlushWrites], bounded by [MemoryFlushTimeout] like
    91  	// the final sync, with a warning logged when either bound cuts work off;
    92  	// it can still lose edits. Zero uses [DefaultMemorySyncInterval]; a
    93  	// positive value below [MinMemorySyncInterval] makes Run and HandleItem
    94  	// fail up front with [ErrMemorySyncIntervalTooShort]; a negative value
    95  	// disables memory download and sync entirely, silently.
    96  	// Memory stores are only touched for work items whose secret carries a
    97  	// sessions token, because the memory endpoints reject the environment
    98  	// key. Without a token, a work item whose session has stores attached
    99  	// fails — its memory cannot be mounted — unless the interval is
   100  	// negative, in which case the session quietly runs without memory:
   101  	// disabling sync is the operator's explicit choice.
   102  	//
   103  	// A store the worker cannot materialise on disk fails the work item too:
   104  	// see [SessionMemoryStores.Download].
   105  	MemorySyncInterval time.Duration
   106  
   107  	// MemorySyncDeletions gates whether local deletions may delete server
   108  	// memories; see [MemoryDeleteMode]. Uploads and pulls are unaffected.
   109  	// The zero value is [MemorySyncDeletionsEnabled].
   110  	MemorySyncDeletions MemoryDeleteMode
   111  
   112  	// RequestOptions are applied to every request the worker issues, on top
   113  	// of the environment-key auth and x-stainless-helper telemetry it adds
   114  	// itself: the [WorkPoller]'s Poll/Ack/Stop, the lease heartbeat and
   115  	// force-stop, the per-session skill download, and the SessionToolRunner's
   116  	// event stream/list/send. Use it for a proxy/custom header or a base-URL
   117  	// override that must reach the whole self-hosted runner. These options are
   118  	// applied first, so the worker's own environment-key auth and helper
   119  	// header take precedence and cannot be clobbered by a caller option.
   120  	RequestOptions []option.RequestOption
   121  
   122  	// Logger receives non-fatal warnings. Defaults to slog.Default().
   123  	Logger *slog.Logger
   124  }
   125  
   126  // EnvironmentWorker is the self-hosted environment runner, composed from the
   127  // control-plane [WorkPoller] and the per-session
   128  // [github.com/anthropics/anthropic-sdk-go.SessionToolRunner].
   129  //
   130  // For each claimed `session` work item it builds the per-session
   131  // [agenttoolset.AgentToolContext], downloads the session agent's skills
   132  // ([agenttoolset.AgentToolContext.SetupSkillsFromSession]), then runs a SessionToolRunner for the
   133  // session WHILE heartbeating the work-item lease in parallel; on exit it
   134  // force-stops the work item (unless the lease was lost, in which case the item
   135  // is left to whoever holds it now) and loops to the next one. The lease
   136  // heartbeat reports state "stopping"/"stopped" or a lost lease back into the
   137  // run by cancelling the session runner.
   138  //
   139  // [EnvironmentWorker.Run] drives the poll loop. [EnvironmentWorker.HandleItem]
   140  // exposes the per-item flow for callers that already hold a claimed work item
   141  // (for example a `worker poll --on-work` hook) and only need the
   142  // run/heartbeat/force-stop machinery.
   143  type EnvironmentWorker struct {
   144  	client anthropic.Client
   145  	opts   EnvironmentWorkerOptions
   146  
   147  	// memoryClock overrides the SessionMemoryStores time source; tests use
   148  	// it to drive the sync cadence deterministically. Nil means time.Now.
   149  	memoryClock func() time.Time
   150  }
   151  
   152  // NewEnvironmentWorker returns an [EnvironmentWorker] bound to client. Call
   153  // [EnvironmentWorker.Run] to start polling.
   154  func NewEnvironmentWorker(client anthropic.Client, opts EnvironmentWorkerOptions) *EnvironmentWorker {
   155  	if opts.Workdir == "" {
   156  		// Snapshot the cwd at construction so a later os.Chdir cannot move the
   157  		// per-session workdir out from under the worker. Mirrors the TS/Python
   158  		// helpers, which capture process.cwd()/os.getcwd() the same way. If the
   159  		// lookup fails, fall back to "." (resolved at use time).
   160  		if wd, err := os.Getwd(); err == nil {
   161  			opts.Workdir = wd
   162  		} else {
   163  			opts.Workdir = "."
   164  		}
   165  	}
   166  	return &EnvironmentWorker{client: client, opts: opts}
   167  }
   168  
   169  // checkOptions rejects option values that can never work, before any request
   170  // is sent: the deprecated UnrestrictedPaths, and a MemorySyncInterval below
   171  // the floor.
   172  func (w *EnvironmentWorker) checkOptions() error {
   173  	if w.opts.UnrestrictedPaths {
   174  		return agenttoolset.ErrUnrestrictedPathsUnsupported
   175  	}
   176  	return checkMemorySyncInterval(w.opts.MemorySyncInterval)
   177  }
   178  
   179  // Run polls the environment and services each claimed session until ctx is
   180  // cancelled. A cancelled ctx (deadline or otherwise) is normal termination and
   181  // yields a nil error; a non-retryable poll error is returned.
   182  //
   183  // EnvironmentID and EnvironmentKey are required to poll; Run returns an
   184  // error immediately if either is unset, and likewise if the deprecated
   185  // UnrestrictedPaths option is set or MemorySyncInterval is below
   186  // [MinMemorySyncInterval].
   187  func (w *EnvironmentWorker) Run(ctx context.Context) error {
   188  	if err := w.checkOptions(); err != nil {
   189  		return fmt.Errorf("EnvironmentWorker.Run: %w", err)
   190  	}
   191  	if w.opts.EnvironmentID == "" || w.opts.EnvironmentKey == "" {
   192  		return errors.New("EnvironmentWorker.Run: EnvironmentID and EnvironmentKey are required to poll for work")
   193  	}
   194  
   195  	log := w.opts.Logger
   196  	if log == nil {
   197  		log = slog.Default()
   198  	}
   199  
   200  	poller := NewWorkPoller(ctx, w.client, WorkPollerOptions{
   201  		EnvironmentID:  w.opts.EnvironmentID,
   202  		EnvironmentKey: w.opts.EnvironmentKey,
   203  		WorkerID:       w.opts.WorkerID,
   204  		RequestOptions: w.opts.RequestOptions,
   205  		AutoStop:       param.NewOpt(false),
   206  		Logger:         log,
   207  	})
   208  	defer poller.Close()
   209  
   210  	for poller.Next() {
   211  		work := poller.Current()
   212  		if work == nil {
   213  			continue
   214  		}
   215  		// handleItem logs its own per-item failures; the poll loop keeps going.
   216  		_ = w.handleItem(ctx, work, w.opts.EnvironmentKey)
   217  	}
   218  	if err := poller.Err(); err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
   219  		return err
   220  	}
   221  	return nil
   222  }
   223  
   224  // HandleItemOptions selects the already-claimed work item that
   225  // [EnvironmentWorker.HandleItem] services. Each empty field falls back to the
   226  // matching ANTHROPIC_* environment variable — the same variables the
   227  // `ant worker poll --on-work` hook exports into the child process:
   228  //
   229  //	WorkID          ← ANTHROPIC_WORK_ID
   230  //	EnvironmentID   ← ANTHROPIC_ENVIRONMENT_ID
   231  //	SessionID       ← ANTHROPIC_SESSION_ID
   232  //	EnvironmentKey  ← ANTHROPIC_ENVIRONMENT_KEY
   233  //
   234  // WorkID, EnvironmentID and SessionID are required (after the env-var
   235  // fallback). EnvironmentKey resolves in order: this field, then the
   236  // [EnvironmentWorker]'s own EnvironmentKey option, then
   237  // ANTHROPIC_ENVIRONMENT_KEY — and is also required.
   238  type HandleItemOptions struct {
   239  	// WorkID identifies the already-claimed work item; falls back to
   240  	// ANTHROPIC_WORK_ID when empty.
   241  	WorkID string
   242  	// EnvironmentID is the self-hosted environment the work item belongs to;
   243  	// falls back to ANTHROPIC_ENVIRONMENT_ID when empty.
   244  	EnvironmentID string
   245  	// SessionID is the managed-agents session the work item refers to; falls
   246  	// back to ANTHROPIC_SESSION_ID when empty.
   247  	SessionID string
   248  	// EnvironmentKey authorizes the per-session calls; falls back to the
   249  	// [EnvironmentWorker]'s own EnvironmentKey, then ANTHROPIC_ENVIRONMENT_KEY.
   250  	EnvironmentKey string
   251  	// WorkSecret is the work item's per-item secret payload from the poll
   252  	// response; falls back to ANTHROPIC_WORK_SECRET (the variable the
   253  	// `ant worker poll --on-work` command sets alongside the others). Unlike
   254  	// the fields above it is optional — when present, the sessions token
   255  	// extracted from it is preferred over EnvironmentKey as the Bearer
   256  	// credential for this item's heartbeat / force-stop / skill-download /
   257  	// session calls; when absent (or undecodable) those calls use
   258  	// EnvironmentKey.
   259  	WorkSecret string
   260  }
   261  
   262  // HandleItem services a single already-claimed session work item — the per-item
   263  // flow [EnvironmentWorker.Run] runs for each claimed item: it builds the
   264  // per-session [agenttoolset.AgentToolContext] (Workdir/MaxFileBytes from the
   265  // worker's options), downloads the session agent's skills
   266  // ([agenttoolset.AgentToolContext.SetupSkillsFromSession]), then runs a SessionToolRunner for the
   267  // session WHILE heartbeating the work-item lease in parallel; on exit — success
   268  // or error — it force-stops the work item. The one exception is a lost lease:
   269  // the item then belongs to the queue or another worker and is left alone. Use
   270  // it from a `worker poll --on-work` hook (or any caller that has already
   271  // claimed a work item itself).
   272  //
   273  // Each empty field of opts is read from the matching ANTHROPIC_* environment
   274  // variable (see [HandleItemOptions]); inside a `worker poll --on-work` child
   275  // process every value is already exported, so HandleItem(ctx, HandleItemOptions{})
   276  // just works. After the env-var fallback, WorkID/EnvironmentID/SessionID must
   277  // all be non-empty or HandleItem returns an error naming the missing one.
   278  // EnvironmentKey resolves in order — the opts field, the [EnvironmentWorker]'s
   279  // own EnvironmentKey option, then ANTHROPIC_ENVIRONMENT_KEY — and must also
   280  // resolve to a non-empty value. A worker built with the deprecated
   281  // UnrestrictedPaths option, or a MemorySyncInterval below
   282  // [MinMemorySyncInterval], returns an error before any of this.
   283  //
   284  // It returns the SessionToolRunner's terminal error unless that error is a
   285  // benign session termination or idle timeout, in which case it returns nil.
   286  // A failed session lookup fails the item (the force-stop still runs), and so
   287  // do memory stores that cannot be mounted: the work item carried no sessions
   288  // token for a session that has stores attached, or a store failed to
   289  // download (a [SessionMemoryError]). A negative MemorySyncInterval is the
   290  // exception — the item runs without its memory.
   291  func (w *EnvironmentWorker) HandleItem(ctx context.Context, opts HandleItemOptions) error {
   292  	if err := w.checkOptions(); err != nil {
   293  		return fmt.Errorf("EnvironmentWorker.HandleItem: %w", err)
   294  	}
   295  	workID := cmp.Or(opts.WorkID, os.Getenv("ANTHROPIC_WORK_ID"))
   296  	environmentID := cmp.Or(opts.EnvironmentID, os.Getenv("ANTHROPIC_ENVIRONMENT_ID"))
   297  	sessionID := cmp.Or(opts.SessionID, os.Getenv("ANTHROPIC_SESSION_ID"))
   298  	environmentKey := cmp.Or(opts.EnvironmentKey, w.opts.EnvironmentKey, os.Getenv("ANTHROPIC_ENVIRONMENT_KEY"))
   299  	// The per-item secret is optional: the field, then ANTHROPIC_WORK_SECRET,
   300  	// then empty (use the environment key).
   301  	workSecret := cmp.Or(opts.WorkSecret, os.Getenv("ANTHROPIC_WORK_SECRET"))
   302  
   303  	for _, req := range []struct{ name, val, env string }{
   304  		{"work_id", workID, "ANTHROPIC_WORK_ID"},
   305  		{"environment_id", environmentID, "ANTHROPIC_ENVIRONMENT_ID"},
   306  		{"session_id", sessionID, "ANTHROPIC_SESSION_ID"},
   307  		{"environment_key", environmentKey, "ANTHROPIC_ENVIRONMENT_KEY"},
   308  	} {
   309  		if req.val == "" {
   310  			return fmt.Errorf("EnvironmentWorker.HandleItem: %s is required — pass it in HandleItemOptions or set %s", req.name, req.env)
   311  		}
   312  	}
   313  
   314  	// The per-item code only reads work.ID / work.EnvironmentID / work.Secret /
   315  	// work.Data.Type / work.Data.ID, so a minimally populated item is enough.
   316  	work := &anthropic.BetaSelfHostedWork{
   317  		ID:            workID,
   318  		EnvironmentID: environmentID,
   319  		Secret:        workSecret,
   320  		Data: anthropic.BetaSessionWorkData{
   321  			ID:   sessionID,
   322  			Type: "session",
   323  		},
   324  	}
   325  	return w.handleItem(ctx, work, environmentKey)
   326  }
   327  
   328  // handleItem is the per-item flow shared by [EnvironmentWorker.Run]'s poll loop
   329  // and [EnvironmentWorker.HandleItem]: build the per-session [agenttoolset.AgentToolContext],
   330  // download the session agent's skills, run a SessionToolRunner WHILE
   331  // heartbeating the work-item lease in parallel, and force-stop the work item on
   332  // exit unless the lease was lost. A non-session work item is only force-stopped.
   333  //
   334  // The per-session calls — heartbeat, force-stop, skill download, and the
   335  // SessionToolRunner's stream/list/send — are authorized by the sessions token
   336  // extracted from work.Secret (the item's per-item secret payload) when one is
   337  // present, and by environmentKey otherwise; a payload that yields no token
   338  // logs a warning and falls back to environmentKey unchanged.
   339  func (w *EnvironmentWorker) handleItem(ctx context.Context, work *anthropic.BetaSelfHostedWork, environmentKey string) error {
   340  	log := w.opts.Logger
   341  	if log == nil {
   342  		log = slog.Default()
   343  	}
   344  
   345  	sessionID := work.Data.ID
   346  	log = log.With(slog.String("work_id", work.ID), slog.String("session_id", sessionID))
   347  
   348  	// The per-item credential: the sessions token carried inside the work
   349  	// item's secret payload when the server issued one, otherwise the
   350  	// environment key. Never log the payload or the token.
   351  	sessionsToken := sessionsTokenFromSecret(work.Secret)
   352  	if work.Secret != "" && sessionsToken == "" {
   353  		log.Warn("work item carried a secret payload but no sessions token could be extracted; falling back to the environment key")
   354  	}
   355  	itemCredential := cmp.Or(sessionsToken, environmentKey)
   356  
   357  	// The per-item credential authorizes the per-session calls: the lease
   358  	// heartbeat and the force-stop here, the skill download below, plus the
   359  	// SessionToolRunner's stream/list/send. The x-stainless-helper header
   360  	// attributes the heartbeat/force-stop/skill traffic to this helper.
   361  	// helperReqOpts also clears the parent client's default X-Api-Key so it
   362  	// doesn't ride alongside the bearer credential. Caller-supplied
   363  	// RequestOptions are applied first so a proxy/custom header reaches every
   364  	// per-session call while the worker's own X-Api-Key delete, per-item
   365  	// bearer auth and helper header (appended last) still win.
   366  	helperOpts, err := helperReqOpts(itemCredential, stainlessheader.EnvironmentsWorker)
   367  	if err != nil {
   368  		// Run and HandleItem validate environmentKey at their entry points
   369  		// (itemCredential can only be empty if it was), so an empty
   370  		// credential here means a future code path bypassed that
   371  		// validation; surface it rather than fire requests with the parent
   372  		// client's credentials.
   373  		return err
   374  	}
   375  	hbStopOpts := make([]option.RequestOption, 0, len(w.opts.RequestOptions)+len(helperOpts))
   376  	hbStopOpts = append(hbStopOpts, w.opts.RequestOptions...)
   377  	hbStopOpts = append(hbStopOpts, helperOpts...)
   378  
   379  	// The heartbeat records each server-reported lease TTL here; the runner
   380  	// picks it up from sessCtx as its tool-result send retry window.
   381  	var leaseTTL sendwindow.Window
   382  
   383  	// Per-session context: cancelled when the outer ctx is cancelled (it is a
   384  	// child), when the session runner finishes, or when the lease heartbeat
   385  	// says to stop. Constructed BEFORE skill setup so the heartbeat goroutine
   386  	// below can use it.
   387  	sessCtx, sessCancel := context.WithCancel(sendwindow.NewContext(ctx, &leaseTTL))
   388  	defer sessCancel()
   389  
   390  	// Start the lease heartbeat BEFORE skill setup. The poller already acked
   391  	// this work item when it yielded — every second between the ack and the
   392  	// first heartbeat is a window during which the control plane sees no
   393  	// liveness signal and may reclaim the lease. The session lookup and skill
   394  	// setup below can be slow (a per-skill download/extract can dwarf the
   395  	// lease TTL on a slow network or a large bundle), so starting the
   396  	// heartbeat afterwards was a race that let a second worker pick up the
   397  	// same session.
   398  	hbDone := make(chan struct{})
   399  	// Written by the heartbeat goroutine before hbDone closes; read only
   400  	// after receiving from hbDone.
   401  	var leaseEnd leaseEndReason
   402  	go func() {
   403  		defer close(hbDone)
   404  		leaseEnd = runHeartbeat(sessCtx, w.client, work, hbStopOpts, log, &leaseTTL)
   405  		sessCancel()
   406  	}()
   407  
   408  	// Deferred first so it runs last on every exit path: end the heartbeat,
   409  	// then force-stop the item on a fresh context (a cancelled ctx must not
   410  	// skip it; a 409 means it already stopped) unless the lease was lost.
   411  	defer func() {
   412  		sessCancel()
   413  		<-hbDone
   414  		if leaseEnd.lost() {
   415  			log.Info("lease lost; released without stopping it")
   416  			return
   417  		}
   418  
   419  		stopCtx, cancel := context.WithTimeout(context.Background(), stopTimeout)
   420  		defer cancel()
   421  		if err := stopWork(stopCtx, w.client, work.ID,
   422  			anthropic.BetaEnvironmentWorkStopParams{
   423  				EnvironmentID: work.EnvironmentID,
   424  				BetaSelfHostedWorkStopRequest: anthropic.BetaSelfHostedWorkStopRequestParam{
   425  					Force: param.NewOpt(true),
   426  				},
   427  			},
   428  			hbStopOpts...,
   429  		); err != nil && !isStatus(err, 409) {
   430  			log.Warn("force-stop on exit failed", slog.Any("error", err))
   431  		}
   432  	}()
   433  
   434  	// The queue also sends "healthcheck" items, which the generated type does not model.
   435  	if work.Data.Type != "session" {
   436  		log.Debug("skipping non-session work item", slog.String("type", string(work.Data.Type)))
   437  		return nil
   438  	}
   439  
   440  	env := &agenttoolset.AgentToolContext{
   441  		Workdir:      w.opts.Workdir,
   442  		MaxFileBytes: w.opts.MaxFileBytes,
   443  	}
   444  	// The session lookup, skill download and memory calls are
   445  	// environment-scoped, so they need the per-item credential like the
   446  	// heartbeat/stop and the runner do — without it they fall back to the
   447  	// client's default credentials and fail.
   448  	// Use sessCtx so a heartbeat-driven lease loss (the heartbeat goroutine
   449  	// cancels sessCtx on a permanent failure / stopping state / 412 reclaim)
   450  	// also aborts the downloads instead of letting them run to completion on
   451  	// a session we no longer own.
   452  	//
   453  	// One session fetch, shared by the skills download and the memory-store
   454  	// download — two fetches could disagree about the attached resources.
   455  	session, err := w.client.Beta.Sessions.Get(sessCtx, sessionID, anthropic.BetaSessionGetParams{}, hbStopOpts...)
   456  	if err != nil {
   457  		// A session that can't be fetched fails the whole item (the deferred
   458  		// force-stop still runs), as does a memory store that can't be
   459  		// materialised below. Per-skill failures stay tolerant: a missing
   460  		// skill degrades the session, a missing memory folder corrupts it.
   461  		err = fmt.Errorf("retrieve session %s: %w", sessionID, err)
   462  		log.Warn("session lookup failed", slog.Any("error", err))
   463  		return err
   464  	}
   465  	if err := env.SetupSkillsFromSession(sessCtx, w.client, session, hbStopOpts...); err != nil {
   466  		log.Warn("skill setup failed", slog.Any("error", err))
   467  	}
   468  	// Clean up the skills this work item downloaded so one session's skills
   469  	// don't leak into the next item served by the same worker.
   470  	defer func() {
   471  		if err := env.Cleanup(); err != nil {
   472  			log.Warn("skill cleanup failed", slog.Any("error", err))
   473  		}
   474  	}()
   475  
   476  	// Memory stores: the memory_stores endpoints accept the per-item
   477  	// sessions token but reject the environment key, so download and sync
   478  	// only run when the item carried a usable secret (and the interval isn't
   479  	// negative). The memory calls ride the same token-scoped request options
   480  	// as the heartbeat and skill download.
   481  	var stores *SessionMemoryStores
   482  	var cleanEnd bool
   483  	if sessionsToken != "" && w.opts.MemorySyncInterval >= 0 {
   484  		var err error
   485  		stores, err = NewSessionMemoryStores(w.client, SessionMemoryStoresOptions{
   486  			Workdir:        w.opts.Workdir,
   487  			SyncInterval:   w.opts.MemorySyncInterval,
   488  			SyncDeletions:  w.opts.MemorySyncDeletions,
   489  			RequestOptions: hbStopOpts,
   490  			Logger:         log,
   491  		})
   492  		if err != nil {
   493  			// Unreachable after checkOptions; kept so a bad interval can never
   494  			// run a session without its memory.
   495  			return err
   496  		}
   497  		if w.memoryClock != nil {
   498  			stores.setClock(w.memoryClock)
   499  		}
   500  		// Registered before the download, so a store that landed before a
   501  		// later one failed still has its directory removed. Runs after the
   502  		// agenttoolset.CloseAll defer — bash killed, nothing still writing.
   503  		defer func() { stores.Cleanup(cleanEnd) }()
   504  		if err := stores.Download(sessCtx, session); err != nil {
   505  			// A store that cannot be materialised fails the whole item (the
   506  			// deferred force-stop still runs): a session whose system prompt
   507  			// names a memory folder that isn't there would run with amnesia
   508  			// and sync nothing back, which is worse than not running at all.
   509  			log.Error("memory store download failed", slog.Any("error", err))
   510  			return err
   511  		}
   512  		// A store mounted outside the workdir must stay reachable by the file
   513  		// tools; read-only stores still refuse writes via ReadOnlyRoots.
   514  		env.AllowedRoots = stores.Roots()
   515  		env.ReadOnlyRoots = stores.ReadOnlyRoots()
   516  	} else {
   517  		// The gate above cannot tell a session that simply has no memory from
   518  		// one whose memory we cannot mount — only the fetched session can. A
   519  		// negative interval is a deliberate opt-out and stays quiet; a missing
   520  		// sessions token on a session that *does* have stores fails the whole
   521  		// item (the deferred force-stop still runs) — a hosted sandbox refuses
   522  		// to start in this state, and running here without memories would
   523  		// silently diverge from it.
   524  		if w.opts.MemorySyncInterval >= 0 && hasMemoryStore(session) {
   525  			err := fmt.Errorf("session %s: %w", sessionID, ErrSessionMemoryNoToken)
   526  			log.Error("memory stores cannot be mounted", slog.Any("error", err))
   527  			return err
   528  		}
   529  		log.Debug("memory stores disabled for this item")
   530  	}
   531  
   532  	var (
   533  		tools      []anthropic.BetaTool
   534  		closeTools bool
   535  	)
   536  	switch {
   537  	case w.opts.ToolsFunc != nil:
   538  		tools = w.opts.ToolsFunc(env)
   539  		closeTools = true
   540  	case w.opts.Tools != nil:
   541  		tools = w.opts.Tools
   542  	default:
   543  		tools = agenttoolset.BetaAgentToolset20260401(env)
   544  		closeTools = true
   545  	}
   546  	if closeTools {
   547  		defer agenttoolset.CloseAll(tools)
   548  	}
   549  
   550  	// Authorize the runner's stream/list/send calls with the per-item
   551  	// credential (via bearerReqOpts — the runner stamps its own x-stainless-helper
   552  	// header "session-tool-runner" internally *after* these options, so we
   553  	// just need the auth bits here; any helper tag we passed in would be
   554  	// overwritten). bearerReqOpts also clears the parent client's default
   555  	// X-Api-Key so it doesn't ride alongside the bearer credential.
   556  	// Caller-supplied RequestOptions go first so a proxy/custom header
   557  	// reaches stream/list/send too; the X-Api-Key delete + per-item bearer
   558  	// auth (appended last) still win.
   559  	runnerBearerOpts, err := bearerReqOpts(itemCredential)
   560  	if err != nil {
   561  		// Same validation invariant as the hbStopOpts construction above —
   562  		// surface rather than send with the parent client's credentials.
   563  		return err
   564  	}
   565  	runnerReqOpts := make([]option.RequestOption, 0, len(w.opts.RequestOptions)+len(runnerBearerOpts))
   566  	runnerReqOpts = append(runnerReqOpts, w.opts.RequestOptions...)
   567  	runnerReqOpts = append(runnerReqOpts, runnerBearerOpts...)
   568  	runner := w.client.Beta.Sessions.Events.NewToolRunner(sessCtx, sessionID, anthropic.SessionToolRunnerOptions{
   569  		Tools:          tools,
   570  		MaxIdle:        w.opts.MaxIdle,
   571  		Logger:         log,
   572  		RequestOptions: runnerReqOpts,
   573  	})
   574  	for runner.Next() {
   575  		call := runner.Current()
   576  		log.Info("dispatched tool",
   577  			slog.String("tool", call.Name),
   578  			slog.Bool("is_error", call.IsError),
   579  			slog.Bool("posted", call.Posted))
   580  		if stores != nil {
   581  			stores.SyncIfDue(sessCtx)
   582  		}
   583  	}
   584  	var runErr error
   585  	if err := runner.Err(); err != nil &&
   586  		!errors.Is(err, anthropic.ErrSessionTerminated) &&
   587  		!errors.Is(err, anthropic.ErrIdleTimeout) {
   588  		log.Warn("session tool runner exited with error", slog.Any("error", err))
   589  		runErr = err
   590  	}
   591  	cleanEnd = runErr == nil && sessCtx.Err() == nil
   592  	// Close is documented to always return nil (it satisfies io.Closer so
   593  	// callers can defer it uniformly), but don't bank on that: a future
   594  	// non-nil error is surfaced rather than dropped.
   595  	if err := runner.Close(); err != nil {
   596  		log.Warn("session tool runner close failed", slog.Any("error", err))
   597  		if runErr == nil {
   598  			runErr = err
   599  		}
   600  	}
   601  
   602  	return runErr
   603  }
   604  
   605  // leaseEndReason is why heartbeating of a work item ended. The zero value,
   606  // leaseHeld, means it has not.
   607  type leaseEndReason int
   608  
   609  const (
   610  	leaseHeld leaseEndReason = iota
   611  	// ctx ended first: the run finished or was cancelled with the lease held.
   612  	leaseRunnerDone
   613  	// State stopping/stopped, or lease_extended false.
   614  	leaseControlPlaneStop
   615  	// A heartbeat met 412: the item was re-queued or another worker holds it.
   616  	leaseLost
   617  	// A heartbeat met another 4xx that retrying will not fix.
   618  	leaseHeartbeatRejected
   619  	// No heartbeat succeeded for a full lease TTL.
   620  	leaseAssumedLost
   621  )
   622  
   623  // lost reports whether the item now belongs to the queue or another worker,
   624  // so this one must not stop it.
   625  func (r leaseEndReason) lost() bool {
   626  	return r == leaseLost || r == leaseAssumedLost
   627  }
   628  
   629  // runHeartbeat keeps the work-item lease alive while a session is being served
   630  // and returns why heartbeating ended: ctx ended first (the run finished or was
   631  // cancelled), the control plane reported the work stopping/stopped or no longer
   632  // extended the lease, a heartbeat was rejected (a 412 means the lease already
   633  // belongs to someone else), or transient failures ran long enough that the
   634  // lease must be assumed lost. The caller cancels the session once it returns,
   635  // so two runners don't end up serving the same work.
   636  //
   637  // Each Heartbeat request is sent with a per-request timeout derived from the
   638  // last server-reported TTL (or heartbeatDefault for the first beat); a hung
   639  // request can't outlive the lease window. A run of transient failures is
   640  // bounded by a staleness ceiling: if more than the last known TTL has elapsed
   641  // since the most recent successful beat, the lease is presumed expired
   642  // server-side and the session is cancelled rather than executing tools
   643  // against a session another worker may also have claimed. leaseTTL is updated
   644  // with the server-reported TTL after every successful beat.
   645  func runHeartbeat(ctx context.Context, client anthropic.Client, work *anthropic.BetaSelfHostedWork, reqOpts []option.RequestOption, log *slog.Logger, leaseTTL *sendwindow.Window) leaseEndReason {
   646  	interval := heartbeatDefault
   647  	// ttl tracks the last server-reported TTL. It bounds the staleness
   648  	// ceiling: a run of transient errors lasting longer than this means the
   649  	// server has almost certainly let the lease expire, so we must stop
   650  	// heartbeating and cancel the session before posting more tool results
   651  	// against a reclaimed lease. Initialized to heartbeatDefault so a
   652  	// permanently-failing first beat is bounded too.
   653  	ttl := heartbeatDefault
   654  	last := noHeartbeatSentinel
   655  	// lastSuccess seeds the staleness clock from goroutine start so the
   656  	// first-beat-never-succeeds case is bounded by ttl rather than retrying
   657  	// forever.
   658  	lastSuccess := time.Now()
   659  
   660  	// beat sends one heartbeat and returns leaseHeld to keep beating, or why
   661  	// the lease ended.
   662  	beat := func() leaseEndReason {
   663  		// Per-request timeout: cap at the current wait-between-beats interval
   664  		// (which tracks ttl/2). A single Heartbeat call must never outlive
   665  		// the lease window — without this, the call inherits the SDK default
   666  		// (~10 minutes) and one hung request can let the lease expire while
   667  		// we sit on the connection. The full slice expression caps capacity
   668  		// so append cannot mutate the caller's reqOpts backing array.
   669  		beatOpts := append(reqOpts[:len(reqOpts):len(reqOpts)], option.WithRequestTimeout(interval))
   670  		resp, err := client.Beta.Environments.Work.Heartbeat(
   671  			ctx,
   672  			work.ID,
   673  			anthropic.BetaEnvironmentWorkHeartbeatParams{
   674  				EnvironmentID:         work.EnvironmentID,
   675  				ExpectedLastHeartbeat: param.NewOpt(last),
   676  			},
   677  			beatOpts...,
   678  		)
   679  		if err != nil {
   680  			// A 412 means the server re-queued the item or another worker holds
   681  			// the lease, so this worker must neither keep serving it nor stop
   682  			// it; checked before ctx so a 412 racing the run's finish counts.
   683  			if isStatus(err, 412) {
   684  				server := serverLeaseState(err)
   685  				log.Error("lease lost: heartbeat precondition failed",
   686  					slog.Any("server_state", server["state"]),
   687  					slog.Any("server_ttl_seconds", server["ttl_seconds"]),
   688  					slog.Any("server_last_heartbeat", server["last_heartbeat"]))
   689  				return leaseLost
   690  			}
   691  			if ctx.Err() != nil {
   692  				return leaseRunnerDone
   693  			}
   694  			if isFatal4xx(err) {
   695  				log.Error("permanent heartbeat failure", slog.Any("error", err))
   696  				return leaseHeartbeatRejected
   697  			}
   698  			// Bound the transient-retry window. The control plane lets the
   699  			// lease lapse silently after ttl elapsed without a successful
   700  			// heartbeat — without this check we'd keep executing tools
   701  			// against a reclaimed session.
   702  			if stale := time.Since(lastSuccess); stale > ttl {
   703  				log.Error("heartbeat staleness ceiling exceeded; lease presumed expired",
   704  					slog.Duration("since_last_success", stale),
   705  					slog.Duration("ttl", ttl),
   706  					slog.Any("error", err))
   707  				return leaseAssumedLost
   708  			}
   709  			log.Warn("transient heartbeat failure", slog.Any("error", err))
   710  			return leaseHeld
   711  		}
   712  		last = resp.LastHeartbeat
   713  		lastSuccess = time.Now()
   714  		if resp.TTLSeconds > 0 {
   715  			ttl = max(time.Duration(resp.TTLSeconds)*time.Second, heartbeatFloor)
   716  			interval = clampDur(ttl/2, heartbeatFloor, heartbeatDefault)
   717  			leaseTTL.Set(ttl)
   718  		}
   719  		switch resp.State {
   720  		case anthropic.BetaSelfHostedWorkHeartbeatResponseStateStopping,
   721  			anthropic.BetaSelfHostedWorkHeartbeatResponseStateStopped:
   722  			log.Info("heartbeat reports shutdown", slog.String("state", string(resp.State)))
   723  			return leaseControlPlaneStop
   724  		}
   725  		if !resp.LeaseExtended {
   726  			log.Warn("lease not extended; shutting down")
   727  			return leaseControlPlaneStop
   728  		}
   729  		return leaseHeld
   730  	}
   731  
   732  	if end := beat(); end != leaseHeld {
   733  		return end
   734  	}
   735  	for {
   736  		t := time.NewTimer(interval)
   737  		select {
   738  		case <-ctx.Done():
   739  			t.Stop()
   740  			return leaseRunnerDone
   741  		case <-t.C:
   742  		}
   743  		if end := beat(); end != leaseHeld {
   744  			return end
   745  		}
   746  	}
   747  }
   748  
   749  func clampDur(v, lo, hi time.Duration) time.Duration {
   750  	return max(lo, min(hi, v))
   751  }
   752  
   753  // serverLeaseState is the server's view of the lease carried in a rejected
   754  // heartbeat's response body, or nil when the body carries none.
   755  func serverLeaseState(err error) map[string]any {
   756  	var apiErr *anthropic.Error
   757  	if !errors.As(err, &apiErr) {
   758  		return nil
   759  	}
   760  	var body struct {
   761  		Error struct {
   762  			Details struct {
   763  				CurrentState map[string]any `json:"current_state"`
   764  			} `json:"details"`
   765  		} `json:"error"`
   766  	}
   767  	_ = json.Unmarshal([]byte(apiErr.RawJSON()), &body)
   768  	return body.Error.Details.CurrentState
   769  }
   770  
   771  // hasMemoryStore reports whether the session has at least one memory store
   772  // attached.
   773  func hasMemoryStore(session *anthropic.BetaManagedAgentsSession) bool {
   774  	for _, res := range session.Resources {
   775  		if res.Type == "memory_store" {
   776  			return true
   777  		}
   778  	}
   779  	return false
   780  }
   781  
   782  // sessionsTokenFromSecret extracts the per-item sessions token from a work
   783  // item's secret payload.
   784  //
   785  // The secret the poll response populates is not itself a credential: it is a
   786  // URL-safe base64 JSON payload bundling the per-item material — the
   787  // sessions_token (the bearer for this item's work lifecycle and session-level
   788  // calls) plus ingress / source tokens this worker does not consume. Returns
   789  // the sessions token, or "" (meaning: fall back to the environment key) when
   790  // the payload is missing, doesn't decode, or carries no token. Never log the
   791  // payload or anything extracted from it.
   792  func sessionsTokenFromSecret(secret string) string {
   793  	if secret == "" {
   794  		return ""
   795  	}
   796  	// The payload may arrive with or without base64 padding; RawURLEncoding
   797  	// after stripping any padding accepts both forms.
   798  	raw, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(secret, "="))
   799  	if err != nil {
   800  		return ""
   801  	}
   802  	var parsed struct {
   803  		SessionsToken string `json:"sessions_token"`
   804  	}
   805  	// A payload that is not a JSON object (or has no usable token) resolves
   806  	// to "" — the caller falls back to the environment key.
   807  	if err := json.Unmarshal(raw, &parsed); err != nil {
   808  		return ""
   809  	}
   810  	return parsed.SessionsToken
   811  }