oh-pi 0.1.73 → 0.1.75
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/package.json +1 -1
- package/pi-package/extensions/ant-colony/index.ts +27 -17
- package/pi-package/extensions/ant-colony/parser.test.ts +33 -0
- package/pi-package/extensions/ant-colony/parser.ts +128 -33
- package/pi-package/extensions/ant-colony/queen.test.ts +35 -1
- package/pi-package/extensions/ant-colony/queen.ts +108 -32
- package/pi-package/extensions/ant-colony/types.ts +1 -1
- package/pi-package/extensions/ant-colony/ui.test.ts +1 -0
- package/pi-package/extensions/ant-colony/ui.ts +3 -2
- package/pi-package/extensions/bg-process.ts +52 -31
package/package.json
CHANGED
|
@@ -51,7 +51,6 @@ export default function antColonyExtension(pi: ExtensionAPI) {
|
|
|
51
51
|
|
|
52
52
|
// 当前运行中的后台蚁群(同时只允许一个)
|
|
53
53
|
let activeColony: BackgroundColony | null = null;
|
|
54
|
-
let uiListenersRegistered = false;
|
|
55
54
|
|
|
56
55
|
// ─── Status 渲染 ───
|
|
57
56
|
|
|
@@ -63,12 +62,18 @@ export default function antColonyExtension(pi: ExtensionAPI) {
|
|
|
63
62
|
pi.events.emit("ant-colony:render");
|
|
64
63
|
};
|
|
65
64
|
|
|
66
|
-
//
|
|
65
|
+
// 每次 session_start 重新绑定事件,确保 ctx 始终是最新的
|
|
66
|
+
let renderHandler: (() => void) | null = null;
|
|
67
|
+
let clearHandler: (() => void) | null = null;
|
|
68
|
+
let notifyHandler: ((data: { msg: string; level: "info" | "success" | "warning" | "error" }) => void) | null = null;
|
|
69
|
+
|
|
67
70
|
pi.on("session_start", async (_event, ctx) => {
|
|
68
|
-
|
|
69
|
-
|
|
71
|
+
// 移除旧监听器(session 重启 / /reload 时 ctx 已失效)
|
|
72
|
+
if (renderHandler) pi.events.off("ant-colony:render", renderHandler);
|
|
73
|
+
if (clearHandler) pi.events.off("ant-colony:clear-ui", clearHandler);
|
|
74
|
+
if (notifyHandler) pi.events.off("ant-colony:notify", notifyHandler);
|
|
70
75
|
|
|
71
|
-
|
|
76
|
+
renderHandler = () => {
|
|
72
77
|
if (!activeColony) return;
|
|
73
78
|
const { state } = activeColony;
|
|
74
79
|
const elapsed = state ? formatDuration(Date.now() - state.createdAt) : "0s";
|
|
@@ -81,14 +86,17 @@ export default function antColonyExtension(pi: ExtensionAPI) {
|
|
|
81
86
|
parts.push(elapsed);
|
|
82
87
|
|
|
83
88
|
ctx.ui.setStatus("ant-colony", parts.join(" │ "));
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
pi.events.on("ant-colony:clear-ui", () => {
|
|
89
|
+
};
|
|
90
|
+
clearHandler = () => {
|
|
87
91
|
ctx.ui.setStatus("ant-colony", undefined);
|
|
88
|
-
}
|
|
89
|
-
|
|
92
|
+
};
|
|
93
|
+
notifyHandler = (data) => {
|
|
90
94
|
ctx.ui.notify(data.msg, data.level);
|
|
91
|
-
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
pi.events.on("ant-colony:render", renderHandler);
|
|
98
|
+
pi.events.on("ant-colony:clear-ui", clearHandler);
|
|
99
|
+
pi.events.on("ant-colony:notify", notifyHandler);
|
|
92
100
|
});
|
|
93
101
|
|
|
94
102
|
// ─── 同步模式(print mode):阻塞等待蚁群完成 ───
|
|
@@ -162,14 +170,14 @@ export default function antColonyExtension(pi: ExtensionAPI) {
|
|
|
162
170
|
const callbacks: QueenCallbacks = {
|
|
163
171
|
onSignal(signal) {
|
|
164
172
|
colony.phase = signal.message;
|
|
165
|
-
//
|
|
173
|
+
// 阶段切换时注入消息到主进程对话流(display: true 让 LLM 下次可见,无需轮询)
|
|
166
174
|
if (signal.phase !== lastPhase) {
|
|
167
175
|
lastPhase = signal.phase;
|
|
168
176
|
const pct = Math.round(signal.progress * 100);
|
|
169
177
|
pi.sendMessage({
|
|
170
178
|
customType: "ant-colony-progress",
|
|
171
179
|
content: `[COLONY_SIGNAL:${signal.phase.toUpperCase()}] 🐜 ${signal.message} (${pct}%, ${formatCost(signal.cost)})`,
|
|
172
|
-
display:
|
|
180
|
+
display: true,
|
|
173
181
|
}, { triggerTurn: false, deliverAs: "followUp" });
|
|
174
182
|
}
|
|
175
183
|
throttledRender();
|
|
@@ -194,7 +202,7 @@ export default function antColonyExtension(pi: ExtensionAPI) {
|
|
|
194
202
|
pi.sendMessage({
|
|
195
203
|
customType: "ant-colony-progress",
|
|
196
204
|
content: `[COLONY_SIGNAL:TASK_DONE] 🐜 ${icon} ${task.title.slice(0, 60)} (${progress}, ${cost})`,
|
|
197
|
-
display:
|
|
205
|
+
display: true,
|
|
198
206
|
}, { triggerTurn: false, deliverAs: "followUp" });
|
|
199
207
|
throttledRender();
|
|
200
208
|
},
|
|
@@ -367,12 +375,14 @@ export default function antColonyExtension(pi: ExtensionAPI) {
|
|
|
367
375
|
};
|
|
368
376
|
|
|
369
377
|
// 定时刷新
|
|
370
|
-
|
|
378
|
+
let timer: ReturnType<typeof setInterval> | null = setInterval(() => {
|
|
371
379
|
cachedWidth = undefined;
|
|
372
380
|
cachedLines = undefined;
|
|
373
381
|
tui.requestRender();
|
|
374
382
|
}, 1000);
|
|
375
383
|
|
|
384
|
+
const cleanup = () => { if (timer) { clearInterval(timer); timer = null; } };
|
|
385
|
+
|
|
376
386
|
return {
|
|
377
387
|
render(width: number): string[] {
|
|
378
388
|
if (cachedLines && cachedWidth === width) return cachedLines;
|
|
@@ -380,10 +390,10 @@ export default function antColonyExtension(pi: ExtensionAPI) {
|
|
|
380
390
|
cachedWidth = width;
|
|
381
391
|
return cachedLines;
|
|
382
392
|
},
|
|
383
|
-
invalidate() { cachedWidth = undefined; cachedLines = undefined; },
|
|
393
|
+
invalidate() { cachedWidth = undefined; cachedLines = undefined; cleanup(); },
|
|
384
394
|
handleInput(data: string) {
|
|
385
395
|
if (matchesKey(data, "escape")) {
|
|
386
|
-
|
|
396
|
+
cleanup();
|
|
387
397
|
done(undefined);
|
|
388
398
|
}
|
|
389
399
|
},
|
|
@@ -81,6 +81,39 @@ describe("parseSubTasks", () => {
|
|
|
81
81
|
const tasks = parseSubTasks(output);
|
|
82
82
|
expect(tasks[0].context).toBeTruthy();
|
|
83
83
|
});
|
|
84
|
+
|
|
85
|
+
it("parses chinese task format with full-width colon", () => {
|
|
86
|
+
const output = `### 任务:生成重启检查报告
|
|
87
|
+
- 描述:创建重启检查文档
|
|
88
|
+
- 文件:docs/ant-colony-restart-check.md
|
|
89
|
+
- 角色:worker
|
|
90
|
+
- 优先级:1`;
|
|
91
|
+
const tasks = parseSubTasks(output);
|
|
92
|
+
expect(tasks).toHaveLength(1);
|
|
93
|
+
expect(tasks[0].title).toContain("重启检查报告");
|
|
94
|
+
expect(tasks[0].files).toEqual(["docs/ant-colony-restart-check.md"]);
|
|
95
|
+
expect(tasks[0].caste).toBe("worker");
|
|
96
|
+
expect(tasks[0].priority).toBe(1);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("does not infer tasks from plain next-step narrative", () => {
|
|
100
|
+
const output = `目前发现如下\n\n下一步我会继续定位:
|
|
101
|
+
- 写入 docs/ant-colony-restart-check.md
|
|
102
|
+
- 校验 src/index.ts 的入口流程`;
|
|
103
|
+
const tasks = parseSubTasks(output);
|
|
104
|
+
expect(tasks).toEqual([]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("parses bold markdown field keys", () => {
|
|
108
|
+
const output = `### TASK: Harden parser
|
|
109
|
+
- **description**: support bold fields
|
|
110
|
+
- **files**: pi-package/extensions/ant-colony/parser.ts
|
|
111
|
+
- **caste**: worker
|
|
112
|
+
- **priority**: 2`;
|
|
113
|
+
const tasks = parseSubTasks(output);
|
|
114
|
+
expect(tasks).toHaveLength(1);
|
|
115
|
+
expect(tasks[0].files).toEqual(["pi-package/extensions/ant-colony/parser.ts"]);
|
|
116
|
+
});
|
|
84
117
|
});
|
|
85
118
|
|
|
86
119
|
describe("extractPheromones", () => {
|
|
@@ -2,6 +2,7 @@ import type { AntCaste, Pheromone, PheromoneType } from "./types.js";
|
|
|
2
2
|
import { makePheromoneId } from "./spawner.js";
|
|
3
3
|
|
|
4
4
|
const VALID_CASTES = new Set(["scout", "worker", "soldier", "drone"]);
|
|
5
|
+
const TASK_HEADER_RE = /^\s*#{2,6}\s*(?:task|任务)\s*[::]\s*(.+?)\s*$/i;
|
|
5
6
|
|
|
6
7
|
export interface ParsedSubTask {
|
|
7
8
|
title: string;
|
|
@@ -12,44 +13,138 @@ export interface ParsedSubTask {
|
|
|
12
13
|
context?: string;
|
|
13
14
|
}
|
|
14
15
|
|
|
16
|
+
function normalizePriority(v: unknown): 1 | 2 | 3 | 4 | 5 {
|
|
17
|
+
const n = parseInt(String(v ?? "3"), 10);
|
|
18
|
+
return Math.min(5, Math.max(1, Number.isNaN(n) ? 3 : n)) as 1 | 2 | 3 | 4 | 5;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizeCaste(v: unknown): AntCaste {
|
|
22
|
+
const raw = String(v ?? "worker").trim().toLowerCase();
|
|
23
|
+
if (VALID_CASTES.has(raw)) return raw as AntCaste;
|
|
24
|
+
if (raw.includes("侦察") || raw.includes("scout")) return "scout";
|
|
25
|
+
if (raw.includes("工") || raw.includes("worker")) return "worker";
|
|
26
|
+
if (raw.includes("兵") || raw.includes("review") || raw.includes("soldier")) return "soldier";
|
|
27
|
+
if (raw.includes("drone") || raw.includes("bash") || raw.includes("shell")) return "drone";
|
|
28
|
+
return "worker";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function extractFileLike(value: string): string[] {
|
|
32
|
+
const normalized = value.replace(/[,、;;]/g, ",").replace(/["']/g, "").replace(/`/g, "");
|
|
33
|
+
const tokens = normalized.split(",").map(s => s.trim()).filter(Boolean);
|
|
34
|
+
const fileish = tokens
|
|
35
|
+
.map(t => t.replace(/^\.?\//, ""))
|
|
36
|
+
.filter(t => /[./\\]/.test(t) || /\.[a-z0-9]+$/i.test(t));
|
|
37
|
+
return [...new Set(fileish)];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalizeJsonTasks(parsed: unknown): ParsedSubTask[] {
|
|
41
|
+
const arr = (Array.isArray(parsed) ? parsed : [parsed]) as Array<Record<string, unknown>>;
|
|
42
|
+
return arr.map((t) => ({
|
|
43
|
+
title: String(t.title || "Untitled"),
|
|
44
|
+
description: String(t.description || t.title || ""),
|
|
45
|
+
files: Array.isArray(t.files)
|
|
46
|
+
? t.files.map(String).map(f => f.trim()).filter(Boolean)
|
|
47
|
+
: extractFileLike(String(t.files || "")),
|
|
48
|
+
caste: normalizeCaste(t.caste),
|
|
49
|
+
priority: normalizePriority(t.priority),
|
|
50
|
+
context: t.context ? String(t.context) : undefined,
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parseTasksFromStructuredLines(output: string): ParsedSubTask[] {
|
|
55
|
+
const lines = output.split(/\r?\n/);
|
|
56
|
+
const tasks: ParsedSubTask[] = [];
|
|
57
|
+
|
|
58
|
+
let current: ParsedSubTask | null = null;
|
|
59
|
+
|
|
60
|
+
const flushCurrent = () => {
|
|
61
|
+
if (!current) return;
|
|
62
|
+
current.title = current.title.trim() || "Untitled";
|
|
63
|
+
current.description = current.description.trim() || current.title;
|
|
64
|
+
current.files = [...new Set(current.files.map(f => f.trim()).filter(Boolean))];
|
|
65
|
+
current.priority = normalizePriority(current.priority);
|
|
66
|
+
current.caste = normalizeCaste(current.caste);
|
|
67
|
+
if (current.context) current.context = current.context.trim();
|
|
68
|
+
tasks.push(current);
|
|
69
|
+
current = null;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const fieldMatch = (line: string) => {
|
|
73
|
+
return line.match(/^\s*(?:[-*]|\d+\.)?\s*(?:\*\*|__)?\s*(description|desc|描述|说明|files?|文件|路径|caste|role|角色|priority|prio|优先级|context|上下文)\s*(?:\*\*|__)?\s*[::]\s*(.*)$/i);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
for (let i = 0; i < lines.length; i++) {
|
|
77
|
+
const line = lines[i];
|
|
78
|
+
|
|
79
|
+
const header = line.match(TASK_HEADER_RE);
|
|
80
|
+
if (header) {
|
|
81
|
+
flushCurrent();
|
|
82
|
+
current = {
|
|
83
|
+
title: header[1]?.trim() || "Untitled",
|
|
84
|
+
description: "",
|
|
85
|
+
files: [],
|
|
86
|
+
caste: "worker",
|
|
87
|
+
priority: 3,
|
|
88
|
+
};
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!current) continue;
|
|
93
|
+
|
|
94
|
+
const m = fieldMatch(line);
|
|
95
|
+
if (!m) continue;
|
|
96
|
+
|
|
97
|
+
const key = m[1].toLowerCase();
|
|
98
|
+
const value = (m[2] || "").trim();
|
|
99
|
+
|
|
100
|
+
if (["description", "desc", "描述", "说明"].includes(key)) {
|
|
101
|
+
current.description = value;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (["files", "file", "文件", "路径"].includes(key)) {
|
|
106
|
+
current.files.push(...extractFileLike(value));
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (["caste", "role", "角色"].includes(key)) {
|
|
111
|
+
current.caste = normalizeCaste(value);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (["priority", "prio", "优先级"].includes(key)) {
|
|
116
|
+
current.priority = normalizePriority(value);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (["context", "上下文"].includes(key)) {
|
|
121
|
+
const contextLines = [value];
|
|
122
|
+
while (i + 1 < lines.length) {
|
|
123
|
+
const next = lines[i + 1];
|
|
124
|
+
if (TASK_HEADER_RE.test(next) || fieldMatch(next)) break;
|
|
125
|
+
if (/^\s*#{1,6}\s+/.test(next)) break;
|
|
126
|
+
contextLines.push(next);
|
|
127
|
+
i++;
|
|
128
|
+
}
|
|
129
|
+
current.context = contextLines.join("\n").trim();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
flushCurrent();
|
|
134
|
+
return tasks;
|
|
135
|
+
}
|
|
136
|
+
|
|
15
137
|
export function parseSubTasks(output: string): ParsedSubTask[] {
|
|
16
|
-
//
|
|
17
|
-
const jsonMatch = output.match(/```json\s*([\s\S]*?)```/);
|
|
138
|
+
// 1) JSON fenced block
|
|
139
|
+
const jsonMatch = output.match(/```json\s*([\s\S]*?)```/i);
|
|
18
140
|
if (jsonMatch?.[1]) {
|
|
19
141
|
try {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
return arr.map((t: Record<string, unknown>) => ({
|
|
23
|
-
title: String(t.title || "Untitled"),
|
|
24
|
-
description: String(t.description || t.title || ""),
|
|
25
|
-
files: Array.isArray(t.files) ? t.files.map(String) : String(t.files || "").split(",").map((f: string) => f.trim()).filter(Boolean),
|
|
26
|
-
caste: (VALID_CASTES.has(String(t.caste)) ? String(t.caste) : "worker") as AntCaste,
|
|
27
|
-
priority: (Math.min(5, Math.max(1, parseInt(String(t.priority || "3")))) as 1 | 2 | 3 | 4 | 5),
|
|
28
|
-
context: t.context ? String(t.context) : undefined,
|
|
29
|
-
}));
|
|
30
|
-
} catch { /* fallback to regex */ }
|
|
142
|
+
return normalizeJsonTasks(JSON.parse(jsonMatch[1].trim()));
|
|
143
|
+
} catch { /* fallback */ }
|
|
31
144
|
}
|
|
32
145
|
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
const regex = /### TASK:\s*(.+)\n(?:- description:\s*(.+)\n)?(?:- files:\s*(.+)\n)?(?:- caste:\s*(\w+)\n)?(?:- priority:\s*(\d))?/g;
|
|
36
|
-
const taskBlocks = output.split(/(?=### TASK:)/);
|
|
37
|
-
for (const m of output.matchAll(regex)) {
|
|
38
|
-
try {
|
|
39
|
-
const block = taskBlocks.find(b => b.includes(`### TASK: ${m[1]?.trim()}`)) || "";
|
|
40
|
-
const ctxMatch = block.match(/- context:\s*([\s\S]*?)(?=### TASK:|## |\n\n|$)/);
|
|
41
|
-
const context = ctxMatch?.[1]?.trim() || undefined;
|
|
42
|
-
tasks.push({
|
|
43
|
-
title: m[1]?.trim() || "Untitled",
|
|
44
|
-
description: m[2]?.trim() || m[1]?.trim() || "",
|
|
45
|
-
files: (m[3]?.trim() || "").split(",").map((f: string) => f.trim()).filter(Boolean),
|
|
46
|
-
caste: (VALID_CASTES.has(m[4]?.trim() ?? "") ? m[4]!.trim() : "worker") as AntCaste,
|
|
47
|
-
priority: (parseInt(m[5] || "3") as 1 | 2 | 3 | 4 | 5) || 3,
|
|
48
|
-
context,
|
|
49
|
-
});
|
|
50
|
-
} catch { /* skip malformed task, continue */ }
|
|
51
|
-
}
|
|
52
|
-
return tasks;
|
|
146
|
+
// 2) Structured markdown task blocks (English/Chinese)
|
|
147
|
+
return parseTasksFromStructuredLines(output);
|
|
53
148
|
}
|
|
54
149
|
|
|
55
150
|
export function extractPheromones(antId: string, caste: AntCaste, taskId: string, output: string, files: string[], failed = false): Pheromone[] {
|
|
@@ -13,7 +13,7 @@ vi.mock("@mariozechner/pi-coding-agent", () => ({
|
|
|
13
13
|
}));
|
|
14
14
|
vi.mock("@mariozechner/pi-ai", () => ({ getModel: vi.fn() }));
|
|
15
15
|
|
|
16
|
-
import { classifyError, quorumMergeTasks } from "./queen.js";
|
|
16
|
+
import { classifyError, quorumMergeTasks, shouldUseScoutQuorum, validateExecutionPlan } from "./queen.js";
|
|
17
17
|
import { Nest } from "./nest.js";
|
|
18
18
|
import type { ColonyState, Task } from "./types.js";
|
|
19
19
|
|
|
@@ -69,6 +69,40 @@ describe("classifyError", () => {
|
|
|
69
69
|
});
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
+
describe("shouldUseScoutQuorum", () => {
|
|
73
|
+
it("returns true for multi-step goals", () => {
|
|
74
|
+
expect(shouldUseScoutQuorum("1) scan repo; 2) write report; 3) review output")).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("returns false for simple single-step goals", () => {
|
|
78
|
+
expect(shouldUseScoutQuorum("List top-level files")).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("validateExecutionPlan", () => {
|
|
83
|
+
it("accepts well-formed worker tasks", () => {
|
|
84
|
+
const plan = validateExecutionPlan([
|
|
85
|
+
mkTask({ id: "t-plan-1", caste: "worker", title: "Do x", description: "desc", priority: 1, files: ["a.ts"] }),
|
|
86
|
+
]);
|
|
87
|
+
expect(plan.ok).toBe(true);
|
|
88
|
+
expect(plan.issues).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("rejects empty plans", () => {
|
|
92
|
+
const plan = validateExecutionPlan([]);
|
|
93
|
+
expect(plan.ok).toBe(false);
|
|
94
|
+
expect(plan.issues).toContain("no_pending_worker_tasks");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("flags non-worker cates as invalid for execution phase", () => {
|
|
98
|
+
const plan = validateExecutionPlan([
|
|
99
|
+
mkTask({ id: "t-plan-2", caste: "scout" as any }),
|
|
100
|
+
]);
|
|
101
|
+
expect(plan.ok).toBe(false);
|
|
102
|
+
expect(plan.issues.some(i => i.includes("invalid_caste"))).toBe(true);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
72
106
|
// ═══ quorumMergeTasks ═══
|
|
73
107
|
|
|
74
108
|
const mkState = (overrides: Partial<ColonyState> = {}): ColonyState => ({
|
|
@@ -136,6 +136,88 @@ export function quorumMergeTasks(nest: Nest): void {
|
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
export interface PlanValidation {
|
|
140
|
+
ok: boolean;
|
|
141
|
+
issues: string[];
|
|
142
|
+
warnings: string[];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function shouldUseScoutQuorum(goal: string): boolean {
|
|
146
|
+
// 多步骤/复合目标更适合至少 2 个 Scout 投票
|
|
147
|
+
return /(\n\s*\d+[\.)]|[;;]| and |以及|并且|同时|步骤|phase|then|之后)/i.test(goal);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function validateExecutionPlan(tasks: Task[]): PlanValidation {
|
|
151
|
+
const issues: string[] = [];
|
|
152
|
+
const warnings: string[] = [];
|
|
153
|
+
|
|
154
|
+
if (tasks.length === 0) {
|
|
155
|
+
issues.push("no_pending_worker_tasks");
|
|
156
|
+
return { ok: false, issues, warnings };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
for (const t of tasks) {
|
|
160
|
+
if (!t.title?.trim()) issues.push(`task:${t.id}:missing_title`);
|
|
161
|
+
if (!t.description?.trim()) issues.push(`task:${t.id}:missing_description`);
|
|
162
|
+
if (t.caste !== "worker" && t.caste !== "drone") issues.push(`task:${t.id}:invalid_caste:${t.caste}`);
|
|
163
|
+
if (t.priority < 1 || t.priority > 5) issues.push(`task:${t.id}:invalid_priority:${t.priority}`);
|
|
164
|
+
if (t.files.length === 0) warnings.push(`task:${t.id}:broad_scope`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return { ok: issues.length === 0, issues, warnings };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function collectScoutIntelligence(nest: Nest, maxChars = 6000): string {
|
|
171
|
+
const scoutResults = nest.getAllTasks()
|
|
172
|
+
.filter(t => t.caste === "scout" && t.status === "done" && t.result)
|
|
173
|
+
.map(t => `## ${t.title}\n${t.result}`)
|
|
174
|
+
.join("\n\n");
|
|
175
|
+
return scoutResults.slice(0, maxChars);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function makeRecoveryScoutTask(goal: string, attempt: number, planIssues: string[], intel: string): Task {
|
|
179
|
+
const issueText = planIssues.length > 0 ? planIssues.map(i => `- ${i}`).join("\n") : "- no parseable worker/drone tasks generated";
|
|
180
|
+
return {
|
|
181
|
+
id: makeTaskId(),
|
|
182
|
+
parentId: null,
|
|
183
|
+
title: `Scout recovery ${attempt}: structure executable plan`,
|
|
184
|
+
description: [
|
|
185
|
+
"Previous scout output could not pass plan validation.",
|
|
186
|
+
"Transform existing intelligence into a VALID structured execution plan.",
|
|
187
|
+
"",
|
|
188
|
+
"Goal:",
|
|
189
|
+
goal,
|
|
190
|
+
"",
|
|
191
|
+
"Validation issues:",
|
|
192
|
+
issueText,
|
|
193
|
+
"",
|
|
194
|
+
"Intelligence from prior scouts:",
|
|
195
|
+
intel || "(none)",
|
|
196
|
+
"",
|
|
197
|
+
"Output requirements (STRICT):",
|
|
198
|
+
"- Return at least ONE task block",
|
|
199
|
+
"### TASK: <title>",
|
|
200
|
+
"- description: <what to do>",
|
|
201
|
+
"- files: <comma-separated file paths>",
|
|
202
|
+
"- caste: worker",
|
|
203
|
+
"- priority: <1-5>",
|
|
204
|
+
"",
|
|
205
|
+
"Do NOT execute changes. Only planning.",
|
|
206
|
+
].join("\n"),
|
|
207
|
+
caste: "scout",
|
|
208
|
+
status: "pending",
|
|
209
|
+
priority: 1,
|
|
210
|
+
files: [],
|
|
211
|
+
claimedBy: null,
|
|
212
|
+
result: null,
|
|
213
|
+
error: null,
|
|
214
|
+
spawnedTasks: [],
|
|
215
|
+
createdAt: Date.now(),
|
|
216
|
+
startedAt: null,
|
|
217
|
+
finishedAt: null,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
139
221
|
function makeReviewTask(completedTasks: Task[]): Task {
|
|
140
222
|
const files = [...new Set(completedTasks.flatMap(t => t.files))];
|
|
141
223
|
return {
|
|
@@ -530,7 +612,8 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
|
|
|
530
612
|
|
|
531
613
|
try {
|
|
532
614
|
// ═══ Phase 1: 侦察(Bio 5: 蚁群投票 — 复杂目标派多 Scout) ═══
|
|
533
|
-
const
|
|
615
|
+
const scoutCountBase = opts.goal.length > 500 ? 3 : opts.goal.length > 200 ? 2 : 1;
|
|
616
|
+
const scoutCount = shouldUseScoutQuorum(opts.goal) ? Math.max(2, scoutCountBase) : scoutCountBase;
|
|
534
617
|
if (scoutCount > 1) {
|
|
535
618
|
// 多 Scout 并行:为每只 Scout 创建独立任务
|
|
536
619
|
for (let i = 1; i < scoutCount; i++) {
|
|
@@ -561,43 +644,36 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
|
|
|
561
644
|
// Bio 5: 合并多 Scout 产生的重复任务
|
|
562
645
|
if (scoutCount > 1) quorumMergeTasks(nest);
|
|
563
646
|
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
result: null,
|
|
583
|
-
error: null,
|
|
584
|
-
spawnedTasks: [],
|
|
585
|
-
createdAt: Date.now(),
|
|
586
|
-
startedAt: null,
|
|
587
|
-
finishedAt: null,
|
|
588
|
-
};
|
|
589
|
-
nest.writeTask(relayTask);
|
|
590
|
-
callbacks.onPhase?.("scouting", "Scout relay: generating worker tasks...");
|
|
591
|
-
emitSignal("scouting", "Retrying scout...");
|
|
647
|
+
const getPendingExecutionTasks = () => nest.getAllTasks().filter(t => (t.caste === "worker" || t.caste === "drone") && t.status === "pending");
|
|
648
|
+
|
|
649
|
+
let workerTasks = getPendingExecutionTasks();
|
|
650
|
+
let plan = validateExecutionPlan(workerTasks);
|
|
651
|
+
|
|
652
|
+
// 计划恢复回路:Scout 输出不可执行时,不直接造 Worker,先让 Scout 结构化重组
|
|
653
|
+
const MAX_PLAN_RECOVERY_ROUNDS = 2;
|
|
654
|
+
let recoveryRound = 0;
|
|
655
|
+
while (!plan.ok && recoveryRound < MAX_PLAN_RECOVERY_ROUNDS) {
|
|
656
|
+
recoveryRound++;
|
|
657
|
+
nest.updateState({ status: "planning_recovery" });
|
|
658
|
+
|
|
659
|
+
const intel = collectScoutIntelligence(nest);
|
|
660
|
+
const recoveryTask = makeRecoveryScoutTask(opts.goal, recoveryRound, plan.issues, intel);
|
|
661
|
+
nest.writeTask(recoveryTask);
|
|
662
|
+
|
|
663
|
+
callbacks.onPhase?.("planning_recovery", `Plan recovery ${recoveryRound}/${MAX_PLAN_RECOVERY_ROUNDS}: restructuring scout intelligence...`);
|
|
664
|
+
emitSignal("planning_recovery", `Recovering plan (${recoveryRound}/${MAX_PLAN_RECOVERY_ROUNDS})`);
|
|
592
665
|
await runAntWave({ ...waveBase, caste: "scout" });
|
|
593
|
-
|
|
666
|
+
quorumMergeTasks(nest);
|
|
667
|
+
|
|
668
|
+
workerTasks = getPendingExecutionTasks();
|
|
669
|
+
plan = validateExecutionPlan(workerTasks);
|
|
594
670
|
}
|
|
595
671
|
|
|
596
|
-
if (
|
|
672
|
+
if (!plan.ok) {
|
|
597
673
|
nest.updateState({ status: "failed", finishedAt: Date.now() });
|
|
598
674
|
const finalState = nest.getState();
|
|
599
675
|
callbacks.onComplete?.(finalState);
|
|
600
|
-
emitSignal("failed",
|
|
676
|
+
emitSignal("failed", `No valid execution plan: ${plan.issues.slice(0, 3).join(", ")}`);
|
|
601
677
|
return finalState;
|
|
602
678
|
}
|
|
603
679
|
|
|
@@ -95,7 +95,7 @@ export interface AntStreamEvent {
|
|
|
95
95
|
export interface ColonyState {
|
|
96
96
|
id: string;
|
|
97
97
|
goal: string;
|
|
98
|
-
status: "scouting" | "working" | "reviewing" | "done" | "failed" | "budget_exceeded";
|
|
98
|
+
status: "scouting" | "planning_recovery" | "working" | "reviewing" | "done" | "failed" | "budget_exceeded";
|
|
99
99
|
tasks: Task[];
|
|
100
100
|
ants: Ant[];
|
|
101
101
|
pheromones: Pheromone[];
|
|
@@ -27,6 +27,7 @@ describe("formatTokens", () => {
|
|
|
27
27
|
describe("statusIcon", () => {
|
|
28
28
|
it("scouting", () => expect(statusIcon("scouting")).toBe("🔍"));
|
|
29
29
|
it("working", () => expect(statusIcon("working")).toBe("⚒️"));
|
|
30
|
+
it("planning_recovery", () => expect(statusIcon("planning_recovery")).toBe("♻️"));
|
|
30
31
|
it("reviewing", () => expect(statusIcon("reviewing")).toBe("🛡️"));
|
|
31
32
|
it("done", () => expect(statusIcon("done")).toBe("✅"));
|
|
32
33
|
it("failed", () => expect(statusIcon("failed")).toBe("❌"));
|
|
@@ -17,7 +17,7 @@ export function formatTokens(n: number): string {
|
|
|
17
17
|
|
|
18
18
|
export function statusIcon(status: string): string {
|
|
19
19
|
const icons: Record<string, string> = {
|
|
20
|
-
scouting: "🔍", working: "⚒️", reviewing: "🛡️",
|
|
20
|
+
scouting: "🔍", planning_recovery: "♻️", working: "⚒️", reviewing: "🛡️",
|
|
21
21
|
done: "✅", failed: "❌", budget_exceeded: "💰",
|
|
22
22
|
};
|
|
23
23
|
return icons[status] || "🐜";
|
|
@@ -33,7 +33,8 @@ export function buildReport(state: ColonyState): string {
|
|
|
33
33
|
return [
|
|
34
34
|
`## 🐜 Ant Colony Report`,
|
|
35
35
|
`**Goal:** ${state.goal}`,
|
|
36
|
-
`**Status:** ${statusIcon(state.status)} ${state.status} │ ${
|
|
36
|
+
`**Status:** ${statusIcon(state.status)} ${state.status} │ ${formatCost(m.totalCost)}`,
|
|
37
|
+
`**Duration:** ${elapsed}`,
|
|
37
38
|
`**Tasks:** ${m.tasksDone}/${m.tasksTotal} done${m.tasksFailed > 0 ? `, ${m.tasksFailed} failed` : ""}`,
|
|
38
39
|
``,
|
|
39
40
|
...state.tasks.filter(t => t.status === "done").map(t =>
|
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
* oh-pi Background Process Extension
|
|
3
3
|
*
|
|
4
4
|
* 任何 bash 命令超时未完成时,自动送到后台执行。
|
|
5
|
+
* 进程完成后自动通过 sendMessage 通知 LLM,无需轮询。
|
|
5
6
|
* 提供 bg_status 工具让 LLM 查看/停止后台进程。
|
|
6
7
|
*/
|
|
7
8
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
8
9
|
import { Type } from "@sinclair/typebox";
|
|
9
10
|
import { StringEnum } from "@mariozechner/pi-ai";
|
|
10
11
|
import { spawn, execSync } from "node:child_process";
|
|
11
|
-
import { writeFileSync, readFileSync, existsSync } from "node:fs";
|
|
12
|
+
import { writeFileSync, readFileSync, appendFileSync, existsSync } from "node:fs";
|
|
12
13
|
|
|
13
14
|
/** 超时阈值(毫秒),超过此时间自动后台化 */
|
|
14
15
|
const BG_TIMEOUT_MS = 10_000;
|
|
@@ -18,6 +19,8 @@ interface BgProcess {
|
|
|
18
19
|
command: string;
|
|
19
20
|
logFile: string;
|
|
20
21
|
startedAt: number;
|
|
22
|
+
finished: boolean;
|
|
23
|
+
exitCode: number | null;
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
export default function (pi: ExtensionAPI) {
|
|
@@ -41,6 +44,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
41
44
|
let stdout = "";
|
|
42
45
|
let stderr = "";
|
|
43
46
|
let settled = false;
|
|
47
|
+
let backgrounded = false;
|
|
44
48
|
|
|
45
49
|
const child = spawn("bash", ["-c", command], {
|
|
46
50
|
cwd: process.cwd(),
|
|
@@ -48,36 +52,58 @@ export default function (pi: ExtensionAPI) {
|
|
|
48
52
|
stdio: ["ignore", "pipe", "pipe"],
|
|
49
53
|
});
|
|
50
54
|
|
|
51
|
-
child.stdout?.on("data", (d: Buffer) => {
|
|
52
|
-
|
|
55
|
+
child.stdout?.on("data", (d: Buffer) => {
|
|
56
|
+
const chunk = d.toString();
|
|
57
|
+
stdout += chunk;
|
|
58
|
+
// 后台化后追加写入日志
|
|
59
|
+
if (backgrounded) {
|
|
60
|
+
try { appendFileSync(bgProcesses.get(child.pid!)?.logFile ?? "", chunk); } catch {}
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
child.stderr?.on("data", (d: Buffer) => {
|
|
64
|
+
const chunk = d.toString();
|
|
65
|
+
stderr += chunk;
|
|
66
|
+
if (backgrounded) {
|
|
67
|
+
try { appendFileSync(bgProcesses.get(child.pid!)?.logFile ?? "", chunk); } catch {}
|
|
68
|
+
}
|
|
69
|
+
});
|
|
53
70
|
|
|
54
|
-
//
|
|
71
|
+
// 超时处理:保持管道,标记为后台
|
|
55
72
|
const timer = setTimeout(() => {
|
|
56
73
|
if (settled) return;
|
|
57
74
|
settled = true;
|
|
75
|
+
backgrounded = true;
|
|
58
76
|
|
|
59
|
-
// 分离子进程,让它继续运行
|
|
60
|
-
child.stdout?.removeAllListeners();
|
|
61
|
-
child.stderr?.removeAllListeners();
|
|
62
|
-
child.removeAllListeners();
|
|
63
77
|
child.unref();
|
|
64
78
|
|
|
65
79
|
const logFile = `/tmp/oh-pi-bg-${Date.now()}.log`;
|
|
66
80
|
const pid = child.pid!;
|
|
67
81
|
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
82
|
+
// 把已有输出写入日志
|
|
83
|
+
writeFileSync(logFile, stdout + stderr);
|
|
84
|
+
|
|
85
|
+
const proc: BgProcess = { pid, command, logFile, startedAt: Date.now(), finished: false, exitCode: null };
|
|
86
|
+
bgProcesses.set(pid, proc);
|
|
87
|
+
|
|
88
|
+
// 监听完成事件,自动通知 LLM
|
|
89
|
+
child.on("close", (code) => {
|
|
90
|
+
proc.finished = true;
|
|
91
|
+
proc.exitCode = code;
|
|
92
|
+
const tail = (stdout + stderr).slice(-3000);
|
|
93
|
+
const truncated = (stdout + stderr).length > 3000 ? "[...truncated]\n" + tail : tail;
|
|
94
|
+
// 最终输出写入日志
|
|
95
|
+
try { writeFileSync(logFile, stdout + stderr); } catch {}
|
|
96
|
+
|
|
97
|
+
pi.sendMessage({
|
|
98
|
+
content: `[BG_PROCESS_DONE] PID ${pid} finished (exit ${code ?? "?"})\nCommand: ${command}\n\nOutput (last 3000 chars):\n${truncated}`,
|
|
99
|
+
display: true,
|
|
100
|
+
triggerTurn: true,
|
|
101
|
+
deliverAs: "followUp",
|
|
102
|
+
});
|
|
103
|
+
});
|
|
78
104
|
|
|
79
105
|
const preview = (stdout + stderr).slice(0, 500);
|
|
80
|
-
const text = `Command still running after ${effectiveTimeout / 1000}s, moved to background.\nPID: ${pid}\nLog: ${logFile}\
|
|
106
|
+
const text = `Command still running after ${effectiveTimeout / 1000}s, moved to background.\nPID: ${pid}\nLog: ${logFile}\nStop: kill ${pid}\n\nOutput so far:\n${preview}\n\n⏳ You will be notified automatically when it finishes. No need to poll.`;
|
|
81
107
|
|
|
82
108
|
resolve({
|
|
83
109
|
content: [{ type: "text", text }],
|
|
@@ -85,7 +111,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
85
111
|
});
|
|
86
112
|
}, effectiveTimeout);
|
|
87
113
|
|
|
88
|
-
//
|
|
114
|
+
// 正常结束(超时前)
|
|
89
115
|
child.on("close", (code) => {
|
|
90
116
|
if (settled) return;
|
|
91
117
|
settled = true;
|
|
@@ -146,8 +172,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
146
172
|
return { content: [{ type: "text", text: "No background processes." }], details: {} };
|
|
147
173
|
}
|
|
148
174
|
const lines = [...bgProcesses.values()].map((p) => {
|
|
149
|
-
const
|
|
150
|
-
const status = alive ? "🟢 running" : "⚪ stopped";
|
|
175
|
+
const status = p.finished ? `⚪ stopped (exit ${p.exitCode ?? "?"})` : (isAlive(p.pid) ? "🟢 running" : "⚪ stopped");
|
|
151
176
|
return `PID: ${p.pid} | ${status} | Log: ${p.logFile}\n Cmd: ${p.command}`;
|
|
152
177
|
});
|
|
153
178
|
return { content: [{ type: "text", text: lines.join("\n\n") }], details: {} };
|
|
@@ -171,13 +196,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
171
196
|
return { content: [{ type: "text", text: `Error reading log: ${e.message}` }], details: {}, isError: true };
|
|
172
197
|
}
|
|
173
198
|
}
|
|
174
|
-
|
|
175
|
-
try {
|
|
176
|
-
const out = execSync(`tail -20 /proc/${pid}/fd/1 2>/dev/null || echo "(cannot read output)"`, { timeout: 3000 }).toString();
|
|
177
|
-
return { content: [{ type: "text", text: out }], details: {} };
|
|
178
|
-
} catch {
|
|
179
|
-
return { content: [{ type: "text", text: "No log available for this PID." }], details: {} };
|
|
180
|
-
}
|
|
199
|
+
return { content: [{ type: "text", text: "No log available for this PID." }], details: {} };
|
|
181
200
|
}
|
|
182
201
|
|
|
183
202
|
if (action === "stop") {
|
|
@@ -197,8 +216,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
197
216
|
|
|
198
217
|
// 清理:退出时杀掉所有后台进程
|
|
199
218
|
pi.on("session_shutdown", async () => {
|
|
200
|
-
for (const [pid] of bgProcesses) {
|
|
201
|
-
|
|
219
|
+
for (const [pid, proc] of bgProcesses) {
|
|
220
|
+
if (!proc.finished) {
|
|
221
|
+
try { process.kill(pid, "SIGTERM"); } catch {}
|
|
222
|
+
}
|
|
202
223
|
}
|
|
203
224
|
bgProcesses.clear();
|
|
204
225
|
});
|