Skip to content

feat(workspace): event-driven file freshness, drop the 2s polling - #1090

Draft
ChrAlpha wants to merge 15 commits into
felinics:mainfrom
ChrAlpha:feat/workspace-fs-freshness
Draft

feat(workspace): event-driven file freshness, drop the 2s polling#1090
ChrAlpha wants to merge 15 commits into
felinics:mainfrom
ChrAlpha:feat/workspace-fs-freshness

Conversation

@ChrAlpha

@ChrAlpha ChrAlpha commented Aug 26, 2026

Copy link
Copy Markdown
Member

解决 memohai/Memoh-Cloud#220(文件面板每 2 秒轮询 fs/list)。

问题

Web UI 的文件树、文件查看器、预览面板各自持有一个无条件的 2 秒 setInterval,只要标签页开着就持续拉取。除请求量本身(每 tick = 根目录 + 每个展开目录各一条,叠加 JWT + DB 鉴权 + bridge RPC)外,在 Cloud 上后果更重:E2B 沙箱按数据面活动判定 AutoPause,且数据面调用会透明唤醒 PAUSED 沙箱——挂着文件面板的标签页 = 沙箱永远无法暂停

方案

对照 VS Code(watcher 推送、零轮询)、GitHub Codespaces(空闲策略在会话层而非文件层)、JupyterLab(无 watch 基建时的退避轮询基线)后取形:

  • internal/fsevent:进程内 per-bot hub,200ms 合并窗口,>16 路径坍缩为通配;每订阅者独立信箱 goroutine,慢消费者不拖累他人。
  • 四个发布源:fs REST 变更端点;native 工具包装(assembleTools 内,write/edit 带路径、exec/apply_patch 通配,覆盖 web/渠道/schedule/后台全部触发面);ACP prompt 事件 sink(mapper 已归一化为 native 工具词汇,分类器 fsevent.ToolChange 两侧共享);bridge WatchDir 流(见下)。
  • WS 协议:服务端沿既有 per-bot WebSocket 推 fs_changed {paths|null},前端汇入现有 fs-beacon,树按变更路径的父目录+自身定向重列;客户端用 fs_watch {dirs} 上报展开目录集(借 reliable-request 队列断线自动重发)。
  • bridge WatchDir(新 gRPC,非递归):全部流复用单一 fsnotify watcher(每个 inotify instance 是稀缺内核资源,默认 128/user),按目录 refcount Add/Remove,sink 溢出退化为整目录重列;旧 bridge 返回 Unimplemented 自动退避,Cloud/E2B 天然走无 watch 降级。
  • host FSWatchService:watch 生命周期 = 观察者注意力(面板隐藏即清空);per-bot 128 / 全局 1024 预算;不变量:每处 watch 删除后跟一次 promotion pass(排除 just-died key 以保留重试退避);unsupported 标记自带 TTL 到期自动重获。
  • 前端:三处 2s 轮询全部移除,换成注意力门控 ticker(仅当前 bot 有活跃 turn 时 5s 兜底,重获可见性补一次),目录列取串行防重入,后台刷新失败静默保留旧数据。

空闲标签页净流量归零;活跃期刷新由事件驱动。

验证

  • go build ./...go test ./... 全绿;internal/fseventinternal/workspace{,/bridge,/bridgesvc}internal/handlers 追加 -race -count=3 绿。
  • vitest web 1181 通过;仅 useMediaGallery.test.tspanel-preview.test.ts 两个既有基线失败(在 origin/main 上同样失败,环境性:node 环境缺 localStorage / vue-i18n mock)。
  • swagger/sdk 无需重生成(纯 WS 协议扩展,无 REST 变更)。

备注

  • dev 环境验收需先 mise run bridge:build(WatchDir 需新 bridge 二进制;旧的优雅降级为纯事件模式)。
  • Cloud submodule 同步在本 PR 合并后按双 PR 准则另行处理。

⚠️ No human QA — this PR has not been verified by a human yet. Remove this line once a human confirms the happy path.

