8000 feat(runtime): integrate distributed session runtime by Fodesu · Pull Request #801 · felinics/Memoh · GitHub
[go: up one dir, main page]

Skip to content

feat(runtime): integrate distributed session runtime - #801

Closed
Fodesu wants to merge 7 commits into
felinics:mainfrom
Fodesu:feat/session-runtime-backend-integration
Closed

Fodesu wants to merge 7 commits into
felinics:mainfrom
Fodesu:feat/session-runtime-backend-integration

Conversation

@Fodesu
@Fodesu Fodesu commented Jul 14, 2026 •
Copy link
Copy Markdown
Member

Summary

Backend half of the session-runtime cutover. Builds on the merged foundation in
#800 and wires distributed ownership, decision routing, retry/edit replacement,
and the snapshot/delta contract the Web client in #802 consumes.

  • canonical runtime_snapshot / runtime_delta for active and terminal runs
  • one owning server per active run, with commands routed to it across processes
  • approval and ask_user routed to the active owner, or a fenced continuation
  • lease detects a dead owner; a PostgreSQL fencing token blocks stale writes
  • legacy WebSocket frames kept for older Web clients
  • stable public error codes; private causes stay in server logs

Architecture

flowchart TB
    Handler["internal/handlers<br/>LocalChannelHandler (HTTP/WS)"]
    Runtime["internal/sessionruntime<br/>Manager · ownership · subscriptions"]
    Decision["internal/decisionruntime<br/>approval / ask_user routing"]
    Flow["internal/conversation/flow<br/>retry/edit prep · agent stream"]
    Fence["internal/runtimefence<br/>PostgreSQL write token"]
    Message["internal/message<br/>StartCanonicalTurn / AppendCanonicalTurn"]

    Handler --> Runtime
    Handler --> Decision
    Runtime --> Flow
    Decision --> Flow
    Flow -->|"each step, guarded"| Fence
    Fence --> Message
    Runtime -.snapshot/delta.-> Handler
    Message -.message_created.-> Handler
Loading

Distributed ownership

A run is stateful in-process: the server that admits it holds the Go abort
channel, injection channel, and the live agent stream. That server is the
owner. State that must survive a process (status, messages, ownership) lives
in the backend; the live control does not.

  • Owner server. Ownership is recorded as a StreamRef{OwnerID, Generation}
    and enforced by Manager.ValidateRunOwnership (internal/sessionruntime/ownership.go).
    Only the owner can drive the run, because only it holds the in-process channels.

  • Redis command channel. The pub/sub primitive
    (RedisBackend.PublishCommand / SubscribeCommands, keyed per owner as
    commands:<ownerID>) was added in the merged foundation feat(runtime): add session runtime foundation #800. This PR wires it
    up: Manager.DispatchActiveCommand runs a command locally when
    ownerID == m.ownerID, otherwise dispatchRemoteCommand publishes it to the
    owner and waits for the result (internal/sessionruntime/routed_command.go).
    This is how an approval or abort received on server B reaches a run owned by
    server A.

  • Owner lease. OwnerLeaseExpiresAt is a TTL in Redis, renewed by the owner
    via RenewLease (renewRedisLeaseScript in internal/sessionruntime/redis.go).
    It detects a dead or partitioned owner: once the lease expires the run is no
    longer owned, another process can reconcile it, and the old owner starts
    failing ValidateRunOwnership (ctrl.leaseIsValidAt). The lease stops two
    servers from both believing they own the same run.

  • Fencing token. A lease is time-based, so a lagging owner can still be
    running for a moment after losing it. runtimefence.Fence{Token} is a
    monotonic PostgreSQL generation — "a newer run increments Token and
    permanently invalidates older fences" (internal/runtimefence/fence.go).
    Every canonical write validates the token (ValidateScope), so a stale owner's
    append or commit is rejected with ErrStale even if its lease check briefly
    raced. The lease guards who may run; the token guards which run may write
    history
    , re-checked on every step append.

Restack note

a2697c7fc (replacement message_created) and 3d5c1e9e6 (public error
sanitization) are the review fixes and now live here. #802 is rebased on
3d5c1e9e6; GitHub lists these commits there too only because both PRs target
main.

Validation

  • go test ./internal/handlers ./internal/message ./internal/sessionruntime
  • mise run runtime-contract-fixtures

@qqqqqf-q qqqqqf-q left a comment
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the integration locally against real Redis 7 + PostgreSQL 16 (sessionruntime memory+Redis contract suites, runtimefence, handlers contract + multiprocess tests, conversation/flow, userinput, toolapproval, message, acpagent). All green.

The wiring quality is high: text-delta batching (20ms/4KB) addresses the per-token Redis round-trip concern from #800, the legacy/runtime protocol switch is mutex-coordinated so a session never sees mixed frames, ownership is checked three times before durable writes, and PreserveDecision is correctly connected on the WS deferred response paths. No lease leaks found on any error path I traced.

