Files
zero/internal/tools/glob.go
euxaristiaandClaude Fable 5 ba6c026469 fix(tools): make grep and glob respect run cancellation (#464)
* fix(tools): make grep and glob respect run cancellation

grep and glob's Run/RunWithSandbox discarded their context parameter
entirely (bound to _) and ran a synchronous filepath.WalkDir with no way
to know it should stop. An unscoped search over a large tree - a broad
parent directory, not just the workspace, which the model can and does
reach for - could run long enough to be effectively unkillable: the TUI's
Ctrl+C/exit path calls the stored CancelFunc and immediately marks the run
"cancelled" in the UI, but the underlying walk kept running regardless,
because nothing in it ever checked ctx.Err(). Worse, the TUI's quit path
explicitly waits for the cancelled run's own response (to drain
flushRunIDs, so /rewind checkpoints stay consistent) before it will
actually exit - so with the walk unable to hear the cancellation, neither
a second Ctrl+C nor /exit could complete. The only way out was killing the
process.

Thread ctx through Run/RunWithSandbox into grepFiles/scanGlob and check
ctx.Err() at the top of each WalkDir callback, ahead of walkErr, so a
cancelled run aborts the walk on its very next entry instead of visiting
every remaining file. Checked the rest of the tools package for the same
_ context.Context pattern: list_directory (single-level os.ReadDir, no
walk), skill, ask_user, and update_plan don't touch the filesystem, so
grep and glob were the only two tools with this gap.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(tools): thread cancellation through grep's match-collection phase

The walk-level fix in grepFiles stopped a cancelled search from visiting
new filesystem entries, but collectGrepMatches still read and
regex-scanned every file the walk had already found, so a search that
had discovered its full file list before cancellation kept running
regardless. Adds the same per-item ctx.Err() check to that phase, plus
a deterministic test that cancels after a fixed number of files instead
of relying on timing.

* test(tools): add mid-walk cancellation coverage and shared helper

Add deterministic mid-walk tests for grepFiles and scanGlob using countingCancelContext, and extract searchCancelledResult so grep/glob share the same cancellation Result mapping.

* style(tools): gofmt search_cancel.go (add missing trailing newline)

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-05 11:35:01 +08:00

226 lines
7.2 KiB
Go

