optimize session refresh with pubsub delta events

- Parse new session/idx format with all data in one read:
  name, id, paused, connected, cwd, backend, model, agents
- Handle delta events from eventwait instead of full refresh:
  - session.{sid}.agent.{aid}.state -> update cache, emit signal
  - Structural changes (new/kill/pause/resume) -> full refresh
- Remove per-agent statewait streams (eliminated N processes)
- Single eventwait stream handles all events via pubsub

This eliminates O(sessions * agents) synchronous reads per refresh,
replacing them with one read + incremental delta updates.
This commit is contained in:
Levi Neely 2026-08-03 19:17:40 +02:00
parent eedee30332
commit 8e8466d4ef
2 changed files with 75 additions and 105 deletions

View File

@ -84,15 +84,13 @@ Ollie9pClient::Ollie9pClient(QObject *parent)
refreshSessions(); refreshSessions();
ensureRootDataLoaded(); ensureRootDataLoaded();
startAgentConnections(); startAgentConnections();
reconcileAgentStateStreams();
}); });
connect(m_daemon, &NinePConnection::disconnected, this, [this]() { connect(m_daemon, &NinePConnection::disconnected, this, [this]() {
setDaemonConnected(false); setDaemonConnected(false);
stopAgentConnections(); stopAgentConnections();
stopAgentStateStreams();
}); });
connect(m_daemon, &NinePConnection::readyRead, this, [this](const QByteArray &) { connect(m_daemon, &NinePConnection::readyRead, this, [this](const QByteArray &data) {
refreshSessions(); handleEvent(QString::fromUtf8(data).trimmed());
}); });
refreshSessions(); refreshSessions();
@ -288,63 +286,52 @@ void Ollie9pClient::stopAgentConnections()
m_agentConnections.clear(); m_agentConnections.clear();
} }
void Ollie9pClient::reconcileAgentStateStreams() // handleEvent processes a single event from eventwait.
// Event format: "topic [payload]"
// Topics: session.{sid}.{action}, session.{sid}.agent.{aid}.{action}
void Ollie9pClient::handleEvent(const QString &eventLine)
{ {
if (!m_daemonConnected) return; if (eventLine.isEmpty()) return;
QSet<QString> wanted;
for (const QVariant &value : std::as_const(m_sessions)) { // Split into topic and payload
const QVariantMap session = value.toMap(); int spaceIdx = eventLine.indexOf(' ');
const QString sid = session.value("id").toString(); QString topic = spaceIdx > 0 ? eventLine.left(spaceIdx) : eventLine;
const bool paused = session.value("paused").toBool(); QString payload = spaceIdx > 0 ? eventLine.mid(spaceIdx + 1) : QString();
for (const QVariant &agentValue : session.value("agents").toList()) {
const QVariantMap agent = agentValue.toMap(); QStringList parts = topic.split('.');
const QString aid = agent.value("id").toString(); // Expected: session.{sid}.* or session.{sid}.agent.{aid}.*
const QString key = agentKey(sid, aid);
wanted.insert(key); if (parts.size() >= 3 && parts[0] == "session") {
if (m_agentStateStreams.contains(key)) continue; QString sessionId = parts[1];
if (paused) continue; // Don't start streams for paused sessions QString action = parts[2];
auto *stream = new StreamFsm(StreamFsm::Looping, this);
m_agentStateStreams.insert(key, stream); if (action == "agent" && parts.size() >= 5) {
m_agentStateValues.insert(key, "idle"); // Agent event: session.{sid}.agent.{aid}.{action}
connect(stream, &StreamFsm::readyRead, this, [this, key, sid, aid](const QByteArray &data) { QString agentId = parts[3];
QString state = QString::fromUtf8(data).trimmed(); QString agentAction = parts[4];
if (!state.isEmpty() && m_agentStateValues.value(key) != state) { QString key = agentKey(sessionId, agentId);
m_agentStateValues[key] = state;
emit agentStateChanged(sid, aid, state); if (agentAction == "state") {
// Update cached state
if (!payload.isEmpty() && m_agentStateValues.value(key) != payload) {
m_agentStateValues[key] = payload;
emit agentStateChanged(sessionId, agentId, payload);
// Also update activeState if this is the active agent // Also update activeState if this is the active agent
if (sid == m_activeSessionId && aid == m_agentId) { if (sessionId == m_activeSessionId && agentId == m_agentId) {
m_activeState = state; m_activeState = payload;
emit activeStateChanged(); emit activeStateChanged();
} }
} }
}); } else if (agentAction == "new" || agentAction == "kill") {
stream->start(ollie9pBin(), {"-a", serverAddr(), "read", // Structural change — need full refresh
"session/" + sid + "/agent/" + aid + "/statewait"}); refreshSessions();
}
} else if (action == "pause" || action == "resume" ||
action == "new" || action == "kill" || action == "rename") {
// Session-level structural change — need full refresh
refreshSessions();
} }
} }
// Remove streams for agents that no longer exist
for (auto it = m_agentStateStreams.begin(); it != m_agentStateStreams.end();) {
if (wanted.contains(it.key())) {
++it;
continue;
}
it.value()->disconnect(this);
it.value()->stop();
it.value()->deleteLater();
m_agentStateValues.remove(it.key());
it = m_agentStateStreams.erase(it);
}
}
void Ollie9pClient::stopAgentStateStreams()
{
for (StreamFsm *stream : std::as_const(m_agentStateStreams)) {
stream->disconnect(this);
stream->stop();
}
qDeleteAll(m_agentStateStreams);
m_agentStateStreams.clear();
m_agentStateValues.clear();
} }
QString Ollie9pClient::agentState(const QString &sessionId, const QString &agentId) const QString Ollie9pClient::agentState(const QString &sessionId, const QString &agentId) const
@ -356,57 +343,46 @@ void Ollie9pClient::refreshSessions()
{ {
if (!m_daemonConnected) return; if (!m_daemonConnected) return;
QByteArray out = run9p({"read", "session/idx"}); QByteArray out = run9p({"read", "session/idx"});
// Don't trim — trailing tabs are significant fields
QString raw = QString::fromUtf8(out); QString raw = QString::fromUtf8(out);
m_sessions.clear(); m_sessions.clear();
QSet<QString> seen;
if (!raw.isEmpty()) { if (!raw.isEmpty()) {
for (const QString &line : raw.split('\n', Qt::SkipEmptyParts)) { for (const QString &line : raw.split('\n', Qt::SkipEmptyParts)) {
QStringList parts = line.split('\t'); QStringList parts = line.split('\t');
// Skip lines where the first field (session name) is empty // New format: name\tid\tpaused\tconnected\tcwd\tbackend\tmodel\tagents
if (parts.isEmpty() || parts[0].isEmpty()) continue; // Where agents is semicolon-separated: name:id:state;name:id:state
QString sid = parts[0]; if (parts.size() < 2) continue;
if (seen.contains(sid)) continue; // already added (multi-agent)
seen.insert(sid); QVariantMap session;
QVariantMap session; session["name"] = parts.value(0);
// session/idx format: name\tstate\tcwd\tbackend\tmodel\tagentName\tid session["id"] = parts.value(1);
// The immutable ID is in field 6 (0-indexed), avoiding a round-trip. session["paused"] = (parts.value(2) == "true");
QString sessionId = parts.size() > 6 ? parts[6] : ""; session["connected"] = (parts.value(3) == "true");
if (sessionId.isEmpty()) sessionId = sid; session["cwd"] = parts.value(4);
session["id"] = sessionId; session["backend"] = parts.value(5);
session["name"] = sid; session["model"] = parts.value(6);
session["state"] = parts.size() > 1 ? parts[1] : "";
session["cwd"] = parts.size() > 2 ? parts[2] : ""; // Parse agents from semicolon-separated list
session["backend"] = parts.size() > 3 ? parts[3] : ""; QVariantList agents;
session["model"] = parts.size() > 4 ? parts[4] : ""; QString agentStr = parts.value(7);
// Check if session is paused if (!agentStr.isEmpty()) {
QString pausedStr = QString::fromUtf8(run9p({"read", "session/" + sessionId + "/paused"})).trimmed(); for (const QString &agentEntry : agentStr.split(';', Qt::SkipEmptyParts)) {
session["paused"] = (pausedStr == "true"); QStringList agentParts = agentEntry.split(':');
// Check RPC connection status if (agentParts.size() >= 3) {
QString connectedStr = QString::fromUtf8(run9p({"read", "session/" + sessionId + "/connected"})).trimmed(); QVariantMap av;
session["connected"] = (connectedStr == "true"); av["name"] = agentParts.value(0);
QVariantList agents; av["id"] = agentParts.value(1);
for (const QString &name : QString::fromUtf8(run9p({"ls", "session/" + sessionId + "/agent"})).trimmed().split('\n', Qt::SkipEmptyParts)) { av["state"] = agentParts.value(2);
if (name == "new") continue; av["connected"] = session["connected"]; // Agent inherits session connectivity
const QString path = "session/" + sessionId + "/agent/" + name;
AgentRecord a;
a.id = QString::fromUtf8(run9p({"read", path + "/id"})).trimmed();
a.name = name;
if (a.id.isEmpty()) a.id = name;
QString agentState = QString::fromUtf8(run9p({"read", path + "/state"})).trimmed();
if (agentState.isEmpty()) agentState = "idle";
QVariantMap av; av["id"] = a.id; av["name"] = a.name;
av["state"] = agentState;
av["connected"] = m_daemonConnected && m_agentConnected.value(agentKey(sessionId, a.id), false);
agents.append(av); agents.append(av);
} }
session["agents"] = agents; }
m_sessions.append(session); }
session["agents"] = agents;
m_sessions.append(session);
} }
} }
reconcileAgentConnections(); reconcileAgentConnections();
reconcileAgentStateStreams();
emit sessionsChanged(); emit sessionsChanged();
// Check if the currently active session/agent still exist // Check if the currently active session/agent still exist
@ -625,13 +601,9 @@ void Ollie9pClient::switchAgent(const QString &sessionId, const QString &agentId
stopStreams(); stopStreams();
m_activeSessionId = sessionId; m_activeSessionId = sessionId;
m_agentId = agentId; m_agentId = agentId;
m_activeState = "idle";
// Use cached state from live tracking if available, otherwise default to idle
// Read initial state immediately m_activeState = m_agentStateValues.value(agentKey(sessionId, agentId), "idle");
QByteArray stateOut = run9p({"read", agentPath() + "/state"});
QString stateStr = QString::fromUtf8(stateOut).trimmed();
if (!stateStr.isEmpty())
m_activeState = stateStr;
emit activeSessionIdChanged(); emit activeSessionIdChanged();
emit activeAgentIdChanged(); emit activeAgentIdChanged();

View File

@ -120,9 +120,8 @@ private:
void startAgentConnections(); void startAgentConnections();
void startActiveAgentStreams(); void startActiveAgentStreams();
void reconcileAgentConnections(); void reconcileAgentConnections();
void reconcileAgentStateStreams();
void stopAgentConnections(); void stopAgentConnections();
void stopAgentStateStreams(); void handleEvent(const QString &eventLine);
QVariantList m_sessions; QVariantList m_sessions;
QString m_activeSessionId; QString m_activeSessionId;
@ -143,8 +142,7 @@ private:
QSet<QString> m_seenAgentConnections; QSet<QString> m_seenAgentConnections;
NinePConnection *m_daemon = nullptr; NinePConnection *m_daemon = nullptr;
// Per-agent state tracking // Agent state cache — updated via delta events
QHash<QString, StreamFsm *> m_agentStateStreams;
QHash<QString, QString> m_agentStateValues; QHash<QString, QString> m_agentStateValues;
// Streaming FSMs — each wraps a QProcess lifecycle // Streaming FSMs — each wraps a QProcess lifecycle