Merge pull request #14676 from SivanCola/fix/environment-snapshot-precedence

fix(boot): preserve environment snapshot precedence / 保持环境探测快照前缀稳定
This commit is contained in:
YHH authored and GitHub committed 2026-10-29 21:26:18 -07:00
commit 1c147780ff
9 files changed
+198 -22

No files matched your search

+8
View File
@@ -180,6 +180,14 @@ tool_timeout_seconds = { "generate_video" = 1800 } # optional raw MCP tool nam
For the full schema and every field's contract, see [`SPEC.md` §5](./SPEC.md#5-configuration-toml).
The startup tool summary reuses a trusted snapshot in the current Reasonix data
directory for up to 24 hours. Probes refreshed by another data directory do not
replace that valid snapshot.
On expiry, transient probe failures retain previous
successful observations, keeping the system prefix stable across rebuilds and
restarts; definitive changes, such as a removed tool, are adopted on refresh.
Installed and project-configured MCP servers need no per-tool trust
list. The dedicated two-model Planner may use every non-destructive MCP tool,
even when the server omits `readOnlyHint`; strict read-only sub-agents still
+4
View File
@@ -189,6 +189,10 @@ tool_timeout_seconds = { "generate_video" = 1800 } # 可选:raw MCP tool 名
完整 schema 与每个字段的契约见 [`SPEC.md` §5](./SPEC.md#5-configuration-toml)。
启动工具摘要会复用当前 Reasonix 数据目录中仍可信的快照,有效期为 24 小时。其他数据目录
刷新探测结果不会替换这份有效快照。快照过期后,临时探测失败会保留先前的成功记录,让系统
前缀在运行时重建和应用重启之间保持稳定;工具被移除等确定性变化会在刷新时生效。
已安装或由项目配置声明的 MCP server 不需要逐工具信任名单。独立双模型 Planner 可使用所有
非 destructive 工具,即使 server 没有声明 `readOnlyHint`;严格只读 subagent 仍要求
`readOnlyHint: true` 且无 `destructiveHint`。
@@ -73,6 +73,9 @@ func adoptCollectingNotices(t *testing.T, from *control.Controller, home, dir st
if err := ApplyRuntimeMigration(to, from, CaptureRuntimeMigration(from)); err != nil {
t.Fatalf("ApplyRuntimeMigration: %v", err)
}
if from.History()[0].Content != to.History()[0].Content {
t.Logf("system prompt changed during migration: %q", firstDivergence(from.History()[0].Content, to.History()[0].Content))
}
return notices
}
@@ -0,0 +1,87 @@
package boot
import (
"context"
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"reasonix/internal/base/testenv"
"reasonix/internal/contract/event"
"reasonix/internal/platform/environment"
)
func environmentSnapshotPrompt(t *testing.T, home, dir string) string {
t.Helper()
c, err := Build(context.Background(), Options{Home: home, WorkspaceRoot: dir, resolvedShell: pinnedEffectShell(), Sink: event.Discard})
if err != nil {
t.Fatal(err)
}
defer c.Close()
return systemMessage(c.History())
}
func TestBuildEnvironmentSnapshotHelper(t *testing.T) {
args := flag.Args()
if len(args) != 3 {
return
}
if err := os.WriteFile(args[2], []byte(environmentSnapshotPrompt(t, args[0], args[1])), 0o600); err != nil {
t.Fatal(err)
}
}
func TestBuildKeepsEnvironmentSnapshotAcrossOtherHomeRefresh(t *testing.T) {
home, dir := statedBootHome(t), testenv.TempDir(t)
tool := filepath.Join(testenv.TempDir(t), "probe-tool")
if runtime.GOOS == "windows" {
tool += ".bat"
}
writeTool := func(version string) {
t.Helper()
body := "#!/bin/sh\nprintf '%s\\n' '" + version + "'\n"
if runtime.GOOS == "windows" {
body = "@echo " + version + "\r\n"
}
if err := os.WriteFile(tool, []byte(body), 0o755); err != nil {
t.Fatal(err)
}
}
writeTool("probe version one")
var config strings.Builder
config.WriteString("default_model = 'test'\n[[providers]]\nname = 'test'\nkind = 'openai'\nmodel = 'test'\nbase_url = 'https://example.invalid'\n[environment.tools]\n")
for _, command := range environment.DefaultProbes() {
fmt.Fprintf(&config, "%s = %q\n", strings.Fields(command)[0], tool)
}
writeFile(t, dir, "reasonix.toml", config.String())
approveWorkspaceAt(t, home, dir)
promptFile := filepath.Join(testenv.TempDir(t), "prompt.txt")
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestBuildEnvironmentSnapshotHelper$", "--", home, dir, promptFile)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("initial process: %v\n%s", err, output)
}
initial, err := os.ReadFile(promptFile)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(initial), "probe version one") {
t.Fatal("initial build did not render the fixture's tool versions")
}
writeTool("probe version two")
otherHome := statedBootHome(t)
approveWorkspaceAt(t, otherHome, dir)
if other := environmentSnapshotPrompt(t, otherHome, dir); !strings.Contains(other, "probe version two") {
t.Fatal("new home did not probe the changed tool")
}
if rebuilt := environmentSnapshotPrompt(t, home, dir); rebuilt != string(initial) {
t.Fatalf("another home's shared probes changed the persisted prefix: %q", firstDivergence(string(initial), rebuilt))
}
}
+7 -13
View File
@@ -94,30 +94,24 @@ func DefaultProbes() []string {
func RunProbesWithOptions(ctx context.Context, commands []string, opts ProbeOptions) []ProbeResult {
key := probeFingerprint(commands, opts)
now := probeNow()
snapshot, hasSnapshot := loadProbeSnapshot(opts.SnapshotDir, key)
if hasSnapshot && now.Sub(snapshot.StoredAt) < probeSnapshotTTL && reusableHere(snapshot.Results, opts.DenyRoots) {
return cloneProbeResults(snapshot.Results)
}
if results, ok := cachedProbeResults(key, now); ok && reusableHere(results, opts.DenyRoots) {
adoptSnapshot(opts.SnapshotDir, key, results, now)
return results
return adoptSharedProbe(opts, key, snapshot, results, now)
}
if call, ok := beginProbe(key); ok {
<-call.done
if reusableHere(call.results, opts.DenyRoots) {
adoptSnapshot(opts.SnapshotDir, key, call.results, now)
return cloneProbeResults(call.results)
return adoptSharedProbe(opts, key, snapshot, call.results, now)
}
// Someone else's answer ran something this workspace will not. Its own
// run below is not shared back, so it neither reads nor writes the key.
return runProbesUncached(ctx, commands, opts)
}
// A fresh persisted snapshot substitutes for a live run entirely: rebuilds
// and app relaunches within the TTL render the exact bytes the sessions on
// this machine were recorded with, so the provider prefix cache survives.
snapshot, hasSnapshot := loadProbeSnapshot(opts.SnapshotDir, key)
if hasSnapshot && now.Sub(snapshot.StoredAt) < probeSnapshotTTL && reusableHere(snapshot.Results, opts.DenyRoots) {
finishProbe(key, snapshot.Results, now)
return cloneProbeResults(snapshot.Results)
}
results := runProbesUncached(ctx, commands, opts)
if hasSnapshot {
if hasSnapshot && reusableHere(snapshot.Results, opts.DenyRoots) {
// Even an expired snapshot anchors the flap merge: transient failures
// (timeout, nonzero exit) keep the previous successful observation so
// a slow tool cannot rewrite the prompt prefix.
+8 -7
View File
@@ -144,12 +144,13 @@ func mergeProbeSnapshot(previous, fresh []ProbeResult) []ProbeResult {
return merged
}
// adoptSnapshot records an answer this snapshot directory was served from
// memory, so that when the in-memory entry expires the directory re-serves the
// same bytes instead of probing again. An existing snapshot is left alone.
func adoptSnapshot(dir, fingerprint string, results []ProbeResult, now time.Time) {
if _, ok := loadProbeSnapshot(dir, fingerprint); ok {
return
// Shared results refresh only missing or expired snapshots. A previous trusted
// success still anchors transient-failure merging, whichever caller probed.
func adoptSharedProbe(opts ProbeOptions, fingerprint string, previous probeSnapshot, results []ProbeResult, now time.Time) []ProbeResult {
results = cloneProbeResults(results)
if reusableHere(previous.Results, opts.DenyRoots) {
results = mergeProbeSnapshot(previous.Results, results)
}
saveProbeSnapshot(dir, fingerprint, results, now)
saveProbeSnapshot(opts.SnapshotDir, fingerprint, results, now)
return results
}
@@ -0,0 +1,74 @@
package environment
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"reasonix/internal/base/testenv"
)
func TestSnapshotOwnerKeepsItsAnswerAfterAnotherHomeRefreshes(t *testing.T) {
now := time.Unix(1000, 0)
resetProbeCacheForTest(t, now)
tool := filepath.Join(testenv.TempDir(t), "ownertool")
toolPath := writeProbeTool(t, tool, "version one")
commands := []string{"ownertool --version"}
firstHome := ProbeOptions{Overrides: map[string]string{"ownertool": toolPath}, SnapshotDir: testenv.TempDir(t)}
otherHome := firstHome
otherHome.SnapshotDir = testenv.TempDir(t)
first := RunProbesWithOptions(context.Background(), commands, firstHome)
if len(first) != 1 || !first[0].Found || first[0].Output != "version one" {
t.Fatalf("initial probe = %+v", first)
}
setProbeNowForTest(now.Add(probeCacheTTL + time.Second))
writeProbeTool(t, tool, "version two")
other := RunProbesWithOptions(context.Background(), commands, otherHome)
if len(other) != 1 || other[0].Output != "version two" {
t.Fatalf("new home must refresh the expired shared cache: %+v", other)
}
rebuilt := RunProbesWithOptions(context.Background(), commands, firstHome)
if FormatSection(rebuilt, "test/os", "/bin/sh", nil) != FormatSection(first, "test/os", "/bin/sh", nil) {
t.Fatalf("another home's cache replaced a fresh persisted answer: first=%+v rebuilt=%+v", first, rebuilt)
}
}
func TestSharedRefreshKeepsTheExpiredOwnersSuccessfulObservation(t *testing.T) {
now := time.Unix(1000, 0)
resetProbeCacheForTest(t, now)
tool := filepath.Join(testenv.TempDir(t), "refreshowner")
toolPath := writeProbeTool(t, tool, "version one")
commands := []string{"refreshowner --version"}
owner := ProbeOptions{Overrides: map[string]string{"refreshowner": toolPath}, SnapshotDir: testenv.TempDir(t)}
first := RunProbesWithOptions(context.Background(), commands, owner)
if len(first) != 1 || !first[0].Found {
t.Fatalf("first observation = %+v", first)
}
setProbeNowForTest(now.Add(probeSnapshotTTL + time.Second))
ctx, cancel := context.WithCancel(context.Background())
cancel()
other := owner
other.SnapshotDir = testenv.TempDir(t)
failed := RunProbesWithOptions(ctx, commands, other)
if len(failed) != 1 || !transientProbeFailure(failed[0]) {
t.Fatalf("canceled probe must be transient: %+v", failed)
}
got := RunProbesWithOptions(context.Background(), commands, owner)
if len(got) != 1 || !got[0].Found || got[0].Output != first[0].Output {
t.Fatalf("shared transient result erased the owner's successful observation: %+v", got)
}
snapshot, ok := loadProbeSnapshot(owner.SnapshotDir, probeFingerprint(commands, owner))
if !ok || snapshot.Results[0].Output != first[0].Output || !snapshot.StoredAt.Equal(probeNow()) {
t.Fatalf("refreshed snapshot = %+v", snapshot)
}
resetProbeCacheForTest(t, probeNow().Add(time.Second))
if err := os.Remove(toolPath); err != nil {
t.Fatal(err)
}
restarted := RunProbesWithOptions(context.Background(), commands, owner)
if len(restarted) != 1 || restarted[0].Output != first[0].Output {
t.Fatalf("restart lost the merged answer: %+v", restarted)
}
}
@@ -171,7 +171,7 @@ func TestProbeCacheHitPersistsSnapshotSoExpiryKeepsTheAnswer(t *testing.T) {
}
}
func TestProbeCacheHitLeavesAnExistingSnapshotAlone(t *testing.T) {
func TestFreshSnapshotTakesPrecedenceOverSharedMemory(t *testing.T) {
resetProbeCacheForTest(t, time.Unix(1000, 0))
dir := testenv.TempDir(t)
toolPath := writeProbeTool(t, filepath.Join(dir, "keeptool"), "keeptool 1.0")
@@ -182,7 +182,10 @@ func TestProbeCacheHitLeavesAnExistingSnapshotAlone(t *testing.T) {
saveProbeSnapshot(opts.SnapshotDir, key, []ProbeResult{{Command: commands[0], Binary: "keeptool", Found: true, Output: "keeptool 0.9", Path: toolPath}}, time.Unix(900, 0))
finishProbe(key, []ProbeResult{{Command: commands[0], Binary: "keeptool", Found: true, Output: "keeptool 1.0", Path: toolPath}}, time.Unix(1000, 0))
RunProbesWithOptions(context.Background(), commands, opts)
got := RunProbesWithOptions(context.Background(), commands, opts)
if len(got) != 1 || got[0].Output != "keeptool 0.9" {
t.Fatalf("shared memory overrode the snapshot's rendered answer: %+v", got)
}
snap, ok := loadProbeSnapshot(opts.SnapshotDir, key)
if !ok || len(snap.Results) != 1 || snap.Results[0].Output != "keeptool 0.9" || !snap.StoredAt.Equal(time.Unix(900, 0)) {
t.Fatalf("a cache hit rewrote the snapshot it found: %+v", snap)
@@ -0,0 +1,2 @@
- 修复:启动环境摘要优先复用当前数据目录的有效可信快照,避免其他目录的探测结果改变系统提示。此前使用了其他目录结果的会话可能发生一次前缀切换,并在首次请求时降低缓存命中率。修复 #14676。
- Fixed: startup summaries prefer each data home's valid trusted snapshot. Previously affected sessions may change prefix once, reducing cache reuse for the first request. Fixes #14676.