Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions docs/idempotency.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# Idempotency

Grove contributions support explicit idempotency keys for retry-safe submissions. The semantics follow [RFC 8284](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/) and Stripe's `Idempotency-Key` convention.

## How to use

### MCP tools

Pass the optional `idempotencyKey` parameter to any contribution tool:

```json
{
"summary": "Fix the parser bug",
"artifacts": { "fix.ts": "blake3:..." },
"agent": { "agentId": "coder-1", "role": "coder" },
"idempotencyKey": "my-unique-key"
}
```

Available on: `grove_submit_work`, `grove_submit_review`, `grove_discuss`, `grove_reproduce`, `grove_adopt`.

### HTTP API

Pass the `Idempotency-Key` header on `POST /api/contributions`:

```http
POST /api/contributions
Content-Type: application/json
Idempotency-Key: my-unique-key

{ "kind": "work", "summary": "...", ... }
```

### CLI

Pass `--idempotency-key` to `grove contribute`:

```bash
grove contribute --summary "Fix parser" --idempotency-key my-unique-key --role coder
```

**Important**: For cross-process retry safety, always set `--role` or `--agent-id` alongside `--idempotency-key`. Without a stable identity, each process generates a unique agent ID (`hostname-pid`) and cross-process retries won't match.

## Semantics

| Scenario | Behavior |
|----------|----------|
| Same key + same input | Returns cached result (retry) |
| Same key + different input | Returns `STATE_CONFLICT` error (HTTP 409) |
| Same key + in-flight request | Awaits the pending write (single-flight) |
| No key provided | No deduplication — each call creates a new contribution |

## Key details

- **Scope**: Keys are namespaced per agent (`agent.role` if set, otherwise `agent.agentId`). Two different agents can use the same key without colliding.
- **TTL**: Cached results expire after **5 minutes**. After expiry, the key can be reused.
- **Cache size**: Up to 1024 entries (LRU eviction when full).
- **Persistent**: The cache is backed by SQLite (`idempotency_keys` table in `grove.db`), so keys survive across CLI process restarts. An in-memory layer provides single-flight deduplication within a single process.
- **Fingerprint coverage**: The conflict check hashes `kind`, `mode`, `summary`, `description`, `artifacts` (name + hash), `relations`, `scores`, `tags`, `context`, and agent scope. Any difference in these fields triggers `STATE_CONFLICT` on key reuse.

## Key format

Keys are opaque strings. UUIDv4 or UUIDv7 are recommended. The key itself is not stored in the contribution — it only controls deduplication during the cache TTL window.

## When to use

- **Agent retry loops**: Generate a key before the first attempt, reuse it on retries.
- **Network retries**: If a submission times out, replay with the same key to avoid duplicates.
- **Iterative work**: When an agent intentionally resubmits with updated artifacts under the same summary, use a **new key** (or no key) so the submission is not suppressed.

Idempotency keys are optional. Callers that don't need retry safety can omit the key entirely.
97 changes: 96 additions & 1 deletion src/cli/commands/contribute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
* Covers argument parsing, validation, execution logic, and edge cases.
*/

import { describe, expect, test } from "bun:test";
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { _resetIdempotencyCacheForTests } from "../../core/operations/contribute.js";
import type { ContributeOptions } from "./contribute.js";
import {
executeContribute,
Expand Down Expand Up @@ -265,6 +266,16 @@ describe("parseContributeArgs", () => {
expect(opts.agentOverrides.agentId).toBe("my-agent");
expect(opts.agentOverrides.provider).toBe("openai");
});

test("parses --idempotency-key flag", () => {
const opts = parseContributeArgs(["--summary", "test", "--idempotency-key", "my-key-1"]);
expect(opts.idempotencyKey).toBe("my-key-1");
});

test("idempotencyKey is undefined when --idempotency-key is omitted", () => {
const opts = parseContributeArgs(["--summary", "test"]);
expect(opts.idempotencyKey).toBeUndefined();
});
});

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1235,3 +1246,87 @@ describe("grove contribute E2E", () => {
}
});
});

// ---------------------------------------------------------------------------
// Idempotency key plumbing (CLI surface)
// ---------------------------------------------------------------------------

