perf: track execution duration by state and bound summary I/O

This commit is contained in:
mesalogo
2026-10-04 16:21:55 +08:00
parent 2c2b6c31eb
commit 4d18794e74
31 changed files with 987 additions and 1397 deletions
+9 -17
View File
@@ -10,20 +10,11 @@ recovery. `SubagentProgressStorage` caches event encoding state, not task statis
## Runtime Read Design
Execution statistics use one lazily started worker with a read-only connection.
The worker performs the existing evidence query inside a short read transaction.
Main supplies the current reply lease IDs and receives only scalar summaries.
There is no worker pool or duplicate persisted summary. The worker is terminated
when its owning database closes; errors reject reads, without a synchronous Main
fallback. In-memory test databases use the synchronous query because independent
connections cannot access the same private in-memory database.
Derived caches must specify scope, capacity, lifetime and invalidation. Statistics
keep at most eight scopes for 30 seconds. SQLite `data_version` detects other
connections' commits; `total_changes()` detects writes on the owning connection.
Reads inside an existing transaction clear and bypass the cache, so rolled-back
values cannot escape. Closing the connection clears its cache. Live leases bypass
the cache because elapsed time changes without a database write.
Execution duration is persisted on running-state transitions in Main. Snapshots
read small indexed timing rows; they require no historical evidence query, worker
or result cache. Main owns current segments independently of renderer visibility.
The [duration design](../features/task-and-job/technical-design.md) defines the
state boundaries, restart handling and incomplete history.
Read transactions end before replies are posted. Long-lived read transactions
would prevent WAL checkpoints from reclaiming old pages. Each reader has its own
@@ -36,9 +27,10 @@ rules, without introducing a general query protocol before a second use case exi
Hidden task panels use React `Activity` to preserve state while scheduling hidden
render work below visible updates. Panel factories run inside that boundary.
Terminal and browser panels retain their existing mounted effects and sessions.
Statistics refresh only when the task center and document are visible. Results
must remain scoped and late responses from a previous selection must be discarded.
Duration snapshots refresh on Main change notifications while the task center is
open. The renderer ticks locally for open segments and fetches fresh snapshots on
reopening. Results remain scoped; superseded responses are discarded.
The [task statistics design](../features/task-and-job/technical-design.md) owns the
query contract and evidence rules. Tests and timings demonstrate only their fixture
snapshot contract and timing rules. Tests and timings demonstrate only their fixture
conditions; they do not establish a latency guarantee for arbitrary retained history.
+3 -3
View File
@@ -9,9 +9,9 @@
3. [Scheduled Task PRD](./scheduled-task-prd.md):时间或事件触发的 Task。
4. [Goal Task PRD](./goal-task-prd.md):围绕可验证结果有界推进的 Task。
5. [Job 与 Subjob PRD](./job-and-subjob-prd.md):Task 内部串行、并行和委派执行。
6. [技术设计](./technical-design.md):统计查询契约、请求证据与不完整数据边界,以及立即创建的事务和入队规则。
6. [技术设计](./technical-design.md):运行状态计时、快照订阅与不完整历史边界,以及立即创建的事务和入队规则。
统计读取与共享存储、worker 生命周期和 Renderer 调度的通用约束见[运行时存储架构](../../architecture/runtime-storage.md)。
共享存储、后台读取和 Renderer 调度的通用约束见[运行时存储架构](../../architecture/runtime-storage.md)。
正式功能范围固定为当前项目:顶部显示当前会话消息总数和 Agent 累计回复时间,下方列出本项目全部会话的任务。创建默认立即执行并关联当前会话,可选择本项目其他会话、新会话或定时周期;产品与验收规则见 Task Center PRD 和 Scheduled Task PRD。
@@ -20,7 +20,7 @@
[当前会话统计 Demo](./hierarchy-demo.html) 用于评审任务中心中的紧凑统计区:上方显示当前会话的消息数与 Agent 累计回复时长,下方管理当前项目全部会话的任务及用时。切换会话只更新会话统计,任务范围固定为当前项目,没有全局或所有项目入口。支持状态筛选、定位所属会话,以及暂停、继续和取消任务的模拟操作。新建任务支持填写内容、关联本项目已有或新建会话、立即或定时执行;仅在页面内模拟,刷新后恢复样例。
配套 `hierarchy-demo.css` 与 `hierarchy-demo.js` 仅服务于该静态原型;使用演示数据,未接入产品。
应用中的项目任务标题右侧仅保留 `+` 新建按钮;展开列表后直接显示状态选项,默认选中“进行中”,收起后保留所选状态。静态 Demo 的筛选入口仍为旧交互。
时间为固定样例,Demo 从每次回复的起止秒数计算。正式功能按请求有效区间累计,包含轮内工具和审批等待、不计轮间空闲;任务用时按 `schedule_runs` 关联请求汇总,不按父任务生命周期计算。缺失历史有提示,远程缺少可靠证据时不推算用时,数据来源与边界以技术设计为准。
时间为固定样例,Demo 从每次回复的起止秒数计算。正式功能由 Main 累计回复请求处于 `running` 状态的时间,暂停、等待审批和轮间空闲不计时;任务用时汇总到所属任务卡。旧历史和中断时未闭合的区间标为不完整,不查询历史消息重建。远程请求只统计桌面观察到的运行区间,断连期间的用时未知;具体规则见技术设计。
先阅读统一领域模型。其他功能文档不得重新定义 Task、Conversation、Job、Run 或 Subagent。
若实现与文档出现冲突,应先修正统一模型,再同步功能 PRD。
@@ -64,10 +64,11 @@ FR-TC1:当前项目是唯一范围,列表只显示 `projectId` 匹配的 Tas
### 4.1 当前会话统计
FR-TC2:顶部紧凑展示当前会话消息总数与 Agent 累计回复时间。累计时间按每轮请求的有效
执行区间相加,包含轮内工具执行和审批等待,不计轮间空闲;不重复累加子专家用时。
缺失历史或时间证据不完整时提示“部分历史用时不可用”,已知用时不能表示为完整总量。
远程执行缺少可靠时间证据时显示不可用或不完整,不用事件接收时间虚构远程用时。
FR-TC2:顶部紧凑展示当前会话消息总数与 Agent 累计回复时间。Main 累计每轮请求处于
`running` 状态的时间,包含该状态下的工具执行;暂停、等待审批和轮间空闲不计时,
子专家用时不重复累加。界面根据 Main 快照本地计时,切换会话或隐藏面板不影响累计。
旧历史和中断时未闭合的区间提示“部分历史用时不可用”,不扫描历史消息重建时间。
远程请求统计桌面观察到的运行区间,断连期间的用时标为不完整。
没有当前会话时提示选择会话,项目任务列表仍保留。
项目名称放在当前会话统计卡片内部的顶部。
@@ -156,7 +157,7 @@ FR-TC3:任务累计执行时间按其历次执行请求汇总,周期触发
- [ ] 删除 Task 默认保留 Conversation 和既有消息。
- [x] Smart Heartbeat 不进入 Task Center。
- [ ] FR-TC1:仅显示当前项目任务,切换会话不缩小列表;无项目时显示选择项目提示。
- [ ] FR-TC2:消息总数与累计回复时间随当前会话更新;轮内工具和审批计时,轮间空闲不计时,历史缺失及远程证据不足有提示。
- [ ] FR-TC2:消息总数与累计回复时间随当前会话更新;仅运行状态计时,暂停、等待审批和轮间空闲不计时,旧历史与中断区间有不完整提示。
- [ ] FR-TC3:多次执行汇总到同一任务,不按父 Task 生命周期计算用时。
- [x] FR-TC4:标题右侧仅保留新建;展开列表直接显示状态选项,默认进行中,收起后保留所选条件。
- [ ] FR-TC5:创建默认立即执行和当前会话,可选择本项目其他会话、新会话及定时周期,空名称从内容生成。
+71 -122
View File
@@ -1,147 +1,95 @@
# Execution Statistics Query
# Execution Duration
## Runtime Read Path
## Running State
WAL、连接所有权、内存缓存和后台读取的通用规则见
[运行时存储架构](../../architecture/runtime-storage.md)。
Main owns reply timing independently of the visible conversation or task panel.
Acquiring a reply lease starts a segment after the request task has been created.
Manual context compression uses a maintenance lease and creates no clock. Nested
subagents and stable schedule parents are excluded; their work is already inside
the top-level reply interval. Background and channel requests use the same lease.
`tasks:execution-stats` 在 Main 校验来源、范围和当前回复租约,然后经
`AssistantDatabase.getExecutionStatsAsync` 调用独立的 `execution-stats-worker`。
worker 使用只读连接和短读事务执行下文查询,不运行数据库初始化或任务恢复。
查询错误返回调用方,关闭数据库时终止 worker 并拒绝未完成请求。
Task transitions away from `running` close the segment, including `paused`,
`queued`, `waiting_approval`, completion, failure and cancellation. Resuming a
leased request opens another segment. Repeated `running` notifications leave the
original segment intact. Abort closes timing immediately, even when runtime
cleanup is still pending. Releasing a lease prevents subsequent task updates from
restarting its clock. Concurrent replies contribute their durations independently.
worker 内按项目或会话范围缓存结果,最多 8 项、30 秒;每次读取先检查 SQLite 连接版本。
活动回复绕过缓存,事务内读取清空并绕过缓存,关闭连接清空缓存。写入提交后的下一次读取
会重新计算,覆盖本地消息、远程事件、任务删除和恢复。统计数据仍以查询快照时刻为准。
Remote requests measure Main's observed running state. The existing committed
terminal checkpoint closes their timing; event persistence, replay deduplication
and Agent ACK order are unchanged. Disconnection/recovery gaps are incomplete.
This duration does not claim to measure autonomous execution while the desktop
is disconnected. Application exit closes observed detached remote intervals
without cancelling the remote operation and marks the remaining duration unknown.
Renderer 按会话及所属项目、项目分别保留内存统计快照,切回已查询范围时同步显示缓存,
同时后台刷新;首次打开未缓存范围仍等待查询,缓存不跨应用重启保留,也不在前端推算时长。
保留可见时 5 秒刷新以及 revision、focus、visibility 触发刷新;失败的范围清除缓存并显示
不可用,旧范围的迟到结果丢弃。重复缓存结果保留对象引用,更新通过 `startTransition`
提交,避免无变化的轮询反复更新 App 的任务时长映射。任务面板使用 React `Activity`
保留状态并降低隐藏时的渲染优先级;面板工厂在该边界内部调用。浏览器和终端沿用原有挂载
与 effect 生命周期。
## Storage
Schema 59 adds `execution_timing`: one row per timed request with its project,
conversation, task-card owner, accumulated milliseconds, optional running start,
and incomplete flag. Scope indexes keep snapshots separate from large task and
message payloads. A schedule run copies the stable task association already
stored in `parent_task_id`; repeated runs aggregate under that card. Task deletion
cascades to its timing row.
Only state transitions write timing. Tokens, tools, display ticks and snapshot
reads do not update it. Startup drops any unclosed segment and marks it incomplete,
retaining previously accumulated time. It never counts application downtime or
uses recovery timestamps as execution endpoints.
Migration marks existing conversations and visible task cards incomplete without
reconstructing their past duration. Imported legacy conversation snapshots are
also incomplete. No timing query reads historical messages, tools or task events.
The same migration adds
`messages_summary_idx ON messages(conversation_id, sequence, role, created_at)`
for conversation summaries. It performs no history conversion or `VACUUM`.
## Contract
`window.goodbuddy.tasks.getExecutionStats(input)` invokes `tasks:execution-stats`.
`ExecutionStatsInput` accepts exactly one UUID scope: `{ conversationId }` or
`{ projectId }`. Main validates the scope and trusted sender. The database returns:
Input accepts exactly one UUID scope: `{ conversationId }` or `{ projectId }`.
Main validates the scope and trusted sender, then reads the small timing rows
synchronously. The old evidence query, cache, reader and statistics worker are
removed.
```ts
interface ExecutionStats {
durationMs: number
requestCount: number
incompleteRequestCount: number
activeRequestCount: number
asOf: number // epoch milliseconds at query time
durationMs: number // closed segments plus open segments through asOf
runningCount: number // open segments in this scope
incomplete: boolean
asOf: number // Main epoch milliseconds
taskDurations: Array<{
id: string
durationMs: number
incompleteRequestCount: number
runningCount: number
incomplete: boolean
}>
}
```
The project scope sums request evidence across its conversations and returns card
totals in `taskDurations`. A scheduled execution uses `requestId = schedule_runs.id`;
join its `schedule_id` to the stable task's `schedule_id` to aggregate repeated runs.
Request creation also saves that proven relation in `parent_task_id`. Before plan
deletion cascades its run records, bind older requests that still lack this link.
Statistics can then use the retained link to a visible `origin = schedule` parent,
even after the parent's `schedule_id` has been cleared. A shared conversation alone
does not establish ownership. Other
identified requests retain their own task ID. Visible tasks with no executions
have a zero entry. Conversation queries return an empty `taskDurations` array.
Message counts used to detect missing history stay in Main; no bodies are returned.
Conversation snapshots have an empty `taskDurations` array. Historical request
counts are no longer part of this API.
## Evidence And Boundaries
`onExecutionStatsChanged(listener)` returns an unsubscribe function. Main sends
`tasks:execution-stats-changed` after timing transitions or history deletion;
synchronous changes are coalesced into one notification. The renderer subscribes
before fetching its current conversation and project snapshots and refreshes on
notifications. It discards superseded responses and clears failed scopes.
- Query all retained scoped task records, including hidden chat requests, without
the visible-task list's pagination. Require a persisted message `request_id`
link, a reply event (`text`, `reasoning`, `tool`, `done`, `error`, `subagent`,
`generated-image`, or `question`) associated by `task_events.task_id`, a schedule
run association, or a current reply lease. Local message snapshots do not persist
request links, so local replies normally use event membership and kind. Do not
parse each stream/tool payload merely to repeat its owning request ID.
- Runtime status events also prove a reply when `requestId` matches the owning
task, `type` is `status`, and `message` is a string. Maintenance status events
such as `{ status: 'running' }` do not. Status-only interrupted replies retain
their last evidenced interval just like text/tool replies.
- Exclude stable schedule parents (`schedule_id` is set) and expert tasks
(`origin = subagent`). Scheduled reply requests remain eligible. Nested expert
execution is already inside the parent's elapsed interval and is not added again.
- Maintenance-only records do not establish reply identity. This excludes manual
context compression, heartbeat summaries, and unexecuted suggestions. A record
with no reply identity evidence cannot be classified; it is not counted.
- Sum intervals from `started_at` or later `running` status events to terminal
events/statuses. Pause/queue closes an interval; a later run starts another.
Repeated approval/running transitions do not reset the start. Tools and approval
waits inside a reply are included; idle gaps between replies/runs are excluded.
- `interrupted` may be written at application restart. Count only through the
preceding evidence, mark the request incomplete, and never use its recovery
`completed_at` as an execution endpoint. Missing or invalid timing also marks
the request incomplete; unknown time is not replaced with zero-length certainty.
- A non-aborted reply lease confirms current execution: an open local interval
with a reliable start contributes through `asOf` and remains complete while active.
Manual context compression leases are excluded.
Without a live lease, stop at the latest evidence and mark an open interval
incomplete. Renderer must query again after `asOf`; it does not extrapolate.
- Remote-recoverable records, tasks in persisted SSH project execution spaces,
and events with remote operation provenance are
incomplete and contribute no duration: their receipt timestamps cannot establish
remote execution intervals, particularly after replay. No Runtime, Agent, or
remote persistence behavior changes are required by this read-only query.
The SSH project check applies before the first provenance event, including
channel/delegation requests with `remote_recoverable = false`, so an unknown
remote duration is not first reported as complete local execution time.
- Completed, failed, or running schedule runs with no request history count as
incomplete, including in their stable task card. Pending runs do not count.
- Former schedule parents are never execution clocks, including after deletion.
A removed plan without retained execution ownership is reported as incomplete,
not a complete zero; its former lifetime cannot reconstruct missing runs.
- Retained top-level assistant messages establish missing-history evidence.
Explicit missing request links count as incomplete; unlinked snapshots use a
count comparison within each conversation/task, after covering linked replies.
Missing schedule runs already counted above cover their messages, avoiding a
second missing count. Expert-task messages and nested subagent content are not
counted again. A wholly missing reply contributes zero known duration and one
incomplete request, not a complete zero.
- Exclude the first local assistant message only when it has no request/task link
and exactly matches a shipped Chinese/English default greeting. Persisted
greetings have no separate marker. Other assistant-only history and identical
text later in the conversation remain eligible; do not discard all messages
before the first user turn.
- These counts are a lower bound when local snapshots have no request links;
fully deleted history cannot be reconstructed. `requestCount` includes replies
inferred from missing-history evidence. Concurrent top-level requests may
overlap in wall-clock time; their durations are summed independently.
The UI adds `max(0, now - asOf) * runningCount` locally once per second while an
open segment exists, including the corresponding per-card calculation. Hidden
panels unsubscribe and fetch fresh snapshots when reopened. There is no five-second
statistics polling, message-count revision trigger, or tick-time IPC. Main timing
continues while the panel is hidden or another conversation is selected.
The query materializes scoped task evidence once, then returns only status and
terminal events. Only status payloads use JSON extraction. Request membership and
remote provenance inspect scalar event columns; last-event and pre-interruption
timestamps use `(task_id, id)` index seeks. This preserves a tool/delta event as
the last reliable boundary without loading its body. Grouped assistant-message
counts read task metadata from message rows; event rows are not scanned for that count.
It adds no tables, columns, migration, or persisted timing totals. Stable task
lifecycle timestamps are never duration endpoints.
## Validation
## Validation Coverage
The focused execution-statistics tests cover hidden requests, scope isolation,
approval waits, repeated intervals, restart interruption, remote replay, missing
start times, and history beyond visible-task pagination. IPC tests cover sender
and scope validation; preload tests cover typed forwarding. Renderer integration
must supply `getExecutionStats` in its `DesktopApi.tasks` mocks.
The large-payload regression inspects the production query plan, measures paired
conversation/project polling on 3,000 events with approximately 94 MiB of payload,
and replaces stream/tool bodies with invalid JSON to prove they are not parsed.
Run it with `npx vitest run src/main/assistant/assistant-execution-stats.test.ts -t seeks --reporter=verbose --disableConsoleIntercept`
to print timings. These are synthetic warm-query measurements, not a guarantee
for arbitrarily large histories. Scalar event filtering and per-message metadata
work still grow with retained history; if that becomes measurable in deployment,
prefer polling only during active execution and refreshing on history/task changes.
`assistant-execution-stats.test.ts` exercises real SQLite state transitions,
restart interruption, remote checkpoints/replay, schedule ownership, scope
isolation, deletion, schema migration and read-only snapshots. It verifies that
snapshot SQL never reads messages or task events. IPC tests cover trusted scope
validation and production lease completion, failure and abort; preload tests cover
the subscription bridge. Hook tests verify local ticking without polling, hidden
panels, stale responses, and failed reads.
## Custom Task Creation
@@ -163,7 +111,8 @@ conversation queue pump. App does not follow creation with `runNow`. Execution
uses the existing readiness and conversation serialization rules. A runtime
failure belongs to the created task; it does not reject creation or create another
task. Refresh failures after a successful creation are notifications, not a reason
to retry creation. No schema migration or preload changes are required.
to retry creation. No schema migration or preload changes are required for this
creation command.
`schedule-creation.test.ts` verifies transaction rollback and timer non-duplication
with real SQLite. IPC tests exercise that database through the production queue
@@ -0,0 +1,67 @@
# Execution Timing and Summary I/O
## Decision
Duration means time spent in the running state, including model and tool work.
It is not a reconstruction of historical execution evidence. Main starts a
clock for a reply lease, pauses it when the task stops running, and closes it
when the lease ends. Maintenance requests and nested subagents do not open
another top-level clock. The renderer advances the displayed value locally.
Waiting for an answer or approval does not accrue time. Multiple pending
questions resume the clock only after the last one resolves. Recovered waiting
requests remain paused during reconnection. Startup preserves closed intervals,
discards unclosed intervals, and marks them incomplete; old history is not
backfilled by scanning messages or events.
Schema 59 stores timing rows and adds a covering index for conversation
summaries. The old historical aggregation, its caches, dedicated reader/worker,
worker build entries, and five-second statistics polling have been removed.
The preliminary materialized-CTE fix is superseded by this replacement.
## Summary Reads
Summary-only requests now read counts, first role, and latest message time from
`messages_summary_idx`, without loading message bodies or metadata. Full
histories require explicit detail IDs. IPC still includes recovered requests
whose pending questions must be restored. The renderer's existing retained
history requests and refresh/settlement rules are preserved.
## Measurement
Windows logical process I/O counters, not physical disk throughput. Measurements
used an isolated copy of `dist/GoodBuddy-windows-x64/data`; the source database
was not opened. Its largest message metadata was 4,283,685 characters. A large
message was marked streaming in the copy to exercise the former implicit-detail
path. Initialization, migration, and fixture preparation are outside samples.
| Operation | Before | After |
| --- | --- | --- |
| Project duration | One read: 1,224 MiB read, 740 MiB written, 2.7-4 s | Twenty reads: about 8 KiB read, no measured writes, about 1 ms total |
| Pure conversation summaries | One read: about 423 MiB, about 0.4 s | One read: 0.535 MiB, no measured writes, 9 ms |
These compare the old historical statistic with the intentionally simpler
state-based statistic. They do not establish that all live application I/O
comes from these paths. Requested full histories still incur their normal cost.
The installed running application was not replaced during this work.
Reproduce on Windows with a quiescent source data directory:
```powershell
$env:GB_WPERF_DATA = 'D:\my_git\goodbuddy\dist\GoodBuddy-windows-x64\data'
node build/run-write-io-perf.cjs
```
The script copies the database, upgrades the copy, probes production methods,
checks retained message contents, and deletes the temporary copy.
## Verification
- State-transition, interruption, recovery, question, and notification tests.
- Covering-query-plan and explicit full-history regression tests.
- Assistant database: 120 tests passed.
- Reader, persistence, timing hook, and preload: 22 tests passed.
- Focused App duration/focus/refresh/summary tests: 44 passed.
- Full App: 350 passed, 3 failed in project creation workflows (discard dialog,
create invocation, and create-error display). These remain unresolved.
- Production bundle passed; removed worker is absent from its entry points.