Skip to content

feat(ts): mesh.route() and mesh.a2a.mount() should support explicit MeshExpress runtimes #942

Description

@dhyansraj

Context

Surfaced during PR #943 (#938 fix) review. Originally scoped to mesh.a2a.mount() only; expanded scope after realizing the same architectural coupling exists for mesh.route() — both surfaces inherit the singleton getApiRuntime() assumption that breaks under explicit MeshExpress usage.

The bug — broader than originally scoped

Both mesh.route() (src/runtime/typescript/src/route.ts:350,367) and mesh.a2a.mount() (src/runtime/typescript/src/a2a/producer/mount.ts) hardcode the singleton:

// In mesh.route():
const registry = RouteRegistry.getInstance();
registry.registerRoute(...);
getApiRuntime().scheduleStart();   // ← always the singleton

// In mesh.a2a.mount():
const runtime = getApiRuntime();
runtime.scheduleStart();
runtime.pushSurfacesUpdate();      // ← always the singleton (added in #943)

Users who construct an explicit MeshExpress(app, config) get a separate JsAgentHandle that doesn't receive routes/surfaces from these calls.

Symptoms by combo

User pattern Behavior today
mesh.route() only ✅ Works — auto-init singleton handles everything
meshExpress() only ✅ Works — explicit runtime, no mesh.route() calls
meshExpress() + mesh.route() Two mesh runtimes heartbeat to registry, both claim the same routes (registry collision) — mesh.route() always triggers getApiRuntime().scheduleStart() regardless of whether MeshExpress is in use
meshExpress() + mesh.a2a.mount() ❌ Same: deferred mount pushes to singleton, MeshExpress runtime stays stale
meshExpress() + both ❌ Compounds both bugs

Why nobody hit this before

The express.ts header comment explicitly recommends the simple path:

Note: For simpler usage, just use mesh.route() without meshExpress(). The API runtime auto-initializes when the first mesh.route() is called.

Most users:

  • Use mesh.route() only → no conflict (only singleton runs)
  • Use meshExpress() only → no mesh.route() calls → no second runtime spawns

The @example block in express.ts:30-43 shows meshExpress() + mesh.route() together as an "advanced" pattern, but the dual-runtime conflict means that example is broken in the same way mesh.a2a.mount() is.

Pre-existing scope note

Neither mesh.route() nor mesh.a2a.mount() ever properly worked with explicit MeshExpress. PR #943 documented the limitation for mesh.a2a.mount() specifically (mount.ts code comment + docs/a2a/producer.md admonition both linking here), but the broader mesh.route() issue was previously undocumented.

What "fixed" looks like

A user should be able to:

const meshApp = meshExpress(app, { name: "my-api", httpPort: 3000 });

// Both should push to meshApp's handle, NOT the singleton:
app.post("/x", mesh.route(["foo"], handler));
mesh.a2a.mount(app, { ... }, async (deps, payload, jobSubmitter) => { ... });

meshApp.start();

…and have a single mesh runtime registration with the registry, containing both the routes AND the A2A surfaces.

Implementation pointers

Three options (apply equally to routes + a2a surfaces):

  1. Registration shim — track which runtime owns each Express app instance. MeshExpress constructor registers (app → this). Both mesh.route(...) and mesh.a2a.mount(app, ...) look up the runtime from the app reference and dispatch to it (falls back to getApiRuntime() if not registered). Recommended — keeps user-facing API unchanged, transparent fallback.

  2. MeshExpress.route() / MeshExpress.mount() wrappers — explicit methods on MeshExpress that internally do the right thing. Requires API addition for both surfaces.

  3. AsyncLocalStorage — wrap MeshExpress operations in an ALS context that mesh.route() / mesh.a2a.mount() read. Cleaner if surrounding code allows; more complex if not.

Java has the same dormant bug (MeshAutoConfiguration.meshAgentSpecFinalizer runs ONCE post-Spring-init). But Java's @MeshA2A and @MeshRoute beans are static at boot, so the runtime hot-add scenario doesn't trigger in practice. Track Java separately if hot-add ever becomes a real ask.

Acceptance criteria

  • User can construct MeshExpress(...) AND call mesh.route(...) → routes are owned by the MeshExpress handle, not the singleton; only one mesh runtime registers
  • User can construct MeshExpress(...) AND call mesh.a2a.mount(...) deferred (after start) → next heartbeat reflects agent_type=a2a + populated surfaces[] on the MeshExpress handle
  • Auto-init ApiRuntime path continues to work (no regression on the canonical mesh.route()-style apps)
  • No regression in mount-surface-push.spec.ts test suite
  • New regression tests:
    • Deferred mount on a MeshExpress instance correctly updates the right handle
    • meshExpress() + mesh.route() results in exactly ONE registry registration (not two)
  • Update mount.ts code comment + docs/a2a/producer.md to remove the limitation note (and add a similar note for mesh.route() if not already covered)
  • Smoke tests: deferred mount + route on MeshExpress end-to-end shows wire-correct heartbeat envelope (mirrors the fix(a2a-ts): agentType/surfaces computed once at startup; should be per-heartbeat #938 smoke test pattern)

Out of scope

  • Java equivalent (separate issue if hot-add ever ships)
  • Hot-remove (un-mounting a surface mid-flight)

Related

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

    enhancementNew feature or request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions