@qjp88995/agent-runtime-redis 0.0.0-stage → 0.2.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/dist/coordination.d.ts +133 -0
- package/dist/coordination.d.ts.map +1 -0
- package/dist/coordination.js +315 -0
- package/dist/coordination.js.map +1 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +37 -0
- package/dist/index.js.map +1 -0
- package/dist/keys.d.ts +23 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +14 -0
- package/dist/keys.js.map +1 -0
- package/dist/stream-bus.d.ts +80 -0
- package/dist/stream-bus.d.ts.map +1 -0
- package/dist/stream-bus.js +285 -0
- package/dist/stream-bus.js.map +1 -0
- package/package.json +46 -3
- package/src/coordination.ts +459 -0
- package/src/index.ts +91 -0
- package/src/keys.ts +36 -0
- package/src/stream-bus.ts +343 -0
- package/README.md +0 -3
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
import type { Redis } from 'ioredis';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
AWAIT_LEASE_TTL_MS,
|
|
6
|
+
consoleLogger,
|
|
7
|
+
LOCK_TTL_MS,
|
|
8
|
+
type Logger,
|
|
9
|
+
type LoggerFactory,
|
|
10
|
+
type SessionLock,
|
|
11
|
+
type SubagentAwaitLease,
|
|
12
|
+
type TaskLease,
|
|
13
|
+
type TaskSignals,
|
|
14
|
+
type TreeBudget,
|
|
15
|
+
type WakeChannel,
|
|
16
|
+
} from '@qjp88995/agent-runtime';
|
|
17
|
+
|
|
18
|
+
import { type RedisKeyOptions, type RedisKeys, redisKeys } from './keys.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 会话锁、唤醒 / 空闲广播、子 Agent 等待租约、树级名额、任务监视租约与转后台广播的 Redis 实现。
|
|
22
|
+
* 搬自 agent-base `agent-core/loop/{session-lock,session-wake}.service.ts`、`session-idle.ts`、
|
|
23
|
+
* `agent-core/subagent/{subagent-await,tree-budget}.service.ts`,语义见 runtime 的 `ports/coordination`。
|
|
24
|
+
*
|
|
25
|
+
* 锁 / 租约 / 名额都直接用宿主给的共享连接(全是非阻塞的常规命令),只有唤醒频道与转后台广播
|
|
26
|
+
* 各要一条独立的订阅连接(进了 subscribe 模式的连接不能再发普通命令)。
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** DEL 前比对 token,避免误删「TTL 过期后被别人重新抢到」的锁。 */
|
|
30
|
+
const RELEASE_LUA =
|
|
31
|
+
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
|
|
32
|
+
/** PEXPIRE 前比对 token,只续自己的锁。 */
|
|
33
|
+
const RENEW_LUA =
|
|
34
|
+
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('pexpire', KEYS[1], ARGV[2]) else return 0 end";
|
|
35
|
+
|
|
36
|
+
export interface RedisSessionLockOptions extends RedisKeyOptions {
|
|
37
|
+
/** 缺省 `LOCK_TTL_MS`(60s)。 */
|
|
38
|
+
ttlMs?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** SET NX PX 抢,Lua 比对 token 再 DEL / PEXPIRE。会话锁与任务租约共用,只是键空间不同。 */
|
|
42
|
+
class RedisTokenLock {
|
|
43
|
+
constructor(
|
|
44
|
+
private readonly redis: Redis,
|
|
45
|
+
private readonly keyOf: (id: string) => string,
|
|
46
|
+
private readonly ttlMs: number
|
|
47
|
+
) {}
|
|
48
|
+
|
|
49
|
+
async acquire(id: string): Promise<string | null> {
|
|
50
|
+
const token = randomUUID();
|
|
51
|
+
const res = await this.redis.set(
|
|
52
|
+
this.keyOf(id),
|
|
53
|
+
token,
|
|
54
|
+
'PX',
|
|
55
|
+
this.ttlMs,
|
|
56
|
+
'NX'
|
|
57
|
+
);
|
|
58
|
+
return res === 'OK' ? token : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async renew(id: string, token: string): Promise<boolean> {
|
|
62
|
+
const res = await this.redis.eval(
|
|
63
|
+
RENEW_LUA,
|
|
64
|
+
1,
|
|
65
|
+
this.keyOf(id),
|
|
66
|
+
token,
|
|
67
|
+
String(this.ttlMs)
|
|
68
|
+
);
|
|
69
|
+
return res === 1;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async release(id: string, token: string): Promise<void> {
|
|
73
|
+
await this.redis.eval(RELEASE_LUA, 1, this.keyOf(id), token);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async isHeld(id: string): Promise<boolean> {
|
|
77
|
+
return (await this.redis.exists(this.keyOf(id))) === 1;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 会话锁。TTL 兜底持有者崩溃。 */
|
|
82
|
+
export class RedisSessionLock implements SessionLock {
|
|
83
|
+
private readonly lock: RedisTokenLock;
|
|
84
|
+
|
|
85
|
+
constructor(redis: Redis, options: RedisSessionLockOptions = {}) {
|
|
86
|
+
this.lock = new RedisTokenLock(
|
|
87
|
+
redis,
|
|
88
|
+
redisKeys(options).lock,
|
|
89
|
+
options.ttlMs ?? LOCK_TTL_MS
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
acquire(sessionId: string): Promise<string | null> {
|
|
94
|
+
return this.lock.acquire(sessionId);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
renew(sessionId: string, token: string): Promise<boolean> {
|
|
98
|
+
return this.lock.renew(sessionId, token);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
release(sessionId: string, token: string): Promise<void> {
|
|
102
|
+
return this.lock.release(sessionId, token);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
isHeld(sessionId: string): Promise<boolean> {
|
|
106
|
+
return this.lock.isHeld(sessionId);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface RedisTaskLeaseOptions extends RedisKeyOptions {
|
|
111
|
+
/** 缺省 `LOCK_TTL_MS`(60s)。监视器每段等待(runtime 缺省 20s)之后续一次。 */
|
|
112
|
+
ttlMs?: number;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 任务监视租约:与会话锁同一套写法,键 `agent-task:lease:<taskId>`。 */
|
|
116
|
+
export class RedisTaskLease implements TaskLease {
|
|
117
|
+
private readonly lock: RedisTokenLock;
|
|
118
|
+
|
|
119
|
+
constructor(redis: Redis, options: RedisTaskLeaseOptions = {}) {
|
|
120
|
+
this.lock = new RedisTokenLock(
|
|
121
|
+
redis,
|
|
122
|
+
redisKeys(options).taskLease,
|
|
123
|
+
options.ttlMs ?? LOCK_TTL_MS
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
acquire(taskId: string): Promise<string | null> {
|
|
128
|
+
return this.lock.acquire(taskId);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
renew(taskId: string, token: string): Promise<boolean> {
|
|
132
|
+
return this.lock.renew(taskId, token);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
release(taskId: string, token: string): Promise<void> {
|
|
136
|
+
return this.lock.release(taskId, token);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export interface RedisWakeChannelOptions extends RedisKeyOptions {
|
|
141
|
+
/**
|
|
142
|
+
* 订阅用的连接。缺省 `redis.duplicate()`,由本对象负责关闭;宿主自己给的不关。
|
|
143
|
+
*/
|
|
144
|
+
subscriber?: Redis;
|
|
145
|
+
logger?: LoggerFactory;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
type Handler = (sessionId: string) => void;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 唤醒 / 空闲广播走 Redis pub/sub(at-most-once,订阅前发的收不到——与端口约定一致)。
|
|
152
|
+
*
|
|
153
|
+
* 用 `RedisWakeChannel.create()` 构造:它等 SUBSCRIBE 应答回来才返回,之后 `onWake` / `onIdle`
|
|
154
|
+
* 只是登记本地处理函数,同步生效。否则「刚订阅就有人 wake」会落在 SUBSCRIBE 应答之前、静默丢失。
|
|
155
|
+
* 断线重连后 ioredis 会自动重新订阅(`autoResubscribe` 缺省开);断线期间的广播照样丢,
|
|
156
|
+
* 这由 sweeper 的启动补唤醒与消费方自己的兜底扫描覆盖。
|
|
157
|
+
*
|
|
158
|
+
* 一个处理函数抛错只记日志,不影响别的处理函数,也不冒进 ioredis 的事件循环。
|
|
159
|
+
*/
|
|
160
|
+
export class RedisWakeChannel implements WakeChannel {
|
|
161
|
+
private readonly wakeHandlers = new Set<Handler>();
|
|
162
|
+
private readonly idleHandlers = new Set<Handler>();
|
|
163
|
+
private disposed = false;
|
|
164
|
+
|
|
165
|
+
private constructor(
|
|
166
|
+
private readonly redis: Redis,
|
|
167
|
+
private readonly sub: Redis,
|
|
168
|
+
private readonly ownsSub: boolean,
|
|
169
|
+
private readonly keys: RedisKeys,
|
|
170
|
+
private readonly logger: Logger
|
|
171
|
+
) {}
|
|
172
|
+
|
|
173
|
+
static async create(
|
|
174
|
+
redis: Redis,
|
|
175
|
+
options: RedisWakeChannelOptions = {}
|
|
176
|
+
): Promise<RedisWakeChannel> {
|
|
177
|
+
const sub = options.subscriber ?? redis.duplicate();
|
|
178
|
+
const channel = new RedisWakeChannel(
|
|
179
|
+
redis,
|
|
180
|
+
sub,
|
|
181
|
+
!options.subscriber,
|
|
182
|
+
redisKeys(options),
|
|
183
|
+
(options.logger ?? consoleLogger)('RedisWakeChannel')
|
|
184
|
+
);
|
|
185
|
+
sub.on('message', (ch: string, message: string) =>
|
|
186
|
+
channel.dispatch(ch, message)
|
|
187
|
+
);
|
|
188
|
+
await sub.subscribe(channel.keys.wakeChannel, channel.keys.idleChannel);
|
|
189
|
+
return channel;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private dispatch(ch: string, message: string): void {
|
|
193
|
+
if (ch === this.keys.wakeChannel) {
|
|
194
|
+
this.fanOut(this.wakeHandlers, message, 'wake');
|
|
195
|
+
} else if (ch === this.keys.idleChannel) {
|
|
196
|
+
let sessionId: unknown;
|
|
197
|
+
try {
|
|
198
|
+
sessionId = (JSON.parse(message) as { sessionId?: unknown }).sessionId;
|
|
199
|
+
} catch {
|
|
200
|
+
sessionId = undefined;
|
|
201
|
+
}
|
|
202
|
+
if (typeof sessionId !== 'string') {
|
|
203
|
+
this.logger.warn(`dropping malformed idle message: ${message}`);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
this.fanOut(this.idleHandlers, sessionId, 'idle');
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private fanOut(handlers: Set<Handler>, sessionId: string, kind: string) {
|
|
211
|
+
for (const handler of [...handlers]) {
|
|
212
|
+
try {
|
|
213
|
+
handler(sessionId);
|
|
214
|
+
} catch (err) {
|
|
215
|
+
this.logger.error(
|
|
216
|
+
`${kind} handler failed for ${sessionId}: ${String(err)}`,
|
|
217
|
+
err instanceof Error ? err.stack : undefined
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async wake(sessionId: string): Promise<void> {
|
|
224
|
+
await this.redis.publish(this.keys.wakeChannel, sessionId);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
onWake(handler: Handler): () => void {
|
|
228
|
+
this.wakeHandlers.add(handler);
|
|
229
|
+
return () => this.wakeHandlers.delete(handler);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async idle(sessionId: string): Promise<void> {
|
|
233
|
+
await this.redis.publish(
|
|
234
|
+
this.keys.idleChannel,
|
|
235
|
+
JSON.stringify({ sessionId })
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
onIdle(handler: Handler): () => void {
|
|
240
|
+
this.idleHandlers.add(handler);
|
|
241
|
+
return () => this.idleHandlers.delete(handler);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** 摘掉所有处理函数;订阅连接是本对象建的就关掉。 */
|
|
245
|
+
async dispose(): Promise<void> {
|
|
246
|
+
if (this.disposed) return;
|
|
247
|
+
this.disposed = true;
|
|
248
|
+
this.wakeHandlers.clear();
|
|
249
|
+
this.idleHandlers.clear();
|
|
250
|
+
if (this.ownsSub) {
|
|
251
|
+
await this.sub.quit().catch(() => undefined);
|
|
252
|
+
} else {
|
|
253
|
+
await this.sub
|
|
254
|
+
.unsubscribe(this.keys.wakeChannel, this.keys.idleChannel)
|
|
255
|
+
.catch(() => undefined);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export interface RedisTaskSignalsOptions extends RedisKeyOptions {
|
|
261
|
+
/** 订阅用的连接。缺省 `redis.duplicate()`,由本对象负责关闭;宿主自己给的不关。 */
|
|
262
|
+
subscriber?: Redis;
|
|
263
|
+
logger?: LoggerFactory;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* 「任务转后台」广播走 Redis pub/sub,载荷就是任务 id。与 `RedisWakeChannel` 同样用
|
|
268
|
+
* `create()` 等 SUBSCRIBE 应答回来才返回。广播丢了不要紧:前台等待方每段等待之后都回看任务行
|
|
269
|
+
* 的 `background`,信号只是让它不必等满这一段。
|
|
270
|
+
*/
|
|
271
|
+
export class RedisTaskSignals implements TaskSignals {
|
|
272
|
+
private readonly handlers = new Set<Handler>();
|
|
273
|
+
private disposed = false;
|
|
274
|
+
|
|
275
|
+
private constructor(
|
|
276
|
+
private readonly redis: Redis,
|
|
277
|
+
private readonly sub: Redis,
|
|
278
|
+
private readonly ownsSub: boolean,
|
|
279
|
+
private readonly channel: string,
|
|
280
|
+
private readonly logger: Logger
|
|
281
|
+
) {}
|
|
282
|
+
|
|
283
|
+
static async create(
|
|
284
|
+
redis: Redis,
|
|
285
|
+
options: RedisTaskSignalsOptions = {}
|
|
286
|
+
): Promise<RedisTaskSignals> {
|
|
287
|
+
const sub = options.subscriber ?? redis.duplicate();
|
|
288
|
+
const signals = new RedisTaskSignals(
|
|
289
|
+
redis,
|
|
290
|
+
sub,
|
|
291
|
+
!options.subscriber,
|
|
292
|
+
redisKeys(options).taskBackgroundChannel,
|
|
293
|
+
(options.logger ?? consoleLogger)('RedisTaskSignals')
|
|
294
|
+
);
|
|
295
|
+
sub.on('message', (ch: string, taskId: string) => {
|
|
296
|
+
if (ch === signals.channel) signals.dispatch(taskId);
|
|
297
|
+
});
|
|
298
|
+
await sub.subscribe(signals.channel);
|
|
299
|
+
return signals;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private dispatch(taskId: string): void {
|
|
303
|
+
for (const handler of [...this.handlers]) {
|
|
304
|
+
try {
|
|
305
|
+
handler(taskId);
|
|
306
|
+
} catch (err) {
|
|
307
|
+
this.logger.error(
|
|
308
|
+
`background handler failed for ${taskId}: ${String(err)}`,
|
|
309
|
+
err instanceof Error ? err.stack : undefined
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async background(taskId: string): Promise<void> {
|
|
316
|
+
await this.redis.publish(this.channel, taskId);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
onBackground(handler: Handler): () => void {
|
|
320
|
+
this.handlers.add(handler);
|
|
321
|
+
return () => this.handlers.delete(handler);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** 摘掉处理函数;订阅连接是本对象建的就关掉,否则只退订本频道。 */
|
|
325
|
+
async dispose(): Promise<void> {
|
|
326
|
+
if (this.disposed) return;
|
|
327
|
+
this.disposed = true;
|
|
328
|
+
this.handlers.clear();
|
|
329
|
+
if (this.ownsSub) await this.sub.quit().catch(() => undefined);
|
|
330
|
+
else await this.sub.unsubscribe(this.channel).catch(() => undefined);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export interface RedisSubagentAwaitLeaseOptions extends RedisKeyOptions {
|
|
335
|
+
/** 缺省 `AWAIT_LEASE_TTL_MS`(60s)。 */
|
|
336
|
+
ttlMs?: number;
|
|
337
|
+
logger?: LoggerFactory;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* 等待租约:一个带 TTL 的键。`claim` 是一条原子 DEL,父与 finalizer 抢同一条,恰有一方胜出。
|
|
342
|
+
* Redis 出错时按端口约定一律退化成异步(见各方法)。
|
|
343
|
+
*/
|
|
344
|
+
export class RedisSubagentAwaitLease implements SubagentAwaitLease {
|
|
345
|
+
private readonly keys: RedisKeys;
|
|
346
|
+
private readonly ttlMs: number;
|
|
347
|
+
private readonly logger: Logger;
|
|
348
|
+
|
|
349
|
+
constructor(
|
|
350
|
+
private readonly redis: Redis,
|
|
351
|
+
options: RedisSubagentAwaitLeaseOptions = {}
|
|
352
|
+
) {
|
|
353
|
+
this.keys = redisKeys(options);
|
|
354
|
+
this.ttlMs = options.ttlMs ?? AWAIT_LEASE_TTL_MS;
|
|
355
|
+
this.logger = (options.logger ?? consoleLogger)('RedisSubagentAwaitLease');
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** 失败返回 false:没有租约就没有互斥,调用方退回异步模式。 */
|
|
359
|
+
async begin(childSessionId: string): Promise<boolean> {
|
|
360
|
+
try {
|
|
361
|
+
await this.redis.set(
|
|
362
|
+
this.keys.awaited(childSessionId),
|
|
363
|
+
'1',
|
|
364
|
+
'PX',
|
|
365
|
+
this.ttlMs
|
|
366
|
+
);
|
|
367
|
+
return true;
|
|
368
|
+
} catch (err) {
|
|
369
|
+
this.logger.warn(
|
|
370
|
+
`await lease begin(${childSessionId}) failed, falling back to async: ${String(err)}`
|
|
371
|
+
);
|
|
372
|
+
return false;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** 只续已存在的租约(PEXPIRE 对不存在的键是 no-op)。失败不抛。 */
|
|
377
|
+
async renew(childSessionId: string): Promise<void> {
|
|
378
|
+
try {
|
|
379
|
+
await this.redis.pexpire(this.keys.awaited(childSessionId), this.ttlMs);
|
|
380
|
+
} catch {
|
|
381
|
+
// 续租失败不打断等待:最坏是租约过期、finalizer 改走回投,父随后超时降级。
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** 拿不准当作没人等:猜「有人等」会让该回投的产出被跳过,那是静默丢失。 */
|
|
386
|
+
async isAwaited(childSessionId: string): Promise<boolean> {
|
|
387
|
+
try {
|
|
388
|
+
return (await this.redis.exists(this.keys.awaited(childSessionId))) === 1;
|
|
389
|
+
} catch (err) {
|
|
390
|
+
this.logger.warn(
|
|
391
|
+
`await lease isAwaited(${childSessionId}) failed, assuming none: ${String(err)}`
|
|
392
|
+
);
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** 拿不准时父得 true(放弃等待)、finalizer 得 false(照常回投),合起来就是退化成异步。 */
|
|
398
|
+
async claim(
|
|
399
|
+
childSessionId: string,
|
|
400
|
+
by: 'parent' | 'finalizer'
|
|
401
|
+
): Promise<boolean> {
|
|
402
|
+
try {
|
|
403
|
+
return (await this.redis.del(this.keys.awaited(childSessionId))) === 1;
|
|
404
|
+
} catch (err) {
|
|
405
|
+
this.logger.warn(
|
|
406
|
+
`await lease claim(${childSessionId}, ${by}) failed: ${String(err)}`
|
|
407
|
+
);
|
|
408
|
+
return by === 'parent';
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** 只有当前值 < 上限才 INCR(并刷新 TTL),否则不动。 */
|
|
414
|
+
const INCR_IF_UNDER_LUA =
|
|
415
|
+
"local v=tonumber(redis.call('get',KEYS[1]) or '0') if v<tonumber(ARGV[1]) then redis.call('incr',KEYS[1]) redis.call('expire',KEYS[1],ARGV[2]) return 1 else return 0 end";
|
|
416
|
+
/** DECR 后 ≤ 0 就 DEL,不留负值。 */
|
|
417
|
+
const DECR_LUA =
|
|
418
|
+
"local v=redis.call('decr',KEYS[1]) if v<=0 then redis.call('del',KEYS[1]) end return v";
|
|
419
|
+
|
|
420
|
+
export interface RedisTreeBudgetOptions extends RedisKeyOptions {
|
|
421
|
+
/** 计数键的 TTL,兜底「整棵树都没了、计数还留着」。缺省 1 天。 */
|
|
422
|
+
ttlSeconds?: number;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** 树级名额:Lua 里做「读比较写」,并发 `tryIncr` 不超发。 */
|
|
426
|
+
export class RedisTreeBudget implements TreeBudget {
|
|
427
|
+
private readonly keys: RedisKeys;
|
|
428
|
+
private readonly ttlSeconds: number;
|
|
429
|
+
|
|
430
|
+
constructor(
|
|
431
|
+
private readonly redis: Redis,
|
|
432
|
+
options: RedisTreeBudgetOptions = {}
|
|
433
|
+
) {
|
|
434
|
+
this.keys = redisKeys(options);
|
|
435
|
+
this.ttlSeconds = options.ttlSeconds ?? 86_400;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
async tryIncr(rootSessionId: string, limit: number): Promise<boolean> {
|
|
439
|
+
const res = await this.redis.eval(
|
|
440
|
+
INCR_IF_UNDER_LUA,
|
|
441
|
+
1,
|
|
442
|
+
this.keys.liveSubs(rootSessionId),
|
|
443
|
+
String(limit),
|
|
444
|
+
String(this.ttlSeconds)
|
|
445
|
+
);
|
|
446
|
+
return res === 1;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async decr(rootSessionId: string): Promise<void> {
|
|
450
|
+
await this.redis.eval(DECR_LUA, 1, this.keys.liveSubs(rootSessionId));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** 与进程内实现一致:n ≤ 0 直接删键。 */
|
|
454
|
+
async set(rootSessionId: string, n: number): Promise<void> {
|
|
455
|
+
const key = this.keys.liveSubs(rootSessionId);
|
|
456
|
+
if (n <= 0) await this.redis.del(key);
|
|
457
|
+
else await this.redis.set(key, String(n), 'EX', this.ttlSeconds);
|
|
458
|
+
}
|
|
459
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@qjp88995/agent-runtime` 的 Redis 适配:多实例部署时替换进程内的总线与协调件。
|
|
3
|
+
*
|
|
4
|
+
* 换的是七个端口——`StreamBus`、`SessionLock`、`WakeChannel`、`SubagentAwaitLease`、`TreeBudget`、
|
|
5
|
+
* `TaskLease`、`TaskSignals`。
|
|
6
|
+
* 存储类端口(跑次、消息、会话、问题、厂商文件、任务表)不在这里:它们归宿主的数据库。
|
|
7
|
+
*/
|
|
8
|
+
import type { Redis } from 'ioredis';
|
|
9
|
+
|
|
10
|
+
import type { LoggerFactory } from '@qjp88995/agent-runtime';
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
RedisSessionLock,
|
|
14
|
+
RedisSubagentAwaitLease,
|
|
15
|
+
RedisTaskLease,
|
|
16
|
+
RedisTaskSignals,
|
|
17
|
+
RedisTreeBudget,
|
|
18
|
+
RedisWakeChannel,
|
|
19
|
+
} from './coordination.js';
|
|
20
|
+
import type { RedisKeyOptions } from './keys.js';
|
|
21
|
+
import { RedisStreamBus, type RedisStreamBusOptions } from './stream-bus.js';
|
|
22
|
+
|
|
23
|
+
export {
|
|
24
|
+
RedisSessionLock,
|
|
25
|
+
type RedisSessionLockOptions,
|
|
26
|
+
RedisSubagentAwaitLease,
|
|
27
|
+
type RedisSubagentAwaitLeaseOptions,
|
|
28
|
+
RedisTaskLease,
|
|
29
|
+
type RedisTaskLeaseOptions,
|
|
30
|
+
RedisTaskSignals,
|
|
31
|
+
type RedisTaskSignalsOptions,
|
|
32
|
+
RedisTreeBudget,
|
|
33
|
+
type RedisTreeBudgetOptions,
|
|
34
|
+
RedisWakeChannel,
|
|
35
|
+
type RedisWakeChannelOptions,
|
|
36
|
+
} from './coordination.js';
|
|
37
|
+
export { type RedisKeyOptions, type RedisKeys, redisKeys } from './keys.js';
|
|
38
|
+
export { RedisStreamBus, type RedisStreamBusOptions } from './stream-bus.js';
|
|
39
|
+
|
|
40
|
+
export interface RedisPortsOptions extends RedisKeyOptions {
|
|
41
|
+
logger?: LoggerFactory;
|
|
42
|
+
/** 透传给 `RedisStreamBus`(`keyPrefix` 以外层的为准)。 */
|
|
43
|
+
bus?: Omit<RedisStreamBusOptions, 'keyPrefix'>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface RedisPorts {
|
|
47
|
+
bus: RedisStreamBus;
|
|
48
|
+
lock: RedisSessionLock;
|
|
49
|
+
wake: RedisWakeChannel;
|
|
50
|
+
awaitLease: RedisSubagentAwaitLease;
|
|
51
|
+
treeBudget: RedisTreeBudget;
|
|
52
|
+
taskLease: RedisTaskLease;
|
|
53
|
+
taskSignals: RedisTaskSignals;
|
|
54
|
+
/** 停总线定时器、关唤醒频道与转后台广播的订阅连接。不关 `redis` 本身(它是宿主的)。 */
|
|
55
|
+
dispose(): Promise<void>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 一组 Redis 实现,键名与 `createInProcessPorts()` 的对应项相同,宿主可以直接覆盖:
|
|
60
|
+
*
|
|
61
|
+
* ```ts
|
|
62
|
+
* const ports = { ...createInProcessPorts(), ...(await createRedisPorts(redis)) };
|
|
63
|
+
* ```
|
|
64
|
+
*
|
|
65
|
+
* `redis` 是宿主的共享连接(锁、租约、名额、写会话流都用它);唤醒频道、转后台广播与每个会话订阅
|
|
66
|
+
* 各自 `duplicate()` 一条。等两个频道的订阅都就绪才返回。
|
|
67
|
+
*/
|
|
68
|
+
export async function createRedisPorts(
|
|
69
|
+
redis: Redis,
|
|
70
|
+
options: RedisPortsOptions = {}
|
|
71
|
+
): Promise<RedisPorts> {
|
|
72
|
+
const { keyPrefix, logger } = options;
|
|
73
|
+
const [wake, taskSignals] = await Promise.all([
|
|
74
|
+
RedisWakeChannel.create(redis, { keyPrefix, logger }),
|
|
75
|
+
RedisTaskSignals.create(redis, { keyPrefix, logger }),
|
|
76
|
+
]);
|
|
77
|
+
const bus = new RedisStreamBus(redis, { ...options.bus, keyPrefix });
|
|
78
|
+
return {
|
|
79
|
+
bus,
|
|
80
|
+
lock: new RedisSessionLock(redis, { keyPrefix }),
|
|
81
|
+
wake,
|
|
82
|
+
awaitLease: new RedisSubagentAwaitLease(redis, { keyPrefix, logger }),
|
|
83
|
+
treeBudget: new RedisTreeBudget(redis, { keyPrefix }),
|
|
84
|
+
taskLease: new RedisTaskLease(redis, { keyPrefix }),
|
|
85
|
+
taskSignals,
|
|
86
|
+
async dispose() {
|
|
87
|
+
bus.dispose();
|
|
88
|
+
await Promise.all([wake.dispose(), taskSignals.dispose()]);
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
}
|
package/src/keys.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 键与频道名。缺省与 agent-base 线上用的一字不差(`agent-task:*` 是 0.2.0 新加的,没有旧格式要兼容)——agent-base 换上本包时可以滚动发布
|
|
3
|
+
* (新旧实例共用一个 Redis 期间锁、唤醒、租约、会话流都互通),空闲广播的载荷也照旧是
|
|
4
|
+
* JSON `{ sessionId }`,它现有的订阅方不用改。
|
|
5
|
+
*
|
|
6
|
+
* 多个 APP 共用一个 Redis 时给 `keyPrefix`(例如 `'novel:'`),键和 pub/sub 频道都加前缀。
|
|
7
|
+
* 不用 ioredis 自带的 `keyPrefix`:它只管键、不管频道,而且是整条连接的设置,宿主的其它用途会被连带。
|
|
8
|
+
*/
|
|
9
|
+
export interface RedisKeyOptions {
|
|
10
|
+
keyPrefix?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface RedisKeys {
|
|
14
|
+
lock(sessionId: string): string;
|
|
15
|
+
wakeChannel: string;
|
|
16
|
+
idleChannel: string;
|
|
17
|
+
awaited(childSessionId: string): string;
|
|
18
|
+
liveSubs(rootSessionId: string): string;
|
|
19
|
+
sessionStream(rootSessionId: string): string;
|
|
20
|
+
taskLease(taskId: string): string;
|
|
21
|
+
taskBackgroundChannel: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function redisKeys(options: RedisKeyOptions = {}): RedisKeys {
|
|
25
|
+
const p = options.keyPrefix ?? '';
|
|
26
|
+
return {
|
|
27
|
+
lock: s => `${p}agent-loop:lock:${s}`,
|
|
28
|
+
wakeChannel: `${p}agent-loop:wake`,
|
|
29
|
+
idleChannel: `${p}agent-session:idle`,
|
|
30
|
+
awaited: c => `${p}agent-subagent:awaited:${c}`,
|
|
31
|
+
liveSubs: r => `${p}agent-tree:live-subs:${r}`,
|
|
32
|
+
sessionStream: r => `${p}agent-session:${r}`,
|
|
33
|
+
taskLease: t => `${p}agent-task:lease:${t}`,
|
|
34
|
+
taskBackgroundChannel: `${p}agent-task:background`,
|
|
35
|
+
};
|
|
36
|
+
}
|