From a409fd3216f5d4a10ff428887701345312545477 Mon Sep 17 00:00:00 2001 From: oldschoola Date: Mon, 15 Jun 2026 17:55:48 -0700 Subject: [PATCH] feat(robomp): resume needs-info issues --- python/robomp/README.md | 4 +- python/robomp/src/db.py | 1 + python/robomp/src/host_tools.py | 31 +++++--- python/robomp/src/prompts/host_tools.toml | 2 +- .../prompts/unable_to_reproduce_comment.md | 2 + python/robomp/tests/test_host_tools.py | 34 +++++++-- python/robomp/tests/test_server.py | 75 +++++++++++++++++++ 7 files changed, 130 insertions(+), 19 deletions(-) diff --git a/python/robomp/README.md b/python/robomp/README.md index bc74bf913..4628ccc6b 100644 --- a/python/robomp/README.md +++ b/python/robomp/README.md @@ -162,8 +162,8 @@ The integration test spawns a real `omp --mode rpc` against an - **One PR per issue.** Follow-up events push amendments to the same `farm//` branch. - **No PR without a recorded repro.** Persona prompt requires - `repro_record`; `mark_unable_to_reproduce` closes the loop when - reproduction genuinely fails. + `repro_record`; `mark_unable_to_reproduce` asks for missing details, + marks the row `needs_info`, and resumes the same session on the next reply. - **Crash recovery.** On startup, `db.reset_stuck_running()` flips `running` rows back to `queued`. Existing `/*.jsonl` triggers `--continue`. Drain bounded by diff --git a/python/robomp/src/db.py b/python/robomp/src/db.py index 06906b59c..32d55e81a 100644 --- a/python/robomp/src/db.py +++ b/python/robomp/src/db.py @@ -23,6 +23,7 @@ IssueState = Literal[ "opened", "merged", "closed", + "needs_info", "abandoned", ] diff --git a/python/robomp/src/host_tools.py b/python/robomp/src/host_tools.py index 2b44c050f..f72d2ea71 100644 --- a/python/robomp/src/host_tools.py +++ b/python/robomp/src/host_tools.py @@ -405,17 +405,15 @@ def _run_pre_publish_bun_check( _raise_command(msg) -_AUTOCLOSE_INELIGIBLE_STATES: frozenset[str] = frozenset({"closed", "merged", "abandoned"}) +_AUTOCLOSE_INELIGIBLE_STATES: frozenset[str] = frozenset({"closed", "merged", "needs_info", "abandoned"}) +_NEEDS_INFO_LABEL = "needs-info" def _should_schedule_autoclose(bindings: ToolBindings, target_number: int) -> float | None: """Return the configured close window (hours) when this comment should - schedule an auto-close; ``None`` otherwise. - - Conditions: feature enabled in `Settings`, the comment lands on the - originating issue (not a different number, not a PR thread), the issue is - classified as `question`, and the issue is not already in a terminal - state (closed/merged/abandoned). + schedule the question auto-close job: feature enabled, same issue, + classified as `question`, and the issue is not already in a terminal or + waiting-for-reporter state. """ settings = bindings.settings if settings is None or not settings.question_autoclose_enabled: @@ -888,9 +886,22 @@ def _build_mark_unable(bindings: ToolBindings) -> HostTool[Any, Any]: except GitHubError as exc: _audit(bindings, "mark_unable_to_reproduce", args, error=str(exc)) _raise_command(f"GitHub rejected comment: {exc.status} {exc.message}") - bindings.db.set_issue_state(bindings.issue_key, "abandoned") - _audit(bindings, "mark_unable_to_reproduce", args, result={"comment_id": comment.id}) - return f"posted abandonment comment id={comment.id}" + result: dict[str, Any] = {"comment_id": comment.id, "state": "needs_info"} + try: + labels = _run_coro( + bindings.loop, + bindings.github.add_issue_labels(bindings.repo.full_name, bindings.issue.number, [_NEEDS_INFO_LABEL]), + ) + result["labels"] = list(labels) + except GitHubError as exc: + # Some repos have not created the optional status label yet. The + # durable behavior is the non-terminal sqlite state plus the visible + # info-request comment, so label setup must not block resumption. + log.warning("needs-info label failed", extra={"issue": bindings.issue_key, "err": str(exc)}) + result["label_error"] = f"{exc.status} {exc.message}" + bindings.db.set_issue_state(bindings.issue_key, "needs_info") + _audit(bindings, "mark_unable_to_reproduce", args, result=result) + return f"posted needs-info comment id={comment.id}" return host_tool( name="mark_unable_to_reproduce", diff --git a/python/robomp/src/prompts/host_tools.toml b/python/robomp/src/prompts/host_tools.toml index 54b5203a7..ef28c5fac 100644 --- a/python/robomp/src/prompts/host_tools.toml +++ b/python/robomp/src/prompts/host_tools.toml @@ -61,7 +61,7 @@ description = "Persist a reproduction transcript (command, output, exit code) fo reproduced = "True when the recorded run demonstrates the bug." [mark_unable_to_reproduce] -description = "Close the loop without a PR: comment with diagnosis + info request, mark issue abandoned." +description = "Ask the reporter for missing reproduction details, mark the issue `needs_info`, and keep the session resumable for the next reply." [abort_task] description = "Irrecoverably abandon this task WITHOUT posting any visible message. Use ONLY for orchestrator/environment defects you cannot work around (broken filesystem permissions, missing system tools, corrupted git metadata, harness bugs). NEVER for normal workflow problems — failed builds, missing repro info, unclear requests use `gh_post_comment` or `mark_unable_to_reproduce` instead. `reason` is audit-only and NEVER shown to the reporter." diff --git a/python/robomp/src/prompts/unable_to_reproduce_comment.md b/python/robomp/src/prompts/unable_to_reproduce_comment.md index 0751ca976..325e893eb 100644 --- a/python/robomp/src/prompts/unable_to_reproduce_comment.md +++ b/python/robomp/src/prompts/unable_to_reproduce_comment.md @@ -5,3 +5,5 @@ ## Information needed {{info_needed}} + +I'll keep this issue waiting on reporter details and resume from this context when the requested information arrives. diff --git a/python/robomp/tests/test_host_tools.py b/python/robomp/tests/test_host_tools.py index 850173f80..5f5c1060e 100644 --- a/python/robomp/tests/test_host_tools.py +++ b/python/robomp/tests/test_host_tools.py @@ -419,12 +419,15 @@ def test_repro_record_rejects_bad_args(db: Database, tmp_path: Path) -> None: _stop_loop(loop, t) -def test_mark_unable_posts_comment_and_abandons(db: Database, tmp_path: Path) -> None: +def test_mark_unable_posts_comment_marks_needs_info_and_labels_issue(db: Database, tmp_path: Path) -> None: captured: dict[str, Any] = {} def handler(request: httpx.Request) -> httpx.Response: - captured["body"] = json.loads(request.content) - return httpx.Response(201, json={"id": 77, "user": {"login": "robomp-bot"}, "body": "x", "created_at": "t"}) + if request.url.path.endswith("/labels"): + captured["labels"] = json.loads(request.content) + return httpx.Response(200, json=[{"name": "bug"}, {"name": "needs-info"}]) + captured["comment"] = json.loads(request.content) + return httpx.Response(201, json={"id": 321, "user": {"login": "robomp-bot"}, "body": "x", "created_at": "t"}) bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(handler)) try: @@ -432,10 +435,29 @@ def test_mark_unable_posts_comment_and_abandons(db: Database, tmp_path: Path) -> result = tool.execute({"diagnosis": "needed exact version", "info_needed": "post bun --version"}, _ctx()) finally: _stop_loop(loop, t) - assert "abandonment" in result - assert "Could not reproduce" in captured["body"]["body"] + + assert "needs-info comment" in result + assert captured["labels"] == {"labels": ["needs-info"]} + assert "resume from this context" in captured["comment"]["body"] issue = db.get_issue(bindings.issue_key) - assert issue and issue.state == "abandoned" + assert issue and issue.state == "needs_info" + + +def test_mark_unable_keeps_needs_info_when_label_is_missing(db: Database, tmp_path: Path) -> None: + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/labels"): + return httpx.Response(422, json={"message": "Label does not exist"}) + return httpx.Response(201, json={"id": 321, "user": {"login": "robomp-bot"}, "body": "x", "created_at": "t"}) + + bindings, loop, t = _bindings(db, tmp_path, httpx.MockTransport(handler)) + try: + tool = next(x for x in build(bindings) if x.name == "mark_unable_to_reproduce") + tool.execute({"diagnosis": "needed exact version", "info_needed": "post bun --version"}, _ctx()) + finally: + _stop_loop(loop, t) + + issue = db.get_issue(bindings.issue_key) + assert issue and issue.state == "needs_info" def test_abort_task_signals_controller_and_abandons_without_comment(db: Database, tmp_path: Path) -> None: diff --git a/python/robomp/tests/test_server.py b/python/robomp/tests/test_server.py index 557a7d170..f572e268a 100644 --- a/python/robomp/tests/test_server.py +++ b/python/robomp/tests/test_server.py @@ -2211,6 +2211,81 @@ async def test_handle_comment_finalized_without_directive_still_replies( close_database() +async def test_handle_comment_resumes_needs_info_reply( + settings: Settings, tmp_path: Path, stub_run_task, monkeypatch +) -> None: + """Reporter details after a needs-info request resume the existing session.""" + from robomp import tasks + from robomp.github_client import GitHubClient, IssueInfo, RepoInfo + + sandbox = _RecordingSandbox(tmp_path) + db = get_database(settings.sqlite_path) + db.upsert_issue( + key="octo/widget#88", + repo="octo/widget", + number=88, + state="needs_info", + branch="farm/old/branch", + ) + + repo = RepoInfo( + full_name="octo/widget", default_branch="main", clone_url="https://github.com/octo/widget.git", private=False + ) + issue = IssueInfo( + repo="octo/widget", + number=88, + title="boom", + body="details", + state="open", + author="alice", + labels=("needs-info",), + is_pull_request=False, + ) + + async def _resolve(_gh, _payload): + return repo, issue + + monkeypatch.setattr(tasks, "_resolve_repo_and_issue", _resolve) + + post_comment_calls: list = [] + + async def _capture_post(self, *args, **kwargs): + post_comment_calls.append((args, kwargs)) + return None + + monkeypatch.setattr(GitHubClient, "post_comment", _capture_post) + + payload = { + "action": "created", + "issue": {"number": 88, "user": {"login": "alice"}, "title": "boom"}, + "comment": { + "user": {"login": "alice"}, + "body": "I am on Bun 1.3.14 and here is the trace", + "id": 4, + "created_at": "2026-05-14T23:00:00Z", + }, + "repository": {"full_name": "octo/widget"}, + } + await tasks.handle_comment( + settings=settings, + db=db, + github=GitHubClient("t"), + git_transport=LocalGitTransport(token=None), + sandbox=sandbox, + payload=payload, + delivery_id="test-delivery-needs-info", + ) + assert len(stub_run_task) == 1 + call = stub_run_task[0] + assert call["task_kind"] == "handle_comment" + assert call["comment"].body == "I am on Bun 1.3.14 and here is the trace" + assert sandbox.ensure_calls[0]["existing_branch"] == "farm/old/branch" + assert post_comment_calls == [], "needs-info replies must not get the finalized-issue notice" + row = db.get_issue("octo/widget#88") + assert row is not None and row.state == "needs_info" + close_database() + + async def test_directive_handler_attaches_thread_from_github( settings: Settings, tmp_path: Path, stub_run_task, monkeypatch ) -> None: