Skip to content

Commit 7cd1429

Browse files
committed
feat: idempotent run dispatch + double-submit dedup (resiliency track 1)
Two complementary guards close the idempotency gap for long-horizon distributed agent runs: Backend (redelivery idempotency): - services/idempotency.py: claim_run() does an atomic Redis SET NX EX on run:dedup:{run_id} (mirrors the abort.py key convention), so a TaskIQ at-least-once redelivery of the same run_id is detected before any work. - workers/tasks.py run_agent_stream(): claim-at-entry short-circuits a duplicate run_id, returning {status: duplicate} without re-executing. Frontend (double-submit dedup): - hooks/useMessageQueue.ts enqueue(): a 1.5s content-window guard rejects an identical message re-submitted in rapid succession, so a double-click or double-Enter dispatches exactly one run instead of two queued runs. Determination (RED on development -> GREEN here): - backend pytest tests/resiliency/test_idempotency.py: 2 passed. - evals/run.sh --probe resiliency-idempotency: REGRESSION -> PASS. - frontend e2e idempotency.spec.ts (test.fail removed): Playwright passes on desktop 1920x1080 and mobile 414x896 (mobile double-Enter skipped by design; Enter-submit is gated off on touch). 6 new unit tests for the dedup window, full vitest suite 223 passed. Signed-off-by: ryaneggz <kre8mymedia@gmail.com>
1 parent 3ca3deb commit 7cd1429

6 files changed

