oh-pi 0.1.69 → 0.1.70
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/tui/provider-setup.js +33 -3
- package/package.json +1 -1
- package/pi-package/extensions/ant-colony/concurrency.ts +17 -11
- package/pi-package/extensions/ant-colony/index.ts +23 -44
- package/pi-package/extensions/ant-colony/nest.ts +102 -20
- package/pi-package/extensions/ant-colony/parser.ts +34 -12
- package/pi-package/extensions/ant-colony/queen.ts +73 -25
- package/pi-package/extensions/ant-colony/spawner.ts +22 -5
- package/pi-package/extensions/ant-colony/types.ts +1 -0
|
@@ -12,6 +12,36 @@ const PROVIDER_API_URLS = {
|
|
|
12
12
|
xai: "https://api.x.ai",
|
|
13
13
|
mistral: "https://api.mistral.ai",
|
|
14
14
|
};
|
|
15
|
+
/** Block internal/private IPs to prevent SSRF */
|
|
16
|
+
function isUnsafeUrl(urlStr) {
|
|
17
|
+
try {
|
|
18
|
+
const u = new URL(urlStr);
|
|
19
|
+
const host = u.hostname;
|
|
20
|
+
// Allow localhost for local dev servers (Ollama, vLLM, etc.)
|
|
21
|
+
if (host === "localhost" || host === "127.0.0.1" || host === "::1")
|
|
22
|
+
return false;
|
|
23
|
+
// Block private IP ranges
|
|
24
|
+
if (/^10\./.test(host))
|
|
25
|
+
return true;
|
|
26
|
+
if (/^172\.(1[6-9]|2\d|3[01])\./.test(host))
|
|
27
|
+
return true;
|
|
28
|
+
if (/^192\.168\./.test(host))
|
|
29
|
+
return true;
|
|
30
|
+
if (/^0\./.test(host) || host === "0.0.0.0")
|
|
31
|
+
return true;
|
|
32
|
+
if (host.includes(":") || host.startsWith("["))
|
|
33
|
+
return true;
|
|
34
|
+
if (/^169\.254\./.test(host))
|
|
35
|
+
return true;
|
|
36
|
+
// Block non-https for remote hosts
|
|
37
|
+
if (u.protocol !== "https:")
|
|
38
|
+
return true;
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
15
45
|
/**
|
|
16
46
|
* 动态获取模型列表,依次尝试 Anthropic、Google、OpenAI 兼容 API 风格。
|
|
17
47
|
* @param provider - 提供商名称
|
|
@@ -85,7 +115,7 @@ async function fetchModels(provider, baseUrl, apiKey) {
|
|
|
85
115
|
api: "openai-completions",
|
|
86
116
|
models: data.map((m) => ({
|
|
87
117
|
id: m.id,
|
|
88
|
-
reasoning: m.thinking_enabled ?? m.id.includes("o3")
|
|
118
|
+
reasoning: m.thinking_enabled ?? m.id.includes("o3"),
|
|
89
119
|
input: ["text", "image"],
|
|
90
120
|
contextWindow: m.context_window ?? m.max_tokens ?? 128000,
|
|
91
121
|
maxTokens: m.max_output ?? 16384,
|
|
@@ -158,7 +188,7 @@ export async function setupProviders(env) {
|
|
|
158
188
|
const url = await p.text({
|
|
159
189
|
message: t("provider.baseUrl", { label: info.label }),
|
|
160
190
|
placeholder: "https://proxy.example.com",
|
|
161
|
-
validate: (v) => (!v || !v.startsWith("http")) ? t("provider.baseUrlValidation") : undefined,
|
|
191
|
+
validate: (v) => (!v || !v.startsWith("http")) ? t("provider.baseUrlValidation") : isUnsafeUrl(v) ? "URL must use HTTPS for remote hosts (private IPs blocked)" : undefined,
|
|
162
192
|
});
|
|
163
193
|
if (p.isCancel(url)) {
|
|
164
194
|
p.cancel(t("cancelled"));
|
|
@@ -203,7 +233,7 @@ async function setupCustomProvider() {
|
|
|
203
233
|
const baseUrl = await p.text({
|
|
204
234
|
message: t("provider.baseUrlCustom"),
|
|
205
235
|
placeholder: t("provider.baseUrlCustomPlaceholder"),
|
|
206
|
-
validate: (v) => (!v || !v.startsWith("http")) ? t("provider.baseUrlValidation") : undefined,
|
|
236
|
+
validate: (v) => (!v || !v.startsWith("http")) ? t("provider.baseUrlValidation") : isUnsafeUrl(v) ? "URL must use HTTPS for remote hosts (private IPs blocked)" : undefined,
|
|
207
237
|
});
|
|
208
238
|
if (p.isCancel(baseUrl)) {
|
|
209
239
|
p.cancel(t("cancelled"));
|
package/package.json
CHANGED
|
@@ -76,18 +76,27 @@ export function adapt(config: ConcurrencyConfig, pendingTasks: number): Concurre
|
|
|
76
76
|
const latest = samples[samples.length - 1];
|
|
77
77
|
const prev = samples[samples.length - 2];
|
|
78
78
|
|
|
79
|
-
//
|
|
80
|
-
|
|
79
|
+
// CPU 滑动窗口:最近 3 次采样平均值
|
|
80
|
+
const recentCpuSamples = samples.slice(-3);
|
|
81
|
+
const avgCpu = recentCpuSamples.reduce((s, x) => s + x.cpuLoad, 0) / recentCpuSamples.length;
|
|
82
|
+
|
|
83
|
+
// 429 冷却:30s 内不允许增加并发
|
|
84
|
+
const inRateLimitCooldown = config.lastRateLimitAt != null && Date.now() - config.lastRateLimitAt < 30000;
|
|
85
|
+
|
|
86
|
+
// 硬约束:系统过载立即减少(滞回带:>85% 减,60%-85% 保持)
|
|
87
|
+
if (avgCpu > 0.85 || latest.memFree < 500 * 1024 * 1024) {
|
|
81
88
|
next.current = Math.max(config.min, config.current - 1);
|
|
82
89
|
return next;
|
|
83
90
|
}
|
|
84
91
|
|
|
92
|
+
// 滞回带:CPU 在 60%-85% 之间保持不变
|
|
93
|
+
const canIncrease = avgCpu < 0.6 && !inRateLimitCooldown;
|
|
94
|
+
|
|
85
95
|
// 探索期:样本不足,逐步提升
|
|
86
96
|
if (samples.length < 10) {
|
|
87
|
-
if (latest.throughput >= prev.throughput) {
|
|
88
|
-
// 吞吐量还在涨,继续探索
|
|
97
|
+
if (latest.throughput >= prev.throughput && canIncrease) {
|
|
89
98
|
next.current = Math.min(config.current + 1, taskCap);
|
|
90
|
-
} else {
|
|
99
|
+
} else if (latest.throughput < prev.throughput) {
|
|
91
100
|
// 吞吐量下降,找到拐点
|
|
92
101
|
next.optimal = prev.concurrency;
|
|
93
102
|
next.current = prev.concurrency;
|
|
@@ -99,20 +108,17 @@ export function adapt(config: ConcurrencyConfig, pendingTasks: number): Concurre
|
|
|
99
108
|
const recentThroughput = samples.slice(-5).reduce((s, x) => s + x.throughput, 0) / 5;
|
|
100
109
|
const olderThroughput = samples.slice(-10, -5).reduce((s, x) => s + x.throughput, 0) / 5;
|
|
101
110
|
|
|
102
|
-
if (recentThroughput > olderThroughput * 1.1 &&
|
|
103
|
-
// 吞吐量上升且 CPU 有余量,尝试+1
|
|
111
|
+
if (recentThroughput > olderThroughput * 1.1 && canIncrease) {
|
|
104
112
|
next.current = Math.min(config.current + 1, taskCap);
|
|
105
113
|
if (recentThroughput > olderThroughput * 1.2) {
|
|
106
|
-
next.optimal = next.current;
|
|
114
|
+
next.optimal = next.current;
|
|
107
115
|
}
|
|
108
116
|
} else if (recentThroughput < olderThroughput * 0.8) {
|
|
109
|
-
// 吞吐量下降,回退
|
|
110
117
|
next.current = Math.max(config.min, config.optimal);
|
|
111
118
|
}
|
|
112
|
-
// 否则保持不变
|
|
113
119
|
|
|
114
120
|
// 429 recovery: restore to optimal when CPU is underutilized (e.g. after backoff)
|
|
115
|
-
if (
|
|
121
|
+
if (avgCpu < 0.5 && next.current < config.optimal && !inRateLimitCooldown) {
|
|
116
122
|
next.current = config.optimal;
|
|
117
123
|
}
|
|
118
124
|
|
|
@@ -18,10 +18,19 @@ import { runColony, resumeColony, type QueenCallbacks } from "./queen.js";
|
|
|
18
18
|
import { Nest } from "./nest.js";
|
|
19
19
|
import type { ColonyState, ColonyMetrics, AntStreamEvent } from "./types.js";
|
|
20
20
|
|
|
21
|
-
import { formatDuration, formatCost, formatTokens, statusIcon, casteIcon } from "./ui.js";
|
|
21
|
+
import { formatDuration, formatCost, formatTokens, statusIcon, casteIcon, buildReport } from "./ui.js";
|
|
22
22
|
|
|
23
23
|
// ═══ Background colony state ═══
|
|
24
24
|
|
|
25
|
+
/** Ensure .ant-colony/ is in .gitignore */
|
|
26
|
+
function ensureGitignore(cwd: string) {
|
|
27
|
+
const gitignorePath = join(cwd, ".gitignore");
|
|
28
|
+
const content = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf-8") : "";
|
|
29
|
+
if (!content.includes(".ant-colony/")) {
|
|
30
|
+
appendFileSync(gitignorePath, `${content.length && !content.endsWith("\n") ? "\n" : ""}.ant-colony/\n`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
25
34
|
interface AntStreamState {
|
|
26
35
|
antId: string;
|
|
27
36
|
caste: string;
|
|
@@ -89,11 +98,7 @@ export default function antColonyExtension(pi: ExtensionAPI) {
|
|
|
89
98
|
cwd: string;
|
|
90
99
|
modelRegistry?: any;
|
|
91
100
|
}, signal?: AbortSignal | null) {
|
|
92
|
-
|
|
93
|
-
const gitContent = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf-8") : "";
|
|
94
|
-
if (!gitContent.includes(".ant-colony/")) {
|
|
95
|
-
appendFileSync(gitignorePath, `${gitContent.length && !gitContent.endsWith("\n") ? "\n" : ""}.ant-colony/\n`);
|
|
96
|
-
}
|
|
101
|
+
ensureGitignore(params.cwd);
|
|
97
102
|
|
|
98
103
|
const callbacks: QueenCallbacks = {};
|
|
99
104
|
|
|
@@ -110,24 +115,8 @@ export default function antColonyExtension(pi: ExtensionAPI) {
|
|
|
110
115
|
modelRegistry: params.modelRegistry,
|
|
111
116
|
});
|
|
112
117
|
|
|
113
|
-
const m = state.metrics;
|
|
114
|
-
const elapsed = state.finishedAt ? formatDuration(state.finishedAt - state.createdAt) : "?";
|
|
115
|
-
const report = [
|
|
116
|
-
`## 🐜 Ant Colony Report`,
|
|
117
|
-
`**Goal:** ${state.goal}`,
|
|
118
|
-
`**Status:** ${statusIcon(state.status)} ${state.status} │ ${elapsed} │ ${formatCost(m.totalCost)}`,
|
|
119
|
-
`**Tasks:** ${m.tasksDone}/${m.tasksTotal} done${m.tasksFailed > 0 ? `, ${m.tasksFailed} failed` : ""}`,
|
|
120
|
-
``,
|
|
121
|
-
...state.tasks.filter(t => t.status === "done").map(t =>
|
|
122
|
-
`- ✓ **${t.title}**`
|
|
123
|
-
),
|
|
124
|
-
...state.tasks.filter(t => t.status === "failed").map(t =>
|
|
125
|
-
`- ✗ **${t.title}** — ${t.error?.slice(0, 80) || "unknown"}`
|
|
126
|
-
),
|
|
127
|
-
].join("\n");
|
|
128
|
-
|
|
129
118
|
return {
|
|
130
|
-
content: [{ type: "text" as const, text:
|
|
119
|
+
content: [{ type: "text" as const, text: buildReport(state) }],
|
|
131
120
|
isError: state.status === "failed" || state.status === "budget_exceeded",
|
|
132
121
|
};
|
|
133
122
|
} catch (e) {
|
|
@@ -226,11 +215,7 @@ export default function antColonyExtension(pi: ExtensionAPI) {
|
|
|
226
215
|
};
|
|
227
216
|
|
|
228
217
|
// Ensure .ant-colony/ is in .gitignore
|
|
229
|
-
|
|
230
|
-
const gitContent = existsSync(gitignorePath) ? readFileSync(gitignorePath, "utf-8") : "";
|
|
231
|
-
if (!gitContent.includes(".ant-colony/")) {
|
|
232
|
-
appendFileSync(gitignorePath, `${gitContent.length && !gitContent.endsWith("\n") ? "\n" : ""}.ant-colony/\n`);
|
|
233
|
-
}
|
|
218
|
+
ensureGitignore(params.cwd);
|
|
234
219
|
|
|
235
220
|
const colonyOpts = {
|
|
236
221
|
cwd: params.cwd,
|
|
@@ -251,23 +236,9 @@ export default function antColonyExtension(pi: ExtensionAPI) {
|
|
|
251
236
|
|
|
252
237
|
// 后台等待完成,注入结果
|
|
253
238
|
colony.promise.then((state) => {
|
|
254
|
-
const m = state.metrics;
|
|
255
|
-
const elapsed = state.finishedAt ? formatDuration(state.finishedAt - state.createdAt) : "?";
|
|
256
239
|
const ok = state.status === "done";
|
|
257
|
-
|
|
258
|
-
const
|
|
259
|
-
`## 🐜 Ant Colony Report`,
|
|
260
|
-
`**Goal:** ${state.goal}`,
|
|
261
|
-
`**Status:** ${statusIcon(state.status)} ${state.status} │ ${elapsed} │ ${formatCost(m.totalCost)}`,
|
|
262
|
-
`**Tasks:** ${m.tasksDone}/${m.tasksTotal} done${m.tasksFailed > 0 ? `, ${m.tasksFailed} failed` : ""}`,
|
|
263
|
-
``,
|
|
264
|
-
...state.tasks.filter(t => t.status === "done").map(t =>
|
|
265
|
-
`- ✓ **${t.title}**`
|
|
266
|
-
),
|
|
267
|
-
...state.tasks.filter(t => t.status === "failed").map(t =>
|
|
268
|
-
`- ✗ **${t.title}** — ${t.error?.slice(0, 80) || "unknown"}`
|
|
269
|
-
),
|
|
270
|
-
].join("\n");
|
|
240
|
+
const report = buildReport(state);
|
|
241
|
+
const m = state.metrics;
|
|
271
242
|
|
|
272
243
|
// 清理 UI
|
|
273
244
|
pi.events.emit("ant-colony:clear-ui");
|
|
@@ -594,6 +565,14 @@ export default function antColonyExtension(pi: ExtensionAPI) {
|
|
|
594
565
|
pi.on("session_shutdown", async () => {
|
|
595
566
|
if (activeColony) {
|
|
596
567
|
activeColony.abortController.abort();
|
|
568
|
+
// Wait for colony to finish gracefully (max 5s)
|
|
569
|
+
try {
|
|
570
|
+
await Promise.race([
|
|
571
|
+
activeColony.promise,
|
|
572
|
+
new Promise(r => setTimeout(r, 5000)),
|
|
573
|
+
]);
|
|
574
|
+
} catch { /* ignore */ }
|
|
575
|
+
pi.events.emit("ant-colony:clear-ui");
|
|
597
576
|
activeColony = null;
|
|
598
577
|
}
|
|
599
578
|
});
|
|
@@ -22,6 +22,8 @@ export class Nest {
|
|
|
22
22
|
private pheromoneOffset: number = 0;
|
|
23
23
|
private taskCache: Map<string, Task> = new Map();
|
|
24
24
|
private stateCache: ColonyState | null = null;
|
|
25
|
+
private gcCounter: number = 0;
|
|
26
|
+
private pheromoneByFile: Map<string, Pheromone[]> = new Map();
|
|
25
27
|
|
|
26
28
|
constructor(private cwd: string, private colonyId: string) {
|
|
27
29
|
this.dir = path.join(cwd, ".ant-colony", colonyId);
|
|
@@ -90,12 +92,50 @@ export class Nest {
|
|
|
90
92
|
}
|
|
91
93
|
|
|
92
94
|
claimTask(taskId: string, antId: string): boolean {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
95
|
+
return this.withStateLock(() => {
|
|
96
|
+
const task = this.getTask(taskId);
|
|
97
|
+
if (!task || task.status !== "pending") return false;
|
|
98
|
+
task.status = "claimed";
|
|
99
|
+
task.claimedBy = antId;
|
|
100
|
+
this.writeTask(task);
|
|
101
|
+
return true;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 原子选取并 claim 下一个任务,消灭 nextPendingTask+claimTask 之间的竞态窗口 */
|
|
106
|
+
claimNextTask(caste: "scout" | "worker" | "soldier" | "drone", antId: string): Task | null {
|
|
107
|
+
return this.withStateLock(() => {
|
|
108
|
+
const tasks = this.getAllTasks().filter(t => t.status === "pending" && t.caste === caste);
|
|
109
|
+
if (tasks.length === 0) return null;
|
|
110
|
+
|
|
111
|
+
this.getAllPheromones();
|
|
112
|
+
let chosen: Task;
|
|
113
|
+
if (tasks.length > 1 && Math.random() < 0.1) {
|
|
114
|
+
chosen = tasks[Math.floor(Math.random() * tasks.length)];
|
|
115
|
+
} else {
|
|
116
|
+
const scored = tasks.map(t => {
|
|
117
|
+
let pScore = 0;
|
|
118
|
+
const seen = new Set<Pheromone>();
|
|
119
|
+
for (const f of t.files) {
|
|
120
|
+
for (const p of this.pheromoneByFile.get(f) ?? []) {
|
|
121
|
+
if (seen.has(p) || p.strength <= 0.1) continue;
|
|
122
|
+
seen.add(p);
|
|
123
|
+
if (p.type === "discovery" || p.type === "completion") pScore += p.strength;
|
|
124
|
+
else if (p.type === "repellent") pScore -= p.strength * 3;
|
|
125
|
+
else if (p.type === "warning") pScore -= p.strength;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return { task: t, score: (6 - t.priority) + pScore };
|
|
129
|
+
});
|
|
130
|
+
scored.sort((a, b) => b.score - a.score);
|
|
131
|
+
chosen = scored[0].task;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
chosen.status = "claimed";
|
|
135
|
+
chosen.claimedBy = antId;
|
|
136
|
+
this.writeTask(chosen);
|
|
137
|
+
return chosen;
|
|
138
|
+
});
|
|
99
139
|
}
|
|
100
140
|
|
|
101
141
|
updateTaskStatus(taskId: string, status: TaskStatus, result?: string, error?: string): void {
|
|
@@ -129,15 +169,21 @@ export class Nest {
|
|
|
129
169
|
return tasks[Math.floor(Math.random() * tasks.length)];
|
|
130
170
|
}
|
|
131
171
|
|
|
132
|
-
//
|
|
133
|
-
|
|
172
|
+
// 信息素加权:用索引查询而非全量扫描
|
|
173
|
+
this.getAllPheromones(); // 确保索引已建立
|
|
134
174
|
const scored = tasks.map(t => {
|
|
135
175
|
let pScore = 0;
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
176
|
+
const seen = new Set<Pheromone>();
|
|
177
|
+
for (const f of t.files) {
|
|
178
|
+
const related = this.pheromoneByFile.get(f);
|
|
179
|
+
if (!related) continue;
|
|
180
|
+
for (const p of related) {
|
|
181
|
+
if (seen.has(p) || p.strength <= 0.1) continue;
|
|
182
|
+
seen.add(p);
|
|
183
|
+
if (p.type === "discovery" || p.type === "completion") pScore += p.strength;
|
|
184
|
+
else if (p.type === "repellent") pScore -= p.strength * 3;
|
|
185
|
+
else if (p.type === "warning") pScore -= p.strength;
|
|
186
|
+
}
|
|
141
187
|
}
|
|
142
188
|
return { task: t, score: (6 - t.priority) + pScore };
|
|
143
189
|
});
|
|
@@ -171,10 +217,32 @@ export class Nest {
|
|
|
171
217
|
}
|
|
172
218
|
|
|
173
219
|
// 衰减 + 过滤弱信息素
|
|
220
|
+
const beforeLen = this.pheromoneCache.length;
|
|
174
221
|
this.pheromoneCache = this.pheromoneCache.filter(p => {
|
|
175
|
-
p.strength =
|
|
222
|
+
p.strength = Math.pow(0.5, (now - p.createdAt) / HALF_LIFE);
|
|
176
223
|
return p.strength > 0.05;
|
|
177
224
|
});
|
|
225
|
+
const hadGarbage = this.pheromoneCache.length < beforeLen;
|
|
226
|
+
|
|
227
|
+
// 重建文件索引
|
|
228
|
+
this.pheromoneByFile.clear();
|
|
229
|
+
for (const p of this.pheromoneCache) {
|
|
230
|
+
for (const f of p.files) {
|
|
231
|
+
let arr = this.pheromoneByFile.get(f);
|
|
232
|
+
if (!arr) { arr = []; this.pheromoneByFile.set(f, arr); }
|
|
233
|
+
arr.push(p);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// GC:每 10 次调用,若有弱条目被过滤则重写文件
|
|
238
|
+
this.gcCounter++;
|
|
239
|
+
if (this.gcCounter >= 10 && hadGarbage) {
|
|
240
|
+
this.gcCounter = 0;
|
|
241
|
+
const tmp = this.pheromoneFile + ".tmp";
|
|
242
|
+
fs.writeFileSync(tmp, this.pheromoneCache.map(p => JSON.stringify(p)).join("\n") + (this.pheromoneCache.length ? "\n" : ""));
|
|
243
|
+
fs.renameSync(tmp, this.pheromoneFile);
|
|
244
|
+
this.pheromoneOffset = fs.statSync(this.pheromoneFile).size;
|
|
245
|
+
}
|
|
178
246
|
|
|
179
247
|
return this.pheromoneCache;
|
|
180
248
|
}
|
|
@@ -234,16 +302,22 @@ export class Nest {
|
|
|
234
302
|
const start = Date.now();
|
|
235
303
|
while (true) {
|
|
236
304
|
try {
|
|
237
|
-
fs.writeFileSync(this.lockFile, `${process.pid}`, { flag: "wx" });
|
|
305
|
+
fs.writeFileSync(this.lockFile, `${process.pid}:${Date.now()}`, { flag: "wx" });
|
|
238
306
|
break;
|
|
239
307
|
} catch {
|
|
240
308
|
if (Date.now() - start > MAX_WAIT) {
|
|
241
|
-
// 超时:检查锁持有者是否存活
|
|
242
309
|
try {
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
310
|
+
const content = fs.readFileSync(this.lockFile, "utf-8");
|
|
311
|
+
const [pidStr, tsStr] = content.split(":");
|
|
312
|
+
const holder = parseInt(pidStr, 10);
|
|
313
|
+
const lockTime = parseInt(tsStr, 10);
|
|
314
|
+
// 超过 30s 的锁视为过期
|
|
315
|
+
if (lockTime && Date.now() - lockTime > 30_000) { fs.unlinkSync(this.lockFile); continue; }
|
|
316
|
+
// 进程存活检查作为第二道防线
|
|
317
|
+
try { process.kill(holder, 0); } catch { fs.unlinkSync(this.lockFile); continue; }
|
|
318
|
+
} catch { try { fs.unlinkSync(this.lockFile); } catch {} }
|
|
319
|
+
// 进程存活且锁未过期,放弃等待
|
|
320
|
+
throw new Error(`[Nest] withStateLock timeout after ${MAX_WAIT}ms`);
|
|
247
321
|
}
|
|
248
322
|
// 简单 busy-wait,避免 SharedArrayBuffer 依赖
|
|
249
323
|
const until = Date.now() + SPIN_MS + Math.random() * SPIN_MS;
|
|
@@ -294,5 +368,13 @@ export class Nest {
|
|
|
294
368
|
this.writeTask(task);
|
|
295
369
|
}
|
|
296
370
|
}
|
|
371
|
+
// 将 orphaned working/idle ants 标记为 failed
|
|
372
|
+
for (const ant of this.stateCache.ants) {
|
|
373
|
+
if (ant.status === "working" || ant.status === "idle") {
|
|
374
|
+
ant.status = "failed";
|
|
375
|
+
ant.finishedAt = Date.now();
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
this.writeJson(this.stateFile, this.stateCache);
|
|
297
379
|
}
|
|
298
380
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { AntCaste, Pheromone, PheromoneType } from "./types.js";
|
|
2
2
|
import { makePheromoneId } from "./spawner.js";
|
|
3
3
|
|
|
4
|
+
const VALID_CASTES = new Set(["scout", "worker", "soldier", "drone"]);
|
|
5
|
+
|
|
4
6
|
export interface ParsedSubTask {
|
|
5
7
|
title: string;
|
|
6
8
|
description: string;
|
|
@@ -11,21 +13,41 @@ export interface ParsedSubTask {
|
|
|
11
13
|
}
|
|
12
14
|
|
|
13
15
|
export function parseSubTasks(output: string): ParsedSubTask[] {
|
|
16
|
+
// Try JSON block first
|
|
17
|
+
const jsonMatch = output.match(/```json\s*([\s\S]*?)```/);
|
|
18
|
+
if (jsonMatch?.[1]) {
|
|
19
|
+
try {
|
|
20
|
+
const parsed = JSON.parse(jsonMatch[1].trim());
|
|
21
|
+
const arr = Array.isArray(parsed) ? parsed : [parsed];
|
|
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 */ }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Fallback: regex parsing with per-task try-catch
|
|
14
34
|
const tasks: ParsedSubTask[] = [];
|
|
15
35
|
const regex = /### TASK:\s*(.+)\n(?:- description:\s*(.+)\n)?(?:- files:\s*(.+)\n)?(?:- caste:\s*(\w+)\n)?(?:- priority:\s*(\d))?/g;
|
|
16
36
|
const taskBlocks = output.split(/(?=### TASK:)/);
|
|
17
37
|
for (const m of output.matchAll(regex)) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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 */ }
|
|
29
51
|
}
|
|
30
52
|
return tasks;
|
|
31
53
|
}
|
|
@@ -35,7 +57,7 @@ export function extractPheromones(antId: string, caste: AntCaste, taskId: string
|
|
|
35
57
|
const now = Date.now();
|
|
36
58
|
const sections = ["Discoveries", "Pheromone", "Files Changed", "Warnings", "Review"];
|
|
37
59
|
for (const section of sections) {
|
|
38
|
-
const regex = new RegExp(
|
|
60
|
+
const regex = new RegExp(`#{1,2} ${section}\\n([\\s\\S]*?)(?=\\n#{1,2} |$)`, "i");
|
|
39
61
|
const match = output.match(regex);
|
|
40
62
|
if (match?.[1]?.trim()) {
|
|
41
63
|
const type: PheromoneType =
|
|
@@ -167,7 +167,8 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
|
|
|
167
167
|
|
|
168
168
|
let backoffMs = 0; // 429 退避时间
|
|
169
169
|
let consecutiveRateLimits = 0; // 连续限流计数
|
|
170
|
-
const
|
|
170
|
+
const retryCount = new Map<string, number>(); // taskId → retry count
|
|
171
|
+
const MAX_RETRIES = 2;
|
|
171
172
|
|
|
172
173
|
const runOne = async (): Promise<"done" | "empty" | "rate_limited" | "budget"> => {
|
|
173
174
|
// Budget 刹车:预算用完就不出发(drone 免费,不检查)
|
|
@@ -177,9 +178,8 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
|
|
|
177
178
|
if (spent >= state.maxCost) return "budget";
|
|
178
179
|
}
|
|
179
180
|
|
|
180
|
-
const task = nest.
|
|
181
|
+
const task = nest.claimNextTask(caste, "queen");
|
|
181
182
|
if (!task) return "empty";
|
|
182
|
-
if (!nest.claimTask(task.id, "queen")) return "empty";
|
|
183
183
|
|
|
184
184
|
const ant: Ant = {
|
|
185
185
|
id: "", caste, status: "idle", taskId: task.id,
|
|
@@ -190,15 +190,35 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
|
|
|
190
190
|
callbacks.onAntSpawn?.(ant, task);
|
|
191
191
|
|
|
192
192
|
try {
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
193
|
+
const ANT_TIMEOUT = 5 * 60 * 1000; // 5 min hard timeout per ant
|
|
194
|
+
const antAbort = new AbortController();
|
|
195
|
+
signal?.addEventListener("abort", () => antAbort.abort(), { once: true });
|
|
196
|
+
const antSignal = antAbort.signal;
|
|
197
|
+
const antPromise = caste === "drone"
|
|
198
|
+
? runDrone(cwd, nest, task)
|
|
199
|
+
: spawnAnt(cwd, nest, task, config, antSignal, callbacks.onAntStream, opts.authStorage, opts.modelRegistry);
|
|
200
|
+
let timeoutId: ReturnType<typeof setTimeout>;
|
|
201
|
+
const result = await Promise.race([
|
|
202
|
+
antPromise.finally(() => clearTimeout(timeoutId)),
|
|
203
|
+
new Promise<never>((_, reject) => {
|
|
204
|
+
timeoutId = setTimeout(() => { antAbort.abort(); reject(new Error("Ant timeout (5min)")); }, ANT_TIMEOUT);
|
|
205
|
+
}),
|
|
206
|
+
]);
|
|
196
207
|
callbacks.onAntDone?.(result.ant, task, result.output);
|
|
197
208
|
|
|
198
209
|
if (result.rateLimited) {
|
|
199
210
|
return "rate_limited";
|
|
200
211
|
}
|
|
201
212
|
|
|
213
|
+
// 成本预警:超 80% 预算时发信号
|
|
214
|
+
const curState = nest.getState();
|
|
215
|
+
if (curState.maxCost != null) {
|
|
216
|
+
const spent = curState.ants.reduce((s, a) => s + a.usage.cost, 0);
|
|
217
|
+
if (spent >= curState.maxCost * 0.8) {
|
|
218
|
+
emitSignal("working", `Budget warning: ${(spent / curState.maxCost * 100).toFixed(0)}% used`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
202
222
|
// 蚂蚁产生的子任务加入巢穴(限制繁殖上限,防止任务膨胀)
|
|
203
223
|
const MAX_TOTAL_TASKS = 30;
|
|
204
224
|
const MAX_SUB_PER_TASK = 5;
|
|
@@ -237,8 +257,11 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
|
|
|
237
257
|
|
|
238
258
|
return "done";
|
|
239
259
|
} catch (e) {
|
|
240
|
-
|
|
241
|
-
|
|
260
|
+
const errStr = String(e);
|
|
261
|
+
const isRetryable = errStr.includes("timeout") || errStr.includes("Timeout") || errStr.includes("ECONNRESET") || errStr.includes("429");
|
|
262
|
+
const count = retryCount.get(task.id) ?? 0;
|
|
263
|
+
if (isRetryable && count < MAX_RETRIES) {
|
|
264
|
+
retryCount.set(task.id, count + 1);
|
|
242
265
|
nest.updateTaskStatus(task.id, "pending");
|
|
243
266
|
} else {
|
|
244
267
|
// 负信息素:失败任务释放 warning,阻止后续蚂蚁走同一条路
|
|
@@ -270,7 +293,7 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
|
|
|
270
293
|
}
|
|
271
294
|
|
|
272
295
|
// 解除 blocked 任务(如果锁定文件和依赖文件都已释放)
|
|
273
|
-
const activeTasks = state.tasks.filter(t => t.status === "active");
|
|
296
|
+
const activeTasks = state.tasks.filter(t => t.status === "active" || t.status === "claimed");
|
|
274
297
|
const activeFiles = new Set(activeTasks.flatMap(t => t.files));
|
|
275
298
|
for (const t of state.tasks.filter(t => t.status === "blocked" && t.caste === caste)) {
|
|
276
299
|
const fileConflict = t.files.some(f => activeFiles.has(f));
|
|
@@ -321,12 +344,12 @@ async function runAntWave(opts: WaveOptions): Promise<"ok" | "budget"> {
|
|
|
321
344
|
return "budget";
|
|
322
345
|
}
|
|
323
346
|
|
|
324
|
-
// 429 处理:降低并发 + 渐进退避(2s → 5s → 10s,上限 10s
|
|
347
|
+
// 429 处理:降低并发 + 渐进退避(2s → 5s → 10s,上限 10s)+ 记录时间戳
|
|
325
348
|
if (results.includes("rate_limited")) {
|
|
326
349
|
consecutiveRateLimits++;
|
|
327
350
|
const cur = nest.getState().concurrency;
|
|
328
351
|
const reduced = Math.max(cur.min, cur.current - 1); // 每次只减 1,不砍半
|
|
329
|
-
nest.updateState({ concurrency: { ...cur, current: reduced } });
|
|
352
|
+
nest.updateState({ concurrency: { ...cur, current: reduced, lastRateLimitAt: Date.now() } });
|
|
330
353
|
backoffMs = Math.min(consecutiveRateLimits * 2000, 10000);
|
|
331
354
|
} else {
|
|
332
355
|
consecutiveRateLimits = 0;
|
|
@@ -371,13 +394,6 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
|
|
|
371
394
|
|
|
372
395
|
nest.init(initialState);
|
|
373
396
|
const { signal, callbacks } = opts;
|
|
374
|
-
const waveBase: Omit<WaveOptions, "caste"> & { importGraph?: ImportGraph } = {
|
|
375
|
-
nest, cwd: opts.cwd, signal, callbacks, emitSignal,
|
|
376
|
-
currentModel: opts.currentModel,
|
|
377
|
-
modelOverrides: opts.modelOverrides,
|
|
378
|
-
authStorage: opts.authStorage,
|
|
379
|
-
modelRegistry: opts.modelRegistry,
|
|
380
|
-
};
|
|
381
397
|
|
|
382
398
|
const cleanup = () => {
|
|
383
399
|
nest.destroy();
|
|
@@ -389,12 +405,21 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
|
|
|
389
405
|
};
|
|
390
406
|
|
|
391
407
|
const emitSignal = (phase: ColonyState["status"], message: string) => {
|
|
392
|
-
const
|
|
393
|
-
const
|
|
408
|
+
const state = nest.getState();
|
|
409
|
+
const m = state.metrics;
|
|
410
|
+
const active = state.ants.filter(a => a.status === "working").length;
|
|
394
411
|
const progress = m.tasksTotal > 0 ? m.tasksDone / m.tasksTotal : 0;
|
|
395
412
|
callbacks.onSignal?.({ phase, progress, active, cost: m.totalCost, message });
|
|
396
413
|
};
|
|
397
414
|
|
|
415
|
+
const waveBase: Omit<WaveOptions, "caste"> & { importGraph?: ImportGraph } = {
|
|
416
|
+
nest, cwd: opts.cwd, signal, callbacks, emitSignal,
|
|
417
|
+
currentModel: opts.currentModel,
|
|
418
|
+
modelOverrides: opts.modelOverrides,
|
|
419
|
+
authStorage: opts.authStorage,
|
|
420
|
+
modelRegistry: opts.modelRegistry,
|
|
421
|
+
};
|
|
422
|
+
|
|
398
423
|
try {
|
|
399
424
|
// ═══ Phase 1: 侦察(快速单次,不再多轮接力) ═══
|
|
400
425
|
callbacks.onPhase?.("scouting", "Dispatching scout ant to explore codebase...");
|
|
@@ -490,7 +515,7 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
|
|
|
490
515
|
// ═══ 持续探索:Worker 完成后检查是否有新发现,有则再派 Scout ═══
|
|
491
516
|
const discoveries = nest.getAllPheromones().filter(p => p.type === "discovery");
|
|
492
517
|
const allDone = nest.getAllTasks().filter(t => t.status === "done");
|
|
493
|
-
if (discoveries.length > allDone.length
|
|
518
|
+
if (discoveries.length > allDone.length) {
|
|
494
519
|
const spent = nest.getState().ants.reduce((s, a) => s + a.usage.cost, 0);
|
|
495
520
|
if (spent < (nest.getState().maxCost ?? Infinity)) {
|
|
496
521
|
callbacks.onPhase?.("scouting", "Re-exploring based on new discoveries...");
|
|
@@ -563,7 +588,8 @@ export async function runColony(opts: QueenOptions): Promise<ColonyState> {
|
|
|
563
588
|
emitSignal("failed", String(e).slice(0, 100));
|
|
564
589
|
return failState;
|
|
565
590
|
} finally {
|
|
566
|
-
|
|
591
|
+
const finalStatus = nest.getState().status;
|
|
592
|
+
if (finalStatus === "done") cleanup();
|
|
567
593
|
}
|
|
568
594
|
}
|
|
569
595
|
|
|
@@ -580,8 +606,9 @@ export async function resumeColony(opts: QueenOptions): Promise<ColonyState> {
|
|
|
580
606
|
const { signal, callbacks } = opts;
|
|
581
607
|
|
|
582
608
|
const emitSignal = (phase: ColonyState["status"], message: string) => {
|
|
583
|
-
const
|
|
584
|
-
const
|
|
609
|
+
const state = nest.getState();
|
|
610
|
+
const m = state.metrics;
|
|
611
|
+
const active = state.ants.filter(a => a.status === "working").length;
|
|
585
612
|
const progress = m.tasksTotal > 0 ? m.tasksDone / m.tasksTotal : 0;
|
|
586
613
|
callbacks.onSignal?.({ phase, progress, active, cost: m.totalCost, message });
|
|
587
614
|
};
|
|
@@ -623,6 +650,26 @@ export async function resumeColony(opts: QueenOptions): Promise<ColonyState> {
|
|
|
623
650
|
}
|
|
624
651
|
}
|
|
625
652
|
|
|
653
|
+
// Soldier review for resumed colony(条件与 runColony 对齐)
|
|
654
|
+
let tscPassed = true;
|
|
655
|
+
try {
|
|
656
|
+
const { execSync } = await import("node:child_process");
|
|
657
|
+
execSync("npx tsc --noEmit", { cwd: opts.cwd, timeout: 30000, stdio: "pipe" });
|
|
658
|
+
} catch { tscPassed = false; }
|
|
659
|
+
|
|
660
|
+
const completedWorkerTasks = nest.getAllTasks().filter(t => t.caste === "worker" && t.status === "done");
|
|
661
|
+
if (completedWorkerTasks.length > 0 && (!tscPassed || completedWorkerTasks.length > 3)) {
|
|
662
|
+
nest.updateState({ status: "reviewing" });
|
|
663
|
+
const reviewTask = makeReviewTask(completedWorkerTasks);
|
|
664
|
+
nest.writeTask(reviewTask);
|
|
665
|
+
await runAntWave({ ...waveBase, caste: "soldier" });
|
|
666
|
+
const fixTasks = nest.getAllTasks().filter(t => t.caste === "worker" && t.status === "pending" && t.parentId !== null);
|
|
667
|
+
if (fixTasks.length > 0) {
|
|
668
|
+
nest.updateState({ status: "working" });
|
|
669
|
+
await runAntWave({ ...waveBase, caste: "worker" });
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
626
673
|
const finalMetrics = updateMetrics(nest);
|
|
627
674
|
nest.updateState({ status: "done", finishedAt: Date.now(), metrics: finalMetrics });
|
|
628
675
|
const finalState = nest.getState();
|
|
@@ -637,6 +684,7 @@ export async function resumeColony(opts: QueenOptions): Promise<ColonyState> {
|
|
|
637
684
|
emitSignal("failed", String(e).slice(0, 100));
|
|
638
685
|
return failState;
|
|
639
686
|
} finally {
|
|
640
|
-
|
|
687
|
+
const finalStatus = nest.getState().status;
|
|
688
|
+
if (finalStatus === "done") cleanup();
|
|
641
689
|
}
|
|
642
690
|
}
|
|
@@ -120,7 +120,14 @@ export async function runDrone(
|
|
|
120
120
|
|
|
121
121
|
try {
|
|
122
122
|
const { execSync } = await import("node:child_process");
|
|
123
|
-
|
|
123
|
+
// 优先从 context 代码块提取 bash 命令,fallback 到 description
|
|
124
|
+
const ctxMatch = task.context?.match(/```(?:bash|sh)?\s*\n?([\s\S]*?)```/);
|
|
125
|
+
const cmd = ctxMatch?.[1]?.trim() || task.description;
|
|
126
|
+
// 基本危险命令校验
|
|
127
|
+
const DANGEROUS = /\brm\s+-rf\s+\/|mkfs\b|dd\s+if=|chmod\s+777\s+\/|>\s*\/dev\/sd/i;
|
|
128
|
+
if (DANGEROUS.test(cmd)) {
|
|
129
|
+
throw new Error(`Drone refused dangerous command: ${cmd.slice(0, 80)}`);
|
|
130
|
+
}
|
|
124
131
|
const output = execSync(cmd, { cwd, encoding: "utf-8", timeout: 30000, stdio: "pipe" }).trim();
|
|
125
132
|
|
|
126
133
|
ant.status = "done";
|
|
@@ -191,9 +198,10 @@ export async function spawnAnt(
|
|
|
191
198
|
|
|
192
199
|
let accumulatedText = "";
|
|
193
200
|
let rateLimited = false;
|
|
201
|
+
let session: any = null;
|
|
194
202
|
|
|
195
203
|
try {
|
|
196
|
-
const
|
|
204
|
+
const created = await createAgentSession({
|
|
197
205
|
cwd,
|
|
198
206
|
model,
|
|
199
207
|
thinkingLevel: "off",
|
|
@@ -204,6 +212,7 @@ export async function spawnAnt(
|
|
|
204
212
|
sessionManager: SessionManager.inMemory(),
|
|
205
213
|
settingsManager,
|
|
206
214
|
});
|
|
215
|
+
session = created.session;
|
|
207
216
|
|
|
208
217
|
session.subscribe((event: AgentSessionEvent) => {
|
|
209
218
|
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
|
|
@@ -241,8 +250,9 @@ export async function spawnAnt(
|
|
|
241
250
|
|
|
242
251
|
const userPrompt = `Execute this task: ${task.title}\n\n${task.description}`;
|
|
243
252
|
|
|
253
|
+
let onAbort: (() => void) | undefined;
|
|
244
254
|
if (signal) {
|
|
245
|
-
|
|
255
|
+
onAbort = () => session.abort();
|
|
246
256
|
if (signal.aborted) {
|
|
247
257
|
await session.abort();
|
|
248
258
|
} else {
|
|
@@ -250,7 +260,13 @@ export async function spawnAnt(
|
|
|
250
260
|
}
|
|
251
261
|
}
|
|
252
262
|
|
|
253
|
-
|
|
263
|
+
try {
|
|
264
|
+
await session.prompt(userPrompt);
|
|
265
|
+
} finally {
|
|
266
|
+
if (signal && onAbort) {
|
|
267
|
+
signal.removeEventListener("abort", onAbort);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
254
270
|
|
|
255
271
|
const messages = session.messages;
|
|
256
272
|
let finalOutput = accumulatedText;
|
|
@@ -278,7 +294,6 @@ export async function spawnAnt(
|
|
|
278
294
|
for (const p of pheromones) nest.dropPheromone(p);
|
|
279
295
|
|
|
280
296
|
nest.updateTaskStatus(task.id, "done", finalOutput);
|
|
281
|
-
session.dispose();
|
|
282
297
|
|
|
283
298
|
return { ant, output: finalOutput, newTasks, pheromones, rateLimited: false };
|
|
284
299
|
|
|
@@ -305,5 +320,7 @@ export async function spawnAnt(
|
|
|
305
320
|
nest.updateTaskStatus(task.id, "failed", accumulatedText, errStr);
|
|
306
321
|
|
|
307
322
|
return { ant, output: accumulatedText, newTasks, pheromones, rateLimited: false };
|
|
323
|
+
} finally {
|
|
324
|
+
try { session?.dispose(); } catch { /* ignore dispose errors */ }
|
|
308
325
|
}
|
|
309
326
|
}
|