@co0ontty/wand 4.76.0 → 4.77.0-beta.g6c4df97
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-dispatch.d.ts +46 -0
- package/dist/agent-dispatch.js +84 -0
- package/dist/ai-team-availability.d.ts +69 -0
- package/dist/ai-team-availability.js +100 -0
- package/dist/ai-team-prompts.d.ts +113 -0
- package/dist/ai-team-prompts.js +435 -0
- package/dist/ai-team-runner.d.ts +241 -0
- package/dist/ai-team-runner.js +1370 -0
- package/dist/ai-team-types.d.ts +147 -0
- package/dist/ai-team-types.js +32 -0
- package/dist/attention.d.ts +40 -0
- package/dist/attention.js +86 -0
- package/dist/build-info.json +4 -4
- package/dist/claude-sdk-runner.d.ts +5 -0
- package/dist/claude-sdk-runner.js +2 -1
- package/dist/commit-task-archive.d.ts +3 -0
- package/dist/commit-task-archive.js +43 -0
- package/dist/config.js +0 -2
- package/dist/git-quick-commit.d.ts +2 -1
- package/dist/git-quick-commit.js +72 -60
- package/dist/message-truncator.d.ts +41 -5
- package/dist/message-truncator.js +255 -38
- package/dist/missions.d.ts +4 -2
- package/dist/missions.js +2 -2
- package/dist/process-manager.d.ts +1 -0
- package/dist/process-manager.js +17 -8
- package/dist/prompt-optimizer.js +13 -13
- package/dist/server-ai-team-routes.d.ts +11 -0
- package/dist/server-ai-team-routes.js +283 -0
- package/dist/server-attention-routes.d.ts +9 -0
- package/dist/server-attention-routes.js +26 -0
- package/dist/server-file-routes.js +1 -1
- package/dist/server-session-routes.js +41 -5
- package/dist/server-settings-routes.js +1 -1
- package/dist/server-task-routes.d.ts +2 -0
- package/dist/server-task-routes.js +6 -40
- package/dist/server-workspace-routes.js +13 -5
- package/dist/server.js +43 -0
- package/dist/session-topic.js +13 -13
- package/dist/session-transport.d.ts +3 -0
- package/dist/session-transport.js +1 -0
- package/dist/storage.d.ts +54 -1
- package/dist/storage.js +413 -2
- package/dist/structured-claude-adapter.d.ts +5 -0
- package/dist/structured-claude-adapter.js +13 -2
- package/dist/structured-claude-protocol.d.ts +2 -0
- package/dist/structured-claude-protocol.js +4 -0
- package/dist/structured-codex-adapter.js +2 -2
- package/dist/structured-grok-adapter.js +2 -1
- package/dist/structured-opencode-adapter.js +2 -2
- package/dist/structured-pi-adapter.js +2 -1
- package/dist/structured-provider-common.d.ts +17 -0
- package/dist/structured-provider-common.js +52 -0
- package/dist/structured-qoder-adapter.js +2 -1
- package/dist/structured-session-manager.d.ts +18 -1
- package/dist/structured-session-manager.js +67 -10
- package/dist/system-ai.d.ts +13 -2
- package/dist/system-ai.js +9 -5
- package/dist/task-title.js +10 -10
- package/dist/types.d.ts +25 -2
- package/dist/wand-task-sync.d.ts +1 -0
- package/dist/wand-task-sync.js +1 -0
- package/dist/web-ui/content/ai-teams.js +12 -0
- package/dist/web-ui/content/scripts.js +86 -5301
- package/dist/web-ui/content/styles.css +1 -1
- package/dist/web-ui/embedded-assets.d.ts +1 -0
- package/dist/web-ui/embedded-assets.js +3 -2
- package/dist/web-ui/scripts.d.ts +8 -0
- package/dist/web-ui/scripts.js +28 -2
- package/dist/ws-broadcast.js +52 -19
- package/package.json +1 -1
|
@@ -0,0 +1,1370 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { dispatchAgentForTask, resolveTaskDispatchTarget, sendToAgentSession, stopAgentSession, } from "./agent-dispatch.js";
|
|
7
|
+
import { AI_TEAM_REPORT_DIR, aiTeamChatHistoryPath, aiTeamHandoffPath, aiTeamReportPath, buildLeaderFollowupPrompt, buildLeaderFormatRetryPrompt, buildAiTeamObjective, buildLeaderKickoffPrompt, buildMemberPrompt, leaderOf, parseLeaderDecision, renderChatHistoryFile, renderHandoffFile, } from "./ai-team-prompts.js";
|
|
8
|
+
import { AI_TEAM_STARTUP_WINDOW_MS, classifyCandidateFailure, classifyStartupFailure, isDegradableWorkFailure, isModelUnknownBeforeDispatch, } from "./ai-team-availability.js";
|
|
9
|
+
import { AI_TEAM_ACTIVE_RUN_STATUSES, AI_TEAM_DETAIL_CHAT_TURNS, agentKey, memberAgents, } from "./ai-team-types.js";
|
|
10
|
+
import { getErrorMessage } from "./error-utils.js";
|
|
11
|
+
import { activityState } from "./missions.js";
|
|
12
|
+
const REPORT_MAX_BYTES = 64 * 1024;
|
|
13
|
+
/** 报告文件最后一次写入后要稳定这么久才读取,避免读到写了一半的文件。 */
|
|
14
|
+
const REPORT_SETTLE_MS = 1500;
|
|
15
|
+
const OUTPUT_DEBOUNCE_MS = 1000;
|
|
16
|
+
const SWEEP_INTERVAL_MS = 5000;
|
|
17
|
+
const MAX_FORMAT_RETRIES = 2;
|
|
18
|
+
/** 同一个候选累计这么多次 startup-timeout 就按五元组拉黑(§3.4 黑名单 agents 层)。 */
|
|
19
|
+
const STARTUP_TIMEOUT_STRIKES = 2;
|
|
20
|
+
/** 群聊会话的 automationId 前缀;后面接运行 id。 */
|
|
21
|
+
export const AI_TEAM_CHAT_PREFIX = "ai-team-chat:";
|
|
22
|
+
/** 等批准时,群里回复这些词就是批准,其余内容都当修改意见。 */
|
|
23
|
+
const APPROVE_REPLY = /^(批准|同意|可以|开始|开干|好的?|ok|okay|yes|lgtm|approve)[。.!!]*$/i;
|
|
24
|
+
const STOP_REPLY = /^(停止|停下|stop)[。.!!]*$/i;
|
|
25
|
+
/** 用户可见的冲突错误(路由转 409)。 */
|
|
26
|
+
export class AiTeamConflictError extends Error {
|
|
27
|
+
}
|
|
28
|
+
export function createAiTeamSessionOps(deps) {
|
|
29
|
+
return {
|
|
30
|
+
async open(input) {
|
|
31
|
+
const { session } = await dispatchAgentForTask(deps, input);
|
|
32
|
+
return session.id;
|
|
33
|
+
},
|
|
34
|
+
send: (sessionId, text) => sendToAgentSession(deps, sessionId, text),
|
|
35
|
+
stop: (sessionId) => stopAgentSession(deps, sessionId),
|
|
36
|
+
snapshot: (sessionId) => deps.sessions.getLatest(sessionId),
|
|
37
|
+
ownerOf: (sessionId) => deps.sessions.ownerOf(sessionId),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function createAiTeamChatOps(deps) {
|
|
41
|
+
const structured = deps.structured;
|
|
42
|
+
if (!structured)
|
|
43
|
+
return undefined;
|
|
44
|
+
return {
|
|
45
|
+
open({ task, run, title }) {
|
|
46
|
+
const target = resolveTaskDispatchTarget(deps, task);
|
|
47
|
+
const leader = leaderOf(run.team);
|
|
48
|
+
const session = structured.createRelaySession({
|
|
49
|
+
cwd: run.cwd,
|
|
50
|
+
mode: leader.agent.mode,
|
|
51
|
+
provider: leader.agent.provider,
|
|
52
|
+
worktreeEnabled: false,
|
|
53
|
+
sessionSource: "interactive",
|
|
54
|
+
automationId: `${AI_TEAM_CHAT_PREFIX}${run.id}`,
|
|
55
|
+
workspaceId: target.workspaceId,
|
|
56
|
+
workspaceTaskId: target.workspaceTaskId,
|
|
57
|
+
title,
|
|
58
|
+
});
|
|
59
|
+
deps.storage.bindWandTaskSession(task.id, session.id);
|
|
60
|
+
return session.id;
|
|
61
|
+
},
|
|
62
|
+
post: (sessionId, turns) => {
|
|
63
|
+
structured.appendRelayTurns(sessionId, turns);
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
export function createAiTeamRunner(deps) {
|
|
68
|
+
const runner = new AiTeamRunner({
|
|
69
|
+
storage: deps.storage,
|
|
70
|
+
resolveCwd: (task) => resolveTaskDispatchTarget(deps, task).cwd,
|
|
71
|
+
ops: createAiTeamSessionOps(deps),
|
|
72
|
+
chat: createAiTeamChatOps(deps),
|
|
73
|
+
notify: deps.notify,
|
|
74
|
+
models: deps.models,
|
|
75
|
+
});
|
|
76
|
+
deps.structured?.registerRelay(AI_TEAM_CHAT_PREFIX, (sessionId, text) => runner.chatInput(sessionId, text));
|
|
77
|
+
return runner;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* 群聊里的发言人。上传的头像是 data URL,每条消息都带一份太重,改用按 id 取的像素猫。
|
|
81
|
+
* `provider` 传该步**实际使用候选**的 provider(§3.6/B15);Leader 或没有步骤的场景回退首选。
|
|
82
|
+
*/
|
|
83
|
+
function chatAuthor(member, sessionId, provider) {
|
|
84
|
+
return {
|
|
85
|
+
id: member.id,
|
|
86
|
+
name: member.name,
|
|
87
|
+
avatar: member.avatar && !member.avatar.startsWith("data:") ? member.avatar : undefined,
|
|
88
|
+
leader: member.isLeader || undefined,
|
|
89
|
+
provider: provider ?? memberAgents(member)[0]?.provider ?? member.agent.provider,
|
|
90
|
+
sessionId: sessionId ?? undefined,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
const chatText = (text) => [{ type: "text", text }];
|
|
94
|
+
/** CLI 的错误常是「一行摘要 + 一大段 JSON」,有用的信息在头尾;中间截掉以免撑爆报告。 */
|
|
95
|
+
function summarizeError(error) {
|
|
96
|
+
const text = (error ?? "").trim();
|
|
97
|
+
if (text.length <= 400)
|
|
98
|
+
return text;
|
|
99
|
+
return `${text.slice(0, 160)} … ${text.slice(-240)}`;
|
|
100
|
+
}
|
|
101
|
+
const HOME_PATH_PLACEHOLDER = "~";
|
|
102
|
+
const CONFIG_PATH_PLACEHOLDER = "<配置目录>";
|
|
103
|
+
const SECRET_VALUE_PLACEHOLDER = "<已隐藏>";
|
|
104
|
+
/**
|
|
105
|
+
* 凭据参数名。强名字(token / secret / api_key / connection_code …)`=` 与 `:` 两种写法都收;
|
|
106
|
+
* 弱名字(code / key)只收 `code=` / `key=` 形式——否则 provider 错误 JSON 里的
|
|
107
|
+
* `code: 'ENOENT'`、`status_code: 503` 也会被误吞,而连接码本身就是 64 位 hex,逃不掉。
|
|
108
|
+
*/
|
|
109
|
+
const SECRET_PARAM_PATTERN = new RegExp("\\b((?:(?:access[_-]?token|refresh[_-]?token|id[_-]?token|auth[_-]?token|api[_-]?key|apikey"
|
|
110
|
+
+ "|secret[_-]?key|client[_-]?secret|private[_-]?key|connection[_-]?code|password|passwd|secret|token)"
|
|
111
|
+
+ "\\s*[=:]|(?:code|key)\\s*=)\\s*)(?:\"[^\"]*\"|'[^']*'|[^\\s&;,}\"]+)", "gi");
|
|
112
|
+
/** 已知签发前缀(sk- / ghp_ / xoxb- / AIza…)后面跟的长串一定是凭据。 */
|
|
113
|
+
const KNOWN_SECRET_PATTERN = /\b(?:sk|pk|ghp|gho|ghu|ghs|ghr|xox[abprs]?|AIza|ya29)[-_][A-Za-z0-9]{10,}/g;
|
|
114
|
+
/** HTTP Authorization 头的值形态:`Bearer <凭据>` 整段收掉。 */
|
|
115
|
+
const BEARER_PATTERN = /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi;
|
|
116
|
+
/** 高熵长串兜底:≥32 字符的连续 body(provider 文案里的路径、模型名都不到这个长度)。 */
|
|
117
|
+
const LONG_TOKEN_PATTERN = /\b[A-Za-z0-9][A-Za-z0-9+/=_-]{30,}\b/g;
|
|
118
|
+
/**
|
|
119
|
+
* 落库 / 进群聊前的脱敏(§4.5「全链路不写连接码 / token / home 路径」)。
|
|
120
|
+
* 纯函数、无 IO:目录由调用方传进来,同一个输入永远得到同一个输出,重复清洗结果不变。
|
|
121
|
+
* 顺序很关键:先把配置目录(在家目录下)整段换掉,再换家目录,否则 `~/.wand` 会漏半截。
|
|
122
|
+
* 方向是宁多勿漏:认不准的超长串一律隐藏,代价只是文案少一段可调试信息。
|
|
123
|
+
*/
|
|
124
|
+
export function redactAiTeamErrorText(text, dirs = {}) {
|
|
125
|
+
if (!text)
|
|
126
|
+
return text;
|
|
127
|
+
let out = text;
|
|
128
|
+
const configDir = dirs.configDir?.trim();
|
|
129
|
+
if (configDir && configDir.length > 1)
|
|
130
|
+
out = out.split(configDir).join(CONFIG_PATH_PLACEHOLDER);
|
|
131
|
+
const homeDir = dirs.homeDir?.trim();
|
|
132
|
+
if (homeDir && homeDir.length > 1 && homeDir !== path.sep) {
|
|
133
|
+
out = out.split(homeDir).join(HOME_PATH_PLACEHOLDER);
|
|
134
|
+
}
|
|
135
|
+
// 报错里提到的可能是别的用户 / 别的机器(本机 homedir 比对不上),通用家目录前缀一并收掉。
|
|
136
|
+
out = out.replace(/(^|[\s"'(=,])(?:\/(?:Users|home)\/|C:\\+Users\\+)[^/"'(),\s]*/g, `$1${HOME_PATH_PLACEHOLDER}`);
|
|
137
|
+
out = out.replace(SECRET_PARAM_PATTERN, `$1${SECRET_VALUE_PLACEHOLDER}`);
|
|
138
|
+
out = out.replace(BEARER_PATTERN, SECRET_VALUE_PLACEHOLDER);
|
|
139
|
+
out = out.replace(KNOWN_SECRET_PATTERN, SECRET_VALUE_PLACEHOLDER);
|
|
140
|
+
out = out.replace(LONG_TOKEN_PATTERN, (match) => (/\d/.test(match) && /[A-Za-z]/.test(match)) || /[_-]/.test(match) ? SECRET_VALUE_PLACEHOLDER : match);
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
function isSessionGone(snapshot) {
|
|
144
|
+
return snapshot.status === "exited" || snapshot.status === "failed" || snapshot.status === "stopped";
|
|
145
|
+
}
|
|
146
|
+
function turnText(turn) {
|
|
147
|
+
return turn.content
|
|
148
|
+
.filter((block) => block.type === "text" && typeof block.text === "string")
|
|
149
|
+
.map((block) => block.text.trim())
|
|
150
|
+
.filter(Boolean)
|
|
151
|
+
.join("\n\n");
|
|
152
|
+
}
|
|
153
|
+
/** 找到提示词(含 reportPath)那一轮之后的 assistant 文本;没有回复返回 null。 */
|
|
154
|
+
function assistantReplyAfterPrompt(snapshot, reportPath) {
|
|
155
|
+
const messages = snapshot.messages ?? [];
|
|
156
|
+
let promptIndex = -1;
|
|
157
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
158
|
+
const turn = messages[index];
|
|
159
|
+
if (turn.role === "user" && turnText(turn).includes(reportPath)) {
|
|
160
|
+
promptIndex = index;
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (promptIndex < 0)
|
|
165
|
+
return null;
|
|
166
|
+
const replies = messages.slice(promptIndex + 1).filter((turn) => turn.role === "assistant");
|
|
167
|
+
if (replies.length === 0)
|
|
168
|
+
return null;
|
|
169
|
+
return replies.map(turnText).filter(Boolean).join("\n\n");
|
|
170
|
+
}
|
|
171
|
+
function readReport(file) {
|
|
172
|
+
const buffer = readFileSync(file);
|
|
173
|
+
if (buffer.length <= REPORT_MAX_BYTES)
|
|
174
|
+
return buffer.toString("utf8");
|
|
175
|
+
return `${buffer.subarray(0, REPORT_MAX_BYTES).toString("utf8")}\n\n(已截断)`;
|
|
176
|
+
}
|
|
177
|
+
/** 把 .wand-team/ 加进仓库的 info/exclude,避免报告文件出现在未跟踪列表里。 */
|
|
178
|
+
function excludeReportDir(cwd) {
|
|
179
|
+
try {
|
|
180
|
+
const gitDir = execFileSync("git", ["rev-parse", "--git-common-dir"], {
|
|
181
|
+
cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
|
|
182
|
+
}).trim();
|
|
183
|
+
if (!gitDir)
|
|
184
|
+
return;
|
|
185
|
+
const excludeFile = path.join(path.resolve(cwd, gitDir), "info", "exclude");
|
|
186
|
+
const line = `/${AI_TEAM_REPORT_DIR}/`;
|
|
187
|
+
const current = existsSync(excludeFile) ? readFileSync(excludeFile, "utf8") : "";
|
|
188
|
+
if (current.split(/\r?\n/).some((entry) => entry.trim() === line))
|
|
189
|
+
return;
|
|
190
|
+
appendFileSync(excludeFile, `${current && !current.endsWith("\n") ? "\n" : ""}${line}\n`);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
// 不在 git 仓库里或没有写权限:跳过,不影响运行。
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* AI 团队调度器。状态全部在数据库里,会话事件驱动推进;同一运行的所有操作串行执行。
|
|
198
|
+
* 成员会话是普通 Wand 会话,服务端只负责传话:Leader 决定派谁做什么,成员交报告。
|
|
199
|
+
*/
|
|
200
|
+
export class AiTeamRunner {
|
|
201
|
+
storage;
|
|
202
|
+
ops;
|
|
203
|
+
chat;
|
|
204
|
+
resolveCwd;
|
|
205
|
+
notifyListener;
|
|
206
|
+
modelsOf;
|
|
207
|
+
now;
|
|
208
|
+
chains = new Map();
|
|
209
|
+
outputTimers = new Map();
|
|
210
|
+
recheckTimers = new Map();
|
|
211
|
+
/** 已经确定「不降级、按既有路径记 failed」的步骤,防止 finishStep 再判一次形成回环。 */
|
|
212
|
+
noDegrade = new Set();
|
|
213
|
+
sweepTimer = null;
|
|
214
|
+
/** 脱敏参照目录只算一次:os.homedir() 读 env,configDir 随 storage 定,运行期不变。 */
|
|
215
|
+
redactionDirsValue = null;
|
|
216
|
+
constructor(options) {
|
|
217
|
+
this.storage = options.storage;
|
|
218
|
+
this.ops = options.ops;
|
|
219
|
+
this.chat = options.chat;
|
|
220
|
+
this.resolveCwd = options.resolveCwd;
|
|
221
|
+
this.notifyListener = options.notify;
|
|
222
|
+
this.modelsOf = options.models;
|
|
223
|
+
this.now = options.now ?? Date.now;
|
|
224
|
+
}
|
|
225
|
+
/** 本 runner 的遮蔽参照目录(§4.5)。 */
|
|
226
|
+
get redactionDirs() {
|
|
227
|
+
if (!this.redactionDirsValue) {
|
|
228
|
+
let homeDir = "";
|
|
229
|
+
try {
|
|
230
|
+
homeDir = os.homedir();
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
homeDir = ""; // 拿不到家目录时仍按通用 /Users、/home 前缀与凭据规则清洗
|
|
234
|
+
}
|
|
235
|
+
this.redactionDirsValue = { homeDir, configDir: this.storage.directory() };
|
|
236
|
+
}
|
|
237
|
+
return this.redactionDirsValue;
|
|
238
|
+
}
|
|
239
|
+
/** 只脱敏、不改长度语义(用于本来就不截断的文案)。 */
|
|
240
|
+
redact(text) {
|
|
241
|
+
return redactAiTeamErrorText(text, this.redactionDirs);
|
|
242
|
+
}
|
|
243
|
+
/** 先脱敏再头尾截断:截断会把中间的敏感串切出来,顺序不能反。 */
|
|
244
|
+
cleanError(text) {
|
|
245
|
+
return summarizeError(this.redact(text ?? ""));
|
|
246
|
+
}
|
|
247
|
+
// ── 公开操作 ──
|
|
248
|
+
async start(input) {
|
|
249
|
+
const team = this.storage.getAiTeam(input.teamId);
|
|
250
|
+
if (!team)
|
|
251
|
+
throw new Error("团队不存在。");
|
|
252
|
+
const task = this.storage.getWandTask(input.taskId);
|
|
253
|
+
if (!task)
|
|
254
|
+
throw new Error("任务不存在。");
|
|
255
|
+
const active = this.storage.listAiTeamRuns({ taskId: task.id, statuses: AI_TEAM_ACTIVE_RUN_STATUSES });
|
|
256
|
+
if (active.length > 0)
|
|
257
|
+
throw new AiTeamConflictError("这张任务已经有进行中的团队运行。");
|
|
258
|
+
const cwd = this.resolveCwd(task);
|
|
259
|
+
const objective = buildAiTeamObjective(task, input.note);
|
|
260
|
+
const createdAt = this.iso();
|
|
261
|
+
const run = {
|
|
262
|
+
id: `run_${randomUUID().replace(/-/g, "").slice(0, 12)}`,
|
|
263
|
+
teamId: team.id,
|
|
264
|
+
team,
|
|
265
|
+
taskId: task.id,
|
|
266
|
+
objective,
|
|
267
|
+
cwd,
|
|
268
|
+
status: "running",
|
|
269
|
+
statusDetail: "",
|
|
270
|
+
stepsUsed: 0,
|
|
271
|
+
stepLimit: team.maxSteps,
|
|
272
|
+
formatRetries: 0,
|
|
273
|
+
planApproved: !team.requirePlanApproval,
|
|
274
|
+
chatSessionId: input.chatSessionId ?? null,
|
|
275
|
+
pendingNotes: [],
|
|
276
|
+
createdAt,
|
|
277
|
+
updatedAt: createdAt,
|
|
278
|
+
};
|
|
279
|
+
// 群聊只回显用户刚输入的那句。任务标题已经在会话名里,不要再伪装成用户又说了一次。
|
|
280
|
+
const shown = input.chatSessionId ? "" : (input.note?.trim() || objective);
|
|
281
|
+
// 续跑(用户在同一个群聊里接着说话)要在写进新 notice 之前把历史落下,否则会把
|
|
282
|
+
// 「团队这次接手」这种新行也当成历史写进去。
|
|
283
|
+
const chatHistory = input.chatSessionId ? this.writeChatHistory(run) : null;
|
|
284
|
+
this.openChat(run, task, shown, chatHistory);
|
|
285
|
+
this.storage.saveAiTeamRun(run);
|
|
286
|
+
if (task.status === "todo")
|
|
287
|
+
this.storage.updateWandTask(task.id, { status: "doing" });
|
|
288
|
+
excludeReportDir(cwd);
|
|
289
|
+
await this.enqueue(run.id, async () => {
|
|
290
|
+
const step = this.createLeaderStep(run, "制定计划");
|
|
291
|
+
await this.dispatchStep(run, step, (reportPath, fresh) => (buildLeaderKickoffPrompt(run, reportPath, fresh, chatHistory)));
|
|
292
|
+
});
|
|
293
|
+
return this.detail(run.id);
|
|
294
|
+
}
|
|
295
|
+
approve(runId, options = {}) {
|
|
296
|
+
return this.act(runId, async (run) => {
|
|
297
|
+
this.requireStatus(run, "awaiting_approval", "当前没有待批准的计划。");
|
|
298
|
+
if (options.echo !== false)
|
|
299
|
+
this.postUser(run, "批准");
|
|
300
|
+
run.planApproved = true;
|
|
301
|
+
this.setStatus(run, "running", "");
|
|
302
|
+
await this.advance(run);
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
reject(runId, feedback, options = {}) {
|
|
306
|
+
return this.act(runId, async (run) => {
|
|
307
|
+
this.requireStatus(run, "awaiting_approval", "当前没有待批准的计划。");
|
|
308
|
+
const text = feedback.trim();
|
|
309
|
+
if (!text)
|
|
310
|
+
throw new Error("请写下驳回意见。");
|
|
311
|
+
if (options.echo !== false)
|
|
312
|
+
this.postUser(run, text);
|
|
313
|
+
for (const step of this.storage.listAiTeamSteps(run.id)) {
|
|
314
|
+
if (step.status === "queued")
|
|
315
|
+
this.saveStep({ ...step, status: "skipped", endedAt: this.iso() });
|
|
316
|
+
}
|
|
317
|
+
this.setStatus(run, "running", "");
|
|
318
|
+
await this.startLeaderRound(run, `用户没有批准这个计划,意见:${text}\n请重新安排。`);
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
reply(runId, text, options = {}) {
|
|
322
|
+
return this.act(runId, async (run) => {
|
|
323
|
+
this.requireStatus(run, "waiting_user", "团队当前没有在等你回复。");
|
|
324
|
+
const note = text.trim();
|
|
325
|
+
if (!note)
|
|
326
|
+
throw new Error("回复不能为空。");
|
|
327
|
+
if (options.echo !== false)
|
|
328
|
+
this.postUser(run, note);
|
|
329
|
+
run.formatRetries = 0;
|
|
330
|
+
this.refreshTeam(run);
|
|
331
|
+
this.setStatus(run, "running", "");
|
|
332
|
+
await this.startLeaderRound(run, note);
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
continueRun(runId, extraSteps) {
|
|
336
|
+
return this.act(runId, async (run) => {
|
|
337
|
+
this.requireStatus(run, "waiting_user", "团队当前没有在等你。");
|
|
338
|
+
const extra = Math.min(50, Math.max(5, Math.round(extraSteps) || 10));
|
|
339
|
+
this.refreshTeam(run);
|
|
340
|
+
run.stepLimit = Math.max(run.stepLimit, run.stepsUsed) + extra;
|
|
341
|
+
this.postNotice(run, `步数上限加到 ${run.stepLimit},继续执行`);
|
|
342
|
+
this.setStatus(run, "running", "");
|
|
343
|
+
await this.advance(run);
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
completeStep(runId, stepId, report) {
|
|
347
|
+
return this.act(runId, async (run) => {
|
|
348
|
+
const step = this.storage.listAiTeamSteps(run.id).find((item) => item.id === stepId);
|
|
349
|
+
if (!step || step.status !== "running" || step.kind !== "work") {
|
|
350
|
+
throw new AiTeamConflictError("只能手动完成正在进行的成员步骤。");
|
|
351
|
+
}
|
|
352
|
+
await this.finishStep(run, step, { kind: "done", text: report.trim() || "用户手动标记完成", fromFile: false });
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
stop(runId) {
|
|
356
|
+
return this.act(runId, async (run) => {
|
|
357
|
+
if (!AI_TEAM_ACTIVE_RUN_STATUSES.includes(run.status))
|
|
358
|
+
throw new AiTeamConflictError("这次运行已经结束。");
|
|
359
|
+
const endedAt = this.iso();
|
|
360
|
+
for (const step of this.storage.listAiTeamSteps(run.id)) {
|
|
361
|
+
if (step.status !== "running" && step.status !== "queued")
|
|
362
|
+
continue;
|
|
363
|
+
if (step.status === "running" && step.sessionId) {
|
|
364
|
+
try {
|
|
365
|
+
this.ops.stop(step.sessionId);
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
console.error(`[AiTeam] stop session ${step.sessionId} failed:`, getErrorMessage(error));
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
this.saveStep({ ...step, status: "skipped", endedAt });
|
|
372
|
+
}
|
|
373
|
+
this.postNotice(run, "团队已停止。在群里发消息可以让团队接着处理。");
|
|
374
|
+
this.setStatus(run, "stopped", "已由用户停止");
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* 用户在群聊里说的话。等回复时转给负责人;等批准时「批准」类短语直接批准,其余当修改意见;
|
|
379
|
+
* 成员干活时先记下,负责人下一轮一起看到;运行已结束则接着开一轮新的。
|
|
380
|
+
*/
|
|
381
|
+
async chatInput(sessionId, text) {
|
|
382
|
+
const run = this.storage.getLatestAiTeamRunByChat(sessionId);
|
|
383
|
+
if (!run)
|
|
384
|
+
throw new Error("找不到这个群聊对应的团队运行。");
|
|
385
|
+
const note = text.trim();
|
|
386
|
+
if (STOP_REPLY.test(note) && AI_TEAM_ACTIVE_RUN_STATUSES.includes(run.status)) {
|
|
387
|
+
await this.stop(run.id);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
switch (run.status) {
|
|
391
|
+
case "awaiting_approval":
|
|
392
|
+
if (APPROVE_REPLY.test(note))
|
|
393
|
+
await this.approve(run.id, { echo: false });
|
|
394
|
+
else
|
|
395
|
+
await this.reject(run.id, note, { echo: false });
|
|
396
|
+
return;
|
|
397
|
+
case "waiting_user":
|
|
398
|
+
await this.reply(run.id, note, { echo: false });
|
|
399
|
+
return;
|
|
400
|
+
case "running":
|
|
401
|
+
await this.act(run.id, async (current) => {
|
|
402
|
+
if (current.status !== "running")
|
|
403
|
+
throw new AiTeamConflictError("团队状态刚变了,请再发一次。");
|
|
404
|
+
current.pendingNotes = [...current.pendingNotes, note];
|
|
405
|
+
this.postNotice(current, "已记下,负责人安排下一步时会看到。回复「停止」可以叫停团队。");
|
|
406
|
+
this.saveRun(current);
|
|
407
|
+
});
|
|
408
|
+
return;
|
|
409
|
+
default: {
|
|
410
|
+
const team = this.storage.getAiTeam(run.teamId);
|
|
411
|
+
if (!team)
|
|
412
|
+
throw new Error("团队已被删除,没法接着处理。");
|
|
413
|
+
await this.start({ teamId: team.id, taskId: run.taskId, note, chatSessionId: sessionId });
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
detail(runId) {
|
|
418
|
+
const run = this.storage.getAiTeamRun(runId);
|
|
419
|
+
if (!run)
|
|
420
|
+
throw new Error("团队运行不存在。");
|
|
421
|
+
const steps = this.storage.listAiTeamSteps(run.id);
|
|
422
|
+
const memberStates = {};
|
|
423
|
+
for (const step of steps) {
|
|
424
|
+
if (step.status !== "running" || !step.sessionId)
|
|
425
|
+
continue;
|
|
426
|
+
const snapshot = this.ops.snapshot(step.sessionId);
|
|
427
|
+
if (snapshot)
|
|
428
|
+
memberStates[step.sessionId] = activityState(snapshot);
|
|
429
|
+
}
|
|
430
|
+
// 群聊回合只给最近这一段(§4.4);没有群聊会话的旧运行给空数组,前端不用判 null。
|
|
431
|
+
const messages = run.chatSessionId ? this.ops.snapshot(run.chatSessionId)?.messages ?? [] : [];
|
|
432
|
+
return {
|
|
433
|
+
run,
|
|
434
|
+
steps,
|
|
435
|
+
memberStates,
|
|
436
|
+
chatTurns: messages.slice(-AI_TEAM_DETAIL_CHAT_TURNS),
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
listForTask(taskId) {
|
|
440
|
+
return this.storage.listAiTeamRuns({ taskId });
|
|
441
|
+
}
|
|
442
|
+
/** 团队页的运行记录;teamId 为空时列出所有团队(侧边栏计数用)。 */
|
|
443
|
+
listRuns(filter) {
|
|
444
|
+
const runs = this.storage.listAiTeamRuns({
|
|
445
|
+
teamId: filter.teamId,
|
|
446
|
+
statuses: filter.activeOnly ? AI_TEAM_ACTIVE_RUN_STATUSES : undefined,
|
|
447
|
+
limit: filter.limit,
|
|
448
|
+
});
|
|
449
|
+
return runs.map((run) => {
|
|
450
|
+
const task = this.storage.getWandTask(run.taskId);
|
|
451
|
+
return { ...run, taskTitle: task?.title ?? "(任务已删除)", taskIdentifier: task?.identifier ?? "" };
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
// ── 事件驱动 ──
|
|
455
|
+
/** 与 missions.ingest 并列调用。绝大多数事件与团队无关,必须廉价返回。 */
|
|
456
|
+
ingest(event) {
|
|
457
|
+
if (!event.sessionId || event.sessionId === "__system__")
|
|
458
|
+
return;
|
|
459
|
+
if (event.type !== "status" && event.type !== "ended" && event.type !== "task" && event.type !== "output")
|
|
460
|
+
return;
|
|
461
|
+
const step = this.storage.getRunningAiTeamStepBySession(event.sessionId);
|
|
462
|
+
if (!step)
|
|
463
|
+
return;
|
|
464
|
+
if (event.type === "output") {
|
|
465
|
+
if (this.outputTimers.has(event.sessionId))
|
|
466
|
+
return;
|
|
467
|
+
const timer = setTimeout(() => {
|
|
468
|
+
this.outputTimers.delete(event.sessionId);
|
|
469
|
+
void this.enqueue(step.runId, () => this.evaluate(step.runId));
|
|
470
|
+
}, OUTPUT_DEBOUNCE_MS);
|
|
471
|
+
timer.unref?.();
|
|
472
|
+
this.outputTimers.set(event.sessionId, timer);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
void this.enqueue(step.runId, () => this.evaluate(step.runId));
|
|
476
|
+
}
|
|
477
|
+
/** 服务启动时调用:把重启前未结束的运行接回来,并开始兜底巡检。 */
|
|
478
|
+
reconcile() {
|
|
479
|
+
for (const run of this.storage.listAiTeamRuns({ statuses: ["running"] })) {
|
|
480
|
+
void this.enqueue(run.id, async () => {
|
|
481
|
+
const current = this.storage.getAiTeamRun(run.id);
|
|
482
|
+
if (!current || current.status !== "running")
|
|
483
|
+
return;
|
|
484
|
+
const running = this.storage.listAiTeamSteps(current.id).filter((step) => step.status === "running");
|
|
485
|
+
for (const step of running) {
|
|
486
|
+
if (step.sessionId && this.ops.snapshot(step.sessionId))
|
|
487
|
+
continue;
|
|
488
|
+
await this.finishStep(current, step, { kind: "failed", text: "服务重启后会话丢失" }, false);
|
|
489
|
+
}
|
|
490
|
+
await this.evaluate(current.id);
|
|
491
|
+
const latest = this.storage.getAiTeamRun(current.id);
|
|
492
|
+
if (latest?.status === "running")
|
|
493
|
+
await this.advance(latest);
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
this.startSweep();
|
|
497
|
+
}
|
|
498
|
+
/** 兜底巡检:PTY 成员写完报告后可能不再产生事件。 */
|
|
499
|
+
startSweep() {
|
|
500
|
+
if (this.sweepTimer)
|
|
501
|
+
return;
|
|
502
|
+
this.sweepTimer = setInterval(() => {
|
|
503
|
+
for (const run of this.storage.listAiTeamRuns({ statuses: ["running"] })) {
|
|
504
|
+
void this.enqueue(run.id, () => this.evaluate(run.id));
|
|
505
|
+
}
|
|
506
|
+
}, SWEEP_INTERVAL_MS);
|
|
507
|
+
this.sweepTimer.unref?.();
|
|
508
|
+
}
|
|
509
|
+
dispose() {
|
|
510
|
+
if (this.sweepTimer)
|
|
511
|
+
clearInterval(this.sweepTimer);
|
|
512
|
+
this.sweepTimer = null;
|
|
513
|
+
for (const timer of this.outputTimers.values())
|
|
514
|
+
clearTimeout(timer);
|
|
515
|
+
for (const timer of this.recheckTimers.values())
|
|
516
|
+
clearTimeout(timer);
|
|
517
|
+
this.outputTimers.clear();
|
|
518
|
+
this.recheckTimers.clear();
|
|
519
|
+
this.noDegrade.clear();
|
|
520
|
+
}
|
|
521
|
+
/** 等当前排队的所有操作跑完(测试用)。 */
|
|
522
|
+
async idle() {
|
|
523
|
+
while (this.chains.size > 0) {
|
|
524
|
+
await Promise.all([...this.chains.values()]);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
// ── 内部:串行化 ──
|
|
528
|
+
enqueue(runId, task) {
|
|
529
|
+
const previous = this.chains.get(runId) ?? Promise.resolve();
|
|
530
|
+
const next = previous.catch(() => undefined).then(task);
|
|
531
|
+
const tracked = next.catch((error) => {
|
|
532
|
+
if (!(error instanceof AiTeamConflictError)) {
|
|
533
|
+
console.error(`[AiTeam] run ${runId}:`, getErrorMessage(error));
|
|
534
|
+
}
|
|
535
|
+
});
|
|
536
|
+
this.chains.set(runId, tracked);
|
|
537
|
+
void tracked.then(() => {
|
|
538
|
+
if (this.chains.get(runId) === tracked)
|
|
539
|
+
this.chains.delete(runId);
|
|
540
|
+
});
|
|
541
|
+
return next;
|
|
542
|
+
}
|
|
543
|
+
async act(runId, action) {
|
|
544
|
+
await this.enqueue(runId, async () => {
|
|
545
|
+
const run = this.storage.getAiTeamRun(runId);
|
|
546
|
+
if (!run)
|
|
547
|
+
throw new Error("团队运行不存在。");
|
|
548
|
+
await action(run);
|
|
549
|
+
});
|
|
550
|
+
return this.detail(runId);
|
|
551
|
+
}
|
|
552
|
+
requireStatus(run, status, message) {
|
|
553
|
+
if (run.status !== status)
|
|
554
|
+
throw new AiTeamConflictError(message);
|
|
555
|
+
}
|
|
556
|
+
// ── 内部:推进 ──
|
|
557
|
+
async evaluate(runId) {
|
|
558
|
+
const run = this.storage.getAiTeamRun(runId);
|
|
559
|
+
if (!run || run.status !== "running")
|
|
560
|
+
return;
|
|
561
|
+
const running = this.storage.listAiTeamSteps(run.id).filter((item) => item.status === "running");
|
|
562
|
+
let finished = false;
|
|
563
|
+
for (const step of running) {
|
|
564
|
+
const outcome = this.stepOutcome(run, step);
|
|
565
|
+
if (outcome.kind === "pending") {
|
|
566
|
+
if (outcome.recheckInMs !== undefined)
|
|
567
|
+
this.scheduleRecheck(run.id, outcome.recheckInMs);
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
// 并行成员可能同时交差:先全部记下,最后统一推进一次。
|
|
571
|
+
await this.finishStep(run, step, outcome, false);
|
|
572
|
+
finished = true;
|
|
573
|
+
if (run.status !== "running")
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
if (finished)
|
|
577
|
+
await this.advance(run);
|
|
578
|
+
}
|
|
579
|
+
scheduleRecheck(runId, delayMs) {
|
|
580
|
+
if (this.recheckTimers.has(runId))
|
|
581
|
+
return;
|
|
582
|
+
const timer = setTimeout(() => {
|
|
583
|
+
this.recheckTimers.delete(runId);
|
|
584
|
+
void this.enqueue(runId, () => this.evaluate(runId));
|
|
585
|
+
}, delayMs);
|
|
586
|
+
timer.unref?.();
|
|
587
|
+
this.recheckTimers.set(runId, timer);
|
|
588
|
+
}
|
|
589
|
+
stepOutcome(run, step) {
|
|
590
|
+
if (!step.sessionId)
|
|
591
|
+
return { kind: "pending" };
|
|
592
|
+
const snapshot = this.ops.snapshot(step.sessionId);
|
|
593
|
+
if (!snapshot)
|
|
594
|
+
return { kind: "failed", text: "会话不存在" };
|
|
595
|
+
const state = activityState(snapshot);
|
|
596
|
+
const gone = isSessionGone(snapshot);
|
|
597
|
+
const busy = !gone && (state === "needs_permission"
|
|
598
|
+
|| state === "needs_input"
|
|
599
|
+
|| snapshot.structuredState?.inFlight === true
|
|
600
|
+
|| (snapshot.queuedMessages?.length ?? 0) > 0
|
|
601
|
+
|| snapshot.ptyBusy === true);
|
|
602
|
+
const reportFile = path.join(run.cwd, step.reportPath);
|
|
603
|
+
if (existsSync(reportFile)) {
|
|
604
|
+
const age = this.now() - statSync(reportFile).mtimeMs;
|
|
605
|
+
if (!gone && age < REPORT_SETTLE_MS)
|
|
606
|
+
return { kind: "pending", recheckInMs: REPORT_SETTLE_MS - age + 100 };
|
|
607
|
+
// 结构化会话还在说话时先等它结束:文件可能还会被改写。
|
|
608
|
+
if (!busy || this.ops.ownerOf(step.sessionId) === "pty") {
|
|
609
|
+
return { kind: "done", text: readReport(reportFile), fromFile: true };
|
|
610
|
+
}
|
|
611
|
+
return { kind: "pending" };
|
|
612
|
+
}
|
|
613
|
+
if (busy)
|
|
614
|
+
return { kind: "pending" };
|
|
615
|
+
const reply = snapshot.messages?.length ? assistantReplyAfterPrompt(snapshot, step.reportPath) : null;
|
|
616
|
+
if (gone) {
|
|
617
|
+
// 出错退出时最后一条回复往往就是错误本身(如 API 503),不能当成报告或决定。
|
|
618
|
+
if (reply && snapshot.status !== "failed")
|
|
619
|
+
return { kind: "done", text: reply, fromFile: false };
|
|
620
|
+
return {
|
|
621
|
+
kind: "failed",
|
|
622
|
+
text: this.cleanError(snapshot.structuredState?.lastError) || "会话已结束,未交付报告",
|
|
623
|
+
sessionError: snapshot.status === "failed",
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
if (this.ops.ownerOf(step.sessionId) === "structured" && reply !== null) {
|
|
627
|
+
return { kind: "done", text: reply, fromFile: false };
|
|
628
|
+
}
|
|
629
|
+
return { kind: "pending" };
|
|
630
|
+
}
|
|
631
|
+
/** proceed=false 时只记结果不推进,由调用方在记完一批之后统一 advance。 */
|
|
632
|
+
async finishStep(run, step, outcome, proceed = true) {
|
|
633
|
+
const endedAt = this.iso();
|
|
634
|
+
// §3.4 触发点 2(异步主通道):必须在 stepsUsed 递增之前判,否则首个失败候选白扣一步预算。
|
|
635
|
+
if (step.kind === "work" && outcome.kind === "failed" && outcome.sessionError
|
|
636
|
+
&& !this.noDegrade.has(step.id)) {
|
|
637
|
+
const failure = this.asyncStartupFailure(run, step, outcome.text);
|
|
638
|
+
if (failure) {
|
|
639
|
+
await this.degradeWorkStep(run, step, failure, proceed);
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
run.stepsUsed += 1;
|
|
644
|
+
if (step.kind === "work") {
|
|
645
|
+
const report = outcome.text.trim() || (outcome.kind === "done" ? "(成员没有写报告)" : "失败");
|
|
646
|
+
this.saveStep({ ...step, status: outcome.kind === "done" ? "done" : "failed", report, endedAt });
|
|
647
|
+
const member = this.member(run.team, step.memberId);
|
|
648
|
+
if (member) {
|
|
649
|
+
this.postTurn(run, {
|
|
650
|
+
role: "assistant",
|
|
651
|
+
author: chatAuthor(member, step.sessionId, this.actualAgent(member, step)?.provider),
|
|
652
|
+
content: chatText(`${outcome.kind === "done" ? "✅ 完成" : "❌ 没完成"}「${step.title}」\n\n${report}`),
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
this.saveRun(run);
|
|
656
|
+
if (proceed)
|
|
657
|
+
await this.advance(run);
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
// 负责人的模型报错时停下来等用户(换模型或稍后回复即可接着来),不当成格式错误空转重试。
|
|
661
|
+
const leader = leaderOf(run.team);
|
|
662
|
+
if (outcome.kind === "failed" && outcome.sessionError) {
|
|
663
|
+
this.saveStep({ ...step, status: "failed", report: outcome.text, endedAt });
|
|
664
|
+
this.postTurn(run, {
|
|
665
|
+
role: "assistant", notice: true, author: chatAuthor(leader, step.sessionId),
|
|
666
|
+
content: chatText(`负责人的模型出错:${outcome.text}\n可以去团队页换个模型,然后在群里回复一句让团队继续。`),
|
|
667
|
+
});
|
|
668
|
+
this.setStatus(run, "waiting_user", `负责人出错:${outcome.text}`);
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
// 会话丢失和回复格式错误走同一条有限重试:会话没了就新开一个并补上团队背景。
|
|
672
|
+
const parsed = outcome.kind === "failed"
|
|
673
|
+
? { ok: false, error: `负责人会话没有交付决定(${outcome.text})。` }
|
|
674
|
+
: parseLeaderDecision(outcome.text, run.team);
|
|
675
|
+
if (!parsed.ok) {
|
|
676
|
+
this.saveStep({ ...step, status: "failed", report: parsed.error, endedAt });
|
|
677
|
+
if (run.formatRetries >= MAX_FORMAT_RETRIES) {
|
|
678
|
+
this.postNotice(run, `负责人连续几次回复都没按约定格式,团队先停下。在群里回复一句让他重新安排。`, step.sessionId);
|
|
679
|
+
this.setStatus(run, "waiting_user", outcome.kind === "failed" ? parsed.error : "Leader 回复格式多次不正确");
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
run.formatRetries += 1;
|
|
683
|
+
this.postNotice(run, `负责人的回复没能读懂,让他重答(${run.formatRetries}/${MAX_FORMAT_RETRIES})`, step.sessionId);
|
|
684
|
+
this.saveRun(run);
|
|
685
|
+
const retry = this.createLeaderStep(run, "重新回复");
|
|
686
|
+
await this.dispatchStep(run, retry, (reportPath, fresh) => (buildLeaderFormatRetryPrompt(run, parsed.error, reportPath, fresh, this.chatHistoryFor(run))));
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
run.formatRetries = 0;
|
|
690
|
+
const decision = parsed.decision;
|
|
691
|
+
this.saveStep({ ...step, status: "done", report: decision.message, endedAt });
|
|
692
|
+
this.postLeaderDecision(run, step, decision);
|
|
693
|
+
if (decision.action === "ask") {
|
|
694
|
+
this.setStatus(run, "waiting_user", decision.message);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
if (decision.action === "finish") {
|
|
698
|
+
this.setStatus(run, "done", decision.message);
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
let seq = this.nextSeq(run.id);
|
|
702
|
+
const ids = decision.steps.map(() => randomUUID());
|
|
703
|
+
for (const [index, assigned] of decision.steps.entries()) {
|
|
704
|
+
this.saveStep({
|
|
705
|
+
id: ids[index],
|
|
706
|
+
runId: run.id,
|
|
707
|
+
seq,
|
|
708
|
+
kind: "work",
|
|
709
|
+
memberId: assigned.memberId,
|
|
710
|
+
title: assigned.title,
|
|
711
|
+
instructions: assigned.instructions,
|
|
712
|
+
sessionId: null,
|
|
713
|
+
status: "queued",
|
|
714
|
+
report: "",
|
|
715
|
+
reportPath: aiTeamReportPath(run.id, seq, "work", assigned.memberId),
|
|
716
|
+
dependsOn: assigned.after.map((position) => ids[position]),
|
|
717
|
+
startedAt: null,
|
|
718
|
+
endedAt: null,
|
|
719
|
+
});
|
|
720
|
+
seq += 1;
|
|
721
|
+
}
|
|
722
|
+
if (!run.planApproved) {
|
|
723
|
+
this.setStatus(run, "awaiting_approval", decision.message);
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
this.saveRun(run);
|
|
727
|
+
await this.advance(run);
|
|
728
|
+
}
|
|
729
|
+
/**
|
|
730
|
+
* 派出所有已就绪的成员步骤:依赖都已完成、且该成员手上没有活。不同成员并行,同一成员串行。
|
|
731
|
+
* 有成员失败、队列清空或依赖再也满足不了时,等在跑的都结束后交回 Leader。
|
|
732
|
+
*/
|
|
733
|
+
async advance(run) {
|
|
734
|
+
if (run.status !== "running")
|
|
735
|
+
return;
|
|
736
|
+
const steps = this.storage.listAiTeamSteps(run.id);
|
|
737
|
+
const running = steps.filter((step) => step.status === "running");
|
|
738
|
+
if (running.some((step) => step.kind === "leader"))
|
|
739
|
+
return;
|
|
740
|
+
const lastLeaderSeq = Math.max(0, ...steps.filter((step) => step.kind === "leader" && step.status !== "queued").map((step) => step.seq));
|
|
741
|
+
const failedSinceLeader = steps.some((step) => step.kind === "work" && step.seq > lastLeaderSeq && step.status === "failed");
|
|
742
|
+
const queued = steps.filter((step) => step.status === "queued");
|
|
743
|
+
if (queued.some((step) => step.kind === "leader")) {
|
|
744
|
+
// 重启时可能留下一个还没发出的 Leader 步骤:直接重开一轮。
|
|
745
|
+
if (running.length === 0)
|
|
746
|
+
await this.startLeaderRound(run);
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
if (!failedSinceLeader) {
|
|
750
|
+
const byId = new Map(steps.map((step) => [step.id, step]));
|
|
751
|
+
const busyMembers = new Set(running.map((step) => step.memberId));
|
|
752
|
+
let inFlight = running.length;
|
|
753
|
+
for (const next of queued) {
|
|
754
|
+
if (busyMembers.has(next.memberId))
|
|
755
|
+
continue;
|
|
756
|
+
if (!next.dependsOn.every((id) => byId.get(id)?.status === "done"))
|
|
757
|
+
continue;
|
|
758
|
+
if (run.stepsUsed + inFlight >= run.stepLimit)
|
|
759
|
+
break;
|
|
760
|
+
const member = this.member(run.team, next.memberId);
|
|
761
|
+
busyMembers.add(next.memberId);
|
|
762
|
+
inFlight += 1;
|
|
763
|
+
if (!member) {
|
|
764
|
+
await this.finishStep(run, next, { kind: "failed", text: "成员不存在" }, false);
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
await this.dispatchStep(run, next, (_reportPath, fresh) => (buildMemberPrompt(run, next, member, fresh, this.memberUpstream(run, next), this.chatHistoryFor(run))), false);
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
// 还有人在干活就等;否则(队列清空、有人失败、依赖落空或步数用完)交回 Leader,
|
|
771
|
+
// 步数用完时 startLeaderRound 会停下来等用户。
|
|
772
|
+
if (this.storage.listAiTeamSteps(run.id).some((step) => step.status === "running"))
|
|
773
|
+
return;
|
|
774
|
+
await this.startLeaderRound(run);
|
|
775
|
+
}
|
|
776
|
+
/** 开一轮 Leader:把上一轮 Leader 之后结束的成员报告交给他。 */
|
|
777
|
+
async startLeaderRound(run, userNote) {
|
|
778
|
+
if (!this.hasBudget(run))
|
|
779
|
+
return;
|
|
780
|
+
// 成员干活时用户在群里说的话,这一轮一起交给负责人。
|
|
781
|
+
if (run.pendingNotes.length > 0) {
|
|
782
|
+
userNote = [...run.pendingNotes, userNote ?? ""].filter(Boolean).join("\n\n");
|
|
783
|
+
run.pendingNotes = [];
|
|
784
|
+
this.saveRun(run);
|
|
785
|
+
}
|
|
786
|
+
const steps = this.storage.listAiTeamSteps(run.id);
|
|
787
|
+
// Leader 重新安排时,之前排队的步骤作废。
|
|
788
|
+
const endedAt = this.iso();
|
|
789
|
+
for (const step of steps) {
|
|
790
|
+
if (step.status === "queued")
|
|
791
|
+
this.saveStep({ ...step, status: "skipped", endedAt });
|
|
792
|
+
}
|
|
793
|
+
const lastLeaderSeq = Math.max(0, ...steps.filter((step) => step.kind === "leader" && step.status === "done").map((step) => step.seq));
|
|
794
|
+
const finished = steps
|
|
795
|
+
.filter((step) => step.kind === "work" && step.seq > lastLeaderSeq && (step.status === "done" || step.status === "failed"))
|
|
796
|
+
.map((step) => ({ step, memberName: this.member(run.team, step.memberId)?.name ?? step.memberId }));
|
|
797
|
+
const step = this.createLeaderStep(run, userNote ? "回应用户" : "安排下一步");
|
|
798
|
+
const handoffPath = this.writeHandoff(run, step.seq, "leader", finished.map(({ step: item, memberName }) => this.handoffEntry(run, item, memberName)));
|
|
799
|
+
await this.dispatchStep(run, step, (reportPath, fresh) => (buildLeaderFollowupPrompt(run, finished, reportPath, userNote, fresh, handoffPath, this.chatHistoryFor(run))));
|
|
800
|
+
}
|
|
801
|
+
/** 一条交接条目:元信息 + 报告全文(全文只进交接文件,不进提示词)。 */
|
|
802
|
+
handoffEntry(run, step, memberName) {
|
|
803
|
+
return {
|
|
804
|
+
seq: step.seq,
|
|
805
|
+
title: step.title,
|
|
806
|
+
memberName: memberName ?? this.member(run.team, step.memberId)?.name ?? step.memberId,
|
|
807
|
+
status: step.status,
|
|
808
|
+
reportPath: step.reportPath,
|
|
809
|
+
report: step.report,
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* 成员步骤的上游交接:把依赖步骤的报告写成一个交接文件,提示词只给路径。
|
|
814
|
+
* 没有依赖、或写文件失败时返回 null(提示词仍逐行列出上游报告文件)。
|
|
815
|
+
*/
|
|
816
|
+
memberUpstream(run, step) {
|
|
817
|
+
if (step.dependsOn.length === 0)
|
|
818
|
+
return null;
|
|
819
|
+
const byId = new Map(this.storage.listAiTeamSteps(run.id).map((item) => [item.id, item]));
|
|
820
|
+
const deps = step.dependsOn
|
|
821
|
+
.map((id) => byId.get(id))
|
|
822
|
+
.filter((item) => Boolean(item));
|
|
823
|
+
if (deps.length === 0)
|
|
824
|
+
return null;
|
|
825
|
+
const entries = deps.map((item) => this.handoffEntry(run, item));
|
|
826
|
+
const handoffPath = this.writeHandoff(run, step.seq, "work", entries);
|
|
827
|
+
if (!handoffPath)
|
|
828
|
+
return null;
|
|
829
|
+
return {
|
|
830
|
+
handoffPath,
|
|
831
|
+
steps: entries.map(({ seq, title, memberName, status, reportPath }) => ({ seq, title, memberName, status, reportPath })),
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
/**
|
|
835
|
+
* 同一个群聊里接着开新运行时的续跑交接(§4.4 同一套文件交接):把之前几轮的步骤摘要
|
|
836
|
+
* 与群聊原文写进 `.wand-team/<runId>/chat-history.md`,提示词只给路径。
|
|
837
|
+
* 不是续跑(没有群聊会话、之前没有运行、群聊也还是空的)返回 null,提示词就不带这一段。
|
|
838
|
+
*/
|
|
839
|
+
writeChatHistory(run) {
|
|
840
|
+
const chatSessionId = run.chatSessionId;
|
|
841
|
+
if (!chatSessionId)
|
|
842
|
+
return null;
|
|
843
|
+
// 库按 created_at DESC 返回,换回时间正序,读的人看到的是「先做了什么、后做了什么」。
|
|
844
|
+
const previous = this.storage.listAiTeamRuns({ taskId: run.taskId })
|
|
845
|
+
.filter((item) => item.id !== run.id && item.chatSessionId === chatSessionId)
|
|
846
|
+
.reverse();
|
|
847
|
+
const turns = this.ops.snapshot(chatSessionId)?.messages ?? [];
|
|
848
|
+
if (previous.length === 0 && turns.length === 0)
|
|
849
|
+
return null;
|
|
850
|
+
const runs = previous.map((item) => ({
|
|
851
|
+
status: item.status,
|
|
852
|
+
steps: this.storage.listAiTeamSteps(item.id).map((step) => ({
|
|
853
|
+
seq: step.seq,
|
|
854
|
+
memberName: this.member(run.team, step.memberId)?.name ?? step.memberId,
|
|
855
|
+
title: step.title,
|
|
856
|
+
status: step.status,
|
|
857
|
+
})),
|
|
858
|
+
}));
|
|
859
|
+
const relative = aiTeamChatHistoryPath(run.id);
|
|
860
|
+
try {
|
|
861
|
+
const file = path.join(run.cwd, relative);
|
|
862
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
863
|
+
writeFileSync(file, renderChatHistoryFile({ runs, turns, generatedAt: this.iso() }), "utf8");
|
|
864
|
+
return relative;
|
|
865
|
+
}
|
|
866
|
+
catch (error) {
|
|
867
|
+
console.error(`[AiTeam] write chat history ${relative} failed:`, getErrorMessage(error));
|
|
868
|
+
return null;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* 这次运行要接着的群聊上下文文件;文件不在(不是续跑、或写失败)就给 null。
|
|
873
|
+
* 续跑标记就落在文件本身,所以服务重启后同一路径依旧成立,不用额外存一列。
|
|
874
|
+
*/
|
|
875
|
+
chatHistoryFor(run) {
|
|
876
|
+
const relative = aiTeamChatHistoryPath(run.id);
|
|
877
|
+
return existsSync(path.join(run.cwd, relative)) ? relative : null;
|
|
878
|
+
}
|
|
879
|
+
/** 交接文件写到报告目录;失败只记日志,不阻断派发。 */
|
|
880
|
+
writeHandoff(run, seq, kind, entries) {
|
|
881
|
+
if (entries.length === 0)
|
|
882
|
+
return null;
|
|
883
|
+
const relative = aiTeamHandoffPath(run.id, seq, kind);
|
|
884
|
+
try {
|
|
885
|
+
const file = path.join(run.cwd, relative);
|
|
886
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
887
|
+
writeFileSync(file, renderHandoffFile(entries, this.iso()), "utf8");
|
|
888
|
+
return relative;
|
|
889
|
+
}
|
|
890
|
+
catch (error) {
|
|
891
|
+
console.error(`[AiTeam] write handoff ${relative} failed:`, getErrorMessage(error));
|
|
892
|
+
return null;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
hasBudget(run) {
|
|
896
|
+
if (run.stepsUsed < run.stepLimit)
|
|
897
|
+
return true;
|
|
898
|
+
this.postNotice(run, `已用完 ${run.stepLimit} 步的上限,团队先停下。回复一句让团队继续,或在任务面板里加步数。`);
|
|
899
|
+
this.setStatus(run, "waiting_user", "已达到步数上限");
|
|
900
|
+
return false;
|
|
901
|
+
}
|
|
902
|
+
createLeaderStep(run, title) {
|
|
903
|
+
const seq = this.nextSeq(run.id);
|
|
904
|
+
const leader = leaderOf(run.team);
|
|
905
|
+
const step = {
|
|
906
|
+
id: randomUUID(),
|
|
907
|
+
runId: run.id,
|
|
908
|
+
seq,
|
|
909
|
+
kind: "leader",
|
|
910
|
+
memberId: leader.id,
|
|
911
|
+
title,
|
|
912
|
+
instructions: "",
|
|
913
|
+
sessionId: null,
|
|
914
|
+
status: "queued",
|
|
915
|
+
report: "",
|
|
916
|
+
reportPath: aiTeamReportPath(run.id, seq, "leader", leader.id),
|
|
917
|
+
dependsOn: [],
|
|
918
|
+
startedAt: null,
|
|
919
|
+
endedAt: null,
|
|
920
|
+
};
|
|
921
|
+
this.saveStep(step);
|
|
922
|
+
return step;
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* 派出一个步骤:先把步骤标成 running 再发送;同一成员的活会话复用(上下文延续)。
|
|
926
|
+
* 提示词分两段:`system`(角色与规则)只在新建会话时交给系统提示通道,`message` 是本轮用户消息。
|
|
927
|
+
*/
|
|
928
|
+
async dispatchStep(run, step, buildPrompt, proceed = true) {
|
|
929
|
+
const member = this.member(run.team, step.memberId);
|
|
930
|
+
const candidates = member ? memberAgents(member) : [];
|
|
931
|
+
const info = this.infoOf(step);
|
|
932
|
+
const agent = candidates[info.usedCandidate];
|
|
933
|
+
// §3.4 触发点 1 的事前分支:当前候选已被拉黑、候选列表被改到没有这一档,或快照就绪且模型
|
|
934
|
+
// 明确不在清单里。三者都走 degradeWorkStep 留痕,不在这里静默换候选。
|
|
935
|
+
if (member && step.kind === "work") {
|
|
936
|
+
const blocked = agent
|
|
937
|
+
? this.blockedCandidate(run.id, agent)
|
|
938
|
+
: { kind: "runtime-failure", reason: `候选列表已变更,没有候选 ${info.usedCandidate + 1}` };
|
|
939
|
+
const unknown = blocked === null && agent.kind === "structured"
|
|
940
|
+
&& isModelUnknownBeforeDispatch(agent, this.modelsOf?.(agent.provider))
|
|
941
|
+
? { kind: "model-unknown", reason: `模型 ${agent.model} 不在 ${agent.provider} 已发现的模型清单里` }
|
|
942
|
+
: null;
|
|
943
|
+
const pre = blocked ?? unknown;
|
|
944
|
+
if (pre) {
|
|
945
|
+
await this.degradeWorkStep(run, step, pre, proceed);
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
const reusable = member ? this.liveSessionFor(run, member, step.id, agent) : null;
|
|
950
|
+
const prompt = buildPrompt(step.reportPath, reusable === null);
|
|
951
|
+
const startedAt = this.iso();
|
|
952
|
+
let running = { ...step, instructions: step.kind === "leader" ? prompt.message : step.instructions, status: "running", startedAt, sessionId: reusable };
|
|
953
|
+
this.saveStep(running);
|
|
954
|
+
this.notify(run);
|
|
955
|
+
if (member && step.kind === "work") {
|
|
956
|
+
this.postNotice(run, `${member.name} 开始「${step.title}」`, reusable, member, agent?.provider);
|
|
957
|
+
}
|
|
958
|
+
try {
|
|
959
|
+
if (!member)
|
|
960
|
+
throw new Error("成员不存在");
|
|
961
|
+
if (!agent)
|
|
962
|
+
throw new Error("成员没有配置可用的 CLI 候选");
|
|
963
|
+
if (reusable) {
|
|
964
|
+
await this.ops.send(reusable, prompt.message);
|
|
965
|
+
}
|
|
966
|
+
else {
|
|
967
|
+
const task = this.storage.getWandTask(run.taskId);
|
|
968
|
+
if (!task)
|
|
969
|
+
throw new Error("任务卡已被删除");
|
|
970
|
+
const sessionId = await this.ops.open({
|
|
971
|
+
task,
|
|
972
|
+
agent,
|
|
973
|
+
prompt: prompt.message,
|
|
974
|
+
automationId: `ai-team:${run.id}`,
|
|
975
|
+
systemPrompt: prompt.system,
|
|
976
|
+
});
|
|
977
|
+
running = { ...running, sessionId };
|
|
978
|
+
this.saveStep(running);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
catch (error) {
|
|
982
|
+
const message = getErrorMessage(error);
|
|
983
|
+
const kind = classifyCandidateFailure(message);
|
|
984
|
+
if (agent && kind === "host-disabled") {
|
|
985
|
+
// 进程级信号(agent-dispatch 的「当前服务未启用 … 会话」):本次运行该 kind 的候选全部不再
|
|
986
|
+
// 尝试,也不能当成「不可降级」直接吞掉——记进黑名单后交给既有失败出口。
|
|
987
|
+
this.disableHostKind(run.id, agent.kind);
|
|
988
|
+
}
|
|
989
|
+
else if (agent && step.kind === "work" && kind === "spawn-missing"
|
|
990
|
+
&& !existsSync(path.join(run.cwd, step.reportPath)) && !this.noDegrade.has(step.id)) {
|
|
991
|
+
// §3.4 触发点 1:会话根本没起来,ENOENT 对 structured / pty 同样可靠(修正 B4 排除的是
|
|
992
|
+
// PTY 的异步分类)。传 `running` 而不是入参 `step`:复用会话时 sessionId 只在 running 上。
|
|
993
|
+
// 候选边界与黑名单由 degradeWorkStep 自己判,耗尽时它走 failed 出口。
|
|
994
|
+
await this.degradeWorkStep(run, running, { kind, reason: message }, proceed);
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
await this.finishStep(run, running, { kind: "failed", text: `派发失败:${this.redact(message)}` }, proceed);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* 换候选 = 开新步(§3.4 五步),与 Leader 的格式重试同构。全程在调用方的 enqueue 链里;
|
|
1002
|
+
* 只直接派出 A′,**不整轮 advance**(N1:advance 的 queued/byId/busyMembers 是进循环时的快照,
|
|
1003
|
+
* 整轮调整会在同一栈里对同一步二次派发)。`proceed` 原样透传给 A′ 的 dispatchStep。
|
|
1004
|
+
*/
|
|
1005
|
+
async degradeWorkStep(run, step, failure, proceed) {
|
|
1006
|
+
const info = this.infoOf(step);
|
|
1007
|
+
const member = this.member(run.team, step.memberId);
|
|
1008
|
+
const candidates = member ? memberAgents(member) : [];
|
|
1009
|
+
const agent = candidates[info.usedCandidate];
|
|
1010
|
+
// 三条流(群聊 notice / 旧步 report / dispatch_info_json.skipped[].reason)共用这一个出口,
|
|
1011
|
+
// 先脱敏再截断:CLI 原文常带 /Users/<user>/… 与 token=…,落库前必须收掉(§4.5)。
|
|
1012
|
+
const reason = this.cleanError(failure.reason) || failure.kind;
|
|
1013
|
+
const skipped = [
|
|
1014
|
+
...info.skipped,
|
|
1015
|
+
...(agent ? [{ candidate: info.usedCandidate, agent, reason, errorKind: failure.kind }] : []),
|
|
1016
|
+
];
|
|
1017
|
+
const next = info.usedCandidate + 1;
|
|
1018
|
+
const tail = candidates.slice(next);
|
|
1019
|
+
// 级联深度天然以候选上限(4)为界:每降一级 usedCandidate +1。耗尽时退回既有 failed 出口,
|
|
1020
|
+
// 交回 Leader 决定 ask/finish,不另设 waiting_user 捷径、不循环。
|
|
1021
|
+
if (!member || !agent || tail.length === 0 || tail.every((item) => this.blockedCandidate(run.id, item))) {
|
|
1022
|
+
const detail = skipped.map((item) => `候选 ${item.candidate + 1}(${item.reason})`).join("、");
|
|
1023
|
+
this.noDegrade.add(step.id);
|
|
1024
|
+
try {
|
|
1025
|
+
await this.finishStep(run, step, {
|
|
1026
|
+
kind: "failed",
|
|
1027
|
+
sessionError: true,
|
|
1028
|
+
text: `${member?.name ?? "该成员"}的所有候选均不可用:${detail || reason}`,
|
|
1029
|
+
}, proceed);
|
|
1030
|
+
}
|
|
1031
|
+
finally {
|
|
1032
|
+
this.noDegrade.delete(step.id);
|
|
1033
|
+
}
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
// ① 安全阀:判错也不能让两个 CLI 同时写同一个工作目录。
|
|
1037
|
+
if (step.sessionId) {
|
|
1038
|
+
const previous = this.ops.snapshot(step.sessionId);
|
|
1039
|
+
if (previous && !isSessionGone(previous)) {
|
|
1040
|
+
try {
|
|
1041
|
+
this.ops.stop(step.sessionId);
|
|
1042
|
+
}
|
|
1043
|
+
catch (error) {
|
|
1044
|
+
// stop 抛错不做兜底会让旧步卡在 running,这里只记日志继续记账。
|
|
1045
|
+
console.error(`[AiTeam] stop degraded session ${step.sessionId} failed:`, getErrorMessage(error));
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
// ② 旧步只留痕:skipped 不占 stepsUsed(走 finishStep 会记 failed 并扣预算)。
|
|
1050
|
+
// 跳过链随这一步一起落 dispatch_info_json,重启后恢复链不会从候选 1 重来。
|
|
1051
|
+
this.saveStep({
|
|
1052
|
+
...step,
|
|
1053
|
+
status: "skipped",
|
|
1054
|
+
report: `候选 ${info.usedCandidate + 1} 不可用:${reason}`,
|
|
1055
|
+
endedAt: this.iso(),
|
|
1056
|
+
dispatchInfo: { ...info, skipped },
|
|
1057
|
+
});
|
|
1058
|
+
// ③ 新步:同成员、同标题、同指令,继承 dependsOn;新 seq → reportPath 天然不同,
|
|
1059
|
+
// 不会撞上上一候选留在原地的半截报告文件。
|
|
1060
|
+
const seq = this.nextSeq(run.id);
|
|
1061
|
+
const replacement = {
|
|
1062
|
+
id: randomUUID(),
|
|
1063
|
+
runId: run.id,
|
|
1064
|
+
seq,
|
|
1065
|
+
kind: "work",
|
|
1066
|
+
memberId: step.memberId,
|
|
1067
|
+
title: step.title,
|
|
1068
|
+
instructions: step.instructions,
|
|
1069
|
+
sessionId: null,
|
|
1070
|
+
status: "queued",
|
|
1071
|
+
report: "",
|
|
1072
|
+
reportPath: aiTeamReportPath(run.id, seq, "work", step.memberId),
|
|
1073
|
+
dependsOn: step.dependsOn,
|
|
1074
|
+
startedAt: null,
|
|
1075
|
+
endedAt: null,
|
|
1076
|
+
dispatchInfo: { usedCandidate: next, skipped },
|
|
1077
|
+
};
|
|
1078
|
+
this.saveStep(replacement);
|
|
1079
|
+
// 群聊降级行(§5.3/B15):一次降级一行,署名用切过去的新候选;事前 model-unknown 跳过
|
|
1080
|
+
// 同样走 degradeWorkStep,所以这里也覆盖它。reason 已经过脱敏 + 头尾截断(§4.5)。
|
|
1081
|
+
this.postNotice(run, `⚠️ ${member.name} 的首选配置不可用(${reason}),已切换到候选 ${next + 1}`, null, member, candidates[next]?.provider);
|
|
1082
|
+
// ④ 依赖重指向(R1):排队步骤等的仍是「这件事做完」,只是换了承载候选的步骤。
|
|
1083
|
+
// running / done 的历史步骤不改写。
|
|
1084
|
+
for (const queued of this.storage.listAiTeamSteps(run.id)) {
|
|
1085
|
+
if (queued.status !== "queued" || !queued.dependsOn.includes(step.id))
|
|
1086
|
+
continue;
|
|
1087
|
+
this.saveStep({
|
|
1088
|
+
...queued,
|
|
1089
|
+
dependsOn: queued.dependsOn.map((id) => (id === step.id ? replacement.id : id)),
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
// ⑤ 黑名单记账(立即写 run_state_json),然后只直接派 A′。
|
|
1093
|
+
this.accountCandidateFailure(run.id, agent, failure.kind);
|
|
1094
|
+
await this.dispatchStep(run, replacement, (reportPath, fresh) => {
|
|
1095
|
+
const target = this.member(run.team, replacement.memberId) ?? member;
|
|
1096
|
+
return buildMemberPrompt(run, replacement, target, fresh, this.memberUpstream(run, replacement), this.chatHistoryFor(run));
|
|
1097
|
+
}, proceed);
|
|
1098
|
+
}
|
|
1099
|
+
/**
|
|
1100
|
+
* 会话「起来又倒下」的启动期归因(§3.4 触发点 2)。判定核在 ai-team-availability,
|
|
1101
|
+
* 这里只负责喂时钟、快照与报告产出;不满足降级条件返回 null,按既有路径记 failed。
|
|
1102
|
+
*/
|
|
1103
|
+
asyncStartupFailure(run, step, outcomeText) {
|
|
1104
|
+
const member = this.member(run.team, step.memberId);
|
|
1105
|
+
if (!member)
|
|
1106
|
+
return null;
|
|
1107
|
+
const info = this.infoOf(step);
|
|
1108
|
+
const candidates = memberAgents(member);
|
|
1109
|
+
const agent = candidates[info.usedCandidate];
|
|
1110
|
+
if (!agent)
|
|
1111
|
+
return null;
|
|
1112
|
+
const snapshot = step.sessionId ? this.ops.snapshot(step.sessionId) : null;
|
|
1113
|
+
// 启动时刻未知时按窗口外处理:宁可交回 Leader,不猜它是启动失败。
|
|
1114
|
+
const startedAt = step.startedAt ? Date.parse(step.startedAt) : Number.NaN;
|
|
1115
|
+
const elapsedMs = Number.isFinite(startedAt) ? Math.max(0, this.now() - startedAt) : AI_TEAM_STARTUP_WINDOW_MS;
|
|
1116
|
+
const errorMessage = snapshot?.structuredState?.lastError ?? outcomeText;
|
|
1117
|
+
const kind = classifyStartupFailure({
|
|
1118
|
+
status: snapshot?.status ?? "failed",
|
|
1119
|
+
hasAssistantReply: Boolean(snapshot?.messages?.some((turn) => turn.role === "assistant")),
|
|
1120
|
+
elapsedMs,
|
|
1121
|
+
errorMessage,
|
|
1122
|
+
});
|
|
1123
|
+
if (!isDegradableWorkFailure({
|
|
1124
|
+
stepKind: step.kind,
|
|
1125
|
+
agentKind: agent.kind,
|
|
1126
|
+
sessionError: true,
|
|
1127
|
+
hasReportOutput: existsSync(path.join(run.cwd, step.reportPath)),
|
|
1128
|
+
failure: kind,
|
|
1129
|
+
usedCandidate: info.usedCandidate,
|
|
1130
|
+
candidateCount: candidates.length,
|
|
1131
|
+
nextCandidateBlocked: this.blockedCandidate(run.id, candidates[info.usedCandidate + 1]) !== null,
|
|
1132
|
+
}))
|
|
1133
|
+
return null;
|
|
1134
|
+
return { kind, reason: errorMessage };
|
|
1135
|
+
}
|
|
1136
|
+
/** run 级黑名单里这一层是谁封的、为什么封(§3.4 修正 B12 两层键值 + host-disabled 进程级)。 */
|
|
1137
|
+
blockedCandidate(runId, agent) {
|
|
1138
|
+
if (!agent)
|
|
1139
|
+
return null;
|
|
1140
|
+
const state = this.stateOf(runId);
|
|
1141
|
+
if (state.hostDisabled.includes(agent.kind)) {
|
|
1142
|
+
return {
|
|
1143
|
+
kind: "host-disabled",
|
|
1144
|
+
reason: `本服务未启用${agent.kind === "pty" ? "终端" : "结构化"}会话,候选 ${agent.provider} 不再尝试`,
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
if (state.providers.includes(agent.provider)) {
|
|
1148
|
+
return { kind: "spawn-missing", reason: `${agent.provider} CLI 不可用` };
|
|
1149
|
+
}
|
|
1150
|
+
const blocked = state.agents.find((item) => item.key === agentKey(agent));
|
|
1151
|
+
if (blocked)
|
|
1152
|
+
return { kind: blocked.kind, reason: `${agent.provider}/${agent.model} 已被标记不可用` };
|
|
1153
|
+
return null;
|
|
1154
|
+
}
|
|
1155
|
+
/** 黑名单从库里读(§3.4):run 不存在或列里是脏值时退化成空黑名单。 */
|
|
1156
|
+
stateOf(runId) {
|
|
1157
|
+
return this.storage.getAiTeamRunState(runId);
|
|
1158
|
+
}
|
|
1159
|
+
/** 每次黑名单变更立即落库,重启后已拉黑的候选与 strikes 计数都还在。 */
|
|
1160
|
+
saveState(runId, state) {
|
|
1161
|
+
this.storage.setAiTeamRunState(runId, state);
|
|
1162
|
+
}
|
|
1163
|
+
/** host-disabled 是进程级信号:本次运行该 kind 的候选全部不再尝试。 */
|
|
1164
|
+
disableHostKind(runId, kind) {
|
|
1165
|
+
const state = this.stateOf(runId);
|
|
1166
|
+
if (state.hostDisabled.includes(kind))
|
|
1167
|
+
return;
|
|
1168
|
+
this.saveState(runId, { ...state, hostDisabled: [...state.hostDisabled, kind] });
|
|
1169
|
+
}
|
|
1170
|
+
/**
|
|
1171
|
+
* 失败候选的黑名单记账(§3.4):spawn-missing 封 provider、model-unknown 直接封五元组、
|
|
1172
|
+
* startup-timeout 累计到 STARTUP_TIMEOUT_STRIKES 才封。runtime-failure 这类不计入,
|
|
1173
|
+
* 一次写完一次落库。
|
|
1174
|
+
*/
|
|
1175
|
+
accountCandidateFailure(runId, agent, kind) {
|
|
1176
|
+
const state = this.stateOf(runId);
|
|
1177
|
+
const key = agentKey(agent);
|
|
1178
|
+
if (kind === "spawn-missing") {
|
|
1179
|
+
if (state.providers.includes(agent.provider))
|
|
1180
|
+
return;
|
|
1181
|
+
this.saveState(runId, { ...state, providers: [...state.providers, agent.provider] });
|
|
1182
|
+
return;
|
|
1183
|
+
}
|
|
1184
|
+
if (kind === "model-unknown") {
|
|
1185
|
+
if (state.agents.some((item) => item.key === key))
|
|
1186
|
+
return;
|
|
1187
|
+
this.saveState(runId, { ...state, agents: [...state.agents, { key, kind }] });
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
if (kind !== "startup-timeout")
|
|
1191
|
+
return;
|
|
1192
|
+
const strikes = Math.min(999, (state.strikes[key] ?? 0) + 1);
|
|
1193
|
+
const agents = strikes >= STARTUP_TIMEOUT_STRIKES && !state.agents.some((item) => item.key === key)
|
|
1194
|
+
? [...state.agents, { key, kind: "startup-timeout" }]
|
|
1195
|
+
: state.agents;
|
|
1196
|
+
this.saveState(runId, { ...state, strikes: { ...state.strikes, [key]: strikes }, agents });
|
|
1197
|
+
}
|
|
1198
|
+
/**
|
|
1199
|
+
* 本步实际用的候选与累计跳过的候选(`dispatch_info_json`);没有记录 = 首选、没跳过过任何候选。
|
|
1200
|
+
* 读的是步骤行自己,重启后同一条恢复链继续从库里接着走。
|
|
1201
|
+
*/
|
|
1202
|
+
infoOf(step) {
|
|
1203
|
+
return step.dispatchInfo ?? { usedCandidate: 0, skipped: [] };
|
|
1204
|
+
}
|
|
1205
|
+
/** 该步实际使用的候选(§3.6 署名口径):候选一律经 memberAgents 读,越界回退首选。 */
|
|
1206
|
+
actualAgent(member, step) {
|
|
1207
|
+
const candidates = memberAgents(member);
|
|
1208
|
+
return candidates[this.infoOf(step).usedCandidate] ?? candidates[0] ?? null;
|
|
1209
|
+
}
|
|
1210
|
+
/**
|
|
1211
|
+
* 运行停下来等用户时,用户可能刚在团队页换了成员的 CLI / 模型(比如额度用尽换一个)。
|
|
1212
|
+
* 接着跑之前换成最新配置;成员被删了或没有负责人时保留原快照。
|
|
1213
|
+
*/
|
|
1214
|
+
refreshTeam(run) {
|
|
1215
|
+
const latest = this.storage.getAiTeam(run.teamId);
|
|
1216
|
+
if (!latest || !latest.members.some((member) => member.isLeader))
|
|
1217
|
+
return;
|
|
1218
|
+
const used = new Set(this.storage.listAiTeamSteps(run.id).map((step) => step.memberId));
|
|
1219
|
+
if ([...used].some((id) => !latest.members.some((member) => member.id === id)))
|
|
1220
|
+
return;
|
|
1221
|
+
run.team = latest;
|
|
1222
|
+
}
|
|
1223
|
+
/**
|
|
1224
|
+
* 同一成员复用还活着的会话。比对基准是该成员**最近一步实际使用的候选**(§3.6),
|
|
1225
|
+
* 降级之后不会因为「和首选不一致」而每步都另开会话;候选换了五元组就不复用。
|
|
1226
|
+
*/
|
|
1227
|
+
liveSessionFor(run, member, excludeStepId, agent) {
|
|
1228
|
+
const steps = this.storage.listAiTeamSteps(run.id);
|
|
1229
|
+
for (let index = steps.length - 1; index >= 0; index -= 1) {
|
|
1230
|
+
const step = steps[index];
|
|
1231
|
+
if (step.id === excludeStepId || step.memberId !== member.id || !step.sessionId)
|
|
1232
|
+
continue;
|
|
1233
|
+
const snapshot = this.ops.snapshot(step.sessionId);
|
|
1234
|
+
if (!snapshot || isSessionGone(snapshot))
|
|
1235
|
+
return null;
|
|
1236
|
+
const candidates = memberAgents(member);
|
|
1237
|
+
const previous = candidates[this.infoOf(step).usedCandidate] ?? candidates[0];
|
|
1238
|
+
if (!previous || (agent && agentKey(previous) !== agentKey(agent)))
|
|
1239
|
+
return null;
|
|
1240
|
+
if (snapshot.provider && snapshot.provider !== previous.provider)
|
|
1241
|
+
return null;
|
|
1242
|
+
const model = previous.model === "default" ? "" : previous.model;
|
|
1243
|
+
if (model && snapshot.selectedModel && snapshot.selectedModel !== model)
|
|
1244
|
+
return null;
|
|
1245
|
+
return step.sessionId;
|
|
1246
|
+
}
|
|
1247
|
+
return null;
|
|
1248
|
+
}
|
|
1249
|
+
// ── 群聊 ──
|
|
1250
|
+
openChat(run, task, objective, chatHistory) {
|
|
1251
|
+
if (!this.chat)
|
|
1252
|
+
return;
|
|
1253
|
+
try {
|
|
1254
|
+
if (!run.chatSessionId) {
|
|
1255
|
+
run.chatSessionId = this.chat.open({ task, run, title: `${run.team.name} · ${task.title}` });
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
catch (error) {
|
|
1259
|
+
console.error(`[AiTeam] open chat for ${run.id} failed:`, getErrorMessage(error));
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
const roster = run.team.members.map((member) => member.isLeader ? `${member.name}(负责人)` : member.name).join("、");
|
|
1263
|
+
if (objective)
|
|
1264
|
+
this.postUser(run, objective);
|
|
1265
|
+
// 续跑时不说「接手」:用户看得出这是同一轮工作往下走,并把交给团队的历史文件摊开给他核。
|
|
1266
|
+
this.postNotice(run, chatHistory
|
|
1267
|
+
? `接着这个群聊里上一轮的进度继续;交给负责人和成员的记录见 ${chatHistory}`
|
|
1268
|
+
: `团队「${run.team.name}」接手了这个任务:${roster}`);
|
|
1269
|
+
}
|
|
1270
|
+
postTurn(run, turn) {
|
|
1271
|
+
if (!this.chat || !run.chatSessionId)
|
|
1272
|
+
return;
|
|
1273
|
+
try {
|
|
1274
|
+
this.chat.post(run.chatSessionId, [turn]);
|
|
1275
|
+
}
|
|
1276
|
+
catch (error) {
|
|
1277
|
+
console.error(`[AiTeam] post to chat ${run.chatSessionId} failed:`, getErrorMessage(error));
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
postUser(run, text) {
|
|
1281
|
+
this.postTurn(run, { role: "user", content: chatText(text) });
|
|
1282
|
+
}
|
|
1283
|
+
postNotice(run, text, sessionId, member, provider) {
|
|
1284
|
+
this.postTurn(run, {
|
|
1285
|
+
role: "assistant", notice: true, content: chatText(text),
|
|
1286
|
+
author: member ? chatAuthor(member, sessionId, provider) : undefined,
|
|
1287
|
+
});
|
|
1288
|
+
}
|
|
1289
|
+
/** 负责人的决定:说明 + 派工清单;等批准 / 等回复时告诉用户在群里怎么接话。 */
|
|
1290
|
+
postLeaderDecision(run, step, decision) {
|
|
1291
|
+
const lines = [decision.message.trim()];
|
|
1292
|
+
if (decision.action === "assign") {
|
|
1293
|
+
const assigned = decision.steps.map((item, index) => {
|
|
1294
|
+
const name = this.member(run.team, item.memberId)?.name ?? item.memberId;
|
|
1295
|
+
const after = item.after.length > 0 ? `(等第 ${item.after.map((at) => at + 1).join("、")} 项完成后)` : "";
|
|
1296
|
+
return `${index + 1}. **@${name}** ${item.title}${after}`;
|
|
1297
|
+
});
|
|
1298
|
+
lines.push(assigned.join("\n"));
|
|
1299
|
+
}
|
|
1300
|
+
this.postTurn(run, {
|
|
1301
|
+
role: "assistant",
|
|
1302
|
+
author: chatAuthor(leaderOf(run.team), step.sessionId),
|
|
1303
|
+
content: chatText(lines.filter(Boolean).join("\n\n")),
|
|
1304
|
+
});
|
|
1305
|
+
if (decision.action === "assign" && !run.planApproved) {
|
|
1306
|
+
this.postNotice(run, "等你批准计划:回复「批准」开始执行,或者直接写修改意见。");
|
|
1307
|
+
}
|
|
1308
|
+
else if (decision.action === "ask") {
|
|
1309
|
+
this.postNotice(run, "负责人在等你回复,直接在群里回答即可。");
|
|
1310
|
+
}
|
|
1311
|
+
else if (decision.action === "finish") {
|
|
1312
|
+
this.postNotice(run, "团队已完成。还有要改的,直接在群里说,团队会接着处理。");
|
|
1313
|
+
}
|
|
1314
|
+
this.postVerifyOverlapNotices(run, decision);
|
|
1315
|
+
}
|
|
1316
|
+
/**
|
|
1317
|
+
* 职责交叉只提示、不拦截(§3.3 修正 B13):同一个成员既实现过又验收时,在群里提一句,
|
|
1318
|
+
* 由用户决定是否让负责人调整。服务端不做任何职责硬校验,步骤照常派发。
|
|
1319
|
+
*/
|
|
1320
|
+
postVerifyOverlapNotices(run, decision) {
|
|
1321
|
+
if (decision.action !== "assign")
|
|
1322
|
+
return;
|
|
1323
|
+
// 此刻本步还没落库,listAiTeamSteps 里只有历史步骤,正好用来判「已经做过实现」。
|
|
1324
|
+
const history = this.storage.listAiTeamSteps(run.id);
|
|
1325
|
+
const notified = new Set();
|
|
1326
|
+
for (const assigned of decision.steps) {
|
|
1327
|
+
if (notified.has(assigned.memberId))
|
|
1328
|
+
continue;
|
|
1329
|
+
const member = this.member(run.team, assigned.memberId);
|
|
1330
|
+
if (!member || member.role !== "verify")
|
|
1331
|
+
continue;
|
|
1332
|
+
const didWork = history.some((step) => step.kind === "work" && step.memberId === member.id && step.status === "done");
|
|
1333
|
+
if (!didWork)
|
|
1334
|
+
continue;
|
|
1335
|
+
notified.add(member.id);
|
|
1336
|
+
this.postNotice(run, `「${member.name}」既做过实现又被安排验收,介意的话在群里说一句让负责人调整。`);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
member(team, memberId) {
|
|
1340
|
+
return team.members.find((member) => member.id === memberId) ?? null;
|
|
1341
|
+
}
|
|
1342
|
+
nextSeq(runId) {
|
|
1343
|
+
const steps = this.storage.listAiTeamSteps(runId);
|
|
1344
|
+
return (steps[steps.length - 1]?.seq ?? 0) + 1;
|
|
1345
|
+
}
|
|
1346
|
+
setStatus(run, status, detail) {
|
|
1347
|
+
run.status = status;
|
|
1348
|
+
run.statusDetail = detail;
|
|
1349
|
+
this.saveRun(run);
|
|
1350
|
+
}
|
|
1351
|
+
saveRun(run) {
|
|
1352
|
+
run.updatedAt = this.iso();
|
|
1353
|
+
this.storage.saveAiTeamRun(run);
|
|
1354
|
+
this.notify(run);
|
|
1355
|
+
}
|
|
1356
|
+
saveStep(step) {
|
|
1357
|
+
this.storage.saveAiTeamStep(step);
|
|
1358
|
+
}
|
|
1359
|
+
notify(run) {
|
|
1360
|
+
try {
|
|
1361
|
+
this.notifyListener?.(run);
|
|
1362
|
+
}
|
|
1363
|
+
catch {
|
|
1364
|
+
// 通知失败不影响已写入的状态;客户端会在下次打开时重新拉取。
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
iso() {
|
|
1368
|
+
return new Date(this.now()).toISOString();
|
|
1369
|
+
}
|
|
1370
|
+
}
|