mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Merge pull request #14676 from SivanCola/fix/environment-snapshot-precedence
fix(boot): preserve environment snapshot precedence / 保持环境探测快照前缀稳定
This commit is contained in:
9 files changed
+198
-22
No files matched your search
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user