Give Slack threads a persistent Session

KN
7d ago
+5367-121

What type of PR is this?

/kind api

What this PR does / why we need it:

A Slack-driven agent starts over between messages. Each message spawns a fresh Task that runs the agent with no conversation history, so iterating in a thread does not work the way iterating in a Claude Code conversation does: you re-explain context every turn.

Session and SessionSpawner already cover a conversation's lifecycle, but nothing could drive a turn from outside a browser, and nothing connected Slack to any of it. This PR adds both halves. It is the bottom of a two-PR stack; the next PR names the channel and thread in every prompt the bridge submits.

A turn driver, internal/sessionturn. There is no request/response API for a Session turn: the runtime listens on a unix socket inside the Session Pod, and the console server reaches it by pods/exec-ing /kelos/bin/kelos-session-runtime. This package speaks that protocol over the same bridge. It subscribes, submits one message request with a request ID, learns its turn ID from the echoed user message, follows only that turn's events, and returns the assistant reply on turn.completed. It handles a turn already in flight (the runtime queues behind it), a prompt merged into an already-queued turn, a stream that drops mid-turn, and a 60-minute bound on one turn. WaitReady resolves a Session to its Pod, requesting a resume when the idle policy suspended it.

The Slack ingress. SessionSpawner.spec.when gains a slack source, mutually exclusive with githubWebhook, reusing the channel, trigger, exclusion and bot-message rules TaskSpawner's Slack source already has. sessionTemplate.initialPrompt and sessionTemplate.suspend are rejected for a Slack source: every matching thread message already runs as its own turn, so an initial prompt would only delay the message that asked for something, and a suspended Session cannot answer the thread that created it.

Every prompt the bridge submits begins with a fixed envelope naming the author. That is load-bearing, not cosmetic: the runtime parses a prompt for its own commands before the agent sees it, and text beginning with ! runs as a shell command in the Session Pod with the Session's credentials.

The Slack server maps channel + thread_ts to a deterministic Session name (hashed, since a Slack timestamp contains a dot), creates the Session on the thread's first matching message and reuses it for every later matching message in that thread. An unmatched reply is ignored and never reaches the Session. Each thread gets its own serial queue, so a message arriving mid-turn waits rather than opening a second stream into the same Session; up to eight may wait before the bot says so in the thread.

What it looks like in Slack. The bot posts a :hourglass: Working on your request... reply into the thread, then edits that same message in place with the answer once the turn finishes. A reply too long for one Slack message continues in further thread replies. A failed turn, an interrupted turn, or a Session that never becomes ready reports the reason in the same place rather than leaving the thread waiting.

Which issue(s) this PR is related to:

N/A. This is #1772 rebased onto current main; that PR was opened from our fork's production branch by mistake and closed.

Special notes for your reviewer:

Decisions worth a second opinion:

  • Where the driver runs. In the Slack server process, not a separate controller. A turn does not survive a Slack server restart; the Session and its history do, so the next message in the thread picks up where it left off.
  • Concurrency within a thread. Queued, in arrival order, one at a time. The runtime also merges a prompt into an already-queued turn, which the driver follows rather than fighting.
  • Lifecycle. Every matching thread gets a Pod and, with a volumeClaimTemplate, a PVC. idlePolicy is the lever; the example sets suspendAfterSeconds: 1800 and deleteAfterSeconds: 86400.
  • Scope. spec.when.slack.channels keeps the first deploy to one channel.
  • A TaskSpawner and a SessionSpawner that both match a message each act on it, producing two replies. Documented; not enforced.

RBAC: the Slack server now needs create on pods/exec, get/list/watch on sessionspawners, and create/get/list/watch/patch on sessions. The chart grants these.

Since #1772 the Session provider contract moved turn completion onto the event sink (SetEventSink + StartTurn), so the echo provider in internal/sessionturn/integration_test.go was ported to it; the driver itself needed no change, since it speaks the socket protocol rather than the provider interface.

No UI changes, so no screenshots. make update produces no diff, and make verify, make test and make test-integration (181 + 12 install specs) pass locally.

Does this PR introduce a user-facing change?

SessionSpawner now accepts a `spec.when.slack` source. A matching Slack thread gets its own persistent Session: the first matching message creates it and every later matching message in that thread runs as another turn in the same conversation, so the agent keeps what it has already been told. A reply that does not match (by default, one that does not @-mention the bot) is ignored and never reaches the Session. The reply is posted into the thread and edited in place as the turn finishes. `spec.sessionTemplate.initialPrompt` and `spec.sessionTemplate.suspend` are rejected for a Slack source.

šŸ¤– Generated with Claude Code


Summary by cubic

Slack threads now keep a persistent Session instead of starting a fresh Task per message, so the agent retains conversation history across turns instead of re-explaining context each time.

Adds internal/sessionturn, which drives one conversation turn over the same pods/exec bridge the console server uses, and a spec.when.slack source on SessionSpawner that reuses the existing Slack matching rules from TaskSpawner. The Slack server maps channel and thread to a deterministic Session, creates it on the thread's first matching message, and runs every later matching message as another turn in the same conversation. Provider questions are declined so a turn cannot stall mid-ask, and an abandoned turn is withdrawn — interrupted if running, removed if queued — unless it also carries another client's prompt.

Slack behavior

  • The bot posts a placeholder thread reply that it edits in place with the answer; long replies continue in further thread replies, and failures report the reason in the thread. A graceful shutdown resolves any placeholder left behind.
  • Each thread queues turns one at a time; up to eight may wait before further messages are dropped with a note in the thread.
  • Unmatched replies are ignored and never reach the Session.
  • Prompts beginning with ! run as a shell command in the Session Pod, so the bridge's fixed prompt envelope — which keeps nothing user-controlled first — is load-bearing.

Validation and operations

  • spec.when now requires exactly one of githubWebhook or slack; initialPrompt and suspend are rejected for a Slack source, an idle policy is required, and a later message in the thread resumes a suspended Session.
  • The Slack server needs new RBAC (pods/exec create plus reads and writes on Sessions and SessionSpawners); the chart grants it.
  • Turn timeout (default 60m) and the queued-turns bound (default 8) are server flags configurable through chart values.
  • A turn doesn't survive a Slack server restart, but the Session and its history do, so the next message in the thread resumes the conversation.
  • A TaskSpawner and a SessionSpawner that both match a message each reply; documented, not enforced.