package tools
import (
"context"
"fmt"
"io/fs"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/Gitlawb/zero/internal/sandbox"
)
type globTool struct {
baseTool
workspaceRoot string
scope PathScope
}
func NewGlobTool(workspaceRoot string) Tool {
return NewScopedGlobTool(workspaceRoot, nil)
}
func NewScopedGlobTool(workspaceRoot string, scope PathScope) Tool {
return globTool{
baseTool: baseTool{
name: "glob",
description: "Find files by glob pattern inside the workspace or an explicitly granted extra root.",
parameters: Schema{
Type: "object",
Properties: map[string]PropertySchema{
"pattern": {Type: "string", Description: `Glob pattern to match, for example "**/*.go".`},
"cwd": {Type: "string", Description: "Directory to scan. Relative paths stay in the workspace; use an absolute path to scan a granted extra root. Defaults to workspace root.", Default: "."},
"limit": {Type: "integer", Description: "Maximum matches to return.", Default: 100, Minimum: intPtr(1), Maximum: intPtr(1000)},
"include_dirs": {Type: "boolean", Description: "Whether directory matches should be included.", Default: false},
},
Required: []string{"pattern"},
AdditionalProperties: false,
},
safety: readOnlySafety("Finds matching paths without reading contents or modifying files."),
},
workspaceRoot: normalizeWorkspaceRoot(workspaceRoot),
scope: scope,
}
}
func (tool globTool) Run(ctx context.Context, args map[string]any) Result {
return tool.runWith(ctx, args, readExcluder{})
}
// RunWithSandbox runs glob while skipping subtrees the sandbox policy denies
// reads to (DenyRead). With no DenyRead configured the excluder is a no-op and
// behavior is unchanged.
func (tool globTool) RunWithSandbox(ctx context.Context, args map[string]any, engine *sandbox.Engine) Result {
return tool.runWith(ctx, args, sandboxReadExcluder(engine))
}
func (tool globTool) runWith(ctx context.Context, args map[string]any, exclude readExcluder) Result {
pattern, err := aliasedStringArg(args, []string{"pattern", "glob", "match", "query", "expression"}, "", true, false)
if err != nil {
return errorResult("Error: Invalid arguments for glob: " + err.Error())
}
// Optional with a "." default: treat an explicit empty cwd (a common
// weak-model quirk) the same as the key being absent rather than erroring.
cwd, err := aliasedStringArg(args, []string{"cwd", "dir", "directory", "path"}, ".", false, true)
if err != nil {
return errorResult("Error: Invalid arguments for glob: " + err.Error())
}
if cwd == "" {
cwd = "."
}
limit, err := intArg(args, "limit", 100, 1, 1000)
if err != nil {
return errorResult("Error: Invalid arguments for glob: " + err.Error())
}
includeDirs, err := boolArg(args, "include_dirs", false)
if err != nil {
return errorResult("Error: Invalid arguments for glob: " + err.Error())
}
root, displayRoot, err := resolveScopedReadPath(tool.workspaceRoot, tool.scope, cwd)
if err != nil {
return errorResult("Error running glob " + fmt.Sprintf("%q", pattern) + ": " + err.Error())
}
matcher, err := compileGlob(pattern)
if err != nil {
return errorResult("Error running glob " + fmt.Sprintf("%q", pattern) + ": " + err.Error())
}
matches, err := scanGlob(ctx, root, displayRoot, matcher, includeDirs, exclude)
if err != nil {
if res, ok := searchCancelledResult("glob", err); ok {
return res
}
return errorResult("Error running glob " + fmt.Sprintf("%q", pattern) + ": " + err.Error())
}
if len(matches) == 0 {
return okResult("No matches found for " + pattern)
}
sort.Strings(matches)
totalMatches := len(matches)
truncated := totalMatches > limit
if truncated {
matches = matches[:limit]
}
output := strings.Join(matches, "\n")
if truncated {
output += fmt.Sprintf("\n\n[truncated: showing first %d of %d matches; increase limit or narrow cwd/pattern]", len(matches), totalMatches)
}
budgeted := applyOutputBudget(output, searchOutputBudgetBytes, "increase limit or narrow cwd/pattern")
meta := outputBudgetMeta(budgeted)
meta["pattern"] = pattern
if truncated || budgeted.Truncated {
meta["truncated"] = "true"
if budgeted.Truncated {
meta["truncation_reason"] = "byte_budget"
} else {
meta["truncation_reason"] = "limit"
}
}
return Result{
Status: StatusOK,
Output: budgeted.Output,
Truncated: truncated || budgeted.Truncated,
Meta: meta,
}
}
func scanGlob(ctx context.Context, root string, displayRoot string, matcher *regexp.Regexp, includeDirs bool, exclude readExcluder) ([]string, error) {
matches := []string{}
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
// Checked first, ahead of walkErr: an unscoped scan over a large tree can
// run long enough that cancelling the run must stop the walk promptly
// rather than visiting every remaining entry to completion.
if err := ctx.Err(); err != nil {
return err
}
if walkErr != nil {
if path == root {
return walkErr
}
if entry != nil && entry.IsDir() {
return filepath.SkipDir
}
return nil
}
if path == root {
return nil
}
if entry.IsDir() && shouldSkipDirectory(entry.Name()) {
return filepath.SkipDir
}
if entry.IsDir() && exclude.dirExcluded(path) {
// Skip a read-denied subtree (sandbox DenyRead) wholesale.
return filepath.SkipDir
}
if entry.IsDir() && !includeDirs {
return nil
}
relative, err := filepath.Rel(root, path)
if err != nil {
return err
}
normalized := filepath.ToSlash(relative)
if !entry.IsDir() && shouldSkipWorkspaceFile(normalized) {
return nil
}
// A read-denied path (even an includeDirs directory with a nested allow we
// still descended into) must not appear in results.
if exclude.fileExcluded(path) {
return nil
}
if matcher.MatchString(normalized) {
match := normalized
// When cwd resolved to an extra (non-workspace) root, resolveScopedPath
// returns an absolute displayRoot. Emit absolute matches there so the
// agent can feed them straight back into read_file/edit_file; a bare
// relative "foo.txt" would otherwise resolve against the workspace and
// hit the wrong file when the same name exists in both roots.
if filepath.IsAbs(displayRoot) {
match = filepath.ToSlash(filepath.Join(displayRoot, relative))
}
matches = append(matches, match)
}
return nil
})
return matches, err
}
func compileGlob(pattern string) (*regexp.Regexp, error) {
if pattern == "" {
return nil, fmt.Errorf("pattern is required")
}
return regexp.Compile("^" + globToRegexp(filepath.ToSlash(pattern)) + "$")
}
func globToRegexp(pattern string) string {
var builder strings.Builder
for index := 0; index < len(pattern); index++ {
char := pattern[index]
switch char {
case '*':
if index+1 < len(pattern) && pattern[index+1] == '*' {
index++
if index+1 < len(pattern) && pattern[index+1] == '/' {
index++
builder.WriteString("(?:.*/)?")
} else {
builder.WriteString(".*")
}
} else {
builder.WriteString("[^/]*")
}
case '?':
builder.WriteString("[^/]")
default:
builder.WriteString(regexp.QuoteMeta(string(char)))
}
}
return builder.String()
}