feat(robomp): resume needs-info issues
This commit is contained in:
@@ -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/<hex>/<slug>` 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 `<session_dir>/*.jsonl`
|
||||
triggers `--continue`. Drain bounded by
|
||||
|
||||
@@ -23,6 +23,7 @@ IssueState = Literal[
|
||||
"opened",
|
||||
"merged",
|
||||
"closed",
|
||||
"needs_info",
|
||||
"abandoned",
|
||||
]
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user