pi-pier 0.1.2 → 0.1.4
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 +23 -5
- package/package.json +1 -1
- package/src/core/grid-shape.ts +10 -5
- package/src/core/subagent.ts +297 -96
- package/src/core/todo.ts +150 -69
- package/src/herdr-client.ts +6 -3
- package/src/history-store.ts +10 -0
- package/src/index.ts +72 -10
- package/src/pane-title.ts +11 -1
- package/src/pipe-channel.ts +3 -0
- package/src/settle-wake-core.ts +54 -0
- package/src/slim-frame.ts +219 -0
- package/src/stale-core.ts +76 -0
- package/src/subagent-core.ts +100 -0
- package/src/todo-core.ts +66 -15
- package/src/todo-read-hook.ts +122 -20
- package/src/todos-service.ts +13 -3
package/src/core/subagent.ts
CHANGED
|
@@ -21,9 +21,9 @@ import type { PiSurface } from '../pi-surface.ts';
|
|
|
21
21
|
import type { HerdrClientLike, HerdrAgentState } from '../herdr-client.ts';
|
|
22
22
|
import type { TodosService } from '../todos-service.ts';
|
|
23
23
|
import { mountSubagentScope } from '../subagent-scope.ts';
|
|
24
|
-
import { Semaphore, SUBS_CUSTOM_TYPE, buildLaunchLine, classifyWorktreeZone, foldSubsRegistry, formatSubagentResult, isPathUnder, makeProgressUpdate, makeRegistry, planTabPlacement, tabNameForTask, type SubEntry, type SubagentSpec, type TabPlacementPlan, type WorktreeZone } from '../subagent-core.ts';
|
|
24
|
+
import { Semaphore, SUBS_CUSTOM_TYPE, agoText, buildAliveNotice, buildBlockedGateNotice, buildLaunchLine, buildLaunchParts, classifyWorktreeZone, foldSubsRegistry, formatSubagentResult, isAlive, isPathUnder, makeProgressUpdate, makeRegistry, planTabPlacement, psQuote, tabNameForTask, type AliveProbe, type SubEntry, type SubagentSpec, type TabPlacementPlan, type WorktreeZone } from '../subagent-core.ts';
|
|
25
25
|
import { hasAssistantAfter, hasPendingToolCall, lastAssistantText, listSessionFiles, readSessionFile, sessionFileById } from '../session-tail.ts';
|
|
26
|
-
import { appendHistory, applyReportedSessionFile, historyFilePath, latestGeneration, normalizeEntryKind, readHistory, type HistoryEntry } from '../history-store.ts';
|
|
26
|
+
import { appendHistory, applyReportedSessionFile, historyFilePath, inheritOutcome, latestGeneration, normalizeEntryKind, readHistory, type HistoryEntry } from '../history-store.ts';
|
|
27
27
|
import { execFile } from 'node:child_process';
|
|
28
28
|
import { stat as statCb } from 'node:fs';
|
|
29
29
|
import { resolve as pathResolve } from 'node:path';
|
|
@@ -33,7 +33,7 @@ import { pingUntilReady, pipeNameFor, pipeRequest } from '../pipe-channel.ts';
|
|
|
33
33
|
import { shouldClosePane, shouldCloseTaskTab } from '../gc-core.ts';
|
|
34
34
|
import { composeForRole } from '../manifest-compose.ts';
|
|
35
35
|
import { formatSettlementNotice } from '../vocab.ts';
|
|
36
|
-
import {
|
|
36
|
+
import { ABORT_STOP_REASON } from '../settle-wake-core.ts';
|
|
37
37
|
|
|
38
38
|
export interface SubagentEnv {
|
|
39
39
|
paneId: string;
|
|
@@ -66,9 +66,10 @@ export interface SubagentDeps {
|
|
|
66
66
|
* D92 结算通知注入器(index.ts 提供):忙时入缓冲、turn_end 折叠 steer 批量注入。
|
|
67
67
|
* 缺省回退 pi.sendUserMessage(followUp)(旧路径——整个 run 结束才回填,测试桩用)。
|
|
68
68
|
*/
|
|
69
|
-
deliverNotice?: (content: string) => Promise<void>;
|
|
69
|
+
deliverNotice?: (content: string, paneId?: string) => Promise<void>;
|
|
70
|
+
/** B2:结算通知尚在缓冲未送达的 pane 集合(GC 豁免——先送达再回收)。 */
|
|
71
|
+
noticePending?: () => ReadonlySet<string>;
|
|
70
72
|
/** terminal 族 GC 豁免槽。 */
|
|
71
|
-
terminalState: { activePaneIds: () => Set<string> };
|
|
72
73
|
todos: TodosService;
|
|
73
74
|
}
|
|
74
75
|
|
|
@@ -84,10 +85,18 @@ const SUBAGENT_DESCRIPTION = [
|
|
|
84
85
|
|
|
85
86
|
/** 子代理并发上限(D10)。 */
|
|
86
87
|
const SUBAGENT_CONCURRENCY = 4;
|
|
87
|
-
/**
|
|
88
|
+
/** B1:观察超时 = 无活动时长预算(非总墙钟)——working 切片持续续命;marker
|
|
89
|
+
* PI_HERDR_SUBAGENT_TIMEOUT_MS 沿用旧名,语义已从「poller 起算总墙钟」改为
|
|
90
|
+
* 「距最近一次活动」——>10min 的健康长任务不再被误杀(session 01a03c0d 实证)。 */
|
|
88
91
|
const SUBAGENT_TIMEOUT_MS = Number(process.env.PI_HERDR_SUBAGENT_TIMEOUT_MS ?? 600000) || 600000;
|
|
89
92
|
/** 就绪等待上限(TUI 启动 + herdr 检测)。 */
|
|
90
93
|
const SUB_READY_TIMEOUT_MS = 30000;
|
|
94
|
+
/** D94/B4:结算观察窗(此期内 working 默认视为用户接管)。 */
|
|
95
|
+
const OBSERVE_WINDOW_MS = Number(process.env.PI_HERDR_OBSERVE_MS ?? 30000) || 30000;
|
|
96
|
+
/** B4:机器注入宽限——观察窗内的 working 若由自己的 prompt/follow_up 触发,不算接管。 */
|
|
97
|
+
const MACHINE_INJECT_GRACE_MS = Number(process.env.PI_HERDR_INJECT_GRACE_MS ?? 30000) || 30000;
|
|
98
|
+
/** D94:接管态归还阈值(持续 idle 即归还)。 */
|
|
99
|
+
const TAKEOVER_IDLE_MS = Number(process.env.PI_HERDR_TAKEOVER_IDLE_MS ?? 60000) || 60000;
|
|
91
100
|
|
|
92
101
|
function defaultAgentSessionsDir(): string {
|
|
93
102
|
const base = process.env.PI_CODING_AGENT_DIR || join(homedir(), '.pi', 'agent');
|
|
@@ -114,7 +123,9 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
114
123
|
extPath: d.extPath,
|
|
115
124
|
};
|
|
116
125
|
const subSemaphore = new Semaphore(SUBAGENT_CONCURRENCY);
|
|
117
|
-
|
|
126
|
+
/** B4:每 pane 最近一次机器注入(prompt/follow_up pipe 投递成功)时间——观察窗内的
|
|
127
|
+
* working 若发生在宽限期内,是自己的注入在被处理,不是用户接管。 */
|
|
128
|
+
const lastMachineInjectAt = new Map<string, number>();
|
|
118
129
|
/* 子代理注册表(custom 条目持久化,分支正确;parent 重启后可从分支重建) */
|
|
119
130
|
const subs = new Map<string, SubEntry>();
|
|
120
131
|
const pollers = new Set<string>();
|
|
@@ -122,13 +133,22 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
122
133
|
/** D50:每 pane 最近一次机器请求 id(interrupt 按轮次占位、pollLoop 去重用)。 */
|
|
123
134
|
const lastRequestIdByPane = new Map<string, string>();
|
|
124
135
|
|
|
136
|
+
/** O1:上次落盘快照(内容哈希门控——01a03c0d 实证 48% session 行是心跳期重复快照)。 */
|
|
137
|
+
let lastSubsSnapshot = '';
|
|
138
|
+
|
|
125
139
|
function persistSubs(): void {
|
|
126
140
|
try {
|
|
127
|
-
|
|
141
|
+
const reg = makeRegistry([...subs.values()]);
|
|
142
|
+
const snap = JSON.stringify(reg);
|
|
143
|
+
if (snap === lastSubsSnapshot) return; // 内容未变不落(1s 观察窗/5s 接管循环的重复心跳)
|
|
144
|
+
lastSubsSnapshot = snap;
|
|
145
|
+
pi.appendEntry?.(SUBS_CUSTOM_TYPE, reg);
|
|
128
146
|
} catch {
|
|
129
147
|
/* 持久化尽力而为 */
|
|
130
148
|
}
|
|
131
149
|
}
|
|
150
|
+
/** E2:已发过闸门通知的 pane(blocked 期间去重;解除后移除,二次 ask 可再通知)。 */
|
|
151
|
+
const blockedGateNotified = new Set<string>();
|
|
132
152
|
|
|
133
153
|
// slots 回填:common 段 pipe reply 消费
|
|
134
154
|
slots.applyReplySession = (paneId, sessionFile) => {
|
|
@@ -138,7 +158,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
138
158
|
if (next === entry.sessionFile) return;
|
|
139
159
|
entry.sessionFile = next;
|
|
140
160
|
persistSubs();
|
|
141
|
-
writeHistory(entry);
|
|
161
|
+
writeHistory(entry, undefined, 'session-report');
|
|
142
162
|
};
|
|
143
163
|
slots.reconcileOnReply = (paneId) => {
|
|
144
164
|
const entry = subs.get(paneId);
|
|
@@ -157,23 +177,88 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
157
177
|
?.sessionManager?.getBranch?.() ?? [];
|
|
158
178
|
const reg = foldSubsRegistry(entries as Parameters<typeof foldSubsRegistry>[0]);
|
|
159
179
|
for (const sub of reg.subs) subs.set(sub.paneId, sub);
|
|
180
|
+
lastSubsSnapshot = ''; // 分支回放改变了状态 → 下次 persistSubs 强制落盘
|
|
160
181
|
} catch {
|
|
161
182
|
/* 重建失败不影响主流程 */
|
|
162
183
|
}
|
|
163
184
|
}
|
|
164
185
|
|
|
186
|
+
/** B6:master 崩溃遗留的 running 行——按 herdr 实况补 closed(pane 没了才关,
|
|
187
|
+
* pane 还在的真在跑则保留)。01a03c0d 台账实证:p6/p7 永久 running 无回收。 */
|
|
188
|
+
async function sweepZombieRunning(): Promise<void> {
|
|
189
|
+
if (!client.available || subs.size === 0) return;
|
|
190
|
+
let livePaneIds: ReadonlySet<string>;
|
|
191
|
+
try {
|
|
192
|
+
livePaneIds = new Set((await client.listPanes()).map((p) => p.paneId));
|
|
193
|
+
} catch {
|
|
194
|
+
return; // 查询失败不误关
|
|
195
|
+
}
|
|
196
|
+
let changed = false;
|
|
197
|
+
for (const [paneId, e] of subs) {
|
|
198
|
+
if (e.status !== 'running' || livePaneIds.has(paneId)) continue;
|
|
199
|
+
e.status = 'closed';
|
|
200
|
+
writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'zombie-sweep');
|
|
201
|
+
changed = true;
|
|
202
|
+
}
|
|
203
|
+
if (changed) persistSubs();
|
|
204
|
+
}
|
|
205
|
+
|
|
165
206
|
scoped.on('session_start', async (_event: unknown, eventCtx: unknown) => {
|
|
166
207
|
rebuildSubs(eventCtx);
|
|
208
|
+
await sweepZombieRunning();
|
|
167
209
|
});
|
|
168
210
|
scoped.on('session_tree', async (_event: unknown, eventCtx: unknown) => {
|
|
169
211
|
rebuildSubs(eventCtx);
|
|
212
|
+
await sweepZombieRunning();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
/* ── B1(用户实证修复):subagent isError 结果的探活改写 ──────────────
|
|
216
|
+
* 实测事故:前台误判 no-output → 模型读裸错误文案 → "failed, 我自己干" 抢活。
|
|
217
|
+
* pi 的 tool_result 事件官方语义 "Can modify result"——在文本进模型上下文前,
|
|
218
|
+
* 当场探活(agent.list + 会话 mtime);判活则改写为统一存活通知(buildAliveNotice
|
|
219
|
+
* 与 A2 转后台同一出口)。hook 层硬保障,纯 prompt 防不住的自作主张在此物理拦截。 */
|
|
220
|
+
scoped.on('tool_result', async (event: { toolName?: string; toolCallId?: string; isError?: boolean; content?: Array<{ type: string; text?: string }> }) => {
|
|
221
|
+
if (event?.toolName !== 'subagent' || !event.isError) return;
|
|
222
|
+
// 从错误文本里取 pane id(我们自己的错误文案都带 pane);取不到则不改写
|
|
223
|
+
const errText = (event.content ?? []).map((c) => c.text ?? '').join(' ');
|
|
224
|
+
const paneId = [...subs.keys()].find((id) => errText.includes(id))
|
|
225
|
+
?? (errText.match(/\bw[A-Za-z0-9]+:p\d+\b/) ?? [])[0];
|
|
226
|
+
if (!paneId) return;
|
|
227
|
+
const entry = subs.get(paneId);
|
|
228
|
+
if (!entry || entry.status === 'settled' || entry.status === 'consumed') return;
|
|
229
|
+
const probe = await probeAlive(paneId, entry.cwd);
|
|
230
|
+
if (!isAlive(probe, Date.now())) return; // 真死了 → 保留原错误,模型可重做
|
|
231
|
+
// 活着 → 转 poller(若尚未在跑)并改写结果文本
|
|
232
|
+
if (!pollers.has(paneId)) {
|
|
233
|
+
entry.background = true;
|
|
234
|
+
startPoller(paneId, entry.cwd, entry.createdAt, entry.createdAt, entry.description, lastRequestIdByPane.get(paneId) ?? `probe-${paneId}`);
|
|
235
|
+
persistSubs();
|
|
236
|
+
}
|
|
237
|
+
const notice = buildAliveNotice(
|
|
238
|
+
{ paneId, description: entry.description, scenario: 'error-alive', probe },
|
|
239
|
+
Date.now(),
|
|
240
|
+
);
|
|
241
|
+
return { content: [{ type: 'text', text: notice }] };
|
|
170
242
|
});
|
|
171
243
|
|
|
172
244
|
/* ── D41:stop 未完成提醒(仅主控;子代理不注册本块) ── */
|
|
173
245
|
|
|
174
246
|
let todoReminders = 0;
|
|
175
247
|
const TODO_REMINDERS_MAX = 3;
|
|
248
|
+
// 反唤醒风暴:用户 ESC 中止后的 settled 不催(否则 abort → 提醒 → 新 run → 再 abort
|
|
249
|
+
// 死循环;与 index 的 D96/结算缓冲同款守卫,session 01a03bf0 实证)。
|
|
250
|
+
let lastAssistantStopReason: string | null = null;
|
|
251
|
+
scoped.on('turn_end', async (event: unknown) => {
|
|
252
|
+
if (event === null || typeof event !== 'object' || !('message' in event)) return;
|
|
253
|
+
const msg = (event as { message: unknown }).message; // 'message' in 已守卫
|
|
254
|
+
if (msg === null || typeof msg !== 'object') return;
|
|
255
|
+
const { role, stopReason } = msg as { role?: unknown; stopReason?: unknown };
|
|
256
|
+
if (role === 'assistant' && typeof stopReason === 'string') {
|
|
257
|
+
lastAssistantStopReason = stopReason;
|
|
258
|
+
}
|
|
259
|
+
});
|
|
176
260
|
scoped.on('agent_settled', async () => {
|
|
261
|
+
if (lastAssistantStopReason === ABORT_STOP_REASON) return;
|
|
177
262
|
if (todoReminders >= TODO_REMINDERS_MAX) return;
|
|
178
263
|
if (pollers.size > 0) return; // 有在途子代理 → 主控本来就在等,不催
|
|
179
264
|
if (d.getBlockedDepth() > 0) return; // 等人类回答 → 不催
|
|
@@ -253,6 +338,39 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
253
338
|
return null;
|
|
254
339
|
}
|
|
255
340
|
|
|
341
|
+
/** E1/E2:读子 pane 的人类闸门问题(tokens['pi-ask'];子扩展 reportAskFlag 上报)。 */
|
|
342
|
+
async function readAskFlag(paneId: string): Promise<string | null> {
|
|
343
|
+
try {
|
|
344
|
+
const a = (await client.listAgents()).find((x) => x.paneId === paneId);
|
|
345
|
+
const v = a?.tokens?.['pi-ask'];
|
|
346
|
+
return typeof v === 'string' && v ? v : null;
|
|
347
|
+
} catch {
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** A2/B1 探活:pane 实时状态 + 会话候选最新 mtime(毫秒级,失败字段为 null)。 */
|
|
353
|
+
async function probeAlive(paneId: string, cwd: string): Promise<AliveProbe> {
|
|
354
|
+
const probe: AliveProbe = { paneExists: false, agentStatus: null, lastActivityMs: null };
|
|
355
|
+
try {
|
|
356
|
+
const agents = await client.listAgents();
|
|
357
|
+
const a = agents.find((x) => x.paneId === paneId);
|
|
358
|
+
probe.paneExists = a != null;
|
|
359
|
+
probe.agentStatus = a?.status ?? null;
|
|
360
|
+
} catch {
|
|
361
|
+
/* agent.list 失败 → 状态未知,靠会话活动判定 */
|
|
362
|
+
}
|
|
363
|
+
for (const file of await resolveSessionFileCandidates(paneId, cwd)) {
|
|
364
|
+
try {
|
|
365
|
+
const mtime = (await statAsync(file)).mtimeMs;
|
|
366
|
+
if (probe.lastActivityMs == null || mtime > probe.lastActivityMs) probe.lastActivityMs = mtime;
|
|
367
|
+
} catch {
|
|
368
|
+
/* 文件消失跳过 */
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return probe;
|
|
372
|
+
}
|
|
373
|
+
|
|
256
374
|
/**
|
|
257
375
|
* 子会话结算状态(v1.3 M8 结算竞态修复):
|
|
258
376
|
* herdr 的 idle/done 在"注入瞬间"也是真,不能当结算信号;
|
|
@@ -266,8 +384,8 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
266
384
|
): Promise<{ text: string | null; pendingTool: boolean; activity: boolean }> {
|
|
267
385
|
for (const file of await resolveSessionFileCandidates(paneId, cwd)) {
|
|
268
386
|
const entries = readSessionFile(file);
|
|
269
|
-
if (
|
|
270
|
-
const r = lastAssistantText(entries,
|
|
387
|
+
if (entries.length === 0) continue;
|
|
388
|
+
const r = lastAssistantText(entries, sinceTs);
|
|
271
389
|
if (r?.text) return { text: r.text, pendingTool: false, activity: true };
|
|
272
390
|
if (hasPendingToolCall(entries, sinceTs)) return { text: null, pendingTool: true, activity: true };
|
|
273
391
|
if (hasAssistantAfter(entries, sinceTs)) return { text: null, pendingTool: false, activity: true };
|
|
@@ -275,21 +393,6 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
275
393
|
return { text: null, pendingTool: false, activity: false };
|
|
276
394
|
}
|
|
277
395
|
|
|
278
|
-
/** 等待结算:idle/done=settled、blocked=人类闸门、超时切片循环。 */
|
|
279
|
-
async function waitSubSettled(
|
|
280
|
-
paneId: string,
|
|
281
|
-
timeoutMs: number,
|
|
282
|
-
): Promise<'settled' | 'blocked' | 'timeout'> {
|
|
283
|
-
const deadline = Date.now() + timeoutMs;
|
|
284
|
-
while (Date.now() < deadline) {
|
|
285
|
-
const state = await client.waitAgent(paneId, ['idle', 'done', 'blocked'], 30000);
|
|
286
|
-
if (state === 'idle' || state === 'done') return 'settled';
|
|
287
|
-
if (state === 'blocked') return 'blocked';
|
|
288
|
-
// null = 30s 切片超时(working 中或异常),继续
|
|
289
|
-
}
|
|
290
|
-
return 'timeout';
|
|
291
|
-
}
|
|
292
|
-
|
|
293
396
|
/** D92:结算通知统一出口——经 deps 缓冲(忙时 turn_end 折叠注入),缺省回退旧 followUp 直投。 */
|
|
294
397
|
const injectNotice = (content: string): Promise<void> =>
|
|
295
398
|
d.deliverNotice ? d.deliverNotice(content)
|
|
@@ -305,7 +408,11 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
305
408
|
requestId: string,
|
|
306
409
|
): Promise<void> {
|
|
307
410
|
void spawnedAt;
|
|
411
|
+
// B1:无活动预算锚点——working 切片(waitAgent 超时 null)即心跳(O3):每次续命;
|
|
412
|
+
// 只有「不 working 也无结算进展」的真空期才消耗预算(01a03c0d:27min 健康长任务
|
|
413
|
+
// 被旧总墙钟 10min 误杀两次)。startedAt 保留供通知文案诊断。
|
|
308
414
|
const startedAt = Date.now();
|
|
415
|
+
let lastActivityAt = Date.now();
|
|
309
416
|
try {
|
|
310
417
|
while (true) {
|
|
311
418
|
const entry = subs.get(paneId);
|
|
@@ -360,7 +467,22 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
360
467
|
} catch {
|
|
361
468
|
state = null;
|
|
362
469
|
}
|
|
363
|
-
if (state === 'blocked')
|
|
470
|
+
if (state === 'blocked') {
|
|
471
|
+
// E2(人类闸门):blocked 时 master 对话流里也要知道(此前只有 herdr 系统
|
|
472
|
+
// 通知发给人类,master 毫不知情 → 可能自作主张揽活)。一次性 gate 通知注入;
|
|
473
|
+
// 解除(idle)后重置,二次 ask 新问题可再通知。
|
|
474
|
+
if (!blockedGateNotified.has(paneId)) {
|
|
475
|
+
blockedGateNotified.add(paneId);
|
|
476
|
+
const question = await readAskFlag(paneId);
|
|
477
|
+
try {
|
|
478
|
+
await injectNotice(buildBlockedGateNotice({ paneId, description, question }));
|
|
479
|
+
} catch {
|
|
480
|
+
/* 注入失败静默(下次 turn 可 list_agents 自查) */
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
continue; // 人类闸门:保持 running
|
|
484
|
+
}
|
|
485
|
+
if (state !== null) blockedGateNotified.delete(paneId); // 闸门解除(working/idle)
|
|
364
486
|
if (state === 'idle' || state === 'done') {
|
|
365
487
|
const s = await subSessionState(paneId, cwd, injectTs);
|
|
366
488
|
if (s.text || (!s.pendingTool && s.activity)) {
|
|
@@ -380,17 +502,29 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
380
502
|
// 已在观察期
|
|
381
503
|
const elapsed = Date.now() - entry.observationStartedAt;
|
|
382
504
|
|
|
383
|
-
// 检测用户介入:查询当前状态是否变为 working
|
|
505
|
+
// 检测用户介入:查询当前状态是否变为 working。
|
|
506
|
+
// B4:观察窗内 working ≠ 必然用户接管——自己的 follow_up 注入也呈现
|
|
507
|
+
// working。距最近机器注入 < MACHINE_INJECT_GRACE_MS 的 working 视为
|
|
508
|
+
// 注入被处理(取消结算继续观察),否则才判接管(01a03c0d:契约消息
|
|
509
|
+
// 20min 后投递,恰好撞进观察窗)。
|
|
384
510
|
try {
|
|
385
511
|
const agents = await client.listAgents();
|
|
386
512
|
const agent = agents.find((a) => a.paneId === paneId);
|
|
387
513
|
if (agent && agent.status === 'working') {
|
|
388
|
-
|
|
389
|
-
|
|
514
|
+
const injectedAgo = Date.now() - (lastMachineInjectAt.get(paneId) ?? 0);
|
|
515
|
+
if (injectedAgo > MACHINE_INJECT_GRACE_MS) {
|
|
516
|
+
// 用户介入!标记接管,取消结算
|
|
517
|
+
entry.userTakeover = true;
|
|
518
|
+
entry.observationStartedAt = Date.now();
|
|
519
|
+
entry.lastAgentStatus = 'working';
|
|
520
|
+
persistSubs();
|
|
521
|
+
continue; // 跳过结算,进入用户接管模式
|
|
522
|
+
}
|
|
523
|
+
// 机器注入在处理中 → 归零观察窗,等这轮 run 收尾再判结算
|
|
390
524
|
entry.observationStartedAt = Date.now();
|
|
391
|
-
entry.lastAgentStatus = 'working';
|
|
392
525
|
persistSubs();
|
|
393
|
-
|
|
526
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
527
|
+
continue;
|
|
394
528
|
}
|
|
395
529
|
} catch {
|
|
396
530
|
// listAgents 失败静默
|
|
@@ -405,7 +539,6 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
405
539
|
// 观察期结束(30s 无用户介入),正常结算
|
|
406
540
|
entry.observationStartedAt = null; // 清除观察期标记
|
|
407
541
|
}
|
|
408
|
-
|
|
409
542
|
// 正常结算逻辑
|
|
410
543
|
// O6:reply 已写的 sessionFile 是权威;poll 只在缺失时用扫描补,绝不反向覆盖
|
|
411
544
|
if (!entry.sessionFile) {
|
|
@@ -416,15 +549,13 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
416
549
|
}
|
|
417
550
|
entry.status = 'consumed';
|
|
418
551
|
entry.consumedAt = Date.now();
|
|
419
|
-
|
|
420
|
-
writeHistory(entry, { outcome: closing });
|
|
552
|
+
writeHistory(entry, { outcome: closing }, 'poll-settle');
|
|
421
553
|
// M17:结算自动对账(先于通知;与 reply 快路径幂等双跑)
|
|
422
554
|
const notes = d.reconcileOnSettlement(description, 'settled');
|
|
423
555
|
const notice = d.withReconcileNotes(
|
|
424
556
|
formatSettlementNotice(`${paneId} (${description})`, closing),
|
|
425
557
|
notes,
|
|
426
558
|
);
|
|
427
|
-
// D50:push 快路径可能已发过通知 → 按轮次(paneId+请求id)去重,只发一条
|
|
428
559
|
if (d.claimSettleNotice(`${paneId}:${requestId}`)) {
|
|
429
560
|
try {
|
|
430
561
|
await injectNotice(notice);
|
|
@@ -436,6 +567,8 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
436
567
|
}
|
|
437
568
|
// 挂起 toolCall(等人类输入)或还没开工 → 继续等
|
|
438
569
|
}
|
|
570
|
+
// B1/O3:state=null(30s 切片超时)= working 中的心跳 → 续命无活动预算。
|
|
571
|
+
if (state === null) lastActivityAt = Date.now();
|
|
439
572
|
// 切片超时:检查 pane 存活与总预算
|
|
440
573
|
let alive = false;
|
|
441
574
|
try {
|
|
@@ -447,7 +580,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
447
580
|
entry.status = 'consumed';
|
|
448
581
|
entry.consumedAt = Date.now(); // O8:提前退出路径必须置消费时间,否则 tab TTL 被卡住
|
|
449
582
|
persistSubs();
|
|
450
|
-
writeHistory(entry, { outcome: 'pane closed before settling' });
|
|
583
|
+
writeHistory(entry, { outcome: 'pane closed before settling' }, 'poll-pane-closed');
|
|
451
584
|
const notes = d.reconcileOnSettlement(description, 'failed');
|
|
452
585
|
const notice = d.withReconcileNotes(
|
|
453
586
|
`Background subagent ${paneId} (${description}) stopped before settling (its pane closed).`,
|
|
@@ -460,14 +593,19 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
460
593
|
}
|
|
461
594
|
return;
|
|
462
595
|
}
|
|
463
|
-
|
|
596
|
+
// B1:无活动预算超时——working 心跳持续续命,此处只在「pane 活着但
|
|
597
|
+
// 既不 working、也无结算进展」真空期超预算时触发(旧总墙钟 10min 误杀
|
|
598
|
+
// 27min 健康长任务 ×2,01a03c0d 实证)。
|
|
599
|
+
if (Date.now() - lastActivityAt > SUBAGENT_TIMEOUT_MS) {
|
|
464
600
|
entry.status = 'consumed';
|
|
465
601
|
entry.consumedAt = Date.now(); // O8:observation timeout 同样置消费时间
|
|
466
602
|
persistSubs();
|
|
467
|
-
writeHistory(entry, { outcome: 'observation timeout' });
|
|
603
|
+
writeHistory(entry, { outcome: 'observation timeout' }, 'poll-timeout');
|
|
468
604
|
const notes = d.reconcileOnSettlement(description, 'failed');
|
|
605
|
+
// O2:给行动钩子——list_agents 看活态;真在跑就让它跑完(结算会自动通知),
|
|
606
|
+
// 别 sleep 盲等(01a03c0d 实证 master 收到旧文案后连 sleep 90/120/180)。
|
|
469
607
|
const notice = d.withReconcileNotes(
|
|
470
|
-
`Background subagent ${paneId} (${description})
|
|
608
|
+
`Background subagent ${paneId} (${description}) has shown no progress for ${Math.round((Date.now() - lastActivityAt) / 1000)}s (observed since ${new Date(startedAt).toISOString()}). Run list_agents to check its live state; if it is working, let it run — its settlement notice will arrive automatically. Do not sleep-wait.`,
|
|
471
609
|
notes,
|
|
472
610
|
);
|
|
473
611
|
try {
|
|
@@ -509,7 +647,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
509
647
|
return historyFilePath(agentRootDir(), cwd);
|
|
510
648
|
}
|
|
511
649
|
|
|
512
|
-
function toHistory(e: SubEntry): HistoryEntry {
|
|
650
|
+
function toHistory(e: SubEntry, outcome: string | null): HistoryEntry {
|
|
513
651
|
return {
|
|
514
652
|
taskId: e.taskId,
|
|
515
653
|
kind: e.kind,
|
|
@@ -522,7 +660,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
522
660
|
sessionFile: e.sessionFile,
|
|
523
661
|
launchCommand: e.launchCommand,
|
|
524
662
|
status: e.status,
|
|
525
|
-
outcome
|
|
663
|
+
outcome,
|
|
526
664
|
createdAt: e.createdAt,
|
|
527
665
|
consumedAt: e.consumedAt ?? null,
|
|
528
666
|
closedAt: null,
|
|
@@ -530,8 +668,14 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
530
668
|
};
|
|
531
669
|
}
|
|
532
670
|
|
|
533
|
-
|
|
534
|
-
|
|
671
|
+
/** B5:每 taskId 最近非空 outcome(closed 补记行继承——latestGeneration 取最新行,
|
|
672
|
+
* 旧实现 closed 行恒 outcome:null → 结算成果在「最新行」语义下丢失)。 */
|
|
673
|
+
const lastOutcomeByTask = new Map<string, string>();
|
|
674
|
+
|
|
675
|
+
function writeHistory(e: SubEntry, patch?: Partial<HistoryEntry>, via?: string): void {
|
|
676
|
+
const outcome = inheritOutcome(lastOutcomeByTask.get(e.taskId), patch?.outcome);
|
|
677
|
+
if (typeof outcome === 'string' && outcome.length > 0) lastOutcomeByTask.set(e.taskId, outcome);
|
|
678
|
+
appendHistory(histFile(e.cwd), { ...toHistory(e, outcome), ...(patch ?? {}), ...(via ? { via } : {}) });
|
|
535
679
|
}
|
|
536
680
|
|
|
537
681
|
/** 存活任务 tab(本 workspace;herdr 为权威,tab.rename/自动关后自动纠正)。 */
|
|
@@ -600,7 +744,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
600
744
|
mainTabId,
|
|
601
745
|
});
|
|
602
746
|
if (plan.mode === 'append' && plan.tabId) {
|
|
603
|
-
//
|
|
747
|
+
// D97 网格形态:目标格 = tab 内面积最大的格子,一律 down(全宽横条,title 静帧横读);
|
|
604
748
|
// board 等无 agent 常驻 shell(agentStatus=unknown)不入候选,避免被 worker 蚕食。
|
|
605
749
|
const exclude = new Set(
|
|
606
750
|
allPanes.filter((p) => p.tabId === plan.tabId && p.agentStatus === 'unknown').map((p) => p.paneId),
|
|
@@ -617,7 +761,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
617
761
|
?? allPanes.find((p) => p.tabId === plan.tabId)?.paneId;
|
|
618
762
|
if (anchorPaneId) {
|
|
619
763
|
const paneId = await client.splitPane({
|
|
620
|
-
direction: pick?.direction ?? '
|
|
764
|
+
direction: pick?.direction ?? 'down',
|
|
621
765
|
cwd,
|
|
622
766
|
env: envOver,
|
|
623
767
|
targetPaneId: anchorPaneId,
|
|
@@ -643,12 +787,8 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
643
787
|
|
|
644
788
|
function launchLine(resumeFile?: string | null, roleModel?: string | null, approve = false): string {
|
|
645
789
|
// 裸 argv → 平台 shell 语法(win32=PowerShell `&`,POSIX=sh);引号转义在 buildLaunchLine
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
parts.push('-e', runtime.extPath);
|
|
649
|
-
if (roleModel) parts.push('--provider', roleModel.split('/')[0], '--model', roleModel.split('/')[1] ?? roleModel);
|
|
650
|
-
if (resumeFile) parts.push('--session', resumeFile);
|
|
651
|
-
return buildLaunchLine(parts);
|
|
790
|
+
// argv 构造在 buildLaunchParts(D97:默认 --tui-mode fullscreen,PI_HERDR_TUI=regular 逃生)
|
|
791
|
+
return buildLaunchLine(buildLaunchParts(runtime, { resumeFile, roleModel, approve }));
|
|
652
792
|
}
|
|
653
793
|
|
|
654
794
|
/**
|
|
@@ -677,9 +817,10 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
677
817
|
} catch {
|
|
678
818
|
return;
|
|
679
819
|
}
|
|
680
|
-
const statuses = new Map(panesList.map((p) => [p.paneId, p.agentStatus]));
|
|
681
820
|
// D71:有活跃终端会话的 pane 豁免回收(tab 级与 pane 级两处)
|
|
682
821
|
const termPaneIds = terminalState.activePaneIds();
|
|
822
|
+
// B2:结算通知未送达的 pane 豁免(先送达再回收)——pD/pC 实证被 GC 抢关。
|
|
823
|
+
const pendingNoticeIds = d.noticePending?.() ?? new Set<string>();
|
|
683
824
|
|
|
684
825
|
// 任务 tab 分组(含 closed 条目:closed 工作 pane 也算完成)
|
|
685
826
|
const byTab = new Map<string, SubEntry[]>();
|
|
@@ -702,7 +843,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
702
843
|
for (const e of entries) {
|
|
703
844
|
if (e.status !== 'closed') {
|
|
704
845
|
e.status = 'closed';
|
|
705
|
-
writeHistory(e, { status: 'closed', closedAt: Date.now() });
|
|
846
|
+
writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'gc');
|
|
706
847
|
}
|
|
707
848
|
}
|
|
708
849
|
continue;
|
|
@@ -714,7 +855,8 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
714
855
|
now: Date.now(),
|
|
715
856
|
});
|
|
716
857
|
if (!autoCloseTabs || !should) continue;
|
|
717
|
-
|
|
858
|
+
// B2:结算通知未送达的 pane 豁免(先送达再回收)
|
|
859
|
+
if (tabPanes.some((p) => termPaneIds.has(p.paneId) || pendingNoticeIds.has(p.paneId))) continue;
|
|
718
860
|
try {
|
|
719
861
|
await client.tabClose(tabId);
|
|
720
862
|
} catch {
|
|
@@ -723,7 +865,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
723
865
|
for (const e of entries) {
|
|
724
866
|
if (e.status !== 'closed') {
|
|
725
867
|
e.status = 'closed';
|
|
726
|
-
writeHistory(e, { status: 'closed', closedAt: Date.now() });
|
|
868
|
+
writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'gc');
|
|
727
869
|
}
|
|
728
870
|
}
|
|
729
871
|
await new Promise((r) => setTimeout(r, 300)); // 串行关闭(#1358 同类风险护栏)
|
|
@@ -736,7 +878,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
736
878
|
(e) => e.status === 'consumed' && !(e.tabId && closableTaskTabIds.has(e.tabId)),
|
|
737
879
|
);
|
|
738
880
|
for (const e of candidates) {
|
|
739
|
-
if (termPaneIds.has(e.paneId)) continue; //
|
|
881
|
+
if (termPaneIds.has(e.paneId) || pendingNoticeIds.has(e.paneId)) continue; // 活跃终端(D71)/未送达通知(B2)豁免
|
|
740
882
|
if (!shouldClosePane({
|
|
741
883
|
consumedAt: e.consumedAt ?? null,
|
|
742
884
|
herdrStatus: statuses.get(e.paneId),
|
|
@@ -745,7 +887,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
745
887
|
if (statuses.get(e.paneId) === undefined) {
|
|
746
888
|
// pane 已消失(随 tab 被关)→ 补记 closed
|
|
747
889
|
e.status = 'closed';
|
|
748
|
-
writeHistory(e, { status: 'closed', closedAt: Date.now() });
|
|
890
|
+
writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'gc');
|
|
749
891
|
continue;
|
|
750
892
|
}
|
|
751
893
|
try {
|
|
@@ -754,7 +896,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
754
896
|
/* pane 已消失 */
|
|
755
897
|
}
|
|
756
898
|
e.status = 'closed';
|
|
757
|
-
writeHistory(e, { status: 'closed', closedAt: Date.now() });
|
|
899
|
+
writeHistory(e, { status: 'closed', closedAt: Date.now() }, 'gc');
|
|
758
900
|
await new Promise((r) => setTimeout(r, 300));
|
|
759
901
|
}
|
|
760
902
|
persistSubs();
|
|
@@ -831,7 +973,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
831
973
|
entry.revivedFrom = latest.paneId;
|
|
832
974
|
entry.launchCommand = [launchLine(resumeFile, null, approve)];
|
|
833
975
|
entry.createdAt = Date.now();
|
|
834
|
-
writeHistory(entry);
|
|
976
|
+
writeHistory(entry, undefined, 'revive');
|
|
835
977
|
return entry;
|
|
836
978
|
}
|
|
837
979
|
|
|
@@ -931,7 +1073,6 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
931
1073
|
const release = await subSemaphore.acquire();
|
|
932
1074
|
let paneId = '';
|
|
933
1075
|
try {
|
|
934
|
-
onUpdate?.(makeProgressUpdate(`spawning ${kind !== 'task' ? `${kind} ` : ''}${background ? 'background ' : ''}subagent pane…`));
|
|
935
1076
|
const spawnedAt = Date.now();
|
|
936
1077
|
const taskId = randomUUID();
|
|
937
1078
|
const spawned = await spawnPaneInTaskTab(
|
|
@@ -961,10 +1102,12 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
961
1102
|
entry.sessionFile = await resolveSessionFile(paneId, cwd);
|
|
962
1103
|
subs.set(paneId, entry);
|
|
963
1104
|
persistSubs();
|
|
964
|
-
writeHistory(entry);
|
|
965
1105
|
onUpdate?.(makeProgressUpdate(`subagent ready in pane ${paneId}; injecting prompt via pipe…`));
|
|
966
1106
|
// M11(D45/D46):注入走扩展管道 → 子扩展 sendUserMessage(followUp);
|
|
967
1107
|
// PTY 键盘通道 100% 归人(无软锁窗口、无混行)。
|
|
1108
|
+
// 回归修复(session 01a03bf0 实证):ecc0bc4 重写前台等待时误删本块——
|
|
1109
|
+
// prompt 永不注入 + injectTs 未定义(ReferenceError → spawn-failed,
|
|
1110
|
+
// 台账留幽灵 running 条目)。任何后续重构不得把注入与 injectTs 拆开。
|
|
968
1111
|
const injectTs = Date.now();
|
|
969
1112
|
const injected = await pipeRequest(pipeNameFor(cwd, paneId), {
|
|
970
1113
|
type: 'prompt',
|
|
@@ -976,48 +1119,87 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
976
1119
|
if (injected.type !== 'ok') {
|
|
977
1120
|
throw new Error(`pipe prompt rejected: ${injected.type === 'error' ? injected.message : 'unknown response'}`);
|
|
978
1121
|
}
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1122
|
+
lastMachineInjectAt.set(paneId, injectTs); // B4:观察窗内 working 归因判据
|
|
1123
|
+
// A1+A2(用户实证修复):前台等待 = 内容闸 + 耐心阈值转后台。
|
|
1124
|
+
// 旧实现的 idle 即结算 + 90s 硬窗口在真实任务(working 期 4-6 分钟)上必然误判
|
|
1125
|
+
// no-output(实测:3 个健康子代理 101s 时被同时 consumed,成果 4 分钟后才产出)。
|
|
1126
|
+
const PATIENCE_MS = Number(process.env.PI_HERDR_FRONTIER_PATIENCE_MS ?? 300000) || 300000;
|
|
1127
|
+
const patienceDeadline = Date.now() + PATIENCE_MS;
|
|
1128
|
+
let text: string | null = null;
|
|
1129
|
+
let settledKind: 'settled' | 'timeout' = 'timeout';
|
|
1130
|
+
while (Date.now() < Math.min(patienceDeadline, spawnedAt + SUBAGENT_TIMEOUT_MS)) {
|
|
1131
|
+
const state = await client.waitAgent(paneId, ['idle', 'done', 'blocked'], 30_000);
|
|
1132
|
+
if (state === 'blocked') {
|
|
1133
|
+
// 返回 gate 通知:不揽活、提醒用户去 pane 答题、授权例外经 send_message 转达。
|
|
1134
|
+
const question = await readAskFlag(paneId);
|
|
1135
|
+
entry.background = true;
|
|
1136
|
+
lastRequestIdByPane.set(paneId, `prompt-${taskId}`);
|
|
1137
|
+
startPoller(paneId, cwd, spawnedAt, injectTs, spec.description, `prompt-${taskId}`);
|
|
1138
|
+
persistSubs();
|
|
1139
|
+
writeHistory(entry, undefined, 'to-background');
|
|
1140
|
+
return {
|
|
1141
|
+
content: [{ type: 'text', text: buildBlockedGateNotice({ paneId, description: spec.description, question }) }],
|
|
1142
|
+
details: { paneId, taskId, background: true, blocked: true, role: kind },
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
if (state === 'idle' || state === 'done') {
|
|
1146
|
+
// A1 内容闸:herdr idle 只是"可能是结算"——以会话内容定夺
|
|
1147
|
+
const s = await subSessionState(paneId, cwd, injectTs);
|
|
1148
|
+
if (s.text) { text = s.text; settledKind = 'settled'; break; }
|
|
1149
|
+
if (s.pendingTool || (!s.text && !s.activity)) {
|
|
1150
|
+
// 挂起 toolCall(等人类)或注入前瞬间的 idle → 不当结算,继续观察
|
|
1151
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
1152
|
+
continue;
|
|
1004
1153
|
}
|
|
1154
|
+
// 有活动无文本(活动已停止)→ 再给 collectFinalText 的重试窗口
|
|
1155
|
+
text = await collectFinalText(paneId, cwd, injectTs, 6);
|
|
1156
|
+
if (text) { settledKind = 'settled'; break; }
|
|
1157
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
1158
|
+
continue;
|
|
1005
1159
|
}
|
|
1160
|
+
// null = 30s 切片超时(working 中)→ 继续循环
|
|
1161
|
+
}
|
|
1162
|
+
// A2 耐心阈值到点仍在跑 → 探活;活着 → 转后台并返回统一存活通知
|
|
1163
|
+
if (!text && settledKind !== 'blocked') {
|
|
1164
|
+
const probe = await probeAlive(paneId, cwd);
|
|
1165
|
+
if (isAlive(probe, Date.now())) {
|
|
1166
|
+
entry.background = true;
|
|
1167
|
+
lastRequestIdByPane.set(paneId, `prompt-${taskId}`);
|
|
1168
|
+
startPoller(paneId, cwd, spawnedAt, injectTs, spec.description, `prompt-${taskId}`);
|
|
1169
|
+
persistSubs();
|
|
1170
|
+
const notice = buildAliveNotice(
|
|
1171
|
+
{ paneId, description: spec.description, scenario: 'moved-to-bg', probe },
|
|
1172
|
+
Date.now(),
|
|
1173
|
+
);
|
|
1174
|
+
return {
|
|
1175
|
+
content: [{ type: 'text', text: notice }],
|
|
1176
|
+
details: { paneId, taskId, background: true, movedToBackground: true, role: kind },
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
// 探活失败(pane 消失且会话沉寂)→ 保留原 timeout 语义
|
|
1180
|
+
settledKind = 'timeout';
|
|
1006
1181
|
}
|
|
1007
1182
|
const outcome =
|
|
1008
|
-
|
|
1009
|
-
: settled === 'timeout' ? { kind: 'timeout' as const, text: text ?? '' }
|
|
1183
|
+
settledKind === 'timeout' ? { kind: 'timeout' as const, text: text ?? '' }
|
|
1010
1184
|
: text ? { kind: 'completed' as const, text }
|
|
1011
1185
|
: { kind: 'no-output' as const, text: '' };
|
|
1012
1186
|
entry.status = 'consumed';
|
|
1013
1187
|
entry.consumedAt = Date.now();
|
|
1014
1188
|
persistSubs();
|
|
1015
|
-
writeHistory(entry, { outcome: outcome.kind === 'completed' ? text : null });
|
|
1189
|
+
writeHistory(entry, { outcome: outcome.kind === 'completed' ? text : null }, 'fg-settle');
|
|
1016
1190
|
return {
|
|
1017
1191
|
content: [{ type: 'text', text: formatSubagentResult(outcome, spec.description) }],
|
|
1018
1192
|
details: { paneId, taskId, background, role: kind },
|
|
1019
1193
|
};
|
|
1020
1194
|
} catch (err) {
|
|
1195
|
+
// 回归修复(session 01a03bf0):spawn 中途失败必须回收台账 + 关 pane——
|
|
1196
|
+
// 否则幽灵 running 条目永不结算 → D96 提醒风暴 + send_message 打到
|
|
1197
|
+
// 无任务上下文的空会话。pane 关闭尽力而为(board pane 例外场景由 GC 兜底)。
|
|
1198
|
+
if (paneId) {
|
|
1199
|
+
subs.delete(paneId);
|
|
1200
|
+
persistSubs();
|
|
1201
|
+
void client.closePane(paneId).catch(() => { /* 尽力而为 */ });
|
|
1202
|
+
}
|
|
1021
1203
|
return {
|
|
1022
1204
|
content: [{
|
|
1023
1205
|
type: 'text',
|
|
@@ -1083,7 +1265,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
1083
1265
|
};
|
|
1084
1266
|
subs.set(entry.paneId, entry);
|
|
1085
1267
|
persistSubs();
|
|
1086
|
-
writeHistory(entry);
|
|
1268
|
+
writeHistory(entry, undefined, 'resume');
|
|
1087
1269
|
return {
|
|
1088
1270
|
content: [{
|
|
1089
1271
|
type: 'text',
|
|
@@ -1138,7 +1320,7 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
1138
1320
|
name: 'list_agents',
|
|
1139
1321
|
label: 'List Subagents',
|
|
1140
1322
|
description:
|
|
1141
|
-
'List the background subagents you started, with their live state: running (working or blocked), idle (settled), plus pane ids, role (task or a role label), and descriptions. Foreground one-shot panes are transient and not listed. Covers only this session branch — for tasks from earlier sessions or collected panes, read the delegation ledger (path and format in resume_subagent) and resume by taskId.',
|
|
1323
|
+
'List the background subagents you started, with their live state: running (working or blocked), idle (settled), plus pane ids, live agent status, last session activity time, role (task or a role label), and descriptions. Foreground one-shot panes are transient and not listed. Covers only this session branch — for tasks from earlier sessions or collected panes, read the delegation ledger (path and format in resume_subagent) and resume by taskId.',
|
|
1142
1324
|
parameters: Type.Object({}),
|
|
1143
1325
|
async execute() {
|
|
1144
1326
|
const listed = [...subs.values()].filter((s) => s.background);
|
|
@@ -1151,6 +1333,12 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
1151
1333
|
const prev = byTask.get(sub.taskId);
|
|
1152
1334
|
if (!prev || sub.createdAt >= prev.createdAt) byTask.set(sub.taskId, sub);
|
|
1153
1335
|
}
|
|
1336
|
+
// C1:实时探活(pane 状态 + 会话活动时间)——master 能区分"在干活"vs"真卡死"
|
|
1337
|
+
const probes = new Map<string, AliveProbe>();
|
|
1338
|
+
await Promise.all([...byTask.values()].map(async (sub) => {
|
|
1339
|
+
if (sub.status === 'closed') return;
|
|
1340
|
+
try { probes.set(sub.paneId, await probeAlive(sub.paneId, sub.cwd)); } catch { /* 显示层尽力 */ }
|
|
1341
|
+
}));
|
|
1154
1342
|
const lines: string[] = [];
|
|
1155
1343
|
for (const sub of byTask.values()) {
|
|
1156
1344
|
const tabTag = sub.tabName ? ` [tab: ${sub.tabName}]` : '';
|
|
@@ -1162,19 +1350,28 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
1162
1350
|
const state = sub.status === 'settled' ? 'idle' : 'running';
|
|
1163
1351
|
// D94:用户接管标记
|
|
1164
1352
|
const takeoverMark = sub.userTakeover ? ', user-controlled' : '';
|
|
1165
|
-
|
|
1353
|
+
const probe = probes.get(sub.paneId);
|
|
1354
|
+
const statusTag = probe?.agentStatus ? ` ${probe.agentStatus}` : '';
|
|
1355
|
+
const activityTag = probe?.lastActivityMs != null ? `, active ${agoText(probe.lastActivityMs, Date.now())}` : '';
|
|
1356
|
+
// E:blocked 时附上闸门问题摘要(提示 master 该叫用户去 pane,不是自己接手)
|
|
1357
|
+
let gateTag = '';
|
|
1358
|
+
if (probe?.agentStatus === 'blocked') {
|
|
1359
|
+
const q = await readAskFlag(sub.paneId);
|
|
1360
|
+
gateTag = q ? ` — AWAITING HUMAN: "${q}"` : ' — AWAITING HUMAN decision';
|
|
1361
|
+
}
|
|
1362
|
+
lines.push(`${sub.paneId} [${state}${takeoverMark}${statusTag}${activityTag}${gateTag}] (${sub.kind})${tabTag} ${sub.description}`);
|
|
1166
1363
|
}
|
|
1167
1364
|
return { content: [{ type: 'text', text: lines.join('\n') }], details: {} };
|
|
1168
1365
|
},
|
|
1169
1366
|
});
|
|
1170
1367
|
|
|
1171
|
-
/* ── 工具:send_message(
|
|
1368
|
+
/* ── 工具:send_message(steer 间隙投递) ── */
|
|
1172
1369
|
|
|
1173
1370
|
scoped.registerTool({
|
|
1174
1371
|
name: 'send_message',
|
|
1175
1372
|
label: 'Send to Subagent',
|
|
1176
1373
|
description:
|
|
1177
|
-
'Send a follow-up message to a background subagent. If it is working, the message is
|
|
1374
|
+
'Send a follow-up message to a background subagent. If it is working, the message is delivered at its next tool-call gap (steer, seconds); if it is idle, it wakes the subagent for a new turn. `agentId` is the id returned by the subagent tool or shown by list_agents.',
|
|
1178
1375
|
parameters: Type.Object({
|
|
1179
1376
|
agentId: Type.String({ description: 'The subagent id (herdr pane id)' }),
|
|
1180
1377
|
message: Type.String({ description: 'The follow-up message' }),
|
|
@@ -1192,7 +1389,9 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
1192
1389
|
subs.set(entry.paneId, entry);
|
|
1193
1390
|
persistSubs();
|
|
1194
1391
|
}
|
|
1195
|
-
// M11(D46):follow_up
|
|
1392
|
+
// M11(D46):follow_up 走扩展管道。B3:带 steer——worker 长跑中途的补充
|
|
1393
|
+
// 指令在 tool-call 间隙秒级到达(旧 followUp 队列语义 = 整个 run 结束才投,
|
|
1394
|
+
// 01a03c0d 实证 20min 延迟致 worker 按旧契约返工)。
|
|
1196
1395
|
const ready = await waitSubReady(entry.cwd, entry.paneId);
|
|
1197
1396
|
if (!ready) throw new Error(`subagent pane ${entry.paneId} pipe not ready`);
|
|
1198
1397
|
const fuId = `fu-${Date.now()}`;
|
|
@@ -1202,10 +1401,12 @@ export default function subagentPlugin(ctx: Context): void {
|
|
|
1202
1401
|
text: String(params?.message ?? ''),
|
|
1203
1402
|
from: pipeNameFor(entry.cwd, env?.paneId ?? ''),
|
|
1204
1403
|
push: true,
|
|
1404
|
+
steer: true,
|
|
1205
1405
|
});
|
|
1206
1406
|
if (res.type !== 'ok') {
|
|
1207
1407
|
throw new Error(`pipe follow_up rejected: ${res.type === 'error' ? res.message : 'unknown response'}`);
|
|
1208
1408
|
}
|
|
1409
|
+
lastMachineInjectAt.set(entry.paneId, Date.now()); // B4:观察窗内 working 归因判据
|
|
1209
1410
|
entry.status = 'running';
|
|
1210
1411
|
persistSubs();
|
|
1211
1412
|
lastRequestIdByPane.set(entry.paneId, fuId);
|