返回 DeepSeek-Reasonix
workspace_lease_test.go
根目录 / internal / agent / workspace_lease_test.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "os"
7 "path/filepath"
8 "sync/atomic"
9 "testing"
10 "time"
11
12 "reasonix/internal/event"
13 "reasonix/internal/evidence"
14 "reasonix/internal/provider"
15 "reasonix/internal/tool"
16 "reasonix/internal/workspacelease"
17 )
18
19 type workspaceLeaseTestTool struct {
20 name string
21 readOnly bool
22 calls atomic.Int32
23 }
24
25 type workspaceLeaseTestHooks struct{ preCalls atomic.Int32 }
26 type workspaceLeaseDenyGate struct{}
27
28 func (workspaceLeaseDenyGate) Check(context.Context, string, json.RawMessage, bool) (bool, string, error) {
29 return false, "test denial", nil
30 }
31
32 func (h *workspaceLeaseTestHooks) PreToolUse(context.Context, string, json.RawMessage) (bool, string) {
33 h.preCalls.Add(1)
34 return false, ""
35 }
36 func (*workspaceLeaseTestHooks) PostToolUse(context.Context, string, json.RawMessage, string) {}
37 func (*workspaceLeaseTestHooks) PostToolUseFailure(context.Context, string, json.RawMessage, string, error) {
38 }
39 func (*workspaceLeaseTestHooks) PostLLMCall(_ context.Context, reasoning string, _ int) string {
40 return reasoning
41 }
42 func (*workspaceLeaseTestHooks) HasPostLLMCall() bool { return false }
43 func (*workspaceLeaseTestHooks) SubagentStop(context.Context, string) {}
44 func (*workspaceLeaseTestHooks) PreCompact(context.Context, string) string { return "" }
45
46 func (t *workspaceLeaseTestTool) Name() string { return t.name }
47 func (t *workspaceLeaseTestTool) Description() string { return t.name }
48 func (t *workspaceLeaseTestTool) Schema() json.RawMessage {
49 return json.RawMessage(`{"type":"object"}`)
50 }
51 func (t *workspaceLeaseTestTool) ReadOnly() bool { return t.readOnly }
52 func (t *workspaceLeaseTestTool) Execute(context.Context, json.RawMessage) (string, error) {
53 t.calls.Add(1)
54 return "ok", nil
55 }
56
57 func deliveryLeaseTestAgent(t *testing.T, owner *workspacelease.Owner, tools ...tool.Tool) *Agent {
58 t.Helper()
59 reg := tool.NewRegistry()
60 for _, candidate := range tools {
61 reg.Add(candidate)
62 }
63 a := New(nil, reg, NewSession(""), Options{WorkspaceLease: owner}, event.Discard)
64 a.setTodoState([]evidence.TodoItem{{Content: "mutate", Status: "in_progress"}})
65 return a
66 }
67
68 func TestDeliveryWriterWaitsBeforeToolExecutionButReaderDoesNot(t *testing.T) {
69 root, locks := t.TempDir(), t.TempDir()
70 first, err := workspacelease.New(root, locks, nil)
71 if err != nil {
72 t.Fatal(err)
73 }
74 second, err := workspacelease.New(root, locks, nil)
75 if err != nil {
76 t.Fatal(err)
77 }
78 first.BeginRun()
79 if err := first.AcquireWrite(context.Background()); err != nil {
80 t.Fatal(err)
81 }
82 defer first.EndRun()
83
84 reader := &workspaceLeaseTestTool{name: "lease_reader", readOnly: true}
85 writer := &workspaceLeaseTestTool{name: "lease_writer", readOnly: false}
86 a := deliveryLeaseTestAgent(t, second, reader, writer)
87 second.BeginRun()
88 defer second.EndRun()
89
90 if outcome := a.executeOne(context.Background(), &a.turn, providerToolCall("read", reader.Name())); outcome.errMsg != "" {
91 t.Fatalf("reader was blocked by another Delivery writer: %+v", outcome)
92 }
93 if got := reader.calls.Load(); got != 1 {
94 t.Fatalf("reader calls = %d, want 1", got)
95 }
96
97 hooks := &workspaceLeaseTestHooks{}
98 a.svc.hooks = hooks
99 ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond)
100 defer cancel()
101 outcome := a.executeOne(ctx, &a.turn, providerToolCall("write", writer.Name()))
102 if !outcome.blocked || outcome.errMsg != "blocked: workspace write lease unavailable" {
103 t.Fatalf("writer outcome = %+v, want lease block", outcome)
104 }
105 if got := writer.calls.Load(); got != 0 {
106 t.Fatalf("writer executed %d times before lease acquisition", got)
107 }
108 if got := hooks.preCalls.Load(); got != 0 {
109 t.Fatalf("PreToolUse ran %d times before lease acquisition", got)
110 }
111 }
112
113 func TestDeniedDeliveryWriterDoesNotAcquireWorkspaceLease(t *testing.T) {
114 root, locks := t.TempDir(), t.TempDir()
115 deniedOwner, _ := workspacelease.New(root, locks, nil)
116 probeOwner, _ := workspacelease.New(root, locks, nil)
117 writer := &workspaceLeaseTestTool{name: "denied_writer"}
118 a := deliveryLeaseTestAgent(t, deniedOwner, writer)
119 a.svc.setGate(workspaceLeaseDenyGate{})
120 deniedOwner.BeginRun()
121 outcome := a.executeOne(context.Background(), &a.turn, providerToolCall("write", writer.Name()))
122 deniedOwner.EndRun()
123 if !outcome.blocked || outcome.errMsg != "blocked by permission policy" {
124 t.Fatalf("denied outcome = %+v", outcome)
125 }
126 if writer.calls.Load() != 0 {
127 t.Fatal("denied writer executed")
128 }
129 probeOwner.BeginRun()
130 ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
131 defer cancel()
132 if err := probeOwner.AcquireWrite(ctx); err != nil {
133 t.Fatalf("permission denial leaked workspace lease: %v", err)
134 }
135 probeOwner.EndRun()
136 }
137
138 func TestReadOnlyBashDoesNotTakeWorkspaceLease(t *testing.T) {
139 root, locks := t.TempDir(), t.TempDir()
140 holder, err := workspacelease.New(root, locks, nil)
141 if err != nil {
142 t.Fatal(err)
143 }
144 readerOwner, err := workspacelease.New(root, locks, nil)
145 if err != nil {
146 t.Fatal(err)
147 }
148 holder.BeginRun()
149 if err := holder.AcquireWrite(context.Background()); err != nil {
150 t.Fatal(err)
151 }
152 defer holder.EndRun()
153 bash := &workspaceLeaseTestTool{name: "bash"}
154 a := deliveryLeaseTestAgent(t, readerOwner, bash)
155 readerOwner.BeginRun()
156 defer readerOwner.EndRun()
157 out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{
158 ID: "st",
159 Name: "bash",
160 Arguments: `{"command":"git status"}`,
161 })
162 if out.blocked || out.errMsg != "" {
163 t.Fatalf("read-only bash was blocked by a writer: %+v", out)
164 }
165 }
166
167 func TestPathWriteReleasesLeaseAfterToolReturns(t *testing.T) {
168 repo, locks := t.TempDir(), t.TempDir()
169 if err := os.Mkdir(filepath.Join(repo, ".git"), 0o755); err != nil {
170 t.Fatal(err)
171 }
172 first, err := workspacelease.New(repo, locks, nil)
173 if err != nil {
174 t.Fatal(err)
175 }
176 second, err := workspacelease.New(repo, locks, nil)
177 if err != nil {
178 t.Fatal(err)
179 }
180 w1 := &recordingWriter{name: "write_file"}
181 w2 := &recordingWriter{name: "write_file"}
182 reg1 := tool.NewRegistry()
183 reg1.Add(w1)
184 a1 := New(nil, reg1, NewSession(""), Options{WorkspaceLease: first, WriteWorkspaceRoot: repo}, event.Discard)
185 a1.setTodoState([]evidence.TodoItem{{Content: "mutate", Status: "in_progress"}})
186 reg2 := tool.NewRegistry()
187 reg2.Add(w2)
188 a2 := New(nil, reg2, NewSession(""), Options{WorkspaceLease: second, WriteWorkspaceRoot: repo}, event.Discard)
189 a2.setTodoState([]evidence.TodoItem{{Content: "mutate", Status: "in_progress"}})
190 first.BeginRun()
191 second.BeginRun()
192 defer first.EndRun()
193 defer second.EndRun()
194 out1 := a1.executeOne(context.Background(), &a1.turn, provider.ToolCall{
195 ID: "w1", Name: "write_file",
196 Arguments: string(mustJSON(t, map[string]string{"path": filepath.Join(repo, "a.go"), "content": "a"})),
197 })
198 if out1.blocked || out1.errMsg != "" {
199 t.Fatalf("first write: %+v", out1)
200 }
201 out2 := a2.executeOne(context.Background(), &a2.turn, provider.ToolCall{
202 ID: "w2", Name: "write_file",
203 Arguments: string(mustJSON(t, map[string]string{"path": filepath.Join(repo, "b.go"), "content": "b"})),
204 })
205 if out2.blocked || out2.errMsg != "" {
206 t.Fatalf("second write after first tool returned: %+v", out2)
207 }
208 }
209
210 func providerToolCall(id, name string) provider.ToolCall {
211 return provider.ToolCall{ID: id, Name: name, Arguments: `{}`}
212 }
213
214 func TestNestedRepoWriteFileLeasesDoNotBlock(t *testing.T) {
215 parent, locks := t.TempDir(), t.TempDir()
216 repoA := filepath.Join(parent, "A")
217 repoB := filepath.Join(parent, "B")
218 for _, repo := range []string{repoA, repoB} {
219 if err := os.MkdirAll(filepath.Join(repo, ".git"), 0o755); err != nil {
220 t.Fatal(err)
221 }
222 }
223 first, err := workspacelease.New(parent, locks, nil)
224 if err != nil {
225 t.Fatal(err)
226 }
227 second, err := workspacelease.New(parent, locks, nil)
228 if err != nil {
229 t.Fatal(err)
230 }
231 w1 := &recordingWriter{name: "write_file"}
232 w2 := &recordingWriter{name: "write_file"}
233 reg1 := tool.NewRegistry()
234 reg1.Add(w1)
235 a1 := New(nil, reg1, NewSession(""), Options{
236 WorkspaceLease: first, WriteWorkspaceRoot: parent,
237 }, event.Discard)
238 a1.setTodoState([]evidence.TodoItem{{Content: "mutate", Status: "in_progress"}})
239 reg2 := tool.NewRegistry()
240 reg2.Add(w2)
241 a2 := New(nil, reg2, NewSession(""), Options{
242 WorkspaceLease: second, WriteWorkspaceRoot: parent,
243 }, event.Discard)
244 a2.setTodoState([]evidence.TodoItem{{Content: "mutate", Status: "in_progress"}})
245 first.BeginRun()
246 second.BeginRun()
247 defer first.EndRun()
248 defer second.EndRun()
249
250 out1 := a1.executeOne(context.Background(), &a1.turn, provider.ToolCall{
251 ID: "w1",
252 Name: "write_file",
253 Arguments: string(mustJSON(t, map[string]string{
254 "path": filepath.Join(repoA, "a.go"), "content": "a",
255 })),
256 })
257 if out1.blocked || out1.errMsg != "" {
258 t.Fatalf("first nested write: %+v", out1)
259 }
260 out2 := a2.executeOne(context.Background(), &a2.turn, provider.ToolCall{
261 ID: "w2",
262 Name: "write_file",
263 Arguments: string(mustJSON(t, map[string]string{
264 "path": filepath.Join(repoB, "b.go"), "content": "b",
265 })),
266 })
267 if out2.blocked || out2.errMsg != "" {
268 t.Fatalf("second nested write should not wait on the first: %+v", out2)
269 }
270 }
271
271 lines GO