From 6fe06f55ddbfa8559d1b46f38761389fbbf3b285 Mon Sep 17 00:00:00 2001 From: hzhang Date: Sat, 23 May 2026 11:37:08 +0100 Subject: [PATCH] feat(plugin): wire PresenceSync into gateway_start lifecycle (Phase 1.5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the Phase 1 hand-off chain — HF status now actually reaches Fabric.Backend.Guild and busy-discard on announce channels becomes operational end-to-end. inbound.ts: - Add getPresenceAccounts() — returns per-agent {agentId, fabricUserId, guildBaseUrl, fabricApiKey} for every agent that successfully logged in. fabricUserId comes from session.user.id cached on the identity registry; guildBaseUrl from session.guilds[0].endpoint captured in a new private firstGuildEndpointByAgent map during connectAgent(). - Multi-guild presence is deferred; the first guild per agent is the push target. For sim/prod-v1 each agent is in one guild so this is a no-op simplification. index.ts gateway_start: - After inbound.start() resolves, instantiate PresenceSync, call setAccounts(inbound.getPresenceAccounts()), start(). - 5-min refresh timer re-harvests accounts (catches agents added via tool-based identity registration AFTER initial start — e.g. recruitment flow). setAccounts is idempotent. - gateway_stop now clears the refresh timer and stops PresenceSync before stopping inbound. End-to-end check (still need sim verification): HF plugin scheduler heartbeat -> globalThis.__hfAgentStatus -> PresenceSync tick (30s) -> PUT /agents/:uid/presence -> agent_presences row -> computeDelivery for xType=announce -> busy recipients skipped, idle recipients get observer delivery. Type-check: only pre-existing openclaw/* runtime-resolved-by-jiti errors remain; new presence wiring compiles clean. See DIALECTIC-V2-DESIGN.md section 10 Phase 1 (deferred items now landed). --- index.ts | 28 +++++++++++++++++++++++++++- src/inbound.ts | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/index.ts b/index.ts index a744a5a..fb3247d 100644 --- a/index.ts +++ b/index.ts @@ -13,11 +13,17 @@ import { registerFabricTools } from './src/tools.js'; import { FabricClient } from './src/fabric-client.js'; import { IdentityRegistry } from './src/identity.js'; import { syncFabricCommands } from './src/command-sync.js'; +import { PresenceSync } from './src/presence-sync.js'; import path from 'node:path'; import os from 'node:os'; let runtimeRef: unknown = null; let inbound: FabricInbound | null = null; +let presence: PresenceSync | null = null; +// Periodic re-harvest of presence accounts so newly-connected agents +// (registered through tool-based identity flow AFTER initial start) +// get picked up. Cleared on gateway_stop. +let presenceRefreshTimer: ReturnType | null = null; export { fabricChannelPlugin } from './src/channel.js'; @@ -82,7 +88,24 @@ export default defineChannelPluginEntry({ api.logger, accounts, ); - void inbound.start(); + // start() resolves once all accounts have attempted login; per- + // agent failures are logged but don't reject. Once it resolves we + // can harvest the presence accounts (those that DID log in have + // their fabricUserId + first guild endpoint populated). + void inbound.start().then(() => { + if (!inbound) return; + presence = new PresenceSync(api.logger); + presence.setAccounts(inbound.getPresenceAccounts()); + presence.start(); + api.logger.info(`fabric: presence-sync started for ${inbound.getPresenceAccounts().length} account(s)`); + + // Re-harvest every 5 min: catches agents added via tool-based + // identity provisioning after gateway_start (recruitment flow). + // setAccounts is idempotent — duplicates collapse on agentId. + presenceRefreshTimer = setInterval(() => { + if (inbound && presence) presence.setAccounts(inbound.getPresenceAccounts()); + }, 5 * 60_000); + }); api.logger.info(`fabric: inbound started for ${accounts.length} account(s)`); void syncFabricCommands(client, cfg, accounts, api.logger); }); @@ -93,6 +116,9 @@ export default defineChannelPluginEntry({ // BEFORE deliver()). gateway_stop only flushes any leftover buffer. api.on('gateway_stop', () => { void flushAllFabric(); + if (presenceRefreshTimer) { clearInterval(presenceRefreshTimer); presenceRefreshTimer = null; } + presence?.stop(); + presence = null; inbound?.stop(); inbound = null; }); diff --git a/src/inbound.ts b/src/inbound.ts index 06c1973..3cafa01 100644 --- a/src/inbound.ts +++ b/src/inbound.ts @@ -263,8 +263,52 @@ export class FabricInbound { this.sockets = []; } + /** + * Per-account metadata harvested during `start()` — used by + * PresenceSync to know where to push each agent's HF status. + * + * `fabricUserId` is filled from `session.user.id` after agent-login. + * `guildBaseUrl` is the FIRST guild the agent is connected to (multi- + * guild presence push is a future concern; for sim/prod-v1 each agent + * is in one guild). + * + * Returns ONLY agents that successfully connected — failed-login + * agents have no fabricUserId yet and are excluded. + */ + getPresenceAccounts(): Array<{ + agentId: string; + fabricUserId: string; + guildBaseUrl: string; + fabricApiKey: string; + }> { + const out: Array<{ agentId: string; fabricUserId: string; guildBaseUrl: string; fabricApiKey: string }> = []; + for (const entry of this.identity.list()) { + if (!entry.fabricUserId) continue; + const presenceGuildUrl = this.firstGuildEndpointByAgent.get(entry.agentId); + if (!presenceGuildUrl) continue; + out.push({ + agentId: entry.agentId, + fabricUserId: entry.fabricUserId, + guildBaseUrl: presenceGuildUrl, + fabricApiKey: entry.fabricApiKey, + }); + } + return out; + } + + // Filled by connectAgent for each (agent, guild). Tracks ONLY the first + // guild per agent (used as the presence-push target). + private firstGuildEndpointByAgent = new Map(); + private async connectAgent(agentId: string, session: FabricSession): Promise { const selfUserId = session.user.id; + // First-guild capture for presence-sync push target. session.guilds is + // already in priority order from Center; we take the first one with a + // valid endpoint and stop. Multi-guild presence is a future concern. + if (!this.firstGuildEndpointByAgent.has(agentId)) { + const firstGuild = session.guilds.find((g) => typeof g.endpoint === 'string' && g.endpoint.length > 0); + if (firstGuild) this.firstGuildEndpointByAgent.set(agentId, firstGuild.endpoint); + } for (const g of session.guilds) { const tok = session.guildAccessTokens.find((t) => t.guildNodeId === g.nodeId)?.token; if (!tok) continue;