This repository has been archived on 2026-08-04. You can view files and clone it, but cannot push or open issues or pull requests.
orchestrator/agent-team/agent_team/transport/base.py
Adam Moussa 3847e43ba3 feat(agent-team): one Slack thread per task — root "Task received" message + threaded questions/milestones
WS Slack-UX Feature 1. A /new-task task now maps to ONE Slack thread instead of
several top-level messages.

- /new-task posts an immediate root "📥 Task received: …" ack and captures its
  ts (root_ts); this is the instant acknowledgement.
- root_ts is plumbed into start: new PipelineState/TaskRecord channel
  slack_thread_ts, seeded by graph.start_task and threaded through
  Coordinator.start_task. The NewTaskCallback is now (task_text, via, root_ts).
- All clarifier questions for the task post as THREADED REPLIES under root_ts
  (chat.postMessage thread_ts=root_ts), and each question's ledger channel_ref
  is set to root_ts (NOT the reply's own ts). Because answer-mapping resolves a
  reply via find_open_question_by_channel_ref(thread_ts), a reply in the root
  thread (thread_ts==root_ts) maps to the task's currently-open question with NO
  change to the mapping logic or the first-answer-wins CAS. The open-only
  partial-unique index still holds (one open question per task at a time).
- Lifecycle milestones (parked / plan-ready / needs-input) and follow-up
  questions thread under root_ts too; the notify sink gained an optional
  thread_ts kwarg (degrades to top-level on a sink that doesn't accept it).
  notify failures still never break tick.
- SlackTransport.post_question + the live poster accept/forward thread_ts.
- No root_ts (non-/new-task origin) ⇒ top-level posts exactly as before.

AUTHZ-01 (owner-allowlist-first, fail-closed) and the atomic open→answered
compare-and-set are unchanged.

Adds plumbing for the inbound-ack reactor seam used by Feature 2 (dormant until
a reactor is injected). Tests cover thread_ts forwarding, channel_ref=root_ts,
graph seeding, and coordinator threading.
2026-06-23 15:49:40 -04:00

117 lines
4.3 KiB
Python

"""Transport interface ABC + payload dataclasses (design §3.3.1).
The durable human-in-the-loop responder owns a notify+resume seam that is
transport-agnostic. This module defines the contract every adapter implements:
* :class:`Transport` — abstract base with ``post_question`` (deliver a
question-set, return a ``channel_ref`` that embeds the ``question_id``) and
``parse_answer`` (normalize an inbound raw payload to
``(question_id, answer, via)``).
* :class:`QuestionSet` — the question-set payload carried by a LangGraph
``interrupt()``.
* :class:`NormalizedAnswer` — the normalized inbound answer the responder
feeds into the §3.3.1 first-answer-wins compare-and-set.
Concrete Slack / GitHub / Claude-Code adapters subclass :class:`Transport` in
the leaves. The signatures here are CONTRACTS the leaf builders import
verbatim, so they are explicit and final.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any
__all__ = [
"NormalizedAnswer",
"QuestionSet",
"Transport",
]
# Marker template embedded in transports without native callback metadata
# (e.g. a GitHub issue comment), so an inbound answer can be mapped back to its
# question. Slack embeds the question_id in ``callback_id`` instead.
GITHUB_MARKER_TEMPLATE = "<!-- shq:{question_id} -->"
@dataclass
class QuestionSet:
"""A set of questions delivered to Adam for one ``turn`` of a task (§3.3.1).
Carried in the ``interrupt()`` payload alongside ``thread_id``,
``question_id``, ``turn``, ``transport``, and ``deadline``. ``questions`` is
the ordered list of prompts; ``context`` is optional rendering metadata
(repo, summary) the adapter may surface.
"""
thread_id: str
question_id: str
turn: int
questions: list[str]
context: dict[str, Any] = field(default_factory=dict)
@dataclass
class NormalizedAnswer:
"""A transport-normalized inbound answer (§3.3.1).
The responder maps this into the first-answer-wins compare-and-set:
``UPDATE ... SET status='answered' ... WHERE question_id=? AND
status='open'``. ``via`` records the answering channel/identity for the
audit trail (``answered_via``).
"""
question_id: str
answer: Any
via: str
class Transport(ABC):
"""Abstract transport adapter (§3.3.1).
Subclasses implement delivery and answer parsing for one channel. The
ledger and resume worker depend only on this interface, so an adapter can
ship first (Slack) and others follow without touching the durable core.
"""
@abstractmethod
def post_question(
self,
*,
thread_id: str,
question_id: str,
turn: int,
question_set: QuestionSet,
deadline: str,
thread_ts: str | None = None,
) -> str:
"""Deliver ``question_set`` and return its ``channel_ref``.
The posted message MUST embed ``question_id`` so an inbound answer can
be mapped back (Slack ``callback_id``; a
``<!-- shq:<question_id> -->`` marker in a GitHub comment). The returned
``channel_ref`` is the transport's locator for the post (Slack message
``ts`` / issue-comment id / Claude session id) and is stored on the
ledger row so reconcile/recovery can act on it (§3.3.1).
``thread_ts`` is an OPTIONAL transport-specific threading hint (Slack's
one-thread-per-task: post the message as a reply under that root ``ts``).
Transports without native threading may ignore it. The responder only
forwards it when set, so a transport that does not accept it is never
called with it.
"""
raise NotImplementedError
@abstractmethod
def parse_answer(self, raw: Any) -> tuple[str, Any, str]:
"""Normalize an inbound ``raw`` payload to ``(question_id, answer, via)``.
Extracts the embedded ``question_id`` (from the Slack ``callback_id`` /
the GitHub marker / the Claude session), the answer value, and the
``via`` channel identity. The responder feeds the result into the
atomic compare-and-set. Implementations may build a
:class:`NormalizedAnswer` internally and return its fields as the tuple
the contract specifies.
"""
raise NotImplementedError