Skip to content

fix: port permission idempotency model to question replies#515

Open
omercnet wants to merge 1 commit into
NeuralNomadsAI:devfrom
omercnet:fix/058-question-reply-idempotency
Open

fix: port permission idempotency model to question replies#515
omercnet wants to merge 1 commit into
NeuralNomadsAI:devfrom
omercnet:fix/058-question-reply-idempotency

Conversation

@omercnet
Copy link
Copy Markdown
Contributor

@omercnet omercnet commented Jun 1, 2026

Problem

Answering a question prompt after a reconnect, process restart, or rapid double-submit could POST a reply for a request the backend no longer tracks, producing opencode's reply for unknown request warning. The reply was either silently dropped or mis-routed to a fallback (root) target.

Root cause: the question request lifecycle in the UI lacked the idempotency and stale-guard layer that the permission lifecycle already had. It reproduces even with a single root worktree (not primarily a worktree issue).

Fix

Ports the existing permission idempotency model to questions (entirely packages/ui, no opencode core changes):

  • New stores/question-replies.ts ledger (markQuestionReplied / hasRepliedQuestion / pruneRepliedQuestions / clearRepliedQuestions) — a structural mirror of permission-replies.ts. No shared abstraction (YAGNI; prune semantics may diverge).
  • Reconcile-before-POST: sendQuestionReply / sendQuestionReject check question.list() before POSTing. If the request is no longer pending, the UI surfaces a localized "this prompt expired — send your answer as a message instead" notice rather than POSTing to a fallback. No error-string parsing.
  • syncPendingQuestions adopts the full timestamp-based prune + hasRepliedQuestion filter so replied questions are never rehydrated on reconnect.
  • Ledger retired only via timestamp prune — never cleared on rehydrateInstance (only on instance removal), so in-flight stale replies can't slip through.
  • Stale guards on handleQuestionAsked / handleQuestionAnswered.
  • Synchronous double-submit guard at the top of handleQuestionSubmit (Enter + button can no longer both fire).
  • New i18n key toolCall.question.errors.expired added to all 7 locales.

Also fixes two pre-existing module-load crashes uncovered while making the UI suite green: client-identity.ts dereferenced window storage before its typeof window guard, and server-events.ts opened an EventSource at module load without a guard. Both minimal and production-safe.

User-visible behavior

  • Expired question prompts show a clear "answer as a message" notice instead of failing silently or mis-routing.
  • Rapid double-submit can no longer fire two replies.
  • Reconnect no longer re-surfaces an already-answered question.

Validation

  • UI suite 49/49 pass (bun test --conditions browser)
  • UI typecheck clean (tsc --noEmit)
  • Rebased on origin/dev (upstream default branch)

Eliminate opencode's "reply for unknown request" warning caused by
orphaned/stale question replies in the CodeNomad UI. Previously the
question request lifecycle lacked the idempotency and stale-guard layer
that permissions already had, so a question popup answered after a
reconnect, restart, or rapid double-submit could POST a reply for a
request the backend no longer tracked.

User-visible behavior change:
- Answering a question prompt that has already expired no longer fails
  silently or mis-routes; the UI now surfaces a localized "this prompt
  expired, send your answer as a message instead" notice (new i18n key
  toolCall.question.errors.expired added to all 7 locales).
- Rapid double-submit (Enter + button) can no longer fire two replies.
- Reconnect/rehydrate no longer re-surfaces an already-answered question.

Implementation:
- New stores/question-replies.ts ledger (markQuestionReplied /
  hasRepliedQuestion / pruneRepliedQuestions / clearRepliedQuestions),
  a structural mirror of permission-replies.ts (no shared abstraction,
  per YAGNI; prune semantics may diverge).
- sendQuestionReply/sendQuestionReject reconcile against question.list()
  before POSTing (no error-string parsing); on absent requestID they
  take the expired-prompt path instead of POSTing to a "root" fallback,
  and resolve the owning worktree when the stored slug is missing.
  markQuestionReplied is recorded on success before removeQuestionFromQueue.
- syncPendingQuestions adopts the full timestamp-based prune+filter
  pattern so replied questions are never rehydrated.
- The question ledger is cleared only on instance removal, never on
  rehydrate, so in-flight stale replies cannot slip through.
- session-events stale guards on handleQuestionAsked/handleQuestionAnswered.
- tool-call.tsx synchronous double-submit guard before the await boundary.

Also fixes two pre-existing module-load crashes uncovered while making the
UI test suite green (ownership policy): client-identity.ts dereferenced
window storage before its typeof-window guard, and server-events.ts opened
an EventSource at module load without an EventSource guard. Both fixes are
minimal and production-safe.

Validation: UI suite 49/49 pass (bun test --conditions browser), UI
typecheck clean (tsc --noEmit). Evidence packet under
evidences/058-question-reply-idempotency/.
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Jun 1, 2026

PR builds are available as GitHub Actions artifacts:

https://github.com/NeuralNomadsAI/CodeNomad/actions/runs/26752782315

Artifacts expire in 7 days.
Artifacts:

  • pr-515-f7618722fbc94518371e09e8f2f6af5d008579c9-tauri-linux
  • pr-515-f7618722fbc94518371e09e8f2f6af5d008579c9-tauri-windows
  • pr-515-f7618722fbc94518371e09e8f2f6af5d008579c9-tauri-macos
  • pr-515-f7618722fbc94518371e09e8f2f6af5d008579c9-electron-macos
  • pr-515-f7618722fbc94518371e09e8f2f6af5d008579c9-tauri-macos-arm64
  • pr-515-f7618722fbc94518371e09e8f2f6af5d008579c9-electron-windows
  • pr-515-f7618722fbc94518371e09e8f2f6af5d008579c9-electron-linux

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.

1 participant