Goal
When an agent in Slack or Discord asks the user a question (ask_question) and the process restarts before the answer, the user's next message is still taken as the answer and the turn continues, and the continuation is appended to the session transcript. Today the answer becomes a new turn and the question is orphaned. Known limit from round 1 (STATE.md "After the loop": "Channels: a pending ask_question in Slack or Discord is lost on restart"; our-report row 10).
Current state
- The pause itself is durable:
src/tools/built-in/askQuestion.ts:4-9 ("pauses the run through the approval mechanism (needsApproval: true) ... the pending record has kind: 'question'"), needsApproval: true (58); recorded as kind: 'question' by describeApproval (src/createAgentApprovals.ts:133-136).
- What is lost is in memory:
- Slack:
const questions = new Map<string, string>() (sessionKey to approval id, src/channels/slackChannel.ts:140), set in onApproval (213-215), read in toInbound (186-189): if (question) return { decision: { id: question, answer: read.text } }; with no inbound.
- Discord: same map (
src/channels/discordChannel.ts:153), set at 223-225, read at 183-187, returning { decision: { id: question, answer: input }, inbound }.
mountChannels: paused is new Map<string, PausedTurn>() (src/channels/mountChannels.ts:111, set in finish at 156). decide() (207-216) uses inbound if the decision carries it, else paused; else 404 "No pending approval".
createAgentApprovals: pending and sessions maps (src/createAgentApprovals.ts:129-130); list() returns only this process's pauses (203); resolve binds the continuation to a session only if sessions knows the approval (184-189), which is why a continuation after a restart is not appended to the transcript.
- Sessions after a restart (
docs/sessions.md:174-181): while a turn waits on approval, the session's resume(), send() and stream() throw SessionAwaitingApprovalError (with its approvalId, src/execution/errors.ts:73); "After a restart, open the session and call resume() (or send()) once before resolving, so the agent knows which session the approval belongs to".
ChannelContext (src/channels/defineChannel.ts:89-96): approval(id) (this process only), sessionId(sessionKey), hasSession(sessionKey). Built in mountChannels.ts:116-123.
- Docs:
docs/channels.md:196-201, 227-229 (Slack: "the next message in the thread is the answer"), 263-268 ("Only a pending ask_question ... lives in the handler's memory: a restart forgets it ... a continuation is posted but ... it is not appended to the session transcript"), 290-292 and 330-333 (Discord, same).
- Tests: restart pattern for approval clicks, "a click still resolves after a restart: a second channel over the same stores" (
src/channels/slackChannel.test.ts:275-290, src/channels/discordChannel.test.ts:235-246); single-process ask_question tests (slackChannel.test.ts:311-322, discordChannel.test.ts:248-259, src/channels/channels.test.ts:161). Fakes: fakeSlack() (slackChannel.test.ts:23-26), fakeDiscord() (discordChannel.test.ts:31-32); setup() builds createAgent with mockModel plus mountChannels.
Scope
In:
- New
ChannelContext.pendingQuestion(sessionKey: string): Promise<string | undefined>: the id of the kind: 'question' approval the session for sessionKey is waiting on, or undefined. Implementation in mountChannels: first this process's pending list; when it has none for that session, open the session (agent.session({ id, store })) and call resume() once, catching SessionAwaitingApprovalError for its approvalId (this also binds the approval to the session, so the continuation is appended); then confirm the record is a question (from agent.approvals.list() after the resume, or from the error if it carries the kind; add the kind to the error if needed). A session with no pending turn returns undefined and does not run anything new; if resume() would finish an interrupted, non-approval turn, do not call it: check session.pending() first (docs/sessions.md:170-173) and only call resume() when the pending turn is awaiting approval.
- Slack (
slackChannel.ts:186-189) and Discord (discordChannel.ts:183-187): keep the in-memory questions map as a fast path; when it has no entry, ask ctx.pendingQuestion(key). Slack's answer decision now includes inbound (rebuilt from the message's channel and thread, as the click path does at 179-180), so decide() works without paused.
- Only the session's own thread may answer: the sessionKey already scopes it (thread for Slack, channel for Discord); keep the existing rule that answers are not restricted to
approvers (docs/channels.md:196-201), unchanged.
- Docs
docs/channels.md: rewrite 227-229, 263-268, 290-292 and 330-333 to say a pending question survives a restart (given durable stores for sessions, checkpoints and approvals) and the continuation is appended to the transcript; document pendingQuestion in the ChannelContext description for custom channels (near line 61). No heading changes. CHANGELOG entry.
Out:
- Function-form
approvers failing closed after a restart (docs/channels.md:198): not in this ticket; open an issue if not already tracked.
- Other channels (webhook, HTTP) and the new channels of N11: they get
pendingQuestion through ChannelContext and can use it later.
- Approval clicks: already survive a restart.
Acceptance criteria
Live test
None: this ticket spends nothing (no live platform calls; the fakes cover Slack and Discord).
Dependencies
None. N11a/b/c (new channels) should land after this ticket so they can use pendingQuestion. Touches src/createAgentApprovals.ts only if the error needs the approval kind; coordinate with M4 (hub) if both are open.
Notes for the implementer
SessionAwaitingApprovalError is thrown by the session API while a turn waits; confirm by reading src/session/AgentSession.ts that resume() on such a session binds the approval to it in createAgentApprovals' sessions map; if it does not, add that binding in this ticket (it is the reason continuations are not appended today).
- Use the same stores in both
setup() calls in the tests, as the click-restart tests do; MemorySessionStore and InMemoryApprovalStore shared between instances simulate durable stores.
- Serialize per session as
runTurn does (serialized, mountChannels.ts:141-147), so two quick messages after a restart do not both try to answer.
Round 2 ticket M10a. Before starting, read the agent brief (worktree rules, verification list, live-test budget) and the plan. One ticket is one pull request; put Closes #<this issue> in it.
Goal
When an agent in Slack or Discord asks the user a question (
ask_question) and the process restarts before the answer, the user's next message is still taken as the answer and the turn continues, and the continuation is appended to the session transcript. Today the answer becomes a new turn and the question is orphaned. Known limit from round 1 (STATE.md "After the loop": "Channels: a pendingask_questionin Slack or Discord is lost on restart"; our-report row 10).Current state
src/tools/built-in/askQuestion.ts:4-9("pauses the run through the approval mechanism (needsApproval: true) ... the pending record haskind: 'question'"),needsApproval: true(58); recorded askind: 'question'bydescribeApproval(src/createAgentApprovals.ts:133-136).const questions = new Map<string, string>()(sessionKey to approval id,src/channels/slackChannel.ts:140), set inonApproval(213-215), read intoInbound(186-189):if (question) return { decision: { id: question, answer: read.text } };with noinbound.src/channels/discordChannel.ts:153), set at 223-225, read at 183-187, returning{ decision: { id: question, answer: input }, inbound }.mountChannels:pausedisnew Map<string, PausedTurn>()(src/channels/mountChannels.ts:111, set infinishat 156).decide()(207-216) usesinboundif the decision carries it, elsepaused; else 404 "No pending approval".createAgentApprovals:pendingandsessionsmaps (src/createAgentApprovals.ts:129-130);list()returns only this process's pauses (203);resolvebinds the continuation to a session only ifsessionsknows the approval (184-189), which is why a continuation after a restart is not appended to the transcript.docs/sessions.md:174-181): while a turn waits on approval, the session'sresume(),send()andstream()throwSessionAwaitingApprovalError(with itsapprovalId,src/execution/errors.ts:73); "After a restart, open the session and callresume()(orsend()) once before resolving, so the agent knows which session the approval belongs to".ChannelContext(src/channels/defineChannel.ts:89-96):approval(id)(this process only),sessionId(sessionKey),hasSession(sessionKey). Built inmountChannels.ts:116-123.docs/channels.md:196-201,227-229(Slack: "the next message in the thread is the answer"),263-268("Only a pendingask_question... lives in the handler's memory: a restart forgets it ... a continuation is posted but ... it is not appended to the session transcript"),290-292and330-333(Discord, same).src/channels/slackChannel.test.ts:275-290,src/channels/discordChannel.test.ts:235-246); single-processask_questiontests (slackChannel.test.ts:311-322,discordChannel.test.ts:248-259,src/channels/channels.test.ts:161). Fakes:fakeSlack()(slackChannel.test.ts:23-26),fakeDiscord()(discordChannel.test.ts:31-32);setup()buildscreateAgentwithmockModelplusmountChannels.Scope
In:
ChannelContext.pendingQuestion(sessionKey: string): Promise<string | undefined>: the id of thekind: 'question'approval the session forsessionKeyis waiting on, orundefined. Implementation inmountChannels: first this process's pending list; when it has none for that session, open the session (agent.session({ id, store })) and callresume()once, catchingSessionAwaitingApprovalErrorfor itsapprovalId(this also binds the approval to the session, so the continuation is appended); then confirm the record is a question (fromagent.approvals.list()after the resume, or from the error if it carries the kind; add the kind to the error if needed). A session with no pending turn returnsundefinedand does not run anything new; ifresume()would finish an interrupted, non-approval turn, do not call it: checksession.pending()first (docs/sessions.md:170-173) and only callresume()when the pending turn is awaiting approval.slackChannel.ts:186-189) and Discord (discordChannel.ts:183-187): keep the in-memoryquestionsmap as a fast path; when it has no entry, askctx.pendingQuestion(key). Slack's answer decision now includesinbound(rebuilt from the message's channel and thread, as the click path does at 179-180), sodecide()works withoutpaused.approvers(docs/channels.md:196-201), unchanged.docs/channels.md: rewrite 227-229, 263-268, 290-292 and 330-333 to say a pending question survives a restart (given durablestores for sessions, checkpoints and approvals) and the continuation is appended to the transcript; documentpendingQuestionin theChannelContextdescription for custom channels (near line 61). No heading changes. CHANGELOG entry.Out:
approversfailing closed after a restart (docs/channels.md:198): not in this ticket; open an issue if not already tracked.pendingQuestionthroughChannelContextand can use it later.Acceptance criteria
src/channels/slackChannel.test.ts: "a pending ask_question survives a restart": firstsetup()runs a turn that pauses onask_questionand posts the question; a secondsetup()over the same session, checkpoint and approval stores (as at 275-290) receives the user's next thread message; the turn continues with that answer, the reply is posted to the thread, and the session transcript contains the question, the answer and the final reply.discordChannel.test.ts(next/askin the channel).pendingQuestioninmountChannels(in-process hit, after-restart hit, no pending turn, pending non-question approval).docs/channels.mdand CHANGELOG updated;docs:verify-snippets,docs:llms:checkpass.Live test
None: this ticket spends nothing (no live platform calls; the fakes cover Slack and Discord).
Dependencies
None. N11a/b/c (new channels) should land after this ticket so they can use
pendingQuestion. Touchessrc/createAgentApprovals.tsonly if the error needs the approval kind; coordinate with M4 (hub) if both are open.Notes for the implementer
SessionAwaitingApprovalErroris thrown by the session API while a turn waits; confirm by readingsrc/session/AgentSession.tsthatresume()on such a session binds the approval to it increateAgentApprovals'sessionsmap; if it does not, add that binding in this ticket (it is the reason continuations are not appended today).setup()calls in the tests, as the click-restart tests do;MemorySessionStoreandInMemoryApprovalStoreshared between instances simulate durable stores.runTurndoes (serialized,mountChannels.ts:141-147), so two quick messages after a restart do not both try to answer.Round 2 ticket
M10a. Before starting, read the agent brief (worktree rules, verification list, live-test budget) and the plan. One ticket is one pull request; putCloses #<this issue>in it.