Skip to content

feat: expose idempotency key to HTTP, MCP, and CLI callers#251

Merged
windoliver merged 9 commits intomainfrom
feat/expose-idempotency-key-209
Apr 14, 2026
Merged

feat: expose idempotency key to HTTP, MCP, and CLI callers#251
windoliver merged 9 commits intomainfrom
feat/expose-idempotency-key-209

Conversation

@windoliver
Copy link
Copy Markdown
Owner

Summary

Closes #209.

The idempotency engine (fingerprint-based dedup, single-flight concurrency, STATE_CONFLICT on key reuse with different payload) already existed at the operation layer but no external caller could use it — HTTP, MCP, and CLI surfaces didn't expose the idempotencyKey parameter.

This PR wires it through all three surfaces:

  • HTTP: Idempotency-Key header on POST /api/contributions (RFC 8284 / Stripe convention)
  • MCP: idempotencyKey param on all 5 contribution tools via shared Zod fragment
  • CLI: --idempotency-key flag on grove contribute
  • Core: idempotencyKey field added to ReviewInput, ReproduceInput, DiscussInput, AdoptInput and threaded through sugar operations
  • Docs: docs/idempotency.md — full contract reference (TTL, scoping, fingerprint coverage, conflict behavior)

What this enables

Agents running in retry loops (network timeouts, process restarts, iterative workflows) can now submit contributions with a stable key. If the same key + same payload arrives again within 5 minutes, Grove returns the cached result instead of creating a duplicate. If the same key arrives with a different payload, Grove rejects it with STATE_CONFLICT — surfacing the client bug instead of silently returning stale data.

This is especially important for:

  • Agent retry storms: an agent times out waiting for a response, retries, and accidentally double-submits work
  • Iterative loops: agents that reuse stable summaries like "Fix reviewer feedback" no longer risk having legitimate resubmissions collapsed by heuristics
  • Concurrent agents: two instances of the same role racing to submit share the same key namespace, so single-flight dedup prevents duplicate writes

Test plan

  • 7 MCP integration tests: happy-path + conflict for grove_submit_work, flow-through for review/discuss/reproduce/adopt
  • 3 HTTP E2E tests: header dedup, header conflict (409), no-header baseline
  • 5 CLI tests: arg parsing, cached CID, conflict throw, no-key baseline
  • Type-check clean (tsc --noEmit)
  • 1143 tests pass, 0 new failures

The idempotency engine (fingerprint-based, single-flight, STATE_CONFLICT
on mismatch) already existed at the operation layer but was unreachable
from external callers. This wires it through all three surfaces so agents
and scripts can submit retry-safe contributions.

- HTTP: Idempotency-Key header on POST /api/contributions (RFC 8284)
- MCP: idempotencyKey param on all 5 contribution tools (shared Zod fragment)
- CLI: --idempotency-key flag on grove contribute
- Core: idempotencyKey added to ReviewInput, ReproduceInput, DiscussInput, AdoptInput
- Docs: docs/idempotency.md with full contract reference
- Tests: 15 new integration tests across all three surfaces

Closes #209
The in-memory idempotency cache was invisible across CLI invocations
because each `grove contribute` spawns a new process with an empty Map.
This made `--idempotency-key` silently useless for CLI users.

- Add `idempotency_keys` table to SQLite schema (DDL in SCHEMA_DDL)
- Add `SqliteIdempotencyStore` class with lookup/store/clear
- Add `IdempotencyStore` interface to `OperationDeps`
- `contributeOperation` checks SQLite on in-memory miss, writes to
  both layers after commit
- Wire `SqliteIdempotencyStore` through CLI `executeContribute`
- In-memory Map retained for single-flight (pending Promise coalescence)

Verified: 5/5 cross-process E2E tests pass (retry, conflict, agent
scoping, no-key baseline). 1143 unit tests pass, 0 regressions.
serve-http.ts only created a TopologyRouter when nexusClient was
present, leaving local-mode MCP servers without topology routing or
handoffs. This mirrored the serve.ts pattern: if topology exists,
use NexusEventBus when available, otherwise fall back to LocalEventBus.

Also wires runtime.handoffStore as fallback when nexusHandoffStore
is unavailable, and passes eventBus into McpDeps.

Verified: 7/7 E2E tests pass — coder submits work via MCP, handoff
created to reviewer, idempotent retry returns cached CID with no
duplicate handoff, STATE_CONFLICT on key reuse, reviewer review
auto-transitions handoff to replied.
Two findings from adversarial review:

1. Durable idempotency row is now written inside the same SQLite
   transaction as the contribution (via onCommit callback in
   writeContributionWithHandoffs). Closes the crash window where a
   contribution could be committed without its idempotency record.

2. Pending in-memory cache entries are no longer TTL-expired or
   LRU-evicted. A slow write (>5min) no longer lets a retry bypass
   single-flight and start a duplicate write.
Adversarial review round 2 findings:

1. Cross-process race: two processes could both miss the SQLite lookup
   and both commit. Now uses INSERT OR IGNORE reservation before the
   write — only one process wins. Losers detect the conflict via a
   follow-up lookup and return the winner's CID or STATE_CONFLICT.

2. HTTP session leakage: idempotency keys were not scoped by sessionId,
   so a cached CID from session A could bypass session B's policy.
   Now appends sessionId to the key when present.
…urfaces

Adversarial review round 3 findings:

1. Durable reservation now uses pending/committed states. Pending rows
   are never returned as successful results — callers get a retryable
   STATE_CONFLICT instead. Eliminates the {} placeholder parse bug and
   prevents cross-process duplicate writes.

2. IdempotencyStore now wired through all surfaces:
   - Server (serve.ts) via ServerDeps + toOperationDeps
   - MCP stdio (serve.ts) via McpDeps
   - MCP HTTP (serve-http.ts) via McpDeps
   - CLI (contribute.ts) — already wired
   All surfaces now use durable SQLite reservation.
Adversarial review round 4 findings:

1. Pending reservations now rolled back on pre-commit failures via
   rollback() — no more stuck pending rows for the full TTL.

2. CLI path (no handoffs) now uses putWithCowrite when onCommit is
   set, making contribution + idempotency row truly atomic even
   without handoff records.

3. reserve() purges expired rows before INSERT OR IGNORE, so keys
   are reusable after TTL with different fingerprints.
…identity warning

Adversarial review round 5 (final) findings:

1. reserve() now only purges expired committed rows, never pending —
   prevents stealing an in-flight reservation from a slow process.

2. Idempotency cache key now includes GROVE_SESSION_ID when set,
   preventing cross-session namespace collisions in MCP HTTP.

3. CLI warns when --idempotency-key is used without --role or
   --agent-id (hostname-pid is process-scoped, breaks cross-process
   retries). Docs updated with guidance.
@windoliver windoliver merged commit e200fa8 into main Apr 14, 2026
1 check passed
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.

fix: replace summary-based contribution dedupe with explicit idempotency semantics

1 participant