fix(projects): keep the model's Project name when a promotion carries a source

A promotion with `source` (clone or attach) registers the Project row in its
off-loop half, before the worker-side named create. `create_project` returns
an existing row untouched, so the display name given by the model was dropped
and the Project was named by its id. For a non-Latin name that id is
`proj_<digest>`, which then showed in the sidebar, the card footer and the
System rows.

The display name now reaches the first create. A title over the registry
limit is withheld there instead of refusing a finished clone, which keeps
today's outcome for that case.
This commit is contained in:
Ouroboros 2026-09-21 00:57:30 +03:00
parent ac1f236709
commit db927715c2
4 changed files with 64 additions and 5 deletions

View file

@ -15,7 +15,7 @@ def _source_project_id(source: str, is_git: bool) -> str:
def resolve_promote_source(
ctx: Any, source: str, project_id: str,
ctx: Any, source: str, project_id: str, *, project_name: str = "",
) -> Tuple[str, str, str, str, bool]:
"""Attach/clone only after the supervisor has admitted an executor.
@ -27,7 +27,10 @@ def resolve_promote_source(
the project row — the promote continuation carries it so the one
workers-side ``project_started`` announce still fires for a creation that
happened in this off-loop half (owner 2=A: it is the same agent-initiated
promote flow).
promote flow). ``project_name`` is the display name the model gave: this
half registers the row FIRST, and a later named ``create_project`` returns
the existing row untouched, so a name withheld here is lost for good and
the Project shows its id (``proj_<digest>`` for a non-Latin name).
"""
from ouroboros.config import DATA_DIR
from ouroboros.project_sources import clone_project_repo, valid_git_url, validate_attach_path
@ -88,11 +91,14 @@ def resolve_promote_source(
if prior_wd == folder and str((existing or {}).get("provenance") or "").strip() not in ("", "none"):
return folder, note, "", pid, False
try:
from ouroboros.projects_registry import create_project, update_project
from ouroboros.projects_registry import PROJECT_NAME_MAX, create_project, update_project
from ouroboros.utils import utc_now_iso
name = str(project_name or "").strip()
if len(name) > PROJECT_NAME_MAX:
name = "" # never refuse a finished clone over a long title; the row keeps its id
created = bool(create_project(
drive_root, pid, origin="promote_chat_to_task",
drive_root, pid, name=name, origin="promote_chat_to_task",
).get("created"))
update_project(
drive_root,

View file

@ -303,6 +303,7 @@ def _prepare_promote_source_off_loop(evt: Dict[str, Any], ctx: Any) -> None:
ctx,
str(evt.get("source") or ""),
str(evt.get("project_id") or ""),
project_name=str(evt.get("project_name") or ""),
)
continuation["project_id"] = project_id
continuation["_source_note"] = note

View file

@ -914,7 +914,10 @@ def test_source_resolution_runs_off_supervisor_loop_and_continues_once(
release = threading.Event()
continuation_bus = thread_queue.Queue()
def slow_resolve(_ctx, _source, project_id):
seen_names = []
def slow_resolve(_ctx, _source, project_id, *, project_name=""):
seen_names.append(project_name)
started.set()
assert release.wait(2)
return "", "source checked", "", project_id, False
@ -946,6 +949,7 @@ def test_source_resolution_runs_off_supervisor_loop_and_continues_once(
"routing_token": "source-token",
"objective": "Inspect source",
"source": "https://github.com/example/project.git",
"project_name": "Исходники проекта",
"chat_id": 1,
}
@ -965,3 +969,6 @@ def test_source_resolution_runs_off_supervisor_loop_and_continues_once(
assert load_task_result(tmp_path, "source-task")["promotion_admission"][
"routing_token"
] == "source-token"
# The off-loop half registers the Project row first, so the model's display
# name has to reach it: the later named create finds the row and changes nothing.
assert seen_names == ["Исходники проекта"]

View file

@ -463,3 +463,48 @@ def test_promote_source_registers_derived_project_and_mirrors_conflict(tmp_path,
)
ws4, _, err4, _, _ = resolve_promote_source(ctx, "https://example.com/myrepo.git", "myrepo")
assert ws4 == "" and "conflict" in err4
def test_promote_with_a_source_keeps_the_display_name_the_model_gave(tmp_path, monkeypatch):
"""A sourced promotion registers the row in its off-loop half, BEFORE the named create.
``create_project`` returns an existing row untouched, so the name must reach the first
call: withheld, a non-Latin name (whose id is ``proj_<digest>``) showed the id everywhere.
"""
from types import SimpleNamespace
import ouroboros.config as config
from ouroboros.project_facts import project_id_from_display_name
from ouroboros.projects_registry import PROJECT_NAME_MAX, create_project, get_project
from ouroboros.promotion_source import resolve_promote_source
data = tmp_path / "data"
data.mkdir()
monkeypatch.setattr(config, "DATA_DIR", data)
ctx = SimpleNamespace(repo_dir=str(tmp_path / "repo"))
name = "Переключатели субагентов"
pid = project_id_from_display_name(name)
assert pid.startswith("proj_") # no Latin slug: the id is a digest, the NAME is what people read
folder = tmp_path / "work"
folder.mkdir()
_, _, err, got, created = resolve_promote_source(ctx, str(folder), pid, project_name=name)
assert err == "" and got == pid and created is True
assert get_project(data, pid)["name"] == name
# The worker-side named create that follows finds the row and changes nothing.
assert create_project(data, pid, name=name, origin="promote_chat_to_task")["created"] is False
assert get_project(data, pid)["name"] == name
# No name given stays what it was: the row is named by its id.
plain = tmp_path / "plain"
plain.mkdir()
_, _, err2, pid2, _ = resolve_promote_source(ctx, str(plain), "plain-project")
assert err2 == "" and get_project(data, pid2)["name"] == "plain-project"
# A title over the registry limit never turns a finished clone/attach into a refusal.
longer = tmp_path / "longer"
longer.mkdir()
_, _, err3, pid3, created3 = resolve_promote_source(
ctx, str(longer), "long-title", project_name="x" * (PROJECT_NAME_MAX + 1))
assert err3 == "" and created3 is True and get_project(data, pid3)["name"] == "long-title"