feat(question): save partial answers to a multi-step question
What: A person can answer some steps of a multi-step question and finish later, from another client (web, Slack, Messages) or after a reload. New PUT /question/:requestID/progress stores the answers given so far on the question's existing question_request row, and GET /question returns them as progress.
Why: Today every client keeps partial answers in its own memory: the web card in an in-memory map, Slack in message metadata, Messages in delivery state. A reload or a switch of client loses them. It is also the prerequisite for auto-answering: when a question times out, only the steps the person has not answered should be filled in for them. Agreed order: rebase first (#52, merged), then these question changes. Slack thread · consumers: andytown #28112 (auto-answering) and #27021 (question state).
How:
progress?: Answer[]onQuestionRequest, positional likereply's answers (an empty array is a step not answered yet). It lives in the row's existing JSONdatacolumn, so there is no migration.Question.saveProgressupdates the row, scoped to the instance's project with the same conditionclaimuses (extracted asinProject). If a reply or reject already claimed the row, the write matches nothing and returnsQuestionNotFoundError(404), the same errorreplyreturns.- Saving progress never resolves the question, publishes no event, and does not touch the in-memory waiter. A question still resolves only through
replyorreject.
Compatibility across binary versions. Sandboxes pick up a new binary only when they respawn, so old and new binaries run side by side for days:
- New binary, current andytown: payloads are unchanged.
progressappears only after something saves it (the test asserts a fresh request has noprogresskey). - Rolled back to v1.18.32-1 after progress was saved:
GET /questionkeeps working. The old schema strips the unknown key: encoding a row that carriesprogresswith the v1.18.32-1 schema gives[{"id":…,"sessionID":…,"questions":[…]}]. - Old binary, new andytown: the route does not exist, and the web app catch-all answers
200 text/html. Measured by sendingPUT /question/que_x/progressto pristinedevthrough the production route tree.
So the client contract is: count the save as done only when the response is JSON true. A JSON QuestionNotFoundError means the question was already answered. Anything else, including a 200 HTML page, means the sandbox predates this route, so keep progress on the client exactly as today.
Testing done:
- Fork CI gates on this branch:
bun typecheck30/30;packages/opencodebun run test3,657 pass / 0 fail;bun run test:httpapi210/210 in all three modes, 0 missing (new scenarios: 400 for a malformed body, 404 for an unknown request); core migration gate 18/18. - New test: partial answers are listed, a fresh request carries no
progress, and saving after the reply fails withNotFoundError.
Not in this PR: auto-answering itself (the per-session delay, a recommended answer on each question, and marking which answers were chosen for the person). It is built and parked on claude/question-auto-answer-timer and will follow as its own PR on top of this one.
After merge: cut v1.18.32-2, then bump the two andytown pins. I will ask before each.
🤖 Generated with Claude Code
Summary by cubic
Saves partial answers to a multi-step question so a person can finish later from any client or after a reload. New PUT /question/:requestID/progress stores the answers so far on the existing question_request row (no migration), and GET /question returns them as progress. This is the prerequisite for auto-answering, which needs to know which steps are already answered.
Saving progress never resolves the question, publishes no event, or touches the in-memory waiter; a row claimed by reply or reject returns QuestionNotFoundError (404), same as reply.
Compatibility
- Old binaries strip the unknown
progresskey, soGET /questionstill works after a rollback. - New clients hitting an old binary get a 200 HTML catch-all, so a client must treat only a JSON
trueresponse as saved. - Rollout: cut
v1.18.32-2, then bump the two andytown pins (asking before each).