@zhushanwen/pi-goal 0.6.3 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-goal",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "description": "Codex-style /goal command for Pi — persistent goal-driven autonomous loop with evidence-based completion, token/time budgets, blocked detection, and steering templates.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -65,6 +65,7 @@ function makeFakeCtx(overrides?: {
65
65
  aborted?: boolean;
66
66
  hasUI?: boolean;
67
67
  contextUsage?: { tokens?: number; contextWindow?: number };
68
+ entries?: unknown[];
68
69
  }): { ctx: ExtensionContext; calls: RecordedCall[] } {
69
70
  const calls: RecordedCall[] = [];
70
71
  const signal = { aborted: overrides?.aborted ?? false } as AbortSignal;
@@ -87,7 +88,7 @@ function makeFakeCtx(overrides?: {
87
88
  bold: (text: string) => text,
88
89
  },
89
90
  },
90
- sessionManager: { getEntries: () => [], getBranch: () => undefined },
91
+ sessionManager: { getEntries: () => overrides?.entries ?? [], getBranch: () => undefined },
91
92
  } as unknown as ExtensionContext;
92
93
  return { ctx, calls };
93
94
  }
@@ -179,6 +180,153 @@ describe("handleAgentEnd — continuation", () => {
179
180
  expect(sendContext).toHaveLength(1);
180
181
  expect(sendContext[0]!.content).toContain("[GOAL]");
181
182
  });
