返回 DeepSeek-Reasonix
workspace_lease_regression_test.go
根目录 / internal / agent / workspace_lease_regression_test.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "io"
8 "os"
9 "path/filepath"
10 "sync/atomic"
11 "testing"
12 "time"
13
14 "reasonix/internal/event"
15 "reasonix/internal/jobs"
16 "reasonix/internal/provider"
17 "reasonix/internal/tool/builtin"
18 "reasonix/internal/workspacelease"
19 )
20
21 type workspaceWritingHooks struct {
22 path string
23 calls atomic.Int32
24 }
25
26 type blockingWorkspaceLeaseMetaTool struct {
27 started chan struct{}
28 release chan struct{}
29 }
30
31 func (*blockingWorkspaceLeaseMetaTool) Name() string { return "fleet" }
32 func (*blockingWorkspaceLeaseMetaTool) Description() string { return "fleet test double" }
33 func (*blockingWorkspaceLeaseMetaTool) Schema() json.RawMessage {
34 return json.RawMessage(`{"type":"object"}`)
35 }
36 func (*blockingWorkspaceLeaseMetaTool) ReadOnly() bool { return false }
37 func (t *blockingWorkspaceLeaseMetaTool) Execute(ctx context.Context, _ json.RawMessage) (string, error) {
38 close(t.started)
39 select {
40 case <-t.release:
41 return "ok", nil
42 case <-ctx.Done():
43 return "", ctx.Err()
44 }
45 }
46
47 func (h *workspaceWritingHooks) PreToolUse(context.Context, string, json.RawMessage) (bool, string) {
48 h.calls.Add(1)
49 _ = os.WriteFile(h.path, []byte("hook"), 0o600)
50 return false, ""
51 }
52
53 func (*workspaceWritingHooks) PostToolUse(context.Context, string, json.RawMessage, string) {}
54 func (*workspaceWritingHooks) PostToolUseFailure(context.Context, string, json.RawMessage, string, error) {
55 }
56 func (*workspaceWritingHooks) PostLLMCall(_ context.Context, reasoning string, _ int) string {
57 return reasoning
58 }
59 func (*workspaceWritingHooks) HasPostLLMCall() bool { return false }
60 func (*workspaceWritingHooks) SubagentStop(context.Context, string) {}
61 func (*workspaceWritingHooks) PreCompact(context.Context, string) string { return "" }
62
63 func TestFleetMetaToolDoesNotTakeOuterWorkspaceLease(t *testing.T) {
64 root, locks := t.TempDir(), t.TempDir()
65 fleetOwner, err := workspacelease.New(root, locks, nil)
66 if err != nil {
67 t.Fatal(err)
68 }
69 probeOwner, err := workspacelease.New(root, locks, nil)
70 if err != nil {
71 t.Fatal(err)
72 }
73 fleetOwner.BeginRun()
74 probeOwner.BeginRun()
75 defer fleetOwner.EndRun()
76 defer probeOwner.EndRun()
77
78 fleet := &blockingWorkspaceLeaseMetaTool{
79 started: make(chan struct{}),
80 release: make(chan struct{}),
81 }
82 t.Cleanup(func() {
83 select {
84 case <-fleet.release:
85 default:
86 close(fleet.release)
87 }
88 })
89 a := deliveryLeaseTestAgent(t, fleetOwner, fleet)
90 a.writeWorkspaceRoot = root
91 done := make(chan toolOutcome, 1)
92 go func() {
93 done <- a.executeOne(context.Background(), &a.turn, providerToolCall("fleet", fleet.Name()))
94 }()
95
96 select {
97 case <-fleet.started:
98 case <-time.After(time.Second):
99 t.Fatal("fleet did not start")
100 }
101 ctx, cancel := context.WithTimeout(context.Background(), time.Second)
102 releaseProbe, err := probeOwner.HoldWrite(ctx)
103 cancel()
104 if err != nil {
105 t.Fatalf("fleet meta call held an outer workspace lease: %v", err)
106 }
107 releaseProbe()
108 close(fleet.release)
109
110 select {
111 case out := <-done:
112 if out.blocked || out.errMsg != "" {
113 t.Fatalf("fleet outcome = %+v", out)
114 }
115 case <-time.After(time.Second):
116 t.Fatal("fleet did not return after release")
117 }
118 }
119
120 func TestKillShellDoesNotWaitForBackgroundWriterLease(t *testing.T) {
121 root, locks := t.TempDir(), t.TempDir()
122 owner, err := workspacelease.New(root, locks, nil)
123 if err != nil {
124 t.Fatal(err)
125 }
126 owner.BeginRun()
127 defer owner.EndRun()
128
129 manager := jobs.NewManager(event.Discard)
130 defer manager.Close()
131 leaseHeld := make(chan struct{})
132 job := manager.StartForSession("parent-session", "task", "writer", func(ctx context.Context, _ io.Writer) (string, error) {
133 release, err := owner.HoldWriteForPath(ctx, filepath.Join(root, "held.go"))
134 if err != nil {
135 return "", err
136 }
137 defer release()
138 close(leaseHeld)
139 <-ctx.Done()
140 return "", ctx.Err()
141 })
142 select {
143 case <-leaseHeld:
144 case <-time.After(time.Second):
145 t.Fatal("background writer did not acquire its path lease")
146 }
147
148 tools := (builtin.Workspace{Dir: root}).Tools("kill_shell")
149 if len(tools) != 1 {
150 t.Fatalf("kill_shell tools = %d, want 1", len(tools))
151 }
152 a := deliveryLeaseTestAgent(t, owner, tools[0])
153 a.svc.jobs = manager
154 a.writeWorkspaceRoot = root
155
156 ctx := jobs.WithManager(context.Background(), manager)
157 ctx = jobs.WithSession(ctx, "parent-session")
158 ctx, cancel := context.WithTimeout(ctx, time.Second)
159 defer cancel()
160 args, err := json.Marshal(map[string]string{"job_id": job.ID})
161 if err != nil {
162 t.Fatal(err)
163 }
164 out := a.executeOne(ctx, &a.turn, provider.ToolCall{ID: "kill", Name: "kill_shell", Arguments: string(args)})
165 if out.blocked || out.errMsg != "" {
166 t.Fatalf("kill_shell waited for the background writer lease: %+v", out)
167 }
168 result := manager.WaitForSession(context.Background(), "parent-session", []string{job.ID}, 1)
169 if len(result) != 1 || result[0].Status != jobs.Killed {
170 t.Fatalf("background job after kill_shell = %+v, want killed", result)
171 }
172 }
173
174 func TestWritableHooksUseWorkspaceLease(t *testing.T) {
175 root, locks := t.TempDir(), t.TempDir()
176 holder, _ := workspacelease.New(root, locks, nil)
177 writerOwner, _ := workspacelease.New(root, locks, nil)
178 protected := filepath.Join(root, "protected.go")
179 releaseProtected, err := holder.HoldWriteForPath(context.Background(), protected)
180 if err != nil {
181 t.Fatal(err)
182 }
183
184 writer := &workspaceLeaseTestTool{name: "lease_reader", readOnly: true}
185 hooks := &workspaceWritingHooks{path: protected}
186 a := deliveryLeaseTestAgent(t, writerOwner, writer)
187 a.writeWorkspaceRoot = root
188 a.svc.hooks = hooks
189 call := providerToolCall("write", writer.Name())
190 call.Arguments = `{"path":"probe.go","content":"probe"}`
191 ctx, cancel := context.WithTimeout(context.Background(), 80*time.Millisecond)
192 out := a.executeOne(ctx, &a.turn, call)
193 cancel()
194 if !out.blocked || !errors.Is(ctx.Err(), context.DeadlineExceeded) {
195 t.Fatalf("hook-capable writer was not blocked by active workspace write: %+v", out)
196 }
197 if got := hooks.calls.Load(); got != 0 {
198 t.Fatalf("hook ran %d times before the workspace became exclusive", got)
199 }
200
201 releaseProtected()
202 out = a.executeOne(context.Background(), &a.turn, call)
203 if out.blocked || out.errMsg != "" {
204 t.Fatalf("writer after release: %+v", out)
205 }
206 if got := hooks.calls.Load(); got != 1 {
207 t.Fatalf("hook calls = %d, want 1", got)
208 }
209 }
210
211 func TestWritableHooksReserveWholeParentWorkspace(t *testing.T) {
212 root := t.TempDir()
213 scheduler := NewSubagentScheduler(4, 2)
214 hookClaim, err := NormalizeWritePaths(root, []string{"hook-side.go"})
215 if err != nil {
216 t.Fatal(err)
217 }
218 hooks := &parentClaimProbeHooks{scheduler: scheduler, claim: hookClaim}
219 writer := &recordingWriter{name: "lease_reader", readOnly: true}
220 a := deliveryLeaseTestAgent(t, nil, writer)
221 a.svc.hooks = hooks
222 a.svc.writeScheduler = scheduler
223 a.writeWorkspaceRoot = root
224 call := providerToolCall("write", writer.Name())
225 call.Arguments = `{"path":"probe.go","content":"probe"}`
226 out := a.executeOne(context.Background(), &a.turn, call)
227 if out.blocked || out.errMsg != "" {
228 t.Fatalf("executeOne failed: %+v", out)
229 }
230 if hooks.acquireErr == nil {
231 t.Fatal("hook-side path bypassed the parent workspace reservation")
232 }
233 }
234
235 func TestWritableHooksSerializeReadOnlyToolBatch(t *testing.T) {
236 root := t.TempDir()
237 first := &workspaceLeaseTestTool{name: "lease_reader_a", readOnly: true}
238 second := &workspaceLeaseTestTool{name: "lease_reader_b", readOnly: true}
239 hooks := &workspaceWritingHooks{path: filepath.Join(root, "hook-side.go")}
240 a := deliveryLeaseTestAgent(t, nil, first, second)
241 a.svc.writeScheduler = NewSubagentScheduler(4, 2)
242 a.writeWorkspaceRoot = root
243 calls := []provider.ToolCall{
244 providerToolCall("read-a", first.Name()),
245 providerToolCall("read-b", second.Name()),
246 }
247
248 if batches := a.toolCallBatches(calls); len(batches) != 1 || !batches[0].parallel {
249 t.Fatalf("ordinary read batches = %+v, want unchanged parallel fan-out", batches)
250 }
251 a.svc.hooks = hooks
252 batches := a.toolCallBatches(calls)
253 if len(batches) != 1 || batches[0].parallel {
254 t.Fatalf("hook-capable read batches = %+v, want one serial batch", batches)
255 }
256 result := a.executeBatch(context.Background(), &a.turn, calls)
257 for i, outcome := range result.outcomes {
258 if outcome.blocked || outcome.errMsg != "" {
259 t.Fatalf("read-only call %d was dropped by hook coordination: %+v", i, outcome)
260 }
261 }
262 if first.calls.Load() != 1 || second.calls.Load() != 1 || hooks.calls.Load() != 2 {
263 t.Fatalf("executions first=%d second=%d hooks=%d, want 1/1/2",
264 first.calls.Load(), second.calls.Load(), hooks.calls.Load())
265 }
266 }
267
267 lines GO