Skip to content

Make approved agent mutations replay-safe - #61

Merged
ruslanmv merged 26 commits into
masterfrom
fix/idempotent-approved-mutations
Sep 12, 2026
Merged

ruslanmv merged 26 commits into
masterfrom
fix/idempotent-approved-mutations

Conversation

@ruslanmv

@ruslanmv ruslanmv commented Sep 12, 2026 •

Copy link
Copy Markdown
Owner

Summary

This hardens GitPilot's human-in-the-loop mutation path around a production invariant:

one explicit approval authorizes one exact externally-visible action, and retries cannot silently repeat it.

The final design keeps the generic toolkit predictable, adds durable replay protection only at the real agent-runtime boundary, preserves legacy compatibility, and keeps read/search performance on the zero-ledger fast path.

Production runtime boundary

  • The generic ToolRegistry keeps its existing SDK/test/direct-call semantics.
  • RuntimeToolRegistry adds replay protection only when a call belongs to a real run/session.
  • Mutations are detected from declared ToolSpec.effects (WRITES_FS, GIT_LOCAL, GIT_REMOTE, FORGE_WRITE, EXTERNAL_WRITE).
  • The existing stable ToolCall.id — also used as the approval request id — becomes the runtime idempotency key automatically. The model does not invent or manage keys.
  • The key is bound to the canonical tool id, exact arguments, run/session and target.
  • A completed retry replays the original ToolResult without invoking the handler again.
  • Reusing an approval id with changed arguments is rejected.
  • If a downstream mutation fails ambiguously, the first attempt returns the normal tool error but the key is marked indeterminate; an automatic retry is then blocked pending reconciliation/new approval.
  • Read-only tools and standalone toolkit calls bypass idempotency storage entirely.

Durable mutation ledger

Adds a small standard-library SQLite/WAL execution ledger (~/.gitpilot/idempotency.sqlite3, configurable with GITPILOT_IDEMPOTENCY_DB):

  • transactional reservation with BEGIN IMMEDIATE;
  • WAL journal mode and full durability sync;
  • SHA-256 fingerprints of canonical operation arguments (raw tool arguments are not persisted);
  • completed-result replay across process restarts;
  • concurrent duplicate suppression;
  • fail-closed indeterminate state after a lost/ambiguous response.

No Redis, Kafka, Temporal, database service, or other runtime infrastructure is introduced.

Human approval UX

  • Local/reversible mutations may retain session approval for low-friction workflows.
  • Git remote, GitHub/forge and external-system writes are one-shot approvals only.
  • Approval payloads expose allowed scopes so clients can hide unsafe session-wide choices.
  • Any attempted session-scope authorization for a one-shot external effect is reduced server-side to one-time approval.

Legacy CrewAI compatibility

The compatibility surface still exposes idempotency_key where Trustabl expects mutation replay protection, but existing caller ergonomics are preserved where possible:

  • local file, issue and PR compatibility tools retain their historical argument order and accept the key as an optional final argument;
  • when a real approval/request key is supplied, those tools use the same durable ledger;
  • the canonical V4 runtime never depends on the optional fallback — it always has the stable call/approval id and enforces replay protection at RuntimeToolRegistry;
  • GitHub parity tests supply explicit unique approval ids for mutating legacy repository operations.

This removes Trustabl CREW-006 findings at the implementation level rather than suppressing the scanner.

Reliability and test hardening

Two unrelated-but-real CI assumptions were also made deterministic while validating the change:

  • the compaction stress test now explicitly pins its intended 8k context window instead of depending on a model catalog entry whose advertised context can change;
  • the real Git fixture now stores a repository-local user.name/user.email, so commit tests do not depend on a developer or CI runner having global Git identity configured.

Validation

Final head: f8719e6f5cd409f46443fe74e38ec9997fc35dde

  • Trustabl run 34703430520: 100/100 readiness, 0 critical / 0 high / 0 medium / 0 low, native exit 0, gate passed.
  • Strict mypy: 0 issues in 80 source files.
  • Coverage run 34703430537: 3,874 passed, 4 skipped, no failures.
  • Total coverage: 91.63% (required threshold: 80%).

The PR is green end-to-end on its final head.

@vercel

vercel Bot commented Sep 12, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
gitpilot Ready Ready Preview Sep 12, 2026 3:50pm UTC

@github-actions

Copy link
Copy Markdown
Contributor

Trustabl scan

ruslanmv/gitpilot · fix/idempotent-approved-mutations · 1 findings

Readiness goes from 100 → 100 (+0)

Readiness now   🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩   100 / 100

Projected if all findings resolved   🟩🟩🟩🟩🟩🟩🟩🟩🟩🟩   100 / 100   +0

Findings by severity

Severity Count
critical 0 ▱▱▱▱▱▱▱▱
high 0 ▱▱▱▱▱▱▱▱
medium 0 ▱▱▱▱▱▱▱▱
low 0 ▱▱▱▱▱▱▱▱
info 1 ▰▰▰▰▰▰▰▰
Projected headroom — estimate, not a re-scan
Fix scope Readiness Δ
Fix critical 100 → 100 +0
+ high 100 → 100 +0
+ medium 100 → 100 +0
+ low 100 → 100 +0
+ info (all) 100 → 100 +0

Projected by re-applying trustabl's own scoring with the listed findings resolved (nothing new introduced). Treat as guidance, not a guarantee.

Metric Value
Repository ruslanmv/gitpilot
Branch fix/idempotent-approved-mutations
Readiness score 100
Risk score 0
Findings 1
Max severity info
Native exit 0
Rules version 184118273e833a55b83be9a77da993b616cd0b1588c66610f55c328b82243503

✅ Passed scanning

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fc7f686dcc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread gitpilot/toolkit/registry.py Outdated
Comment on lines +858 to +861
payload = await store.run_once_async(
scope=ctx.idempotency_scope(spec),
idempotency_key=call.id,
arguments={"tool": spec.id, "arguments": call.arguments or {}},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Include the turn in synthesized mutation keys

When a REACT_TEXT run invokes the same mutating tool twice, ReactTextAdapter._to_turn() assigns both calls the constant ID react_0, while this ledger scopes keys only by run, target, and tool. Consequently, a second separately approved call with the same arguments replays the first result without executing, and one with different arguments is rejected as an idempotency conflict; use a call ID that incorporates the turn/iteration before treating it as the approval key.

Useful? React with 👍 / 👎.

Comment thread gitpilot/issue_tools.py
Comment on lines 77 to 80
def create_issue(
title: str,
idempotency_key: str,
body: str = "",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Inject legacy idempotency keys outside the CrewAI schema

In the default legacy CrewAI path, _build_issue_agent() passes ISSUE_TOOLS directly to CrewAI and no wrapper injects an approval/request ID, so this required parameter becomes model-facing even though the model is never given such an ID. Mutating calls will either fail argument validation when the model omits it or use a fabricated value that cannot remain stable across retries, defeating the new guarantee; bind the runtime-generated key in a wrapper instead of adding it to the exposed tool signature.

Useful? React with 👍 / 👎.

Comment thread gitpilot/toolkit/registry.py Outdated
Comment on lines +854 to +856
async def _approved_operation() -> Dict[str, Any]:
result = await _invoke_handler()
return result.to_idempotency_payload()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Treat failed external ToolResults as indeterminate

When an externally mutating handler catches a lost-response exception and returns ToolResult.failure—as _handler_for() does for every MCP exception and the remote filesystem and Git push handlers do for network failures—this callback returns normally, so run_once_async() records the key as completed. The caller then receives an ordinary failure without requires_reconciliation, even though the external action may have committed, and a newly approved retry can repeat it; propagate or explicitly mark ambiguous external failures as indeterminate rather than serializing every ToolResult as completed.

Useful? React with 👍 / 👎.

Comment on lines +195 to +199
first = asyncio.run(registry.execute(call, ctx))
second = asyncio.run(registry.execute(call, ctx))

assert not first.ok and first.error == "TimeoutError"
assert not second.ok and second.error == "idempotency_guard"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Make the new timeout regression test pass

On every supported Python version (3.11–3.12), built-in TimeoutError is an alias of asyncio.TimeoutError, so the exception raised by this test is caught by ToolRegistry.execute() and returned as error="timeout", not "TimeoutError"; running the targeted suite on Python 3.12 therefore fails this assertion consistently (and the message also says timed out after Nones). Adjust the timeout handling or the expected result so the committed suite is green.

AGENTS.md reference: AGENTS.md:L49-L49

Useful? React with 👍 / 👎.

@ruslanmv
ruslanmv merged commit 1b731f3 into master Sep 12, 2026
6 checks passed

This branch was successfully deployed

1 active deployment
Preview — f8719e6f Deployed Sep 12, 2026 by vercel[bot]
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