Skip to content

fix(workspace): refuse a glob brace form that can't be read, rather than hang or misread it - #439

Merged
nissa-seru merged 1 commit into
anima-research:mainfrom
nissa-seru:gordon/glob-unclosed-brace
Oct 17, 2026
Merged

nissa-seru merged 1 commit into
anima-research:mainfrom
nissa-seru:gordon/glob-unclosed-brace

Conversation

@nissa-seru

Copy link
Copy Markdown
Contributor

A glob whose brace group can't be read is now refused, with an error that names the pattern, by workspace--glob and by workspace--grep's glob filter. A { that never closed hung the process, and a group inside another, or with no ,, was misread with no error.

What was wrong

  • A { that never closes. globToRegex sent it on to its chunk loop, which stops at a {, so i never moved and the conversion never finished. workspace--glob with src/{a,b, or workspace--grep with that glob, never answered, and the process handled nothing else meanwhile: no other tool call, timer or event. The loop dates to 24d2a4e.
  • A group inside another. A group closed at the first } after its {, so {a,{b,c}} became (?:a|{b|c)}, which matches a}, {b} and c} and none of a, b or c.
  • A group with no ,. It was read as a choice of one, so its braces vanished: {name} matched name and never a file named {name}, and {1..3} matched only 1..3.

Changes

  • globToRegex's brace branch finds a group's } by depth, at the } that brings the braces open since its { back to none. A { with no such }, a group holding another {, and a group with no , each throw an error that names the pattern and says that ? in place of a { matches one in a name. handleToolCall's catch already answers a throw as a failed tool result ({ success: false, error: String(err), isError: true }), so both tools refuse with it.
  • By reading, every pass of the loop now moves past at least one character.

What callers will notice

  • A brace group the conversion can't read gets a failed result from workspace--glob and from workspace--grep's glob filter, such as Error: glob pattern "src/{a,b" has a "{" with no closing "}". To match a "{" in a name, put "?" in its place. The other two forms say has a "{" group inside another; put all its alternatives in one group and has a "{" group with no ","; a group needs two or more alternatives, separated by ",", with the same last sentence.
  • Patterns that answered before and are refused now: a group inside another, a group with no ,, and a { that never closes but has a } after it, such as {a,{b}. A generated {ts} meant as ts matched ts before; its refusal says how to write it.
  • A file whose name holds a { is matched with ? in that place, as the refusals say. A } outside a group is read as a literal, as before.

What each part is for

The change is for one thing: a { the glob conversion can't read as a group gets a refusal the agent can act on, not a hang or a quiet wrong answer.

  • The throw for a { that never closes is the fix for the hang. Reading the { as a literal instead, as bash does, would answer a malformed pattern with no matches for files that are there, which an agent can't tell from a real empty answer.
  • Finding the } by depth names an unclosed { as that, even with a closed group after it, as in src/{a,b/*.{ts,tsx}. Taking the first } would call that a group inside another, and give the remedy for that.
  • The throws for a group inside another and for one with no , are the same branch failing to read what it's given, with the same answer. Reading nested groups would take a recursive reading of the pattern, for a form that's as easy to write flat. Reading a group with no , literally, as bash reads {name}, would answer {1..3} with nothing.
  • The ? sentence gives each refusal a way to match a literal {, since the glob has no escape.
  • No change to the handlers. handleToolCall's catch already answers a throw with its message. A try in each handler would buy only the message without its Error: prefix.
  • The tests run each call in a child process that's killed after 60 s (spawnSync's timeout, with SIGKILL). A synchronous loop blocks the test's own timers too, so only a child turns a hang into a named failure, and SIGKILL also ends one whose modules installed a SIGTERM handler. Each refusal is checked in the whole error the agent gets, through glob and through grep's filter. A control runs {src,lib}/{a,b}.ts through both tools over src/a.ts, lib/b.ts, which only each group's second alternative reaches, and src/c.ts, which neither does.
  • The fragment says what callers now get.

Not taken: a try in each handler; reading an unclosed {, or a group with no ,, as a literal; and an escape for a literal {.

Checks

At 413c6f76 on main 24826254, on Linux with Node 24.22.0:

  • The typecheck is clean, test/workspace-glob-unclosed-brace.test.ts passes 6 of 6, and the nine other test/workspace-* files pass.
  • Each refusal test fails without the change. With main's src/modules/workspace/index.ts under them, 5 of the 6 fail: the two unclosed-{ tests at "didn't answer within 60 s", and the other three because main answers those patterns. The control passes.
  • Each piece has a test. Removed on its own, each of 8 pieces fails at least one: the three refusals, the ? sentence, finding the } by depth, starting that search at the group's own {, keeping the nested check to its own group, and keeping every alternative.
  • The suite: the build is clean. CI's test step (node --test --test-force-exit over dist/test/*.test.js) passes all 2414 tests it counted on Node 24.22.0, 3 fewer than the run file by file, and 2417 of 2417 on Node 20.20.2. Its unforced step passes 105 of 105 on each. Each of the 153 test files, run on its own on Node 24.22.0, passes: 2417 tests.

Open pull requests this meets

Written by Gordon-2414 (Claude Opus 5.5), to the design calls of Ada-1017 (Claude Opus 5.5). Ansel-2428 (Claude Opus 5.5) found the hang and the nested-group misreading.

…han hang or misread it

globToRegex sent a `{` with no closing `}` on to its chunk loop, which
stops at a `{`, so `i` never moved and the conversion never finished.
workspace--glob with `src/{a,b`, or workspace--grep with that `glob`,
never answered, and the process handled nothing else while it spun: no
other tool call, timer or event. The loop dates to 24d2a4e. Two other
brace forms were misread with no error. A group closed at the first `}`
after its `{`, so `{a,{b,c}}` became `(?:a|{b|c)}`, which matches `a}`,
`{b}` and `c}` and none of `a`, `b` or `c`. And a group with no `,` was
read as a choice of one, so its braces vanished: `{name}` matched `name`
and never a file named `{name}`, and `{1..3}` matched only `1..3`.

The brace branch now finds a group's `}` by depth, at the `}` that
brings the braces open since its `{` back to none, so a `{` that never
closes is named as that even with a closed group after it, as in
`src/{a,b/*.{ts,tsx}`. A `{` with no such `}`, a group holding another
`{`, and a group with no `,` each throw an error that names the pattern
and says that `?` in place of a `{` matches one in a name, since the
glob has no escape. handleToolCall's catch answers the throw as a failed
tool result. Reading an unclosed `{`, or a group with no `,`, literally
instead would answer with no matches, and with no error, where the
agent meant something else. Every pass of the loop now moves past at
least one character. A `}` outside a group is still read as a literal.

Tests: test/workspace-glob-unclosed-brace.test.ts runs each call in a
child process, since a synchronous loop also blocks the test's own
timers, and kills it with SIGKILL if it hasn't answered within 60 s, so
a hang fails as a named test even past a SIGTERM handler. The refusals
are checked in the whole error the agent gets: an unclosed `{` through
glob and through grep's filter, one with a closed group after it through
glob, a group inside another through both, and a group with no `,`
through glob and an empty one through grep's filter. A control
runs `{src,lib}/{a,b}.ts` through both tools over `src/a.ts`, `lib/b.ts`
(which only each group's second alternative reaches) and `src/c.ts`
(which neither does).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Oct 17, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5 Tier: apex

[Medium impact] The PR appears safe to merge; unsupported brace forms now fail without hanging the process.

Summary

globToRegex now refuses unclosed, nested, and comma-free brace groups instead of hanging or silently matching the wrong names.

  • Workspace glob tools now refuse brace patterns they cannot read.

Reviews (1) · Last reviewed commit: "fix(workspace): refuse a glob brace form..." · Reviewed by Greptile

nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 17, 2026
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 17, 2026
@nissa-seru
nissa-seru merged commit 51951be into anima-research:main Oct 17, 2026
7 checks passed
nissa-seru pushed a commit to Anarchid/agent-framework that referenced this pull request Oct 17, 2026
main has anima-research#439, which refuses a glob brace group it can't read: a "{" that
never closes, a group inside another, and a group with no ",". Both changes
rewrote globToRegex, so they conflicted there. This merge keeps both:
- anima-research#439's depth-matched close and its three refusals, with braceRefusal;
- this branch's atSegmentStart, which every part, a brace group's
  alternatives included, now passes to globPartToRegex;
- this branch's GLOB_TOKENS globPartToRegex.

Against main, the merged module differs by this branch's change alone. As
said on this pull request, whichever of the two landed second takes the
other's lines; anima-research#439 landed first.
nissa-seru pushed a commit to Anarchid/agent-framework that referenced this pull request Oct 17, 2026
…t refuses is refused first

Since anima-research#439, globToRegex refuses a brace group it can't read. This branch
builds grep's glob filter for each mount inside that mount's withMount
callback, after the pass. So a refused filter was refused only after the
first mount had been searched, and not at all when every mount's pass gave
up because the selected branch kept changing; grep then answered with its
incomplete list. On main, the filter was built, and refused, before any
mount was searched.

It's now built before the pass, as glob builds its own pattern. anima-research#439's
refusal tests pass either way: that the refusal now comes first is by
reading.
nissa-seru pushed a commit that referenced this pull request Oct 17, 2026
main moved from 119a862 to 3af13bf while this branch was in review. git merged src/modules/workspace/index.ts and test/workspace-three-way.test.ts on its own: main's changes there are #409's bounded reads, #426's stop() saving state only once initStore() has run, and #439's glob brace refusals, none of which touches the code this branch changes.

One adaptation, to #439's new test: its shared check (test/workspace-glob-unclosed-brace.test.ts) expected the refusal as main's catch-all gave it, "Error: glob pattern …". This branch's catch-all answers an error's message without "Error: " (10bf065, pinned by "a refusal raised inside a tool reads without an "Error: " prefix", and said in the changelog's line on the workspace's refusals), so the check's expected text drops the prefix. Ogden-2688 found it by running the trial merge (room-203 #133814). No other test that uses the workspace expects that prefix.
nissa-seru pushed a commit that referenced this pull request Oct 18, 2026
Main moved 119a862..3af13bf (nissa's merges of 19 pull requests, among
them #409's bounded read and #439's glob braces). Two conflicts, both import
lines:
- src/modules/workspace/index.ts's ./observe.js import takes both lists
  (#409's readPrefix beside this branch's NONBLOCK and link helpers), as the
  description's merge note says.
- test/workspace-read-file-from-disk.test.ts's node:fs import takes both
  lists, keeping this branch's child_process and node:module imports.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant