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
+61 -31
View File
@@ -444,38 +444,68 @@ int cst_agent_store_info(const char *workspace, const char *id,
FILE *fp = fopen(p, "rb");
if (!fp)
return 0;
size_t cap = 1 << 16;
char *line = malloc(cap);
int have_first = 0;
while (line && fgets(line, (int)cap, fp)) {
size_t n = strlen(line);
if (n == cap - 1 && line[n - 1] != '\n') {
/* very long line: skip the rest, it is not the first user line */
int ch;
while ((ch = fgetc(fp)) != EOF && ch != '\n')
;
info->messages++;
continue;
}
if (n <= 1)
continue;
info->messages++;
if (have_first)
continue;
cJSON *o = cJSON_Parse(line);
const cJSON *r = cJSON_GetObjectItemCaseSensitive(o, "role");
const cJSON *c = cJSON_GetObjectItemCaseSensitive(o, "content");
const cJSON *k = cJSON_GetObjectItemCaseSensitive(
cJSON_GetObjectItemCaseSensitive(o, "meta"), "kind");
int note = cJSON_IsString(k) && strcmp(k->valuestring, "note") == 0;
if (cJSON_IsString(r) && strcmp(r->valuestring, "user") == 0 && !note &&
cJSON_IsString(c) && c->valuestring[0]) {
clip_line(info->first_user, sizeof(info->first_user), c->valuestring);
have_first = 1;
}
cJSON_Delete(o);
/* Reading a transcript must stay cheap: the session list asks for this for
* every stored session, on the GUI thread, every time the store changes.
* So a line is only ever *parsed* while the first user message is still
* missing and only within the first kFirstUserBytes; everything else is
* counted by looking for newlines, which costs a memchr per block instead
* of a cJSON tree per line. */
enum { kBlock = 1 << 16, kFirstUserBytes = 1 << 18 };
char *buf = malloc(kBlock);
if (!buf) {
fclose(fp);
return 0;
}
free(line);
size_t have = 0; /* bytes in buf that belong to the current line */
size_t seen = 0; /* bytes already consumed by finished lines */
int slice = 0; /* the current line did not fit: its tail is dropped */
int want_first = 1; /* still looking for the first user message */
for (;;) {
size_t got = fread(buf + have, 1, kBlock - have, fp);
size_t total = have + got;
size_t at = 0;
for (;;) {
char *nl = memchr(buf + at, '\n', total - at);
if (!nl)
break;
const size_t len = (size_t)(nl - (buf + at));
if (len > 0)
info->messages++;
if (!slice && len > 0 && want_first && seen < kFirstUserBytes) {
*nl = 0; /* the parse reads a C string; the newline is ours */
cJSON *o = cJSON_Parse(buf + at);
const cJSON *r = cJSON_GetObjectItemCaseSensitive(o, "role");
const cJSON *c = cJSON_GetObjectItemCaseSensitive(o, "content");
const cJSON *k = cJSON_GetObjectItemCaseSensitive(
cJSON_GetObjectItemCaseSensitive(o, "meta"), "kind");
const int note = cJSON_IsString(k) && strcmp(k->valuestring, "note") == 0;
if (cJSON_IsString(r) && strcmp(r->valuestring, "user") == 0 && !note &&
cJSON_IsString(c) && c->valuestring[0]) {
clip_line(info->first_user, sizeof(info->first_user), c->valuestring);
want_first = 0;
}
cJSON_Delete(o);
}
seen += len + 1;
at += len + 1;
slice = 0;
}
have = total - at;
if (got == 0)
break; /* EOF */
if (have == kBlock) {
/* a line longer than one block: count it, drop its tail, keep going */
info->messages++;
seen += have;
have = 0;
slice = 1;
} else if (have > 0) {
memmove(buf, buf + at, have);
}
}
if (have > 0 && !slice)
info->messages++; /* the last line, without a newline */
free(buf);
fclose(fp);
return 0;
}