mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-23 18:17:44 +02:00
* feat(channel): add channel-agnostic engine Supervisor (MUL-3620) Stage-1 (MUL-3515) shipped the channel abstraction but nothing drove it. Add the generic engine that does: - channel.InboundHandler + Config.Handler: the single shared inbound entry the engine injects into every adapter (Hermes set_message_handler model). - channel.Channel.Connect now blocks for the connection lifetime (doc), so the supervisor can tie lease renewal to connection liveness. - new package channel/engine: Supervisor, generalized out of lark.Hub. It enumerates active installations across ALL channel types (no hard-coded feishu), fences each behind the WS lease CAS, builds the platform Channel via channel.Registry, drives Connect/Disconnect with backoff+jitter, and restarts on credential rotation. Knows nothing about any platform. channel.Channel is now driven by an engine; integrations/channel has an external consumer. Feishu adapter + boot cutover follow next. Tests: supervisor_test.go covers lease CAS, reclaim, reap-on-revoke, rotation restart + token fencing, backoff on build error, lease-loss teardown, bounded release, shutdown timeout. Race-clean. Co-authored-by: multica-agent <github@multica.ai> * feat(lark): drive Feishu through the channel engine; remove lark.Hub (MUL-3620) Refactor Feishu into the first channel.Channel and cut boot over to the channel-agnostic engine.Supervisor, removing the Feishu-only Hub. - feishuChannel implements channel.Channel: Connect runs the existing WS long-conn connector for one installation; Send posts a text reply via the Lark IM API; Capabilities declares Feishu's feature set. RegisterFeishu wires it to channel.TypeFeishu — adding a platform is now 'register a Factory', no engine edit. - FeishuRuntime extracts the former Hub.handleEvent / scheduleReply: runs the Dispatcher and drives the detached typing indicator + OutcomeReplier off the connector ACK path. main.go drains it on shutdown after the supervisor stops delivering events. - channelInstallationStore (engine.InstallationStore) enumerates active installations across ALL channel types via the new de-hardcoded query ListAllActiveChannelInstallations; the Supervisor routes each row to its registered Factory by channel_type. Generic per-row fingerprint replaces the feishu-specific one. - boot: engine.Supervisor replaces lark.Hub.Run; MULTICA_LARK_HUB_DISABLED keeps its name for runbook compatibility. - delete hub.go / hub_pgx.go / hub_test.go; relocate the connector contract (EventConnector/EventEmitter), uuidString, and the reply-path tests (-> feishu_runtime_test.go) so coverage is preserved. No channel_* schema change. Feishu behaviour unchanged; lark + channel + engine tests green under -race; go build/vet ./... clean. Remaining (follow-up): lift the Dispatcher pipeline into a channel- agnostic engine.Router over channel.InboundMessage + resolver interfaces, so the inbound core stops being Lark-shaped and adding a channel needs zero core edits (validated by Slack, MUL-3516). Co-authored-by: multica-agent <github@multica.ai> * feat(channel): add channel-agnostic engine.Router (inbound pipeline) (MUL-3620) Generalize lark.Dispatcher's inbound pipeline into engine.Router: the single shared channel.InboundHandler the Supervisor injects into every Channel. It routes by ChannelType to a registered ResolverSet and runs the same ordered pipeline for every platform (install route -> two-phase dedup -> group @bot filter -> identity+membership -> ensure session -> append+mark -> /issue -> debounced run), then drives the detached OutboundReplier + typing indicator. Platform specifics live behind resolver interfaces (InstallationResolver, IdentityResolver, Deduper, SessionBinder, Auditor, OutboundReplier, TypingNotifier) + shared services (IssueCreator/TaskEnqueuer/SessionReader). Adding a platform is 'register a ResolverSet', not 'edit the Router'. Outcome / DropReason values match the legacy lark ones 1:1. Additive: lark.Dispatcher untouched and still wired; the feishu ResolverSet, the cutover, and the old-path removal land next. channel.InboundMessage gains ForceFresh (the normalized /fresh affordance). Batcher moved into engine. router_test.go covers the pipeline invariants (routing, dedup finalize states, group filter, identity, membership, ensure/append, /issue, debounce, flush offline, force-fresh, drain) with generic fakes; race-clean. Co-authored-by: multica-agent <github@multica.ai> * feat(lark): cut Feishu over to engine.Router; remove lark.Dispatcher; core no longer Lark-shaped (MUL-3620) Wire the channel-agnostic engine.Router (added in the prior commit) as the shared inbound handler and refactor Feishu into a ResolverSet, completing the generic-engine cutover. The inbound core (engine.Router) now contains zero platform specifics. - Feishu ResolverSet (feishu_resolvers.go): InstallationResolver, IdentityResolver, Deduper, SessionBinder, Auditor, OutboundReplier, TypingNotifier — each backed by the existing ChannelStore / ChatSessionService / OutcomeReplier / typing indicator, translating at the channel.InboundMessage boundary (platform fields read from Raw). origin_type stays 'lark_chat'. - feishuChannel now produces a normalized channel.InboundMessage and hands it to the engine handler via channel.Config.Handler; the old Raw round-trip through lark.Dispatcher is gone. - Remove lark.Dispatcher, FeishuRuntime, and lark's pending_batcher (the engine owns the pipeline + batcher now); their behavioural coverage moved to engine.Router tests. Surviving native types (InboundMessage / Outcome / DispatchResult) relocated to feishu_types.go. elon review nits addressed: - The channel engine (Registry + Router + Supervisor) is now built UNCONDITIONALLY, outside the MULTICA_LARK_SECRET_KEY gate, so a non-Lark deployment runs it; Feishu registers its Factory + ResolverSet only when its key is present. - channel.Config.Raw is now genuinely the platform config JSONB (channel_installation.config): the feishu factory builds a credentials-only Installation from it, and the workspace/agent identity is resolved per message by the Router — no full-db-row marshaling. - feishuChannel gains direct unit tests: factory config decode, Send text + reply-target mapping, Capabilities, inbound normalization + Raw round-trip, msg-type + result mapping. No channel_* schema change. go build/vet ./... clean; channel + engine + lark green under -race. Feishu behaviour preserved (pipeline logic lifted verbatim, only generalized). Co-authored-by: multica-agent <github@multica.ai> * docs(channel): fix stale comments on the channel engine boot (MUL-3620) Address Elon's review nit: three comments still described the pre-cutover behavior. - handler.go: ChannelSupervisor is built UNCONDITIONALLY now, not nil when MULTICA_LARK_SECRET_KEY is unset. - main.go: same — the supervisor always exists; only MULTICA_LARK_HUB_DISABLED parks it. - router.go: with no platform registered the store still lists active rows; Registry.Build returns ErrUnknownType and the supervisor backs off (it does not 'find no installations'). Comment-only; no behavior change. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
152 lines
4.8 KiB
Go
152 lines
4.8 KiB
Go
package engine
|
|
|
|
import (
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// DefaultChatRunBatchWindow is the silence window the inbound debouncer waits
|
|
// before triggering an agent run for a chat session. 3s (MUL-2968): long
|
|
// enough to absorb a "forward a transcript, then type a note" burst into one
|
|
// run, short enough that the bot's first reply is not perceptibly late.
|
|
const DefaultChatRunBatchWindow = 3 * time.Second
|
|
|
|
// stoppableTimer is the slice of *time.Timer the batcher depends on, pinned to
|
|
// an interface so tests inject a manually-fired fake. *time.Timer satisfies it.
|
|
type stoppableTimer interface {
|
|
Stop() bool
|
|
}
|
|
|
|
// pendingBatcher debounces the per-chat_session run trigger. Each inbound
|
|
// message that lands in a session calls Schedule, which (re)arms a single
|
|
// timer for that session; when the session goes quiet for the window the
|
|
// latest flush runs exactly once. This collapses a burst into ONE agent run —
|
|
// safe because the chat task reads the WHOLE session at run time. Only the run
|
|
// TRIGGER is debounced; the chat_message rows, dedup, and frame ACK already
|
|
// happened synchronously upstream.
|
|
//
|
|
// State is in-process, keyed by chat_session_id (a globally-unique UUID). The
|
|
// WS lease guarantees a single active owner per installation, so a session is
|
|
// debounced by one process. A hard crash inside the window drops the pending
|
|
// trigger (messages are durable; they just do not fire a run until the next
|
|
// message). Graceful shutdown calls FlushAll so that boundary is not hit on a
|
|
// normal restart. Goroutine-safe; one instance is shared across supervisors.
|
|
type pendingBatcher struct {
|
|
window time.Duration
|
|
|
|
// afterFunc builds a timer that invokes fn after d. Defaults to
|
|
// time.AfterFunc; tests substitute a fake for deterministic flushes.
|
|
afterFunc func(d time.Duration, fn func()) stoppableTimer
|
|
|
|
mu sync.Mutex
|
|
pending map[string]*pendingEntry
|
|
// seq mints a monotonic generation per (re)schedule. onFire carries the
|
|
// generation it was armed with and bails if a newer schedule superseded
|
|
// it — fencing the AfterFunc race where a timer fires concurrently with
|
|
// the Stop() meant to cancel it.
|
|
seq uint64
|
|
stopped bool
|
|
inflight sync.WaitGroup
|
|
}
|
|
|
|
type pendingEntry struct {
|
|
timer stoppableTimer
|
|
flush func()
|
|
gen uint64
|
|
}
|
|
|
|
// newPendingBatcher returns a batcher with the given silence window. A
|
|
// non-positive window falls back to DefaultChatRunBatchWindow.
|
|
func newPendingBatcher(window time.Duration) *pendingBatcher {
|
|
if window <= 0 {
|
|
window = DefaultChatRunBatchWindow
|
|
}
|
|
return &pendingBatcher{
|
|
window: window,
|
|
afterFunc: realAfterFunc,
|
|
pending: make(map[string]*pendingEntry),
|
|
}
|
|
}
|
|
|
|
func realAfterFunc(d time.Duration, fn func()) stoppableTimer {
|
|
return time.AfterFunc(d, fn)
|
|
}
|
|
|
|
// Schedule (re)arms the silence window for key. The most recent flush wins:
|
|
// only session-level information is needed to fire a run, so keeping the latest
|
|
// closure (which captures the latest installation/message context) suffices.
|
|
// Calling Schedule after FlushAll runs the flush inline rather than dropping it
|
|
// (the shutdown race where a message arrives after the drain has begun).
|
|
func (b *pendingBatcher) Schedule(key string, flush func()) {
|
|
b.mu.Lock()
|
|
if b.stopped {
|
|
b.mu.Unlock()
|
|
flush()
|
|
return
|
|
}
|
|
b.seq++
|
|
gen := b.seq
|
|
fire := func() { b.onFire(key, gen) }
|
|
if e, ok := b.pending[key]; ok {
|
|
e.timer.Stop()
|
|
e.flush = flush
|
|
e.gen = gen
|
|
e.timer = b.afterFunc(b.window, fire)
|
|
b.mu.Unlock()
|
|
return
|
|
}
|
|
b.pending[key] = &pendingEntry{
|
|
flush: flush,
|
|
gen: gen,
|
|
timer: b.afterFunc(b.window, fire),
|
|
}
|
|
b.mu.Unlock()
|
|
}
|
|
|
|
// onFire runs the flush for key if it is still the live, armed generation. It
|
|
// is the timer callback; in production it runs on time.AfterFunc's goroutine,
|
|
// so the flush is naturally detached from the inbound path.
|
|
func (b *pendingBatcher) onFire(key string, gen uint64) {
|
|
b.mu.Lock()
|
|
e, ok := b.pending[key]
|
|
if !ok || b.stopped || e.gen != gen {
|
|
b.mu.Unlock()
|
|
return
|
|
}
|
|
delete(b.pending, key)
|
|
flush := e.flush
|
|
b.inflight.Add(1)
|
|
b.mu.Unlock()
|
|
|
|
defer b.inflight.Done()
|
|
flush()
|
|
}
|
|
|
|
// FlushAll stops the batcher and runs every still-pending flush exactly once,
|
|
// then waits for concurrently-firing flushes to finish. Call once from
|
|
// graceful shutdown AFTER inbound delivery has stopped. After FlushAll the
|
|
// batcher is terminal: later Schedule calls run inline.
|
|
func (b *pendingBatcher) FlushAll() {
|
|
b.mu.Lock()
|
|
b.stopped = true
|
|
entries := make([]*pendingEntry, 0, len(b.pending))
|
|
for _, e := range b.pending {
|
|
e.timer.Stop()
|
|
entries = append(entries, e)
|
|
}
|
|
b.pending = make(map[string]*pendingEntry)
|
|
b.mu.Unlock()
|
|
|
|
for _, e := range entries {
|
|
e.flush()
|
|
}
|
|
b.inflight.Wait()
|
|
}
|
|
|
|
// pendingCount reports how many sessions currently have an armed window.
|
|
func (b *pendingBatcher) pendingCount() int {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return len(b.pending)
|
|
}
|