Skip to content

[M10a] Slack and Discord: a pending ask_question survives a restart #233

Description

@LinuxDevil

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

  • src/channels/slackChannel.test.ts: "a pending ask_question survives a restart": first setup() runs a turn that pauses on ask_question and posts the question; a second setup() 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.
  • The same test for Discord in discordChannel.test.ts (next /ask in the channel).
  • A message in a thread whose session has no pending question after a restart still starts a normal turn; a session whose pending turn is a regular approval (not a question) is not answered by a plain message.
  • Unit test for pendingQuestion in mountChannels (in-process hit, after-restart hit, no pending turn, pending non-question approval).
  • Existing channel tests pass unchanged.
  • docs/channels.md and CHANGELOG updated; docs:verify-snippets, docs:llms:check pass.
  • All BRIEF-2.md verification commands pass.

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    model:opusRun loop, security or API design; needs Opusround-2Round 2 plan ticketwave-2Round 2, wave 2

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions