@akasha-book/session 0.2.5 → 0.3.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/README.md +3 -0
- package/lib/index-worker.mjs +72 -0
- package/lib/index.js +109 -3
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -10,3 +10,6 @@
|
|
|
10
10
|
- **索引观测线**:`session-index`(added / skipped / parseFails / ms / **lagMs**=事件循环延迟采样)/ `session-index-skip` / `session-index-error` / **`session-index-slow`**(单次索引 >1.5s 告警,含 lagMs)。
|
|
11
11
|
- 配置:`akashaDir`(核心库位置)/ `sessionsRoot`(宿主会话档案目录)/ `storeFile` / `metaFile` / `log`;默认见 `lib/index.js` 的 DEFAULTS。
|
|
12
12
|
- 自测:`node selftest.mjs`(桩 ctx 端到端;临时目录,不碰真实数据;需与本包 `core/` 并列)。
|
|
13
|
+
|
|
14
|
+
- **索引 worker 化(2026-10-10 · 计划队列 v0.4 项)**:宿主回调只投递(`postMessage`,µs 级返回),索引在**常驻 worker 线程**(`lib/index-worker.mjs`,插件加载即预热)串行执行、同会话排队去重;`indexSession` 以 `metaFile` 为锁对象串行化整段「读水位 → 追加 → 记账」(`core/lib.mjs` 的 `withFileLock`;**锁 meta 而非 store**——`appendSegments` 内部已持 store 锁,嵌套会死等)。退出协议:dispose → `{"type":"quit"}` → drain → 关闭(5s 兜底强杀)。降级链:`indexMode: 'worker' | 'inline'`(默认 worker;**连续** 3 次失败自动降级,任何成功回零)。
|
|
15
|
+
- **观测语义(worker 化后)**:`session-index` 新增 `via`(worker/inline)与 `hostMs`(宿主投递耗时);**`session-index-slow` 的含义变为「后台耗时 >1.5s」**——宿主卡顿请看 `hostMs` / `lagMs`。
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// index-worker.mjs —— 小阿卡夏 v0.4 索引 worker(worker_threads 入口)。
|
|
2
|
+
// 协议(宿主 → worker):{type:'index', id, job:{akashaDir,session,file,storeFile,metaFile,trigger,full}}
|
|
3
|
+
// {type:'quit'} / {type:'ping'}
|
|
4
|
+
// 协议(worker → 宿主):{type:'ready'} / {type:'pong'} / {type:'result', id, session, trigger, ms, ...stats|error}
|
|
5
|
+
// 语义:FIFO 串行;同 session 排队去重(保留最新一条);quit 时 drain 当前 job 后关闭。
|
|
6
|
+
import { parentPort } from 'node:worker_threads';
|
|
7
|
+
import { createRequire } from 'node:module';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
const require_ = createRequire(import.meta.url);
|
|
11
|
+
let core = null;
|
|
12
|
+
let coreDir = '';
|
|
13
|
+
function coreOf(akashaDir) {
|
|
14
|
+
if (!core || coreDir !== akashaDir) {
|
|
15
|
+
core = require_(join(akashaDir, 'session.mjs'));
|
|
16
|
+
coreDir = akashaDir;
|
|
17
|
+
}
|
|
18
|
+
return core;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const queue = [];
|
|
22
|
+
const queuedBySession = new Set();
|
|
23
|
+
let running = false;
|
|
24
|
+
let quitting = false;
|
|
25
|
+
let processed = 0;
|
|
26
|
+
|
|
27
|
+
function enqueue(job) {
|
|
28
|
+
if (queuedBySession.has(job.session)) {
|
|
29
|
+
// 同会话已在排队:保留最新一份(旧 job 丢弃;水位/去重保证不漏段)
|
|
30
|
+
for (let i = queue.length - 1; i >= 0; i--) if (queue[i].session === job.session) { queue.splice(i, 1); break; }
|
|
31
|
+
queuedBySession.delete(job.session);
|
|
32
|
+
}
|
|
33
|
+
queue.push(job);
|
|
34
|
+
queuedBySession.add(job.session);
|
|
35
|
+
parentPort.postMessage({ type: 'queued', id: job.id, session: job.session, depth: queue.length, running });
|
|
36
|
+
if (!running) drain();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function drain() {
|
|
40
|
+
running = true;
|
|
41
|
+
const job = queue.shift();
|
|
42
|
+
if (!job) {
|
|
43
|
+
running = false;
|
|
44
|
+
if (quitting) { parentPort.postMessage({ type: 'bye', processed }); parentPort.close(); }
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
queuedBySession.delete(job.session);
|
|
48
|
+
const t0 = Date.now();
|
|
49
|
+
try {
|
|
50
|
+
const stats = coreOf(job.akashaDir).indexSession({
|
|
51
|
+
file: job.file, session: job.session,
|
|
52
|
+
storeFile: job.storeFile, metaFile: job.metaFile, full: !!job.full,
|
|
53
|
+
});
|
|
54
|
+
processed += 1;
|
|
55
|
+
parentPort.postMessage({ type: 'result', id: job.id, session: job.session, trigger: job.trigger, ms: Date.now() - t0, depth: queue.length, ...stats });
|
|
56
|
+
} catch (error) {
|
|
57
|
+
parentPort.postMessage({ type: 'result', id: job.id, session: job.session, trigger: job.trigger, ms: Date.now() - t0, depth: queue.length, error: String(error?.message ?? error).slice(0, 300) });
|
|
58
|
+
}
|
|
59
|
+
setImmediate(drain);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
parentPort.on('message', (msg) => {
|
|
63
|
+
if (!msg || typeof msg !== 'object') return;
|
|
64
|
+
if (msg.type === 'index' && msg.job?.session) enqueue({ ...msg.job, id: msg.id });
|
|
65
|
+
else if (msg.type === 'ping') parentPort.postMessage({ type: 'pong', depth: queue.length, running });
|
|
66
|
+
else if (msg.type === 'quit') {
|
|
67
|
+
quitting = true;
|
|
68
|
+
if (!running && queue.length === 0) { parentPort.postMessage({ type: 'bye', processed }); parentPort.close(); }
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
parentPort.postMessage({ type: 'ready', pid: process.pid, node: process.version });
|
package/lib/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { appendFileSync, mkdirSync, readFileSync, statSync } from 'node:fs';
|
|
|
8
8
|
import { createRequire } from 'node:module';
|
|
9
9
|
import { homedir } from 'node:os';
|
|
10
10
|
import { dirname, join } from 'node:path';
|
|
11
|
+
import { Worker } from 'node:worker_threads';
|
|
11
12
|
|
|
12
13
|
export const inject = ['systemPrompt', 'timer'];
|
|
13
14
|
|
|
@@ -24,7 +25,9 @@ const DEFAULTS = {
|
|
|
24
25
|
contextOrder: 134,
|
|
25
26
|
loopWatch: true,
|
|
26
27
|
watchMinChars: 200,
|
|
27
|
-
watchCheckEvery: 480
|
|
28
|
+
watchCheckEvery: 480,
|
|
29
|
+
indexMode: 'worker', // 'worker'(v0.4 默认)| 'inline'(回退)
|
|
30
|
+
workerRespawnMs: 15000
|
|
28
31
|
};
|
|
29
32
|
|
|
30
33
|
/** 去抖判定(纯函数)。 */
|
|
@@ -246,6 +249,94 @@ export function apply(ctx, config = {}) {
|
|
|
246
249
|
for (const c of compacts) if (c.session === sid && c.indexed !== true) { c.indexed = true; c.reason = 'recovered'; c.recoveredBy = trigger; }
|
|
247
250
|
};
|
|
248
251
|
|
|
252
|
+
// ---------- 索引 worker(v0.4):宿主只投递任务,重活在后台线程串行跑 ----------
|
|
253
|
+
// 协议见 lib/index-worker.mjs;降级链:worker → (连续失败或 spawn 失败)→ inline 旧路径。
|
|
254
|
+
const workerUrl = new URL('./index-worker.mjs', import.meta.url);
|
|
255
|
+
let worker = null;
|
|
256
|
+
let workerFails = 0;
|
|
257
|
+
let workerMode = cfg.indexMode === 'inline' ? 'inline' : 'worker';
|
|
258
|
+
let quitting = false;
|
|
259
|
+
let respawnTimer = null;
|
|
260
|
+
let jobSeq = 0;
|
|
261
|
+
const pendingJobs = new Map(); // id -> { sid, trigger, extra, hostMs, stat }
|
|
262
|
+
|
|
263
|
+
const queueDepth = () => pendingJobs.size;
|
|
264
|
+
|
|
265
|
+
const scheduleRespawn = () => {
|
|
266
|
+
if (respawnTimer || quitting || workerMode !== 'worker') return;
|
|
267
|
+
respawnTimer = setTimeout(() => { respawnTimer = null; spawnWorker(); }, cfg.workerRespawnMs);
|
|
268
|
+
if (typeof respawnTimer.unref === 'function') respawnTimer.unref();
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const onWorkerMessage = (msg) => {
|
|
272
|
+
if (!msg || typeof msg !== 'object') return;
|
|
273
|
+
if (msg.type === 'ready') { log({ kind: 'session-worker', event: 'ready', node: msg.node }); return; }
|
|
274
|
+
if (msg.type === 'queued') { log({ kind: 'session-worker', event: 'queued', session: msg.session, depth: msg.depth, running: msg.running }); return; }
|
|
275
|
+
if (msg.type === 'bye') { log({ kind: 'session-worker', event: 'bye', processed: msg.processed }); return; }
|
|
276
|
+
if (msg.type !== 'result') return;
|
|
277
|
+
const p = pendingJobs.get(msg.id);
|
|
278
|
+
pendingJobs.delete(msg.id);
|
|
279
|
+
const extra = p?.extra ?? {};
|
|
280
|
+
if (msg.error) {
|
|
281
|
+
lastFailAt.set(msg.session, Date.now());
|
|
282
|
+
log({ kind: 'session-index-error', session: msg.session, trigger: msg.trigger, message: msg.error, workerMs: msg.ms, hostMs: p?.hostMs ?? null, via: 'worker' });
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (p?.stat) lastStat.set(msg.session, { size: p.stat.size, mtimeMs: p.stat.mtimeMs }); // 只记成功:失败后同档可重试
|
|
286
|
+
workerFails = 0; // 「连续失败」语义:任何一次成功都回零(2026-10-10 复查修正)
|
|
287
|
+
log({ kind: 'session-index', session: msg.session, trigger: msg.trigger, turn: extra.turn ?? null, added: msg.added, skipped: msg.skipped, parseFails: msg.parseFails, frameFails: msg.frameFails ?? 0, ms: msg.ms, hostMs: p?.hostMs ?? null, lagMs: loopLagMs, via: 'worker' });
|
|
288
|
+
if (msg.ms > 1500) log({ kind: 'session-index-slow', session: msg.session, trigger: msg.trigger, ms: msg.ms, lagMs: loopLagMs, added: msg.added, via: 'worker' });
|
|
289
|
+
settleCompacts(msg.session, msg.trigger);
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
function spawnWorker() {
|
|
293
|
+
if (workerMode !== 'worker' || worker || quitting) return;
|
|
294
|
+
try {
|
|
295
|
+
worker = new Worker(workerUrl);
|
|
296
|
+
worker.unref?.();
|
|
297
|
+
log({ kind: 'session-worker', event: 'spawn', pid: process.pid, pending: queueDepth() });
|
|
298
|
+
worker.on('message', (msg) => { try { onWorkerMessage(msg); } catch { /* 观测失败不拖宿主 */ } });
|
|
299
|
+
worker.on('error', (err) => {
|
|
300
|
+
log({ kind: 'session-worker-error', event: 'error', message: String(err?.message ?? err).slice(0, 300) });
|
|
301
|
+
worker = null;
|
|
302
|
+
workerFails += 1;
|
|
303
|
+
if (workerFails >= 3) { workerMode = 'inline'; log({ kind: 'session-worker', event: 'degraded-inline', fails: workerFails }); }
|
|
304
|
+
else scheduleRespawn();
|
|
305
|
+
});
|
|
306
|
+
worker.on('exit', (code) => {
|
|
307
|
+
const lost = pendingJobs.size;
|
|
308
|
+
if (lost) pendingJobs.clear();
|
|
309
|
+
log({ kind: 'session-worker', event: 'exit', code, lost, quitting });
|
|
310
|
+
worker = null;
|
|
311
|
+
if (!quitting) scheduleRespawn();
|
|
312
|
+
});
|
|
313
|
+
} catch (e) {
|
|
314
|
+
worker = null;
|
|
315
|
+
log({ kind: 'session-worker-error', event: 'spawn', message: String(e?.message ?? e).slice(0, 300) });
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
spawnWorker(); // 预热:apply 即起线程,首单不付冷启动
|
|
319
|
+
|
|
320
|
+
/** 投递一个索引任务;返回 { queued:true, hostMs } 或 { queued:false }(走 inline 回退)。 */
|
|
321
|
+
const dispatchIndex = (sid, file, trigger, extra, opts, st) => {
|
|
322
|
+
if (workerMode !== 'worker') return { queued: false };
|
|
323
|
+
spawnWorker();
|
|
324
|
+
if (!worker) return { queued: false };
|
|
325
|
+
const id = ++jobSeq;
|
|
326
|
+
const t0 = Date.now();
|
|
327
|
+
try {
|
|
328
|
+
worker.postMessage({ type: 'index', id, job: { akashaDir: cfg.akashaDir, session: sid, file, storeFile: cfg.storeFile, metaFile: cfg.metaFile, trigger, full: !!opts.full } });
|
|
329
|
+
const hostMs = Date.now() - t0;
|
|
330
|
+
pendingJobs.set(id, { sid, trigger, extra, hostMs, stat: st });
|
|
331
|
+
return { queued: true, hostMs };
|
|
332
|
+
} catch (e) {
|
|
333
|
+
workerFails += 1;
|
|
334
|
+
log({ kind: 'session-worker-error', event: 'post', message: String(e?.message ?? e).slice(0, 200) });
|
|
335
|
+
if (workerFails >= 3) { workerMode = 'inline'; log({ kind: 'session-worker', event: 'degraded-inline', fails: workerFails }); }
|
|
336
|
+
return { queued: false };
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
|
|
249
340
|
/**
|
|
250
341
|
* 返回索引结论(B1):{ ok:true, reason:'indexed'|'unchanged' } = 档案内容已在会话层;
|
|
251
342
|
* { ok:false, reason:'no-session'|'backoff'|'not-found'|'error' } = 没收进去;
|
|
@@ -278,11 +369,17 @@ export function apply(ctx, config = {}) {
|
|
|
278
369
|
}
|
|
279
370
|
}
|
|
280
371
|
const t0 = Date.now();
|
|
372
|
+
const disp = dispatchIndex(sid, file, trigger, extra, opts, st);
|
|
373
|
+
if (disp.queued) {
|
|
374
|
+
// 宿主只投递(v0.4):结果由 worker 回传,落地线条在 onWorkerMessage
|
|
375
|
+
return { ok: null, reason: 'queued', hostMs: disp.hostMs };
|
|
376
|
+
}
|
|
377
|
+
// 回退:inline 旧路径(indexMode:'inline' / worker 不可用 / 连续失败降级)
|
|
281
378
|
const r = core().indexSession({ file, session: sid, storeFile: cfg.storeFile, metaFile: cfg.metaFile });
|
|
282
379
|
if (st) lastStat.set(sid, { size: st.size, mtimeMs: st.mtimeMs }); // 只记成功:失败后同档可重试
|
|
283
380
|
const dur = Date.now() - t0;
|
|
284
|
-
log({ kind: 'session-index', session: sid, trigger, turn: extra.turn ?? null, added: r.added, skipped: r.skipped, parseFails: r.parseFails, frameFails: r.frameFails ?? 0, ms: dur, lagMs: loopLagMs });
|
|
285
|
-
if (dur > 1500) log({ kind: 'session-index-slow', session: sid, trigger, ms: dur, lagMs: loopLagMs, added: r.added }); // >1.5s 告警线(2026-10-07 补4)
|
|
381
|
+
log({ kind: 'session-index', session: sid, trigger, turn: extra.turn ?? null, added: r.added, skipped: r.skipped, parseFails: r.parseFails, frameFails: r.frameFails ?? 0, ms: dur, lagMs: loopLagMs, via: 'inline' });
|
|
382
|
+
if (dur > 1500) log({ kind: 'session-index-slow', session: sid, trigger, ms: dur, lagMs: loopLagMs, added: r.added, via: 'inline' }); // >1.5s 告警线(2026-10-07 补4)
|
|
286
383
|
settleCompacts(sid, trigger);
|
|
287
384
|
return { ok: true, reason: 'indexed', added: r.added };
|
|
288
385
|
} catch (error) {
|
|
@@ -336,6 +433,15 @@ export function apply(ctx, config = {}) {
|
|
|
336
433
|
} catch { /* 静默 */ }
|
|
337
434
|
}
|
|
338
435
|
|
|
436
|
+
// 退出协议(v0.4):通知 worker drain 后关闭;5s 未退则强杀。
|
|
437
|
+
ctx.effect(() => () => {
|
|
438
|
+
quitting = true;
|
|
439
|
+
try { worker?.postMessage({ type: 'quit' }); } catch { /* ignore */ }
|
|
440
|
+
const w = worker;
|
|
441
|
+
const t = setTimeout(() => { try { w?.terminate(); } catch { /* ignore */ } }, 5000);
|
|
442
|
+
if (typeof t.unref === 'function') t.unref();
|
|
443
|
+
});
|
|
444
|
+
|
|
339
445
|
// 条子:每回合一行(无内容静默)。
|
|
340
446
|
ctx.systemPrompt.context({
|
|
341
447
|
name: 'akasha:session',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akasha-book/session",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"engines": { "node": ">=22.15.0" },
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
12
|
"lib/index.js",
|
|
13
|
+
"lib/index-worker.mjs",
|
|
13
14
|
"cordis.patch.yml",
|
|
14
15
|
"README.md"
|
|
15
16
|
],
|