perf: track execution duration by state and bound summary I/O
This commit is contained in:
@@ -674,6 +674,30 @@ describe('AssistantDatabase', () => {
|
||||
} finally { database.close() }
|
||||
})
|
||||
|
||||
it('reads summaries from a covering index without loading active message metadata', async () => {
|
||||
const database = await createDatabase()
|
||||
try {
|
||||
const id = randomUUID()
|
||||
const message = { id: randomUUID(), role: 'assistant' as const, state: 'streaming' as const,
|
||||
content: 'Partial reply', reasoning: 'x'.repeat(1_000_000), createdAt: 7 }
|
||||
database.saveLocalConversations([{ header: { id, title: 'Active', updatedAt: 7 }, messages: [message] }])
|
||||
database.createTask({ id: randomUUID(), conversationId: id, title: 'Active', instructions: '', workMode: 'ask', status: 'running' })
|
||||
const raw = (database as unknown as { database: DatabaseSync }).database
|
||||
const prepare = vi.spyOn(raw, 'prepare')
|
||||
const summary = database.listConversationSummaries([]).find(item => item.id === id)!
|
||||
expect(summary.messages).toEqual([])
|
||||
expect(summary.messageSummary).toEqual({ count: 1, firstRole: 'assistant', latestMessageAt: 7 })
|
||||
const statements = prepare.mock.calls.map(([sql]) => sql)
|
||||
prepare.mockRestore()
|
||||
expect(statements.some(sql => sql.includes('metadata_json'))).toBe(false)
|
||||
const aggregate = statements.find(sql => sql.includes('count(*) AS count'))!
|
||||
const plan = raw.prepare(`EXPLAIN QUERY PLAN ${aggregate}`).all().map(row => String(row.detail))
|
||||
expect(plan.some(detail => /(?:SCAN|SEARCH) m USING COVERING INDEX messages_summary_idx/.test(detail))).toBe(true)
|
||||
expect(plan.some(detail => /SEARCH f USING COVERING INDEX messages_summary_idx/.test(detail))).toBe(true)
|
||||
expect(database.listConversationSummaries([id]).find(item => item.id === id)?.messages).toEqual([message])
|
||||
} finally { database.close() }
|
||||
})
|
||||
|
||||
it.each([0, 1, 100, 500, 999])('keeps listTasks(%i) recent history plus old visible live tasks', async (limit) => {
|
||||
const database = await createDatabase()
|
||||
vi.useFakeTimers()
|
||||
|
||||
@@ -5,7 +5,6 @@ import { knowledgeReferenceKey } from '../../shared/knowledge-reference'
|
||||
import { appendConversationQuestionBlock } from '../../shared/conversation-question-blocks'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { Worker } from 'node:worker_threads'
|
||||
import { ExecutionStatsReader } from './execution-stats-reader'
|
||||
import { ReadonlyQueryReader, readWithFallback } from '../readonly-query-reader'
|
||||
import { ActivityHistoryRepository, migrateActivityHistoryOrder } from './activity-history-repository'
|
||||
import { statSync } from 'node:fs'
|
||||
@@ -139,7 +138,7 @@ import {
|
||||
SUBAGENT_PROGRESS_STORAGE_SCHEMA_VERSION
|
||||
} from './subagent-progress-storage'
|
||||
|
||||
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 58
|
||||
export const ASSISTANT_DATABASE_SCHEMA_VERSION = 59
|
||||
/**
|
||||
* Messages startup recovery may have to end (schema 58): still streaming, or
|
||||
* metadata naming an unfinished tool/subagent (pending, running, queued) or
|
||||
@@ -1983,12 +1982,102 @@ function deleteProjectRecords(
|
||||
}
|
||||
}
|
||||
|
||||
/** Only Main leases can open a clock. Task state changes cannot revive a released lease. */
|
||||
class ExecutionTiming {
|
||||
private readonly active = new Set<string>()
|
||||
private readonly listeners = new Set<() => void>()
|
||||
private notificationPending = false
|
||||
|
||||
constructor(private readonly database: DatabaseSync) {}
|
||||
|
||||
onChanged(listener: () => void): () => void {
|
||||
this.listeners.add(listener)
|
||||
return () => { this.listeners.delete(listener) }
|
||||
}
|
||||
|
||||
changed(): void {
|
||||
if (this.notificationPending) return
|
||||
this.notificationPending = true
|
||||
queueMicrotask(() => {
|
||||
this.notificationPending = false
|
||||
for (const listener of this.listeners) listener()
|
||||
})
|
||||
}
|
||||
|
||||
start(taskId: string): void {
|
||||
const task = this.database.prepare(`SELECT project_id, conversation_id, parent_task_id,
|
||||
visible, origin, schedule_id, status FROM tasks WHERE id = ?`).get(taskId)
|
||||
if (!task) throw new Error('Cannot time a request before its task exists')
|
||||
if (task.origin === 'subagent' || task.schedule_id !== null || (task.origin === 'schedule' && task.visible === 1)) return
|
||||
const result = this.database.prepare(`INSERT INTO execution_timing
|
||||
(task_id, project_id, conversation_id, owner_id, running_since)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(task_id) DO UPDATE SET running_since = excluded.running_since
|
||||
WHERE execution_timing.running_since IS NULL AND excluded.running_since IS NOT NULL`)
|
||||
.run(taskId, task.project_id ?? null, task.conversation_id ?? null,
|
||||
task.parent_task_id ?? (task.visible ? taskId : null), task.status === 'running' ? Date.now() : null)
|
||||
this.active.add(taskId)
|
||||
if (result.changes) this.changed()
|
||||
}
|
||||
|
||||
state(taskId: string, status: string): void {
|
||||
if (!this.active.has(taskId)) return
|
||||
const running = status === 'running'
|
||||
const now = Date.now()
|
||||
const result = running
|
||||
? this.database.prepare(`UPDATE execution_timing SET running_since = ?
|
||||
WHERE task_id = ? AND running_since IS NULL`).run(now, taskId)
|
||||
: this.database.prepare(`UPDATE execution_timing
|
||||
SET duration_ms = duration_ms + MAX(0, ? - running_since), running_since = NULL
|
||||
WHERE task_id = ? AND running_since IS NOT NULL`).run(now, taskId)
|
||||
if (result.changes) this.changed()
|
||||
}
|
||||
|
||||
end(taskId: string, incomplete = false): void {
|
||||
this.state(taskId, 'stopped')
|
||||
this.active.delete(taskId)
|
||||
if (incomplete && this.database.prepare(`UPDATE execution_timing SET incomplete = 1
|
||||
WHERE task_id = ? AND incomplete = 0`).run(taskId).changes) this.changed()
|
||||
}
|
||||
|
||||
snapshot(scope: ExecutionStatsInput): ExecutionStats {
|
||||
const conversation = 'conversationId' in scope
|
||||
const scopeId = conversation ? scope.conversationId : scope.projectId
|
||||
const column = conversation ? 'conversation_id' : 'project_id'
|
||||
const asOf = Date.now()
|
||||
const result: ExecutionStats = {
|
||||
durationMs: 0, runningCount: 0, asOf,
|
||||
incomplete: Boolean(this.database.prepare(`SELECT 1 FROM conversations
|
||||
WHERE ${conversation ? 'id' : 'project_id'} = ? AND timing_incomplete = 1 LIMIT 1`).get(scopeId)),
|
||||
taskDurations: []
|
||||
}
|
||||
const cards = new Map<string, ExecutionStats['taskDurations'][number]>()
|
||||
for (const row of this.database.prepare(`SELECT owner_id, duration_ms, running_since, incomplete
|
||||
FROM execution_timing WHERE ${column} = ?`).iterate(scopeId)) {
|
||||
const runningCount = row.running_since === null ? 0 : 1
|
||||
const durationMs = Number(row.duration_ms) + (runningCount ? Math.max(0, asOf - Number(row.running_since)) : 0)
|
||||
const incomplete = row.incomplete === 1
|
||||
result.durationMs += durationMs
|
||||
result.runningCount += runningCount
|
||||
result.incomplete ||= incomplete
|
||||
if (!conversation && row.owner_id) {
|
||||
const id = String(row.owner_id)
|
||||
const card = cards.get(id) ?? { id, durationMs: 0, runningCount: 0, incomplete: false }
|
||||
card.durationMs += durationMs
|
||||
card.runningCount += runningCount
|
||||
card.incomplete ||= incomplete
|
||||
cards.set(id, card)
|
||||
}
|
||||
}
|
||||
result.taskDurations = [...cards.values()]
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
export class AssistantDatabase {
|
||||
private static readonly executionStatsCacheTtlMs = 30_000
|
||||
private static readonly executionStatsCacheLimit = 8
|
||||
private database?: DatabaseSync
|
||||
private activityHistoryRepository?: { database: DatabaseSync; repository: ActivityHistoryRepository }
|
||||
private executionStatsReader?: ExecutionStatsReader
|
||||
private executionTiming?: ExecutionTiming
|
||||
private readonlyReader?: ReadonlyQueryReader
|
||||
private checkpointWorker?: Worker
|
||||
private readonlyWorkerPath?: string
|
||||
@@ -2000,10 +2089,6 @@ export class AssistantDatabase {
|
||||
private channelEventWrites = 0
|
||||
private channelOutboxWrites = 0
|
||||
private modelUsageNotificationQueued = false
|
||||
private readonly executionStatsCache = new Map<
|
||||
string,
|
||||
{ value: ExecutionStats; cachedAt: number; dataVersion: number; totalChanges: number }
|
||||
>()
|
||||
|
||||
constructor(
|
||||
private readonly databasePath: string,
|
||||
@@ -2253,6 +2338,10 @@ export class AssistantDatabase {
|
||||
const recoveredAt = new Date().toISOString()
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
// A process interruption has no known end time. Retain only closed segments.
|
||||
database.exec(`UPDATE execution_timing SET running_since = NULL, incomplete = 1
|
||||
WHERE running_since IS NOT NULL`)
|
||||
this.executionTiming = new ExecutionTiming(database)
|
||||
database
|
||||
.prepare(
|
||||
`UPDATE schedule_runs
|
||||
@@ -2428,12 +2517,10 @@ export class AssistantDatabase {
|
||||
|
||||
close(): void {
|
||||
this.stopWalCheckpointWorker()
|
||||
this.executionStatsReader?.close()
|
||||
this.executionStatsReader = undefined
|
||||
this.executionTiming = undefined
|
||||
this.readonlyReader?.close()
|
||||
this.readonlyReader = undefined
|
||||
this.foldedSearchConnection = undefined
|
||||
this.executionStatsCache.clear()
|
||||
this.activityHistoryRepository = undefined
|
||||
this.database?.close()
|
||||
this.database = undefined
|
||||
@@ -2498,6 +2585,7 @@ export class AssistantDatabase {
|
||||
this.pendingMagicNoteCleanup.clear()
|
||||
this.reconcileMagicNoteFiles()
|
||||
this.options.onMagicNotesChanged?.()
|
||||
this.executionTiming?.changed()
|
||||
}
|
||||
|
||||
listProjects(includeArchived = false): AssistantProject[] {
|
||||
@@ -3027,6 +3115,7 @@ export class AssistantDatabase {
|
||||
|
||||
deleteProjectRecords(database, projectId)
|
||||
database.exec('COMMIT')
|
||||
this.executionTiming?.changed()
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
throw error
|
||||
@@ -3081,33 +3170,27 @@ export class AssistantDatabase {
|
||||
const database = this.requireDatabase()
|
||||
const conversations = this.listConversationRows()
|
||||
type MessageSummaryRow = {
|
||||
conversation_id: string; count: number; streaming: number | null
|
||||
conversation_id: string; count: number
|
||||
firstRole: ConversationMessage['role'] | null; latestMessageAt: string | null
|
||||
}
|
||||
const activeIds = new Set(
|
||||
(database.prepare(`SELECT conversation_id FROM (${activeVisibleTaskSelect})`).all() as
|
||||
{ conversation_id: string }[]).map(row => row.conversation_id)
|
||||
)
|
||||
// Batched reads (PERF-15): one aggregate for every summary and one ordered
|
||||
// scan for every detailed conversation, instead of 1-2 statements each.
|
||||
const summaries = new Map<string, MessageSummaryRow>()
|
||||
if (detailIds) {
|
||||
for (const row of database.prepare(
|
||||
`SELECT m.conversation_id AS conversation_id, count(*) AS count,
|
||||
max(m.state = 'streaming') AS streaming,
|
||||
max(m.created_at) AS latestMessageAt,
|
||||
(SELECT f.role FROM messages f WHERE f.conversation_id = m.conversation_id
|
||||
(SELECT f.role FROM messages AS f INDEXED BY messages_summary_idx WHERE f.conversation_id = m.conversation_id
|
||||
ORDER BY f.sequence LIMIT 1) AS firstRole
|
||||
FROM messages m JOIN conversations c ON c.id = m.conversation_id
|
||||
FROM messages AS m INDEXED BY messages_summary_idx JOIN conversations c ON c.id = m.conversation_id
|
||||
WHERE c.status = 'active'
|
||||
GROUP BY m.conversation_id`
|
||||
).all() as MessageSummaryRow[]) summaries.set(row.conversation_id, row)
|
||||
}
|
||||
const summaryOf = (id: string): MessageSummaryRow => summaries.get(id) ??
|
||||
{ conversation_id: id, count: 0, streaming: null, firstRole: null, latestMessageAt: null }
|
||||
{ conversation_id: id, count: 0, firstRole: null, latestMessageAt: null }
|
||||
const detailed = conversations.filter(conversation =>
|
||||
!detailIds || detailIds.has(conversation.id) || activeIds.has(conversation.id) ||
|
||||
Boolean(summaryOf(conversation.id).streaming))
|
||||
!detailIds || detailIds.has(conversation.id))
|
||||
const messagesByConversation = new Map<string, MessageRow[]>()
|
||||
if (detailed.length > 0) {
|
||||
for (const row of database.prepare(
|
||||
@@ -3315,8 +3398,8 @@ export class AssistantDatabase {
|
||||
(id, project_id, runtime_selection_json, knowledge_retrieval_mode,
|
||||
context_state_json, work_mode, title,
|
||||
branch_source_conversation_id, branch_source_title, status,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 'ask', ?, ?, ?, 'active', ?, ?)`
|
||||
created_at, updated_at, timing_incomplete)
|
||||
VALUES (?, ?, ?, ?, ?, 'ask', ?, ?, ?, 'active', ?, ?, 1)`
|
||||
)
|
||||
const insertMessage = database.prepare(
|
||||
`INSERT INTO messages
|
||||
@@ -3356,6 +3439,7 @@ export class AssistantDatabase {
|
||||
}
|
||||
}
|
||||
database.exec('COMMIT')
|
||||
this.executionTiming?.changed()
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
throw error
|
||||
@@ -3736,6 +3820,7 @@ export class AssistantDatabase {
|
||||
)
|
||||
.run(conversationId).changes === 1
|
||||
database.exec('COMMIT')
|
||||
if (deleted) this.executionTiming?.changed()
|
||||
return deleted
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
@@ -4989,54 +5074,21 @@ export class AssistantDatabase {
|
||||
return rows.map(toTask)
|
||||
}
|
||||
|
||||
getExecutionStats(
|
||||
input: ExecutionStatsInput,
|
||||
activeRequestIds: ReadonlySet<string> = new Set()
|
||||
): ExecutionStats {
|
||||
const scope = executionStatsInputSchema.parse(input)
|
||||
const cacheKey = 'conversationId' in scope
|
||||
? `conversation:${scope.conversationId}`
|
||||
: `project:${scope.projectId}`
|
||||
const database = this.requireDatabase()
|
||||
// Never retain values read from a transaction that its caller can roll back.
|
||||
if (database.isTransaction || activeRequestIds.size > 0) {
|
||||
this.executionStatsCache.clear()
|
||||
return this.readExecutionStatsSnapshot(scope, activeRequestIds)
|
||||
}
|
||||
const dataVersion = Number((database.prepare('PRAGMA data_version').get() as { data_version: number }).data_version)
|
||||
const totalChanges = Number((database.prepare('SELECT total_changes() AS total_changes').get() as { total_changes: number }).total_changes)
|
||||
const cached = activeRequestIds.size === 0
|
||||
? this.executionStatsCache.get(cacheKey)
|
||||
: undefined
|
||||
if (cached && cached.dataVersion === dataVersion && cached.totalChanges === totalChanges &&
|
||||
Date.now() - cached.cachedAt < AssistantDatabase.executionStatsCacheTtlMs) {
|
||||
this.executionStatsCache.delete(cacheKey)
|
||||
this.executionStatsCache.set(cacheKey, cached)
|
||||
return structuredClone(cached.value)
|
||||
}
|
||||
const result = this.readExecutionStatsSnapshot(scope, activeRequestIds)
|
||||
this.executionStatsCache.delete(cacheKey)
|
||||
this.executionStatsCache.set(cacheKey, {
|
||||
value: structuredClone(result), cachedAt: Date.now(), dataVersion, totalChanges
|
||||
})
|
||||
while (this.executionStatsCache.size > AssistantDatabase.executionStatsCacheLimit) {
|
||||
this.executionStatsCache.delete(this.executionStatsCache.keys().next().value!)
|
||||
}
|
||||
return result
|
||||
getExecutionStats(input: ExecutionStatsInput): ExecutionStats {
|
||||
this.requireDatabase()
|
||||
return this.executionTiming!.snapshot(executionStatsInputSchema.parse(input))
|
||||
}
|
||||
|
||||
getExecutionStatsAsync(
|
||||
input: ExecutionStatsInput,
|
||||
activeRequestIds: ReadonlySet<string>,
|
||||
workerPath: string
|
||||
): Promise<ExecutionStats> {
|
||||
this.requireDatabase()
|
||||
const scope = executionStatsInputSchema.parse(input)
|
||||
if (this.databasePath === ':memory:') {
|
||||
return Promise.resolve(this.getExecutionStats(scope, activeRequestIds))
|
||||
}
|
||||
this.executionStatsReader ??= new ExecutionStatsReader(this.databasePath, workerPath)
|
||||
return this.executionStatsReader.read(scope, activeRequestIds)
|
||||
onExecutionStatsChanged(listener: () => void): () => void {
|
||||
return this.executionTiming!.onChanged(listener)
|
||||
}
|
||||
|
||||
startExecutionTiming(taskId: string): void {
|
||||
this.executionTiming!.start(taskId)
|
||||
}
|
||||
|
||||
endExecutionTiming(taskId: string, incomplete = false): void {
|
||||
this.executionTiming!.end(taskId, incomplete)
|
||||
}
|
||||
|
||||
openReadOnly(): void {
|
||||
@@ -5044,6 +5096,13 @@ export class AssistantDatabase {
|
||||
this.database = new DatabaseSync(this.databasePath, { readOnly: true, timeout: 5_000 })
|
||||
}
|
||||
|
||||
/** Worker-only connection: no startup recovery, schema migration or Main object sharing. */
|
||||
openSupervisionWorker(): void {
|
||||
if (this.database) throw new Error('Database already open')
|
||||
this.database = new DatabaseSync(this.databasePath, { timeout: 5_000 })
|
||||
this.database.exec('PRAGMA foreign_keys = ON; PRAGMA wal_autocheckpoint = 0')
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a connection used only to checkpoint the WAL from a worker thread
|
||||
* (PERF-15). It never writes rows; see enableWalCheckpointWorker.
|
||||
@@ -5145,246 +5204,6 @@ export class AssistantDatabase {
|
||||
return this.readonlyQueryReader()
|
||||
}
|
||||
|
||||
private readExecutionStatsSnapshot(
|
||||
scope: ExecutionStatsInput,
|
||||
activeRequestIds: ReadonlySet<string>
|
||||
): ExecutionStats {
|
||||
const database = this.requireDatabase()
|
||||
if (database.isTransaction) return this.computeExecutionStats(scope, activeRequestIds)
|
||||
database.exec('BEGIN')
|
||||
try {
|
||||
const result = this.computeExecutionStats(scope, activeRequestIds)
|
||||
database.exec('COMMIT')
|
||||
return result
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private computeExecutionStats(
|
||||
scope: ExecutionStatsInput,
|
||||
activeRequestIds: ReadonlySet<string>
|
||||
): ExecutionStats {
|
||||
const database = this.requireDatabase()
|
||||
const result: ExecutionStats = {
|
||||
durationMs: 0, requestCount: 0, incompleteRequestCount: 0,
|
||||
activeRequestCount: 0, asOf: Date.now(), taskDurations: []
|
||||
}
|
||||
const scopeId = 'conversationId' in scope ? scope.conversationId : scope.projectId
|
||||
const scopeColumn = 'conversationId' in scope ? 'conversation_id' : 'project_id'
|
||||
const durations = new Map<string, ExecutionStats['taskDurations'][number]>()
|
||||
const observed = new Set<string>()
|
||||
const coverage = new Map<string, number>()
|
||||
const bucket = (conversationId: string | null, owner: string | null): string =>
|
||||
JSON.stringify([conversationId, owner])
|
||||
const add = (id: string | null, durationMs: number, incomplete: number): void => {
|
||||
if (!id || !('projectId' in scope)) return
|
||||
const entry = durations.get(id) ?? { id, durationMs: 0, incompleteRequestCount: 0 }
|
||||
entry.durationMs += durationMs
|
||||
entry.incompleteRequestCount += incomplete
|
||||
durations.set(id, entry)
|
||||
}
|
||||
// Materialize task-level evidence once. Stream/tool payloads are never parsed;
|
||||
// task_events_task_idx supplies request membership and boundary seeks.
|
||||
const rows = database.prepare(`
|
||||
WITH scoped AS MATERIALIZED (
|
||||
SELECT t.id, t.conversation_id, t.visible, t.started_at, t.remote_recoverable,
|
||||
COALESCE(parent.id, retained.id) AS schedule_task_id,
|
||||
space.kind = 'ssh' AS ssh_project,
|
||||
EXISTS (SELECT 1 FROM messages m
|
||||
WHERE m.conversation_id = t.conversation_id AND m.request_id = t.id) AS linked,
|
||||
EXISTS (SELECT 1 FROM task_events proof WHERE proof.task_id = t.id
|
||||
AND proof.kind IN ('text', 'reasoning', 'tool', 'done', 'error',
|
||||
'subagent', 'generated-image', 'question')) AS reply_event,
|
||||
EXISTS (SELECT 1 FROM task_events remote WHERE remote.task_id = t.id
|
||||
AND remote.remote_operation_id IS NOT NULL) AS remote_event,
|
||||
(SELECT tail.created_at FROM task_events tail WHERE tail.task_id = t.id
|
||||
ORDER BY tail.id DESC LIMIT 1) AS last_at
|
||||
FROM tasks t
|
||||
LEFT JOIN schedule_runs sr ON sr.id = t.id
|
||||
LEFT JOIN tasks parent ON parent.schedule_id = sr.schedule_id
|
||||
LEFT JOIN tasks retained ON retained.id = t.parent_task_id
|
||||
AND retained.origin = 'schedule' AND retained.visible = 1
|
||||
LEFT JOIN project_execution_spaces space ON space.project_id = t.project_id
|
||||
WHERE t.${scopeColumn} = ?
|
||||
AND t.schedule_id IS NULL AND t.origin != 'subagent'
|
||||
AND NOT (t.origin = 'schedule' AND t.visible = 1)
|
||||
)
|
||||
SELECT scoped.*, e.kind, e.created_at,
|
||||
CASE WHEN e.kind = 'status'
|
||||
THEN json_extract(e.payload_json, '$.status') END AS event_status,
|
||||
CASE WHEN e.kind = 'status' THEN
|
||||
json_extract(e.payload_json, '$.requestId') = scoped.id
|
||||
AND json_extract(e.payload_json, '$.type') = 'status'
|
||||
AND json_type(e.payload_json, '$.message') = 'text'
|
||||
END AS runtime_status,
|
||||
CASE WHEN e.kind = 'status' THEN (
|
||||
SELECT previous.created_at FROM task_events previous
|
||||
WHERE previous.task_id = scoped.id AND previous.id < e.id
|
||||
ORDER BY previous.id DESC LIMIT 1
|
||||
) END AS previous_at
|
||||
FROM scoped LEFT JOIN task_events e ON e.task_id = scoped.id
|
||||
AND e.kind IN ('status', 'done', 'error')
|
||||
ORDER BY scoped.id, e.id
|
||||
`).iterate(scopeId)
|
||||
let task: {
|
||||
id: string; proven: boolean; remote: boolean; incomplete: boolean
|
||||
start: number | undefined; last: number | undefined; duration: number
|
||||
conversationId: string | null; owner: string | null
|
||||
lastEvidence: number
|
||||
} | undefined
|
||||
const finish = (): void => {
|
||||
if (!task?.proven) return
|
||||
result.requestCount++
|
||||
observed.add(task.id)
|
||||
const key = bucket(task.conversationId, task.owner)
|
||||
coverage.set(key, (coverage.get(key) ?? 0) + 1)
|
||||
if (activeRequestIds.has(task.id)) result.activeRequestCount++
|
||||
if (task.remote) {
|
||||
task.duration = 0
|
||||
task.incomplete = true
|
||||
} else if (task.start !== undefined) {
|
||||
if (Number.isFinite(task.lastEvidence) && task.lastEvidence >= task.start && task.lastEvidence <= result.asOf) {
|
||||
task.last = Math.max(task.last ?? task.start, task.lastEvidence)
|
||||
}
|
||||
const live = activeRequestIds.has(task.id) && task.start <= result.asOf
|
||||
task.duration += Math.max(0, (live ? result.asOf : task.last ?? task.start) - task.start)
|
||||
task.incomplete ||= !live
|
||||
}
|
||||
result.durationMs += task.duration
|
||||
if (task.incomplete) result.incompleteRequestCount++
|
||||
add(task.owner ?? task.id, task.duration, Number(task.incomplete))
|
||||
}
|
||||
for (const raw of rows) {
|
||||
const row = raw as unknown as {
|
||||
id: string; started_at: string | null; remote_recoverable: number
|
||||
linked: number; kind: string | null; created_at: string | null
|
||||
reply_event: number; remote_event: number; last_at: string | null; previous_at: string | null
|
||||
event_status: string | null; runtime_status: number | null; ssh_project: number | null
|
||||
conversation_id: string | null; visible: number; schedule_task_id: string | null
|
||||
}
|
||||
if (task?.id !== row.id) {
|
||||
finish()
|
||||
const start = Date.parse(row.started_at ?? '')
|
||||
task = {
|
||||
id: row.id, proven: row.linked === 1 || row.reply_event === 1 || row.schedule_task_id !== null || activeRequestIds.has(row.id),
|
||||
remote: row.remote_recoverable === 1 || row.remote_event === 1 || row.ssh_project === 1,
|
||||
lastEvidence: Date.parse(row.last_at ?? ''),
|
||||
conversationId: row.conversation_id,
|
||||
owner: row.schedule_task_id ?? (row.visible === 1 ? row.id : null),
|
||||
incomplete: !Number.isFinite(start),
|
||||
start: Number.isFinite(start) ? start : undefined,
|
||||
last: Number.isFinite(start) ? start : undefined, duration: 0
|
||||
}
|
||||
}
|
||||
const current = task!
|
||||
current.proven ||= row.runtime_status === 1
|
||||
if (row.kind === null) continue
|
||||
const status = row.event_status
|
||||
// Recovery writes interrupted at restart, not when execution actually stopped.
|
||||
if (status === 'interrupted') {
|
||||
if (current.start !== undefined) {
|
||||
const previous = Date.parse(row.previous_at ?? '')
|
||||
if (Number.isFinite(previous) && previous >= current.start && previous <= result.asOf) {
|
||||
current.last = Math.max(current.last ?? current.start, previous)
|
||||
}
|
||||
current.duration += Math.max(0, (current.last ?? current.start) - current.start)
|
||||
}
|
||||
current.start = undefined
|
||||
current.last = undefined
|
||||
current.incomplete = true
|
||||
continue
|
||||
}
|
||||
const time = Date.parse(row.created_at ?? '')
|
||||
if (!Number.isFinite(time) || time > result.asOf || (current.last !== undefined && time < current.last)) {
|
||||
current.incomplete = true
|
||||
continue
|
||||
}
|
||||
if (status === 'running' && current.start === undefined) current.start = time
|
||||
const closes = row.kind === 'done' || row.kind === 'error' ||
|
||||
['completed', 'failed', 'cancelled', 'paused', 'queued', 'idle'].includes(status ?? '')
|
||||
if (closes && current.start !== undefined) {
|
||||
current.duration += time - current.start
|
||||
current.start = undefined
|
||||
}
|
||||
current.last = time
|
||||
}
|
||||
finish()
|
||||
|
||||
// A schedule run has an independent request ID. Its stable task is never a clock.
|
||||
for (const row of database.prepare(`
|
||||
SELECT t.id, t.conversation_id, t.origin, t.schedule_id,
|
||||
sr.id AS run_id, sr.status AS run_status
|
||||
FROM tasks t LEFT JOIN schedule_runs sr ON sr.schedule_id = t.schedule_id
|
||||
WHERE t.${scopeColumn} = ? AND t.visible = 1 AND t.origin != 'subagent'
|
||||
ORDER BY t.id, sr.id
|
||||
`).iterate(scopeId)) {
|
||||
const id = row.id as string
|
||||
add(id, 0, 0)
|
||||
const key = bucket(row.conversation_id as string | null, id)
|
||||
// A removed plan without retained execution links cannot establish a zero.
|
||||
if (row.origin === 'schedule' && row.schedule_id === null && !coverage.has(key)) {
|
||||
result.requestCount++
|
||||
result.incompleteRequestCount++
|
||||
add(id, 0, 1)
|
||||
coverage.set(key, 1)
|
||||
}
|
||||
if (row.run_id && ['completed', 'failed', 'running'].includes(row.run_status as string) && !observed.has(row.run_id as string)) {
|
||||
observed.add(row.run_id as string)
|
||||
result.requestCount++
|
||||
result.incompleteRequestCount++
|
||||
add(id, 0, 1)
|
||||
coverage.set(key, (coverage.get(key) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
// Local snapshots lack request IDs. Compare top-level assistant rows per
|
||||
// conversation/task, never nested subagent blocks or child-task messages.
|
||||
const messages = database.prepare(`
|
||||
SELECT m.conversation_id, m.request_id,
|
||||
COALESCE(parent.id, retained.id, json_extract(m.metadata_json, '$.task.id'),
|
||||
CASE WHEN mt.visible = 1 THEN mt.id END) AS owner,
|
||||
COUNT(*) AS count
|
||||
FROM messages m JOIN conversations c ON c.id = m.conversation_id
|
||||
LEFT JOIN tasks mt ON mt.id = m.request_id
|
||||
LEFT JOIN schedule_runs sr ON sr.id = m.request_id
|
||||
LEFT JOIN tasks parent ON parent.schedule_id = sr.schedule_id
|
||||
LEFT JOIN tasks retained ON retained.id = mt.parent_task_id
|
||||
AND retained.origin = 'schedule' AND retained.visible = 1
|
||||
WHERE ${'conversationId' in scope ? 'm.conversation_id' : 'c.project_id'} = ?
|
||||
AND m.role = 'assistant'
|
||||
AND NOT (c.channel IS NULL AND m.sequence = 0 AND m.request_id IS NULL
|
||||
AND json_extract(m.metadata_json, '$.task.id') IS NULL
|
||||
AND m.content IN (?, ?))
|
||||
AND COALESCE(mt.origin, '') != 'subagent'
|
||||
AND NOT EXISTS (SELECT 1 FROM tasks child WHERE child.origin = 'subagent'
|
||||
AND child.id = json_extract(m.metadata_json, '$.task.id'))
|
||||
GROUP BY m.conversation_id, owner, m.request_id
|
||||
ORDER BY m.request_id IS NULL
|
||||
`).all(
|
||||
scopeId,
|
||||
// Persisted greetings have no marker. Match only shipped default copy,
|
||||
// not arbitrary assistant-only/imported history or localized prefixes.
|
||||
'你好,我是 GoodBuddy。你可以直接向我提问、添加本地文件,或使用知识库整理和检索信息。需要我操作文件或调用工具时,请选择合适的 Agent Runtime 和工作模式。',
|
||||
'Hi, I’m GoodBuddy. Ask me a question, add local files, or use your knowledge base to organize and retrieve information. When you want me to operate on files or use tools, choose the appropriate Agent Runtime and work mode.'
|
||||
)
|
||||
for (const row of messages) {
|
||||
const owner = row.owner as string | null
|
||||
const key = bucket(row.conversation_id as string, owner)
|
||||
const available = coverage.get(key) ?? 0
|
||||
const linked = row.request_id as string | null
|
||||
const count = linked ? 1 : row.count as number
|
||||
const covered = linked ? Number(observed.has(linked)) : Math.min(count, available)
|
||||
coverage.set(key, Math.max(0, available - covered))
|
||||
const missing = count - covered
|
||||
result.requestCount += missing
|
||||
result.incompleteRequestCount += missing
|
||||
add(owner, 0, missing)
|
||||
}
|
||||
result.taskDurations = [...durations.values()]
|
||||
return result
|
||||
}
|
||||
|
||||
/** Activity history storage and queries (PERF-15); the SQL lives in the repository. */
|
||||
activityHistory(): ActivityHistoryRepository {
|
||||
const database = this.requireDatabase()
|
||||
@@ -5986,6 +5805,7 @@ export class AssistantDatabase {
|
||||
`UPDATE conversations SET updated_at = ? WHERE id = ?`
|
||||
)
|
||||
.run(now, owner.conversation_id)
|
||||
this.executionTiming?.state(taskId, status)
|
||||
database.exec('COMMIT')
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
@@ -6277,6 +6097,7 @@ export class AssistantDatabase {
|
||||
throw new Error('任务不存在')
|
||||
}
|
||||
this.appendTaskEvent(taskId, 'status', { status, error })
|
||||
this.executionTiming?.state(taskId, status)
|
||||
}
|
||||
|
||||
resolveAssistantSuggestionTask(
|
||||
@@ -6860,6 +6681,7 @@ export class AssistantDatabase {
|
||||
if (taskUpdate.changes !== 1) {
|
||||
throw new Error('远程终态无法更新所属任务')
|
||||
}
|
||||
this.executionTiming?.state(event.taskId, taskStatus)
|
||||
}
|
||||
|
||||
private insertRemoteTaskEventOnce(
|
||||
@@ -12400,6 +12222,32 @@ export class AssistantDatabase {
|
||||
COMMIT;
|
||||
`)
|
||||
}
|
||||
if (version.user_version < 59) {
|
||||
database.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const columns = database.prepare('PRAGMA table_info(conversations)').all()
|
||||
if (!columns.some(column => column.name === 'timing_incomplete')) {
|
||||
database.exec(`
|
||||
CREATE TABLE execution_timing (
|
||||
task_id TEXT PRIMARY KEY REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
project_id TEXT, conversation_id TEXT, owner_id TEXT,
|
||||
duration_ms INTEGER NOT NULL DEFAULT 0,
|
||||
running_since INTEGER,
|
||||
incomplete INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX execution_timing_project ON execution_timing(project_id);
|
||||
CREATE INDEX execution_timing_conversation ON execution_timing(conversation_id);
|
||||
INSERT INTO execution_timing (task_id, project_id, conversation_id, owner_id, incomplete)
|
||||
SELECT id, project_id, conversation_id, id, 1 FROM tasks WHERE visible = 1 AND origin != 'subagent';
|
||||
ALTER TABLE conversations ADD COLUMN timing_incomplete INTEGER NOT NULL DEFAULT 0;
|
||||
UPDATE conversations SET timing_incomplete = 1;
|
||||
CREATE INDEX conversations_timing_incomplete ON conversations(project_id) WHERE timing_incomplete = 1;
|
||||
CREATE INDEX messages_summary_idx ON messages(conversation_id, sequence, role, created_at);
|
||||
`)
|
||||
}
|
||||
database.exec('PRAGMA user_version = 59; COMMIT;')
|
||||
} catch (error) { database.exec('ROLLBACK'); throw error }
|
||||
}
|
||||
}
|
||||
|
||||
supervisionExperiences(): SupervisionExperienceStore {
|
||||
|
||||
@@ -1,445 +1,257 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AssistantDatabase } from './assistant-database'
|
||||
|
||||
describe('execution duration evidence', () => {
|
||||
describe('persisted running-state duration', () => {
|
||||
let directory: string
|
||||
let path: string
|
||||
let database: AssistantDatabase
|
||||
let projectId: string
|
||||
let conversationId: string
|
||||
const epoch = Date.parse('2026-09-17T00:00:00Z')
|
||||
const at = (seconds: number): void => { vi.setSystemTime(epoch + seconds * 1000) }
|
||||
const request = (overrides: Partial<Parameters<AssistantDatabase['createTask']>[0]> = {}): string => {
|
||||
const id = randomUUID()
|
||||
database.createTask({ id, projectId, conversationId, title: 'Reply', instructions: 'Reply', workMode: 'ask', visible: false, ...overrides })
|
||||
let now: number
|
||||
const raw = (): DatabaseSync => (database as unknown as { database: DatabaseSync }).database
|
||||
const stats = () => database.getExecutionStats({ conversationId })
|
||||
const task = (options: Partial<Parameters<AssistantDatabase['createTask']>[0]> = {}): string => {
|
||||
const id = options.id ?? randomUUID()
|
||||
database.createTask({ id, projectId, conversationId, title: 'Reply', instructions: 'Reply',
|
||||
workMode: 'ask', visible: false, ...options })
|
||||
return id
|
||||
}
|
||||
const text = (id: string): void => database.appendTaskEvent(id, 'text', { requestId: id, type: 'text', delta: 'Reply' })
|
||||
|
||||
beforeEach(async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'goodbuddy-execution-stats-'))
|
||||
database = new AssistantDatabase(join(directory, 'assistant.sqlite'))
|
||||
directory = await mkdtemp(join(tmpdir(), 'goodbuddy-timing-'))
|
||||
path = join(directory, 'assistant.sqlite')
|
||||
database = new AssistantDatabase(path)
|
||||
database.initialize(directory)
|
||||
projectId = database.listProjects()[0]!.id
|
||||
conversationId = randomUUID()
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
at(0)
|
||||
})
|
||||
afterEach(async () => {
|
||||
database.close()
|
||||
vi.useRealTimers()
|
||||
await rm(directory, { recursive: true, force: true })
|
||||
now = Date.now()
|
||||
vi.spyOn(Date, 'now').mockImplementation(() => now)
|
||||
database.saveLocalConversations([{ header: { id: conversationId, projectId, title: 'Timing', updatedAt: now }, messages: [] }])
|
||||
})
|
||||
afterEach(async () => { database.close(); vi.restoreAllMocks(); await rm(directory, { recursive: true, force: true }) })
|
||||
|
||||
it('sums hidden replies without round idle, counts approval/tool time, and scopes projects', () => {
|
||||
const first = request()
|
||||
text(first)
|
||||
at(2)
|
||||
database.updateTaskStatus(first, 'waiting_approval')
|
||||
at(8)
|
||||
database.updateTaskStatus(first, 'running')
|
||||
at(10)
|
||||
database.updateTaskStatus(first, 'completed')
|
||||
at(100)
|
||||
const second = request()
|
||||
text(second)
|
||||
at(105)
|
||||
database.updateTaskStatus(second, 'failed')
|
||||
const anotherConversation = request({ conversationId: randomUUID() })
|
||||
text(anotherConversation)
|
||||
at(108)
|
||||
database.updateTaskStatus(anotherConversation, 'cancelled')
|
||||
const unrelated = request({ projectId: undefined, conversationId: randomUUID() })
|
||||
text(unrelated)
|
||||
at(120)
|
||||
database.updateTaskStatus(unrelated, 'completed')
|
||||
expect(database.listTasks()).toEqual([])
|
||||
expect(database.getExecutionStats({ conversationId })).toEqual({
|
||||
durationMs: 15000, requestCount: 2, incompleteRequestCount: 0,
|
||||
activeRequestCount: 0, asOf: epoch + 120000, taskDurations: []
|
||||
})
|
||||
expect(database.getExecutionStats({ projectId })).toMatchObject({ durationMs: 18000, requestCount: 3 })
|
||||
expect(database.getExecutionStats({ conversationId: randomUUID() })).toMatchObject({ durationMs: 0, requestCount: 0 })
|
||||
})
|
||||
|
||||
it('invalidates cached scopes for local messages, remote events and external deletion', () => {
|
||||
expect(database.getExecutionStats({ conversationId }).requestCount).toBe(0)
|
||||
database.saveLocalConversations([{
|
||||
header: { id: conversationId, projectId, title: 'History', updatedAt: epoch },
|
||||
messages: [{ id: randomUUID(), role: 'assistant', content: 'Retained', state: 'complete', createdAt: epoch }]
|
||||
}])
|
||||
expect(database.getExecutionStats({ conversationId }).requestCount).toBe(1)
|
||||
const id = request()
|
||||
database.getExecutionStats({ projectId })
|
||||
database.appendRemoteTaskEventOnce({ taskId: id, bindingId: randomUUID(), operationId: randomUUID(), semanticSequence: '1', eventIndex: 0, kind: 'done', payload: { requestId: id, type: 'done' } })
|
||||
expect(database.getExecutionStats({ projectId }).incompleteRequestCount).toBe(1)
|
||||
const raw = new DatabaseSync(join(directory, 'assistant.sqlite'))
|
||||
try {
|
||||
raw.prepare('DELETE FROM messages WHERE conversation_id = ?').run(conversationId)
|
||||
raw.prepare('DELETE FROM tasks WHERE id = ?').run(id)
|
||||
expect(database.getExecutionStats({ conversationId }).requestCount).toBe(0)
|
||||
expect(database.getExecutionStats({ projectId }).requestCount).toBe(0)
|
||||
} finally { raw.close() }
|
||||
})
|
||||
|
||||
it('does not cache transaction snapshots across rollback, or serve a closed connection', () => {
|
||||
const connection = (database as unknown as { database: DatabaseSync }).database
|
||||
const id = request()
|
||||
expect(database.getExecutionStats({ conversationId }).requestCount).toBe(0)
|
||||
connection.exec('BEGIN')
|
||||
text(id)
|
||||
expect(database.getExecutionStats({ conversationId }).requestCount).toBe(1)
|
||||
connection.exec('ROLLBACK')
|
||||
expect(database.getExecutionStats({ conversationId }).requestCount).toBe(0)
|
||||
database.close()
|
||||
expect(() => database.getExecutionStats({ conversationId })).toThrow()
|
||||
database.initialize(directory)
|
||||
expect(database.getExecutionStats({ conversationId }).requestCount).toBe(0)
|
||||
})
|
||||
|
||||
it('bounds cache scopes, expires idle entries and isolates returned objects', () => {
|
||||
const prepare = vi.spyOn(DatabaseSync.prototype, 'prepare')
|
||||
const reads = (): number => prepare.mock.calls.filter(([sql]) => sql.includes('WITH scoped AS MATERIALIZED')).length
|
||||
try {
|
||||
const first = database.getExecutionStats({ projectId })
|
||||
first.requestCount = 999
|
||||
expect(database.getExecutionStats({ projectId }).requestCount).toBe(0)
|
||||
expect(reads()).toBe(1)
|
||||
for (let index = 0; index < 8; index++) database.getExecutionStats({ conversationId: randomUUID() })
|
||||
database.getExecutionStats({ projectId })
|
||||
expect(reads()).toBe(10)
|
||||
at(31)
|
||||
database.getExecutionStats({ projectId })
|
||||
expect(reads()).toBe(11)
|
||||
} finally { prepare.mockRestore() }
|
||||
})
|
||||
|
||||
it('excludes stable schedules, nested experts, and status-only maintenance but includes scheduled reply requests', () => {
|
||||
const schedule = database.createSchedule({ projectId, title: 'Schedule', prompt: 'Reply', workMode: 'ask', recurrence: 'daily', nextRunAt: new Date(epoch).toISOString() })
|
||||
database.updateTaskStatus(schedule.taskId, 'running')
|
||||
text(schedule.taskId)
|
||||
const child = request({ origin: 'subagent', parentTaskId: schedule.taskId })
|
||||
text(child)
|
||||
const compact = request({ title: 'Compressed context' })
|
||||
const actual = request({ conversationId: schedule.conversationId, parentTaskId: schedule.taskId })
|
||||
text(actual)
|
||||
at(10)
|
||||
for (const id of [schedule.taskId, child, compact, actual]) database.updateTaskStatus(id, 'completed')
|
||||
expect(database.getExecutionStats({ projectId })).toMatchObject({ durationMs: 10000, requestCount: 1, incompleteRequestCount: 0 })
|
||||
})
|
||||
|
||||
it('uses message request identity when a reply has no runtime events and does not use message timestamps', () => {
|
||||
const id = request()
|
||||
database.saveLocalConversations([{
|
||||
header: { id: conversationId, projectId, title: 'Chat', updatedAt: epoch },
|
||||
messages: [{ id: randomUUID(), role: 'assistant', content: '', state: 'error', createdAt: epoch - 86400000 }]
|
||||
}])
|
||||
// Only persisted request links are evidence; local snapshot saves do not supply them.
|
||||
const raw = new DatabaseSync(join(directory, 'assistant.sqlite'))
|
||||
raw.prepare('UPDATE messages SET request_id = ? WHERE conversation_id = ?').run(id, conversationId)
|
||||
raw.close()
|
||||
at(3)
|
||||
database.updateTaskStatus(id, 'failed')
|
||||
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 3000, requestCount: 1, incompleteRequestCount: 0 })
|
||||
})
|
||||
|
||||
it('keeps only evidenced time for open and restart-interrupted replies', () => {
|
||||
const id = request()
|
||||
at(5)
|
||||
text(id)
|
||||
at(100)
|
||||
expect(database.getExecutionStats({ conversationId }, new Set([id]))).toMatchObject({ durationMs: 100000, requestCount: 1, incompleteRequestCount: 0, activeRequestCount: 1 })
|
||||
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 5000, incompleteRequestCount: 1, activeRequestCount: 0 })
|
||||
database.close()
|
||||
at(86400)
|
||||
database.initialize(directory)
|
||||
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 5000, requestCount: 1, incompleteRequestCount: 1, activeRequestCount: 0 })
|
||||
// A later resumed segment cannot include the shutdown/restart gap.
|
||||
at(86500)
|
||||
it('accumulates only running segments across pause, approval, queue, resume and completion', () => {
|
||||
const id = task({ visible: true })
|
||||
database.startExecutionTiming(id)
|
||||
now += 2000
|
||||
database.updateTaskStatus(id, 'running')
|
||||
at(86510)
|
||||
database.updateTaskStatus(id, 'completed')
|
||||
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 15000, incompleteRequestCount: 1 })
|
||||
})
|
||||
|
||||
it('segments repeated runs and pauses without using the final completed_at span', () => {
|
||||
const id = request()
|
||||
text(id)
|
||||
at(5)
|
||||
now += 1000
|
||||
database.updateTaskStatus(id, 'paused')
|
||||
at(100)
|
||||
now += 90_000
|
||||
expect(stats()).toMatchObject({ durationMs: 3000, runningCount: 0, incomplete: false })
|
||||
database.updateTaskStatus(id, 'running')
|
||||
at(107)
|
||||
database.updateTaskStatus(id, 'completed')
|
||||
at(200)
|
||||
now += 4000
|
||||
database.updateTaskStatus(id, 'waiting_approval')
|
||||
now += 60_000
|
||||
database.updateTaskStatus(id, 'queued')
|
||||
database.updateTaskStatus(id, 'running')
|
||||
at(203)
|
||||
now += 2000
|
||||
expect(stats()).toMatchObject({ durationMs: 9000, runningCount: 1 })
|
||||
database.updateTaskStatus(id, 'completed')
|
||||
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 15000, requestCount: 1, incompleteRequestCount: 0 })
|
||||
database.endExecutionTiming(id)
|
||||
now += 60_000
|
||||
expect(stats()).toMatchObject({ durationMs: 9000, runningCount: 0 })
|
||||
expect(database.getExecutionStats({ projectId }).taskDurations).toEqual([
|
||||
{ id, durationMs: 9000, runningCount: 0, incomplete: false }
|
||||
])
|
||||
})
|
||||
|
||||
it('does not substitute remote replay receipt time or unknown starts for actual execution time', () => {
|
||||
const remote = request()
|
||||
at(86400)
|
||||
database.appendRemoteTaskEventOnce({ taskId: remote, bindingId: randomUUID(), operationId: randomUUID(), semanticSequence: '1', eventIndex: 0, kind: 'done', payload: { requestId: remote, type: 'done' } })
|
||||
database.updateTaskStatus(remote, 'completed')
|
||||
const unknown = request()
|
||||
text(unknown)
|
||||
at(86410)
|
||||
database.updateTaskStatus(unknown, 'completed')
|
||||
const raw = new DatabaseSync(join(directory, 'assistant.sqlite'))
|
||||
raw.prepare('UPDATE tasks SET started_at = NULL WHERE id = ?').run(unknown)
|
||||
raw.close()
|
||||
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 0, requestCount: 2, incompleteRequestCount: 2 })
|
||||
it.each(['failed', 'cancelled', 'interrupted'] as const)('closes on %s and cannot resume after lease release', (status) => {
|
||||
const id = task()
|
||||
database.startExecutionTiming(id)
|
||||
now += 3000
|
||||
database.updateTaskStatus(id, status)
|
||||
database.endExecutionTiming(id)
|
||||
now += 30_000
|
||||
database.updateTaskStatus(id, 'running')
|
||||
expect(stats()).toMatchObject({ durationMs: 3000, runningCount: 0 })
|
||||
})
|
||||
|
||||
it('includes all retained requests beyond list pagination', () => {
|
||||
for (let index = 0; index < 105; index++) {
|
||||
at(index * 10)
|
||||
const id = request()
|
||||
text(id)
|
||||
at(index * 10 + 1)
|
||||
database.updateTaskStatus(id, 'completed')
|
||||
}
|
||||
expect(database.getExecutionStats({ projectId })).toMatchObject({ durationMs: 105000, requestCount: 105, incompleteRequestCount: 0 })
|
||||
it.each(['waiting_approval', 'paused', 'queued'] as const)('registers a new %s lease without opening a segment until running', (status) => {
|
||||
const id = task({ status })
|
||||
database.startExecutionTiming(id)
|
||||
now += 60_000
|
||||
expect(stats()).toMatchObject({ durationMs: 0, runningCount: 0 })
|
||||
database.updateTaskStatus(id, 'running')
|
||||
now += 3000
|
||||
database.endExecutionTiming(id)
|
||||
expect(stats()).toMatchObject({ durationMs: 3000, runningCount: 0 })
|
||||
})
|
||||
|
||||
it('groups real schedule request IDs into stable cards across runs without parent lifecycle time', () => {
|
||||
const schedule = database.createSchedule({ projectId, title: 'Schedule', prompt: 'Reply', workMode: 'ask', recurrence: 'daily', nextRunAt: new Date(epoch).toISOString() })
|
||||
const idle = database.createSchedule({ projectId, conversationId: schedule.conversationId, title: 'Idle', prompt: 'Reply', workMode: 'ask', recurrence: 'daily', nextRunAt: new Date(epoch + 86400000).toISOString() })
|
||||
for (const start of [100, 1000]) {
|
||||
at(start)
|
||||
const queued = database.queueScheduleNow(schedule.id)
|
||||
database.claimConversationQueueItem(schedule.conversationId)
|
||||
database.completeConversationUserQueueItem(queued.id)
|
||||
// Production agentRun uses scheduleRunId as requestId, without parentTaskId.
|
||||
const id = queued.scheduleRunId!
|
||||
request({ id, conversationId: schedule.conversationId })
|
||||
database.updateTaskStatus(schedule.taskId, 'running')
|
||||
text(id)
|
||||
at(start + 10)
|
||||
database.updateTaskStatus(id, 'completed')
|
||||
database.completeTaskScheduleRun(id)
|
||||
}
|
||||
const live = database.queueScheduleNow(schedule.id)
|
||||
database.claimConversationQueueItem(schedule.conversationId)
|
||||
request({ id: live.scheduleRunId!, conversationId: schedule.conversationId })
|
||||
text(live.scheduleRunId!)
|
||||
at(1025)
|
||||
const stats = database.getExecutionStats({ projectId }, new Set([live.scheduleRunId!]))
|
||||
expect(stats).toMatchObject({ durationMs: 35000, requestCount: 3, incompleteRequestCount: 0, activeRequestCount: 1 })
|
||||
expect(stats.taskDurations).toEqual(expect.arrayContaining([
|
||||
{ id: schedule.taskId, durationMs: 35000, incompleteRequestCount: 0 },
|
||||
{ id: idle.taskId, durationMs: 0, incompleteRequestCount: 0 }
|
||||
]))
|
||||
expect(stats.taskDurations.some(item => item.id === live.scheduleRunId)).toBe(false)
|
||||
it('stops immediately on lease abort even if task cleanup is delayed', () => {
|
||||
const id = task()
|
||||
database.startExecutionTiming(id)
|
||||
now += 5000
|
||||
database.endExecutionTiming(id)
|
||||
now += 60_000
|
||||
database.updateTaskStatus(id, 'running')
|
||||
database.updateTaskStatus(id, 'cancelled')
|
||||
expect(stats()).toMatchObject({ durationMs: 5000, runningCount: 0 })
|
||||
})
|
||||
|
||||
it.each([false, true])('retains schedule execution totals after plan deletion (repair old link: %s)', (oldLink) => {
|
||||
const schedule = database.createSchedule({ projectId, title: 'Removable plan', prompt: 'Reply', workMode: 'ask', recurrence: 'daily', nextRunAt: new Date(epoch).toISOString() })
|
||||
at(100)
|
||||
const run = database.queueScheduleNow(schedule.id)
|
||||
database.claimConversationQueueItem(schedule.conversationId)
|
||||
const task = database.createTask({ id: run.scheduleRunId!, projectId, conversationId: schedule.conversationId, title: 'Run', instructions: 'Reply', workMode: 'ask', visible: false })
|
||||
expect(task.parentTaskId).toBe(schedule.taskId)
|
||||
text(task.id)
|
||||
at(110)
|
||||
database.updateTaskStatus(task.id, 'completed')
|
||||
database.completeTaskScheduleRun(task.id)
|
||||
if (oldLink) {
|
||||
const raw = new DatabaseSync(join(directory, 'assistant.sqlite'))
|
||||
raw.prepare('UPDATE tasks SET parent_task_id = NULL WHERE id = ?').run(task.id)
|
||||
raw.close()
|
||||
}
|
||||
const before = database.getExecutionStats({ projectId })
|
||||
at(10000)
|
||||
database.removeSchedule(schedule.id)
|
||||
expect(database.getExecutionStats({ projectId })).toEqual({ ...before, asOf: epoch + 10000000 })
|
||||
expect(before.taskDurations).toEqual([{ id: schedule.taskId, durationMs: 10000, incompleteRequestCount: 0 }])
|
||||
it('does not write or read history on snapshots, tokens, or repeated running notifications', async () => {
|
||||
const id = task()
|
||||
database.startExecutionTiming(id)
|
||||
await Promise.resolve()
|
||||
const changed = vi.fn()
|
||||
const unsubscribe = database.onExecutionStatsChanged(changed)
|
||||
const before = raw().prepare('SELECT total_changes() AS n').get()!.n
|
||||
const prepare = vi.spyOn(raw(), 'prepare')
|
||||
for (let tick = 0; tick < 10; tick++) { now += 1000; stats(); database.getExecutionStats({ projectId }) }
|
||||
expect(raw().prepare('SELECT total_changes() AS n').get()!.n).toBe(before)
|
||||
expect(prepare.mock.calls.map(([sql]) => sql).join('\n')).not.toMatch(/\b(messages|task_events|tools)\b/)
|
||||
database.appendTaskEvent(id, 'text', { requestId: id, type: 'text', delta: 'Token' })
|
||||
database.updateTaskStatus(id, 'running')
|
||||
await Promise.resolve()
|
||||
expect(changed).not.toHaveBeenCalled()
|
||||
database.updateTaskStatus(id, 'paused')
|
||||
await Promise.resolve()
|
||||
expect(changed).toHaveBeenCalledOnce()
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('retains closed intervals and marks the open interval unknown after restart', () => {
|
||||
const id = task()
|
||||
database.startExecutionTiming(id)
|
||||
now += 2000
|
||||
database.updateTaskStatus(id, 'paused')
|
||||
database.updateTaskStatus(id, 'running')
|
||||
now += 5000
|
||||
database.close()
|
||||
now += 86_400_000
|
||||
database.initialize(directory)
|
||||
expect(database.getExecutionStats({ projectId }).taskDurations).toEqual(before.taskDurations)
|
||||
expect(stats()).toMatchObject({ durationMs: 2000, runningCount: 0, incomplete: true })
|
||||
database.startExecutionTiming(id)
|
||||
database.updateTaskStatus(id, 'running')
|
||||
now += 3000
|
||||
database.endExecutionTiming(id)
|
||||
expect(stats()).toMatchObject({ durationMs: 5000, runningCount: 0, incomplete: true })
|
||||
})
|
||||
|
||||
it('does not claim a complete zero for an already removed plan with no retained execution relation', () => {
|
||||
const schedule = database.createSchedule({ projectId, title: 'Old removed plan', prompt: 'Reply', workMode: 'ask', recurrence: 'daily', nextRunAt: new Date(epoch).toISOString() })
|
||||
database.removeSchedule(schedule.id)
|
||||
expect(database.getExecutionStats({ projectId }).taskDurations).toEqual([{ id: schedule.taskId, durationMs: 0, incompleteRequestCount: 1 }])
|
||||
})
|
||||
|
||||
it('recognizes runtime status-only replies but not maintenance statuses at restart', () => {
|
||||
const id = request()
|
||||
const maintenance = request({ title: 'Maintenance' })
|
||||
at(5)
|
||||
database.appendTaskEvent(id, 'status', { requestId: id, type: 'status', message: 'Processing request' })
|
||||
database.updateTaskStatus(maintenance, 'running')
|
||||
database.close()
|
||||
at(10000)
|
||||
database.initialize(directory)
|
||||
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 5000, requestCount: 1, incompleteRequestCount: 1 })
|
||||
})
|
||||
|
||||
it('marks SSH channel timing unknown before the first remote provenance and keeps that result on replay', () => {
|
||||
const project = database.createSshProject({
|
||||
project: { name: 'SSH project', description: '', rootPath: '/srv/workspace', defaultWorkMode: 'execute', runtimeSelection: { provider: 'opencode' } },
|
||||
executionSpace: { kind: 'ssh', hostId: randomUUID(), remoteRootPath: '/srv/workspace' },
|
||||
assertCurrent: () => {}
|
||||
})
|
||||
const conversation = database.getOrCreateRemoteConversation({ projectId: project.id, channel: 'wecom', accountId: 'account', externalConversationId: 'external', conversationType: 'direct', title: 'SSH channel', accountDisplay: 'Account' })
|
||||
const id = request({ projectId: project.id, conversationId: conversation.id, origin: 'delegation' })
|
||||
at(5)
|
||||
database.appendTaskEvent(id, 'status', { requestId: id, type: 'status', message: 'Remote Agent is processing' })
|
||||
const expected = { durationMs: 0, requestCount: 1, incompleteRequestCount: 1, activeRequestCount: 1 }
|
||||
expect(database.getExecutionStats({ projectId: project.id }, new Set([id]))).toMatchObject(expected)
|
||||
at(100)
|
||||
database.appendRemoteTaskEventOnce({ taskId: id, bindingId: randomUUID(), operationId: randomUUID(), semanticSequence: '1', eventIndex: 0, kind: 'text', payload: { requestId: id, type: 'text', delta: 'Reply' } })
|
||||
expect(database.getExecutionStats({ projectId: project.id }, new Set([id]))).toMatchObject(expected)
|
||||
database.updateTaskStatus(id, 'completed')
|
||||
expect(database.getExecutionStats({ conversationId: conversation.id })).toMatchObject({ ...expected, activeRequestCount: 0 })
|
||||
})
|
||||
|
||||
it('marks retained assistant replies without request history incomplete and ignores nested subagent content', () => {
|
||||
const known = request()
|
||||
text(known)
|
||||
at(2)
|
||||
database.updateTaskStatus(known, 'completed')
|
||||
const child = request({ origin: 'subagent', parentTaskId: known })
|
||||
text(child)
|
||||
it('excludes maintenance and nested tasks and sums concurrent replies across conversations', () => {
|
||||
const maintenance = task()
|
||||
const child = task({ origin: 'subagent' })
|
||||
database.startExecutionTiming(child)
|
||||
const first = task()
|
||||
database.startExecutionTiming(first)
|
||||
const otherConversation = randomUUID()
|
||||
const second = task({ conversationId: otherConversation })
|
||||
database.startExecutionTiming(second)
|
||||
now += 3000
|
||||
database.updateTaskStatus(maintenance, 'completed')
|
||||
database.updateTaskStatus(child, 'completed')
|
||||
const childMessage = randomUUID()
|
||||
database.saveLocalConversations([{
|
||||
header: { id: conversationId, projectId, title: 'Retained', updatedAt: epoch },
|
||||
messages: [
|
||||
{ id: randomUUID(), role: 'assistant', content: 'Known reply', state: 'complete', createdAt: epoch, subagents: [] },
|
||||
{ id: randomUUID(), role: 'assistant', content: 'Older reply without a task', state: 'complete', createdAt: epoch },
|
||||
{ id: childMessage, role: 'assistant', content: 'Child reply', state: 'complete', createdAt: epoch }
|
||||
]
|
||||
}])
|
||||
const raw = new DatabaseSync(join(directory, 'assistant.sqlite'))
|
||||
raw.prepare('UPDATE messages SET request_id = ? WHERE id = ?').run(child, childMessage)
|
||||
raw.close()
|
||||
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 2000, requestCount: 2, incompleteRequestCount: 1 })
|
||||
expect(database.getExecutionStats({ projectId })).toMatchObject({ durationMs: 2000, requestCount: 2, incompleteRequestCount: 1 })
|
||||
expect(stats()).toMatchObject({ durationMs: 3000, runningCount: 1 })
|
||||
expect(database.getExecutionStats({ projectId })).toMatchObject({ durationMs: 6000, runningCount: 2 })
|
||||
})
|
||||
|
||||
it('flags missing schedule execution once even when its assistant message is retained', () => {
|
||||
const schedule = database.createSchedule({ projectId, title: 'Lost run', prompt: 'Reply', workMode: 'ask', recurrence: 'daily', nextRunAt: new Date(epoch).toISOString() })
|
||||
const queued = database.queueScheduleNow(schedule.id)
|
||||
database.claimConversationQueueItem(schedule.conversationId)
|
||||
database.completeScheduleRun(queued.scheduleRunId!, 'completed')
|
||||
database.saveLocalConversations([{
|
||||
header: { id: schedule.conversationId, projectId, title: 'Lost run', updatedAt: epoch },
|
||||
messages: [{ id: randomUUID(), role: 'assistant', content: 'Retained result', state: 'complete', createdAt: epoch, task: { id: schedule.taskId, title: 'Lost run' } }]
|
||||
}])
|
||||
expect(database.getExecutionStats({ projectId })).toMatchObject({
|
||||
durationMs: 0, requestCount: 1, incompleteRequestCount: 1,
|
||||
taskDurations: [{ id: schedule.taskId, durationMs: 0, incompleteRequestCount: 1 }]
|
||||
it.each(['running', 'waiting_approval'] as const)('recovers remote %s timing without counting known waiting or changing terminal checkpoints', (status) => {
|
||||
const project = database.createSshProject({
|
||||
project: { name: 'Remote', description: '', rootPath: '/srv/project', defaultWorkMode: 'execute' },
|
||||
executionSpace: { kind: 'ssh', hostId: randomUUID(), remoteRootPath: '/srv/project' }, assertCurrent: () => {}
|
||||
})
|
||||
const remoteConversationId = randomUUID()
|
||||
database.saveLocalConversations([{ header: { id: remoteConversationId, projectId: project.id, title: 'Remote', updatedAt: now }, messages: [] }])
|
||||
const assistantMessageId = randomUUID()
|
||||
const id = task({ projectId: project.id, conversationId: remoteConversationId, remoteRecovery: {
|
||||
recoverable: true, currentUserMessageId: randomUUID(), currentAssistantMessageId: assistantMessageId
|
||||
} })
|
||||
database.startExecutionTiming(id)
|
||||
now += 2000
|
||||
database.updateTaskStatus(id, 'paused')
|
||||
database.updateTaskStatus(id, status)
|
||||
database.close()
|
||||
now += 86_400_000
|
||||
database.initialize(directory)
|
||||
database.startExecutionTiming(id)
|
||||
if (status === 'waiting_approval') {
|
||||
now += 60_000
|
||||
expect(database.getTask(id).status).toBe('waiting_approval')
|
||||
expect(database.getExecutionStats({ conversationId: remoteConversationId })).toMatchObject({
|
||||
durationMs: 2000, runningCount: 0, incomplete: false
|
||||
})
|
||||
database.startExecutionTiming(id)
|
||||
now += 60_000
|
||||
expect(database.getExecutionStats({ conversationId: remoteConversationId }).durationMs).toBe(2000)
|
||||
database.updateTaskStatus(id, 'running')
|
||||
}
|
||||
now += 3000
|
||||
const bindingId = randomUUID()
|
||||
const operationId = randomUUID()
|
||||
const append = (eventIndex: number, type: 'done' | 'remote-semantic-checkpoint') => database.appendRemoteConversationTaskEventOnce({
|
||||
taskId: id, conversationId: remoteConversationId, assistantMessageId,
|
||||
bindingId, operationId, semanticSequence: '1', eventIndex, event: { type, requestId: id }
|
||||
})
|
||||
expect(append(0, 'done')).toBe(true)
|
||||
expect(database.getExecutionStats({ conversationId: remoteConversationId })).toMatchObject({
|
||||
durationMs: 5000, runningCount: 1, incomplete: status === 'running'
|
||||
})
|
||||
expect(append(1, 'remote-semantic-checkpoint')).toBe(true)
|
||||
now += 60_000
|
||||
expect(append(0, 'done')).toBe(false)
|
||||
expect(append(1, 'remote-semantic-checkpoint')).toBe(false)
|
||||
expect(database.getTask(id).status).toBe('completed')
|
||||
expect(database.getExecutionStats({ conversationId: remoteConversationId })).toMatchObject({
|
||||
durationMs: 5000, runningCount: 0, incomplete: status === 'running'
|
||||
})
|
||||
})
|
||||
|
||||
it('cannot make a remote or missing-start request complete just because it has a live lease', () => {
|
||||
const remote = request()
|
||||
database.appendRemoteTaskEventOnce({ taskId: remote, bindingId: randomUUID(), operationId: randomUUID(), semanticSequence: '1', eventIndex: 0, kind: 'text', payload: { requestId: remote, type: 'text', delta: 'Remote' } })
|
||||
const unknown = request()
|
||||
text(unknown)
|
||||
const raw = new DatabaseSync(join(directory, 'assistant.sqlite'))
|
||||
raw.prepare('UPDATE tasks SET started_at = NULL WHERE id = ?').run(unknown)
|
||||
raw.close()
|
||||
at(100)
|
||||
expect(database.getExecutionStats({ conversationId }, new Set([remote, unknown]))).toMatchObject({ durationMs: 0, incompleteRequestCount: 2, activeRequestCount: 2 })
|
||||
})
|
||||
|
||||
it('counts confirmed live replies before their first event without counting maintenance leases', () => {
|
||||
const id = request()
|
||||
request({ title: 'Context compression' })
|
||||
at(10)
|
||||
expect(database.getExecutionStats({ conversationId }, new Set([id]))).toMatchObject({ durationMs: 10000, requestCount: 1, incompleteRequestCount: 0, activeRequestCount: 1 })
|
||||
})
|
||||
|
||||
it('reports wholly missing reply history and task attribution without using message timestamps', () => {
|
||||
const schedule = database.createSchedule({ projectId, title: 'Old task', prompt: 'Reply', workMode: 'ask', recurrence: 'daily', nextRunAt: new Date(epoch).toISOString() })
|
||||
database.saveLocalConversations([{
|
||||
header: { id: schedule.conversationId, projectId, title: 'Old conversation', updatedAt: epoch },
|
||||
messages: [{ id: randomUUID(), role: 'assistant', content: 'Old reply', state: 'complete', createdAt: epoch - 100000, task: { id: schedule.taskId, title: 'Old task' } }]
|
||||
}])
|
||||
expect(database.getExecutionStats({ conversationId: schedule.conversationId })).toMatchObject({ durationMs: 0, requestCount: 1, incompleteRequestCount: 1 })
|
||||
expect(database.getExecutionStats({ projectId }).taskDurations).toEqual([{ id: schedule.taskId, durationMs: 0, incompleteRequestCount: 1 }])
|
||||
})
|
||||
|
||||
it('does not let request coverage in another conversation conceal missing replies', () => {
|
||||
const id = request()
|
||||
text(id)
|
||||
at(1)
|
||||
database.updateTaskStatus(id, 'completed')
|
||||
database.saveLocalConversations([{
|
||||
header: { id: randomUUID(), projectId, title: 'Other conversation', updatedAt: epoch },
|
||||
messages: [{ id: randomUUID(), role: 'assistant', content: 'Missing history', state: 'complete', createdAt: epoch }]
|
||||
}])
|
||||
expect(database.getExecutionStats({ projectId })).toMatchObject({ durationMs: 1000, requestCount: 2, incompleteRequestCount: 1 })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['zh-CN', '你好,我是 GoodBuddy。你可以直接向我提问、添加本地文件,或使用知识库整理和检索信息。需要我操作文件或调用工具时,请选择合适的 Agent Runtime 和工作模式。'],
|
||||
['en-US', 'Hi, I’m GoodBuddy. Ask me a question, add local files, or use your knowledge base to organize and retrieve information. When you want me to operate on files or use tools, choose the appropriate Agent Runtime and work mode.']
|
||||
])('excludes the persisted default greeting but retains unknown reply history (%s)', async (locale, greeting) => {
|
||||
expect(await readFile(join(process.cwd(), 'src/renderer/src/i18n/locales', locale, 'app.ts'), 'utf8')).toContain(greeting)
|
||||
const header = { id: conversationId, projectId, title: 'New conversation', updatedAt: epoch }
|
||||
const welcome = { id: randomUUID(), role: 'assistant' as const, content: greeting, state: 'complete' as const, createdAt: epoch }
|
||||
database.saveLocalConversations([{ header, messages: [welcome] }])
|
||||
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 0, requestCount: 0, incompleteRequestCount: 0 })
|
||||
database.saveLocalConversations([{ header, messages: [welcome,
|
||||
{ id: randomUUID(), role: 'user', content: 'Question', state: 'complete', createdAt: epoch + 1 },
|
||||
{ id: randomUUID(), role: 'assistant', content: greeting, state: 'complete', createdAt: epoch + 2 }
|
||||
] }])
|
||||
expect(database.getExecutionStats({ projectId })).toMatchObject({ durationMs: 0, requestCount: 1, incompleteRequestCount: 1 })
|
||||
})
|
||||
|
||||
it('seeks interruption boundaries without parsing large tool/delta payloads on repeated scope queries', () => {
|
||||
const id = request()
|
||||
const raw = new DatabaseSync(join(directory, 'assistant.sqlite'))
|
||||
const prepare = vi.spyOn(DatabaseSync.prototype, 'prepare')
|
||||
try {
|
||||
const insert = raw.prepare('INSERT INTO task_events (task_id, kind, payload_json, created_at) VALUES (?, ?, ?, ?)')
|
||||
const payload = JSON.stringify({ requestId: id, output: 'x'.repeat(32768) })
|
||||
raw.exec('BEGIN')
|
||||
for (let index = 0; index < 3000; index++) {
|
||||
insert.run(id, index % 2 ? 'text' : 'tool', payload, new Date(epoch + 5000).toISOString())
|
||||
}
|
||||
raw.exec('COMMIT')
|
||||
at(100)
|
||||
database.updateTaskStatus(id, 'interrupted')
|
||||
const baseline = raw.prepare("SELECT SUM(length(json_extract(payload_json, '$.requestId'))) FROM task_events WHERE task_id = ?")
|
||||
const baselineStart = performance.now()
|
||||
baseline.get(id)
|
||||
baseline.get(id)
|
||||
const baselineMs = performance.now() - baselineStart
|
||||
// One visible-panel tick reads both scopes; repeat to exercise warm polling.
|
||||
const start = performance.now()
|
||||
for (let iteration = 0; iteration < 5; iteration++) {
|
||||
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 5000, requestCount: 1, incompleteRequestCount: 1 })
|
||||
expect(database.getExecutionStats({ projectId })).toMatchObject({ durationMs: 5000, requestCount: 1, incompleteRequestCount: 1 })
|
||||
}
|
||||
const pairMs = (performance.now() - start) / 5
|
||||
const query = prepare.mock.calls.find(([sql]) => sql.includes('WITH scoped AS MATERIALIZED'))![0]
|
||||
const plan = raw.prepare(`EXPLAIN QUERY PLAN ${query}`).all(projectId).map(row => row.detail as string)
|
||||
for (const alias of ['proof', 'tail', 'previous']) {
|
||||
expect(plan.some(detail => detail.includes(`SEARCH ${alias} USING INDEX task_events_task_idx`))).toBe(true)
|
||||
}
|
||||
// Filtering these through task_events_task_idx would read every payload row.
|
||||
expect(plan.some(detail => detail.includes('SEARCH remote USING INDEX task_events_remote_task_idx'))).toBe(true)
|
||||
expect(plan.some(detail => detail.includes('SEARCH e USING INDEX task_events_lifecycle_idx'))).toBe(true)
|
||||
// Malformed bodies make accidental JSON extraction fail deterministically.
|
||||
raw.prepare("UPDATE task_events SET payload_json = '{unparsed' WHERE task_id = ? AND kind IN ('text', 'tool')").run(id)
|
||||
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 5000, requestCount: 1, incompleteRequestCount: 1 })
|
||||
console.info(`execution stats: 3000 events, ${Math.round(payload.length * 3000 / 1024 / 1024)} MiB payload; old JSON extraction pair ${baselineMs.toFixed(1)} ms; scalar scope pair ${pairMs.toFixed(1)} ms`)
|
||||
} finally {
|
||||
prepare.mockRestore()
|
||||
raw.close()
|
||||
it('aggregates repeated schedule runs under the stable card without timing the parent', () => {
|
||||
const schedule = database.createSchedule({ projectId, conversationId, title: 'Scheduled', prompt: 'Run',
|
||||
workMode: 'ask', recurrence: 'once', nextRunAt: new Date(now + 60_000).toISOString() })
|
||||
database.startExecutionTiming(schedule.taskId)
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const item = database.queueScheduleNow(schedule.id)
|
||||
database.claimConversationQueueItem(conversationId, item.id)
|
||||
task({ id: item.scheduleRunId!, parentTaskId: schedule.taskId })
|
||||
database.startExecutionTiming(item.scheduleRunId!)
|
||||
now += 3000
|
||||
database.updateTaskStatus(item.scheduleRunId!, 'completed')
|
||||
database.endExecutionTiming(item.scheduleRunId!)
|
||||
database.completeTaskScheduleRun(item.scheduleRunId!)
|
||||
}
|
||||
expect(database.getExecutionStats({ projectId }).taskDurations).toEqual([
|
||||
{ id: schedule.taskId, durationMs: 6000, runningCount: 0, incomplete: false }
|
||||
])
|
||||
})
|
||||
|
||||
it('marks migrated history incomplete without reconstructing messages and adds the covering index', () => {
|
||||
const id = task({ visible: true })
|
||||
database.updateTaskStatus(id, 'completed')
|
||||
database.close()
|
||||
const old = new DatabaseSync(path)
|
||||
old.exec(`DROP TABLE execution_timing;
|
||||
DROP INDEX conversations_timing_incomplete;
|
||||
ALTER TABLE conversations DROP COLUMN timing_incomplete;
|
||||
DROP INDEX messages_summary_idx;
|
||||
PRAGMA user_version = 58;`)
|
||||
old.close()
|
||||
database.initialize(directory)
|
||||
expect(stats()).toMatchObject({ durationMs: 0, runningCount: 0, incomplete: true })
|
||||
expect(database.getExecutionStats({ projectId }).taskDurations).toEqual([
|
||||
{ id, durationMs: 0, runningCount: 0, incomplete: true }
|
||||
])
|
||||
expect(raw().prepare('PRAGMA index_info(messages_summary_idx)').all().map(row => row.name))
|
||||
.toEqual(['conversation_id', 'sequence', 'role', 'created_at'])
|
||||
expect(raw().prepare('PRAGMA integrity_check').get()).toEqual({ integrity_check: 'ok' })
|
||||
})
|
||||
|
||||
it('deletes timing with its conversation and notifies snapshot subscribers', async () => {
|
||||
const id = task()
|
||||
database.startExecutionTiming(id)
|
||||
now += 3000
|
||||
database.updateTaskStatus(id, 'completed')
|
||||
database.endExecutionTiming(id)
|
||||
await Promise.resolve()
|
||||
const changed = vi.fn()
|
||||
database.onExecutionStatsChanged(changed)
|
||||
database.deleteLocalConversation(conversationId)
|
||||
await Promise.resolve()
|
||||
expect(changed).toHaveBeenCalledOnce()
|
||||
expect(database.getExecutionStats({ projectId })).toMatchObject({ durationMs: 0, runningCount: 0 })
|
||||
expect(raw().prepare('SELECT count(*) AS n FROM execution_timing').get()!.n).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
// @vitest-environment node
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { build } from 'esbuild'
|
||||
import { afterAll, beforeAll, expect, it } from 'vitest'
|
||||
import { AssistantDatabase } from './assistant-database'
|
||||
import { ExecutionStatsReader } from './execution-stats-reader'
|
||||
|
||||
let directory: string
|
||||
let workerPath: string
|
||||
beforeAll(async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'goodbuddy-statistics-reader-'))
|
||||
workerPath = process.env.GOODBUDDY_STATS_WORKER_PATH ?? join(directory, 'worker.cjs')
|
||||
if (!process.env.GOODBUDDY_STATS_WORKER_PATH) {
|
||||
await build({ entryPoints: [resolve('src/main/execution-stats-worker.ts')], outfile: workerPath,
|
||||
bundle: true, platform: 'node', format: 'cjs', logLevel: 'silent' })
|
||||
}
|
||||
})
|
||||
afterAll(async () => { await rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) })
|
||||
|
||||
it('reads committed WAL evidence off-thread without recovering live tasks and refreshes external writes', async () => {
|
||||
const path = join(directory, 'assistant.sqlite')
|
||||
const database = new AssistantDatabase(path)
|
||||
database.initialize(directory)
|
||||
const projectId = database.listProjects()[0]!.id
|
||||
const conversationId = randomUUID()
|
||||
const id = randomUUID()
|
||||
database.createTask({ id, projectId, conversationId, title: 'Live', instructions: 'Reply', workMode: 'ask', visible: false })
|
||||
database.appendTaskEvent(id, 'text', { requestId: id, type: 'text', delta: 'Reply' })
|
||||
const sql = new DatabaseSync(path)
|
||||
try {
|
||||
const status = database.getTask(id).status
|
||||
const [conversation, project] = await Promise.all([
|
||||
database.getExecutionStatsAsync({ conversationId }, new Set([id]), workerPath),
|
||||
database.getExecutionStatsAsync({ projectId }, new Set([id]), workerPath)
|
||||
])
|
||||
expect(conversation).toMatchObject({ requestCount: 1, activeRequestCount: 1, incompleteRequestCount: 0 })
|
||||
expect(project.requestCount).toBe(1)
|
||||
expect(database.getTask(id).status).toBe(status)
|
||||
database.updateTaskStatus(id, 'completed')
|
||||
expect(await database.getExecutionStatsAsync({ conversationId }, new Set(), workerPath)).toMatchObject({ requestCount: 1, activeRequestCount: 0 })
|
||||
sql.exec('BEGIN IMMEDIATE')
|
||||
sql.prepare('DELETE FROM tasks WHERE id = ?').run(id)
|
||||
expect((await database.getExecutionStatsAsync({ conversationId }, new Set(), workerPath)).requestCount).toBe(1)
|
||||
sql.exec('ROLLBACK')
|
||||
expect((await database.getExecutionStatsAsync({ conversationId }, new Set(), workerPath)).requestCount).toBe(1)
|
||||
sql.prepare('DELETE FROM tasks WHERE id = ?').run(id)
|
||||
expect((await database.getExecutionStatsAsync({ conversationId }, new Set(), workerPath)).requestCount).toBe(0)
|
||||
// A returned result must not leave a read transaction pinning the WAL.
|
||||
expect(sql.prepare('PRAGMA wal_checkpoint(TRUNCATE)').get()?.busy).toBe(0)
|
||||
const pending = database.getExecutionStatsAsync({ projectId }, new Set(), workerPath)
|
||||
const rejection = expect(pending).rejects.toThrow('closed')
|
||||
database.close()
|
||||
await rejection
|
||||
} finally { sql.close(); database.close() }
|
||||
})
|
||||
|
||||
it('rejects worker startup errors and permits the next read to retry', async () => {
|
||||
const reader = new ExecutionStatsReader(join(directory, 'missing.sqlite'), workerPath)
|
||||
try {
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
await expect(reader.read({ projectId: randomUUID() }, new Set())).rejects.toThrow()
|
||||
}
|
||||
} finally { reader.close() }
|
||||
})
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Worker } from 'node:worker_threads'
|
||||
import type { ExecutionStats, ExecutionStatsInput } from '../../shared/assistant-contracts'
|
||||
|
||||
export class ExecutionStatsReader {
|
||||
private worker?: Worker
|
||||
private nextId = 0
|
||||
private readonly pending = new Map<number, {
|
||||
resolve: (value: ExecutionStats) => void
|
||||
reject: (error: Error) => void
|
||||
}>()
|
||||
|
||||
constructor(private readonly databasePath: string, private readonly workerPath: string) {}
|
||||
|
||||
read(scope: ExecutionStatsInput, activeRequestIds: ReadonlySet<string>): Promise<ExecutionStats> {
|
||||
if (!this.worker) {
|
||||
const worker = new Worker(this.workerPath, { workerData: { databasePath: this.databasePath } })
|
||||
this.worker = worker
|
||||
worker.on('message', (message: { id: number; result?: ExecutionStats; error?: string }) => {
|
||||
const request = this.pending.get(message.id)
|
||||
if (!request) return
|
||||
this.pending.delete(message.id)
|
||||
if (message.result) request.resolve(message.result)
|
||||
else request.reject(new Error(message.error ?? 'Statistics query failed'))
|
||||
})
|
||||
const failed = (error: Error): void => {
|
||||
if (this.worker !== worker) return
|
||||
this.worker = undefined
|
||||
for (const request of this.pending.values()) request.reject(error)
|
||||
this.pending.clear()
|
||||
void worker.terminate()
|
||||
}
|
||||
worker.once('error', failed)
|
||||
worker.once('exit', (code) => failed(new Error(`Statistics worker exited (${code})`)))
|
||||
worker.unref()
|
||||
}
|
||||
// Bound outstanding work when a window repeatedly changes scope during a slow read.
|
||||
if (this.pending.size >= 16) return Promise.reject(new Error('Statistics reader is busy'))
|
||||
const id = ++this.nextId
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(id, { resolve, reject })
|
||||
this.worker!.postMessage({ id, scope, activeRequestIds: [...activeRequestIds] })
|
||||
})
|
||||
}
|
||||
|
||||
close(): void {
|
||||
const worker = this.worker
|
||||
this.worker = undefined
|
||||
for (const request of this.pending.values()) request.reject(new Error('Statistics reader closed'))
|
||||
this.pending.clear()
|
||||
if (worker) void worker.terminate()
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { parentPort, workerData } from 'node:worker_threads'
|
||||
import type { ExecutionStatsInput } from '../shared/assistant-contracts'
|
||||
import { AssistantDatabase } from './assistant/assistant-database'
|
||||
|
||||
const database = new AssistantDatabase((workerData as { databasePath: string }).databasePath)
|
||||
database.openReadOnly()
|
||||
parentPort!.on('message', (message: {
|
||||
id: number; scope: ExecutionStatsInput; activeRequestIds: string[]
|
||||
}) => {
|
||||
try {
|
||||
const result = database.getExecutionStats(message.scope, new Set(message.activeRequestIds))
|
||||
parentPort!.postMessage({ id: message.id, result })
|
||||
} catch (error) {
|
||||
parentPort!.postMessage({ id: message.id, error: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
})
|
||||
parentPort!.on('close', () => database.close())
|
||||
+143
-7
@@ -49,11 +49,20 @@ import { CapabilityService } from './capabilities/capability-service'
|
||||
import { ObsidianService } from './obsidian'
|
||||
import { packageObsidianMcpVault } from './obsidian/package-mcpvault'
|
||||
import {
|
||||
registerIpcHandlers,
|
||||
registerIpcHandlers as registerProductionIpcHandlers,
|
||||
sendRemoteEnvironmentUpdateProgress,
|
||||
sendRemoteProjectSaveProgress
|
||||
} from './ipc'
|
||||
|
||||
// Most IPC tests use partial databases. Real database fixtures retain their timing implementation.
|
||||
function registerIpcHandlers(...args: Parameters<typeof registerProductionIpcHandlers>): ReturnType<typeof registerProductionIpcHandlers> {
|
||||
const database = args[7]
|
||||
database.onExecutionStatsChanged ??= vi.fn(() => () => {})
|
||||
database.startExecutionTiming ??= vi.fn()
|
||||
database.endExecutionTiming ??= vi.fn()
|
||||
return registerProductionIpcHandlers(...args)
|
||||
}
|
||||
|
||||
describe('embedding IPC boundary', () => {
|
||||
it('validates explicit IDs and keeps ZIP paths in the native dialog', async () => {
|
||||
const source = await readFile(
|
||||
@@ -4435,13 +4444,13 @@ describe('registerIpcHandlers token usage', () => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('validates execution statistics scope and sender before reading evidence', async () => {
|
||||
const summary = { durationMs: 5000, requestCount: 2, incompleteRequestCount: 1, activeRequestCount: 0, asOf: 10000, taskDurations: [{ id: 'task', durationMs: 5000, incompleteRequestCount: 1 }] }
|
||||
it('validates execution statistics scope and sender before reading a timing snapshot', async () => {
|
||||
const summary = { durationMs: 5000, runningCount: 0, incomplete: true, asOf: 10000, taskDurations: [{ id: 'task', durationMs: 5000, runningCount: 0, incomplete: true }] }
|
||||
const assistantDatabase = {
|
||||
queueDueSchedules: vi.fn(() => []),
|
||||
listConversationQueueItems: vi.fn(() => []),
|
||||
listPendingConversationQueueIds: vi.fn(() => []),
|
||||
getExecutionStatsAsync: vi.fn(async () => summary)
|
||||
getExecutionStats: vi.fn(() => summary)
|
||||
}
|
||||
const webContents = { on: vi.fn(), removeListener: vi.fn(),
|
||||
mainFrame: { url: 'file:///goodbuddy/index.html' },
|
||||
@@ -4459,13 +4468,13 @@ describe('registerIpcHandlers token usage', () => {
|
||||
const event = { sender: webContents, senderFrame: webContents.mainFrame }
|
||||
const id = '00000000-0000-4000-8000-000000000301'
|
||||
for (const scope of [{ conversationId: id }, { projectId: id }]) {
|
||||
await expect(handler(event, scope)).resolves.toEqual(summary)
|
||||
expect(assistantDatabase.getExecutionStatsAsync).toHaveBeenLastCalledWith(scope, new Set(), expect.stringContaining('execution-stats-worker.js'))
|
||||
expect(handler(event, scope)).toEqual(summary)
|
||||
expect(assistantDatabase.getExecutionStats).toHaveBeenLastCalledWith(scope)
|
||||
}
|
||||
expect(() => handler(event, {})).toThrow()
|
||||
expect(() => handler(event, { projectId: id, conversationId: id })).toThrow()
|
||||
expect(() => handler({ sender: {}, senderFrame: webContents.mainFrame }, { projectId: id })).toThrow()
|
||||
expect(assistantDatabase.getExecutionStatsAsync).toHaveBeenCalledTimes(2)
|
||||
expect(assistantDatabase.getExecutionStats).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
@@ -5796,6 +5805,128 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
knowledgeLibraryIds: []
|
||||
})
|
||||
|
||||
it.each(['completed', 'failed', 'cancelled'] as const)('persists reply duration through the production lease and %s path', async (status) => {
|
||||
const database = new AssistantDatabase(':memory:')
|
||||
database.initialize(process.cwd())
|
||||
const projectId = database.listProjects()[0]!.id
|
||||
const conversationId = crypto.randomUUID()
|
||||
const requestId = crypto.randomUUID()
|
||||
let now = Date.now()
|
||||
const clock = vi.spyOn(Date, 'now').mockImplementation(() => now)
|
||||
database.saveLocalConversations([{ header: { id: conversationId, projectId, title: 'Timing', updatedAt: now }, messages: [] }])
|
||||
let finish!: () => void
|
||||
const gate = new Promise<void>(resolve => { finish = resolve })
|
||||
let started!: () => void
|
||||
const ready = new Promise<void>(resolve => { started = resolve })
|
||||
const run = vi.fn(async function* (request: AgentExecutionRequest) {
|
||||
started()
|
||||
await gate
|
||||
if (status !== 'completed') throw new Error('Stopped')
|
||||
yield { type: 'done' as const, requestId: request.requestId }
|
||||
})
|
||||
const harness = createHarness({ runtimeId: 'model', capability: 'chat', supportsToolExecution: true, run },
|
||||
undefined, 'always', undefined, false, undefined, undefined, undefined, false,
|
||||
undefined, undefined, undefined, undefined, database)
|
||||
try {
|
||||
const event = trustedEvent(harness.webContents)
|
||||
await harness.handler!(event, { requestId, conversationId, projectId, prompt: 'Run', workMode: 'ask' })
|
||||
await ready
|
||||
now += 5000
|
||||
const read = () => electronMocks.handlers.get(ipcChannels.tasksExecutionStats)!(event, { conversationId })
|
||||
expect(read()).toMatchObject({ durationMs: 5000, runningCount: 1, incomplete: false })
|
||||
if (status === 'cancelled') {
|
||||
harness.cancelHandler!(event, requestId)
|
||||
now += 60_000
|
||||
expect(read()).toMatchObject({ durationMs: 5000, runningCount: 0 })
|
||||
}
|
||||
finish()
|
||||
await vi.waitFor(() => expect(database.getTask(requestId).status).toBe(status))
|
||||
now += 60_000
|
||||
expect(read()).toMatchObject({ durationMs: 5000, runningCount: 0 })
|
||||
expect(harness.webContents.send).toHaveBeenCalledWith(ipcChannels.tasksExecutionStatsChanged)
|
||||
} finally {
|
||||
finish()
|
||||
await harness.dispose()
|
||||
clock.mockRestore()
|
||||
database.close()
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['answer', 'runtime-event'] as const)('pauses ordinary question timing until the last %s resolution and retains failed answers', async (resolution) => {
|
||||
const database = new AssistantDatabase(':memory:')
|
||||
database.initialize(process.cwd())
|
||||
const projectId = database.listProjects()[0]!.id
|
||||
const conversationId = crypto.randomUUID()
|
||||
const requestId = crypto.randomUUID()
|
||||
const questionIds = [crypto.randomUUID(), crypto.randomUUID()]
|
||||
let now = Date.now()
|
||||
const clock = vi.spyOn(Date, 'now').mockImplementation(() => now)
|
||||
database.saveLocalConversations([{ header: { id: conversationId, projectId, title: 'Questions', updatedAt: now }, messages: [] }])
|
||||
const deferred = () => {
|
||||
let resolve!: () => void
|
||||
const promise = new Promise<void>(complete => { resolve = complete })
|
||||
return { promise, resolve }
|
||||
}
|
||||
const ready = deferred()
|
||||
const finish = deferred()
|
||||
const steps = questionIds.map(() => ({ release: deferred(), processed: deferred() }))
|
||||
const respondToQuestion = vi.fn().mockRejectedValueOnce(new Error('Answer delivery failed')).mockResolvedValue(undefined)
|
||||
const run = vi.fn(async function* () {
|
||||
now += 2000
|
||||
for (const questionId of questionIds) yield {
|
||||
type: 'question' as const, requestId, questionId,
|
||||
questions: [{ header: 'Input', question: 'Continue?', options: [], custom: true, multiple: false }]
|
||||
}
|
||||
ready.resolve()
|
||||
for (const [index, questionId] of questionIds.entries()) {
|
||||
await steps[index]!.release.promise
|
||||
if (resolution === 'runtime-event') yield { type: 'question-resolved' as const, requestId, questionId }
|
||||
steps[index]!.processed.resolve()
|
||||
}
|
||||
await finish.promise
|
||||
yield { type: 'done' as const, requestId }
|
||||
})
|
||||
const harness = createHarness({ runtimeId: 'model', capability: 'chat', supportsToolExecution: true, run, respondToQuestion },
|
||||
undefined, 'always', undefined, false, undefined, undefined, undefined, false,
|
||||
undefined, undefined, undefined, undefined, database)
|
||||
const read = () => database.getExecutionStats({ conversationId })
|
||||
try {
|
||||
const event = trustedEvent(harness.webContents)
|
||||
await harness.handler!(event, { requestId, conversationId, projectId, prompt: 'Ask me', workMode: 'ask' })
|
||||
await ready.promise
|
||||
expect(database.getTask(requestId).status).toBe('waiting_approval')
|
||||
now += 60_000
|
||||
expect(read()).toMatchObject({ durationMs: 2000, runningCount: 0 })
|
||||
const answer = electronMocks.handlers.get(ipcChannels.agentQuestionRespond)!
|
||||
await expect(answer(event, { questionId: questionIds[0], answers: [['Yes']] })).rejects.toThrow('Answer delivery failed')
|
||||
now += 60_000
|
||||
expect(database.getTask(requestId).status).toBe('waiting_approval')
|
||||
expect(read()).toMatchObject({ durationMs: 2000, runningCount: 0 })
|
||||
for (const [index, questionId] of questionIds.entries()) {
|
||||
if (resolution === 'answer') await answer(event, { questionId, answers: index === 0 ? [['Yes']] : [] })
|
||||
steps[index]!.release.resolve()
|
||||
await steps[index]!.processed.promise
|
||||
if (index === 0) {
|
||||
now += 60_000
|
||||
expect(database.getTask(requestId).status).toBe('waiting_approval')
|
||||
expect(read()).toMatchObject({ durationMs: 2000, runningCount: 0 })
|
||||
}
|
||||
}
|
||||
expect(database.getTask(requestId).status).toBe('running')
|
||||
now += 3000
|
||||
finish.resolve()
|
||||
await vi.waitFor(() => expect(database.getTask(requestId).status).toBe('completed'))
|
||||
expect(read()).toMatchObject({ durationMs: 5000, runningCount: 0 })
|
||||
if (resolution === 'answer') expect(respondToQuestion).toHaveBeenLastCalledWith(questionIds[1], undefined)
|
||||
} finally {
|
||||
for (const step of steps) step.release.resolve()
|
||||
finish.resolve()
|
||||
await harness.dispose()
|
||||
clock.mockRestore()
|
||||
database.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('binds the production image service before text dispatch and routes regenerate, Ask rejection, and deletion through IPC', async () => {
|
||||
const database = new AssistantDatabase(':memory:')
|
||||
database.initialize(process.cwd())
|
||||
@@ -6721,11 +6852,14 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
const database = new AssistantDatabase(':memory:')
|
||||
database.initialize(process.cwd())
|
||||
const conversationId = crypto.randomUUID()
|
||||
let timingNow = Date.now()
|
||||
const timingClock = vi.spyOn(Date, 'now').mockImplementation(() => timingNow)
|
||||
database.saveLocalConversations([{
|
||||
header: { id: conversationId, title: 'Immediate', updatedAt: Date.now(), workMode: 'ask' },
|
||||
messages: []
|
||||
}])
|
||||
const run = vi.fn(async function* (request: AgentExecutionRequest) {
|
||||
timingNow += 3000
|
||||
if (status === 'failed') {
|
||||
yield { type: 'error' as const, requestId: request.requestId, message: 'Runtime failed' }
|
||||
return
|
||||
@@ -6773,8 +6907,10 @@ describe('registerIpcHandlers agent terminal state', () => {
|
||||
expect(run).toHaveBeenCalledOnce()
|
||||
expect(database.listConversationQueueItems()).toEqual([])
|
||||
expect(database.listSchedules()).toHaveLength(1)
|
||||
expect(database.getExecutionStats({ conversationId })).toMatchObject({ durationMs: 3000, runningCount: 0, incomplete: false })
|
||||
} finally {
|
||||
await harness.dispose()
|
||||
timingClock.mockRestore()
|
||||
database.close()
|
||||
}
|
||||
})
|
||||
|
||||
+45
-32
@@ -21,7 +21,7 @@ import {
|
||||
stat
|
||||
} from 'node:fs/promises'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { basename, extname, join } from 'node:path'
|
||||
import { basename, extname } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
import { documentResourceInputSchema } from '../shared/document-result-contracts'
|
||||
import { maximumAttachmentsPerMessage } from '../shared/attachment-limits'
|
||||
@@ -1111,14 +1111,16 @@ export function registerIpcHandlers(
|
||||
}
|
||||
type ActiveRequestLease = {
|
||||
controller: AbortController
|
||||
isReply: boolean
|
||||
conversationId: string
|
||||
detachOnApplicationExit: boolean
|
||||
teamMode?: boolean
|
||||
recoveredMessageId?: string
|
||||
release(): void
|
||||
release(incomplete?: boolean): void
|
||||
}
|
||||
const activeRequests = new Map<string, ActiveRequestLease>()
|
||||
const removeExecutionStatsListener = assistantDatabase.onExecutionStatsChanged(() => {
|
||||
if (!window.isDestroyed()) window.webContents.send(ipcChannels.tasksExecutionStatsChanged)
|
||||
})
|
||||
const onRuntimeSettingsChanged = async (): Promise<void> => {
|
||||
// The expert scheduler cancels its work on replacement. Its parent must
|
||||
// not synthesize partial old-generation results with the replacement model.
|
||||
@@ -1139,12 +1141,19 @@ export function registerIpcHandlers(
|
||||
controller: AbortController,
|
||||
isReply = true
|
||||
): ActiveRequestLease => {
|
||||
if (isReply) assistantDatabase.startExecutionTiming(requestId)
|
||||
const stopTiming = (): void => {
|
||||
if (isReply) assistantDatabase.endExecutionTiming(requestId)
|
||||
}
|
||||
controller.signal.addEventListener('abort', stopTiming, { once: true })
|
||||
if (controller.signal.aborted) stopTiming()
|
||||
const lease: ActiveRequestLease = {
|
||||
controller,
|
||||
isReply,
|
||||
conversationId,
|
||||
detachOnApplicationExit: false,
|
||||
release: (): void => {
|
||||
release: (incomplete = false): void => {
|
||||
if (isReply) assistantDatabase.endExecutionTiming(requestId, incomplete)
|
||||
controller.signal.removeEventListener('abort', stopTiming)
|
||||
if (activeRequests.get(requestId) === lease) {
|
||||
activeRequests.delete(requestId)
|
||||
}
|
||||
@@ -1162,6 +1171,14 @@ export function registerIpcHandlers(
|
||||
string,
|
||||
{ requestId: string; runtime: AgentRuntime; question: Extract<AgentEvent, { type: 'question' }> }
|
||||
>()
|
||||
const resumeAfterQuestions = (requestId: string): boolean => {
|
||||
const lease = activeRequests.get(requestId)
|
||||
if (!lease || lease.controller.signal.aborted ||
|
||||
[...pendingAgentQuestions.values()].some(pending => pending.requestId === requestId) ||
|
||||
assistantDatabase.getTask(requestId).status !== 'waiting_approval') return false
|
||||
assistantDatabase.updateTaskStatus(requestId, 'running')
|
||||
return true
|
||||
}
|
||||
const supervisionModelPool = new SupervisionModelPool()
|
||||
let activeSshDirectoryBrowse: AbortController | undefined
|
||||
let shuttingDown = false
|
||||
@@ -1398,6 +1415,7 @@ export function registerIpcHandlers(
|
||||
preserveApplicationExitDetached &&
|
||||
lease.detachOnApplicationExit
|
||||
) {
|
||||
assistantDatabase.endExecutionTiming(requestId, true)
|
||||
continue
|
||||
}
|
||||
lease.controller.abort(new Error(reason))
|
||||
@@ -1702,9 +1720,7 @@ export function registerIpcHandlers(
|
||||
assistantDatabase.updateTaskStatus(task.taskId, 'waiting_approval')
|
||||
} else {
|
||||
pendingAgentQuestions.delete(rawEvent.questionId)
|
||||
if (![...pendingAgentQuestions.values()].some(pending => pending.requestId === task.taskId)) {
|
||||
assistantDatabase.updateTaskStatus(task.taskId, 'running')
|
||||
}
|
||||
resumeAfterQuestions(task.taskId)
|
||||
}
|
||||
publishConversationChange()
|
||||
continue
|
||||
@@ -1828,7 +1844,7 @@ export function registerIpcHandlers(
|
||||
for (const [id, pending] of pendingAgentQuestions) {
|
||||
if (pending.requestId === task.taskId) pendingAgentQuestions.delete(id)
|
||||
}
|
||||
lease.release()
|
||||
lease.release(!sawTerminal)
|
||||
publishConversationChange()
|
||||
assistantDatabase.completeTaskScheduleRun(task.taskId)
|
||||
}
|
||||
@@ -2159,11 +2175,6 @@ export function registerIpcHandlers(
|
||||
const runtimeConversationId =
|
||||
remoteContext?.conversationId ??
|
||||
`${origin}:${schedule.id}`
|
||||
const activeRequestLease = leaseActiveRequest(
|
||||
requestId,
|
||||
runtimeConversationId,
|
||||
controller
|
||||
)
|
||||
if (input.origin !== 'delegation') {
|
||||
assistantDatabase.updateTaskStatus(taskId, 'running')
|
||||
} else {
|
||||
@@ -2178,6 +2189,11 @@ export function registerIpcHandlers(
|
||||
visible: false
|
||||
})
|
||||
}
|
||||
const activeRequestLease = leaseActiveRequest(
|
||||
requestId,
|
||||
runtimeConversationId,
|
||||
controller
|
||||
)
|
||||
let output = ''
|
||||
let completed = false
|
||||
let backgroundQuestionError: Error | undefined
|
||||
@@ -4423,10 +4439,13 @@ export function registerIpcHandlers(
|
||||
runtime: selectedRuntime,
|
||||
question: publicEvent
|
||||
})
|
||||
assistantDatabase.updateTaskStatus(request.requestId, 'waiting_approval')
|
||||
publishConversationChange()
|
||||
}
|
||||
if (publicEvent.type === 'question-resolved' &&
|
||||
pendingAgentQuestions.get(publicEvent.questionId)?.requestId === request.requestId) {
|
||||
pendingAgentQuestions.delete(publicEvent.questionId)
|
||||
if (resumeAfterQuestions(request.requestId)) publishConversationChange()
|
||||
}
|
||||
if (publicEvent.type === 'error') {
|
||||
runtimeErrorEvent = publicEvent
|
||||
@@ -4600,7 +4619,7 @@ export function registerIpcHandlers(
|
||||
}
|
||||
}
|
||||
knowledgeGateway?.revoke(request.knowledgeCapabilityToken)
|
||||
activeRequestLease.release()
|
||||
activeRequestLease.release(remoteRecoveryPending)
|
||||
if (remoteRecoveryPending && request.projectId) {
|
||||
startRemoteProjectRecovery(request.projectId)
|
||||
}
|
||||
@@ -4696,15 +4715,8 @@ export function registerIpcHandlers(
|
||||
}))
|
||||
})
|
||||
pendingAgentQuestions.delete(response.questionId)
|
||||
if (activeRequests.get(pending.requestId)?.recoveredMessageId) {
|
||||
if (
|
||||
![...pendingAgentQuestions.values()].some(value => value.requestId === pending.requestId) &&
|
||||
assistantDatabase.getTask(pending.requestId).status === 'waiting_approval'
|
||||
) {
|
||||
assistantDatabase.updateTaskStatus(pending.requestId, 'running')
|
||||
}
|
||||
}
|
||||
if (recorded) {
|
||||
const resumed = resumeAfterQuestions(pending.requestId)
|
||||
if (recorded || resumed) {
|
||||
publishConversationChange()
|
||||
}
|
||||
}
|
||||
@@ -6344,9 +6356,13 @@ export function registerIpcHandlers(
|
||||
registerHandler(ipcChannels.conversationsListSummaries, async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
const { detailIds } = conversationListRequestSchema.parse(input)
|
||||
return projectConversationRequests(await assistantDatabase.listConversationSummariesAsync([
|
||||
...detailIds, ...[...activeRequests.values()].map(request => request.conversationId)
|
||||
]))
|
||||
// Recovery needs the message carrying pending questions; ordinary active
|
||||
// replies already reach the renderer through their event stream.
|
||||
return projectConversationRequests(await assistantDatabase.listConversationSummariesAsync([...new Set([
|
||||
...detailIds, ...[...activeRequests.values()]
|
||||
.filter(request => request.recoveredMessageId)
|
||||
.map(request => request.conversationId)
|
||||
])]))
|
||||
})
|
||||
registerHandler(ipcChannels.conversationsGet, async (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
@@ -6840,11 +6856,7 @@ export function registerIpcHandlers(
|
||||
})
|
||||
registerHandler(ipcChannels.tasksExecutionStats, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
return assistantDatabase.getExecutionStatsAsync(
|
||||
executionStatsInputSchema.parse(input),
|
||||
new Set([...activeRequests].filter(([, lease]) => lease.isReply && !lease.controller.signal.aborted).map(([id]) => id)),
|
||||
join(app.getAppPath(), 'out/main/execution-stats-worker.js')
|
||||
)
|
||||
return assistantDatabase.getExecutionStats(executionStatsInputSchema.parse(input))
|
||||
})
|
||||
registerHandler(ipcChannels.tasksSetStatus, (event, input: unknown) => {
|
||||
assertTrustedSender(event, window)
|
||||
@@ -7480,6 +7492,7 @@ export function registerIpcHandlers(
|
||||
registerKnowledgeIpcHandlers(registerHandler, window, knowledgeService, settingsStore)
|
||||
|
||||
return async () => {
|
||||
removeExecutionStatsListener()
|
||||
disposeWindowIpc()
|
||||
shuttingDown = true
|
||||
if (nativeClientCoordinator) window.webContents.removeListener('destroyed', closeNativeClients)
|
||||
|
||||
@@ -187,8 +187,11 @@ describe('assistant readonly worker', () => {
|
||||
const summaries = database.listConversationSummaries([conversations[2]!.id])
|
||||
const byId = new Map(summaries.map(item => [item.id, item]))
|
||||
expect(byId.get(conversations[2]!.id)).toEqual(database.getConversation(conversations[2]!.id))
|
||||
// A streaming conversation always carries its messages.
|
||||
expect(byId.get(conversations[1]!.id)).toEqual(database.getConversation(conversations[1]!.id))
|
||||
// Streaming histories are loaded only when explicitly requested.
|
||||
expect(byId.get(conversations[1]!.id)?.messages).toEqual([])
|
||||
expect(byId.get(conversations[1]!.id)?.messageSummary).toEqual({ count: 2, firstRole: 'user', latestMessageAt: 2 })
|
||||
expect(database.listConversationSummaries([conversations[1]!.id])
|
||||
.find(item => item.id === conversations[1]!.id)).toEqual(database.getConversation(conversations[1]!.id))
|
||||
expect(byId.get(conversations[0]!.id)?.messageSummary).toEqual({ count: 0, firstRole: undefined, latestMessageAt: undefined })
|
||||
expect(byId.get(conversations[3]!.id)?.messageSummary).toEqual({ count: 1, firstRole: 'assistant', latestMessageAt: 3 })
|
||||
expect(byId.get(conversations[3]!.id)?.messages).toEqual([])
|
||||
@@ -213,9 +216,10 @@ describe('assistant readonly worker', () => {
|
||||
const detailSets = [[], [conversations[3]!.id], conversations.map(item => item.id), [randomUUID()]]
|
||||
const expected = detailSets.map(ids => database.listConversationSummaries(ids))
|
||||
const expectedFull = conversations.map(item => database.getConversation(item.id))
|
||||
// Summaries without details carry messageSummary; retained/streaming ones carry messages.
|
||||
// Only explicitly retained histories carry messages, including during streaming.
|
||||
expect(expected[0]!.find(item => item.id === conversations[4]!.id)?.messageSummary).toEqual({ count: 4, firstRole: 'user', latestMessageAt: 43 })
|
||||
expect(expected[0]!.find(item => item.id === conversations[5]!.id)?.messages).toHaveLength(5)
|
||||
expect(expected[0]!.find(item => item.id === conversations[5]!.id)?.messages).toHaveLength(0)
|
||||
expect(expected[0]!.find(item => item.id === conversations[5]!.id)?.messageSummary?.count).toBe(5)
|
||||
expect(expected[1]!.find(item => item.id === conversations[3]!.id)).toEqual(expectedFull[3])
|
||||
|
||||
database.enableReadonlyWorker(workerPath)
|
||||
|
||||
@@ -12,10 +12,17 @@ vi.mock('electron', () => electron)
|
||||
it('reads execution statistics through the explicit typed bridge', async () => {
|
||||
await import('./index')
|
||||
const api = electron.contextBridge.exposeInMainWorld.mock.calls[0]![1] as DesktopApi
|
||||
const summary = { durationMs: 1000, requestCount: 1, incompleteRequestCount: 0, activeRequestCount: 0, asOf: 2000, taskDurations: [{ id: 'task', durationMs: 1000, incompleteRequestCount: 0 }] }
|
||||
const summary = { durationMs: 1000, runningCount: 0, incomplete: false, asOf: 2000, taskDurations: [{ id: 'task', durationMs: 1000, runningCount: 0, incomplete: false }] }
|
||||
electron.ipcRenderer.invoke.mockResolvedValue(summary)
|
||||
for (const scope of [{ conversationId: 'conversation' }, { projectId: 'project' }]) {
|
||||
expect(await api.tasks.getExecutionStats(scope)).toBe(summary)
|
||||
expect(electron.ipcRenderer.invoke).toHaveBeenLastCalledWith(ipcChannels.tasksExecutionStats, scope)
|
||||
}
|
||||
const listener = vi.fn()
|
||||
const unsubscribe = api.tasks.onExecutionStatsChanged(listener)
|
||||
const handler = electron.ipcRenderer.on.mock.calls.find(([channel]) => channel === ipcChannels.tasksExecutionStatsChanged)![1]
|
||||
handler()
|
||||
expect(listener).toHaveBeenCalledOnce()
|
||||
unsubscribe()
|
||||
expect(electron.ipcRenderer.removeListener).toHaveBeenCalledWith(ipcChannels.tasksExecutionStatsChanged, handler)
|
||||
})
|
||||
|
||||
@@ -1273,6 +1273,11 @@ const desktopApi: DesktopApi = {
|
||||
ipcRenderer.invoke(ipcChannels.tasksList) as Promise<AssistantTask[]>,
|
||||
getExecutionStats: (input) =>
|
||||
ipcRenderer.invoke(ipcChannels.tasksExecutionStats, input) as Promise<ExecutionStats>,
|
||||
onExecutionStatsChanged: (listener) => {
|
||||
const handler = (): void => listener()
|
||||
ipcRenderer.on(ipcChannels.tasksExecutionStatsChanged, handler)
|
||||
return () => { ipcRenderer.removeListener(ipcChannels.tasksExecutionStatsChanged, handler) }
|
||||
},
|
||||
setStatus: async (
|
||||
taskId: string,
|
||||
status: 'completed' | 'cancelled'
|
||||
|
||||
@@ -674,9 +674,10 @@ const api: DesktopApi & RuntimeNativeClientApi = {
|
||||
tasks: {
|
||||
list: vi.fn(async () => []),
|
||||
getExecutionStats: vi.fn(async () => ({
|
||||
durationMs: 0, requestCount: 0, incompleteRequestCount: 0,
|
||||
activeRequestCount: 0, asOf: Date.now(), taskDurations: [],
|
||||
durationMs: 0, runningCount: 0, incomplete: false,
|
||||
asOf: Date.now(), taskDurations: [],
|
||||
})),
|
||||
onExecutionStatsChanged: vi.fn(() => () => {}),
|
||||
setStatus: vi.fn(async () => {}),
|
||||
},
|
||||
activityHistory: {
|
||||
@@ -1437,6 +1438,10 @@ describe("App", () => {
|
||||
};
|
||||
});
|
||||
vi.mocked(api.tasks.list).mockReset().mockResolvedValue([]);
|
||||
vi.mocked(api.tasks.getExecutionStats).mockReset().mockImplementation(async () => ({
|
||||
durationMs: 0, runningCount: 0, incomplete: false, asOf: Date.now(), taskDurations: [],
|
||||
}));
|
||||
vi.mocked(api.tasks.onExecutionStatsChanged).mockReset().mockReturnValue(() => {});
|
||||
vi.mocked(api.activityHistory.get).mockReset().mockResolvedValue({
|
||||
records: [],
|
||||
legacyHistoryMayBeIncomplete: false,
|
||||
@@ -7241,6 +7246,35 @@ describe("App", () => {
|
||||
expect(runtimeButton).toBeEnabled();
|
||||
});
|
||||
|
||||
it("renders authoritative duration snapshots and refreshes them from timing notifications", async () => {
|
||||
const conversationId = crypto.randomUUID();
|
||||
vi.mocked(api.conversations.list).mockResolvedValue([{
|
||||
id: conversationId, projectId, title: "Timed conversation", updatedAt: Date.now(), messages: [],
|
||||
}]);
|
||||
const summary = { durationMs: 2000, runningCount: 0, incomplete: true, asOf: Date.now(), taskDurations: [] };
|
||||
vi.mocked(api.tasks.getExecutionStats).mockResolvedValue(summary);
|
||||
let changed: (() => void) | undefined;
|
||||
const unsubscribe = vi.fn();
|
||||
vi.mocked(api.tasks.onExecutionStatsChanged).mockImplementation((listener) => {
|
||||
changed = listener;
|
||||
return unsubscribe;
|
||||
});
|
||||
render(<App />);
|
||||
await waitFor(() => expect(api.conversationQueue.ready).toHaveBeenCalledWith(conversationId));
|
||||
fireEvent.click(screen.getByRole("button", { name: "切换助手工作栏" }));
|
||||
fireEvent.click(screen.getByRole("tab", { name: "任务中心" }));
|
||||
const statistics = await screen.findByLabelText("当前会话统计");
|
||||
expect(await within(statistics).findByText("00:00:02")).toBeVisible();
|
||||
expect(within(statistics).getByText("部分回复缺少计时记录,时长仅包含已知记录。")).toBeVisible();
|
||||
expect(api.tasks.getExecutionStats).toHaveBeenCalledWith({ conversationId });
|
||||
expect(api.tasks.getExecutionStats).toHaveBeenCalledWith({ projectId });
|
||||
vi.mocked(api.tasks.getExecutionStats).mockResolvedValue({ ...summary, durationMs: 65_000 });
|
||||
act(() => changed?.());
|
||||
expect(await within(statistics).findByText("00:01:05")).toBeVisible();
|
||||
fireEvent.click(screen.getByRole("button", { name: "切换助手工作栏" }));
|
||||
await waitFor(() => expect(unsubscribe).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("associates live scheduled approvals using the dispatched task rather than another task in the conversation", async () => {
|
||||
const conversationId = crypto.randomUUID();
|
||||
const taskId = crypto.randomUUID();
|
||||
|
||||
@@ -173,7 +173,6 @@ import {
|
||||
useProductTasks,
|
||||
useSidebarArtifacts,
|
||||
useTaskActivityRows,
|
||||
useTaskStatsRevision,
|
||||
useTaskStatusRows,
|
||||
} from "./task-selectors";
|
||||
import {
|
||||
@@ -2797,20 +2796,18 @@ function App(): React.JSX.Element {
|
||||
// The supervisor page needs every task; App follows them only while it is shown.
|
||||
const heartbeatTasks = useAllTasks(taskStore, view !== "heartbeat");
|
||||
const executionStatsEnabled = assistantSidebarOpen && assistantSidebarTab === "tasks";
|
||||
const executionStatsRevision = useTaskStatsRevision(taskStore, executionStatsEnabled);
|
||||
const statsConversation = activeConversation?.projectId === activeProjectId
|
||||
? activeConversation : undefined;
|
||||
const statsMessageCount = statsConversation?.messageCount ?? 0;
|
||||
const executionStats = useExecutionStats(
|
||||
statsConversation?.id,
|
||||
activeProjectId || undefined,
|
||||
`${executionStatsRevision}:${statsMessageCount}:${activeConversationIds.has(activeId)}`,
|
||||
executionStatsEnabled,
|
||||
);
|
||||
const taskDurations = useMemo(() => new Map(
|
||||
executionStats.project?.taskDurations.map((task) => [task.id, {
|
||||
durationMs: task.durationMs,
|
||||
incomplete: task.incompleteRequestCount > 0,
|
||||
incomplete: task.incomplete,
|
||||
}]) ?? [],
|
||||
), [executionStats.project]);
|
||||
const tasksByConversation = useMemo(() => {
|
||||
@@ -6717,7 +6714,7 @@ function App(): React.JSX.Element {
|
||||
);
|
||||
const statsConversationId = statsConversation?.id;
|
||||
const statsDurationMs = executionStats.conversation?.durationMs;
|
||||
const statsIncomplete = (executionStats.conversation?.incompleteRequestCount ?? 0) > 0;
|
||||
const statsIncomplete = executionStats.conversation?.incomplete ?? false;
|
||||
const conversationStats = useMemo(
|
||||
() =>
|
||||
statsConversationId && statsDurationMs !== undefined
|
||||
|
||||
@@ -109,17 +109,6 @@ export function useTaskActivityRows(store: TaskStore): TaskActivityRow[] {
|
||||
);
|
||||
}
|
||||
|
||||
const selectStatsRevision = (tasks: AssistantTask[]): string =>
|
||||
tasks.map((task) => `${task.id}:${task.status}:${task.completedAt ?? ""}`).join("|");
|
||||
const selectNoRevision = (): string => "";
|
||||
|
||||
/**
|
||||
* Changes whenever a task starts, finishes or changes status; constant while
|
||||
* `enabled` is false, so nothing re-renders for statistics nobody sees.
|
||||
*/
|
||||
export function useTaskStatsRevision(store: TaskStore, enabled: boolean): string {
|
||||
return useTaskSelector(store, enabled ? selectStatsRevision : selectNoRevision);
|
||||
}
|
||||
/** Whether a task of this conversation is running or waiting for approval. */
|
||||
export function useConversationHasBusyTask(store: TaskStore, conversationId: string): boolean {
|
||||
const select = useCallback(
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
useProductTasks,
|
||||
useSidebarArtifacts,
|
||||
useTaskActivityRows,
|
||||
useTaskStatsRevision,
|
||||
useTaskStatusRows,
|
||||
} from "./task-selectors";
|
||||
import { createTaskStore, mergeArtifacts } from "./task-store";
|
||||
@@ -103,7 +102,6 @@ describe("task selectors", () => {
|
||||
status: useTaskStatusRows(store),
|
||||
activity: useTaskActivityRows(store),
|
||||
busy: useConversationHasBusyTask(store, "c1"),
|
||||
revision: useTaskStatsRevision(store, false),
|
||||
};
|
||||
});
|
||||
const first = result.current;
|
||||
@@ -124,13 +122,10 @@ describe("task selectors", () => {
|
||||
const { result } = renderHook(() => ({
|
||||
busy: useConversationHasBusyTask(store, "c1"),
|
||||
status: useTaskStatusRows(store),
|
||||
revision: useTaskStatsRevision(store, true),
|
||||
}));
|
||||
const revision = result.current.revision;
|
||||
act(() => store.setTasks((current) => current.map((item) => ({ ...item, status: "completed" }))));
|
||||
expect(result.current.busy).toBe(false);
|
||||
expect(result.current.status[0]!.status).toBe("completed");
|
||||
expect(result.current.revision).not.toBe(revision);
|
||||
});
|
||||
|
||||
it("keep the artifact map and sidebar entries while artifacts are unchanged", () => {
|
||||
|
||||
@@ -1,299 +1,84 @@
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ExecutionStats, ExecutionStatsInput } from '../../shared/assistant-contracts'
|
||||
import type { ExecutionStats } from '../../shared/assistant-contracts'
|
||||
import { useExecutionStats } from './use-execution-stats'
|
||||
|
||||
function stats(durationMs: number): ExecutionStats {
|
||||
return {
|
||||
durationMs, requestCount: 1, incompleteRequestCount: 0,
|
||||
activeRequestCount: 0, asOf: 1_000, taskDurations: []
|
||||
}
|
||||
}
|
||||
|
||||
function deferred() {
|
||||
let resolve!: (value: ExecutionStats) => void
|
||||
let reject!: (reason: Error) => void
|
||||
const promise = new Promise<ExecutionStats>((res, rej) => {
|
||||
resolve = res
|
||||
reject = rej
|
||||
})
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
|
||||
const empty = { conversation: undefined, project: undefined }
|
||||
const initialProps = {
|
||||
conversationId: 'chat-a' as string | undefined,
|
||||
projectId: 'project-a' as string | undefined,
|
||||
revision: '1', enabled: true
|
||||
}
|
||||
|
||||
function renderStats() {
|
||||
return renderHook(({ conversationId, projectId, revision, enabled }) =>
|
||||
useExecutionStats(conversationId, projectId, revision, enabled), { initialProps })
|
||||
}
|
||||
|
||||
const getExecutionStats = vi.fn<(input: ExecutionStatsInput) => Promise<ExecutionStats>>()
|
||||
|
||||
const snapshot = (runningCount = 0, durationMs = 1000): ExecutionStats => ({
|
||||
durationMs, runningCount, incomplete: false, asOf: Date.now(),
|
||||
taskDurations: [{ id: 'task', durationMs, runningCount, incomplete: false }]
|
||||
})
|
||||
const getExecutionStats = vi.fn()
|
||||
const listeners = new Set<() => void>()
|
||||
const flush = async (): Promise<void> => { await act(async () => { await Promise.resolve() }) }
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('visible')
|
||||
getExecutionStats.mockReset().mockResolvedValue(stats(10))
|
||||
vi.stubGlobal('goodbuddy', { tasks: { getExecutionStats } })
|
||||
vi.setSystemTime(10_000)
|
||||
getExecutionStats.mockReset().mockImplementation(async () => snapshot())
|
||||
vi.stubGlobal('goodbuddy', { tasks: { getExecutionStats,
|
||||
onExecutionStatsChanged: (listener: () => void) => {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
}
|
||||
} })
|
||||
})
|
||||
afterEach(() => { cleanup(); listeners.clear(); vi.useRealTimers(); vi.unstubAllGlobals() })
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('useExecutionStats', () => {
|
||||
it.each(['conversation', 'project'] as const)(
|
||||
'restores cached statistics immediately when returning to a %s', async (scope) => {
|
||||
const { result, rerender } = renderStats()
|
||||
await act(async () => {})
|
||||
const cached = result.current
|
||||
getExecutionStats.mockResolvedValue(stats(20))
|
||||
rerender({
|
||||
...initialProps, conversationId: 'chat-b',
|
||||
projectId: scope === 'project' ? 'project-b' : 'project-a'
|
||||
})
|
||||
await act(async () => {})
|
||||
const conversation = deferred()
|
||||
const project = deferred()
|
||||
getExecutionStats.mockReturnValueOnce(conversation.promise).mockReturnValueOnce(project.promise)
|
||||
rerender(initialProps)
|
||||
expect(result.current.conversation).toBe(cached.conversation)
|
||||
expect(result.current.project).toEqual(scope === 'project' ? cached.project : stats(20))
|
||||
await act(async () => {
|
||||
conversation.resolve(stats(30))
|
||||
project.resolve(stats(40))
|
||||
})
|
||||
expect(result.current).toEqual({ conversation: stats(30), project: stats(40) })
|
||||
}
|
||||
)
|
||||
|
||||
it('retains snapshot references for identical cached polling results', async () => {
|
||||
getExecutionStats.mockImplementation(async () => stats(10))
|
||||
const { result } = renderStats()
|
||||
await act(async () => {})
|
||||
const before = result.current
|
||||
await act(async () => { vi.advanceTimersByTime(5_000) })
|
||||
expect(result.current.conversation).toBe(before.conversation)
|
||||
expect(result.current.project).toBe(before.project)
|
||||
expect(getExecutionStats).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
it.each(['project', 'conversation'] as const)(
|
||||
'clears the previous %s selection and ignores its late polling result', async (scope) => {
|
||||
const { result, rerender } = renderStats()
|
||||
await act(async () => {})
|
||||
expect(result.current).toEqual({ conversation: stats(10), project: stats(10) })
|
||||
|
||||
const oldConversation = deferred()
|
||||
const oldProject = deferred()
|
||||
getExecutionStats.mockReturnValueOnce(oldConversation.promise).mockReturnValueOnce(oldProject.promise)
|
||||
await act(async () => { vi.advanceTimersByTime(5_000) })
|
||||
|
||||
const newConversation = deferred()
|
||||
const newProject = deferred()
|
||||
getExecutionStats.mockReturnValueOnce(newConversation.promise).mockReturnValueOnce(newProject.promise)
|
||||
rerender({
|
||||
...initialProps, conversationId: 'chat-b',
|
||||
projectId: scope === 'project' ? 'project-b' : 'project-a'
|
||||
})
|
||||
expect(result.current).toEqual({
|
||||
conversation: undefined, project: scope === 'project' ? undefined : stats(10)
|
||||
})
|
||||
expect(getExecutionStats.mock.calls.slice(-2)).toEqual([
|
||||
[{ conversationId: 'chat-b' }],
|
||||
[{ projectId: scope === 'project' ? 'project-b' : 'project-a' }]
|
||||
])
|
||||
|
||||
await act(async () => {
|
||||
newConversation.resolve(stats(20))
|
||||
newProject.resolve(stats(30))
|
||||
})
|
||||
await act(async () => {
|
||||
oldConversation.resolve(stats(90))
|
||||
oldProject.resolve(stats(99))
|
||||
})
|
||||
expect(result.current).toEqual({ conversation: stats(20), project: stats(30) })
|
||||
}
|
||||
)
|
||||
|
||||
it('does not resurrect cancelled results when switching back to the same IDs', async () => {
|
||||
const oldConversation = deferred()
|
||||
const oldProject = deferred()
|
||||
getExecutionStats.mockReturnValueOnce(oldConversation.promise).mockReturnValueOnce(oldProject.promise)
|
||||
const { result, rerender } = renderStats()
|
||||
rerender({ ...initialProps, conversationId: 'chat-b', projectId: 'project-b' })
|
||||
await act(async () => {})
|
||||
const currentConversation = deferred()
|
||||
const currentProject = deferred()
|
||||
getExecutionStats.mockReturnValueOnce(currentConversation.promise).mockReturnValueOnce(currentProject.promise)
|
||||
rerender(initialProps)
|
||||
await act(async () => {
|
||||
oldConversation.resolve(stats(90))
|
||||
oldProject.resolve(stats(99))
|
||||
})
|
||||
expect(result.current).toEqual(empty)
|
||||
await act(async () => {
|
||||
currentConversation.resolve(stats(20))
|
||||
currentProject.resolve(stats(30))
|
||||
})
|
||||
expect(result.current).toEqual({ conversation: stats(20), project: stats(30) })
|
||||
})
|
||||
|
||||
it('does not overlap polling, focus or visibility refreshes while either query is pending', async () => {
|
||||
const conversation = deferred()
|
||||
const project = deferred()
|
||||
getExecutionStats.mockReturnValueOnce(conversation.promise).mockReturnValueOnce(project.promise)
|
||||
const { result } = renderStats()
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(15_000)
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
conversation.resolve(stats(20))
|
||||
})
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(5_000)
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
})
|
||||
describe('state-driven duration snapshots', () => {
|
||||
it('ticks locally for concurrent running segments without polling or writes', async () => {
|
||||
getExecutionStats.mockImplementation(async () => snapshot(2))
|
||||
const { result } = renderHook(() => useExecutionStats('conversation', 'project', true))
|
||||
await flush()
|
||||
expect(getExecutionStats).toHaveBeenCalledTimes(2)
|
||||
expect(result.current).toEqual(empty)
|
||||
await act(async () => { project.resolve(stats(30)) })
|
||||
expect(result.current).toEqual({ conversation: stats(20), project: stats(30) })
|
||||
await act(async () => { vi.advanceTimersByTime(4_999) })
|
||||
act(() => { vi.advanceTimersByTime(6000) })
|
||||
expect(result.current.conversation?.durationMs).toBe(13_000)
|
||||
expect(result.current.project?.taskDurations[0]?.durationMs).toBe(13_000)
|
||||
expect(getExecutionStats).toHaveBeenCalledTimes(2)
|
||||
await act(async () => { vi.advanceTimersByTime(1) })
|
||||
getExecutionStats.mockImplementation(async () => snapshot(0, 13_000))
|
||||
act(() => { listeners.forEach((listener) => listener()) })
|
||||
await flush()
|
||||
act(() => { vi.advanceTimersByTime(60_000) })
|
||||
expect(result.current.conversation?.durationMs).toBe(13_000)
|
||||
expect(getExecutionStats).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it('replaces the poller on revision changes and removes timers and listeners on unmount', async () => {
|
||||
const conversation = deferred()
|
||||
const project = deferred()
|
||||
getExecutionStats.mockReturnValueOnce(conversation.promise).mockReturnValueOnce(project.promise)
|
||||
const { result, rerender, unmount } = renderStats()
|
||||
await act(async () => { vi.advanceTimersByTime(2_000) })
|
||||
rerender({ ...initialProps, revision: '2' })
|
||||
await act(async () => {})
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
await act(async () => {
|
||||
conversation.resolve(stats(90))
|
||||
project.resolve(stats(99))
|
||||
vi.advanceTimersByTime(3_000)
|
||||
})
|
||||
expect(result.current).toEqual({ conversation: stats(10), project: stats(10) })
|
||||
expect(getExecutionStats).toHaveBeenCalledTimes(4)
|
||||
await act(async () => { vi.advanceTimersByTime(2_000) })
|
||||
expect(getExecutionStats).toHaveBeenCalledTimes(6)
|
||||
|
||||
const lastConversation = deferred()
|
||||
const lastProject = deferred()
|
||||
getExecutionStats.mockReturnValueOnce(lastConversation.promise).mockReturnValueOnce(lastProject.promise)
|
||||
await act(async () => { window.dispatchEvent(new Event('focus')) })
|
||||
unmount()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
await act(async () => {
|
||||
lastConversation.resolve(stats(50))
|
||||
lastProject.resolve(stats(60))
|
||||
})
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(15_000)
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
})
|
||||
expect(getExecutionStats).toHaveBeenCalledTimes(8)
|
||||
})
|
||||
|
||||
it.each(['conversation', 'project', 'both'] as const)(
|
||||
'clears stale statistics when %s queries fail and recovers on the next poll', async (failure) => {
|
||||
const { result } = renderStats()
|
||||
await act(async () => {})
|
||||
expect(result.current).toEqual({ conversation: stats(10), project: stats(10) })
|
||||
getExecutionStats.mockImplementation(async (input) => {
|
||||
if (failure === 'both' ||
|
||||
(failure === 'conversation' && 'conversationId' in input) ||
|
||||
(failure === 'project' && 'projectId' in input)) {
|
||||
throw new Error('stats unavailable')
|
||||
}
|
||||
return stats(20)
|
||||
})
|
||||
await act(async () => { vi.advanceTimersByTime(5_000) })
|
||||
expect(result.current).toEqual({
|
||||
conversation: failure === 'project' ? stats(20) : undefined,
|
||||
project: failure === 'conversation' ? stats(20) : undefined
|
||||
})
|
||||
getExecutionStats.mockResolvedValue(stats(30))
|
||||
await act(async () => { vi.advanceTimersByTime(5_000) })
|
||||
expect(result.current).toEqual({ conversation: stats(30), project: stats(30) })
|
||||
}
|
||||
)
|
||||
|
||||
it('does not query while hidden and resumes when visible', async () => {
|
||||
const visibility = vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden')
|
||||
const { result } = renderStats()
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(15_000)
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
})
|
||||
expect(getExecutionStats).not.toHaveBeenCalled()
|
||||
expect(result.current).toEqual(empty)
|
||||
visibility.mockReturnValue('visible')
|
||||
await act(async () => { document.dispatchEvent(new Event('visibilitychange')) })
|
||||
expect(getExecutionStats).toHaveBeenCalledTimes(2)
|
||||
expect(result.current).toEqual({ conversation: stats(10), project: stats(10) })
|
||||
visibility.mockReturnValue('hidden')
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10_000)
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
})
|
||||
expect(getExecutionStats).toHaveBeenCalledTimes(2)
|
||||
visibility.mockReturnValue('visible')
|
||||
await act(async () => { window.dispatchEvent(new Event('focus')) })
|
||||
expect(getExecutionStats).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
it.each(['disabled', 'no-project'] as const)('stops querying and hides results when %s', async (mode) => {
|
||||
const { result, rerender } = renderStats()
|
||||
await act(async () => {})
|
||||
const conversation = deferred()
|
||||
const project = deferred()
|
||||
getExecutionStats.mockReturnValueOnce(conversation.promise).mockReturnValueOnce(project.promise)
|
||||
await act(async () => { vi.advanceTimersByTime(5_000) })
|
||||
rerender({
|
||||
...initialProps, enabled: mode !== 'disabled',
|
||||
projectId: mode === 'no-project' ? undefined : initialProps.projectId
|
||||
})
|
||||
expect(result.current).toEqual(empty)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
await act(async () => {
|
||||
conversation.resolve(stats(90))
|
||||
project.reject(new Error('late failure'))
|
||||
})
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(15_000)
|
||||
window.dispatchEvent(new Event('focus'))
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
})
|
||||
expect(getExecutionStats).toHaveBeenCalledTimes(4)
|
||||
expect(result.current).toEqual(empty)
|
||||
rerender(initialProps)
|
||||
await act(async () => {})
|
||||
expect(getExecutionStats).toHaveBeenCalledTimes(6)
|
||||
expect(result.current).toEqual({ conversation: stats(10), project: stats(10) })
|
||||
})
|
||||
|
||||
it('queries only the project when no conversation is selected', async () => {
|
||||
const { result, rerender } = renderStats()
|
||||
await act(async () => {})
|
||||
getExecutionStats.mockClear()
|
||||
rerender({ ...initialProps, conversationId: undefined })
|
||||
it('unsubscribes while hidden and gets authoritative snapshots on reopening', async () => {
|
||||
const { result, rerender, unmount } = renderHook(({ enabled }) => useExecutionStats('c', 'p', enabled),
|
||||
{ initialProps: { enabled: true } })
|
||||
await flush()
|
||||
rerender({ enabled: false })
|
||||
expect(listeners.size).toBe(0)
|
||||
expect(result.current.conversation).toBeUndefined()
|
||||
await act(async () => {})
|
||||
expect(getExecutionStats.mock.calls).toEqual([[{ projectId: 'project-a' }]])
|
||||
expect(result.current).toEqual({ conversation: undefined, project: stats(10) })
|
||||
act(() => { vi.advanceTimersByTime(60_000) })
|
||||
expect(getExecutionStats).toHaveBeenCalledTimes(2)
|
||||
getExecutionStats.mockImplementation(async () => snapshot(1, 60_000))
|
||||
rerender({ enabled: true })
|
||||
await flush()
|
||||
expect(result.current.conversation?.durationMs).toBe(60_000)
|
||||
unmount()
|
||||
expect(listeners.size).toBe(0)
|
||||
})
|
||||
|
||||
it('discards old scopes and in-flight snapshots superseded by a state change', async () => {
|
||||
const pending: Array<(value: ExecutionStats) => void> = []
|
||||
getExecutionStats.mockImplementation(() => new Promise<ExecutionStats>((resolve) => pending.push(resolve)))
|
||||
const { result, rerender } = renderHook(({ id }) => useExecutionStats(id, 'p', true), { initialProps: { id: 'old' } })
|
||||
rerender({ id: 'new' })
|
||||
act(() => { listeners.forEach((listener) => listener()) })
|
||||
await act(async () => { pending[4]!(snapshot(0, 9000)); pending[5]!(snapshot(0, 9000)) })
|
||||
await act(async () => { for (const resolve of pending.slice(0, 4)) resolve(snapshot(1, 1000)) })
|
||||
expect(result.current.conversation?.durationMs).toBe(9000)
|
||||
expect(result.current.project?.runningCount).toBe(0)
|
||||
})
|
||||
|
||||
it('does not tick idle snapshots and clears a failed scope', async () => {
|
||||
const { result } = renderHook(() => useExecutionStats('c', 'p', true))
|
||||
await flush()
|
||||
act(() => { vi.advanceTimersByTime(60_000) })
|
||||
expect(result.current.conversation?.durationMs).toBe(1000)
|
||||
expect(getExecutionStats).toHaveBeenCalledTimes(2)
|
||||
getExecutionStats.mockRejectedValue(new Error('unavailable'))
|
||||
act(() => { listeners.forEach((listener) => listener()) })
|
||||
await flush()
|
||||
expect(result.current).toEqual({ conversation: undefined, project: undefined })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,61 +1,56 @@
|
||||
import { startTransition, useEffect, useState } from 'react'
|
||||
import type { ExecutionStats } from '../../shared/assistant-contracts'
|
||||
|
||||
// Keep timing evidence in Main; never extrapolate a stopped or disconnected run.
|
||||
export function useExecutionStats(
|
||||
conversationId: string | undefined,
|
||||
projectId: string | undefined,
|
||||
revision: string,
|
||||
enabled: boolean
|
||||
): { conversation?: ExecutionStats; project?: ExecutionStats } {
|
||||
const [snapshots, setSnapshots] = useState(() => new Map<string, ExecutionStats>())
|
||||
const conversationKey = JSON.stringify([projectId, conversationId])
|
||||
const projectKey = JSON.stringify([projectId])
|
||||
const key = JSON.stringify([projectId, conversationId])
|
||||
const [snapshot, setSnapshot] = useState<{
|
||||
key: string; conversation?: ExecutionStats; project?: ExecutionStats
|
||||
}>()
|
||||
const [now, setNow] = useState(Date.now)
|
||||
useEffect(() => {
|
||||
if (!enabled || !projectId) return
|
||||
let cancelled = false
|
||||
let pending = false
|
||||
let revision = 0
|
||||
const refresh = async (): Promise<void> => {
|
||||
if (pending || document.visibilityState === 'hidden') return
|
||||
pending = true
|
||||
const current = ++revision
|
||||
const [conversation, project] = await Promise.allSettled([
|
||||
conversationId
|
||||
? window.goodbuddy.tasks.getExecutionStats({ conversationId })
|
||||
: Promise.resolve(undefined),
|
||||
window.goodbuddy.tasks.getExecutionStats({ projectId })
|
||||
])
|
||||
pending = false
|
||||
if (!cancelled) startTransition(() => setSnapshots((current) => {
|
||||
if (cancelled) return current
|
||||
const next = new Map(current)
|
||||
// IPC clones cached snapshots. Preserve references when their contents are unchanged
|
||||
// so idle polling does not rerender App and rebuild task duration maps.
|
||||
for (const [key, result] of [[conversationKey, conversation], [projectKey, project]] as const) {
|
||||
const value = result.status === 'fulfilled' ? result.value : undefined
|
||||
if (JSON.stringify(current.get(key)) === JSON.stringify(value)) continue
|
||||
if (value) next.set(key, value)
|
||||
else next.delete(key)
|
||||
}
|
||||
return next.size === current.size &&
|
||||
next.get(conversationKey) === current.get(conversationKey) &&
|
||||
next.get(projectKey) === current.get(projectKey) ? current : next
|
||||
}))
|
||||
if (cancelled || current !== revision) return
|
||||
startTransition(() => {
|
||||
if (cancelled || current !== revision) return
|
||||
setNow(Date.now())
|
||||
setSnapshot({ key,
|
||||
conversation: conversation.status === 'fulfilled' ? conversation.value : undefined,
|
||||
project: project.status === 'fulfilled' ? project.value : undefined })
|
||||
})
|
||||
}
|
||||
const unsubscribe = window.goodbuddy.tasks.onExecutionStatsChanged(() => { void refresh() })
|
||||
void refresh()
|
||||
const interval = window.setInterval(() => { void refresh() }, 5_000)
|
||||
const onFocus = (): void => { void refresh() }
|
||||
window.addEventListener('focus', onFocus)
|
||||
document.addEventListener('visibilitychange', onFocus)
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearInterval(interval)
|
||||
window.removeEventListener('focus', onFocus)
|
||||
document.removeEventListener('visibilitychange', onFocus)
|
||||
}
|
||||
}, [conversationId, projectId, conversationKey, projectKey, revision, enabled])
|
||||
return {
|
||||
conversation: enabled && projectId && conversationId
|
||||
? snapshots.get(conversationKey) : undefined,
|
||||
project: enabled && projectId ? snapshots.get(projectKey) : undefined
|
||||
return () => { cancelled = true; unsubscribe() }
|
||||
}, [conversationId, projectId, key, enabled])
|
||||
|
||||
const current = enabled && snapshot?.key === key ? snapshot : undefined
|
||||
const running = Boolean(current?.conversation?.runningCount || current?.project?.runningCount)
|
||||
useEffect(() => {
|
||||
if (!running) return
|
||||
// Display only: no IPC or persistence on ticks, including in background tabs.
|
||||
const interval = window.setInterval(() => setNow(Date.now()), 1_000)
|
||||
return () => window.clearInterval(interval)
|
||||
}, [running])
|
||||
const display = (value: ExecutionStats | undefined): ExecutionStats | undefined => {
|
||||
if (!value?.runningCount) return value
|
||||
const elapsed = Math.max(0, now - value.asOf)
|
||||
return { ...value, durationMs: value.durationMs + elapsed * value.runningCount,
|
||||
taskDurations: value.taskDurations.map((task) => ({ ...task,
|
||||
durationMs: task.durationMs + elapsed * task.runningCount })) }
|
||||
}
|
||||
return { conversation: display(current?.conversation), project: display(current?.project) }
|
||||
}
|
||||
|
||||
@@ -14,17 +14,16 @@ export const executionStatsInputSchema = z.union([
|
||||
])
|
||||
export type ExecutionStatsInput = z.infer<typeof executionStatsInputSchema>
|
||||
export interface ExecutionStats {
|
||||
/** Sum of evidenced top-level reply intervals, including tools and approvals. */
|
||||
/** Accumulated running time, including open segments through asOf. */
|
||||
durationMs: number
|
||||
requestCount: number
|
||||
/** Requests/replies with missing timing evidence; confirmed live intervals are complete as of asOf. */
|
||||
incompleteRequestCount: number
|
||||
/** Requests currently leased by Main; does not authorize renderer extrapolation. */
|
||||
activeRequestCount: number
|
||||
/** Epoch milliseconds at query time. */
|
||||
/** Number of open segments; the UI adds (now - asOf) * runningCount. */
|
||||
runningCount: number
|
||||
/** Pre-timing history or an interrupted segment has unknown duration. */
|
||||
incomplete: boolean
|
||||
/** Main epoch milliseconds at snapshot time. */
|
||||
asOf: number
|
||||
/** Project card totals; empty for conversation queries. */
|
||||
taskDurations: Array<{ id: string; durationMs: number; incompleteRequestCount: number }>
|
||||
taskDurations: Array<{ id: string; durationMs: number; runningCount: number; incomplete: boolean }>
|
||||
}
|
||||
export const interactiveWorkModes = ['ask', 'execute'] as const
|
||||
export const workModeSchema = z.enum(interactiveWorkModes)
|
||||
|
||||
@@ -2155,6 +2155,7 @@ export type DesktopApi = {
|
||||
tasks: {
|
||||
list: () => Promise<AssistantTask[]>
|
||||
getExecutionStats: (input: ExecutionStatsInput) => Promise<ExecutionStats>
|
||||
onExecutionStatsChanged: (listener: () => void) => () => void
|
||||
setStatus: (
|
||||
taskId: string,
|
||||
status: Extract<AssistantTask['status'], 'completed' | 'cancelled'>
|
||||
|
||||
@@ -252,6 +252,7 @@ export const ipcChannels = {
|
||||
workspacePathOpen: 'workspace:path:open',
|
||||
tasksList: 'tasks:list',
|
||||
tasksExecutionStats: 'tasks:execution-stats',
|
||||
tasksExecutionStatsChanged: 'tasks:execution-stats-changed',
|
||||
tasksSetStatus: 'tasks:set-status',
|
||||
activityHistoryGet: 'activity-history:get',
|
||||
activityHistoryReplace: 'activity-history:replace',
|
||||
|
||||
Reference in New Issue
Block a user