返回 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
121 // WithSubagentWriteClaim carries a child's declared write claim into its run so
122 // the host can audit, after the fact, that every mutation it observed fell
123 // inside the claim the scheduler parallelized on.
124 func WithSubagentWriteClaim(ctx context.Context, claims WritePathSet) context.Context {
125 return context.WithValue(ctx, subagentWriteClaimKey{}, claims)
126 }
127
128 // WithSubagentClaimID carries the scheduler live-claim id so path-bound tools
129 // can Realize and opaque writers can MarkOpaque.
130 func WithSubagentClaimID(ctx context.Context, id int64) context.Context {
131 if id == 0 {
132 return ctx
133 }
134 return context.WithValue(ctx, subagentClaimIDKey{}, id)
135 }
136
137 // SubagentClaimID returns the live claim id of the running child, if any.
138 func SubagentClaimID(ctx context.Context) int64 {
139 id, _ := ctx.Value(subagentClaimIDKey{}).(int64)
140 return id
141 }
142
143 // SubagentWriteClaim returns the write claim of the running child, if any.
144 func SubagentWriteClaim(ctx context.Context) WritePathSet {
145 claims, _ := ctx.Value(subagentWriteClaimKey{}).(WritePathSet)
146 return claims
147 }
148
149 // WholeWorkspaceWriteClaim claims the entire workspace for a writer that did
150 // not declare write_paths. Such tasks may only run serially among writers.
151 func WholeWorkspaceWriteClaim(workspaceRoot string) (WritePathSet, error) {
152 root, err := normalizeExistingRoot(workspaceRoot)
153 if err != nil {
154 return WritePathSet{}, err
155 }
156 return WritePathSet{WholeWorkspace: true, WorkspaceRoot: root}, nil
157 }
158
159 // ScheduleOverlaps reports whether two claims must not start at the same time.
160 // Capability Overlaps stays stricter: identical directory claims still overlap
161 // for sandbox/AllowsPath. Directory-vs-directory claims (including identity)
162 // may run in parallel; a directory still blocks a concrete file inside it.
163 func ScheduleOverlaps(a, b WritePathSet) bool {
164 if a.Empty() || b.Empty() {
165 return false
166 }
167 if a.WholeWorkspace || b.WholeWorkspace {
168 return a.Overlaps(b)
169 }
170 for i, pa := range a.Paths {
171 for j, pb := range b.Paths {
172 if !pathWithinFold(pa, pb) && !pathWithinFold(pb, pa) {
173 continue
174 }
175 if a.kindAt(i) == pathKindDir && b.kindAt(j) == pathKindDir {
176 continue
177 }
178 return true
179 }
180 }
181 return false
182 }
183
184 func (s WritePathSet) kindAt(i int) pathKind {
185 if i >= 0 && i < len(s.Kinds) {
186 return s.Kinds[i]
187 }
188 return pathKindFile
189 }
190
191 func classifyWritePath(abs string, trailingSep bool) pathKind {
192 if trailingSep {
193 return pathKindDir
194 }
195 info, err := os.Stat(abs)
196 if err == nil && info.IsDir() {
197 return pathKindDir
198 }
199 return pathKindFile
200 }
201
202 // Overlaps reports whether two write claims conflict (identical, parent/child,
203 // or case-equivalent on case-insensitive filesystems).
204 func (s WritePathSet) Overlaps(other WritePathSet) bool {
205 if s.Empty() || other.Empty() {
206 return false
207 }
208 if s.WholeWorkspace || other.WholeWorkspace {
209 // Whole-workspace claims collide with every other writer claim that
210 // shares the same workspace root (or has an empty root).
211 if s.WorkspaceRoot == "" || other.WorkspaceRoot == "" {
212 return true
213 }
214 return pathWithinFold(s.WorkspaceRoot, other.WorkspaceRoot) ||
215 pathWithinFold(other.WorkspaceRoot, s.WorkspaceRoot)
216 }
217 for _, a := range s.Paths {
218 for _, b := range other.Paths {
219 if pathWithinFold(a, b) || pathWithinFold(b, a) {
220 return true
221 }
222 }
223 }
224 return false
225 }
226
227 // ValidateNonOverlappingWriteClaims fails if any pair of claims overlaps.
228 // Used by fleet preflight so no task starts when path division is invalid.
229 func ValidateNonOverlappingWriteClaims(claims []WritePathSet) error {
230 for i := range claims {
231 if claims[i].Empty() {
232 continue
233 }
234 for j := i + 1; j < len(claims); j++ {
235 if claims[j].Empty() {
236 continue
237 }
238 if claims[i].Overlaps(claims[j]) {
239 return fmt.Errorf("write path conflict between task %d and task %d", i+1, j+1)
240 }
241 }
242 }
243 return nil
244 }
245
246 // AllowsPath reports whether target is inside this claim (for re-bound writers).
247 func (s WritePathSet) AllowsPath(target string) bool {
248 if s.Empty() {
249 return false
250 }
251 abs, err := realPathForClaim(target)
252 if err != nil {
253 return false
254 }
255 if s.WholeWorkspace {
256 if s.WorkspaceRoot == "" {
257 return true
258 }
259 return pathWithinFold(s.WorkspaceRoot, abs)
260 }
261 for _, root := range s.Paths {
262 if pathWithinFold(root, abs) {
263 return true
264 }
265 }
266 return false
267 }
268
269 // Roots returns the concrete root list used to re-confine built-in writers and
270 // bash sandbox WriteRoots. Whole-workspace claims return the workspace root.
271 func (s WritePathSet) Roots() []string {
272 if s.WholeWorkspace {
273 if s.WorkspaceRoot == "" {
274 return nil
275 }
276 return []string{s.WorkspaceRoot}
277 }
278 return append([]string(nil), s.Paths...)
279 }
280
281 func normalizeExistingRoot(root string) (string, error) {
282 root = strings.TrimSpace(root)
283 if root == "" {
284 return "", fmt.Errorf("workspace root is required for write_paths")
285 }
286 abs, err := filepath.Abs(root)
287 if err != nil {
288 return "", fmt.Errorf("resolve workspace root: %w", err)
289 }
290 abs = filepath.Clean(abs)
291 real, err := filepath.EvalSymlinks(abs)
292 if err != nil {
293 // Workspace may not exist yet in some tests; keep cleaned abs.
294 return abs, nil
295 }
296 return real, nil
297 }
298
299 func resolveWriteClaimPath(workspaceRoot, raw string) (string, error) {
300 path := raw
301 if !filepath.IsAbs(path) {
302 path = filepath.Join(workspaceRoot, path)
303 }
304 return realPathForClaim(path)
305 }
306
307 // realPathForClaim mirrors the write-tool realPath helper: resolve the deepest
308 // existing ancestor so a not-yet-created file claim still cannot escape via a
309 // symlinked parent.
310 func realPathForClaim(path string) (string, error) {
311 abs, err := filepath.Abs(path)
312 if err != nil {
313 return "", err
314 }
315 abs = filepath.Clean(abs)
316 tail := ""
317 cur := abs
318 for {
319 if real, err := filepath.EvalSymlinks(cur); err == nil {
320 return filepath.Join(real, tail), nil
321 }
322 parent := filepath.Dir(cur)
323 if parent == cur {
324 return abs, nil
325 }
326 // Reject intermediate symlink escapes when parent exists as a symlink
327 // that leaves the tree — EvalSymlinks failed on cur but may succeed on
328 // parent; loop continues.
329 info, err := os.Lstat(cur)
330 if err == nil && info.Mode()&os.ModeSymlink != 0 {
331 // Symlink that does not resolve — treat as escape risk.
332 return "", fmt.Errorf("cannot resolve symlink path %q", path)
333 }
334 tail = filepath.Join(filepath.Base(cur), tail)
335 cur = parent
336 }
337 }
338
339 func pathWithinFold(root, path string) bool {
340 if root == "" || path == "" {
341 return false
342 }
343 if foldPaths() {
344 root = strings.ToLower(root)
345 path = strings.ToLower(path)
346 }
347 rel, err := filepath.Rel(root, path)
348 if err != nil {
349 return false
350 }
351 return rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
352 }
353
354 func foldPathKey(path string) string {
355 if foldPaths() {
356 return strings.ToLower(path)
357 }
358 return path
359 }
360
361 func foldPaths() bool {
362 return runtime.GOOS == "windows" || runtime.GOOS == "darwin"
363 }
364
364 lines GO