@anthropic-ai/sdk
Version:
The official TypeScript library for the Anthropic API
794 lines (758 loc) • 36.3 kB
text/typescript
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();
}
}