diff --git a/src/plane_conductor/webhook.py b/src/plane_conductor/webhook.py index 18b2fc8..6c7e7af 100644 --- a/src/plane_conductor/webhook.py +++ b/src/plane_conductor/webhook.py @@ -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 `` (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 + + 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 `` + # 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]], @@ -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, @@ -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 diff --git a/tests/test_webhook.py b/tests/test_webhook.py index a7e4777..3c9cf23 100644 --- a/tests/test_webhook.py +++ b/tests/test_webhook.py @@ -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() @@ -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 @@ -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": "

ответы выше, продолжай

", + }, + } + ).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' {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": "

Step 3 done. ✅ pytest green.

", + } + ], + ) + 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 `` 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"

FYI initiator id is {initiator_uuid}

", + } + ], + ) + 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": "

by the way…

", + }, + } + ).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 == []