v0.6.4
v0.6.4 — Tell callers when require_dry_run silently overrides dry_run=false
Fixes #19. If you've ever asked Claude Code or Cursor to apply an AdLoop plan and watched it loop forever — "Applying with dry_run=false… the server is treating the first dry_run=false call as a preview anyway. Re-calling to actually commit…" — this release fixes the root cause. The safety gate was doing the right thing; the error channel was lying about why, and every sane agent retried because the response literally told it to.
The bug
safety.require_dry_run: true is the default written by adloop init (a sensible safety rail for new installs). Inside confirm_and_apply it silently coerced any incoming dry_run=false back to true:
if config.safety.require_dry_run:
dry_run = TrueThe returned payload then told the caller:
"Dry run completed — no changes were made to your Google Ads account. To apply for real, call confirm_and_apply again with dry_run=false."
…which was exactly the argument the caller had just passed. That's the retry loop Jesse documented in #19. The LLM's only escape hatch was to invent theories — dashboard toggles, OAuth scopes, read-only accounts — none of which exist. None of the runtime surfaces (tool docstring, response payload, error message) named require_dry_run, pointed at ~/.adloop/config.yaml, or mentioned "restart the MCP server". The workspace-level rules in .cursor/rules/adloop.mdc and .claude/rules/adloop.md did mention the override, but those are only loaded when the developer is working on adloop itself — not when a user has adloop-MCP connected to another project. The information had to ride on the wire.
The fix
confirm_and_apply now distinguishes caller-requested dry runs from config-forced dry runs. When the override fires, the response gains three new fields alongside the existing DRY_RUN_SUCCESS:
dry_run_forced_by: "config.safety.require_dry_run"— a machine-readable reason code.config_path— the absolute path of the exact config file that was loaded, so there's no guessing.AdLoopConfignow carries asource_pathrecorded byload_config, respecting the sameADLOOP_CONFIG→~/.adloop/config.yamlresolution order.remediation— plain-English instruction: edit that file, setrequire_dry_run: falseundersafety:, and restart the AdLoop MCP server. The config is read once at server startup, so flipping the flag without a restart won't do anything — that was the second foot-gun.
The top-level message string now says IGNORED and names the file, instead of repeating the instruction the caller already followed. Caller-requested dry runs keep the old, neutral message — no false "config override" signal when the caller genuinely asked for a preview.
The MCP tool docstring for confirm_and_apply was also strengthened so agents see the override contract in the tool schema, not just post-hoc:
If
safety.require_dry_run: trueis set in the user's config file,dry_run=falseis IGNORED and this tool will keep returningDRY_RUN_SUCCESS. When that happens the response includesdry_run_forced_by,config_path, andremediation— surface those to the user verbatim and STOP retrying.
Safety
No behavior change to the safety default. adloop init still writes require_dry_run: true and the override still works exactly as before. The only change is that the server now explains itself instead of pretending a retry will succeed. Every forced dry-run is still written to ~/.adloop/audit.log with result="dry_run_success".
Verification
- 142 unit tests pass (
uv run pytest), including four new ones covering the override branch, the caller-requested branch, the fallback whensource_pathis empty, and the audit-log entry. - Three new config tests verify
source_pathis recorded for present files, missing files, and when resolved viaADLOOP_CONFIG.
Install / upgrade
pipx upgrade adloop # or
pip install --upgrade adloop
# or on-demand:
uvx adloop@0.6.4Then restart your MCP host (Claude Desktop, Cursor, Claude Code, Cowork, etc.). If you want real writes, edit ~/.adloop/config.yaml, set safety.require_dry_run: false, and restart the MCP server.
Credits
Thanks to @JesseLeeStringer for the bug report in #19 — the transcript of Claude Code's retry loop was the exact signature needed to pin this on the response shape rather than on any higher-level "write authorization" theory.
Full Changelog: v0.6.3...v0.6.4