mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* fix(web): answer one inline question form exactly once The `<question-form>` submit lock lived only in a ref owned by the mounted FormBlock, and a send that parked in the busy conversation's queue was reported back to the form as refused. Leaving the project while the answer was still settling therefore rebuilt the form as "never submitted" — and rolled back any file it had uploaded for a send the queue still pointed at. The user re-answered, the queue drained, and one logical task produced two identical user messages and two assistant runs: duplicate model calls, duplicate billing, and a reply split in two with a mid-turn action bar. The lock now belongs to the form occurrence (project + conversation + assistant message + form id), recorded next to the draft so it outlives the component, and a busy conversation no longer counts as the send being consumed. Only the answer surfacing in history, or an explicit submit failure, re-opens the form. * fix(web): take the form's occurrence lock before the submit, not after it The lock was written from `acceptSubmission`, i.e. only once the host had settled the send. That left every await in between unguarded — uploading the answer's files, and above all the host's own send while it sits in a pre-run gate or parks in a busy conversation's queue. Leaving the project in that window is the reported sequence, and the remount rebuilt the form as "never submitted": the same answer could be sent a second time and both sends ran. The lock is now taken the moment the answer leaves the form, and every failure path releases it. Because a submit can outlive the component that started it, a release has to reach whichever form is mounted when it lands, or an answer refused after the user navigated away would leave the form locked with no way back — so the occurrence lock notifies its subscribers. * fix(web): keep the form's occurrence lock when storage is denied Session storage carries the lock so it also survives a reload, but a private window or an embedded frame refuses it: the write threw, the read returned false, and every remount there read "never submitted" — handing exactly the duplicate submit back to the users whose context denies storage. The lock now falls back to a page-lived set when the write throws. That set is populated only on refusal, so a context with working storage keeps storage as the single record and nothing outlives the tab's own cleanup. * fix(web): identify a question-form answer by its occurrence "At most one user answer message and one non-failed run per sourceAssistantMessageId + formId" was only ever a property of the form's own submit lock, so every path around that lock still doubled the answer: a second tab, a reload where storage is denied, a queue replay. The answer's send ids are now derived from the occurrence the way the Home handoff already derives its own, which hands the guarantee to the layers that already dedupe on identity — the conversation queue keys entries on `clientRequestId`, the answer row keeps one `userMessageId` instead of appending a second, and the daemon's `createOrReuse` returns the first run rather than spawning another. `formId` is plumbed from the form to the host so two forms in one assistant message stay distinct occurrences. Also pins the invariant that keeps the two halves apart: a question form and its answer share a `taskAnalytics` lineage by design, and folding on that lineage would merge a form with the work its answer asked for. Only the daemon-issued `strategyTaskRunIndex` marks a continuation carrying no prompt of its own, which off-mode never emits. * test(e2e): pin one answer per question-form occurrence Covers OPEND-2367's acceptance at the daemon boundary: after a rapid double submit, leaving the project and coming back, and a full reload, the conversation still holds exactly one answer for the occurrence. The docblock states its limit rather than implying more: it stays green on the pre-fix build, because once the answer reaches the message list that build locked the form from history too. The window this bug actually lives in — the answer still in flight — needs the submit promise held open and is covered at the component layer. * fix(web): key only the run on the answer occurrence The occurrence identity was also naming the answer's message rows, but handleSend persists the user row before the daemon answers. A second tab answering the same form differently would upsert the first tab's accepted answer and still lose the run to createOrReuse, leaving a transcript showing one answer while another actually ran. Only clientRequestId is derived now, so a duplicate still costs at most one run while it can no longer rewrite an accepted answer. Making the first answer authoritative for the row too needs the check and the write at one authority; the client cannot make that atomic before the daemon has replied. * fix: claim a question-form answer once at the daemon Keying only the run left the acceptance half met: a second tab still wrote a second answer row for the same occurrence, and "at most one user answer message" is not satisfied by calling the extra row cosmetic. The client cannot decide this. `handleSend` persists the user row before the daemon has answered, and no client-side read-then-write is atomic against another tab. So the answer's row keeps its occurrence-derived id and the write goes out `createOnly`: the daemon, where the check and the write are one operation, keeps whichever answer landed first and returns it. The run is claimed on the same key by `createOrReuse`, and the losing submitter adopts the returned answer so its transcript shows what the surviving run actually read rather than an answer no run ever saw. `saveMessage` now returns the stored row so a caller can tell it lost. * test(e2e): pin the concurrent occurrence claim for a question-form answer The review's remaining worry was a cross-tab race: two submitters answering one occurrence differently, one winning the answer row and the other the run, so the transcript would show an answer no run consumed. This drives that race at the daemon's HTTP boundary instead of arguing about orderings — two concurrent POST /api/runs sharing the occurrence's clientRequestId and userMessageId, with different answers, one of which makes the fake agent finish differently so the terminal run reports which prompt it actually ran. The daemon turns out to be stricter than reuse: the occupancy is a request fingerprint too, so the divergent submitter is refused with 409 IDEMPOTENCY_CONFLICT and never creates a run at all. The spec asserts that, one answer row carrying the occurrence's id, and that the finished run agrees with the stored answer. `ChatRunCreateBody` gains the `userMessageId` the daemon already accepts, so a spec can pin the user row the way the web client does. * fix(daemon): keep a turn's recovery lineage when a rewrite omits it `upsertMessage` bound `task_analytics_json` from the incoming write unconditionally, so any rewrite that carried no opinion about the field erased it. The run-create seed is exactly such a writer: it rebuilds the user row from the request that won the claim, with content, attachments, session mode, run context and plugin snapshot — and no analytics. A client PUT landing first therefore wrote the answer's lineage and the seed immediately nulled it, which is why an accepted clarification read back with `taskAnalytics: null` and lost the logical task it belongs to after a reload. The UPDATE now preserves the stored value when the writer omits the field. An explicit null still clears it, so deliberately dropping lineage stays possible; only silence is treated as silence. Covered both orderings — PUT before the run request and after — in the e2e dialog spec; the first was red before this change. --------- Co-authored-by: Cheems <94773058+itscheems@users.noreply.github.com>