Stop naming direct conversation turns; delete the proactive namer

A direct turn renders as an activity block without a title placeholder and is
never offered Turn into project (owner decision 14=A), so the light-model
title the direct lane coined for every fresh Main turn — one paid call per
message, a daemon thread with its own wall-clock bound and a late cost-refresh
path — has no reader left. The lane no longer spawns it; spawn_proactive_namer
and _refresh_root_cost_after_naming are deleted with the threading/contextvars
/pathlib imports only they used, the two tests that exercised them, the
terminal-writer inventory row and five test stubs that silenced the thread.

Managed promotes and headless runs keep their admission names
(admission_names, _admitted_suggested_name) and the task_named frame, so
applySuggestedName still titles managed cards. The post-task checkpoint's
same-terminal refresh stays (its own test covers it); its comments no longer
attribute it to the retired namer.

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
This commit is contained in:
Ouroboros 2026-09-15 17:20:41 +03:00
parent fc40ea335c
commit 56be6556fd
14 changed files with 25 additions and 271 deletions

View file

@ -117,7 +117,7 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de
├── vision_routing.py ← Send-time image routing SSOT: inline vision vs generic captions vs placeholders on a per-send message copy (`OUROBOROS_IMAGE_INPUT_MODE`, `OUROBOROS_MODEL_VISION`)
├── fallback_cooldown.py ← Per-process 429-aware cooldown for the `OUROBOROS_MODEL_FALLBACKS` chain: a transiently-failed model is parked for a short window so fallback walks and repeated rounds skip it; advisory, default-on, fail-soft, passive heal; per-process only — honestly not a swarm-wide governor
├── model_concurrency.py ← Per-(model,use_local) `BoundedSemaphore` capping concurrent provider calls (`OUROBOROS_MODEL_MAX_CONCURRENCY`, default 3) so a task's own loop + subagent threads + status pings cannot self-DoS one model's rate limit; excess threads WAIT deadline-bounded; wraps only the provider call in `loop_llm_call.call_llm_with_retry`; per-process only
├── project_naming.py ← SSOT for LLM-first project naming: bounded light-model title with deterministic fallback, shared by the proactive card namer (supervisor/worker_chat_lane.py), turn-into-project conversion (gateway/projects.py), and `ensure_project_scope`; the provider call goes through the model_concurrency slot
├── project_naming.py ← SSOT for LLM-first project naming: bounded light-model title with deterministic fallback, shared by admission naming (`admission_names`: headless runs and chat promotion, no model call), turn-into-project conversion (gateway/projects.py), and `ensure_project_scope` — a direct conversation turn is never named; the provider call goes through the model_concurrency slot
├── loop_tool_execution.py ← Tool dispatch and tool-result handling
├── deadline_utils.py ← Shared deadline parsing/remaining-time helpers + the transport-vs-logical wait seam for loop milestones and process-tool/review timeouts
├── observability.py ← Private forensic execution ledger: redaction, gzip CAS blobs, call manifests, trace refs

View file

@ -232,8 +232,8 @@ def project_root_post_task_checkpoint_fields(
The root writer owns only post-task synthesis and its accounting snapshot;
acceptance remains whatever the current record says. Once post-task state
is terminal, an open or different-terminal stale patch cannot replace that
state or its accounting. A same-terminal patch remains valid so the
proactive namer's explicit ``refresh`` can update the final cost snapshot.
state or its accounting. A same-terminal patch remains valid so an explicit
``refresh`` can update the final cost snapshot.
"""
overlay = dict(patch_fields)
if canonical_fields.get("status"):
@ -316,8 +316,8 @@ def set_root_post_task_checkpoint(
return
authority_root = roots[0]
finalized_event: Dict[str, Any] | None = None
# The proactive namer can settle concurrently with post-task synthesis. A
# shared critical section makes its refresh and the final snapshot linear.
# A late cost refresh can settle concurrently with post-task synthesis. A
# shared critical section makes that refresh and the final snapshot linear.
with POST_TASK_SYNTHESIS_LOCK:
existing = load_task_result(authority_root, task_id) or {}
checkpoint = existing.get("root_phase_checkpoint")

View file

@ -3,10 +3,11 @@
An LLM-first short human title for a project card, with a deterministic heuristic
fallback. Shared by every path that names a project so the UI conversion and the
agent never drift:
- the proactive card namer (names ANY task card up front, supervisor side);
- ``gateway/projects.py`` turn-into-project conversion (reuses the up-front name,
- ``gateway/projects.py`` turn-into-project conversion (reuses an admission name,
or names inline as a race fallback);
- ``ensure_project_scope`` (the agent self-creates + names a project).
- ``ensure_project_scope`` (the agent self-creates + names a project);
- ``admission_names`` (headless runs and chat promotion, no model call).
A direct conversation turn is never named: it renders as an activity block.
Doctrine:
- P5 LLM-first: the model COINS the name; post-processing is purely lexical
@ -19,11 +20,8 @@ Doctrine:
from __future__ import annotations
import logging
import pathlib
import threading
from dataclasses import replace
from typing import Any, Callable, Dict, Optional, Sequence
import contextvars
from typing import Any, Dict, Optional, Sequence
log = logging.getLogger("ouroboros.project_naming")
@ -271,134 +269,6 @@ async def llm_project_name_async(
return fb
def _refresh_root_cost_after_naming(drive_root: Any, task_id: str) -> None:
"""Refresh a terminal root projection after the naming attempt settles."""
try:
from types import SimpleNamespace
from ouroboros.agent_task_pipeline import _set_root_post_task_checkpoint
from ouroboros.task_results import load_task_result
current = load_task_result(drive_root, task_id) or {}
refreshed = {**current, "id": task_id, "budget_drive_root": str(drive_root)}
_set_root_post_task_checkpoint(
SimpleNamespace(drive_root=pathlib.Path(drive_root)), refreshed, "refresh",
)
except Exception:
log.debug("project naming cost refresh failed for %s", task_id, exc_info=True)
def spawn_proactive_namer(
drive_root: Any, task_id: str, text: str, *, broadcast: Optional[Callable[[dict], None]] = None,
) -> None:
"""Proactively coin an LLM project name for a fresh card in a DAEMON thread (Cluster B).
Writes the coined ``suggested_name`` onto the task result (turn-into-project then reuses
it with zero extra call) and, via ``broadcast``, emits a ``task_named`` event so the live
card shows a human title up front. NEVER blocks the task. ``drive_root`` is captured at
CALL time — NOT read from a mutable module global at thread-execution time — so a later
context switch (or a test that swaps the supervisor drive) can't redirect this thread's
write. Skips cleanly unless ``drive_root`` is a real directory (test safety: a stub /
MagicMock drive must never materialise a stray path — chat_observed persists BEFORE the
LLM call). Fail-soft."""
from ouroboros.settings_integrity import copy_task_settings_context
body = " ".join(str(text or "").split())
if not body:
return
try:
if not pathlib.Path(str(drive_root)).is_dir():
return
except (OSError, TypeError, ValueError):
return
def _work() -> None:
try:
# v6.58.0 (§3.4b): HARD total wall-clock bound. The transport timeout bounds
# ONE attempt, but llm.chat's retry/fallback chain under a degraded provider
# could stretch the whole call to tens of minutes (the incident where the
# card was named 24 minutes late). A title is cosmetic: if it hasn't landed
# within the transport budget + slack, drop it — the id/title heuristics and
# the convert path's own bounded inline call (8s) already cover naming.
_result: list[str] = []
_detached = threading.Event()
_finished = threading.Event()
_refresh_lock = threading.Lock()
_refreshed = False
def _refresh_detached_once() -> None:
nonlocal _refreshed
with _refresh_lock:
if _refreshed:
return
_refreshed = True
_refresh_root_cost_after_naming(drive_root, task_id)
def _call() -> None:
try:
_result.append(llm_project_name(body, drive_root=drive_root, task_id=task_id))
except Exception:
log.debug("proactive namer inner call failed for %s", task_id, exc_info=True)
finally:
_finished.set()
if _detached.is_set():
_refresh_detached_once()
settings_context = contextvars.Context()
copy_task_settings_context(settings_context)
inner = threading.Thread(target=settings_context.run, args=(_call,), name=f"namer-call-{task_id}", daemon=True)
inner.start()
if not _finished.wait(timeout=max(0.0, _naming_timeout_sec() + 30.0)):
_detached.set()
# Close the race where settlement lands between wait() and
# the detached marker. The once-guard covers both interleavings.
if _finished.is_set():
_refresh_detached_once()
log.debug("proactive namer exceeded its wall-clock bound for %s; skipped", task_id)
return
inner.join()
if not _result:
log.debug("proactive namer exceeded its wall-clock bound for %s; skipped", task_id)
return
name = _result[0]
if not name:
return
from ouroboros.task_results import (
STATUS_RUNNING,
load_task_result,
write_task_result,
)
# Persist suggested_name as same-status ENRICHMENT, not a RUNNING transition: a
# fast task may already be terminal (completed/failed/cancelled) by the time this
# daemon finishes, and write_task_result's monotonic guard DROPS a regressing
# RUNNING write — which would silently lose the name the convert path reuses.
# Writing under the current on-disk status lets the monotonic guard's same-status
# enrichment carry the field through (and a benign drop only in the rare race where
# the status advanced past our read — acceptable for a best-effort title).
current = load_task_result(drive_root, task_id) or {}
status = str(current.get("status") or "") or STATUS_RUNNING
write_task_result(drive_root, task_id, status, suggested_name=name)
# A cosmetic namer may settle concurrently with or after the ordinary
# post-task worker. The shared refresh/checkpoint critical section
# linearizes both cases without marking an unfinished phase complete.
_refresh_root_cost_after_naming(drive_root, task_id)
if broadcast is not None:
try:
broadcast({"type": "task_named", "task_id": task_id, "suggested_name": name})
except Exception:
log.debug("task_named broadcast failed for %s", task_id, exc_info=True)
except Exception:
log.debug("proactive namer failed for %s", task_id, exc_info=True)
try:
settings_context = contextvars.Context()
copy_task_settings_context(settings_context)
threading.Thread(target=settings_context.run, args=(_work,), name=f"namer-{task_id}", daemon=True).start()
except Exception:
log.debug("proactive namer thread spawn failed for %s", task_id, exc_info=True)
def admission_names(body: Dict[str, Any], description: str) -> tuple:
"""The run's owner-facing name at admission: ``(title, suggested_name)``.

View file

@ -868,7 +868,7 @@ def _ensure_project_scope(ctx: ToolContext, project_name: str = "", project_id:
)
from ouroboros.project_naming import clean_model_title
# Run the agent-supplied name through the SAME lexical cleaner the proactive namer and
# Run the agent-supplied name through the SAME lexical cleaner admission naming and
# turn-into-project conversion use (project_naming SSOT) so every project-naming path
# produces consistent titles (quote/emoji strip, length cap); fall back to the raw value.
display_name = clean_model_title(project_name) or str(project_name or "").strip()

View file

@ -187,7 +187,7 @@ def _host_operation_failure(metadata: Optional[dict]) -> dict:
def _broadcast_task_named(msg: dict) -> None:
"""Bridge broadcast callback for the proactive namer (kept tiny + fail-soft)."""
"""Bridge broadcast callback for admission naming (kept tiny + fail-soft)."""
try:
from supervisor.message_bus import get_bridge
@ -345,15 +345,10 @@ def _run_chat_task(
_pool()._report_binding_failure(task["id"], pid, exc, path="direct_project_turn")
if not task["text"]:
task["text"] = "(image attached)" if image_data else ""
# Cluster B: proactively coin a project name for a fresh MAIN-CHAT direct card
# (not an already-bound project-thread task) so
# the card shows a human title up front and turn-into-project reuses it.
if not task.get("project_id"):
from ouroboros.project_naming import spawn_proactive_namer
spawn_proactive_namer(
_pool().DRIVE_ROOT, str(task["id"]), task["text"], broadcast=_broadcast_task_named
)
# A direct turn is not named: it renders as an activity block, never as
# a titled task card, and joins a Project only through the model's own
# scope tools (owner decision 14=A). Managed promotes keep their
# admission names (worker_promotion._admitted_suggested_name).
attach_task_contract(task)
# Announce the authoritative start immediately (owner decision 2A):

View file

@ -529,10 +529,6 @@ class TestDesktopChatFullSetStaging:
# isolation precedent as tests/test_inflight_indicator_seams.py::
# _patch_workers).
monkeypatch.setattr(workers, "get_event_q", lambda: queue.Queue())
# Avoid the proactive namer spinning a real thread/LLM in this unit test
# (it is a local `from ouroboros.project_naming import ...`, so patch source).
import ouroboros.project_naming as project_naming
monkeypatch.setattr(project_naming, "spawn_proactive_namer", lambda *a, **k: None)
# Two uploads on disk: an image and a non-image PDF.
img_src = tmp_path / "photo.png"
@ -586,8 +582,6 @@ class TestDesktopChatFullSetStaging:
# lifespan in this xdist worker must not abort the turn before
# handle_task.
monkeypatch.setattr(workers, "get_event_q", lambda: queue.Queue())
import ouroboros.project_naming as project_naming
monkeypatch.setattr(project_naming, "spawn_proactive_namer", lambda *a, **k: None)
b64 = base64.b64encode(_PNG_BYTES).decode("ascii")
agent = _FakeChatAgent()

View file

@ -78,7 +78,6 @@ TERMINAL_WRITERS = {
('ouroboros/post_task_checkpoint.py::set_root_post_task_checkpoint', 'str(existing.get("status") or task.get("status") or STATUS_COMPLETED)'): 'terminal',
('ouroboros/project_dialogue.py::_append_terminal_task_projection', 'status'): 'dynamic',
('ouroboros/project_dialogue.py::persist_continuation_narrative', 'requested_status'): 'dynamic',
('ouroboros/project_naming.py::spawn_proactive_namer._work', 'status'): 'dynamic',
# The locked field projector preserves the existing status, including a
# terminal one; publishing review evidence never completes the task itself.
('ouroboros/review_projection.py::publish_acceptance_checkpoint', '"running"'): 'dynamic',

View file

@ -125,10 +125,10 @@ def test_a_title_hidden_in_metadata_is_refused_like_a_project_id(admission):
def test_a_derived_name_is_not_reported_as_model_coined():
"""Turn-into-project reuses the name slot; it must not claim authorship.
Two producers fill `suggested_name`: the proactive namer coins one with a
model, and headless admission derives one from the request's first line.
The conversion cannot tell them apart, so its naming reason names the SLOT
it read rather than a coiner that may not exist.
`suggested_name` is filled by admission naming (a caller title, or the
request's first line) and by the agent's own scope tools; the conversion
cannot tell them apart, so its naming reason names the SLOT it read rather
than a coiner that may not exist.
"""
import pathlib

View file

@ -126,7 +126,6 @@ def test_ordinary_main_and_transport_refusals_keep_their_original_envelope(monke
@pytest.mark.parametrize("host_operation", [False, True])
def test_chat_crash_preserves_only_the_host_accepted_operation(tmp_path, monkeypatch, host_operation):
from queue import SimpleQueue
from ouroboros import project_naming
from supervisor import worker_chat_lane, workers
class CrashingAgent:
@ -140,7 +139,6 @@ def test_chat_crash_preserves_only_the_host_accepted_operation(tmp_path, monkeyp
monkeypatch.setattr(workers, "DRIVE_ROOT", tmp_path)
monkeypatch.setattr(workers, "get_event_q", SimpleQueue)
monkeypatch.setattr(workers, "send_with_budget", message_bus.send_with_budget)
monkeypatch.setattr(project_naming, "spawn_proactive_namer", lambda *a, **kw: None)
client = _client(tmp_path, bridge)
assert client.post("/chat/inject", headers=_headers(), json={
"chat_id": CHAT, "client_message_id": MSG, "text": "do work",

View file

@ -64,9 +64,6 @@ def _patch_workers(monkeypatch, tmp_path):
# Windows CI shard; same isolation precedent as
# tests/test_promote_event_transport.py::_isolate_event_bus_shutdown_latch).
monkeypatch.setattr(workers, "get_event_q", lambda: queue.Queue())
import ouroboros.project_naming as project_naming
monkeypatch.setattr(project_naming, "spawn_proactive_namer", lambda *a, **k: None)
# Capture the turn's start announce (bridge typing frame) instead of
# touching the real singleton bridge.
bridge_probe = _BridgeProbe()

View file

@ -10,7 +10,6 @@ from supervisor.active_activity import get_direct_activity_registry
def _lane(monkeypatch, tmp_path):
from ouroboros import project_naming
from supervisor import message_bus, state
monkeypatch.setattr(workers, "DRIVE_ROOT", tmp_path)
@ -19,7 +18,6 @@ def _lane(monkeypatch, tmp_path):
monkeypatch.setattr(workers, "send_with_budget", lambda *a, **kw: None)
monkeypatch.setattr(state, "load_state", lambda: {})
monkeypatch.setattr(state, "budget_remaining", lambda *a, **kw: 100)
monkeypatch.setattr(project_naming, "spawn_proactive_namer", lambda *a, **kw: None)
monkeypatch.setattr(message_bus, "get_bridge", lambda: SimpleNamespace(send_chat_action=lambda *a, **kw: None))
workers.open_repo_writer_admission()

View file

@ -85,7 +85,7 @@ def test_native_wait_controls_and_manual_restart_address_all_registered_actors(t
@pytest.mark.parametrize("project_id", ["", "room"])
@pytest.mark.parametrize("action", ["switch", "stop", "stop_settled"])
def test_native_post_task_wait_remains_addressable_after_dialogue_closes(phase, monkeypatch, project_id, action):
from ouroboros import agent_task_pipeline as pipeline, project_naming
from ouroboros import agent_task_pipeline as pipeline
from ouroboros.gateway.state import _chat_activities_snapshot_safe
from ouroboros.post_task_checkpoint import post_task_model_wait
from ouroboros.task_results import load_task_result, write_task_result
@ -97,7 +97,6 @@ def test_native_post_task_wait_remains_addressable_after_dialogue_closes(phase,
monkeypatch.setattr(workers, "DRIVE_ROOT", f.root)
monkeypatch.setattr(workers, "WORKERS", {})
monkeypatch.setattr(workers, "get_event_q", lambda: f.events)
monkeypatch.setattr(project_naming, "spawn_proactive_namer", lambda *a, **kw: None)
monkeypatch.setattr(message_bus, "get_bridge", lambda: SimpleNamespace(send_chat_action=lambda *a, **kw: None))
monkeypatch.setattr(task_queue, "DRIVE_ROOT", f.root)
monkeypatch.setattr(task_queue, "RUNNING", {})

View file

@ -217,102 +217,6 @@ def test_read_paths_do_not_create_task_results_dir(tmp_path):
assert (root / "task_results").is_dir()
def test_proactive_namer_persists_name_on_already_terminal_task(tmp_path, monkeypatch):
"""v6.40.0 #1: the proactive namer must persist ``suggested_name`` even when the task
already raced to a terminal status — it enriches under the CURRENT status instead of a
regressing RUNNING write (which the monotonic guard would drop, losing the convert-reuse
name)."""
import threading
import time
from ouroboros import project_naming
tr.write_task_result(tmp_path, "t", tr.STATUS_COMPLETED, result="fast done")
monkeypatch.setattr(project_naming, "llm_project_name", lambda *a, **k: "Nice Title")
project_naming.spawn_proactive_namer(tmp_path, "t", "build me a thing")
for _ in range(100): # join the daemon namer thread (best-effort, bounded)
if not any(th.name == "namer-t" for th in threading.enumerate()):
break
time.sleep(0.02)
r = tr.load_task_result(tmp_path, "t")
assert r["status"] == tr.STATUS_COMPLETED, "namer must NOT regress a terminal task to running"
assert r.get("suggested_name") == "Nice Title", "suggested_name must survive on a terminal task"
def test_proactive_namer_late_settlement_refreshes_cost_without_late_name(tmp_path, monkeypatch):
"""A provider thread outliving the cosmetic deadline still closes accounting only."""
import threading
import time
from ouroboros import agent_task_pipeline, project_naming, usage_accounting
tr.write_task_result(
tmp_path,
"late-root",
tr.STATUS_COMPLETED,
root_task_id="late-root",
accounted_upper_bound_usd=0.0,
cost_final=True,
root_phase_checkpoint={"post_task_synthesis": "completed"},
)
entered = threading.Event()
release = threading.Event()
def late_paid_name(*_args, **_kwargs):
entered.set()
assert release.wait(2)
attempt = usage_accounting.reserve_attempt(usage_accounting.AttemptRequest(
model="openai/gpt-5.2",
provider="openai",
reservation_usd=0.25,
drive_root=tmp_path,
task_id="late-root",
root_task_id="late-root",
global_limit_usd=5.0,
root_limit_usd=5.0,
))
usage_accounting.mark_dispatched(attempt)
usage_accounting.settle_attempt(attempt, {}, cost_usd=0.25, cost_final=True)
return "Too Late"
monkeypatch.setattr(project_naming, "llm_project_name", late_paid_name)
monkeypatch.setattr(project_naming, "_naming_timeout_sec", lambda: -29.98)
broadcasts = []
project_naming.spawn_proactive_namer(
tmp_path, "late-root", "build a thing", broadcast=broadcasts.append,
)
assert entered.wait(1)
for _ in range(100):
if not any(th.name == "namer-late-root" for th in threading.enumerate()):
break
time.sleep(0.01)
assert not any(th.name == "namer-late-root" for th in threading.enumerate())
release.set()
# The subject is that the late settlement's refresh LANDS, not how fast: the
# detached thread runs reserve → dispatch → settle → cost reconstruction →
# locked task-result write, each a real lock cycle with fsyncs, and the
# Windows full-test leg took longer than a 2 s poll twice out of five runs
# (d0bb839e, 5fbdabd3) while green on the others. A refresh that never lands
# still fails here — after a bound wide enough for a loaded runner.
started = time.monotonic()
for _ in range(2000):
stored = tr.load_task_result(tmp_path, "late-root")
if stored.get("accounted_upper_bound_usd") == 0.25:
break
time.sleep(0.01)
stored = tr.load_task_result(tmp_path, "late-root")
assert stored["accounted_upper_bound_usd"] == 0.25, f"no refresh after {time.monotonic() - started:.1f}s"
assert stored["accounted_upper_bound_usd_with_children"] == 0.25
assert stored["cost_final"] is True
assert stored.get("suggested_name") is None
assert broadcasts == []
assert agent_task_pipeline._root_post_task_already_completed(
type("Env", (), {"drive_root": tmp_path})(),
{"id": "late-root", "root_task_id": "late-root"},
)
def test_read_with_stub_root_leaks_no_cwd_dir(tmp_path, monkeypatch):
"""The exact pollution repro: a MagicMock-derived root (``MagicMock/mock``) reaching a
READ scan must not create a ``MagicMock`` tree in the cwd."""

View file

@ -3745,10 +3745,10 @@ export function createChatInstance({
withRemoteActivity(() => updateLiveCardFromLogEvent(msg.data));
});
// the proactive namer coined a project name for a fresh card — show it
// as the card title up front (turn-into-project then reuses the same name). Not
// thread-gated on chat_id: the broadcast carries only task_id, and applySuggestedName
// no-ops unless THIS thread already holds that card.
// Admission naming (a promoted root, a headless run) coined a name for a
// managed card — show it as the card title up front (turn-into-project then
// reuses the same name). Not thread-gated on chat_id: the broadcast carries
// only task_id, and applySuggestedName no-ops unless THIS thread holds that card.
onWs('task_named', (msg) => {
withRemoteActivity(
() => applySuggestedName(msg?.task_id || '', msg?.suggested_name || ''),