@zhushanwen/pi-goal 0.12.2 → 0.14.0

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.
Files changed (41) hide show
  1. package/README.md +3 -3
  2. package/package.json +3 -3
  3. package/src/__tests__/circuit-breaker.test.ts +449 -0
  4. package/src/__tests__/criteria-array-adapter.test.ts +4 -4
  5. package/src/__tests__/criteria-array-migration.test.ts +0 -2
  6. package/src/__tests__/deserialize-state.test.ts +54 -6
  7. package/src/__tests__/event-adapter.test.ts +2 -2
  8. package/src/__tests__/goal-control-adapter.test.ts +5 -5
  9. package/src/__tests__/goal-control-rpc.test.ts +1 -1
  10. package/src/__tests__/liveness.test.ts +162 -0
  11. package/src/__tests__/ports.test.ts +1 -1
  12. package/src/__tests__/schema.test.ts +15 -0
  13. package/src/__tests__/service.test.ts +5 -5
  14. package/src/__tests__/session.test.ts +0 -2
  15. package/src/__tests__/stale-checker.test.ts +2 -2
  16. package/src/adapters/command-adapter.ts +31 -8
  17. package/src/adapters/event-handlers/agent-end.ts +202 -29
  18. package/src/adapters/event-handlers/before-agent-start.ts +7 -3
  19. package/src/adapters/event-handlers/message-end.ts +2 -14
  20. package/src/adapters/event-handlers/session-shutdown.ts +23 -0
  21. package/src/adapters/event-handlers/session-start.ts +4 -1
  22. package/src/adapters/goal-control-adapter.ts +1 -1
  23. package/src/adapters/ports.ts +17 -23
  24. package/src/constants.ts +13 -0
  25. package/src/engine/__tests__/budget.test.ts +7 -7
  26. package/src/engine/__tests__/goal.test.ts +40 -1
  27. package/src/engine/budget.ts +7 -13
  28. package/src/engine/goal.ts +6 -2
  29. package/src/engine/liveness.ts +150 -0
  30. package/src/engine/types.ts +21 -2
  31. package/src/index.ts +23 -50
  32. package/src/persistence.ts +22 -5
  33. package/src/ports.ts +13 -3
  34. package/src/projection/__tests__/prompts.test.ts +19 -11
  35. package/src/projection/__tests__/widget.test.ts +9 -6
  36. package/src/projection/prompts.ts +8 -28
  37. package/src/projection/widget.ts +6 -38
  38. package/src/service.ts +6 -6
  39. package/src/session.ts +25 -0
  40. package/src/adapters/event-adapter.ts +0 -16
  41. package/src/adapters/event-handlers/shared.ts +0 -27
