@zhushanwen/pi-pending-notifications 0.3.4 → 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 +4 -1
- package/src/__tests__/pending-notifications.test.ts +172 -1
- package/src/index.ts +26 -9
- package/src/state.ts +87 -25
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-pending-notifications",
|
|
3
|
-
"version": "0.
|
|
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",
|
|
@@ -32,6 +32,9 @@
|
|
|
32
32
|
"@earendil-works/pi-coding-agent": "^0.84.1",
|
|
33
33
|
"typebox": "*"
|
|
34
34
|
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@zhushanwen/pi-extension-logger": "0.3.0"
|
|
37
|
+
},
|
|
35
38
|
"scripts": {
|
|
36
39
|
"typecheck": "npx tsc --noEmit",
|
|
37
40
|
"test": "vitest run"
|
|
@@ -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 {
|
|
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
|
|
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)。
|
|
@@ -23,27 +24,32 @@
|
|
|
23
24
|
*/
|
|
24
25
|
|
|
25
26
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
27
|
+
import { getLogger } from "@zhushanwen/pi-extension-logger";
|
|
26
28
|
import { Type } from "typebox";
|
|
27
29
|
|
|
28
30
|
import {
|
|
29
31
|
createRegistry,
|
|
30
32
|
getActive,
|
|
33
|
+
PENDING_LIFECYCLE,
|
|
31
34
|
PENDING_TTL_MS,
|
|
32
35
|
type PendingEntry,
|
|
33
36
|
type PendingRegistry,
|
|
34
37
|
type PendingStatus,
|
|
35
38
|
type PendingType,
|
|
39
|
+
normalizePendingType,
|
|
36
40
|
rebuildFromEntries,
|
|
37
41
|
register,
|
|
38
42
|
unregister,
|
|
39
43
|
} from "./state.ts";
|
|
40
44
|
|
|
41
45
|
// 跨扩展消费 API:goal(continuation 守卫)/ subagent-workflow(agent_end 后代判定)
|
|
42
|
-
// 直接 import 本包的导出,避免各自复制差集逻辑。
|
|
46
|
+
// 直接 import 本包的导出,避免各自复制差集逻辑。PENDING_LIFECYCLE 供消费方(如
|
|
47
|
+
// base-tool-enhance 启动时的 peer 版本检查)读取分档声明。
|
|
43
48
|
export {
|
|
44
49
|
countActiveFromEntries,
|
|
45
50
|
createRegistry,
|
|
46
51
|
getActive,
|
|
52
|
+
PENDING_LIFECYCLE,
|
|
47
53
|
PENDING_TTL_MS,
|
|
48
54
|
type CountActiveOptions,
|
|
49
55
|
type CountActiveResult,
|
|
@@ -56,6 +62,8 @@ export {
|
|
|
56
62
|
unregister,
|
|
57
63
|
} from "./state.ts";
|
|
58
64
|
|
|
65
|
+
const logger = getLogger("pending-notifications");
|
|
66
|
+
|
|
59
67
|
/** 工具参数 schema */
|
|
60
68
|
const PendingNotificationsParams = Type.Object({
|
|
61
69
|
action: Type.Union([
|
|
@@ -94,7 +102,7 @@ export default function pendingNotificationsExtension(pi: ExtensionAPI): void {
|
|
|
94
102
|
unsub();
|
|
95
103
|
} catch (err) {
|
|
96
104
|
// unsubscribe 失败不阻断初始化(监听器可能已被 EventBus 内部清理)
|
|
97
|
-
|
|
105
|
+
logger.debug("unsubscribe failed during cleanup", { error: String(err) });
|
|
98
106
|
}
|
|
99
107
|
}
|
|
100
108
|
unsubscribers = [];
|
|
@@ -116,14 +124,14 @@ export default function pendingNotificationsExtension(pi: ExtensionAPI): void {
|
|
|
116
124
|
}
|
|
117
125
|
}
|
|
118
126
|
|
|
119
|
-
// debug 日志:环境变量 XYZ_AGENT_DEBUG=1
|
|
127
|
+
// debug 日志:环境变量 XYZ_AGENT_DEBUG=1 时经共享 logger 写文件日志(默认 no-op)。
|
|
120
128
|
// 不再写入 session entry(pending:log)——session entries 是 append-only 无法 GC,
|
|
121
129
|
// 12 处 debug 日志会让长 session 的 entries 线性膨胀,而 goal before-agent-start
|
|
122
130
|
// 每 turn 全量扫描 getEntries()。状态数据(pending:register/unregister)仍写 entry。
|
|
123
131
|
const debugEnabled = process.env.XYZ_AGENT_DEBUG === "1";
|
|
124
132
|
function debugLog(level: string, message: string, data?: unknown): void {
|
|
125
133
|
if (!debugEnabled) return;
|
|
126
|
-
|
|
134
|
+
logger.debug(`${level}: ${message}`, data ?? "");
|
|
127
135
|
}
|
|
128
136
|
|
|
129
137
|
// ── EventBus 监听:pending:register ─────────────────────
|
|
@@ -138,13 +146,15 @@ export default function pendingNotificationsExtension(pi: ExtensionAPI): void {
|
|
|
138
146
|
debugLog("debug", "listener: pending:register parsed", parsed);
|
|
139
147
|
|
|
140
148
|
const now = Date.now();
|
|
149
|
+
// D16 分档:process 档(bash 后台任务)不计算 expiresAt——进程级生命周期
|
|
150
|
+
// 无 TTL 概念,任务寿命由其自身超时/reaper 管理,session 档保持 TTL 不变。
|
|
141
151
|
const entry: PendingEntry = {
|
|
142
152
|
id: parsed.id,
|
|
143
153
|
type: parsed.type,
|
|
144
154
|
name: parsed.name,
|
|
145
155
|
status: "active",
|
|
146
156
|
registeredAt: now,
|
|
147
|
-
expiresAt: now + PENDING_TTL_MS,
|
|
157
|
+
expiresAt: PENDING_LIFECYCLE[parsed.type] === "session" ? now + PENDING_TTL_MS : undefined,
|
|
148
158
|
sessionId: currentSessionId,
|
|
149
159
|
};
|
|
150
160
|
|
|
@@ -155,12 +165,14 @@ export default function pendingNotificationsExtension(pi: ExtensionAPI): void {
|
|
|
155
165
|
return;
|
|
156
166
|
}
|
|
157
167
|
|
|
168
|
+
// 落盘与内存 entry 对称:process 档省略 expiresAt 字段(而非写 undefined),
|
|
169
|
+
// 读取侧 normalizeRegisterEntry 对 process 档同样不回填,两侧共同兑现 TTL 豁免。
|
|
158
170
|
safeAppendEntry("pending:register", {
|
|
159
171
|
id: entry.id,
|
|
160
172
|
type: entry.type,
|
|
161
173
|
name: entry.name,
|
|
162
174
|
registeredAt: entry.registeredAt,
|
|
163
|
-
expiresAt: entry.expiresAt,
|
|
175
|
+
...(entry.expiresAt !== undefined ? { expiresAt: entry.expiresAt } : {}),
|
|
164
176
|
sessionId: entry.sessionId,
|
|
165
177
|
});
|
|
166
178
|
|
|
@@ -223,6 +235,10 @@ export default function pendingNotificationsExtension(pi: ExtensionAPI): void {
|
|
|
223
235
|
pi.on("session_shutdown", (_event, _ctx: ExtensionContext) => {
|
|
224
236
|
const active = getActive(registry);
|
|
225
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;
|
|
226
242
|
const changed = unregister(registry, op.id, "cancelled");
|
|
227
243
|
if (changed) {
|
|
228
244
|
safeAppendEntry("pending:unregister", {
|
|
@@ -238,7 +254,7 @@ export default function pendingNotificationsExtension(pi: ExtensionAPI): void {
|
|
|
238
254
|
name: "pending_notifications",
|
|
239
255
|
label: "Pending Notifications",
|
|
240
256
|
description:
|
|
241
|
-
"查询当前活跃的异步操作(workflow/subagent
|
|
257
|
+
"查询当前活跃的异步操作(workflow/subagent/bash 后台任务)。action=count 返回数量;action=list 返回列表。状态由 EventBus + session entries 维护,无需手动注册。",
|
|
242
258
|
parameters: PendingNotificationsParams,
|
|
243
259
|
execute: async (_toolCallId: string, params: { action: "count" | "list" }, _signal: AbortSignal | undefined, _onUpdate: unknown, _ctx: ExtensionContext): Promise<{ content: { type: "text"; text: string }[]; details: PendingToolDetails }> => {
|
|
244
260
|
const active = getActive(registry);
|
|
@@ -276,7 +292,8 @@ function parseRegisterEvent(data: unknown): ParsedRegister | null {
|
|
|
276
292
|
if (typeof d.id !== "string") return null;
|
|
277
293
|
return {
|
|
278
294
|
id: d.id,
|
|
279
|
-
|
|
295
|
+
// D16:bash 类型直通(归一化映射与 state.ts 读取侧共用同一函数,防两侧漂移)
|
|
296
|
+
type: normalizePendingType(d.type),
|
|
280
297
|
name: typeof d.name === "string" ? d.name : d.id,
|
|
281
298
|
};
|
|
282
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
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
|
|
300
|
+
type,
|
|
247
301
|
name: typeof data.name === "string" ? data.name : (data.id as string),
|
|
248
302
|
status: "active",
|
|
249
303
|
registeredAt,
|
|
250
|
-
|
|
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
|
}
|