Main finding — side-band decision responses carry no fence.

Channel-inbound replies (internal/channel/inbound/channel.go, e.g. handleUserInputResponseCommand ~:3447) and the HTTP approval endpoints (internal/handlers/tool_approval.go ~:79) call the resolver with a bare context: no runtimefence.WithContext, no allocate/activate. In distributed mode, once a decision has been claimed by a run (runtime_fencing_token non-NULL), a side-band response falls into the SQL's runtime_fencing_token IS NULL branch → ErrNoRows → the user gets "already decided / not found" while the approval silently does nothing — with no log indicating this was a fence rejection. The WS path has the full deferred + PreserveDecision fallback; side-band has none. Same user action, two different semantics depending on entry point.

Also note: #817 (ask_user cross-channel interactions) just merged into main, so this branch now conflicts — including the migration 0111 number collision and resolver_user_input.go. Suggest rebasing onto main first; the side-band fence integration should be designed together with #817's plain-text channel responses, e.g. side-band paths should reuse the Prepare*Response interfaces to obtain a PreservedDecision rather than growing a parallel path. Happy to help think through that integration — it touches code I just wrote in #817.

Non-blocking:

  1. redis.go WATCH/MULTI loops retry unbounded on TxFailedErr with no backoff or cap (:183, :240, :308, :379) — carried over from #800, but this PR puts live traffic on them. Under contention (concurrent steer/abort on one session) this can spin.
  2. Lease-loss cleanup is soft: cancelRunControl (control.go:395) only revokes ownership and signals abortCh; if the runner is blocked on a ctx-unaware call, the run shows running until lease TTL. A watchdog marking the run lost on lease failure would close the window; at minimum worth a documented note.
  3. ErrStale / ErrRuntimeDecisionOwnerUnavailable surface as raw English strings via wsErrorMessage (resolver_tool_approval.go:224, resolver_user_input.go:117) instead of structured errors — the frontend can't distinguish "superseded by a newer run" from a real failure, so it can't offer actionable guidance like "refresh and retry".

