Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 121 additions & 1 deletion src/plane_conductor/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,78 @@ def _is_nickname_allowed(
return nickname in allow


async def _resolve_pending_agent_member(
plane: PlaneClient,
workspace: WorkspaceConfig,
initiator_uuid: UUID,
issue_uuid: UUID,
agents_by_nick: dict[str, Any],
) -> UUID | None:
"""Find the agent (if any) currently waiting on the initiator for `issue_uuid`.

Returns the member UUID of the latest agent whose most recent comment on
the issue opens with an initiator `<mention-component>` (the auto-stamp
written by tower's `request_handoff(target_role='initiator', …)`). That's
the structured «I'm awaiting your input» signal agents already emit
today, so we read it instead of inventing a new state-tracking surface.

Returns None when there's no such comment, when the comment author isn't
a registered agent for this workspace, or when a 4xx Plane lookup fails.
The caller treats a None as «no auto-resume, return the original no-op».

**Transient (5xx) Plane errors re-raise** — webhook handler turns them
into a 503 so Plane retries the webhook delivery, same contract as the
mention-driven spawn path.
"""
try:
comments = await plane.list_issue_comments(workspace.project_id, issue_uuid)
except PlaneAPIError as exc:
if exc.is_transient:
raise
log.info(
"auto_resume_comments_lookup_failed",
workspace=workspace.workspace_slug,
issue=str(issue_uuid),
status=exc.status_code,
)
return None
Comment thread
coderabbitai[bot] marked this conversation as resolved.

initiator_uuid_obj = initiator_uuid
comments_sorted = sorted(comments, key=lambda c: c.get("created_at", ""), reverse=True)
for comment in comments_sorted:
actor_raw = comment.get("actor")
if not actor_raw:
continue
actor_str = str(actor_raw).lower()
if actor_str == str(initiator_uuid_obj).lower():
continue
# Require the structured `<mention-component entity_identifier="<initiator>">`
# tag as the first mention in the body — NOT just a substring match on the
# raw UUID. The tag is what tower stamps on `request_handoff`; raw UUID text
# in someone's prose is conversational noise and must not trigger a resume.
mentions = extract_mention_uuids(comment.get("comment_html") or "")
if not mentions or mentions[0] != initiator_uuid_obj:
continue
try:
actor_uuid = UUID(actor_str)
except ValueError:
continue
try:
member = await plane.get_member(actor_uuid)
except PlaneAPIError as exc:
if exc.is_transient:
raise
continue
email = _email_of(member)
if not email:
continue
nickname = email.split("@", 1)[0].lower()
if not _is_nickname_allowed(nickname, workspace, agents_by_nick):
continue
return actor_uuid
return None


def build_router(
settings: Settings,
workspaces: dict[str, tuple[WorkspaceConfig, PlaneClient]],
Expand Down Expand Up @@ -163,6 +235,46 @@ async def receive( # type: ignore[no-untyped-def]
return {"ok": True, "workspace": slug, "ignored": "bad issue uuid"}

mention_uuids = extract_mention_uuids(comment_html)
auto_resumed: str | None = None
if not mention_uuids:
actor_raw = data.get("actor")
if actor_raw is not None and str(actor_raw).lower() == str(initiator_uuid).lower():
# Initiator replied without an explicit @-mention. Plane's
# mention component is rejected by tower in outbound agent
# comments, so agents end runs via `request_handoff(
# target_role='initiator', …)` — which stamps the initiator
# mention at the top of the agent's last comment. Use that
# signal to identify which agent is waiting and respawn it.
try:
resumed_member = await _resolve_pending_agent_member(
plane=plane,
workspace=workspace,
initiator_uuid=initiator_uuid,
issue_uuid=issue_uuid,
agents_by_nick=agents_by_nick,
)
except PlaneAPIError as exc:
# Helper re-raises only on transient (5xx). Match the
# mention-driven path's behaviour: ask Plane to retry.
log.warning(
"webhook_returning_503_for_retry",
workspace=slug,
reason="auto_resume_probe",
status=exc.status_code,
)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="plane api transient error; retry",
) from exc
if resumed_member is not None:
mention_uuids = [resumed_member]
auto_resumed = str(resumed_member)
log.info(
"auto_resume_triggered",
workspace=slug,
issue=str(issue_uuid),
member=auto_resumed,
)
if not mention_uuids:
return {
"ok": True,
Expand Down Expand Up @@ -236,4 +348,12 @@ async def receive( # type: ignore[no-untyped-def]
log.error("spawn_failed", workspace=slug, nickname=nickname, error=str(exc))
skipped.append({"nickname": nickname, "reason": "spawn failed"})

return {"ok": True, "workspace": slug, "spawned": spawned, "skipped": skipped}
result: dict[str, Any] = {
"ok": True,
"workspace": slug,
"spawned": spawned,
"skipped": skipped,
}
if auto_resumed is not None:
result["auto_resumed"] = auto_resumed
return result
226 changes: 225 additions & 1 deletion tests/test_webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,13 @@


class StubPlane:
def __init__(self, members: dict[str, dict[str, Any]] | None = None) -> None:
def __init__(
self,
members: dict[str, dict[str, Any]] | None = None,
comments: list[dict[str, Any]] | None = None,
) -> None:
self.members = members or {}
self.comments = comments or []

async def get_member(self, member_id: str) -> dict[str, Any]:
key = str(member_id).lower()
Expand All @@ -31,6 +36,9 @@ async def get_member(self, member_id: str) -> dict[str, Any]:
raise PlaneAPIError(404, f"missing {member_id}")
return self.members[key]

async def list_issue_comments(self, project_id: Any, issue_id: Any) -> list[dict[str, Any]]:
return list(self.comments)

async def aclose(self) -> None:
pass

Expand Down Expand Up @@ -545,3 +553,219 @@ async def aclose(self) -> None:
resp = _send(client, settings, workspace_config, _comment_body(SARK))
assert resp.status_code == 200
assert resp.json()["skipped"][0]["reason"] == "lookup failed"


# --- auto-resume on initiator reply ------------------------------------------


def _initiator_reply_body(initiator_uuid: UUID, issue: str | None = None) -> bytes:
"""Plane webhook for an initiator's free-text reply (no mention component)."""
return json.dumps(
{
"event": "issue_comment",
"action": "created",
"data": {
"issue": issue or "44444444-4444-4444-4444-444444444444",
"actor": str(initiator_uuid),
"comment_html": "<p>ответы выше, продолжай</p>",
},
}
).encode()


def _agent_handoff_comment(
actor: str, initiator_uuid: UUID, created_at: str, text: str = "PLAN ready"
) -> dict[str, Any]:
"""Comment shaped like one produced by `request_handoff(target_role='initiator', …)`.
Initiator mention is auto-stamped by tower at the top of `comment_html`."""
return {
"actor": actor,
"created_at": created_at,
"comment_html": (
f'<mention-component entity_identifier="{initiator_uuid}" '
f'entity_name="user_mention"></mention-component> {text}'
),
}


def test_auto_resume_respawns_agent_when_initiator_replies(
settings: Settings, workspace_config: WorkspaceConfig, initiator_uuid: UUID
) -> None:
plane = StubPlane(
members={SARK: {"email": "sark@example.io"}},
comments=[
_agent_handoff_comment(SARK, initiator_uuid, "2026-05-18T20:43:00Z", "SPEC ready"),
],
)
runner = StubRunner()
client = TestClient(_app(settings, workspace_config, plane, runner))

resp = _send(client, settings, workspace_config, _initiator_reply_body(initiator_uuid))

assert resp.status_code == 200
body = resp.json()
assert body["spawned"] == ["sark"]
assert body["auto_resumed"] == SARK
assert len(runner.calls) == 1
assert runner.calls[0]["nickname"] == "sark"


def test_auto_resume_picks_latest_agent_when_several_have_pinged(
settings: Settings, workspace_config: WorkspaceConfig, initiator_uuid: UUID
) -> None:
"""Multiple agents pinged the initiator at different times — the latest one
is the one currently waiting for input, so it's the one we re-spawn."""
plane = StubPlane(
members={
SARK: {"email": "sark@example.io"},
RINZLER: {"email": "rinzler@example.io"},
},
comments=[
_agent_handoff_comment(SARK, initiator_uuid, "2026-05-18T18:00:00Z"),
_agent_handoff_comment(RINZLER, initiator_uuid, "2026-05-18T20:43:00Z"),
],
)
runner = StubRunner()
client = TestClient(_app(settings, workspace_config, plane, runner))

resp = _send(client, settings, workspace_config, _initiator_reply_body(initiator_uuid))

assert resp.status_code == 200
assert resp.json()["spawned"] == ["rinzler"]


def test_auto_resume_skips_when_no_agent_pinged_initiator(
settings: Settings, workspace_config: WorkspaceConfig, initiator_uuid: UUID
) -> None:
"""Initiator's first-ever comment on the issue (no prior agent activity)
must not trigger any spawn — there's no one to «resume»."""
plane = StubPlane(members={SARK: {"email": "sark@example.io"}}, comments=[])
runner = StubRunner()
client = TestClient(_app(settings, workspace_config, plane, runner))

resp = _send(client, settings, workspace_config, _initiator_reply_body(initiator_uuid))

assert resp.status_code == 200
body = resp.json()
assert body["spawned"] == []
assert "auto_resumed" not in body
assert runner.calls == []


def test_auto_resume_skips_when_agent_comment_has_no_initiator_mention(
settings: Settings, workspace_config: WorkspaceConfig, initiator_uuid: UUID
) -> None:
"""An agent comment without the initiator-mention auto-stamp is treated
as «not awaiting input» — it's progress noise, not a handoff."""
plane = StubPlane(
members={SARK: {"email": "sark@example.io"}},
comments=[
{
"actor": SARK,
"created_at": "2026-05-18T20:43:00Z",
"comment_html": "<p>Step 3 done. ✅ pytest green.</p>",
}
],
)
runner = StubRunner()
client = TestClient(_app(settings, workspace_config, plane, runner))

resp = _send(client, settings, workspace_config, _initiator_reply_body(initiator_uuid))

assert resp.status_code == 200
assert resp.json()["spawned"] == []
assert runner.calls == []


def test_auto_resume_skips_when_uuid_appears_as_plain_text(
settings: Settings, workspace_config: WorkspaceConfig, initiator_uuid: UUID
) -> None:
"""Raw initiator-UUID text inside prose must NOT count as a handoff stamp.
Only the structured `<mention-component entity_identifier="…">` tag at the
front of a comment is the signal — anything else is conversational noise."""
plane = StubPlane(
members={SARK: {"email": "sark@example.io"}},
comments=[
{
"actor": SARK,
"created_at": "2026-05-18T20:43:00Z",
"comment_html": f"<p>FYI initiator id is {initiator_uuid}</p>",
}
],
)
runner = StubRunner()
client = TestClient(_app(settings, workspace_config, plane, runner))

resp = _send(client, settings, workspace_config, _initiator_reply_body(initiator_uuid))

assert resp.status_code == 200
assert resp.json()["spawned"] == []
assert runner.calls == []


def test_auto_resume_returns_503_on_transient_comment_lookup(
settings: Settings, workspace_config: WorkspaceConfig, initiator_uuid: UUID
) -> None:
"""A 5xx from `list_issue_comments` must surface as 503 so Plane retries
the webhook — same contract as the mention-driven `get_member` path. We
don't want a transient blip to silently swallow an auto-resume."""
from plane_conductor.exceptions import PlaneAPIError

class FlakyPlane(StubPlane):
async def list_issue_comments(self, project_id: Any, issue_id: Any) -> list[dict[str, Any]]:
raise PlaneAPIError(503, "service unavailable")

client = TestClient(_app(settings, workspace_config, FlakyPlane(), StubRunner()))
resp = _send(client, settings, workspace_config, _initiator_reply_body(initiator_uuid))
assert resp.status_code == 503


def test_auto_resume_skips_when_commenter_is_not_initiator(
settings: Settings, workspace_config: WorkspaceConfig, initiator_uuid: UUID
) -> None:
"""A no-mention comment from someone OTHER than the initiator must not
auto-resume — that's a teammate / lurker, not the awaited reply."""
stranger = "99999999-9999-9999-9999-999999999999"
plane = StubPlane(
members={SARK: {"email": "sark@example.io"}},
comments=[_agent_handoff_comment(SARK, initiator_uuid, "2026-05-18T20:43:00Z")],
)
runner = StubRunner()
client = TestClient(_app(settings, workspace_config, plane, runner))

body = json.dumps(
{
"event": "issue_comment",
"action": "created",
"data": {
"issue": "44444444-4444-4444-4444-444444444444",
"actor": stranger,
"comment_html": "<p>by the way…</p>",
},
}
).encode()
resp = _send(client, settings, workspace_config, body)

assert resp.status_code == 200
assert resp.json()["spawned"] == []
assert runner.calls == []


def test_auto_resume_skips_unregistered_agent(
settings: Settings, workspace_config: WorkspaceConfig, initiator_uuid: UUID
) -> None:
"""Agent member who pinged the initiator is no longer in the workspace
roster (e.g. removed mid-flight) — fail closed, don't spawn."""
ex_agent = "abababab-abab-abab-abab-abababababab"
plane = StubPlane(
members={ex_agent: {"email": "ex-agent@example.io"}},
comments=[_agent_handoff_comment(ex_agent, initiator_uuid, "2026-05-18T20:43:00Z")],
)
runner = StubRunner()
client = TestClient(_app(settings, workspace_config, plane, runner))

resp = _send(client, settings, workspace_config, _initiator_reply_body(initiator_uuid))

assert resp.status_code == 200
assert resp.json()["spawned"] == []
assert runner.calls == []