fix(tui): stop a stream outlived by /new from leaking into the next conversation
package / wheel (pull_request) Waiting to run

_finish_stream appended the completed reply to self.history - whatever
list that name currently pointed at - not to the conversation the
stream was actually answering. /new reassigns self.history to a fresh
list; a stream still running when that happens finished by silently
appending the old conversation's trailing reply onto the new one, which
then rides along in that new conversation's next /chat/stream history
payload. The conversation_id was already captured by closure for this
exact reason (see the tool-denial path); self.history needed the same
treatment.
This commit is contained in:
Jon Wingender
2026-08-26 14:01:19 -05:00
23 changed files with 1803 additions and 125 deletions
+21 -55
View File
@@ -24,10 +24,8 @@ from . import ncp as services
CONFIG_SCHEMA = {
"home": "path",
"api_url": "url",
"memory_url": "url",
"bind_host": "text",
"backend_port": "port",
"memory_port": "port",
"provider": "provider",
"provider_url": "url",
"provider_timeout": "positive_int",
@@ -39,8 +37,6 @@ CONFIG_SCHEMA = {
}
LEGACY_TARGETS = {
"-m": "memory",
"--memory": "memory",
"-b": "backend",
"--backend": "backend",
"-f": "frontend",
@@ -303,14 +299,13 @@ def diagnostics() -> dict:
)
add(
"service ports",
all(1 <= port <= 65535 for port in (settings.backend_port, settings.memory_port)),
f"backend={settings.backend_port}, memory={settings.memory_port}",
1 <= settings.backend_port <= 65535,
f"backend={settings.backend_port}",
)
for module in ("fastapi", "uvicorn", "httpx", "pydantic", "yaml"):
add(f"import:{module}", _check_import(module), module)
add("backend", _http_ok(settings.api_url + "/status"), settings.api_url, required=False)
add("memory service", _http_ok(settings.memory_url + "/"), settings.memory_url, required=False)
provider = _provider_payload()
add("provider", provider["reachable"], provider["url"], required=False)
if settings.manage_ollama:
@@ -362,7 +357,7 @@ def cmd_doctor(args) -> int:
def service_status() -> dict:
payload = {}
for key in ("backend", "memory", "frontend"):
for key in ("backend", "frontend"):
svc = services.SERVICES[key]
pid = services.read_pid(svc)
payload[key] = {
@@ -380,7 +375,7 @@ def cmd_status(args) -> int:
_emit(payload, True)
return 0
print("Nexus Service Status:\n")
for key in ("backend", "memory", "frontend"):
for key in ("backend", "frontend"):
info = payload[key]
suffix = f" (PID {info['pid']})" if info["pid"] else ""
print(f" {key:<10} {'RUNNING' if info['running'] else 'STOPPED'}{suffix} {info['url']}")
@@ -418,7 +413,6 @@ def cmd_tui(args) -> int:
def _target_flag(target: str | None):
return {
"memory": "--memory",
"backend": "--backend",
"frontend": "--frontend",
"ai": "--ai",
@@ -503,18 +497,14 @@ def cmd_serve(args) -> int:
return 2
settings.backend_port = args.port
settings.memory_port = args.memory_port
settings.bind_host = host
settings.api_url = f"http://127.0.0.1:{args.port}"
settings.memory_url = f"http://127.0.0.1:{args.memory_port}"
os.environ["NEXUS_BACKEND_PORT"] = str(args.port)
os.environ["NEXUS_MEMORY_PORT"] = str(args.memory_port)
os.environ["NEXUS_BIND_HOST"] = host
for origin_host in ("localhost", "127.0.0.1"):
for port in (args.port, args.memory_port):
origin = f"http://{origin_host}:{port}"
if origin not in config.ALLOWED_ORIGINS:
config.ALLOWED_ORIGINS.append(origin)
origin = f"http://{origin_host}:{args.port}"
if origin not in config.ALLOWED_ORIGINS:
config.ALLOWED_ORIGINS.append(origin)
if args.allow_lan:
# Widen to the addresses this bind actually answers on - NOT "*".
# ALLOWED_HOSTS drives TrustedHostMiddleware, which is the DNS-rebinding
@@ -526,10 +516,9 @@ def cmd_serve(args) -> int:
for name in names:
if name not in config.ALLOWED_HOSTS:
config.ALLOWED_HOSTS.append(name)
for port in (args.port, args.memory_port):
origin = f"http://{_origin_host(name)}:{port}"
if origin not in config.ALLOWED_ORIGINS:
config.ALLOWED_ORIGINS.append(origin)
origin = f"http://{_origin_host(name)}:{args.port}"
if origin not in config.ALLOWED_ORIGINS:
config.ALLOWED_ORIGINS.append(origin)
os.environ.setdefault("NEXUS_ALLOWED_HOSTS", ",".join(config.ALLOWED_HOSTS))
os.environ.setdefault("NEXUS_ALLOWED_ORIGINS", ",".join(config.ALLOWED_ORIGINS))
print(
@@ -538,35 +527,13 @@ def cmd_serve(args) -> int:
" has full admin and data access."
)
memory_proc = None
memory_log = None
try:
if not args.no_memory and not _http_ok(settings.memory_url + "/"):
log_path = settings.runtime_dir / "memory.log"
log_path.parent.mkdir(parents=True, exist_ok=True)
memory_log = open(log_path, "ab")
memory_proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "synapse.memory.service:app",
"--host", host, "--port", str(args.memory_port)],
stdout=memory_log, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
)
print(f"Memory service starting on {host}:{args.memory_port} (log: {log_path})")
print(f"NexusOS serving on http://{host}:{args.port}")
import uvicorn
uvicorn.run(
"synapse.main:sio_app", host=host, port=args.port,
reload=bool(args.reload and settings.source_checkout),
log_level=args.log_level,
)
finally:
if memory_proc is not None and memory_proc.poll() is None:
memory_proc.terminate()
try:
memory_proc.wait(timeout=5)
except subprocess.TimeoutExpired:
memory_proc.kill()
if memory_log is not None:
memory_log.close()
print(f"NexusOS serving on http://{host}:{args.port}")
import uvicorn
uvicorn.run(
"synapse.main:sio_app", host=host, port=args.port,
reload=bool(args.reload and settings.source_checkout),
log_level=args.log_level,
)
return 0
@@ -626,7 +593,7 @@ def cmd_nvidia_reqs(_args) -> int:
def cmd_logs(args) -> int:
keys = ("backend", "memory", "frontend") if args.target == "all" else (args.target,)
keys = ("backend", "frontend") if args.target == "all" else (args.target,)
paths = [services.SERVICES[key].log_file for key in keys]
for path in paths:
print(f"=== {path.name} ===")
@@ -779,8 +746,7 @@ def build_parser() -> argparse.ArgumentParser:
p = sub.add_parser("serve", help="run NexusOS in the foreground")
p.add_argument("--host"); p.add_argument("--port", type=_port, default=settings.backend_port)
p.add_argument("--memory-port", type=_port, default=settings.memory_port)
p.add_argument("--no-memory", action="store_true"); p.add_argument("--allow-lan", action="store_true")
p.add_argument("--allow-lan", action="store_true")
p.add_argument("--reload", action="store_true"); p.add_argument("--log-level", default="info")
p.set_defaults(fn=cmd_serve)
@@ -789,7 +755,7 @@ def build_parser() -> argparse.ArgumentParser:
("stop", cmd_stop, "stop background services"),
):
p = sub.add_parser(name, help=help_text)
p.add_argument("target", nargs="?", choices=["all", "backend", "memory", "frontend", "ai"], default="all")
p.add_argument("target", nargs="?", choices=["all", "backend", "frontend", "ai"], default="all")
p.set_defaults(fn=fn)
sub.add_parser("restart", aliases=["refresh"], help="restart all services").set_defaults(fn=cmd_refresh)
sub.add_parser("kill", help="force-stop NexusOS-owned processes").set_defaults(fn=lambda _a: services.cmd_kill() or 0)
@@ -799,7 +765,7 @@ def build_parser() -> argparse.ArgumentParser:
sub.add_parser("web", help="legacy desktop alias for open").set_defaults(fn=cmd_web)
sub.add_parser("panel", help="launch the legacy desktop control panel").set_defaults(fn=cmd_panel)
p = sub.add_parser("logs", help="read or follow service logs")
p.add_argument("target", nargs="?", choices=["all", "backend", "memory", "frontend"], default="all")
p.add_argument("target", nargs="?", choices=["all", "backend", "frontend"], default="all")
p.add_argument("--lines", type=int, choices=range(1, 10001), default=50, metavar="1..10000")
p.add_argument("--follow", "-f", action="store_true"); p.set_defaults(fn=cmd_logs)
sub.add_parser("clean", help="remove runtime logs and stale PID files").set_defaults(fn=cmd_clean)