feat-cma-scale — Cloud Managed Agents at scale
Branch: session/feat-cma-scale. Scope: packages/plugins/claude-managed-agent only
(plus the root pnpm-lock.yaml for the SDK dependency move).
Goal: run Claude Managed Agents (CMA) efficiently at scale — a persistent control plane instead of create-and-delete per run, a bounded-concurrency fan-out service, per-session budget caps, and token/cost figures that actually reach the platform's usage rollups.
What shipped
1. SDK dependency
@anthropic-ai/sdk moved from devDependencies 0.91.1 to dependencies ^0.117.1.
It was already the transport on develop (AnthropicManagedAgentsClient wraps
client.beta.*), but as a dev dependency the published/bundled plugin declared no runtime
dependency on it — tsup keeps it external, so the import would have been unresolved for
any consumer installing the package. 0.117.x is also the first version with the typed
limited networking policy, budget, initial_events and agent_with_overrides shapes
this branch relies on.
There are no left-behind non-SDK call sites — every CMA request goes through
AnthropicManagedAgentsClient, and utils/cma-sdk.ts is the single construction seam
(API key + base URL resolution) used by the pipeline, the control plane and the fan-out.
Deviation from the brief: the brief assumed
managed-agents-client.tswas a hand-rolled REST client to be left alone while new code went through a new SDK wrapper. It was already SDK-based, so the wrapper stayed in place and was extended instead of duplicated.
2. Persistent control plane — utils/control-plane.ts
New user-scope, x-hidden, plugin-written settings: managedAgentId,
managedAgentConfigHash, managedEnvironmentId, managedEnvironmentConfigHash
(persisted through context.updateSettings('user', …)).
ensureManagedAgent— no stored id → create; stored id + matching hash → reuse; hash drift →agents.update(new immutable version upstream); stored id missing/archived → recreate. The config hash deliberately excludes the model: sessions pin the model per run viaagent_with_overrides, so a model change must not churn agent versions.ensureManagedEnvironment— same matrix against the resolved networking policy. The policy comes from the optional serializableexecContext.runtimeEnvironmentobject when the platform provides one (Environments feature, parallel branch — read defensively, the entity is not imported), else the existingCLAUDE_MANAGED_AGENT_EGRESS_HOSTSenv-var fallback.- Persisting state is best-effort: a failed write logs and the run continues with the resolved ids.
New setting reuseControlPlane (boolean, default true). When false the plugin falls
back to today's ephemeral behavior: work-scoped agent + environment created per run and
torn down at cleanup. In reuse mode the run's session is archived (not deleted) and the
agent/environment are never touched.
3. Fan-out — utils/fan-out.ts + ClaudeManagedAgentPlugin.runSessions()
runManagedSessions(client, {prompts, agentId, environmentId, concurrency = 5, perSessionBudgetUsd?, timeoutMs?, resources?, agentOverrides?, signal?, archiveSessions?})
→ per-prompt {id, status, output?, sessionId?, tokens?, costUsd?, error?}, in prompt
order. Simple index-cursor worker pool (no dependencies); a failing session never aborts
its siblings; every created session is archived best-effort; timeoutMs is converted into
a poll-attempt bound over the existing waitForSessionIdle polling (the events stream has
no replay, so poll-to-idle stays the mechanism).
Two entry points:
- Pipeline step
run-variant-sessions(optional,steps.ts): active only when the generation form'svariant_sessions> 1. Each variant gets a single combined prompt (buildVariantSessionPrompt— bootstrap / generate / collect phases in one message), results are parsed independently and merged with de-duplication (mergeVariantOutputs: item key = lowercasedname::source_url, first variant wins). A failed variant becomes a warning; only an all-failed batch fails the run. Withvariant_sessionsat its default of 1 the step is skipped and the single-session path is unchanged. plugin.runSessions({prompts, userId, workId?, concurrency?, perSessionBudgetUsd?, timeoutMs?, resources?, signal?})for API-side / Trigger.dev callers. Resolves settings (work-scoped whenworkIdis given, else user-scoped), ensures the control plane, runs the batch, and tears down an ephemeral control plane afterwards.
Registration: onLoad publishes the service on the platform's custom-capability registry
under CMA_FAN_OUT_CAPABILITY ('claude-managed-agent.fan-out'), so callers can reach it
via CustomCapabilityRegistryService.getImplementation(CMA_FAN_OUT_CAPABILITY) without
holding the plugin instance. Registration is guarded (partial contexts, duplicate-name
throw on re-load) and never fatal to loading the plugin; the lifecycle manager already
unregisters by provider on unload.
4. Budget caps
New form field per_session_budget_usd (1..500). budget is create-only upstream, so it
is passed to sessions.create as {type:'limit', max_list_cost:{amount:'<cents>', currency:'USD'}} — integer minor units, never floats.
The ceiling is enforced by a single helper, clampPerSessionBudgetUsd (form-schema.ts),
applied on all three paths that can reach sessions.create: form validation, a raw
GenerationRequest reaching execute() directly, and the programmatic runSessions().
variant_sessions (1..8) and target_items (1..250) are clamped the same way.
5. Usage accounting — utils/usage-metrics.ts
buildManagedAgentMetrics() writes tokenUsage.total.totalTokens and totalCost at the
metrics root, plus a per-session breakdown under custom for logs.
This was the branch's one real defect. The interrupted session put those keys inside the
custombag (via the plugin-runtimebuildMetrics()helper, which nests whatever it is given undermetrics.custom), while the platform's consumer,extractPipelineUsageMetricsinpackages/agent/src/utils/metrics.util.ts, only readsmetrics.tokenUsage.total.totalTokens,metrics.totalCostand per-stepsteps[*].custom.totalTokens|totalCost. Every token and dollar the plugin reported was therefore dropped from the rollup — wired but dead, and pinned that way by a test. The shape now matches theagent-pipelineplugin's convention, and the contract is asserted with a faithful replica of the consumer in the plugin spec (plugin packages cannot import@ever-works/agent).
Per-session cost comes from usage.list_cost (integer minor units, USD-only; non-USD or
malformed amounts yield undefined rather than a wrong number).
Token counting goes through one seam, toManagedSessionTokenUsage(), shared by the
fan-out and single-session paths. totalTokens counts every billed token class:
input_tokens + output_tokens + cache_creation_input_tokens + cache_read_input_tokens.
The two cache counters are reported by the sessions API outside input_tokens, and
managed-agent sessions re-read a large cached prefix on every turn, so omitting them
under-reported the rollup by orders of magnitude (see review findings below).
No plugin_usage_events ledger row is emitted. That was checked, not assumed:
PluginUsageService is an API/agent-side Nest provider reached only through the
*.facade.ts seams (ai / search / screenshot / extractor / email / notification /
metrics). PluginContext exposes no usage seam and PluginEventName has no usage event,
so a pipeline plugin cannot reach the ledger. Per the brief this falls back to reporting
figures in the pipeline metrics — see follow-ups.
Data model / endpoints / UI
None. No entities, no migration, no controllers, no web routes, no i18n keys. The two new
form fields and reuseControlPlane render from the plugin's existing JSON-schema/form
provider surfaces.
Test commands
cd packages/plugins/claude-managed-agent
pnpm type-check # tsc --noEmit
pnpm test # vitest run — 6 files, 55 tests
npx vitest run src/utils/usage-metrics.spec.ts
npx vitest run src/utils/control-plane.spec.ts
npx vitest run src/utils/fan-out.spec.ts
npx vitest run src/claude-managed-agent.plugin.spec.ts
# from repo root
npx turbo build --filter=@ever-works/claude-managed-agent-plugin...
npx prettier --check "packages/plugins/claude-managed-agent/src/**/*.ts"
Coverage added on this branch: ensure-agent create/reuse/version-bump/recreate matrix;
ensure-environment with and without the runtimeEnvironment context object; fan-out pool
concurrency ceiling, budget pass-through, failure isolation, archive behavior, timeout
derivation and cancellation; ephemeral-mode fallback end to end; capability registration
(including double-load); budget clamping on the programmatic path; and the usage-metrics
shape against a replica of the platform consumer.
Pre-merge review findings (fixed on this branch)
- Cached input tokens were dropped from the usage rollup.
mapSession()mappedcache_creation_input_tokens/cache_read_input_tokensontoManagedAgentsUsage, but nothing read them: both the fan-outtoTokenUsage()and the single-session metrics block computedtotalTokens = input_tokens + output_tokens. Anthropic reports both cache counters outsideinput_tokens, so a session using{input 1200, output 800, cache_creation 24000, cache_read 640000}reportedtotal_tokens_used: 2000instead of 666,000. Thecustom.usagebag was also a regression againstdevelop, which at least carried the raw usage object. Fixed by a single shared mapper (toManagedSessionTokenUsage) used by both paths, with the cache counters summed intototalTokensand broken out incustom.usage. - Ephemeral runs ignored the runtime environment's networking policy. The
reuseControlPlane === falsebranch ofexecute()built its environment inline withcreateEnvironment({ name })and never consultedexecContext.runtimeEnvironment, soresolveNetworking()never ran on that path — an explicitlimitedegress allow-list was silently downgraded to the env-var default (unrestricted). The reuse path andensureControlPlane's own ephemeral branch both honored it; only this duplicated inline copy did not. Networking is a security control, so both modes now resolve it. - The fan-out concurrency test asserted nothing.
expect(peakActive) .toBeLessThanOrEqual(2)also passes for a fully serial pool, so a regression to one-session-at-a-time would have stayed green. Tightened totoBe(2).
Regression coverage added for all three, plus the previously unasserted per-session budget cap on the single-session path.
Known follow-ups
- Ledger rows. To get real
plugin_usage_eventsrows for pipeline runs, the seam has to be added platform-side: aPluginUsageCapability.PIPELINEenum value (additive, the column is varchar — no migration) and aPluginUsageService.record()call fromDataGeneratorService.convertPipelineMetrics(), which already has the work/user scope and the extracted totals. Deliberately out of scope here: it is a cross-cutting change affecting every pipeline plugin, not just this one. - Session runtime cost.
usage.list_costcovers model tokens; the $0.08/hour session runtime charge is not broken out by the API response the plugin sees, sototalCostunder-reports wall-clock-heavy runs. runtimeEnvironmentproducer. The plugin reads the object defensively; nothing sets it yet. When the Environments branch lands, verify the field name and shape againstresolveNetworking()and add an integration-level assertion.- Fan-out concurrency is not exposed on the generation form. Variant runs use the
default of 5; only the programmatic
runSessions()caller can tune it. - No SSE consumption. Sessions are still polled to idle. If the events stream gains
replay,
waitForSessionIdleis the single place to swap.