Skip to content

Support per-node-type activity worker routing (dedicated Docker images/tools per node type) #141

Description

@tbrandenburg

Problem / Motivation

Today, every node type (trigger, decision, ai-agent, visualize, and any future type) executes as an Activity inside the same execution-worker process, built from the same Docker image. This means:

  • Every node type's dependencies must be installed into one shared image — no per-tool isolation.
  • A future node type that needs heavyweight or exotic tooling (a coding-agent CLI such as Claude Code, Docker-in-Docker, GPU/CUDA, a specific Python toolchain, ffmpeg, etc.) would bloat the shared image for every node type, or conflict with existing dependencies.
  • All node types scale together — you can't run many replicas of a cheap node executor and one replica of an expensive one independently.
  • No isolation/blast-radius boundary — a misbehaving dependency for one node type risks the whole worker process.

Goal

Allow specific node types to be executed by a dedicated worker process, running from its own Docker image with its own tools installed, while all other node types keep running on the existing general-purpose worker — with no changes to the workflow/interpreter code.

Non-goals

  • Not proposing a container-per-activity-execution model (spinning up a fresh container per single node run). This is container-per-node-type-pool, i.e. a small number of long-lived specialized worker deployments, matching how Temporal is designed to scale.
  • Not changing the graph interpreter (runGraph) or the deterministic workflow contract at all — this is purely a worker-topology/deployment change.

Proposed Design: Temporal Task-Queue Routing

Temporal already supports this natively: any proxyActivities(...) call can target a specific task queue name. Workers subscribe to (poll) task queues; Temporal server only ever dispatches a task to a worker polling the matching queue. So:

  1. Introduce a taskQueue field on the per-node-type activity profile, so specific node types can be pinned to a non-default queue.
  2. Stand up one additional worker deployment (own Dockerfile, own image, own installed tools) that polls that queue and registers only the executor(s) for that node type.
  3. Everything else keeps running exactly as today, on the existing default queue/worker/image.

No change needed to: runGraph, the graph JSON model, the UI, the DB schema, or the backend's submit() call — the workflow code doesn't know or care which container ran an activity.

Required Changes

1. packages/temporal — extend the activity-profile type

packages/temporal/src/workflow/activity-profiles.ts:

export type ActivityProfile = {
  startToCloseTimeout: DurationString;
  retry: { maximumAttempts: number };
  taskQueue?: string;   // NEW — defaults to the workflow's own task queue if omitted
};

Keep DEFAULT_NODE_ACTIVITY_PROFILE unchanged (no taskQueue = current behavior, zero risk to existing node types).

2. packages/temporal — wire it into proxyActivities

packages/temporal/src/workflow/run-workflow.ts, inside runner.executeNode, pass the resolved profile's taskQueue straight through to proxyActivities(...) (structurally compatible with Temporal's real ActivityOptions already). resolveFromValidatedProfiles/profile-validation.ts need to accept and pass through the optional taskQueue.

3. apps/execution-worker — a second, minimal entrypoint

A new activity-only worker entrypoint (no workflowsPath needed — Temporal supports activity-only workers) that registers only the specialized executor(s) and polls the new task queue name.

4. New Docker image for the specialized worker

  • A new Dockerfile/build stage installing whatever the new node type needs, on top of the shared runtime layer.
  • A new service in the production docker-compose.yml alongside the existing worker service, connecting to the same Temporal server/namespace on a different task queue.
  • A local dev script (own .env if needed) to run it standalone.

5. Validation

profile-validation.ts (already validated at Worker.create time and inside the sandbox) should also guard against a node type routed to a taskQueue with no worker polling it — otherwise the activity silently sits pending forever, which is an easy and hard-to-diagnose misconfiguration.

6. Testing

  • A test that a node profile's taskQueue actually produces a ScheduleActivityTask command targeting that queue (inspect the recorded Event History, akin to the existing replay tests).
  • A replay-determinism check confirming taskQueue is pure routing metadata and doesn't leak into the sandbox's decision-making.
  • A manual/integration check: run a diagram with the specialized node type, confirm in Temporal UI that its task lands on the new queue, not the default one.

Rollout / Compatibility

Fully additive and backward compatible: existing node types with no taskQueue in their profile behave exactly as today. Can be introduced node-type by node-type.

Open Questions

  1. Do specialized workers need their own .env/credentials, separate from the main worker?
  2. Should long-running specialized activities (e.g. a CLI agent run) use Activity heartbeats rather than just a longer startToCloseTimeout?
  3. Should a specialized activity's stdout/stderr integrate into the existing emitEvent/execution-log pipeline as-is, or does it need a richer event shape (streaming partial output)?
  4. Should replica counts per worker type be independently configurable in the deploy compose file, given very different load/cost profiles per node type?

Summary

Add an optional taskQueue to the existing per-node-type ActivityProfile, thread it through to proxyActivities, and support standing up one additional worker process/Docker image per specialized node type that polls that queue — no changes to the graph interpreter, the workflow contract, or any other node type's behavior.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions