Skip to content

Feat/slack sesssion router prod - #1772

Closed
knechtionscoding wants to merge 2 commits into
kelos-dev:mainfrom
datagravity-ai:feat/slack-sesssion-router-prod
Closed

knechtionscoding wants to merge 2 commits into
kelos-dev:mainfrom
datagravity-ai:feat/slack-sesssion-router-prod

Conversation

@knechtionscoding

@knechtionscoding knechtionscoding commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

What type of PR is this?

/kind api

What this PR does / why we need it:

Gravity starts over between Slack 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.

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 — tracked in GRAV-1028.

Special notes for your reviewer:

The ticket proposed this as a stack of two PRs. It is one here because the turn
driver alone cannot be exercised in Slack, and the point of shipping this is to
see what a threaded conversation actually feels like.

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. Worth picking
    real numbers before the first deploy rather than after the cluster fills.
  • 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.

No UI changes, so no screenshots. make verify, make test and
make test-integration all pass locally (166 + 12 integration specs).

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

Gives each Slack thread a persistent Session so matching messages in a thread run as turns of one conversation, instead of each message spawning a fresh Task that starts with no history.

Slack-driven Sessions

  • Adds internal/sessionturn, which drives one turn by speaking the Session runtime's socket protocol over the pods/exec bridge the console server uses.
  • Adds spec.when.slack to SessionSpawner, mutually exclusive with githubWebhook; sessionTemplate.initialPrompt and sessionTemplate.suspend are rejected for a Slack source.
  • The Slack server maps channel + thread_ts to a deterministic Session name, creates the Session on the thread's first matching message, and reuses it for later ones. Messages arriving mid-turn wait per-thread; up to eight may queue before the bot reports the drop.
  • Every prompt starts with a fixed author envelope — the runtime parses !-prefixed text as shell commands — so no user-controlled Slack text can reach the runtime first.
  • The bot posts a placeholder reply and edits it in place with the result; failed or interrupted turns report the reason there.

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.

Written for commit 2932424. Summary will update on new commits.

Review in cubic

knechtionscoding and others added 2 commits September 23, 2026 14:43
Gravity starts over between Slack messages. Each message spawns a fresh Task
with no conversation history, so iterating in a thread means re-explaining
context every turn. Session and SessionSpawner already covered a conversation's
lifecycle, but nothing could drive a turn from outside a browser, and nothing
connected Slack to any of it.

internal/sessionturn drives one turn. The Session runtime exposes no
request/response API for a turn: it listens on a unix socket inside the Session
Pod, which the console server reaches by pods/exec-ing kelos-session-runtime.
This package speaks that protocol over the same bridge — subscribe, submit one
message, follow only that turn's events, return the assistant reply on
turn.completed. It handles a turn already in flight, a prompt merged into a
queued turn, a dropped stream, and a bound on how long a turn may run. A
provider question is declined, because a thread cannot answer one mid-turn and
an unanswered question would stall the turn while holding the runtime. A turn
the driver abandons is withdrawn — interrupted if it started, removed if it is
still queued — unless it carries another client's prompt too, in which case it
is left alone.

SessionSpawner.spec.when gains a slack source, mutually exclusive with
githubWebhook, reusing the Slack matching rules TaskSpawner already has.
initialPrompt and suspend are rejected there: every matching thread message
already runs as its own turn, and a suspended Session cannot answer the thread
that created it.

The Slack server maps channel and thread timestamp to a deterministic Session
name, creates the Session on the thread's first matching message, and reuses it
for every later matching message. Each thread has its own serial queue, so a
message arriving mid-turn waits rather than opening a second stream into the
same Session. While a turn runs the bot posts a placeholder into the thread and
edits it in place with the result; a graceful shutdown resolves any placeholder
left behind rather than leaving the thread showing work that is no longer
running.

Every prompt opens with a literal. The runtime parses a submitted prompt for its
own commands before the agent sees it — text beginning with "!" runs as a shell
command inside the Session Pod with the Session's credentials — so no part of a
Slack message that its author controls, including their display name, may come
first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

This branch had an error being deployed

1 failed deployment
ok-to-test — 29324247 Deployed Sep 25, 2026 by knechtionscoding via fork-e2e / e2e-with-environment #1779
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

kind/api Categorizes issue or PR as related to API changes needs-actor needs-priority needs-triage release-note

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant