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 }