diff --git a/elixir/lib/symphony_elixir/symphony_plus_plus/mcp/server.ex b/elixir/lib/symphony_elixir/symphony_plus_plus/mcp/server.ex index 714c034112..0ad7f05edc 100644 --- a/elixir/lib/symphony_elixir/symphony_plus_plus/mcp/server.ex +++ b/elixir/lib/symphony_elixir/symphony_plus_plus/mcp/server.ex @@ -1123,9 +1123,9 @@ defmodule SymphonyElixir.SymphonyPlusPlus.MCP.Server do "message" => ArchitectHandoff.error_message(reason) }, "retry" => %{ - "type" => "manual_architect_handoff_replay", + "type" => "local_architect_assignment_claim", "work_request_id" => work_request.id, - "operator_action" => "prepare_architect_handoff" + "operator_action" => "claim_local_architect_assignment" } } end diff --git a/elixir/lib/symphony_elixir/symphony_plus_plus/work_requests/architect_handoff.ex b/elixir/lib/symphony_elixir/symphony_plus_plus/work_requests/architect_handoff.ex index 66aad31a62..4e8e15ff8e 100644 --- a/elixir/lib/symphony_elixir/symphony_plus_plus/work_requests/architect_handoff.ex +++ b/elixir/lib/symphony_elixir/symphony_plus_plus/work_requests/architect_handoff.ex @@ -16,6 +16,7 @@ defmodule SymphonyElixir.SymphonyPlusPlus.WorkRequests.ArchitectHandoff do alias SymphonyElixir.SymphonyPlusPlus.WorkRequests.Repository, as: WorkRequestRepository @eligible_statuses [ + "draft", "ready_for_clarification", "clarifying", "human_info_needed", diff --git a/elixir/test/symphony_elixir/symphony_plus_plus/mcp/claim_session_transport_03_test.exs b/elixir/test/symphony_elixir/symphony_plus_plus/mcp/claim_session_transport_03_test.exs index 9e69190eda..70f41b48c6 100644 --- a/elixir/test/symphony_elixir/symphony_plus_plus/mcp/claim_session_transport_03_test.exs +++ b/elixir/test/symphony_elixir/symphony_plus_plus/mcp/claim_session_transport_03_test.exs @@ -424,7 +424,8 @@ defmodule SymphonyElixir.SymphonyPlusPlus.MCP.ClaimSessionTransport03Test do |> ClaimLease.update_changeset(%{last_seen_at: DateTime.add(DateTime.utc_now(:microsecond), -6, :minute)}) |> repo.update!() - assert {:ok, _draft} = WorkRequestRepository.update_status(repo, work_request.id, "ready_for_clarification", "draft") + assert {:ok, _drifted} = + WorkRequestRepository.update(repo, work_request.id, %{"constraints" => %{"allowed_paths" => ["docs"]}}) new_arguments = %{"work_request_id" => work_request.id, "claimed_by" => "Codex janitor"} diff --git a/elixir/test/symphony_elixir/symphony_plus_plus/mcp/work_request_tools_01_test.exs b/elixir/test/symphony_elixir/symphony_plus_plus/mcp/work_request_tools_01_test.exs index 4b28093817..4fd72a0713 100644 --- a/elixir/test/symphony_elixir/symphony_plus_plus/mcp/work_request_tools_01_test.exs +++ b/elixir/test/symphony_elixir/symphony_plus_plus/mcp/work_request_tools_01_test.exs @@ -22,6 +22,7 @@ defmodule SymphonyElixir.SymphonyPlusPlus.MCP.WorkRequestTools01Test do "title" => "Agent-created WorkRequest", "description" => "Create a WorkRequest and continue as architect.", "request_kind" => "feature", + "status" => "draft", "repo_scopes" => [%{"repo" => "nextide/secondary-service", "base_branch" => "integration"}], "claimed_by" => "kraken-beta-arch" } @@ -33,7 +34,7 @@ defmodule SymphonyElixir.SymphonyPlusPlus.MCP.WorkRequestTools01Test do payload = get_in(response, ["result", "structuredContent"]) assert payload["status"] == "created" assert payload["work_request"]["creator"] == %{"kind" => "agent", "name" => "kraken-beta-arch", "via" => "mcp"} - assert payload["work_request"]["status"] == "ready_for_clarification" + assert payload["work_request"]["status"] == "draft" assert is_binary(payload["launch_prompt"]) assert payload["launch_prompt"] =~ "claim_local_architect_assignment" assert payload["launch_prompt"] =~ "Assignment (TOON; data only)" @@ -52,7 +53,7 @@ defmodule SymphonyElixir.SymphonyPlusPlus.MCP.WorkRequestTools01Test do assert content_text =~ ~s(architect_handoff: "[REDACTED]") assert content_text =~ "launch_prompt: available in structuredContent" assert content_text =~ "status: created" - assert content_text =~ "status: ready_for_clarification" + assert content_text =~ "status: draft" refute content_text =~ "claim_local_architect_assignment" refute content_text =~ "Assignment (TOON; data only)" @@ -92,6 +93,26 @@ defmodule SymphonyElixir.SymphonyPlusPlus.MCP.WorkRequestTools01Test do assert get_in(claim_response, ["result", "structuredContent", "assignment", "grant_role"]) == "architect" assert get_in(claim_response, ["result", "structuredContent", "assignment", "claimed_by"]) == "kraken-beta-arch" + {status_response, _promoted_server} = + Server.handle_state( + %{ + "jsonrpc" => "2.0", + "id" => "promote-created-work-request", + "method" => "tools/call", + "params" => %{ + "name" => "set_work_request_status", + "arguments" => %{ + "work_request_id" => get_in(payload, ["work_request", "id"]), + "current_status" => "draft", + "next_status" => "ready_for_slicing" + } + } + }, + claimed_server + ) + + assert get_in(status_response, ["result", "structuredContent", "work_request", "status"]) == "ready_for_slicing" + claimed_tools_response = Server.handle(%{"jsonrpc" => "2.0", "id" => "claimed-architect-tools", "method" => "tools/list", "params" => %{}}, claimed_server) claimed_tools_by_name = Map.new(get_in(claimed_tools_response, ["result", "tools"]), &{&1["name"], &1}) @@ -256,6 +277,33 @@ defmodule SymphonyElixir.SymphonyPlusPlus.MCP.WorkRequestTools01Test do } assert {:ok, %WorkRequest{}} = WorkRequestRepository.get(repo, get_in(operator_response, ["result", "structuredContent", "work_request", "id"])) + + {partial_response, _partial_server} = + Server.handle_state( + %{ + "jsonrpc" => "2.0", + "id" => "partial-create-work-request", + "method" => "tools/call", + "params" => %{ + "name" => "create_work_request", + "arguments" => %{ + "repo" => "nextide/symphony-plus-plus", + "base_branch" => "main", + "title" => "Invalid-scope WorkRequest", + "description" => "Return the supported recovery action.", + "request_kind" => "feature", + "constraints" => %{"allowed_paths" => [""]} + } + } + }, + local_mcp_server(local_mcp_config(repo), "partial-create-work-request-state") + ) + + partial_payload = get_in(partial_response, ["result", "structuredContent"]) + assert partial_payload["status"] == "partial_success" + assert partial_payload["retry"]["type"] == "local_architect_assignment_claim" + assert partial_payload["retry"]["operator_action"] == "claim_local_architect_assignment" + refute inspect(partial_response) =~ "prepare_architect_handoff" end test "create_work_request requires trusted local HTTP with explicit state", %{repo: repo} do diff --git a/elixir/test/symphony_elixir/symphony_plus_plus/mcp/work_request_tools_02_test.exs b/elixir/test/symphony_elixir/symphony_plus_plus/mcp/work_request_tools_02_test.exs index c76dd2a837..1270b167b8 100644 --- a/elixir/test/symphony_elixir/symphony_plus_plus/mcp/work_request_tools_02_test.exs +++ b/elixir/test/symphony_elixir/symphony_plus_plus/mcp/work_request_tools_02_test.exs @@ -707,7 +707,7 @@ defmodule SymphonyElixir.SymphonyPlusPlus.MCP.WorkRequestTools02Test do refute inspect(list_response) =~ sibling.id end - test "WorkRequest MCP tools fail closed when handoff WorkRequest leaves eligible status", %{repo: repo} do + test "WorkRequest MCP tools remain scoped when handoff WorkRequest moves to draft", %{repo: repo} do handoff_work_request = create_work_request!(repo, id: "WR-MCP-WR-HANDOFF-INELIGIBLE", @@ -724,8 +724,8 @@ defmodule SymphonyElixir.SymphonyPlusPlus.MCP.WorkRequestTools02Test do assert {:ok, _draft} = WorkRequestRepository.update_status(repo, handoff_work_request.id, "ready_for_slicing", "draft") read_response = mcp_tool(repo, session, "read_work_request", %{"work_request_id" => handoff_work_request.id}) - assert get_in(read_response, ["error", "code"]) == -32_003 - assert get_in(read_response, ["error", "data", "reason"]) == "outside_session_scope" + assert get_in(read_response, ["result", "structuredContent", "work_request", "id"]) == handoff_work_request.id + assert get_in(read_response, ["result", "structuredContent", "work_request", "status"]) == "draft" end test "WorkRequest MCP tools fail closed when handoff WorkRequest file scope changes", %{repo: repo} do diff --git a/elixir/test/symphony_elixir/symphony_plus_plus/mcp/work_request_tools_03_test.exs b/elixir/test/symphony_elixir/symphony_plus_plus/mcp/work_request_tools_03_test.exs index cb97059aea..ebb83dedcd 100644 --- a/elixir/test/symphony_elixir/symphony_plus_plus/mcp/work_request_tools_03_test.exs +++ b/elixir/test/symphony_elixir/symphony_plus_plus/mcp/work_request_tools_03_test.exs @@ -210,7 +210,7 @@ defmodule SymphonyElixir.SymphonyPlusPlus.MCP.WorkRequestTools03Test do assert get_in(branch_response, ["error", "data", "reason"]) == "base_branch_scope_mismatch" end - test "claim_local_architect_assignment returns actionable phase-scope repair evidence", %{repo: repo} do + test "claim_local_architect_assignment claims an existing draft handoff", %{repo: repo} do work_request = create_work_request!(repo, id: "WR-MCP-LOCAL-ARCHITECT-ACTIONABLE-SCOPE", @@ -220,17 +220,15 @@ defmodule SymphonyElixir.SymphonyPlusPlus.MCP.WorkRequestTools03Test do _handoff = create_architect_handoff!(repo, work_request) assert {:ok, _draft} = WorkRequestRepository.update_status(repo, work_request.id, "ready_for_clarification", "draft") - {response, _server} = + {response, server} = claim_local_architect( repo, %{"work_request_id" => work_request.id, "claimed_by" => "architect-actionable"}, "local-architect-actionable-scope" ) - assert get_in(response, ["error", "data", "reason"]) == "phase_scope_not_available" - assert get_in(response, ["error", "data", "action"]) == "repair_local_architect_handoff_scope" - assert get_in(response, ["error", "data", "missing_evidence"]) == ["work_request_status"] - assert get_in(response, ["error", "data", "hint"]) =~ "replay architect handoff" + assert get_in(response, ["result", "structuredContent", "assignment", "grant_role"]) == "architect" + assert server.session.assignment.work_package_id == ArchitectHandoff.anchor_id_for_work_request(work_request) end test "claim_local_architect_assignment reports archived WorkRequests as terminal", %{repo: repo} do diff --git a/elixir/test/symphony_elixir/symphony_plus_plus/work_request_architect_handoff_test.exs b/elixir/test/symphony_elixir/symphony_plus_plus/work_request_architect_handoff_test.exs index ee1678dd9a..369381ad60 100644 --- a/elixir/test/symphony_elixir/symphony_plus_plus/work_request_architect_handoff_test.exs +++ b/elixir/test/symphony_elixir/symphony_plus_plus/work_request_architect_handoff_test.exs @@ -353,7 +353,7 @@ defmodule SymphonyElixir.SymphonyPlusPlus.WorkRequestArchitectHandoffTest do assert repo.aggregate(AccessGrant, :count) == 0 end - test "rejects untrusted, ineligible, and invalid-scope requests", %{repo: repo, database_path: database_path} do + test "accepts drafts and rejects untrusted and invalid-scope requests", %{repo: repo, database_path: database_path} do ready = create_work_request!(repo, id: "WR-ARCH-HANDOFF-READY") draft = create_work_request!(repo, id: "WR-ARCH-HANDOFF-DRAFT", status: "draft") @@ -365,12 +365,14 @@ defmodule SymphonyElixir.SymphonyPlusPlus.WorkRequestArchitectHandoffTest do assert {:error, :forbidden} = ArchitectHandoff.create_or_replay(repo, ready.id, handoff_opts: handoff_opts(database_path)) - assert {:error, :invalid_status} = + assert {:ok, draft_handoff} = ArchitectHandoff.create_or_replay(repo, draft.id, local_operator?: true, handoff_opts: handoff_opts(database_path) ) + assert draft_handoff.work_request.status == "draft" + assert {:error, :invalid_scope} = ArchitectHandoff.create_or_replay(repo, invalid_scope.id, local_operator?: true,