dsh-session-conductor 0.0.0-stage → 1.0.9

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.
Files changed (68) hide show
  1. package/README.md +165 -2
  2. package/cordis.patch.yml +48 -0
  3. package/lib/apply.js +1361 -0
  4. package/lib/client-parts/apply.js +43 -0
  5. package/lib/client-parts/components/compaction.js +124 -0
  6. package/lib/client-parts/components/conductor-settings.js +62 -0
  7. package/lib/client-parts/components/group.js +115 -0
  8. package/lib/client-parts/components/panel.js +1265 -0
  9. package/lib/client-parts/components/processing.js +98 -0
  10. package/lib/client-parts/components/session-icon.js +20 -0
  11. package/lib/client-parts/components/settings.js +415 -0
  12. package/lib/client-parts/foundation/bootstrap.js +48 -0
  13. package/lib/client-parts/foundation/i18n.js +74 -0
  14. package/lib/client-parts/foundation/list-cache.js +28 -0
  15. package/lib/client-parts/foundation/styles.js +236 -0
  16. package/lib/client.js +2534 -0
  17. package/lib/core/config.js +159 -0
  18. package/lib/core/domain.js +157 -0
  19. package/lib/core/lazy.js +20 -0
  20. package/lib/core/state.js +106 -0
  21. package/lib/core.js +82 -0
  22. package/lib/eio-repair.js +204 -0
  23. package/lib/features/context-usage/index.js +219 -0
  24. package/lib/features/continue/eligibility.js +49 -0
  25. package/lib/features/continue/scan.js +134 -0
  26. package/lib/features/continue/session.js +235 -0
  27. package/lib/features/inject/preset.js +20 -0
  28. package/lib/features/member-model/index.js +14 -0
  29. package/lib/features/member-model/locate.js +157 -0
  30. package/lib/features/member-model/override.js +76 -0
  31. package/lib/features/member-model/setup.js +55 -0
  32. package/lib/features/member-model/switch.js +186 -0
  33. package/lib/features/rename/analysis.js +303 -0
  34. package/lib/features/rename/title.js +158 -0
  35. package/lib/features/rename/value.js +77 -0
  36. package/lib/features/search.js +119 -0
  37. package/lib/features/session-ops-routes.js +218 -0
  38. package/lib/features/session-ops.js +414 -0
  39. package/lib/group.js +303 -0
  40. package/lib/guards.js +28 -0
  41. package/lib/http.js +24 -0
  42. package/lib/i18n/en.json +204 -0
  43. package/lib/i18n/zh.json +204 -0
  44. package/lib/index.js +78 -0
  45. package/lib/repair.js +398 -0
  46. package/lib/seq-gap-repair.js +224 -0
  47. package/lib/session-codec.js +37 -0
  48. package/lib/session-log.js +46 -0
  49. package/lib/sessions/detach.js +113 -0
  50. package/lib/sessions/events.js +73 -0
  51. package/lib/sessions/interruption.js +73 -0
  52. package/lib/sessions/list-cache.js +175 -0
  53. package/lib/sessions/list.js +187 -0
  54. package/lib/sessions/route.js +60 -0
  55. package/lib/sessions/turn.js +21 -0
  56. package/lib/shared/constants.js +20 -0
  57. package/lib/shared/log.js +17 -0
  58. package/lib/shared/settings.js +14 -0
  59. package/lib/shared/test-hooks.js +93 -0
  60. package/lib/shared/util.js +6 -0
  61. package/lib/template-inject.js +289 -0
  62. package/lib/value.js +430 -0
  63. package/lib/zstd-frames.js +177 -0
  64. package/package.json +65 -3
  65. package/skills/dsh-session-conductor-functions.md +197 -0
  66. package/skills/dsh-session-conductor.md +151 -0
  67. package/skills/dsh-session-official-doc.md +245 -0
  68. package/skills/task-completion-report.md +56 -0
