UNPKG

@anthropic-ai/sdk

Version:
794 lines (758 loc) • 36.3 kB
import { AnthropicError, APIError } from '../../core/error'; import type { Anthropic } from '../../client'; import type { BetaSelfHostedWork, BetaWorkSecret } from '../../resources/beta/environments/work'; import { loggerFor, type Logger } from '../../internal/utils/log'; import { fromBase64 } from '../../internal/utils/base64'; import { decodeUTF8 } from '../../internal/utils/bytes'; import { readEnv } from '../../internal/utils/env'; import { sleep } from '../../internal/utils/sleep'; import { isFatal4xx, isStatus } from '../../internal/utils/backoff'; import { linkAbort } from '../../internal/utils/abort'; import { isObj } from '../../internal/utils/values'; import { buildHeaders } from '../../internal/headers'; import type { BetaRunnableTool } from '../tools/BetaRunnableTool'; import type { BetaToolRunnerRequestOptions } from '../tools/BetaToolRunner'; import { SessionToolRunner } from '../tools/SessionToolRunner'; import { WorkPoller } from './poller'; import { copyClientForHelper } from '../helper-client'; // `tools/agent-toolset/node` is Node-only (node:child_process, node:fs, …). // Only the type is imported statically (erased at build); the module's values // (`setupSkills`, `betaAgentToolset20260401`) are loaded lazily inside the // per-item handler. That keeps this file free of Node-only deps in the static // import graph, which is what lets `client.beta.environments.work.worker()` // exist as a resource method without pulling Node built-ins into the SDK core. import type { AgentToolContext, MemoryDeleteMode, SessionMemoryStores } from '../../tools/agent-toolset/node'; import { checkMemorySyncInterval } from '../../tools/agent-toolset/sync-interval'; import type { BetaManagedAgentsSession } from '../../resources/beta/sessions/sessions'; const HEARTBEAT_DEFAULT_MS = 30_000; const HEARTBEAT_TTL_DEFAULT_MS = 90_000; const NO_HEARTBEAT_SENTINEL = 'NO_HEARTBEAT'; /** * Either a fixed tool array or a factory invoked once per claimed session with * that session's {@link AgentToolContext} — use the factory form to bind * `betaAgentToolset20260401` (or any tool that needs the workdir / session * id) to the right session. */ export type EnvironmentWorkerTools = | Array<BetaRunnableTool> | ((ctx: AgentToolContext) => Array<BetaRunnableTool>); export interface EnvironmentWorkerOptions { client: Anthropic; /** * The self-hosted environment to poll for work. Required by * {@link EnvironmentWorker.run}; not used by {@link EnvironmentWorker.handleItem}. */ environmentId?: string; /** * The environment key — the worker's standing credential: polling always * uses it, and per-session calls fall back to it when a claimed item's * `secret` doesn't yield a sessions token. Required by * {@link EnvironmentWorker.run}; {@link EnvironmentWorker.handleItem} falls * back to `ANTHROPIC_ENVIRONMENT_KEY` and needs a key only when the work * item's `secret` yields no sessions token. */ environmentKey?: string; /** * Tools to expose to each claimed session. Defaults to * `betaAgentToolset20260401(ctx)` (the standard `agent_toolset_20260401` set * bound to the per-session {@link AgentToolContext}). * * A `run` that blocks the event loop synchronously also stalls the lease * heartbeat, and the worker can lose the lease; keep tools non-blocking (see * {@link BetaRunnableTool.run}). */ tools?: EnvironmentWorkerTools; /** Base directory for the per-session {@link AgentToolContext}. Defaults to `process.cwd()`. */ workdir?: string; /** * @deprecated No longer accepted: the file tools are always confined to * `workdir` plus `allowedRoots`, which is neither behavior this flag used to * select, so passing either value throws. Remove it; list extra directories * in {@link AgentToolContext.allowedRoots}. The property is removed in a * future release. */ unrestrictedPaths?: boolean; /** Forwarded to the per-session {@link AgentToolContext} (`maxFileBytes`). */ maxFileBytes?: number | null; /** Forwarded to {@link SessionToolRunner} (`maxIdleMs`). */ maxIdleMs?: number; /** * How often (milliseconds) to sync the session's attached memory stores back * while it runs — checked after each dispatched tool call, plus one final * sync when the session ends cleanly. Defaults to * `DEFAULT_MEMORY_SYNC_INTERVAL_MS` (15s); the constructor throws for * values below `MIN_MEMORY_SYNC_INTERVAL_MS` (5s). Every teardown also runs * a push-only flush of changed files; that flush and the final sync are * each bounded by `MEMORY_FLUSH_TIMEOUT_MS`, and a warning is logged when * either bound cuts work off. `null` disables memory download and sync * entirely. Memory stores are only touched for work items whose `secret` * carries a `sessions_token`; while sync is enabled, an item without one * fails when its session has memory stores attached, because those stores * cannot be mounted without the token. With `null` the same item runs, * without memory, and nothing is logged — turning sync off is the * operator's explicit choice. */ memorySyncIntervalMs?: number | null; /** * Whether local file deletions may delete on the server — see * {@link MemoryDeleteMode}. Uploads and pulls are unaffected. * Defaults to `"enabled"`. */ memorySyncDeletions?: MemoryDeleteMode; /** Forwarded to the {@link WorkPoller}. */ workerId?: string; /** External abort signal; aborting it ends the run. */ signal?: AbortSignal; /** * Extra per-request options merged into every call this worker issues — the * work poll/ack/heartbeat/stop control-plane calls and the per-session * SessionToolRunner's stream/list/send. Mirrors what * `client.beta.messages.toolRunner` accepts: custom `headers` (e.g. a proxy's * auth/routing headers) reach all of them. The worker owns the abort signals, * so a `signal` here is ignored — use {@link EnvironmentWorkerOptions.signal}. */ requestOptions?: BetaToolRunnerRequestOptions; } /** * Options for {@link EnvironmentWorker.handleItem}. Every field falls back to the * matching `ANTHROPIC_*` environment variable — the ones the * `ant worker poll --on-work` command sets for the process it spawns — when not * passed explicitly. */ export interface HandleItemOptions { /** Work item id. Falls back to `ANTHROPIC_WORK_ID`. */ workId?: string; /** Self-hosted environment id. Falls back to `ANTHROPIC_ENVIRONMENT_ID`. */ environmentId?: string; /** Session id. Falls back to `ANTHROPIC_SESSION_ID`. */ sessionId?: string; /** * The environment key. Resolution order: this option, then the worker's own * `environmentKey`, then `ANTHROPIC_ENVIRONMENT_KEY`. Needed only when * `workSecret` is absent or its payload yields no sessions token — with a * token-bearing secret the item runs on that token alone. */ environmentKey?: string; /** * The work item's per-item `secret` payload from the poll response. Falls * back to `ANTHROPIC_WORK_SECRET`. Unlike the others it is optional — when * present, the sessions token extracted from it is preferred as the Bearer * credential for this item's heartbeat / force-stop / skill-download / * session calls; when absent (or undecodable) those calls use the * environment key. */ workSecret?: string; /** External abort signal; aborting it ends the run. Defaults to the constructor's signal. */ signal?: AbortSignal; } /** * The fields of {@link BetaSelfHostedWork} the per-item flow reads. `secret` is * declared here rather than picked because the generated model does not carry * it — only the poll response populates it. */ type ClaimedWork = Pick<BetaSelfHostedWork, 'id' | 'environment_id' | 'data'> & { secret?: string | null; }; /** True when the session has at least one memory store attached. */ function hasMemoryStore(session: BetaManagedAgentsSession): boolean { return session.resources.some((r) => r.type === 'memory_store'); } /** * Extract the per-item sessions token from a work item's `secret` payload. * * The `secret` the poll response populates is not itself a credential: it is a * URL-safe base64 JSON payload matching {@link BetaWorkSecret} — the * `sessions_token` (the bearer for this item's work lifecycle and * session-level calls) plus fields this worker does not consume. Returns the * sessions token, or `null` (meaning: fall back to the environment key) when * the payload is missing, doesn't decode, or carries no token. Never log the * payload or anything extracted from it. */ export function sessionsTokenFromSecret(secret: string | null | undefined): string | null { if (!secret) return null; let parsed: unknown; try { // The payload may arrive URL-safe and without base64 padding; normalize // both before decoding. const normalized = secret.replace(/-/g, '+').replace(/_/g, '/'); const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '='); parsed = JSON.parse(decodeUTF8(fromBase64(padded))); } catch { return null; } if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null; // The payload is untrusted input, so the token is still checked at runtime // rather than trusted to match the schema. const token = (parsed as Partial<BetaWorkSecret>).sessions_token; return typeof token === 'string' && token !== '' ? token : null; } /** * The self-hosted environment runner, composed from the control-plane * {@link WorkPoller} and the per-session {@link SessionToolRunner}. * * For each claimed `session` work item it: builds the per-session * {@link AgentToolContext}, downloads the session agent's skills * (`setupSkills`), then runs a {@link SessionToolRunner} for the session * while heartbeating the work-item lease on the same event loop; on exit it * force-stops the work item (unless the lease was lost, in which case the item * is left to whoever holds it now), cleans up the downloaded skills, and loops * to the next one. The lease heartbeat reports `state === "stopping"` / a lost * lease back into the run by aborting the session runner. * * The `environmentKey` is the worker's standing credential. When a claimed * work item carries a per-item `secret` (a short-lived payload the poll * response may populate), the sessions token extracted from it is preferred * over the environment key for that item's heartbeat / force-stop / * skill-download / session calls; polling itself always uses the environment * key, and items without a usable secret fall back to it entirely. * * Use {@link EnvironmentWorker.handleItem} if you already hold a claimed work * item (e.g. a `worker poll --on-work` script handed one to a fresh process) and * just want the per-item flow without the poll loop — with no arguments it reads * the `ANTHROPIC_*` env vars that command sets. * * Construct it via `client.beta.environments.work.worker({ ... })` (or * `new EnvironmentWorker({ client, ... })` directly). * * @example * ```ts * // Long-running daemon: poll for work, serve each session, loop. * await client.beta.environments.work * .worker({ environmentId, environmentKey, workdir: '/workspace' }) * .run(AbortSignal.timeout(60 * 60_000)); * * // Already-claimed item (e.g. inside `ant worker poll --on-work ...`): * await client.beta.environments.work.worker({ workdir: '/workspace' }).handleItem(); * ``` */ export class EnvironmentWorker { readonly client: Anthropic; readonly environmentId: string | undefined; readonly environmentKey: string | undefined; readonly tools: EnvironmentWorkerTools | undefined; readonly workdir: string; /** @deprecated Never set; see {@link EnvironmentWorkerOptions.unrestrictedPaths}. */ readonly unrestrictedPaths: boolean | undefined; readonly maxFileBytes: number | null | undefined; readonly maxIdleMs: number | undefined; readonly memorySyncIntervalMs: number | null | undefined; readonly memorySyncDeletions: MemoryDeleteMode; readonly workerId: string | undefined; readonly requestOptions: BetaToolRunnerRequestOptions | undefined; readonly #signal: AbortSignal | undefined; constructor(opts: Omit<EnvironmentWorkerOptions, 'unrestrictedPaths'>); /** @deprecated `unrestrictedPaths` is no longer accepted — see {@link EnvironmentWorkerOptions.unrestrictedPaths}. */ constructor(opts: Omit<EnvironmentWorkerOptions, 'unrestrictedPaths'> & { unrestrictedPaths: boolean }); // help language servers see deprecation constructor(opts: EnvironmentWorkerOptions) { if (opts.unrestrictedPaths !== undefined) { throw new AnthropicError( 'The `unrestrictedPaths` option you passed to EnvironmentWorker (or ' + 'client.beta.environments.work.worker()) is no longer supported. ' + "The worker's file tools (read, write, edit, glob, grep) are now always confined to `workdir` " + "plus the session's memory folders. Remove `unrestrictedPaths` from your options; to let the " + 'file tools reach any other directory, add it to `AgentToolContext.allowedRoots` from a ' + '`tools` factory.', ); } this.client = opts.client; this.environmentId = opts.environmentId; this.environmentKey = opts.environmentKey; this.tools = opts.tools; this.workdir = opts.workdir ?? process.cwd(); this.maxFileBytes = opts.maxFileBytes; this.maxIdleMs = opts.maxIdleMs; if (opts.memorySyncIntervalMs != null) { checkMemorySyncInterval(opts.memorySyncIntervalMs, 'memorySyncIntervalMs'); } this.memorySyncIntervalMs = opts.memorySyncIntervalMs; this.memorySyncDeletions = opts.memorySyncDeletions ?? 'enabled'; this.workerId = opts.workerId; this.requestOptions = opts.requestOptions; this.#signal = opts.signal; } /** * Poll the environment and service each claimed session until the supplied * signal (or the one passed to the constructor) aborts. Throws if * `environmentId` / `environmentKey` were not provided to the constructor. */ async run(signal?: AbortSignal): Promise<void> { const { environmentId, environmentKey } = this; if (environmentId === undefined || environmentKey === undefined) { throw new AnthropicError( 'EnvironmentWorker.run: environmentId and environmentKey are required to poll for work', ); } const externalSignal = signal ?? this.#signal; const poller = new WorkPoller({ client: this.client, environmentId, environmentKey, ...(this.workerId !== undefined ? { workerId: this.workerId } : {}), ...(externalSignal ? { signal: externalSignal } : {}), ...(this.requestOptions !== undefined ? { requestOptions: this.requestOptions } : {}), // The per-item handler stops or releases every work item on exit; let it // be the single owner of `work.stop` rather than double-posting from the // poller. autoStop: false, }); for await (const work of poller) { try { await this.#handleItem(work, environmentKey, poller.signal); } catch (e) { // One bad item fails that item, not the worker: the handler's teardown // already stopped or released it, so the next poll claims the next // item. A store directory left behind by a killed worker would // otherwise crashloop this process forever. if (poller.signal?.aborted) throw e; loggerFor(this.client).error('work item failed', { work_id: work.id, error: String(e) }); } } } /** * Service a single, already-claimed work item without the poll loop: build the * per-session {@link AgentToolContext} (workdir from this worker's options), * download the session agent's skills (`setupSkills`), run a * {@link SessionToolRunner} for the session while heartbeating the work-item * lease, and force-stop the work item on exit (whether the runner finishes * normally, throws, or the control plane signals shutdown). The one * exception is a lost lease: the item then belongs to the queue or another * worker and is left alone. * * Use this when something else does the claiming — e.g. a `worker poll * --on-work` script that hands an already-claimed item to a fresh process. The * work id / environment id / session id each fall back to `ANTHROPIC_WORK_ID` / * `ANTHROPIC_ENVIRONMENT_ID` / `ANTHROPIC_SESSION_ID` (the env vars that * command sets) when not passed; the environment key resolves from this * option, then the worker's own `environmentKey`, then * `ANTHROPIC_ENVIRONMENT_KEY`, and is needed only when the work item's * `secret` yields no sessions token — a host that receives only the * per-item secret runs without ever holding the key. With no arguments * inside that command it just works. Throws a clear error naming the first * required value still missing after resolution, and — rather than ever * running unauthenticated — when neither a sessions token nor an * environment key resolved. Throws `SessionMemoryError` when the * session has memory stores attached but they cannot be mounted — the work * item carried no sessions token (unless `memorySyncIntervalMs` turned * memory off), or a store failed to download. * * `workSecret` is the work item's per-item `secret` payload from the poll * response, falling back to `ANTHROPIC_WORK_SECRET`; unlike the others it is * optional — when present, the sessions token extracted from it is preferred * as the Bearer credential for this item's heartbeat / force-stop / session * calls; when absent (or undecodable) those calls use the environment key. */ async handleItem(opts?: HandleItemOptions): Promise<void> { const workId = opts?.workId ?? readEnv('ANTHROPIC_WORK_ID'); const environmentId = opts?.environmentId ?? readEnv('ANTHROPIC_ENVIRONMENT_ID'); const sessionId = opts?.sessionId ?? readEnv('ANTHROPIC_SESSION_ID'); // Trailing `|| undefined` / `||` between fallbacks so an empty value reads // as absent (matching how `readEnv` treats empty values). const environmentKey = (opts?.environmentKey ?? this.environmentKey ?? readEnv('ANTHROPIC_ENVIRONMENT_KEY')) || undefined; const workSecret = opts?.workSecret || readEnv('ANTHROPIC_WORK_SECRET') || null; if (!workId) { throw new AnthropicError('handleItem: workId is required — pass it or set ANTHROPIC_WORK_ID'); } if (!environmentId) { throw new AnthropicError( 'handleItem: environmentId is required — pass it or set ANTHROPIC_ENVIRONMENT_ID', ); } if (!sessionId) { throw new AnthropicError('handleItem: sessionId is required — pass it or set ANTHROPIC_SESSION_ID'); } if (!environmentKey && !workSecret) { throw new AnthropicError( 'handleItem: environmentKey is required when there is no work secret — pass it, construct the worker with it, or set ANTHROPIC_ENVIRONMENT_KEY', ); } const work: ClaimedWork = { id: workId, environment_id: environmentId, secret: workSecret, data: { type: 'session', id: sessionId }, }; await this.#handleItem(work, environmentKey, opts?.signal ?? this.#signal); } /** * The per-item body shared by {@link EnvironmentWorker.run}'s poll loop and * {@link EnvironmentWorker.handleItem}: run a {@link SessionToolRunner} for the * work item's session while heartbeating its lease, force-stopping on exit * unless the lease was lost. Non-session work items are ignored. * * When the poll response carried a per-item `secret` (a short-lived payload * scoped to this work item), the sessions token extracted from it is * preferred over `environmentKey` as the Bearer credential for those * per-item calls; a missing/undecodable secret falls back to * `environmentKey` unchanged. */ async #handleItem( work: ClaimedWork, environmentKey: string | undefined, externalSignal: AbortSignal | undefined, ): Promise<void> { const log = loggerFor(this.client); // The per-item credential: the sessions token carried inside the work // item's secret payload when the server issued one, otherwise the // environment key. Never log this value. const sessionsToken = sessionsTokenFromSecret(work.secret); const itemCredential = sessionsToken ?? environmentKey; if (itemCredential === undefined) { throw new AnthropicError( 'handleItem: the work item carried a secret payload but no sessions token could be extracted, ' + 'and there is no environment key to fall back to; the poller must issue a secret whose ' + 'payload carries `sessions_token`, or provide the environment key (pass it, construct the ' + 'worker with it, or set ANTHROPIC_ENVIRONMENT_KEY)', ); } if (work.secret && sessionsToken === null) { log.warn( 'work item carried a secret payload but no sessions token could be extracted; ' + 'falling back to the environment key', { work_id: work.id }, ); } // Every per-session call — the SessionToolRunner event stream/list/send, the // lease heartbeat, the skill download, and the work force-stop — // authenticates with the per-item credential. Scope a client to it once and // thread that through. `copyClientForHelper` also clears the parent's // `apiKey`, so the sub-client emits *only* the bearer credential on the // wire (a plain `withOptions({authToken})` would leave `X-Api-Key` set as // well). const sessionClient = copyClientForHelper(this.client, { authToken: itemCredential, helper: 'environments-worker', }); // The poller runs with `autoStop: false`, so the per-item handler is the // single owner of `work.stop` for every claimed item. const sessionId = work.data.id; // A per-session controller: aborts when the supplied signal aborts, when the // session runner finishes, or when the lease heartbeat says to stop. const ctrl = new AbortController(); const detachExternal = linkAbort(externalSignal, ctrl); const lease = new Lease(ctrl); // Lazily load the Node-only toolset module — see the import note at the top. const agentToolset = await import('../../tools/agent-toolset/node'); // Start the lease heartbeat BEFORE the session fetch and the skill / // memory downloads: those can take longer than the lease TTL, and an // unheartbeated lease lapsing mid-download would let another worker // reclaim the item and serve the same session (split-brain). // // Each heartbeat reports the lease TTL the server is enforcing; it becomes // the runner's tool-result send retry window so a send keeps retrying // exactly as long as the lease could still be live. The runner is only // built after the downloads, so hold the latest TTL until then. let leaseTtlMs: number | undefined; let runner: SessionToolRunner | undefined; const heartbeatPromise = heartbeatLoop(sessionClient, work, lease, log, this.requestOptions, (ttlMs) => { leaseTtlMs = ttlMs; runner?._setSendRetryWindow(ttlMs); }).catch((e) => { if (!ctrl.signal.aborted) log.error('heartbeat loop failed', { work_id: work.id, error: String(e) }); ctrl.abort(); }); let cleanupSkills: () => Promise<void> = async () => {}; let stores: SessionMemoryStores | undefined; let cleanEnd = false; try { if (work.data.type !== 'session') { log.debug('skipping non-session work item', { work_id: work.id, type: work.data.type }); return; } // One session fetch, shared by the skills download and the memory-store // download — two fetches could disagree about the attached resources. // A failed fetch fails the work item (the teardown below still stops // or releases it). const session: BetaManagedAgentsSession = await sessionClient.beta.sessions.retrieve(sessionId); // Only with the session in hand can we tell one that simply has no // memory from one whose memory we cannot mount. Turning memory off // with the interval knob is a deliberate opt-out and stays quiet. if (sessionsToken === null && this.memorySyncIntervalMs !== null && hasMemoryStore(session)) { throw new agentToolset.SessionMemoryError( `cannot mount the session's memories: the work item carried no sessions token ` + `(work_id=${work.id}, session_id=${sessionId}); ` + 'the memory endpoints reject the environment key, so the poller must issue a per-item ' + '`secret` carrying `sessions_token`, or set `memorySyncIntervalMs: null` to run without memory', ); } const ctx: AgentToolContext = { workdir: this.workdir, // The scoped sub-client, not the parent: the skill download // `setupSkills` performs for this session rides the same per-item // credential as every other per-item call. client: sessionClient, session, ...(this.maxFileBytes !== undefined ? { maxFileBytes: this.maxFileBytes } : {}), }; try { cleanupSkills = await agentToolset.setupSkills(ctx); } catch (e) { log.warn('skill setup failed', { session_id: sessionId, work_id: work.id, error: String(e) }); } // Memory stores: the memory_stores endpoints accept the per-item sessions // token but reject the environment key, so download and sync only run when // the item carried a usable secret (and the interval is set). // `sessionClient` is already scoped to that token then, so the memory // calls ride the same sub-client. A store that cannot be materialised // throws `SessionMemoryError` out of `download` and fails the item. if (sessionsToken !== null && this.memorySyncIntervalMs !== null) { stores = new agentToolset.SessionMemoryStores(sessionClient, { workdir: this.workdir, ...(this.memorySyncIntervalMs !== undefined ? { syncIntervalMs: this.memorySyncIntervalMs } : {}), syncDeletions: this.memorySyncDeletions, }); await stores.download(session); // A store mounted outside the workdir must stay reachable by the file // tools; read-only stores still refuse writes. ctx.allowedRoots = stores.roots; ctx.readOnlyRoots = stores.readOnlyRoots; } else { log.debug('memory stores disabled for this item', { work_id: work.id }); } const tools = typeof this.tools === 'function' ? this.tools(ctx) : this.tools ?? agentToolset.betaAgentToolset20260401(ctx); runner = new SessionToolRunner(sessionId, { client: sessionClient, tools, ...(this.maxIdleMs !== undefined ? { maxIdleMs: this.maxIdleMs } : {}), ...(this.requestOptions !== undefined ? { requestOptions: this.requestOptions } : {}), signal: ctrl.signal, }); if (leaseTtlMs !== undefined) runner._setSendRetryWindow(leaseTtlMs); for await (const _ of runner) { // Drive the runner to completion; per-call observability is not part // of this composition's surface — use `SessionToolRunner` directly // (via `client.beta.sessions.events.toolRunner`) if you want it. if (stores) await stores.syncIfDue(); } // Only a clean stream end earns the last full sync; it runs in the // teardown below. cleanEnd = !ctrl.signal.aborted; } finally { // The heartbeat keeps the lease alive until this teardown is done. try { // cleanupSkills first, so its failure cannot skip the memory flush. await cleanupSkills().catch((e) => { log.warn('skill cleanup failed', { session_id: sessionId, work_id: work.id, error: String(e) }); }); } finally { if (stores) { const boundMs = agentToolset.MEMORY_FLUSH_TIMEOUT_MS; if (cleanEnd) { const finishCutOff = await withTimeout(stores.finish(), boundMs); if (finishCutOff) { log.warn( `final memory sync cut off after ${boundMs}ms; the flush that follows still uploads changed files`, { session_id: sessionId, work_id: work.id }, ); } } // Also after finish(): it swallows its own failures, and a // clean flush is a no-op. const flushBound = new AbortController(); const flushCutOff = await withTimeout(stores.flushWrites(flushBound.signal), boundMs); if (flushCutOff) { flushBound.abort(); log.warn( `memory flush cut off after ${boundMs}ms; changed files it had not uploaded yet are not saved`, { session_id: sessionId, work_id: work.id }, ); } await stores.dispose().catch((e) => { log.warn('memory store cleanup failed', { session_id: sessionId, work_id: work.id, error: String(e), }); }); } } lease.finish('runner_done'); detachExternal(); await heartbeatPromise; // Stop only an item this worker still holds — after a lost lease it // belongs to the queue or another worker. if (lease.lost) { log.info('lease lost; released without stopping it', { session_id: sessionId, work_id: work.id }); } else { await forceStop(sessionClient, work, log, this.requestOptions); } } } } /** * Resolve when `p` settles or `ms` elapses — `true` when `ms` elapsed * first. A timed-out `p` keeps running — JS cannot cancel a promise. */ async function withTimeout(p: Promise<void>, ms: number): Promise<boolean> { let timer: ReturnType<typeof setTimeout> | undefined; try { return await Promise.race([ p.then( () => false, () => false, ), new Promise<boolean>((resolve) => { timer = setTimeout(() => resolve(true), ms); }), ]); } finally { if (timer !== undefined) clearTimeout(timer); } } /** Force-stop a claimed work item, swallowing the 409 that means it's already stopped. */ async function forceStop( client: Anthropic, work: Pick<BetaSelfHostedWork, 'id' | 'environment_id'>, log: Logger, requestOptions?: BetaToolRunnerRequestOptions, ): Promise<void> { try { await client.beta.environments.work.stop( work.id, { environment_id: work.environment_id, force: true }, // Caller's headers pass through; the helper-tag header is on the scoped // sub-client's default_headers via copyClientForHelper, so no per-call // re-stamping needed. { ...requestOptions, headers: buildHeaders([requestOptions?.headers]) }, ); } catch (e) { if (!isStatus(e, 409)) { log.error('force-stop on exit failed', { work_id: work.id, error: String(e) }); } } } /** Why heartbeating of a work item ended, as recorded on its {@link Lease}. */ type LeaseEndReason = | 'runner_done' | 'control_plane_stop' | 'lease_lost' | 'heartbeat_rejected' | 'assumed_lost'; /** * This worker's view of one work-item lease: the per-item abort signal plus * why heartbeating ended. The first recorded reason wins, so a run aborted * *because* the lease was lost still reads as lost afterwards; an abort with * no recorded reason (the external signal) is not lost. */ class Lease { readonly #ctrl: AbortController; #endReason: LeaseEndReason | undefined; constructor(ctrl: AbortController) { this.#ctrl = ctrl; } get signal(): AbortSignal { return this.#ctrl.signal; } finish(reason: LeaseEndReason): void { this.#endReason ??= reason; this.#ctrl.abort(); } /** True once the item belongs to the queue or another worker. */ get lost(): boolean { return this.#endReason === 'lease_lost' || this.#endReason === 'assumed_lost'; } } /** The server's view of the lease carried by a 412 heartbeat response, or empty if absent. */ function serverLeaseState(e: unknown): Record<string, unknown> { let node: unknown = e instanceof APIError ? e.error : undefined; for (const key of ['error', 'details', 'current_state']) { if (!isObj(node)) return {}; node = node[key]; } return isObj(node) ? node : {}; } /** * Keep the work-item lease alive while a session is being served. Runs until * `lease` ends, and ends it itself when the control plane reports the work is * `stopping`/`stopped` or no longer extends the lease, when a heartbeat is * rejected (a 412 means the lease already belongs to someone else), or when no * heartbeat has succeeded for longer than the lease ttl (the lease is assumed * lost, so two runners don't end up serving the same work). Each heartbeat * call is cut off after the current beat interval so a hung request cannot * outlive the lease it is meant to renew. */ async function heartbeatLoop( client: Anthropic, work: Pick<BetaSelfHostedWork, 'id' | 'environment_id'>, lease: Lease, logger: Logger, requestOptions?: BetaToolRunnerRequestOptions, /** Called with the server-reported lease TTL after every successful beat. */ onLeaseTtl?: (ttlMs: number) => void, ): Promise<void> { let intervalMs = HEARTBEAT_DEFAULT_MS; let ttlMs = HEARTBEAT_TTL_DEFAULT_MS; let lastSuccessMs = Date.now(); let last = NO_HEARTBEAT_SENTINEL; const beat = async (): Promise<void> => { // Not the request `timeout` option: the core client retries timeouts, so // it would not bound the call as a whole. const beatCtrl = new AbortController(); const detach = linkAbort(lease.signal, beatCtrl); const cutoff = setTimeout(() => beatCtrl.abort(), intervalMs); try { const resp = await client.beta.environments.work.heartbeat( work.id, { environment_id: work.environment_id, expected_last_heartbeat: last }, { ...requestOptions, headers: buildHeaders([requestOptions?.headers]), signal: beatCtrl.signal }, ); lastSuccessMs = Date.now(); last = resp.last_heartbeat; if (resp.ttl_seconds > 0) { ttlMs = resp.ttl_seconds * 1000; intervalMs = Math.max(1_000, Math.min(ttlMs / 2, HEARTBEAT_DEFAULT_MS)); onLeaseTtl?.(ttlMs); } if (resp.state === 'stopping' || resp.state === 'stopped') { logger.info('heartbeat signals shutdown', { work_id: work.id, state: resp.state }); lease.finish('control_plane_stop'); } if (!resp.lease_extended) { logger.warn('lease not extended, shutting down', { work_id: work.id }); lease.finish('control_plane_stop'); } } catch (e) { // An abort throws to unwind the caller (the `heartbeatLoop(...).catch` // in `#handleItem`) rather than returning early. lease.signal.throwIfAborted(); if (isStatus(e, 412)) { const server = serverLeaseState(e); logger.error('lease lost: heartbeat precondition failed', { work_id: work.id, server_state: server['state'], server_ttl_seconds: server['ttl_seconds'], server_last_heartbeat: server['last_heartbeat'], }); lease.finish('lease_lost'); return; } if (isFatal4xx(e)) { logger.error('permanent heartbeat failure', { work_id: work.id, error: String(e) }); lease.finish('heartbeat_rejected'); throw e; } if (Date.now() - lastSuccessMs > ttlMs) { logger.error('lease assumed lost: no successful heartbeat in ttl', { work_id: work.id, ttl_ms: ttlMs, error: String(e), }); lease.finish('assumed_lost'); return; } logger.warn('transient heartbeat failure', { work_id: work.id, error: String(e) }); } finally { clearTimeout(cutoff); detach(); } }; await beat(); while (!lease.signal.aborted) { await sleep(intervalMs, lease.signal); lease.signal.throwIfAborted(); await beat(); } }