Async ingest endpoint that demultiplexes gated-loop submissions from CLI and agents. Idempotent by batch content hash (`ingest_id`). Prevent duplicate L0 evidence in the log. Enrich records with git context (file, commit, blame) if repo.git available.
**Idempotency contract:** If the same `ingest_id` is submitted twice (same batch content), the second request returns 202 with the same `job_id` without re-enqueueing. If `ingest_id` differs but project overlaps, both are enqueued separately (ordering is per-project FIFO after dedup).
**Job status (polling):**
```
GET /memory/ingest/ingest-<job-id>
→ 200 {
"job_id": "...",
"ingest_id": "...",
"project": "poimen",
"status": "running|completed|failed",
"chunks_seen": 42,
"chunks_used": 7,
"error": null,
"created_at": "2026-08-20T...",
"completed_at": null
}
```
## Steps
1. Ingest queue — choose **local in-memory (BTreeMap keyed by ingest_id) or Redis**. For M3.5, start in-memory; scaling to Redis is P2-deferred.
1.`a1_ingest_accepted` — POST /ingest with valid payload returns 202 and body contains `job_id` field.
2.`a2_ingest_id_is_idempotent` — POST twice with same `ingest_id`, same `project` — both return 202 with identical `job_id`.
3.`a3_status_polling_works` — POST /ingest, GET /ingest/<job_id> immediately returns `status: "running"` or `status: "completed"`.
4.`a4_different_ingest_ids_both_queued` — POST /ingest (id_a), POST /ingest (id_b), GET status of both — both in queue.
5.`a5_bad_ingest_id_returns_400` — POST with `ingest_id: "xyz"` (not 64 hex chars) returns 400.
6.`a6_unknown_project_returns_400` — POST with `project: "nonexistent"` returns 400.
7.`a7_async_task_runs` — POST /ingest with a small test batch, poll /ingest/<job_id> repeatedly, verify status transitions from `running` to `completed`.
8.`a8_empty_records_returns_400` — POST with `records: []` returns 400.
**Manual verification:**
- Run the server, ingest two batches with different ingest_ids for the same project, verify they are queued in order by checking JSONL log — both should be present after ingest completes, in the order submitted.
**Command:**`cargo test -p mem-cli ingest_endpoint`
**False pass:**
- Testing with one project only. Multi-project FIFO ordering is the hard part; a single project always looks correct.
- Job status never actually transitions from `running` to `completed`. A mock status endpoint can always return `running` and pass the test if the test only polls once.
- Idempotency checked for `ingest_id` but not for `project` — two requests with same `ingest_id` but different `project` must be treated as different (they are).
- Latency estimate never validated. Estimated wait can be any number; test should assert it is > 0 and < 1 hour.
## Traps
- Using a simple Vec for the queue. FIFO per project requires either a per-project queue map or a global queue with project filtering. Per-project is cheaper.
- Job expiry: in-memory queue will grow unbounded if jobs are never pruned. Set an eviction policy (e.g., remove jobs older than 24h on every ingest request).
- Tokio task panic in the background task. Spawn with `.spawn()` which detaches on panic; use a panic hook or `.spawn_blocking()` with error handling.
- Reusing the M1.7 function directly without error wrapping. If it panics (log write fails, db timeout), the background task crashes and the job status never updates. Wrap in a Result type and catch panics.
---
Background: [DESIGN.md § Distributed API Layer](../DESIGN.md#distributed-api-layer-homelab-frontend)