byterover-cli 3.11.0 → 3.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/infra/tools/implementations/curate-tool.js +18 -8
- package/dist/server/constants.d.ts +6 -0
- package/dist/server/constants.js +11 -0
- package/dist/server/core/domain/entities/task-history-entry.d.ts +775 -0
- package/dist/server/core/domain/entities/task-history-entry.js +88 -0
- package/dist/server/core/domain/transport/schemas.d.ts +1403 -11
- package/dist/server/core/domain/transport/schemas.js +157 -6
- package/dist/server/core/domain/transport/task-info.d.ts +18 -0
- package/dist/server/core/interfaces/process/i-task-lifecycle-hook.d.ts +7 -0
- package/dist/server/core/interfaces/storage/i-task-history-store.d.ts +62 -0
- package/dist/server/core/interfaces/storage/i-task-history-store.js +1 -0
- package/dist/server/infra/daemon/brv-server.js +43 -18
- package/dist/server/infra/dream/dream-response-schemas.d.ts +24 -0
- package/dist/server/infra/dream/dream-response-schemas.js +7 -0
- package/dist/server/infra/dream/operations/consolidate.js +21 -8
- package/dist/server/infra/dream/operations/synthesize.js +35 -8
- package/dist/server/infra/process/task-history-entry-builder.d.ts +36 -0
- package/dist/server/infra/process/task-history-entry-builder.js +101 -0
- package/dist/server/infra/process/task-history-hook.d.ts +37 -0
- package/dist/server/infra/process/task-history-hook.js +70 -0
- package/dist/server/infra/process/task-history-store-cache.d.ts +25 -0
- package/dist/server/infra/process/task-history-store-cache.js +106 -0
- package/dist/server/infra/process/task-router.d.ts +72 -0
- package/dist/server/infra/process/task-router.js +690 -15
- package/dist/server/infra/process/transport-handlers.d.ts +8 -0
- package/dist/server/infra/process/transport-handlers.js +2 -0
- package/dist/server/infra/storage/file-task-history-store.d.ts +294 -0
- package/dist/server/infra/storage/file-task-history-store.js +912 -0
- package/dist/shared/transport/events/index.d.ts +5 -0
- package/dist/shared/transport/events/task-events.d.ts +204 -1
- package/dist/shared/transport/events/task-events.js +11 -0
- package/dist/tui/features/tasks/hooks/use-task-subscriptions.js +7 -0
- package/dist/tui/features/tasks/stores/tasks-store.d.ts +4 -16
- package/dist/tui/features/tasks/stores/tasks-store.js +7 -0
- package/dist/tui/types/messages.d.ts +2 -9
- package/dist/webui/assets/index-DyVvFoM6.css +1 -0
- package/dist/webui/assets/index-lr0byHh9.js +130 -0
- package/dist/webui/index.html +2 -2
- package/dist/webui/sw.js +1 -1
- package/oclif.manifest.json +665 -665
- package/package.json +1 -1
- package/dist/webui/assets/index--sXE__bc.css +0 -1
- package/dist/webui/assets/index-Bkkx961b.js +0 -130
|
@@ -21,14 +21,101 @@ import { transportLog } from '../../utils/process-logger.js';
|
|
|
21
21
|
import { isValidTaskType } from '../../utils/type-guards.js';
|
|
22
22
|
import { resolveProject } from '../project/resolve-project.js';
|
|
23
23
|
import { broadcastToProjectRoom } from './broadcast-utils.js';
|
|
24
|
+
import { buildTaskHistoryEntry } from './task-history-entry-builder.js';
|
|
24
25
|
/**
|
|
25
26
|
* Grace period (in ms) to keep completed tasks in memory for late-arriving events.
|
|
26
27
|
* Prevents silent event drops when llmservice:* events arrive after task:completed.
|
|
27
28
|
*/
|
|
28
29
|
const TASK_CLEANUP_GRACE_PERIOD_MS = 5000;
|
|
30
|
+
/** Default page size for `task:list` when caller omits `pageSize`. Schema caps at 1000. */
|
|
31
|
+
const DEFAULT_TASK_LIST_PAGE_SIZE = 50;
|
|
32
|
+
/** Statuses considered terminal for delete refusal (M2.09). */
|
|
33
|
+
const TERMINAL_STATUSES = new Set(['cancelled', 'completed', 'error']);
|
|
29
34
|
function hasTaskId(data) {
|
|
30
35
|
return typeof data === 'object' && data !== null && 'taskId' in data && typeof data.taskId === 'string';
|
|
31
36
|
}
|
|
37
|
+
/** Type guard for a plain JSON object — replaces ad-hoc `as Record<string, unknown>` casts. */
|
|
38
|
+
function isRecord(value) {
|
|
39
|
+
return typeof value === 'object' && value !== null;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Bounded-concurrency map for async I/O (M2.16 pass-2 lazy crack of data files).
|
|
43
|
+
* Keeps file-descriptor usage well under macOS default soft limit (256).
|
|
44
|
+
* No external dep (`p-limit` is not installed); ~10 lines hand-roll.
|
|
45
|
+
*/
|
|
46
|
+
async function mapBounded(items, limit, fn) {
|
|
47
|
+
const results = Array.from({ length: items.length });
|
|
48
|
+
let nextIdx = 0;
|
|
49
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
50
|
+
while (true) {
|
|
51
|
+
const i = nextIdx++;
|
|
52
|
+
if (i >= items.length)
|
|
53
|
+
return;
|
|
54
|
+
// eslint-disable-next-line no-await-in-loop -- bounded worker pool; awaiting in sequence inside each worker is the point
|
|
55
|
+
results[i] = await fn(items[i]);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
await Promise.all(workers);
|
|
59
|
+
return results;
|
|
60
|
+
}
|
|
61
|
+
/** Concurrency cap for pass-2 lazy crack — keeps FD usage minimal. */
|
|
62
|
+
const FULL_TEXT_CONCURRENCY = 16;
|
|
63
|
+
/** Build a clamped, well-typed empty response (M2.16). */
|
|
64
|
+
function emptyTaskListResponse(data) {
|
|
65
|
+
const pageSize = Math.min(Math.max(data.pageSize ?? DEFAULT_TASK_LIST_PAGE_SIZE, 1), 1000);
|
|
66
|
+
const page = Math.max(data.page ?? 1, 1);
|
|
67
|
+
return {
|
|
68
|
+
availableModels: [],
|
|
69
|
+
availableProviders: [],
|
|
70
|
+
counts: { all: 0, cancelled: 0, completed: 0, failed: 0, running: 0 },
|
|
71
|
+
page,
|
|
72
|
+
pageCount: 1,
|
|
73
|
+
pageSize,
|
|
74
|
+
tasks: [],
|
|
75
|
+
total: 0,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function matchesListFilters(item, filters) {
|
|
79
|
+
const taskProject = item.projectPath;
|
|
80
|
+
if (taskProject !== undefined && taskProject !== filters.projectFilter)
|
|
81
|
+
return false;
|
|
82
|
+
if (filters.statusFilter && filters.statusFilter.length > 0 && !filters.statusFilter.includes(item.status)) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
if (filters.typeFilter && filters.typeFilter.length > 0 && !filters.typeFilter.includes(item.type)) {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
if (filters.providerFilter &&
|
|
89
|
+
filters.providerFilter.length > 0 &&
|
|
90
|
+
(item.provider === undefined || !filters.providerFilter.includes(item.provider)))
|
|
91
|
+
return false;
|
|
92
|
+
if (filters.modelFilter &&
|
|
93
|
+
filters.modelFilter.length > 0 &&
|
|
94
|
+
(item.model === undefined || !filters.modelFilter.includes(item.model)))
|
|
95
|
+
return false;
|
|
96
|
+
if (filters.createdAfter !== undefined && item.createdAt < filters.createdAfter)
|
|
97
|
+
return false;
|
|
98
|
+
if (filters.createdBefore !== undefined && item.createdAt > filters.createdBefore)
|
|
99
|
+
return false;
|
|
100
|
+
if (filters.minDurationMs !== undefined || filters.maxDurationMs !== undefined) {
|
|
101
|
+
if (item.startedAt === undefined || item.completedAt === undefined)
|
|
102
|
+
return false;
|
|
103
|
+
const dur = item.completedAt - item.startedAt;
|
|
104
|
+
if (filters.minDurationMs !== undefined && dur < filters.minDurationMs)
|
|
105
|
+
return false;
|
|
106
|
+
if (filters.maxDurationMs !== undefined && dur > filters.maxDurationMs)
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
if (filters.searchText !== undefined && filters.searchText.length > 0) {
|
|
110
|
+
const haystack = (item.content + '\n' + (item.error?.message ?? '')).toLowerCase();
|
|
111
|
+
if (!haystack.includes(filters.searchText.toLowerCase()))
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
// `synthesizeEntryFromTaskInfo` was extracted to `task-history-entry-builder.ts`
|
|
117
|
+
// alongside `TaskHistoryHook`'s identical code path. Both consumers now import
|
|
118
|
+
// the same builder, so the two no longer drift.
|
|
32
119
|
function toListItem(task) {
|
|
33
120
|
const status = task.status ?? (task.completedAt ? 'completed' : task.startedAt ? 'started' : 'created');
|
|
34
121
|
return {
|
|
@@ -38,7 +125,9 @@ function toListItem(task) {
|
|
|
38
125
|
...(task.error ? { error: task.error } : {}),
|
|
39
126
|
...(task.files && task.files.length > 0 ? { files: task.files } : {}),
|
|
40
127
|
...(task.folderPath ? { folderPath: task.folderPath } : {}),
|
|
128
|
+
...(task.model ? { model: task.model } : {}),
|
|
41
129
|
...(task.projectPath ? { projectPath: task.projectPath } : {}),
|
|
130
|
+
...(task.provider ? { provider: task.provider } : {}),
|
|
42
131
|
...(task.result ? { result: task.result } : {}),
|
|
43
132
|
...(task.startedAt ? { startedAt: task.startedAt } : {}),
|
|
44
133
|
status,
|
|
@@ -47,18 +136,30 @@ function toListItem(task) {
|
|
|
47
136
|
};
|
|
48
137
|
}
|
|
49
138
|
export class TaskRouter {
|
|
139
|
+
/**
|
|
140
|
+
* Throttle window for `onTaskUpdate` flushes — bursts of llmservice events
|
|
141
|
+
* are coalesced into one save per window. 100ms keeps perceived latency low
|
|
142
|
+
* while bounding write volume on chatty multi-step agents.
|
|
143
|
+
*/
|
|
144
|
+
static FLUSH_INTERVAL_MS = 100;
|
|
50
145
|
agentPool;
|
|
51
146
|
/**
|
|
52
147
|
* Track recently completed tasks for grace period.
|
|
53
148
|
* Allows late-arriving llmservice:* events to be routed even after task:completed.
|
|
54
149
|
*/
|
|
55
150
|
completedTasks = new Map();
|
|
151
|
+
/** TaskIds with pending in-flight mutations awaiting the next throttled flush. */
|
|
152
|
+
dirtyTaskIds = new Set();
|
|
153
|
+
/** Pending throttle timer, if any. */
|
|
154
|
+
flushTimer;
|
|
56
155
|
getAgentForProject;
|
|
156
|
+
getTaskHistoryStore;
|
|
57
157
|
isReviewDisabled;
|
|
58
158
|
lifecycleHooks;
|
|
59
159
|
preDispatchCheck;
|
|
60
160
|
projectRegistry;
|
|
61
161
|
projectRouter;
|
|
162
|
+
resolveActiveProvider;
|
|
62
163
|
resolveClientProjectPath;
|
|
63
164
|
/** Track active tasks */
|
|
64
165
|
tasks = new Map();
|
|
@@ -67,16 +168,23 @@ export class TaskRouter {
|
|
|
67
168
|
this.transport = options.transport;
|
|
68
169
|
this.agentPool = options.agentPool;
|
|
69
170
|
this.getAgentForProject = options.getAgentForProject;
|
|
171
|
+
this.getTaskHistoryStore = options.getTaskHistoryStore;
|
|
70
172
|
this.isReviewDisabled = options.isReviewDisabled;
|
|
71
173
|
this.lifecycleHooks = options.lifecycleHooks ?? [];
|
|
72
174
|
this.preDispatchCheck = options.preDispatchCheck;
|
|
73
175
|
this.projectRegistry = options.projectRegistry;
|
|
74
176
|
this.projectRouter = options.projectRouter;
|
|
177
|
+
this.resolveActiveProvider = options.resolveActiveProvider;
|
|
75
178
|
this.resolveClientProjectPath = options.resolveClientProjectPath;
|
|
76
179
|
}
|
|
77
180
|
clearTasks() {
|
|
78
181
|
this.tasks.clear();
|
|
79
182
|
this.completedTasks.clear();
|
|
183
|
+
this.dirtyTaskIds.clear();
|
|
184
|
+
if (this.flushTimer !== undefined) {
|
|
185
|
+
clearTimeout(this.flushTimer);
|
|
186
|
+
this.flushTimer = undefined;
|
|
187
|
+
}
|
|
80
188
|
}
|
|
81
189
|
/**
|
|
82
190
|
* Remove a task from tracking and send error to its client.
|
|
@@ -139,6 +247,11 @@ export class TaskRouter {
|
|
|
139
247
|
this.transport.onRequest(TransportTaskEventNames.CANCEL, (data, clientId) => this.handleTaskCancel(data, clientId));
|
|
140
248
|
// Snapshot query from clients (e.g. web UI Tasks tab)
|
|
141
249
|
this.transport.onRequest(TransportTaskEventNames.LIST, (data, clientId) => this.handleTaskList(data, clientId));
|
|
250
|
+
// M2.09 — persistent-history handlers
|
|
251
|
+
this.transport.onRequest(TransportTaskEventNames.GET, (data, clientId) => this.handleTaskGet(data, clientId));
|
|
252
|
+
this.transport.onRequest(TransportTaskEventNames.DELETE, (data, clientId) => this.handleTaskDelete(data, clientId));
|
|
253
|
+
this.transport.onRequest(TransportTaskEventNames.DELETE_BULK, (data, clientId) => this.handleTaskDeleteBulk(data, clientId));
|
|
254
|
+
this.transport.onRequest(TransportTaskEventNames.CLEAR_COMPLETED, (data, clientId) => this.handleTaskClearCompleted(data, clientId));
|
|
142
255
|
// Task lifecycle events from agent
|
|
143
256
|
this.transport.onRequest(TransportTaskEventNames.STARTED, (data) => {
|
|
144
257
|
this.handleTaskStarted(data);
|
|
@@ -157,6 +270,160 @@ export class TaskRouter {
|
|
|
157
270
|
this.registerLlmEvent(eventName);
|
|
158
271
|
}
|
|
159
272
|
}
|
|
273
|
+
/**
|
|
274
|
+
* Mutate the live `TaskInfo` from an `llmservice:*` event so a tab refresh
|
|
275
|
+
* during the throttle window sees the in-flight state. Each branch:
|
|
276
|
+
* - thinking: push a `{isThinking: true, content: ''}` marker
|
|
277
|
+
* - chunk(reasoning): append to last item / flip empty marker / push fresh
|
|
278
|
+
* - chunk(text): IGNORED for persistence (transient stream)
|
|
279
|
+
* - response: set responseContent + sessionId (overwrite — multi-step keeps latest)
|
|
280
|
+
* - toolCall: push running entry
|
|
281
|
+
* - toolResult: update existing entry by callId
|
|
282
|
+
* - error / unsupportedInput: IGNORED (terminal hooks capture failure)
|
|
283
|
+
*
|
|
284
|
+
* Mutations use immutable `tasks.set(id, {...task, ...delta})` so consumers
|
|
285
|
+
* holding a captured reference (e.g. notifyHooks*) see a stable snapshot.
|
|
286
|
+
*/
|
|
287
|
+
accumulateLlmEvent(taskId, eventName, data) {
|
|
288
|
+
const task = this.tasks.get(taskId);
|
|
289
|
+
if (!task)
|
|
290
|
+
return;
|
|
291
|
+
switch (eventName) {
|
|
292
|
+
case LlmEventNames.CHUNK: {
|
|
293
|
+
if (data.type !== 'reasoning')
|
|
294
|
+
return; // 'text' is transient — ignore
|
|
295
|
+
const content = typeof data.content === 'string' ? data.content : '';
|
|
296
|
+
const items = task.reasoningContents ?? [];
|
|
297
|
+
const last = items.at(-1);
|
|
298
|
+
let nextItems;
|
|
299
|
+
if (last === undefined) {
|
|
300
|
+
// Case C: empty array — push fresh body entry.
|
|
301
|
+
nextItems = [{ content, isThinking: false, timestamp: Date.now() }];
|
|
302
|
+
}
|
|
303
|
+
else if (last.isThinking === true && (last.content ?? '') === '') {
|
|
304
|
+
// Case A: flip the empty isThinking marker to body.
|
|
305
|
+
nextItems = [...items.slice(0, -1), { ...last, content, isThinking: false }];
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
// Case B: append to existing body.
|
|
309
|
+
nextItems = [...items.slice(0, -1), { ...last, content: (last.content ?? '') + content }];
|
|
310
|
+
}
|
|
311
|
+
this.tasks.set(taskId, { ...task, reasoningContents: nextItems });
|
|
312
|
+
this.markDirty(taskId);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
case LlmEventNames.RESPONSE: {
|
|
316
|
+
const content = typeof data.content === 'string' ? data.content : '';
|
|
317
|
+
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : task.sessionId;
|
|
318
|
+
this.tasks.set(taskId, {
|
|
319
|
+
...task,
|
|
320
|
+
responseContent: content,
|
|
321
|
+
...(sessionId === undefined ? {} : { sessionId }),
|
|
322
|
+
});
|
|
323
|
+
this.markDirty(taskId);
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
case LlmEventNames.THINKING: {
|
|
327
|
+
// Dedup parity with the TUI store (`tasks-store.ts:127`): if the last
|
|
328
|
+
// reasoning item is already a THINKING marker, skip — the model is
|
|
329
|
+
// about to stream more reasoning content that will be appended to it.
|
|
330
|
+
// Without this, repeated THINKING events from the provider produce
|
|
331
|
+
// multiple empty `{isThinking: true, content: ''}` items in persisted
|
|
332
|
+
// entries, which the live UI never showed.
|
|
333
|
+
const items = task.reasoningContents ?? [];
|
|
334
|
+
const last = items.at(-1);
|
|
335
|
+
if (last?.isThinking === true)
|
|
336
|
+
return;
|
|
337
|
+
const nextItems = [...items, { content: '', isThinking: true, timestamp: Date.now() }];
|
|
338
|
+
this.tasks.set(taskId, { ...task, reasoningContents: nextItems });
|
|
339
|
+
this.markDirty(taskId);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
case LlmEventNames.TOOL_CALL: {
|
|
343
|
+
const args = isRecord(data.args) ? data.args : {};
|
|
344
|
+
const callId = typeof data.callId === 'string' ? data.callId : undefined;
|
|
345
|
+
const sessionId = typeof data.sessionId === 'string' ? data.sessionId : '';
|
|
346
|
+
const toolName = typeof data.toolName === 'string' ? data.toolName : '';
|
|
347
|
+
const newCall = {
|
|
348
|
+
args,
|
|
349
|
+
...(callId === undefined ? {} : { callId }),
|
|
350
|
+
sessionId,
|
|
351
|
+
status: 'running',
|
|
352
|
+
timestamp: Date.now(),
|
|
353
|
+
toolName,
|
|
354
|
+
};
|
|
355
|
+
this.tasks.set(taskId, {
|
|
356
|
+
...task,
|
|
357
|
+
toolCalls: [...(task.toolCalls ?? []), newCall],
|
|
358
|
+
});
|
|
359
|
+
this.markDirty(taskId);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
case LlmEventNames.TOOL_RESULT: {
|
|
363
|
+
const callId = typeof data.callId === 'string' ? data.callId : undefined;
|
|
364
|
+
if (callId === undefined)
|
|
365
|
+
return;
|
|
366
|
+
const items = task.toolCalls ?? [];
|
|
367
|
+
const idx = items.findIndex((c) => c.callId === callId);
|
|
368
|
+
if (idx === -1)
|
|
369
|
+
return;
|
|
370
|
+
const success = data.success !== false;
|
|
371
|
+
const updated = {
|
|
372
|
+
...items[idx],
|
|
373
|
+
...(typeof data.error === 'string' ? { error: data.error } : {}),
|
|
374
|
+
...(typeof data.errorType === 'string' ? { errorType: data.errorType } : {}),
|
|
375
|
+
...(data.result === undefined ? {} : { result: data.result }),
|
|
376
|
+
status: success ? 'completed' : 'error',
|
|
377
|
+
};
|
|
378
|
+
const nextCalls = [...items.slice(0, idx), updated, ...items.slice(idx + 1)];
|
|
379
|
+
this.tasks.set(taskId, { ...task, toolCalls: nextCalls });
|
|
380
|
+
this.markDirty(taskId);
|
|
381
|
+
break;
|
|
382
|
+
}
|
|
383
|
+
// ERROR + UNSUPPORTED_INPUT: ignored — terminal lifecycle hook captures failure.
|
|
384
|
+
default:
|
|
385
|
+
// No mutation; fall through.
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Emit `task:deleted` to the project room when a task is removed. Skips
|
|
390
|
+
* silently when no projectPath is resolvable (broadcast wouldn't reach
|
|
391
|
+
* any room). Clients that miss the broadcast will simply not see a row
|
|
392
|
+
* disappear; they reconcile on next `task:list`.
|
|
393
|
+
*/
|
|
394
|
+
broadcastTaskDeleted(projectPath, taskId) {
|
|
395
|
+
if (projectPath === undefined)
|
|
396
|
+
return;
|
|
397
|
+
broadcastToProjectRoom(this.projectRegistry, this.projectRouter, projectPath, TransportTaskEventNames.DELETED, {
|
|
398
|
+
taskId,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Drain the dirty set: for each taskId still active, fire `onTaskUpdate` on
|
|
403
|
+
* each lifecycle hook. Tasks moved to `completedTasks` between markDirty
|
|
404
|
+
* and flush are skipped — their terminal lifecycle hook already saved.
|
|
405
|
+
*/
|
|
406
|
+
async flushDirty() {
|
|
407
|
+
this.flushTimer = undefined;
|
|
408
|
+
if (this.dirtyTaskIds.size === 0)
|
|
409
|
+
return;
|
|
410
|
+
const ids = [...this.dirtyTaskIds];
|
|
411
|
+
this.dirtyTaskIds.clear();
|
|
412
|
+
for (const taskId of ids) {
|
|
413
|
+
const task = this.tasks.get(taskId);
|
|
414
|
+
if (!task)
|
|
415
|
+
continue;
|
|
416
|
+
for (const hook of this.lifecycleHooks) {
|
|
417
|
+
try {
|
|
418
|
+
// eslint-disable-next-line no-await-in-loop -- sequential hook calls by design
|
|
419
|
+
await hook.onTaskUpdate?.(task);
|
|
420
|
+
}
|
|
421
|
+
catch (error) {
|
|
422
|
+
transportLog(`LifecycleHook.onTaskUpdate error for ${taskId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
160
427
|
handleTaskCancel(data, _clientId) {
|
|
161
428
|
const { taskId } = data;
|
|
162
429
|
transportLog(`Task cancel requested: ${taskId}`);
|
|
@@ -196,6 +463,39 @@ export class TaskRouter {
|
|
|
196
463
|
this.notifyHooksCancelled(taskId, task).catch(() => { });
|
|
197
464
|
}
|
|
198
465
|
}
|
|
466
|
+
async handleTaskClearCompleted(data, clientId) {
|
|
467
|
+
const projectFilter = data.projectPath ?? this.resolveClientProjectPath?.(clientId);
|
|
468
|
+
if (projectFilter === undefined)
|
|
469
|
+
return { deletedCount: 0 };
|
|
470
|
+
// In-memory: collect terminal completedTasks for the project.
|
|
471
|
+
const inMemoryIds = [];
|
|
472
|
+
for (const [taskId, { task }] of this.completedTasks) {
|
|
473
|
+
if (task.projectPath !== undefined && task.projectPath !== projectFilter)
|
|
474
|
+
continue;
|
|
475
|
+
inMemoryIds.push(taskId);
|
|
476
|
+
}
|
|
477
|
+
// Persistent: clear matching terminal entries from disk (default statuses).
|
|
478
|
+
let storeIds = [];
|
|
479
|
+
if (this.getTaskHistoryStore !== undefined) {
|
|
480
|
+
try {
|
|
481
|
+
const store = this.getTaskHistoryStore(projectFilter);
|
|
482
|
+
const result = await store.clear({ projectPath: projectFilter });
|
|
483
|
+
storeIds = result.taskIds;
|
|
484
|
+
}
|
|
485
|
+
catch (error) {
|
|
486
|
+
transportLog(`handleTaskClearCompleted: store.clear failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
487
|
+
return { deletedCount: 0, error: 'task history store unavailable' };
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
// Remove from in-memory.
|
|
491
|
+
for (const taskId of inMemoryIds)
|
|
492
|
+
this.completedTasks.delete(taskId);
|
|
493
|
+
// Union + dedupe.
|
|
494
|
+
const allIds = new Set([...inMemoryIds, ...storeIds]);
|
|
495
|
+
for (const taskId of allIds)
|
|
496
|
+
this.broadcastTaskDeleted(projectFilter, taskId);
|
|
497
|
+
return { deletedCount: allIds.size };
|
|
498
|
+
}
|
|
199
499
|
handleTaskCompleted(data) {
|
|
200
500
|
const { logId: eventLogId, result, taskId } = data;
|
|
201
501
|
const existing = this.tasks.get(taskId);
|
|
@@ -305,6 +605,12 @@ export class TaskRouter {
|
|
|
305
605
|
return { taskId };
|
|
306
606
|
}
|
|
307
607
|
transportLog(`Task accepted: ${taskId} (type=${data.type}, client=${clientId})`);
|
|
608
|
+
// Resolve active provider/model snapshot. Conditional await preserves the
|
|
609
|
+
// synchronous "store → broadcast" timing when no resolver is configured —
|
|
610
|
+
// an unconditional await would yield a microtask even on an immediately-
|
|
611
|
+
// resolved Promise, breaking tests that assert on broadcasts without
|
|
612
|
+
// awaiting the handler.
|
|
613
|
+
const { model, provider } = this.resolveActiveProvider ? await this.safeResolveActiveProvider() : {};
|
|
308
614
|
this.tasks.set(taskId, {
|
|
309
615
|
clientId,
|
|
310
616
|
content: data.content,
|
|
@@ -313,7 +619,9 @@ export class TaskRouter {
|
|
|
313
619
|
...(data.clientCwd ? { clientCwd: data.clientCwd } : {}),
|
|
314
620
|
...(data.files?.length ? { files: data.files } : {}),
|
|
315
621
|
...(data.folderPath ? { folderPath: data.folderPath } : {}),
|
|
622
|
+
...(model ? { model } : {}),
|
|
316
623
|
...(projectPath ? { projectPath } : {}),
|
|
624
|
+
...(provider ? { provider } : {}),
|
|
317
625
|
taskId,
|
|
318
626
|
type: data.type,
|
|
319
627
|
...(worktreeRoot ? { worktreeRoot } : {}),
|
|
@@ -324,6 +632,8 @@ export class TaskRouter {
|
|
|
324
632
|
...(data.clientCwd ? { clientCwd: data.clientCwd } : {}),
|
|
325
633
|
...(data.files?.length ? { files: data.files } : {}),
|
|
326
634
|
...(data.folderPath ? { folderPath: data.folderPath } : {}),
|
|
635
|
+
...(model ? { model } : {}),
|
|
636
|
+
...(provider ? { provider } : {}),
|
|
327
637
|
taskId,
|
|
328
638
|
type: data.type,
|
|
329
639
|
};
|
|
@@ -441,6 +751,106 @@ export class TaskRouter {
|
|
|
441
751
|
});
|
|
442
752
|
return { ...(logId ? { logId } : {}), taskId };
|
|
443
753
|
}
|
|
754
|
+
async handleTaskDelete(data, clientId) {
|
|
755
|
+
const { taskId } = data;
|
|
756
|
+
// Refusal: non-terminal in-memory tasks must not be deleted out from under the agent.
|
|
757
|
+
const liveTask = this.tasks.get(taskId);
|
|
758
|
+
if (liveTask !== undefined) {
|
|
759
|
+
const { status } = liveTask;
|
|
760
|
+
if (status !== undefined && !TERMINAL_STATUSES.has(status)) {
|
|
761
|
+
return { error: `cannot delete task in status '${status}'`, removed: false, success: false };
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
// Resolve projectPath: in-memory first, then the client's registered project.
|
|
765
|
+
const projectPath = liveTask?.projectPath ??
|
|
766
|
+
this.completedTasks.get(taskId)?.task.projectPath ??
|
|
767
|
+
this.resolveClientProjectPath?.(clientId);
|
|
768
|
+
const wasInMemory = this.tasks.has(taskId) || this.completedTasks.has(taskId);
|
|
769
|
+
this.tasks.delete(taskId);
|
|
770
|
+
this.completedTasks.delete(taskId);
|
|
771
|
+
let wasLive = false;
|
|
772
|
+
if (this.getTaskHistoryStore !== undefined && projectPath !== undefined) {
|
|
773
|
+
try {
|
|
774
|
+
const store = this.getTaskHistoryStore(projectPath);
|
|
775
|
+
wasLive = await store.delete(taskId);
|
|
776
|
+
}
|
|
777
|
+
catch (error) {
|
|
778
|
+
transportLog(`handleTaskDelete: store.delete failed for ${taskId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
const removed = wasInMemory || wasLive;
|
|
782
|
+
if (removed)
|
|
783
|
+
this.broadcastTaskDeleted(projectPath, taskId);
|
|
784
|
+
// C4: `removed` distinguishes "actually deleted" from "idempotent no-op".
|
|
785
|
+
// `task:deleteBulk` sums on this flag so unknown / already-tombstoned ids
|
|
786
|
+
// never inflate `deletedCount`. The wire-level `success` stays `true` to
|
|
787
|
+
// preserve the documented idempotent contract for single-delete callers.
|
|
788
|
+
return { removed, success: true };
|
|
789
|
+
}
|
|
790
|
+
async handleTaskDeleteBulk(data, clientId) {
|
|
791
|
+
// N3: batch store.deleteMany per project instead of N×handleTaskDelete.
|
|
792
|
+
// Per-id work splits into (a) liveness/refusal checks + in-memory cleanup
|
|
793
|
+
// (cheap, sequential) and (b) the actual store mutation (expensive,
|
|
794
|
+
// batched). Bulk delete of 200 ids in one project: 1 readIndexDedup +
|
|
795
|
+
// 1 batched tombstone vs the previous 200 readIndexDedups.
|
|
796
|
+
const pending = [];
|
|
797
|
+
for (const taskId of data.taskIds) {
|
|
798
|
+
// Refusal: non-terminal in-memory tasks must not be deleted out from
|
|
799
|
+
// under the agent — same contract as the single-delete handler.
|
|
800
|
+
const liveTask = this.tasks.get(taskId);
|
|
801
|
+
if (liveTask !== undefined) {
|
|
802
|
+
const { status } = liveTask;
|
|
803
|
+
if (status !== undefined && !TERMINAL_STATUSES.has(status))
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
// Resolve projectPath: in-memory first, then the client's registered project.
|
|
807
|
+
const projectPath = liveTask?.projectPath ??
|
|
808
|
+
this.completedTasks.get(taskId)?.task.projectPath ??
|
|
809
|
+
this.resolveClientProjectPath?.(clientId);
|
|
810
|
+
if (projectPath === undefined)
|
|
811
|
+
continue;
|
|
812
|
+
const wasInMemory = this.tasks.has(taskId) || this.completedTasks.has(taskId);
|
|
813
|
+
this.tasks.delete(taskId);
|
|
814
|
+
this.completedTasks.delete(taskId);
|
|
815
|
+
pending.push({ projectPath, taskId, wasInMemory });
|
|
816
|
+
}
|
|
817
|
+
// Group by projectPath for batched store.deleteMany.
|
|
818
|
+
const byProject = new Map();
|
|
819
|
+
for (const { projectPath, taskId } of pending) {
|
|
820
|
+
const ids = byProject.get(projectPath) ?? [];
|
|
821
|
+
ids.push(taskId);
|
|
822
|
+
byProject.set(projectPath, ids);
|
|
823
|
+
}
|
|
824
|
+
const storeRemoved = new Set();
|
|
825
|
+
if (this.getTaskHistoryStore !== undefined) {
|
|
826
|
+
for (const [projectPath, ids] of byProject) {
|
|
827
|
+
try {
|
|
828
|
+
const store = this.getTaskHistoryStore(projectPath);
|
|
829
|
+
// eslint-disable-next-line no-await-in-loop -- per-project sequential; project count is small
|
|
830
|
+
const removed = await store.deleteMany(ids);
|
|
831
|
+
for (const id of removed)
|
|
832
|
+
storeRemoved.add(id);
|
|
833
|
+
}
|
|
834
|
+
catch (error) {
|
|
835
|
+
transportLog(`handleTaskDeleteBulk: store.deleteMany failed for ${projectPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
// Final removed set = in-memory hits ∪ store hits. Per-id broadcast for
|
|
840
|
+
// each — preserves the C4 contract (no broadcast for unknown ids).
|
|
841
|
+
const removedSet = new Set();
|
|
842
|
+
const projectByTaskId = new Map();
|
|
843
|
+
for (const { projectPath, taskId, wasInMemory } of pending) {
|
|
844
|
+
if (wasInMemory || storeRemoved.has(taskId)) {
|
|
845
|
+
removedSet.add(taskId);
|
|
846
|
+
projectByTaskId.set(taskId, projectPath);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
for (const taskId of removedSet) {
|
|
850
|
+
this.broadcastTaskDeleted(projectByTaskId.get(taskId), taskId);
|
|
851
|
+
}
|
|
852
|
+
return { deletedCount: removedSet.size };
|
|
853
|
+
}
|
|
444
854
|
handleTaskError(data) {
|
|
445
855
|
const { error, taskId } = data;
|
|
446
856
|
const existing = this.tasks.get(taskId);
|
|
@@ -473,25 +883,245 @@ export class TaskRouter {
|
|
|
473
883
|
this.notifyHooksError(taskId, error.message, task).catch(() => { });
|
|
474
884
|
}
|
|
475
885
|
}
|
|
476
|
-
|
|
886
|
+
async handleTaskGet(data, clientId) {
|
|
887
|
+
const { taskId } = data;
|
|
888
|
+
// Try in-memory active first
|
|
889
|
+
const liveTask = this.tasks.get(taskId) ?? this.completedTasks.get(taskId)?.task;
|
|
890
|
+
if (liveTask !== undefined) {
|
|
891
|
+
const synthesized = buildTaskHistoryEntry(liveTask);
|
|
892
|
+
if (synthesized !== undefined)
|
|
893
|
+
return { task: synthesized };
|
|
894
|
+
}
|
|
895
|
+
// Fall back to disk
|
|
896
|
+
if (this.getTaskHistoryStore === undefined)
|
|
897
|
+
return { task: null };
|
|
898
|
+
const projectFilter = liveTask?.projectPath ?? this.resolveClientProjectPath?.(clientId);
|
|
899
|
+
if (projectFilter === undefined)
|
|
900
|
+
return { task: null };
|
|
901
|
+
try {
|
|
902
|
+
const store = this.getTaskHistoryStore(projectFilter);
|
|
903
|
+
const entry = await store.getById(taskId);
|
|
904
|
+
return { task: entry ?? null };
|
|
905
|
+
}
|
|
906
|
+
catch (error) {
|
|
907
|
+
transportLog(`handleTaskGet: store.getById failed for ${taskId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
908
|
+
return { task: null };
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
// eslint-disable-next-line complexity
|
|
912
|
+
async handleTaskList(data, clientId) {
|
|
477
913
|
const projectFilter = data.projectPath ?? this.resolveClientProjectPath?.(clientId);
|
|
478
|
-
// No resolvable project — return empty
|
|
479
|
-
// A client that hasn't registered a project shouldn't see other projects' work.
|
|
914
|
+
// No resolvable project — return empty (don't leak other projects' work).
|
|
480
915
|
if (projectFilter === undefined)
|
|
481
|
-
return
|
|
482
|
-
const
|
|
483
|
-
const
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
916
|
+
return emptyTaskListResponse(data);
|
|
917
|
+
const inMemoryTaskById = new Map();
|
|
918
|
+
const collectInMemory = (task) => {
|
|
919
|
+
if (task.projectPath !== undefined && task.projectPath !== projectFilter)
|
|
920
|
+
return;
|
|
921
|
+
inMemoryTaskById.set(task.taskId, task);
|
|
922
|
+
};
|
|
923
|
+
for (const task of this.tasks.values())
|
|
924
|
+
collectInMemory(task);
|
|
925
|
+
for (const { task } of this.completedTasks.values())
|
|
926
|
+
collectInMemory(task);
|
|
927
|
+
// Persisted entries (best-effort — tolerate store outages). Push down ONLY
|
|
928
|
+
// non-pivot filters (project + type + time). Pivot filters (status / provider
|
|
929
|
+
// / model) are evaluated at the handler level so derivative sets (counts,
|
|
930
|
+
// availableProviders, availableModels) can apply their exclusion rules.
|
|
931
|
+
let persisted = [];
|
|
932
|
+
if (this.getTaskHistoryStore !== undefined) {
|
|
933
|
+
try {
|
|
934
|
+
const store = this.getTaskHistoryStore(projectFilter);
|
|
935
|
+
persisted = await store.list({
|
|
936
|
+
projectPath: projectFilter,
|
|
937
|
+
...(data.createdAfter === undefined ? {} : { createdAfter: data.createdAfter }),
|
|
938
|
+
...(data.createdBefore === undefined ? {} : { createdBefore: data.createdBefore }),
|
|
939
|
+
...(data.type === undefined ? {} : { type: data.type }),
|
|
940
|
+
});
|
|
941
|
+
}
|
|
942
|
+
catch (error) {
|
|
943
|
+
transportLog(`handleTaskList: store.list failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
// Step 1-2: merge candidates + apply NON-PIVOT filters with pass-1 search
|
|
947
|
+
// (project + type + time + duration + content/error.message search).
|
|
948
|
+
// Status/provider/model are pivot filters — applied later. `available*`
|
|
949
|
+
// dropdowns exclude status+provider+model pivots so users can switch
|
|
950
|
+
// selections without the dropdown shrinking. `counts` reflects the FULL
|
|
951
|
+
// current filter (including status) — chip count = visible row count.
|
|
952
|
+
//
|
|
953
|
+
// Pass-2 (full-result lazy crack) re-uses the same `candidatesNoSearch` map
|
|
954
|
+
// built below to find completed tasks whose result text matches but whose
|
|
955
|
+
// content/error.message did not. `pass2Filter` is the same shape as
|
|
956
|
+
// `nonPivotFilterArgs` minus searchText — the spread auto-inherits any
|
|
957
|
+
// future non-pivot filter dim added upstream.
|
|
958
|
+
const merged = new Map();
|
|
959
|
+
const nonPivotFilterArgs = {
|
|
960
|
+
createdAfter: data.createdAfter,
|
|
961
|
+
createdBefore: data.createdBefore,
|
|
962
|
+
maxDurationMs: data.maxDurationMs,
|
|
963
|
+
minDurationMs: data.minDurationMs,
|
|
964
|
+
projectFilter,
|
|
965
|
+
searchText: data.searchText,
|
|
966
|
+
typeFilter: data.type,
|
|
967
|
+
};
|
|
968
|
+
const pass2Filter = { ...nonPivotFilterArgs, searchText: undefined };
|
|
969
|
+
// Single pass over persisted + in-memory: build `candidatesNoSearch` (all non-pivot
|
|
970
|
+
// filters applied EXCEPT search). `merged` derives from it by re-applying the search
|
|
971
|
+
// predicate via `matchesListFilters(item, nonPivotFilterArgs)`. Saves a 2×N traversal
|
|
972
|
+
// when searchText is set; same cost when unset.
|
|
973
|
+
const candidatesNoSearch = new Map();
|
|
974
|
+
for (const item of persisted) {
|
|
975
|
+
if (matchesListFilters(item, pass2Filter))
|
|
976
|
+
candidatesNoSearch.set(item.taskId, item);
|
|
977
|
+
}
|
|
978
|
+
for (const task of inMemoryTaskById.values()) {
|
|
979
|
+
const item = toListItem(task);
|
|
980
|
+
if (matchesListFilters(item, pass2Filter))
|
|
981
|
+
candidatesNoSearch.set(item.taskId, item);
|
|
982
|
+
}
|
|
983
|
+
for (const [taskId, item] of candidatesNoSearch) {
|
|
984
|
+
if (matchesListFilters(item, nonPivotFilterArgs))
|
|
985
|
+
merged.set(taskId, item);
|
|
488
986
|
}
|
|
489
|
-
|
|
490
|
-
|
|
987
|
+
// Step 3-4: pass-2 search (full-text via lazy data-file crack).
|
|
988
|
+
// Only when searchText is set AND the row has status='completed' AND it didn't match pass-1.
|
|
989
|
+
// For in-memory tasks we read result directly from TaskInfo (no I/O); for
|
|
990
|
+
// persisted we call store.getById, swallowing file-race errors.
|
|
991
|
+
if (data.searchText !== undefined && data.searchText.length > 0) {
|
|
992
|
+
const needle = data.searchText.toLowerCase();
|
|
993
|
+
const completedUnmatched = [];
|
|
994
|
+
for (const item of candidatesNoSearch.values()) {
|
|
995
|
+
if (item.status !== 'completed')
|
|
996
|
+
continue;
|
|
997
|
+
if (merged.has(item.taskId))
|
|
998
|
+
continue;
|
|
999
|
+
completedUnmatched.push(item);
|
|
1000
|
+
}
|
|
1001
|
+
// Log race errors at most once per query to avoid log spam during compaction storms.
|
|
1002
|
+
// The rest of the racing reads still execute; we just don't write N log lines.
|
|
1003
|
+
let raceLogged = false;
|
|
1004
|
+
const matchedIds = await mapBounded(completedUnmatched, FULL_TEXT_CONCURRENCY, async (item) => {
|
|
1005
|
+
// In-memory task — match against task.result directly (no I/O).
|
|
1006
|
+
// Invariant: in-memory tasks with status='completed' always have task.result
|
|
1007
|
+
// defined because handleTaskCompleted sets it synchronously with the status
|
|
1008
|
+
// transition (TaskCompletedEvent.result is required by schema). Persisted
|
|
1009
|
+
// snapshots are derived from this same TaskInfo, so no in-memory/disk
|
|
1010
|
+
// result divergence is possible — fallback to getById is unnecessary here.
|
|
1011
|
+
const inMem = inMemoryTaskById.get(item.taskId);
|
|
1012
|
+
if (inMem !== undefined) {
|
|
1013
|
+
if (inMem.result !== undefined && inMem.result.toLowerCase().includes(needle))
|
|
1014
|
+
return item.taskId;
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
// Persisted — load full entry via store.getById.
|
|
1018
|
+
if (this.getTaskHistoryStore === undefined)
|
|
1019
|
+
return;
|
|
1020
|
+
try {
|
|
1021
|
+
const store = this.getTaskHistoryStore(projectFilter);
|
|
1022
|
+
const entry = await store.getById(item.taskId);
|
|
1023
|
+
if (entry?.status === 'completed' && entry.result?.toLowerCase().includes(needle)) {
|
|
1024
|
+
return item.taskId;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
catch (error) {
|
|
1028
|
+
// Swallow file-race (concurrent delete/compaction) — treat as no-match.
|
|
1029
|
+
if (!raceLogged) {
|
|
1030
|
+
raceLogged = true;
|
|
1031
|
+
transportLog(`handleTaskList: pass-2 getById(${item.taskId}) failed: ${error instanceof Error ? error.message : String(error)} (further race errors in this query suppressed)`);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
});
|
|
1035
|
+
for (const id of matchedIds) {
|
|
1036
|
+
if (id === undefined)
|
|
1037
|
+
continue;
|
|
1038
|
+
const fromMap = candidatesNoSearch.get(id);
|
|
1039
|
+
if (fromMap !== undefined)
|
|
1040
|
+
merged.set(id, fromMap);
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
// Step 5: nonPivotFull = merged. Derive availableProviders/availableModels.
|
|
1044
|
+
// Guard both provider AND model length > 0 — wire/index schemas accept empty
|
|
1045
|
+
// strings, which would otherwise emit phantom entries like {providerId: 'openai', modelId: ''}.
|
|
1046
|
+
const availableProviderSet = new Set();
|
|
1047
|
+
const availableModelMap = new Map();
|
|
1048
|
+
for (const item of merged.values()) {
|
|
1049
|
+
const providerId = item.provider;
|
|
1050
|
+
const modelId = item.model;
|
|
1051
|
+
if (providerId !== undefined && providerId.length > 0)
|
|
1052
|
+
availableProviderSet.add(providerId);
|
|
1053
|
+
if (providerId !== undefined && providerId.length > 0 && modelId !== undefined && modelId.length > 0) {
|
|
1054
|
+
const key = `${providerId}|${modelId}`;
|
|
1055
|
+
if (!availableModelMap.has(key))
|
|
1056
|
+
availableModelMap.set(key, { modelId, providerId });
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
const availableProviders = [...availableProviderSet].sort((a, b) => a.localeCompare(b));
|
|
1060
|
+
const availableModels = [...availableModelMap.values()].sort((a, b) => {
|
|
1061
|
+
const p = a.providerId.localeCompare(b.providerId);
|
|
1062
|
+
return p === 0 ? a.modelId.localeCompare(b.modelId) : p;
|
|
1063
|
+
});
|
|
1064
|
+
// Step 6: apply pivot filters (provider + model + status) → allFiltered.
|
|
1065
|
+
// Hoist into consts so TS narrows the loop captures (avoids `!` assertion per CLAUDE.md).
|
|
1066
|
+
const providerFilter = data.provider;
|
|
1067
|
+
const modelFilter = data.model;
|
|
1068
|
+
const statusFilter = data.status;
|
|
1069
|
+
const allFiltered = [];
|
|
1070
|
+
for (const item of merged.values()) {
|
|
1071
|
+
if (providerFilter &&
|
|
1072
|
+
providerFilter.length > 0 &&
|
|
1073
|
+
(item.provider === undefined || !providerFilter.includes(item.provider))) {
|
|
1074
|
+
continue;
|
|
1075
|
+
}
|
|
1076
|
+
if (modelFilter && modelFilter.length > 0 && (item.model === undefined || !modelFilter.includes(item.model))) {
|
|
1077
|
+
continue;
|
|
1078
|
+
}
|
|
1079
|
+
if (statusFilter && statusFilter.length > 0 && !statusFilter.includes(item.status))
|
|
491
1080
|
continue;
|
|
492
|
-
|
|
1081
|
+
allFiltered.push(item);
|
|
493
1082
|
}
|
|
494
|
-
|
|
1083
|
+
// Step 7: counts = status histogram of allFiltered (matches current filter scope — Model A).
|
|
1084
|
+
// Chip count == visible row count; counts.all === total invariant.
|
|
1085
|
+
const counts = { all: allFiltered.length, cancelled: 0, completed: 0, failed: 0, running: 0 };
|
|
1086
|
+
for (const item of allFiltered) {
|
|
1087
|
+
switch (item.status) {
|
|
1088
|
+
case 'cancelled': {
|
|
1089
|
+
counts.cancelled++;
|
|
1090
|
+
break;
|
|
1091
|
+
}
|
|
1092
|
+
case 'completed': {
|
|
1093
|
+
counts.completed++;
|
|
1094
|
+
break;
|
|
1095
|
+
}
|
|
1096
|
+
case 'created':
|
|
1097
|
+
case 'started': {
|
|
1098
|
+
counts.running++;
|
|
1099
|
+
break;
|
|
1100
|
+
}
|
|
1101
|
+
case 'error': {
|
|
1102
|
+
counts.failed++;
|
|
1103
|
+
break;
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
// Step 8: Sort (createdAt DESC, taskId DESC) — stable secondary order for same-millisecond clusters.
|
|
1108
|
+
allFiltered.sort((a, b) => {
|
|
1109
|
+
if (b.createdAt !== a.createdAt)
|
|
1110
|
+
return b.createdAt - a.createdAt;
|
|
1111
|
+
if (b.taskId > a.taskId)
|
|
1112
|
+
return 1;
|
|
1113
|
+
if (b.taskId < a.taskId)
|
|
1114
|
+
return -1;
|
|
1115
|
+
return 0;
|
|
1116
|
+
});
|
|
1117
|
+
// Step 9: paginate. Wire shape unchanged (result preserved per existing toListItem behavior).
|
|
1118
|
+
const pageSize = Math.min(Math.max(data.pageSize ?? DEFAULT_TASK_LIST_PAGE_SIZE, 1), 1000);
|
|
1119
|
+
const page = Math.max(data.page ?? 1, 1);
|
|
1120
|
+
const total = allFiltered.length;
|
|
1121
|
+
const pageCount = Math.max(Math.ceil(total / pageSize), 1);
|
|
1122
|
+
const start = (page - 1) * pageSize;
|
|
1123
|
+
const tasks = allFiltered.slice(start, start + pageSize);
|
|
1124
|
+
return { availableModels, availableProviders, counts, page, pageCount, pageSize, tasks, total };
|
|
495
1125
|
}
|
|
496
1126
|
/**
|
|
497
1127
|
* Emit `task:completed` for a task that the daemon's pre-dispatch gate skipped
|
|
@@ -525,6 +1155,8 @@ export class TaskRouter {
|
|
|
525
1155
|
const task = this.tasks.get(taskId);
|
|
526
1156
|
if (task) {
|
|
527
1157
|
this.tasks.set(taskId, { ...task, startedAt: Date.now(), status: 'started' });
|
|
1158
|
+
// No `onTaskStarted` hook — capture the transition via the throttled flush.
|
|
1159
|
+
this.markDirty(taskId);
|
|
528
1160
|
this.transport.sendTo(task.clientId, TransportTaskEventNames.STARTED, { taskId });
|
|
529
1161
|
broadcastToProjectRoom(this.projectRegistry, this.projectRouter, task.projectPath, TransportTaskEventNames.STARTED, {
|
|
530
1162
|
content: task.content,
|
|
@@ -539,6 +1171,24 @@ export class TaskRouter {
|
|
|
539
1171
|
transportLog(`Task started but no task context found: ${taskId}`);
|
|
540
1172
|
}
|
|
541
1173
|
}
|
|
1174
|
+
/**
|
|
1175
|
+
* Mark a taskId for the next throttled `onTaskUpdate` flush.
|
|
1176
|
+
* Schedules a timer if none is pending. Bursts of dirty marks within the
|
|
1177
|
+
* 100ms window coalesce into a single flush.
|
|
1178
|
+
*/
|
|
1179
|
+
markDirty(taskId) {
|
|
1180
|
+
this.dirtyTaskIds.add(taskId);
|
|
1181
|
+
if (this.flushTimer !== undefined)
|
|
1182
|
+
return;
|
|
1183
|
+
this.flushTimer = setTimeout(() => {
|
|
1184
|
+
this.flushDirty().catch(() => {
|
|
1185
|
+
// flushDirty already swallows per-hook errors; this catch covers
|
|
1186
|
+
// unexpected scheduler-level failures.
|
|
1187
|
+
});
|
|
1188
|
+
}, TaskRouter.FLUSH_INTERVAL_MS);
|
|
1189
|
+
// unref so a pending flush doesn't block daemon shutdown.
|
|
1190
|
+
this.flushTimer.unref?.();
|
|
1191
|
+
}
|
|
542
1192
|
/**
|
|
543
1193
|
* Move a task to the completed tasks map with grace period cleanup.
|
|
544
1194
|
*/
|
|
@@ -547,9 +1197,11 @@ export class TaskRouter {
|
|
|
547
1197
|
if (task) {
|
|
548
1198
|
this.completedTasks.set(taskId, { completedAt: Date.now(), task });
|
|
549
1199
|
this.tasks.delete(taskId);
|
|
550
|
-
setTimeout(() => {
|
|
1200
|
+
const timer = setTimeout(() => {
|
|
551
1201
|
this.completedTasks.delete(taskId);
|
|
552
1202
|
}, TASK_CLEANUP_GRACE_PERIOD_MS);
|
|
1203
|
+
// Don't keep the event loop alive purely for completed-task GC.
|
|
1204
|
+
timer.unref?.();
|
|
553
1205
|
}
|
|
554
1206
|
}
|
|
555
1207
|
/**
|
|
@@ -660,6 +1312,13 @@ export class TaskRouter {
|
|
|
660
1312
|
if (!task) {
|
|
661
1313
|
return;
|
|
662
1314
|
}
|
|
1315
|
+
// Accumulator: mutate the live `TaskInfo` BEFORE broadcasting so a tab
|
|
1316
|
+
// refresh during the next throttle window sees the in-flight state.
|
|
1317
|
+
// Only mutates for ACTIVE tasks — grace-period entries already had their
|
|
1318
|
+
// terminal save persisted by the lifecycle hook.
|
|
1319
|
+
if (activeTask) {
|
|
1320
|
+
this.accumulateLlmEvent(taskId, eventName, data);
|
|
1321
|
+
}
|
|
663
1322
|
// Notify onToolResult hooks only for active tasks
|
|
664
1323
|
if (activeTask && eventName === LlmEventNames.TOOL_RESULT) {
|
|
665
1324
|
for (const hook of this.lifecycleHooks) {
|
|
@@ -697,6 +1356,22 @@ export class TaskRouter {
|
|
|
697
1356
|
}));
|
|
698
1357
|
return logIds.find((id) => typeof id === 'string');
|
|
699
1358
|
}
|
|
1359
|
+
/**
|
|
1360
|
+
* Invoke `resolveActiveProvider` with a try/catch so a thrown resolver
|
|
1361
|
+
* cannot block task dispatch. Returns `{}` when no resolver is configured
|
|
1362
|
+
* or when the resolver rejects/throws.
|
|
1363
|
+
*/
|
|
1364
|
+
async safeResolveActiveProvider() {
|
|
1365
|
+
if (!this.resolveActiveProvider)
|
|
1366
|
+
return {};
|
|
1367
|
+
try {
|
|
1368
|
+
return await this.resolveActiveProvider();
|
|
1369
|
+
}
|
|
1370
|
+
catch (error) {
|
|
1371
|
+
transportLog(`resolveActiveProvider failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1372
|
+
return {};
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
700
1375
|
/**
|
|
701
1376
|
* Reads the project's reviewDisabled flag at task-create.
|
|
702
1377
|
*
|