feat: add app management, local inference controls, and task statistics
This commit is contained in:
@@ -9,13 +9,22 @@
|
||||
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):统计查询契约、请求证据与不完整数据边界,以及立即创建的事务和入队规则。
|
||||
|
||||
正式功能范围固定为当前项目:顶部显示当前会话消息总数和 Agent 累计回复时间,下方列出本项目全部会话的任务。创建默认立即执行并关联当前会话,可选择本项目其他会话、新会话或定时周期;产品与验收规则见 Task Center PRD 和 Scheduled Task PRD。
|
||||
|
||||
## 阅读顺序
|
||||
|
||||
[当前会话统计 Demo](./hierarchy-demo.html) 用于评审任务中心中的紧凑统计区:上方显示当前会话的消息数与 Agent 累计回复时长,下方管理当前项目全部会话的任务及用时。切换会话只更新会话统计,任务范围固定为当前项目,没有全局或所有项目入口。支持状态筛选、定位所属会话,以及暂停、继续和取消任务的模拟操作。新建任务支持填写内容、关联本项目已有或新建会话、立即或定时执行;仅在页面内模拟,刷新后恢复样例。
|
||||
配套 `hierarchy-demo.css` 与 `hierarchy-demo.js` 仅服务于该静态原型;使用演示数据,未接入产品。
|
||||
项目任务标题右侧依次为 `+` 新建按钮与筛选图标,状态筛选默认收起;应用筛选后图标高亮并显示当前筛选条件。
|
||||
时间为固定样例,Demo 从每次回复的起止秒数计算。正式功能按请求有效区间累计,包含轮内工具和审批等待、不计轮间空闲;任务用时按 `schedule_runs` 关联请求汇总,不按父任务生命周期计算。缺失历史有提示,远程缺少可靠证据时不推算用时,数据来源与边界以技术设计为准。
|
||||
|
||||
先阅读统一领域模型。其他功能文档不得重新定义 Task、Conversation、Job、Run 或 Subagent。
|
||||
若实现与文档出现冲突,应先修正统一模型,再同步功能 PRD。
|
||||
|
||||
## 相关功能
|
||||
|
||||
- [本机专用推理架构与任务控制评估](../../architecture/local-inference-task-control.md)记录 ASR、OCR、embedding 的任务与服务状态、执行所有权、取消、超时、清理及观测待办;推理请求不自动等同于本目录的产品 Task,候选迁移尚未采用。
|
||||
- [直连模型 Agent 能力](../direct-model-agent/README.md) 使用本目录定义的 Subagent 与
|
||||
Job/Subjob 语义,为直连模型提供单层编程委派,但不重新定义产品对象层级。
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--surface-canvas: #e8f0f5; --surface-raised: #fff; --surface-subtle: #f4f8fb;
|
||||
--text-primary: #17263a; --text-secondary: #4f6278; --border-default: #cedce7;
|
||||
--accent: #086fc7; --accent-subtle: #e8f4ff; --success: #15725d; --warning: #865200;
|
||||
--font-family-ui: "Inter Variable", "PingFang SC", "Microsoft YaHei UI", "Noto Sans SC Variable", system-ui, sans-serif;
|
||||
--radius-control: 10px; --radius-card: 14px;
|
||||
}
|
||||
[data-theme="dark"] { color-scheme: dark; --surface-canvas: #07131f; --surface-raised: #102335; --surface-subtle: #162c40; --text-primary: #edf7ff; --text-secondary: #b5c6d5; --border-default: #29445a; --accent: #72c5ff; --accent-subtle: #123b5b; --success: #73d9bb; --warning: #f2bd66; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; padding: 24px; background: var(--surface-subtle); color: var(--text-primary); font: 13px/1.65 var(--font-family-ui); }
|
||||
button, select { font: inherit; color: inherit; }
|
||||
button { cursor: pointer; border: 1px solid transparent; border-radius: var(--radius-control); padding: 8px 12px; background: transparent; }
|
||||
button:hover { background: var(--accent-subtle); }
|
||||
button:focus-visible, select:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
|
||||
select { width: 100%; min-width: 0; padding: 8px; border: 1px solid var(--border-default); border-radius: var(--radius-control); background: var(--surface-raised); }
|
||||
h1, h2, h3, p { margin: 0; } h1 { font-size: 24px; font-weight: 600; line-height: 1.4; } h2 { font-size: 14px; } h3 { font-size: 13px; }
|
||||
.demo-bar, .demo-footer { max-width: 1500px; margin: auto; display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.demo-bar { margin-bottom: 16px; }.demo-bar strong { font-weight: 600; }.demo-bar span { margin-left: 16px; color: var(--text-secondary); font-size: 12px; }
|
||||
#theme { border-color: var(--border-default); }
|
||||
.workspace { max-width: 1500px; margin: auto; display: grid; grid-template-columns: 240px minmax(0, 1fr) 328px; height: calc(100dvh - 132px); min-height: 640px; border: 1px solid var(--border-default); border-radius: var(--radius-card); overflow: hidden; background: var(--surface-raised); }
|
||||
.sidebar { display: flex; flex-direction: column; padding: 24px 16px; background: var(--surface-canvas); border-right: 1px solid var(--border-default); overflow: auto; }
|
||||
.brand { display: flex; align-items: center; gap: 12px; margin-bottom: 32px; font-size: 16px; }.brand-mark { display: grid; place-items: center; width: 32px; height: 32px; border-radius: var(--radius-control); color: var(--surface-raised); background: var(--text-primary); font-weight: 700; }
|
||||
label, .eyebrow { font-size: 11px; color: var(--text-secondary); }label { display: block; margin-bottom: 8px; }
|
||||
.muted, .metadata, .sidebar-note, .task-footnote { color: var(--text-secondary); font-size: 12px; }
|
||||
#project-meta { margin: 8px 0 32px; }.section-heading { display: flex; justify-content: space-between; align-items: center; gap: 8px; }.section-heading > span { color: var(--text-secondary); font-size: 11px; }
|
||||
#sessions { margin-top: 12px; display: grid; gap: 8px; }#sessions button { text-align: left; width: 100%; padding: 12px; }#sessions button[aria-current="true"] { color: var(--accent); background: var(--surface-raised); border-color: var(--border-default); }
|
||||
#sessions small { display: block; color: var(--text-secondary); margin-top: 4px; font-size: 11px; }.sidebar-note { margin-top: auto; padding-top: 32px; line-height: 1.9; }
|
||||
main { display: flex; flex-direction: column; min-width: 0; min-height: 0; }.conversation-header { padding: 24px 32px; border-bottom: 1px solid var(--border-default); }.eyebrow { margin-bottom: 8px; }.metadata { display: flex; flex-wrap: wrap; gap: 4px 16px; margin-top: 12px; }
|
||||
.messages { flex: 1; min-height: 0; overflow: auto; padding: 32px; }.message { max-width: 720px; margin: 0 auto 32px; }.message-author { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; font-size: 12px; font-weight: 600; }.message-author time { margin-left: auto; color: var(--text-secondary); font-size: 11px; font-weight: 400; }.message p { font-size: 14px; line-height: 1.9; white-space: pre-line; }.message.user p { background: var(--surface-subtle); padding: 16px; border-radius: var(--radius-card); }
|
||||
.linked-task { display: block; margin-top: 16px; text-align: left; width: 100%; border-color: var(--border-default); color: var(--accent); }.linked-task small { display: block; color: var(--text-secondary); margin-top: 4px; }
|
||||
.composer { padding: 16px 32px 24px; }.composer-box { padding: 16px; border: 1px solid var(--border-default); border-radius: var(--radius-card); color: var(--text-secondary); }.composer-box div { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; margin-top: 24px; font-size: 11px; }.composer > p { margin-top: 8px; font-size: 11px; color: var(--text-secondary); }
|
||||
.tasks { border-left: 1px solid var(--border-default); background: var(--surface-subtle); padding: 24px 16px; overflow: auto; }.tasks header { margin-bottom: 24px; }.tasks header p { margin-top: 4px; }.badge { border: 1px solid var(--border-default); border-radius: var(--radius-control); padding: 0 8px; }.scope-label { margin: 8px 0 16px; color: var(--text-secondary); font-size: 11px; overflow-wrap: anywhere; }
|
||||
.filters { display: flex; gap: 4px; border-bottom: 1px solid var(--border-default); padding-bottom: 12px; margin-bottom: 16px; }.filters button { padding: 4px 12px; font-size: 12px; }.filters [aria-pressed="true"] { color: var(--accent); background: var(--accent-subtle); border-color: var(--accent); }
|
||||
.task-card { background: var(--surface-raised); border: 1px solid var(--border-default); border-radius: var(--radius-card); padding: 16px; margin-bottom: 12px; }.task-card .status { flex-shrink: 0; font-size: 11px; color: var(--accent); }.task-card .status.done { color: var(--success); }.task-card p { color: var(--text-secondary); font-size: 11px; margin-top: 8px; }.empty { padding: 24px 8px; color: var(--text-secondary); text-align: center; }.demo-footer { color: var(--text-secondary); margin-top: 12px; font-size: 11px; }
|
||||
.session-stats { padding-bottom: 24px; border-bottom: 1px solid var(--border-default); }.session-stats h3 { font-weight: 500; }.stat-grid { display: grid; grid-template-columns: 1fr 1fr; margin: 24px 0 12px; gap: 16px; }.stat-grid dt { font-size: 12px; color: var(--text-secondary); }.stat-grid dd { margin: 4px 0 0; font-size: 20px; font-weight: 600; font-variant-numeric: tabular-nums; }.session-stats details { margin-top: 16px; font-size: 11px; color: var(--text-secondary); }.session-stats summary { cursor: pointer; }.session-stats details p { margin-top: 8px; }.task-heading { margin: 24px 0 12px; }.task-duration { display: flex; justify-content: space-between; margin-top: 16px; font-size: 12px; }.task-duration span { color: var(--text-secondary); }.task-duration strong { font-weight: 500; }
|
||||
.task-session { padding: 4px 0; margin-top: 4px; text-align: left; color: var(--accent); font-size: 11px; }.task-actions { display: flex; gap: 8px; margin-top: 12px; }.task-actions button { border-color: var(--border-default); padding: 4px 12px; font-size: 12px; }
|
||||
.task-toolbar { display: flex; gap: 4px; }.task-toolbar button { display: grid; place-items: center; width: 32px; height: 32px; padding: 4px; color: var(--text-secondary); }#new-task { font-size: 22px; line-height: 1; }.task-toolbar svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linejoin: round; }.task-toolbar .is-active { background: var(--accent-subtle); color: var(--accent); border-color: var(--accent); }#task-filter { margin-bottom: 16px; }#task-count { font-size: 11px; font-weight: 400; color: var(--text-secondary); }
|
||||
#task-form { padding: 16px; margin-bottom: 24px; border: 1px solid var(--border-default); border-radius: var(--radius-card); background: var(--surface-raised); }#task-form label { margin-top: 12px; }#task-form textarea, #task-form input { width: 100%; min-width: 0; padding: 8px; border: 1px solid var(--border-default); border-radius: var(--radius-control); background: var(--surface-raised); color: var(--text-primary); font: inherit; }#task-form textarea { resize: vertical; }#task-form p { margin-top: 12px; }#task-form button[type="submit"] { color: var(--accent); border-color: var(--accent); }textarea:focus-visible, input:focus-visible, summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }.task-card h3, .task-session, #sessions button { overflow-wrap: anywhere; }
|
||||
@media (max-width: 1100px) { .workspace { grid-template-columns: 200px minmax(0, 1fr) 280px; }.conversation-header, .messages { padding: 24px; }.composer { padding: 16px 24px; } }
|
||||
@media (max-width: 850px) { body { padding: 16px; }.workspace { height: auto; grid-template-columns: 200px minmax(0, 1fr); }.tasks { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--border-default); }#task-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }.task-card { margin: 0; }.messages { min-height: 280px; }.demo-footer > span:last-child { display: none; } }
|
||||
@media (max-width: 560px) { body { padding: 8px; }.demo-bar { align-items: start; }.demo-bar span { display: block; margin-left: 0; }.workspace { display: flex; flex-direction: column; }.sidebar { border-right: 0; border-bottom: 1px solid var(--border-default); padding: 16px; }.brand, .sidebar-note { display: none; }#project-meta { margin: 8px 0 16px; }#sessions { grid-template-columns: repeat(2, minmax(0, 1fr)); }#sessions button { padding: 8px; }.conversation-header, .messages { padding: 24px 16px; }.composer { padding: 16px; }#task-list { grid-template-columns: 1fr; }.demo-footer { padding: 0 8px; } }
|
||||
@@ -0,0 +1,52 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>GoodBuddy · 当前会话统计</title>
|
||||
<link rel="stylesheet" href="hierarchy-demo.css">
|
||||
<script src="hierarchy-demo.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<header class="demo-bar"><div><strong>GoodBuddy / 会话统计 Demo</strong><span>演示数据 · 时间为固定样例</span></div><button id="theme" aria-pressed="false">深色模式</button></header>
|
||||
<div class="workspace">
|
||||
<aside class="sidebar" aria-label="项目与会话">
|
||||
<div class="brand"><span class="brand-mark">G</span><strong>GoodBuddy</strong></div>
|
||||
<div class="eyebrow">当前项目</div>
|
||||
<strong>GoodBuddy 产品设计</strong>
|
||||
<p id="project-meta" class="muted">工作目录 · goodbuddy</p>
|
||||
<div class="section-heading"><h2>会话</h2><span id="session-count"></span></div>
|
||||
<nav id="sessions" aria-label="当前项目的会话"></nav>
|
||||
</aside>
|
||||
<main>
|
||||
<header class="conversation-header"><div class="eyebrow">GoodBuddy 产品设计</div><h1 id="conversation-title"></h1></header>
|
||||
<section class="messages" id="messages" aria-label="会话内容"></section>
|
||||
<div class="composer"><div class="composer-box">继续交流,或交代下一件事…<div><span>Execute · OpenCode</span><span>输入区仅作布局展示</span></div></div></div>
|
||||
</main>
|
||||
<aside class="tasks" aria-label="任务中心">
|
||||
<header><h2>任务中心</h2><p class="muted">当前项目 · GoodBuddy 产品设计</p></header>
|
||||
<section class="session-stats" aria-label="当前会话统计" aria-live="polite">
|
||||
<div class="eyebrow">当前会话</div><h3 id="stats-title"></h3>
|
||||
<dl class="stat-grid"><div><dt>Agent 累计回复时长</dt><dd id="duration"></dd></div><div><dt>消息数</dt><dd id="message-count"></dd></div></dl>
|
||||
<p id="message-breakdown" class="muted"></p>
|
||||
<details><summary>时间说明</summary><p id="time-description"></p><p>累计每次 Agent 回复的结束时间减去开始时间,不包含回复之间的空闲时间。任务用时单独展示,包含任务中的等待时间。</p></details>
|
||||
</section>
|
||||
<div class="section-heading task-heading"><h2>项目任务 <span id="task-count"></span></h2><div class="task-toolbar"><button id="new-task" aria-label="新建任务" title="新建任务" aria-expanded="false" aria-controls="task-form">+</button><button id="filter-toggle" aria-label="筛选任务" title="筛选任务" aria-expanded="false" aria-controls="task-filter"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5h16l-6 7v6l-4 2v-8z"/></svg></button></div></div>
|
||||
<form id="task-form" hidden>
|
||||
<h3>新建任务</h3>
|
||||
<label for="task-content">任务内容</label><textarea id="task-content" rows="3" required maxlength="500" placeholder="描述要完成的工作"></textarea>
|
||||
<label for="task-conversation">关联会话</label><select id="task-conversation"></select>
|
||||
<label for="task-timing">执行时间</label><select id="task-timing"><option value="now">立即执行</option><option value="scheduled">定时执行</option></select>
|
||||
<div id="schedule-field" hidden><label for="task-date">计划时间</label><input id="task-date" type="datetime-local"></div>
|
||||
<p class="muted">仅在此页面模拟创建,刷新后恢复样例。</p>
|
||||
<div class="task-actions"><button type="submit">创建任务</button><button id="close-form" type="button">取消</button></div>
|
||||
</form>
|
||||
<div id="task-filter" hidden><label for="task-status">任务状态</label>
|
||||
<select id="task-status"><option value="all">全部状态</option><option value="进行中">进行中</option><option value="待执行">待执行</option><option value="已暂停">已暂停</option><option value="ended">已结束</option></select></div>
|
||||
<p id="active-filter" class="scope-label" hidden></p>
|
||||
<div id="task-list" aria-live="polite"></div>
|
||||
</aside>
|
||||
</div>
|
||||
<footer class="demo-footer"><span>切换会话更新统计;项目任务保持可见,可暂停、继续或取消。</span><span>范围固定为当前项目</span></footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,140 @@
|
||||
const conversations = [
|
||||
{ id: 'layout', title: '会话统计信息设计', userCount: 12, assistantCount: 12, start: '14:05', end: '14:32', replies: [[0, 12], [50, 74], [120, 136], [220, 240], [300, 332], [440, 458], [590, 615], [720, 750], [890, 930], [1100, 1145], [1350, 1378], [1580, 1620]], user: '任务中心可以增加当前会话、任务的时长和消息数,信息可能没多少。', reply: '右侧累计每次 Agent 从开始回复到结束回复的时间,不计两次回复之间的空闲。下方管理当前项目的全部任务,也可以直接新建任务。\n\n切换会话时只更新会话统计,项目任务列表保留。', tasks: [
|
||||
{ title: '制作统计布局 Demo', status: '进行中', duration: '6 分钟', time: '14:26 开始', active: true },
|
||||
{ title: '核对统计字段', status: '已完成', duration: '3 分钟', time: '14:21 至 14:24', active: false }
|
||||
] },
|
||||
{ id: 'review', title: '导航方案评审', userCount: 5, assistantCount: 7, start: '10:18', end: '11:46', replies: [[0, 30], [300, 350], [700, 740], [1400, 1470], [2200, 2260], [3500, 3580], [5230, 5280]], user: '整理一下导航方案中的问题,形成一份评审清单。', reply: '评审清单已整理完成,包括项目入口、会话导航和任务状态展示。', tasks: [
|
||||
{ title: '整理导航评审清单', status: '已完成', duration: '8 分钟', time: '11:38 至 11:46', active: false },
|
||||
{ title: '补充导航交互说明', status: '已暂停', duration: '2 分钟', time: '11:48 开始', active: true }
|
||||
] },
|
||||
{ id: 'ideas', title: '随手聊聊产品想法', userCount: 3, assistantCount: 3, start: '16:20', end: '16:38', replies: [[0, 8], [400, 411], [1071, 1080]], user: '消息数量应该算条数,还是算对话轮数?', reply: '显示消息条数更直观。用户发言和助手回复分别计数,工具调用不单独算成聊天消息。', tasks: [] }
|
||||
]
|
||||
let conversationId = 'layout'
|
||||
const $ = (id) => document.getElementById(id)
|
||||
const escape = (value) => String(value).replace(/[&<>"']/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char])
|
||||
function render() {
|
||||
const conversation = conversations.find((item) => item.id === conversationId)
|
||||
$('session-count').textContent = `${conversations.length} 个`
|
||||
$('sessions').innerHTML = conversations.map((item) => `<button data-conversation="${item.id}" aria-current="${item.id === conversationId}">${escape(item.title)}<small>${item.userCount + item.assistantCount} 条消息</small></button>`).join('')
|
||||
$('conversation-title').textContent = conversation.title
|
||||
$('stats-title').textContent = conversation.title
|
||||
// Demo intervals are reply start/end offsets in seconds, including idle gaps.
|
||||
const replySeconds = conversation.replies.reduce((sum, [start, end]) => sum + end - start, 0)
|
||||
$('duration').textContent = replySeconds >= 60 ? `${Math.floor(replySeconds / 60)} 分 ${replySeconds % 60} 秒` : `${replySeconds} 秒`
|
||||
$('message-count').textContent = `${conversation.userCount + conversation.assistantCount} 条`
|
||||
$('message-breakdown').textContent = `你 ${conversation.userCount} 条 · 助手 ${conversation.assistantCount} 条`
|
||||
$('time-description').textContent = `本会话 ${conversation.replies.length} 次已结束回复,累计 ${replySeconds} 秒。演示按每次回复的起止时间计算。`
|
||||
$('messages').innerHTML = conversation.userCount ? `<article class="message user"><div class="message-author">你 <time>${conversation.start}</time></div><p>${escape(conversation.user)}</p></article><article class="message"><div class="message-author">GoodBuddy <time>${conversation.end}</time></div><p>${escape(conversation.reply)}</p></article>` : '<p class="empty">任务已关联此会话,尚无消息。Demo 不执行真实 Agent。</p>'
|
||||
const projectTasks = conversations.flatMap((session) => session.tasks.map((task, index) => ({ task, session, index })))
|
||||
const status = $('task-status').value
|
||||
const filtered = status !== 'all'
|
||||
const filterLabel = $('task-status').selectedOptions[0].textContent
|
||||
$('filter-toggle').classList.toggle('is-active', filtered)
|
||||
$('filter-toggle').setAttribute('aria-label', filtered ? `筛选任务:${filterLabel}` : '筛选任务')
|
||||
$('filter-toggle').title = filtered ? `筛选任务:${filterLabel}` : '筛选任务'
|
||||
$('active-filter').hidden = !filtered
|
||||
$('active-filter').textContent = `筛选:${filterLabel}`
|
||||
const visibleTasks = projectTasks.filter(({ task }) => status === 'all' || (status === 'ended' ? !task.active : task.status === status))
|
||||
$('task-count').textContent = `${visibleTasks.length} / ${projectTasks.length} 项`
|
||||
$('task-list').innerHTML = visibleTasks.length ? visibleTasks.map(({ task, session, index }) => `<article class="task-card" data-session="${session.id}" data-index="${index}"><div class="section-heading"><h3>${escape(task.title)}</h3><span class="status ${task.active ? '' : 'done'}">${task.status}</span></div><button class="task-session" data-open="${session.id}">${session.id === conversationId ? '当前会话' : '会话'} · ${escape(session.title)}</button><div class="task-duration"><span>${task.active ? '已用时' : '总用时'}</span><strong>${task.duration}</strong></div><p>${escape(task.time)}</p>${task.active ? `<div class="task-actions"><button data-action="toggle">${task.status === '已暂停' ? '继续' : '暂停'}</button><button data-action="cancel">取消任务</button></div>` : ''}</article>`).join('') : '<p class="empty">当前项目暂无此状态的任务</p>'
|
||||
}
|
||||
$('task-status').addEventListener('change', render)
|
||||
$('filter-toggle').addEventListener('click', () => {
|
||||
const expanded = $('task-filter').hidden
|
||||
$('task-filter').hidden = !expanded
|
||||
$('filter-toggle').setAttribute('aria-expanded', String(expanded))
|
||||
if (expanded) $('task-status').focus()
|
||||
})
|
||||
$('task-filter').addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Escape') return
|
||||
$('task-filter').hidden = true
|
||||
$('filter-toggle').setAttribute('aria-expanded', 'false')
|
||||
$('filter-toggle').focus()
|
||||
})
|
||||
$('task-list').addEventListener('click', (event) => {
|
||||
const button = event.target.closest('button')
|
||||
if (!button) return
|
||||
if (button.dataset.open) {
|
||||
conversationId = button.dataset.open
|
||||
render()
|
||||
$('conversation-title').tabIndex = -1
|
||||
$('conversation-title').focus()
|
||||
return
|
||||
}
|
||||
const card = button.closest('[data-session]')
|
||||
const task = conversations.find((session) => session.id === card.dataset.session).tasks[Number(card.dataset.index)]
|
||||
if (button.dataset.action === 'cancel') {
|
||||
task.status = '已取消'
|
||||
task.active = false
|
||||
} else {
|
||||
if (task.status === '已暂停') {
|
||||
task.status = task.resumeStatus || '进行中'
|
||||
} else {
|
||||
task.resumeStatus = task.status
|
||||
task.status = '已暂停'
|
||||
}
|
||||
}
|
||||
const sessionId = card.dataset.session
|
||||
const index = card.dataset.index
|
||||
render()
|
||||
const next = $('task-list').querySelector(`[data-session="${sessionId}"][data-index="${index}"] button`)
|
||||
;(next || ($('task-filter').hidden ? $('filter-toggle') : $('task-status'))).focus()
|
||||
})
|
||||
$('sessions').addEventListener('click', (event) => {
|
||||
const button = event.target.closest('[data-conversation]')
|
||||
if (!button) return
|
||||
conversationId = button.dataset.conversation
|
||||
render()
|
||||
$('sessions').querySelector(`[data-conversation="${conversationId}"]`).focus()
|
||||
})
|
||||
$('theme').addEventListener('click', () => {
|
||||
const dark = document.documentElement.dataset.theme !== 'dark'
|
||||
document.documentElement.dataset.theme = dark ? 'dark' : 'light'
|
||||
$('theme').setAttribute('aria-pressed', String(dark))
|
||||
$('theme').textContent = dark ? '浅色模式' : '深色模式'
|
||||
})
|
||||
$('new-task').addEventListener('click', () => {
|
||||
$('task-form').hidden = false
|
||||
$('new-task').setAttribute('aria-expanded', 'true')
|
||||
$('task-conversation').innerHTML = conversations.map((session) => `<option value="${session.id}">${escape(session.title)}</option>`).join('') + '<option value="new">新建会话</option>'
|
||||
$('task-conversation').value = conversationId
|
||||
$('task-content').focus()
|
||||
})
|
||||
$('close-form').addEventListener('click', () => {
|
||||
$('task-form').hidden = true
|
||||
$('new-task').setAttribute('aria-expanded', 'false')
|
||||
$('new-task').focus()
|
||||
})
|
||||
$('task-timing').addEventListener('change', () => {
|
||||
const scheduled = $('task-timing').value === 'scheduled'
|
||||
$('schedule-field').hidden = !scheduled
|
||||
$('task-date').required = scheduled
|
||||
$('task-date').disabled = !scheduled
|
||||
})
|
||||
$('task-content').addEventListener('input', () => $('task-content').setCustomValidity(''))
|
||||
$('task-date').addEventListener('input', () => $('task-date').setCustomValidity(''))
|
||||
$('task-form').addEventListener('submit', (event) => {
|
||||
event.preventDefault()
|
||||
const title = $('task-content').value.trim()
|
||||
$('task-content').setCustomValidity(title ? '' : '请输入任务内容')
|
||||
const scheduled = $('task-timing').value === 'scheduled'
|
||||
$('task-date').setCustomValidity(scheduled && !(new Date($('task-date').value).getTime() > Date.now()) ? '请选择未来的时间' : '')
|
||||
if (!$('task-form').reportValidity()) return
|
||||
let session = conversations.find((item) => item.id === $('task-conversation').value)
|
||||
if (!session) {
|
||||
session = { id: `new-${conversations.length}`, title: title.slice(0, 24), userCount: 0, assistantCount: 0, replies: [], tasks: [] }
|
||||
conversations.push(session)
|
||||
}
|
||||
session.tasks.unshift({ title, status: scheduled ? '待执行' : '进行中', duration: '0 秒', time: scheduled ? `计划于 ${$('task-date').value.replace('T', ' ')}` : '刚刚创建 · 模拟执行', active: true })
|
||||
$('task-status').value = 'all'
|
||||
$('task-form').reset()
|
||||
$('schedule-field').hidden = true
|
||||
$('task-date').required = false
|
||||
$('task-date').disabled = true
|
||||
$('task-form').hidden = true
|
||||
$('new-task').setAttribute('aria-expanded', 'false')
|
||||
render()
|
||||
const created = $('task-list').querySelector(`[data-session="${session.id}"][data-index="0"] .task-session`)
|
||||
created.focus()
|
||||
})
|
||||
render()
|
||||
@@ -5,8 +5,8 @@
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | 首期稳定 Task 生命周期、创建体验与 Conversation 输入仲裁已实现;高级触发和执行治理待实施 |
|
||||
| 版本 | 0.8 |
|
||||
| 日期 | 2026-09-10 |
|
||||
| 版本 | 0.9 |
|
||||
| 日期 | 2026-09-17 |
|
||||
| 依赖 | [Task 与 Job 统一领域模型](./task-and-job-model.md) |
|
||||
| 相关架构 | [自动化平台总体设计](../../architecture/automation-platform-architecture.md) |
|
||||
|
||||
@@ -21,7 +21,7 @@ Scheduled Task 是带时间或事件触发器的 Task。每个 Scheduled Task
|
||||
|
||||
创建 Scheduled Task 时:
|
||||
|
||||
1. 用户选择关联当前 Conversation 或创建新 Conversation。
|
||||
1. 默认关联当前 Conversation,也可选择本项目其他可用 Conversation 或创建新 Conversation。
|
||||
2. 系统创建一个 Task,并保存稳定 `conversationId` 和 Schedule/Trigger Binding。
|
||||
3. 每次触发在同一 Task 内创建新的 Job 和 Run。
|
||||
4. 面向用户的文本进展和结果写回关联 Conversation,并标明 Task 来源。
|
||||
@@ -34,11 +34,11 @@ Scheduled Task 是带时间或事件触发器的 Task。每个 Scheduled Task
|
||||
|
||||
GoodBuddy 当前已实现首期统一生命周期:
|
||||
|
||||
- 创建 Modal 可以关联当前 Conversation 或原子创建新 Conversation,不修改当前
|
||||
- 创建 Modal 可以关联当前或本项目其他可用 Conversation,或原子创建新 Conversation,不修改已有
|
||||
Conversation 的标题和既有消息。
|
||||
- 每个 Schedule 绑定一个稳定产品级 Task 和 Conversation;重复触发复用同一身份,不再
|
||||
为每次触发创建新的顶层 Task。
|
||||
- 创建表单只设置任务要求、关联会话和时间;工作模式在关联会话中设置,未设置时使用项目默认值。
|
||||
- 创建表单设置任务要求、可选名称、关联会话和执行时间,默认立即执行;工作模式在关联会话中设置,未设置时使用项目默认值。
|
||||
- 单次、每日和每周计划支持暂停、恢复、立即运行和应用重启恢复。
|
||||
- 到期和手动运行先进入关联 Conversation 的持久输入队列,与回复期间继续发送的普通消息
|
||||
顺序仲裁;默认不打断当前回复,也不与其并发写入时间线。
|
||||
@@ -58,7 +58,7 @@ Job/Subjob/Run 的统一持久化抽象。当前每日和每周按既有 UTC 间
|
||||
|
||||
- 支持单次、每日、每周、每月、工作日和受限 Cron。
|
||||
- 支持 Task 完成、失败、Conversation 完成等内部事件触发。
|
||||
- 创建时明确选择当前或新 Conversation。
|
||||
- 创建时明确选择当前、本项目其他可用或新 Conversation。
|
||||
- 使用关联会话当前的工作模式;新会话使用普通项目默认值。
|
||||
- 在实际发送时解析上下文和执行配置,与手动发送共用执行流程。
|
||||
- 提供时区、错过执行、幂等、租约、重试、恢复、取消、预算和审计。
|
||||
@@ -82,10 +82,10 @@ Task Center 和 Conversation 操作都可以提供“新建定制任务”,但
|
||||
|
||||
```text
|
||||
新建定制任务
|
||||
创建一个可以按计划自动运行,并持续记录在会话中的任务
|
||||
创建一个立即或按计划运行,并持续记录在会话中的任务
|
||||
|
||||
任务名称 *
|
||||
[ 每周项目总结 ]
|
||||
任务名称(可选)
|
||||
[ 留空时从任务内容生成 ]
|
||||
|
||||
任务要求 *
|
||||
[ 总结本周完成和失败的工作,并列出下周优先事项。 ]
|
||||
@@ -97,11 +97,13 @@ Task Center 和 Conversation 操作都可以提供“新建定制任务”,但
|
||||
○ 新建会话
|
||||
为任务创建一条新会话,默认标题为任务名称
|
||||
|
||||
运行频率
|
||||
[ 单次 ] [ 每日 ] [ 每周 ] [ 每月 ] [ 工作日 ] [ Cron ]
|
||||
○ 本项目其他会话
|
||||
[ 选择会话 ▾ ]
|
||||
|
||||
首次运行 [ 2026-08-21 ] [ 17:00 ]
|
||||
时区 [ Asia/Shanghai ▾ ]
|
||||
执行时间
|
||||
◉ 立即执行 ○ 定时执行
|
||||
定时选项 [ 单次 ] [ 每日 ] [ 每周 ]
|
||||
首次运行 [ 日期 ] [ 时间 ](仅定时执行时显示)
|
||||
|
||||
关联项目
|
||||
GoodBuddy Desktop · 项目工作目录
|
||||
@@ -112,14 +114,18 @@ GoodBuddy Desktop · 项目工作目录
|
||||
|
||||
### 5.1 Conversation 选择
|
||||
|
||||
- 从当前聊天发起时默认选择当前 Conversation。
|
||||
- 从 Task Center 发起时默认选择新 Conversation。
|
||||
- 从当前聊天或 Task Center 发起时均默认选择当前 Conversation。
|
||||
- 可选本项目其他可用 Conversation 或新 Conversation;当前会话不可用时明确提示用户选择,不隐藏目标。
|
||||
- 当前选择必须持续可见,不能根据入口静默决定后隐藏。
|
||||
- 关联当前 Conversation 不修改其标题、既有消息和普通聊天能力。
|
||||
- 当前 Conversation 已有关联 Task 时,显示 Task 数量和共享上下文说明。
|
||||
- 新 Conversation 默认使用 Task 名称作为标题,用户可以单独修改。
|
||||
- 远程通道、归档、正在删除或 Project 不匹配的 Conversation 不可选择,并显示原因。
|
||||
|
||||
任务内容必填。名称可留空,使用压缩连续空白后的内容前 120 个字符生成。默认立即执行;
|
||||
选择定时后支持单次、每日和每周,首次运行时间必须在未来。每月、工作日、Cron 和 IANA
|
||||
时区仍属于后续能力,不显示为本次已支持选项。
|
||||
|
||||
### 5.2 创建摘要
|
||||
|
||||
提交前显示确定性摘要:
|
||||
@@ -133,8 +139,13 @@ GoodBuddy Desktop · 项目工作目录
|
||||
✓ 独立交付物保存到成果
|
||||
```
|
||||
|
||||
摘要随目标会话和执行时间更新,默认立即执行时明确显示“创建后进入会话队列”。
|
||||
创建 Task、可选新 Conversation、关联关系和 Schedule Binding 必须在 Main 中原子提交。
|
||||
失败时保持 Modal 和用户输入,不只显示短暂通知。提交期间锁定重复操作。
|
||||
立即创建同时提交一次执行及其队列项,只入队一次,遵循同会话串行规则;自动定时触发不得
|
||||
再添加一次执行。事务失败保留 Modal 和输入;提交期间锁定重复操作。创建成功后的刷新失败
|
||||
只提示刷新错误,不重新创建;后续执行失败显示在已创建任务中。
|
||||
数据库事务与队列实现见[技术设计](./technical-design.md#custom-task-creation),其中 schedule run
|
||||
与 queue 和创建记录在同一事务内写入,前端创建后不再调用 `runNow`。
|
||||
|
||||
### 5.3 Modal 行为与无障碍
|
||||
|
||||
@@ -346,7 +357,8 @@ Task Center 显示 Scheduled Task 的范围、关联 Conversation、状态、最
|
||||
|
||||
## 16. 验收标准
|
||||
|
||||
- [x] 创建 Scheduled Task 可以选择当前或新 Conversation。
|
||||
- [ ] 创建默认立即执行并关联当前 Conversation,可选本项目其他可用会话或新会话;名称可留空从内容生成。
|
||||
- [ ] 立即创建原子提交且只入队一次,定时器不重复触发;入队失败回滚创建,刷新或执行失败不重复创建。
|
||||
- [x] 关联当前 Conversation 不修改其标题、类型或既有消息。
|
||||
- [x] 一条 Conversation 可以在左侧展开一个或多个 Task。
|
||||
- [x] 工作模式使用关联会话当前设置,未设置时使用项目默认值。
|
||||
@@ -369,3 +381,5 @@ Task Center 显示 Scheduled Task 的范围、关联 Conversation、状态、最
|
||||
- [x] 文本结果只写入 Conversation,独立交付物才进入成果。
|
||||
- [ ] Task Center 和桌面通知可以打开正确 Conversation 并定位 Task。
|
||||
- [ ] 应用重启不自动重放结果未知的副作用。
|
||||
|
||||
本次新增或变更的创建验收项未在本轮文档同步中执行验证,保留未勾选状态。
|
||||
|
||||
@@ -5,22 +5,20 @@
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 状态 | Scheduled Task 与项目范围跟随已实现;Goal/Event Task 与完整操作待实施 |
|
||||
| 版本 | 0.4 |
|
||||
| 日期 | 2026-09-10 |
|
||||
| 版本 | 0.5 |
|
||||
| 日期 | 2026-09-17 |
|
||||
| 依赖 | [Task 与 Job 统一领域模型](./task-and-job-model.md) |
|
||||
| 界面归属 | [通用助手工作栏与执行空间](../assistant-workbar/prd.md) |
|
||||
|
||||
## 1. 产品定义
|
||||
|
||||
Task Center 是工作栏中的单实例 Task 索引,默认跟随当前项目。它不是 Automation Center,
|
||||
也不复制 Conversation 内容。点击条目打开其关联 Conversation,并定位或展开对应 Task。
|
||||
Task Center 是工作栏中的单实例 Task 索引,范围固定为当前项目。顶部显示当前会话统计,
|
||||
下方管理本项目全部会话的任务。点击条目打开其关联 Conversation,并定位或展开对应 Task。
|
||||
|
||||
每个 Task 只关联一条 Conversation;一条 Conversation 可以关联零个、一个或多个 Task。
|
||||
Conversation 不因为关联 Task 而改变对象类型。
|
||||
|
||||
“单实例”和“项目范围”是两个独立属性:应用内只有一个 Task Center Tab,但该 Tab 默认展示
|
||||
当前项目的 Task。用户可以在同一 Tab 内切换到“所有项目”,不能为每个项目创建
|
||||
独立 Task Center 实例。
|
||||
应用内只有一个 Task Center Tab,不提供全局或所有项目入口,也不为每个项目创建独立实例。
|
||||
|
||||
## 2. 收录边界
|
||||
|
||||
@@ -45,7 +43,7 @@ Job/Subjob/Run 树、独立详情或路由。
|
||||
|
||||
每条 Task 至少显示:
|
||||
|
||||
- 名称和所属项目;无项目时标注“未绑定项目”。
|
||||
- 名称和所属项目。
|
||||
- 关联 Conversation 标题。
|
||||
- Task 类型和触发来源。
|
||||
- Ask / Execute 模式。
|
||||
@@ -54,25 +52,32 @@ Job/Subjob/Run 树、独立详情或路由。
|
||||
- 最近活动时间。
|
||||
- 等待审批、失败或需要关注状态。
|
||||
- 下次计划时间(如适用)。
|
||||
- 累计执行时间:汇总该任务历次执行请求的有效区间,不使用父 Task 从创建到结束的生命周期。
|
||||
|
||||
Task 行只显示聚合后的用户状态,不要求用户理解内部 Job/Run。
|
||||
|
||||
## 4. 范围
|
||||
|
||||
范围控件提供:
|
||||
FR-TC1:当前项目是唯一范围,列表只显示 `projectId` 匹配的 Task。切换项目刷新列表;
|
||||
同一项目内切换会话只更新顶部统计,不按当前会话筛掉项目任务。没有活动项目时提示选择项目。
|
||||
历史范围偏好不恢复全局或所有项目视图;范围收窄不删除历史任务或修改其项目绑定。
|
||||
|
||||
- `当前项目`:默认值,跟随活动项目变化,只显示 `projectId` 匹配的 Task。
|
||||
- `所有项目`:显示当前用户可见的项目 Task 和没有 `projectId` 的历史 Task,后者标注“未绑定项目”。
|
||||
### 4.1 当前会话统计
|
||||
|
||||
没有活动项目时,“当前项目”显示明确空状态,并允许切换到“所有项目”;系统不
|
||||
自动改变用户已经选择的范围。范围模式作为工作栏公开偏好持久化,但“当前项目”模式不保存
|
||||
具体项目 ID;重启后继续跟随届时的活动项目。旧布局保存的 `global` 范围在 UI 恢复时映射为
|
||||
`all-projects`,后续保存使用新值;不修改任务数据或后端项目绑定规则。
|
||||
FR-TC2:顶部紧凑展示当前会话消息总数与 Agent 累计回复时间。累计时间按每轮请求的有效
|
||||
执行区间相加,包含轮内工具执行和审批等待,不计轮间空闲;不重复累加子专家用时。
|
||||
缺失历史或时间证据不完整时提示“部分历史用时不可用”,已知用时不能表示为完整总量。
|
||||
远程执行缺少可靠时间证据时显示不可用或不完整,不用事件接收时间虚构远程用时。
|
||||
没有当前会话时提示选择会话,项目任务列表仍保留。
|
||||
|
||||
FR-TC3:任务累计执行时间按其历次执行请求汇总,周期触发仍显示在同一任务内。
|
||||
未执行与缺失执行证据必须区分;具体关联和统计边界见[技术设计](./technical-design.md)。
|
||||
|
||||
## 5. 交互
|
||||
|
||||
- 点击条目打开关联 Conversation,并定位到该 Task。
|
||||
- 状态筛选仅保留进行中、暂停、已结束,默认选择进行中。
|
||||
- FR-TC4:项目任务标题右侧依次放置“+”新建按钮和筛选图标。状态筛选默认收起、默认全部,
|
||||
展开后提供全部、进行中、暂停、已结束;收起不清除已选条件,非全部时图标高亮并显示筛选条件。
|
||||
- 进行中包含 `idle`、`queued`、`running`、`waiting_approval`、`failed` 和 `interrupted`;
|
||||
暂停对应 `paused`,已结束包含 `completed` 和 `cancelled`。分类只影响列表筛选,
|
||||
不改变任务状态语义;任务行保留具体状态、错误信息和原有操作。
|
||||
@@ -83,6 +88,8 @@ Task 行只显示聚合后的用户状态,不要求用户理解内部 Job/Run
|
||||
- 审批徽标统计当前项目范围内全部待审批请求,不受状态筛选影响。
|
||||
- 支持立即运行、暂停、恢复、取消、编辑和删除。
|
||||
- 后台变化更新状态和徽标,不自动抢占当前页面。
|
||||
- FR-TC5:创建默认立即执行并关联当前会话,也可选择本项目其他可用会话或新建会话,
|
||||
改用单次定时、每日或每周计划。内容必填,名称可留空并从内容生成,详见[创建规则](./scheduled-task-prd.md#5-创建入口与-modal)。
|
||||
- 立即运行、重试和恢复在 UI 上都是 Task 操作,内部 Job/Run 不单独显示。
|
||||
- 完整消息留在 Conversation;工具、审批和错误可以在活动或 Runtime 中按 Task 查看;
|
||||
独立交付物在成果中查看。
|
||||
@@ -145,6 +152,11 @@ Task 行只显示聚合后的用户状态,不要求用户理解内部 Job/Run
|
||||
- [x] 普通模型请求和工具调用不会误显示为 Task。
|
||||
- [ ] 删除 Task 默认保留 Conversation 和既有消息。
|
||||
- [x] Smart Heartbeat 不进入 Task Center。
|
||||
- [x] Task Center 默认只显示当前项目的 Task,切换项目后在同一 Tab 更新列表。
|
||||
- [x] 仅提供“当前项目”和“所有项目”;后者保留无项目 Task,并标注“未绑定项目”。
|
||||
- [x] 范围切换不创建第二个 Task Center Tab;任务中心保持默认存在且不可关闭。
|
||||
- [ ] FR-TC1:仅显示当前项目任务,切换会话不缩小列表;无项目时显示选择项目提示。
|
||||
- [ ] FR-TC2:消息总数与累计回复时间随当前会话更新;轮内工具和审批计时,轮间空闲不计时,历史缺失及远程证据不足有提示。
|
||||
- [ ] FR-TC3:多次执行汇总到同一任务,不按父 Task 生命周期计算用时。
|
||||
- [ ] FR-TC4:标题右侧先新建、后筛选;筛选默认收起且为全部,收起后保留所选条件。
|
||||
- [ ] FR-TC5:创建默认立即执行和当前会话,可选择本项目其他会话、新会话及定时周期,空名称从内容生成。
|
||||
- [x] 任务中心保持单实例、默认存在且不可关闭。
|
||||
|
||||
本次新增或变更的验收项未在本轮文档同步中执行验证,保留未勾选状态。
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
# Execution Statistics Query
|
||||
|
||||
## 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:
|
||||
|
||||
```ts
|
||||
interface ExecutionStats {
|
||||
durationMs: number
|
||||
requestCount: number
|
||||
incompleteRequestCount: number
|
||||
activeRequestCount: number
|
||||
asOf: number // epoch milliseconds at query time
|
||||
taskDurations: Array<{
|
||||
id: string
|
||||
durationMs: number
|
||||
incompleteRequestCount: number
|
||||
}>
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Evidence And Boundaries
|
||||
|
||||
- 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 is not marked incomplete
|
||||
just because it is active. Manual context compression leases are excluded.
|
||||
Without a live lease, stop at the latest evidence and mark an open interval
|
||||
incomplete. Renderer must requery rather than extrapolate beyond `asOf`.
|
||||
- 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 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 still read task metadata from message rows rather than from every event.
|
||||
It adds no tables, columns, migration, or persisted timing totals. Stable task
|
||||
lifecycle timestamps are never duration endpoints.
|
||||
|
||||
## 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.
|
||||
|
||||
## Custom Task Creation
|
||||
|
||||
The dialog defaults to the current conversation and immediate execution. Content
|
||||
is required; an empty name uses the first 120 characters of whitespace-normalized
|
||||
content. App supplies other local conversations in the current project and also
|
||||
allows creating a new conversation. Scheduled creation retains once/daily/weekly
|
||||
recurrence and requires a future timestamp, checked by both App and Main IPC.
|
||||
|
||||
`ScheduleCreateInput.runImmediately` is an optional creation command, not a stored
|
||||
schedule field. It is valid only for `recurrence: 'once'`. `createSchedule` creates
|
||||
the conversation (if needed), task, disabled schedule, pending schedule run and
|
||||
conversation queue item in one SQLite transaction. Queue insertion failure rolls
|
||||
back all of them. Disabling automatic scheduling prevents timer ticks from adding
|
||||
another occurrence; pending/running queue state still determines task status.
|
||||
|
||||
The create IPC handler publishes the queue change and wakes the existing
|
||||
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.
|
||||
|
||||
`schedule-creation.test.ts` verifies transaction rollback and timer non-duplication
|
||||
with real SQLite. IPC tests exercise that database through the production queue
|
||||
and ordinary run handler with a controlled runtime, including failed execution.
|
||||
App creation tests cover destination selection, time validation, and failure handling.
|
||||
Reference in New Issue
Block a user