When Your AI Agent Stops Trusting the Language Server: Three Bugs Behind the Noise

We caught our AI coding agent saying this mid-task:
“Subagent confirmed 32/32 constructions now at 16 args (all diagnostics are codegen/Lombok LSP noise).”
Read that again. The agent had refactored a repository method, the language server flagged a wall of errors, and the agent — correctly, as it turns out — dismissed every single one of them as false positives and moved on.
That’s the worst possible state for an agent-assisted codebase. A language server that cries wolf is worse than no language server at all, because the agent doesn’t just lose the signal — it learns a habit. Once “diagnostics are noise here” gets reinforced a few times, the agent will shrug off the diagnostic that’s telling the truth. The whole point of wiring an LSP into an agent harness is that the agent gets compiler-grade feedback after every edit, instead of discovering breakage minutes later in a full build. False positives quietly cancel that contract.
So we spent an evening figuring out where the noise came from. It turned out to be three independent bugs stacked on top of each other, each one masking the next. One of them is a genuine, unfixed incompatibility between Eclipse JDT Language Server and Gradle 9 that you’ll hit in any multi-project build — that one’s the headline, and it’s why this post exists. If you landed here from searching:
Resolution of the configuration ':<project>:annotationProcessor' was attempted
without an exclusive lock. This is unsafe and not allowed.skip ahead to bug #3.
The setup
The codebase is a Spring Boot modulith on a JDK 25 toolchain — a multi-project Gradle build (Gradle 9.3.1) with Lombok, MapStruct, and Netflix DGS GraphQL codegen writing generated types into build/generated/sources/dgs-codegen. The agent harness is Claude Code with the official jdtls-lsp plugin, which launches whatever jdtls binary is on the PATH (ours came from Homebrew, version 1.58.0).
Three details there — Lombok, generated sources, and “launches whatever’s on the PATH” — are each one of the three bugs. None of this is exotic; if you run a Lombok-using Gradle project under jdtls (or vscode-java, which wraps the same server), some subset of this post applies to you.
Bug #1: nobody told jdtls about Lombok
Lombok generates members at compile time via an annotation-processor-plus-agent trick. Build tools handle this through the annotationProcessor configuration, but the language server compiles code with its own embedded compiler, inside its own JVM. For jdtls to resolve @Getter/@Builder/@Data members, the Lombok jar must be attached as a javaagent to the language server’s JVM — that’s literally what vscode-java does when you enable Lombok support.
The Claude Code plugin’s launch configuration is minimal:
"lspServers": {
"jdtls": {
"command": "jdtls",
"extensionToLanguage": { ".java": "java" },
"startupTimeout": 120000
}
}No args, no agent, no config knob. Result: every Lombok-generated method in the codebase reported as “undefined” — hundreds of diagnostics of pure noise, before the agent typed a single character.
The fix exploits the launch command being PATH-resolved. The official jdtls Python launcher accepts --jvm-arg=, so a tiny shim earlier on the PATH does the job:
#!/bin/bash
# ~/.local/bin/jdtls — shim for LSP clients that launch bare `jdtls`.
# Attaches the Lombok javaagent so the server resolves generated members.
LOMBOK_JAR="$HOME/.local/share/lombok/lombok.jar"
if [[ -f "$LOMBOK_JAR" ]]; then
exec /opt/homebrew/bin/jdtls "--jvm-arg=-javaagent:$LOMBOK_JAR" "$@"
fi
exec /opt/homebrew/bin/jdtls "$@"One version gotcha worth knowing: the Lombok agent has to support the JDK the language server runs on — and pinning down which JDK that actually is took us a detour. It’s not the project toolchain; that’s pinned in build.gradle.kts and moves when you decide. It’s not necessarily the JDK that came with your jdtls install either: Homebrew’s launcher only falls back to its bundled OpenJDK when JAVA_HOME is unset (JAVA_HOME="${JAVA_HOME:-/opt/homebrew/opt/openjdk/...}"), so in practice the server inherits whatever your version manager — mise, SDKMAN, asdf — exported into the shell that launched the agent session. Ours turned out to be running a version-manager Temurin 25, not the Homebrew JDK we’d first assumed; lsof on the server process settles it, not your install history. The point stands either way: the server’s JDK follows your version-manager config, on its own schedule, independent of the project toolchain — and the day it jumps ahead of your Lombok agent’s supported range, the LSP silently loses Lombok again. So the shim deliberately points at its own agent jar, decoupled from the project dependency: we pinned 1.18.42 (newest at the time, with a JDK-25 javadoc-parsing fix on top of 1.18.40’s JDK-25 support) because a newer agent handles older annotations fine, while an agent older than the server’s JDK is what breaks. When your version manager bumps Java, check the Lombok changelog’s JDK-support line before anything else.
Verification was satisfying: goToDefinition on a SomeEntity.builder() call jumped straight to the @Builder annotation on the class. That’s the Lombok-aware behavior — before the shim, the same request returned “cannot be resolved”.
Bug #2: a 120-second timeout vs a 7-minute import (the death spiral)
With Lombok fixed, the server still wouldn’t answer. The plugin gives jdtls 120 seconds to initialize. A cold import of our monorepo — Gradle model build, dependency resolution, source indexing across a dozen subprojects — takes longer. How much longer? The workspace log answered precisely:
!MESSAGE Workspace initialized in 437755ms7.3 minutes. The timeout fires at 2 minutes. But here’s the part that turns a slow start into a death spiral: when the client gives up and disconnects, the orphaned server keeps running — and keeps holding the workspace lock. The next LSP request spawns a fresh server, which can’t acquire the locked workspace, so the launcher hands it a brand-new workspace directory… which starts the 7-minute import from zero. Repeat forever.
No single attempt can ever succeed, and the failure mode looks like “the LSP is just flaky.” When we finally looked at the process table, we found jdtls instances that had been running for over a week, each owning a different half-imported workspace.
Two fixes:
- Raise the timeout above the cold-import time (we went to 600 s). In Claude Code’s case that meant editing the plugin manifest — there’s no exposed setting, which we’d argue is a gap: a fixed 120 s budget assumes every Java project on earth imports in two minutes. Heads-up: that edit lives in the marketplace’s cloned manifest, so a plugin update will silently revert it — re-apply if 120 s timeouts return.
- Operationally, break the spiral by hand once: let one orphan finish importing in the background (watch the CPU settle), kill all the server processes, and reconnect. The warm workspace makes the next initialization comfortably fast.
While we were in there, we also wiped the accumulated workspace caches. The server’s per-directory workspaces had collected Eclipse projects pointing at git worktrees that no longer existed, and every resource-change event tripped over them (“Can’t read root project location…”). If your agent workflow creates and destroys worktrees — ours does, constantly — those corpses pile up in the language server’s workspace until you clear it.
Bug #3: Gradle 9 broke jdtls’s annotation-processing init script
This is the one that survives both fixes above, the one with no fix upstream, and the one you’ll hit on any jdtls-based editor — including vscode-java — the day your multi-project build upgrades to Gradle 9.
Symptom: every type from a code generator (in our case, Netflix DGS codegen output under build/generated/sources/dgs-codegen) is unresolvable. Imports of generated packages light up as errors across the whole codebase, despite the directory existing, being registered in sourceSets.main.java.srcDirs, and compiling perfectly from the command line.
Mechanism: jdt.ls injects its own Gradle init script (gradle/apt/init.gradle, bundled inside the server) into every sync. The script registers a ToolingModelBuilder that walks all projects from the root and reads each one’s annotation-processor configuration:
FileCollection apPath = options.getAnnotationProcessorPath()
if (apPath != null) {
processors.addAll(apPath.getFiles()) // resolves the configuration
}That getFiles() call resolves the annotationProcessor configuration of another project, from the root project’s model builder. Gradle 9 enforces project isolation here and refuses:
Caused by: org.gradle.api.internal.artifacts.configurations.DefaultConfiguration$IllegalResolutionException:
Resolution of the configuration ':auth-server:annotationProcessor' was attempted
without an exclusive lock. This is unsafe and not allowed.The exception kills the entire model fetch, the model fetch failure aborts the language server’s classpath refresh, and the classpath refresh is what would have registered the generated-sources folder. So the visible casualty (generated types unresolved) is three steps removed from the actual crime (one config resolved without a lock) — which is why this took an evening to find.
We checked jdt.ls master hoping to backport a fix: the script there is byte-identical to the bundled one. As of this writing, the bug ships in the latest release.
Prior art, and why it’s still unfixed. This was reported as eclipse.jdt.ls #3505 shortly after Gradle 9.0.0 — same script, same line, same exception. The reporter closed it himself after discovering that org.gradle.parallel=false makes the error disappear, and a follow-up question in the thread (“is this a Gradle bug or an LSP bug, then?”) went unanswered. So the issue is closed, the script is unchanged, and anyone with a parallel multi-project build — which is the configuration you want in any build big enough to care — still hits it. That’s our trigger too: org.gradle.parallel=true sits in our gradle.properties, as it should. And we’re not alone in the agent world: Serena, another LSP-for-agents toolkit, hit the identical crash and reached for “let users disable annotation processing entirely” as the escape hatch.
That gives you three workaround tiers, in descending order of cost:
org.gradle.parallel=falseingradle.properties— fixes the import by slowing down every build for every developer on the project. A language-server bug shouldn’t tax your CI.--no-parallelviajava.import.gradle.jvmArguments— scopes the cost to the language server’s own syncs, but requires a client that forwards settings to jdt.ls. vscode-java can; headless launchers (Claude Code’s plugin, Serena) currently can’t.- Patch the bundled script so the model builder degrades gracefully — what we did.
Since the closed report left the bug unowned, we’ve filed eclipse.jdt.ls #3807 with the root-cause analysis, the mitigation below, and an offer to PR a proper lock-safe fix.
The interim fix is blunt but contained — wrap the per-project collection in a try/catch so one project’s lock failure degrades to “no annotation-processor info for that project” instead of aborting the whole model:
private void collectApConfiguration(project, compileTaskName, processors, compilerArgs) {
// Gradle 9 forbids resolving another project's annotationProcessor configuration
// without its state lock (IllegalResolutionException). Degrade to "no AP info" for
// that project instead of failing the whole model fetch, which aborts the
// classpath refresh for every project in the build.
try {
JavaCompile javaCompile = project.getTasks().findByName(compileTaskName)
if (javaCompile != null) {
CompileOptions options = javaCompile.getOptions()
if (!options.compilerArgs.contains("-proc:none")) {
FileCollection apPath = options.getAnnotationProcessorPath()
if (apPath != null) {
processors.addAll(apPath.getFiles())
}
compilerArgs.addAll(options.getCompilerArgs())
}
}
} catch (Throwable t) {
// ignore — project keeps default annotation processing config
}
}The script lives inside the server’s extracted OSGi bundle cache, where the directory holding it has a volatile numeric segment. Locate it rather than guessing:
find "$(brew --prefix jdtls)/libexec/configuration" -path '*apt*' -name init.gradle
# …/libexec/configuration/org.eclipse.osgi/<n>/0/.cp/gradle/apt/init.gradleThe patch is local to the machine and gets reverted by upgrades — re-apply after brew upgrade jdtls. The proper fix belongs upstream in eclipse.jdt.ls (tracked in #3807); the script needs to gather per-project AP info in a Gradle-9-safe way rather than reaching across project boundaries.
After the patch: sync completes, the model fetch logs no errors, and — the moment we’d been working toward — the language server’s compiler resolves every generated type. Hover on a generated class returns its full FQN and Javadoc, sourced from the generated file. Diagnostics on files importing generated types: zero.
A forensic footnote: when hover works but goToDefinition doesn’t
One residual quirk is worth documenting because it nearly sent us down a fourth rabbit hole. After everything above, hover on a generated type resolves perfectly — but goToDefinition and workspaceSymbol into the generated folder return nothing.
This looked like the classpath was still broken. It isn’t, and the way we proved it generalizes: check what the compiler sees, not what navigation sees. The server’s own build output (bin/main/... for jdtls) contained freshly compiled .class files for the generated types, timestamped seconds after the sync — meaning the source folder is live, bindings resolve, and diagnostics (which come from the compile layer) are trustworthy. The index-backed operations failing on source folders under build/ is a separate, long-standing jdt.ls limitation — cosmetic for our purposes, since the hover card carries a clickable link to the exact file.
The distinction matters for debugging: diagnostics and hover come from bindings; goToDefinition and symbol search come from the index. If hover resolves a type, your classpath is fine, full stop — don’t let a dead navigation request convince you otherwise.
What this means if you’re wiring an LSP into an agent
The generic lesson isn’t about Java. It’s that an agent harness changes the economics of language-server reliability:
- A human tolerates false positives; an agent generalizes from them. A developer sees squiggles on Lombok getters, sighs, and reads past them. An agent builds a policy: “diagnostics in this repo lie.” That policy then eats the true positives. If your agent’s transcripts contain phrases like “LSP noise” — that’s not the agent being sloppy, that’s your toolchain teaching it learned helplessness.
- Headless launch paths skip the IDE’s care and feeding. vscode-java attaches the Lombok agent, manages workspace lifecycles, and surfaces sync errors in a UI panel. A bare
jdtlson the PATH gets none of that, and the errors land in a log file nobody reads (~/Library/Caches/jdtls/<workspace>/.metadata/.logrepaid every minute we spent in it). - Timeouts need to fit the project, not the demo. Any fixed startup budget will be wrong for someone’s monorepo. If the client also abandons-but-doesn’t-kill the server, a too-short timeout doesn’t just fail — it actively manufactures the orphan-lock death spiral.
- Verify the layer you actually depend on. For an agent, that’s diagnostics — the compile layer. Prove it with bindings-level checks (hover, fresh build output), and don’t burn an evening because the symbol index has opinions about
build/directories.
The payoff is real: with all three bugs fixed, the agent gets honest compiler feedback seconds after every edit, in a codebase where Lombok and GraphQL codegen had previously made the LSP unusable. The diagnostics are boring now. Boring was the goal.
Versions: jdtls 1.58.0 (Eclipse JDT.LS), Gradle 9.3.1, Lombok 1.18.42 (agent) / 1.18.40 (project), JDK 25 (project toolchain and server JVM), Netflix DGS codegen 8.3.0, Claude Code with the jdtls-lsp plugin. The original upstream report is eclipse.jdt.ls #3505 (closed with the org.gradle.parallel=false workaround rather than a fix); the active follow-up with this post’s analysis and patch is eclipse.jdt.ls #3807. If it gets resolved upstream, prefer that fix over the local patch.
Cover photo by Michael Dziedzic on Unsplash.


