Skip to content

channels: bind restarted click decisions to the checkpointed session; approvals.get() reads the durable store - #388

Merged
LinuxDevil merged 1 commit into
mainfrom
fix/lou-s3-channels
Oct 4, 2026
Merged

LinuxDevil merged 1 commit into
mainfrom
fix/lou-s3-channels

Conversation

@LinuxDevil

Copy link
Copy Markdown
Owner

Summary

Closes #279
Closes #280

#279 - click after restart was not appended to the session transcript

mountChannels called agent.approvals.resolve() for a decision this process did not pause on, but nothing bound that approval to the restarted session, so the continuation ran outside AgentSession.continueTurn() and was never committed to the transcript.

Now a decision carrying inbound (the conversation as the click names it) is checked against that conversation's checkpointed turn before the approval is touched: the pending checkpoint must be awaiting-approval for exactly this approval id and a matching kind (button = tool call, answer = question; sign-in pauses are refused). session.resume() then throws the expected SessionAwaitingApprovalError, which binds the approval to the session, and agent.approvals.resolve() continues the turn inside it - so the continuation joins the transcript, a replayed click cannot run the tool twice, a click naming another conversation is refused with LOUSHO_APPROVAL_NOT_FOUND, and the next message in the thread continues the same session. onDecision audits only decisions that pass the check. Without a checkpoint store nothing can be checked, so the approval store alone decides, as before.

#280 - function-form approvers failed closed after restart

ctx.approval(id) asked only agent.approvals.list() - the pauses this process made - so a durable pending record a restarted process did not create looked identical to a denial, and function approvers failed closed.

ApprovalStore gains an optional load(id) - resolve() without claiming or deleting the record - implemented by InMemoryApprovalStore, StorageServiceApprovalStore, fileStore, SqliteStore.approvals, the KV store and Agent Forge's store (a custom store without it keeps compiling and behaves as before). The new agent.approvals.get(id) reads through it (and normalizes question records via describeApproval), and ctx.approval() falls back to it, so the approver function sees the recorded request (toolName, input, sessionId, principal) after a restart and fails closed only when no store knows the id.

Tests

  • Generic channel (channels.test.ts): restarted click appends the continuation to the transcript and the next message continues the session; click in a conversation waiting on another approval, in one with nothing pending, a replay and a concurrent double-click are refused (404) without touching the approval; a button decision on a pending ask_question is refused; an in-process click naming another conversation is refused; ctx.approval(id) answers from the durable store after a restart and is undefined once decided.
  • Slack / Discord / Telegram / Teams / GitHub: a click (tap, card press, /approve command) after a restart appends the continuation to the transcript; Slack also covers a replayed click (tool runs once) and a function approvers deciding after a restart.
  • createAgentApprovals.test.ts: agent.approvals.get() reads the durable store and stops answering once resolved.
  • Store contract (storeContracts.ts): load() reads a saved approval without resolving it and returns null after resolution.

Validation

npm run build, npm run typecheck, npm run lint, npm run docs:llms, and npx vitest run (4243 passed; the only 5 failures are pre-existing environmental ones in packages/create-lousho-agent, which needs its own dist/ build this worktree lacks).

… approvals.get() reads the durable store (#279, #280)

A channel button click after a restart resolved the approval but the
continuation ran outside the session, so it never reached the transcript;
and a function approvers saw only this process's pending list, so it
failed closed after a restart.

- mountChannels: a decision with inbound that this process did not pause
  on is checked against the conversation's checkpointed turn (exact
  approval id, matching kind, no sign-in), then bound via session.resume()
  (SessionAwaitingApprovalError) so agent.approvals.resolve() continues
  the turn inside the session and records the transcript. Mismatched,
  stale or replayed decisions are refused with LOUSHO_APPROVAL_NOT_FOUND
  before the approval is touched and without an audit entry.
- ApprovalStore gains optional load(id) - resolve() without claiming -
  implemented by the in-memory, StorageService, file, SQLite, KV and
  agent-forge stores; agent.approvals.get(id) reads through it and
  ctx.approval() falls back to it, so a function approvers sees the
  recorded request (toolName, input, sessionId, principal) after a
  restart and fails closed only when no store knows the id.
@LinuxDevil
LinuxDevil merged commit 2fd87ea into main Oct 4, 2026
2 of 8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant