From aaac3e1353ae2e92a3763d0b2d092e34d286f6ae Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Wed, 18 Mar 2026 23:08:38 -0400 Subject: [PATCH] feat: persist orchestrator sessions + user service + docs update - Orchestrator now saves turns to session store so history survives page refresh - UI session_id updated from job result; history controls attached to agent turns - Cortex migrated from system service to systemd user service (no more sudo) - Update README.md and CLAUDE.md with correct service commands Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 4 ++-- README.md | 22 +++++++++++++--------- cortex/routers/orchestrator.py | 19 +++++++++++++++---- cortex/static/app.js | 15 ++++++++++++++- 4 files changed, 44 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d2f9b25..685b50b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,7 +78,7 @@ Cortex_and_Inara_dev/ docker compose up -d # Restart service (after any Python change) -sudo systemctl restart cortex +systemctl --user restart cortex # Syntax check a file before restarting python3 -m py_compile cortex/.py @@ -92,7 +92,7 @@ done cd cortex && .venv/bin/pip install -r requirements.txt # Logs -journalctl -u cortex -f +journalctl --user -u cortex -f # Web UI (local) http://localhost:8000 diff --git a/README.md b/README.md index 0ed2613..ceefdde 100644 --- a/README.md +++ b/README.md @@ -23,22 +23,26 @@ Cortex is a self-hosted multi-agent orchestration layer. Inara is the primary co ## Running Cortex +Cortex runs as a **systemd user service** (no sudo required). + ```bash -# Start (Docker) -cd ~/agents_sync/projects/Cortex_and_Inara_dev -docker compose up -d +# Start / stop / restart +systemctl --user start cortex +systemctl --user stop cortex +systemctl --user restart cortex -# Restart service only (after backend changes) -sudo systemctl restart cortex - -# Logs -journalctl -u cortex -f +# Status and logs +systemctl --user status cortex +journalctl --user -u cortex -f # Web UI http://localhost:8000 (or cortex.dgrzone.com on WireGuard) ``` -Config lives in `cortex/config.py` and a `.env` file at the project root (not tracked — see `env.default`). +The service starts automatically at boot via `loginctl enable-linger`. +Service file: `~/.config/systemd/user/cortex.service` + +Config lives in `cortex/config.py` and a `.env` file at the project root (not tracked — see `.env.default`). --- diff --git a/cortex/routers/orchestrator.py b/cortex/routers/orchestrator.py index 555673c..b3f77c3 100644 --- a/cortex/routers/orchestrator.py +++ b/cortex/routers/orchestrator.py @@ -59,6 +59,7 @@ class JobStatusResponse(BaseModel): task: str created_at: str completed_at: str | None = None + session_id: str | None = None response: str | None = None tool_calls: list[dict] | None = None backend: str | None = None @@ -129,6 +130,8 @@ async def _run_job(job_id: str, req: OrchestrateRequest) -> None: _jobs[job_id]["status"] = "running" try: + from session_store import load as load_session, save as save_session, generate_session_id + # Load Inara's system prompt (same as the chat router does) tier = req.tier or settings.default_tier system_prompt = load_context( @@ -139,10 +142,9 @@ async def _run_job(job_id: str, req: OrchestrateRequest) -> None: ) # Load session history if a session_id was provided - session_messages: list[dict] | None = None - if req.session_id: - from session_store import load as load_session - session_messages = load_session(req.session_id) or None + session_id = req.session_id or generate_session_id() + history = load_session(session_id) + session_messages = history or None result = await orchestrator_engine.run( task=req.task, @@ -151,11 +153,20 @@ async def _run_job(job_id: str, req: OrchestrateRequest) -> None: respond_with_claude=req.respond_with_claude, ) + # Save the turn to the session store so it survives a page refresh + history.append({"role": "user", "content": req.task}) + history.append({"role": "assistant", "content": result.response}) + save_session(session_id, history) + + from session_logger import log_turn + log_turn(session_id, req.task, result.response) + now = datetime.now(timezone.utc).isoformat() async with _jobs_lock: _jobs[job_id].update({ "status": "complete", "completed_at": now, + "session_id": session_id, "response": result.response, "tool_calls": result.tool_calls, "backend": result.backend, diff --git a/cortex/static/app.js b/cortex/static/app.js index 0c4a5d6..886f805 100644 --- a/cortex/static/app.js +++ b/cortex/static/app.js @@ -651,7 +651,8 @@ activeController = new AbortController(); - addMessage('user', text); + currentHistory.push({ role: 'user', content: text }); + const userMsgDiv = addMessage('user', text); scrollToBottom(); const thinkingDiv = addMessage('assistant thinking', '⚡ working…'); @@ -701,8 +702,20 @@ if (job.status === 'error') throw new Error(job.error || 'Orchestrator failed'); + // Update session so this turn is part of the resumable history + if (job.session_id) { + sessionId = job.session_id; + sessionEl.textContent = `session: ${sessionId}`; + } + + const userHistIdx = currentHistory.length - 1; // pushed before fetch + attachHistoryControls(userMsgDiv, userHistIdx); + thinkingDiv.className = 'message assistant'; setMessageText(thinkingDiv, 'assistant', job.response || '(no response)'); + const assistHistIdx = currentHistory.length; + currentHistory.push({ role: 'assistant', content: job.response || '' }); + attachHistoryControls(thinkingDiv, assistHistIdx); const n = job.tool_calls?.length || 0; if (n) {