Files
Agent-System-Backend/internal/runmgr/events.go
T
weichen adcd8fefd2 Fence U3 account writes and stabilize directory cursors
Why: Account disable could be followed by late Backend business writes, API key creation could implicitly create users, account bootstrap locks could invert, and directory cursors were process-local or leaked out-of-scope changes.

Evidence: Local go test ./... and isolated PostgreSQL package suites passed on the staged candidate; exact committed-head SELF replay and stage 6 human review remain pending.

Impact: Adds final account transactions and external-effect checks across HTTP, Host, Bus and run manager; tightens API key issuance and persists versioned cursor signing. Delegated RDM rename still needs the separately proposed real-operator contract, so A1 and stage 6 are not complete.

Refs: Documents U3 r1, approved A1-A4 amendment fb3a821, pending delegated-projection amendment 5783940, Dev feature/iam-u3-account-directory-recovery.
2026-10-14 07:33:54 +00:00

236 lines
7.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package runmgr
import (
"encoding/json"
"log"
"sync"
"time"
"agent-system-backend/internal/bus"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// eventBuffer 是会话事件批处理器(写放大治理):
// - 高频增量(message.delta / thinking.delta / agent.progress)缓冲合并落盘;
// - 其余事件(终态、HITL、工具、阶段)立即落盘,落盘前先冲掉同会话缓冲保证顺序;
// - seq 在落盘事务内分配(会话内连续),发布顺序 = seq 顺序;
// - 停机清空缓冲;未冲盘的增量在崩溃时丢失(≤ interval 窗口,重放由终态消息树对账)。
//
// 会话内事件天然单线程(一个会话至多一个在途 run,子代理在父执行栈内同步运行),
// 少数跨 goroutine 路径(HITL resolve)与冲盘互斥由 flushing 锁保证不交叉。
type eventBuffer struct {
store *store.Store
bus *bus.Bus
accountGuardEnabled bool
allowImplicitLocal bool
interval time.Duration // 定时冲盘窗口
maxBuf int // 单会话缓冲条数上限(触发立即冲盘)
// onPersisted 落盘+本地发布后的回调(PG 档 NOTIFY 跨实例扇出;nil = 无)。
onPersisted func(conversationID string, evs []core.StreamEvent)
mu sync.Mutex
buf map[string][]core.StreamEvent
flushing sync.Mutex // 串行化落盘(批量插入很快,全局锁足够)
ticker *time.Ticker
done chan struct{}
wg sync.WaitGroup
}
func newEventBuffer(st *store.Store, b *bus.Bus) *eventBuffer {
eb := &eventBuffer{
store: st, bus: b,
interval: 150 * time.Millisecond,
maxBuf: 64,
buf: map[string][]core.StreamEvent{},
done: make(chan struct{}),
}
eb.wg.Add(1)
go eb.loop()
return eb
}
func (eb *eventBuffer) loop() {
defer eb.wg.Done()
eb.ticker = time.NewTicker(eb.interval)
defer eb.ticker.Stop()
for {
select {
case <-eb.done:
return
case <-eb.ticker.C:
eb.flushAll()
}
}
}
// stop 停机:停止定时器并清空全部缓冲(优雅停机时无增量丢失)。
func (eb *eventBuffer) stop() {
close(eb.done)
eb.wg.Wait()
eb.flushAll()
}
// isDelta 标记高频可缓冲事件。
func isDelta(typ core.StreamEventType) bool {
switch typ {
case core.EvMessageDelta, core.EvThinkingDelta, core.EvAgentProgress, core.EvToolCallDelta, core.EvToolOutput:
return true
}
return false
}
// emit 入队一条事件;返回已落盘事件的 seq(缓冲事件返回 0)与错误。
func (eb *eventBuffer) emit(conversationID, runID string, typ core.StreamEventType, payload any) (int64, error) {
e := core.StreamEvent{
ConversationID: conversationID, RunID: runID,
TS: time.Now().UTC().Format(time.RFC3339Nano), V: core.ProtocolVersion, Type: typ,
}
// 项目流(D50):伪会话键复用同一持久化/seq/重放机制;
// 信封在此盖章项目归属,前端按 ProjectID 识别项目流事件。
if pid, ok := core.ProjectFromChannel(core.ID(conversationID)); ok {
e.ProjectID = pid
}
if payload != nil {
buf, err := json.Marshal(payload)
if err != nil {
return 0, err
}
e.Payload = buf
}
if !isDelta(typ) {
// 立即事件:先冲掉同会话缓冲(保序),再单条落盘发布。
eb.flushConversation(conversationID)
batch := []core.StreamEvent{e}
if err := eb.append(conversationID, batch); err != nil {
return 0, err
}
return batch[0].Seq, nil
}
eb.mu.Lock()
eb.buf[conversationID] = append(eb.buf[conversationID], e)
n := len(eb.buf[conversationID])
eb.mu.Unlock()
if n >= eb.maxBuf {
eb.flushConversation(conversationID)
}
return 0, nil
}
// emitAccountChecked flushes any earlier deltas and the new structural event
// in one final account-checked Store transaction. A disabled owner loses its
// buffered deltas instead of persisting them after disable.
func (eb *eventBuffer) emitAccountChecked(actor, conversationID, runID string, typ core.StreamEventType, payload any, allowImplicitDefault bool) error {
e := core.StreamEvent{ConversationID: conversationID, RunID: runID,
TS: time.Now().UTC().Format(time.RFC3339Nano), V: core.ProtocolVersion, Type: typ}
if projectID, ok := core.ProjectFromChannel(core.ID(conversationID)); ok {
e.ProjectID = projectID
}
if payload != nil {
raw, err := json.Marshal(payload)
if err != nil {
return err
}
e.Payload = raw
}
eb.flushing.Lock()
defer eb.flushing.Unlock()
eb.mu.Lock()
batch := append([]core.StreamEvent(nil), eb.buf[conversationID]...)
delete(eb.buf, conversationID)
eb.mu.Unlock()
batch = append(batch, e)
if _, err := eb.store.AppendEventsAccountChecked(actor, conversationID, batch, allowImplicitDefault); err != nil {
return err
}
for _, event := range batch {
eb.bus.Publish(event)
}
if eb.onPersisted != nil {
eb.onPersisted(conversationID, batch)
}
return nil
}
// emitProjectShared persists an explicit internal publication and its proof
// before fan-out. A client-visible payload cannot opt itself into this path.
func (eb *eventBuffer) emitProjectShared(owner, producer, channel, runID string, typ core.StreamEventType, payload any) error {
project, ok := core.ProjectFromChannel(channel)
if !ok {
return store.ErrNotFound
}
encoded, err := json.Marshal(payload)
if err != nil {
return err
}
event := core.StreamEvent{ConversationID: channel, ProjectID: project, RunID: runID, Type: typ, Payload: encoded, TS: time.Now().UTC().Format(time.RFC3339Nano), V: core.ProtocolVersion}
eb.flushConversation(channel)
eb.flushing.Lock()
defer eb.flushing.Unlock()
events := []core.StreamEvent{event}
if eb.accountGuardEnabled {
_, err = eb.store.AppendProjectSharedEventsAccountChecked(owner, producer, channel, events, eb.allowImplicitLocal)
} else {
_, err = eb.store.AppendProjectSharedEvents(owner, producer, channel, events)
}
if err != nil {
return err
}
for _, event := range events {
eb.bus.Publish(event)
}
if eb.onPersisted != nil {
eb.onPersisted(channel, events)
}
return nil
}
// append 落盘并按 seq 顺序发布。
func (eb *eventBuffer) append(conversationID string, evs []core.StreamEvent) error {
eb.flushing.Lock()
defer eb.flushing.Unlock()
var err error
if eb.accountGuardEnabled {
_, err = eb.store.AppendEventsForEffectiveChannel(conversationID, evs, eb.allowImplicitLocal)
} else {
_, err = eb.store.AppendEvents(conversationID, evs)
}
if err != nil {
return err
}
for i := range evs {
eb.bus.Publish(evs[i])
}
if eb.onPersisted != nil {
eb.onPersisted(conversationID, evs)
}
return nil
}
// flushConversation 冲掉指定会话的缓冲(失败记日志丢弃:增量可由重放补偿)。
func (eb *eventBuffer) flushConversation(conversationID string) {
eb.mu.Lock()
evs := eb.buf[conversationID]
delete(eb.buf, conversationID)
eb.mu.Unlock()
if len(evs) == 0 {
return
}
if err := eb.append(conversationID, evs); err != nil {
log.Printf("[events] flush %s: %v(%d 条增量待重放补偿)", conversationID, err, len(evs))
}
}
func (eb *eventBuffer) flushAll() {
eb.mu.Lock()
ids := make([]string, 0, len(eb.buf))
for id := range eb.buf {
ids = append(ids, id)
}
eb.mu.Unlock()
for _, id := range ids {
eb.flushConversation(id)
}
}