ChrAlpha and others added 15 commits August 26, 2026 13:51
Replace the unconditional 2s polling in files-pane, file-viewer and
panel-preview with an attention-gated freshness ticker: passive refresh
runs only while the surface is visible and a turn is streaming for the
current bot, with one catch-up tick on regained attention. fs-change
signals now carry path-scoped refreshes into the tree, background
refreshes are serialized per directory and stay silent on failure.
Each web WS connection subscribes to the fsevent hub for its bot;
debounced change batches arrive as {type: fs_changed, paths} where a
null paths means unknown scope. The web client routes them into the
existing fs-change beacon, so the files pane, file viewer, and preview
refresh from server-side signals regardless of which session, surface,
or member caused the change.
FSWrite/FSUpload/FSMkdir/FSDelete/FSRename/FSExtract publish the
touched paths to the fsevent hub after success, so every connected
viewer of the bot refreshes when another tab, member, or the desktop
app mutates the workspace.
assembleTools wraps write/edit (path-scoped) and exec/apply_patch
(wildcard) so every successful execution publishes to the fsevent hub
with the turn's bot id — covering web, channel, schedule, background
and subagent surfaces alike.
An fs-change sink wraps the prompt event stream: successful
ToolCallEnd events whose mapper-normalized name is fs-mutating publish
their paths to the fsevent hub with the prompt's bot id. Tool
classification is shared with the native wrapper via
fsevent.ToolChange.
New server-stream RPC on ContainerService: the in-container bridge
watches one directory non-recursively via fsnotify, coalesces events
into 100ms batches of container-visible paths, and ends the stream
when the watched directory disappears. bridge.Client.WatchDir exposes
it host-side with ErrWatchUnsupported for older bridges.
FSWatchService keeps one refcounted bridge WatchDir stream per
(bot, dir), driven by each web WS connection's fs_watch message — the
files pane reports its expanded directories (debounced, resent on
reconnect via the reliable-request queue) and clears them when hidden.
Batches flow into the fsevent hub; a dying stream publishes its
directory as stale and retries once while still wanted; bridges
without the RPC back off via ErrWatchUnsupported. Watch lifetime
equals viewer attention, so idle workspaces carry no watches.
Hub deliveries now run on one goroutine per subscription draining a
single merged pending batch, so a wedged WebSocket writer can neither
block flush timers (goroutine pile-up) nor delay other subscribers;
backlog coalesces instead of queueing. The ws writer also sets a write
deadline so a peer that stops reading cannot wedge the writer
goroutine that Close() waits on.
…restart after unsupported TTL

An established watch stream dying now publishes a wildcard — the
previous path-scoped signal only refreshed the parent listing, leaving
the stale directory's own contents unrefreshed. Client-side,
dirsFromChangedPaths includes each changed path itself alongside its
parent so a directory-valued signal (deleted watched dir) re-lists
that directory too.

SetSubscription also (re)acquires every wanted key rather than only
newly added ones, so a subscription whose watch was marked unsupported
restarts after the TTL even when the client re-sends an identical
directory set (workspace upgraded to a watch-capable bridge).
…cation

The unsupported mark now schedules its own TTL-expiry timer that
reacquires still-wanted keys — the client's fs_watch reporter
suppresses identical sets, so nothing external retriggers acquisition
after an in-place bridge upgrade. startWatchLocked also enforces
per-bot (128) and process-wide (1024) watch budgets: every unique
(bot, dir) costs a host goroutine, a bridge stream, and bridge watch
state, and the per-connection 64-dir cap alone lets many connections
amplify without bound. Over-budget keys stay subscribed but watchless;
existing watches are never evicted.
Each inotify instance is a scarce kernel resource
(fs.inotify.max_user_instances defaults to 128); one watcher per
stream let a busy files pane starve every other inotify user in the
container. Streams now share a single lazily-started watcher with
per-directory refcounted Add/Remove and per-sink mailboxes; sink
overflow degrades to reporting the watched directory itself as
changed, and watcher failure ends all streams so hosts re-subscribe
against a fresh instance.
…rees

Budget-rejected keys stayed watchless forever once capacity opened up:
releaseLocked only deleted the freed watch, and the client suppresses
unchanged fs_watch reports, so nothing retriggered acquisition. Any
release path (drop, set shrink) that actually frees a watch now runs
one promotion pass over still-wanted watchless keys, bounded by the
existing budget and unsupported gates.
runWatch's deletion paths (unsupported and stream failure) freed
budget without promoting, so a cross-bot waiter could stay watchless
indefinitely while unsupported-TTL timers only reacquired the failing
bot's own keys. Both branches now run the promotion pass under the
same lock; the just-died key is excluded so its recovery stays with
the delayed retry instead of spinning through promotion on a
persistent failure. Invariant: every s.watches deletion is followed by
one promotion pass.
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.

2 participants