@@ -0,0 +1,162 @@
1
+ /**
2
+ * engine/liveness.ts 纯函数测试 — 轮次活性熔断判定单元(chat-domain-v1x D4 / W5)
3
+ *
4
+ * 覆盖:env 配置解析(合法/非法/缺省)、退避等级数学(第 N 轮起 ×2、指数天花板)、
5
+ * 进展信号边界、toolCall 块计数(duck-typed 守卫)、pending 集合变化判定。
6
+ * 行为级四断言(封顶/退避/恢复/defer 去重经 handleAgentEnd)见 circuit-breaker.test.ts。
7
+ */
8
+ import { describe, expect, it } from "vitest";
9
+
10
+ import {
11
+ continuationBackoffDelayMs,
12
+ countToolCallBlocks,
13
+ isProgressSignal,
14
+ nextNoProgressTurns,
15
+ pendingSetChanged,
16
+ resolveLivenessConfig,
17
+ type LivenessConfig,
18
+ } from "../engine/liveness";
19
+ import { createGoalState } from "../engine/goal";
20
+
21
+ const CFG: LivenessConfig = resolveLivenessConfig({});
22
+
23
+ // ── resolveLivenessConfig(env 覆盖)──────────────────
24
+
25
+ describe("resolveLivenessConfig", () => {
26
+ it("缺省 env → 全默认值(50 / 5 / 1000 / 10s / 10min)", () => {
27
+ const cfg = resolveLivenessConfig({});
28
+ expect(cfg.continuationCap).toBe(50);
29
+ expect(cfg.noProgressTurnsThreshold).toBe(5);
30
+ expect(cfg.noProgressTokenThreshold).toBe(1000);
31
+ expect(cfg.backoffBaseMs).toBe(10_000);
32
+ expect(cfg.backoffMaxMs).toBe(600_000);
33
+ });
34
+
35
+ it("PI_GOAL_* 合法正整数 → 覆盖默认值", () => {
36
+ const cfg = resolveLivenessConfig({
37
+ PI_GOAL_CONTINUATION_CAP: "10",
38
+ PI_GOAL_NO_PROGRESS_TURNS: "3",
39
+ PI_GOAL_NO_PROGRESS_TOKEN_THRESHOLD: "500",
40
+ PI_GOAL_BACKOFF_BASE_MS: "1000",
41
+ PI_GOAL_BACKOFF_MAX_MS: "30000",
42
+ });
43
+ expect(cfg.continuationCap).toBe(10);
44
+ expect(cfg.noProgressTurnsThreshold).toBe(3);
45
+ expect(cfg.noProgressTokenThreshold).toBe(500);
46
+ expect(cfg.backoffBaseMs).toBe(1000);
47
+ expect(cfg.backoffMaxMs).toBe(30000);
48
+ });
49
+
50
+ it.each([
51
+ ["非数字", "abc"],
52
+ ["零", "0"],
53
+ ["负数", "-5"],
54
+ ["小数", "2.7"],
55
+ ])("非法值(%s)→ 回退默认(安全网不因坏配置挂掉)", (_label, raw) => {
56
+ const cfg = resolveLivenessConfig({ PI_GOAL_CONTINUATION_CAP: raw });
57
+ expect(cfg.continuationCap).toBe(50);
58
+ });
59
+ });
60
+
61
+ // ── 进展信号 + 无进展计数 ────────────────────────────
62
+
63
+ describe("isProgressSignal / nextNoProgressTurns", () => {
64
+ it("有工具调用(toolDelta>0)即进展,即使 tokenDelta 低", () => {
65
+ expect(isProgressSignal(1, 100, CFG)).toBe(true);
66
+ });
67
+
68
+ it("tokenDelta 越阈值即进展,即使无工具调用(纯文本迭代型任务豁免退避)", () => {
69
+ expect(isProgressSignal(0, 1000, CFG)).toBe(true);
70
+ expect(isProgressSignal(0, 999, CFG)).toBe(false);
71
+ });
72
+
73
+ it("无进展 → 计数 +1;进展 → 清零", () => {
74
+ expect(nextNoProgressTurns(4, false)).toBe(5);
75
+ expect(nextNoProgressTurns(4, true)).toBe(0);
76
+ });
77
+ });
78
+
79
+ // ── 退避等级数学 ─────────────────────────────────────
80
+
81
+ describe("continuationBackoffDelayMs", () => {
82
+ it("未达阈值(1~4 轮)→ 0(立即发)", () => {
83
+ expect(continuationBackoffDelayMs(0, CFG)).toBe(0);
84
+ expect(continuationBackoffDelayMs(4, CFG)).toBe(0);
85
+ });
86
+
87
+ it("第 5 轮起 ×2 递增:5 轮 → 2×base,6 轮 → 4×base,7 轮 → 8×base", () => {
88
+ expect(continuationBackoffDelayMs(5, CFG)).toBe(20_000);
89
+ expect(continuationBackoffDelayMs(6, CFG)).toBe(40_000);
90
+ expect(continuationBackoffDelayMs(7, CFG)).toBe(80_000);
91
+ });
92
+
93
+ it("指数封顶:delay 永不超过 backoffMaxMs", () => {
94
+ expect(continuationBackoffDelayMs(50, CFG)).toBe(600_000);
95
+ expect(continuationBackoffDelayMs(1000, CFG)).toBe(600_000);
96
+ });
97
+ });
98
+
99
+ // ── toolCall 块计数 ──────────────────────────────────
100
+
101
+ describe("countToolCallBlocks", () => {
102
+ it("assistant content 内 toolCall 块计数(多个 entry 累计)", () => {
103
+ const entries = [
104
+ { type: "message", message: { role: "user", content: "hi" } },
105
+ {
106
+ type: "message",
107
+ message: {
108
+ role: "assistant",
109
+ content: [
110
+ { type: "text", text: "thinking..." },
111
+ { type: "toolCall", id: "tc-1", name: "bash" },
112
+ { type: "toolCall", id: "tc-2", name: "read" },
113
+ ],
114
+ },
115
+ },
116
+ {
117
+ type: "message",
118
+ message: { role: "assistant", content: [{ type: "toolCall", id: "tc-3", name: "bash" }] },
119
+ },
120
+ { type: "message", message: { role: "toolResult", content: [], toolName: "bash", toolCallId: "tc-1" } },
121
+ { type: "custom", customType: "goal-state", data: {} },
122
+ ];
123
+ expect(countToolCallBlocks(entries)).toBe(3);
124
+ });
125
+
126
+ it("畸形 entry(null/非对象/缺 message/content 非数组)→ 跳过不计数", () => {
127
+ expect(
128
+ countToolCallBlocks([null, 42, "x", { type: "message" }, { type: "message", message: null }, {
129
+ type: "message",
130
+ message: { role: "assistant", content: "plain-string" },
131
+ }]),
132
+ ).toBe(0);
133
+ });
134
+ });
135
+
136
+ // ── pending 集合变化 ─────────────────────────────────
137
+
138
+ describe("pendingSetChanged", () => {
139
+ it("集合相同(含顺序不同)→ 无变化", () => {
140
+ expect(pendingSetChanged(["a", "b"], ["b", "a"])).toBe(false);
141
+ expect(pendingSetChanged([], [])).toBe(false);
142
+ });
143
+
144
+ it("首次进入 defer(last 为空)或有增删 → 变化", () => {
145
+ expect(pendingSetChanged(["a"], [])).toBe(true);
146
+ expect(pendingSetChanged(["a"], ["a", "b"])).toBe(true);
147
+ expect(pendingSetChanged(["a", "c"], ["a", "b"])).toBe(true);
148
+ });
149
+ });
150
+
151
+ // ── createGoalState 初始化 ───────────────────────────
152
+
153
+ describe("createGoalState — W5 计数初始化", () => {
154
+ it("新 goal 熔断计数全部从零开始", () => {
155
+ const state = createGoalState("obj");
156
+ expect(state.continuationsSent).toBe(0);
157
+ expect(state.noProgressTurns).toBe(0);
158
+ expect(state.continuationCapNotified).toBe(false);
159
+ expect(state.lastDeferredPendingIds).toEqual([]);
160
+ expect(state.toolCallsSeen).toBe(0);
161
+ });
162
+ });
@@ -46,7 +46,7 @@ describe("buildPorts UiPort.setGuiWidget(marker 编码集成)", () => {
46
46
  const captured = getCaptured();
47
47
  expect(captured).toBeDefined();
48
48
  expect(captured).toHaveLength(1);
49
- // marker 前缀(host 侧 event-adapter 据此解码成 extension:widgetGui)
49
+ // marker 前缀(host 侧 EventAdapter 据此解码成 extension:widgetGui)
50
50
  expect(captured![0].startsWith(GUI_WIDGET_MARKER)).toBe(true);
51
51
  // 解码还原 GuiComponent
52
52
  const json = captured![0].slice(GUI_WIDGET_MARKER.length);
@@ -16,6 +16,8 @@ import { describe, expect, it } from "vitest";
16
16
  import { Value } from "typebox/value";
17
17
 
18
18
  import { GoalControlParams } from "../adapters/goal-control-adapter";
19
+ import { createGoalState } from "../engine/goal";
20
+ import { serializeState } from "../persistence";
19
21
 
20
22
  describe("GoalControlParams — 扁平 Object(OpenAI 兼容)", () => {
21
23
  // 顶层合规:序列化形态顶层必须有 type:"object",不得有 anyOf(否则被严格网关 400 拒绝)
@@ -52,3 +54,16 @@ describe("GoalControlParams — 扁平 Object(OpenAI 兼容)", () => {
52
54
  expect(Value.Check(GoalControlParams, params)).toBe(false);
53
55
  });
54
56
  });
57
+
58
+ // ── 持久化 schema:serializeState 输出(P-m14-2,ext-simplify-03 D2)──
59
+
60
+ describe("persisted goal-state schema — serializeState 输出(P-m14-2)", () => {
61
+ // D2 一步删除 lastProgressTurn/objectiveUpdatedAt 后,新写入的 goal-state entry
62
+ // 不得再携带这两个 write-only 字段。serializeState 是浅拷贝透传({ ...state, budget: {...} }),
63
+ // 字段从 GoalRuntimeState 删除后输出自动少两键——此处断言删除完整(fixture 残留即红)。
64
+ it("新写入 entry(serializeState(createGoalState))不含已删两字段", () => {
65
+ const serialized = serializeState(createGoalState("obj"));
66
+ expect(serialized).not.toHaveProperty("lastProgressTurn");
67
+ expect(serialized).not.toHaveProperty("objectiveUpdatedAt");
68
+ });
69
+ });
@@ -34,9 +34,11 @@ function makeFakePorts(): ServicePorts & {
34
34
  setStatus: () => {},
35
35
  notify: () => {},
36
36
  hasUI: true,
37
- // ThemeLike 直接成员(widget 经 asTheme 取出,见 projection/widget.ts)
38
- fg: (_color: string, text: string) => text,
39
- bold: (text: string) => text,
37
+ // ThemeLike 嵌套成员(UiPort.theme 显式声明,updateWidget 直读 uiPort.theme)
38
+ theme: {
39
+ fg: (_color: string, text: string) => text,
40
+ bold: (text: string) => text,
41
+ },
40
42
  },
41
43
  messaging: {
42
44
  sendContextMessage: () => {},
@@ -44,8 +46,6 @@ function makeFakePorts(): ServicePorts & {
44
46
  },
45
47
  session: {
46
48
  getEntries: () => [],
47
- getContextUsage: () => null,
48
- signal: undefined,
49
49
  },
50
50
  };
51
51
  }
@@ -31,8 +31,6 @@ function makeFakeSessionPort(entries: SessionEntryLike[]): SessionPort {
31
31
  return {
32
32
  // 模拟 Pi SDK filter-copy 语义:返回新数组,避免测试掩盖 splice GC 失效
33
33
  getEntries: () => entries.slice(),
34
- getContextUsage: () => null,
35
- signal: undefined,
36
34
  };
37
35
  }
38
36
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * event-adapter 并发保护辅助函数测试(TC-3)
2
+ * agent-end stale-check 辅助函数测试(TC-3)
3
3
  *
4
4
  * 直接测试 makeStaleChecker 的快照语义,
5
5
  * 非间接覆盖。重点:makeStaleChecker 在 state=null 时 snapshot=undefined,
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { describe, expect, it } from "vitest";
9
9
 
10
- import { makeStaleChecker } from "../adapters/event-handlers/shared";
10
+ import { makeStaleChecker } from "../adapters/event-handlers/agent-end";
11
11
  import { createGoalState } from "../engine/goal";
12
12
  import type { GoalRuntimeState } from "../engine/types";
13
13
  import { createGoalSession } from "../session";
@@ -31,7 +31,7 @@ import { updateWidget } from "../projection/widget";
31
31
  import { finalizeAndPersist, persistState, tickState } from "../service";
32
32
  import type { GoalHistoryEntry } from "../ports";
33
33
  import type { GoalSession } from "../session";
34
- import { clearGoalSession } from "../session";
34
+ import { clearGoalSession, cancelContinuationTimer } from "../session";
35
35
  import { buildPorts } from "./ports";
36
36
  import { CRITERIA_HINTS_SLASH, validateSuccessCriteriaItems } from "./success-criteria";
37
37
 
@@ -122,6 +122,9 @@ function handlePause(pi: ExtensionAPI, session: GoalSession, ctx: ExtensionConte
122
122
  // 先 tickState 累加当前运行段(此时 status 仍为 active)
123
123
  tickState(state);
124
124
  state.status = transitionStatus(state.status, "paused");
125
+ // W5:用户主动叫停 → 取消待发的退避 continuation(paused 期间不自动续跑;
126
+ // resume 后由其触发的 user message 重新驱动循环)
127
+ cancelContinuationTimer(session);
125
128
 
126
129
  const ports = buildPorts(pi, ctx);
127
130
  persistState(session, ports);
@@ -141,13 +144,27 @@ function handleResume(pi: ExtensionAPI, session: GoalSession, ctx: ExtensionCont
141
144
  ctx.ui.notify(`Goal is in terminal state (${state.status}), cannot resume.`, "warning");
142
145
  return;
143
146
  }
147
+ // W5 恢复通道②:active 且 continuation 已封顶停发 → resume 重置熔断计数续跑
148
+ // (goal 封顶时刻意保持 active 不转终态,续跑决策权在用户)。
149
+ // 未封顶的普通 active goal 无需 resume(保持原语义)。
150
+ const cappedActive = state.status === "active" && state.continuationCapNotified;
144
151
  // FR-3: resume 支持 paused→active 和 blocked→active(两者对称,都做 budget 重检 + 触发 AI)
145
- if (state.status !== "paused" && state.status !== "blocked") {
152
+ if (state.status !== "paused" && state.status !== "blocked" && !cappedActive) {
146
153
  ctx.ui.notify("Goal is not paused or blocked, no need to resume.", "info");
147
154
  return;
148
155
  }
149
- state.status = "active";
150
- state.timeStartedAt = Date.now();
156
+ if (state.status !== "active") {
157
+ state.status = transitionStatus(state.status, "active");
158
+ state.timeStartedAt = Date.now();
159
+ }
160
+ // W5:resume = 新激活周期——熔断计数清零。主判据总次数(continuationsSent)只经
161
+ // 用户显式 resume 重置,agent 侧任何行为(含每轮调工具)都不可清零(正交性)。
162
+ state.continuationsSent = 0;
163
+ state.noProgressTurns = 0;
164
+ state.continuationCapNotified = false;
165
+ state.lastDeferredPendingIds = [];
166
+ // toolCallsSeen 不清零:entries 差分口径跨周期连续(清零会让首轮差分把历史
167
+ // 调用全算成本轮进展,虚高一次无害但无意义)
151
168
 
152
169
  const ports = buildPorts(pi, ctx);
153
170
 
@@ -171,8 +188,11 @@ function handleResume(pi: ExtensionAPI, session: GoalSession, ctx: ExtensionCont
171
188
  const blockerNote = state.lastBlockerReason
172
189
  ? `\n\nPrevious blocker: ${state.lastBlockerReason}. Try a different approach.`
173
190
  : "";
191
+ const capNote = cappedActive
192
+ ? "\n\nContinuation circuit breaker was reached — pursue the objective with concrete tool-backed progress."
193
+ : "";
174
194
  pi.sendUserMessage(
175
- `Goal resumed. Continuing toward the objective.${blockerNote}\n\nObjective: ${state.objective}`,
195
+ `Goal resumed. Continuing toward the objective.${blockerNote}${capNote}\n\nObjective: ${state.objective}`,
176
196
  { deliverAs: "followUp" },
177
197
  );
178
198
  }
@@ -264,7 +284,7 @@ function handleClear(pi: ExtensionAPI, session: GoalSession, ctx: ExtensionConte
264
284
 
265
285
  /**
266
286
  * FR-8.4 G-002:重塑(reset)。重置 objective/budget flags/
267
- * currentTurnIndex/lastProgressTurn,保留 goalId。
287
+ * currentTurnIndex,保留 goalId。
268
288
  * active 状态下向 AI 注入 objective-updated steering。
269
289
  */
270
290
  function handleUpdate(
@@ -293,12 +313,15 @@ function handleUpdate(
293
313
  const oldObjective = state.objective;
294
314
  // FR-8.4 G-002: 重塑(重置,保留 goalId)
295
315
  state.objective = newObjective;
296
- state.objectiveUpdatedAt = Date.now();
297
316
  state.currentTurnIndex = 0;
298
- state.lastProgressTurn = 0;
299
317
  state.budgetLimitSteeringSent = false;
300
318
  state.tokenWarning70Sent = false;
301
319
  state.tokenWarning90Sent = false;
320
+ // W5:重塑 = 新激活周期,熔断计数与 budget flag 同类(cycle-scoped 记账)一并重置
321
+ state.continuationsSent = 0;
322
+ state.noProgressTurns = 0;
323
+ state.continuationCapNotified = false;
324
+ state.lastDeferredPendingIds = [];
302
325
  // GAP-6: update 是重塑,旧 slug 已不匹配新 objective → 置空(widget fallback objective 截断)
303
326
  state.slug = undefined;
304
327
  // update 重塑后旧 successCriteria 可能不再完全匹配新 objective,但语义内容仍可部分适用。
@@ -7,7 +7,8 @@
7
7
  * - FR-6.7 ESC 守卫(最关键):ctx.signal?.aborted → 不发 continuation、不做 budget 检查、
8
8
  * 不做任何状态变更,goal 保持 active,等用户下次输入恢复
9
9
  *
10
- * #8 后流程:budget 预警/steering → continuation 去抖。
10
+ * #8 后流程:budget 预警/steering → continuation 去抖 + 轮次活性熔断(W5:
11
+ * 连发总次数封顶 + 无进展退避 + defer 通知去重,见 handleContinuation 头注)。
11
12
  * agent_end 只做提醒(warning + steering),不做终态转换。
12
13
  * 终态转换不在 agent_end——由 persistAndUpdate 兜底(#5 范围,单一检查点)。
13
14
  *
@@ -23,15 +24,25 @@ import { countActiveFromEntries } from "@zhushanwen/pi-pending-notifications";
23
24
 
24
25
  import { checkBudgetOnTurnEnd } from "../../engine/budget";
25
26
  import { isActiveStatus, isTerminalStatus } from "../../engine/goal";
27
+ import {
28
+ type LivenessConfig,
29
+ continuationBackoffDelayMs,
30
+ countToolCallBlocks,
31
+ isContinuationCapped,
32
+ isProgressSignal,
33
+ nextNoProgressTurns,
34
+ pendingSetChanged,
35
+ resolveLivenessConfig,
36
+ } from "../../engine/liveness";
26
37
  import {
27
38
  budgetLimitPrompt,
28
39
  continuationPrompt,
29
40
  } from "../../projection/prompts";
30
41
  import { persistAndUpdate, tickState } from "../../service";
31
- import type { GoalSession } from "../../session";
42
+ import { serializeState } from "../../persistence";
43
+ import { cancelContinuationTimer, type GoalSession } from "../../session";
32
44
  import type { ServicePorts } from "../../service";
33
45
  import { buildPorts } from "../ports";
34
- import { makeStaleChecker } from "./shared";
35
46
 
36
47
  export async function handleAgentEnd(
37
48
  pi: ExtensionAPI,
@@ -135,13 +146,19 @@ async function handleBudgetChecks(
135
146
  }
136
147
 
137
148
  /**
138
- * continuation 去抖(budget 预警未拦截时的尾部逻辑)。
149
+ * continuation 去抖 + 轮次活性熔断(budget 预警未拦截时的尾部逻辑)。
139
150
  *
140
151
  * - continuation 去抖:tokenDelta=0(空 turn)不发,只 persist
141
- * - 否则 persist + 发 continuationPrompt
152
+ * - W5 主判据:continuation 连发总次数封顶(默认 50,PI_GOAL_CONTINUATION_CAP)——
153
+ * 封顶必停发 + notify 用户 + goal 保持 active(不擅自终态)。与「是否调工具」
154
+ * 正交:计数只在 deliverContinuation 实发时 +1,绝不被工具调用清零
155
+ * (2026-09-08 事故:pending 守卫击穿后 64 分钟 ~320 turn 空转,无任何熔断)。
156
+ * - W5 辅判据:连续 N 轮(默认 5)无工具调用且 tokenDelta 低于阈值 → continuation
157
+ * 延迟 base×2^level 发出(间隔 ×2 递增);真实进展清零退避计数(不动总次数)。
158
+ * - W5 defer 通知去重:pending 集合不变的连续 defer 轮不重复 notify。
142
159
  *
143
160
  * 注:maxTurnsReached / stall 自动终态分支随 #6 删除;budget terminal 分支随 #8 删除。
144
- * 终态转换由 persistAndUpdate 兑底(#5 范围,单一检查点)。
161
+ * 终态转换由 persistAndUpdate 兜底(#5 范围,单一检查点)。
145
162
  */
146
163
  async function handleContinuation(
147
164
  pi: ExtensionAPI,
@@ -153,20 +170,24 @@ async function handleContinuation(
153
170
  const state = session.state!;
154
171
  if (checkStale()) return;
155
172
 
173
+ const cfg = resolveLivenessConfig();
174
+ const entries = ctx.sessionManager.getEntries();
175
+
156
176
  // FR-8.6: continuation 去抖(空 turn 不发)
157
177
  const tokenDelta = state.tokensUsed - state.lastTurnTokensUsed;
158
178
  state.lastTurnTokensUsed = state.tokensUsed;
159
- // 单次 persist(H2 合并:原空 turn/非空 turn 两处重复 persistAndUpdate 提到 if 前统一调用)。
160
- // budget 终态检查点在 persistAndUpdate 内部(#5 单一检查点),与调用位置无关——
161
- // 合并不影响预算终态触发时机(explorer 论证)。
162
- persistAndUpdate(session, ports);
163
- if (tokenDelta <= 0) {
164
- // 空 turn:已 persist,不发 continuation
165
- return;
166
- }
167
- // 终态守卫:persistAndUpdate 可能把 goal 转为 budget_limited 终态。
168
- // 此时不应发 continuation(deliverAs:"followUp" 会触发新 turn,让已耗尽预算的 agent 再跑一轮)。
169
- if (isTerminalStatus(state.status)) return;
179
+
180
+ // W5 辅判据记账:本 turn(自上次 agent_end 以来)真实工具活动 = toolCall 块差分。
181
+ // 无进展 → 计数 +1;有进展(工具调用或 tokenDelta 越阈值)→ 清零退避计数
182
+ // (主判据 continuationsSent 不在此处触碰——正交性)。
183
+ const toolCallsTotal = countToolCallBlocks(entries);
184
+ const toolDelta = toolCallsTotal - state.toolCallsSeen;
185
+ state.toolCallsSeen = toolCallsTotal;
186
+ state.noProgressTurns = nextNoProgressTurns(
187
+ state.noProgressTurns,
188
+ isProgressSignal(toolDelta, tokenDelta, cfg),
189
+ );
190
+
170
191
  // pending 守卫:有活跃 background subagent/workflow 时不发 continuation。
171
192
  // 靠 subagent/workflow 完成时 sendMessage({triggerTurn:true,deliverAs:"steer"}) 自然唤醒主 agent;
172
193
  // goal 抢先催会与异步任务完成通知叠加,形成“停不下来”的死循环——continuation 排入
@@ -174,8 +195,15 @@ async function handleContinuation(
174
195
  // 新 turn → 又 agent_end → 又 continuation。
175
196
  // 刻意不校验 expiresAt:长任务 subagent(>1h TTL)完成时仍 triggerTurn 唤醒主 agent,
176
197
  // 按 TTL 判非活跃会让死循环在长任务场景复现。
177
- const pendingOps = countActiveFromEntries(ctx.sessionManager.getEntries());
198
+ // [W4 读侧过滤①消费口] 传当前 session 基准:fork 继承的父级注册残留不进差集
199
+ // (守卫不幻 defer);设计 D4「读侧过滤一刀」在本消费口的落地(W6 探针实测
200
+ // registry-fork-filter.test.ts 证不传则虚增,A9② 验收口径)。
201
+ const pendingOps = countActiveFromEntries(entries, {
202
+ currentSessionId: ctx.sessionManager.getSessionId(),
203
+ });
204
+ let deferred = false;
178
205
  if (pendingOps.count > 0) {
206
+ deferred = true;
179
207
  pi.appendEntry("goal:log", {
180
208
  timestamp: Date.now(),
181
209
  level: "debug",
@@ -183,17 +211,162 @@ async function handleContinuation(
183
211
  message: `continuation deferred: ${pendingOps.count} active pending operation(s)`,
184
212
  data: { activePending: pendingOps.count, ids: pendingOps.ids },
185
213
  });
186
- // 用户可见反馈:goal 在等待后台任务完成而非卡死(deferred 无反馈时用户只看到
187
- // goal active 但无产出,无法区分等待 vs 死循环)。
188
- ctx.ui.notify(
189
- `Goal waiting for ${pendingOps.count} background task(s) to complete.`,
190
- "info",
191
- );
214
+ // W5 defer 通知去重:状态变化(pending 集合变化或首次进入 defer)才 notify——
215
+ // 事故中每轮 agent_end 都发「Goal waiting for N…」,集合不变时是纯噪音。
216
+ // 用户可见反馈保留:deferred 无反馈时用户只看到 goal active 但无产出,
217
+ // 无法区分等待 vs 死循环。
218
+ if (pendingSetChanged(pendingOps.ids, state.lastDeferredPendingIds)) {
219
+ ctx.ui.notify(
220
+ `Goal waiting for ${pendingOps.count} background task(s) to complete.`,
221
+ "info",
222
+ );
223
+ state.lastDeferredPendingIds = [...pendingOps.ids].sort();
224
+ }
225
+ } else {
226
+ // 非 defer 轮清空去重锚:每个等待 episode 的首次 defer 都会通知一次
227
+ state.lastDeferredPendingIds = [];
228
+ }
229
+
230
+ // 单次 persist(H2 合并):熔断计数(noProgressTurns / toolCallsSeen /
231
+ // lastDeferredPendingIds)随同一快照落盘。budget 终态检查点在 persistAndUpdate
232
+ // 内部(#5 单一检查点),与调用位置无关——合并不影响预算终态触发时机(explorer 论证)。
233
+ persistAndUpdate(session, ports);
234
+ if (deferred) {
235
+ // defer 轮:已 persist + 已去重通知,不发 continuation
192
236
  return;
193
237
  }
194
- // 发 continuation(FR-8.7: 去 debounce 后才发)
195
- ports.messaging.sendContextMessage(
196
- continuationPrompt(state),
197
- "followUp",
198
- );
238
+ if (tokenDelta <= 0) {
239
+ // 空 turn:已 persist,不发 continuation
240
+ return;
241
+ }
242
+ // 终态守卫:persistAndUpdate 可能把 goal 转为 budget_limited 终态。
243
+ // 此时不应发 continuation(deliverAs:"followUp" 会触发新 turn,让已耗尽预算的 agent 再跑一轮)。
244
+ if (isTerminalStatus(state.status)) return;
245
+
246
+ // W5 主判据:连发总次数封顶(与是否调工具正交——toolDelta 只影响辅判据退避)。
247
+ // 封顶必停发 + 通知一次(含恢复指引)+ goal 保持 active(不擅自终态)。
248
+ if (isContinuationCapped(state, cfg)) {
249
+ if (!state.continuationCapNotified) {
250
+ state.continuationCapNotified = true;
251
+ ports.ui.notify(
252
+ `Goal continuation stopped: ${cfg.continuationCap} continuations sent this cycle without completing (no-progress circuit breaker). The goal stays active. Use /goal resume to continue with a fresh allowance, or /goal clear to stop.`,
253
+ "warning",
254
+ );
255
+ pi.appendEntry("goal:log", {
256
+ timestamp: Date.now(),
257
+ level: "info",
258
+ component: "goal:agent-end",
259
+ message: `continuation capped: ${state.continuationsSent}/${cfg.continuationCap} sent, stopping (goal stays active; /goal resume resets)`,
260
+ data: { continuationsSent: state.continuationsSent, cap: cfg.continuationCap },
261
+ });
262
+ // 停发标志单独落盘(罕见路径——每激活周期至多一次):否则下次 persist 前
263
+ // 进程重启会让停发通知重发一次
264
+ ports.persistence.appendState(serializeState(state));
265
+ }
266
+ return;
267
+ }
268
+
269
+ // W5 辅判据:无进展退避。到决策点先取消旧的待发退避 continuation——等待期内若
270
+ // 有外部驱动的 turn(用户输入 / 后台任务通知唤醒)到达这里,按最新状态重算:
271
+ // 有进展 → 立即发;仍无进展 → 按当前等级重排。保证任意时刻至多一个待发。
272
+ cancelContinuationTimer(session);
273
+
274
+ const delayMs = continuationBackoffDelayMs(state.noProgressTurns, cfg);
275
+ if (delayMs <= 0) {
276
+ deliverContinuation(ports, session);
277
+ return;
278
+ }
279
+ pi.appendEntry("goal:log", {
280
+ timestamp: Date.now(),
281
+ level: "debug",
282
+ component: "goal:agent-end",
283
+ message: `continuation delayed by backoff: ${delayMs}ms (no-progress turns: ${state.noProgressTurns})`,
284
+ data: { delayMs, noProgressTurns: state.noProgressTurns },
285
+ });
286
+ const goalId = state.goalId;
287
+ session.continuationTimer = setTimeout(() => {
288
+ session.continuationTimer = null;
289
+ // MF-6②:timer 回调跨生命周期持有本次 agent_end 的 ctx/ports 闭包,且不在
290
+ // pi runner 的 handler 错误隔离范围内——回调内同步异常 = uncaughtException,
291
+ // 可能带崩 pi 进程。整体 try/catch:stale ctx(session 关闭/替换后 ctx 的
292
+ // isIdle / sessionManager getter 走 assertActive 抛错,development-guide
293
+ // §11.1)等异常降级为一条 goal:log 记录后放弃,goal 状态仍在盘上,后续任意
294
+ // agent_end 会重新决策。(正常运行时路径的兜底:session_shutdown 已在
295
+ // invalidate 前取消旧 timer,stale 到期属极端残留场景。)
296
+ try {
297
+ fireBackoffContinuation(session, ports, ctx, cfg, goalId);
298
+ } catch (err) {
299
+ // MF-R2-1 兜底:pi.appendEntry 与 ctx 同生命周期(loader.js appendEntry
300
+ // 首行 assertActive,0.84.4 实装核对)——stale 场景此调用同样必抛,
301
+ // 故降级日志自包 try/catch,保证 catch 块自身零抛出。放弃日志无害:
302
+ // goal 状态在盘上,后续任意 agent_end 会重新决策。
303
+ try {
304
+ pi.appendEntry("goal:log", {
305
+ timestamp: Date.now(),
306
+ level: "warn",
307
+ component: "goal:agent-end",
308
+ message: `backoff continuation dropped after error: ${String(err)}`,
309
+ });
310
+ } catch (logErr) {
311
+ // 刻意放弃:appendEntry(goal:log 落盘通道)自身失效的场景(stale /
312
+ // runtime 失效)下再调任何 runtime 方法都会二次抛出——本 catch 存在的
313
+ // 意义即阻断该 uncaughtException 路径。void 显式忽略,对齐
314
+ // extension-logger 内部 appendEntry 失败兜底的既定惯例。
315
+ void logErr;
316
+ }
317
+ }
318
+ }, delayMs);
319
+ }
320
+
321
+ /**
322
+ * 退避 timer 到期后的发射前守卫 + 实发(延迟期间状态可能已变):
323
+ * - goal 未被覆盖/清除(goalId 比对)、仍 active
324
+ * - MF-6① idle 守卫:ctx.signal 为实时求值(pi-agent-core agent.js get signal 返回
325
+ * activeRun?.abortController.signal),agent idle 后恒 undefined,`signal?.aborted`
326
+ * 恒放行、不可作守卫;改用 ctx.isIdle()(SDK types.d.ts L232)——延迟窗内有活动
327
+ * turn 时不发,该轮 agent_end 会按最新状态重新决策
328
+ * - 未封顶、无活跃 pending(等待期新出现的后台任务由其完成通知唤醒,goal 不抢先催)
329
+ */
330
+ function fireBackoffContinuation(
331
+ session: GoalSession,
332
+ ports: ServicePorts,
333
+ ctx: ExtensionContext,
334
+ cfg: LivenessConfig,
335
+ goalId: string,
336
+ ): void {
337
+ if (!session.state || session.state.goalId !== goalId) return;
338
+ if (!isActiveStatus(session.state.status)) return;
339
+ if (!ctx.isIdle()) return;
340
+ if (isContinuationCapped(session.state, cfg)) return;
341
+ if (countActiveFromEntries(ctx.sessionManager.getEntries(), { currentSessionId: ctx.sessionManager.getSessionId() }).count > 0) return;
342
+ deliverContinuation(ports, session);
343
+ }
344
+
345
+ /**
346
+ * 发出一次 continuation 并立即落盘计数(主判据只在实发时 +1,发出即持久化——
347
+ * 崩溃后封顶计数不从零开始)。
348
+ */
349
+ function deliverContinuation(ports: ServicePorts, session: GoalSession): void {
350
+ const state = session.state!;
351
+ state.continuationsSent += 1;
352
+ ports.messaging.sendContextMessage(continuationPrompt(state), "followUp");
353
+ ports.persistence.appendState(serializeState(state));
354
+ }
355
+
356
+ /**
357
+ * 构造 stale-check 闭包(FR-8.2 G-020):入口快照 goalId,后续判断是否被新 goal 覆盖。
358
+ *
359
+ * 用法(agent_end):
360
+ * ```ts
361
+ * const checkStale = makeStaleChecker(session);
362
+ * // ... 长流程 ...
363
+ * if (checkStale()) return; // goal 被覆盖,本次 agent_end 作废
364
+ * ```
365
+ *
366
+ * 语义:snapshot 时 session.state 可能为 null(首次启动),此时 snapshotGoalId
367
+ * 为 undefined;后续若有新 goal(goalId !== undefined)即视为 stale。
368
+ */
369
+ export function makeStaleChecker(session: GoalSession): () => boolean {
370
+ const snapshotGoalId = session.state?.goalId;
371
+ return () => !session.state || session.state.goalId !== snapshotGoalId;
199
372
  }
@@ -20,9 +20,9 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
20
20
  import { AUTO_CLEAR_TURNS, CONTEXT_USAGE_RATIO_LIMIT } from "../../constants";
21
21
  import { isActiveStatus, isTerminalStatus } from "../../engine/goal";
22
22
  import { contextInjectionPrompt } from "../../projection/prompts";
23
- import { asTheme, renderTerminalStatusLine } from "../../projection/widget";
23
+ import { renderTerminalStatusLine } from "../../projection/widget";
24
24
  import type { GoalSession } from "../../session";
25
- import { clearGoalSession } from "../../session";
25
+ import { cancelContinuationTimer, clearGoalSession } from "../../session";
26
26
  import { buildPorts } from "../ports";
27
27
 
28
28
  interface BeforeAgentStartResult {
@@ -38,6 +38,10 @@ export async function handleBeforeAgentStart(
38
38
  session: GoalSession,
39
39
  ctx: ExtensionContext,
40
40
  ): Promise<BeforeAgentStartResult | undefined> {
41
+ // MF-6③:新用户活动(新 turn 开始)使旧的退避 continuation 作废——该轮
42
+ // agent_end 会按最新状态重新决策。放在 state 检查之前:即使 goal 已清,
43
+ // 旧 timer 也不该在新 turn 开始后仍存活
44
+ cancelContinuationTimer(session);
41
45
  if (!session.state) return;
42
46
 
43
47
  pi.appendEntry("goal:log", {
@@ -88,7 +92,7 @@ function handleTerminalStateBeforeAgent(
88
92
  return;
89
93
  }
90
94
  // 折叠 status bar(终态显示)
91
- const statusText = renderTerminalStatusLine(state, asTheme(buildPorts(pi, ctx).ui));
95
+ const statusText = renderTerminalStatusLine(state, buildPorts(pi, ctx).ui.theme);
92
96
  if (statusText && ctx.hasUI) ctx.ui.setStatus("goal", statusText);
93
97
  if (ctx.hasUI) ctx.ui.setWidget("goal", undefined);
94
98
  }