mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 02:36:01 -10:00
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:
1 parent
4377886f88
commit
728a0ed165
5 files changed
+543
-11
No files matched your search
@@ -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 与未检查结果保留
|
||||
仍是下一层工作。本文是作者安全评估,不代替独立审查。
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user