Files
deepseek-harness/docs/cordis-tutorial/02-lifecycle-and-effects.md
T
imccyu ec601ca13d build(vendor): rescope the vendored Cordis packages into @deepseek-ai
Machine-produced by `pnpm run rescope-vendor --apply` plus the regeneration it
prints: `pnpm install` for the lockfile, `pnpm run gen-third-party-notices`,
`verify-translation-pairing --write` for the touched bilingual pairs,
`gen-doc-graphs`, and one typert snapshot whose ids embed character offsets.
`pnpm run rescope-vendor --check` verifies the result.

Renames nine vendored packages (cordis, cosmokit, schemastery and the six
@cordisjs plugins) and every reference that resolves them: manifest names and
dependency keys, module specifiers including declare-module merges, cordis.yml
plugin names, tsconfig paths, every Markdown fence, and `docs/` prose.
Directory names, upstream versions, and dependency ranges are unchanged, so
vendor/README.md still reads as an upstream snapshot; its manifest table gains
an upstream-name column so THIRD_PARTY_NOTICES keeps MIT attribution pointed
at each fork's origin.

The tutorial tier follows the rename end to end: its yaml fences named plugins
the Loader can no longer resolve, its `ts ignore-check` fences disagreed with
the compiled fences beside them, and its prose quoted both. The contracts that
told readers to keep upstream names — the root convention and the vendoring
cookbook's tree comment and manifest invariant — now say to rescope instead.

Two rules read `@deepseek-ai/` as "another workspace plugin": the client bundle
purity gate now names the vendored libraries a browser bundle inlines, and the
files where a bare `cordis` is an agent-preset id keep that product data.
2026-08-10 22:04:13 +08:00

4.1 KiB

2. Lifecycle and effects

English | 中文

A Cordis plugin can be unloaded by a config edit, hot reload, explicit disposal, or loss of a required service. Registrations made through Cordis APIs are effects and are undone when their owning plugin unloads; resources managed outside those APIs must be wrapped in ctx.effect().

Effects

For a resource Cordis does not already manage — a timer, a connection, a watcher — wrap it in ctx.effect() and return a disposer:

Create lifecycle.ts in tmp/cordis-tutorial:

import type { Context } from '@deepseek-ai/cordis'

export const name = 'lifecycle-demo'

function heartbeat(ctx: Context) {
  console.log('heartbeat plugin loading')
  ctx.effect(() => {
    const timer = setInterval(() => console.log('tick'), 200)
    return () => {
      clearInterval(timer)
      console.log('heartbeat cleaned up')
    }
  })
}

export function apply(ctx: Context) {
  // Mount a child plugin and keep its fiber to dispose it later.
  const fiber = ctx.plugin(heartbeat)
  // The demo timer is itself an effect: if THIS plugin is unloaded first,
  // the pending callback is cancelled instead of firing on a dead app.
  ctx.effect(() => {
    const timer = setTimeout(async () => {
      await fiber.dispose()
      console.log('disposed')
      process.exit(0)
    }, 700)
    return () => clearTimeout(timer)
  })
}

Point cordis.yml at it:

- name: './lifecycle.ts'

Run (node --import tsx ../../vendor/cordis/bin.js) and you get:

heartbeat plugin loading
tick
tick
tick
heartbeat cleaned up
disposed

Three things to notice:

  • ctx.plugin(heartbeat) mounts a function from code as a plugin — the same operation the YAML loader performs for each config entry. A function plugin needs no apply method: Cordis calls the function directly and uses its name only for diagnostics. An apply method is required only for the object form, ctx.plugin({ apply(ctx) { /* ... */ } }). The call returns a fiber, the runtime handle for one loaded plugin instance.
  • The effect body runs during load; the disposer it returns runs during unload. You never call the disposer yourself for a plugin-lifetime resource.
  • fiber.dispose() resolves after all of the plugin's cleanup — including async disposers — has finished, and recursively unloads any child plugins it mounted.

The fiber state machine

Every loaded plugin instance owns a fiber that moves through these states:

PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED
                 ↘ FAILED
  • PENDING — declared, but a required service (chapter 3) is not available yet.
  • LOADING / ACTIVEapply is running / has completed.
  • FAILEDapply or config validation threw.
  • UNLOADING / DISPOSED — disposers are running / everything is torn down.

You will meet PENDING again in chapter 6, where it is the usual answer to "why does my plugin print nothing?".

What is already an effect

You rarely write ctx.effect() yourself, because the built-in registration APIs are effects already:

  • ctx.on(event, listener) — the listener is removed on unload (chapter 4).
  • ctx.plugin(child) — the child is disposed with its parent.
  • Service registrations are effects. Harness registries such as ctx.tools.register(...) also attach their returned disposers to the calling plugin, so they unwind automatically (chapter 7).

For a resource Cordis does not manage, acquire it inside ctx.effect() and return a disposer that releases it. Cordis then invokes that release during unloading, including hot reload.

One ordering caveat: disposers start in reverse registration order, but multiple async disposers run concurrently. If teardown steps must run in sequence, keep them in one disposer and await them there.

Next: Services — how plugins share capabilities.