v0.9.7: a turn that cannot hang, a session list that reads nothing twice, and PDFs
- consent: on_permission / on_ask_user wait at most two / five minutes, and in
the background (a phone off screen, a window that is gone) the answer is
refused at once with a reason -- a dialog nobody can see blocked the worker
for good, which is what "the app hangs while the agent works" looked like.
- finishTurn() no longer joins the thread it is called from; the event loop
reaps the Turn instead.
- AndroidTools: the hop to the GUI thread pings first and gives up after 3 s
instead of waiting forever on a busy GUI thread.
- the session list stopped reading every transcript: cst_agent_store_info()
counts newlines in 64 KiB blocks (first_user only within the first 256 KiB),
and AgentCore::sessions() reuses a row while (mtime, size) is unchanged.
- session_search gets a budget (max_bytes 32 MB, max_seconds 5 s), skips lines
over 64 KiB, and says when the budget stopped the walk.
- the header shows the phase ("shell: make -j8"), so a long turn no longer
looks like a stuck one.
- md_to_pdf: the front end's own Qt tool (owner app:qt, every platform):
markdown or a workspace .md/.html becomes an A4 PDF with 15 mm margins,
paginated by hand, written into the workspace and delivered as a file card.
No PrintSupport, so the phone gets it too.
- --uitest runs md_to_pdf through the registry with Chinese text and checks the
bytes are a PDF; app/CMakeLists.txt no longer drops src/web_md.c from the
standalone engine build, which the window needs to render markdown.
Version 0.9.7.
This commit is contained in:
+137
-23
@@ -189,6 +189,12 @@ void AgentCore::close() {
|
||||
/* --- turns --------------------------------------------------------------- */
|
||||
|
||||
namespace {
|
||||
/* How long the worker waits for a person. Long enough to read a file diff and
|
||||
* decide, short enough that a missed dialog (or a screen that went dark) does
|
||||
* not leave the turn waiting for good. */
|
||||
constexpr int kConsentTimeoutMs = 2 * 60 * 1000;
|
||||
constexpr int kQuestionTimeoutMs = 5 * 60 * 1000;
|
||||
|
||||
/* The first line of a prompt, cut short: the line the phone's "working"
|
||||
* notification carries while the turn runs (app::holdForeground). */
|
||||
QString firstLine(const QString &s) {
|
||||
@@ -224,9 +230,28 @@ void AgentCore::cancel() {
|
||||
cst_agent_request_cancel(m_agent);
|
||||
}
|
||||
|
||||
/* Qt suspends the application when the phone puts it away, and a desktop
|
||||
* window can be gone too (--uitest runs headless). Either way a consent
|
||||
* dialog would be invisible. */
|
||||
bool AgentCore::backgrounded() const {
|
||||
QGuiApplication *gui =
|
||||
qobject_cast<QGuiApplication *>(QCoreApplication::instance());
|
||||
if (!gui)
|
||||
return false;
|
||||
if (gui->applicationState() == Qt::ApplicationSuspended)
|
||||
return true;
|
||||
return gui->allWindows().isEmpty() && gui->topLevelWindows().isEmpty();
|
||||
}
|
||||
|
||||
void AgentCore::finishTurn(int rc, const QString &err) {
|
||||
Turn *t = static_cast<Turn *>(sender());
|
||||
if (t) {
|
||||
/* run() is the caller: waiting for the thread here would be waiting for
|
||||
* this very call to return. Let the event loop reap it instead. */
|
||||
connect(t, &QThread::finished, t, &QObject::deleteLater);
|
||||
}
|
||||
post([this, rc, err] {
|
||||
m_turn = nullptr;
|
||||
setRunning(false);
|
||||
app::releaseForeground(); /* the turn is over: let the phone sleep again */
|
||||
/* The prompt carried whatever was pending; the engine dropped it. */
|
||||
@@ -234,11 +259,6 @@ void AgentCore::finishTurn(int rc, const QString &err) {
|
||||
emit pendingChanged();
|
||||
emit turnFinished(rc, err);
|
||||
});
|
||||
if (t) {
|
||||
t->wait();
|
||||
t->deleteLater();
|
||||
}
|
||||
m_turn = nullptr;
|
||||
}
|
||||
|
||||
void AgentCore::setRunning(bool on) {
|
||||
@@ -275,16 +295,43 @@ int AgentCore::hookPermission(const char *tool, const char *detail,
|
||||
return CST_PERM_DENY;
|
||||
if (self->m_autoApprove)
|
||||
return CST_PERM_ONCE;
|
||||
const QString t = QString::fromUtf8(tool ? tool : "");
|
||||
/* The turn survives the app leaving the screen (v0.9.4), so a question
|
||||
* asked there would block the worker on a dialog nobody can see -- which is
|
||||
* what "the app hangs while the agent works" looks like. In the background
|
||||
* the answer is no, with a line the person finds in the transcript. */
|
||||
if (self->backgrounded()) {
|
||||
self->post([self, t] {
|
||||
emit self->permissionExpired(t);
|
||||
emit self->statusChanged(
|
||||
QStringLiteral("%1: refused — the app was in the background").arg(t));
|
||||
});
|
||||
return CST_PERM_DENY;
|
||||
}
|
||||
QMutexLocker lock(&self->m_askMu);
|
||||
self->m_permAnswer = CST_PERM_DENY;
|
||||
self->m_permPending = true;
|
||||
const QString t = QString::fromUtf8(tool ? tool : "");
|
||||
const QString d = QString::fromUtf8(detail ? detail : "");
|
||||
const QString p = QString::fromUtf8(preview ? preview : "");
|
||||
self->post([self, t, d, p] { emit self->permissionRequested(t, d, p); });
|
||||
while (self->m_permPending && !self->m_closing)
|
||||
self->m_askCv.wait(&self->m_askMu);
|
||||
/* And even in the foreground nobody may hang forever: a dialog can be
|
||||
* missed, a screen can go dark. Two minutes, then the answer is no. */
|
||||
bool expired = false;
|
||||
while (self->m_permPending && !self->m_closing) {
|
||||
if (!self->m_askCv.wait(&self->m_askMu, kConsentTimeoutMs)) {
|
||||
expired = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
self->m_permPending = false;
|
||||
if (expired) {
|
||||
self->post([self, t] {
|
||||
emit self->permissionExpired(t);
|
||||
emit self->statusChanged(
|
||||
QStringLiteral("%1: no answer for two minutes — refused").arg(t));
|
||||
});
|
||||
return CST_PERM_DENY;
|
||||
}
|
||||
return self->m_permAnswer;
|
||||
}
|
||||
|
||||
@@ -295,16 +342,40 @@ int AgentCore::hookAskUser(const char *question, const char *options, char *ans,
|
||||
return -1;
|
||||
if (self->m_autoApprove)
|
||||
return -1;
|
||||
if (self->backgrounded()) {
|
||||
self->post([self] {
|
||||
emit self->questionExpired();
|
||||
emit self->statusChanged(
|
||||
QStringLiteral("the question was dropped — the app was in the "
|
||||
"background"));
|
||||
});
|
||||
return -1;
|
||||
}
|
||||
QMutexLocker lock(&self->m_askMu);
|
||||
self->m_questionPending = true;
|
||||
self->m_answer.clear();
|
||||
const QString q = QString::fromUtf8(question ? question : "");
|
||||
const QString o = QString::fromUtf8(options ? options : "");
|
||||
self->post([self, q, o] { emit self->questionAsked(q, o); });
|
||||
while (self->m_questionPending && !self->m_closing)
|
||||
self->m_askCv.wait(&self->m_askMu);
|
||||
bool expired = false;
|
||||
while (self->m_questionPending && !self->m_closing) {
|
||||
if (!self->m_askCv.wait(&self->m_askMu, kQuestionTimeoutMs)) {
|
||||
expired = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (self->m_closing)
|
||||
return -1;
|
||||
self->m_questionPending = false;
|
||||
if (expired) {
|
||||
self->post([self] {
|
||||
emit self->questionExpired();
|
||||
emit self->statusChanged(
|
||||
QStringLiteral("no answer for five minutes — the question was "
|
||||
"dropped"));
|
||||
});
|
||||
return -1;
|
||||
}
|
||||
snprintf(ans, ans_sz, "%s", self->m_answer.constData());
|
||||
return 0;
|
||||
}
|
||||
@@ -530,29 +601,56 @@ QString AgentCore::shareArtifact(const QString &rel) {
|
||||
|
||||
QVariantList AgentCore::sessions() const {
|
||||
QVariantList out;
|
||||
if (m_workspace.isEmpty())
|
||||
if (m_workspace.isEmpty()) {
|
||||
m_sessionCache.clear();
|
||||
return out;
|
||||
}
|
||||
const QByteArray ws = m_workspace.toUtf8();
|
||||
const int kMax = 200;
|
||||
char(*ids)[128] = new char[kMax][128];
|
||||
const int n = cst_agent_store_list(ws.constData(), ids, kMax);
|
||||
const QString current = sessionId();
|
||||
QHash<QString, SessionCache> fresh;
|
||||
fresh.reserve(n);
|
||||
for (int i = 0; i < n; i++) {
|
||||
cst_session_info_t in;
|
||||
memset(&in, 0, sizeof(in));
|
||||
if (cst_agent_store_info(ws.constData(), ids[i], &in) != 0)
|
||||
continue;
|
||||
const QString id = utf8(in.id[0] ? in.id : ids[i]);
|
||||
QVariantMap m;
|
||||
m["id"] = id;
|
||||
m["title"] = utf8(in.title);
|
||||
m["firstUser"] = utf8(in.first_user);
|
||||
m["messages"] = in.messages;
|
||||
m["mtime"] = static_cast<qlonglong>(in.mtime) * 1000; /* ms, for JS Date */
|
||||
const QString id = utf8(ids[i]);
|
||||
/* Filling one row walks that session's whole transcript, and the sidebar
|
||||
* asks for the list on every store change: the transcript's own stat is
|
||||
* what tells "nothing happened to this one" from "it grew", so an
|
||||
* unchanged session costs a stat instead of a read. */
|
||||
char path[4096] = "";
|
||||
const bool have_path = cst_agent_store_path_for_id(
|
||||
ws.constData(), ids[i], path, sizeof(path)) == 0;
|
||||
const QFileInfo fi(have_path ? QString::fromUtf8(path) : QString());
|
||||
const qint64 mtime = fi.lastModified().toSecsSinceEpoch();
|
||||
const qint64 size = fi.size();
|
||||
SessionCache entry;
|
||||
const auto cached = m_sessionCache.constFind(id);
|
||||
if (cached != m_sessionCache.constEnd() && cached->mtime == mtime &&
|
||||
cached->size == size) {
|
||||
entry = *cached;
|
||||
} else {
|
||||
cst_session_info_t in;
|
||||
memset(&in, 0, sizeof(in));
|
||||
if (cst_agent_store_info(ws.constData(), ids[i], &in) != 0)
|
||||
continue;
|
||||
entry.mtime = mtime;
|
||||
entry.size = size;
|
||||
QVariantMap m;
|
||||
m["id"] = id;
|
||||
m["title"] = utf8(in.title);
|
||||
m["firstUser"] = utf8(in.first_user);
|
||||
m["messages"] = in.messages;
|
||||
m["mtime"] = static_cast<qlonglong>(in.mtime) * 1000; /* ms, for JS Date */
|
||||
entry.row = m;
|
||||
}
|
||||
QVariantMap m = entry.row;
|
||||
m["current"] = (id == current);
|
||||
out.append(m);
|
||||
fresh.insert(id, entry);
|
||||
}
|
||||
delete[] ids;
|
||||
m_sessionCache = fresh; /* and the ids that are gone are forgotten */
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -710,6 +808,9 @@ bool AgentCore::deleteSession(const QString &id) {
|
||||
bool AgentCore::renameSession(const QString &id, const QString &title) {
|
||||
if (id.isEmpty() || m_workspace.isEmpty())
|
||||
return false;
|
||||
/* A title is not in the transcript, so the cached rows would not notice the
|
||||
* rename: drop them and let the next list read them again. */
|
||||
m_sessionCache.clear();
|
||||
const QByteArray ws = m_workspace.toUtf8(), sid = id.toUtf8(),
|
||||
t = title.toUtf8();
|
||||
if (cst_agent_store_title_set(ws.constData(), sid.constData(), t.constData()) !=
|
||||
@@ -1138,7 +1239,20 @@ void AgentCore::hookToolEvent(const char *phase, const ctl_tool_call_t *call,
|
||||
const QString id = QString::fromUtf8(call && call->id ? call->id : "");
|
||||
const QString n = QString::fromUtf8(call ? call->name : "");
|
||||
const QString d = QString::fromUtf8(detail ? detail : "");
|
||||
self->post([self, p, id, n, d] { emit self->toolEvent(p, id, n, d); });
|
||||
self->post([self, p, id, n, d] {
|
||||
emit self->toolEvent(p, id, n, d);
|
||||
/* The header says what the turn is doing right now ("shell: make -j8"),
|
||||
* not just that something is happening: a long turn is otherwise
|
||||
* indistinguishable from a stuck one. */
|
||||
if (p == QLatin1String("done")) {
|
||||
emit self->statusChanged(QString());
|
||||
return;
|
||||
}
|
||||
QString what = d;
|
||||
if (what.size() > 80)
|
||||
what = what.left(79) + QStringLiteral("…");
|
||||
emit self->statusChanged(what.isEmpty() ? n : QStringLiteral("%1: %2").arg(n, what));
|
||||
});
|
||||
}
|
||||
|
||||
void AgentCore::hookToolResult(const ctl_tool_call_t *call, const char *out,
|
||||
|
||||
Reference in New Issue
Block a user