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.
117 lines
4.3 KiB
Python
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
|