team-anya-cli 0.1.1 → 0.1.3
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/apps/server/dist/cli.js +1 -1
- package/apps/server/dist/loid/mcp-server.js +1 -0
- package/apps/server/dist/main.js +1 -0
- package/package.json +1 -1
- package/packages/mcp-tools/dist/layer2/loid/task-dispatch.js +19 -0
- package/packages/mcp-tools/dist/registry.js +6 -2
- package/apps/server/dist/loid/brief-assembler.js +0 -156
- package/apps/server/dist/loid/commitment-tracker.js +0 -236
- package/apps/server/dist/loid/dispatcher.js +0 -544
- package/apps/server/dist/loid/intent-classifier.js +0 -158
- package/apps/server/dist/loid/process-manager.js +0 -186
- package/packages/mcp-tools/dist/layer2/loid/task-review.js +0 -151
- package/packages/mcp-tools/dist/layer2/loid/workspace-cleanup.js +0 -7
- package/packages/mcp-tools/dist/layer2/loid/workspace-info.js +0 -31
- package/packages/mcp-tools/dist/layer2/loid/workspace-prepare.js +0 -12
- package/packages/mcp-tools/dist/layer2/yor/code-lint.js +0 -47
- package/packages/mcp-tools/dist/layer2/yor/code-test.js +0 -52
- package/packages/mcp-tools/dist/layer2/yor/git-add.js +0 -24
- package/packages/mcp-tools/dist/layer2/yor/git-commit.js +0 -24
- package/packages/mcp-tools/dist/layer2/yor/git-push.js +0 -64
|
@@ -1,544 +0,0 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
|
-
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
3
|
-
import { dirname, join, resolve } from 'node:path';
|
|
4
|
-
import { getTask, getTasksByStatus, updateTask, getCommitmentByTask, insertAuditEvent, getMessageLogBySourceRef } from '@team-anya/db';
|
|
5
|
-
import { TaskStatus } from '@team-anya/core';
|
|
6
|
-
export class Dispatcher {
|
|
7
|
-
slots;
|
|
8
|
-
db;
|
|
9
|
-
config;
|
|
10
|
-
logger;
|
|
11
|
-
spawnFn;
|
|
12
|
-
targetConcurrency;
|
|
13
|
-
shuttingDown = false;
|
|
14
|
-
shutdownResolve = null;
|
|
15
|
-
onTaskStatusChange;
|
|
16
|
-
briefAssembler;
|
|
17
|
-
onTaskComplete;
|
|
18
|
-
onDeliveryReady;
|
|
19
|
-
worktreeManager;
|
|
20
|
-
constructor(deps, spawnFn) {
|
|
21
|
-
this.db = deps.db;
|
|
22
|
-
this.config = deps.config;
|
|
23
|
-
this.logger = deps.logger ?? { info: console.log, error: console.error };
|
|
24
|
-
this.spawnFn = spawnFn ?? spawn;
|
|
25
|
-
this.onTaskStatusChange = deps.onTaskStatusChange;
|
|
26
|
-
this.briefAssembler = deps.briefAssembler;
|
|
27
|
-
this.onTaskComplete = deps.onTaskComplete;
|
|
28
|
-
this.onDeliveryReady = deps.onDeliveryReady ?? undefined;
|
|
29
|
-
this.worktreeManager = deps.worktreeManager;
|
|
30
|
-
this.targetConcurrency = deps.config.MAX_YOR_CONCURRENCY;
|
|
31
|
-
this.slots = Array.from({ length: deps.config.MAX_YOR_CONCURRENCY }, (_, i) => ({
|
|
32
|
-
index: i,
|
|
33
|
-
busy: false,
|
|
34
|
-
taskId: null,
|
|
35
|
-
process: null,
|
|
36
|
-
startedAt: null,
|
|
37
|
-
}));
|
|
38
|
-
}
|
|
39
|
-
/**
|
|
40
|
-
* 设置 Yor 交付完成后的回调(唤醒 Loid 验收)
|
|
41
|
-
*/
|
|
42
|
-
setDeliveryCallback(cb) {
|
|
43
|
-
this.onDeliveryReady = cb;
|
|
44
|
-
}
|
|
45
|
-
getSlots() {
|
|
46
|
-
return this.slots.map(s => ({ ...s, process: null })); // 不暴露 process 引用
|
|
47
|
-
}
|
|
48
|
-
/**
|
|
49
|
-
* 获取每个槽位的详细状态
|
|
50
|
-
*/
|
|
51
|
-
getSlotStatus() {
|
|
52
|
-
return this.slots.map(s => ({
|
|
53
|
-
id: s.index,
|
|
54
|
-
taskId: s.taskId,
|
|
55
|
-
startedAt: s.startedAt,
|
|
56
|
-
status: s.busy ? 'busy' : 'idle',
|
|
57
|
-
}));
|
|
58
|
-
}
|
|
59
|
-
/**
|
|
60
|
-
* 获取槽位使用率指标
|
|
61
|
-
* total 以 targetConcurrency 为准(而非实际 slots 数组长度),
|
|
62
|
-
* 因为缩减并发度时忙碌槽位可能暂时超出 targetConcurrency
|
|
63
|
-
*/
|
|
64
|
-
getUtilization() {
|
|
65
|
-
const busy = this.slots.filter(s => s.busy).length;
|
|
66
|
-
return {
|
|
67
|
-
total: this.targetConcurrency,
|
|
68
|
-
busy,
|
|
69
|
-
idle: Math.max(0, this.targetConcurrency - busy),
|
|
70
|
-
};
|
|
71
|
-
}
|
|
72
|
-
/**
|
|
73
|
-
* 动态调整最大并发度
|
|
74
|
-
* - 增加:立即扩展 slots 数组
|
|
75
|
-
* - 减少:不中断正在运行的任务,只回收空闲的多余槽位
|
|
76
|
-
*/
|
|
77
|
-
setConcurrency(n) {
|
|
78
|
-
if (n < 1) {
|
|
79
|
-
throw new Error(`并发度必须 >= 1,收到 ${n}`);
|
|
80
|
-
}
|
|
81
|
-
this.targetConcurrency = n;
|
|
82
|
-
if (n > this.slots.length) {
|
|
83
|
-
// 增加槽位
|
|
84
|
-
const startIndex = this.slots.length;
|
|
85
|
-
for (let i = startIndex; i < n; i++) {
|
|
86
|
-
this.slots.push({
|
|
87
|
-
index: i,
|
|
88
|
-
busy: false,
|
|
89
|
-
taskId: null,
|
|
90
|
-
process: null,
|
|
91
|
-
startedAt: null,
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
else if (n < this.slots.length) {
|
|
96
|
-
// 减少:回收空闲的超出槽位(从末尾开始)
|
|
97
|
-
this.shrinkIdleSlots();
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
/**
|
|
101
|
-
* 回收超出 targetConcurrency 的空闲槽位
|
|
102
|
-
*/
|
|
103
|
-
shrinkIdleSlots() {
|
|
104
|
-
// 从末尾向前移除空闲的超出槽位
|
|
105
|
-
while (this.slots.length > this.targetConcurrency) {
|
|
106
|
-
const last = this.slots[this.slots.length - 1];
|
|
107
|
-
if (last.busy)
|
|
108
|
-
break; // 忙碌的不能回收
|
|
109
|
-
this.slots.pop();
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
findFreeSlot() {
|
|
113
|
-
// 只在 targetConcurrency 范围内查找空闲槽位
|
|
114
|
-
for (const slot of this.slots) {
|
|
115
|
-
if (!slot.busy && slot.index < this.targetConcurrency) {
|
|
116
|
-
return slot;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
return null;
|
|
120
|
-
}
|
|
121
|
-
/**
|
|
122
|
-
* 派工:找空闲槽位 → 更新 DB → spawn Yor 子进程
|
|
123
|
-
*/
|
|
124
|
-
async dispatch(taskId) {
|
|
125
|
-
if (this.shuttingDown)
|
|
126
|
-
return false;
|
|
127
|
-
const task = getTask(this.db, taskId);
|
|
128
|
-
if (!task) {
|
|
129
|
-
this.logger.error(`[派工] 失败: 任务 ${taskId} 不存在`);
|
|
130
|
-
return false;
|
|
131
|
-
}
|
|
132
|
-
if (task.status !== TaskStatus.READY) {
|
|
133
|
-
this.logger.error(`[派工] 失败: ${taskId} 状态 ${task.status},需要 READY`);
|
|
134
|
-
return false;
|
|
135
|
-
}
|
|
136
|
-
const slot = this.findFreeSlot();
|
|
137
|
-
if (!slot)
|
|
138
|
-
return false;
|
|
139
|
-
// Brief 组装(失败不阻止派工)
|
|
140
|
-
if (this.briefAssembler) {
|
|
141
|
-
try {
|
|
142
|
-
const briefContent = await this.briefAssembler.assembleBrief(taskId);
|
|
143
|
-
const taskDir = resolve(this.config.WORKSPACE_PATH, 'yor', 'tasks', taskId);
|
|
144
|
-
await mkdir(taskDir, { recursive: true });
|
|
145
|
-
await writeFile(resolve(taskDir, 'brief.md'), briefContent, 'utf-8');
|
|
146
|
-
}
|
|
147
|
-
catch (err) {
|
|
148
|
-
this.logger.error(`[派工] Brief 组装失败 (${taskId}), 继续:`, err);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
// 准备工作目录
|
|
152
|
-
let workspace = null;
|
|
153
|
-
if (this.worktreeManager) {
|
|
154
|
-
try {
|
|
155
|
-
this.logger.info(`[anya:pipeline] [dispatch] prepare 开始 | task=${taskId} project_id=${task.project_id ?? 'null'}`);
|
|
156
|
-
workspace = await this.worktreeManager.prepare(taskId, task.project_id ?? undefined);
|
|
157
|
-
this.logger.info(`[anya:pipeline] [dispatch] prepare 完成 | mode=${workspace.mode} workingDir=${workspace.workingDir} repos=${workspace.repos?.length ?? 0}`);
|
|
158
|
-
}
|
|
159
|
-
catch (err) {
|
|
160
|
-
this.logger.error(`[anya:pipeline] [Yor] 启动失败 ${taskId}: 工作目录准备出错`, err);
|
|
161
|
-
return false;
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
else {
|
|
165
|
-
this.logger.info(`[anya:pipeline] [dispatch] 无 worktreeManager,跳过 prepare`);
|
|
166
|
-
}
|
|
167
|
-
const workingDir = resolve(workspace?.workingDir ?? this.config.WORKSPACE_PATH);
|
|
168
|
-
// 更新任务状态为 IN_PROGRESS
|
|
169
|
-
updateTask(this.db, taskId, {
|
|
170
|
-
status: TaskStatus.IN_PROGRESS,
|
|
171
|
-
assignee: `yor-slot-${slot.index}`,
|
|
172
|
-
workspace_path: workingDir,
|
|
173
|
-
branch: workspace?.branch ?? null,
|
|
174
|
-
});
|
|
175
|
-
// 占据槽位
|
|
176
|
-
slot.busy = true;
|
|
177
|
-
slot.taskId = taskId;
|
|
178
|
-
slot.startedAt = new Date().toISOString();
|
|
179
|
-
// spawn Yor 子进程
|
|
180
|
-
// cwd 设为 Yor 入口所在目录(确保能找到 node_modules)
|
|
181
|
-
// --workspace 传实际工作目录给 Yor
|
|
182
|
-
const yorEntry = resolve(this.config.YOR_ENTRY);
|
|
183
|
-
const yorCwd = dirname(yorEntry);
|
|
184
|
-
const briefPath = resolve(this.config.WORKSPACE_PATH, 'yor', 'tasks', taskId, 'brief.md');
|
|
185
|
-
const child = this.spawnFn('npx', [
|
|
186
|
-
'tsx', yorEntry,
|
|
187
|
-
'--task', taskId,
|
|
188
|
-
'--workspace', workingDir,
|
|
189
|
-
'--brief', briefPath,
|
|
190
|
-
'--title', task.title ?? '',
|
|
191
|
-
], {
|
|
192
|
-
cwd: yorCwd,
|
|
193
|
-
env: {
|
|
194
|
-
...process.env,
|
|
195
|
-
SQLITE_PATH: this.config.SQLITE_PATH,
|
|
196
|
-
WORKSPACE_PATH: this.config.WORKSPACE_PATH,
|
|
197
|
-
CLAUDE_CODE_BINARY: this.config.CLAUDE_CODE_BINARY,
|
|
198
|
-
},
|
|
199
|
-
stdio: 'pipe',
|
|
200
|
-
});
|
|
201
|
-
slot.process = child;
|
|
202
|
-
// 捕获 stderr 用于诊断崩溃原因
|
|
203
|
-
let stderrBuf = '';
|
|
204
|
-
child.stderr?.on('data', (data) => {
|
|
205
|
-
stderrBuf += data.toString();
|
|
206
|
-
});
|
|
207
|
-
child.on('exit', (code) => {
|
|
208
|
-
if (code !== 0 && stderrBuf.trim()) {
|
|
209
|
-
this.logger.error(`[anya:pipeline] [Yor] stderr (${taskId}): ${stderrBuf.trim().slice(0, 500)}`);
|
|
210
|
-
}
|
|
211
|
-
this.onYorExit(slot, code);
|
|
212
|
-
});
|
|
213
|
-
child.on('error', (err) => {
|
|
214
|
-
this.logger.error(`[anya:pipeline] [Yor] 进程异常 ${taskId}:`, err);
|
|
215
|
-
this.onYorExit(slot, 1);
|
|
216
|
-
});
|
|
217
|
-
// 一条日志说清 Yor 启动的关键信息
|
|
218
|
-
const modeLabel = workspace?.mode === 'product'
|
|
219
|
-
? `product=${task.project_id} repos=${workspace.repos?.length ?? 0}`
|
|
220
|
-
: task.project_id ? `project=${task.project_id}` : 'scratch';
|
|
221
|
-
const branchLabel = workspace?.branch ? ` branch=${workspace.branch}` : '';
|
|
222
|
-
this.logger.info(`[anya:pipeline] [Yor] 启动 ${taskId} | slot=${slot.index} ${modeLabel}${branchLabel} | cwd=${workingDir}`);
|
|
223
|
-
return true;
|
|
224
|
-
}
|
|
225
|
-
/**
|
|
226
|
-
* 扫描 READY 任务,按优先级排序后依次派工
|
|
227
|
-
* 优先级:有 commitment deadline 的任务优先(deadline 越近越优先),无 deadline 的按 created_at 排序
|
|
228
|
-
*/
|
|
229
|
-
async drainReadyQueue() {
|
|
230
|
-
if (this.shuttingDown)
|
|
231
|
-
return 0;
|
|
232
|
-
const readyTasks = getTasksByStatus(this.db, TaskStatus.READY);
|
|
233
|
-
// 按优先级排序
|
|
234
|
-
const sorted = this.sortByPriority(readyTasks);
|
|
235
|
-
let dispatched = 0;
|
|
236
|
-
for (const task of sorted) {
|
|
237
|
-
if (!this.findFreeSlot())
|
|
238
|
-
break;
|
|
239
|
-
if (await this.dispatch(task.task_id)) {
|
|
240
|
-
dispatched++;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
return dispatched;
|
|
244
|
-
}
|
|
245
|
-
/**
|
|
246
|
-
* 按优先级排序任务
|
|
247
|
-
* 1. 有 commitment deadline 的优先(deadline 越近越优先)
|
|
248
|
-
* 2. 无 deadline 的按 created_at 排序(越早越优先)
|
|
249
|
-
*/
|
|
250
|
-
sortByPriority(tasks) {
|
|
251
|
-
// 获取每个任务的 commitment deadline
|
|
252
|
-
const deadlineMap = new Map();
|
|
253
|
-
for (const task of tasks) {
|
|
254
|
-
const commitment = getCommitmentByTask(this.db, task.task_id);
|
|
255
|
-
deadlineMap.set(task.task_id, commitment?.deadline ?? null);
|
|
256
|
-
}
|
|
257
|
-
return [...tasks].sort((a, b) => {
|
|
258
|
-
const deadlineA = deadlineMap.get(a.task_id) ?? null;
|
|
259
|
-
const deadlineB = deadlineMap.get(b.task_id) ?? null;
|
|
260
|
-
// 有 deadline 的排前面
|
|
261
|
-
if (deadlineA && !deadlineB)
|
|
262
|
-
return -1;
|
|
263
|
-
if (!deadlineA && deadlineB)
|
|
264
|
-
return 1;
|
|
265
|
-
// 都有 deadline:deadline 越近越优先
|
|
266
|
-
if (deadlineA && deadlineB) {
|
|
267
|
-
return deadlineA.localeCompare(deadlineB);
|
|
268
|
-
}
|
|
269
|
-
// 都无 deadline:按 created_at 排序
|
|
270
|
-
return (a.created_at ?? '').localeCompare(b.created_at ?? '');
|
|
271
|
-
});
|
|
272
|
-
}
|
|
273
|
-
/**
|
|
274
|
-
* 启动时崩溃恢复:将 IN_PROGRESS 任务重置为 READY
|
|
275
|
-
*/
|
|
276
|
-
recover() {
|
|
277
|
-
const inProgress = getTasksByStatus(this.db, TaskStatus.IN_PROGRESS);
|
|
278
|
-
let recovered = 0;
|
|
279
|
-
for (const task of inProgress) {
|
|
280
|
-
// IN_PROGRESS 不能直接回 READY,需要先 BLOCKED 再 READY
|
|
281
|
-
// 但恢复场景特殊,直接标记为 BLOCKED 再恢复为 READY
|
|
282
|
-
updateTask(this.db, task.task_id, {
|
|
283
|
-
status: TaskStatus.BLOCKED,
|
|
284
|
-
blocked_reason: '服务重启崩溃恢复',
|
|
285
|
-
blocked_since: new Date().toISOString(),
|
|
286
|
-
assignee: null,
|
|
287
|
-
});
|
|
288
|
-
updateTask(this.db, task.task_id, {
|
|
289
|
-
status: TaskStatus.READY,
|
|
290
|
-
blocked_reason: null,
|
|
291
|
-
blocked_since: null,
|
|
292
|
-
});
|
|
293
|
-
recovered++;
|
|
294
|
-
}
|
|
295
|
-
return recovered;
|
|
296
|
-
}
|
|
297
|
-
/**
|
|
298
|
-
* C1 增强崩溃恢复:
|
|
299
|
-
* 1. 扫描 IN_PROGRESS 任务
|
|
300
|
-
* 2. 检查 execution-log.jsonl 最后事件 → 判断可恢复/需重置
|
|
301
|
-
* 3. 可恢复(有 sessionId + 最后事件非错误)→ 标记为 resume 策略
|
|
302
|
-
* 4. 不可恢复 → 重置为 READY
|
|
303
|
-
* 5. 写审计日志
|
|
304
|
-
*/
|
|
305
|
-
async recoverEnhanced() {
|
|
306
|
-
const inProgress = getTasksByStatus(this.db, TaskStatus.IN_PROGRESS);
|
|
307
|
-
const details = [];
|
|
308
|
-
let resumed = 0;
|
|
309
|
-
let reset = 0;
|
|
310
|
-
for (const task of inProgress) {
|
|
311
|
-
const taskDir = resolve(this.config.WORKSPACE_PATH, 'yor', 'tasks', task.task_id);
|
|
312
|
-
// 读取 execution-log.jsonl 最后一行
|
|
313
|
-
const lastEvent = await this.readLastLogEvent(join(taskDir, 'execution-log.jsonl'));
|
|
314
|
-
// 读取 handoff.md 中的 sessionId
|
|
315
|
-
const sessionId = await this.extractSessionId(join(taskDir, 'handoff.md'));
|
|
316
|
-
// 判断恢复策略
|
|
317
|
-
const isRecoverable = lastEvent !== null
|
|
318
|
-
&& lastEvent.type !== 'ERROR'
|
|
319
|
-
&& sessionId !== null;
|
|
320
|
-
if (isRecoverable) {
|
|
321
|
-
// 可恢复:保持 IN_PROGRESS,记录 resume 信息
|
|
322
|
-
resumed++;
|
|
323
|
-
details.push({ taskId: task.task_id, strategy: 'resume', sessionId: sessionId });
|
|
324
|
-
insertAuditEvent(this.db, {
|
|
325
|
-
event_type: 'crash_recovery',
|
|
326
|
-
actor: 'system',
|
|
327
|
-
task_id: task.task_id,
|
|
328
|
-
summary: `崩溃恢复: 任务可通过 session resume 恢复`,
|
|
329
|
-
detail: JSON.stringify({ strategy: 'resume', sessionId, lastEvent }),
|
|
330
|
-
});
|
|
331
|
-
}
|
|
332
|
-
else {
|
|
333
|
-
// 不可恢复:重置为 READY
|
|
334
|
-
updateTask(this.db, task.task_id, {
|
|
335
|
-
status: TaskStatus.BLOCKED,
|
|
336
|
-
blocked_reason: '服务重启崩溃恢复',
|
|
337
|
-
blocked_since: new Date().toISOString(),
|
|
338
|
-
assignee: null,
|
|
339
|
-
});
|
|
340
|
-
updateTask(this.db, task.task_id, {
|
|
341
|
-
status: TaskStatus.READY,
|
|
342
|
-
blocked_reason: null,
|
|
343
|
-
blocked_since: null,
|
|
344
|
-
});
|
|
345
|
-
reset++;
|
|
346
|
-
details.push({ taskId: task.task_id, strategy: 'reset' });
|
|
347
|
-
insertAuditEvent(this.db, {
|
|
348
|
-
event_type: 'crash_recovery',
|
|
349
|
-
actor: 'system',
|
|
350
|
-
task_id: task.task_id,
|
|
351
|
-
summary: `崩溃恢复: 任务重置为 READY 重新派工`,
|
|
352
|
-
detail: JSON.stringify({ strategy: 'reset', reason: lastEvent ? `最后事件: ${lastEvent.type}` : '无执行日志' }),
|
|
353
|
-
});
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
return { total: inProgress.length, resumed, reset, details };
|
|
357
|
-
}
|
|
358
|
-
/**
|
|
359
|
-
* 读取 execution-log.jsonl 的最后一行并解析
|
|
360
|
-
*/
|
|
361
|
-
async readLastLogEvent(logPath) {
|
|
362
|
-
try {
|
|
363
|
-
const content = await readFile(logPath, 'utf-8');
|
|
364
|
-
const lines = content.trim().split('\n').filter(l => l.trim().length > 0);
|
|
365
|
-
if (lines.length === 0)
|
|
366
|
-
return null;
|
|
367
|
-
return JSON.parse(lines[lines.length - 1]);
|
|
368
|
-
}
|
|
369
|
-
catch {
|
|
370
|
-
return null;
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
/**
|
|
374
|
-
* 从 handoff.md 中提取 sessionId
|
|
375
|
-
* 格式: "sessionId: xxx" 或 "sessionId:xxx"
|
|
376
|
-
*/
|
|
377
|
-
async extractSessionId(handoffPath) {
|
|
378
|
-
try {
|
|
379
|
-
const content = await readFile(handoffPath, 'utf-8');
|
|
380
|
-
const match = content.match(/sessionId:\s*(\S+)/);
|
|
381
|
-
return match ? match[1] : null;
|
|
382
|
-
}
|
|
383
|
-
catch {
|
|
384
|
-
return null;
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
/**
|
|
388
|
-
* 优雅关闭
|
|
389
|
-
* 1. 标记为关闭中,不再接受新派工
|
|
390
|
-
* 2. 等待进行中任务完成
|
|
391
|
-
* 3. 超时后发 SIGTERM,返回未完成的任务列表
|
|
392
|
-
*/
|
|
393
|
-
async shutdown(timeoutMs = 30_000) {
|
|
394
|
-
this.shuttingDown = true;
|
|
395
|
-
const busySlots = this.slots.filter(s => s.busy);
|
|
396
|
-
if (busySlots.length === 0) {
|
|
397
|
-
return { unfinished: [] };
|
|
398
|
-
}
|
|
399
|
-
// 等待所有忙碌槽位完成,或超时
|
|
400
|
-
return new Promise((resolve) => {
|
|
401
|
-
const timer = setTimeout(() => {
|
|
402
|
-
// 超时:发送 SIGTERM 给所有仍在运行的进程
|
|
403
|
-
const unfinished = [];
|
|
404
|
-
for (const slot of this.slots) {
|
|
405
|
-
if (slot.busy && slot.process) {
|
|
406
|
-
slot.process.kill('SIGTERM');
|
|
407
|
-
if (slot.taskId) {
|
|
408
|
-
unfinished.push(slot.taskId);
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
resolve({ unfinished });
|
|
413
|
-
}, timeoutMs);
|
|
414
|
-
// 设置 resolve 回调,让 onYorExit 可以在所有任务完成时触发
|
|
415
|
-
this.shutdownResolve = () => {
|
|
416
|
-
clearTimeout(timer);
|
|
417
|
-
resolve({ unfinished: [] });
|
|
418
|
-
};
|
|
419
|
-
});
|
|
420
|
-
}
|
|
421
|
-
/**
|
|
422
|
-
* Yor 子进程退出回调
|
|
423
|
-
*/
|
|
424
|
-
onYorExit(slot, code) {
|
|
425
|
-
const taskId = slot.taskId;
|
|
426
|
-
if (taskId) {
|
|
427
|
-
const label = code === 0 ? '正常退出' : `异常退出 code=${code}`;
|
|
428
|
-
this.logger.info(`[anya:pipeline] [Yor] 结束 ${taskId} | ${label}`);
|
|
429
|
-
}
|
|
430
|
-
// 释放槽位
|
|
431
|
-
slot.busy = false;
|
|
432
|
-
slot.taskId = null;
|
|
433
|
-
slot.process = null;
|
|
434
|
-
slot.startedAt = null;
|
|
435
|
-
// 异步清理工作目录(不阻塞后续流程)
|
|
436
|
-
if (taskId && this.worktreeManager) {
|
|
437
|
-
const task = getTask(this.db, taskId);
|
|
438
|
-
this.worktreeManager.cleanup(taskId, task?.project_id ?? undefined).catch(err => {
|
|
439
|
-
this.logger.error(`[WorktreeManager] 清理失败 (${taskId}):`, err);
|
|
440
|
-
});
|
|
441
|
-
}
|
|
442
|
-
if (taskId) {
|
|
443
|
-
const task = getTask(this.db, taskId);
|
|
444
|
-
if (task) {
|
|
445
|
-
if (code !== 0 && task.status === TaskStatus.IN_PROGRESS) {
|
|
446
|
-
// 非正常退出,标记为 BLOCKED
|
|
447
|
-
updateTask(this.db, taskId, {
|
|
448
|
-
status: TaskStatus.BLOCKED,
|
|
449
|
-
blocked_reason: `Yor 进程异常退出 (code: ${code})`,
|
|
450
|
-
blocked_since: new Date().toISOString(),
|
|
451
|
-
});
|
|
452
|
-
this.onTaskStatusChange?.(taskId, TaskStatus.BLOCKED, {
|
|
453
|
-
reason: `Yor 进程异常退出 (code: ${code})`,
|
|
454
|
-
});
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
// 唤醒 Loid 验收(核心改动)
|
|
458
|
-
if (this.onDeliveryReady) {
|
|
459
|
-
this.buildDeliveryContext(taskId, code).then(deliveryCtx => {
|
|
460
|
-
return this.onDeliveryReady(deliveryCtx);
|
|
461
|
-
}).catch(err => {
|
|
462
|
-
this.logger.error('[Loid] 验收触发失败:', err);
|
|
463
|
-
});
|
|
464
|
-
}
|
|
465
|
-
}
|
|
466
|
-
// 触发后处理回调(异步,不阻塞 drainReadyQueue)
|
|
467
|
-
if (taskId && code === 0 && this.onTaskComplete) {
|
|
468
|
-
this.onTaskComplete(taskId, code).catch(err => {
|
|
469
|
-
this.logger.error('[Yor] 后处理失败:', err);
|
|
470
|
-
});
|
|
471
|
-
}
|
|
472
|
-
// 缩减时回收超出的空闲槽位
|
|
473
|
-
this.shrinkIdleSlots();
|
|
474
|
-
// 检查是否所有任务都已完成(shutdown 等待中)
|
|
475
|
-
if (this.shuttingDown && this.shutdownResolve) {
|
|
476
|
-
const stillBusy = this.slots.some(s => s.busy);
|
|
477
|
-
if (!stillBusy) {
|
|
478
|
-
this.shutdownResolve();
|
|
479
|
-
this.shutdownResolve = null;
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
// 非 shutdown 模式下尝试继续消费 ready queue
|
|
483
|
-
if (!this.shuttingDown) {
|
|
484
|
-
this.drainReadyQueue().catch(err => {
|
|
485
|
-
this.logger.error('[派工] drainReadyQueue 失败:', err);
|
|
486
|
-
});
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
/**
|
|
490
|
-
* 构建 Yor 交付上下文,供 Loid 验收
|
|
491
|
-
*/
|
|
492
|
-
async buildDeliveryContext(taskId, exitCode) {
|
|
493
|
-
const task = getTask(this.db, taskId);
|
|
494
|
-
const taskDir = resolve(this.config.WORKSPACE_PATH, 'yor', 'tasks', taskId);
|
|
495
|
-
// 读取 brief
|
|
496
|
-
let briefContent = null;
|
|
497
|
-
try {
|
|
498
|
-
briefContent = await readFile(join(taskDir, 'brief.md'), 'utf-8');
|
|
499
|
-
}
|
|
500
|
-
catch {
|
|
501
|
-
// brief 可能不存在
|
|
502
|
-
}
|
|
503
|
-
// 读取执行结果
|
|
504
|
-
let testResults = null;
|
|
505
|
-
try {
|
|
506
|
-
testResults = await readFile(join(taskDir, 'test-results.txt'), 'utf-8');
|
|
507
|
-
}
|
|
508
|
-
catch {
|
|
509
|
-
// 测试结果可能不存在
|
|
510
|
-
}
|
|
511
|
-
// 读取变更文件列表
|
|
512
|
-
let changedFiles = [];
|
|
513
|
-
try {
|
|
514
|
-
const resultJson = await readFile(join(taskDir, 'result.json'), 'utf-8');
|
|
515
|
-
const result = JSON.parse(resultJson);
|
|
516
|
-
changedFiles = result.changed_files ?? [];
|
|
517
|
-
}
|
|
518
|
-
catch {
|
|
519
|
-
// result.json 可能不存在
|
|
520
|
-
}
|
|
521
|
-
const sourceChatId = task?.source_ref
|
|
522
|
-
? (getMessageLogBySourceRef(this.db, task.source_ref)?.chat_id ?? undefined)
|
|
523
|
-
: undefined;
|
|
524
|
-
return {
|
|
525
|
-
type: 'delivery',
|
|
526
|
-
taskId,
|
|
527
|
-
briefContent,
|
|
528
|
-
exitCode,
|
|
529
|
-
retryCount: task?.retry_count ?? 0,
|
|
530
|
-
maxRetries: task?.max_retries ?? 3,
|
|
531
|
-
prUrl: task?.pr_url ?? null,
|
|
532
|
-
changedFiles,
|
|
533
|
-
testResults,
|
|
534
|
-
originalMessage: task?.context ?? null,
|
|
535
|
-
chatId: task?.source_ref
|
|
536
|
-
? (getMessageLogBySourceRef(this.db, task.source_ref)?.chat_id ?? null)
|
|
537
|
-
: null,
|
|
538
|
-
conversationId: null,
|
|
539
|
-
activeTasks: [],
|
|
540
|
-
sourceChatId,
|
|
541
|
-
};
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
//# sourceMappingURL=dispatcher.js.map
|
|
@@ -1,158 +0,0 @@
|
|
|
1
|
-
import { IntentLevel } from '@team-anya/core';
|
|
2
|
-
// ── 关键词列表 ──
|
|
3
|
-
const TASK_VERBS = [
|
|
4
|
-
'修复', '优化', '排查', '添加', '实现', '删除', '重构', '升级',
|
|
5
|
-
'fix', 'add', 'implement', 'delete', 'remove', 'refactor', 'upgrade',
|
|
6
|
-
'optimize', 'debug', 'resolve', 'migrate',
|
|
7
|
-
];
|
|
8
|
-
const RISK_WORDS = [
|
|
9
|
-
'故障', '报警', '错误', '崩溃', '超时', '500', 'OOM', 'panic',
|
|
10
|
-
'宕机', '挂了', '异常', '告警', '失败', 'error', 'crash', 'timeout',
|
|
11
|
-
'fatal', 'outage', 'down',
|
|
12
|
-
];
|
|
13
|
-
const TECH_KEYWORDS = [
|
|
14
|
-
'React', 'Vue', 'Angular', 'TypeScript', 'JavaScript', 'Python', 'Go',
|
|
15
|
-
'PostgreSQL', 'MySQL', 'Redis', 'Docker', 'Kubernetes', 'API',
|
|
16
|
-
'GraphQL', 'REST', 'gRPC', 'CI/CD', 'Jenkins', 'GitHub',
|
|
17
|
-
'npm', 'pnpm', 'yarn', 'webpack', 'vite', 'Node.js',
|
|
18
|
-
'AWS', 'Azure', 'GCP', 'Linux', 'Nginx',
|
|
19
|
-
];
|
|
20
|
-
const ANYA_MENTION_PATTERN = /@anya\b/i;
|
|
21
|
-
// 纯表情检测(Unicode Emoji + 常见 emoji 字符)
|
|
22
|
-
const EMOJI_ONLY_PATTERN = /^[\s\p{Emoji_Presentation}\p{Extended_Pictographic}\u200d\ufe0f]*$/u;
|
|
23
|
-
// ── IntentClassifier ──
|
|
24
|
-
export class IntentClassifier {
|
|
25
|
-
orgLookup;
|
|
26
|
-
constructor(deps) {
|
|
27
|
-
this.orgLookup = deps.orgLookup;
|
|
28
|
-
}
|
|
29
|
-
/**
|
|
30
|
-
* 提取消息中的意图信号
|
|
31
|
-
*/
|
|
32
|
-
extractSignals(message) {
|
|
33
|
-
const content = message.content;
|
|
34
|
-
const contentLower = content.toLowerCase();
|
|
35
|
-
const mentionsAnya = message.mentionsAnya === true || ANYA_MENTION_PATTERN.test(content);
|
|
36
|
-
const hasTaskVerbs = TASK_VERBS.some(verb => contentLower.includes(verb.toLowerCase()));
|
|
37
|
-
const hasRiskWords = RISK_WORDS.some(word => contentLower.includes(word.toLowerCase()));
|
|
38
|
-
const isDirectMessage = message.isDirectMessage === true;
|
|
39
|
-
return {
|
|
40
|
-
mentionsAnya,
|
|
41
|
-
hasTaskVerbs,
|
|
42
|
-
hasRiskWords,
|
|
43
|
-
senderHasOwnership: false, // 初始值,classify 中可能更新
|
|
44
|
-
isDirectMessage,
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
/**
|
|
48
|
-
* 判断是否为显式命令(@Anya + 任务动词)
|
|
49
|
-
*/
|
|
50
|
-
isExplicitCommand(message) {
|
|
51
|
-
const signals = this.extractSignals(message);
|
|
52
|
-
return signals.mentionsAnya && signals.hasTaskVerbs;
|
|
53
|
-
}
|
|
54
|
-
/**
|
|
55
|
-
* 对消息进行意图分级
|
|
56
|
-
*/
|
|
57
|
-
async classify(message) {
|
|
58
|
-
const content = message.content.trim();
|
|
59
|
-
const signals = this.extractSignals(message);
|
|
60
|
-
// 空消息或纯表情 → L0
|
|
61
|
-
if (content.length === 0 || this.isNoiseContent(content)) {
|
|
62
|
-
return {
|
|
63
|
-
level: IntentLevel.L0_NOISE,
|
|
64
|
-
confidence: 1.0,
|
|
65
|
-
signals,
|
|
66
|
-
suggestedAction: 'ignore',
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
// 尝试 orgLookup 查询 sender ownership
|
|
70
|
-
if (message.sender && this.orgLookup) {
|
|
71
|
-
try {
|
|
72
|
-
const orgInfo = await this.orgLookup(message.sender);
|
|
73
|
-
signals.senderHasOwnership = orgInfo.hasOwnership;
|
|
74
|
-
}
|
|
75
|
-
catch {
|
|
76
|
-
// orgLookup 不可用,安全降级
|
|
77
|
-
signals.senderHasOwnership = false;
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
// 规则优先判定
|
|
81
|
-
// 显式命令 → L3_COMMITMENT
|
|
82
|
-
if (signals.mentionsAnya && (signals.hasTaskVerbs || signals.hasRiskWords)) {
|
|
83
|
-
return {
|
|
84
|
-
level: IntentLevel.L3_COMMITMENT,
|
|
85
|
-
confidence: 0.95,
|
|
86
|
-
signals,
|
|
87
|
-
suggestedAction: 'create_task',
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
// 私聊 + 任务动词 → L3_COMMITMENT
|
|
91
|
-
if (signals.isDirectMessage && (signals.hasTaskVerbs || signals.hasRiskWords)) {
|
|
92
|
-
return {
|
|
93
|
-
level: IntentLevel.L3_COMMITMENT,
|
|
94
|
-
confidence: 0.9,
|
|
95
|
-
signals,
|
|
96
|
-
suggestedAction: 'create_task',
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
// sender 有 ownership + 任务动词 → L3_COMMITMENT
|
|
100
|
-
if (signals.senderHasOwnership && signals.hasTaskVerbs) {
|
|
101
|
-
return {
|
|
102
|
-
level: IntentLevel.L3_COMMITMENT,
|
|
103
|
-
confidence: 0.85,
|
|
104
|
-
signals,
|
|
105
|
-
suggestedAction: 'create_task',
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
// 有风险词或任务动词(但非显式命令)→ L2_OPPORTUNITY
|
|
109
|
-
if (signals.hasRiskWords || signals.hasTaskVerbs) {
|
|
110
|
-
return {
|
|
111
|
-
level: IntentLevel.L2_OPPORTUNITY,
|
|
112
|
-
confidence: 0.7,
|
|
113
|
-
signals,
|
|
114
|
-
suggestedAction: 'create_opportunity',
|
|
115
|
-
};
|
|
116
|
-
}
|
|
117
|
-
// 包含技术关键词 → L1_CONTEXT
|
|
118
|
-
if (this.hasTechContent(content)) {
|
|
119
|
-
return {
|
|
120
|
-
level: IntentLevel.L1_CONTEXT,
|
|
121
|
-
confidence: 0.6,
|
|
122
|
-
signals,
|
|
123
|
-
suggestedAction: 'cache_context',
|
|
124
|
-
};
|
|
125
|
-
}
|
|
126
|
-
// 默认 → L0_NOISE
|
|
127
|
-
return {
|
|
128
|
-
level: IntentLevel.L0_NOISE,
|
|
129
|
-
confidence: 0.5,
|
|
130
|
-
signals,
|
|
131
|
-
suggestedAction: 'ignore',
|
|
132
|
-
};
|
|
133
|
-
}
|
|
134
|
-
/**
|
|
135
|
-
* 判断内容是否为噪音(纯表情、纯标点、过短)
|
|
136
|
-
*/
|
|
137
|
-
isNoiseContent(content) {
|
|
138
|
-
// 纯表情
|
|
139
|
-
if (EMOJI_ONLY_PATTERN.test(content))
|
|
140
|
-
return true;
|
|
141
|
-
// 纯标点符号和空格
|
|
142
|
-
if (/^[\s\p{P}]+$/u.test(content))
|
|
143
|
-
return true;
|
|
144
|
-
// 非常短且无有意义词汇(2 个字符以下)
|
|
145
|
-
const stripped = content.replace(/[\s\p{P}]/gu, '');
|
|
146
|
-
if (stripped.length <= 2 && !TASK_VERBS.some(v => content.toLowerCase().includes(v.toLowerCase()))) {
|
|
147
|
-
return true;
|
|
148
|
-
}
|
|
149
|
-
return false;
|
|
150
|
-
}
|
|
151
|
-
/**
|
|
152
|
-
* 判断内容是否包含技术相关词汇
|
|
153
|
-
*/
|
|
154
|
-
hasTechContent(content) {
|
|
155
|
-
return TECH_KEYWORDS.some(keyword => content.toLowerCase().includes(keyword.toLowerCase()));
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
//# sourceMappingURL=intent-classifier.js.map
|