diff --git a/src/built/agent/chat.py b/src/built/agent/chat.py index d3fb720..d13f13a 100644 --- a/src/built/agent/chat.py +++ b/src/built/agent/chat.py @@ -1,6 +1,7 @@ """Project-level chat: a human and an LLM brainstorming and workshopping ticket ideas together, with read-only repo-browsing tools plus create_ticket/update_ticket -to file or edit cards mid-conversation. The read-only-explore-plus-file-tickets +to file or edit cards mid-conversation, and unblock_card/archive_card/unarchive_card +for the human's board-maintenance requests. The read-only-explore-plus-file-tickets sibling of agent/curation.py, but step-wise rather than run-to-completion: unlike every other agent/* module, a "run" here is one bounded reply to whatever's new, not a loop that only ends via a terminal tool. Persisted history @@ -177,10 +178,11 @@ async def _dispatch( tickets_created: int, ) -> tuple[str, bool, str | None]: """Repo-browsing tools go through the shared, DB-agnostic ToolDispatcher (same - as every other column); search_tickets/get_ticket/create_ticket/update_ticket - are dispatched here instead, inline, because they need `session` and - card_service — the first case in this codebase of one tool-call batch needing - both a worktree-scoped dispatcher AND direct DB access.""" + as every other column); search_tickets/get_ticket/create_ticket/update_ticket/ + unblock_card/archive_card/unarchive_card are dispatched here instead, inline, + because they need `session` and card_service — the first case in this codebase + of one tool-call batch needing both a worktree-scoped dispatcher AND direct DB + access.""" if name == "search_tickets": query = str(arguments.get("query", "")).strip() include_archived = bool(arguments.get("include_archived", False)) @@ -237,6 +239,50 @@ async def _dispatch( return f"no such card: {card_id!r}", True, None await session.commit() return f"Updated card {card.id}: {card.title!r}.", False, card.id + if name == "unblock_card": + card_id = str(arguments.get("card_id", "")).strip() + note = str(arguments.get("note", "")).strip() or None + if not card_id: + return "card_id is required.", True, None + try: + card = await card_service.retry_card(session, card_id, note=note) + except card_service.NotFoundError: + return f"no such card: {card_id!r}", True, None + except ValueError as exc: + return str(exc), True, None + await session.commit() + message = ( + f"Unblocked card {card.id}: {card.title!r} — back to ACTIVE in the " + f"{card.column.value} column with a fresh retry budget." + ) + if note: + message += f" Note left for the next agent: {note}" + return message, False, card.id + if name == "archive_card": + card_id = str(arguments.get("card_id", "")).strip() + if not card_id: + return "card_id is required.", True, None + try: + card = await card_service.archive_card(session, card_id) + except card_service.NotFoundError: + return f"no such card: {card_id!r}", True, None + await session.commit() + return ( + f"Archived card {card.id}: {card.title!r} — off the board, history intact. " + "It can be restored with unarchive_card if needed.", + False, + card.id, + ) + if name == "unarchive_card": + card_id = str(arguments.get("card_id", "")).strip() + if not card_id: + return "card_id is required.", True, None + try: + card = await card_service.unarchive_card(session, card_id) + except card_service.NotFoundError: + return f"no such card: {card_id!r}", True, None + await session.commit() + return f"Restored card {card.id}: {card.title!r} — back on the board.", False, card.id outcome = await dispatcher.dispatch(name, arguments) return outcome.result.output, outcome.result.is_error, None diff --git a/src/built/agent/context.py b/src/built/agent/context.py index af8b13d..f423659 100644 --- a/src/built/agent/context.py +++ b/src/built/agent/context.py @@ -213,9 +213,13 @@ def build_chat_prompt( "workshopping ticket ideas with a human — including ones already on the board, not just new " "ones. You have read-only tools to explore the actual repository; search_tickets/get_ticket " "to find and inspect existing cards (use these instead of guessing an id whenever the human " - "references a card by name or description); and create_ticket/update_ticket to file or edit " + "references a card by name or description); create_ticket/update_ticket to file or edit " "cards directly once an idea is concrete — use them proactively once the conversation reaches " - "a real, well-scoped decision, rather than waiting to be asked explicitly. A created card " + "a real, well-scoped decision, rather than waiting to be asked explicitly; and " + "unblock_card/archive_card/unarchive_card for board maintenance the human explicitly asks " + "for — unblocking a blocked/failed card (a human-initiated retry, not gated by the Reviver's " + "auto-retry budget), or archiving/unarchiving one to take it off the board or bring it back. " + "A created card " "starts in the PM column and flows through PM -> Developer -> Tester -> Reviewer -> Deployer " "autonomously from there; you are not implementing anything yourself, only shaping what gets " "filed or changed. Keep replies conversational and concise — this is a live back-and-forth, " diff --git a/src/built/llm/tool_schemas.py b/src/built/llm/tool_schemas.py index 871e394..de5da79 100644 --- a/src/built/llm/tool_schemas.py +++ b/src/built/llm/tool_schemas.py @@ -791,10 +791,72 @@ }, } +UNBLOCK_CARD = { + "type": "function", + "function": { + "name": "unblock_card", + "description": ( + "Retry a blocked or failed card at the human's explicit request — re-activate it so " + "the pipeline picks it back up from its current column. Only call this when the human " + "has actually asked you to unblock a specific card. Unlike the Reviver's autonomous " + "retries this is a human override, so it isn't gated by the auto-retry budget; it resets " + "the card's safety-valve counters and leaves a fresh budget for the next run. The " + "optional note is surfaced to whichever agent runs the card next." + ), + "parameters": { + "type": "object", + "properties": { + "card_id": {"type": "string", "description": "The blocked/failed card to unblock."}, + "note": { + "type": "string", + "description": "Optional guidance for whichever column runs the card next.", + }, + }, + "required": ["card_id"], + }, + }, +} + +ARCHIVE_CARD = { + "type": "function", + "function": { + "name": "archive_card", + "description": ( + "Archive a card — hide it from the board and stop the pipeline from ever claiming it " + "again. History, events, and visits are all kept intact; the card stays reachable by id " + "and can be restored with unarchive_card. Use this when the human asks to take a card " + "off the board (e.g. it turned out not to be wanted)." + ), + "parameters": { + "type": "object", + "properties": {"card_id": {"type": "string", "description": "The card to archive."}}, + "required": ["card_id"], + }, + }, +} + +UNARCHIVE_CARD = { + "type": "function", + "function": { + "name": "unarchive_card", + "description": ( + "Restore a previously archived card back onto the board so the pipeline can work it " + "again. Use this when the human asks to bring a card back — e.g. after an accidental " + "archive." + ), + "parameters": { + "type": "object", + "properties": {"card_id": {"type": "string", "description": "The archived card to restore."}}, + "required": ["card_id"], + }, + }, +} + # Project chat (agent/chat.py) — read-only browsing plus search_tickets/get_ticket/ -# create_ticket/update_ticket, all ordinary (non-terminal) tools: a chat turn ends when the -# model replies with no further tool calls, not by calling a designated "done" tool, so the -# conversation stays open after a card is looked up, filed, or edited. +# create_ticket/update_ticket/unblock_card/archive_card/unarchive_card, all ordinary +# (non-terminal) tools: a chat turn ends when the model replies with no further tool calls, +# not by calling a designated "done" tool, so the conversation stays open after a card is +# looked up, filed, edited, unblocked, or archived. CHAT_TOOLS = [ READ_FILE, LIST_FILES, @@ -804,6 +866,9 @@ GET_TICKET, CREATE_TICKET, UPDATE_TICKET, + UNBLOCK_CARD, + ARCHIVE_CARD, + UNARCHIVE_CARD, ] APPROVE = { diff --git a/src/built/sandbox/worktree.py b/src/built/sandbox/worktree.py index 93b7ffe..38f798f 100644 --- a/src/built/sandbox/worktree.py +++ b/src/built/sandbox/worktree.py @@ -4,6 +4,8 @@ does not use refs/remotes/origin/*), so worktrees are created off the plain branch name, not `origin/`.""" +import logging +import re from pathlib import Path from built.config import settings @@ -11,6 +13,12 @@ from built.tools import git_tools from built.tools.git_tools import GitCommandError, run_git +logger = logging.getLogger(__name__) + +# git refuses to update a branch that is checked out in a linked worktree: +# "fatal: refusing to fetch into branch 'refs/heads/card/x' checked out at ''". +_REFUSING_TO_FETCH_CHECKED_OUT = re.compile(r"refusing to fetch into branch '[^']+' checked out at ") + def bare_repo_path(project: Project) -> Path: return settings.data_dir / "repos" / f"{project.id}.git" @@ -35,13 +43,44 @@ async def ensure_managed_clone(project: Project) -> Path: the current upstream tip).""" bare_path = bare_repo_path(project) if bare_path.exists(): - await run_git("fetch", "origin", "+refs/heads/*:refs/heads/*", cwd=bare_path) + await _fetch_managed_clone(project, bare_path) return bare_path bare_path.parent.mkdir(parents=True, exist_ok=True) await run_git("clone", "--bare", project.repo_remote_url, str(bare_path), cwd=bare_path.parent) return bare_path +async def _fetch_managed_clone(project: Project, bare_path: Path) -> None: + """Advance the bare clone's refs from the remote. Fetching the full + +refs/heads/*:refs/heads/* keeps every branch fresh, but git refuses to update + a branch that is checked out in a linked worktree — and every live card's + branch IS checked out in its own worktree. Once one card's branch has been + pushed to the remote (pr_to_operator), any later fetch for a *different* card + dies with "refusing to fetch into branch 'refs/heads/card/...' checked out at + ..." and wedges that visit's setup. The default branch is never checked out + anywhere (card and tool worktrees both branch off it), so when the full fetch + trips over a checked-out branch, fall back to fetching just it — the one branch + worktree creation and tool resets actually need advanced.""" + try: + await run_git("fetch", "origin", "+refs/heads/*:refs/heads/*", cwd=bare_path) + except GitCommandError as exc: + if not _REFUSING_TO_FETCH_CHECKED_OUT.search(exc.stderr): + raise + logger.info( + "full fetch for project %s refused on a checked-out worktree branch (%s); " + "falling back to fetching only %s", + project.id, + exc.stderr.strip().splitlines()[0], + project.default_branch, + ) + await run_git( + "fetch", + "origin", + f"+refs/heads/{project.default_branch}:refs/heads/{project.default_branch}", + cwd=bare_path, + ) + + def tool_worktree_path(project: Project, *, tool: str) -> Path: return settings.data_dir / "worktrees" / f"_tool_{tool}" / project.id diff --git a/tests/integration/test_chat.py b/tests/integration/test_chat.py index 46a6dbf..271fb60 100644 --- a/tests/integration/test_chat.py +++ b/tests/integration/test_chat.py @@ -9,7 +9,7 @@ from built.agent.chat import run_chat_turn from built.db.models import ChatMessage -from built.domain.enums import ChatRole +from built.domain.enums import ChatRole, LifecycleState from built.llm.client import LLMResult, ToolCallRequest from built.llm.tool_schemas import MAX_TICKETS_PER_CHAT_TURN from built.main import app @@ -271,6 +271,141 @@ async def test_chat_get_ticket_missing_card_yields_error_tool_result(db_session, assert "no-such-card" in tool_result.content +async def test_chat_unblock_card_retries_a_blocked_card(db_session, toy_repo_remote): + project, wt_path = await _make_project(db_session, toy_repo_remote, _n="3e") + card = await card_service.create_card(db_session, project.id, title="Fix login bug", raw_request="r") + card.lifecycle_state = LifecycleState.BLOCKED + card.revision_count = 3 + await chat_service.append_user_message(db_session, project.id, content="unblock the login bug card") + await db_session.commit() + + llm = ScriptedLLMClient( + [ + LLMResult( + content=None, + tool_calls=[ + ToolCallRequest( + id="call_1", + name="unblock_card", + arguments={"card_id": card.id, "note": "rebase onto main first"}, + ) + ], + endpoint_used="fake::model", + ), + LLMResult(content="Done — it's back in the queue.", tool_calls=[], endpoint_used="fake::model"), + ] + ) + + appended = await run_chat_turn( + db_session, project, llm_client=llm, dispatcher=_dispatcher(wt_path), max_tool_iterations=10 + ) + + await db_session.refresh(card) + assert card.lifecycle_state == LifecycleState.ACTIVE + assert card.revision_count == 0 + assert card.retry_note == "rebase onto main first" + tool_result = next(m for m in appended if m.role == ChatRole.TOOL) + assert not tool_result.is_error + assert tool_result.card_id == card.id + assert "Unblocked" in tool_result.content + + +async def test_chat_unblock_card_not_blocked_yields_error_tool_result(db_session, toy_repo_remote): + project, wt_path = await _make_project(db_session, toy_repo_remote, _n="3f") + card = await card_service.create_card(db_session, project.id, title="Add dark mode", raw_request="r") + await chat_service.append_user_message(db_session, project.id, content="unblock the dark mode card") + await db_session.commit() + + llm = ScriptedLLMClient( + [ + LLMResult( + content=None, + tool_calls=[ + ToolCallRequest(id="call_1", name="unblock_card", arguments={"card_id": card.id}) + ], + endpoint_used="fake::model", + ), + LLMResult(content="Can't — it isn't blocked.", tool_calls=[], endpoint_used="fake::model"), + ] + ) + + appended = await run_chat_turn( + db_session, project, llm_client=llm, dispatcher=_dispatcher(wt_path), max_tool_iterations=10 + ) + + tool_result = next(m for m in appended if m.role == ChatRole.TOOL) + assert tool_result.is_error + assert "cannot retry" in tool_result.content + assert card.lifecycle_state == LifecycleState.ACTIVE + + +async def test_chat_archive_card_hides_it_from_the_board(db_session, toy_repo_remote): + project, wt_path = await _make_project(db_session, toy_repo_remote, _n="3g") + card = await card_service.create_card(db_session, project.id, title="Dead idea", raw_request="r") + await chat_service.append_user_message(db_session, project.id, content="drop that card from the board") + await db_session.commit() + + llm = ScriptedLLMClient( + [ + LLMResult( + content=None, + tool_calls=[ + ToolCallRequest( + id="call_1", name="archive_card", arguments={"card_id": card.id} + ) + ], + endpoint_used="fake::model", + ), + LLMResult(content="Archived it.", tool_calls=[], endpoint_used="fake::model"), + ] + ) + + appended = await run_chat_turn( + db_session, project, llm_client=llm, dispatcher=_dispatcher(wt_path), max_tool_iterations=10 + ) + + await db_session.refresh(card) + assert card.archived_at is not None + tool_result = next(m for m in appended if m.role == ChatRole.TOOL) + assert not tool_result.is_error + assert tool_result.card_id == card.id + assert await card_service.list_cards(db_session, project.id) == [] + + +async def test_chat_unarchive_card_restores_it_to_the_board(db_session, toy_repo_remote): + project, wt_path = await _make_project(db_session, toy_repo_remote, _n="3h") + card = await card_service.create_card(db_session, project.id, title="Keep after all", raw_request="r") + await card_service.archive_card(db_session, card.id) + await db_session.commit() + await chat_service.append_user_message(db_session, project.id, content="actually, bring that card back") + await db_session.commit() + + llm = ScriptedLLMClient( + [ + LLMResult( + content=None, + tool_calls=[ + ToolCallRequest( + id="call_1", name="unarchive_card", arguments={"card_id": card.id} + ) + ], + endpoint_used="fake::model", + ), + LLMResult(content="It's back.", tool_calls=[], endpoint_used="fake::model"), + ] + ) + + appended = await run_chat_turn( + db_session, project, llm_client=llm, dispatcher=_dispatcher(wt_path), max_tool_iterations=10 + ) + + await db_session.refresh(card) + assert card.archived_at is None + tool_result = next(m for m in appended if m.role == ChatRole.TOOL) + assert not tool_result.is_error + assert [c.id for c in await card_service.list_cards(db_session, project.id)] == [card.id] + + async def test_chat_no_op_guard_when_history_already_ends_on_assistant(db_session, toy_repo_remote): project, wt_path = await _make_project(db_session, toy_repo_remote, _n="4") await chat_service.append_user_message(db_session, project.id, content="hello") diff --git a/tests/integration/test_deployer_loop.py b/tests/integration/test_deployer_loop.py index 0df253d..dc055b4 100644 --- a/tests/integration/test_deployer_loop.py +++ b/tests/integration/test_deployer_loop.py @@ -440,6 +440,8 @@ def handler(request: httpx.Request) -> httpx.Response: mode=DeployMode.PR_TO_OPERATOR, ) + # Opening the PR is the Deployer's job; the card's completion isn't — it stays + # ACTIVE (excluded from claiming via pr_number), so no postmortem yet. assert result.lifecycle_state == LifecycleState.ACTIVE assert result.pr_number == 7 assert await _postmortems_for(db_session, card.id) == [] @@ -449,6 +451,12 @@ def handler(request: httpx.Request) -> httpx.Response: "fetch_pr_status", lambda project, card: _async_result(deploy_runner.PullRequestStatus(merged=True, state="closed")), ) + + # The PR merges (by a human, or the watcher after an approving review). + async def _merged(_project, _card): + return deploy_runner.PullRequestStatus(merged=True, state="closed") + + monkeypatch.setattr(deploy_runner, "fetch_pr_status", _merged) await run_pr_watcher_once() await db_session.refresh(card) diff --git a/tests/integration/test_git_tools.py b/tests/integration/test_git_tools.py index 33e05b8..982b63e 100644 --- a/tests/integration/test_git_tools.py +++ b/tests/integration/test_git_tools.py @@ -8,6 +8,7 @@ agent/context_window.py's compaction ever got a chance to run. """ +import base64 import subprocess from built.tools import git_tools @@ -72,3 +73,57 @@ async def test_diff_shortstat_against_ref_is_zero_when_nothing_changed(toy_repo_ insertions, deletions = await git_tools.diff_shortstat_against_ref(toy_repo_remote, "base") assert (insertions, deletions) == (0, 0) + + +def test_token_auth_config_scopes_the_token_and_clears_credential_helpers(): + """The deploy push fix ("authenticate deploy git push with the configured + GitHub token"): the token travels as a Basic-auth header via + http.https://github.com/.extraheader — scoped to github.com only, passed as + -c config for that one invocation (never written to any on-disk gitconfig), + and base64-encoded so the literal token never appears in argv where ps could + see it. credential.helper= is cleared so an ambient helper (e.g. gh's, holding + a differently-scoped token) can't silently win over the explicit header.""" + args = git_tools._token_auth_config("sekrit-token") + + expected_basic = base64.b64encode(b"x-access-token:sekrit-token").decode() + assert args == ( + "-c", + "credential.helper=", + "-c", + f"http.https://github.com/.extraheader=AUTHORIZATION: basic {expected_basic}", + ) + assert "sekrit-token" not in " ".join(args) + + +async def test_run_git_token_scoped_push_completes(tmp_path): + """A token-scoped push: run_git(..., token=...) must accept the token and push + successfully. The fake github.com URL is rewritten (url..insteadOf) to a + local receiver so no network is touched; the auth -c args ride along harmlessly. + Guards against the auth flags ever breaking the push command they're meant to + secure.""" + receiver = tmp_path / "receiver.git" + subprocess.run(["git", "init", "-q", "-b", "main", "--bare", str(receiver)], check=True) + work = tmp_path / "work" + work.mkdir() + for args in ( + ["init", "-q", "-b", "main"], + ["config", "user.email", "test@example.com"], + ["config", "user.name", "Test"], + ["remote", "add", "origin", "https://github.com/fake-owner/fake-repo.git"], + ["config", f"url.{receiver}.insteadOf", "https://github.com/fake-owner/fake-repo.git"], + ): + subprocess.run(["git", *args], cwd=work, check=True, capture_output=True) + (work / "app.py").write_text("x = 1\n") + subprocess.run(["git", "add", "."], cwd=work, check=True, capture_output=True) + subprocess.run(["git", "commit", "-q", "-m", "c1"], cwd=work, check=True, capture_output=True) + + await git_tools.run_git("push", "origin", "main:main", cwd=work, token="sekrit-token") + + log = subprocess.run( + ["git", "log", "--oneline", "main"], + cwd=receiver, + check=True, + capture_output=True, + text=True, + ).stdout + assert "c1" in log diff --git a/tests/integration/test_worktree.py b/tests/integration/test_worktree.py index 8c9be71..69f6cab 100644 --- a/tests/integration/test_worktree.py +++ b/tests/integration/test_worktree.py @@ -110,6 +110,45 @@ async def test_sync_card_branch_with_default_leaves_conflict_markers_for_develop assert "<<<<<<<" in (wt_path / "app.py").read_text() +async def test_ensure_managed_clone_falls_back_to_default_branch_when_a_card_branch_is_checked_out( + toy_repo_remote, +): + """Regression for the deploy-time "refusing to fetch into branch + 'refs/heads/card/...' checked out at ..." failure: once a card's branch has been + pushed to the remote (pr_to_operator) while its worktree still has it checked + out, the full +refs/heads/*:refs/heads/* fetch for the *next* card's visit + refuses, wedging that visit. ensure_managed_clone must fall back to fetching + just the default branch (never checked out anywhere) instead.""" + project = Project( + id="proj-wt-fetch-conflict", repo_remote_url=str(toy_repo_remote), default_branch="main" + ) + card = Card(id="card-wt-fetch-conflict", project_id=project.id, branch_name="card/fetch-conflict") + bare_path = await worktree.ensure_managed_clone(project) + wt_path = await worktree.create_card_worktree(project, card) + + # Simulate the deployer having pushed the card's branch: it now exists on the + # remote while also being checked out in the card's own worktree. + (wt_path / "app.py").write_text("def greet():\n return 'from the card'\n") + await git_tools.commit_all(wt_path, message="card change") + await git_tools.run_git( + "push", "origin", f"{card.branch_name}:{card.branch_name}", cwd=wt_path + ) + + # main advances on the remote in the meantime. + (toy_repo_remote / "other.py").write_text("value = 2\n") + subprocess.run(["git", "add", "-A"], cwd=toy_repo_remote, check=True, capture_output=True) + subprocess.run( + ["git", "commit", "-q", "-m", "advance main"], cwd=toy_repo_remote, check=True, capture_output=True + ) + + # Before the fix, this raised "refusing to fetch into branch ... checked out". + await worktree.ensure_managed_clone(project) + + # The default branch still advanced despite the fallback. + log = await git_tools.run_git("log", "--oneline", "-1", "refs/heads/main", cwd=bare_path) + assert "advance main" in log + + async def test_read_default_branch_file_returns_none_when_missing(toy_repo_remote): project = Project(id="proj-wt-read-1", repo_remote_url=str(toy_repo_remote), default_branch="main") content = await worktree.read_default_branch_file(project, "AGENTS.md")