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:
WHC
2026-10-11 19:26:36 +08:00
parent 2ffe74b37c
commit 042d7f6999
15 changed files with 699 additions and 67 deletions
+137 -23
View File
@@ -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,