Skip to content

v0.6.4

Choose a tag to compare

@github-actions github-actions released this 24 Apr 09:33
· 17 commits to main since this release

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 = True

The 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. AdLoopConfig now carries a source_path recorded by load_config, respecting the same ADLOOP_CONFIG~/.adloop/config.yaml resolution order.
  • remediation — plain-English instruction: edit that file, set require_dry_run: false under safety:, 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: true is set in the user's config file, dry_run=false is IGNORED and this tool will keep returning DRY_RUN_SUCCESS. When that happens the response includes dry_run_forced_by, config_path, and remediation — 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 when source_path is empty, and the audit-log entry.
  • Three new config tests verify source_path is recorded for present files, missing files, and when resolved via ADLOOP_CONFIG.

Install / upgrade

pipx upgrade adloop        # or
pip install --upgrade adloop
# or on-demand:
uvx adloop@0.6.4

Then 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