feat(isolation): prepare Store-owned candidates from one baseline

Problem: candidate groups need a common baseline while preserving the result
Store's source, managed-root and identity ownership.

Root cause: ordinary Begin captures afresh, while arbitrary snapshot/path
adoption would bypass the owning Store and its existing settlement contract.

Fix: add an opaque process-local Preparation, recheck source/workspace/Git
directory identities, and create independent results from the captured
snapshot. Extract the existing Begin bodies into private shared helpers.
No best-of, runtime, default or persisted-format activation is included.

Verification: complete 239-package root inventory, full isolation race,
independent builtin/boot command, vet, full lint and make check pass with the
2664 baseline unchanged. Compiling unsafe controls distinguish baseline
refresh and replaced-directory admission; the preceding Store implementation
passes the new preparation tests. Linked-source overlay passes; Windows and
Linux amd64 compile only. Native/hosted validation and independent review
remain separate gates.
This commit is contained in:
SivanCola committed 2026-10-26 01:50:29 +08:00
1 parent 4377886f88
commit 728a0ed165
5 files changed
+543 -11

No files matched your search

+121
View File
@@ -0,0 +1,121 @@
---
owner: @SivanCola
backup: @esengine
status: active
reviewed: 2026-10-26
---
# Store-owned candidate preparation / 结果存储拥有的候选准备
## Scope / 范围
`Store.Prepare` captures one Git snapshot. Its process-local `Preparation`
creates independent candidates from that snapshot through `Begin`. The source,
workspace, managed root and result IDs come from the Store and its existing
creation owner; callers do not pass an arbitrary snapshot or candidate path.
Existing `Store.Begin` still captures a fresh snapshot per call. Its snapshot
and candidate creation bodies share private helpers with the new entry point.
No runtime caller uses the new preparation API in this change.
宿主可先捕获一次 Git 快照,再从同一个结果 Store 创建多个独立候选。调用者不能向
准备对象传入任意快照或候选路径。原 `Store.Begin` 仍逐次捕获新快照,仅提取了共享
私有实现;本次没有运行时接线或默认策略变更。
## Input, authority and effects / 输入、权威与副作用
- The host constructs a Store with its selected Git repository, workspace and
managed root. `Prepare` takes only a context. The returned object's fields
are private; nil and zero preparations refuse creation.
- Preparation checks that the Store workspace is the captured source's actual
directory or selected subdirectory using the existing rooted identity helper.
Mismatched host configuration is refused before creating a candidate.
- It records filesystem identities for source, selected workspace, Git common
and Git administrative directories. `Begin` rechecks them before creating a
candidate; matching path spelling alone does not accept replaced directories.
- Existing Git repository/backlink validation still runs in candidate creation.
Context cancellation is checked before capture and before candidate creation.
- A snapshot operation writes dangling Git objects. Candidate creation writes
a managed worktree and its Git administration. This is not a pure read or a
filesystem transaction, although it does not apply work into the source.
- Each candidate gets its own existing result ID and worktree. There is no
group-wide abort, automatic Finish or publication of running results.
源目录、所选工作区域与 Git 元数据目录会核对实际目录身份,创建候选前再次检查。
相同路径下替换目录不能仅凭名称继续使用准备对象;既有 Git 回链校验仍执行。
捕获会写 Git 对象,创建会写托管工作树与管理信息,不是纯读取或文件系统事务。
每个候选拥有独立 ID;本次不自动终结整批候选,也不发布运行中身份。
## Compatibility / 兼容性
| Surface | Behaviour |
| --- | --- |
| Existing `Store.Begin` | Fresh capture per call; existing arguments and error policy |
| Prepared candidates | One captured Git commit/tree, distinct IDs and worktrees |
| Finished `entry.json` | Existing encoding, readers, apply/discard and discovery |
| Failed inspection | Existing inspection-v1 owner and fence; no new state |
| A preparation during restart | No persisted representation; not discoverable |
| Returned `Entry` | Existing trusted-host mutable type; no new unforgeability claim |
No persisted version or field, wire payload, permission, tool definition or
provider-visible prefix changes. Old readers handle finished candidates as
ordinary results. An un-Finished candidate retains the existing crash window;
this API does not establish durable Begin or running-result recovery.
已有完成记录格式、检查失败记录、恢复读取器和 apply/discard 接口不变。准备对象仅
存在于进程内,重启后不能重建它。未 Finish 的候选仍有原有崩溃窗口,不是持久化
Begin、运行中恢复或新的权限协议。
## Evidence / 验证
Tests create two candidates after the source changes and assert their original
captured Git baseline. An ordinary Begin beside them sees the new source.
Concurrent creation yields separate IDs and candidate state; a write in one
candidate reaches neither its sibling nor the source.
Selected-subdirectory identity, mismatched Store configuration, nil/zero
objects, cancellation and real directory replacement are covered. Finished
prepared results are discovered by a reconstructed Store, refuse an ordinary
stale apply and remain available for existing discard.
Unsafe compiled controls can deliberately refresh the baseline or ignore
recorded directory identity. These test the new API's promises; the previous
release has no preparation API, so they are not preceding-product defects.
测试覆盖源变化后的同快照候选、原 Begin 的新快照、并发独立性、子目录、错配配置、
取消和真实目录替换。准备出的完成记录可由重建 Store 发现,普通变化导致的过期应用
仍拒绝,随后可用既有 discard 处理。故意削弱新接口的编译对照不冒充旧版缺陷复现。
## Limits and next owner / 边界与下一层所有者
A captured Git tree is not a complete immutable filesystem view. Existing stat
cache, hidden edit, ignored-file, filter, symlink and native metadata boundaries
remain; [snapshot content work](https://github.com/esengine/DeepSeek-Reasonix/pull/14371)
is a separate pending review.
Stable Git identities do not prove identical
native bytes across changed checkout configuration.
Directory identity checks are observations before Git operations, not held
authority over every subsequent access. They do not fence an arbitrary
external writer or establish sandbox/private-worktree isolation. The Store's
managed directory remains trusted under its existing creation contract.
The snapshot's dangling objects are not pinned against Git garbage collection.
Creation/partial cleanup errors retain existing semantics; this change does not
repair the separate ignored-cleanup-error, retention or settlement-journal
gaps. Capturing once does not implement budgets, CAS or a short transaction.
Best-of has not been connected. Its runner panic still needs a separate
goroutine boundary and an uninspected-result owner before uncertain work can
be kept out of judging and automatic cleanup. This preparation API alone
neither catches that panic nor makes a result durable before execution.
Git 快照不等于完整冻结的文件系统;隐藏编辑、过滤器、忽略文件和原生元数据等边界
仍在,快照内容修复另行待审。目录身份只是 Git 操作前的观察,不会约束任意外部
写入者,也不构成沙箱证明。托管目录仍需可信。
本次不固定 Git 对象的 GC 寿命,不修复既有部分创建清理、回收或结算日志缺口,
不建立额度、CAS 或短事务。best-of 尚未接入,其 goroutine panic 与未检查结果保留
仍是下一层工作。本文是作者安全评估,不代替独立审查。
+133
View File
@@ -0,0 +1,133 @@
package isolation
import (
"context"
"os"
"path/filepath"
"reasonix/internal/contract/tool"
"reasonix/internal/platform/worktree"
)
// Preparation binds candidate creation to one Store and captured Git snapshot.
// It is process-local and does not publish or retain a running result.
type Preparation struct {
store *Store
snap worktree.Snapshot
identity preparationIdentity
}
type preparationIdentity struct{ source, workspace, common, git os.FileInfo }
// Prepare captures one Git snapshot for subsequent candidates from this Store.
// It does not take arbitrary snapshot, source or candidate-path inputs.
func (s *Store) Prepare(ctx context.Context) (*Preparation, error) {
if s == nil {
return nil, errUnavailable
}
if err := ctx.Err(); err != nil {
return nil, err
}
snap, err := s.captureSnapshot(ctx)
if err != nil {
return nil, err
}
identity, err := preparationWorkspace(s, snap)
if err != nil {
return nil, err
}
if err := ctx.Err(); err != nil {
return nil, err
}
return &Preparation{store: s, snap: snap, identity: identity}, nil
}
// Begin creates a distinct result candidate from the captured snapshot.
// The Store owns its source workspace, managed root and result identity.
func (p *Preparation) Begin(ctx context.Context) (*Entry, error) {
if p == nil || p.store == nil {
return nil, errUnavailable
}
if err := ctx.Err(); err != nil {
return nil, err
}
current, err := preparationWorkspace(p.store, p.snap)
if err != nil {
return nil, err
}
if !samePreparationIdentity(p.identity, current) {
return nil, errUnavailable
}
if err := ctx.Err(); err != nil {
return nil, err
}
return p.store.beginSnapshot(ctx, p.store.workspace, p.snap)
}
func preparationWorkspace(store *Store, snap worktree.Snapshot) (preparationIdentity, error) {
source, err := openRecordDirectory(snap.RepoRoot)
if err != nil {
return preparationIdentity{}, errUnavailable
}
defer source.root.Close()
prefix := filepath.FromSlash(snap.Prefix)
if prefix == "" {
prefix = "."
}
if !filepath.IsLocal(prefix) || !source.same(store.workspace, prefix) {
return preparationIdentity{}, tool.Refusal{Code: CodeUnavailable, Message: "the Store workspace no longer matches its prepared source"}
}
common, err := openRecordDirectory(snap.Repo.CommonDir)
if err != nil {
return preparationIdentity{}, errUnavailable
}
defer common.root.Close()
gitDir, ok := common.local(snap.Repo.GitDir)
if !ok {
return preparationIdentity{}, errUnavailable
}
var identity preparationIdentity
for _, item := range []struct {
root *os.Root
path string
info *os.FileInfo
}{{source.root, ".", &identity.source}, {source.root, prefix, &identity.workspace},
{common.root, ".", &identity.common}, {common.root, gitDir, &identity.git}} {
info, err := item.root.Stat(item.path)
if err != nil || !info.IsDir() {
return preparationIdentity{}, errUnavailable
}
*item.info = info
}
return identity, nil
}
func samePreparationIdentity(a, b preparationIdentity) bool {
for _, pair := range [][2]os.FileInfo{{a.source, b.source}, {a.workspace, b.workspace}, {a.common, b.common}, {a.git, b.git}} {
if pair[0] == nil || pair[1] == nil || !os.SameFile(pair[0], pair[1]) {
return false
}
}
return true
}
func (s *Store) captureSnapshot(ctx context.Context) (worktree.Snapshot, error) {
snap, err := worktree.TakeSnapshot(ctx, s.repo)
if err != nil {
return worktree.Snapshot{}, tool.Refusal{Code: CodeNotGit, Message: "worktree isolation needs a Git workspace with a commit: " + err.Error()}
}
return snap, nil
}
func (s *Store) beginSnapshot(ctx context.Context, workspaceRoot string, snap worktree.Snapshot) (*Entry, error) {
cand, err := worktree.CreateCandidate(ctx, snap, s.managedRoot)
if err != nil {
return nil, err
}
id, err := newID()
if err != nil {
_ = worktree.RemoveCandidate(context.WithoutCancel(ctx), snap, cand)
return nil, err
}
return &Entry{ID: id, Workspace: workspaceRoot, Snap: snap, Cand: cand}, nil
}
@@ -0,0 +1,283 @@
package isolation
import (
"context"
"errors"
"os"
"path/filepath"
"sync"
"testing"
"reasonix/internal/contract/tool"
"reasonix/internal/platform/gitcmd"
)
func TestPreparationUsesOneCapturedSnapshotAcrossCandidates(t *testing.T) {
root, managed := gitRepo(t), t.TempDir()
store := NewStore(managed, root, opened(t, root))
prepared, err := store.Prepare(t.Context())
if err != nil {
t.Fatal(err)
}
write(t, root, "a.txt", "source moved\n")
var entries []*Entry
t.Cleanup(func() {
for _, e := range entries {
if err := store.Abort(context.Background(), e); err != nil {
t.Error(err)
}
}
})
for range 2 {
e, err := prepared.Begin(t.Context())
if err != nil {
t.Fatal(err)
}
entries = append(entries, e)
if got := read(t, e.Cand.WorkspaceRoot, "a.txt"); got != "one\n" {
t.Errorf("preparation refreshed the source: %q", got)
}
if e.Workspace != root || filepath.Dir(filepath.Dir(e.Cand.WorktreeRoot)) != managed {
t.Errorf("candidate escaped its Store: %+v", e)
}
}
if entries[0].ID == entries[1].ID || entries[0].Cand.WorktreeRoot == entries[1].Cand.WorktreeRoot {
t.Fatal("preparation reused a result identity or candidate")
}
if entries[0].Snap.Commit != entries[1].Snap.Commit || entries[0].Snap.Tree != entries[1].Snap.Tree {
t.Fatal("candidate baselines differ")
}
legacy, err := store.Begin(t.Context(), root)
if err != nil {
t.Fatal(err)
}
entries = append(entries, legacy)
if got := read(t, legacy.Cand.WorkspaceRoot, "a.txt"); got != "source moved\n" {
t.Errorf("ordinary Begin did not capture fresh source: %q", got)
}
if legacy.Snap.Commit == entries[0].Snap.Commit || legacy.Snap.Tree == entries[0].Snap.Tree {
t.Fatal("ordinary Begin reused an older preparation")
}
if len(store.Pending()) != 0 {
t.Fatal("preparation published unfinished results")
}
}
func TestPreparationResultsUseExistingSettlementAndRecovery(t *testing.T) {
root, managed := gitRepo(t), t.TempDir()
store := NewStore(managed, root, opened(t, root))
p, err := store.Prepare(t.Context())
if err != nil {
t.Fatal(err)
}
e, err := p.Begin(t.Context())
if err != nil {
t.Fatal(err)
}
write(t, e.Cand.WorkspaceRoot, "a.txt", "candidate\n")
if kept, err := store.Finish(t.Context(), e); err != nil || !kept {
t.Fatalf("Finish: kept=%v error=%v", kept, err)
}
rebuilt := NewStore(managed, root, opened(t, root))
if ids := rebuilt.Pending(); len(ids) != 1 || ids[0] != e.ID {
t.Fatalf("ordinary pending reader did not find prepared result: %v", ids)
}
write(t, root, "a.txt", "user edit\n")
var refusal tool.Refusal
if _, err := rebuilt.Apply(t.Context(), e.ID); !errors.As(err, &refusal) || refusal.Code != CodeApplyConflict {
t.Fatalf("stale prepared result applied: %v", err)
}
if got := read(t, root, "a.txt"); got != "user edit\n" {
t.Fatalf("stale apply changed user bytes: %q", got)
}
if ids := rebuilt.Pending(); len(ids) != 1 || ids[0] != e.ID {
t.Fatalf("conflicted result disappeared: %v", ids)
}
if _, err := rebuilt.Discard(t.Context(), e.ID); err != nil {
t.Fatal(err)
}
if len(rebuilt.Pending()) != 0 {
t.Fatal("discard left a prepared result published")
}
}
func TestPreparationRejectsUnavailableAndCancelledCalls(t *testing.T) {
var store *Store
if p, err := store.Prepare(t.Context()); p != nil || !errors.Is(err, errUnavailable) {
t.Fatalf("nil Store preparation: %v %v", p, err)
}
for _, p := range []*Preparation{nil, {}} {
if e, err := p.Begin(t.Context()); e != nil || !errors.Is(err, errUnavailable) {
t.Fatalf("unbound preparation: %v %v", e, err)
}
}
root, managed := gitRepo(t), t.TempDir()
store = NewStore(managed, root, opened(t, root))
ctx, cancel := context.WithCancel(t.Context())
cancel()
if p, err := store.Prepare(ctx); p != nil || !errors.Is(err, context.Canceled) {
t.Fatalf("cancelled capture: %v %v", p, err)
}
p, err := store.Prepare(t.Context())
if err != nil {
t.Fatal(err)
}
if e, err := p.Begin(ctx); e != nil || !errors.Is(err, context.Canceled) {
t.Fatalf("cancelled candidate creation: %v %v", e, err)
}
files, err := os.ReadDir(managed)
if err != nil || len(files) != 0 {
t.Fatalf("cancelled preparation created candidates: %v %v", files, err)
}
if e, err := p.Begin(t.Context()); err != nil {
t.Fatal(err)
} else if err := store.Abort(t.Context(), e); err != nil {
t.Fatal(err)
}
}
func TestPreparationPreservesSubdirectoryIdentity(t *testing.T) {
root := gitRepo(t)
write(t, root, "nested/a.txt", "nested\n")
for _, args := range [][]string{{"add", "nested/a.txt"}, {"commit", "-q", "-m", "nested fixture"}} {
cmd := gitcmd.Command(t.Context(), root, args...)
cmd.Env = append(cmd.Env, "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
sub := filepath.Join(root, "nested")
repo, err := gitcmd.Open(t.Context(), sub)
if err != nil {
t.Fatal(err)
}
store := NewStore(t.TempDir(), sub, repo)
p, err := store.Prepare(t.Context())
if err != nil {
t.Fatal(err)
}
e, err := p.Begin(t.Context())
if err != nil {
t.Fatal(err)
}
defer func() {
if err := store.Abort(context.Background(), e); err != nil {
t.Error(err)
}
}()
if e.Workspace != sub || e.Snap.Prefix != "nested" || filepath.Base(e.Cand.WorkspaceRoot) != "nested" {
t.Fatalf("lost source subdirectory: %+v", e)
}
if got := read(t, e.Cand.WorkspaceRoot, "a.txt"); got != "nested\n" {
t.Fatalf("subdirectory candidate bytes: %q", got)
}
}
func TestPreparationRejectsMismatchedStoreWorkspace(t *testing.T) {
root, other, managed := gitRepo(t), gitRepo(t), t.TempDir()
store := NewStore(managed, other, opened(t, root))
if p, err := store.Prepare(t.Context()); p != nil || err == nil {
t.Fatalf("mismatched source/workspace preparation: %v %v", p, err)
}
if files, err := os.ReadDir(managed); err != nil || len(files) != 0 {
t.Fatalf("mismatched source created candidates: %v %v", files, err)
}
}
func TestPreparationRejectsReplacedSourceDirectories(t *testing.T) {
for _, what := range []string{"workspace", "git_metadata"} {
t.Run(what, func(t *testing.T) {
root := gitRepo(t)
store := NewStore(t.TempDir(), root, opened(t, root))
p, err := store.Prepare(t.Context())
if err != nil {
t.Fatal(err)
}
path := root
if what == "git_metadata" {
path = filepath.Join(root, ".git")
}
parked := path + ".parked"
if err := os.Rename(path, parked); err != nil {
t.Fatal(err)
}
defer func() {
if err := os.RemoveAll(path); err != nil {
t.Error(err)
}
if err := os.Rename(parked, path); err != nil {
t.Error(err)
}
}()
if err := os.CopyFS(path, os.DirFS(parked)); err != nil {
t.Fatal(err)
}
e, err := p.Begin(t.Context())
if e != nil {
if cleanupErr := store.Abort(t.Context(), e); cleanupErr != nil {
t.Error(cleanupErr)
}
}
if e != nil || !errors.Is(err, errUnavailable) {
t.Fatalf("preparation accepted replaced %s: %v %v", what, e, err)
}
})
}
}
func TestPreparationRejectsReplacedGitIdentity(t *testing.T) {
root := gitRepo(t)
store := NewStore(t.TempDir(), root, opened(t, root))
p, err := store.Prepare(t.Context())
if err != nil {
t.Fatal(err)
}
gitDir := filepath.Join(root, ".git")
if err := os.Rename(gitDir, gitDir+".parked"); err != nil {
t.Fatal(err)
}
defer func() {
if err := os.Rename(gitDir+".parked", gitDir); err != nil {
t.Error(err)
}
}()
if e, err := p.Begin(t.Context()); e != nil || err == nil {
t.Fatalf("preparation accepted missing source identity: %v %v", e, err)
}
}
func TestPreparationConcurrentCandidatesDoNotShareResultState(t *testing.T) {
root := gitRepo(t)
store := NewStore(t.TempDir(), root, opened(t, root))
p, err := store.Prepare(t.Context())
if err != nil {
t.Fatal(err)
}
var wg sync.WaitGroup
entries, errs := make([]*Entry, 2), make([]error, 2)
for i := range entries {
wg.Go(func() { entries[i], errs[i] = p.Begin(t.Context()) })
}
wg.Wait()
for i, e := range entries {
if errs[i] != nil {
t.Errorf("candidate %d: %v", i, errs[i])
continue
}
defer func() {
if err := store.Abort(context.Background(), e); err != nil {
t.Error(err)
}
}()
}
if t.Failed() {
return
}
write(t, entries[0].Cand.WorkspaceRoot, "a.txt", "private\n")
if read(t, entries[1].Cand.WorkspaceRoot, "a.txt") != "one\n" || read(t, root, "a.txt") != "one\n" {
t.Fatal("candidate write leaked to sibling or source")
}
if entries[0].ID == entries[1].ID || entries[0].Cand.WorktreeRoot == entries[1].Cand.WorktreeRoot {
t.Fatal("concurrent creation reused result state")
}
}
+2 -11
View File
@@ -65,20 +65,11 @@ func (s *Store) Begin(ctx context.Context, workspaceRoot string) (*Entry, error)
if s == nil {
return nil, errUnavailable
}
snap, err := worktree.TakeSnapshot(ctx, s.repo)
if err != nil {
return nil, tool.Refusal{Code: CodeNotGit, Message: "worktree isolation needs a Git workspace with a commit: " + err.Error()}
}
cand, err := worktree.CreateCandidate(ctx, snap, s.managedRoot)
snap, err := s.captureSnapshot(ctx)
if err != nil {
return nil, err
}
id, err := newID()
if err != nil {
_ = worktree.RemoveCandidate(context.WithoutCancel(ctx), snap, cand)
return nil, err
}
return &Entry{ID: id, Workspace: workspaceRoot, Snap: snap, Cand: cand}, nil
return s.beginSnapshot(ctx, workspaceRoot, snap)
}
// Finish records what the run changed. Inspection errors retain its identity
@@ -0,0 +1,4 @@
## 变更
- Add Store-owned preparation for distinct result candidates sharing one captured Git baseline; existing calls still capture freshly. No runtime policy is activated. (#14025)
- 新增由结果 Store 拥有的候选准备接口,同批候选共享捕获的 Git 基线但保留独立身份;原调用仍捕获新快照,本次不启用新运行策略。 (#14025)