describe("executeContribute: idempotencyKey", () => {
beforeEach(() => {
_resetIdempotencyCacheForTests();
});

afterEach(() => {
_resetIdempotencyCacheForTests();
});

test("same idempotency key + same input returns the cached CID", async () => {
const dir = await createTempDir();
try {
await executeInit(makeInitOptions(dir));

const opts = makeContributeOptions({
summary: "Idempotent CLI work",
idempotencyKey: "cli-key-1",
cwd: dir,
});

const first = await executeContribute(opts);
expect(first.cid).toMatch(/^blake3:/);

const second = await executeContribute(opts);
expect(second.cid).toBe(first.cid);
} finally {
await rm(dir, { recursive: true, force: true });
}
});

test("same idempotency key + different input throws STATE_CONFLICT", async () => {
const dir = await createTempDir();
try {
await executeInit(makeInitOptions(dir));

const first = await executeContribute(
makeContributeOptions({
summary: "First CLI work",
idempotencyKey: "cli-conflict-key",
cwd: dir,
}),
);
expect(first.cid).toMatch(/^blake3:/);

await expect(
executeContribute(
makeContributeOptions({
summary: "Different work, same key",
idempotencyKey: "cli-conflict-key",
cwd: dir,
}),
),
).rejects.toThrow(/different request body/i);
} finally {
await rm(dir, { recursive: true, force: true });
}
});

test("no idempotency key produces distinct contributions", async () => {
const dir = await createTempDir();
try {
await executeInit(makeInitOptions(dir));

const opts = makeContributeOptions({
summary: "No key CLI work",
cwd: dir,
});

const first = await executeContribute(opts);
const second = await executeContribute(opts);

expect(first.cid).toMatch(/^blake3:/);
expect(second.cid).toMatch(/^blake3:/);
expect(second.cid).not.toBe(first.cid);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
});
30 changes: 27 additions & 3 deletions src/cli/commands/contribute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ export interface ContributeOptions {
// Metadata
readonly tags: readonly string[];

// Idempotency
readonly idempotencyKey?: string | undefined;

// Agent
readonly agentOverrides: AgentOverrides;

Expand Down Expand Up @@ -101,6 +104,7 @@ export function parseContributeArgs(args: readonly string[]): ContributeOptions
metric: { type: "string", multiple: true, default: [] },
score: { type: "string", multiple: true, default: [] },
tag: { type: "string", multiple: true, default: [] },
"idempotency-key": { type: "string" },
"agent-id": { type: "string" },
"agent-name": { type: "string" },
provider: { type: "string" },
Expand Down Expand Up @@ -130,6 +134,7 @@ export function parseContributeArgs(args: readonly string[]): ContributeOptions
metric: values.metric as string[],
score: values.score as string[],
tags: values.tag as string[],
idempotencyKey: values["idempotency-key"] as string | undefined,
agentOverrides: {
agentId: values["agent-id"] as string | undefined,
agentName: values["agent-name"] as string | undefined,
Expand Down Expand Up @@ -294,6 +299,23 @@ export async function executeContribute(options: ContributeOptions): Promise<{ c
throw new Error(`Invalid contribute options:\n ${validation.errors.join("\n ")}`);
}

// Warn if --idempotency-key is set without a stable agent identity.
// The default agentId is hostname-pid which changes on restart, making
// cross-process idempotency silently ineffective.
if (
options.idempotencyKey !== undefined &&
!options.agentOverrides.role &&
!options.agentOverrides.agentId &&
!process.env.GROVE_AGENT_ROLE &&
!process.env.GROVE_AGENT_ID
) {
process.stderr.write(
"[grove] Warning: --idempotency-key without --role or --agent-id uses a process-scoped " +
"identity (hostname-pid). Cross-process retries will not match. Set --role or --agent-id " +
"for stable idempotency.\n",
);
}

// 2. Find .grove/
const grovePath = join(options.cwd, ".grove");
try {
Expand All @@ -303,9 +325,8 @@ export async function executeContribute(options: ContributeOptions): Promise<{ c
}

// Dynamic imports for lazy loading
const { SqliteContributionStore, SqliteClaimStore, initSqliteDb } = await import(
"../../local/sqlite-store.js"
);
const { SqliteContributionStore, SqliteClaimStore, SqliteIdempotencyStore, initSqliteDb } =
await import("../../local/sqlite-store.js");
const { FsCas } = await import("../../local/fs-cas.js");
const { DefaultFrontierCalculator } = await import("../../core/frontier.js");
const { parseGroveContract } = await import("../../core/contract.js");
Expand All @@ -316,6 +337,7 @@ export async function executeContribute(options: ContributeOptions): Promise<{ c
const db = initSqliteDb(dbPath);
const rawStore = new SqliteContributionStore(db);
const claimStore = new SqliteClaimStore(db);
const idempotencyStore = new SqliteIdempotencyStore(db);
const cas = new FsCas(casPath);
const frontier = new DefaultFrontierCalculator(rawStore);

Expand Down Expand Up @@ -471,6 +493,7 @@ export async function executeContribute(options: ContributeOptions): Promise<{ c
claimStore,
cas,
frontier,
idempotencyStore,
...(contract !== undefined ? { contract } : {}),
};

Expand All @@ -484,6 +507,7 @@ export async function executeContribute(options: ContributeOptions): Promise<{ c
...(scores !== undefined ? { scores } : {}),
tags: [...options.tags],
agent: options.agentOverrides,
...(options.idempotencyKey !== undefined ? { idempotencyKey: options.idempotencyKey } : {}),
};

const result = await contributeOperation(input, opDeps);
Expand Down
Loading
Loading