@zhushanwen/pi-session-manager 0.4.0 → 0.5.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.
@@ -1,7 +1,7 @@
1
1
  // watch-orchestration.test.ts — notify-once U4 extension 编排单测(mock select 响应)。
2
2
  // 覆盖:send/create arm(register 三键 + 开表不传 timeout + willNotify 透传)/
3
3
  // reason 类别分支 / deathSeq·settleSeq 分组去重(两层攒批模型)/ 两例外(cancelled·orphaned
4
- // 静默 + 死亡新闻槽)/ record 幂等键与 deliveryCustomType / session_compact 接线。
4
+ // 静默 + 死亡新闻槽)/ port.deliver 幂等键与通道(外部通道逐条)。
5
5
  //
6
6
  // watch select 全部走 deferred:开表时挂起,用例按调用序精确控制应答时机与内容
7
7
  // (覆盖「晚 respond」「W1 悬置 W2 应答」等时序形态)。
@@ -12,6 +12,7 @@ import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vite
12
12
  import { SESSION_MANAGER_MARKER } from "@zhushanwen/extension-protocol";
13
13
  // logger mock helper 必须先于 extension-harness(其 import 链触发被 mock 模块加载)
14
14
  import { createLoggerModuleMock, getLoggerMock } from "./helpers/logger-mock.ts";
