dsh-recall-plugin 1.5.0 → 1.6.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/CHANGELOG.md +38 -0
- package/README.en.md +15 -4
- package/README.md +10 -8
- package/cordis.patch.yml +1 -0
- package/lib/client.js +349 -48
- package/lib/config.js +24 -9
- package/lib/index.js +198 -36
- package/lib/maintenance.js +33 -8
- package/lib/snapshots.js +216 -214
- package/lib/store.js +37 -4
- package/package.json +9 -7
package/lib/index.js
CHANGED
|
@@ -12,10 +12,11 @@
|
|
|
12
12
|
* 文件拆分见 lib/ 下各模块头注释;本文件只做接线,不承载业务逻辑。
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { createConfig } from './config.js'
|
|
15
|
+
import { createConfig, Config } from './config.js'
|
|
16
16
|
import { createRuntime } from './store.js'
|
|
17
17
|
import { createSnapshots } from './snapshots.js'
|
|
18
18
|
import { createMaintenance } from './maintenance.js'
|
|
19
|
+
import { installSettingsSection } from '@deepseek-ai/dsh-settings'
|
|
19
20
|
|
|
20
21
|
export const name = 'dsh-recall-plugin'
|
|
21
22
|
|
|
@@ -23,8 +24,13 @@ export const name = 'dsh-recall-plugin'
|
|
|
23
24
|
// webServer(Client 半的 HTTP API 通道)。其余服务按需 ctx.get。
|
|
24
25
|
export const inject = ['shell', 'sessions', 'webServer']
|
|
25
26
|
|
|
26
|
-
//
|
|
27
|
-
//
|
|
27
|
+
// 入口配置 schema:cordis 加载器据此校验 insert 行 config 并填充默认值,
|
|
28
|
+
// 非法配置在插件加载时响亮失败(官方「插件配置」文档要求)。
|
|
29
|
+
export { Config }
|
|
30
|
+
|
|
31
|
+
// config 由 cordis.patch.yml 的 insert 行 config 键下发(schema 默认值兜底),
|
|
32
|
+
// 设置页「插件配置」卡片的用户覆盖经 settings namespace 热更新进 cfg
|
|
33
|
+
// (见下方 installSettingsSection 接线)
|
|
28
34
|
export function apply(ctx, config) {
|
|
29
35
|
const webServer = ctx.webServer
|
|
30
36
|
|
|
@@ -34,6 +40,27 @@ export function apply(ctx, config) {
|
|
|
34
40
|
const maint = createMaintenance(ctx, rt, snaps, cfg)
|
|
35
41
|
const state = rt.state
|
|
36
42
|
|
|
43
|
+
// ---- settings namespace「dsh-recall」:设置页「插件配置」分区正规接入 ----
|
|
44
|
+
// installSettingsSection(dsh-settings 官方辅助):settings 服务挂载后以
|
|
45
|
+
// 真 Config schema 注册 namespace、组合 base 取入口 config;服务卸载时
|
|
46
|
+
// 源回退入口 config。解析层 = schema 默认 → 组合 base → 用户文档(设置
|
|
47
|
+
// 卡片写入、dsh-settings 持久化),变更经 watch 热更新进运行中的 cfg
|
|
48
|
+
// (Object.assign 原地改,各域按调用时读取立即生效)。环境变量仍最高
|
|
49
|
+
// 优先(createConfig 内处理)。settings 服务未组装(非 web 部署)时
|
|
50
|
+
// 整段静默不运行,插件照常以入口配置工作。
|
|
51
|
+
let readSettings = () => config
|
|
52
|
+
function applyResolvedConfig(resolved) {
|
|
53
|
+
Object.assign(cfg, createConfig(resolved && typeof resolved === 'object' ? resolved : {}))
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
installSettingsSection(ctx, 'dsh-recall', Config, config, {
|
|
57
|
+
setSource: (fn) => { readSettings = fn },
|
|
58
|
+
onChange: () => applyResolvedConfig(readSettings()),
|
|
59
|
+
})
|
|
60
|
+
} catch (error) {
|
|
61
|
+
rt.recordError('recall settings namespace skipped: ' + String(error))
|
|
62
|
+
}
|
|
63
|
+
|
|
37
64
|
// 平台门控:win32 走 PowerShell 模板,linux/darwin 走 bash 模板
|
|
38
65
|
// (ctx.shell 由 DSH 平台层单选挂载 pwsh/bash 执行器,见 dsh-shell README)。
|
|
39
66
|
// 其余平台干净短路:init 返回 unsupported,Client 弹一次性提示;
|
|
@@ -51,6 +78,9 @@ export function apply(ctx, config) {
|
|
|
51
78
|
// 也不是零成本(1 条 shell + 若干日志解压)。设置页打开、删除后刷新
|
|
52
79
|
// 都会重拉,30s 缓存让二次打开即时;delete 与新快照落地时失效。
|
|
53
80
|
let listCache = { at: 0, payload: null }
|
|
81
|
+
// 排除配置枚举缓存(30s):exclude-get 首次要遍历工作区、逐文件 shell 读,
|
|
82
|
+
// 设置页反复打开时不该每次重算;exclude-set 成功写入后立即失效。
|
|
83
|
+
let excludeCache = { at: 0, payload: null }
|
|
54
84
|
|
|
55
85
|
// 会话标题缓存(apply 级跨请求共享):冷会话标题要 readSession 整日志
|
|
56
86
|
// 解压 + 重放校验(大日志 10 秒级),绝不能挡列表首屏——list 只查
|
|
@@ -138,7 +168,9 @@ export function apply(ctx, config) {
|
|
|
138
168
|
if (cwd) roots.add(cwd)
|
|
139
169
|
}
|
|
140
170
|
const byFile = new Map()
|
|
141
|
-
|
|
171
|
+
// 并行解析全部 root:冷启动时每个 root 首次 resolveStore 可能触发 shell
|
|
172
|
+
// 建目录/写 root.txt,串行会随工作区数量线性变慢;Promise.all 让它们并发。
|
|
173
|
+
await Promise.all(Array.from(roots).map(async (root) => {
|
|
142
174
|
try {
|
|
143
175
|
const store = await rt.resolveStore(root)
|
|
144
176
|
if (store && !byFile.has(store.excludeFile)) byFile.set(store.excludeFile, { store, roots: [] })
|
|
@@ -146,7 +178,7 @@ export function apply(ctx, config) {
|
|
|
146
178
|
} catch (error) {
|
|
147
179
|
/* 单个根解析失败只影响它自己,不拖垮整个列表 */
|
|
148
180
|
}
|
|
149
|
-
}
|
|
181
|
+
}))
|
|
150
182
|
// 磁盘兜底:冷启动时会话注册表为空(惰性载入),但 home 容器目录可能
|
|
151
183
|
// 早已存在(历史快照)。容器在 ⇒ 共享 exclude.txt 可编辑(哪怕从未
|
|
152
184
|
// 写过、内容为空);容器不在 ⇒ 全新安装,让设置页显示引导文案。
|
|
@@ -278,6 +310,21 @@ export function apply(ctx, config) {
|
|
|
278
310
|
return run
|
|
279
311
|
}
|
|
280
312
|
|
|
313
|
+
// 通用并发限制器:冷会话标题/消息文本补齐会 readSession 整日志解压,
|
|
314
|
+
// 首次大量冷数据时全量 Promise.all 会同时压垮磁盘/CPU。限制同时最多
|
|
315
|
+
// CONCURRENCY 个任务,剩余排队执行;这是纯内存调度,不依赖额外依赖。
|
|
316
|
+
async function runLimited(tasks, concurrency) {
|
|
317
|
+
const limit = concurrency > 0 ? concurrency : 4
|
|
318
|
+
let index = 0
|
|
319
|
+
const workers = Array.from({ length: Math.min(limit, tasks.length) }, async () => {
|
|
320
|
+
while (index < tasks.length) {
|
|
321
|
+
const task = tasks[index++]
|
|
322
|
+
await task()
|
|
323
|
+
}
|
|
324
|
+
})
|
|
325
|
+
await Promise.all(workers)
|
|
326
|
+
}
|
|
327
|
+
|
|
281
328
|
// 收集全量快照记录(内存 + 磁盘 dump 并集),供树形管理的按工作区/
|
|
282
329
|
// 会话批量删除使用。返回 Map<消息ID, {id, root, sessionId, time}>;
|
|
283
330
|
// 磁盘条目缺 root 时按 store 的 root.txt/内存映射补全(与 manage list
|
|
@@ -392,7 +439,9 @@ export function apply(ctx, config) {
|
|
|
392
439
|
homeFallback: store ? !store.home : false
|
|
393
440
|
}
|
|
394
441
|
}
|
|
395
|
-
|
|
442
|
+
// 顺带下发客户端行为开关(fillDraft 等):Client 无须为读配置单开请求,
|
|
443
|
+
// init 是每会话必经的预热通道
|
|
444
|
+
return { ok: Boolean(root), root: root || null, notice, config: { refillDraft: cfg.refillDraft } }
|
|
396
445
|
},
|
|
397
446
|
|
|
398
447
|
'snapshot-info': async (args) => {
|
|
@@ -443,14 +492,19 @@ export function apply(ctx, config) {
|
|
|
443
492
|
// 设置页「撤回设置」标签的配置读取。不支持平台照常短路:Client
|
|
444
493
|
// 显示不可用提示而不是空白表单,与 init 的 notice 语义对齐。
|
|
445
494
|
if (!supported) return { ok: false, unsupported: true }
|
|
495
|
+
// 30s 结果缓存:首次进入要并行 resolveStore + 逐文件 shell 读,
|
|
496
|
+
// 二次打开/切标签不应重复付出这份代价;exclude-set 写入后失效。
|
|
497
|
+
if (excludeCache.payload && Date.now() - excludeCache.at < 30000) return excludeCache.payload
|
|
446
498
|
const byFile = await listExcludeFiles()
|
|
447
|
-
|
|
448
|
-
|
|
499
|
+
// 并行读取各 exclude 文件内容:每个文件一条 shell,串行会放大延迟
|
|
500
|
+
const files = await Promise.all(Array.from(byFile.entries()).map(async ([path, info]) => {
|
|
449
501
|
let content = ''
|
|
450
502
|
try { content = await snaps.readExclude(info.store) } catch (error) { content = '' }
|
|
451
|
-
|
|
452
|
-
}
|
|
453
|
-
|
|
503
|
+
return { path, home: Boolean(info.store.home), roots: info.roots, content }
|
|
504
|
+
}))
|
|
505
|
+
const payload = { ok: true, files }
|
|
506
|
+
excludeCache = { at: Date.now(), payload }
|
|
507
|
+
return payload
|
|
454
508
|
},
|
|
455
509
|
|
|
456
510
|
'exclude-set': async (args) => {
|
|
@@ -463,6 +517,70 @@ export function apply(ctx, config) {
|
|
|
463
517
|
const info = byFile.get(path)
|
|
464
518
|
if (!info) return { ok: false, code: 'UNKNOWN_PATH', message: '未知的排除文件路径' }
|
|
465
519
|
await snaps.writeExclude(info.store, content)
|
|
520
|
+
// 写入后立即失效:设置页保存后刷新必须看到最新内容
|
|
521
|
+
excludeCache.payload = null
|
|
522
|
+
return { ok: true }
|
|
523
|
+
},
|
|
524
|
+
|
|
525
|
+
// 设置页「插件配置」卡片读配置:resolved 全量值 + 用户已覆盖字段(来自
|
|
526
|
+
// settings.describe 的 user 层,字段在层里出现即用户覆盖)+ env 锁定
|
|
527
|
+
// 字段(环境变量优先级最高,设置改不动)+ 可写性(只读 provider 禁存)。
|
|
528
|
+
'config-get': async () => {
|
|
529
|
+
const envLocks = {
|
|
530
|
+
gcSnaps: Boolean(process.env && process.env.DSH_RECALL_GC_SNAPS),
|
|
531
|
+
gcHours: Boolean(process.env && process.env.DSH_RECALL_GC_HOURS),
|
|
532
|
+
}
|
|
533
|
+
let overridden = {}
|
|
534
|
+
let writable = false
|
|
535
|
+
try {
|
|
536
|
+
const settings = ctx.get('settings')
|
|
537
|
+
if (settings && typeof settings.describe === 'function') {
|
|
538
|
+
const list = settings.describe()
|
|
539
|
+
const ours = (Array.isArray(list) ? list : []).find((d) => d && d.ns === 'dsh-recall')
|
|
540
|
+
if (ours && ours.user && typeof ours.user === 'object') overridden = ours.user
|
|
541
|
+
writable = settings.writable !== false
|
|
542
|
+
}
|
|
543
|
+
} catch (error) { /* describe 不可用按「无覆盖」处理 */ }
|
|
544
|
+
return {
|
|
545
|
+
ok: true,
|
|
546
|
+
values: {
|
|
547
|
+
gcSnaps: cfg.gcSnaps,
|
|
548
|
+
gcHours: cfg.gcHours,
|
|
549
|
+
maxFileBytes: cfg.maxFileBytes,
|
|
550
|
+
baseExcludes: cfg.baseExcludes.slice(),
|
|
551
|
+
refillDraft: cfg.refillDraft,
|
|
552
|
+
},
|
|
553
|
+
overridden,
|
|
554
|
+
envLocks,
|
|
555
|
+
writable,
|
|
556
|
+
}
|
|
557
|
+
},
|
|
558
|
+
|
|
559
|
+
// 设置页「插件配置」卡片存配置:白名单字段 + 类型清洗后经 settings.update
|
|
560
|
+
// 写进用户层(schema 校验失败会在持久化前 reject,错误信息回显卡片),
|
|
561
|
+
// watch 链路把新值热更新进 cfg,无需重启。
|
|
562
|
+
'config-set': async (args) => {
|
|
563
|
+
const patch = args && args.patch && typeof args.patch === 'object' ? args.patch : {}
|
|
564
|
+
const clean = {}
|
|
565
|
+
if (patch.gcSnaps !== undefined) clean.gcSnaps = Number(patch.gcSnaps)
|
|
566
|
+
if (patch.gcHours !== undefined) clean.gcHours = Number(patch.gcHours)
|
|
567
|
+
if (patch.maxFileBytes !== undefined) clean.maxFileBytes = Number(patch.maxFileBytes)
|
|
568
|
+
if (patch.refillDraft !== undefined) clean.refillDraft = Boolean(patch.refillDraft)
|
|
569
|
+
if (patch.baseExcludes !== undefined) {
|
|
570
|
+
if (!Array.isArray(patch.baseExcludes)) return { ok: false, code: 'BAD_TYPE', message: 'baseExcludes 必须是字符串数组' }
|
|
571
|
+
clean.baseExcludes = patch.baseExcludes.filter((p) => typeof p === 'string' && p.trim())
|
|
572
|
+
}
|
|
573
|
+
if (!Object.keys(clean).length) return { ok: false, code: 'EMPTY_PATCH', message: '没有可写入的配置字段' }
|
|
574
|
+
let settings = null
|
|
575
|
+
try { settings = ctx.get('settings') } catch (error) { settings = null }
|
|
576
|
+
if (!settings || typeof settings.update !== 'function') {
|
|
577
|
+
return { ok: false, code: 'SETTINGS_UNAVAILABLE', message: '设置服务不可用:请在 profile 的 cordis.patch.yml 按 id: recall 覆盖配置' }
|
|
578
|
+
}
|
|
579
|
+
try {
|
|
580
|
+
await settings.update('dsh-recall', clean)
|
|
581
|
+
} catch (error) {
|
|
582
|
+
return { ok: false, code: 'SETTINGS_WRITE_FAILED', message: '配置写入失败:' + String(error && error.message ? error.message : error) }
|
|
583
|
+
}
|
|
466
584
|
return { ok: true }
|
|
467
585
|
},
|
|
468
586
|
|
|
@@ -542,10 +660,14 @@ export function apply(ctx, config) {
|
|
|
542
660
|
// 重放校验,大日志 10 秒级——独立于列表让首屏即时。并发交给
|
|
543
661
|
// sessionQuery 自带的 inspect 并发闸,这里全量并行发车。
|
|
544
662
|
if (!supported) return { ok: false, unsupported: true }
|
|
545
|
-
const ids = Array.
|
|
663
|
+
const ids = Array.from(new Set(
|
|
664
|
+
(Array.isArray(args && args.sessionIds) ? args.sessionIds.map(String) : []).filter(Boolean)
|
|
665
|
+
)).slice(0, 100)
|
|
546
666
|
const out = {}
|
|
547
|
-
|
|
548
|
-
|
|
667
|
+
// 并发限 4:冷标题 readSession 是重 IO,全量并发会把首次设置页
|
|
668
|
+
// 的 shell/日志解压都挤在一起;限制后列表本身不受影响,标题渐进补齐。
|
|
669
|
+
await runLimited(ids.map((sid) => async () => {
|
|
670
|
+
if (out[sid] !== undefined) return
|
|
549
671
|
let title = liveTitleFast(sid)
|
|
550
672
|
if (title === null) {
|
|
551
673
|
const query = ctx.get('sessionQuery')
|
|
@@ -558,7 +680,7 @@ export function apply(ctx, config) {
|
|
|
558
680
|
}
|
|
559
681
|
sessionTitles.set(sid, title)
|
|
560
682
|
out[sid] = title
|
|
561
|
-
}))
|
|
683
|
+
}), 4)
|
|
562
684
|
return { ok: true, titles: out }
|
|
563
685
|
}
|
|
564
686
|
if (op === 'messages') {
|
|
@@ -576,7 +698,7 @@ export function apply(ctx, config) {
|
|
|
576
698
|
bySession.get(sid).push(mid)
|
|
577
699
|
}
|
|
578
700
|
const texts = {}
|
|
579
|
-
await
|
|
701
|
+
await runLimited(Array.from(bySession.entries()).map(([sid, mids]) => async () => {
|
|
580
702
|
// 该会话所有消息都已缓存(含 null)时,不必 readSession 冷读
|
|
581
703
|
const allCached = mids.every((mid) => messageTexts.has(String(sid) + '\u0000' + String(mid)))
|
|
582
704
|
let log = null
|
|
@@ -604,16 +726,31 @@ export function apply(ctx, config) {
|
|
|
604
726
|
messageTexts.set(key, text)
|
|
605
727
|
texts[mid] = text
|
|
606
728
|
}
|
|
607
|
-
}))
|
|
729
|
+
}), 4)
|
|
608
730
|
return { ok: true, messageTexts: texts }
|
|
609
731
|
}
|
|
610
732
|
if (op === 'usage') {
|
|
611
|
-
|
|
612
|
-
if (
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
733
|
+
let bytes = 0
|
|
734
|
+
if (sessionId) {
|
|
735
|
+
// 旧调用方(带会话上下文):单工作区占用
|
|
736
|
+
const root = await rt.resolveRoot(sessionId)
|
|
737
|
+
if (!root) return { ok: false, code: 'NO_ROOT', message: '无法解析当前工作区' }
|
|
738
|
+
const store = state.stores.get(root)
|
|
739
|
+
if (!store) return { ok: false, code: 'NO_STORE', message: '当前工作区尚未创建快照存储' }
|
|
740
|
+
const out = await rt.runShell(rt.scripts.diskUsageScript(store.dir), { stdoutMaxBytes: 4096 })
|
|
741
|
+
bytes = parseInt(rt.scripts.stripBom(out).trim(), 10) || 0
|
|
742
|
+
} else {
|
|
743
|
+
// 新调用方(settings.plugin.item 卡片无会话上下文):全部已知
|
|
744
|
+
// store 汇总。store 全集取内存缓存(启动预热填齐);单个失败
|
|
745
|
+
// 不影响汇总,best-effort。
|
|
746
|
+
for (const store of state.stores.values()) {
|
|
747
|
+
if (!store || !store.dir) continue
|
|
748
|
+
try {
|
|
749
|
+
const out = await rt.runShell(rt.scripts.diskUsageScript(store.dir), { stdoutMaxBytes: 4096 })
|
|
750
|
+
bytes += parseInt(rt.scripts.stripBom(out).trim(), 10) || 0
|
|
751
|
+
} catch (error) { /* 单 store 失败跳过 */ }
|
|
752
|
+
}
|
|
753
|
+
}
|
|
617
754
|
return { ok: true, bytes }
|
|
618
755
|
}
|
|
619
756
|
if (op === 'delete') {
|
|
@@ -676,7 +813,11 @@ export function apply(ctx, config) {
|
|
|
676
813
|
return { ok: true }
|
|
677
814
|
}
|
|
678
815
|
if (op === 'gc') {
|
|
679
|
-
|
|
816
|
+
// 带会话上下文:只 gc 该会话的工作区;无上下文(设置卡片):
|
|
817
|
+
// 全部已知 store 逐个 gc。两者都排进串行队列,与快照互斥。
|
|
818
|
+
const done = sessionId
|
|
819
|
+
? await enqueue(() => maint.runGc(sessionId, true))
|
|
820
|
+
: await enqueue(() => maint.runGcAll())
|
|
680
821
|
return { ok: true, gc: Boolean(done) }
|
|
681
822
|
}
|
|
682
823
|
return { ok: false, code: 'UNKNOWN_OP', message: '未知的管理操作: ' + op }
|
|
@@ -730,16 +871,37 @@ export function apply(ctx, config) {
|
|
|
730
871
|
// 启动预热:所有已存在工作区解析存储、重建索引与孤儿快照,
|
|
731
872
|
// 并清理旧版项目内 blobs 目录(home 可用时)。
|
|
732
873
|
// 不触发维护(gc/清理):开机预热应尽量轻,重活等第一条消息再按节流来。
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
.
|
|
740
|
-
.
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
874
|
+
// 冷启动时 ctx.sessions.list() 常为空(惰性载入),必须叠加
|
|
875
|
+
// sessionQuery.listSessions() 冷元数据,否则设置页首次打开仍要现场建 store。
|
|
876
|
+
// apply 不是 async,这里用 IIFE 把冷元数据扫描包成异步任务。
|
|
877
|
+
;(async () => {
|
|
878
|
+
const warmupRoots = new Map()
|
|
879
|
+
for (const session of ctx.sessions.list()) {
|
|
880
|
+
const cwd = session && session.header && session.header.cwd
|
|
881
|
+
if (cwd && !warmupRoots.has(cwd)) warmupRoots.set(cwd, session.id)
|
|
882
|
+
}
|
|
883
|
+
const querySvc = ctx.get('sessionQuery')
|
|
884
|
+
if (querySvc && typeof querySvc.listSessions === 'function') {
|
|
885
|
+
try {
|
|
886
|
+
const records = await querySvc.listSessions()
|
|
887
|
+
for (const record of records || []) {
|
|
888
|
+
// listSessions 记录形如 {header, live, persisted},会话 id 在
|
|
889
|
+
// header.id——此前误用顶层 record.id(恒 undefined),预热重建
|
|
890
|
+
// 的孤儿快照 sessionId 记为空,树形管理里会落进「已删除会话」。
|
|
891
|
+
const id = record && record.header && record.header.id ? record.header.id : null
|
|
892
|
+
const cwd = record && record.header && record.header.cwd
|
|
893
|
+
if (cwd && !warmupRoots.has(cwd)) warmupRoots.set(cwd, id)
|
|
894
|
+
}
|
|
895
|
+
} catch (error) { /* 冷元数据不可用则退回 live 注册表 */ }
|
|
896
|
+
}
|
|
897
|
+
for (const [cwd, sessionId] of warmupRoots) {
|
|
898
|
+
Promise.resolve(rt.resolveStore(cwd))
|
|
899
|
+
.then(() => rt.tryUpgradeToHome(cwd))
|
|
900
|
+
.then((store) => rt.ensureGit(cwd, store))
|
|
901
|
+
.then(() => snaps.loadIndex(cwd, sessionId))
|
|
902
|
+
.then(() => snaps.rebuildOrphans(cwd, sessionId))
|
|
903
|
+
.then(() => rt.cleanupLegacy(cwd))
|
|
904
|
+
.catch(() => {})
|
|
905
|
+
}
|
|
906
|
+
})()
|
|
745
907
|
}
|
package/lib/maintenance.js
CHANGED
|
@@ -11,13 +11,12 @@
|
|
|
11
11
|
* 接线),因此 gc/清理与快照天然互斥,不存在 git 锁竞态。
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
// gc 节流阈值来自 config
|
|
15
|
-
//
|
|
14
|
+
// gc 节流阈值来自 config 域(设置页「插件配置」卡片可实时改写 cfg,
|
|
15
|
+
// 因此这里按调用时取值而不是工厂创建时快照;环境变量
|
|
16
|
+
// DSH_RECALL_GC_SNAPS/GC_HOURS 仍最高优先,见 config.js):
|
|
16
17
|
// 每 gcSnaps 条快照或距上次 gc gcHours 小时,先到先触发。默认「50 条或
|
|
17
18
|
// 24 小时」——重活(gc)一天至多一次的量级,轻会话用户也不会等太久。
|
|
18
19
|
export function createMaintenance(ctx, rt, snaps, config) {
|
|
19
|
-
const GC_SNAPS = config.gcSnaps
|
|
20
|
-
const GC_MS = config.gcHours * 3600000
|
|
21
20
|
const sessions = ctx.sessions
|
|
22
21
|
const state = rt.state
|
|
23
22
|
// 平台选择的脚本模板(gc/purge 两套模板同名导出)
|
|
@@ -97,7 +96,7 @@ export function createMaintenance(ctx, rt, snaps, config) {
|
|
|
97
96
|
const last = state.gcLastAt.get(store.git) || 0
|
|
98
97
|
const count = (state.gcCount.get(store.git) || 0) + 1
|
|
99
98
|
state.gcCount.set(store.git, count)
|
|
100
|
-
if (!force && count <
|
|
99
|
+
if (!force && count < config.gcSnaps && now - last < config.gcHours * 3600000) return false
|
|
101
100
|
state.gcCount.set(store.git, 0)
|
|
102
101
|
try {
|
|
103
102
|
await sweepDeletedSessions()
|
|
@@ -109,12 +108,38 @@ export function createMaintenance(ctx, rt, snaps, config) {
|
|
|
109
108
|
return true
|
|
110
109
|
}
|
|
111
110
|
|
|
111
|
+
// 全局 gc(设置卡片没有会话上下文):清理扫描一次 + 逐 store gc。
|
|
112
|
+
// store 全集取内存缓存(启动预热与历次操作会填齐已知工作区);逐个
|
|
113
|
+
// best-effort,单个失败记错误继续。调用方(manage 端点)把它排进同一条
|
|
114
|
+
// 串行队列,与快照天然互斥,无 git 锁竞态。
|
|
115
|
+
async function runGcAll() {
|
|
116
|
+
const stores = Array.from(new Set(Array.from(state.stores.values()).filter(Boolean)))
|
|
117
|
+
if (!stores.length || !state.gitExe) return false
|
|
118
|
+
try {
|
|
119
|
+
await sweepDeletedSessions()
|
|
120
|
+
} catch (error) {
|
|
121
|
+
rt.recordError('recall sweep failed: ' + String(error))
|
|
122
|
+
}
|
|
123
|
+
let done = 0
|
|
124
|
+
for (const store of stores) {
|
|
125
|
+
try {
|
|
126
|
+
await rt.runShell(S.gcScript(store, state.gitExe), { timeoutMs: 600000, stdoutMaxBytes: 4096 })
|
|
127
|
+
done++
|
|
128
|
+
} catch (error) {
|
|
129
|
+
rt.recordError('recall gc failed for ' + (store && store.git) + ': ' + String(error))
|
|
130
|
+
}
|
|
131
|
+
state.gcLastAt.set(store.git, Date.now())
|
|
132
|
+
state.gcCount.set(store.git, 0)
|
|
133
|
+
}
|
|
134
|
+
return true
|
|
135
|
+
}
|
|
136
|
+
|
|
112
137
|
// 每条消息快照后串行调用(见 index.js 事件接线)
|
|
113
138
|
async function maybeMaintain(sessionId) {
|
|
114
139
|
await runGc(sessionId, false)
|
|
115
140
|
}
|
|
116
141
|
|
|
117
|
-
//
|
|
118
|
-
// purgeSession 是
|
|
119
|
-
return { maybeMaintain, runGc }
|
|
142
|
+
// 模块收敛:只导出被装配层消费的入口;sweepDeletedSessions /
|
|
143
|
+
// purgeSession 是 gc 的内部步骤,不对外暴露面
|
|
144
|
+
return { maybeMaintain, runGc, runGcAll }
|
|
120
145
|
}
|