Skip to main content

Plugin lifecycle

A plugin moves through three phases over the lifetime of an application. Each phase has a clear contract and a set of guarantees that you can rely on when writing code.

Phase 1. Registration

During the call to discover(path) the registry:

  1. Walks the supplied directory.
  2. Locates folders that contain a dagstack.toml.
  3. Validates the manifest against the pydantic schema.
  4. Imports the implementation module (Python: plugin.py; TypeScript: plugin.ts/plugin.js; Go: determined by the manifest).
  5. Creates an instance of the plugin class via the no-argument constructor.
  6. Stores the (manifest, instance) pair in the registry.

At this stage setup has not been called yet — the plugin exists as an object, but neither resources nor configuration have been delivered to it. The constructor body should be trivial: no file operations, no network connections, no heavy computation.

class MyPlugin:
def __init__(self) -> None:
# Correct: only initialise the fields that setup will populate.
self._client = None
self._config = None

Phase 2. Initialisation

The registry's setup_all() method initialises every registered plugin:

  1. The registry builds the dependency graph of the plugins (when declared in the manifest).
  2. topo_sort determines the initialisation order — plugin A is initialised before B if B depends on A.
  3. For every plugin in this order the registry calls setup(context), where context: PluginContext contains:
    • config — the validated section of the plugin's configuration;
    • resources — the injected standard resources (Clock, Rng, BlobStore, HttpClient and the others from STANDARD_RESOURCES);
    • tenant — a TenantContext or a NoOpTenantContext;
    • event_bus — the publish/subscribe event bus;
    • logger — the structured logger from dagstack/logger-spec.

Inside setup a plugin should:

  • Create clients (HTTP, database), open connections.
  • Read the configuration, store the values it needs.
  • Subscribe to configuration changes (when reactivity is required).
  • Register additional resources for dependent plugins.

Forbidden: business-logic calls (handling input data, sending user requests). setup is preparation only.

class MyPlugin:
async def setup(self, context: PluginContext) -> None:
self._config = context.config
# Resources are injected at setup time and exposed as attributes
# on the per-plugin `ResourceContainer` (`ctx.resources`); the
# async `await ctx.resources.get(name)` form is also available.
self._http = context.resources.http_client
self._clock = context.resources.clock
self._client = MyClient(
base_url=self._config["base_url"],
http=self._http,
)

:::info Phase 2 scope Live config-section subscriptions (on_section_change) are part of the Phase 2 dagstack/config-spec integration. In 0.1.0-rc.2 config is delivered once during setup. :::

Phase 3. Teardown

The registry's teardown_all() method runs the closing cycle:

  1. It calls teardown() on every plugin in the reverse of the initialisation order.
  2. If a plugin's teardown raises, the exception does not abort the loop — exceptions are collected into TeardownErrors.
  3. After every teardown has run, the aggregated error (if any) is propagated upwards.

teardown should:

  • Close connections (HTTP clients, sockets, file descriptors).
  • Cancel subscriptions to configuration and events.
  • Release injected resources that the plugin created itself (when applicable).
async def teardown(self) -> None:
if self._client:
self._client.close()

Inter-plugin dependencies

A plugin may explicitly declare that it depends on another plugin. The registry takes that into account during topo_sort.

plugins/reranker/dagstack.toml
[plugin]
name = "cross-encoder"
kind = "reranker"
runtime = "in_process"
core_version = ">=0.1.0,<1.0.0"

[[plugin.depends_on]]
kind = "embedder"
name = "openai_compatible"

Once that is in place, embedder.openai_compatible is initialised before reranker.cross-encoder. The dependent plugin can be retrieved via context.registry:

async def setup(self, context: PluginContext) -> None:
self._embedder = context.registry.get_plugin(
"embedder", name="openai_compatible",
)

A cycle in the dependency graph causes a DependencyCycle error during setup_all().

Partial failure

If setup of one plugin throws an exception:

  1. The registry catches the exception.
  2. It calls teardown on every plugin already initialised (in reverse order).
  3. It propagates the original exception upwards.

This guarantees that a partially initialised registry is never left in a dangling state. From the user's point of view: setup_all() either completes successfully as a whole or it raises an exception and leaves the registry in the pre-initialisation state.

Sequence diagram

See also