Something went wrong. Try again.
The agentic engineering control plane for the posthuman future
Something went wrong. Try again.
21 kB · 558 lines
C++
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559#include "PanePool.h"
#include <QFileInfo>#include <QLatin1String>#include <QJsonObject>#include <QQmlEngine>#include <QQuickItem>#include <QTimer>#include <QUuid>
#include "AppModel.h"#include "DaemonLink.h"#include "EditorController.h"#include "ProcessScanner.h"#include "TerminalQuickItem.h"
PanePool* PanePool::s_instance = nullptr;
namespace {// Restart orchestration (PanePool::restartDaemon): the old daemon process// gets this long to disappear after the shutdown request before the// successor spawn is attempted anyway, and the exit probe runs this often.constexpr int kRestartExitTimeoutMs = 3000;constexpr int kRestartPollIntervalMs = 100;} // namespace
PanePool::PanePool(QObject* parent) : QObject(parent), m_restartPollTimer(new QTimer(this)), m_daemon(new DaemonLink(this)) { s_instance = this; // A newly created pane must attach its data connection promptly. // refreshStreaming() is idempotent (setStreaming no-ops when the state // already matches), so firing it per pane creation is cheap. This is a // pool self-connection: it must NOT depend on setParkHost having run // (connectDaemon() runs BEFORE AppModel.startup creates any pane, so // without this the attach trigger is whatever model signal happens to // fire afterwards). connect(this, &PanePool::paneCreated, this, &PanePool::refreshStreaming); // The stats page tracks the link's status too: every transition // (connecting/ready/unavailable) invalidates daemonStats(). Ready is also // when queued work proceeds, and Unavailable clears a stuck restart guard. connect(m_daemon, &DaemonLink::statusChanged, this, [this](DaemonLink::Status status) { if (status == DaemonLink::Status::Ready) { onDaemonReady(); } else if (status == DaemonLink::Status::Unavailable && m_restartConnecting) { // The reconnect this restart asked for failed (the link's own // watchdogs report Unavailable on spawn/handshake timeout). // Release the guard so the user can try again. qWarning() << "xenomorphic: daemon restart failed to reconnect"; m_restarting = false; m_restartConnecting = false; } emit statsChanged(); }); // Restart-exit poll (restartDaemon): a timer, never a nested event loop, // because restartDaemon runs from a QML button handler. m_restartPollTimer->setSingleShot(true); connect(m_restartPollTimer, &QTimer::timeout, this, &PanePool::pollRestartExit);}
PanePool::~PanePool() { if (s_instance == this) { s_instance = nullptr; }}
PanePool* PanePool::create(QQmlEngine* engine, QJSEngine* /*scriptEngine*/) { auto* pool = new PanePool(engine); pool->m_engine = engine; return pool;}
PanePool* PanePool::instance() { return s_instance; }
QString PanePool::createPaneId() const { // NOLINT(readability-convert-member-functions-to-static) - QML cannot invoke a static Q_INVOKABLE on the singleton; the method must stay a member. return QUuid::createUuid().toString(QUuid::WithoutBraces);}
QString PanePool::createPane(const QVariantMap& spawn) { return createPaneWithId(createPaneId(), spawn);}
QString PanePool::createPaneWithId(const QString& paneId, const QVariantMap& spawn) { // Create the TerminalQuickItem directly from C++. The QML component // approach (TerminalItem.qml) fails during early singleton init when // the module QRC isn't registered yet. Config bindings are set by the // TerminalPane wrapper after adoption. auto* term = new TerminalQuickItem; if (m_engine) { // Ensure JS ownership semantics are correct. QQmlEngine::setObjectOwnership(term, QQmlEngine::CppOwnership); }
// Apply spawn properties before the terminal's componentComplete fires. term->setWorkingDirectory(spawn.value(QStringLiteral("cwd")).toString()); term->setCommand(spawn.value(QStringLiteral("command")).toString()); term->setInitialCommands( spawn.value(QStringLiteral("initialCommands")).toStringList()); const QString initialTitle = spawn.value(QStringLiteral("title")).toString(); if (!initialTitle.isEmpty()) { term->setTitle(initialTitle); }
// Inject the daemon link + pane id and the restart spec BEFORE start(): // PtyClient::start folds them into the spawn spec it sends on /ctl. The // restart fields are PaneRecord shape-parity only (the quit-time restart // scan is dead code in phase 1). term->setDaemon(m_daemon, paneId); term->setRestartSpec(spawn.value(QStringLiteral("restart")).toBool(), spawn.value(QStringLiteral("restartArgv")).toStringList());
// A C++-new'd item skips the QQmlParserStatus lifecycle (componentComplete // fires only for QML-created objects), so drive the terminal startup // manually via start() — it runs the same ensureTerminal() path. term->start();
// Park on the hidden host until a wrapper adopts the item. if (m_parkHost) { term->setParentItem(m_parkHost); term->setVisible(false); }
connectItem(term, paneId);
PaneEntry entry; entry.item = term; entry.spawn = spawn; m_panes.insert(paneId, entry);
emit paneCreated(paneId); return paneId;}
QObject* PanePool::item(const QString& paneId) const { auto it = m_panes.constFind(paneId); if (it == m_panes.constEnd()) { return nullptr; } return it->item.data();}
void PanePool::setParkHost(QQuickItem* host) { m_parkHost = host; // Connect pool signals to AppModel (the only listener per contract). // The AppModel QML singleton always exists by the time this is called // (TerminalPage.qml wires parkHost in Component.onCompleted, after the // engine created the singletons). NEVER AppModel::create() here: a // C++-created, unparented second instance gets GC'd and s_instance // churns, leaving stale QPointer caches elsewhere. if (auto* model = AppModel::instance()) { connect(this, &PanePool::paneTitleChanged, model, &AppModel::paneTitleFromPool); connect(this, &PanePool::paneCwdChanged, model, &AppModel::paneCwdFromPool); connect(this, &PanePool::paneCloseRequested, model, &AppModel::paneCloseFromPool); // Model -> pool: any change to which panes are in the streaming set must // push the attach/detach state. The set is the active tab of the active // workspace, widened to the active tab of every workspace while a // preview surface is visible. refreshStreaming is idempotent, so coarse // signals are fine — setStreaming is a no-op for panes whose state // already matches. connect(model, &AppModel::activeTabChanged, this, &PanePool::refreshStreaming); connect(model, &AppModel::activeWorkspaceChanged, this, &PanePool::refreshStreaming); connect(model, &AppModel::structureChanged, this, &PanePool::refreshStreaming); connect(model, &AppModel::workspacesChanged, this, &PanePool::refreshStreaming); }}
void PanePool::destroyPane(const QString& paneId) { if (auto editorIt = m_editors.find(paneId); editorIt != m_editors.end()) { EditorController* controller = editorIt.value().data(); m_editors.erase(editorIt); if (controller != nullptr) { // Drop the view's document first: the wrapper may still hold the // controller when this runs, and a document call after the document // died must not touch it. controller->detach(); controller->deleteLater(); } } auto it = m_panes.find(paneId); if (it == m_panes.end()) { return; } // EXPLICIT kill BEFORE deleteLater: the item's destructor only detaches // (PtyClient::close() never kills), so this daemon request is the ONLY // path that terminates a shell. Fire-and-forget — destroyPane can run // during teardown, so no requestSync here. if (m_daemon->isReady()) { m_daemon->request(QJsonObject{{QLatin1String("op"), QLatin1String("kill")}, {QLatin1String("paneId"), paneId}}, {}); } else { qWarning() << "xenomorphic: daemon not ready; cannot kill pane" << paneId; } if (auto* term = it->item.data()) { term->deleteLater(); } m_panes.erase(it); emit paneDestroyed(paneId);}
void PanePool::connectDaemon() { // Non-blocking by contract: discovery, the daemon spawn, and the handshake // all complete on the GUI thread's event loop. Panes created in the // meantime queue their spawn inside PtyClient and start when the link // reports Ready (onDaemonReady pushes the streaming set at the same moment). // A nested loop here would run under QML handlers (this is called from // TerminalPage.qml's Component.onCompleted) and would deliver QML deferred // deletes while a handler is still on the stack, which Qt aborts on. m_daemon->connectAsync();}
QVariantMap PanePool::daemonStats() const { // Pure projection of the DaemonLink caches — no round trip. The QML page // re-reads this whenever statsChanged() fires (or on demand). const DaemonLink::Status st = m_daemon->status(); QString status; switch (st) { case DaemonLink::Status::Disconnected: status = QStringLiteral("disconnected"); break; case DaemonLink::Status::Connecting: status = QStringLiteral("connecting"); break; case DaemonLink::Status::Ready: status = QStringLiteral("ready"); break; case DaemonLink::Status::Unavailable: status = QStringLiteral("unavailable"); break; } QVariantMap stats; stats.insert(QStringLiteral("status"), status); stats.insert(QStringLiteral("pid"), m_daemon->daemonPid()); stats.insert(QStringLiteral("port"), m_daemon->port()); stats.insert(QStringLiteral("boot"), m_daemon->boot()); stats.insert(QStringLiteral("panes"), m_daemon->paneCount()); stats.insert(QStringLiteral("alivePanes"), m_daemon->alivePaneCount()); stats.insert(QStringLiteral("clients"), m_daemon->ctlClients()); return stats;}
void PanePool::refreshDaemonStats() { // Non-blocking `list`: the callback fires on the GUI thread when the reply // lands (or fails), which is exactly when statsChanged() is meaningful. m_daemon->refreshPanesAsync([this](bool) { emit statsChanged(); });}
void PanePool::restartDaemon() { // Fully asynchronous: no nested event loop runs here, because this is // called from a QML button handler and a loop there would deliver QML // deferred deletes (destroy()) while a handler is still on the stack, which // Qt aborts on. The respawn pass runs from onDaemonReady(). if (m_restarting) { qWarning() << "xenomorphic: daemon restart already in progress"; return; } m_restarting = true;
// 1) Ask the running daemon to shut down. A 0 return (nothing connected) // is fine: the exit poll finds no live process and the successor spawn // follows. m_daemon->requestShutdown();
// 2) Poll for the daemon PROCESS to die so the single-instance lock and the // /ctl listen socket are free before spawning a successor. On timeout // proceed anyway: connectAsync() surfaces any real failure. m_restartElapsed.start(); m_restartPollTimer->start(kRestartPollIntervalMs);}
void PanePool::pollRestartExit() { const bool stillAlive = m_daemon->daemonProcessAlive(); if (stillAlive && !m_restartElapsed.hasExpired(kRestartExitTimeoutMs)) { m_restartPollTimer->start(kRestartPollIntervalMs); return; } if (stillAlive) { qWarning() << "xenomorphic: daemon did not exit within" << kRestartExitTimeoutMs << "ms; attempting respawn anyway"; } // Discover-or-spawn + handshake, in the background. m_restarting stays set // until Ready (onDaemonReady runs the respawn pass) or a failed connect // (the status handler clears the guard). m_restartConnecting = true; m_daemon->connectAsync();}
void PanePool::onDaemonReady() { if (m_restarting) { // 4) Respawn every pooled pane against the fresh daemon. respawn() // re-queues the PTY spawn (same pane id, same spec); the items' vt/render // state is untouched, so the terminals keep showing their old content // until the new shell draws. m_restarting = false; m_restartConnecting = false; for (auto entryIt = m_panes.constBegin(); entryIt != m_panes.constEnd(); ++entryIt) { if (auto* term = entryIt->item.data()) { term->respawn(); } } } // Panes that asked to stream while the link was down attach now; this is // also the ordinary path, and setStreaming is idempotent. refreshStreaming();}
void PanePool::setPreviewStreaming(bool on) { if (m_previewStreaming == on) { return; } m_previewStreaming = on; refreshStreaming();}
void PanePool::refreshStreaming() { AppModel* model = AppModel::instance(); QStringList streamIds; if (model != nullptr) { streamIds = m_previewStreaming ? model->streamingPaneIds() : model->activePaneIds(); } for (auto entryIt = m_panes.constBegin(); entryIt != m_panes.constEnd(); ++entryIt) { if (auto* term = entryIt->item.data()) { term->setStreaming(streamIds.contains(entryIt.key())); } }}
QVariantMap PanePool::spawnSpec(const QString& paneId) const { auto it = m_panes.constFind(paneId); if (it == m_panes.constEnd()) { return {}; } QVariantMap spec = it->spawn; // Refresh cwd from the live terminal so session save captures the // current working directory, not just the spawn-time cwd. if (auto* term = it->item.data()) { spec.insert(QStringLiteral("cwd"), term->workingDirectory()); spec.insert(QStringLiteral("title"), term->title()); } return spec;}
// Quit-time restart scan. Reads the foreground process of every pane from// the DaemonLink pane-snapshot cache (keyed by paneId, populated from the// daemon that outlives the app's QML teardown), matches them against the// user's whitelist, and records the result on the MODEL (where// buildSessionRoot reads it) via AppModel::setPaneRestart. Q_INVOKABLE so// AppModel::buildSessionRoot can reach it through QMetaObject::invokeMethod// on the QPointer<QObject> m_pool, keeping models/ compile-time Quick-free.void PanePool::runRestartScan() { if (AppModel::instance() == nullptr) { return; } // Bounded refresh of the cached pane table; on timeout degrade to // whatever the cache still holds rather than hanging the quit. The // explicit 800ms bound (instead of the 1500ms default) keeps the // worst-case quit stall bounded: 800ms daemon `list` here + up to // 2000ms inside scanForeground's `ps -Ao` waitForFinished = ~2.8s // worst case, milliseconds in the common case. That is the deliberate // arithmetic — the blocking subprocess below is NOT unbounded. m_daemon->refreshPanes(800);
const QVariantList entries = AppModel::instance()->settings()->processRestartMatches(); if (entries.isEmpty()) { return; // scanForeground would return empty anyway; skip the ps run. }
// Enumerate the daemon's cached snapshots through the pane ids THIS pool // owns. Copy the values out immediately: pane() returns a pointer INTO // the link's internal hash and must not be retained. QVariantList pids; QHash<QString, QString> paneIdByPid; for (auto it = m_panes.constBegin(); it != m_panes.constEnd(); ++it) { const DaemonLink::PaneSnapshot* snap = m_daemon->pane(it.key()); if (snap == nullptr) { continue; } const qint64 pid = snap->foregroundPid > 0 ? snap->foregroundPid : snap->pid; if (pid <= 0) { continue; } pids.append(QVariant(pid)); paneIdByPid.insert(QString::number(pid), it.key()); } if (pids.isEmpty()) { return; }
const QVariantMap matched = ProcessScanner::scanForeground(pids, entries); for (auto mIt = matched.constBegin(); mIt != matched.constEnd(); ++mIt) { const QVariantMap entry = mIt.value().toMap(); if (!entry.value(QLatin1String("matched")).toBool()) { continue; } const QString paneId = paneIdByPid.value(mIt.key()); if (paneId.isEmpty()) { continue; } AppModel::instance()->setPaneRestart( paneId, true, entry.value(QLatin1String("argv")).toStringList()); }}
QString PanePool::paneIdOf(QObject* term) const { for (auto it = m_panes.constBegin(); it != m_panes.constEnd(); ++it) { if (it->item.data() == term) { return it.key(); } } return {};}
void PanePool::reparent(const QString& paneId, QQuickItem* newParent) { auto it = m_panes.find(paneId); if (it == m_panes.end()) { return; } if (auto* term = it->item.data()) { term->setParentItem(newParent); term->setVisible(newParent != nullptr); if (newParent == nullptr) { // The item just entered park limbo. A wrapper elsewhere in the // scene may still believe it owns this paneId (its `term` ref is // set); announce the orphan so it re-adopts. emit paneOrphaned(paneId); } }}
QString PanePool::spatialNeighborPaneId(const QString& fromPaneId, // NOLINT(readability-convert-member-functions-to-static) const QString& direction) const { if (fromPaneId.isEmpty()) { return {}; } AppModel* model = AppModel::instance(); if (model == nullptr) { return {}; } const int wsIndex = model->workspaceIndexOf(model->activeWorkspaceId()); const int tabIndex = model->tabIndexOf(model->activeWorkspaceId(), model->activeTabId()); if (wsIndex < 0 || tabIndex < 0) { return {}; } SplitContainerNode* tree = model->tabTree(wsIndex, tabIndex); if (tree == nullptr) { return {}; } // Model-native: the tree's normalized rects, so an editor or picker pane // is a neighbour like any terminal. return ::spatialNeighborPaneId(*tree, fromPaneId, direction);}
void PanePool::swapFocusedDirection(const QString& direction) const { AppModel* model = AppModel::instance(); if (model == nullptr) { return; } const QString fromId = model->focusedPaneId(); if (fromId.isEmpty()) { return; } const QString neighbor = spatialNeighborPaneId(fromId, direction); if (neighbor.isEmpty()) { return; } model->swapPanes(fromId, neighbor);}
EditorController* PanePool::editorControllerFor(const QString& paneId) { if (paneId.isEmpty()) { return nullptr; } if (EditorController* existing = m_editors.value(paneId).data()) { return existing; } auto* controller = new EditorController(this); m_editors.insert(paneId, controller); connect(controller, &EditorController::dirtyChanged, this, [this, paneId]() { AppModel* model = AppModel::instance(); if (model != nullptr) { EditorController* live = m_editors.value(paneId).data(); model->notePaneDirty(paneId, live != nullptr && live->dirty()); } refreshEditorTitle(paneId); }); AppModel* model = AppModel::instance(); if (model != nullptr) { const PaneRecord record = model->paneRecord(paneId); if (!record.editorPath.isEmpty() && controller->openFile(record.editorPath) && record.editorLine > 0) { controller->requestCursor(record.editorLine - 1, qMax(0, record.editorColumn - 1)); } } refreshEditorTitle(paneId); return controller;}
void PanePool::refreshEditorTitle(const QString& paneId) { EditorController* controller = m_editors.value(paneId).data(); if (controller == nullptr) { return; } AppModel* model = AppModel::instance(); if (model == nullptr) { return; } const QString name = QFileInfo(controller->filePath()).fileName(); model->notePaneTitle(paneId, controller->dirty() ? name + QStringLiteral(" •") : name);}
void PanePool::saveDirtyEditors(const QStringList& paneIds) { for (const QString& paneId : paneIds) { EditorController* controller = m_editors.value(paneId).data(); if (controller != nullptr && controller->dirty()) { controller->save(); } }}
void PanePool::connectItem(TerminalQuickItem* term, const QString& paneId) { connect(term, &TerminalQuickItem::titleChanged, this, [this, paneId]() { if (auto* t = m_panes.value(paneId).item.data()) { emit paneTitleChanged(paneId, t->title()); } }); connect(term, &TerminalQuickItem::workingDirectoryChanged, this, [this, paneId]() { if (auto* t = m_panes.value(paneId).item.data()) { emit paneCwdChanged(paneId, t->workingDirectory()); } }); connect(term, &TerminalQuickItem::closeRequested, this, [this, paneId]() { emit paneCloseRequested(paneId); });}