返回 DeepSeek-Reasonix
write_claims.go
根目录 / internal / agent / write_claims.go
1 package agent
2
3 import (
4 "context"
5 "fmt"
6 "os"
7 "path/filepath"
8 "runtime"
9 "strings"
10 )
11
12 // DefaultMaxSubagentConcurrency is the session-wide sub-agent concurrency
13 // default (task, fleet items, profile skills, nested children).
14 const DefaultMaxSubagentConcurrency = 6
15
16 // DefaultMaxParallelWriters is the default cap on concurrent writer-capable
17 // sub-agents that declare non-overlapping write_paths.
18 const DefaultMaxParallelWriters = 3
19
20 // MaxSubagentConcurrencyLimit is the upper bound for both concurrency knobs.
21 const MaxSubagentConcurrencyLimit = 32
22
23 // pathKind classifies one declared write_paths entry. Directory claims are
24 // capability prefixes (sandbox AllowsPath) but do not serialize against each
25 // other at schedule time; file claims do.
26 type pathKind uint8
27
28 const (
29 pathKindFile pathKind = iota
30 pathKindDir
31 )
32
33 // WritePathSet is a normalized claim over workspace paths a sub-agent may write.
34 // WholeWorkspace is true when a writer-capable task omitted write_paths and
35 // therefore claims the entire workspace (forcing writer serialization).
36 type WritePathSet struct {
37 // Paths are absolute, cleaned, and symlink-resolved when possible.
38 Paths []string
39 // Kinds is parallel to Paths. Empty when WholeWorkspace or Paths is empty.
40 Kinds []pathKind
41 // WholeWorkspace claims the entire workspace root.
42 WholeWorkspace bool
43 // WorkspaceRoot is the absolute workspace root used for WholeWorkspace claims.
44 WorkspaceRoot string
45 }
46
47 // Empty reports whether the set claims nothing (read-only work).
48 func (s WritePathSet) Empty() bool {
49 return !s.WholeWorkspace && len(s.Paths) == 0
50 }
51
52 // NormalizeConcurrencyLimits clamps total/writer limits into the public range
53 // 1–32 and ensures writers never exceed total. Zero inputs become defaults so
54 // old configs stay at 6/3 without migration.
55 func NormalizeConcurrencyLimits(total, writers int) (int, int) {
56 if total <= 0 {
57 total = DefaultMaxSubagentConcurrency
58 }
59 if writers <= 0 {
60 writers = DefaultMaxParallelWriters
61 }
62 if total > MaxSubagentConcurrencyLimit {
63 total = MaxSubagentConcurrencyLimit
64 }
65 if writers > MaxSubagentConcurrencyLimit {
66 writers = MaxSubagentConcurrencyLimit
67 }
68 if writers > total {
69 writers = total
70 }
71 return total, writers
72 }
73
74 // NormalizeWritePaths validates and normalizes declared write_paths against a
75 // workspace root. It rejects globs, empty entries, workspace-escape paths, and
76 // symlink escapes. An empty raw list yields an empty set (read-only / no claim).
77 func NormalizeWritePaths(workspaceRoot string, raw []string) (WritePathSet, error) {
78 root, err := normalizeExistingRoot(workspaceRoot)
79 if err != nil {
80 return WritePathSet{}, err
81 }
82 if len(raw) == 0 {
83 return WritePathSet{}, nil
84 }
85 out := WritePathSet{WorkspaceRoot: root}
86 seen := map[string]bool{}
87 for i, entry := range raw {
88 entry = strings.TrimSpace(entry)
89 if entry == "" {
90 return WritePathSet{}, fmt.Errorf("write_paths[%d]: path is required", i)
91 }
92 if strings.ContainsAny(entry, "*?[") {
93 return WritePathSet{}, fmt.Errorf("write_paths[%d]: globs are not allowed (%q)", i, entry)
94 }
95 trailingSep := strings.HasSuffix(entry, "/") || strings.HasSuffix(entry, `\`)
96 trimmed := strings.TrimRight(entry, `/\`)
97 if trimmed == "" {
98 trimmed = entry
99 }
100 abs, err := resolveWriteClaimPath(root, trimmed)
101 if err != nil {
102 return WritePathSet{}, fmt.Errorf("write_paths[%d]: %w", i, err)
103 }
104 if !pathWithinFold(root, abs) {
105 return WritePathSet{}, fmt.Errorf("write_paths[%d]: path %q is outside the workspace", i, entry)
106 }
107 key := foldPathKey(abs)
108 if seen[key] {
109 continue
110 }
111 seen[key] = true
112 out.Paths = append(out.Paths, abs)
113 out.Kinds = append(out.Kinds, classifyWritePath(abs, trailingSep))
114 }
115 return out, nil
116 }
117
118 type subagentWriteClaimKey struct{}
119 type subagentClaimIDKey struct{}
120 type parentWriteClaimIDKey struct{}
121
122 // WithParentWriteClaimID marks ctx as running inside the tool call that holds
123 // parent write claim id, so subagents it acquires are not refused by that claim.
124 func WithParentWriteClaimID(ctx context.Context, id int64) context.Context {
125 if id == 0 {
126 return ctx
127 }
128 return context.WithValue(ctx, parentWriteClaimIDKey{}, id)
129 }
130
131 // ParentWriteClaimID returns the parent write claim the calling tool holds.
132 func ParentWriteClaimID(ctx context.Context) int64 {
133 id, _ := ctx.Value(parentWriteClaimIDKey{}).(int64)
134 return id
135 }
136
137 // WithSubagentWriteClaim carries a child's declared write claim into its run so
138 // the host can audit, after the fact, that every mutation it observed fell
139 // inside the claim the scheduler parallelized on.
140 func WithSubagentWriteClaim(ctx context.Context, claims WritePathSet) context.Context {
141 return context.WithValue(ctx, subagentWriteClaimKey{}, claims)
142 }
143
144 // WithSubagentClaimID carries the scheduler live-claim id so path-bound tools
145 // can Realize and opaque writers can MarkOpaque.
146 func WithSubagentClaimID(ctx context.Context, id int64) context.Context {
147 if id == 0 {
148 return ctx
149 }
150 return context.WithValue(ctx, subagentClaimIDKey{}, id)
151 }
152
153 // SubagentClaimID returns the live claim id of the running child, if any.
154 func SubagentClaimID(ctx context.Context) int64 {
155 id, _ := ctx.Value(subagentClaimIDKey{}).(int64)
156 return id
157 }
158
159 // SubagentWriteClaim returns the write claim of the running child, if any.
160 func SubagentWriteClaim(ctx context.Context) WritePathSet {
161 claims, _ := ctx.Value(subagentWriteClaimKey{}).(WritePathSet)
162 return claims
163 }
164
165 // WholeWorkspaceWriteClaim claims the entire workspace for a writer that did
166 // not declare write_paths. Such tasks may only run serially among writers.
167 func WholeWorkspaceWriteClaim(workspaceRoot string) (WritePathSet, error) {
168 root, err := normalizeExistingRoot(workspaceRoot)
169 if err != nil {
170 return WritePathSet{}, err
171 }
172 return WritePathSet{WholeWorkspace: true, WorkspaceRoot: root}, nil
173 }
174
175 // ScheduleOverlaps reports whether two claims must not start at the same time.
176 // Capability Overlaps stays stricter: identical directory claims still overlap
177 // for sandbox/AllowsPath. Directory-vs-directory claims (including identity)
178 // may run in parallel; a directory still blocks a concrete file inside it.
179 func ScheduleOverlaps(a, b WritePathSet) bool {
180 if a.Empty() || b.Empty() {
181 return false
182 }
183 if a.WholeWorkspace || b.WholeWorkspace {
184 return a.Overlaps(b)
185 }
186 for i, pa := range a.Paths {
187 for j, pb := range b.Paths {
188 if !pathWithinFold(pa, pb) && !pathWithinFold(pb, pa) {
189 continue
190 }
191 if a.kindAt(i) == pathKindDir && b.kindAt(j) == pathKindDir {
192 continue
193 }
194 return true
195 }
196 }
197 return false
198 }
199
200 func (s WritePathSet) kindAt(i int) pathKind {
201 if i >= 0 && i < len(s.Kinds) {
202 return s.Kinds[i]
203 }
204 return pathKindFile
205 }
206
207 func classifyWritePath(abs string, trailingSep bool) pathKind {
208 if trailingSep {
209 return pathKindDir
210 }
211 info, err := os.Stat(abs)
212 if err == nil && info.IsDir() {
213 return pathKindDir
214 }
215 return pathKindFile
216 }
217
218 // Overlaps reports whether two write claims conflict (identical, parent/child,
219 // or case-equivalent on case-insensitive filesystems).
220 func (s WritePathSet) Overlaps(other WritePathSet) bool {
221 if s.Empty() || other.Empty() {
222 return false
223 }
224 if s.WholeWorkspace || other.WholeWorkspace {
225 // Whole-workspace claims collide with every other writer claim that
226 // shares the same workspace root (or has an empty root).
227 if s.WorkspaceRoot == "" || other.WorkspaceRoot == "" {
228 return true
229 }
230 return pathWithinFold(s.WorkspaceRoot, other.WorkspaceRoot) ||
231 pathWithinFold(other.WorkspaceRoot, s.WorkspaceRoot)
232 }
233 for _, a := range s.Paths {
234 for _, b := range other.Paths {
235 if pathWithinFold(a, b) || pathWithinFold(b, a) {
236 return true
237 }
238 }
239 }
240 return false
241 }
242
243 // ValidateNonOverlappingWriteClaims fails if any pair of claims overlaps.
244 // Used by fleet preflight so no task starts when path division is invalid.
245 func ValidateNonOverlappingWriteClaims(claims []WritePathSet) error {
246 for i := range claims {
247 if claims[i].Empty() {
248 continue
249 }
250 for j := i + 1; j < len(claims); j++ {
251 if claims[j].Empty() {
252 continue
253 }
254 if claims[i].Overlaps(claims[j]) {
255 return fmt.Errorf("write path conflict between task %d and task %d", i+1, j+1)
256 }
257 }
258 }
259 return nil
260 }
261
262 // AllowsPath reports whether target is inside this claim (for re-bound writers).
263 func (s WritePathSet) AllowsPath(target string) bool {
264 if s.Empty() {
265 return false
266 }
267 abs, err := realPathForClaim(target)
268 if err != nil {
269 return false
270 }
271 if s.WholeWorkspace {
272 if s.WorkspaceRoot == "" {
273 return true
274 }
275 return pathWithinFold(s.WorkspaceRoot, abs)
276 }
277 for _, root := range s.Paths {
278 if pathWithinFold(root, abs) {
279 return true
280 }
281 }
282 return false
283 }
284
285 // Roots returns the concrete root list used to re-confine built-in writers and
286 // bash sandbox WriteRoots. Whole-workspace claims return the workspace root.
287 func (s WritePathSet) Roots() []string {
288 if s.WholeWorkspace {
289 if s.WorkspaceRoot == "" {
290 return nil
291 }
292 return []string{s.WorkspaceRoot}
293 }
294 return append([]string(nil), s.Paths...)
295 }
296
297 func normalizeExistingRoot(root string) (string, error) {
298 root = strings.TrimSpace(root)
299 if root == "" {
300 return "", fmt.Errorf("workspace root is required for write_paths")
301 }
302 abs, err := filepath.Abs(root)
303 if err != nil {
304 return "", fmt.Errorf("resolve workspace root: %w", err)
305 }
306 abs = filepath.Clean(abs)
307 real, err := filepath.EvalSymlinks(abs)
308 if err != nil {
309 // Workspace may not exist yet in some tests; keep cleaned abs.
310 return abs, nil
311 }
312 return real, nil
313 }
314
315 func resolveWriteClaimPath(workspaceRoot, raw string) (string, error) {
316 path := raw
317 if !filepath.IsAbs(path) {
318 path = filepath.Join(workspaceRoot, path)
319 }
320 return realPathForClaim(path)
321 }
322
323 // realPathForClaim mirrors the write-tool realPath helper: resolve the deepest
324 // existing ancestor so a not-yet-created file claim still cannot escape via a
325 // symlinked parent.
326 func realPathForClaim(path string) (string, error) {
327 abs, err := filepath.Abs(path)
328 if err != nil {
329 return "", err
330 }
331 abs = filepath.Clean(abs)
332 tail := ""
333 cur := abs
334 for {
335 if real, err := filepath.EvalSymlinks(cur); err == nil {
336 return filepath.Join(real, tail), nil
337 }
338 parent := filepath.Dir(cur)
339 if parent == cur {
340 return abs, nil
341 }
342 // Reject intermediate symlink escapes when parent exists as a symlink
343 // that leaves the tree — EvalSymlinks failed on cur but may succeed on
344 // parent; loop continues.
345 info, err := os.Lstat(cur)
346 if err == nil && info.Mode()&os.ModeSymlink != 0 {
347 // Symlink that does not resolve — treat as escape risk.
348 return "", fmt.Errorf("cannot resolve symlink path %q", path)
349 }
350 tail = filepath.Join(filepath.Base(cur), tail)
351 cur = parent
352 }
353 }
354
355 func pathWithinFold(root, path string) bool {
356 if root == "" || path == "" {
357 return false
358 }
359 if foldPaths() {
360 root = strings.ToLower(root)
361 path = strings.ToLower(path)
362 }
363 rel, err := filepath.Rel(root, path)
364 if err != nil {
365 return false
366 }
367 return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
368 }
369
370 func foldPathKey(path string) string {
371 if foldPaths() {
372 return strings.ToLower(path)
373 }
374 return path
375 }
376
377 func foldPaths() bool {
378 return runtime.GOOS == "windows" || runtime.GOOS == "darwin"
379 }
380
380 lines GO