Lines changed: 322 additions & 32 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""Idempotency service for distributed worker deduplication.
2+
3+
Provides a Redis-backed claim-at-entry guard so that duplicate dispatches
4+
of the same run_id are detected and short-circuited before any heavyweight
5+
work (LLM calls, DB writes, stream setup) begins.
6+
7+
Key design:
8+
- Key pattern: ``run:dedup:{run_id}``
9+
- Atomic SET ... NX EX: returns True (claimed) on first caller, False on
10+
every subsequent caller — no race window between GET and SET.
11+
- TTL matches the stream TTL so keys are garbage-collected automatically.
12+
13+
Usage::
14+
15+
claimed = await claim_run(run_id, redis_client=redis_client)
16+
if not claimed:
17+
return {"status": "duplicate", "stream_key": stream_key}
18+
# proceed with execution ...
19+
"""
20+
21+
import redis.asyncio as redis
22+
23+
from src.utils.logger import logger
24+
25+
# Prefix mirrors the abort:signal: pattern in abort.py
26+
DEDUP_KEY_PREFIX = "run:dedup:"
27+
28+
# TTL for dedup keys — long enough to cover any plausible queue redelivery
29+
# window; matches STREAM_KEY_TTL_SECONDS (24 h) imported from utils.stream.
30+
DEDUP_TTL_SECONDS = 86400 # 24 hours
31+
32+
33+
async def claim_run(
34+
run_id: str,
35+
*,
36+
redis_client: redis.Redis,
37+
ttl: int = DEDUP_TTL_SECONDS,
38+
) -> bool:
39+
"""Atomically claim a run_id idempotency slot in Redis.
40+
41+
Uses ``SET key value NX EX ttl`` so the claim is atomic — no race
42+
window between checking and writing.
43+
44+
Args:
45+
run_id: The unique run identifier to claim.
46+
redis_client: An already-open async Redis client (caller owns lifecycle).
47+
ttl: Key TTL in seconds; defaults to DEDUP_TTL_SECONDS (24 h).
48+
49+
Returns:
50+
True — key was not present; this caller now owns the run.
51+
False — key already existed; this is a duplicate dispatch.
52+
"""
53+
key = f"{DEDUP_KEY_PREFIX}{run_id}"
54+
result = await redis_client.set(key, "1", nx=True, ex=ttl)
55+
claimed = result is not None # SET NX returns None when key already exists
56+
if not claimed:
57+
logger.info(
58+
"run_duplicate_detected",
59+
extra={
60+
"event": "run_duplicate_detected",
61+
"run_id": run_id,
62+
"dedup_key": key,
63+
},
64+
)
65+
return claimed

backend/src/workers/tasks.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from src.schemas.entities import LLMRequest
2222
from src.workers.broker import broker
2323
from src.constants.redis import REDIS_URL
24+
from src.services.idempotency import claim_run
2425
from src.utils.stream import get_distributed_stream_key, STREAM_KEY_TTL_SECONDS
2526

2627

@@ -141,6 +142,23 @@ async def run_agent_stream(
141142
service_context = None
142143

143144
try:
145+
# Idempotency guard: claim this run_id atomically before any work.
146+
# A duplicate dispatch (at-least-once redelivery, double-click, etc.)
147+
# will find the key already set and short-circuit here.
148+
if not await claim_run(run_id, redis_client=redis_client):
149+
from src.utils.logger import logger
150+
151+
logger.info(
152+
"run_agent_stream_duplicate_skipped",
153+
extra={
154+
"event": "run_agent_stream_duplicate_skipped",
155+
"run_id": run_id,
156+
"thread_id": thread_id,
157+
"user_id": user_id,
158+
},
159+
)
160+
return {"status": "duplicate", "stream_key": stream_key}
161+
144162
# Write initializing event immediately so clients waiting for the stream
145163
# see activity before the heavy init work (model loading, DB connections, etc.)
146164
await redis_client.xadd(stream_key, {"data": ujson.dumps(("initializing", {"run_id": run_id}))})

evals/RESULTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,6 @@ in [`evals/README.md`](README.md). `SKIPPED` does not count toward pass-rate.
99
| resiliency-correlation-id | resiliency | 2026-06-13 23:49 | REGRESSION | resiliency track — correlation-id plan (backend/tests/resiliency/test_correlation_id.py) |
1010
| resiliency-dlq | resiliency | 2026-06-13 23:49 | REGRESSION | resiliency track — DLQ replay plan (backend/tests/resiliency/test_dlq_replay.py) |
1111
| resiliency-heartbeat-drain | resiliency | 2026-06-13 23:49 | REGRESSION | resiliency track — heartbeat-drain plan (backend/tests/resiliency/test_heartbeat_drain.py) |
12-
| resiliency-idempotency | resiliency | 2026-06-13 23:49 | REGRESSION | resiliency track — idempotency plan (backend/tests/resiliency/test_idempotency.py) |
12+
| resiliency-idempotency | resiliency | 2026-06-14 03:33 | PASS | resiliency track — idempotency plan (backend/tests/resiliency/test_idempotency.py) |
1313

1414
<!-- benchmark: pass-rate = PASS / (PASS + REGRESSION + TIMEOUT); SKIPPED excluded -->

frontend/e2e/idempotency.spec.ts

Lines changed: 63 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -2,78 +2,110 @@
22
* Resiliency spec: Double-submit idempotency
33
*
44
* Sending a message twice in rapid succession (two clicks / two Enter presses
5-
* before the first response arrives) must produce exactly ONE assistant reply
6-
* bubble — not two. Today, the frontend does not guard against this and a
7-
* second identical run is kicked off, so the test is annotated test.fail().
5+
* before the first response arrives) must produce exactly ONE assistant run —
6+
* not two. A short-window content dedup guard in useMessageQueue rejects the
7+
* duplicate submit so only a single run is kicked off.
88
*
9-
* FLIP: remove test.fail() once the double-submit guard lands.
9+
* Oracle: each accepted submit renders exactly one user message bubble
10+
* (`div.rounded-br-sm`). Counting user bubbles measures the number of runs
11+
* directly and is independent of whether/how the assistant reply renders (a
12+
* single assistant turn emits several `rounded-bl-sm` part elements, so the
13+
* assistant-bubble count cannot distinguish one run from two). After a
14+
* deduped double-submit there must be exactly ONE user bubble.
15+
*
16+
* The chat list is virtualized (ChatMessages.tsx useVirtualizer): a long
17+
* streaming reply scrolls row 0 (the user bubble) out of the virtual window
18+
* and unmounts it, so the count must be taken with the list scrolled to the
19+
* top. `countUserBubbles` scrolls to top, lets the virtualizer re-mount, then
20+
* counts.
1021
*/
1122

12-
import { test, expect } from "@playwright/test";
23+
import { test, expect, Page } from "@playwright/test";
1324
import { loginAsAdmin } from "./helpers/auth";
1425

1526
// Selector constants derived from verified live selectors in the briefing
1627
const CHAT_INPUT = 'textarea[placeholder="How can I help you be more productive?"]';
1728
const SUBMIT_BUTTON = '[data-tour="chat-submit-button"]';
18-
// Assistant response bubble class confirmed in ChatMessages.tsx line ~194-204
19-
const ASSISTANT_BUBBLE = "div.rounded-bl-sm";
29+
// User (human) message bubble class — one per accepted submit (ChatMessages.tsx:100)
30+
const USER_BUBBLE = "div.rounded-br-sm";
31+
32+
/**
33+
* Scroll the virtualized chat list to the top so row 0 (the user bubble) is
34+
* mounted, then return the number of user bubbles. Without scrolling to top a
35+
* long streaming reply can unmount row 0 and yield a false 0 count.
36+
*/
37+
async function countUserBubbles(page: Page): Promise<number> {
38+
await page.evaluate(() => {
39+
const scroller = document.querySelector("div.overflow-auto");
40+
if (scroller) scroller.scrollTop = 0;
41+
});
42+
// Give the virtualizer a frame to re-mount the top rows
43+
await page.waitForTimeout(300);
44+
return page.locator(USER_BUBBLE).count();
45+
}
2046

2147
test.describe("Double-submit idempotency", () => {
2248
test.beforeEach(async ({ page }) => {
2349
await loginAsAdmin(page);
24-
// Navigate to chat; adjust if the default route is different
25-
await page.goto("/", { waitUntil: "networkidle" });
50+
// Navigate straight to /chat (the authed default route) to avoid the
51+
// "/" -> "/chat" redirect race that can delay the input mount. Use
52+
// domcontentloaded (not networkidle) — the chat page holds a long-lived
53+
// streaming connection, so the network never goes idle and networkidle
54+
// would abort the navigation.
55+
await page.goto("/chat", { waitUntil: "domcontentloaded" });
56+
await page.locator(CHAT_INPUT).waitFor({ state: "visible", timeout: 30_000 });
2657
});
2758

28-
// FLIP: remove test.fail() once the double-submit guard lands.
29-
test.fail(
30-
true,
31-
"Double-submit currently produces two assistant bubbles; expected one (guard not yet implemented)",
32-
);
33-
34-
test("typing a message and submitting twice produces exactly one assistant bubble", async ({
59+
test("typing a message and submitting twice produces exactly one user bubble", async ({
3560
page,
3661
}) => {
3762
// Type a short deterministic message
3863
const input = page.locator(CHAT_INPUT);
3964
await input.fill("ping idempotency test");
4065

41-
// Submit twice as fast as possible
66+
// Submit twice as fast as possible (force-click to bypass actionability
67+
// waits and devtools overlay so the two clicks land in rapid succession).
4268
const submitBtn = page.locator(SUBMIT_BUTTON);
43-
await submitBtn.click();
44-
// Second click immediately — no await between them
45-
await submitBtn.click();
69+
await submitBtn.click({ force: true });
70+
// Second click immediately — no await between dispatch and re-fire
71+
await submitBtn.click({ force: true });
4672

47-
// Wait for at least one assistant bubble to appear (the backend will reply)
48-
await expect(page.locator(ASSISTANT_BUBBLE).first()).toBeVisible({
73+
// Wait for the first user bubble to render
74+
await expect(page.locator(USER_BUBBLE).first()).toBeVisible({
4975
timeout: 30_000,
5076
});
5177

52-
// Allow up to 5 s for a second bubble to materialise (it shouldn't)
78+
// Allow up to 5 s for a second run to materialise (it shouldn't)
5379
await page.waitForTimeout(5_000);
5480

55-
// RESILIENT OUTCOME: exactly one assistant bubble
56-
const bubbles = page.locator(ASSISTANT_BUBBLE);
57-
await expect(bubbles).toHaveCount(1);
81+
// RESILIENT OUTCOME: exactly one user bubble (one accepted run)
82+
expect(await countUserBubbles(page)).toBe(1);
5883
});
5984

60-
test("pressing Enter twice rapidly produces exactly one assistant bubble", async ({
85+
test("pressing Enter twice rapidly produces exactly one user bubble", async ({
6186
page,
62-
}) => {
87+
}, testInfo) => {
88+
// Enter-to-submit is intentionally disabled on mobile (ChatInput.tsx:157
89+
// gates handleEnqueue on `!isLikelyMobile()`), so the double-Enter path
90+
// does not exist on touch devices — there is nothing to dedup there.
91+
test.skip(
92+
testInfo.project.name === "mobile",
93+
"Enter-to-submit is disabled on mobile by design (ChatInput.tsx:157)",
94+
);
95+
6396
const input = page.locator(CHAT_INPUT);
6497
await input.fill("ping idempotency enter");
6598

6699
// Two Enter presses with no gap
67100
await input.press("Enter");
68101
await input.press("Enter");
69102

70-
await expect(page.locator(ASSISTANT_BUBBLE).first()).toBeVisible({
103+
await expect(page.locator(USER_BUBBLE).first()).toBeVisible({
71104
timeout: 30_000,
72105
});
73106

74107
await page.waitForTimeout(5_000);
75108

76-
const bubbles = page.locator(ASSISTANT_BUBBLE);
77-
await expect(bubbles).toHaveCount(1);
109+
expect(await countUserBubbles(page)).toBe(1);
78110
});
79111
});

frontend/src/hooks/useMessageQueue.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ const MAX_RETRIES = 3;
1212
/** Maximum number of messages allowed in the queue */
1313
const MAX_QUEUE_SIZE = 10;
1414

15+
/**
16+
* Time window (ms) within which an identical, non-empty enqueue is treated as
17+
* an accidental double-submit (double-click / double-Enter) and rejected.
18+
*/
19+
const DEDUP_WINDOW_MS = 1500;
20+
1521
/**
1622
* Custom hook for managing a frontend message queue.
1723
*
@@ -63,6 +69,10 @@ export function useMessageQueue(
6369
// Guard to prevent concurrent processNext calls
6470
const processingRef = useRef(false);
6571

72+
// Tracks the last accepted enqueue so an identical-content message arriving
73+
// within DEDUP_WINDOW_MS (a double-click / double-Enter) can be rejected.
74+
const lastEnqueueRef = useRef<{ query: string; at: number } | null>(null);
75+
6676
// Ref to track editingId for use in processNext callback
6777
const editingIdRef = useRef<string | null>(null);
6878
editingIdRef.current = editingId;
@@ -167,6 +177,25 @@ export function useMessageQueue(
167177
*/
168178
const enqueue = useCallback(
169179
(query: string, images: File[] = []): boolean => {
180+
// Content dedup: reject an identical, non-empty message submitted
181+
// within DEDUP_WINDOW_MS (accidental double-click / double-Enter)
182+
// without appending or dispatching. Empty queries are already a
183+
// no-op downstream, so only the identical-non-empty case is blocked.
184+
const trimmedQuery = query.trim();
185+
const last = lastEnqueueRef.current;
186+
if (
187+
trimmedQuery.length > 0 &&
188+
last !== null &&
189+
last.query === trimmedQuery &&
190+
Date.now() - last.at < DEDUP_WINDOW_MS
191+
) {
192+
console.debug(
193+
"[MessageQueue] Dropped duplicate submit within dedup window:",
194+
trimmedQuery.slice(0, 50),
195+
);
196+
return false;
197+
}
198+
170199
// Enforce MAX_QUEUE_SIZE: drop oldest messages to make room
171200
if (queueRef.current.length >= MAX_QUEUE_SIZE) {
172201
const droppedCount = queueRef.current.length - MAX_QUEUE_SIZE + 1;
@@ -193,6 +222,13 @@ export function useMessageQueue(
193222
queueRef.current = [...queueRef.current, newMessage];
194223
syncQueueState();
195224

225+
// Record this accepted enqueue for short-window dedup. Empty
226+
// queries are not tracked so a real message after a blank send is
227+
// never mistaken for a duplicate.
228+
if (trimmedQuery.length > 0) {
229+
lastEnqueueRef.current = { query: trimmedQuery, at: Date.now() };
230+
}
231+
196232
// If not streaming, process immediately
197233
if (!isStreaming && !processingRef.current) {
198234
processNext();

0 commit comments

Comments
 (0)