Phase 5 of 5 · part of #363
Blocked by: #358 and #359 — both closed, so this is unblocked.
Blocks: nothing. #358–#361 do not depend on this.
One nullable column on automation_runs. No new table.
Background
Every matched event creates a brand-new AutomationRun, and therefore a brand-new
conversation with no memory of what came before.
On the OSS VM this is visible today: each app_mention in a Slack thread starts a
fresh run. Someone can @-mention the bot, get an answer, reply in-thread with a
follow-up, and the bot answers the follow-up as though it had never seen the
thread. The same shape applies to repeated events on one GitHub PR or one Linear
issue.
PR #118 attempted this with a SessionConfig on EventTrigger, JMESPath
session-key extraction, an AutomationSession model, a PendingSessionEvent
queue, and active/dead/expired session states. Its diagnosis of the user problem
is right. Its mechanism makes the automation sandbox a long-lived event consumer,
and the PR itself notes it still needs agent-server event queue endpoints, an SDK
polling loop inside the sandbox, and sandbox restart with event requeue.
That is a substantial amount of lifecycle machinery to add inside a system whose
central abstraction is a finite run.
Problem
Threaded, multi-turn interactions over an external subject (a Slack thread, a PR,
an issue) cannot share context, and the obvious fix builds a second conversation
system inside the sandbox.
Proposed change
Keep conversations where they already live, and derive the conversation id
instead of storing a mapping.
Principle: the id is a documented, deterministic function of the external
subject. Nothing is looked up to compute it, so there is no table, no
migration, and nothing that can dangle or need expiring.
conversation_id = uuid5(
AUTOMATION_CONVERSATION_NS,
f"{org_id}/{automation_id}/{source}/{subject_key}",
)
AUTOMATION_CONVERSATION_NS is pinned permanently — changing it orphans every
live thread. automation_id is in the key on purpose: editing an automation
re-keys its threads. Without it a thread stays pinned to whatever agent and
config the first event saw, and attaching with a different agent kind raises
(_agent_kind_mismatch_message).
Providers emit an optional subject on AcceptedEvent (the field was reserved in
phase 1, and the extractor hook in phase 2's descriptor):
- Slack —
team/channel/thread_ts
- GitHub —
owner/repo#number
The agent server supports this directly — verified against the source:
ConversationConfig.conversation_id is caller-supplied (request.py:117), and
_start_conversation does request.conversation_id or uuid4().
- Starting with an existing id does not error — it attaches and returns 200
instead of 201 (conversation_router.py:230).
- The SDK already implements attach-or-create:
RemoteConversation.__init__ GETs
/conversations/{id} and creates only on 404, so Conversation(conversation_id=...)
needs no branching from us.
send_message has no execution-status guard, so a new turn continues an IDLE,
ERROR or STUCK conversation. The only refusal is conversation_already_running,
which re-arms rather than dropping the message.
Reaching the sandbox
The id is derivable; the sandbox is not. In cloud each run gets a fresh
sandbox that is hard-DELETEd when it finishes (backends/cloud.py →
delete_sandbox), and conversations live on the sandbox's own filesystem
(conversations_path), so a derived id alone would land in a sandbox that has
never seen it.
So a run that owns a subject keeps its sandbox — complete_run treats it like
keep_alive, and the runtime TTL reaper still collects it eventually. A later
event finds that sandbox through AutomationRun.subject_key, one nullable
indexed column, and delivers a turn to it directly. That is what makes a
continued event cost no run at all, which a rule on its own cannot provide.
The column is not a mapping: it is never read to decide the conversation id,
only to find the sandbox. When the sandbox is gone the turn fails, the column
is cleared, and the event falls back to a run.
Flow:
event → derive subject_key → lock the subject's most recent run on a sandbox
→ found? derive the conversation id, send a turn, create no run
→ otherwise create a run, carrying the subject on it
Reuse from PR #118
- the
key_expr concept and its session-key extraction tests
- the requirement that events for one subject be serialised rather than processed
concurrently
- the worked examples (Slack
thread_ts, GitHub issue/PR number)
- the principle that this behaviour is opt-in per route/trigger
Do not adopt from PR #118
PendingSessionEvent and any SDK-side polling loop inside the sandbox
- session lifecycle states (active/dead/expired) owned by the automation service
- sandbox restart plus event requeue semantics
Open questions
- Should this be opt-in per trigger or per automation?
Acceptance criteria
References
Background
Every matched event creates a brand-new
AutomationRun, and therefore a brand-newconversation with no memory of what came before.
On the OSS VM this is visible today: each
app_mentionin a Slack thread starts afresh run. Someone can @-mention the bot, get an answer, reply in-thread with a
follow-up, and the bot answers the follow-up as though it had never seen the
thread. The same shape applies to repeated events on one GitHub PR or one Linear
issue.
PR #118 attempted this with a
SessionConfigonEventTrigger, JMESPathsession-key extraction, an
AutomationSessionmodel, aPendingSessionEventqueue, and active/dead/expired session states. Its diagnosis of the user problem
is right. Its mechanism makes the automation sandbox a long-lived event consumer,
and the PR itself notes it still needs agent-server event queue endpoints, an SDK
polling loop inside the sandbox, and sandbox restart with event requeue.
That is a substantial amount of lifecycle machinery to add inside a system whose
central abstraction is a finite run.
Problem
Threaded, multi-turn interactions over an external subject (a Slack thread, a PR,
an issue) cannot share context, and the obvious fix builds a second conversation
system inside the sandbox.
Proposed change
Keep conversations where they already live, and derive the conversation id
instead of storing a mapping.
Principle: the id is a documented, deterministic function of the external
subject. Nothing is looked up to compute it, so there is no table, no
migration, and nothing that can dangle or need expiring.
AUTOMATION_CONVERSATION_NSis pinned permanently — changing it orphans everylive thread.
automation_idis in the key on purpose: editing an automationre-keys its threads. Without it a thread stays pinned to whatever agent and
config the first event saw, and attaching with a different agent kind raises
(
_agent_kind_mismatch_message).Providers emit an optional
subjectonAcceptedEvent(the field was reserved inphase 1, and the extractor hook in phase 2's descriptor):
team/channel/thread_tsowner/repo#numberThe agent server supports this directly — verified against the source:
ConversationConfig.conversation_idis caller-supplied (request.py:117), and_start_conversationdoesrequest.conversation_id or uuid4().instead of 201 (
conversation_router.py:230).RemoteConversation.__init__GETs/conversations/{id}and creates only on 404, soConversation(conversation_id=...)needs no branching from us.
send_messagehas no execution-status guard, so a new turn continues an IDLE,ERROR or STUCK conversation. The only refusal is
conversation_already_running,which re-arms rather than dropping the message.
Reaching the sandbox
The id is derivable; the sandbox is not. In cloud each run gets a fresh
sandbox that is hard-DELETEd when it finishes (
backends/cloud.py→delete_sandbox), and conversations live on the sandbox's own filesystem(
conversations_path), so a derived id alone would land in a sandbox that hasnever seen it.
So a run that owns a subject keeps its sandbox —
complete_runtreats it likekeep_alive, and the runtime TTL reaper still collects it eventually. A laterevent finds that sandbox through
AutomationRun.subject_key, one nullableindexed column, and delivers a turn to it directly. That is what makes a
continued event cost no run at all, which a rule on its own cannot provide.
The column is not a mapping: it is never read to decide the conversation id,
only to find the sandbox. When the sandbox is gone the turn fails, the column
is cleared, and the event falls back to a run.
Flow:
Reuse from PR #118
key_exprconcept and its session-key extraction testsconcurrently
thread_ts, GitHub issue/PR number)Do not adopt from PR #118
PendingSessionEventand any SDK-side polling loop inside the sandboxOpen questions
Acceptance criteria
app_mentionin the same Slack thread reaches a run that has thecontext of the first. Stated behaviourally on purpose: with the transcript
fallback it will not always be the same conversation.
References
approach once the requirements above are extracted.