Problem
The Discord bot can sometimes respond twice to a single user message. Reported behavior: one response asks for further information before committing work, while another independently running instance later times out or reports a different state after it may already have implemented changes.
This is not about the repository move. It points to a concurrency/idempotency bug in the gateway/harness around Discord mention handling and Codex runs.
Findings
Current safeguards are only process-local:
StudyBot._seen_message_ids deduplicates Discord message IDs, but only in memory inside one bot process.
StudyBot._active_mention_tasks and _mention_generations cancel/supersede active work, but only inside one bot process.
AgentGateway._channel_locks serializes Codex session resume per channel, but only inside one Python process.
ChannelSessionStore persists the Codex session id to JSON, but it does not act as a cross-process lock or job claim.
codex exec --json is spawned once per accepted mention. If two gateway processes/containers receive the same Discord event, each can start an independent Codex process and both can eventually reply.
This matches the symptom better than a single-process race: duplicate Discord gateway instances using the same bot token, or a restart/reconnect path that reprocesses the same message without a persisted idempotency record, would bypass the current in-memory dedupe.
Codex docs context
The OpenAI Codex docs describe two relevant integration modes:
codex exec is the documented non-interactive automation path. It emits JSONL events such as thread.started, turn.completed, item.*, and error.
codex app-server is a JSON-RPC integration surface with explicit thread/turn lifecycle (thread/start, thread/resume, turn/start, turn/steer, turn/completed) and bounded request queues/overload signaling.
The current gateway uses codex exec --json, not a long-lived app-server client. So the immediate race is in gateway dispatch/job ownership around each Discord event. If the project later moves to app-server, the same requirement still exists: one source message should map to one claimed turn/job, and retries should observe existing job state instead of starting a second turn.
Impact
- Users see contradictory bot replies for one request.
- One run may ask for clarification while another proceeds with implementation.
- Timeout/error reporting can be misleading because another run may have already changed files or opened a PR.
- Channel session JSON can be updated by whichever run emits a session id last.
Suggested fix
Add durable idempotency/job tracking for Discord-originated work:
- Persist a job/claim record keyed by
discord_message_id (and probably channel_id) before spawning Codex.
- Claim creation must be atomic across processes, for example SQLite with a unique constraint, file lock plus atomic create, or another shared store already mounted in the deployment.
- If a duplicate event arrives while the job is running, do not start another Codex process. Optionally edit/reuse the existing progress message or reply that the request is already running.
- Persist final status:
running, completed, failed, timed_out, cancelled, plus Codex session id, started/finished timestamps, and the Discord reply message id if available.
- Make timeout/cancel handling record status before posting failure text, so a later retry can distinguish "already completed" from "failed before producing output".
- Consider adding a deployment guard or startup warning if more than one gateway instance is expected to consume the same Discord bot token without shared job locking.
Acceptance criteria
- Two concurrent handlers/processes receiving the same Discord message id result in only one Codex invocation.
- Duplicate delivery while the first run is in progress does not produce a second Discord reply.
- Duplicate delivery after completion does not rerun Codex and can reuse or ignore the completed result.
- Same-channel follow-up messages still cancel/supersede the previous active task as currently intended.
- Different channels can still run in parallel.
- Tests cover same-process duplicate delivery and cross-process/shared-store duplicate claims.
Notes
Related files to inspect:
src/study_discord_agent/discord_bot.py
src/study_discord_agent/agent.py
src/study_discord_agent/command_runner.py
src/study_discord_agent/session_store.py
tests/test_discord_bot.py
tests/test_agent_sessions.py
Problem
The Discord bot can sometimes respond twice to a single user message. Reported behavior: one response asks for further information before committing work, while another independently running instance later times out or reports a different state after it may already have implemented changes.
This is not about the repository move. It points to a concurrency/idempotency bug in the gateway/harness around Discord mention handling and Codex runs.
Findings
Current safeguards are only process-local:
StudyBot._seen_message_idsdeduplicates Discord message IDs, but only in memory inside one bot process.StudyBot._active_mention_tasksand_mention_generationscancel/supersede active work, but only inside one bot process.AgentGateway._channel_locksserializes Codex session resume per channel, but only inside one Python process.ChannelSessionStorepersists the Codex session id to JSON, but it does not act as a cross-process lock or job claim.codex exec --jsonis spawned once per accepted mention. If two gateway processes/containers receive the same Discord event, each can start an independent Codex process and both can eventually reply.This matches the symptom better than a single-process race: duplicate Discord gateway instances using the same bot token, or a restart/reconnect path that reprocesses the same message without a persisted idempotency record, would bypass the current in-memory dedupe.
Codex docs context
The OpenAI Codex docs describe two relevant integration modes:
codex execis the documented non-interactive automation path. It emits JSONL events such asthread.started,turn.completed,item.*, anderror.codex app-serveris a JSON-RPC integration surface with explicit thread/turn lifecycle (thread/start,thread/resume,turn/start,turn/steer,turn/completed) and bounded request queues/overload signaling.The current gateway uses
codex exec --json, not a long-lived app-server client. So the immediate race is in gateway dispatch/job ownership around each Discord event. If the project later moves to app-server, the same requirement still exists: one source message should map to one claimed turn/job, and retries should observe existing job state instead of starting a second turn.Impact
Suggested fix
Add durable idempotency/job tracking for Discord-originated work:
discord_message_id(and probablychannel_id) before spawning Codex.running,completed,failed,timed_out,cancelled, plus Codex session id, started/finished timestamps, and the Discord reply message id if available.Acceptance criteria
Notes
Related files to inspect:
src/study_discord_agent/discord_bot.pysrc/study_discord_agent/agent.pysrc/study_discord_agent/command_runner.pysrc/study_discord_agent/session_store.pytests/test_discord_bot.pytests/test_agent_sessions.py