@zhushanwen/pi-goal 0.12.2 → 0.13.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.
@@ -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 = "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
  }
@@ -299,6 +319,11 @@ function handleUpdate(
299
319
  state.budgetLimitSteeringSent = false;
300
320
  state.tokenWarning70Sent = false;
301
321
  state.tokenWarning90Sent = false;
322
+ // W5:重塑 = 新激活周期,熔断计数与 budget flag 同类(cycle-scoped 记账)一并重置
323
+ state.continuationsSent = 0;
324
+ state.noProgressTurns = 0;
325
+ state.continuationCapNotified = false;
326
+ state.lastDeferredPendingIds = [];
302
327
  // GAP-6: update 是重塑,旧 slug 已不匹配新 objective → 置空(widget fallback objective 截断)
303
328
  state.slug = undefined;
304
329
  // update 重塑后旧 successCriteria 可能不再完全匹配新 objective,但语义内容仍可部分适用。
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Event adapter — 薄路由(#4 拆分后)。
3
3
  *
4
- * 5 个事件 handler + 1 个共享辅助拆在 event-handlers/,本文件只做 re-export。
4
+ * 6 个事件 handler + 1 个共享辅助拆在 event-handlers/,本文件只做 re-export。
5
5
  * handler 逻辑见各自文件。pi.on 注册在 index.ts(保持不变)。
6
6
  *
7
7
  * 行为等价(#4 验收):handler 逻辑原样搬迁,仅 persistAndUpdate 签名 ports 化
@@ -12,5 +12,6 @@ export { handleAgentEnd } from "./event-handlers/agent-end";
12
12
  export { handleBeforeAgentStart } from "./event-handlers/before-agent-start";
13
13
  export type { MessageEndLikeEvent } from "./event-handlers/message-end";
14
14
  export { handleMessageEnd } from "./event-handlers/message-end";
15
+ export { handleSessionShutdown } from "./event-handlers/session-shutdown";
15
16
  export { handleSessionStart } from "./event-handlers/session-start";
16
17
  export { handleTurnEnd } from "./event-handlers/turn-end";
@@ -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,12 +24,23 @@ 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
46
  import { makeStaleChecker } from "./shared";
@@ -135,13 +147,19 @@ async function handleBudgetChecks(
135
147
  }
136
148
 
137
149
  /**
138
- * continuation 去抖(budget 预警未拦截时的尾部逻辑)。
150
+ * continuation 去抖 + 轮次活性熔断(budget 预警未拦截时的尾部逻辑)。
139
151
  *
140
152
  * - continuation 去抖:tokenDelta=0(空 turn)不发,只 persist
141
- * - 否则 persist + 发 continuationPrompt
153
+ * - W5 主判据:continuation 连发总次数封顶(默认 50,PI_GOAL_CONTINUATION_CAP)——
154
+ * 封顶必停发 + notify 用户 + goal 保持 active(不擅自终态)。与「是否调工具」
155
+ * 正交:计数只在 deliverContinuation 实发时 +1,绝不被工具调用清零
156
+ * (2026-09-08 事故:pending 守卫击穿后 64 分钟 ~320 turn 空转,无任何熔断)。
157
+ * - W5 辅判据:连续 N 轮(默认 5)无工具调用且 tokenDelta 低于阈值 → continuation
158
+ * 延迟 base×2^level 发出(间隔 ×2 递增);真实进展清零退避计数(不动总次数)。
159
+ * - W5 defer 通知去重:pending 集合不变的连续 defer 轮不重复 notify。
142
160
  *
143
161
  * 注:maxTurnsReached / stall 自动终态分支随 #6 删除;budget terminal 分支随 #8 删除。
144
- * 终态转换由 persistAndUpdate 兑底(#5 范围,单一检查点)。
162
+ * 终态转换由 persistAndUpdate 兜底(#5 范围,单一检查点)。
145
163
  */
146
164
  async function handleContinuation(
147
165
  pi: ExtensionAPI,
@@ -153,20 +171,24 @@ async function handleContinuation(
153
171
  const state = session.state!;
154
172
  if (checkStale()) return;
155
173
 
174
+ const cfg = resolveLivenessConfig();
175
+ const entries = ctx.sessionManager.getEntries();
176
+
156
177
  // FR-8.6: continuation 去抖(空 turn 不发)
157
178
  const tokenDelta = state.tokensUsed - state.lastTurnTokensUsed;
158
179
  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;
180
+
181
+ // W5 辅判据记账:本 turn(自上次 agent_end 以来)真实工具活动 = toolCall 块差分。
182
+ // 无进展 → 计数 +1;有进展(工具调用或 tokenDelta 越阈值)→ 清零退避计数
183
+ // (主判据 continuationsSent 不在此处触碰——正交性)。
184
+ const toolCallsTotal = countToolCallBlocks(entries);
185
+ const toolDelta = toolCallsTotal - state.toolCallsSeen;
186
+ state.toolCallsSeen = toolCallsTotal;
187
+ state.noProgressTurns = nextNoProgressTurns(
188
+ state.noProgressTurns,
189
+ isProgressSignal(toolDelta, tokenDelta, cfg),
190
+ );
191
+
170
192
  // pending 守卫:有活跃 background subagent/workflow 时不发 continuation。
171
193
  // 靠 subagent/workflow 完成时 sendMessage({triggerTurn:true,deliverAs:"steer"}) 自然唤醒主 agent;
172
194
  // goal 抢先催会与异步任务完成通知叠加,形成“停不下来”的死循环——continuation 排入
@@ -174,8 +196,15 @@ async function handleContinuation(
174
196
  // 新 turn → 又 agent_end → 又 continuation。
175
197
  // 刻意不校验 expiresAt:长任务 subagent(>1h TTL)完成时仍 triggerTurn 唤醒主 agent,
176
198
  // 按 TTL 判非活跃会让死循环在长任务场景复现。
177
- const pendingOps = countActiveFromEntries(ctx.sessionManager.getEntries());
199
+ // [W4 读侧过滤①消费口] 传当前 session 基准:fork 继承的父级注册残留不进差集
200
+ // (守卫不幻 defer);设计 D4「读侧过滤一刀」在本消费口的落地(W6 探针实测
201
+ // registry-fork-filter.test.ts 证不传则虚增,A9② 验收口径)。
202
+ const pendingOps = countActiveFromEntries(entries, {
203
+ currentSessionId: ctx.sessionManager.getSessionId(),
204
+ });
205
+ let deferred = false;
178
206
  if (pendingOps.count > 0) {
207
+ deferred = true;
179
208
  pi.appendEntry("goal:log", {
180
209
  timestamp: Date.now(),
181
210
  level: "debug",
@@ -183,17 +212,144 @@ async function handleContinuation(
183
212
  message: `continuation deferred: ${pendingOps.count} active pending operation(s)`,
184
213
  data: { activePending: pendingOps.count, ids: pendingOps.ids },
185
214
  });
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
- );
215
+ // W5 defer 通知去重:状态变化(pending 集合变化或首次进入 defer)才 notify——
216
+ // 事故中每轮 agent_end 都发「Goal waiting for N…」,集合不变时是纯噪音。
217
+ // 用户可见反馈保留:deferred 无反馈时用户只看到 goal active 但无产出,
218
+ // 无法区分等待 vs 死循环。
219
+ if (pendingSetChanged(pendingOps.ids, state.lastDeferredPendingIds)) {
220
+ ctx.ui.notify(
221
+ `Goal waiting for ${pendingOps.count} background task(s) to complete.`,
222
+ "info",
223
+ );
224
+ state.lastDeferredPendingIds = [...pendingOps.ids].sort();
225
+ }
226
+ } else {
227
+ // 非 defer 轮清空去重锚:每个等待 episode 的首次 defer 都会通知一次
228
+ state.lastDeferredPendingIds = [];
229
+ }
230
+
231
+ // 单次 persist(H2 合并):熔断计数(noProgressTurns / toolCallsSeen /
232
+ // lastDeferredPendingIds)随同一快照落盘。budget 终态检查点在 persistAndUpdate
233
+ // 内部(#5 单一检查点),与调用位置无关——合并不影响预算终态触发时机(explorer 论证)。
234
+ persistAndUpdate(session, ports);
235
+ if (deferred) {
236
+ // defer 轮:已 persist + 已去重通知,不发 continuation
192
237
  return;
193
238
  }
194
- // 发 continuation(FR-8.7: 去 debounce 后才发)
195
- ports.messaging.sendContextMessage(
196
- continuationPrompt(state),
197
- "followUp",
198
- );
239
+ if (tokenDelta <= 0) {
240
+ // 空 turn:已 persist,不发 continuation
241
+ return;
242
+ }
243
+ // 终态守卫:persistAndUpdate 可能把 goal 转为 budget_limited 终态。
244
+ // 此时不应发 continuation(deliverAs:"followUp" 会触发新 turn,让已耗尽预算的 agent 再跑一轮)。
245
+ if (isTerminalStatus(state.status)) return;
246
+
247
+ // W5 主判据:连发总次数封顶(与是否调工具正交——toolDelta 只影响辅判据退避)。
248
+ // 封顶必停发 + 通知一次(含恢复指引)+ goal 保持 active(不擅自终态)。
249
+ if (isContinuationCapped(state, cfg)) {
250
+ if (!state.continuationCapNotified) {
251
+ state.continuationCapNotified = true;
252
+ ports.ui.notify(
253
+ `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.`,
254
+ "warning",
255
+ );
256
+ pi.appendEntry("goal:log", {
257
+ timestamp: Date.now(),
258
+ level: "info",
259
+ component: "goal:agent-end",
260
+ message: `continuation capped: ${state.continuationsSent}/${cfg.continuationCap} sent, stopping (goal stays active; /goal resume resets)`,
261
+ data: { continuationsSent: state.continuationsSent, cap: cfg.continuationCap },
262
+ });
263
+ // 停发标志单独落盘(罕见路径——每激活周期至多一次):否则下次 persist 前
264
+ // 进程重启会让停发通知重发一次
265
+ ports.persistence.appendState(serializeState(state));
266
+ }
267
+ return;
268
+ }
269
+
270
+ // W5 辅判据:无进展退避。到决策点先取消旧的待发退避 continuation——等待期内若
271
+ // 有外部驱动的 turn(用户输入 / 后台任务通知唤醒)到达这里,按最新状态重算:
272
+ // 有进展 → 立即发;仍无进展 → 按当前等级重排。保证任意时刻至多一个待发。
273
+ cancelContinuationTimer(session);
274
+
275
+ const delayMs = continuationBackoffDelayMs(state.noProgressTurns, cfg);
276
+ if (delayMs <= 0) {
277
+ deliverContinuation(ports, session);
278
+ return;
279
+ }
280
+ pi.appendEntry("goal:log", {
281
+ timestamp: Date.now(),
282
+ level: "debug",
283
+ component: "goal:agent-end",
284
+ message: `continuation delayed by backoff: ${delayMs}ms (no-progress turns: ${state.noProgressTurns})`,
285
+ data: { delayMs, noProgressTurns: state.noProgressTurns },
286
+ });
287
+ const goalId = state.goalId;
288
+ session.continuationTimer = setTimeout(() => {
289
+ session.continuationTimer = null;
290
+ // MF-6②:timer 回调跨生命周期持有本次 agent_end 的 ctx/ports 闭包,且不在
291
+ // pi runner 的 handler 错误隔离范围内——回调内同步异常 = uncaughtException,
292
+ // 可能带崩 pi 进程。整体 try/catch:stale ctx(session 关闭/替换后 ctx 的
293
+ // isIdle / sessionManager getter 走 assertActive 抛错,development-guide
294
+ // §11.1)等异常降级为一条 goal:log 记录后放弃,goal 状态仍在盘上,后续任意
295
+ // agent_end 会重新决策。(正常运行时路径的兜底:session_shutdown 已在
296
+ // invalidate 前取消旧 timer,stale 到期属极端残留场景。)
297
+ try {
298
+ fireBackoffContinuation(session, ports, ctx, cfg, goalId);
299
+ } catch (err) {
300
+ // MF-R2-1 兜底:pi.appendEntry 与 ctx 同生命周期(loader.js appendEntry
301
+ // 首行 assertActive,0.84.4 实装核对)——stale 场景此调用同样必抛,
302
+ // 故降级日志自包 try/catch,保证 catch 块自身零抛出。放弃日志无害:
303
+ // goal 状态在盘上,后续任意 agent_end 会重新决策。
304
+ try {
305
+ pi.appendEntry("goal:log", {
306
+ timestamp: Date.now(),
307
+ level: "warn",
308
+ component: "goal:agent-end",
309
+ message: `backoff continuation dropped after error: ${String(err)}`,
310
+ });
311
+ } catch (logErr) {
312
+ // 刻意放弃:appendEntry(goal:log 落盘通道)自身失效的场景(stale /
313
+ // runtime 失效)下再调任何 runtime 方法都会二次抛出——本 catch 存在的
314
+ // 意义即阻断该 uncaughtException 路径。void 显式忽略,对齐
315
+ // extension-logger 内部 appendEntry 失败兜底的既定惯例。
316
+ void logErr;
317
+ }
318
+ }
319
+ }, delayMs);
320
+ }
321
+
322
+ /**
323
+ * 退避 timer 到期后的发射前守卫 + 实发(延迟期间状态可能已变):
324
+ * - goal 未被覆盖/清除(goalId 比对)、仍 active
325
+ * - MF-6① idle 守卫:ctx.signal 为实时求值(pi-agent-core agent.js get signal 返回
326
+ * activeRun?.abortController.signal),agent idle 后恒 undefined,`signal?.aborted`
327
+ * 恒放行、不可作守卫;改用 ctx.isIdle()(SDK types.d.ts L232)——延迟窗内有活动
328
+ * turn 时不发,该轮 agent_end 会按最新状态重新决策
329
+ * - 未封顶、无活跃 pending(等待期新出现的后台任务由其完成通知唤醒,goal 不抢先催)
330
+ */
331
+ function fireBackoffContinuation(
332
+ session: GoalSession,
333
+ ports: ServicePorts,
334
+ ctx: ExtensionContext,
335
+ cfg: LivenessConfig,
336
+ goalId: string,
337
+ ): void {
338
+ if (!session.state || session.state.goalId !== goalId) return;
339
+ if (!isActiveStatus(session.state.status)) return;
340
+ if (!ctx.isIdle()) return;
341
+ if (isContinuationCapped(session.state, cfg)) return;
342
+ if (countActiveFromEntries(ctx.sessionManager.getEntries(), { currentSessionId: ctx.sessionManager.getSessionId() }).count > 0) return;
343
+ deliverContinuation(ports, session);
344
+ }
345
+
346
+ /**
347
+ * 发出一次 continuation 并立即落盘计数(主判据只在实发时 +1,发出即持久化——
348
+ * 崩溃后封顶计数不从零开始)。
349
+ */
350
+ function deliverContinuation(ports: ServicePorts, session: GoalSession): void {
351
+ const state = session.state!;
352
+ state.continuationsSent += 1;
353
+ ports.messaging.sendContextMessage(continuationPrompt(state), "followUp");
354
+ ports.persistence.appendState(serializeState(state));
199
355
  }
@@ -22,7 +22,7 @@ import { isActiveStatus, isTerminalStatus } from "../../engine/goal";
22
22
  import { contextInjectionPrompt } from "../../projection/prompts";
23
23
  import { asTheme, 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", {
@@ -0,0 +1,23 @@
1
+ /**
2
+ * 事件 7: session_shutdown(MF-R2-1 根修:invalidate 前取消旧退避 timer)。
3
+ *
4
+ * SDK 触发序(@earendil-works/pi-coding-agent 0.84.4 实装核对)——所有使旧
5
+ * runner 失效的路径都先 emit session_shutdown 再 invalidate,此刻 pi/ctx 仍可用:
6
+ * - reload:agent-session.js(emit → oldRunner.invalidate())
7
+ * - new / resume / fork 替换:agent-session-runtime.js teardownCurrent
8
+ * (emit → session.dispose() 内 invalidate)
9
+ * - quit:agent-session-runtime.js dispose(emit → session.dispose())
10
+ *
11
+ * 此处取消待发的退避 continuation timer。不清理则旧 timer 携旧 ctx/ports 闭包
12
+ * 存活到至多 600s:stale 后回调内访问 ctx(isIdle / sessionManager getter 走
13
+ * assertActive)必抛,catch 内降级日志 pi.appendEntry 同生命周期(loader.js
14
+ * appendEntry 首行 assertActive)二次必抛——timer 回调不在 pi runner handler
15
+ * 错误隔离范围内,同步异常 = uncaughtException,可能带崩 pi 进程。
16
+ */
17
+
18
+ import type { GoalSession } from "../../session";
19
+ import { cancelContinuationTimer } from "../../session";
20
+
21
+ export async function handleSessionShutdown(session: GoalSession): Promise<void> {
22
+ cancelContinuationTimer(session);
23
+ }
@@ -8,7 +8,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
8
8
 
9
9
  import { updateWidget } from "../../projection/widget";
10
10
  import type { GoalSession } from "../../session";
11
- import { reconstructGoalState } from "../../session";
11
+ import { cancelContinuationTimer, reconstructGoalState } from "../../session";
12
12
  import { buildPorts } from "../ports";
13
13
 
14
14
  export async function handleSessionStart(
@@ -16,6 +16,9 @@ export async function handleSessionStart(
16
16
  session: GoalSession,
17
17
  ctx: ExtensionContext,
18
18
  ): Promise<void> {
19
+ // MF-6③:session 句柄跨 session 复用,新 session 生命周期开始时上一 session
20
+ // 遗留的退避 timer 作废(不清理则旧 timer 携旧 ctx 闭包存活至多 600s)
21
+ cancelContinuationTimer(session);
19
22
  const ports = buildPorts(pi, ctx);
20
23
  reconstructGoalState(session, ports.session);
21
24
  if (session.state) {
package/src/constants.ts CHANGED
@@ -35,3 +35,16 @@ export const TOKEN_K_THRESHOLD = 1000; // token 数 ≥ 此值时缩写为 k
35
35
 
36
36
  export const AUTO_CLEAR_TURNS = 2; // 终态后自动清理轮数
37
37
  export const MAX_HISTORY_ENTRIES = 20; // goal-history entry GC 上限
38
+
39
+ // ── 轮次活性熔断(chat-domain-v1x D4 / W5)─────────────
40
+ //
41
+ // 事故背景(2026-09-08,64 分钟 ~320 turn 空转烧 2.93M token):pending 守卫被
42
+ // 「等待已注销的后台任务」击穿后,goal 每轮注入 continuation、主 agent 每轮回一句
43
+ // 等待,无任何无进展熔断。双维度封顶:主判据总量上限(与是否调工具正交,不可被
44
+ // 任何目标行为绕过)+ 辅判据无进展退避(间隔 ×2 递增)。全部可经 PI_GOAL_* env 覆盖。
45
+
46
+ export const DEFAULT_CONTINUATION_CAP = 50; // 主判据:单激活周期 continuation 连发总次数上限(封顶必停发)
47
+ export const DEFAULT_NO_PROGRESS_TURNS = 5; // 辅判据:连续无进展轮数阈值(第 N 轮起退避间隔翻倍)
48
+ export const DEFAULT_NO_PROGRESS_TOKEN_THRESHOLD = 1000; // 辅判据:tokenDelta 低于此值视为低产出(一句「等待」量级)
49
+ export const DEFAULT_BACKOFF_BASE_MS = 10_000; // 辅判据:退避基础间隔(×2 递增的基数)
50
+ export const DEFAULT_BACKOFF_MAX_MS = 600_000; // 辅判据:退避间隔上限(指数天花板,防止级数失控)
@@ -58,5 +58,11 @@ export function createGoalState(
58
58
  tokenWarning90Sent: false,
59
59
  lastTurnTokensUsed: 0,
60
60
  currentTurnIndex: 0,
61
+ // W5 熔断计数从零开始(chat-domain-v1x D4)
62
+ continuationsSent: 0,
63
+ noProgressTurns: 0,
64
+ continuationCapNotified: false,
65
+ lastDeferredPendingIds: [],
66
+ toolCallsSeen: 0,
61
67
  };
62
68
  }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * 轮次活性熔断 — 纯函数(chat-domain-v1x 设计 §3.2 D4 / W5)
3
+ *
4
+ * 双维度互补(非二选一,职责不同):
5
+ * - 主判据:continuation 连发总次数独立上限。与「是否调工具」正交——计数绝不能被
6
+ * 工具调用清零(设计 R1 击穿记录:旧提案「任何一轮有工具调用即清零」可被
7
+ * 「每轮调一次 subagents list + 回一句等待」打成永不熔断)。清零只经用户显式
8
+ * /goal resume(新激活周期)。
9
+ * - 辅判据:无进展退避。连续 N 轮无工具调用且 tokenDelta 低于阈值 → continuation
10
+ * 间隔 ×2 递增;恢复 = 真实工具调用或 tokenDelta 越阈值(只清零退避计数)。
11
+ *
12
+ * 零 Pi 依赖(engine 层契约)。所有判定输入由调用方(agent-end)采集传入。
13
+ */
14
+
15
+ import {
16
+ DEFAULT_BACKOFF_BASE_MS,
17
+ DEFAULT_BACKOFF_MAX_MS,
18
+ DEFAULT_CONTINUATION_CAP,
19
+ DEFAULT_NO_PROGRESS_TOKEN_THRESHOLD,
20
+ DEFAULT_NO_PROGRESS_TURNS,
21
+ } from "../constants";
22
+ import type { GoalRuntimeState } from "./types";
23
+
24
+ // ── 配置(env 可覆盖)─────────────────────────────────
25
+
26
+ export interface LivenessConfig {
27
+ /** 主判据:单激活周期 continuation 连发总次数上限 */
28
+ continuationCap: number;
29
+ /** 辅判据:连续无进展轮数阈值(第 N 轮起退避) */
30
+ noProgressTurnsThreshold: number;
31
+ /** 辅判据:tokenDelta 低于此值视为低产出 */
32
+ noProgressTokenThreshold: number;
33
+ /** 辅判据:退避基础间隔 ms(×2 递增的基数) */
34
+ backoffBaseMs: number;
35
+ /** 辅判据:退避间隔上限 ms(指数天花板) */
36
+ backoffMaxMs: number;
37
+ }
38
+
39
+ /**
40
+ * 从 env 解析熔断配置(PI_GOAL_* 前缀,对齐 subagent-workflow 的 PI_SUBAGENT_* 风格)。
41
+ *
42
+ * 非法值(非数字 / 非正整数)一律回退默认值——熔断参数是安全网,坏配置降级为
43
+ * 默认而非抛错挂掉 goal 循环。env 参数注入便于单测,不 mock 全局 process.env。
44
+ */
45
+ export function resolveLivenessConfig(env: NodeJS.ProcessEnv = process.env): LivenessConfig {
46
+ return {
47
+ continuationCap: positiveIntEnv(env.PI_GOAL_CONTINUATION_CAP, DEFAULT_CONTINUATION_CAP),
48
+ noProgressTurnsThreshold: positiveIntEnv(
49
+ env.PI_GOAL_NO_PROGRESS_TURNS,
50
+ DEFAULT_NO_PROGRESS_TURNS,
51
+ ),
52
+ noProgressTokenThreshold: positiveIntEnv(
53
+ env.PI_GOAL_NO_PROGRESS_TOKEN_THRESHOLD,
54
+ DEFAULT_NO_PROGRESS_TOKEN_THRESHOLD,
55
+ ),
56
+ backoffBaseMs: positiveIntEnv(env.PI_GOAL_BACKOFF_BASE_MS, DEFAULT_BACKOFF_BASE_MS),
57
+ backoffMaxMs: positiveIntEnv(env.PI_GOAL_BACKOFF_MAX_MS, DEFAULT_BACKOFF_MAX_MS),
58
+ };
59
+ }
60
+
61
+ function positiveIntEnv(raw: string | undefined, fallback: number): number {
62
+ if (raw === undefined) return fallback;
63
+ // Number + isInteger(而非 parseInt):parseInt 会把 "2.7" 截断成 2 静默放行
64
+ const parsed = Number(raw);
65
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
66
+ }
67
+
68
+ // ── 工具活动检测(辅判据输入)─────────────────────────
69
+
70
+ /** 退避乘数:间隔 = base × MULTIPLIER^level(设计语义「×2 递增」)。 */
71
+ const BACKOFF_MULTIPLIER = 2;
72
+
73
+ /**
74
+ * 统计 session entries 中 assistant message content 里的 toolCall 块总数。
75
+ *
76
+ * pi 的工具调用落在 type="message"、role="assistant" 的 entry,content 数组内
77
+ * `{type:"toolCall", id, name, arguments}` block(session-reader probe 实测 519/519,
78
+ * message.toolCalls 顶层字段从未存在)。这里只数块个数——调用方以「相邻两次
79
+ * agent_end 的差分」判定本 turn 是否有真实工具活动,不需要 name/arguments。
80
+ *
81
+ * duck-typed 逐层守卫(对齐 pending-notifications state.ts 的 EntryLike 风格),
82
+ * 不依赖 SDK 具体类型;畸形 entry 跳过不计。
83
+ */
84
+ export function countToolCallBlocks(entries: unknown[]): number {
85
+ let count = 0;
86
+ for (const raw of entries) {
87
+ if (!raw || typeof raw !== "object") continue;
88
+ // 断言目标带必填 unknown 字段(typeof object 已守卫),非全可选形状
89
+ const entry = raw as { type: unknown; message: unknown };
90
+ if (entry.type !== "message") continue;
91
+ if (!entry.message || typeof entry.message !== "object") continue;
92
+ const message = entry.message as { role: unknown; content: unknown };
93
+ if (message.role !== "assistant") continue;
94
+ if (!Array.isArray(message.content)) continue;
95
+ for (const block of message.content) {
96
+ if (block && typeof block === "object" && (block as { type: unknown }).type === "toolCall") {
97
+ count++;
98
+ }
99
+ }
100
+ }
101
+ return count;
102
+ }
103
+
104
+ // ── 辅判据:无进展判定与退避 ─────────────────────────
105
+
106
+ /**
107
+ * 本轮是否有真实进展:出现工具调用(toolDelta>0)或 tokenDelta 越阈值。
108
+ *
109
+ * 两者任一即进展——纯文本迭代型任务(长文起草)每轮合法无工具调用,但 tokenDelta
110
+ * 高(整段重写),不应被退避拖慢;事故形态(每轮回一句「等待」)则两者皆无。
111
+ */
112
+ export function isProgressSignal(toolDelta: number, tokenDelta: number, cfg: LivenessConfig): boolean {
113
+ return toolDelta > 0 || tokenDelta >= cfg.noProgressTokenThreshold;
114
+ }
115
+
116
+ /** 无进展 → 计数 +1;有进展 → 清零(只清退避计数,主判据总数不动)。 */
117
+ export function nextNoProgressTurns(current: number, progress: boolean): number {
118
+ return progress ? 0 : current + 1;
119
+ }
120
+
121
+ /**
122
+ * 退避等级:连续无进展轮数达到阈值(默认 5)的第 N 轮起 level=1,此后每多一轮 +1。
123
+ * 间隔 = base × 2^level(第 5 轮起 ×2,第 6 轮 ×4,…),封顶 backoffMaxMs。
124
+ */
125
+ export function continuationBackoffDelayMs(noProgressTurns: number, cfg: LivenessConfig): number {
126
+ const level = Math.max(0, noProgressTurns - cfg.noProgressTurnsThreshold + 1);
127
+ if (level <= 0) return 0;
128
+ return Math.min(cfg.backoffBaseMs * BACKOFF_MULTIPLIER ** level, cfg.backoffMaxMs);
129
+ }
130
+
131
+ // ── 主判据:连发总次数封顶 ───────────────────────────
132
+
133
+ /** 是否已达连发总次数上限(封顶后必停发,goal 保持 active 等用户 /goal resume)。 */
134
+ export function isContinuationCapped(state: GoalRuntimeState, cfg: LivenessConfig): boolean {
135
+ return state.continuationsSent >= cfg.continuationCap;
136
+ }
137
+
138
+ // ── defer 通知去重 ───────────────────────────────────
139
+
140
+ /**
141
+ * 活跃 pending 集合是否发生变化(顺序无关)。
142
+ *
143
+ * defer 分支的用户通知只在「集合变化或首次进入 defer」时发——事故中每轮 agent_end
144
+ * 都 notify「Goal waiting for N task(s)」,pending 集合不变时是纯噪音。
145
+ */
146
+ export function pendingSetChanged(current: readonly string[], last: readonly string[]): boolean {
147
+ if (current.length !== last.length) return true;
148
+ const lastSet = new Set(last);
149
+ return current.some((id) => !lastSet.has(id));
150
+ }
@@ -81,4 +81,25 @@ export interface GoalRuntimeState {
81
81
  lastTurnTokensUsed: number;
82
82
  currentTurnIndex: number;
83
83
  completedAtTurnIndex?: number;
84
+ // ── 轮次活性熔断计数(chat-domain-v1x D4 / W5,随 state 持久化)──
85
+ /**
86
+ * 主判据:本激活周期累计发出的 continuation 次数。封顶(LivenessConfig.continuationCap)
87
+ * 必停发。与「是否调工具」正交——任何路径不得因工具调用清零;唯一重置点 = 用户
88
+ * 显式 /goal resume(新激活周期)。旧持久化数据无此字段,deserialize 默认 0。
89
+ */
90
+ continuationsSent: number;
91
+ /**
92
+ * 辅判据:连续无进展轮数(无工具调用且 tokenDelta 低于阈值)。达到阈值起退避
93
+ * 间隔 ×2 递增;出现真实进展即清零(只清退避计数,不动 continuationsSent)。
94
+ */
95
+ noProgressTurns: number;
96
+ /** 封顶停发通知只发一次的锚点(resume 时复位)。 */
97
+ continuationCapNotified: boolean;
98
+ /** defer 通知去重锚:最近一次 defer 时的活跃 pending id 集合(排序后快照)。 */
99
+ lastDeferredPendingIds: string[];
100
+ /**
101
+ * session entries 内 assistant toolCall 块的累计数。相邻两次 agent_end 的差分 =
102
+ * 本 turn 的真实工具活动(辅判据输入)。随 state 持久化以在重启后保持差分口径。
103
+ */
104
+ toolCallsSeen: number;
84
105
  }