15
+ import { DELIVERY_PORT_SLOT_KEY } from "@zhushanwen/pi-delivery-port";
15
16
  import {
16
17
  flushMicrotasks,
17
18
  makeRespondWatch,
@@ -26,7 +27,9 @@ vi.mock("@zhushanwen/pi-extension-logger", () => createLoggerModuleMock());
26
27
 
27
28
  const loggerMock = getLoggerMock();
28
29
 
29
- const LEDGER_SLOT_KEY = Symbol.for("@zhushanwen/subagent-core.notifyLedger");
30
+ // pi-delivery-port 进程级槽(C-ext-06;recordManagedNotify 的消费入口——unified-
31
+ // delivery 波 2 切换后 watch 应答的投递观测点 = port.deliver)。
32
+ const PORT_SLOT_KEY = DELIVERY_PORT_SLOT_KEY;
30
33
 
31
34
  /** watch 开表调用(deferred)——opts 为开表时传入的 select 第三参原样值 */
32
35
  interface DeferredWatch extends WatchInvocation {
@@ -79,35 +82,62 @@ function emittedIds(h: Harness, event: string): string[] {
79
82
  return h.emitted.filter((e) => e.event === event).map((e) => e.data.id as string);
80
83
  }
81
84
 
82
- function registersOf(h: Harness): Array<{ id: string; type: unknown; name: unknown }> {
85
+ function registersOf(h: Harness): Array<{ id: string; type: unknown; name: unknown; kind: unknown }> {
83
86
  return h.emitted
84
87
  .filter((e) => e.event === "pending:register")
85
- .map((e) => ({ id: String(e.data.id), type: e.data.type, name: e.data.name }));
88
+ .map((e) => ({ id: String(e.data.id), type: e.data.type, name: e.data.name, kind: e.data.kind }));
86
89
  }
87
90
 
88
91
  const SEND_OK = '{"queued":true,"willNotify":true}';
89
92
  const SEND_NO_NOTIFY = '{"queued":true,"willNotify":false}';
90
93
 
91
- /** 植入 ledger 槽(notify-ledger 镜像的读取目标)——record spy 由用例自持 */
92
- function plantLedger(
93
- record: (id: string, content: string, details: object, options?: unknown) => boolean,
94
- compactionCheck: () => number = () => 0,
95
- ): void {
96
- Reflect.set(globalThis, LEDGER_SLOT_KEY, { current: { record, compactionCheck } });
94
+ type DeliverCall = {
95
+ id: string | undefined;
96
+ customType: string;
97
+ content: string;
98
+ details: unknown;
99
+ collectKey: string[] | undefined;
100
+ };
101
+
102
+ /** 植入投递端口槽(pi-delivery-port 槽镜像的读取目标)——deliver spy 由用例自持;
103
+ * merge 判定在端口侧,本替身逐条受理即 accepted(无窗口语义,recorded 即投递)。 */
104
+ function plantPort(acceptAll = true): Array<DeliverCall> {
105
+ const calls: Array<DeliverCall> = [];
106
+ Reflect.set(globalThis, PORT_SLOT_KEY, {
107
+ current: {
108
+ deliver: (
109
+ envelope: {
110
+ id?: string;
111
+ payload: { customType: string; content: string; details?: unknown };
112
+ },
113
+ opts?: { collectKey?: string[] },
114
+ ) => {
115
+ if (!acceptAll) return { kind: "rejected", reason: "persist-failed (fake)" };
116
+ calls.push({
117
+ id: envelope.id,
118
+ customType: envelope.payload.customType,
119
+ content: envelope.payload.content,
120
+ details: envelope.payload.details,
121
+ collectKey: opts?.collectKey,
122
+ });
123
+ return { kind: "accepted", id: envelope.id ?? "gen-id" };
124
+ },
125
+ flush: () => {},
126
+ },
127
+ });
128
+ return calls;
97
129
  }
98
130
 
99
- function recordSpy(): ReturnType<typeof vi.fn> {
100
- const spy = vi.fn(() => true);
101
- plantLedger(spy as never);
102
- return spy;
131
+ function deliverSpy(): Array<DeliverCall> {
132
+ return plantPort(true);
103
133
  }
104
134
 
105
- /** 「兼容与防御」组共用前置:植入 record spy → arm 单笔 send → 应答首个 watch →
135
+ /** 「兼容与防御」组共用前置:植入投递端口(deliver spy)→ arm 单笔 send → 应答首个 watch →
106
136
  * 排干微任务 → 推进 50ms 攒批窗(三用例的时序骨架逐字同款) */
107
137
  async function armSendAndRespondWatch(
108
138
  respondValue: unknown,
109
- ): Promise<{ record: ReturnType<typeof vi.fn>; h: Harness }> {
110
- const record = recordSpy();
139
+ ): Promise<{ delivered: Array<DeliverCall>; h: Harness }> {
140
+ const delivered = deliverSpy();
111
141
  const h = createHarness();
112
142
  h.actionResults.send = () => SEND_OK;
113
143
  await runToolCall(h, "send_to_session", { sessionId: "child-1", prompt: "x" });
@@ -115,7 +145,7 @@ async function armSendAndRespondWatch(
115
145
  h.respondWatch(0, respondValue);
116
146
  await flushMicrotasks();
117
147
  await vi.advanceTimersByTimeAsync(50);
118
- return { record, h };
148
+ return { delivered, h };
119
149
  }
120
150
 
121
151
  beforeEach(() => {
@@ -125,7 +155,7 @@ beforeEach(() => {
125
155
 
126
156
  afterEach(() => {
127
157
  vi.useRealTimers();
128
- Reflect.deleteProperty(globalThis, LEDGER_SLOT_KEY);
158
+ Reflect.deleteProperty(globalThis, PORT_SLOT_KEY);
129
159
  });
130
160
 
131
161
  // ── arm:send/create 成功 → register + 开表 + willNotify 透传 ──────────────────
@@ -137,7 +167,7 @@ describe("arm(send/create → pending:register + watch 开表)", () => {
137
167
  await runToolCall(h, "send_to_session", { sessionId: "child-1", prompt: "go" });
138
168
  await flushMicrotasks();
139
169
 
140
- // 请求面:send params 携带 sm- 形态 notifyId
170
+ // 请求侧:send params 携带 sm- 形态 notifyId
141
171
  const sendPayload = JSON.parse(h.selectMock.mock.calls[0][1][0]) as {
142
172
  action: string;
143
173
  params: Record<string, unknown>;
@@ -145,11 +175,13 @@ describe("arm(send/create → pending:register + watch 开表)", () => {
145
175
  expect(sendPayload.action).toBe("send");
146
176
  expect(String(sendPayload.params.notifyId)).toMatch(/^sm-/);
147
177
 
148
- // register:P4 三键契约(label 缓存空 → name 退化 sessionId)
178
+ // register:P4 三键契约(label 缓存空 → name 退化 sessionId)+ send 债权无 kind
179
+ // (缺省 = send;result-collect 设计 §3.4)
149
180
  const regs = registersOf(h);
150
181
  expect(regs).toHaveLength(1);
151
182
  expect(regs[0].id).toMatch(/^sm-/);
152
183
  expect(regs[0]).toEqual({ id: regs[0].id, type: "session", name: "child-1" });
184
+ expect(regs[0].kind).toBeUndefined();
153
185
 
154
186
  // 开表:同 notifyId、无 timeout(D2/P1 fire-and-forget)
155
187
  expect(h.watches).toHaveLength(1);
@@ -157,7 +189,7 @@ describe("arm(send/create → pending:register + watch 开表)", () => {
157
189
  expect(h.watches[0].opts?.timeout).toBeUndefined();
158
190
  });
159
191
 
160
- it("工具结果原样透传:send result 含 willNotify 字段(LLM 契约面显式化)", async () => {
192
+ it("工具结果原样透传:send result 含 willNotify 字段(LLM 契约范围显式化)", async () => {
161
193
  const h = createHarness();
162
194
  h.actionResults.send = () => SEND_OK;
163
195
  const result = await runToolForText(h, "send_to_session", {
@@ -190,7 +222,9 @@ describe("arm(send/create → pending:register + watch 开表)", () => {
190
222
  expect(regs).toHaveLength(2);
191
223
  expect(regs[0].id).toMatch(/^sm-/);
192
224
  expect(regs[0]).toEqual({ id: regs[0].id, type: "session", name: "auth-refactor" });
193
- expect(regs[1]).toEqual({ id: lifetime, type: "session", name: "auth-refactor" });
225
+ // lifetime 死亡债权注册申报 kind:"lifetime"(result-collect 设计 §3.4:emit
226
+ // payload 增字段,collect 缺省等待集据此排除;send 债权无 kind = 缺省)
227
+ expect(regs[1]).toEqual({ id: lifetime, type: "session", name: "auth-refactor", kind: "lifetime" });
194
228
  expect(h.watches.map((w) => w.notifyId)).toEqual([regs[0].id, lifetime]);
195
229
 
196
230
  // label 缓存回填:随后 send 的 register name = 工具入参 label(D9 正常路径)
@@ -222,7 +256,8 @@ describe("arm(send/create → pending:register + watch 开表)", () => {
222
256
  const regs = registersOf(h);
223
257
  expect(regs).toHaveLength(1);
224
258
  // lifetime register:type 'session' + name=label(label 缺省 → sessionId 回退前的入参)
225
- expect(regs[0]).toEqual({ id: lifetime, type: "session", name: "L" });
259
+ // + kind:"lifetime" 债权类别标记(result-collect 设计 §3.4)
260
+ expect(regs[0]).toEqual({ id: lifetime, type: "session", name: "L", kind: "lifetime" });
226
261
  // lifetime watch 无条件开(死亡通知载体)
227
262
  expect(h.watches.map((w) => w.notifyId)).toEqual([lifetime]);
228
263
  expect(h.watches[0].opts?.timeout).toBeUndefined();
@@ -251,9 +286,9 @@ describe("arm(send/create → pending:register + watch 开表)", () => {
251
286
  });
252
287
  });
253
288
 
254
- // ── 默认 record + trailing debounce 两层攒批(settle 腿) ──────────────────────
289
+ // ── 默认 record + trailing debounce 两层攒批(settle 路径) ──────────────────────
255
290
 
256
- describe("watch 应答 → 两层攒批 record(settle 腿)", () => {
291
+ describe("watch 应答 → 两层攒批 record(settle 路径)", () => {
257
292
  async function armTwoSends(h: Harness): Promise<{ id1: string; id2: string }> {
258
293
  h.actionResults.send = () => SEND_OK;
259
294
  await runToolCall(h, "send_to_session", { sessionId: "child-1", prompt: "a" });
@@ -266,7 +301,7 @@ describe("watch 应答 → 两层攒批 record(settle 腿)", () => {
266
301
  }
267
302
 
268
303
  it("同 settleSeq 两笔 → 恰一条 record(fulfills 2)+ 逐笔 unregister;trailing 50ms 前不 flush(A6 合批)", async () => {
269
- const record = recordSpy();
304
+ const delivered = deliverSpy();
270
305
  const h = createHarness();
271
306
  const { id1, id2 } = await armTwoSends(h);
272
307
 
@@ -280,30 +315,28 @@ describe("watch 应答 → 两层攒批 record(settle 腿)", () => {
280
315
  h.respondWatch(1, JSON.stringify(respond));
281
316
  await flushMicrotasks();
282
317
 
283
- // trailing debounce:应答到达但 50ms 窗未到 → 零 record 零 unregister
284
- expect(record).not.toHaveBeenCalled();
318
+ // trailing debounce:应答到达但 50ms 窗未到 → 零投递零 unregister
319
+ expect(delivered).toHaveLength(0);
285
320
  expect(emittedIds(h, "pending:unregister")).toHaveLength(0);
286
321
 
287
322
  await vi.advanceTimersByTimeAsync(50);
288
- expect(record).toHaveBeenCalledTimes(1);
289
- const [notifyId, content, details, options] = record.mock.calls[0] as [
290
- string,
291
- string,
292
- Record<string, unknown>,
293
- { deliveryCustomType: string },
294
- ];
295
- expect(notifyId).toBe(id1); // 批幂等键 = 批内首个响应 notifyId
296
- expect(content).toBe(
323
+ expect(delivered).toHaveLength(1);
324
+ const call = delivered[0]!;
325
+ expect(call.id).toBe(id1); // 批幂等键 = 批内首个响应 notifyId(port.deliver 的 id)
326
+ expect(call.content).toBe(
297
327
  'Managed session "child-1" (child-1) finished with status "completed". (fulfills 2 requests)',
298
328
  );
299
- expect(details).toEqual({
329
+ expect(call.details).toEqual({
300
330
  notifyId: id1,
301
331
  sessionId: "child-1",
302
332
  reason: "completed",
303
333
  fulfills: 2,
304
334
  label: "child-1",
305
335
  });
306
- expect(options.deliveryCustomType).toBe("managed-session-notify");
336
+ expect(call.customType).toBe("managed-session-notify"); // 外部通道(port 批组隔离恒逐条)
337
+ // collectKey = 批内全部债权键(result-collect 设计 §3.4 managed 行:合批条目
338
+ // 载荷幂等键 = 批首债权键,collect 按键集逐键展开定位非首键)
339
+ expect(call.collectKey).toEqual([id1, id2]);
307
340
  expect(emittedIds(h, "pending:unregister").sort()).toEqual([id1, id2].sort());
308
341
  expect(
309
342
  h.emitted.filter((e) => e.event === "pending:unregister").map((e) => e.data.reason),
@@ -311,7 +344,7 @@ describe("watch 应答 → 两层攒批 record(settle 腿)", () => {
311
344
  });
312
345
 
313
346
  it("不同 settleSeq → 两条 record(A7:每轮一次,seq 边界排除跨轮合流)", async () => {
314
- const record = recordSpy();
347
+ const delivered = deliverSpy();
315
348
  const h = createHarness();
316
349
  const { id1, id2 } = await armTwoSends(h);
317
350
  h.respondWatch(
@@ -324,13 +357,13 @@ describe("watch 应答 → 两层攒批 record(settle 腿)", () => {
324
357
  );
325
358
  await flushMicrotasks();
326
359
  await vi.advanceTimersByTimeAsync(50);
327
- expect(record).toHaveBeenCalledTimes(2);
328
- expect(record.mock.calls.map((c) => c[0]).sort()).toEqual([id1, id2].sort());
360
+ expect(delivered).toHaveLength(2);
361
+ expect(delivered.map((c) => c.id).sort()).toEqual([id1, id2].sort());
329
362
  expect(emittedIds(h, "pending:unregister")).toHaveLength(2);
330
363
  });
331
364
 
332
365
  it("reason 分类:failed / stopped 应答 → 文案 status 对应(渲染侧 outcome 再映射)", async () => {
333
- const record = recordSpy();
366
+ const delivered = deliverSpy();
334
367
  const h = createHarness();
335
368
  const { id1, id2 } = await armTwoSends(h);
336
369
  h.respondWatch(
@@ -343,14 +376,14 @@ describe("watch 应答 → 两层攒批 record(settle 腿)", () => {
343
376
  );
344
377
  await flushMicrotasks();
345
378
  await vi.advanceTimersByTimeAsync(50);
346
- expect(record).toHaveBeenCalledTimes(2);
347
- const byId = new Map(record.mock.calls.map((c) => [c[0], c[1] as string]));
379
+ expect(delivered).toHaveLength(2);
380
+ const byId = new Map(delivered.map((c) => [c.id, c.content] as const));
348
381
  expect(byId.get(id1)).toContain('status "failed"');
349
382
  expect(byId.get(id2)).toContain('status "stopped"');
350
383
  });
351
384
 
352
- it("跨 session 不合批(分拣键 sessionId 腿):同 settleSeq 不同 session → 两条 record", async () => {
353
- const record = recordSpy();
385
+ it("跨 session 不合批(分拣键 sessionId 路径):同 settleSeq 不同 session → 两条 record", async () => {
386
+ const delivered = deliverSpy();
354
387
  const h = createHarness();
355
388
  h.actionResults.send = () => SEND_OK;
356
389
  await runToolCall(h, "send_to_session", { sessionId: "child-a", prompt: "x" });
@@ -367,13 +400,18 @@ describe("watch 应答 → 两层攒批 record(settle 腿)", () => {
367
400
  );
368
401
  await flushMicrotasks();
369
402
  await vi.advanceTimersByTimeAsync(50);
370
- expect(record).toHaveBeenCalledTimes(2);
371
- const sessions = record.mock.calls.map((c) => (c[2] as { sessionId: string }).sessionId);
403
+ expect(delivered).toHaveLength(2);
404
+ const sessions = delivered.map((c) => (c.details as { sessionId: string }).sessionId);
372
405
  expect(sessions.sort()).toEqual(["child-a", "child-b"]);
373
406
  });
374
407
 
375
- it("record 返回 false(幂等拒收/槽空降级)不改 unregister 语义", async () => {
376
- plantLedger(() => false);
408
+ it("port rejected(persist-failed)/槽空降级 → false 不改 unregister 语义", async () => {
409
+ Reflect.set(globalThis, PORT_SLOT_KEY, {
410
+ current: {
411
+ deliver: () => ({ kind: "rejected", reason: "persist-failed (fake)" }),
412
+ flush: () => {},
413
+ },
414
+ });
377
415
  const h = createHarness();
378
416
  await armTwoSends(h);
379
417
  const respond = {
@@ -393,8 +431,8 @@ describe("watch 应答 → 两层攒批 record(settle 腿)", () => {
393
431
  // ── 两例外(D3)─────────────────────────────────────────────────────────────────
394
432
 
395
433
  describe("两例外(cancelled·orphaned 静默 / 死亡新闻槽)", () => {
396
- it("例外1:cancelled / orphaned → 逐笔即时 unregister(50ms 窗前即到),零 record", async () => {
397
- const record = recordSpy();
434
+ it("例外1:cancelled / orphaned → 逐笔即时 unregister(50ms 窗前即到),零投递", async () => {
435
+ const delivered = deliverSpy();
398
436
  const h = createHarness();
399
437
  const { id1, id2 } = await armTwo();
400
438
  h.respondWatch(0, JSON.stringify({ reason: "cancelled", sessionId: "child-1" }));
@@ -403,10 +441,10 @@ describe("两例外(cancelled·orphaned 静默 / 死亡新闻槽)", () => {
403
441
 
404
442
  // 逐笔即时(不被攒批窗延迟)
405
443
  expect(emittedIds(h, "pending:unregister").sort()).toEqual([id1, id2].sort());
406
- expect(record).not.toHaveBeenCalled();
444
+ expect(delivered).toHaveLength(0);
407
445
 
408
446
  await vi.advanceTimersByTimeAsync(50);
409
- expect(record).not.toHaveBeenCalled();
447
+ expect(delivered).toHaveLength(0);
410
448
 
411
449
  async function armTwo() {
412
450
  h.actionResults.send = () => SEND_OK;
@@ -418,7 +456,7 @@ describe("两例外(cancelled·orphaned 静默 / 死亡新闻槽)", () => {
418
456
  });
419
457
 
420
458
  it("例外2:同 (sessionId, deathSeq) 首条死亡新闻 record + fulfills N,同键后续仅 unregister;新 deathSeq 自然发声", async () => {
421
- const record = recordSpy();
459
+ const delivered = deliverSpy();
422
460
  const h = createHarness();
423
461
  const lifetime = "sm-11111111-2222-3333-4444-555555555555";
424
462
  h.actionResults.create = () =>
@@ -449,8 +487,10 @@ describe("两例外(cancelled·orphaned 静默 / 死亡新闻槽)", () => {
449
487
  await flushMicrotasks();
450
488
  await vi.advanceTimersByTimeAsync(50);
451
489
 
452
- expect(record).toHaveBeenCalledTimes(1);
453
- const [id, content, details] = record.mock.calls[0] as [string, string, Record<string, unknown>];
490
+ expect(delivered).toHaveLength(1);
491
+ const id = delivered[0]!.id;
492
+ const content = delivered[0]!.content;
493
+ const details = delivered[0]!.details as Record<string, unknown>;
454
494
  expect(id).toBe(claimId); // 首条 = 批内首个响应(claim 先应答)
455
495
  expect(content).toContain(
456
496
  'Managed session "doomed" (child-d) finished with status "exited" (exit code: 1).',
@@ -473,22 +513,22 @@ describe("两例外(cancelled·orphaned 静默 / 死亡新闻槽)", () => {
473
513
  );
474
514
  await flushMicrotasks();
475
515
  await vi.advanceTimersByTimeAsync(50);
476
- expect(record).toHaveBeenCalledTimes(2);
477
- expect(record.mock.calls[1][0]).toBe(emittedIds(h, "pending:register")[2]);
478
- expect(String(record.mock.calls[1][1])).toContain("(exit code: 2).");
479
- expect(String(record.mock.calls[1][1])).not.toContain("fulfills");
480
- expect(String(record.mock.calls[1][1])).not.toContain("Full transcript");
516
+ expect(delivered).toHaveLength(2);
517
+ expect(delivered[1]!.id).toBe(emittedIds(h, "pending:register")[2]);
518
+ expect(delivered[1]!.content).toContain("(exit code: 2).");
519
+ expect(delivered[1]!.content).not.toContain("fulfills");
520
+ expect(delivered[1]!.content).not.toContain("Full transcript");
481
521
  });
482
522
 
483
523
  it("例外2 跨 reason 组同槽:claim 'exited' 与 lifetime 'deleted' 同 deathSeq → 仅首条发声(分拣组保序)", async () => {
484
- const record = recordSpy();
524
+ const delivered = deliverSpy();
485
525
  const lifetime = "sm-11111111-2222-3333-4444-555555555555";
486
526
  const claimId = "sm-aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee";
487
527
  const h = createHarness([
488
528
  { customType: "pending:register", data: { id: claimId, type: "session", name: "D" } },
489
529
  { customType: "pending:register", data: { id: lifetime, type: "session", name: "D" } },
490
530
  ]);
491
- // 经收口腿开两条 watch(开表序 = entries 序,claim 先到)
531
+ // 经收尾路径开两条 watch(开表序 = entries 序,claim 先到)
492
532
  h.handlers.session_start({ type: "session_start" }, h.ctx);
493
533
  await flushMicrotasks();
494
534
  expect(h.watches).toHaveLength(2);
@@ -504,9 +544,9 @@ describe("两例外(cancelled·orphaned 静默 / 死亡新闻槽)", () => {
504
544
  await flushMicrotasks();
505
545
  await vi.advanceTimersByTimeAsync(50);
506
546
 
507
- expect(record).toHaveBeenCalledTimes(1);
508
- expect(record.mock.calls[0][0]).toBe(claimId); // 先到先占槽(claim 组先入队)
509
- expect(String(record.mock.calls[0][1])).toContain('status "exited"');
547
+ expect(delivered).toHaveLength(1);
548
+ expect(delivered[0]!.id).toBe(claimId); // 先到先占槽(claim 组先入队)
549
+ expect(delivered[0]!.content).toContain('status "exited"');
510
550
  expect(emittedIds(h, "pending:unregister").sort()).toEqual([claimId, lifetime].sort());
511
551
  const unregisterPairs = h.emitted
512
552
  .filter((e) => e.event === "pending:unregister")
@@ -521,20 +561,20 @@ describe("两例外(cancelled·orphaned 静默 / 死亡新闻槽)", () => {
521
561
  });
522
562
  });
523
563
 
524
- // ── 兼容与防御(D6 象限 / 形状守卫)─────────────────────────────────────────────
564
+ // ── 兼容与防御(D6 象限 / 形状检查)─────────────────────────────────────────────
525
565
 
526
566
  describe("兼容与防御", () => {
527
567
  it("旧 runtime 象限:respond 'null' → 折叠 cancelled 静默 unregister,零 record", async () => {
528
- const { record, h } = await armSendAndRespondWatch("null");
529
- expect(record).not.toHaveBeenCalled();
568
+ const { delivered, h } = await armSendAndRespondWatch("null");
569
+ expect(delivered).toHaveLength(0);
530
570
  expect(emittedIds(h, "pending:unregister")).toHaveLength(1);
531
571
  // D6 显式静默豁免:null 不是漂移信号,零 warn 留痕
532
572
  expect(loggerMock.warn).not.toHaveBeenCalled();
533
573
  });
534
574
 
535
- it("畸形/词表外 payload → 折叠 cancelled 收口(不落 default 误标、不产通知)+ 漂移 warn 留痕", async () => {
536
- const { record, h } = await armSendAndRespondWatch(JSON.stringify({ reason: "some-future-reason" }));
537
- expect(record).not.toHaveBeenCalled();
575
+ it("畸形/词表外 payload → 折叠 cancelled 收尾(不落 default 误标、不产通知)+ 漂移 warn 留痕", async () => {
576
+ const { delivered, h } = await armSendAndRespondWatch(JSON.stringify({ reason: "some-future-reason" }));
577
+ expect(delivered).toHaveLength(0);
538
578
  expect(emittedIds(h, "pending:unregister")).toHaveLength(1);
539
579
  // 协议漂移信号必须留痕:warn 带 notifyId + 漂移形态摘要(词表外 reason 原词)
540
580
  expect(loggerMock.warn).toHaveBeenCalledTimes(1);
@@ -546,18 +586,12 @@ describe("兼容与防御", () => {
546
586
  });
547
587
  });
548
588
 
549
- it("select 侧 cancelled(应答未达,resolve undefined)→ 不动 pending(残留交下次 session_start 收口腿)", async () => {
550
- const { record, h } = await armSendAndRespondWatch(undefined);
589
+ it("select 侧 cancelled(应答未达,resolve undefined)→ 不动 pending(残留交下次 session_start 收尾路径)", async () => {
590
+ const { delivered, h } = await armSendAndRespondWatch(undefined);
551
591
  expect(emittedIds(h, "pending:unregister")).toHaveLength(0);
552
- expect(record).not.toHaveBeenCalled();
592
+ expect(delivered).toHaveLength(0);
553
593
  });
554
594
 
555
- it("session_compact → compactionCheck 接线(D3 装配纪律④)", () => {
556
- const compactionCheck = vi.fn(() => 0);
557
- plantLedger(vi.fn(() => true) as never, compactionCheck);
558
- const h = createHarness();
559
- expect(h.handlers.session_compact).toBeTypeOf("function");
560
- h.handlers.session_compact({ type: "session_compact" }, h.ctx);
561
- expect(compactionCheck).toHaveBeenCalledTimes(1);
562
- });
595
+ // [session_compact → compactionCheck 接线随波 2 账本退役删除]:pi session
596
+ // append-only 契约排除 entry 被清事件(unified-delivery §3.7②),无补写接线可锁。
563
597
  });
package/src/index.ts CHANGED
@@ -5,7 +5,6 @@ import type {
5
5
  ExtensionAPI,
6
6
  ExtensionContext,
7
7
  ExtensionToolContext,
8
- SessionCompactEvent,
9
8
  SessionStartEvent,
10
9
  Theme,
11
10
  } from "@earendil-works/pi-coding-agent";
@@ -21,7 +20,6 @@ import { getLogger, setPiHandle } from "@zhushanwen/pi-extension-logger";
21
20
  import { Type, type Static, type TObject } from "typebox";
22
21
 
23
22
  import { renderSessionManagerResult } from "./render.ts";
24
- import { ensureLedgerBound, runLedgerCompactionCheck } from "./notify-ledger.ts";
25
23
  import { createWatchCoordinator } from "./watch-coordinator.ts";
26
24
 
27
25
  // 模块级 logger(default export 首行 setPiHandle 注入后自动走 appendEntry 持久化,
@@ -101,7 +99,7 @@ function newNotifyId(): string {
101
99
  return `sm-${crypto.randomUUID()}`;
102
100
  }
103
101
 
104
- /** 运行时守卫(读侧不信任外部格式):结果是否 JSON object——形状断言前的类型 guard。 */
102
+ /** 运行时检查(读侧不信任外部格式):结果是否 JSON object——形状断言前的类型 guard。 */
105
103
  function asResultRecord(v: unknown): Record<string, unknown> | undefined {
106
104
  return typeof v === "object" && v !== null && !Array.isArray(v)
107
105
  ? (v as Record<string, unknown>)
@@ -227,26 +225,18 @@ export default function sessionManagerExtension(pi: ExtensionAPI): void {
227
225
  setPiHandle(pi);
228
226
 
229
227
  // watch 桥编排(notify-once U4):工具结果 arm / watch 应答两层攒批 record /
230
- // session_start 重启收口腿共用同一闭包状态(label 缓存 + 死亡新闻槽 + 攒批队列)。
228
+ // session_start 重启收尾路径共用同一闭包状态(label 缓存 + 死亡新闻槽 + 攒批队列)。
231
229
  const coordinator = createWatchCoordinator({
232
230
  pi,
233
231
  callWatch: (ctx, notifyId) => callSessionManager(ctx, "watch", { notifyId }),
234
232
  });
235
233
 
236
- // 重启收口腿(D7③):父 pi 死亡后永无 unregister 的活跃 type 'session' register
234
+ // 重启收尾路径(D7③):父 pi 死亡后永无 unregister 的活跃 type 'session' register
237
235
  // 逐条重开 watch(入参 = entry id 即 notifyId),应答经 watch 处理链静默注销,
238
236
  // 活跃集回归基线;每 entry 独立 handler(禁串行 await)在 coordinator 内保证。
239
- // ledger 装配纪律①②(D3):session_start 无条件 bind + recoverFromSession 成对
240
- //(唯一装配方形态,与 subagent-workflow bindLedgerHostAndRecover 同款——先装配,
241
- // 收口腿/watch 应答到达时消费面已就绪)。
242
237
  pi.on("session_start", (_event: SessionStartEvent, ctx: ExtensionContext) => {
243
- ensureLedgerBound(pi, ctx);
244
238
  coordinator.recoverStaleRegisters(ctx);
245
239
  });
246
- // compactionCheck 接线(D3 装配纪律④,P-B4 降级——subagent-workflow 同款先例)
247
- pi.on("session_compact", (_event: SessionCompactEvent, _ctx: ExtensionContext) => {
248
- runLedgerCompactionCheck();
249
- });
250
240
 
251
241
  registerSessionTool(pi, {
252
242
  name: "create_managed_session",
@@ -274,7 +264,10 @@ export default function sessionManagerExtension(pi: ExtensionAPI): void {
274
264
  coordinator.armSessionNotify(ctx, notifyId, name);
275
265
  }
276
266
  if (typeof r.lifetimeNotifyId === "string") {
277
- coordinator.armSessionNotify(ctx, r.lifetimeNotifyId, name);
267
+ // kind:"lifetime"(result-collect 设计 §3.4):lifetime 死亡债权注册在
268
+ // emit 侧申报类别标记(collect 缺省等待集据此排除);send 债权注册
269
+ // 不带 kind(缺省 = send)。
270
+ coordinator.armSessionNotify(ctx, r.lifetimeNotifyId, name, "lifetime");
278
271
  }
279
272
  },
280
273
  toSummary: (params, result) => {
@@ -8,7 +8,7 @@
8
8
  // d69583684;runtime 查得到即携、查不到不携,不携时整行省略语义不变)。
9
9
  //
10
10
  // label 两路径(U4):正常路径 = 工具入参 label(create 入参 / send 经 labelCache 回填);
11
- // 重启收口腿 = pending:register 三键的 name。两条路径都汇入本函数的 params.label,
11
+ // 重启收尾路径 = pending:register 三键的 name。两条路径都汇入本函数的 params.label,
12
12
  // deathSeq 后 kind 不再决定文案(D3 例外2 内容确定性)。
13
13
 
14
14
  /** managed session 完成通知的送达 customType(D9)。与 packages/shared
@@ -26,12 +26,12 @@ export interface ManagedNotifyContentParams {
26
26
  sessionId: string;
27
27
  /** 通知状态(settle: completed/failed/stopped;death: exited/deleted) */
28
28
  status: string;
29
- /** 本批销账的债权笔数(D3 合批 fulfills N;0/undefined → 不出行;纯死亡通知无债权) */
29
+ /** 本批处理完毕的债权笔数(D3 合批 fulfills N;0/undefined → 不出行;纯死亡通知无债权) */
30
30
  fulfills?: number;
31
31
  /** transcript 指针行数据源(watch respond payload.sessionFilePath——u-protocol d69583684;
32
32
  * runtime 查不到不携 → 缺席时整行省略) */
33
33
  sessionFilePath?: string;
34
- /** death 应答携带(D6 meta,exit 腿诊断通路复刻) */
34
+ /** death 应答携带(D6 meta,exit 路径诊断通路复刻) */
35
35
  exitCode?: number | null;
36
36
  stderrTail?: string;
37
37
  }
@@ -62,7 +62,7 @@ export function buildManagedNotifyContent(params: ManagedNotifyContentParams): s
62
62
  }
63
63
 
64
64
  /**
65
- * record 的投递 details(ledger record 第三参)——core `extractNotifyRecords`
65
+ * 投递的 details(port.deliver payload.details)——core `extractNotifyRecords`
66
66
  * 的 `managed-session-notify` 分支读 `notifyId`(去重键)与 `reason`(outcome 映射
67
67
  * completed→success / failed→failed / 其余 neutral,D9)。其余字段为诊断冗余。
68
68
  */
@@ -71,7 +71,7 @@ export interface ManagedNotifyDetails {
71
71
  notifyId: string;
72
72
  sessionId: string;
73
73
  reason: string;
74
- /** 批内销账笔数(runtime fulfillsN 直供,缺省回退批长——见 watch-coordinator) */
74
+ /** 批内处理完毕笔数(runtime fulfillsN 直供,缺省回退批长——见 watch-coordinator) */
75
75
  fulfills: number;
76
76
  label: string;
77
77
  }
@@ -0,0 +1,87 @@
1
+ // src/notify-delivery.ts — managed session 死亡通知的投递接入(unified-delivery 波 2:
2
+ // 投递路径切 pi 半区投递端口,getDeliveryPort 进程级槽消费,C-ext-06)。
3
+ //
4
+ // 投递形态(老能力映射 #6):watch 上游不变,recordManagedNotify 投递路径切
5
+ // port.deliver(managed-session-notify 外部通道逐条不合批——port 批组隔离原生
6
+ // 承载,合批已在 watch-coordinator 微窗内重建批身份,D3 裁决4)。port 恒装配于
7
+ // base-tool-enhance session_start(P6 infrastructure 档必载),本域零装配义务
8
+ //(通知账本装配随波 2 账本退役删除——两套装配并存违背装配单一归属,§3.7)。
9
+ //
10
+ // 槽空降级(port 缺席:base-tool-enhance session_start 未跑 / bind 失败——装配异常
11
+ // 窗口,生产不可达常态):warn 留痕 + false(STANDARDS §11.1 通知类接入点降级,
12
+ // 非静默吞)。缺席回退不建第二投递路径(unified-delivery §3.7:两套装配并存违背
13
+ // 装配单一归属——notifier 直发兜底属 notifier 自身形态,本域降级 = 放弃该条 +
14
+ // warn 修复信号)。
15
+
16
+ import { getDeliveryPort } from "@zhushanwen/pi-delivery-port";
17
+ import { getLogger } from "@zhushanwen/pi-extension-logger";
18
+ import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
19
+
20
+ import {
21
+ MANAGED_SESSION_NOTIFY_CUSTOM_TYPE,
22
+ type ManagedNotifyDetails,
23
+ } from "./notify-content.ts";
24
+
25
+ const logger = getLogger("session-manager");
26
+
27
+ /**
28
+ * 投递一条死亡通知(unified-delivery 波 2:port.deliver 主投——managed-session-notify
29
+ * 外部通道逐条不合批)。
30
+ *
31
+ * collectKeys(result-collect 设计 §3.4 managed 行):批内全部债权键集申报——合批
32
+ * 条目载荷幂等键 = 批首债权键,其余成员仅注销 pending 无独立日志条目,单值申报下
33
+ * collect 无法按非首键定位载荷;键集申报经端口逐键展开(命中 = 等待集 ∩ 条目键集
34
+ * ≠ ∅)。必传:批恒至少一条应答,无键集的 deliver 在本域 = collect 永远等不到,
35
+ * 属调用方缺陷,构造性防漏(编译期拦截)。
36
+ *
37
+ * port 缺席 → warn + false(降级登记,见文件头)。deliver swallowed = 幂等命中
38
+ * (同 notifyId 已受理或已终局——重放/重复应答的重复提交),按已投递返回 true;
39
+ * deliver rejected(persist-failed 等)→ warn + false(幂等键未登记,watch 重试
40
+ * 合法)。
41
+ */
42
+ export function recordManagedNotify(
43
+ notifyId: string,
44
+ content: string,
45
+ details: ManagedNotifyDetails,
46
+ collectKeys: string[],
47
+ ): boolean {
48
+ const port = getDeliveryPort();
49
+ if (!port) {
50
+ logger.warn(
51
+ "[session-manager] delivery port not bound — notification dropped (base-tool-enhance session_start 装配缺失;下次 session_start 重试装配)",
52
+ { notifyId },
53
+ );
54
+ return false;
55
+ }
56
+ try {
57
+ const result = port.deliver(
58
+ {
59
+ payload: {
60
+ kind: "custom",
61
+ customType: MANAGED_SESSION_NOTIFY_CUSTOM_TYPE,
62
+ content,
63
+ display: true,
64
+ details,
65
+ },
66
+ id: notifyId,
67
+ },
68
+ { collectKey: collectKeys },
69
+ );
70
+ if (result.kind === "rejected") {
71
+ logger.warn("[session-manager] delivery port rejected notification (idempotency key not registered)", {
72
+ notifyId,
73
+ reason: result.reason,
74
+ });
75
+ return false;
76
+ }
77
+ return true;
78
+ } catch (err) {
79
+ // 写入异常(stale ctx / appendEntry 失败)按接入点降级:不拖垮 watch 应答编排
80
+ //(unregister 照常),warn 留痕可归因
81
+ logger.warn("[session-manager] notify delivery failed", {
82
+ notifyId,
83
+ error: toErrorMessage(err),
84
+ });
85
+ return false;
86
+ }
87
+ }