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):
-
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.
-
MeshExpress.route() / MeshExpress.mount() wrappers — explicit methods on MeshExpress that internally do the right thing. Requires API addition for both surfaces.
-
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
Out of scope
- Java equivalent (separate issue if hot-add ever ships)
- Hot-remove (un-mounting a surface mid-flight)
Related
Context
Surfaced during PR #943 (#938 fix) review. Originally scoped to
mesh.a2a.mount()only; expanded scope after realizing the same architectural coupling exists formesh.route()— both surfaces inherit the singletongetApiRuntime()assumption that breaks under explicitMeshExpressusage.The bug — broader than originally scoped
Both
mesh.route()(src/runtime/typescript/src/route.ts:350,367) andmesh.a2a.mount()(src/runtime/typescript/src/a2a/producer/mount.ts) hardcode the singleton:Users who construct an explicit
MeshExpress(app, config)get a separateJsAgentHandlethat doesn't receive routes/surfaces from these calls.Symptoms by combo
mesh.route()onlymeshExpress()onlymesh.route()callsmeshExpress()+mesh.route()mesh.route()always triggersgetApiRuntime().scheduleStart()regardless of whetherMeshExpressis in usemeshExpress()+mesh.a2a.mount()meshExpress()+ bothWhy nobody hit this before
The express.ts header comment explicitly recommends the simple path:
Most users:
mesh.route()only → no conflict (only singleton runs)meshExpress()only → nomesh.route()calls → no second runtime spawnsThe
@exampleblock in express.ts:30-43 showsmeshExpress()+mesh.route()together as an "advanced" pattern, but the dual-runtime conflict means that example is broken in the same waymesh.a2a.mount()is.Pre-existing scope note
Neither
mesh.route()normesh.a2a.mount()ever properly worked with explicitMeshExpress. PR #943 documented the limitation formesh.a2a.mount()specifically (mount.tscode comment +docs/a2a/producer.mdadmonition both linking here), but the broadermesh.route()issue was previously undocumented.What "fixed" looks like
A user should be able to:
…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):
Registration shim — track which runtime owns each Express
appinstance.MeshExpressconstructor registers(app → this). Bothmesh.route(...)andmesh.a2a.mount(app, ...)look up the runtime from the app reference and dispatch to it (falls back togetApiRuntime()if not registered). Recommended — keeps user-facing API unchanged, transparent fallback.MeshExpress.route()/MeshExpress.mount()wrappers — explicit methods onMeshExpressthat internally do the right thing. Requires API addition for both surfaces.AsyncLocalStorage — wrap
MeshExpressoperations in an ALS context thatmesh.route()/mesh.a2a.mount()read. Cleaner if surrounding code allows; more complex if not.Java has the same dormant bug (
MeshAutoConfiguration.meshAgentSpecFinalizerruns ONCE post-Spring-init). But Java's@MeshA2Aand@MeshRoutebeans 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
MeshExpress(...)AND callmesh.route(...)→ routes are owned by the MeshExpress handle, not the singleton; only one mesh runtime registersMeshExpress(...)AND callmesh.a2a.mount(...)deferred (after start) → next heartbeat reflectsagent_type=a2a+ populatedsurfaces[]on the MeshExpress handleApiRuntimepath continues to work (no regression on the canonicalmesh.route()-style apps)mount-surface-push.spec.tstest suiteMeshExpressinstance correctly updates the right handlemeshExpress()+mesh.route()results in exactly ONE registry registration (not two)mount.tscode comment +docs/a2a/producer.mdto remove the limitation note (and add a similar note formesh.route()if not already covered)MeshExpressend-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
Related
mesh.route()was the originalMeshExpress-coupling source (route.ts:367); this issue makes the gap explicit