183
+
184
+ // ── pending 守卫:background subagent/workflow 运行时不死循环 ──
185
+
186
+ it("有活跃 pending → 不发 continuation(pending 守卫生效)", async () => {
187
+ const { pi, calls: piCalls } = makeFakePi();
188
+ const { ctx, calls: ctxCalls } = makeFakeCtx({
189
+ entries: [
190
+ { type: "custom", customType: "pending:register", data: { id: "bg-1", type: "subagent", name: "worker" } },
191
+ ],
192
+ });
193
+ const session = createGoalSession();
194
+ session.state = makeRunningState({ tokensUsed: 200, lastTurnTokensUsed: 0 });
195
+
196
+ await handleAgentEnd(pi, session, ctx);
197
+ const all = allCalls(piCalls, ctxCalls);
198
+
199
+ const followUp = all.filter(
200
+ (c) => c.kind === "sendContext" && (c.payload as { deliverAs?: string } | undefined)?.deliverAs === "followUp",
201
+ );
202
+ expect(followUp).toHaveLength(0); // 有活跃 pending,不发 continuation
203
+ });
204
+
205
+ it("pending register 已 unregister → 差集归零,正常发 continuation", async () => {
206
+ const { pi, calls: piCalls } = makeFakePi();
207
+ const { ctx, calls: ctxCalls } = makeFakeCtx({
208
+ entries: [
209
+ { type: "custom", customType: "pending:register", data: { id: "bg-1" } },
210
+ { type: "custom", customType: "pending:unregister", data: { id: "bg-1" } },
211
+ ],
212
+ });
213
+ const session = createGoalSession();
214
+ session.state = makeRunningState({ tokensUsed: 200, lastTurnTokensUsed: 0 });
215
+
216
+ await handleAgentEnd(pi, session, ctx);
217
+ const all = allCalls(piCalls, ctxCalls);
218
+
219
+ const followUp = all.filter(
220
+ (c) => c.kind === "sendContext" && (c.payload as { deliverAs?: string } | undefined)?.deliverAs === "followUp",
221
+ );
222
+ expect(followUp).toHaveLength(1);
223
+ });
224
+
225
+ it("pending expiresAt 已过期 → 仍判活跃不发 continuation(刻意不校验 TTL)", async () => {
226
+ const { pi, calls: piCalls } = makeFakePi();
227
+ const { ctx, calls: ctxCalls } = makeFakeCtx({
228
+ entries: [
229
+ { type: "custom", customType: "pending:register", data: { id: "bg-1", expiresAt: Date.now() - 1000 } },
230
+ ],
231
+ });
232
+ const session = createGoalSession();
233
+ session.state = makeRunningState({ tokensUsed: 200, lastTurnTokensUsed: 0 });
234
+
235
+ await handleAgentEnd(pi, session, ctx);
236
+ const all = allCalls(piCalls, ctxCalls);
237
+
238
+ const followUp = all.filter(
239
+ (c) => c.kind === "sendContext" && (c.payload as { deliverAs?: string } | undefined)?.deliverAs === "followUp",
240
+ );
241
+ expect(followUp).toHaveLength(0); // 过期仍判活跃,避免长任务死循环复现
242
+ });
243
+
244
+ it("有活跃 pending → appendEntry goal:log 诊断记录(多元素计数 + ids 透出)", async () => {
245
+ const { pi, states } = makeFakePi();
246
+ const { ctx } = makeFakeCtx({
247
+ entries: [
248
+ { type: "custom", customType: "pending:register", data: { id: "bg-1" } },
249
+ { type: "custom", customType: "pending:register", data: { id: "bg-2" } },
250
+ ],
251
+ });
252
+ const session = createGoalSession();
253
+ session.state = makeRunningState({ tokensUsed: 200, lastTurnTokensUsed: 0 });
254
+
255
+ await handleAgentEnd(pi, session, ctx);
256
+
257
+ // goal:log 走 appendEntry(非 goal-history)→ 进 states
258
+ const deferredLog = states.find(
259
+ (s) =>
260
+ (s as { component?: string } | undefined)?.component === "goal:agent-end" &&
261
+ /continuation deferred/.test(String((s as { message?: string } | undefined)?.message ?? "")),
262
+ );
263
+ expect(deferredLog).toBeDefined();
264
+ const data = (deferredLog as { data?: { activePending?: number; ids?: string[] } } | undefined)?.data;
265
+ expect(data?.activePending).toBe(2); // 多元素计数(替代原 >=1 弱断言)
266
+ expect(data?.ids).toEqual(["bg-1", "bg-2"]); // ids 透出
267
+ });
268
+
269
+ it("重复 register 同 id → 去重计为 1(seen.has 守卫)", async () => {
270
+ const { pi, calls: piCalls, states } = makeFakePi();
271
+ const { ctx, calls: ctxCalls } = makeFakeCtx({
272
+ entries: [
273
+ { type: "custom", customType: "pending:register", data: { id: "bg-1" } },
274
+ { type: "custom", customType: "pending:register", data: { id: "bg-1" } },
275
+ ],
276
+ });
277
+ const session = createGoalSession();
278
+ session.state = makeRunningState({ tokensUsed: 200, lastTurnTokensUsed: 0 });
279
+
280
+ await handleAgentEnd(pi, session, ctx);
281
+ const all = allCalls(piCalls, ctxCalls);
282
+
283
+ // 守卫触发:count>0 不发 continuation
284
+ const followUp = all.filter(
285
+ (c) => c.kind === "sendContext" && (c.payload as { deliverAs?: string } | undefined)?.deliverAs === "followUp",
286
+ );
287
+ expect(followUp).toHaveLength(0);
288
+ // seen 去重:2 条同 id register 计为 1(非 2)
289
+ const deferredLog = states.find(
290
+ (s) => (s as { component?: string } | undefined)?.component === "goal:agent-end",
291
+ );
292
+ expect((deferredLog as { data?: { activePending?: number } } | undefined)?.data?.activePending).toBe(1);
293
+ });
294
+
295
+ it("非字符串/缺失 id 的 register → 被忽略,正常发 continuation(typeof id 防御)", async () => {
296
+ const { pi, calls: piCalls } = makeFakePi();
297
+ const { ctx, calls: ctxCalls } = makeFakeCtx({
298
+ entries: [
299
+ { type: "custom", customType: "pending:register", data: { id: 123 } }, // 数字 id(非字符串)
300
+ { type: "custom", customType: "pending:register", data: {} }, // 无 id
301
+ ],
302
+ });
303
+ const session = createGoalSession();
304
+ session.state = makeRunningState({ tokensUsed: 200, lastTurnTokensUsed: 0 });
305
+
306
+ await handleAgentEnd(pi, session, ctx);
307
+ const all = allCalls(piCalls, ctxCalls);
308
+
309
+ // count=0,守卫不触发,走正常 continuation
310
+ const followUp = all.filter(
311
+ (c) => c.kind === "sendContext" && (c.payload as { deliverAs?: string } | undefined)?.deliverAs === "followUp",
312
+ );
313
+ expect(followUp).toHaveLength(1);
314
+ });
315
+
316
+ it("无 pending entries → 正常发 continuation(回归保护)", async () => {
317
+ const { pi, calls: piCalls } = makeFakePi();
318
+ const { ctx, calls: ctxCalls } = makeFakeCtx(); // getEntries 默认空
319
+ const session = createGoalSession();
320
+ session.state = makeRunningState({ tokensUsed: 200, lastTurnTokensUsed: 0 });
321
+
322
+ await handleAgentEnd(pi, session, ctx);
323
+ const all = allCalls(piCalls, ctxCalls);
324
+
325
+ const followUp = all.filter(
326
+ (c) => c.kind === "sendContext" && (c.payload as { deliverAs?: string } | undefined)?.deliverAs === "followUp",
327
+ );
328
+ expect(followUp).toHaveLength(1);
329
+ });
182
330
  });
