fix(agent): a turn's check baseline is captured before its tools run, not by them

checkContract carried a lazy write: the first caller to ask what the task owed
captured the baseline the task is held to. Every parallel tool call asks as it
finishes, so that first ask arrives on several goroutines at once — -race
reports the read at final_readiness.go against the write beside it, through
obligations -> finishToolExecution -> executeOne -> runParallel.

Two baselines could overwrite each other, and the baseline is what decides
whether the turn's delivery claim stands.

The capture moves to beginRunTurn, where nothing else is running, and the ask
becomes a pure read. That is also the stronger reading of its own rule: the
baseline must come from the pre-image, before any call could rewrite the
declaration it is taken from. Guarding the write instead would have kept a
mutable field on a path that has no reason to write at all.

TestCancelInsideLargeParallelBatchStopsSchedulingNewTools and
TestBuildRegistersUsableHistoryAndMemoryRetrievalTools both go green under
-race; both failed on it before.
This commit is contained in:
yhh committed 2026-08-28 01:51:32 -07:00
1 parent e85e3e5511
commit e44b3871d2
4 files changed
+20 -43

No files matched your search

+3 -4
View File
@@ -113,10 +113,9 @@ func (a *Agent) captureCriteriaUnder(store *evidence.BaselineStore, root string)
// captureCriterionAt files one path if it carries criteria the host does not
// hold. Already held comes first: a criterion captured earlier is safe whatever
// the store can do now, so a backend that has gone unwritable never re-blocks a
// change to something already kept. The map records a durable write, not a
// handle — a store that later loses its contents surfaces at evaluation as
// unavailable rather than here.
// the store can do now, so a backend gone unwritable never re-blocks a change
// to something already kept. The map records a durable write, not a handle — a
// store that later loses its contents surfaces at evaluation, not here.
func (a *Agent) captureCriterionAt(store *evidence.BaselineStore, path string) error {
if _, held := a.task.baselineCriteria[path]; held {
return nil
+15 -5
View File
@@ -333,17 +333,27 @@ func (a *Agent) mutationEpoch() uint64 {
// re-reading them is how a rewritten declaration would come to speak for the
// requirement it replaced.
func (a *Agent) checkContract() evidence.CheckContract {
return evidence.CaptureCheckContract(a.task.checkpoint.BaselineChecks, a.declaredChecks())
}
// declaredChecks is what the project asks for right now, which the contract
// pairs against the baseline rather than trusting on its own.
func (a *Agent) declaredChecks() []string {
current := make([]string, 0, len(a.projectChecks))
for _, check := range a.projectChecks {
current = append(current, check.Command)
}
return current
}
// captureBaselineChecks fixes what this task is held to, and never recaptures:
// after it the project may declare what it likes. It runs once at the turn's
// start, where nothing else is running — asking for it lazily made the capture
// a write that every parallel tool call reached at the moment it finished.
func (a *Agent) captureBaselineChecks() {
if len(a.task.checkpoint.BaselineChecks) == 0 {
// Captured the first time the task is asked what it owes, and never
// recaptured: after this the project may declare what it likes, and what
// it declared here is still what the task is held to.
a.task.checkpoint.BaselineChecks = evidence.CaptureCheckContract(current, nil).Baseline()
a.task.checkpoint.BaselineChecks = evidence.CaptureCheckContract(a.declaredChecks(), nil).Baseline()
}
return evidence.CaptureCheckContract(a.task.checkpoint.BaselineChecks, current)
}
// turnHasNothingToAnswerFor reports the turn no gate has a subject in: nothing
+1
View File
@@ -145,6 +145,7 @@ func (a *Agent) beginRunTurn(ctx context.Context, input string) (rawInput string
if scoped && a.task.checkpoint.ScopeID != scope.ID {
a.task.checkpoint = evidence.DeliveryCheckpoint{ScopeID: scope.ID}
}
a.captureBaselineChecks()
// Re-lease this session's background-job mutations that no turn has
// committed yet. The Reset above just wiped any lease a failed or
// cancelled turn held (its ledger is gone), and a process restart starts
+1 -34
View File
@@ -1,4 +1,4 @@
这一版有两条线。一条是窗口本身:那条什么读数都往里塞的运行带拆成了面板,窗口不再从一张白纸开始,设置变成盖在工作区上的一张纸而不是第二个全屏窗口,而侧栏里列出的一处改动现在能给出它背后的 diff。另一条是几处「界面报的不是真在跑的那件事」——沙箱面板报的是配置文件里的词而不是生效的那一档,停在提问上的一轮把问题从屏幕上擦掉了,输入框下面那排控件把自己的标签拆成了两行。自 2.8.0 起 16 个提交。
这一版有两条线。一条是窗口本身:那条什么读数都往里塞的运行带拆成了面板,窗口不再从一张白纸开始,设置变成盖在工作区上的一张纸而不是第二个全屏窗口,而侧栏里列出的一处改动现在能给出它背后的 diff。另一条是几处「界面报的不是真在跑的那件事」——沙箱面板报的是配置文件里的词而不是生效的那一档,停在提问上的一轮把问题从屏幕上擦掉了,输入框下面那排控件把自己的标签拆成了两行。自 2.8.0 起 13 个提交。
## 新增
@@ -32,28 +32,9 @@
- `RemoteHostEntry` 在 `workspace` 旁边多了 `workspaces`,由 `WorkspaceList` 折成一份**默认在前**的列表:只写过旧字段的那一行是长度为一的列表,而不是每个读者都要重写一遍的特例;一次 upsert 会把列表的头写成默认,也就是 CLI 仍然连过去的那一个。
- 侧栏把主机簿和远端的 tree 一起画:在这边加了、还没在那边打开过的文件夹留着,在那边打开过、这边没记过的也会出现。
**远端的目录可以直接挑,不必先写进设置**(#9475)
- 后端一直收得下任意路径:`/remotes/open` 把 workspace 原样交给 attach,bootstrap 那边解析 `~`、绝对路径和相对 home 的写法。缺的是入口——侧栏能点的行只有主机簿和远端 tree 的并集,于是一个没写下来的文件夹,没有任何办法说出口。
- `attach.Pool.Browse` 只握链路,不握它上面的任何东西:不装 reasonix,不起 serve,不挂转发。挑目录走的是文件协议,所以在一台从没跑过内核的机器上也是秒开的——而那正是要挑目录的那一刻。答完之后连接留着不放,因为往下走一层是第二次调用,中间断掉就要重拨,而一把带口令的私钥会为此再问一次。
- `GET /remotes/{host}/dirs` 只回目录,路径和名字都由那台机器自己写:一台 Windows 主机答给 mac 的是盘符和反斜杠,这边没有一条 filepath 规则切得动它。往上一级同样是它给的值,而不是这边对一套不属于自己的路径语法做的推断。目录太大就截断,并且说出来——一个不吭声的上限,读起来就是「你要找的那个不在」。
- 挑完就打开,打开的同时写进主机簿:远端的 `/tree` 要那边有内核才答得出,冷启动时唯一还记得这个文件夹的就是这一本。记的是**远端答回来的那个路径**,不是输进去的那个——`~` 是在那边展开的,记错一边,主机簿就会为同一个文件夹多列一行。
- 于是列表要有出口:侧栏里每一个「这本簿子写下的」文件夹都能移除,远端那边什么都不动。远端 tree 报来、这边没记过的行不给这个按钮——那是那台机器自己的记录,在这边删掉,下一次读又回来了。
- 设置页里那个盲打路径的输入框旁边也接上了同一个选择器,条件是这一行已经存过:一个还在草稿里的主机没有地址可拨。
## 修复
- **沙箱面板报的是真在跑的那一档,不是配置文件里的词**(#9454)。`SandboxSettings` 在 `effectiveWriteRoots` 旁边多带一个 `effectiveBash`,于是面板画的是约束器将要做的事:一个没写的值在除 Windows 之外的每台机器上都是 enforce,而 Windows 无论文件怎么写都不受限地跑。这台机器跑不了的那一档现在看得出来是死的,并且自己带着原因——只有一个 `disabled` 的时候,它保留着活控件的 hover 亮度,然后把点击吞掉,那正是被报成「坏了」的东西。在没有后端的机器上存 enforce,现在按代码拒绝而不是按一句手写的话。
- **看不见的那道流光不再烧 CPU**(#9481)。输入框边框上那道「跑起来时沿边框走
的光」写着「停下就必须熄灭」,而熄灭的只有 opacity:`.glowring` 变透明,里面
那个 150px 的径向渐变照旧沿 `offset-path` 每秒 60 次绕圈,在一层
`mask-composite: exclude` 里重新光栅化——从窗口打开到关掉,一刻没停。
`offset-distance` 不走合成器,所以这笔账全落在主线程上:一条 400 轮的会话、
什么都没在跑的窗口,主线程 44%,渲染进程 1.11 核;把它摘掉是 11.4% 和 1.02 核。
报上来的正是这个形状——「webview2 占 50%,关掉软件立刻回到 1% 以下」。
修法是让动画和它的可见性走同一个条件:默认 `animation-play-state: paused`,
只有 `.pane[data-run="running"]` 把它放开。闲置 44.0% → 11.4%(降频 4x)、
9.8% → 2.9%(不降频);正在跑的那一档不变,那时它本来就该在走。
- **一个运行正停在上面的问题,挺得过转录重建**。内核会把挂起的提问/审批重放给每一个接上来的客户端,而窗口随后用 `/history` 重建的内容覆盖了自己的条目——而 ask 这次工具调用在转录里是被**故意**画成空的,因为携带 id 的是 `ask_request`。于是面板停在「等你决定」,屏幕上却没有任何可以决定的东西,排在它后面的那一轮永远发不出去。一个提问不属于记录,它是运行停下来在等一个回答,所以它跟着重建一起过去。同时提问按内核给的 id 认领:重放来的那一帧是同一张卡片而不是第二张可答的,已经答过的那张也不会被帧日志重新打开。
- **输入框下面那排控件不再拆断自己的标签**(#9455)。这排 pill 是普通的 flex 子项,于是一行只差一点点放不下的时候,它压缩它们而不是换行——而中文任意两字之间都可断行,「计划」就此断成两行,把整排的基线一起带歪。现在一颗 pill 要么是它标签的宽度、要么就不存在:这排改为换行(`.row` 本来就允许),发送按钮的自动外边距让它始终待在所在那行的右端。从 980px 一路核到 320px:一行、两行、再三行,每一档都没有任何东西被拆断。模型那颗是这排里唯一没有自己宽度的控件——中转站可以发布比整排还长的 id——所以它先让出字符,整排才让出一行,被缩掉的完整 ref 留在悬停提示里。
- **断掉的推理流被重放而不是丢掉**。OpenAI 与 Anthropic 两侧的 `reasoning_replay`、h2 reset 那个情形、`stream_failure` 以及围着它们的重试路径;doctor 会报出供应商实际做了什么,于是一条坏掉的流是可诊断的,而不只是慢。
@@ -62,22 +43,8 @@
## 内部
- 词表守卫此前一直是红的,红的是它自己。中文是合法的 JS 标识符,`en_storage.ts`
里写的是 `占用: "Space used"`,而 `perf/i18n.mjs` 只认 `"占用": "…"` 那种带引号
的写法——存储面板那六条于是被报成「缺英文翻译」,它们其实一条不少。修好之后它
读出 1276 个键,与 `Object.keys(EN).length` 一字不差。同时它开始报「这一行我读
不出键来」并让退出码变红:一个读源码文本的守卫,必须说得出自己哪里没读懂,否则
下一种写法还是会被当成缺翻译。
- perf 多了 `idle.mjs`:**看不见的东西不许动,闲着的窗口不许烧 CPU**。判据里此前
没有「闲置」这一档,于是一条永远在跑的装饰动画躺了整整一版没人发现。两条断言,
一条读结构一条读开销:在跑的动画,它的元素必须真的看得见(opacity 为 0、
visibility 不可见、盒子没面积,机器再快也是错的);以及动画在闲置窗口上的净
开销——同一台机器把动画全关再量一遍,两者相减——要在预算以内,机器快慢在相减
里抵消。那道流光的两头都断言:跑起来时它要真的在走,否则把它焊死成永不动也是
绿的。`selfcheck.mjs` 收下它。
- perf 多了两道守卫,各自对应一种在其它门全绿时保持沉默的失败。`tokens.mjs`:一个没有回退值的 `var(--x)` 必须指向某处真的有人赋值的东西;两份清单都从源码里读——样式表自己的 `--x:`,以及 JSX 写进 `style` 的那些逐元素变量——因为手抄的清单会烂。`shipped.mjs`:`dist` 必须比 `src` 下的一切都新;一次构建可以成功、打出对勾、然后编译的是别的东西。
- perf 另外多了三个脚本:`selfcheck` 往每一道守卫里塞一个假的断言,证明这道守卫还能变红;`sourcemap` 把一个压缩过的栈帧翻回源码位置;`switchprof` 采样打开一个长会话的开销。`look` 和 `verify` 补上了各自缺的断言。
- wire-parity 收下 `port/remote.ts`:`RemoteHostView`、`RemoteListing`、`RemoteFolder` 与桌面那三份手抄的副本被声明成对子。`RemoteHostEdit` 故意不在里面——内核仍然收旧行那个单数的 `workspace`,而写它的那个页面是有意只送列表;那是一个前端没填满的请求,不是一张它读不到的图。
- wire-parity 现在也盯着 `port/boundary.ts`:`wireMirror` 带上它对应的 TS 文件,`control.SandboxSettings` 与桌面那份手抄的副本被声明成一对。这是一份画安全边界的类型,它读不到的字段就是一种它会画错的姿态。
- 一处符号链接用例改成问文件系统,而不是问操作系统的名字。它此前问 `runtime.GOOS`,而且是在断言 `resp.OK` **之后**才问——于是一台建不了符号链接的机器,是在工具正确报出的那次拒绝上失败,而不是跳过。Windows 只在开发者模式或提权下允许符号链接,平台名分不出这跟一个普通账户的区别;现在用的是这份文件里另外三个用例早就在用的读法:试一下,按文件系统给回来的错误跳过。一台建得了的 Windows 机器现在会真的跑它,而不是被跳过。
- `[remote]` 那一段的渲染搬进 `render_remote.go`。这一段是一件事,而它原先所在的那个函数已经是这个包里最长的一个——顺带把它欠下的一部分预算还了回去。