@zhushanwen/pi-pending-notifications 0.3.5 → 0.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-pending-notifications",
3
- "version": "0.3.5",
3
+ "version": "0.4.0",
4
4
  "description": "Cross-extension async operation registration/query mechanism for Pi — prevents message injection during long-running operations.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -16,7 +16,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
16
16
 
17
17
  import pendingNotificationsExtension from "../index";
18
18
  import type { PendingEntry } from "../state";
19
- import { countActiveFromEntries, createRegistry, getActive, rebuildFromEntries, register, unregister } from "../state";
19
+ import {
20
+ countActiveFromEntries,
21
+ createRegistry,
22
+ getActive,
23
+ PENDING_LIFECYCLE,
24
+ normalizePendingType,
25
+ rebuildFromEntries,
26
+ register,
27
+ unregister,
28
+ } from "../state";
20
29
 
21
30
  // ── Mock 工具 ───────────────────────────────────────
22
31
 
@@ -511,3 +520,165 @@ describe("countActiveFromEntries(纯差集,供 goal / subagent-workflow 复
511
520
  expect(countActiveFromEntries([inherited, flushed, own]).ids).toEqual(["my-bg"]);
512
521
  });
513
522
  });
523
+
524
+ // ────────────────────────────────────────────────────
525
+ // D16 lifecycle 分档(M3):process 档(type=bash)纯函数行为
526
+ // ────────────────────────────────────────────────────
527
+
528
+ /** bash register entry 的真实落盘形状:无 expiresAt 键(写入侧省略,D16) */
529
+ function makeBashRegisterEntry(id: string, overrides: Record<string, unknown> = {}): MockSessionEntry {
530
+ return {
531
+ customType: "pending:register",
532
+ data: {
533
+ id,
534
+ type: "bash",
535
+ name: `task-${id}`,
536
+ registeredAt: NOW,
537
+ sessionId: "sess-current",
538
+ ...overrides,
539
+ },
540
+ };
541
+ }
542
+
543
+ describe("D16 lifecycle 分档:常量与 type 归一化", () => {
544
+ it("PENDING_LIFECYCLE:subagent/workflow=session,bash=process", () => {
545
+ expect(PENDING_LIFECYCLE).toEqual({
546
+ subagent: "session",
547
+ workflow: "session",
548
+ bash: "process",
549
+ });
550
+ });
551
+
552
+ it("normalizePendingType:subagent/bash 直通,其余(缺失/未知/大小写不符)归 workflow", () => {
553
+ expect(normalizePendingType("subagent")).toBe("subagent");
554
+ expect(normalizePendingType("bash")).toBe("bash");
555
+ expect(normalizePendingType("workflow")).toBe("workflow");
556
+ expect(normalizePendingType(undefined)).toBe("workflow");
557
+ expect(normalizePendingType("scheduler")).toBe("workflow");
558
+ expect(normalizePendingType("Bash")).toBe("workflow");
559
+ });
560
+ });
561
+
562
+ describe("D16 process 档(type=bash)纯函数行为", () => {
563
+ it("读取侧不回填 TTL:无 expiresAt 的 bash entry → registry entry.expiresAt=undefined 且 active", () => {
564
+ const r = createRegistry();
565
+ const result = rebuildFromEntries(r, [makeBashRegisterEntry("bt-1")], "sess-current", NOW);
566
+ expect(result.activeIds).toEqual(["bt-1"]);
567
+ expect(result.expiredToFlush).toEqual([]);
568
+ expect(r.operations.get("bt-1")!.expiresAt).toBeUndefined();
569
+ });
570
+
571
+ it("U3 不判过期:bash entry 注册超 1h(TTL 之外)→ 仍 active、无 expiredToFlush", () => {
572
+ const entry = makeBashRegisterEntry("bt-1", { registeredAt: NOW - 3_700_000 });
573
+ const comp = rebuild([entry], "sess-current", NOW);
574
+ expect(comp.activeIds).toEqual(["bt-1"]);
575
+ expect(comp.expiredToFlush).toEqual([]);
576
+ });
577
+
578
+ it("U4 跨 session 不标 expired:bash entry sessionId 不符当前 session → 仍 active(进程级生命周期跨 session 续存)", () => {
579
+ const comp = rebuild(
580
+ [makeBashRegisterEntry("bt-1", { sessionId: "sess-other" })],
581
+ "sess-current",
582
+ NOW,
583
+ );
584
+ expect(comp.activeIds).toEqual(["bt-1"]);
585
+ expect(comp.expiredToFlush).toEqual([]);
586
+ });
587
+
588
+ it("session 档对照回归:同形状(无 expiresAt 键)的 workflow entry → 回填 TTL 且过期照旧判 expired", () => {
589
+ // 保证分档判定没有把 session 档的 TTL 路径一并豁免
590
+ const legacy = {
591
+ customType: "pending:register",
592
+ data: { id: "w-1", type: "workflow", name: "w", registeredAt: NOW - 3_700_000, sessionId: "sess-current" },
593
+ };
594
+ const comp = rebuild([legacy], "sess-current", NOW);
595
+ expect(comp.activeIds).toEqual([]);
596
+ expect(comp.expiredToFlush).toEqual([{ id: "w-1", status: "expired" }]);
597
+ });
598
+
599
+ it("差集路径识别 bash entry:register 计入且 type 保留,unregister 抵消", () => {
600
+ const bashReg = makeBashRegisterEntry("bt-1");
601
+ const res = countActiveFromEntries([bashReg] as unknown[]);
602
+ expect(res.count).toBe(1);
603
+ expect(res.entries[0].type).toBe("bash");
604
+ expect(res.entries[0].expiresAt).toBeUndefined();
605
+
606
+ expect(
607
+ countActiveFromEntries([bashReg, makeUnregisterEntry("bt-1")] as unknown[]).count,
608
+ ).toBe(0);
609
+ });
610
+
611
+ it("types 过滤:bash 可作为过滤类型", () => {
612
+ const entries = [makeBashRegisterEntry("bt-1"), makeRegisterEntry("w-1")] as unknown[];
613
+ expect(countActiveFromEntries(entries, { types: ["bash"] }).ids).toEqual(["bt-1"]);
614
+ expect(countActiveFromEntries(entries, { types: ["workflow"] }).ids).toEqual(["w-1"]);
615
+ });
616
+ });
617
+
618
+ // ────────────────────────────────────────────────────
619
+ // D16 lifecycle 分档(M3):process 档(type=bash)工厂行为
620
+ // ────────────────────────────────────────────────────
621
+
622
+ describe("D16 process 档(type=bash)工厂行为", () => {
623
+ let setup: MockSetup;
624
+
625
+ beforeEach(() => {
626
+ vi.useFakeTimers();
627
+ vi.setSystemTime(NOW);
628
+ setup = createMockPi();
629
+ pendingNotificationsExtension(setup.pi);
630
+ });
631
+
632
+ afterEach(() => {
633
+ vi.useRealTimers();
634
+ });
635
+
636
+ it("register 写入:bash → 落盘 data 无 expiresAt 键且 type=bash 直通(不被归并为 workflow)", async () => {
637
+ fireSessionStart(setup, createMockCtx([]));
638
+
639
+ setup.handlers.pendingRegister!({ id: "bt-1", type: "bash", name: "run tests" });
640
+
641
+ expect(await getCount(setup)).toBe(1);
642
+ const regCall = setup.appendEntryMock.mock.calls.find((c) => c[0] === "pending:register");
643
+ expect(regCall).toBeDefined();
644
+ const data = regCall![1] as Record<string, unknown>;
645
+ expect(data.type).toBe("bash");
646
+ expect("expiresAt" in data).toBe(false);
647
+ });
648
+
649
+ it("session 档对照回归:workflow register → 落盘 data.expiresAt = NOW + TTL", async () => {
650
+ fireSessionStart(setup, createMockCtx([]));
651
+
652
+ setup.handlers.pendingRegister!({ id: "w-1", type: "workflow", name: "run" });
653
+
654
+ const regCall = setup.appendEntryMock.mock.calls.find((c) => c[0] === "pending:register");
655
+ const data = regCall![1] as Record<string, unknown>;
656
+ expect(data.expiresAt).toBe(NOW + 3_600_000);
657
+ });
658
+
659
+ it("U3 工厂级:bash entry 超 1h 后 session_start rebuild → 仍 active、不补 unregister", async () => {
660
+ vi.setSystemTime(NOW + 3_700_000);
661
+ const stale = makeBashRegisterEntry("bt-1", { registeredAt: NOW });
662
+ fireSessionStart(setup, createMockCtx([stale]));
663
+
664
+ expect(await getCount(setup)).toBe(1);
665
+ const flushCalls = setup.appendEntryMock.mock.calls.filter((c) => c[0] === "pending:unregister");
666
+ expect(flushCalls).toHaveLength(0);
667
+ });
668
+
669
+ it("session_shutdown:bash 不标 cancelled、不补 unregister entry、内存仍 active", async () => {
670
+ fireSessionStart(setup, createMockCtx([]));
671
+ setup.handlers.pendingRegister!({ id: "bt-1", type: "bash", name: "run tests" });
672
+ // 同时注册一个 session 档条目,对照证明分档只豁免 process 档
673
+ setup.handlers.pendingRegister!({ id: "w-1", type: "workflow", name: "run" });
674
+ setup.appendEntryMock.mockClear();
675
+
676
+ if (!setup.handlers.sessionShutdown) throw new Error("session_shutdown not registered");
677
+ void setup.handlers.sessionShutdown({ type: "session_shutdown" }, createMockCtx([]));
678
+
679
+ const unregisterCalls = setup.appendEntryMock.mock.calls.filter((c) => c[0] === "pending:unregister");
680
+ expect(unregisterCalls).toHaveLength(1);
681
+ expect((unregisterCalls[0][1] as { id: string }).id).toBe("w-1");
682
+ expect(await getCount(setup)).toBe(1);
683
+ });
684
+ });
package/src/index.ts CHANGED
@@ -15,7 +15,8 @@
15
15
  * - emit("pending:unregister", { id, reason })
16
16
  *
17
17
  * entry 契约(与 goal before-agent-start.ts 对齐,读取端按 e.data.id 算差集):
18
- * - pending:register → { id, type, name, registeredAt, expiresAt, sessionId }
18
+ * - pending:register → { id, type, name, registeredAt, expiresAt?, sessionId }
19
+ * (expiresAt 仅 session 档写入;process 档(D16)省略该字段)
19
20
  * - pending:unregister → { id, reason, status }
20
21
  *
21
22
  * 监听方式:pi.events.on(Pi 的 EventBus,真实 SDK 为 EventBus.on,非 optional)。
@@ -29,22 +30,26 @@ import { Type } from "typebox";
29
30
  import {
30
31
  createRegistry,
31
32
  getActive,
33
+ PENDING_LIFECYCLE,
32
34
  PENDING_TTL_MS,
33
35
  type PendingEntry,
34
36
  type PendingRegistry,
35
37
  type PendingStatus,
36
38
  type PendingType,
39
+ normalizePendingType,
37
40
  rebuildFromEntries,
38
41
  register,
39
42
  unregister,
40
43
  } from "./state.ts";
41
44
 
42
45
  // 跨扩展消费 API:goal(continuation 守卫)/ subagent-workflow(agent_end 后代判定)
43
- // 直接 import 本包的导出,避免各自复制差集逻辑。
46
+ // 直接 import 本包的导出,避免各自复制差集逻辑。PENDING_LIFECYCLE 供消费方(如
47
+ // base-tool-enhance 启动时的 peer 版本检查)读取分档声明。
44
48
  export {
45
49
  countActiveFromEntries,
46
50
  createRegistry,
47
51
  getActive,
52
+ PENDING_LIFECYCLE,
48
53
  PENDING_TTL_MS,
49
54
  type CountActiveOptions,
50
55
  type CountActiveResult,
@@ -141,13 +146,15 @@ export default function pendingNotificationsExtension(pi: ExtensionAPI): void {
141
146
  debugLog("debug", "listener: pending:register parsed", parsed);
142
147
 
143
148
  const now = Date.now();
149
+ // D16 分档:process 档(bash 后台任务)不计算 expiresAt——进程级生命周期
150
+ // 无 TTL 概念,任务寿命由其自身超时/reaper 管理,session 档保持 TTL 不变。
144
151
  const entry: PendingEntry = {
145
152
  id: parsed.id,
146
153
  type: parsed.type,
147
154
  name: parsed.name,
148
155
  status: "active",
149
156
  registeredAt: now,
150
- expiresAt: now + PENDING_TTL_MS,
157
+ expiresAt: PENDING_LIFECYCLE[parsed.type] === "session" ? now + PENDING_TTL_MS : undefined,
151
158
  sessionId: currentSessionId,
152
159
  };
153
160
 
@@ -158,12 +165,14 @@ export default function pendingNotificationsExtension(pi: ExtensionAPI): void {
158
165
  return;
159
166
  }
160
167
 
168
+ // 落盘与内存 entry 对称:process 档省略 expiresAt 字段(而非写 undefined),
169
+ // 读取侧 normalizeRegisterEntry 对 process 档同样不回填,两侧共同兑现 TTL 豁免。
161
170
  safeAppendEntry("pending:register", {
162
171
  id: entry.id,
163
172
  type: entry.type,
164
173
  name: entry.name,
165
174
  registeredAt: entry.registeredAt,
166
- expiresAt: entry.expiresAt,
175
+ ...(entry.expiresAt !== undefined ? { expiresAt: entry.expiresAt } : {}),
167
176
  sessionId: entry.sessionId,
168
177
  });
169
178
 
@@ -226,6 +235,10 @@ export default function pendingNotificationsExtension(pi: ExtensionAPI): void {
226
235
  pi.on("session_shutdown", (_event, _ctx: ExtensionContext) => {
227
236
  const active = getActive(registry);
228
237
  for (const op of active) {
238
+ // D16 分档:process 档跳过 cancelled 标注——进程级生命周期的任务跨
239
+ // session 替换继续运行(fork/switch),收尾归任务自身/reaper,
240
+ // 不由 session 退出裁定。
241
+ if (PENDING_LIFECYCLE[op.type] === "process") continue;
229
242
  const changed = unregister(registry, op.id, "cancelled");
230
243
  if (changed) {
231
244
  safeAppendEntry("pending:unregister", {
@@ -241,7 +254,7 @@ export default function pendingNotificationsExtension(pi: ExtensionAPI): void {
241
254
  name: "pending_notifications",
242
255
  label: "Pending Notifications",
243
256
  description:
244
- "查询当前活跃的异步操作(workflow/subagent)。action=count 返回数量;action=list 返回列表。状态由 EventBus + session entries 维护,无需手动注册。",
257
+ "查询当前活跃的异步操作(workflow/subagent/bash 后台任务)。action=count 返回数量;action=list 返回列表。状态由 EventBus + session entries 维护,无需手动注册。",
245
258
  parameters: PendingNotificationsParams,
246
259
  execute: async (_toolCallId: string, params: { action: "count" | "list" }, _signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: ExtensionContext): Promise<{ content: { type: "text"; text: string }[]; details: PendingToolDetails }> => {
247
260
  const active = getActive(registry);
@@ -279,7 +292,8 @@ function parseRegisterEvent(data: unknown): ParsedRegister | null {
279
292
  if (typeof d.id !== "string") return null;
280
293
  return {
281
294
  id: d.id,
282
- type: d.type === "subagent" ? "subagent" : "workflow",
295
+ // D16:bash 类型直通(归一化映射与 state.ts 读取侧共用同一函数,防两侧漂移)
296
+ type: normalizePendingType(d.type),
283
297
  name: typeof d.name === "string" ? d.name : d.id,
284
298
  };
285
299
  }
package/src/state.ts CHANGED
@@ -14,8 +14,22 @@
14
14
  * 不直接写 entry —— 写 entry 是副作用,由 index.ts 负责
15
15
  */
16
16
 
17
- /** 异步操作类型(来源:workflow / subagent */
18
- export type PendingType = "workflow" | "subagent";
17
+ /** 异步操作类型(来源:workflow / subagent / bash 后台任务) */
18
+ export type PendingType = "workflow" | "subagent" | "bash";
19
+
20
+ /**
21
+ * 生命周期分档(D16):type 级声明,行为按档判定(不做 type 特判)——
22
+ * 未来 scheduler 等长任务类型声明 process 档即零改动获得同语义。
23
+ * - "session":随 session entry 存活——TTL 过期(U3)、跨 session 清理(U4)、
24
+ * shutdown 标 cancelled 全套生效。
25
+ * - "process":随进程存活——无 TTL(不计算/不回填 expiresAt)、跨 session 续存
26
+ * (fork/switch 后任务仍在跑)、shutdown 不标 cancelled(收尾归任务自身/reaper)。
27
+ */
28
+ export const PENDING_LIFECYCLE: Record<PendingType, "session" | "process"> = {
29
+ subagent: "session",
30
+ workflow: "session",
31
+ bash: "process",
32
+ };
19
33
 
20
34
  /** 异步操作终态/过渡状态。active = 仍在运行;其他都视为已结束 */
21
35
  export type PendingStatus = "active" | "completed" | "failed" | "cancelled" | "expired" | "time_limited" | "aborted";
@@ -32,8 +46,8 @@ export interface PendingEntry {
32
46
  status: PendingStatus;
33
47
  /** 注册时间戳 ms */
34
48
  registeredAt: number;
35
- /** 过期时间戳 ms(registeredAt + TTL) */
36
- expiresAt: number;
49
+ /** 过期时间戳 ms(registeredAt + TTL);process 档恒 undefined(D16:进程级生命周期无 TTL) */
50
+ expiresAt: number | undefined;
37
51
  /** 注册时的 sessionId(用于跨 session 残留检测) */
38
52
  sessionId: string;
39
53
  }
@@ -110,7 +124,7 @@ export function getActive(registry: PendingRegistry): PendingEntry[] {
110
124
 
111
125
  /** countActiveFromEntries 的过滤选项。 */
112
126
  export interface CountActiveOptions {
113
- /** 只统计指定类型的活跃 pending;缺省 = 全部类型(subagent + workflow) */
127
+ /** 只统计指定类型的活跃 pending;缺省 = 全部类型(subagent + workflow + bash) */
114
128
  types?: PendingType[];
115
129
  }
116
130
 
@@ -181,7 +195,8 @@ export interface RebuildResult {
181
195
  *
182
196
  * 算法(对齐 goal before-agent-start.ts 的读取契约):
183
197
  * 1. 收集所有 pending:register entry,按 id 算差集(减去 pending:unregister 的 id)
184
- * 前提:id 全局唯一(workflow runId=`wf-<ts>-<rand>`、subagent id=`bg-/run-<tag>-<seq>-<ts>`)。
198
+ * 前提:id 全局唯一(workflow runId=`wf-<ts>-<rand>`、subagent id=`bg-/run-<tag>-<seq>-<ts>`、
199
+ * bash 后台任务 id=`bt-<ts>-<rand>`)。
185
200
  * 若未来 id 复用(register→unregister→register 同 id),全局 Set 差集会误跳第二次 register。
186
201
  * 2. 对每个活跃的 register entry 检查:
187
202
  * - sessionId 不符当前 session → expired(U4 跨 session 残留)
@@ -190,28 +205,62 @@ export interface RebuildResult {
190
205
  *
191
206
  * 注意:本函数只重建 registry + 计算需补的 entry,不写 entry(副作用归 index.ts)。
192
207
  */
193
- export function rebuildFromEntries(
194
- registry: PendingRegistry,
195
- entries: unknown[],
196
- currentSessionId: string,
197
- now: number,
198
- ): RebuildResult {
199
- const registerEntries: Array<{ data: RegisterEntryData }> = [];
200
- const unregisteredIds = new Set<string>();
208
+ /** rebuildFromEntries 的扫描阶段结果:register 原始 data 列表 + 已注销 id 集合 */
209
+ interface PendingEntryScan {
210
+ registerEntries: Array<{ data: RegisterEntryData }>;
211
+ unregisteredIds: Set<string>;
212
+ }
213
+
214
+ /** 单趟扫描 entries 按 customType 分流(pending:register / pending:unregister)。 */
215
+ function scanPendingEntries(entries: unknown[]): PendingEntryScan {
216
+ const scan: PendingEntryScan = { registerEntries: [], unregisteredIds: new Set() };
201
217
 
202
218
  for (const raw of entries as EntryLike[]) {
203
219
  // S-10:同 countActiveFromEntries——null/undefined 元素先守卫再访问字段。
204
220
  if (!raw || typeof raw !== "object") continue;
205
221
  if (raw.customType === "pending:register") {
206
- registerEntries.push({ data: (raw.data ?? {}) as RegisterEntryData });
222
+ scan.registerEntries.push({ data: (raw.data ?? {}) as RegisterEntryData });
207
223
  } else if (raw.customType === "pending:unregister") {
208
224
  const data = (raw.data ?? {}) as UnregisterEntryData;
209
225
  if (typeof data.id === "string") {
210
- unregisteredIds.add(data.id);
226
+ scan.unregisteredIds.add(data.id);
211
227
  }
212
228
  }
213
229
  }
214
230
 
231
+ return scan;
232
+ }
233
+
234
+ /**
235
+ * 判定单个 register entry 重建时是否应标 expired:
236
+ * - 跨 session 残留(U4)→ expired
237
+ * - TTL 过期(U3)→ expired
238
+ *
239
+ * process 档两检全跳过(D16:进程级生命周期跨 session 续存且无 TTL)。
240
+ */
241
+ function isExpiredEntry(entry: PendingEntry, currentSessionId: string, now: number): boolean {
242
+ // 跨 session 残留(U4)——process 档跳过(D16:进程级生命周期跨 session 续存,
243
+ // fork/switch 后任务仍在跑;标 expired 补 unregister 会让差集消费方误判「无活跃任务」)
244
+ if (PENDING_LIFECYCLE[entry.type] === "session" && entry.sessionId !== currentSessionId) {
245
+ return true;
246
+ }
247
+ // 过期(U3)——process 档跳过(D16:无 TTL,expiresAt 恒 undefined);
248
+ // session 档理论上恒有值,防御 undefined 不过期(缺失 = 该条目不过期)
249
+ return (
250
+ PENDING_LIFECYCLE[entry.type] === "session" &&
251
+ entry.expiresAt !== undefined &&
252
+ entry.expiresAt <= now
253
+ );
254
+ }
255
+
256
+ export function rebuildFromEntries(
257
+ registry: PendingRegistry,
258
+ entries: unknown[],
259
+ currentSessionId: string,
260
+ now: number,
261
+ ): RebuildResult {
262
+ const { registerEntries, unregisteredIds } = scanPendingEntries(entries);
263
+
215
264
  const activeIds: string[] = [];
216
265
  const expiredToFlush: Array<{ id: string; status: PendingStatus }> = [];
217
266
 
@@ -220,13 +269,7 @@ export function rebuildFromEntries(
220
269
  if (unregisteredIds.has(data.id)) continue;
221
270
 
222
271
  const entry = normalizeRegisterEntry(data, currentSessionId);
223
- // session 残留(U4)
224
- if (entry.sessionId !== currentSessionId) {
225
- expiredToFlush.push({ id: entry.id, status: "expired" });
226
- continue;
227
- }
228
- // 过期(U3)
229
- if (entry.expiresAt <= now) {
272
+ if (isExpiredEntry(entry, currentSessionId, now)) {
230
273
  expiredToFlush.push({ id: entry.id, status: "expired" });
231
274
  continue;
232
275
  }
@@ -238,16 +281,35 @@ export function rebuildFromEntries(
238
281
  return { activeIds, expiredToFlush };
239
282
  }
240
283
 
284
+ /**
285
+ * type 归一化:subagent/bash 原样保留,其余(含缺失/未知值)归 workflow。
286
+ * state.ts 与 index.ts 两处归一化共用本函数,防止「写入侧直通、读取侧归并」漂移。
287
+ */
288
+ export function normalizePendingType(raw: unknown): PendingType {
289
+ if (raw === "subagent") return "subagent";
290
+ if (raw === "bash") return "bash";
291
+ return "workflow";
292
+ }
293
+
241
294
  /** 从 entry data 归一化为 PendingEntry(补默认值,容错缺失字段) */
242
295
  function normalizeRegisterEntry(data: RegisterEntryData, currentSessionId: string): PendingEntry {
243
296
  const registeredAt = typeof data.registeredAt === "number" ? data.registeredAt : Date.now();
297
+ const type = normalizePendingType(data.type);
244
298
  return {
245
299
  id: data.id as string,
246
- type: (data.type === "subagent" ? "subagent" : "workflow") as PendingType,
300
+ type,
247
301
  name: typeof data.name === "string" ? data.name : (data.id as string),
248
302
  status: "active",
249
303
  registeredAt,
250
- expiresAt: typeof data.expiresAt === "number" ? data.expiresAt : registeredAt + PENDING_TTL_MS,
304
+ // D16:process 档不回填 TTL——写入侧本就省略 expiresAt,读取侧若回填
305
+ // registeredAt + TTL 会抵消写入侧的豁免(分档必须两侧同改)。session 档
306
+ // 缺失时回填 TTL 兼容旧 entry。
307
+ expiresAt:
308
+ PENDING_LIFECYCLE[type] === "process"
309
+ ? undefined
310
+ : typeof data.expiresAt === "number"
311
+ ? data.expiresAt
312
+ : registeredAt + PENDING_TTL_MS,
251
313
  sessionId: typeof data.sessionId === "string" ? data.sessionId : currentSessionId,
252
314
  };
253
315
  }