@Fodesu
Fodesu force-pushed the feat/session-runtime-backend-integration branch 3 times, most recently from 986e671 to 6083e94 Compare July 17, 2026 10:39
@Fodesu
Fodesu force-pushed the feat/session-runtime-backend-integration branch 2 times, most recently from 899893f to dc1be29 Compare July 17, 2026 13:13
@Fodesu
Fodesu marked this pull request as ready for review July 17, 2026 13:28
@Fodesu
10000
Fodesu requested review from a team July 17, 2026 13:28
}
go func() {
defer h.releaseRuntimeCommand()
handled, dispatchErr := h.sessionRuntime.DispatchActiveCommand(baseCtx, botID, sessionID, commandType, targetID, raw)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1:decision 的 runtime fencing 只覆盖了 WebSocket 入口;IM channel 和 HTTP 入口在 decision 被 runtime 认领后会直接失败。

WS 入口的做法是对的:先经 routeWSRuntimeResponse → DispatchActiveCommand 把响应路由给当前 owner;没有 active run 时,tool approval 和 ask_user 都会走 Prepare*Response + PreserveDecision 激活新的 fenced continuation。

但另外两类入口没有这套逻辑,都是拿普通 context 直接调 resolver:

decision 一旦被某个 runtime run 认领,runtime_fencing_token 即非空,SQL 层要求 token 匹配(user_input、tool_approval)。裸 context 带不出 token,更新匹配不到行,最终映射为 ErrAlreadyDecided / ErrNotFound 或 HTTP 404/409。

结果:同一个 pending approval / ask_user,Web(WS)上能正常回复,换成 Telegram / Discord / Feishu 或 HTTP API 就报「未找到 / 已处理」——decision 实际没提交,原 run 也继续挂着。

建议所有入口复用同一套与 transport 无关的 decision router:

  1. 有 active run 时,用稳定的 command ID + payload hash 路由给当前 owner;
  2. 没有 active owner 时,复用 prepare/preserve 流程激活新的 fenced continuation;
  3. 所有权建立后才真正执行 decision response。

不建议放宽 SQL fencing 条件来兼容这些入口——那等于重新允许旧 owner 或无权 writer 提交 decision。

验证建议:在现有 PostgreSQL fencing 集成测试基础上,用真实 Redis/Valkey + PostgreSQL 起两个 server 进程:B 持有包含 pending approval / ask_user 的 run,从 A 的 HTTP 和 IM channel 提交响应。断言 decision 只提交一次、原 run 能继续、重复 payload 幂等恢复、不同 payload 返回冲突,并覆盖 owner 在数据库提交后、command result 发布前崩溃的窗口。

Comment thread internal/agent/agent.go Outdated
@@ -621,9 +590,68 @@ func (a *Agent) runStream(ctx context.Context, cfg RunConfig, ch chan<- StreamEv
// a fully-disconnected consumer hanging this goroutine forever.
deliveryCtx, deliveryCancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1:terminal hook 会消耗 terminal event 的 5 秒投递预算,可能导致终态事件丢失、历史不落库。

时序问题:runStream 结束时先创建 5 秒的 deliveryCtx,再同步执行 runTerminalHook(),最后才 sendEvent。而 hook 侧单个 action 默认就允许运行 10 秒,多个 hook/action 在 hooks.Service.Run 中串行执行,总时长没有上限——正常配置下 hook 就可能超过 5 秒。

hook 返回时 deliveryCtx 可能已过期。Agent.Stream 用的是无缓冲 channel,sendEvent 此时会走 <-deliveryCtx.Done() 分支,terminal event 没有交给 resolver。

影响不止是客户端收不到结束态:resolver 依赖 terminal snapshot 分支做终态历史持久化并设置 HistoryCommitted;事件丢失后会落入无 snapshot 跳过持久化的分支——已生成的回答不进历史,runtime 无法形成可靠的 canonical terminal,刷新或重连后状态与持久化结果不一致。hook 的 action 数量和超时都可配置且串行累加,配置得长一些还会拖住整个 agent stream goroutine。

建议把 hook 执行和 terminal 投递拆成两份独立预算:

  1. hook 在受 TerminalHookAuthority 约束、且有明确总超时的 context 中执行;
  2. 无论 hook 成功、失败还是超时,结束后重新创建一份完整的 delivery timeout,再发送 terminal event。

这样既保持「hook 先于终态」的顺序,也不让 hook 占用终态事件的投递时间。

验证建议:在 terminal hook authority 测试中增加一个运行超过 5 秒的 hook,断言 terminal event 恰好收到一次、历史持久化成功且 HistoryCommitted=true;同时覆盖 hook 报错、hook 超时、用户取消、执行期间丢失 runtime ownership,确认这些情况都不会阻塞或吞掉终态事件。

@Fodesu
Fodesu force-pushed the feat/session-runtime-backend-integration branch from dc1be29 to d664af2 Compare July 17, 2026 18:31
@Fodesu
Fodesu requested review from a team and ix64 July 17, 2026 18:31
@Fodesu
Fodesu force-pushed the feat/session-runtime-backend-integration branch from d664af2 to 6636b7b Compare July 20, 2026 15:16
@Fodesu
Fodesu force-pushed the feat/session-runtime-backend-integration branch 2 times, most recently from c122dc6 to 1464316 Compare July 22, 2026 08:52
@akazwz
Copy link
Copy Markdown
Member

整体看下来方向没啥问题,前面提到的 HTTP/IM 回复和 terminal hook 也都补上了。

我这边还看到两个收尾点:

  • retry/edit 后数据库虽然更新了,但 message_created 好像没发,其他 tab 可能不会刷新。之前这里有 publishReplacementMessageCreated,这次重构后应该是漏掉了,补回来再加个测试就行。
  • runtime error 还有几处直接把 err.Error() 往前端透,Redis/DB 的报错细节可能也会一起出去。最好换成固定 code 和安全文案,原始错误留在日志里。

另外 CI 还有个 gofumpt 小问题。整体不用改方案,把这几个点收一下就差不多了。

@Fodesu
Fodesu commented Jul 22, 2026
Copy link
Copy Markdown
Member Author

整体看下来方向没啥问题,前面提到的 HTTP/IM 回复和 terminal hook 也都补上了。

我这边还看到两个收尾点:

* retry/edit 后数据库虽然更新了,但 `message_created` 好像没发,其他 tab 可能不会刷新。之前这里有 `publishReplacementMessageCreated`,这次重构后应该是漏掉了,补回来再加个测试就行。

* runtime error 还有几处直接把 `err.Error()` 往前端透,Redis/DB 的报错细节可能也会一起出去。最好换成固定 code 和安全文案,原始错误留在日志里。

另外 CI 还有个 gofumpt 小问题。整体不用改方案,把这几个点收一下就差不多了。

刚刚重构成了 step 粒度的持久化,现在还在测试修复一些bug

@Fodesu
Fodesu force-pushed the feat/session-runtime-backend-integration branch from 1464316 to a4d2011 Compare July 22, 2026 13:27
Fodesu added 5 commits July 23, 2026 15:09
Notify session subscribers after atomic retry and edit replacements so other clients refresh the canonical tail.
Expose stable runtime error codes and localized safe fallbacks while retaining private backend causes only in server logs.
Publish new run admissions and committed ask_user outcomes so remote observers stay idempotent across owners.
Keep required runtime message collections as [] across Redis checkpoints so reconnecting strict clients receive a valid wire shape.
@Fodesu
Fodesu requested a review from a team July 23, 2026 19:50
@Fodesu Fodesu closed this Jul 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

0