Describe the bug
Multiple eca__git calls in a single assistant message execute concurrently, so they race on .git/index.lock and the ref store. The failure is usually silent: the second command operates on the tree as it was before the first, and both report success.
on-tools-called! in src/eca/features/chat/tool_calls.clj reduces over the turn's tool calls and starts each approved one as a future — the :add-future action forces the delay — then moves to the next without waiting. The futures are joined in a doseq only after the reduce has started them all. So a turn containing two git calls runs two git processes against the same repo simultaneously.
That's correct and desirable for most tools. Git is the exception: its writes take exclusive locks, so concurrent invocations in one repo are never safe, even when they look independent.
To Reproduce
Get the model to emit two eca__git calls in one message, e.g.
git add -A
git commit --amend -F - <<'EOF' ... EOF
Observed outcomes:
fatal: Unable to create '.../index.lock': File exists — visible and recoverable
- the amend silently doesn't happen, the previous commit stays in place, and both calls report success
git commit + git push has the same shape, and there the silent case pushes the pre-commit tree while reporting a normal ref update.
Expected behavior
Git writes against the same repo are serialised, or the second fails loudly instead of racing.
Doctor
ECA server: 0.160.2
Client: emacs GNU Emacs 30.2 (aarch64-apple-darwin25.6.0)
OS: Mac OS X 26.7
Java: 25.0.4
Additional context
Prompt-level rules are unreliable here. The system prompt encourages batching independent tool calls, and add + commit read as independent verbs while being one logical operation, so the judgement goes wrong exactly where it matters.
Possible approaches, increasing in precision:
- Serialise all
eca__git calls behind a per-repo lock. Git commands are short and there's little to gain from running them concurrently.
- Serialise writes only. The tool's
operation enum nearly classifies this already (status, diff, log, pr_view are reads), but it's LLM-supplied, so parsing the command is safer — shell-parser/approval-keys already does command parsing for approvals.
- Reject a second concurrent git write with an explicit error. Noisier, but it teaches the model to split the calls rather than hiding the mistake.
eca__shell_command can also run git, so it has the same exposure, though detecting it there is harder.
One caveat: the dispatch behaviour above is from reading tool_calls.clj, not from instrumenting a build. The delay/future/:add-future chain and the post-reduce join look unambiguous, but worth confirming before acting on it.
Describe the bug
Multiple
eca__gitcalls in a single assistant message execute concurrently, so they race on.git/index.lockand the ref store. The failure is usually silent: the second command operates on the tree as it was before the first, and both report success.on-tools-called!insrc/eca/features/chat/tool_calls.cljreduces over the turn's tool calls and starts each approved one as afuture— the:add-futureaction forces thedelay— then moves to the next without waiting. The futures are joined in adoseqonly after the reduce has started them all. So a turn containing two git calls runs twogitprocesses against the same repo simultaneously.That's correct and desirable for most tools. Git is the exception: its writes take exclusive locks, so concurrent invocations in one repo are never safe, even when they look independent.
To Reproduce
Get the model to emit two
eca__gitcalls in one message, e.g.git add -Agit commit --amend -F - <<'EOF' ... EOFObserved outcomes:
fatal: Unable to create '.../index.lock': File exists— visible and recoverablegit commit+git pushhas the same shape, and there the silent case pushes the pre-commit tree while reporting a normal ref update.Expected behavior
Git writes against the same repo are serialised, or the second fails loudly instead of racing.
Doctor
Additional context
Prompt-level rules are unreliable here. The system prompt encourages batching independent tool calls, and
add+commitread as independent verbs while being one logical operation, so the judgement goes wrong exactly where it matters.Possible approaches, increasing in precision:
eca__gitcalls behind a per-repo lock. Git commands are short and there's little to gain from running them concurrently.operationenum nearly classifies this already (status,diff,log,pr_vieware reads), but it's LLM-supplied, so parsing the command is safer —shell-parser/approval-keysalready does command parsing for approvals.eca__shell_commandcan also run git, so it has the same exposure, though detecting it there is harder.One caveat: the dispatch behaviour above is from reading
tool_calls.clj, not from instrumenting a build. Thedelay/future/:add-futurechain and the post-reduce join look unambiguous, but worth confirming before acting on it.