@zhushanwen/pi-subagents 0.0.1
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/agents/context-builder.md +19 -0
- package/agents/oracle.md +19 -0
- package/agents/planner.md +19 -0
- package/agents/researcher.md +19 -0
- package/agents/reviewer.md +19 -0
- package/agents/scout.md +19 -0
- package/agents/worker.md +18 -0
- package/index.ts +1 -0
- package/package.json +59 -0
- package/src/commands/subagents.ts +78 -0
- package/src/core/agent-registry.ts +222 -0
- package/src/core/concurrency-pool.ts +78 -0
- package/src/core/event-bridge.ts +199 -0
- package/src/core/execution-record.ts +500 -0
- package/src/core/model-resolver.ts +206 -0
- package/src/core/output-collector.ts +118 -0
- package/src/core/path-encoding.ts +16 -0
- package/src/core/session-factory.ts +365 -0
- package/src/core/session-runner.ts +303 -0
- package/src/core/turn-limiter.ts +71 -0
- package/src/index.ts +104 -0
- package/src/runtime/config/config.ts +170 -0
- package/src/runtime/discovery-config.ts +135 -0
- package/src/runtime/execution/history-store.ts +196 -0
- package/src/runtime/execution/notifier.ts +209 -0
- package/src/runtime/execution/record-store.ts +280 -0
- package/src/runtime/model-config-service.ts +265 -0
- package/src/runtime/session-file-gc.ts +70 -0
- package/src/runtime/subagent-service.ts +549 -0
- package/src/tools/subagent-tool.ts +286 -0
- package/src/tui/bg-notify-render.ts +139 -0
- package/src/tui/config-wizard.ts +253 -0
- package/src/tui/format-helpers.ts +37 -0
- package/src/tui/format.ts +332 -0
- package/src/tui/list-view.ts +883 -0
- package/src/tui/tool-render.ts +467 -0
- package/src/types.ts +334 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// src/runtime/execution/notifier.ts
|
|
2
|
+
//
|
|
3
|
+
// Background 完成回注主对话。sync 不用(调用方还在 await,结果直接返回)。
|
|
4
|
+
//
|
|
5
|
+
// 职责:
|
|
6
|
+
// - 合并窗口:MERGE_WINDOW_MS 内多个完成合并为一条通知
|
|
7
|
+
// - 去重 TTL:同 id 在 TTL 内不重复通知
|
|
8
|
+
// - 通过 pi.sendMessage({ deliverAs:"followUp", triggerTurn:true }) 注入——
|
|
9
|
+
// 当前 turn 结束后唤醒父 agent 处理结果(followUp 不打断 streaming、不锁滚动)
|
|
10
|
+
|
|
11
|
+
// ============================================================
|
|
12
|
+
// 类型
|
|
13
|
+
// ============================================================
|
|
14
|
+
|
|
15
|
+
/** 一条待发送的完成通知记录。 */
|
|
16
|
+
export interface BgNotifyRecord {
|
|
17
|
+
id: string;
|
|
18
|
+
status: "done" | "failed" | "cancelled";
|
|
19
|
+
agent: string;
|
|
20
|
+
result?: string;
|
|
21
|
+
error?: string;
|
|
22
|
+
startedAt: number;
|
|
23
|
+
endedAt: number | undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** notifier 依赖的 pi 最小接口(解耦,便于测试)。 */
|
|
27
|
+
export interface NotifierHost {
|
|
28
|
+
/** 注入消息到主对话。
|
|
29
|
+
* triggerTurn:true + deliverAs:"followUp" → 当前 streaming 结束后唤醒父 agent
|
|
30
|
+
* 处理结果(不打断、不锁滚动);空闲时立即 prompt 新 turn。 */
|
|
31
|
+
sendMessage(
|
|
32
|
+
message: { customType: string; content: string; display: boolean; details?: unknown },
|
|
33
|
+
options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" },
|
|
34
|
+
): void;
|
|
35
|
+
/** 是否还有 running 的 background 任务(用于滑动窗口立即 flush 判断)。 */
|
|
36
|
+
hasRunningBackground(): boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ============================================================
|
|
40
|
+
// 常量
|
|
41
|
+
// ============================================================
|
|
42
|
+
|
|
43
|
+
/** 合并窗口(ms)。窗口内多个完成合并为一条消息。 */
|
|
44
|
+
const MERGE_WINDOW_MS = 2000;
|
|
45
|
+
/** 去重 TTL(ms)。同 id 在此窗口内不重复通知。 */
|
|
46
|
+
const DEDUP_TTL_MS = 5000;
|
|
47
|
+
|
|
48
|
+
/** 发送给主对话的 customType(bg-notify-render 消费)。 */
|
|
49
|
+
const NOTIFY_CUSTOM_TYPE = "subagent-bg-notify";
|
|
50
|
+
|
|
51
|
+
// content 不再截断——它进 LLM context,截断会让 AI 看不到完整结果而被迫 poll。
|
|
52
|
+
// block 展示靠 details(renderer 自己 firstLine + truncLine 截断),与 content 解耦。
|
|
53
|
+
|
|
54
|
+
// ============================================================
|
|
55
|
+
// BgNotifier
|
|
56
|
+
// ============================================================
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Background 完成通知器(滑动窗口合并)。
|
|
60
|
+
*
|
|
61
|
+
* ╔══════════════════════════════════════════════════════════════════╗
|
|
62
|
+
// ║ notify(record): ║
|
|
63
|
+
// ║ 1. dedup TTL 检查:同 id 在 TTL 内 → 跳过 ║
|
|
64
|
+
// ║ 2. 入 pending 队列 ║
|
|
65
|
+
// ║ 3. 清除旧 timer(滑动窗口重置) ║
|
|
66
|
+
// ║ 4. 已无 running background → 立即 flush(最后一批) ║
|
|
67
|
+
// ║ 5. 否则重启 MERGE_WINDOW_MS timer(等后续完成合并) ║
|
|
68
|
+
// ║ ║
|
|
69
|
+
// ║ flushPending(): timer 到期 / 无 running / shutdown 触发 ║
|
|
70
|
+
// ║ 1. 取出 pending 全部 record ║
|
|
71
|
+
// ║ 2. 合并为一条消息(多条时列 bullet list) ║
|
|
72
|
+
// ║ 3. sendMessage({ customType:"subagent-bg-notify", ║
|
|
73
|
+
// ║ content, display:true, ║
|
|
74
|
+
// ║ triggerTurn:true, deliverAs:"followUp" }) ║
|
|
75
|
+
// ║ 4. 清空 pending + timer ║
|
|
76
|
+
// ╚══════════════════════════════════════════════════════════════════╝
|
|
77
|
+
*
|
|
78
|
+
* 滑动窗口:每次有新完成都重置 2s 计时器,密集完成的任务尽量合并到一条通知。
|
|
79
|
+
* 无 running 时立即 flush——避免最后一条等满窗口。
|
|
80
|
+
*
|
|
81
|
+
* deliverAs:"followUp" + triggerTurn:true:完成通知在当前 streaming turn 结束后
|
|
82
|
+
* 唤醒父 agent 处理结果(followUp 不打断 streaming、不锁滚动)。父 agent 收到后
|
|
83
|
+
* 可继续后续逻辑;多条合并的消息在同一个 followUp turn 里处理。
|
|
84
|
+
*/
|
|
85
|
+
export class BgNotifier {
|
|
86
|
+
private readonly pending: BgNotifyRecord[] = [];
|
|
87
|
+
/** dedup:id → 上次通知时间戳。 */
|
|
88
|
+
private readonly dedup = new Map<string, number>();
|
|
89
|
+
private timer: NodeJS.Timeout | undefined;
|
|
90
|
+
private _disposed = false;
|
|
91
|
+
|
|
92
|
+
constructor(private readonly host: NotifierHost) {}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 入队一条完成通知(去重 + 滑动窗口合并)。dispose 后短路。
|
|
96
|
+
*
|
|
97
|
+
* 滑动窗口策略(每次有新完成就重置 2s 计时器,等待后续 background 批量合并):
|
|
98
|
+
* 1. push 到 pending
|
|
99
|
+
* 2. 清除旧 timer
|
|
100
|
+
* 3. 若已无 running background → 立即 flush(最后一条,不必等窗口)
|
|
101
|
+
* 4. 否则重启 2s timer(滑动:每次新完成都重置,让密集完成的任务尽量合并)
|
|
102
|
+
*/
|
|
103
|
+
notify(record: BgNotifyRecord): void {
|
|
104
|
+
if (this._disposed) return;
|
|
105
|
+
|
|
106
|
+
// dedup TTL:同 id 短时间内不重复通知
|
|
107
|
+
const now = Date.now();
|
|
108
|
+
// sweep 过期 dedup 条目(防 Map 无限增长,M2 修复)
|
|
109
|
+
if (this.dedup.size > 0) {
|
|
110
|
+
for (const [id, ts] of this.dedup) {
|
|
111
|
+
if (now - ts >= DEDUP_TTL_MS) this.dedup.delete(id);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const lastSeen = this.dedup.get(record.id);
|
|
115
|
+
if (lastSeen !== undefined && now - lastSeen < DEDUP_TTL_MS) return;
|
|
116
|
+
this.dedup.set(record.id, now);
|
|
117
|
+
|
|
118
|
+
this.pending.push(record);
|
|
119
|
+
|
|
120
|
+
// 清除旧 timer(滑动窗口:重置计时)
|
|
121
|
+
if (this.timer !== undefined) {
|
|
122
|
+
clearTimeout(this.timer);
|
|
123
|
+
this.timer = undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 已无 running background → 立即 flush(最后一批,不必等窗口)
|
|
127
|
+
if (!this.host.hasRunningBackground()) {
|
|
128
|
+
this.flushPendingNotifications();
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 重启 2s 滑动窗口
|
|
133
|
+
this.timer = setTimeout(() => this.flushPendingNotifications(), MERGE_WINDOW_MS);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 立即 flush(session_shutdown 调用,防丢失)。 */
|
|
137
|
+
flushPendingNotifications(): void {
|
|
138
|
+
if (this.timer !== undefined) {
|
|
139
|
+
clearTimeout(this.timer);
|
|
140
|
+
this.timer = undefined;
|
|
141
|
+
}
|
|
142
|
+
if (this.pending.length === 0) return;
|
|
143
|
+
|
|
144
|
+
const records = this.pending.splice(0);
|
|
145
|
+
// content(进 LLM context)= 完整 result,不截断——截断会让 AI 被迫 poll 拉全量。
|
|
146
|
+
// details(给 TUI renderer)= 完整 record,renderer 自己 firstLine + truncLine 压缩显示。
|
|
147
|
+
// 多条合并时 content 含所有 record 的完整 result(LLM 需要全部)。
|
|
148
|
+
const content = records.length === 1
|
|
149
|
+
? this.buildLlmContent(records[0])
|
|
150
|
+
: records.map((r) => this.buildLlmContent(r)).join("\n\n---\n\n");
|
|
151
|
+
const details = records.length === 1
|
|
152
|
+
? records[0]
|
|
153
|
+
: { batch: true, items: records };
|
|
154
|
+
|
|
155
|
+
// display:true + triggerTurn:true + deliverAs:"followUp" —— 渲染一个完成 block
|
|
156
|
+
// 让用户在对话流看到「X 完成」,并在当前 streaming turn 结束后唤醒父 agent 处理结果。
|
|
157
|
+
//
|
|
158
|
+
// deliverAs:"followUp" 的理由:不打断正在 streaming 的 turn(避免 steer 中断正在
|
|
159
|
+
// 的工具调用);streaming 结束后父 agent 自然收到这条消息继续处理。空闲时(非
|
|
160
|
+
// streaming)triggerTurn:true 直接 prompt 一个新 turn。多条合并的消息在同一个
|
|
161
|
+
// followUp turn 里一起处理。
|
|
162
|
+
this.host.sendMessage({
|
|
163
|
+
customType: NOTIFY_CUSTOM_TYPE,
|
|
164
|
+
content,
|
|
165
|
+
display: true,
|
|
166
|
+
details,
|
|
167
|
+
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 构建 content(进 LLM context)——完整 result,不截断。
|
|
172
|
+
*
|
|
173
|
+
* content 是 custom message 的正文,经 convertToLlm 转成 user message 进 LLM context。
|
|
174
|
+
* 旧实现用 PREVIEW_MAX=200 截断,导致 LLM 看到截断结果后被迫发 subagent poll 拉全量——
|
|
175
|
+
* 与 background 模式「不轮询」的设计目标矛盾。修复:content 含完整 result。
|
|
176
|
+
*
|
|
177
|
+
* block 的视觉展示与 content 解耦:renderer 读 details,自己 firstLine + truncLine 压成
|
|
178
|
+
* 单行预览。content 长不影响 block 显示。
|
|
179
|
+
*/
|
|
180
|
+
private buildLlmContent(record: BgNotifyRecord): string {
|
|
181
|
+
const agent = record.agent;
|
|
182
|
+
const id = record.id;
|
|
183
|
+
switch (record.status) {
|
|
184
|
+
case "done":
|
|
185
|
+
return `Subagent "${agent}" (${id}) completed. Result:\n${record.result ?? "(empty)"}`;
|
|
186
|
+
case "failed":
|
|
187
|
+
return `Subagent "${agent}" (${id}) failed: ${record.error ?? "(unknown error)"}`;
|
|
188
|
+
case "cancelled":
|
|
189
|
+
return `Subagent "${agent}" (${id}) cancelled.`;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
/** session 结束:清 timer,丢弃 pending。 */
|
|
195
|
+
dispose(): void {
|
|
196
|
+
this._disposed = true;
|
|
197
|
+
if (this.timer !== undefined) {
|
|
198
|
+
clearTimeout(this.timer);
|
|
199
|
+
this.timer = undefined;
|
|
200
|
+
}
|
|
201
|
+
this.pending.length = 0;
|
|
202
|
+
this.dedup.clear(); // 防 stale dedup 跨 /resume 残留(M2 修复)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** /resume /fork /new 后复活。 */
|
|
206
|
+
revive(): void {
|
|
207
|
+
this._disposed = false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// src/runtime/execution/record-store.ts
|
|
2
|
+
//
|
|
3
|
+
// Record 的统一容器。替代旧实现中散落在 runtime 的 _runningAgents /
|
|
4
|
+
// _completedAgents / _bgRecords 三个独立 Map。
|
|
5
|
+
//
|
|
6
|
+
// 职责:
|
|
7
|
+
// - 持有 live(running)/ completed(linger)/ bg(detached)三组内存 record
|
|
8
|
+
// - onChange 订阅(TUI widget/list 据此重渲)
|
|
9
|
+
// - 与 history-store 协作:completed 后写入持久化,list 时 merge 四源
|
|
10
|
+
// - 提供 snapshot() 只读视图给 TUI(永不返回可变引用)
|
|
11
|
+
|
|
12
|
+
import { snapshot as toSnapshot } from "../../core/execution-record.ts";
|
|
13
|
+
import type {
|
|
14
|
+
ExecutionMode,
|
|
15
|
+
ExecutionRecord,
|
|
16
|
+
ExecutionStatus,
|
|
17
|
+
RecordSnapshot,
|
|
18
|
+
SubagentRecord,
|
|
19
|
+
} from "../../types.ts";
|
|
20
|
+
import type { HistoryStore } from "./history-store.ts";
|
|
21
|
+
|
|
22
|
+
// ============================================================
|
|
23
|
+
// 常量
|
|
24
|
+
// ============================================================
|
|
25
|
+
|
|
26
|
+
/** sync completed record 在内存的 linger 时长(ms)。过期后从 completed map 移除。 */
|
|
27
|
+
const SYNC_LINGER_MS = 5000;
|
|
28
|
+
|
|
29
|
+
/** background record 的 FIFO 上限(绝不淘汰 running)。 */
|
|
30
|
+
const BG_FIFO_MAX = 50;
|
|
31
|
+
|
|
32
|
+
/** status → 排序优先级(值小排前):running < failed < cancelled < done。 */
|
|
33
|
+
const STATUS_PRIORITY: Record<ExecutionStatus, number> = {
|
|
34
|
+
running: 0,
|
|
35
|
+
failed: 1,
|
|
36
|
+
cancelled: 2,
|
|
37
|
+
done: 3,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** store 变更监听器(返回取消订阅函数)。 */
|
|
41
|
+
export type ChangeListener = () => void;
|
|
42
|
+
|
|
43
|
+
// ============================================================
|
|
44
|
+
// RecordStore
|
|
45
|
+
// ============================================================
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Record 容器。进程单例(随 SubagentService 重建)。
|
|
49
|
+
*
|
|
50
|
+
* ╔══════════════════════════════════════════════════════════════════╗
|
|
51
|
+
// ║ ┌─────────────┐ ┌──────────────┐ ┌──────────────┐ ║
|
|
52
|
+
// ║ │ live Map │ │ completed Map│ │ bg Map │ ║
|
|
53
|
+
// ║ │ (running) │ │ (linger 5s) │ │ (detached) │ ║
|
|
54
|
+
// ║ └─────┬───────┘ └──────┬───────┘ └──────┬───────┘ ║
|
|
55
|
+
// ║ │ 完成时迁移 │ TTL 到期移除 │ 被 poll/淘汰读取 ║
|
|
56
|
+
// ║ └────────┬─────────┴────────────┬────┘ ║
|
|
57
|
+
// ║ ▼ ▼ ║
|
|
58
|
+
// ║ listRecords() history.recent() ║
|
|
59
|
+
// ║ └──────── merge ───────┘ ║
|
|
60
|
+
// ║ │ ║
|
|
61
|
+
// ║ ▼ ║
|
|
62
|
+
// ║ SubagentRecord[](/subagents list 消费) ║
|
|
63
|
+
// ║ ║
|
|
64
|
+
// ║ 任何 mutate(register/archive/expire/cancel)→ notifyChange() ║
|
|
65
|
+
// ╚══════════════════════════════════════════════════════════════════╝
|
|
66
|
+
*/
|
|
67
|
+
export class RecordStore {
|
|
68
|
+
private readonly live = new Map<string, ExecutionRecord>();
|
|
69
|
+
private readonly completed = new Map<string, ExecutionRecord>();
|
|
70
|
+
private readonly bg = new Map<string, ExecutionRecord>();
|
|
71
|
+
private readonly listeners = new Set<ChangeListener>();
|
|
72
|
+
/** sync linger 定时器(key=record id)。 */
|
|
73
|
+
private readonly lingerTimers = new Map<string, NodeJS.Timeout>();
|
|
74
|
+
private _disposed = false;
|
|
75
|
+
|
|
76
|
+
constructor(private readonly history: HistoryStore) {}
|
|
77
|
+
|
|
78
|
+
/** 注册新 record(live map)。触发 onChange。 */
|
|
79
|
+
register(record: ExecutionRecord): void {
|
|
80
|
+
this.live.set(record.id, record);
|
|
81
|
+
this.notifyChange();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 归档:live → completed/bg(按 mode)。sync 进 completed(5s linger 后移除),
|
|
86
|
+
* background 进 bg map(活到被查询或 FIFO 淘汰)。
|
|
87
|
+
*/
|
|
88
|
+
archive(record: ExecutionRecord): void {
|
|
89
|
+
this.live.delete(record.id);
|
|
90
|
+
if (record.mode === "background") {
|
|
91
|
+
this.bg.set(record.id, record);
|
|
92
|
+
this.enforceBgFifo();
|
|
93
|
+
} else {
|
|
94
|
+
this.completed.set(record.id, record);
|
|
95
|
+
this.scheduleSyncExpire(record.id);
|
|
96
|
+
}
|
|
97
|
+
this.notifyChange();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 按 id 查找(live/completed/bg 三内存源)。返回可变 record(仅 runtime 内部用)。 */
|
|
101
|
+
getMutable(id: string): ExecutionRecord | undefined {
|
|
102
|
+
return this.live.get(id) ?? this.completed.get(id) ?? this.bg.get(id);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 列出所有 running record 的只读快照(widget 计数、诊断用)。 */
|
|
106
|
+
listRunning(): RecordSnapshot[] {
|
|
107
|
+
// 只返回 status==="running"——terminal(done/failed/cancelled)record 不应计入
|
|
108
|
+
// running(否则 hasRunningBackground 对已 cancel 但未 archive 的 record 永真,C2 修复)。
|
|
109
|
+
return [...this.live.values()]
|
|
110
|
+
.filter((r) => r.status === "running")
|
|
111
|
+
.map((r) => toSnapshot(r));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 合并四源 → SubagentRecord[](/subagents list 消费)。
|
|
116
|
+
* - history(跨 session jsonl,按 sessionId 过滤)
|
|
117
|
+
* - bg(当前 session detached)
|
|
118
|
+
* - completed(当前 session linger)
|
|
119
|
+
* - live(当前 session running)
|
|
120
|
+
* 合并规则:内存源覆盖 history;cancelled 状态优先保留(用户意图)。
|
|
121
|
+
* 排序:status priority(running<failed<cancelled<done)+ startedAt desc。
|
|
122
|
+
*/
|
|
123
|
+
collectRecords(limit: number, sessionId?: string): SubagentRecord[] {
|
|
124
|
+
// 1. history 基底(跨 session,按 sessionId 过滤)
|
|
125
|
+
const byId = new Map<string, SubagentRecord>();
|
|
126
|
+
for (const h of this.history.recent(limit, sessionId)) {
|
|
127
|
+
byId.set(h.id, RecordStore.persistedToSubagent(h));
|
|
128
|
+
}
|
|
129
|
+
// 2. 内存源覆盖(bg + completed + live,当前 session)
|
|
130
|
+
const memorySources = [
|
|
131
|
+
...this.bg.values(),
|
|
132
|
+
...this.completed.values(),
|
|
133
|
+
...this.live.values(),
|
|
134
|
+
];
|
|
135
|
+
for (const r of memorySources) {
|
|
136
|
+
const existing = byId.get(r.id);
|
|
137
|
+
// cancelled 状态优先保留(用户意图,即使被内存覆盖)
|
|
138
|
+
if (existing?.status === "cancelled" && r.status !== "cancelled") {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
byId.set(r.id, RecordStore.recordToSubagent(r));
|
|
142
|
+
}
|
|
143
|
+
// 3. 排序 + slice
|
|
144
|
+
return [...byId.values()]
|
|
145
|
+
.sort(RecordStore.compareRecords)
|
|
146
|
+
.slice(0, limit);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 订阅变更。返回取消订阅函数。 */
|
|
150
|
+
onChange(listener: ChangeListener): () => void {
|
|
151
|
+
this.listeners.add(listener);
|
|
152
|
+
return () => {
|
|
153
|
+
this.listeners.delete(listener);
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 触发所有监听器(TUI widget/list requestRender)。dispose 后短路。 */
|
|
158
|
+
notifyChange(): void {
|
|
159
|
+
if (this._disposed) return;
|
|
160
|
+
for (const listener of this.listeners) {
|
|
161
|
+
listener();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** session 结束清理:清空所有定时器、丢弃 pending 通知。 */
|
|
166
|
+
dispose(): void {
|
|
167
|
+
this._disposed = true;
|
|
168
|
+
for (const timer of this.lingerTimers.values()) {
|
|
169
|
+
clearTimeout(timer);
|
|
170
|
+
}
|
|
171
|
+
this.lingerTimers.clear();
|
|
172
|
+
this.listeners.clear();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** /resume /fork /new 后复活(dispose 的逆操作)。 */
|
|
176
|
+
revive(): void {
|
|
177
|
+
this._disposed = false;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── 内部 ──────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
/** sync completed record 的 linger 定时器:到期后从 completed 移除。 */
|
|
183
|
+
private scheduleSyncExpire(id: string): void {
|
|
184
|
+
// 防御:dispose 后不再 re-arm(/resume→revive→dispose 序列可能让 stale timer 改 completed,M3 修复)
|
|
185
|
+
if (this._disposed) return;
|
|
186
|
+
const timer = setTimeout(() => {
|
|
187
|
+
this.lingerTimers.delete(id);
|
|
188
|
+
if (this._disposed) return; // timer 触发时 store 已 dispose → 不改 completed
|
|
189
|
+
// 仅当仍为非 running 终态时移除(避免竞态)
|
|
190
|
+
const record = this.completed.get(id);
|
|
191
|
+
if (record && record.status !== "running") {
|
|
192
|
+
this.completed.delete(id);
|
|
193
|
+
this.notifyChange();
|
|
194
|
+
}
|
|
195
|
+
}, SYNC_LINGER_MS);
|
|
196
|
+
this.lingerTimers.set(id, timer);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** bg FIFO 淘汰:超 BG_FIFO_MAX 时移除最旧的非 running record。 */
|
|
200
|
+
private enforceBgFifo(): void {
|
|
201
|
+
while (this.bg.size > BG_FIFO_MAX) {
|
|
202
|
+
// 找最旧的非 running(绝不淘汰 running)
|
|
203
|
+
let oldestId: string | undefined;
|
|
204
|
+
let oldestTs = Infinity;
|
|
205
|
+
for (const [id, record] of this.bg) {
|
|
206
|
+
if (record.status === "running") continue;
|
|
207
|
+
if (record.startedAt < oldestTs) {
|
|
208
|
+
oldestTs = record.startedAt;
|
|
209
|
+
oldestId = id;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (oldestId) {
|
|
213
|
+
this.bg.delete(oldestId);
|
|
214
|
+
} else {
|
|
215
|
+
break; // 全是 running,无法淘汰
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** 排序比较器:status priority(running<failed<cancelled<done)+ startedAt desc。 */
|
|
221
|
+
private static compareRecords(a: SubagentRecord, b: SubagentRecord): number {
|
|
222
|
+
const pdiff = STATUS_PRIORITY[a.status] - STATUS_PRIORITY[b.status];
|
|
223
|
+
if (pdiff !== 0) return pdiff;
|
|
224
|
+
return b.startedAt - a.startedAt; // 新→旧
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** ExecutionRecord → SubagentRecord(内存源投影)。 */
|
|
228
|
+
private static recordToSubagent(r: ExecutionRecord): SubagentRecord {
|
|
229
|
+
return {
|
|
230
|
+
id: r.id,
|
|
231
|
+
agent: r.agent,
|
|
232
|
+
status: r.status,
|
|
233
|
+
mode: r.mode,
|
|
234
|
+
startedAt: r.startedAt,
|
|
235
|
+
endedAt: r.endedAt,
|
|
236
|
+
turns: r.turns,
|
|
237
|
+
totalTokens: r.totalTokens,
|
|
238
|
+
model: r.model,
|
|
239
|
+
thinkingLevel: r.thinkingLevel,
|
|
240
|
+
eventLog: r.eventLog.slice(),
|
|
241
|
+
result: r.result,
|
|
242
|
+
error: r.error,
|
|
243
|
+
sessionFile: r.agentResult?.sessionFile,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** PersistedAgentRecord → SubagentRecord(history 源投影)。 */
|
|
248
|
+
private static persistedToSubagent(p: {
|
|
249
|
+
id: string;
|
|
250
|
+
agent: string;
|
|
251
|
+
status: ExecutionStatus;
|
|
252
|
+
mode: ExecutionMode;
|
|
253
|
+
startedAt: number;
|
|
254
|
+
endedAt?: number;
|
|
255
|
+
turns?: number;
|
|
256
|
+
totalTokens?: number;
|
|
257
|
+
model?: string;
|
|
258
|
+
thinkingLevel?: string;
|
|
259
|
+
resultPreview?: string;
|
|
260
|
+
error?: string;
|
|
261
|
+
sessionFile?: string;
|
|
262
|
+
}): SubagentRecord {
|
|
263
|
+
return {
|
|
264
|
+
id: p.id,
|
|
265
|
+
agent: p.agent,
|
|
266
|
+
status: p.status,
|
|
267
|
+
mode: p.mode,
|
|
268
|
+
startedAt: p.startedAt,
|
|
269
|
+
endedAt: p.endedAt,
|
|
270
|
+
turns: p.turns ?? 0,
|
|
271
|
+
totalTokens: p.totalTokens ?? 0,
|
|
272
|
+
model: p.model ?? "",
|
|
273
|
+
thinkingLevel: p.thinkingLevel,
|
|
274
|
+
eventLog: [],
|
|
275
|
+
result: p.resultPreview,
|
|
276
|
+
error: p.error,
|
|
277
|
+
sessionFile: p.sessionFile,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
}
|