Agent Orchestrator
Framework-level supervisor for every agent blamejs ships (b.mail.agent today; future search-index / AI-classify / DSR / c2pa-watermark agents). The orchestrator owns:
- **Registry** (register / lookup / unregister / list) — pluggable backend; in-memory default, durable via operator- supplied b.config.loadDbBacked for restart-survival. Rows are sealed at rest via b.cryptoField when a vault is configured (the default in a booted app), so tenant names + endpoint metadata don't leak in DB dumps. - **Sharded topics** (spawnConsumers) — consistent-hash route per-shard so each tenant's traffic owns one shard's ordering. - **Leader-elected singletons** (elect) — composes b.cluster DB-row election. Operator marks methods that must run on exactly one node (MDN batch dispatch, virus-DB refresh, journal compaction) as singletons. - **Drain** (drain) — consumer.stop() on every spawned consumer; wait for in-flight envelopes via b.outbox; audit. Wires into b.appShutdown as a registered phase. - **Health probe** (health) — aggregates per-agent + per- consumer + per-election state into one shape for b.middleware.healthcheck.
The orchestrator is the **in-process supervisor of agents**, NOT the **OS-level supervisor of processes**. Spawn / restart-on- crash / autoscaling / network routing all delegate to pm2 / systemd / k8s / Nomad — the framework doesn't compete.
var orch = b.agent.orchestrator.create({
audit: b.audit,
permissions: myPerms,
backend: operatorBackend, // optional; in-memory default
});
await orch.register("tenant-acme.mail", mailAgent, { agentKind: "mail" });
var agent = await orch.lookup("tenant-acme.mail");
b.agent.orchestrator.create(opts) #
{
audit: b.audit namespace, // optional; defaults to b.audit
permissions: b.permissions instance, // optional; orchestrator skips RBAC if absent
backend: { get, set, delete, list }, // optional; in-memory default
cluster: b.cluster module, // optional; defaults to b.cluster
appShutdown: b.appShutdown.create() // optional; orchestrator adds an "agent.orchestrator.drain" phase via addPhase() if supplied
}
Create the orchestrator. Returns a singleton-style facade with registry / spawn / elect / drain / health methods. Operator runs one orchestrator per process; multi-process deployments share coordination via the backing store + b.cluster.
var orch = b.agent.orchestrator.create({});
await orch.register("tenant-acme.mail", mailAgent, { agentKind: "mail" });
var agent = await orch.lookup("tenant-acme.mail");
var folders = await agent.folders({ actor: { id: "u1" } });
b.agent.orchestrator.hydrate(name, agent) #
Attach an in-process live agent reference to a row that already exists in the persistent registry backend. The canonical boot-phase contract: the *first* process to start a new agent calls register() (writes the backend row + holds the live ref); every *subsequent* process that picks up the row from durable storage (cross-orchestrator-restart, multi-process deploy, k8s pod recreate) calls hydrate(name, agent) to install its local live ref WITHOUT trying to re-write the backend row (which would refuse with agent-orchestrator/duplicate).
Throws agent-orchestrator/not-in-registry when no backend row exists for name. Throws agent-orchestrator/already-hydrated if the live ref is already installed (operator's boot phase ran twice).
Boot-phase contract: 1. Process A calls register("tenant-acme.mail", agent, regOpts) → backend row written; A.liveAgents holds the ref. 2. Process A crashes / redeploys. 3. Process B starts: backend row already exists. 4. Process B walks the registry via list() → sees rows it hasn't hydrated yet. 5. For each, Process B reconstructs the agent locally (from its operator config) and calls hydrate(name, agent). 6. lookup("tenant-acme.mail") from Process B now returns the live ref instead of throwing not-hydrated.
var rows = await orch.list({});
for (var i = 0; i < rows.length; i += 1) {
var name = rows[i].name;
var agent = buildAgent(rows[i]);
await orch.hydrate(name, agent);
}
b.agent.orchestrator.shardFor(shardKey, shards) #
Consistent-hash router for sharded topic dispatch. Operator passes a stable shard-key (e.g. tenantId or actor.id); orchestrator picks the topic suffix so each tenant's traffic owns one shard's ordering. Uses FNV-1a 32-bit — fast, good distribution for short keys, no cryptographic guarantees (shard routing is not security-bearing). Empty key returns 0; shards <= 1 always returns 0.
var shard = b.agent.orchestrator.shardFor("tenant-acme", 8);
// → integer in [0, 8)
b.agent.orchestrator.reseal(opts) #
{
store: Object, // { list(): rows[], set(name, row) } (the create() backend contract)
oldRootJson: string, // b.vault.getKeysJson() of the retired keypair
newRootJson: string, // b.vault.getKeysJson() of the new keypair
}
Re-seals every AAD-bound registry cell (tenantId / metadata) on an operator-supplied backend from the OLD vault keypair to the NEW one, out-of-band. The in-tree vault-key rotation pipeline only walks tables inside db.enc, so an operator-supplied orchestrator backend is unreachable to it — after a keypair rotation its cells would otherwise be orphaned under the retired root (CWE-320). Rebuilds each cell's AAD from the registered schema (one source of truth); only AAD-sealed cells are touched. The name row-identity column is the AAD anchor and is never sealed, so it is always present for the write-back.
await b.agent.orchestrator.reseal({ store: backend, oldRootJson: oldKeys, newRootJson: newKeys });
// → { table: "agent_orchestrator_registry", resealed: 4 }
Last updated 2026-08-08T16:39:49.652Z by seeder.