@@ -0,0 +1,159 @@
1
+ // dsh-session-conductor — 运行期配置与开关(插件全局状态)
2
+ //
3
+ // 【为什么单独成文件】cfg(运行期配置)与 switchGroups(自动重命名/自动续跑开关缓存)
4
+ // 是插件被几乎所有域读写的全局状态;集中在此可让各域 import 同一份,
5
+ // 不必反向依赖插件入口(避免循环引用),也让「配置从哪来、写到哪」只此一份实现。
6
+
7
+ import path from "node:path";
8
+ import { homedir } from "node:os";
9
+ import { readFile, mkdir } from "node:fs/promises";
10
+ import { writeFileSync, renameSync, statSync } from "node:fs";
11
+ import { log } from "../shared/log.js";
12
+
13
+ /** 插件默认配置(面板可改;cfg 的初值来源)。 */
14
+ export const DEFAULTS = {
15
+ enabled: true, // 自动续跑总开关
16
+ defaultAutoContinue: false, // 单会话默认关闭,面板可逐会话显式开启
17
+ failRetryDelayMs: 30000, // 本轮运行失败识别后延迟续跑(默认 30s,避免立即重试)
18
+ maxConcurrent: 2, // 全局同时续跑的会话数上限
19
+ maxAttached: 12, // 活跃(attached)会话上限,达到后自动续跑暂停
20
+ listInspectBatch: 2, // 会话列表「冷会话 inspect」并发上限(防一次性并发全量会话把内存打爆)
21
+ listCacheMs: 5000, // 会话列表结果缓存时长(毫秒)——合并同一瞬间的多次 list 请求
22
+ cooldownMs: 15 * 60 * 1000, // 同会话两次自动续跑最小间隔
23
+ maxContinuesPerSession: 3, // 每会话自动续跑总次数上限(防死循环)
24
+ turnTimeoutMs: 20 * 60 * 1000, // 单回合续跑最长等待,超时取消
25
+ scanIntervalMs: 5 * 60 * 1000, // 周期扫描间隔
26
+ };
27
+
28
+ /** 运行期配置(面板/测试可改)。外部只读引用;改整体请用 setConfig。 */
29
+ export let cfg = { ...DEFAULTS };
30
+
31
+ /** 整体替换运行期配置(测试钩子 / 面板应用配置时用)。 */
32
+ export function setConfig(next) {
33
+ cfg = { ...DEFAULTS, ...(next ?? {}) };
34
+ }
35
+
36
+ /**
37
+ * 开关组缓存:autoRename / autoContinue 两组的「会话 → 开关/记账」。
38
+ * 落盘在 <DSH_HOME>/session-conductor/config.json,内存是它的缓存。
39
+ */
40
+ const switchGroups = { autoRename: new Map(), autoContinue: new Map() };
41
+
42
+ /** 解析 DSH 主目录:DSH_HOME 环境变量 > ~/.dsh */
43
+ export function resolveDshHome(ctx, c) {
44
+ if (process.env.DSH_HOME) return process.env.DSH_HOME;
45
+ const home = process.env.HOME || process.env.USERPROFILE || homedir();
46
+ return home ? path.join(home, '.dsh') : '';
47
+ }
48
+
49
+ /**
50
+ * 模板/额外注入系统提示词的「目录浏览」根目录 = DSH 工作区目录(HOME/工作区),
51
+ * 即工作区(skill 仓库、项目仓库都在其下),而不是 DSH 主目录(~/.dsh,只放落盘文件)。
52
+ * 存在性探测:HOME/工作区 存在则用它;否则回退 DSH_HOME(老布局)。
53
+ */
54
+ export function resolveBrowseRoot(ctx, c) {
55
+ const home = process.env.HOME || process.env.USERPROFILE || homedir();
56
+ const ws = home ? path.join(home, '工作区') : '';
57
+ if (ws) {
58
+ try { if (statSync(ws).isDirectory()) return ws; } catch { /* 不存在则回退 */ }
59
+ }
60
+ return resolveDshHome(ctx, c);
61
+ }
62
+
63
+ /** config.json 路径(<DSH_HOME>/session-conductor/config.json)。 */
64
+ export function pluginConfigPath(ctx) {
65
+ const home = resolveDshHome(ctx, cfg);
66
+ return home ? path.join(home, "session-conductor", "config.json") : "";
67
+ }
68
+
69
+ /** 读某组(autoRename/autoContinue)某会话的开关/记账(内存缓存;无记录返回 null)。 */
70
+ export function readSwitch(group, sessionId) {
71
+ const map = switchGroups[group];
72
+ return map?.get(sessionId) ?? null;
73
+ }
74
+
75
+ /** 改→写:更新缓存并原子落盘 config.json,返回更新后 entry。
76
+ * group ∈ "autoRename" | "autoContinue";defaultEntry 为该组无记录时的基线。 */
77
+ export async function patchSwitch(ctx, group, sessionId, patch, defaultEntry = {}) {
78
+ const map = switchGroups[group];
79
+ if (!map) throw new Error(`未知开关组 ${group}`);
80
+ const cur = map.get(sessionId) ?? defaultEntry;
81
+ const next = { ...cur, ...patch };
82
+ map.set(sessionId, next);
83
+ await savePluginConfig(ctx);
84
+ return next;
85
+ }
86
+
87
+ /** 从 config.json 载入两组开关到内存缓存(启动时/测试)。文件缺失或损坏 → 空配置。 */
88
+ export async function loadPluginConfig(ctx) {
89
+ const file = pluginConfigPath(ctx);
90
+ const parsed = {};
91
+ if (file) {
92
+ try {
93
+ const j = JSON.parse(await readFile(file, "utf8"));
94
+ if (j && typeof j === "object") Object.assign(parsed, j);
95
+ } catch { /* 文件不存在/损坏 → 空配置 */ }
96
+ }
97
+ for (const group of Object.keys(switchGroups)) {
98
+ const src = parsed[group] && typeof parsed[group] === "object" ? parsed[group] : {};
99
+ switchGroups[group].clear();
100
+ for (const [sid, e] of Object.entries(src)) if (e && typeof e === "object") switchGroups[group].set(sid, { ...e });
101
+ }
102
+ }
103
+
104
+ /** 原子落盘 config.json(临时文件 + rename;保留未知顶层字段)。 */
105
+ export async function savePluginConfig(ctx) {
106
+ const file = pluginConfigPath(ctx);
107
+ if (!file) return;
108
+ let rest = {};
109
+ try {
110
+ const j = JSON.parse(await readFile(file, "utf8"));
111
+ if (j && typeof j === "object") rest = j;
112
+ } catch { /* 文件不存在/损坏 → 全量写 */ }
113
+ await mkdir(path.dirname(file), { recursive: true });
114
+ const tmpPath = file + ".tmp";
115
+ writeFileSync(tmpPath, JSON.stringify({
116
+ ...rest,
117
+ autoRename: Object.fromEntries(switchGroups.autoRename),
118
+ autoContinue: Object.fromEntries(switchGroups.autoContinue),
119
+ }, null, 2), "utf8");
120
+ renameSync(tmpPath, file);
121
+ }
122
+
123
+ /** 启动复位:自动续跑开关全部置 false(每次 DSH 启动默认关闭;autoRename 不复位)。幂等。 */
124
+ export async function resetAutoContinueOnStart(ctx) {
125
+ await loadPluginConfig(ctx);
126
+ let anyOn = false;
127
+ for (const e of switchGroups.autoContinue.values()) if (e?.enabled === true) { anyOn = true; break; }
128
+ if (!anyOn) return;
129
+ for (const [sid, e] of switchGroups.autoContinue) switchGroups.autoContinue.set(sid, { ...e, enabled: false });
130
+ await savePluginConfig(ctx);
131
+ log(ctx, "🔒 启动复位:自动续跑开关已全部关闭(面板可逐会话开启)");
132
+ }
133
+
134
+ /** 会话的自动重命名是否开启(读 config 缓存;兼容旧签名,state 忽略)。 */
135
+ export function autoRenameEnabled(ctx, state, sessionId) {
136
+ return readSwitch("autoRename", sessionId)?.enabled === true;
137
+ }
138
+
139
+ /** 自动续跑是否生效:面板显式开关优先(读 config),否则跟随全局默认 defaultAutoContinue。 */
140
+ export function effectiveAutoContinue(cfgObj, state, sessionId) {
141
+ const entry = readSwitch("autoContinue", sessionId);
142
+ return entry?.enabled ?? cfgObj.defaultAutoContinue;
143
+ }
144
+
145
+ /** 测试钩子:清空开关缓存(单测在场景之间调用)。 */
146
+ export function resetSwitchGroupsForTest() {
147
+ switchGroups.autoRename.clear();
148
+ switchGroups.autoContinue.clear();
149
+ }
150
+
151
+ /** 删除某组某会话的开关记录(会话删除时清理),返回是否确实删掉了。 */
152
+ export function deleteSwitch(group, sessionId) {
153
+ return switchGroups[group]?.delete(sessionId) ?? false;
154
+ }
155
+
156
+ /** 测试钩子:暴露开关组本身(单测直接断言缓存内容)。 */
157
+ export function getSwitchGroupsForTest() {
158
+ return switchGroups;
159
+ }
@@ -0,0 +1,157 @@
1
+ // dsh-session-conductor — 插件持久化域(全局状态:开关、模型选择、模板元信息)
2
+ //
3
+ // 【为什么单独成文件】domain schema(90 行)与 open/close 生命周期是插件状态的唯一入口,
4
+ // 被列表、续跑、重命名、成员模型切换等多个域读写;抽出后各域只需 import 本模块,
5
+ // 不必依赖插件入口(避免循环引用),也让「域已打开/已关闭」的状态只此一份。
6
+
7
+ import { defineDomain } from "@deepseek-ai/dsh-storage-domain";
8
+ import { z } from "zod";
9
+
10
+ /** 插件自身的持久化域:自动重命名/自动续跑开关 + 记账,存于 <DSH_HOME>/storages/dsh-session-conductor.json。
11
+ * 版本保持 1:autoContinue 是纯增量字段(带 default),旧域文件可无损加载。 */
12
+ const pluginDomainSpec = defineDomain({
13
+ name: "dsh_session_conductor",
14
+ version: 1,
15
+ global: {
16
+ schema: z.object({
17
+ autoRename: z.record(z.string(), z.object({
18
+ enabled: z.boolean(),
19
+ lastAnalysisSeq: z.number().int().nonnegative().optional(),
20
+ lastAnalysisAt: z.number().nonnegative().optional()
21
+ })).default({}),
22
+ autoContinue: z.record(z.string(), z.object({
23
+ enabled: z.boolean().optional(), // 缺省 = 跟随全局默认 defaultAutoContinue
24
+ lastContinuedSeq: z.number().int().nonnegative().optional(),
25
+ lastContinuedAt: z.number().nonnegative().optional(),
26
+ continueCount: z.number().int().nonnegative().optional()
27
+ })).default({}),
28
+ // ⚠️ :自动续跑开关与记账已移至 <DSH_HOME>/session-conductor/config.json
29
+ // (用户流程:控制开关 → 写 config.json → 自动续跑读 config.json;启动时 apply 自动全关)。
30
+ // 上方 autoContinue 字段保留仅为兼容旧域文件(旧数据不再读写,配置以 config.json 为准)。
31
+ // 压缩模型选择:会话模型旁单独选压缩用模型。
32
+ // 取值:{provider, model} 或 null=跟随会话模型。写入后由部署层注入 compaction-basic。
33
+ compactionModel: z.object({
34
+ provider: z.string(),
35
+ model: z.string()
36
+ }).nullable().optional(),
37
+ // 自动重命名模型选择:设置页「DSH 同款解析选择器」选定的模型。
38
+ // 取值:{provider, model} 或 null=跟随会话模型(默认)。优先级:
39
+ // UI 选择 > patch 配置(autoRenameProvider/Model)> 会话 request/header。
40
+ autoRenameModel: z.object({
41
+ provider: z.string(),
42
+ model: z.string()
43
+ }).nullable().optional(),
44
+ // 自动续跑全局闸门(与 guardian 联动):
45
+ // closed → 一切自动续跑跳过(周期扫描/面板 scan 的自动续跑部分均不续);
46
+ // open → 恢复原有判定。DSH 刚启动 guardian 置 closed(防崩溃恢复后自动续跑
47
+ // 批量建空壳会话);用户手动开启(API/面板)或检测到「用户第一次手动对话」
48
+ // (turn/start 由 user 发起)后自动置 open。
49
+ autoContinueGate: z.enum(["open", "closed"]).default("open"),
50
+ // 额外 md 注入:设置卡上传的 md 清单(内容落盘 <DSH_HOME>/extra-inject-md/)
51
+ extraMdFiles: z.array(z.object({
52
+ id: z.string(),
53
+ name: z.string(),
54
+ addedAt: z.number().optional()
55
+ })).default([]),
56
+ // 会话模板注入:plan(方案模板)/ closing(收尾模板)两个固定槽位
57
+ // 元信息存 domain,内容落盘 <DSH_HOME>/template-inject-md/<slot>.md
58
+ sessionTemplates: z.object({
59
+ plan: z.object({
60
+ enabled: z.boolean().default(false),
61
+ enforce: z.boolean().default(false), // 强制门禁:未出提案+未确认前拒绝改码工具
62
+ name: z.string().default(""),
63
+ url: z.string().default(""),
64
+ bytes: z.number().default(0),
65
+ updatedAt: z.number().default(0)
66
+ }).default({ enabled: false, enforce: false, name: "", url: "", bytes: 0, updatedAt: 0 }),
67
+ closing: z.object({
68
+ enabled: z.boolean().default(false),
69
+ enforce: z.boolean().default(false),
70
+ name: z.string().default(""),
71
+ url: z.string().default(""),
72
+ bytes: z.number().default(0),
73
+ updatedAt: z.number().default(0)
74
+ }).default({ enabled: false, enforce: false, name: "", url: "", bytes: 0, updatedAt: 0 })
75
+ }).default({ plan: { enabled: false, enforce: false, name: "", url: "", bytes: 0, updatedAt: 0 }, closing: { enabled: false, enforce: false, name: "", url: "", bytes: 0, updatedAt: 0 } }),
76
+ // 成员模型切换意图(sessionId → {provider, model}):
77
+ // 切模型随时可做(成员 inactive/未挂载时也能切)——意图持久化在这里,
78
+ // 成员下次 resume/运行时由 resumeSetupFor 应用(优先于会话最近路由)。
79
+ memberModelOverrides: z.record(z.object({
80
+ provider: z.string(),
81
+ model: z.string()
82
+ })).default({})
83
+ }),
84
+ initial: {
85
+ autoRename: {},
86
+ autoContinue: {},
87
+ compactionModel: null,
88
+ autoRenameModel: null,
89
+ autoContinueGate: "open",
90
+ extraMdFiles: [],
91
+ sessionTemplates: {
92
+ plan: { enabled: false, enforce: false, name: "", url: "", bytes: 0, updatedAt: 0 },
93
+ closing: { enabled: false, enforce: false, name: "", url: "", bytes: 0, updatedAt: 0 }
94
+ },
95
+ memberModelOverrides: {}
96
+ },
97
+ },
98
+ tables: {}
99
+ });
100
+
101
+ let domainPromise = null;
102
+ /** 域当前是否「已打开且尚未关闭」。
103
+ * 用于判断重载时能否复用缓存——只凭 domainPromise 是否为 null 判断不了
104
+ * 「域是活的还是已被关掉」,这正是「domain already open / domain is closed」两类 500 的根源。 */
105
+ let domainLive = false;
106
+
107
+ /**
108
+ * 打开(或复用)插件持久化域。
109
+ * @param {object} ctx 插件上下文
110
+ * @returns {Promise<object>} 域对象;storageDomain 服务缺失时 reject
111
+ */
112
+ export function pluginDomain(ctx) {
113
+ if (domainPromise === null) {
114
+ const storage = ctx.get("storageDomain");
115
+ if (!storage) return Promise.reject(new Error("storageDomain 不可用"));
116
+ domainPromise = storage.open(pluginDomainSpec).then((domain) => {
117
+ domainLive = true;
118
+ // 只在 apply 的活跃 fiber 上挂 close;HTTP handler / 事件回调 fiber 是 inactive,
119
+ // ctx.effect 会抛 cannot create effect on inactive context,且 rejected Promise 被缓存后 list 一直 500。
120
+ try {
121
+ ctx.effect(() => () => {
122
+ // 关闭的同时把缓存与存活标记一起清掉——下一次 apply 才会重新 open。
123
+ // 【原代码】只 return domain.close()(缓存不清),靠 apply 开头无条件 domainPromise = null 兜,
124
+ // 遇到「域还活着就重载」时会在旧的活域上再 open 一次 → storage 报 already open → list 500。
125
+ domainLive = false;
126
+ domainPromise = null;
127
+ return domain.close();
128
+ }, "session-conductor: domain close");
129
+ } catch {
130
+ /* 请求路径打开时不挂 effect;apply() 已预热并挂过 */
131
+ }
132
+ return domain;
133
+ }).catch((err) => {
134
+ domainPromise = null;
135
+ domainLive = false;
136
+ throw err;
137
+ });
138
+ }
139
+ return domainPromise;
140
+ }
141
+
142
+ /** 读域里的全局状态对象。 */
143
+ export async function pluginState(ctx) {
144
+ const domain = await pluginDomain(ctx);
145
+ return domain.global.get();
146
+ }
147
+
148
+ /** 当前域是否已打开且存活(apply 重载判定用)。 */
149
+ export function isDomainLive() {
150
+ return domainLive;
151
+ }
152
+
153
+ /** 测试钩子:清空域缓存(单测在场景之间调用,避免上一条用例的域串到下一条)。 */
154
+ export function resetDomainForTest() {
155
+ domainPromise = null;
156
+ domainLive = false;
157
+ }
@@ -0,0 +1,20 @@
1
+ // dsh-session-conductor — 重量级模块的按需加载器
2
+ //
3
+ // 【为什么单独成文件】修复/分析类模块(repair、zstd-frames、seq-gap、eio、value)
4
+ // 体积大且只在特定功能触发时才用到;按需 import 可让插件启动更快。
5
+ // 拆分成多个域后,各域都需要这些加载器——集中在这里避免重复定义与循环引用。
6
+
7
+ let _lazyRepair = null;
8
+ let _lazyZstd = null;
9
+ let _lazySeqGap = null;
10
+ let _lazyEio = null;
11
+ let _lazyValue = null;
12
+ let _lazyFs = null;
13
+
14
+ export const lazyRepair = () => (_lazyRepair ??= import("../repair.js"));
15
+ export const lazyZstd = () => (_lazyZstd ??= import("../zstd-frames.js"));
16
+ export const lazySeqGap = () => (_lazySeqGap ??= import("../seq-gap-repair.js"));
17
+ export const lazyEio = () => (_lazyEio ??= import("../eio-repair.js"));
18
+ export const lazyValue = () => (_lazyValue ??= import("../value.js"));
19
+ /** node:fs 的按需加载:只有删会话备份扫描等少数路径需要 readdirSync/existsSync。 */
20
+ export const lazyFs = () => (_lazyFs ??= import("node:fs"));
@@ -0,0 +1,106 @@
1
+ // dsh-session-conductor — 共享运行时状态与串行锁
2
+ //
3
+ // 【为什么单独成文件】这些 Map/计数器被多个域共用:
4
+ // continueJobs(进行中的续跑)既被续跑域写、也被会话列表域读(面板展示「续跑中」);
5
+ // deleteLocks / continueLocks / 并发闸 被续跑、删除、撤回同时用到。
6
+ // 集中在此保证「同一份状态」,避免各域各建一份导致计数/状态不一致。
7
+
8
+ import { cfg } from "./config.js";
9
+ import { pendingTimers } from "../features/rename/analysis.js";
10
+
11
+ /** 防抖定时器(会话 → timer)。 */
12
+ export const continueTimers = new Map();
13
+ /** 每会话串行锁(续跑排队)。 */
14
+ export const continueLocks = new Map();
15
+ /** 进行中的续跑(会话 → job;UI 状态用)。 */
16
+ export const continueJobs = new Map();
17
+ /** 每会话删除锁(删除是破坏性操作,同会话删除请求排队串行)。 */
18
+ export const deleteLocks = new Map();
19
+
20
+ /** 全局并发续跑数(并发闸计数)。 */
21
+ let activeContinues = 0;
22
+ /** 周期扫描定时器句柄。 */
23
+ let scanTimer = null;
24
+
25
+ /** 每会话删除锁:同一会话的删除请求排队串行执行(并发删除不交错)。 */
26
+ export function withDeleteLock(sessionId, fn) {
27
+ const prev = deleteLocks.get(sessionId) ?? Promise.resolve();
28
+ const run = prev.then(fn, () => fn());
29
+ const guard = run
30
+ .catch(() => void 0)
31
+ .finally(() => {
32
+ if (deleteLocks.get(sessionId) === guard) deleteLocks.delete(sessionId);
33
+ });
34
+ deleteLocks.set(sessionId, guard);
35
+ return run;
36
+ }
37
+
38
+ /** 每会话串行锁:同一会话的续跑排队执行。 */
39
+ export function withSessionLock(sessionId, fn) {
40
+ const prev = continueLocks.get(sessionId) ?? Promise.resolve();
41
+ const run = prev.then(fn, () => fn());
42
+ const guard = run
43
+ .catch(() => void 0)
44
+ .finally(() => {
45
+ if (continueLocks.get(sessionId) === guard) continueLocks.delete(sessionId);
46
+ });
47
+ continueLocks.set(sessionId, guard);
48
+ return run;
49
+ }
50
+
51
+ /** 并发闸轮询间隔(毫秒):等名额释放时的重试节奏,兼顾响应速度与空转开销。 */
52
+ const GATE_POLL_INTERVAL_MS = 250;
53
+
54
+ /** 全局并发闸:达到 cfg.maxConcurrent 时等待。 */
55
+ export async function withConcurrencyGate(fn) {
56
+ while (activeContinues >= cfg.maxConcurrent) {
57
+ await new Promise((resolve) => setTimeout(resolve, GATE_POLL_INTERVAL_MS));
58
+ }
59
+ activeContinues += 1;
60
+ try {
61
+ return await fn();
62
+ } finally {
63
+ activeContinues -= 1;
64
+ }
65
+ }
66
+
67
+ /** 取消某会话遗留的防抖定时器与续跑记账(删除时调用,避免删除后定时器再触发)。 */
68
+ export function cancelSessionTimers(sessionId) {
69
+ const ct = continueTimers.get(sessionId);
70
+ if (ct !== void 0) {
71
+ clearTimeout(ct);
72
+ continueTimers.delete(sessionId);
73
+ }
74
+ const pt = pendingTimers.get(sessionId);
75
+ if (pt !== void 0) {
76
+ clearTimeout(pt);
77
+ pendingTimers.delete(sessionId);
78
+ }
79
+ if (continueJobs.has(sessionId)) continueJobs.delete(sessionId);
80
+ }
81
+
82
+ /** 周期扫描定时器读写(插件启停扫描用)。 */
83
+ export function getScanTimer() {
84
+ return scanTimer;
85
+ }
86
+ export function setScanTimer(timer) {
87
+ scanTimer = timer;
88
+ return scanTimer;
89
+ }
90
+
91
+ /** 当前并发续跑数(面板/测试观察用)。 */
92
+ export function activeContinueCount() {
93
+ return activeContinues;
94
+ }
95
+
96
+ /** 测试钩子:清空全部运行时状态(单测在场景之间调用)。 */
97
+ export function resetRuntimeStateForTest() {
98
+ for (const timer of continueTimers.values()) clearTimeout(timer);
99
+ continueTimers.clear();
100
+ continueLocks.clear();
101
+ continueJobs.clear();
102
+ deleteLocks.clear();
103
+ activeContinues = 0;
104
+ if (scanTimer !== null) clearTimeout(scanTimer);
105
+ scanTimer = null;
106
+ }
package/lib/core.js ADDED
@@ -0,0 +1,82 @@
1
+ // dsh-task-completion 核心纯函数:渲染收尾块 + 校验收尾格式。
2
+ // 不依赖 ctx / DSH 服务,可独立单测(test-core.mjs)。
3
+ // 与 skill `task-completion-report` 的格式约定保持一致。
4
+
5
+ export const DIVIDER = "══════════════════════════";
6
+
7
+ const STATUS_MARKERS = {
8
+ done: "✅ 任务完成",
9
+ partial: "⚠️ 未完成",
10
+ failed: "❌ 失败"
11
+ };
12
+
13
+ /** 合法状态集合(供 API/工具入参校验)。 */
14
+ export const VALID_STATUSES = Object.keys(STATUS_MARKERS);
15
+
16
+ /**
17
+ * 渲染标准收尾块。
18
+ * @param options.delivered - 交付了什么(可多行)
19
+ * @param options.verified - 验证状态(可多行)
20
+ * @param options.remaining - 遗留事项(可多行)
21
+ * @param options.status - done | partial | failed
22
+ * @returns 带分隔线与状态标记的完整收尾块文本。
23
+ */
24
+ export function renderCompletionBlock(options = {}) {
25
+ const status = VALID_STATUSES.includes(options.status) ? options.status : "done";
26
+ const lines = [];
27
+ lines.push(DIVIDER);
28
+ lines.push(STATUS_MARKERS[status]);
29
+ lines.push("");
30
+ if (hasText(options.delivered)) {
31
+ lines.push("交付:");
32
+ lines.push(String(options.delivered));
33
+ lines.push("");
34
+ }
35
+ if (hasText(options.verified)) {
36
+ lines.push("验证:");
37
+ lines.push(String(options.verified));
38
+ lines.push("");
39
+ }
40
+ if (hasText(options.remaining)) {
41
+ lines.push("遗留:");
42
+ lines.push(String(options.remaining));
43
+ }
44
+ lines.push(DIVIDER);
45
+ // 去掉结尾多余空行,保持结构紧凑
46
+ while (lines.length > 0 && lines.at(-1) === "") lines.pop();
47
+ return lines.join("\n");
48
+ }
49
+
50
+ /**
51
+ * 校验一段文本是否符合任务完成收尾格式(启发式)。
52
+ * @param text - 待校验文本
53
+ * @returns {ok, hasDivider, hasMarker, hasDelivered, hasVerified, hasRemaining, missing}
54
+ * missing 为缺失项数组(divider / marker / delivered / verified / remaining)。
55
+ */
56
+ export function checkCompletionText(text) {
57
+ const t = typeof text === "string" ? text : "";
58
+ const hasDivider = /^═{4,}$/m.test(t);
59
+ const hasMarker = /(✅\s*任务完成|❌\s*失败|⚠️\s*未完成|✅\s*任务失败|✅\s*Done|✅\s*Task completed)/.test(t);
60
+ const hasDelivered = /交付|delivered|deliver/i.test(t) && /[::]/.test(t);
61
+ const hasVerified = /验证|verified|verify/i.test(t);
62
+ const hasRemaining = /遗留|remaining|left|todo|边界/i.test(t);
63
+ const missing = [];
64
+ if (!hasDivider) missing.push("divider");
65
+ if (!hasMarker) missing.push("marker");
66
+ if (!hasDelivered) missing.push("delivered");
67
+ if (!hasVerified) missing.push("verified");
68
+ if (!hasRemaining) missing.push("remaining");
69
+ return {
70
+ ok: missing.length === 0,
71
+ hasDivider,
72
+ hasMarker,
73
+ hasDelivered,
74
+ hasVerified,
75
+ hasRemaining,
76
+ missing
77
+ };
78
+ }
79
+
80
+ function hasText(value) {
81
+ return typeof value === "string" && value.trim() !== "";
82
+ }