183
331
 
184
332
  // ── handleAgentEnd:并发保护(G-021)+ stale(G-020)───
@@ -339,53 +487,14 @@ describe("handleBeforeAgentStart", () => {
339
487
 
340
488
  // ── pending-notifications(W2 跨扩展集成)───────────────────
341
489
 
342
- it("有活跃 pending operations → 注入提示含 pending 数量", async () => {
343
- const { pi } = makeFakePi();
344
- // 创建带 pending:register entries 的 ctx
345
- const entries = [
346
- { type: "custom", customType: "pending:register", data: { id: "wf-1", type: "workflow", name: "test-wf" } },
347
- { type: "custom", customType: "pending:register", data: { id: "bg-1", type: "subagent", name: "worker" } },
348
- ];
349
- const ctx = {
350
- hasUI: true,
351
- signal: { aborted: false } as AbortSignal,
352
- getContextUsage: () => undefined,
353
- ui: {
354
- notify: vi.fn(),
355
- setStatus: vi.fn(),
356
- setWidget: vi.fn(),
357
- theme: { fg: (_c: string, t: string) => t, bold: (t: string) => t },
358
- },
359
- sessionManager: { getEntries: () => entries, getBranch: () => undefined },
360
- } as unknown as ExtensionContext;
361
- const session = createGoalSession();
362
- session.state = makeRunningState();
363
-
364
- const result = await handleBeforeAgentStart(pi, session, ctx);
365
-
366
- expect(result).toBeDefined();
367
- expect(result!.message.customType).toBe("goal-context");
368
- expect(result!.message.content).toContain("2 pending async operation(s)");
369
- });
370
-
371
- it("有 pending register 但已 unregister → 不注入提示", async () => {
490
+ it("有活跃 pending entries → 注入 content 不含 pending 提示(pendingHint 已移除)", async () => {
372
491
  const { pi } = makeFakePi();
373
- const entries = [
374
- { type: "custom", customType: "pending:register", data: { id: "wf-1", type: "workflow", name: "test-wf" } },
375
- { type: "custom", customType: "pending:unregister", data: { id: "wf-1" } },
376
- ];
377
- const ctx = {
378
- hasUI: true,
379
- signal: { aborted: false } as AbortSignal,
380
- getContextUsage: () => undefined,
381
- ui: {
382
- notify: vi.fn(),
383
- setStatus: vi.fn(),
384
- setWidget: vi.fn(),
385
- theme: { fg: (_c: string, t: string) => t, bold: (t: string) => t },
386
- },
387
- sessionManager: { getEntries: () => entries, getBranch: () => undefined },
388
- } as unknown as ExtensionContext;
492
+ const { ctx } = makeFakeCtx({
493
+ entries: [
494
+ { type: "custom", customType: "pending:register", data: { id: "wf-1", type: "workflow", name: "test-wf" } },
495
+ { type: "custom", customType: "pending:register", data: { id: "bg-1", type: "subagent", name: "worker" } },
496
+ ],
497
+ });
389
498
  const session = createGoalSession();
390
499
  session.state = makeRunningState();
391
500
 
@@ -393,6 +502,7 @@ describe("handleBeforeAgentStart", () => {
393
502
 
394
503
  expect(result).toBeDefined();
395
504
  expect(result!.message.customType).toBe("goal-context");
505
+ // pendingHint 已删除:pending 感知交给 LLM 调 pending_notifications tool(mandatory)
396
506
  expect(result!.message.content).not.toContain("pending async operation");
397
507
  });
398
508
 
@@ -28,6 +28,7 @@ import {
28
28
  } from "../../projection/prompts";
29
29
  import { persistAndUpdate, tickState } from "../../service";
30
30
  import type { GoalSession } from "../../session";
31
+ import type { SessionEntryLike } from "../../ports";
31
32
  import { buildPorts } from "../ports";
32
33
  import { makeStaleChecker } from "./shared";
33
34
 
@@ -169,9 +170,61 @@ async function handleContinuation(
169
170
  // 终态守卫:persistAndUpdate 可能把 goal 转为 budget_limited/time_limited 终态。
170
171
  // 此时不应发 continuation(deliverAs:"followUp" 会触发新 turn,让已耗尽预算的 agent 再跑一轮)。
171
172
  if (isTerminalStatus(state.status)) return;
173
+ // pending 守卫:有活跃 background subagent/workflow 时不发 continuation。
174
+ // 靠 subagent/workflow 完成时 sendMessage({triggerTurn:true,deliverAs:"steer"}) 自然唤醒主 agent;
175
+ // goal 抢先催会与异步任务完成通知叠加,形成“停不下来”的死循环——continuation 排入
176
+ // _followUpMessages 队列 → _handlePostAgentRun 的 hasQueuedMessages() 返回 true → agent.continue()
177
+ // 新 turn → 又 agent_end → 又 continuation。
178
+ // 刻意不校验 expiresAt:长任务 subagent(>1h TTL)完成时仍 triggerTurn 唤醒主 agent,
179
+ // 按 TTL 判非活跃会让死循环在长任务场景复现。
180
+ const pendingOps = countActivePendingOps(ctx.sessionManager.getEntries());
181
+ if (pendingOps.count > 0) {
182
+ pi.appendEntry("goal:log", {
183
+ timestamp: Date.now(),
184
+ level: "debug",
185
+ component: "goal:agent-end",
186
+ message: `continuation deferred: ${pendingOps.count} active pending operation(s)`,
187
+ data: { activePending: pendingOps.count, ids: pendingOps.ids },
188
+ });
189
+ // 用户可见反馈:goal 在等待后台任务完成而非卡死(deferred 无反馈时用户只看到
190
+ // goal active 但无产出,无法区分等待 vs 死循环)。
191
+ ctx.ui.notify(
192
+ `Goal waiting for ${pendingOps.count} background task(s) to complete.`,
193
+ "info",
194
+ );
195
+ return;
196
+ }
172
197
  // 发 continuation(FR-8.7: 去 debounce 后才发)
173
198
  buildPorts(pi, ctx).messaging.sendContextMessage(
174
199
  continuationPrompt(state, state.timeUsedSeconds),
175
200
  "followUp",
176
201
  );
177
202
  }
203
+
204
+ /** 类型守卫:unknown → Record<string, unknown>(替代裸 as 断言访问 entry.data.id)。 */
205
+ const isRecord = (x: unknown): x is Record<string, unknown> =>
206
+ typeof x === "object" && x !== null;
207
+
208
+ /** 计算活跃的 pending async operation(background subagent/workflow)数量。
209
+ * 读 session entries 里 customType="pending:register" 减 "pending:unregister" 的 id 差集。
210
+ * 刻意不校验 expiresAt(见 handleContinuation 守卫注释)。 */
211
+ function countActivePendingOps(
212
+ entries: readonly SessionEntryLike[],
213
+ ): { count: number; ids: string[] } {
214
+ const unregistered = new Set<string>();
215
+ for (const e of entries) {
216
+ if (e.type !== "custom" || e.customType !== "pending:unregister") continue;
217
+ const id = isRecord(e.data) ? e.data.id : undefined;
218
+ if (typeof id === "string") unregistered.add(id);
219
+ }
220
+ const activeIds: string[] = [];
221
+ const seen = new Set<string>();
222
+ for (const e of entries) {
223
+ if (e.type !== "custom" || e.customType !== "pending:register") continue;
224
+ const id = isRecord(e.data) ? e.data.id : undefined;
225
+ if (typeof id !== "string" || unregistered.has(id) || seen.has(id)) continue;
226
+ seen.add(id);
227
+ activeIds.push(id);
228
+ }
229
+ return { count: activeIds.length, ids: activeIds };
230
+ }
@@ -15,7 +15,7 @@
15
15
  * contextInjectionPrompt 恒定建议 plan mode(AI 自行决定是否用)。
16
16
  */
17
17
 
18
- import type { CustomEntry, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
18
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
19
19
 
20
20
  import { AUTO_CLEAR_TURNS, CONTEXT_USAGE_RATIO_LIMIT } from "../../constants";
21
21
  import { isActiveStatus, isTerminalStatus } from "../../engine/goal";
@@ -33,14 +33,6 @@ interface BeforeAgentStartResult {
33
33
  };
34
34
  }
35
35
 
36
- /** 类型守卫:把 SDK 的 SessionEntry 联合收窄到 CustomEntry。
37
- * SessionEntry 现为联合类型(SessionMessageEntry | CompactionEntry | CustomEntry | ...),
38
- * 只有 CustomEntry 带 customType/data(pending:register/unregister 由 pi.appendEntry 落地,
39
- * type 恒为 "custom")。读取前必须先收窄。 */
40
- function isCustomEntry(e: { type: string }): e is CustomEntry {
41
- return e.type === "custom";
42
- }
43
-
44
36
  export async function handleBeforeAgentStart(
45
37
  pi: ExtensionAPI,
46
38
  session: GoalSession,
@@ -69,47 +61,12 @@ export async function handleBeforeAgentStart(
69
61
 
70
62
  // 正常 context injection。
71
63
  // 全解耦:planAvailable 恒 true(contextInjectionPrompt 恒定建议 plan mode,AI 自行决定)。
72
- // pending-notifications:若有活跃的异步操作,注入等待提示。
73
- // 读取 pending:register/unregister entry 算差集,并校验 expiresAt(TTL 过期的视为非活跃)。
74
- // pending-notifications 的 rebuildFromEntries 在其 session_start 已 flush 跨 session/过期残留,
75
- // 但同一 session 内超时未 unregister 的 entry(如崩溃恢复遗漏)需此处完补以防误报 active。
76
- const now = Date.now();
77
- const entries = ctx.sessionManager.getEntries();
78
- // pending:register/unregister 由 pi.appendEntry 落地为 CustomEntry(type:"custom")。
79
- // 先用 isCustomEntry 收窄联合类型,再读 customType/data。
80
- const customEntries = entries.filter(isCustomEntry);
81
- const pendingUnregisters = new Set(
82
- customEntries.filter((e) => e.customType === "pending:unregister").map((e) => (e.data as Record<string, unknown>)?.id),
83
- );
84
- const activePending = customEntries.filter((e) => {
85
- if (e.customType !== "pending:register") return false;
86
- const id = (e.data as Record<string, unknown>)?.id;
87
- if (pendingUnregisters.has(id)) return false;
88
- // TTL 过期视为非活跃(expiresAt 缺失或类型错误时宽容处理为活跃)
89
- const expiresAt = (e.data as Record<string, unknown>)?.expiresAt;
90
- if (typeof expiresAt === "number" && expiresAt <= now) return false;
91
- return true;
92
- });
93
- const pendingHint = activePending.length > 0
94
- ? `\nNote: There are ${activePending.length} pending async operation(s) running. Consider waiting for them to complete before starting new work.`
95
- : "";
96
-
97
- pi.appendEntry("goal:log", {
98
- timestamp: Date.now(),
99
- level: "debug",
100
- component: "goal:before-agent-start",
101
- message: "pending entries computed",
102
- data: {
103
- activePending: activePending.length,
104
- injectHint: activePending.length > 0,
105
- pendingIds: activePending.map((e) => (e.data as Record<string, unknown>)?.id),
106
- },
107
- });
108
-
64
+ // pending 感知由 LLM 自行调 pending_notifications tool(mandatory)查询当前活跃异步操作,
65
+ // goal 不在此注入——避免与 tool 查询结果形成双信息源不一致(tool 是 EventBus+entries 维护的权威源)。
109
66
  return {
110
67
  message: {
111
68
  customType: "goal-context",
112
- content: contextInjectionPrompt(session.state, session.state.timeUsedSeconds, true) + pendingHint,
69
+ content: contextInjectionPrompt(session.state, session.state.timeUsedSeconds, true),
113
70
  display: false,
114
71
  },
115
72
  };