@zhushanwen/pi-delivery-port 0.3.0 → 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 +7 -5
- package/src/__tests__/delivery-port.test.ts +75 -1
- package/src/__tests__/journal.test.ts +1 -1
- package/src/__tests__/pi-host.test.ts +4 -4
- package/src/__tests__/reconcile-edge.test.ts +102 -0
- package/src/__tests__/reconcile-judgement.test.ts +187 -0
- package/src/delivery-port.ts +66 -24
- package/src/index.ts +17 -0
- package/src/journal.ts +3 -3
- package/src/pi-host.ts +3 -3
- package/src/reconcile-edge.ts +92 -0
- package/src/reconcile-judgement.ts +112 -0
- package/src/session-journal-read.ts +95 -0
- package/src/types.ts +15 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-delivery-port",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "In-process delivery port for the pi half of the unified delivery service (design .tmp/tech-design/unified-delivery.md §3.7): binds pi appendEntry / sessionManager.getEntries / isIdle / sendMessage / message_end / session_compact into the @zhushanwen/session-delivery v3 kernel as the sole executor of the custom lane — outbox journal (entry schema v2 + v1 notify-ledger compat read), details-id delivery proof, session_start difference-set replay, edge-immediate dispatch with external-channel isolation (permanent dedup via the delivery journal), and compaction-wait degradation (shared library, not a Pi extension).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -17,21 +17,23 @@
|
|
|
17
17
|
"src/"
|
|
18
18
|
],
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@zhushanwen/extension-protocol": "0.
|
|
20
|
+
"@zhushanwen/extension-protocol": "0.27.0",
|
|
21
|
+
"@zhushanwen/pi-ext-guards": "0.5.1",
|
|
22
|
+
"@zhushanwen/pi-extension-logger": "0.6.2"
|
|
21
23
|
},
|
|
22
24
|
"peerDependencies": {
|
|
23
25
|
"@earendil-works/pi-coding-agent": "^1.0.0",
|
|
24
|
-
"@zhushanwen/session-delivery": "2.
|
|
26
|
+
"@zhushanwen/session-delivery": "2.2.0"
|
|
25
27
|
},
|
|
26
28
|
"devDependencies": {
|
|
27
29
|
"@earendil-works/pi-coding-agent": "^1.0.0",
|
|
28
30
|
"@types/node": "^24.0.0",
|
|
29
31
|
"@vitest/coverage-v8": "^4.1.9",
|
|
30
32
|
"vitest": "^4.1.8",
|
|
31
|
-
"@zhushanwen/session-delivery": "2.
|
|
33
|
+
"@zhushanwen/session-delivery": "2.2.0"
|
|
32
34
|
},
|
|
33
35
|
"scripts": {
|
|
34
36
|
"typecheck": "npx tsc --noEmit",
|
|
35
|
-
"test": "vitest run"
|
|
37
|
+
"test": "vitest run --pool=threads"
|
|
36
38
|
}
|
|
37
39
|
}
|
|
@@ -552,6 +552,32 @@ describe("session_start 差集重放", () => {
|
|
|
552
552
|
// sent 2 条 = first 实例注入 1(投出未送达——无回执无 ack,差集合法重投)+ 重放 1
|
|
553
553
|
expect(h.sent).toHaveLength(2);
|
|
554
554
|
});
|
|
555
|
+
|
|
556
|
+
it("重绑交接(in-flight 预占):前驱已出站条目后继不重放(bindDeliveryPort 双投窗口封住,A4 真机回归);崩溃形态(无前驱交接)照旧重放", () => {
|
|
557
|
+
const h = makeHost();
|
|
558
|
+
// 同一 session 的两次 bind(session_start 连发,真机 10ms 级间隔):第一次
|
|
559
|
+
// bind 后受理并出站一条(注入完成、凭据未落——pi 异步写 entry 的窗口)。
|
|
560
|
+
const first = bindDeliveryPort(h.host);
|
|
561
|
+
first.deliver(defaultEnvelope({ id: "rb-1", payload: { kind: "custom", customType: DEFAULT_NOTIFY_CUSTOM_TYPE, content: "redelivered", display: true } }));
|
|
562
|
+
expect(h.sent).toHaveLength(1); // 已出站(sendMessage 交递完成)
|
|
563
|
+
expect(h.outboxEntries()).toHaveLength(1);
|
|
564
|
+
const deliveredProofCount = (): number =>
|
|
565
|
+
h.host.readSessionEntries().filter(
|
|
566
|
+
(e) => (e as Record<string, unknown>)["type"] === "custom_message",
|
|
567
|
+
).length;
|
|
568
|
+
expect(deliveredProofCount()).toBe(0); // 凭据未落(注入异步——重放窗口的构成条件)
|
|
569
|
+
|
|
570
|
+
// 第二次 bind:交接 in-flight 集合 + dispose 前驱 → 后继 recover 跳过该条
|
|
571
|
+
// (消息在途必达,重放 = 双投)。sent 恒 1、recoveryReplays 零计数。
|
|
572
|
+
const second = bindDeliveryPort(h.host);
|
|
573
|
+
expect(second.recover()).toBe(0);
|
|
574
|
+
expect(h.sent).toHaveLength(1);
|
|
575
|
+
|
|
576
|
+
// 对照(崩溃形态):无前驱交接的新实例(进程重启语义)对同一未凭据 outbox
|
|
577
|
+
// 照旧重放——崩溃恢复通道不受交接影响。
|
|
578
|
+
const third = createDeliveryPort(h.host);
|
|
579
|
+
expect(third.recover()).toBe(1);
|
|
580
|
+
});
|
|
555
581
|
});
|
|
556
582
|
|
|
557
583
|
// ─── 耐久承诺 ───────────────────────────────────────────────
|
|
@@ -577,6 +603,54 @@ describe("受理前落盘(耐久承诺)", () => {
|
|
|
577
603
|
});
|
|
578
604
|
});
|
|
579
605
|
|
|
606
|
+
// ─── 终局持久化失败(磁盘权威)──────────────────────────────
|
|
607
|
+
|
|
608
|
+
describe("终局持久化失败(磁盘权威:ack 写盘失败 → 内核内存不登记终态)", () => {
|
|
609
|
+
it("ack entry 写失败 → message_end 边沿不炸、ack 未落、分型错误经 warn 留痕;写盘恢复后下一边沿补落", () => {
|
|
610
|
+
const h = makeHost();
|
|
611
|
+
const orig = h.host.appendEntry;
|
|
612
|
+
let ackBroken = true;
|
|
613
|
+
h.host.appendEntry = (customType, data) => {
|
|
614
|
+
if (ackBroken && customType === ACK_CUSTOM_TYPE) throw new Error("disk full (fake)");
|
|
615
|
+
orig(customType, data);
|
|
616
|
+
};
|
|
617
|
+
const port = createDeliveryPort(h.host);
|
|
618
|
+
port.deliver(defaultEnvelope({ id: "af-1" }));
|
|
619
|
+
expect(h.sent).toHaveLength(1);
|
|
620
|
+
// message_end 命中 → 内核终局 ack → appendEntry(ack) 抛错:边沿回调不炸
|
|
621
|
+
//(内核捕获分型错误返回 false,异常不上抛 pi 事件循环)
|
|
622
|
+
expect(() => h.injectAndEmit(h.sent[0].message)).not.toThrow();
|
|
623
|
+
expect(h.ackEntries()).toHaveLength(0); // 持久终局未落
|
|
624
|
+
expect(h.warn.mock.calls.some((c) => String(c[0]).includes("store.ack failed"))).toBe(true);
|
|
625
|
+
// 写盘恢复:同一 message 的下一 message_end 边沿重驱动 → ack 补落(重试路径)
|
|
626
|
+
ackBroken = false;
|
|
627
|
+
h.fireMessageEnd({ role: "custom", ...h.sent[0].message, timestamp: Date.now() });
|
|
628
|
+
expect(h.ackEntries()).toHaveLength(1);
|
|
629
|
+
expect(h.ackEntries()[0]!["data"]).toMatchObject({ v: 2, id: "af-1" });
|
|
630
|
+
expect(port.recover()).toBe(0); // 终局已落,差集清零
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it("ack 写失败期间「重启」(新实例重扫):注入事实(custom_message entry)保持判重——不重投", () => {
|
|
634
|
+
const h = makeHost();
|
|
635
|
+
const orig = h.host.appendEntry;
|
|
636
|
+
h.host.appendEntry = (customType, data) => {
|
|
637
|
+
if (customType === ACK_CUSTOM_TYPE) throw new Error("disk full (fake)");
|
|
638
|
+
orig(customType, data);
|
|
639
|
+
};
|
|
640
|
+
const port = createDeliveryPort(h.host);
|
|
641
|
+
port.deliver(defaultEnvelope({ id: "af-2" }));
|
|
642
|
+
h.injectAndEmit(h.sent[0].message); // 注入事实已落 custom_message entry;ack 写失败
|
|
643
|
+
expect(h.ackEntries()).toHaveLength(0);
|
|
644
|
+
port.dispose();
|
|
645
|
+
// 磁盘权威的送达事实 = 注入 entry 携带的 deliveryId(非 ack entry):新实例差集
|
|
646
|
+
// 不含该条,不重投(ack entry 缺席不破坏判重)
|
|
647
|
+
const revived = createDeliveryPort(h.host);
|
|
648
|
+
expect(revived.recover()).toBe(0);
|
|
649
|
+
expect(h.sent).toHaveLength(1);
|
|
650
|
+
revived.dispose();
|
|
651
|
+
});
|
|
652
|
+
});
|
|
653
|
+
|
|
580
654
|
// ─── 装配槽(C-ext-06 惯例)─────────────────────────────────
|
|
581
655
|
|
|
582
656
|
describe("进程级槽(bindDeliveryPort / getDeliveryPort)", () => {
|
|
@@ -599,7 +673,7 @@ describe("进程级槽(bindDeliveryPort / getDeliveryPort)", () => {
|
|
|
599
673
|
});
|
|
600
674
|
});
|
|
601
675
|
|
|
602
|
-
// ─── collect
|
|
676
|
+
// ─── collect 挂起行为(result-collect 设计 §3.1②;§4 中断形态单测分工)───
|
|
603
677
|
|
|
604
678
|
/** pending register entry fixture(写侧落盘形态:index.ts safeAppendEntry 同构)。 */
|
|
605
679
|
const pendingRegister = (id: string, type = "bash") => ({
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// journal schema v2 定形与 v1 兼容读映射的单测锁定(设计「待验证检查点 1」,
|
|
2
2
|
// unified-delivery.impl-plan §0:检查点 1 → u2-delivery-port)。
|
|
3
|
-
//
|
|
3
|
+
// 锁定范围:① v2 outbox/ack data 字段定形与形态校验宽容度;② v1(notify 账本)
|
|
4
4
|
// 映射函数全字段(notifyId→id / timing / customType 通道映射 / record→details);
|
|
5
5
|
// ③ 差集扫描基线(outbox 后写覆盖 / ack 双形态 / delivered details-id 双形态与
|
|
6
6
|
// 接受域)。schema 漂移在此红。
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// createPiDeliveryPortHost 接线行为锁:八个端口的透传语义逐成员断言(pi-host.ts 头注
|
|
2
|
-
//
|
|
2
|
+
// 接线纪律的行为集)——原样透传不加工、订阅返回 pi 退订函数、事件回调只裁剪事件体、
|
|
3
3
|
// sendMessage 恒 { triggerTurn: true }。fake pi/ctx 全内存,无 fs。
|
|
4
4
|
import { describe, expect, it } from "vitest";
|
|
5
5
|
|
|
@@ -62,7 +62,7 @@ describe("createPiDeliveryPortHost 接线(七端口逐成员)", () => {
|
|
|
62
62
|
expect(host.readSessionEntries()).toBe(entries);
|
|
63
63
|
expect(host.isIdle()).toBe(false);
|
|
64
64
|
// [subagent-observability §3.3.7③] hasRunningBackground 已随攒批窗退役删除,
|
|
65
|
-
//
|
|
65
|
+
// 接线结果不再含该成员(结构断言:host 对象无此键)。
|
|
66
66
|
expect("hasRunningBackground" in host).toBe(false);
|
|
67
67
|
});
|
|
68
68
|
|
|
@@ -81,7 +81,7 @@ describe("createPiDeliveryPortHost 接线(七端口逐成员)", () => {
|
|
|
81
81
|
expect(sent[0].options).toEqual({ triggerTurn: true, deliverAs: "steer" });
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
-
it("
|
|
84
|
+
it("订阅类端口注册到 pi.on 对应事件、handler 只收事件体、退订兑现 pi 退订函数", () => {
|
|
85
85
|
const { pi, registered, unsubscribed } = makePi();
|
|
86
86
|
const host = createPiDeliveryPortHost(
|
|
87
87
|
pi as never,
|
|
@@ -113,7 +113,7 @@ describe("createPiDeliveryPortHost 接线(七端口逐成员)", () => {
|
|
|
113
113
|
messageEndHandler({ message: { role: "custom" } });
|
|
114
114
|
expect(received).toEqual({ role: "custom" });
|
|
115
115
|
|
|
116
|
-
// 退订函数 = pi.on 返回的退订(port dispose
|
|
116
|
+
// 退订函数 = pi.on 返回的退订(port dispose 时兑现的契约)
|
|
117
117
|
offMessageEnd();
|
|
118
118
|
expect(unsubscribed).toEqual(["message_end"]);
|
|
119
119
|
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// reconcile-edge.test.ts —— 切回边沿对账转发注册槽(completion-signal-delivery
|
|
2
|
+
// D8② 三入口同构的跨扩展接线件)行为锁:订阅派发 / 多域分发 / 同域覆盖(reload
|
|
3
|
+
// 生命周期)/ 域隔离围栏(单域 throw 不阻断其余域 + 失败清单报告)/ 空槽缺席
|
|
4
|
+
// 语义 / 测试重置。槽挂 globalThis(C-ext-06),跨实例共享由 Symbol.for 键保证。
|
|
5
|
+
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
RESUME_RECONCILE_SLOT_KEY,
|
|
10
|
+
_resetResumeReconcileForTest,
|
|
11
|
+
dispatchResumeReconcile,
|
|
12
|
+
subscribeResumeReconcile,
|
|
13
|
+
type ResumeReconcileContext,
|
|
14
|
+
} from "../reconcile-edge.ts";
|
|
15
|
+
|
|
16
|
+
function makeCtx(): ResumeReconcileContext {
|
|
17
|
+
return {
|
|
18
|
+
sessionManager: {
|
|
19
|
+
getEntries: () => [],
|
|
20
|
+
getSessionFile: () => "/sessions/slot.jsonl",
|
|
21
|
+
getSessionId: () => "sess-slot",
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
_resetResumeReconcileForTest();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
_resetResumeReconcileForTest();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("subscribeResumeReconcile / dispatchResumeReconcile", () => {
|
|
35
|
+
it("订阅后派发:listener 收到 ctx 原样引用", () => {
|
|
36
|
+
const listener = vi.fn();
|
|
37
|
+
subscribeResumeReconcile("session-manager", listener);
|
|
38
|
+
const ctx = makeCtx();
|
|
39
|
+
|
|
40
|
+
const failures = dispatchResumeReconcile(ctx);
|
|
41
|
+
|
|
42
|
+
expect(failures).toEqual([]);
|
|
43
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
44
|
+
expect(listener).toHaveBeenCalledWith(ctx);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("多域订阅全部收到(Map 插入序派发)", () => {
|
|
48
|
+
const calls: string[] = [];
|
|
49
|
+
subscribeResumeReconcile("session-manager", () => calls.push("session-manager"));
|
|
50
|
+
subscribeResumeReconcile("subagent-workflow", () => calls.push("subagent-workflow"));
|
|
51
|
+
|
|
52
|
+
expect(dispatchResumeReconcile(makeCtx())).toEqual([]);
|
|
53
|
+
expect(calls).toEqual(["session-manager", "subagent-workflow"]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("同域重复订阅覆盖前值(extension reload 生命周期:新闭包覆盖旧闭包,旧订阅不再收)", () => {
|
|
57
|
+
const stale = vi.fn();
|
|
58
|
+
const fresh = vi.fn();
|
|
59
|
+
subscribeResumeReconcile("subagent-workflow", stale);
|
|
60
|
+
subscribeResumeReconcile("subagent-workflow", fresh);
|
|
61
|
+
|
|
62
|
+
expect(dispatchResumeReconcile(makeCtx())).toEqual([]);
|
|
63
|
+
expect(stale).not.toHaveBeenCalled();
|
|
64
|
+
expect(fresh).toHaveBeenCalledTimes(1);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("单域 listener throw 不阻断其余域:失败逐域收集返回(派发方 warn 留痕的报告通道)", () => {
|
|
68
|
+
const boom = new Error("listener fence leak");
|
|
69
|
+
const healthy = vi.fn();
|
|
70
|
+
subscribeResumeReconcile("session-manager", () => {
|
|
71
|
+
throw boom;
|
|
72
|
+
});
|
|
73
|
+
subscribeResumeReconcile("subagent-workflow", healthy);
|
|
74
|
+
|
|
75
|
+
const failures = dispatchResumeReconcile(makeCtx());
|
|
76
|
+
|
|
77
|
+
expect(failures).toEqual([{ domain: "session-manager", error: boom }]);
|
|
78
|
+
expect(healthy).toHaveBeenCalledTimes(1);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("空槽派发(两域扩展未启用 / 装配前)→ 空失败清单,不抛(缺席语义非错误)", () => {
|
|
82
|
+
expect(dispatchResumeReconcile(makeCtx())).toEqual([]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("_resetResumeReconcileForTest 清空全部订阅", () => {
|
|
86
|
+
const listener = vi.fn();
|
|
87
|
+
subscribeResumeReconcile("session-manager", listener);
|
|
88
|
+
_resetResumeReconcileForTest();
|
|
89
|
+
|
|
90
|
+
expect(dispatchResumeReconcile(makeCtx())).toEqual([]);
|
|
91
|
+
expect(listener).not.toHaveBeenCalled();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("槽挂 globalThis(C-ext-06:Symbol.for 进程级槽,防 jiti 多实例槽分裂)", () => {
|
|
95
|
+
subscribeResumeReconcile("session-manager", () => {});
|
|
96
|
+
const slot = Reflect.get(globalThis, RESUME_RECONCILE_SLOT_KEY) as {
|
|
97
|
+
listeners: Map<string, unknown>;
|
|
98
|
+
} | undefined;
|
|
99
|
+
expect(slot).toBeDefined();
|
|
100
|
+
expect(slot!.listeners.has("session-manager")).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// reconcile-judgement.test.ts —— 对账补投判据链收编单源的行为锁(原 bash 域
|
|
2
|
+
// notify-reconcile-judgement.test.ts 骨架段 + session-manager / subagent-workflow
|
|
3
|
+
// 域真值表对拍段随判据链收编迁此,三域物理副本退役):三重事实骨架真值表 / 投递
|
|
4
|
+
// 日志幂等键提取(v2 id + collectKey 键集展开 + v1 兼容读 + ack 终局并入 + 畸形
|
|
5
|
+
// 跳过)/ session JSONL 磁盘读侧容错(ENOENT 常态空 / 读错误 warn + 空 / 脏行
|
|
6
|
+
// 单行跳过)。域侧只剩「域事实 → 布尔」映射与编排测试(各域 __tests__)。
|
|
7
|
+
|
|
8
|
+
import { chmodSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
13
|
+
|
|
14
|
+
const { loggerMock } = vi.hoisted(() => ({
|
|
15
|
+
loggerMock: { debug: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
vi.mock("@zhushanwen/pi-extension-logger", () => ({
|
|
19
|
+
getLogger: () => loggerMock,
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
collectJournaledDeliveryIds,
|
|
24
|
+
isReconcileNotifyCandidate,
|
|
25
|
+
type ReconcileNotifyFacts,
|
|
26
|
+
} from "../reconcile-judgement.ts";
|
|
27
|
+
import { readSessionFileEntries } from "../session-journal-read.ts";
|
|
28
|
+
import { readSessionFileEntries as indexExport } from "../index.ts";
|
|
29
|
+
|
|
30
|
+
// 测试 fixture 的通知条目 customType(与 bash 域 BACKGROUND_BASH_CUSTOM_TYPE 同词表
|
|
31
|
+
// ——判据对通知条目形态不敏感,只按结构化字段收集;此处仅作非投递日志条目的占位)。
|
|
32
|
+
const BACKGROUND_BASH_CUSTOM_TYPE = "background-bash";
|
|
33
|
+
|
|
34
|
+
const TASK_ID = "bt-1700000000-abcd12";
|
|
35
|
+
const OTHER_TASK_ID = "bt-1700000000-efgh34";
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
loggerMock.warn.mockClear();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// ── 三重事实判据骨架真值表(跨域单源——bash / session-manager / subagent-workflow
|
|
42
|
+
// 域侧映射进同一组合;原三域真值表对拍段的单点归并)──
|
|
43
|
+
|
|
44
|
+
describe("isReconcileNotifyCandidate 骨架真值表(判据链收编单源)", () => {
|
|
45
|
+
const base: ReconcileNotifyFacts = {
|
|
46
|
+
isTerminal: true,
|
|
47
|
+
excludedByOrigin: false,
|
|
48
|
+
delivered: false,
|
|
49
|
+
journaled: false,
|
|
50
|
+
};
|
|
51
|
+
const truthTable: Array<[Partial<ReconcileNotifyFacts>, boolean, string]> = [
|
|
52
|
+
[{ ...base }, true, "rejected 丢失形态(四事实全命中)→ 候选"],
|
|
53
|
+
[{ isTerminal: false }, false, "在途/未终局 → 非候选"],
|
|
54
|
+
[{ excludedByOrigin: true }, false, "域排除位命中 → 非候选"],
|
|
55
|
+
[{ delivered: true }, false, "送达痕迹命中 → 非候选"],
|
|
56
|
+
[{ journaled: true }, false, "投递日志承载命中 → 非候选"],
|
|
57
|
+
];
|
|
58
|
+
for (const [facts, expected, label] of truthTable) {
|
|
59
|
+
it(label, () => {
|
|
60
|
+
expect(isReconcileNotifyCandidate({ ...base, ...facts })).toBe(expected);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// ── 投递日志幂等键提取(事实三事实源;原 judgement.test.ts「journal entry shapes」
|
|
66
|
+
// 段 + session-manager/workflow 域 collectKey 展开用例的归并)──
|
|
67
|
+
|
|
68
|
+
/** 投递日志 outbox entry(journal v2 落盘形态——port 受理前 appendEntry 写入)。 */
|
|
69
|
+
function journaledOutboxEntry(id: string, dataOverrides: Record<string, unknown> = {}) {
|
|
70
|
+
return {
|
|
71
|
+
type: "custom",
|
|
72
|
+
customType: "delivery-port-outbox",
|
|
73
|
+
data: {
|
|
74
|
+
v: 2,
|
|
75
|
+
id,
|
|
76
|
+
timing: "steer",
|
|
77
|
+
payload: {
|
|
78
|
+
kind: "custom",
|
|
79
|
+
customType: BACKGROUND_BASH_CUSTOM_TYPE,
|
|
80
|
+
content: `notify ${id}`,
|
|
81
|
+
display: true,
|
|
82
|
+
},
|
|
83
|
+
recordedAt: 1,
|
|
84
|
+
...dataOverrides,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe("collectJournaledDeliveryIds: journal entry shapes (fact-three source, D2)", () => {
|
|
90
|
+
it("v2 outbox entry hits via data.id", () => {
|
|
91
|
+
const ids = collectJournaledDeliveryIds([journaledOutboxEntry(TASK_ID)]);
|
|
92
|
+
expect([...ids]).toEqual([TASK_ID]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("v2 outbox collectKey 键集展开(承载反查——条目 id ≠ 业务键的承载形态,session-manager 合批成员键 / workflow 裸 runId 双源)", () => {
|
|
96
|
+
const ids = collectJournaledDeliveryIds([
|
|
97
|
+
journaledOutboxEntry("batch-head-key", { collectKey: [TASK_ID, OTHER_TASK_ID] }),
|
|
98
|
+
]);
|
|
99
|
+
expect([...ids].sort()).toEqual(["batch-head-key", TASK_ID, OTHER_TASK_ID].sort());
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("legacy v1 ledger entry (subagent-bg-notify-ledger) hits via notifyId mapping", () => {
|
|
103
|
+
const ids = collectJournaledDeliveryIds([
|
|
104
|
+
{
|
|
105
|
+
type: "custom",
|
|
106
|
+
customType: "subagent-bg-notify-ledger",
|
|
107
|
+
data: { v: 1, notifyId: TASK_ID, content: "c", record: {} },
|
|
108
|
+
},
|
|
109
|
+
]);
|
|
110
|
+
expect([...ids]).toEqual([TASK_ID]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("ack terminal entry hits (defensive union with outbox in the same pass)", () => {
|
|
114
|
+
const ids = collectJournaledDeliveryIds([
|
|
115
|
+
{
|
|
116
|
+
type: "custom",
|
|
117
|
+
customType: "delivery-port-ack",
|
|
118
|
+
data: { v: 2, id: TASK_ID, outcome: "delivered" },
|
|
119
|
+
},
|
|
120
|
+
]);
|
|
121
|
+
expect([...ids]).toEqual([TASK_ID]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("malformed / non-journal entries are skipped (tolerance aligned with port scanJournal)", () => {
|
|
125
|
+
const ids = collectJournaledDeliveryIds([
|
|
126
|
+
null,
|
|
127
|
+
42,
|
|
128
|
+
{ type: "custom_message", customType: BACKGROUND_BASH_CUSTOM_TYPE, content: `x ${TASK_ID}`, details: { taskId: TASK_ID } }, // 通知条目——非投递日志
|
|
129
|
+
{ type: "custom", customType: "delivery-port-outbox", data: { v: 2 } }, // data 畸形
|
|
130
|
+
{ type: "custom", customType: "delivery-port-outbox" }, // data 缺失
|
|
131
|
+
journaledOutboxEntry(TASK_ID),
|
|
132
|
+
]);
|
|
133
|
+
expect([...ids]).toEqual([TASK_ID]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("multiple ids across entries collect all", () => {
|
|
137
|
+
const ids = collectJournaledDeliveryIds([
|
|
138
|
+
journaledOutboxEntry(TASK_ID),
|
|
139
|
+
journaledOutboxEntry(OTHER_TASK_ID),
|
|
140
|
+
]);
|
|
141
|
+
expect([...ids].sort()).toEqual([TASK_ID, OTHER_TASK_ID].sort());
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
// ── session JSONL 磁盘读侧容错(原三域 readSessionFileEntries 容错段的单点归并;
|
|
146
|
+
// 口径登记见 session-journal-read.ts 函数头注)──
|
|
147
|
+
|
|
148
|
+
describe("readSessionFileEntries(磁盘读侧容错)", () => {
|
|
149
|
+
const DATA_DIR = mkdtempSync(join(tmpdir(), "port-readfile-"));
|
|
150
|
+
|
|
151
|
+
it("文件缺失(ENOENT,首条对话消息前常态)→ 空数组(静默,无 warn)", () => {
|
|
152
|
+
expect(readSessionFileEntries(join(DATA_DIR, "no-such-file.jsonl"))).toEqual([]);
|
|
153
|
+
expect(loggerMock.warn).not.toHaveBeenCalled();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("脏行跳过、合法行解析(JSONL 逐行容错)", () => {
|
|
157
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
158
|
+
const file = join(DATA_DIR, "dirty.jsonl");
|
|
159
|
+
writeFileSync(
|
|
160
|
+
file,
|
|
161
|
+
['{"type":"custom","customType":"x"}', "not-json", "", '{"type":"message"}'].join("\n"),
|
|
162
|
+
);
|
|
163
|
+
const entries = readSessionFileEntries(file);
|
|
164
|
+
expect(entries).toHaveLength(2);
|
|
165
|
+
expect(entries[0]).toEqual({ type: "custom", customType: "x" });
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("不可读文件(chmod 0000)→ 空 + warn(宽容口径:磁盘事实不可得 = 无条目)", () => {
|
|
169
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
170
|
+
const file = join(DATA_DIR, "locked.jsonl");
|
|
171
|
+
writeFileSync(file, "{}\n");
|
|
172
|
+
chmodSync(file, 0o000);
|
|
173
|
+
try {
|
|
174
|
+
expect(readSessionFileEntries(file)).toEqual([]);
|
|
175
|
+
expect(loggerMock.warn).toHaveBeenCalledWith(
|
|
176
|
+
expect.stringContaining("disk entries treated as empty"),
|
|
177
|
+
expect.anything(),
|
|
178
|
+
);
|
|
179
|
+
} finally {
|
|
180
|
+
chmodSync(file, 0o644);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("barrel 导出与实现模块同一符号(单源单实例)", () => {
|
|
185
|
+
expect(indexExport).toBe(readSessionFileEntries);
|
|
186
|
+
});
|
|
187
|
+
});
|
package/src/delivery-port.ts
CHANGED
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
// send 内保留第二道防线(标记检查,覆盖提交同步链与 compaction 边沿交叠的残余窗口);
|
|
46
46
|
// 标记读取无异常面(原 isIdle 探测的保守 busy 吞咽随预检移除)。
|
|
47
47
|
//
|
|
48
|
-
// ── collect
|
|
48
|
+
// ── collect 挂起行为(result-collect 设计 §3.1②)──────────────
|
|
49
49
|
// 批量收取的分流发生在提交编排层(deliver 入口):条目照常受理前落盘(I2 耐久不
|
|
50
50
|
// 变),随后按「collectKey 键集 ∩ 在途等待集 ≠ ∅」分流——命中 → 挂起缓冲(纯内存,
|
|
51
51
|
// 不进投递内核)+ onCollectArrival 唤醒等待器;未命中 → push 现状零变化。挂起不独立
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
// collect 阻塞期不可达(设计 §3.1 [重绑] 登记),dispose 只做在途等待器 warn 留痕
|
|
57
57
|
// 不变量检查,不定义唤醒与返回语义。
|
|
58
58
|
//
|
|
59
|
-
// ──
|
|
59
|
+
// ── 耐久职责的分工(为什么 store.persist 是 no-op)─────────────
|
|
60
60
|
// outbox 权威 = 成员记录(deliver 入口 appendEntry,先于受理——「耐久承诺先于投递
|
|
61
61
|
// 尝试」内核契约由该同步时序等价兑现:appendEntry 抛错 = 承诺未落 → deliver 返回
|
|
62
62
|
// rejected("persist-failed"),不进投递流程)。内核侧 store.persist 因此有意 no-op:
|
|
@@ -126,10 +126,22 @@ function getSlot(): DeliveryPortSlot {
|
|
|
126
126
|
* /new 的 session_start)先 dispose 旧实例再绑定新实例(dispose 旧实例 + 重建
|
|
127
127
|
* ——内核态按 session 重建,防跨 session 重投)。
|
|
128
128
|
* 调用方随后应调 handle.recover() 完成差集重放(装配点接线)。
|
|
129
|
+
*
|
|
130
|
+
* 出站交接(重绑双投窗口封住):dispose 前取走前驱「in-flight(已交递 pi,注入由
|
|
131
|
+
* pi 侧异步完成)」的已受理 id 集合交接给后继预占——session_start 对同会话连发两次
|
|
132
|
+
* (真机实证,10ms 级间隔)时,前驱在两次 bind 间隙受理并出站的条目(如 subagent
|
|
133
|
+
* 域对账补投)尚未产生送达凭据,后继构造期扫描只见「outbox 无凭据」→ recover 重放
|
|
134
|
+
* = 同一条通知双投(A4 真机实证 recoveryReplays=1 + notify=2)。in-flight 条目由
|
|
135
|
+
* pi 侧必达注入,重放对它们是纯重复;预占后后继 recover 跳过、条目留 state.outbox,
|
|
136
|
+
* 凭据若最终未落地由后续 rebind 的重放兜底(跨进程重启无前驱可交接,崩溃恢复通道
|
|
137
|
+
* 不变)。queued / failed(未出站——dispose 丢弃)不在交接集合,后继重放恢复语义
|
|
138
|
+
* 不变。
|
|
129
139
|
*/
|
|
130
140
|
export function bindDeliveryPort(host: DeliveryPortHost): DeliveryPortHandle {
|
|
131
|
-
getSlot().current
|
|
132
|
-
const
|
|
141
|
+
const predecessor = getSlot().current;
|
|
142
|
+
const handoff = predecessor?.takeInFlightHandoff?.();
|
|
143
|
+
predecessor?.dispose();
|
|
144
|
+
const handle = createDeliveryPort(host, { predecessorInFlightIds: handoff });
|
|
133
145
|
getSlot().current = handle;
|
|
134
146
|
return handle;
|
|
135
147
|
}
|
|
@@ -145,7 +157,7 @@ export function _resetDeliveryPortForTest(): void {
|
|
|
145
157
|
getSlot().current = undefined;
|
|
146
158
|
}
|
|
147
159
|
|
|
148
|
-
// ─── 子系统装配(createDeliveryPort
|
|
160
|
+
// ─── 子系统装配(createDeliveryPort 的两个内聚子系统:内核接线 / collect 挂起)───
|
|
149
161
|
//
|
|
150
162
|
// createDeliveryPort 是装配壳:持提交侧闭包态(双缓冲 / hold 标志 / 幂等索引),两个
|
|
151
163
|
// 子系统经工厂构造、以 deps 注入读端口闭包态——可变标志经 getter 出站时点读取(装配
|
|
@@ -157,7 +169,7 @@ function emitBucketLog(host: DeliveryPortHost, bucket: keyof DeliveryPortMetrics
|
|
|
157
169
|
host.logger.warn(`delivery-port bucket [${bucket}]`, { detail: { total } });
|
|
158
170
|
}
|
|
159
171
|
|
|
160
|
-
/**
|
|
172
|
+
/** 内核装配对端口闭包态的最小依赖集。 */
|
|
161
173
|
interface KernelAssemblyDeps {
|
|
162
174
|
host: DeliveryPortHost;
|
|
163
175
|
state: JournalState;
|
|
@@ -180,11 +192,13 @@ function createKernelAssembly(deps: KernelAssemblyDeps): { kernel: ReturnType<ty
|
|
|
180
192
|
|
|
181
193
|
const store: DeliveryStore = {
|
|
182
194
|
persist(_record: DeliveryStoreRecord): void {
|
|
183
|
-
// 有意 no-op:port 半区落盘在 deliver
|
|
195
|
+
// 有意 no-op:port 半区落盘在 deliver 提交层前置完成(文件头「耐久职责的分工」)。
|
|
184
196
|
},
|
|
185
197
|
ack(id: string): void {
|
|
186
|
-
// 内核终局(delivered / cancelled)→
|
|
187
|
-
//
|
|
198
|
+
// 内核终局(delivered / cancelled)→ 持久终局列。磁盘权威:appendEntry 抛错 =
|
|
199
|
+
// 持久终局未写入 → state.ackedIds 不登记(内存派生数据只在落盘成功后登记),
|
|
200
|
+
// 错误上抛由内核包装分型错误 DeliveryAckPersistError——内核不登记内存终态、条目
|
|
201
|
+
// 留守非终态(恢复后可能重投,at-least-once 显式接受),本层不吞咽。
|
|
188
202
|
host.appendEntry(ACK_CUSTOM_TYPE, { v: 2, id });
|
|
189
203
|
state.ackedIds.add(id);
|
|
190
204
|
},
|
|
@@ -267,7 +281,7 @@ function createKernelAssembly(deps: KernelAssemblyDeps): { kernel: ReturnType<ty
|
|
|
267
281
|
return { kernel: createDelivery(kernelPort, kernelConfig) };
|
|
268
282
|
}
|
|
269
283
|
|
|
270
|
-
/** collect
|
|
284
|
+
/** collect 挂起子系统对端口闭包态的最小依赖集(result-collect 设计 §3.1②)。 */
|
|
271
285
|
interface CollectFaceDeps {
|
|
272
286
|
host: DeliveryPortHost;
|
|
273
287
|
state: JournalState;
|
|
@@ -279,7 +293,7 @@ interface CollectFaceDeps {
|
|
|
279
293
|
routeDirect(records: readonly JournalRecord[]): void;
|
|
280
294
|
}
|
|
281
295
|
|
|
282
|
-
/** collect
|
|
296
|
+
/** collect 挂起子系统对端口的路由接口(deliver 分流判定 / handle 装配 / dispose 清理)。 */
|
|
283
297
|
interface CollectFace {
|
|
284
298
|
/** deliver 分流判定:键集 ∩ 在途等待集 ≠ ∅。 */
|
|
285
299
|
hasWaiterFor(collectKeys: readonly string[]): boolean;
|
|
@@ -289,12 +303,12 @@ interface CollectFace {
|
|
|
289
303
|
consume(): CollectedEntry[];
|
|
290
304
|
collectRelease(): void;
|
|
291
305
|
onCollectArrival(cb: () => void): () => void;
|
|
292
|
-
/**
|
|
306
|
+
/** 清空挂起行为的三个集合;返回清空前的在途量(dispose 不变量 warn 的判定与数据)。 */
|
|
293
307
|
dispose(): { waiting: number; suspended: number };
|
|
294
308
|
}
|
|
295
309
|
|
|
296
310
|
/**
|
|
297
|
-
* collect
|
|
311
|
+
* collect 挂起行为(result-collect 设计 §3.1②,纯内存分流态,投递日志即耐久层):
|
|
298
312
|
* 登记等待集 / 挂起缓冲 / 到达唤醒 + consume 收取 + collectRelease 解散,自成
|
|
299
313
|
* 子系统;对 push 管线只有两个触点——deliver 分流(hasWaiterFor / suspend)与
|
|
300
314
|
* 解散直通(routeDirect 回调)。
|
|
@@ -334,6 +348,11 @@ function createCollectFace(deps: CollectFaceDeps): CollectFace {
|
|
|
334
348
|
};
|
|
335
349
|
}
|
|
336
350
|
|
|
351
|
+
/** 注销 key 的 outcome(结清视图优先——权威通道 resolveSettlementReason;legacy status 兜底)。 */
|
|
352
|
+
function unregisterOutcomeOf(pending: PendingBaseline, key: string) {
|
|
353
|
+
return pending.unregisterSettlements.get(key) ?? pending.unregisterStatus.get(key);
|
|
354
|
+
}
|
|
355
|
+
|
|
337
356
|
/**
|
|
338
357
|
* 登记在途等待集 + 对每个 key 按五态判据定态(同步段:JS 单线程内无交错窗口,
|
|
339
358
|
* 与 deliver 分流读同一等待集——I3 等待集原子性)。
|
|
@@ -352,12 +371,7 @@ function createCollectFace(deps: CollectFaceDeps): CollectFace {
|
|
|
352
371
|
* 曾注册集的补集。不登记等待集(防空等到 timeout 挂死)、不虚构终态;工具侧
|
|
353
372
|
* 读本态组装 errors(不再伪装 waiting——端口是唯一未知判定点,词表诚实表达)。
|
|
354
373
|
*/
|
|
355
|
-
|
|
356
|
-
function unregisterOutcomeOf(pending: PendingBaseline, key: string) {
|
|
357
|
-
return pending.unregisterSettlements.get(key) ?? pending.unregisterStatus.get(key);
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
function collectArm(keys: string[]): CollectSnapshot {
|
|
374
|
+
function collectArm(keys: string[]): CollectSnapshot {
|
|
361
375
|
if (deps.isDisposed()) throw new Error("collectArm on disposed delivery port");
|
|
362
376
|
const pending = scanPendingBaseline(host.readSessionEntries());
|
|
363
377
|
const snapshot: CollectSnapshot = [];
|
|
@@ -491,7 +505,10 @@ function collectArm(keys: string[]): CollectSnapshot {
|
|
|
491
505
|
* 构造投递端口(内核装配 + 提交侧机制 + 回执订阅 + 重放)。
|
|
492
506
|
* 事件订阅在构造期建立、dispose 时退订——构造即生效,不依赖调用方再接线。
|
|
493
507
|
*/
|
|
494
|
-
export function createDeliveryPort(
|
|
508
|
+
export function createDeliveryPort(
|
|
509
|
+
host: DeliveryPortHost,
|
|
510
|
+
options?: { predecessorInFlightIds?: ReadonlySet<string> },
|
|
511
|
+
): DeliveryPortHandle {
|
|
495
512
|
const scan = scanJournal(host.readSessionEntries(), Date.now());
|
|
496
513
|
const state = new JournalState(scan);
|
|
497
514
|
const buckets: DeliveryPortMetrics = { settleRejected: 0, recoveryReplays: 0 };
|
|
@@ -505,6 +522,10 @@ export function createDeliveryPort(host: DeliveryPortHost): DeliveryPortHandle {
|
|
|
505
522
|
/** 已受理 id 索引(deliver 幂等预检的全窗口快路径;内核三级判重对已入内核条目
|
|
506
523
|
* 兜底)。仅增量添加,无淘汰——量级 = 每 session 通知总量(数十~数百)。 */
|
|
507
524
|
const seenIds = new Set<string>();
|
|
525
|
+
// 前驱出站交接预占(重绑双投窗口封住,见 bindDeliveryPort 头注):预占进 seenIds
|
|
526
|
+
// 同时封住 recover 重放与同 id 再 deliver 两条路径;条目本身仍在 state.outbox
|
|
527
|
+
// (构造期扫描收录),凭据最终未落地时由后续 rebind 重放兜底。
|
|
528
|
+
for (const id of options?.predecessorInFlightIds ?? []) seenIds.add(id);
|
|
508
529
|
/** merge-commit 期标志:默认通道组逐条提交内核期间 hold 注入,使成员聚合为内核
|
|
509
530
|
* queued 集,最后 kernel.flush() 一次合批出站(见文件头分组放行段)。 */
|
|
510
531
|
let mergeCommitting = false;
|
|
@@ -521,7 +542,7 @@ export function createDeliveryPort(host: DeliveryPortHost): DeliveryPortHandle {
|
|
|
521
542
|
/**
|
|
522
543
|
* 显式覆写的失败保留策略(deliver opts.onFailure → submitToKernel 的单次传递):
|
|
523
544
|
* 条目在提交缓冲期(compaction 挂起等提交期悬置;攒批窗已删 §3.3.7③)暂存 id → policy,提交内核时
|
|
524
|
-
* 消费并摘除。不进 JournalRecord / outbox schema(§3.7
|
|
545
|
+
* 消费并摘除。不进 JournalRecord / outbox schema(§3.7 耐久职责行 schema 定形,
|
|
525
546
|
* 策略是提交时选项非账目字段)。重放重建的条目不在册(缺省 retain):forget
|
|
526
547
|
* 条目首败即 discard 落终局列、不在重放差集;「首败前崩溃」的残留条目重放按
|
|
527
548
|
* retain 重投(通知域安全侧,多留账不丢不重)。
|
|
@@ -543,7 +564,7 @@ export function createDeliveryPort(host: DeliveryPortHost): DeliveryPortHandle {
|
|
|
543
564
|
isCompacting: () => compacting,
|
|
544
565
|
});
|
|
545
566
|
|
|
546
|
-
// ─── collect
|
|
567
|
+
// ─── collect 挂起子系统(result-collect 设计 §3.1②;实现见 createCollectFace)───
|
|
547
568
|
// routeDirect = 解散等待集后未收条目的直通放行(进 pumpDirect 分组放行管线——
|
|
548
569
|
// 边沿即投,攒批窗已删 §3.3.7③,不逐条唤醒泵)。
|
|
549
570
|
const collectFace = createCollectFace({
|
|
@@ -694,8 +715,13 @@ export function createDeliveryPort(host: DeliveryPortHost): DeliveryPortHandle {
|
|
|
694
715
|
): void {
|
|
695
716
|
// collect 挂起分流(result-collect 设计 §3.1②,先于放行):键集 ∩ 在途
|
|
696
717
|
// 等待集 ≠ ∅ → 挂起缓冲(不进内核,push 管线零感知)+ 唤醒等待器;否则 push
|
|
697
|
-
//
|
|
718
|
+
// 现状零变化。分流命中 debug 留痕:挂起条目对 push 读面(对话流/事件流)完全
|
|
719
|
+
// 不可见,无留痕时「受理成功但消息未出站」无从与下游断裂区分(A15 排障
|
|
720
|
+
// 9 轮迷路于此)。
|
|
698
721
|
if (collectKeys !== undefined && collectKeys.length > 0 && collectFace.hasWaiterFor(collectKeys)) {
|
|
722
|
+
host.logger.debug("delivery-port accepted entry suspended by collect waiter (redelivery deferred until consume/collectRelease)", {
|
|
723
|
+
detail: { id, collectKey: [...collectKeys] },
|
|
724
|
+
});
|
|
699
725
|
collectFace.suspend(id, record);
|
|
700
726
|
seenIds.add(id);
|
|
701
727
|
return;
|
|
@@ -762,7 +788,7 @@ export function createDeliveryPort(host: DeliveryPortHost): DeliveryPortHandle {
|
|
|
762
788
|
/**
|
|
763
789
|
* 单成员提交内核。返回值处置:swallowed = 已终局(幂等命中,丢弃即正确——该
|
|
764
790
|
* 通知已送达过);rejected 理论不可达(store.persist no-op 恒成功、payload 恒
|
|
765
|
-
* custom、放行前已挡 disposed),warn 防御留痕;accepted
|
|
791
|
+
* custom、放行前已挡 disposed),warn 防御留痕;accepted 出站环节由内核托管
|
|
766
792
|
* (retain 留守 → settled 边沿 / compaction 边沿重投)。
|
|
767
793
|
*/
|
|
768
794
|
function submitToKernel(rec: JournalRecord): void {
|
|
@@ -797,6 +823,21 @@ export function createDeliveryPort(host: DeliveryPortHost): DeliveryPortHandle {
|
|
|
797
823
|
|
|
798
824
|
// ─── 重放(session_start 差集重放)─────────────────────────
|
|
799
825
|
|
|
826
|
+
/**
|
|
827
|
+
* 重绑交接面(bindDeliveryPort 专用,dispose 前调用):内核 entriesFull 快照中
|
|
828
|
+
* state=in-flight 的已受理 id 集。in-flight = 已交递 pi(sendMessage 完成,注入
|
|
829
|
+
* 由 pi 侧异步完成)——pi 持有必达,后继重放对它们构成同条通知双投(A4 真机实证
|
|
830
|
+
* recoveryReplays=1 + notify=2),交接预占封住;queued(merge-commit-hold 残留)
|
|
831
|
+
* 与 failed(retain 留守)未出站——dispose 丢弃后由后继重放恢复,不在交接集合。
|
|
832
|
+
*/
|
|
833
|
+
function takeInFlightHandoff(): ReadonlySet<string> {
|
|
834
|
+
const handoff = new Set<string>();
|
|
835
|
+
for (const entry of kernel.entriesFull().active) {
|
|
836
|
+
if (entry.state === "in-flight") handoff.add(entry.id);
|
|
837
|
+
}
|
|
838
|
+
return handoff;
|
|
839
|
+
}
|
|
840
|
+
|
|
800
841
|
function recover(): number {
|
|
801
842
|
if (disposed) return 0;
|
|
802
843
|
let replayed = 0;
|
|
@@ -862,6 +903,7 @@ export function createDeliveryPort(host: DeliveryPortHost): DeliveryPortHandle {
|
|
|
862
903
|
metrics,
|
|
863
904
|
flush,
|
|
864
905
|
dispose,
|
|
906
|
+
takeInFlightHandoff,
|
|
865
907
|
collectArm: collectFace.collectArm,
|
|
866
908
|
consume: collectFace.consume,
|
|
867
909
|
collectRelease: collectFace.collectRelease,
|
package/src/index.ts
CHANGED
|
@@ -53,4 +53,21 @@ export {
|
|
|
53
53
|
getDeliveryPort,
|
|
54
54
|
} from "./delivery-port.ts";
|
|
55
55
|
|
|
56
|
+
export {
|
|
57
|
+
RESUME_RECONCILE_SLOT_KEY,
|
|
58
|
+
_resetResumeReconcileForTest,
|
|
59
|
+
dispatchResumeReconcile,
|
|
60
|
+
subscribeResumeReconcile,
|
|
61
|
+
type ResumeReconcileContext,
|
|
62
|
+
type ResumeReconcileListener,
|
|
63
|
+
} from "./reconcile-edge.ts";
|
|
64
|
+
|
|
65
|
+
export {
|
|
66
|
+
collectJournaledDeliveryIds,
|
|
67
|
+
isReconcileNotifyCandidate,
|
|
68
|
+
type ReconcileNotifyFacts,
|
|
69
|
+
} from "./reconcile-judgement.ts";
|
|
70
|
+
|
|
71
|
+
export { readSessionFileEntries, resolveReconcileFactEntries } from "./session-journal-read.ts";
|
|
72
|
+
|
|
56
73
|
export { createPiDeliveryPortHost, type CreatePiDeliveryPortHostOptions } from "./pi-host.ts";
|
package/src/journal.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// src/journal.ts
|
|
2
2
|
//
|
|
3
|
-
// 投递日志(delivery journal,设计 §3.3 词条 / §3.7
|
|
3
|
+
// 投递日志(delivery journal,设计 §3.3 词条 / §3.7 耐久职责行)的 entry schema 与
|
|
4
4
|
// 扫描/映射函数。schema 定形与 v1 兼容读映射 = 设计「待验证检查点 1」(unified-
|
|
5
5
|
// delivery §7)落点,由 src/__tests__/journal.test.ts 单测锁定。
|
|
6
6
|
//
|
|
@@ -56,7 +56,7 @@ export interface OutboxEntryDataV1 { // oe-exempt:20261017:framework:投递日
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
|
-
* v2 outbox entry data(待验证检查点 1 定形;设计 §3.7
|
|
59
|
+
* v2 outbox entry data(待验证检查点 1 定形;设计 §3.7 耐久职责行字段集)。
|
|
60
60
|
* payload 为调用方业务原样(不含 deliveryId——凭据 id 由端口在注入前统一写入
|
|
61
61
|
* details,重放重投时同样注入;journal 记录保持业务原样使记录与注入形态解耦)。
|
|
62
62
|
*/
|
|
@@ -113,7 +113,7 @@ export interface JournalRecord { // oe-exempt:20261017:framework:投递日志记
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
/** 扫描结果(构造期全量 + 运行时增量维护的基线形态)。 */
|
|
116
|
-
export interface JournalScan { // oe-exempt:20261017:framework:投递日志差集扫描契约(session_start
|
|
116
|
+
export interface JournalScan { // oe-exempt:20261017:framework:投递日志差集扫描契约(session_start 重放)
|
|
117
117
|
/** outbox 记录(id → record;后写覆盖——同 id 多条 entry 取最新)。 */
|
|
118
118
|
outbox: Map<string, JournalRecord>;
|
|
119
119
|
/** 终局 id(ack ∪ discard)。 */
|
package/src/pi-host.ts
CHANGED
|
@@ -5,11 +5,11 @@
|
|
|
5
5
|
//
|
|
6
6
|
// 接线纪律:
|
|
7
7
|
// - 原样透传(appendEntry / getEntries / isIdle / sendMessage),不加工不缓存;
|
|
8
|
-
// -
|
|
8
|
+
// - 订阅类端口(message_end / agent_settled / session_before_compact / session_compact /
|
|
9
9
|
// session_compact_failed)透传 handler 并返回 pi 的退订函数(pi 1.0.0 起 on 返回
|
|
10
10
|
// unsubscribe,port dispose 时兑现——防 session_start 重绑累积死 handler);
|
|
11
11
|
// - 事件回调的入参裁剪:port 只消费事件的事件体(message 等),ctx 参数不透传
|
|
12
|
-
// (port
|
|
12
|
+
// (port 的宿主状态只经构造期注入的 ctx);
|
|
13
13
|
// - 注入 sendMessage 恒 { triggerTurn: true }(custom 消息 append 后若无 turn 则成
|
|
14
14
|
// 死文——deliverAs 两档控制队列位置,triggerTurn 控制空闲唤醒,两者正交)。
|
|
15
15
|
|
|
@@ -18,7 +18,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
|
|
|
18
18
|
import type { DeliveryPortHost } from "./types.ts";
|
|
19
19
|
|
|
20
20
|
/** 工厂选项:装配观测差异参数化。 */
|
|
21
|
-
export interface CreatePiDeliveryPortHostOptions { // oe-exempt:20261017:framework:pi
|
|
21
|
+
export interface CreatePiDeliveryPortHostOptions { // oe-exempt:20261017:framework:pi 半区装配契约接口(宿主注入侧,适配层持久契约)
|
|
22
22
|
/** warn/debug 归因标签(建议装配扩展名,如 "base-tool-enhance")。 */
|
|
23
23
|
component: string;
|
|
24
24
|
/** 降级与诊断出口(@zhushanwen/pi-extension-logger 的 ExtensionLogger)。 */
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// src/reconcile-edge.ts
|
|
2
|
+
//
|
|
3
|
+
// 切回边沿对账转发注册槽(completion-signal-delivery D8② 三入口同构的跨扩展
|
|
4
|
+
// 接线件):`__taiji_bg_reconcile__` 内部命令(切回补强边沿——taiji runtime 在
|
|
5
|
+
// getCommands 时 fire-and-forget 触发,无节流——原 60s per-session 节流窗已随
|
|
6
|
+
// ADR-0122 防御机制清查退役,高频防护由订阅域对账预检判据早退承担;覆盖
|
|
7
|
+
// 「切走切回同进程重挂接、session_start 不重发」场景)的注册方是
|
|
8
|
+
// @zhushanwen/pi-base-tool-enhance,而 session-manager /
|
|
9
|
+
// subagent-workflow 两域的对账编排在各自扩展内装配(deps 需当前 pi / session
|
|
10
|
+
// entries / 域闭包状态,外部无法直构)。subagent 域的同类转发走 SubagentService
|
|
11
|
+
// 进程单例(base-tool-enhance 直依 subagent-core,A5 先例);两域与
|
|
12
|
+
// base-tool-enhance 无依赖边(跨扩展依赖通道不存在),本槽作为三方共同依赖包
|
|
13
|
+
// 内的进程级转发点补上该通道:两域 extension load 时订阅,切回 handler 派发。
|
|
14
|
+
//
|
|
15
|
+
// 生命周期(与 pi.on 装配期订阅的 RL13 论证同款):订阅点 = extension load
|
|
16
|
+
// 装配期,同域 key 重复订阅覆盖前值——同进程 session 替换会重建 eventBus 并
|
|
17
|
+
// 重新 load extension,新实例的订阅覆盖旧实例(旧闭包捕获的 stale pi 随之被
|
|
18
|
+
// 替换),无死 handler 累积。槽本体挂 globalThis(C-ext-06:Symbol.for +
|
|
19
|
+
// 进程级槽,防 jiti 多实例模块图槽分裂——delivery-port.ts 的
|
|
20
|
+
// DELIVERY_PORT_SLOT_KEY 同款形态)。
|
|
21
|
+
|
|
22
|
+
/** 切回边沿上下文(派发方 handler ctx 的结构化最小子集——本包不依赖 pi 类型,
|
|
23
|
+
* 与 ExtensionCommandContext.sessionManager 结构兼容;getEntries / getSessionFile /
|
|
24
|
+
* getSessionId 三成员即两域对账 deps 的全部 ctx 侧输入)。 */
|
|
25
|
+
export interface ResumeReconcileContext { // oe-exempt:20261102:framework:跨扩展契约接口(delivery-port 定义、两消费域结构兼容锚,非单实现抽象)
|
|
26
|
+
sessionManager: {
|
|
27
|
+
getEntries(): readonly unknown[];
|
|
28
|
+
getSessionFile(): string | undefined;
|
|
29
|
+
getSessionId(): string;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 域订阅回调(切回边沿 → 该域对账编排;域侧自带围栏——对账编排内部全量
|
|
34
|
+
* catch,失败 warn 留痕后下一对账边沿幂等重查,异常原则上不向派发方传播,
|
|
35
|
+
* 本侧围栏只兜「域侧围栏漏网」的防御位)。 */
|
|
36
|
+
export type ResumeReconcileListener = (ctx: ResumeReconcileContext) => void;
|
|
37
|
+
|
|
38
|
+
/** 进程级槽键(C-ext-06:Symbol.for + globalThis,防 jiti 多实例槽分裂)。 */
|
|
39
|
+
export const RESUME_RECONCILE_SLOT_KEY = Symbol.for(
|
|
40
|
+
"@zhushanwen/pi-delivery-port.resumeReconcile",
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
type ResumeReconcileSlot = { listeners: Map<string, ResumeReconcileListener> };
|
|
44
|
+
|
|
45
|
+
function getSlot(): ResumeReconcileSlot {
|
|
46
|
+
// globalThis 无 symbol 索引签名,Reflect 安全读写(delivery-port.ts getSlot 同款)
|
|
47
|
+
let slot = Reflect.get(globalThis, RESUME_RECONCILE_SLOT_KEY) as
|
|
48
|
+
| ResumeReconcileSlot
|
|
49
|
+
| undefined;
|
|
50
|
+
if (!slot) {
|
|
51
|
+
slot = { listeners: new Map() };
|
|
52
|
+
Reflect.set(globalThis, RESUME_RECONCILE_SLOT_KEY, slot);
|
|
53
|
+
}
|
|
54
|
+
return slot;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 域订阅(extension load 装配期调用;同域重复订阅覆盖前值——reload 后新闭包
|
|
59
|
+
* 覆盖旧闭包,防 stale pi 转发)。domain = 域名单(如 "session-manager" /
|
|
60
|
+
* "subagent-workflow"),仅作槽内键与失败报告的归因标签。
|
|
61
|
+
*/
|
|
62
|
+
export function subscribeResumeReconcile(
|
|
63
|
+
domain: string,
|
|
64
|
+
listener: ResumeReconcileListener,
|
|
65
|
+
): void {
|
|
66
|
+
getSlot().listeners.set(domain, listener);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 切回边沿派发(`__taiji_bg_reconcile__` handler 调用):逐域同步调用,单个
|
|
71
|
+
* listener 异常不阻断其余域;失败逐域收集返回,由派发方 warn 留痕(本包无
|
|
72
|
+
* logger 依赖——错误语义不吞:返回清单即报告通道)。无订阅(两域扩展未启用 /
|
|
73
|
+
* 装配前)= 空清单,缺席语义非错误(与 SubagentService 单例缺席语义同族)。
|
|
74
|
+
*/
|
|
75
|
+
export function dispatchResumeReconcile(
|
|
76
|
+
ctx: ResumeReconcileContext,
|
|
77
|
+
): Array<{ domain: string; error: unknown }> {
|
|
78
|
+
const failures: Array<{ domain: string; error: unknown }> = [];
|
|
79
|
+
for (const [domain, listener] of getSlot().listeners) {
|
|
80
|
+
try {
|
|
81
|
+
listener(ctx);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
failures.push({ domain, error: err });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return failures;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 测试隔离:清空全部域订阅(生产禁用——订阅生命周期与 extension load 一致)。 */
|
|
90
|
+
export function _resetResumeReconcileForTest(): void {
|
|
91
|
+
getSlot().listeners.clear();
|
|
92
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// src/reconcile-judgement.ts
|
|
2
|
+
//
|
|
3
|
+
// 对账补投判据的共享纯函数层(completion-signal-delivery D2 三重事实判据骨架 +
|
|
4
|
+
// 投递日志幂等键提取)——无 I/O、无 pi 依赖。原角色载体 = base-tool-enhance
|
|
5
|
+
// background/notify-reconcile-judgement.ts(u1 抽出的「跨域共享语义落点」);
|
|
6
|
+
// 判据链曾以「域侧同构落位 + 真值表测试逐行对拍」形态在 bash / session-manager /
|
|
7
|
+
// subagent-workflow 三域各持物理副本(subagent-core 受 core ← extensions 依赖
|
|
8
|
+
// 方向红线不能反向共享,第四域仍为同构落位——见 execution/notify/notify-reconcile.ts
|
|
9
|
+
// 文件头),本文件是四域判据链收编进三方共同依赖包后的单源:骨架真值组合与幂等键
|
|
10
|
+
// 提取各只此一份,域侧只留「域事实 → 布尔」映射,真值表测试随迁本包单点锁定
|
|
11
|
+
// (reconcile-judgement.test.ts),域侧逐行对拍的人工同步义务退役。
|
|
12
|
+
//
|
|
13
|
+
// 域侧消费形态(调用方判定好的事实布尔进骨架;骨架零 I/O):bash 域 =
|
|
14
|
+
// pending-reconcile.ts registryFacts(registry 条目);session-manager 域 =
|
|
15
|
+
// notify-reconcile.ts 预检 + reconcileOneManagedCandidate;workflow 域 =
|
|
16
|
+
// workflow-notify-reconcile.ts reconcileOneCandidate;subagent-core 域 = 世代
|
|
17
|
+
// 匹配变体(本包不可达,同构锚定)。排除位语义随域参数化(「主路径不发者排除」
|
|
18
|
+
// 原理,D5 裁决同源),判据演化不产生第二套真值组合。
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
ACK_CUSTOM_TYPE,
|
|
22
|
+
OUTBOX_CUSTOM_TYPE,
|
|
23
|
+
V1_ACK_CUSTOM_TYPE,
|
|
24
|
+
V1_OUTBOX_CUSTOM_TYPE,
|
|
25
|
+
mapV1OutboxData,
|
|
26
|
+
readAckId,
|
|
27
|
+
readOutboxDataV2,
|
|
28
|
+
} from "./journal.ts";
|
|
29
|
+
|
|
30
|
+
/** 三重事实判据的输入(调用方在域内完成事实源映射后传入;骨架零 I/O)。 */
|
|
31
|
+
export interface ReconcileNotifyFacts { // oe-exempt:20261102:framework:port 契约层判据输入形状,域侧以对象字面量消费、无 implements 变体(四域判据链已收编本包单源)
|
|
32
|
+
/** 事实一:任务/记录已终态(域事实源判定——bash 域 = registry 终态枚举成员)。 */
|
|
33
|
+
isTerminal: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* 排除位(「主路径不发者排除」原理,D5 裁决同源):主路径对该终态成因不发的
|
|
36
|
+
* 通知,补投无从恢复也不该发。bash 域 = killed;u3 subagent 域 = cancelled 族
|
|
37
|
+
* + workflow origin 整体排除;session-manager/workflow 域 = 静默注销类 /
|
|
38
|
+
* 恒 false(主路径全终态发通知)。真 = 非候选。
|
|
39
|
+
*/
|
|
40
|
+
excludedByOrigin: boolean;
|
|
41
|
+
/** 事实二:送达痕迹命中(该键的完成通知消息已在会话 entries)。 */
|
|
42
|
+
delivered: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* 事实三:投递日志已含该幂等键条目(受理已发生)。有条目未送达 = 差集重放
|
|
45
|
+
* (port.recover)的职责;已终局 = ack/凭据判据的职责——均非补投对象(D2
|
|
46
|
+
* 采用段:补投只认「从未受理成功」形态,与重放职责不重叠)。
|
|
47
|
+
*/
|
|
48
|
+
journaled: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 三重事实判据骨架(D2):终态 ∧ 未排除 ∧ 无送达痕迹 ∧ 投递日志无条目。
|
|
53
|
+
*
|
|
54
|
+
* 单源位(原 judgement.ts RL12 行为锁收编):bash 域对账(isReapNotifyCandidate)
|
|
55
|
+
* 与三边沿入口(session_start / 切回 / agent_settled)经同一函数消费;session-
|
|
56
|
+
* manager / workflow 域终判循环复用同一真值组合——判据演化不产生第二套真值逻辑。
|
|
57
|
+
*/
|
|
58
|
+
export function isReconcileNotifyCandidate(facts: ReconcileNotifyFacts): boolean {
|
|
59
|
+
return facts.isTerminal && !facts.excludedByOrigin && !facts.delivered && !facts.journaled;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 从会话 entries 提取投递日志已登记的幂等键与承载键集(事实三的事实源)。outbox
|
|
64
|
+
* (受理落盘)与 ack/discard(终局列)v2/v1 四类 entry 兼容读。
|
|
65
|
+
*
|
|
66
|
+
* collectKey 键集展开(单源统一裁决——原 bash 域副本只收条目 id、session-manager/
|
|
67
|
+
* workflow 域副本带展开的分叉收敛):主路径受理条目可按 collectKey 申报承载键
|
|
68
|
+
* (条目 id ≠ 业务幂等键的承载形态——workflow 域裸 runId、session-manager 域合批
|
|
69
|
+
* 成员键),只收 id 会漏「主路径成功受理」形态(误补投由 port 幂等预检吞并,多余
|
|
70
|
+
* 但无害;判据侧直接补齐)。展开是 id 收集的无损超集:多命中方向 = 漏补投(保守,
|
|
71
|
+
* 与「漏补投优于重复投」取向一致);各域键词表不相交(bt- / run / managed notify
|
|
72
|
+
* 键形态互斥),跨域展开无误命中面。
|
|
73
|
+
*
|
|
74
|
+
* 解析复用本包 journal 单点(readOutboxDataV2 / mapV1OutboxData / readAckId),
|
|
75
|
+
* 宽容口径与 port scanJournal 一致:data 畸形按无条目——误判「无条目」的残余
|
|
76
|
+
* 风险由 port 幂等预检承接(重投被 swallowed 吞并,无实害)。
|
|
77
|
+
*/
|
|
78
|
+
export function collectJournaledDeliveryIds(entries: readonly unknown[]): Set<string> {
|
|
79
|
+
const ids = new Set<string>();
|
|
80
|
+
for (const raw of entries) {
|
|
81
|
+
if (!raw || typeof raw !== "object") continue;
|
|
82
|
+
const entry = raw as Record<string, unknown>;
|
|
83
|
+
if (entry["type"] !== "custom") continue;
|
|
84
|
+
switch (entry["customType"]) {
|
|
85
|
+
case OUTBOX_CUSTOM_TYPE: {
|
|
86
|
+
const record = readOutboxDataV2(entry["data"]);
|
|
87
|
+
if (record === undefined) break;
|
|
88
|
+
ids.add(record.id);
|
|
89
|
+
if (record.collectKey !== undefined) {
|
|
90
|
+
for (const key of record.collectKey) ids.add(key);
|
|
91
|
+
}
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
case V1_OUTBOX_CUSTOM_TYPE: {
|
|
95
|
+
// v1 存量(旧通知账本)兼容读;scannedAt 只填 record.recordedAt 元数据,
|
|
96
|
+
// 本判据只取 id——传 0 保持纯函数无时钟依赖
|
|
97
|
+
const record = mapV1OutboxData(entry["data"], 0);
|
|
98
|
+
if (record !== undefined) ids.add(record.id);
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
case ACK_CUSTOM_TYPE:
|
|
102
|
+
case V1_ACK_CUSTOM_TYPE: {
|
|
103
|
+
const id = readAckId(entry["data"]);
|
|
104
|
+
if (id !== undefined) ids.add(id);
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
default:
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return ids;
|
|
112
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// src/session-journal-read.ts
|
|
2
|
+
//
|
|
3
|
+
// session JSONL 落盘条目的磁盘读侧(对账补投判据事实源的共享读原语)——原角色
|
|
4
|
+
// 载体 = 三域各自持有的同构副本(bash pending-reconcile.ts / session-manager
|
|
5
|
+
// notify-reconcile.ts / subagent-workflow workflow-notify-reconcile.ts 的
|
|
6
|
+
// readSessionFileEntries,近逐字同构、容错口径一致),随判据链收编进本包单源。
|
|
7
|
+
// 机制动机(pi SessionManager._appendEntry 幽灵内存 entry 形态——persist 失败无
|
|
8
|
+
// 回滚,内存账不可作判据事实源)的 pi 侧实锚登记 = docs/pi-semantics.json PS-97。
|
|
9
|
+
// 域侧退化语义(no-persist 形态退化内存账 + warn)经 resolveReconcileFactEntries
|
|
10
|
+
// 收编本文件单源(session-manager / workflow 两域消费;bash 域文案词序与其余两域
|
|
11
|
+
// 不同,保持域内原样,登记在该域)。
|
|
12
|
+
//
|
|
13
|
+
// logger 归因:本包共享原语的 warn 走本包通道(getLogger("pi-delivery-port")),
|
|
14
|
+
// 不按消费域归因(三域调用点零参数差异;读失败本身是共享原语事件,域归因由调用
|
|
15
|
+
// 栈排障定位)。容错口径与 packages/subagent-core 的同构读原语
|
|
16
|
+
// readSessionEntriesFromFile 互指登记见该函数头注(core 受 core ← extensions
|
|
17
|
+
// 依赖方向红线不进本包;读错误 warn + [] 口径两侧一致,warn 文本同串——
|
|
18
|
+
// ENOENT 静默空、脏行跳过亦同;差异仅 logger 通道归因)。
|
|
19
|
+
|
|
20
|
+
import { readFileSync } from "node:fs";
|
|
21
|
+
|
|
22
|
+
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
23
|
+
import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
|
|
24
|
+
|
|
25
|
+
const logger = getLogger("pi-delivery-port");
|
|
26
|
+
|
|
27
|
+
/** 脏行 warn 留痕的截断长度(taste/no-magic-numbers 抽常量)。 */
|
|
28
|
+
const DIRTY_LINE_SNIPPET_LEN = 120;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 读 session JSONL 文件的落盘条目(判据事实源的磁盘读侧)。pi _persist 落盘形态
|
|
32
|
+
* = 每条 entry 一行 JSON.stringify,逐行 parse 产出与 getEntries() 同构的条目数组。
|
|
33
|
+
*
|
|
34
|
+
* 容错口径(与 collectJournaledDeliveryIds「data 畸形按无条目」宽容口径同源):
|
|
35
|
+
* - ENOENT → []:session 首条对话消息前文件不存在(pi _persist 首次 flush 前置
|
|
36
|
+
* 条件,PS-97 :797-799 未 flush 无对话早退),常态非异常;
|
|
37
|
+
* - 其他读错误(EACCES 等)→ warn + []:磁盘事实不可得按「无条目」,候选源与
|
|
38
|
+
* 抵消事实同按空(保守 miss + warn 修复信号——按内存账退化则会沿幽灵态静默
|
|
39
|
+
* 吞补投,即 A1 缺陷形态本身);
|
|
40
|
+
* - 脏行(JSON.parse 失败 / 非对象)单行跳过(JSONL 容错纪律,protocol
|
|
41
|
+
* pending-entries 扫描同款)。
|
|
42
|
+
*/
|
|
43
|
+
export function readSessionFileEntries(sessionFile: string): unknown[] {
|
|
44
|
+
let text: string;
|
|
45
|
+
try {
|
|
46
|
+
text = readFileSync(sessionFile, "utf8");
|
|
47
|
+
} catch (err) {
|
|
48
|
+
if ((err as NodeJS.ErrnoException | null)?.code === "ENOENT") return [];
|
|
49
|
+
logger.warn("reconcile journal facts read failed; disk entries treated as empty (candidates retried at next reconcile edge)", {
|
|
50
|
+
detail: { sessionFile, err: toErrorMessage(err) },
|
|
51
|
+
});
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
const entries: unknown[] = [];
|
|
55
|
+
for (const line of text.split("\n")) {
|
|
56
|
+
if (line.trim() === "") continue;
|
|
57
|
+
try {
|
|
58
|
+
const parsed: unknown = JSON.parse(line);
|
|
59
|
+
if (parsed !== null && typeof parsed === "object") entries.push(parsed);
|
|
60
|
+
} catch (e) {
|
|
61
|
+
// 脏行跳过:单行损坏不拖垮整盘事实;warn 留痕(taste/no-silent-catch)
|
|
62
|
+
logger.warn("session JSONL 脏行跳过(对账事实按其余行继续)", {
|
|
63
|
+
detail: { line: line.slice(0, DIRTY_LINE_SNIPPET_LEN), error: toErrorMessage(e) },
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return entries;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 对账事实源解析(session-manager / workflow 两域对账编排共用的 no-persist 退化
|
|
72
|
+
* 形态单源):sessionFile 在场 = 读磁盘落盘条目(readSessionFileEntries);缺席
|
|
73
|
+
* (no-persist 形态,磁盘事实结构性不存在)退化内存 entries + warn——该形态
|
|
74
|
+
* _persist no-op 恒成功、无幽灵态,内存账即完整事实。warn 文案 domain 段参数化
|
|
75
|
+
* (域标签进文案首位,子串 "fell back to in-memory entries" 为两域测试的共享断言锚)。
|
|
76
|
+
*/
|
|
77
|
+
export function resolveReconcileFactEntries(opts: {
|
|
78
|
+
/** 域标签(warn 文案归因段:如 "managed notify reconcile" / "workflow notify reconcile")。 */
|
|
79
|
+
domain: string;
|
|
80
|
+
/** 入口来源(warn detail 透传——入口来源日志可辨)。 */
|
|
81
|
+
reason: string;
|
|
82
|
+
/** 当前 session entries(内存账——退化事实源)。 */
|
|
83
|
+
entries: readonly unknown[];
|
|
84
|
+
/** 当前 session JSONL 路径;undefined = no-persist 形态。 */
|
|
85
|
+
sessionFile?: string;
|
|
86
|
+
}): readonly unknown[] {
|
|
87
|
+
if (opts.sessionFile !== undefined) return readSessionFileEntries(opts.sessionFile);
|
|
88
|
+
logger.warn(
|
|
89
|
+
`${opts.domain} journal facts fell back to in-memory entries: session file unavailable (no-persist session?); ghost in-memory entries would misjudge delivered/journaled facts`,
|
|
90
|
+
{
|
|
91
|
+
detail: { reason: opts.reason },
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
return opts.entries;
|
|
95
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// src/types.ts
|
|
2
2
|
//
|
|
3
|
-
// @zhushanwen/pi-delivery-port
|
|
3
|
+
// @zhushanwen/pi-delivery-port 契约(设计 .tmp/tech-design/unified-delivery.md
|
|
4
4
|
// §3.7 pi 半区组件表 / §3.4 提交选项缺省值表)。
|
|
5
5
|
//
|
|
6
6
|
// 本包是「知 pi 又知内核」的适配层:把 pi 扩展运行时
|
|
@@ -69,7 +69,7 @@ export interface DeliveryDeliverOptions { // oe-exempt:20261017:framework:统一
|
|
|
69
69
|
*/
|
|
70
70
|
export type DeliveryDeliverResult =
|
|
71
71
|
| { kind: "accepted"; id: string }
|
|
72
|
-
| { kind: "swallowed"; reason: "already-seen"
|
|
72
|
+
| { kind: "swallowed"; reason: "already-seen" }
|
|
73
73
|
| { kind: "rejected"; reason: string };
|
|
74
74
|
|
|
75
75
|
// ─── collect 面(批量收取;result-collect 设计 §3.1②③)──────
|
|
@@ -136,7 +136,7 @@ export interface DeliveryPortMetrics { // oe-exempt:20261017:framework:分桶诊
|
|
|
136
136
|
|
|
137
137
|
// ─── host 端口(装配点接线点)───────────────────────────────
|
|
138
138
|
|
|
139
|
-
/** host logger
|
|
139
|
+
/** host logger 最小接口(@zhushanwen/pi-extension-logger 的 ExtensionLogger 结构性满足)。 */
|
|
140
140
|
export interface DeliveryPortLogger { // oe-exempt:20261017:framework:日志注入契约(宿主提供实现)
|
|
141
141
|
warn(msg: string, data?: unknown): void;
|
|
142
142
|
debug(msg: string, data?: unknown): void;
|
|
@@ -146,10 +146,10 @@ export interface DeliveryPortLogger { // oe-exempt:20261017:framework:日志注
|
|
|
146
146
|
* port 宿主最小端口面(session_start 装配点经 createPiDeliveryPortHost 接线;
|
|
147
147
|
* 接口化解耦便于测试,内核/端口本体零 pi import)。
|
|
148
148
|
*
|
|
149
|
-
*
|
|
149
|
+
* 订阅类端口全部返回退订函数(pi 1.0.0 起 on 返回 unsubscribe;port dispose 时兑现,
|
|
150
150
|
* 防 session_start 重绑累积死 handler)。
|
|
151
151
|
*/
|
|
152
|
-
export interface DeliveryPortHost { // oe-exempt:20261017:framework:pi 能力注入契约(sendMessage/appendEntry/on
|
|
152
|
+
export interface DeliveryPortHost { // oe-exempt:20261017:framework:pi 能力注入契约(sendMessage/appendEntry/on 事件宿主接口)
|
|
153
153
|
/** 写 plain custom entry(outbox / ack 通道;pi.appendEntry——不进 LLM 上下文)。 */
|
|
154
154
|
appendEntry(customType: string, data: unknown): void;
|
|
155
155
|
/** 读当前 session 全部 entry(回执扫描 / 重放扫描;ctx.sessionManager.getEntries())。 */
|
|
@@ -217,7 +217,16 @@ export interface DeliveryPortHandle { // oe-exempt:20261017:framework:进程级
|
|
|
217
217
|
/** 销毁:内核 dispose + 退订全部事件订阅 + 摘进程级槽绑定。 */
|
|
218
218
|
dispose(): void;
|
|
219
219
|
/**
|
|
220
|
-
*
|
|
220
|
+
* 重绑交接面(bindDeliveryPort 专用消费,dispose 前调用):返回本实例内核
|
|
221
|
+
* in-flight(已交递 pi,注入由 pi 侧异步完成)的已受理 id 快照。后继实例把集合
|
|
222
|
+
* 作为 predecessorInFlightIds 预占——差集重放对这批 id 跳过(消息已在途,重放
|
|
223
|
+
* 构成同条通知双投,A4 真机实证);跨进程重启无前驱可交接,崩溃恢复通道不变。
|
|
224
|
+
* queued / failed(未出站或出站失败——dispose 丢弃)不在集合内,后继重放恢复
|
|
225
|
+
* 语义不变。
|
|
226
|
+
*/
|
|
227
|
+
takeInFlightHandoff?(): ReadonlySet<string>;
|
|
228
|
+
/**
|
|
229
|
+
* collect 面(result-collect 设计 §3.1② 端口挂起行为,方法语义以该节为准)。
|
|
221
230
|
* collectArm:同步段登记在途等待集 + 对每个 key 按五态判据定态(快照形态
|
|
222
231
|
* CollectSnapshotEntry——unknown 态键不登记等待集,由消费方组装 errors)。
|
|
223
232
|
*/
|