已知限制
这些是代码有意不做的地方,原因通常是再往前做就要替 host 管它该自己管的事。它们都不是 bug;遇到故障请看故障排查。
进程与部署
不会替你启动任何进程
- 限制:
noeta-runtime和noeta-sdk都是库:没有 CLI、没有 HTTP/SSE 服务、没有调度守护进程。没有 worker 在跑时,入队的 Task 只会一直待在队列里。 - 办法: 自己跑一个
WorkerLoop,或调用Client.start_workers(n)。examples/reference-host是最小的 host 示例,只用了公开接口。
多机部署需要 Postgres
- 限制: 多个 worker 进程共用一个数据库,只有 Postgres 是安全的(写事件和校验租约在同一个事务里,租约过期按数据库时钟算)。SQLite 和内存存储只能单机用,两个进程指向同一个 SQLite 文件是不安全的。
- 办法: 跨机器就用 Postgres。单机上跑 worker 池没问题;同一进程里多个 client 也可以共用存储,每个 client 用自己的
HostConfig.queue,子任务沿用父任务的队列,worker 不会跨队列取活。本版修复:以前在同一个存储上再开一个Client,它启动时的恢复会把另一个Client的后台子 agent 重跑一遍;现在恢复会跳过别的 client 还在跑的子 agent。参见 ADR:多机租约隔离、worker 队列路由。
Postgres:每个存储适配器一条连接,没有连接池
- 限制: 事件日志、调度器、内容存储这几个 Postgres 适配器各自只用一条连接,外面套一把锁,所以同一个适配器上的调用是排队执行的。连接断了(数据库重启、空闲被踢、网络中断)会自动重连:单条语句重发一次;事务在发出
COMMIT之前断开,就从头重跑整个事务。如果恰好断在COMMIT过程中,写入到底成没成功说不准,这时直接报错,不重试——带幂等键的事件写入除外,它重试是安全的。数据库一直起不来的话,调用照样失败。 - 办法: 想要更高的数据库吞吐,就多开几个 worker 进程(每个进程有自己的连接)。参见 ADR:多机租约隔离。
持久性
崩溃恢复撤销不了副作用
- 限制: 一步跑到一半被硬杀后,这次尝试会被标记为
StepAttemptAbandoned。只有当它记录下的所有操作本来都不需要审批时,才会自动重跑这一步;否则(或同一轮已经连续标记 3 次)Task 会被搁置:挂起,并附一条origin="system"的通知,逐条列出被打断的调用以及它是否完成。经人工批准的工具执行期间崩溃,一律搁置,重新挂回同一次审批。恢复不会悄悄重跑有副作用的调用,但已经做了的也撤销不了。 - 办法: 打开被搁置的 Task,确认列出的操作到底执行了没有,然后发消息继续(这一轮从尝试开始前的状态重来),或重新批准。正常的 SIGTERM 不会触发这种情况。
关闭时可能留下一个还在跑的步骤
- 限制:
stop()最多等shutdown_grace_s,超时就放弃当前这一步。Python 杀不掉那个线程,它可能还在写事件日志。 - 办法: 放弃之后直接退出进程。租约过期后
requeue_stale()会把 Task 捡回来。shutdown_grace_s=None(或<= 0)表示无限等待,卡死的步骤就只能kill -KILL <pid>。
心跳续租有上限
- 限制: 一步最多持有租约
heartbeat_interval × heartbeat_max(默认 360 次,实际是几个小时)。超过后租约被释放,下一次写入报InvalidLease。 - 办法: 遇到了就去检查这个 Task,别当成会自动恢复。
可观测性
可靠性事件只在本进程内
- 限制: worker 的信号(
stale_requeued、suspended_without_wake、step_failed_retryable、heartbeat_invalid_lease、shutdown_abandoned、timers_fired、attempt_abandoned、attempt_parked、cap_terminal_reconciled、dispatcher_unavailable)默认只写结构化日志,不进事件日志,进程重启就没了。 - 办法: 传一个
reliability_sink,把它们转发到你的监控系统。
Task 等人回复时没人收到通知
- 限制: Task 挂在
HumanResponseReceived唤醒条件上,answer负责送回复,但不会发 webhook、邮件或任何收件箱通知。 - 办法: 订阅一个
Observer,把UserQuestionRequested转发到你自己的通知渠道,再用answer回复。
增长与成本
目录里没有的模型:保守压缩,价格为 0
- 限制: 不认识的模型按 128,000 token 窗口、16,384 token 输出上限处理(压缩照常开启,但可能压得偏早),价格按
0.0算,所以GovernanceState.cost一直是 0,max_cost_usd永远不会触发。每项在日志里提示一次。 - 办法: 通过
HostConfig(extra_models={...})或noeta.sdk.providers的register_models注册一条ModelSpec。
内容只在你主动要求时回收
- 限制: 内容存储不会自己清理。
Client.delete_task会删掉整棵任务树的事件和调度状态,但保留内容块,因为它们可能按哈希被别的 Task 共用(树里还有 Task 持有租约时,它会返回reason="running"拒绝删除)。被新快照取代的旧TaskSnapshot正文(每轮一份,每份都是完整任务状态)也会一直留在盘上,直到有人清。唯一从来不落盘的是发给模型的请求:LLMRequestStarted.request_ref只记模型看到的那份请求的哈希,正文只有在HostConfig(record_llm_requests=True)时才写,而且清理时会一并删掉。 - 办法: 按你自己的节奏(每晚一次的定时任务、健康检查里)调
Client.collect_garbage(grace_seconds=3600.0, vacuum=False)。它会保留所有还有事件引用的内容(直接引用或经由别的内容间接引用),其余的只要比宽限期老就删,所以任务正在跑也能安全调用。vacuum=True会在删完后收缩 sqlite 文件,但整个重写期间会占住写锁,放到空闲时段跑。Postgres 上跑的是普通的VACUUM content(空间可复用但不归还系统;VACUUM FULL由你自己安排)。没有Client的话,noeta.sdk.storage.collect_garbage(event_log, content_store, ...)对你自己打开的存储做同样的事。自己接入的第三方存储需要在 content store 上实现sweep方法,否则调用会返回reason="unsupported"。
Sandbox
不提供容器创建
- 限制:
SandboxProvider只是一个协议。自带的唯一实现是连接一个已经在跑的容器(来自SandboxExecEnvConfig),release什么也不做。创建和回收容器是 host 的事。 - 办法: 自己实现
SandboxProvider,传给HostConfig.sandbox_provider。allocate返回一个SandboxHandle;Task 恢复时,attach按TaskHostBound上记录的exec_env_ref重新连上容器。见 Sandbox。
Sandbox 里的副作用不受租约约束
- 限制: 对容器的调用走 HTTP,不在保护事件日志写入的 Postgres 事务里。一个已经丢了租约的 worker(GC 停顿、被
SIGSTOP)仍然能操作容器,属于"至少一次",跟宿主机上跑了一半的Bash一样。影响范围只限于这个根任务自己的容器。 - 办法: 没有自动办法,靠上面崩溃恢复那一套重跑和人工检查兜底。
Sandbox 里被停下的 Bash 不带回输出
- 限制: 每条前台命令在容器里有自己的 shell,中断、取消、关闭会话或者超时都会在容器里把它杀掉(2026-09-25 起)。这样停下的命令不会带回已经输出的内容,本地跑的则会带回停下前打印的部分。命令正常结束时,它放到后台的进程(
server &)不会被杀,这点跟宿主机一样。 - 办法: 输出多的命令把结果写到工作区的文件里,停下后用
Read去看。
后台 shell 只能在宿主机用
- 限制:
Bash(run_in_background=true)(以及BashOutput/KillShell)依赖宿主机的后台执行器,在 sandbox 里会直接报错。 - 办法: 改成前台运行并给足
timeout,或者不在 sandbox 里跑。
Sandbox 浏览器只看文字
- 限制: 五个工具(
browser_navigate、browser_click、browser_type、browser_extract、browser_screenshot)只有在容器里有可用浏览器、且启用了browser插件时才会出现。browser_extract返回页面文字和编号的可交互元素;browser_screenshot把 PNG 存进工作区,但不会给模型看。浏览器跟容器同生命周期、同成本。 - 办法: 读内容用
browser_extract,不需要交互的页面用WebFetch,截图留给人看。
不开放的扩展点
不能替换上下文组装器
- 限制: 替换
ContextComposer会破坏 provider 缓存依赖的固定提示词前缀。只开放只增不改的钩子:ContentKindSpec常驻内容,或组装时的reminder。 - 办法: 用这两个钩子,或者通过
policy插件点替换Policy。见上下文。