@co0ontty/wand 4.77.0-beta.g7fb0b70 → 4.77.0-beta.gee68088
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/README.md +20 -8
- package/dist/agent-dispatch.js +3 -2
- package/dist/ai-team-live.d.ts +25 -0
- package/dist/ai-team-live.js +135 -0
- package/dist/ai-team-prompts.d.ts +10 -6
- package/dist/ai-team-prompts.js +71 -41
- package/dist/ai-team-runner.d.ts +40 -3
- package/dist/ai-team-runner.js +200 -23
- package/dist/ai-team-types.d.ts +35 -0
- package/dist/ai-team-types.js +4 -0
- package/dist/build-info.json +3 -3
- package/dist/commit-task-archive.js +14 -18
- package/dist/config.js +14 -6
- package/dist/git-quick-commit.js +1 -1
- package/dist/missions.js +0 -2
- package/dist/models.d.ts +6 -1
- package/dist/models.js +35 -0
- package/dist/path-repair.d.ts +6 -5
- package/dist/path-repair.js +3 -3
- package/dist/server-ai-team-routes.js +18 -14
- package/dist/server-local-preview-routes.js +1 -1
- package/dist/server-session-routes.js +0 -2
- package/dist/server-settings-routes.d.ts +1 -1
- package/dist/server-settings-routes.js +3 -2
- package/dist/server-task-routes.js +16 -32
- package/dist/server-workspace-routes.js +4 -15
- package/dist/server.js +36 -11
- package/dist/storage.d.ts +41 -20
- package/dist/storage.js +318 -104
- package/dist/types.d.ts +7 -0
- package/dist/wand-task-sync.d.ts +2 -34
- package/dist/wand-task-sync.js +0 -206
- package/dist/web-ui/asset-version.d.ts +5 -0
- package/dist/web-ui/asset-version.js +5 -0
- package/dist/web-ui/content/ai-teams.js +8 -8
- package/dist/web-ui/content/scripts.js +66 -66
- package/dist/web-ui/content/styles.css +1 -1
- package/dist/web-ui/content/tailwind.css +0 -21
- package/dist/web-ui/embedded-assets.js +8 -7
- package/dist/web-ui/index.js +9 -17
- package/dist/web-ui/scripts.d.ts +3 -1
- package/dist/web-ui/scripts.js +52 -25
- package/dist/web-ui/styles.d.ts +2 -0
- package/dist/web-ui/styles.js +12 -0
- package/package.json +13 -12
package/README.md
CHANGED
|
@@ -135,14 +135,20 @@ Use an isolated config for testing:
|
|
|
135
135
|
npm run dev -- -c /tmp/wand-test/config.json
|
|
136
136
|
```
|
|
137
137
|
|
|
138
|
-
|
|
138
|
+
For server development, check out only the pinned Render binaries. Native clients and
|
|
139
|
+
Rust source are independent submodules; initialize the one you are changing:
|
|
139
140
|
|
|
140
141
|
```bash
|
|
141
|
-
git clone
|
|
142
|
-
|
|
143
|
-
git submodule update --init
|
|
142
|
+
git clone https://github.com/co0ontty/wand.git
|
|
143
|
+
cd wand
|
|
144
|
+
git submodule update --init -- render-bin
|
|
145
|
+
# Optional: git submodule update --init -- android # or ios, macos, render
|
|
144
146
|
```
|
|
145
147
|
|
|
148
|
+
The full cross-platform test suite reads native source contracts, so initialize
|
|
149
|
+
`android ios macos render` before running `npm test`. To preview removable compiler
|
|
150
|
+
caches, run `npm run clean:build-cache`; add `-- --apply` to remove them.
|
|
151
|
+
|
|
146
152
|
Runtime data is stored under `~/.wand/`: `config.json`, `wand.db`, and `sessions/`.
|
|
147
153
|
|
|
148
154
|
OpenCode structured sessions require the current `opencode-ai` CLI (0.1 or newer). Remove the unrelated legacy `opencode` 0.0.x package before installing it:
|
|
@@ -326,14 +332,20 @@ wand/
|
|
|
326
332
|
└── docs/ # 文档与截图
|
|
327
333
|
```
|
|
328
334
|
|
|
329
|
-
|
|
335
|
+
服务端开发只需检出固定版本的 Render 二进制。原生客户端和 Rust 源码都是独立子模块,修改哪个再检出哪个:
|
|
330
336
|
|
|
331
337
|
```bash
|
|
332
|
-
git clone
|
|
333
|
-
|
|
334
|
-
git submodule update --init
|
|
338
|
+
git clone https://github.com/co0ontty/wand.git
|
|
339
|
+
cd wand
|
|
340
|
+
git submodule update --init -- render-bin
|
|
341
|
+
# 按需:git submodule update --init -- android # 或 ios、macos、render
|
|
335
342
|
```
|
|
336
343
|
|
|
344
|
+
全量 `npm test` 包含跨端源码契约检查,运行前另检出 `android ios macos render`。
|
|
345
|
+
`npm run clean:build-cache` 预览可清理的编译缓存,追加 `-- --apply` 才执行删除;
|
|
346
|
+
保留分发包、Release Render 二进制、签名、依赖和会话数据。审计与续接记录见
|
|
347
|
+
[`docs/repository-slimming.md`](docs/repository-slimming.md)。
|
|
348
|
+
|
|
337
349
|
数据存储在 `~/.wand/` 下:`config.json`(配置)、`wand.db`(SQLite)、`sessions/`(日志)。
|
|
338
350
|
|
|
339
351
|
### License
|
package/dist/agent-dispatch.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { getDefaultModelForProvider } from "./config.js";
|
|
2
2
|
import { providerCliCommand } from "./session-provider.js";
|
|
3
|
-
import { ensureWorkspaceTaskForBoardTask } from "./wand-task-sync.js";
|
|
4
3
|
/** 任务卡的执行目录:工作任务的 worktree / 目录 → 所属项目目录 → 全局默认目录。 */
|
|
5
4
|
export function resolveTaskDispatchTarget(deps, task) {
|
|
6
5
|
const workspace = task.workspaceId ? deps.storage.getWorkspace(task.workspaceId) : null;
|
|
7
6
|
if (task.workspaceId && !workspace)
|
|
8
7
|
throw new Error("任务所属项目已被删除,请重新指定。");
|
|
9
|
-
const group =
|
|
8
|
+
const group = task.workspaceTaskId ? deps.storage.getWorkspaceTask(task.workspaceTaskId) : null;
|
|
9
|
+
if (!group)
|
|
10
|
+
throw new Error("任务容器不存在,请恢复任务后再派发。");
|
|
10
11
|
const cwd = group.worktree?.path || group.cwd || workspace?.cwd || deps.config.defaultCwd;
|
|
11
12
|
return { cwd, workspaceId: group.workspaceId, workspaceTaskId: group.id };
|
|
12
13
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { ConversationTurn } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* 运行中步骤的 live 文本渲染结果。`omittedChars` > 0 表示前面被截掉的字数,
|
|
4
|
+
* 客户端据此显示「已省略前面 N 字」。
|
|
5
|
+
*/
|
|
6
|
+
export interface LiveStepText {
|
|
7
|
+
text: string;
|
|
8
|
+
omittedChars: number;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* 渲染「成员此刻干到哪儿了」的紧凑文本,给固定尺寸、内部滚动的 live 卡片用。
|
|
12
|
+
* 口径:从最后一条 user turn(没有就取最后一条 assistant turn)渲染到末尾;
|
|
13
|
+
* text 原样保留,tool_use 压成一行 `▸ 名称[ · 描述]`,tool_result 与 thinking 不渲染;
|
|
14
|
+
* 尾部保留最多 AI_TEAM_LIVE_TEXT_MAX_CHARS 字;渲染结果为空时回落原始 output 尾部
|
|
15
|
+
* (先按尾部截窗再剥 ANSI、同样截尾,见 `outputTailText`),保证 PTY 会话也有内容可显示。
|
|
16
|
+
* 纯函数、无 IO。
|
|
17
|
+
*
|
|
18
|
+
* `preferOutput`:以终端 output 尾部为唯一来源,完全不看 `messages`。给「PTY 但没有人把
|
|
19
|
+
* 流式输出解析成 messages」的会话用(判定在调用方,见 `ai-team-runner.ts` 的 `liveStep`)——
|
|
20
|
+
* 这类会话的 `messages` 在流式期不增长,里面若留着上一条 turn,按上面的口径会一直渲染那段
|
|
21
|
+
* 旧文本,卡片停在过期内容上。此时 output 为空也照实返回空串,不拿旧 messages 凑数。
|
|
22
|
+
*/
|
|
23
|
+
export declare function renderLiveStepText(messages: ConversationTurn[], output: string, options?: {
|
|
24
|
+
preferOutput?: boolean;
|
|
25
|
+
}): LiveStepText;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { AI_TEAM_LIVE_TEXT_MAX_CHARS } from "./ai-team-types.js";
|
|
2
|
+
/** tool_use 行的 description 单行截断长度。 */
|
|
3
|
+
const TOOL_DESCRIPTION_MAX_CHARS = 60;
|
|
4
|
+
/** CSI + OSC + 两字节转义,覆盖 PTY 输出里常见的控制序列。 */
|
|
5
|
+
const ANSI_PATTERN = /\x1B(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1B]*(?:\x07|\x1B\\)|[ @-Z\\-_])/g;
|
|
6
|
+
function stripAnsi(text) {
|
|
7
|
+
return text.replace(ANSI_PATTERN, "");
|
|
8
|
+
}
|
|
9
|
+
/** 行尾空白清掉;连续空行(≥2 个)压成一个换行,单个空行保留,段落还在。 */
|
|
10
|
+
function collapseBlankLines(text) {
|
|
11
|
+
return text.replace(/[ \t]+$/gm, "").replace(/\n{3,}/g, "\n");
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* 把切点往前挪一个 code unit,避开「半个 emoji」。
|
|
15
|
+
* `slice` 按 UTF-16 code unit 切,切点正好落在代理对中间(前一个是高代理、这一个是低代理)时,
|
|
16
|
+
* 尾巴会以一个孤立低代理开头 —— 下游渲染成 1 个 U+FFFD。往**前**挪(丢掉这一对的后半截,
|
|
17
|
+
* 整对归到被省略的前缀里)而不是往后挪,才能保证 `text.length <= AI_TEAM_LIVE_TEXT_MAX_CHARS`。
|
|
18
|
+
* 起点是 0(没切)或 `start - 1` 不是高代理时原样返回。
|
|
19
|
+
*/
|
|
20
|
+
function skipOrphanSurrogate(text, start) {
|
|
21
|
+
if (start <= 0 || start >= text.length)
|
|
22
|
+
return start;
|
|
23
|
+
const previous = text.charCodeAt(start - 1);
|
|
24
|
+
const current = text.charCodeAt(start);
|
|
25
|
+
const splitPair = previous >= 0xD800 && previous <= 0xDBFF && current >= 0xDC00 && current <= 0xDFFF;
|
|
26
|
+
return splitPair ? start + 1 : start;
|
|
27
|
+
}
|
|
28
|
+
function takeTail(text) {
|
|
29
|
+
if (text.length <= AI_TEAM_LIVE_TEXT_MAX_CHARS)
|
|
30
|
+
return { text, omittedChars: 0 };
|
|
31
|
+
const start = skipOrphanSurrogate(text, text.length - AI_TEAM_LIVE_TEXT_MAX_CHARS);
|
|
32
|
+
const tail = text.slice(start);
|
|
33
|
+
return { text: tail, omittedChars: text.length - tail.length };
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* output 回落的截窗宽度:只要保留上限的 8 倍。转义序列通常远少于正文,窗内可见文本一般仍 ≥
|
|
37
|
+
* AI_TEAM_LIVE_TEXT_MAX_CHARS,此时窗内显示的文本与「全量剥完再截尾」逐字相同;极端全是转义的
|
|
38
|
+
* 输出只会让显示的比全量少一截,省略字数仍按截窗点如实计入(是估算值,见 `outputTailText`)。
|
|
39
|
+
*/
|
|
40
|
+
const LIVE_OUTPUT_WINDOW_CHARS = AI_TEAM_LIVE_TEXT_MAX_CHARS * 8;
|
|
41
|
+
/**
|
|
42
|
+
* 截窗点最多往前回退这么多字符去找转义序列的起点(ESC)。
|
|
43
|
+
* 宽度按能装下最长的常见序列给:OSC 标题/超链接可以横跨几 K(`ESC ] 0; <长标题> BEL`),
|
|
44
|
+
* 只回退 64 字会把这种序列从中间切开,残留裸 BEL 和标题片段当正文显示。
|
|
45
|
+
*/
|
|
46
|
+
const LIVE_OUTPUT_ESCAPE_LOOKBACK = 4096;
|
|
47
|
+
/**
|
|
48
|
+
* 把截窗点挪到一个安全边界上:
|
|
49
|
+
* 1. 回退范围内有 ESC → 从该序列的起点开始,剥 ANSI 就能整条剥掉;
|
|
50
|
+
* 2. 退一步到最近的换行之后 → 至少不显示半行的残片;
|
|
51
|
+
* 3. 两者都找不到(超长转义负载里既没 ESC 也没换行)就不兜底,照原切点显示。
|
|
52
|
+
* 只在 `[start - LOOKBACK, start)` 这段里搜,不对整串(能到几 MB)跑 lastIndexOf。
|
|
53
|
+
*/
|
|
54
|
+
function safeTailStart(output, start) {
|
|
55
|
+
const from = Math.max(0, start - LIVE_OUTPUT_ESCAPE_LOOKBACK);
|
|
56
|
+
const window = output.slice(from, start);
|
|
57
|
+
const esc = window.lastIndexOf("\x1B");
|
|
58
|
+
if (esc >= 0)
|
|
59
|
+
return from + esc;
|
|
60
|
+
const newline = window.lastIndexOf("\n");
|
|
61
|
+
return newline >= 0 ? from + newline + 1 : start;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* 终端原始输出的尾部:先按尾部截窗,再剥 ANSI / 换行归一 / 压缩空行 / 截尾。
|
|
65
|
+
* live 每 500ms 就可能算一遍,而 PTY 的 output 能长到几 MB —— 顺序反过来等于每半秒对全量
|
|
66
|
+
* 跑一遍正则。丢掉的前缀计入 omittedChars,是**估算值(可能偏大也可能偏小,用于量级提示)**:
|
|
67
|
+
* 按截窗点的原始字数计,含 ANSI 与控制字符,不精确到账。三条边界写进 docs/ai-teams.md。
|
|
68
|
+
*/
|
|
69
|
+
function outputTailText(output) {
|
|
70
|
+
const rawStart = Math.max(0, output.length - LIVE_OUTPUT_WINDOW_CHARS);
|
|
71
|
+
const start = rawStart > 0 ? skipOrphanSurrogate(output, safeTailStart(output, rawStart)) : 0;
|
|
72
|
+
const stripped = collapseBlankLines(stripAnsi(output.slice(start)).replace(/\r\n?/g, "\n")).trim();
|
|
73
|
+
// 窗内全是转义序列时 stripped 为空,但窗外的正文确实被丢了,省略字数照 start 记,不报 0。
|
|
74
|
+
const tail = takeTail(stripped);
|
|
75
|
+
return start > 0 ? { text: tail.text, omittedChars: tail.omittedChars + start } : tail;
|
|
76
|
+
}
|
|
77
|
+
/** 压成一行并截断到 max 字(省略号算在长度内)。 */
|
|
78
|
+
function oneLine(text, max) {
|
|
79
|
+
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
80
|
+
if (collapsed.length <= max)
|
|
81
|
+
return collapsed;
|
|
82
|
+
return `${collapsed.slice(0, max - 1)}…`;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* 渲染「成员此刻干到哪儿了」的紧凑文本,给固定尺寸、内部滚动的 live 卡片用。
|
|
86
|
+
* 口径:从最后一条 user turn(没有就取最后一条 assistant turn)渲染到末尾;
|
|
87
|
+
* text 原样保留,tool_use 压成一行 `▸ 名称[ · 描述]`,tool_result 与 thinking 不渲染;
|
|
88
|
+
* 尾部保留最多 AI_TEAM_LIVE_TEXT_MAX_CHARS 字;渲染结果为空时回落原始 output 尾部
|
|
89
|
+
* (先按尾部截窗再剥 ANSI、同样截尾,见 `outputTailText`),保证 PTY 会话也有内容可显示。
|
|
90
|
+
* 纯函数、无 IO。
|
|
91
|
+
*
|
|
92
|
+
* `preferOutput`:以终端 output 尾部为唯一来源,完全不看 `messages`。给「PTY 但没有人把
|
|
93
|
+
* 流式输出解析成 messages」的会话用(判定在调用方,见 `ai-team-runner.ts` 的 `liveStep`)——
|
|
94
|
+
* 这类会话的 `messages` 在流式期不增长,里面若留着上一条 turn,按上面的口径会一直渲染那段
|
|
95
|
+
* 旧文本,卡片停在过期内容上。此时 output 为空也照实返回空串,不拿旧 messages 凑数。
|
|
96
|
+
*/
|
|
97
|
+
export function renderLiveStepText(messages, output, options = {}) {
|
|
98
|
+
if (options.preferOutput === true)
|
|
99
|
+
return outputTailText(output);
|
|
100
|
+
let start = -1;
|
|
101
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
102
|
+
if (messages[index].role === "user") {
|
|
103
|
+
start = index;
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (start < 0) {
|
|
108
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
109
|
+
if (messages[index].role === "assistant") {
|
|
110
|
+
start = index;
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const fragments = [];
|
|
116
|
+
for (const turn of start >= 0 ? messages.slice(start) : []) {
|
|
117
|
+
for (const block of turn.content) {
|
|
118
|
+
if (block.type === "text") {
|
|
119
|
+
const text = block.text?.trim();
|
|
120
|
+
if (text)
|
|
121
|
+
fragments.push(text);
|
|
122
|
+
}
|
|
123
|
+
else if (block.type === "tool_use") {
|
|
124
|
+
const description = block.description?.trim();
|
|
125
|
+
fragments.push(description
|
|
126
|
+
? `▸ ${block.name} · ${oneLine(description, TOOL_DESCRIPTION_MAX_CHARS)}`
|
|
127
|
+
: `▸ ${block.name}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const rendered = collapseBlankLines(fragments.join("\n")).trim();
|
|
132
|
+
if (rendered)
|
|
133
|
+
return takeTail(rendered);
|
|
134
|
+
return outputTailText(output);
|
|
135
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type AiTeam, type AiTeamMember, type AiTeamRun, type AiTeamRunStatus, type AiTeamStep } from "./ai-team-types.js";
|
|
2
|
+
import type { WandTaskAgent } from "./task-types.js";
|
|
2
3
|
import type { ConversationTurn } from "./types.js";
|
|
3
4
|
export declare const AI_TEAM_REPORT_DIR = ".wand-team";
|
|
4
5
|
export declare const AI_TEAM_MAX_ASSIGN_STEPS = 5;
|
|
@@ -23,7 +24,7 @@ export type AiTeamLeaderDecision = {
|
|
|
23
24
|
/**
|
|
24
25
|
* 团队这一轮要做的事。
|
|
25
26
|
* 指派框 / 群聊里刚输入的文字就是本轮任务,不再把任务卡上的旧描述静默拼进去。
|
|
26
|
-
* 没有单独输入时(创建任务后立刻交给团队)才用标题 +
|
|
27
|
+
* 没有单独输入时(创建任务后立刻交给团队)才用标题 + 描述,标题已被描述覆盖时不重复。
|
|
27
28
|
*/
|
|
28
29
|
export declare function buildAiTeamObjective(task: {
|
|
29
30
|
title: string;
|
|
@@ -84,21 +85,24 @@ export declare function renderChatHistoryFile(input: {
|
|
|
84
85
|
maxBytes?: number;
|
|
85
86
|
}): string;
|
|
86
87
|
/**
|
|
87
|
-
* 一轮提示词:`system` 交给 CLI
|
|
88
|
-
*
|
|
88
|
+
* 一轮提示词:`system` 交给 CLI 的系统提示通道(角色、规则、回复格式、交接文件的用法),
|
|
89
|
+
* `message` 是这一轮的用户消息(目标、指派、文件路径)。
|
|
90
|
+
* provider 支持系统提示时一律走 `system`:规则不伪装成用户发言,也不每轮重复。
|
|
89
91
|
*/
|
|
90
92
|
export interface AiTeamPrompt {
|
|
91
93
|
system: string;
|
|
92
94
|
message: string;
|
|
93
95
|
}
|
|
96
|
+
/** `default` 哨兵在提示词里怎么写:换成服务端为该 CLI 配置的默认模型名;没配就退回「默认」。 */
|
|
97
|
+
export type AiTeamModelNameResolver = (provider: WandTaskAgent["provider"]) => string;
|
|
94
98
|
/** `chatHistoryPath` 有值时说明这一轮是同一个群聊的续跑(见 `aiTeamChatHistoryPath`)。 */
|
|
95
|
-
export declare function buildLeaderKickoffPrompt(run: AiTeamRun, reportPath: string, fresh: boolean, chatHistoryPath?: string | null): AiTeamPrompt;
|
|
99
|
+
export declare function buildLeaderKickoffPrompt(run: AiTeamRun, reportPath: string, fresh: boolean, chatHistoryPath?: string | null, resolveModel?: AiTeamModelNameResolver): AiTeamPrompt;
|
|
96
100
|
export interface AiTeamFinishedStepSummary {
|
|
97
101
|
step: AiTeamStep;
|
|
98
102
|
memberName: string;
|
|
99
103
|
}
|
|
100
|
-
export declare function buildLeaderFollowupPrompt(run: AiTeamRun, finished: AiTeamFinishedStepSummary[], reportPath: string, userNote: string | undefined, fresh: boolean, handoffPath?: string | null, chatHistoryPath?: string | null): AiTeamPrompt;
|
|
101
|
-
export declare function buildLeaderFormatRetryPrompt(run: AiTeamRun, error: string, reportPath: string, fresh: boolean, chatHistoryPath?: string | null): AiTeamPrompt;
|
|
104
|
+
export declare function buildLeaderFollowupPrompt(run: AiTeamRun, finished: AiTeamFinishedStepSummary[], reportPath: string, userNote: string | undefined, fresh: boolean, handoffPath?: string | null, chatHistoryPath?: string | null, resolveModel?: AiTeamModelNameResolver): AiTeamPrompt;
|
|
105
|
+
export declare function buildLeaderFormatRetryPrompt(run: AiTeamRun, error: string, reportPath: string, fresh: boolean, chatHistoryPath?: string | null, resolveModel?: AiTeamModelNameResolver): AiTeamPrompt;
|
|
102
106
|
/**
|
|
103
107
|
* fresh=false 时成员会话已有上下文,省掉目标段落。`upstream` 有值时只给交接文件路径与
|
|
104
108
|
* 各步报告文件,不在提示词里复述上游报告正文(上一个成员的信息走文件交接)。
|
package/dist/ai-team-prompts.js
CHANGED
|
@@ -4,10 +4,31 @@ export const AI_TEAM_MAX_ASSIGN_STEPS = 5;
|
|
|
4
4
|
const MESSAGE_MAX = 2000;
|
|
5
5
|
const TITLE_MAX = 80;
|
|
6
6
|
const INSTRUCTIONS_MAX = 8000;
|
|
7
|
+
/**
|
|
8
|
+
* 消息里引用文件用的固定标签:用户消息只给路径,怎么用写在系统提示的「交接文件」段
|
|
9
|
+
* (`leaderSystemPrompt` / `memberSystemPrompt`),每轮不重复同一句规则。
|
|
10
|
+
*/
|
|
11
|
+
const ROUND_REPORT_LABEL = "本轮报告文件";
|
|
12
|
+
const HANDOFF_LABEL = "上游交接文件";
|
|
13
|
+
const CHAT_HISTORY_LABEL = "本群聊之前的记录";
|
|
14
|
+
/** 后一段已经完整包含前一段时只留后一段(任务标题常是描述开头被截断出来的前缀)。 */
|
|
15
|
+
function mergeTaskTexts(title, description) {
|
|
16
|
+
const head = title.trim();
|
|
17
|
+
const tail = description.trim();
|
|
18
|
+
if (!head)
|
|
19
|
+
return tail;
|
|
20
|
+
if (!tail)
|
|
21
|
+
return head;
|
|
22
|
+
if (tail.includes(head))
|
|
23
|
+
return tail;
|
|
24
|
+
if (head.includes(tail))
|
|
25
|
+
return head;
|
|
26
|
+
return `${head}\n\n${tail}`;
|
|
27
|
+
}
|
|
7
28
|
/**
|
|
8
29
|
* 团队这一轮要做的事。
|
|
9
30
|
* 指派框 / 群聊里刚输入的文字就是本轮任务,不再把任务卡上的旧描述静默拼进去。
|
|
10
|
-
* 没有单独输入时(创建任务后立刻交给团队)才用标题 +
|
|
31
|
+
* 没有单独输入时(创建任务后立刻交给团队)才用标题 + 描述,标题已被描述覆盖时不重复。
|
|
11
32
|
*/
|
|
12
33
|
export function buildAiTeamObjective(task, note) {
|
|
13
34
|
const assignment = note?.trim() ?? "";
|
|
@@ -15,7 +36,7 @@ export function buildAiTeamObjective(task, note) {
|
|
|
15
36
|
const title = task.title.trim();
|
|
16
37
|
return title && !assignment.includes(title) ? `任务:${title}\n\n${assignment}` : assignment;
|
|
17
38
|
}
|
|
18
|
-
return
|
|
39
|
+
return mergeTaskTexts(task.title, task.description) || "完成这张任务";
|
|
19
40
|
}
|
|
20
41
|
export function leaderOf(team) {
|
|
21
42
|
return team.members.find((member) => member.isLeader) ?? team.members[0];
|
|
@@ -151,10 +172,12 @@ const TEAM_ROLE_LABELS = {
|
|
|
151
172
|
work: "干活实现",
|
|
152
173
|
verify: "验收测试",
|
|
153
174
|
};
|
|
154
|
-
function memberLine(member) {
|
|
175
|
+
function memberLine(member, resolveModel) {
|
|
155
176
|
// 只渲染首选候选(§3.6);备用候选不进提示词,降级由系统处理。
|
|
156
177
|
const preferred = memberAgents(member)[0] ?? member.agent;
|
|
157
|
-
const
|
|
178
|
+
const configured = preferred.model === "default" ? resolveModel?.(preferred.provider)?.trim() ?? "" : "";
|
|
179
|
+
// `default` 不是模型名,写真正会用的那个(拿不到名字才写「默认」)。
|
|
180
|
+
const model = preferred.model === "default" ? configured || "默认" : preferred.model;
|
|
158
181
|
const role = member.role ? TEAM_ROLE_LABELS[member.role] : undefined;
|
|
159
182
|
const duty = member.duty.trim().replace(/\s+/g, " ") || "(未填写)";
|
|
160
183
|
const parts = [`id: ${member.id}`, `名字: ${member.name}`];
|
|
@@ -166,7 +189,7 @@ function memberLine(member) {
|
|
|
166
189
|
function leaderReplyFormat() {
|
|
167
190
|
return [
|
|
168
191
|
"## 回复方式(必须遵守)",
|
|
169
|
-
|
|
192
|
+
`每轮消息末尾都给出这一轮的报告文件(标签「${ROUND_REPORT_LABEL}」);把决定写成 JSON 写入该文件,写完只简短回复「已写入」,不要在消息里复述计划。`,
|
|
170
193
|
"格式三选一:",
|
|
171
194
|
'{"action":"assign","message":"给用户看的一句话说明","steps":[{"member":"成员 id","title":"简短标题","instructions":"具体要做什么、做到什么程度"}]}',
|
|
172
195
|
'{"action":"ask","message":"要问用户的问题"}',
|
|
@@ -189,28 +212,25 @@ function teamInstructions(team) {
|
|
|
189
212
|
/** 目标与用户补充都是用户原话(常带口述笔误),别按字面较真。 */
|
|
190
213
|
const OBJECTIVE_INTENT_NOTE = "目标与用户补充都是用户原话,可能带口述笔误(例如把 CLI 写成 ci),按用户意图理解,不要照字面复述。";
|
|
191
214
|
/**
|
|
192
|
-
*
|
|
193
|
-
*
|
|
215
|
+
* 交接文件怎么用,写在系统提示里(消息里只给路径,会话级规则只说一次)。
|
|
216
|
+
* 负责人用它决定下一步,成员用它接着上游干活。
|
|
194
217
|
*/
|
|
195
|
-
function
|
|
196
|
-
if (!path)
|
|
197
|
-
return [];
|
|
198
|
-
const shared = `用户是在这个群聊里接着提要求,之前几轮的步骤摘要与群聊原文在:${path}`;
|
|
218
|
+
function handoffRules(audience) {
|
|
199
219
|
return audience === "leader"
|
|
200
220
|
? [
|
|
201
|
-
"##
|
|
202
|
-
|
|
203
|
-
|
|
221
|
+
"## 交接文件(消息里出现下列标签时,先读文件再动手)",
|
|
222
|
+
`- ${HANDOFF_LABEL}:上一批步骤的报告全文汇总,先读它再决定下一步;不要凭标题猜成员做了什么,也不要让他们把报告复述进消息里。`,
|
|
223
|
+
`- ${CHAT_HISTORY_LABEL}:用户是在这个群聊里接着提要求;先读它,已经做完的不要重做,上一轮没做完的接着做。`,
|
|
204
224
|
"",
|
|
205
225
|
]
|
|
206
226
|
: [
|
|
207
|
-
"##
|
|
208
|
-
|
|
209
|
-
|
|
227
|
+
"## 交接文件(消息里出现下列标签时,先读文件再动手)",
|
|
228
|
+
`- ${HANDOFF_LABEL}:上游步骤的报告全文汇总,先读它再开始本步骤;不要猜上游做了什么,也不要重复劳动。`,
|
|
229
|
+
`- ${CHAT_HISTORY_LABEL}:用户是在这个群聊里接着提要求;先读它,在已有改动的基础上继续,已经完成的部分不要重做。`,
|
|
210
230
|
"",
|
|
211
231
|
];
|
|
212
232
|
}
|
|
213
|
-
function leaderSystemPrompt(run) {
|
|
233
|
+
function leaderSystemPrompt(run, resolveModel) {
|
|
214
234
|
const leader = leaderOf(run.team);
|
|
215
235
|
return [
|
|
216
236
|
`你是 AI 团队「${run.team.name}」的负责人(${leader.name})。`,
|
|
@@ -218,7 +238,7 @@ function leaderSystemPrompt(run) {
|
|
|
218
238
|
"",
|
|
219
239
|
"## 成员(只能把工作派给下列成员;id 必须原样使用)",
|
|
220
240
|
"成员的名单行只列首选执行配置;执行配置由系统按候选顺序自动降级,你只按成员能力分派,不操心模型可用性。",
|
|
221
|
-
...workersOf(run.team).map(memberLine),
|
|
241
|
+
...workersOf(run.team).map((member) => memberLine(member, resolveModel)),
|
|
222
242
|
...teamInstructions(run.team),
|
|
223
243
|
"",
|
|
224
244
|
"## 工作方式",
|
|
@@ -231,6 +251,7 @@ function leaderSystemPrompt(run) {
|
|
|
231
251
|
"- 需要用户拍板时就提问,不要猜。",
|
|
232
252
|
`- ${OBJECTIVE_INTENT_NOTE}`,
|
|
233
253
|
"",
|
|
254
|
+
...handoffRules("leader"),
|
|
234
255
|
...leaderReplyFormat(),
|
|
235
256
|
].join("\n");
|
|
236
257
|
}
|
|
@@ -245,57 +266,66 @@ function memberSystemPrompt(run, member) {
|
|
|
245
266
|
"- 不要 git commit / push。",
|
|
246
267
|
"- 你的发言会以群聊气泡出现,署名是你的名字;报告仍写进报告文件。",
|
|
247
268
|
"",
|
|
269
|
+
...handoffRules("member"),
|
|
248
270
|
"## 报告约定",
|
|
249
|
-
|
|
271
|
+
`每轮消息末尾都给出这一轮的报告文件(标签「${ROUND_REPORT_LABEL}」);完成后把报告写进该文件(Markdown):`,
|
|
250
272
|
"第一行写 `状态: 完成`、`状态: 受阻` 或 `状态: 失败`,然后写做了什么、改了哪些文件、怎么验证的、还有什么问题。",
|
|
251
273
|
].join("\n");
|
|
252
274
|
}
|
|
275
|
+
/** 本轮报告文件的引用行;怎么用写在系统提示里(见 `leaderReplyFormat` / 「报告约定」)。 */
|
|
276
|
+
function reportLine(reportPath) {
|
|
277
|
+
return `${ROUND_REPORT_LABEL}:${reportPath}`;
|
|
278
|
+
}
|
|
253
279
|
/** 会话是新建的时候才需要补目标:老会话的上下文里已经有目标了。 */
|
|
254
280
|
function objectiveBlock(run, fresh) {
|
|
255
281
|
// 标签内联而不是单独的 `## 团队目标` 行:团队消息也会被会话标题 / 迭代提示词记录取首行,
|
|
256
282
|
// 单独一行的话标题会退化成「团队目标」这个空壳。
|
|
257
283
|
return fresh ? [`团队目标:${run.objective}`, ""] : [];
|
|
258
284
|
}
|
|
285
|
+
/**
|
|
286
|
+
* 续跑(用户在同一个群聊里接着说话)的上下文文件(见 `aiTeamChatHistoryPath`):
|
|
287
|
+
* 消息只给路径,「先读它、别重做已完成的」写在系统提示的「交接文件」段。
|
|
288
|
+
*/
|
|
289
|
+
function chatHistoryBlock(path) {
|
|
290
|
+
return path ? [`${CHAT_HISTORY_LABEL}:${path}`, ""] : [];
|
|
291
|
+
}
|
|
259
292
|
/** `chatHistoryPath` 有值时说明这一轮是同一个群聊的续跑(见 `aiTeamChatHistoryPath`)。 */
|
|
260
|
-
export function buildLeaderKickoffPrompt(run, reportPath, fresh, chatHistoryPath) {
|
|
293
|
+
export function buildLeaderKickoffPrompt(run, reportPath, fresh, chatHistoryPath, resolveModel) {
|
|
261
294
|
return {
|
|
262
|
-
system: leaderSystemPrompt(run),
|
|
295
|
+
system: leaderSystemPrompt(run, resolveModel),
|
|
263
296
|
message: [
|
|
264
297
|
...objectiveBlock(run, fresh),
|
|
265
|
-
...chatHistoryBlock(chatHistoryPath
|
|
266
|
-
|
|
267
|
-
`把计划决策写成 JSON 写入:${reportPath}`,
|
|
298
|
+
...chatHistoryBlock(chatHistoryPath),
|
|
299
|
+
reportLine(reportPath),
|
|
268
300
|
].join("\n"),
|
|
269
301
|
};
|
|
270
302
|
}
|
|
271
|
-
export function buildLeaderFollowupPrompt(run, finished, reportPath, userNote, fresh, handoffPath, chatHistoryPath) {
|
|
303
|
+
export function buildLeaderFollowupPrompt(run, finished, reportPath, userNote, fresh, handoffPath, chatHistoryPath, resolveModel) {
|
|
272
304
|
// 续跑上下文只在新建的负责人会话里补一次:会话复用的时候,kickoff 那一轮已经给过路径。
|
|
273
|
-
const lines = [...objectiveBlock(run, fresh), ...chatHistoryBlock(fresh ? chatHistoryPath : null
|
|
305
|
+
const lines = [...objectiveBlock(run, fresh), ...chatHistoryBlock(fresh ? chatHistoryPath : null)];
|
|
274
306
|
if (finished.length > 0) {
|
|
275
307
|
lines.push("## 已结束的步骤", "");
|
|
276
308
|
for (const { step, memberName } of finished) {
|
|
277
309
|
lines.push(handoffStepLine({ ...step, memberName }));
|
|
278
310
|
}
|
|
279
|
-
if (handoffPath)
|
|
280
|
-
lines.push("",
|
|
281
|
-
}
|
|
311
|
+
if (handoffPath)
|
|
312
|
+
lines.push("", `${HANDOFF_LABEL}:${handoffPath}`);
|
|
282
313
|
lines.push("");
|
|
283
314
|
}
|
|
284
315
|
if (userNote?.trim())
|
|
285
316
|
lines.push("## 用户补充", userNote.trim(), "");
|
|
286
|
-
lines.push(
|
|
287
|
-
return { system: leaderSystemPrompt(run), message: lines.join("\n") };
|
|
317
|
+
lines.push(reportLine(reportPath));
|
|
318
|
+
return { system: leaderSystemPrompt(run, resolveModel), message: lines.join("\n") };
|
|
288
319
|
}
|
|
289
|
-
export function buildLeaderFormatRetryPrompt(run, error, reportPath, fresh, chatHistoryPath) {
|
|
320
|
+
export function buildLeaderFormatRetryPrompt(run, error, reportPath, fresh, chatHistoryPath, resolveModel) {
|
|
290
321
|
return {
|
|
291
|
-
system: leaderSystemPrompt(run),
|
|
322
|
+
system: leaderSystemPrompt(run, resolveModel),
|
|
292
323
|
message: [
|
|
293
324
|
...objectiveBlock(run, fresh),
|
|
294
|
-
...chatHistoryBlock(fresh ? chatHistoryPath : null
|
|
325
|
+
...chatHistoryBlock(fresh ? chatHistoryPath : null),
|
|
295
326
|
`上次的回复没能解析:${error}`,
|
|
296
327
|
"",
|
|
297
|
-
|
|
298
|
-
`请严格按上面的 JSON 格式重新写入:${reportPath}`,
|
|
328
|
+
reportLine(reportPath),
|
|
299
329
|
].join("\n"),
|
|
300
330
|
};
|
|
301
331
|
}
|
|
@@ -307,18 +337,18 @@ export function buildMemberPrompt(run, step, member, fresh, upstream, chatHistor
|
|
|
307
337
|
const lines = [
|
|
308
338
|
...objectiveBlock(run, fresh),
|
|
309
339
|
// 续跑上下文只在新建的成员会话里补一次:成员这一轮以前的信息都在文件里。
|
|
310
|
-
...chatHistoryBlock(fresh ? chatHistoryPath : null
|
|
340
|
+
...chatHistoryBlock(fresh ? chatHistoryPath : null),
|
|
311
341
|
`## 本步骤:${step.title}`,
|
|
312
342
|
step.instructions,
|
|
313
343
|
"",
|
|
314
344
|
];
|
|
315
345
|
if (upstream && upstream.steps.length > 0) {
|
|
316
|
-
lines.push("##
|
|
346
|
+
lines.push("## 上游交接", "");
|
|
317
347
|
for (const item of upstream.steps)
|
|
318
348
|
lines.push(handoffStepLine(item));
|
|
319
|
-
lines.push("",
|
|
349
|
+
lines.push("", `${HANDOFF_LABEL}:${upstream.handoffPath}`, "");
|
|
320
350
|
}
|
|
321
|
-
lines.push(
|
|
351
|
+
lines.push(reportLine(step.reportPath));
|
|
322
352
|
return { system: memberSystemPrompt(run, member), message: lines.join("\n") };
|
|
323
353
|
}
|
|
324
354
|
/** 从 start 处的 `{` 找到配对的 `}`(跳过字符串里的括号)。 */
|
package/dist/ai-team-runner.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type AgentDispatchDeps } from "./agent-dispatch.js";
|
|
2
2
|
import { type ModelCatalogView } from "./ai-team-availability.js";
|
|
3
|
-
import { type AiTeamRun, type AiTeamRunDetail, type AiTeamRunSummary } from "./ai-team-types.js";
|
|
3
|
+
import { type AiTeamLiveStep, type AiTeamLiveUpdate, type AiTeamRun, type AiTeamRunDetail, type AiTeamRunSummary } from "./ai-team-types.js";
|
|
4
4
|
import type { SessionRegistry } from "./session-registry.js";
|
|
5
5
|
import type { WandStorage } from "./storage.js";
|
|
6
6
|
import type { WandTask, WandTaskAgent } from "./task-types.js";
|
|
@@ -41,12 +41,19 @@ export interface AiTeamRunnerOptions {
|
|
|
41
41
|
resolveCwd: (task: WandTask) => string;
|
|
42
42
|
ops: AiTeamSessionOps;
|
|
43
43
|
notify?: (run: AiTeamRun) => void;
|
|
44
|
+
/** 运行中步骤的 live 文本推送(§4.9):去抖后每 500ms 至多一次,stepId+text+omittedChars+state 全同不重复推。 */
|
|
45
|
+
notifyLive?: (update: AiTeamLiveUpdate) => void;
|
|
44
46
|
now?: () => number;
|
|
45
47
|
/**
|
|
46
48
|
* 某 provider 已发现的模型清单(§3.4 model-unknown 事前比对)。目录未就绪 / 清单为空时
|
|
47
49
|
* 返回空值即可,判定方向是「拿不准就放行」。
|
|
48
50
|
*/
|
|
49
51
|
models?: (provider: WandTaskAgent["provider"]) => ModelCatalogView;
|
|
52
|
+
/**
|
|
53
|
+
* 该 provider 服务端配置的默认模型(拿不到返回空串)。只用于提示词里的成员名单:
|
|
54
|
+
* `default` 哨兵不是模型名,写真正会用的那个。
|
|
55
|
+
*/
|
|
56
|
+
defaultModelOf?: (provider: WandTaskAgent["provider"]) => string;
|
|
50
57
|
}
|
|
51
58
|
export declare function createAiTeamSessionOps(deps: AgentDispatchDeps & {
|
|
52
59
|
sessions: SessionRegistry;
|
|
@@ -55,7 +62,9 @@ export declare function createAiTeamChatOps(deps: AgentDispatchDeps): AiTeamChat
|
|
|
55
62
|
export declare function createAiTeamRunner(deps: AgentDispatchDeps & {
|
|
56
63
|
sessions: SessionRegistry;
|
|
57
64
|
notify?: (run: AiTeamRun) => void;
|
|
65
|
+
notifyLive?: (update: AiTeamLiveUpdate) => void;
|
|
58
66
|
models?: (provider: WandTaskAgent["provider"]) => ModelCatalogView;
|
|
67
|
+
defaultModelOf?: (provider: WandTaskAgent["provider"]) => string;
|
|
59
68
|
}): AiTeamRunner;
|
|
60
69
|
/** 遮蔽参照目录:家目录与激活配置目录(含 `/tmp/wand-dev` 这类隔离路径,按传入处理)。 */
|
|
61
70
|
export interface RedactionDirs {
|
|
@@ -79,10 +88,16 @@ export declare class AiTeamRunner {
|
|
|
79
88
|
private readonly chat?;
|
|
80
89
|
private readonly resolveCwd;
|
|
81
90
|
private readonly notifyListener?;
|
|
91
|
+
private readonly notifyLiveListener?;
|
|
82
92
|
private readonly modelsOf?;
|
|
93
|
+
private readonly defaultModelOf?;
|
|
83
94
|
private readonly now;
|
|
84
95
|
private readonly chains;
|
|
85
96
|
private readonly outputTimers;
|
|
97
|
+
/** live 推送的去抖计时器,按 runId 计(§4.9)。 */
|
|
98
|
+
private readonly liveTimers;
|
|
99
|
+
/** 上次推出去的 live 列表指纹(§4.9):stepId + text + omittedChars + state 全同就不重复推。 */
|
|
100
|
+
private readonly lastLiveKey;
|
|
86
101
|
private readonly recheckTimers;
|
|
87
102
|
/** 已经确定「不降级、按既有路径记 failed」的步骤,防止 finishStep 再判一次形成回环。 */
|
|
88
103
|
private readonly noDegrade;
|
|
@@ -90,6 +105,8 @@ export declare class AiTeamRunner {
|
|
|
90
105
|
/** 脱敏参照目录只算一次:os.homedir() 读 env,configDir 随 storage 定,运行期不变。 */
|
|
91
106
|
private redactionDirsValue;
|
|
92
107
|
constructor(options: AiTeamRunnerOptions);
|
|
108
|
+
/** 提示词里的模型名解析器:`default` 哨兵交给服务端配置的默认模型。 */
|
|
109
|
+
private readonly defaultModelName;
|
|
93
110
|
/** 本 runner 的遮蔽参照目录(§4.5)。 */
|
|
94
111
|
private get redactionDirs();
|
|
95
112
|
/** 只脱敏、不改长度语义(用于本来就不截断的文案)。 */
|
|
@@ -112,6 +129,8 @@ export declare class AiTeamRunner {
|
|
|
112
129
|
echo?: boolean;
|
|
113
130
|
}): Promise<AiTeamRunDetail>;
|
|
114
131
|
continueRun(runId: string, extraSteps: number): Promise<AiTeamRunDetail>;
|
|
132
|
+
/** 已有运行的上限独立持久化;修改团队定义后,下次续跑至少采用新的团队上限。 */
|
|
133
|
+
private extendStepLimit;
|
|
115
134
|
completeStep(runId: string, stepId: string, report: string): Promise<AiTeamRunDetail>;
|
|
116
135
|
stop(runId: string): Promise<AiTeamRunDetail>;
|
|
117
136
|
/**
|
|
@@ -121,6 +140,17 @@ export declare class AiTeamRunner {
|
|
|
121
140
|
chatInput(sessionId: string, text: string): Promise<void>;
|
|
122
141
|
detail(runId: string): AiTeamRunDetail;
|
|
123
142
|
listForTask(taskId: string): AiTeamRun[];
|
|
143
|
+
/**
|
|
144
|
+
* 此刻正在干活的步骤的实时文本(§4.9)。只覆盖 running 且有会话的步骤,按 seq 升序;
|
|
145
|
+
* 快照拿不到的步骤跳过而不抛。run 不存在时与 detail() 同口径抛错。
|
|
146
|
+
*/
|
|
147
|
+
live(runId: string): AiTeamLiveStep[];
|
|
148
|
+
private liveSteps;
|
|
149
|
+
private liveStep;
|
|
150
|
+
/** 挂一次 run 级 live 去抖:同一 run 每 500ms 至多算一遍、推一次,与 evaluate 的计时器互不干扰。 */
|
|
151
|
+
private scheduleLivePush;
|
|
152
|
+
/** 推一次 live 列表(§4.9);指纹(stepId + text + omittedChars + state)没变就不重复推,推送失败只忽略。 */
|
|
153
|
+
private pushLive;
|
|
124
154
|
/** 团队页的运行记录;teamId 为空时列出所有团队(侧边栏计数用)。 */
|
|
125
155
|
listRuns(filter: {
|
|
126
156
|
teamId?: string;
|
|
@@ -131,7 +161,12 @@ export declare class AiTeamRunner {
|
|
|
131
161
|
ingest(event: ProcessEvent): void;
|
|
132
162
|
/** 服务启动时调用:把重启前未结束的运行接回来,并开始兜底巡检。 */
|
|
133
163
|
reconcile(): void;
|
|
134
|
-
/**
|
|
164
|
+
/**
|
|
165
|
+
* 兜底巡检:PTY 成员写完报告后可能不再产生事件。
|
|
166
|
+
* 顺带对每个 run 强制对账一次 live(§4.9):registry 内部状态静默翻转时一条事件都不会进来,
|
|
167
|
+
* 状态芯片只能等客户端下一次重拉;这里在同一串里重算列表走 pushLive,指纹变了才推,
|
|
168
|
+
* 没变就什么都不发。巡检间隔与 evaluate 行为都不动。
|
|
169
|
+
*/
|
|
135
170
|
startSweep(): void;
|
|
136
171
|
dispose(): void;
|
|
137
172
|
/** 等当前排队的所有操作跑完(测试用)。 */
|
|
@@ -142,8 +177,10 @@ export declare class AiTeamRunner {
|
|
|
142
177
|
private evaluate;
|
|
143
178
|
private scheduleRecheck;
|
|
144
179
|
private stepOutcome;
|
|
145
|
-
/**
|
|
180
|
+
/** 步骤落地后补推一次 live(§4.9):让卡片收尾,列表可能已空。内部抛错时不推。 */
|
|
146
181
|
private finishStep;
|
|
182
|
+
/** proceed=false 时只记结果不推进,由调用方在记完一批之后统一 advance。 */
|
|
183
|
+
private finishStepCore;
|
|
147
184
|
/**
|
|
148
185
|
* 派出所有已就绪的成员步骤:依赖都已完成、且该成员手上没有活。不同成员并行,同一成员串行。
|
|
149
186
|
* 有成员失败、队列清空或依赖再也满足不了时,等在跑的都结束后交回 Leader。
|