dsh-any-agent 0.5.2 → 0.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 +126 -1
- package/USAGE-NOTES.en.md +3 -1
- package/USAGE-NOTES.md +9 -2
- package/bin/dsh-any-agent-workspaces.mjs +12 -5
- package/bin/dsh-any-agent.mjs +4 -1
- package/bin/restore.mjs +1 -1
- package/docs/en/compatibility.md +27 -6
- package/docs/en/reference.md +2 -1
- package/docs/en/release.md +4 -0
- package/docs/en/tests.md +57 -15
- package/docs/zh/compatibility.md +25 -6
- package/docs/zh/reference.md +2 -1
- package/docs/zh/release.md +3 -0
- package/docs/zh/tests.md +57 -15
- package/package.json +4 -1
- package/scripts/detect-dsh.mjs +55 -0
- package/scripts/lint.mjs +595 -0
- package/scripts/sync-test-counts.mjs +699 -0
- package/src/acp-session.mjs +63 -0
- package/src/acp.mjs +14 -5
- package/src/config.mjs +14 -111
- package/src/conversation.mjs +1600 -0
- package/src/doctor.mjs +203 -0
- package/src/dsh-locate.mjs +560 -0
- package/src/git-snapshot.mjs +196 -0
- package/src/hub-store.mjs +108 -0
- package/src/hub.mjs +26 -1972
- package/src/mcp.mjs +21 -75
- package/src/residue.mjs +278 -0
- package/src/sentinel.mjs +97 -0
- package/src/tool-defs.mjs +399 -0
- package/src/tool-render.mjs +262 -0
- package/src/tools.mjs +18 -767
- package/src/workspace-guard.mjs +89 -0
- package/src/workspace.mjs +295 -59
package/src/hub.mjs
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 注册表(Hub):持有全部会话、共享进程池、哨兵/审核记录的读写与回收。
|
|
3
|
+
*
|
|
4
|
+
* 会话本体(Conversation)在 conversation.mjs;从这里拆出的纯函数模块(git 快照、
|
|
5
|
+
* 哨兵脱敏、cwd 边界、元数据侧车/墓碑、ACP 线格式)也都在本文件里重新导出,
|
|
6
|
+
* 所以外部一直写的 `from '../src/hub.mjs'` 仍然成立。
|
|
3
7
|
*
|
|
4
8
|
* 核心设计:**一个会话 = 一个 DSH ACP 进程**。
|
|
5
9
|
* 进程只是"缓存",不是"身份"——对话的身份是 DSH 的 sessionId,落盘在 DSH 的会话目录里。
|
|
@@ -17,22 +21,14 @@ import {
|
|
|
17
21
|
copyFileSync,
|
|
18
22
|
cpSync,
|
|
19
23
|
mkdirSync,
|
|
20
|
-
mkdtempSync,
|
|
21
24
|
readFileSync,
|
|
22
|
-
realpathSync,
|
|
23
25
|
writeFileSync,
|
|
24
26
|
existsSync,
|
|
25
27
|
renameSync,
|
|
26
28
|
statSync,
|
|
27
|
-
lstatSync,
|
|
28
29
|
rmSync,
|
|
29
|
-
readdirSync,
|
|
30
30
|
} from 'node:fs';
|
|
31
|
-
import { dirname, isAbsolute, join
|
|
32
|
-
import { homedir, tmpdir } from 'node:os';
|
|
33
|
-
import { execFile } from 'node:child_process';
|
|
34
|
-
import { createHash } from 'node:crypto';
|
|
35
|
-
import { promisify } from 'node:util';
|
|
31
|
+
import { dirname, isAbsolute, join } from 'node:path';
|
|
36
32
|
import {
|
|
37
33
|
ALLOW_FULL_ACCESS,
|
|
38
34
|
APPROVAL_POLICIES,
|
|
@@ -44,1999 +40,57 @@ import {
|
|
|
44
40
|
MAX_LIVE,
|
|
45
41
|
MAX_RSS_MB,
|
|
46
42
|
PERMISSION_TIERS,
|
|
47
|
-
PROMPT_TIMEOUT_MS,
|
|
48
43
|
REAP_INTERVAL_MS,
|
|
49
44
|
REASONING_EFFORTS,
|
|
50
45
|
REVIEWS_DIR,
|
|
51
46
|
REVIEW_MODES,
|
|
52
47
|
RUNS_DIR,
|
|
53
|
-
SENTINEL_INCLUDE_REASONING,
|
|
54
48
|
SENTINEL_TTL_MS,
|
|
55
49
|
SHARED_PROCESS,
|
|
56
50
|
STATE_FILE,
|
|
57
51
|
LEGACY_STATE_FILE,
|
|
58
52
|
USING_DEFAULT_STATE,
|
|
59
|
-
DSH_HOME,
|
|
60
53
|
assertWritableDir,
|
|
61
54
|
defaultApprovalPolicy,
|
|
62
55
|
} from './config.mjs';
|
|
63
|
-
import {
|
|
64
|
-
REVIEW_VERDICTS,
|
|
65
|
-
buildReviewTicket,
|
|
66
|
-
normalizeReviewMode,
|
|
67
|
-
readReviewRecord,
|
|
68
|
-
reviewIdForRun,
|
|
69
|
-
writeReviewRecord,
|
|
70
|
-
} from './review.mjs';
|
|
56
|
+
import { normalizeReviewMode } from './review.mjs';
|
|
71
57
|
import { sanitizeApprovalContext } from './redact.mjs';
|
|
72
58
|
import { assertSafeId } from './ids.mjs';
|
|
73
59
|
import { AcpProcess } from './acp.mjs';
|
|
60
|
+
import { applySessionConfig } from './acp-session.mjs';
|
|
74
61
|
import { SessionHost } from './host.mjs';
|
|
75
62
|
import { rssMb } from './procstat.mjs';
|
|
76
63
|
import { readConversationMeta } from './titles.mjs';
|
|
64
|
+
import { Conversation } from './conversation.mjs';
|
|
65
|
+
import { pruneDirTree, sanitizeResultForSentinel } from './sentinel.mjs';
|
|
66
|
+
import { homeWorkspaceProblem } from './workspace-guard.mjs';
|
|
77
67
|
import { registerWorkspace, findSessionHeaderStatus } from './workspace.mjs';
|
|
68
|
+
import {
|
|
69
|
+
META_DIR,
|
|
70
|
+
NOOP_LOG,
|
|
71
|
+
forgottenPath,
|
|
72
|
+
isForgotten,
|
|
73
|
+
pruneStaleTombstones,
|
|
74
|
+
readConversationMetaFile,
|
|
75
|
+
writeConversationMeta,
|
|
76
|
+
} from './hub-store.mjs';
|
|
78
77
|
import {
|
|
79
78
|
LOCKS_DIR,
|
|
80
|
-
classifyHolder,
|
|
81
|
-
describeLeaseConflict,
|
|
82
79
|
heartbeat as lockHeartbeat,
|
|
83
|
-
isLeaseError,
|
|
84
|
-
isPidAlive,
|
|
85
|
-
killHolderChild,
|
|
86
80
|
removeMarker,
|
|
87
81
|
writeMarker,
|
|
88
82
|
} from './locks.mjs';
|
|
89
83
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
*
|
|
96
|
-
* 为什么需要:`forget=true` 只从注册表移除;之后按 id 查询时,`Hub.get()` 会从
|
|
97
|
-
* DSH 会话存储重建会话(这是多实例恢复的刻意设计)。但 DSH 的会话记录里**没有**
|
|
98
|
-
* 本服务选择的 `onApproval` / `reasoning_effort` / `title_hint` / 轮数,
|
|
99
|
-
* 重建就会悄悄回落到默认值(实测 auto-allow→ask、low→max)。这个侧车保存
|
|
100
|
-
* **仅元数据**(不含任何对话/思考正文),让重建后仍与原会话一致。
|
|
101
|
-
*/
|
|
102
|
-
const META_DIR = join(dirname(STATE_FILE), 'meta');
|
|
103
|
-
const metaFilePath = (id) => join(META_DIR, `${id}.json`);
|
|
104
|
-
|
|
105
|
-
function writeConversationMeta(conv) {
|
|
106
|
-
try {
|
|
107
|
-
mkdirSync(META_DIR, { recursive: true });
|
|
108
|
-
const file = metaFilePath(conv.id);
|
|
109
|
-
const tmp = `${file}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
|
|
110
|
-
writeFileSync(tmp, JSON.stringify(conv.toMeta(), null, 2), 'utf8');
|
|
111
|
-
renameSync(tmp, file);
|
|
112
|
-
} catch {
|
|
113
|
-
/* 元数据侧车只是辅助信息,写失败不影响主流程 */
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function readConversationMetaFile(id) {
|
|
118
|
-
try {
|
|
119
|
-
return JSON.parse(readFileSync(metaFilePath(id), 'utf8'));
|
|
120
|
-
} catch {
|
|
121
|
-
return null;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* forget 墓碑:`release(forget=true)` 之后,按 id 查询必须**明确失败**,
|
|
127
|
-
* 而不是从 DSH 会话存储里悄悄复活。墓碑只含 id 与时间,不含任何内容;
|
|
128
|
-
* 删除 `.state/forgotten/<id>.json` 即可恢复按 id 找回(手动、可审计)。
|
|
129
|
-
*/
|
|
130
|
-
// 墓碑放在 DSH home 下而不是注册表旁边:不同实例即使注册表路径不同,
|
|
131
|
-
// 也应该对同一个会话的 forget 达成一致(forget 是会话级终态,不是注册表级)。
|
|
132
|
-
const FORGOTTEN_DIR = process.env.DSH_MCP_FORGOTTEN_DIR ?? join(DSH_HOME, '.dsh-any-agent-forgotten');
|
|
133
|
-
const forgottenPath = (id) => join(FORGOTTEN_DIR, `${id}.json`);
|
|
134
|
-
|
|
135
|
-
function writeTombstone(id) {
|
|
136
|
-
try {
|
|
137
|
-
mkdirSync(FORGOTTEN_DIR, { recursive: true });
|
|
138
|
-
const file = forgottenPath(id);
|
|
139
|
-
const tmp = `${file}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
|
|
140
|
-
writeFileSync(tmp, JSON.stringify({ id, forgottenAt: Date.now() }, null, 2), 'utf8');
|
|
141
|
-
renameSync(tmp, file);
|
|
142
|
-
} catch {
|
|
143
|
-
/* 墓碑写失败时 forget 仍从注册表移除;下次 get 可能仍会复活,但不影响主流程 */
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
function isForgotten(id) {
|
|
148
|
-
try {
|
|
149
|
-
return existsSync(forgottenPath(id));
|
|
150
|
-
} catch {
|
|
151
|
-
return false;
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* 启动时清掉“会话文件已经不存在”的墓碑:它们不再阻止任何 resurrection,
|
|
157
|
-
* 留着只会让测试/临时会话在 `$DSH_HOME/.dsh-any-agent-forgotten` 里堆积。
|
|
158
|
-
* 会话文件还在的墓碑必须保留 —— 那正是 forget 的效力所在。
|
|
159
|
-
*/
|
|
160
|
-
async function pruneStaleTombstones(log = NOOP_LOG) {
|
|
161
|
-
let removed = 0;
|
|
162
|
-
try {
|
|
163
|
-
if (!existsSync(FORGOTTEN_DIR)) return 0;
|
|
164
|
-
for (const f of readdirSync(FORGOTTEN_DIR)) {
|
|
165
|
-
if (!f.endsWith('.json')) continue;
|
|
166
|
-
const id = f.slice(0, -5);
|
|
167
|
-
// 异步读会话头:启动路径不能被大日志的同步解压卡住。
|
|
168
|
-
// ★ 只有确认会话文件不存在时才删墓碑;读超时/损坏时保留,
|
|
169
|
-
// 否则一次慢盘/挂载异常就会让 forget 失效。
|
|
170
|
-
const found = await findSessionHeaderStatus(id);
|
|
171
|
-
if (found.status === 'found' || found.status === 'error') {
|
|
172
|
-
if (found.status === 'error') log.info(`[hub] 墓碑 ${id} 暂不清理:${found.reason}`);
|
|
173
|
-
continue;
|
|
174
|
-
}
|
|
175
|
-
try {
|
|
176
|
-
rmSync(join(FORGOTTEN_DIR, f), { force: true });
|
|
177
|
-
removed++;
|
|
178
|
-
} catch {}
|
|
179
|
-
}
|
|
180
|
-
} catch {
|
|
181
|
-
/* 清理失败不影响启动 */
|
|
182
|
-
}
|
|
183
|
-
return removed;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/** 运行期输出环形缓冲的条目上限(仅内存,超出丢最旧的)。 */
|
|
187
|
-
const MAX_OUT_ENTRIES = 500;
|
|
188
|
-
/** 单条输出正文的字符上限;超出保留尾部,避免一条超长输出把内存撑爆。 */
|
|
189
|
-
const MAX_OUT_TEXT = 200_000;
|
|
190
|
-
/** 记录用户发话时的截断长度(避免一条把缓冲撑爆)。 */
|
|
191
|
-
const MAX_PROMPT_ECHO = 4000;
|
|
192
|
-
/** 保留的回合记录条数(run 结果含正文,所以只留最近若干条,仅内存)。 */
|
|
193
|
-
const MAX_RUNS = 20;
|
|
194
|
-
/** 回合记录里 prompt 预览的长度。 */
|
|
195
|
-
const MAX_RUN_PREVIEW = 200;
|
|
196
|
-
/** 每个回合最多保留多少条"自动放行审批"摘要(总数另计,不受此限)。 */
|
|
197
|
-
const MAX_AUTO_APPROVALS_KEPT = 20;
|
|
198
|
-
/** 自动放行审批里命令预览的长度。 */
|
|
199
|
-
const MAX_AUTO_APPROVAL_PREVIEW = 200;
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* 清掉 `dir` 下超过 TTL 的文件与空会话目录;`.tmp` 残留不论年龄都按同一 cutoff 清。
|
|
203
|
-
* 只遍历一层会话目录,绝不递归进别处 —— 调用方(哨兵/审核目录)都是本服务自己的。
|
|
204
|
-
*/
|
|
205
|
-
function pruneDirTree(dir, ttlMs) {
|
|
206
|
-
if (!ttlMs || !existsSync(dir)) return { pruned: 0, dirs_removed: 0 };
|
|
207
|
-
let pruned = 0;
|
|
208
|
-
let dirsRemoved = 0;
|
|
209
|
-
const cutoff = Date.now() - ttlMs;
|
|
210
|
-
try {
|
|
211
|
-
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
212
|
-
if (!entry.isDirectory()) continue;
|
|
213
|
-
const sub = join(dir, entry.name);
|
|
214
|
-
for (const f of readdirSync(sub, { withFileTypes: true })) {
|
|
215
|
-
if (!f.isFile()) continue;
|
|
216
|
-
const fp = join(sub, f.name);
|
|
217
|
-
try {
|
|
218
|
-
if (statSync(fp).mtimeMs < cutoff) {
|
|
219
|
-
rmSync(fp, { force: true });
|
|
220
|
-
pruned++;
|
|
221
|
-
}
|
|
222
|
-
} catch {
|
|
223
|
-
/* 单个文件失败就跳过 */
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
try {
|
|
227
|
-
if (readdirSync(sub).length === 0) {
|
|
228
|
-
rmSync(sub, { recursive: true, force: true });
|
|
229
|
-
dirsRemoved++;
|
|
230
|
-
}
|
|
231
|
-
} catch {
|
|
232
|
-
/* 目录清理失败无所谓 */
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
} catch {
|
|
236
|
-
/* 目录读不了就跳过 */
|
|
237
|
-
}
|
|
238
|
-
return { pruned, dirs_removed: dirsRemoved };
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/** 按 ACP 权限应答的规范形状挑选项。 */
|
|
242
|
-
function pickOption(options, want) {
|
|
243
|
-
const list = Array.isArray(options) ? options : [];
|
|
244
|
-
const match = list.find((o) => typeof o?.kind === 'string' && o.kind.startsWith(want));
|
|
245
|
-
return match?.optionId;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
function approvalContext(params, toolCall, desc, options) {
|
|
249
|
-
const input = toolCall.rawInput ?? toolCall.input ?? null;
|
|
250
|
-
return sanitizeApprovalContext({
|
|
251
|
-
tool: toolCall.name ?? desc,
|
|
252
|
-
title: desc,
|
|
253
|
-
tool_call_id: toolCall.toolCallId ?? null,
|
|
254
|
-
kind: toolCall.kind ?? null,
|
|
255
|
-
description: input?.description ?? null,
|
|
256
|
-
reason: params?.reason ?? input?.justification ?? input?.reason ?? null,
|
|
257
|
-
input,
|
|
258
|
-
locations: toolCall.locations ?? [],
|
|
259
|
-
options,
|
|
260
|
-
});
|
|
261
|
-
}
|
|
84
|
+
// 外部(测试、bin)一直从 hub.mjs 取这些函数;拆出去之后导入路径保持不变。
|
|
85
|
+
export { Conversation, formatReasoning } from './conversation.mjs';
|
|
86
|
+
export { fsCaseInsensitive, homeWorkspaceProblem } from './workspace-guard.mjs';
|
|
87
|
+
export { gitStatus, sameWorkspaceSnapshot } from './git-snapshot.mjs';
|
|
88
|
+
export { sanitizeResultForSentinel } from './sentinel.mjs';
|
|
262
89
|
|
|
263
|
-
|
|
264
|
-
* 把会话级配置套到 ACP 会话上。
|
|
265
|
-
*
|
|
266
|
-
* 两件必须知道的事:
|
|
267
|
-
* 1) ACP 的 `reasoning_effort` 默认值是**空串**(Provider default),不是最大档,
|
|
268
|
-
* 所以要显式设。传 'default' 即还原成空串(不设置)。
|
|
269
|
-
* 2) **新进程不会记住**这些选择,因此 session/new 之后、以及每次 resume 之后都要重套一遍。
|
|
270
|
-
*
|
|
271
|
-
* 失败不致命:记进 applied/failed 供上层展示,避免因为某个模型不支持某一项而整个建会话失败。
|
|
272
|
-
*/
|
|
273
|
-
async function applySessionConfig(proc, sessionId, cfg) {
|
|
274
|
-
const applied = [];
|
|
275
|
-
const failed = [];
|
|
276
|
-
const set = async (configId, value, label) => {
|
|
277
|
-
if (value === undefined || value === null || value === '') return;
|
|
278
|
-
try {
|
|
279
|
-
// 注意方法名是 ACP 的 snake_case:session/set_config_option
|
|
280
|
-
// (写成 session/setConfigOption 会得到 -32601 Method not found,且很容易被忽略)
|
|
281
|
-
await proc.request('session/set_config_option', { sessionId, configId, value }, 30_000);
|
|
282
|
-
applied.push(label);
|
|
283
|
-
} catch (e) {
|
|
284
|
-
failed.push(`${label} 设置失败: ${e.message}`);
|
|
285
|
-
}
|
|
286
|
-
};
|
|
287
|
-
const effort = cfg.reasoningEffort === 'default' ? '' : cfg.reasoningEffort;
|
|
288
|
-
// Selecting a model resets its dependent reasoning option in ACP. Apply effort last.
|
|
289
|
-
if (cfg.provider && cfg.model) {
|
|
290
|
-
await set('model', JSON.stringify([cfg.provider, cfg.model]), `模型=${cfg.provider}/${cfg.model}`);
|
|
291
|
-
}
|
|
292
|
-
await set('reasoning_effort', effort, `思考深度=${effort}`);
|
|
293
|
-
return { applied, failed };
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
/**
|
|
297
|
-
* 哨兵文件里的 result 是否要剥离思考内容。
|
|
298
|
-
* 默认剥离以维持"思考不落盘"的保证,并留下 thinking_omitted 标记,
|
|
299
|
-
* 免得调用方以为字段是意外丢失的。
|
|
300
|
-
*/
|
|
301
|
-
export function sanitizeResultForSentinel(result) {
|
|
302
|
-
if (!result || typeof result !== 'object') return result ?? null;
|
|
303
|
-
const { thinking, ...rest } = result;
|
|
304
|
-
// ★ 即使 DSH_MCP_SENTINEL_INCLUDE_REASONING=1,也只额外保留 thinking;
|
|
305
|
-
// answer/error/title/tools_used 仍然必须脱敏(以前这个开关会跳过整段清洗)。
|
|
306
|
-
// 正文/错误/标题同样可能夹带密钥;哨兵是落盘副本,统一套脱敏。
|
|
307
|
-
for (const key of ['answer', 'error', 'title']) {
|
|
308
|
-
if (typeof rest[key] === 'string') rest[key] = sanitizeApprovalContext(rest[key], key);
|
|
309
|
-
}
|
|
310
|
-
// 工具输入里会有完整命令、路径,甚至写入的文件内容。哨兵会保留 7 天,
|
|
311
|
-
// 所以按审批上下文同一套规则脱敏(密钥/令牌/密码类字段与常见内联字面量)。
|
|
312
|
-
if (Array.isArray(rest.tools_used)) {
|
|
313
|
-
rest.tools_used = rest.tools_used.map((t) => {
|
|
314
|
-
if (!t || typeof t !== 'object') return t;
|
|
315
|
-
const { input, ...meta } = t;
|
|
316
|
-
const safeMeta = sanitizeApprovalContext(meta);
|
|
317
|
-
return input === undefined ? safeMeta : { ...safeMeta, input: sanitizeApprovalContext(input) };
|
|
318
|
-
});
|
|
319
|
-
}
|
|
320
|
-
// 自动放行审批摘要记录时已脱敏;落盘前再过一遍,防止将来有人加字段漏掉。
|
|
321
|
-
if (Array.isArray(rest.auto_approvals)) {
|
|
322
|
-
rest.auto_approvals = rest.auto_approvals.map((a) => (a && typeof a === 'object' ? sanitizeApprovalContext(a) : a));
|
|
323
|
-
}
|
|
324
|
-
// 审核单里可能有原任务预览、审核理由与证据摘要;哨兵会保留 7 天,同样要脱敏。
|
|
325
|
-
if (rest.review && typeof rest.review === 'object') {
|
|
326
|
-
const rv = { ...rest.review };
|
|
327
|
-
for (const key of ['prompt_preview', 'notes']) {
|
|
328
|
-
if (typeof rv[key] === 'string') rv[key] = sanitizeApprovalContext(rv[key], key);
|
|
329
|
-
}
|
|
330
|
-
if (Array.isArray(rv.checklist)) rv.checklist = rv.checklist.map((x) => sanitizeApprovalContext(x));
|
|
331
|
-
if (Array.isArray(rv.findings)) rv.findings = rv.findings.map((x) => sanitizeApprovalContext(x));
|
|
332
|
-
if (rv.evidence) rv.evidence = sanitizeApprovalContext(rv.evidence);
|
|
333
|
-
rest.review = rv;
|
|
334
|
-
}
|
|
335
|
-
// 始终给出布尔值:调用方才能区分“思考被剥离”与“本来没有思考”。
|
|
336
|
-
const out = { ...rest, thinking_omitted: Boolean(thinking) && !SENTINEL_INCLUDE_REASONING };
|
|
337
|
-
if (SENTINEL_INCLUDE_REASONING && thinking !== undefined) out.thinking = thinking;
|
|
338
|
-
return out;
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
export class Conversation {
|
|
342
|
-
constructor(state, hub) {
|
|
343
|
-
this.id = state.id;
|
|
344
|
-
this.cwd = state.cwd;
|
|
345
|
-
this.permission = state.permission ?? DEFAULT_PERMISSION;
|
|
346
|
-
this.onApproval = state.onApproval ?? defaultApprovalPolicy(this.permission);
|
|
347
|
-
this.createdAt = state.createdAt ?? now();
|
|
348
|
-
this.lastUsedAt = state.lastUsedAt ?? now();
|
|
349
|
-
this.turnCount = state.turnCount ?? 0;
|
|
350
|
-
/** 建会话时给的提示词(仅在 DSH 还没生成原生标题前当兜底显示)。 */
|
|
351
|
-
this.titleHint = state.titleHint ?? null;
|
|
352
|
-
/** true = 不受空闲 TTL / MAX_LIVE 回收(用于"必须一直握住写锁"的长期会话)。 */
|
|
353
|
-
this.keepAlive = state.keepAlive === true;
|
|
354
|
-
/** 会话级 ACP 配置(思考深度 / 模型)。新进程不记得,resume 后要重套。 */
|
|
355
|
-
this.cfg = {
|
|
356
|
-
reasoningEffort: state.reasoningEffort ?? DEFAULT_REASONING_EFFORT,
|
|
357
|
-
provider: state.provider ?? null,
|
|
358
|
-
model: state.model ?? null,
|
|
359
|
-
};
|
|
360
|
-
/** 调用方审核策略(off / advisory)。可在 dsh_send 单次覆盖。 */
|
|
361
|
-
this.review = REVIEW_MODES.includes(state.review) ? state.review : DEFAULT_REVIEW;
|
|
362
|
-
this.lastConfigApplied = null;
|
|
363
|
-
/** 是否已回读并确认会话记录的权限档(null=尚未核对,true/false=结果)。 */
|
|
364
|
-
this.permissionVerified = null;
|
|
365
|
-
/** 工作区登记结果(决定你的 DSH GUI 里能不能看到这个会话)。 */
|
|
366
|
-
this.workspace = state.workspace ?? null;
|
|
367
|
-
this.#hub = hub;
|
|
368
|
-
|
|
369
|
-
this.proc = null;
|
|
370
|
-
this.busy = false;
|
|
371
|
-
/** 正在进行的进程回收(`proc.stop()` 的 promise)。非 null 时不得为同一会话再拉起进程。 */
|
|
372
|
-
this.recycling = null;
|
|
373
|
-
/** 本进程为这个会话拉起过的最后一个子进程 PID(登记被删后仍能归因锁的归属)。 */
|
|
374
|
-
this.lastChildPid = null;
|
|
375
|
-
/** 最近一次撞写锁的记录 `{at, holder_kind, reason, hint}`(见 snapshot())。 */
|
|
376
|
-
this.lockConflict = null;
|
|
377
|
-
this.pendingApprovals = new Map();
|
|
378
|
-
/** 正在跑的 run 记录(进程意外退出时要用它写 error 哨兵)。 */
|
|
379
|
-
this.currentRun = null;
|
|
380
|
-
/** 等待"当前回合结束"的回调队列,供插话排队使用。 */
|
|
381
|
-
this.idleWaiters = [];
|
|
382
|
-
|
|
383
|
-
/**
|
|
384
|
-
* 运行期输出环形缓冲(**仅内存、有界、不落盘**),供"查看正在跑的会话输出"用。
|
|
385
|
-
* 只记三类:用户发的话、agent 的正文、工具调用状态;**思考内容一律不进这里**。
|
|
386
|
-
*/
|
|
387
|
-
this.out = [];
|
|
388
|
-
this.outSeq = 0;
|
|
389
|
-
/** 输出缓冲的**修订号**:每次追加(含合并进已有条目)都 +1,供游标式增量读使用。 */
|
|
390
|
-
this.outRev = 0;
|
|
391
|
-
this.turnOutChars = 0;
|
|
392
|
-
this.currentTurn = 0;
|
|
393
|
-
this.turnStartedAt = 0;
|
|
394
|
-
this.currentTool = null;
|
|
395
|
-
|
|
396
|
-
/**
|
|
397
|
-
* 回合记录(run):让"先派活、回头再验收"成为一等公民。
|
|
398
|
-
* 每条含 {run_id, status, 时间, prompt 预览, result}——**仅内存**,只留最近 MAX_RUNS 条。
|
|
399
|
-
*/
|
|
400
|
-
this.runs = [];
|
|
401
|
-
this.runSeq = 0;
|
|
402
|
-
/** 调用方提供的幂等键 → run_id(与 runs 同步淘汰)。 */
|
|
403
|
-
this.clientRuns = new Map();
|
|
404
|
-
/** 忙时入队的待执行任务(仅内存;重启丢失)。 */
|
|
405
|
-
this.queue = [];
|
|
406
|
-
this.draining = false;
|
|
407
|
-
/** 最近一次回合结果,仅内存(进程退出即消失),不落盘。 */
|
|
408
|
-
this.lastResult = null;
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
#hub;
|
|
412
|
-
|
|
413
|
-
/** 序列化进注册表的最小状态——**只有元数据,没有对话内容**。 */
|
|
414
|
-
toState() {
|
|
415
|
-
return {
|
|
416
|
-
id: this.id,
|
|
417
|
-
cwd: this.cwd,
|
|
418
|
-
permission: this.permission,
|
|
419
|
-
onApproval: this.onApproval,
|
|
420
|
-
createdAt: this.createdAt,
|
|
421
|
-
lastUsedAt: this.lastUsedAt,
|
|
422
|
-
turnCount: this.turnCount,
|
|
423
|
-
titleHint: this.titleHint,
|
|
424
|
-
keepAlive: this.keepAlive,
|
|
425
|
-
reasoningEffort: this.cfg.reasoningEffort,
|
|
426
|
-
provider: this.cfg.provider,
|
|
427
|
-
model: this.cfg.model,
|
|
428
|
-
review: this.review,
|
|
429
|
-
workspace: this.workspace,
|
|
430
|
-
};
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
/** 侧车元数据:与注册表同字段,但独立落盘,forget 之后仍可恢复。 */
|
|
434
|
-
toMeta() {
|
|
435
|
-
return { version: 1, ...this.toState() };
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
/** 合规的公开快照(含磁盘上的元信息)。 */
|
|
439
|
-
snapshot() {
|
|
440
|
-
const meta = readConversationMeta(this.id);
|
|
441
|
-
const nativeTitle = meta?.title ?? null;
|
|
442
|
-
const holder = this.proc?.alive
|
|
443
|
-
? { kind: 'self', reason: '本进程持有该会话的写锁' }
|
|
444
|
-
: classifyHolder(this.id, { knownChildPid: this.lastChildPid });
|
|
445
|
-
return {
|
|
446
|
-
conversation_id: this.id,
|
|
447
|
-
/** 对话名来自 DSH 的 session/title 事件(自动生成);未生成时用建会话提示兜底。 */
|
|
448
|
-
title: nativeTitle ?? this.titleHint ?? null,
|
|
449
|
-
/** native = DSH 自动标题;title_hint = 建会话时给的提示;null = 还没有名字。 */
|
|
450
|
-
title_source: nativeTitle ? 'native' : this.titleHint ? 'title_hint' : null,
|
|
451
|
-
/**
|
|
452
|
-
* DSH 自动生成的标题是**首条消息的截断前缀**(实测 13–15 个字符,
|
|
453
|
-
* 例如「这是一次连通性冒烟测试,请」),信息量很低。
|
|
454
|
-
* 所以调用方给了 title_hint 时,把两者分开返回,由调用方自己选:
|
|
455
|
-
* 想按 DSH 原生标题认会话就看 title,想要可读标签就看 title_hint。
|
|
456
|
-
*/
|
|
457
|
-
title_hint: this.titleHint ?? null,
|
|
458
|
-
cwd: this.cwd,
|
|
459
|
-
permission: this.permission,
|
|
460
|
-
/** 会话默认审核策略;单次 dsh_send 可以覆盖。 */
|
|
461
|
-
review: this.review,
|
|
462
|
-
/** true=已回读会话记录且一致;false/null=缓存暂时没有记录(read-only 仍由审批层兜底)。 */
|
|
463
|
-
permission_verified: this.permissionVerified,
|
|
464
|
-
on_approval: this.onApproval,
|
|
465
|
-
alive: !!this.proc?.alive,
|
|
466
|
-
busy: this.busy,
|
|
467
|
-
keep_alive: this.keepAlive,
|
|
468
|
-
/** 子进程 RSS(MB);量不到就是 null,不影响任何决策。 */
|
|
469
|
-
rss_mb: rssMb(this.proc?.pid),
|
|
470
|
-
/** running = 正在跑回合;idle = 进程活着但空闲;detached = 进程未打开(可 resume)。 */
|
|
471
|
-
state: this.state(),
|
|
472
|
-
current_tool: this.currentTool,
|
|
473
|
-
out_chars: this.turnOutChars,
|
|
474
|
-
running_ms: this.busy && this.turnStartedAt ? now() - this.turnStartedAt : 0,
|
|
475
|
-
turns: this.turnCount,
|
|
476
|
-
created_at: new Date(this.createdAt).toISOString(),
|
|
477
|
-
last_used_at: new Date(this.lastUsedAt).toISOString(),
|
|
478
|
-
model: meta?.model ?? null,
|
|
479
|
-
reasoning_effort: this.cfg.reasoningEffort,
|
|
480
|
-
model_config: this.cfg.provider ? `${this.cfg.provider}/${this.cfg.model}` : null,
|
|
481
|
-
config_applied: this.lastConfigApplied?.applied ?? null,
|
|
482
|
-
config_failed: this.lastConfigApplied?.failed?.length ? this.lastConfigApplied.failed : null,
|
|
483
|
-
workspace_registered: this.workspace?.registered ?? null,
|
|
484
|
-
usage: meta?.usage?.totals ?? null,
|
|
485
|
-
context_pressure: meta?.pressure ?? null,
|
|
486
|
-
pending_approvals: [...this.pendingApprovals.keys()],
|
|
487
|
-
/** 忙时排队的任务(仅内存;重启丢失)。 */
|
|
488
|
-
queue_length: this.queue?.length ?? 0,
|
|
489
|
-
queue: this.queueList(),
|
|
490
|
-
// Keep the ID list compatible; details are ephemeral and excluded from toState().
|
|
491
|
-
pending_approval_details: [...this.pendingApprovals].map(([approvalId, pending]) => ({
|
|
492
|
-
approval_id: approvalId,
|
|
493
|
-
created_at: pending.createdAt ?? null,
|
|
494
|
-
...pending.params.details,
|
|
495
|
-
})),
|
|
496
|
-
/**
|
|
497
|
-
* 不在本进程手里时,写锁在谁手上:
|
|
498
|
-
* self / stale-mcp / live-mcp / none。none 只表示"没有本服务可识别的持有者",
|
|
499
|
-
* 可能是空闲/已释放,也可能被 GUI 持有;原因见 lock_holder_reason。
|
|
500
|
-
*/
|
|
501
|
-
lock_holder: holder.kind,
|
|
502
|
-
lock_holder_reason: holder.reason ?? null,
|
|
503
|
-
/** ★ 最近一次"撞写锁"的持久记录。
|
|
504
|
-
* 只报一次错是不够的:撞锁往往**一次就长期成立**(实测:在 GUI 里点开一次,
|
|
505
|
-
* dsh web 会一直持有写锁,切走/等待都不释放),所以要把"谁占用、怎么解"留在状态里,
|
|
506
|
-
* 否则用户下次只会看到一句没有上下文的报错。 */
|
|
507
|
-
lock_conflict: this.lockConflict,
|
|
508
|
-
};
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
// ── 进程生命周期 ────────────────────────────────────────────────
|
|
512
|
-
|
|
513
|
-
/** 确保有一个可用的 DSH 进程;必要时新建并 resume 回该会话。 */
|
|
514
|
-
async ensureAlive() {
|
|
515
|
-
// ★ 上一代进程正在回收时必须先等它停完:否则会为同一会话再拉起一个进程,
|
|
516
|
-
// 去抢一个**尚未释放**的写锁(reaper/release 里 await stop() 的那个窗口)。
|
|
517
|
-
if (this.recycling) await this.recycling;
|
|
518
|
-
if (this.proc?.alive) return this.proc;
|
|
519
|
-
const shared = SHARED_PROCESS;
|
|
520
|
-
let proc;
|
|
521
|
-
if (shared) {
|
|
522
|
-
proc = this.#hub.acquireHost(this.cwd, this.permission);
|
|
523
|
-
proc.attach(this);
|
|
524
|
-
this.proc = proc;
|
|
525
|
-
} else {
|
|
526
|
-
proc = new AcpProcess({
|
|
527
|
-
cwd: this.cwd,
|
|
528
|
-
permission: this.permission,
|
|
529
|
-
log: this.#hub.log,
|
|
530
|
-
onNotification: (params) => this.onUpdate(params),
|
|
531
|
-
onPermissionRequest: (params) => this.onPermissionRequest(params),
|
|
532
|
-
});
|
|
533
|
-
proc.start();
|
|
534
|
-
this.proc = proc;
|
|
535
|
-
}
|
|
536
|
-
// ★ lastChildPid **推迟到 resume 真正成功后**再写(见 writeMarker 处)。
|
|
537
|
-
// 若在这里就覆盖,一次注定撞锁失败的 resume 会把"上一代还活着的子进程"
|
|
538
|
-
// 这条抢占线索换成刚拉起、注定失败的新进程,takeover 就归因不到真正的持有者。
|
|
539
|
-
// ★ 登记**推迟到真正拿到写锁之后**(见下面 resume 成功处)。
|
|
540
|
-
// 登记的含义是"我正持有这个会话的写锁",不是"我拉起了一个进程"。
|
|
541
|
-
// 若在这里就写,一次注定撞锁失败的 resume 会先把"谁持有"的线索覆盖掉、
|
|
542
|
-
// 失败路径再把它删掉 —— 抢占逻辑就只能看到 none,无法判定持有者(真实 bug)。
|
|
543
|
-
try {
|
|
544
|
-
await proc.initialize();
|
|
545
|
-
// 能走到这里说明本会话刚新建了一个 DSH 进程(Hub.create 之后进程一直活着,不会进这个分支),
|
|
546
|
-
// 而会话本体已经存在于磁盘上,所以必须 resume 把它接回来。
|
|
547
|
-
await proc.request('session/resume', {
|
|
548
|
-
sessionId: this.id,
|
|
549
|
-
cwd: this.cwd,
|
|
550
|
-
mcpServers: [],
|
|
551
|
-
});
|
|
552
|
-
// 新进程不记得会话级配置(思考深度/模型),resume 后必须重套
|
|
553
|
-
this.lastConfigApplied = await applySessionConfig(proc, this.id, this.cfg);
|
|
554
|
-
for (const f of this.lastConfigApplied.failed) this.#hub.log.info(`[conv ${this.id}] ${f}`);
|
|
555
|
-
// ★ 回读会话自己的权限记录,防止 profile defaultPreset 无声提权。
|
|
556
|
-
await this.verifyPermissionRecorded();
|
|
557
|
-
// 锁到手了,这时才登记;从此别人能查到"锁在我们手上"
|
|
558
|
-
writeMarker(this.id, { childPid: proc.pid, cwd: this.cwd });
|
|
559
|
-
// 只有真正拿到写锁后才更新"我拉起的最后一个子进程":失败路径需要保留上一代的 PID。
|
|
560
|
-
this.lastChildPid = proc.pid;
|
|
561
|
-
this.lockConflict = null; // 拿到锁了,之前的撞锁记录作废
|
|
562
|
-
this.#hub.log.info(`[conv ${this.id}] 已 resume`);
|
|
563
|
-
return proc;
|
|
564
|
-
} catch (e) {
|
|
565
|
-
// ★ 半死的进程必须作废:它 alive 但**没有加载这个会话**,一旦留在 this.proc 上,
|
|
566
|
-
// 下次调用会走上面 `this.proc?.alive` 的快路径,把 session/prompt 发给一个
|
|
567
|
-
// 不认识该会话的进程 —— 会话就永久卡死了(重派也不会自愈)。
|
|
568
|
-
// 只作废**自己这一次**拉起的进程:窄窗口里另一个调用可能已经换上了新一代。
|
|
569
|
-
if (this.proc === proc) this.proc = null;
|
|
570
|
-
// ★ 必须 **await**:不等它真的退出,紧接着的 classifyHolder 就会把"正在退出的自己人"
|
|
571
|
-
// 当成持锁者(表现为"上一代子进程还活着并握着锁"),于是每次失败都误报锁冲突 ——
|
|
572
|
-
// 更糟的是旧代码没有 lastChildPid 兜底,会把这种自伤报成"多半是你自己的 GUI",
|
|
573
|
-
// 而 dsh_takeover 对 none 又拒绝接管,会话就卡住了(test/cycle.mjs 实测抓到)。
|
|
574
|
-
const stopped = shared
|
|
575
|
-
? await proc.detach(this).catch(() => false)
|
|
576
|
-
: await proc.stop().catch(() => false);
|
|
577
|
-
// 只删**自己的**登记(removeMarker 会校验 token);进程没确认退出时保留登记,
|
|
578
|
-
// 让 classifyHolder/takeover 仍能归因。
|
|
579
|
-
if (stopped) removeMarker(this.id);
|
|
580
|
-
// 写锁冲突是最常见也最需要"说人话"的失败:DSH 的 SessionAlreadyOwnedError 会被
|
|
581
|
-
// ACP 包成 -32603 "Internal error",只说这句调用方根本不知道该怎么办。
|
|
582
|
-
if (isLeaseError(e.message)) {
|
|
583
|
-
const holder = classifyHolder(this.id, { knownChildPid: this.lastChildPid });
|
|
584
|
-
// ★ 留下持久记录(snapshot().lock_conflict)。撞锁常常**一次就长期成立**:
|
|
585
|
-
// 实测在 GUI 里点开一次,dsh web 就会一直持有写锁(切走、等十几分钟都不释放)。
|
|
586
|
-
// 只报一次错的话,用户下次只会看到一句没有上下文的失败。
|
|
587
|
-
this.lockConflict = {
|
|
588
|
-
at: now(),
|
|
589
|
-
holder_kind: holder.kind,
|
|
590
|
-
holder: holder.marker ?? null,
|
|
591
|
-
reason: holder.reason,
|
|
592
|
-
hint:
|
|
593
|
-
holder.kind === 'none'
|
|
594
|
-
? '持有者不是本服务拉起的进程 —— 多半是你自己的 DSH GUI。解法(无损、可逆):在那个会话的菜单里点「归档会话」让它释放 reference,需要时再「取消归档」;实在不行就重启 dsh web。'
|
|
595
|
-
: holder.kind === 'stale-mcp'
|
|
596
|
-
? '持有者已失联(心跳过期)或是本进程的残留子进程 —— 直接 dsh_takeover,无需 force。'
|
|
597
|
-
: holder.kind === 'live-mcp'
|
|
598
|
-
? '另一个活着的 dsh-any-agent 实例持有它:在那边 dsh_release,或用 dsh_takeover(force=true) 夺过来。'
|
|
599
|
-
: '稍后重试。',
|
|
600
|
-
};
|
|
601
|
-
const err = new Error(describeLeaseConflict(this.id, holder));
|
|
602
|
-
err.code = 'session-writer-held';
|
|
603
|
-
err.holder = holder;
|
|
604
|
-
throw err;
|
|
605
|
-
}
|
|
606
|
-
throw new Error(`恢复会话失败(已作废该进程,下次调用会重建):${e.message}`);
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
/**
|
|
611
|
-
* 核对**会话自己记录**的权限档是否与请求一致。
|
|
612
|
-
*
|
|
613
|
-
* 为什么不能只信入参:DSH profile 里的 `permission.defaultPreset` 会在
|
|
614
|
-
* session/new 时覆盖 `DSH_PERMISSION_MODE`(test/permission.mjs 实测抓到过
|
|
615
|
-
* read-only 被静默提升)。投影缓存可能稍后才刷盘;read-only 必须 fail-closed:
|
|
616
|
-
* 等待至少一个刷盘周期,仍读不到就拒绝继续,不能依赖“审批层兜底”
|
|
617
|
-
* (danger sandbox 下根本不会产生审批请求)。
|
|
618
|
-
*/
|
|
619
|
-
async verifyPermissionRecorded({ attempts, delayMs = 250 } = {}) {
|
|
620
|
-
// read-only 需要覆盖 DSH 约 5 秒的投影缓存刷新周期;其它档位保持较短等待。
|
|
621
|
-
const tries = Number.isFinite(attempts) ? attempts : this.permission === 'read-only' ? 40 : 6;
|
|
622
|
-
for (let i = 0; i < tries; i++) {
|
|
623
|
-
const meta = readConversationMeta(this.id);
|
|
624
|
-
const recorded = meta?.permissions?.preset;
|
|
625
|
-
if (recorded) {
|
|
626
|
-
if (!PERMISSION_TIERS.includes(recorded) || recorded !== this.permission) {
|
|
627
|
-
throw new Error(
|
|
628
|
-
`会话实际权限档为 ${JSON.stringify(recorded)},与请求的 ${this.permission} 不一致。\n` +
|
|
629
|
-
' 这通常意味着 DSH profile 里写了 permission.defaultPreset,覆盖了启动时传入的权限档。\n' +
|
|
630
|
-
` 请检查 ${join(DSH_HOME, 'profiles', process.env.DSH_MCP_PROFILE ?? 'dsh-mcp', 'cordis.patch.yml')},` +
|
|
631
|
-
'删掉 permission.defaultPreset 后重试。',
|
|
632
|
-
);
|
|
633
|
-
}
|
|
634
|
-
this.permissionVerified = true;
|
|
635
|
-
return recorded;
|
|
636
|
-
}
|
|
637
|
-
if (i < tries - 1) await new Promise((r) => setTimeout(r, delayMs));
|
|
638
|
-
}
|
|
639
|
-
this.permissionVerified = false;
|
|
640
|
-
if (this.permission === 'read-only') {
|
|
641
|
-
throw new Error(
|
|
642
|
-
`无法在 ${(tries * delayMs) / 1000}s 内确认会话实际为 read-only(投影缓存无权限记录)。\n` +
|
|
643
|
-
' 为避免 profile 的 defaultPreset 把只读会话静默提升为完全访问,本次调用已拒绝。\n' +
|
|
644
|
-
' 请确认 DSH 会话存储可读,或稍后重试;非只读档位不受此限制。',
|
|
645
|
-
);
|
|
646
|
-
}
|
|
647
|
-
this.#hub.log.info(
|
|
648
|
-
`[conv ${this.id}] 投影缓存里暂时没有权限记录,跳过运行期核对(非只读档)`,
|
|
649
|
-
);
|
|
650
|
-
return null;
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
/**
|
|
654
|
-
* 抢占写锁。
|
|
655
|
-
*
|
|
656
|
-
* 为什么需要"杀进程":DSH 的写锁是跨进程内核信号量,**没有 API 能从活着的持有者手里夺走**。
|
|
657
|
-
* 所以抢锁 == 杀掉持有者,然后重新 resume。既然要杀,就必须先确认那是谁:
|
|
658
|
-
*
|
|
659
|
-
* - `stale-mcp`(本服务自己实例的孤儿残留)→ **自动杀**,安全;
|
|
660
|
-
* - `live-mcp`(另一个活着的 dsh-any-agent 实例)→ 默认拒绝,`force=true` 才杀;
|
|
661
|
-
* - `none`(没有登记,多半是你自己的 DSH GUI)→ **绝不杀**(会连你的界面和正在看的对话一起杀掉),只报告。
|
|
662
|
-
*/
|
|
663
|
-
async takeover({ force = false } = {}) {
|
|
664
|
-
const report = { conversation_id: this.id, ok: false, holder_kind: null, killed_pid: null, message: '' };
|
|
665
|
-
|
|
666
|
-
if (this.proc?.alive) {
|
|
667
|
-
report.ok = true;
|
|
668
|
-
report.message = '本进程已经持有该会话,无需抢占。';
|
|
669
|
-
return report;
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
// 1) 先走正常路径:原持有者可能早已释放
|
|
673
|
-
try {
|
|
674
|
-
await this.ensureAlive();
|
|
675
|
-
report.ok = true;
|
|
676
|
-
report.message = '无需抢占:正常 resume 就成功了(锁是空的,原持有者已释放)。';
|
|
677
|
-
return report;
|
|
678
|
-
} catch (e) {
|
|
679
|
-
if (e.code !== 'session-writer-held') {
|
|
680
|
-
report.message = `不是写锁问题,抢占帮不上忙:${e.message}`;
|
|
681
|
-
return report;
|
|
682
|
-
}
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
// 2) 分类处置
|
|
686
|
-
const holder = classifyHolder(this.id, { knownChildPid: this.lastChildPid });
|
|
687
|
-
report.holder_kind = holder.kind;
|
|
688
|
-
report.holder = holder.marker;
|
|
689
|
-
// 登记可能已随回收被删,但"我们自己的残留子进程"仍可归因(见 locks.classifyHolder)
|
|
690
|
-
const markerChild = holder.marker?.child_pid ?? null;
|
|
691
|
-
const killTarget = isPidAlive(markerChild) ? markerChild : (holder.known_child_pid ?? markerChild);
|
|
692
|
-
|
|
693
|
-
if (holder.kind === 'none' || !holder.childAlive) {
|
|
694
|
-
report.message =
|
|
695
|
-
`抢占未执行:${holder.reason}\n` +
|
|
696
|
-
'本服务**不会**杀非本服务拉起的进程(那可能是你的 DSH GUI,杀它会连你正在看的对话一起没)。\n' +
|
|
697
|
-
'请在那个窗口里关掉该会话(或切走),然后重试。';
|
|
698
|
-
return report;
|
|
699
|
-
}
|
|
700
|
-
if (holder.kind === 'live-mcp' && !force) {
|
|
701
|
-
report.message =
|
|
702
|
-
`抢占被拒绝:${holder.reason}\n` +
|
|
703
|
-
'如果确认那边已经不用它了,重试时带 force=true 强行夺过来。';
|
|
704
|
-
return report;
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
// 3) 共享进程:杀它会连坐同组会话,先把代价说清楚,别让对方在不知情下按下去。
|
|
708
|
-
// 共享进程池(0.5.0 默认开启)让一个 PID 载着多个会话,takeover 只看"谁握着锁"
|
|
709
|
-
// 会做出一个很贵的决定 —— 杀掉持有者 = 同组所有会话的进行中回合一起断。
|
|
710
|
-
const sharedVictims = this.#hub.hostVictimsOf(killTarget, this.id);
|
|
711
|
-
if (sharedVictims.length > 0) {
|
|
712
|
-
report.shared_sessions = sharedVictims;
|
|
713
|
-
if (!force) {
|
|
714
|
-
report.message =
|
|
715
|
-
`抢占被拒绝:PID ${killTarget} 是共享进程,上面还挂着 ${sharedVictims.length} 个其它会话` +
|
|
716
|
-
`(${sharedVictims.map((id) => id.slice(0, 8)).join(', ')})。\n` +
|
|
717
|
-
'杀掉它会连坐这些会话:各自进行中的回合会中断(会话本体无损,之后照常 resume)。\n' +
|
|
718
|
-
'确认可以接受就带 force=true 重试;不想连坐就先 dsh_release 那些会话,或等它们空闲。';
|
|
719
|
-
return report;
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
// 4) 杀 + 重试(内核在持有者进程退出时释放信号量)
|
|
724
|
-
const kill = killHolderChild(killTarget, { expectedSpawnedAt: holder.marker?.spawned_at });
|
|
725
|
-
report.killed_pid = killTarget;
|
|
726
|
-
report.kill = kill;
|
|
727
|
-
if (!kill.killed) {
|
|
728
|
-
report.message = `抢占失败:${kill.reason}`;
|
|
729
|
-
return report;
|
|
730
|
-
}
|
|
731
|
-
removeMarker(this.id, { force: true });
|
|
732
|
-
// 等它**真的**退出再接管:只发 kill 就立刻 resume 会撞上尚未释放的锁
|
|
733
|
-
// (进程退出与内核回收信号量之间有一小段窗口)。
|
|
734
|
-
for (let i = 0; i < 20; i++) {
|
|
735
|
-
if (!isPidAlive(killTarget)) break;
|
|
736
|
-
await new Promise((r) => setTimeout(r, 250));
|
|
737
|
-
}
|
|
738
|
-
try {
|
|
739
|
-
await this.ensureAlive();
|
|
740
|
-
report.ok = true;
|
|
741
|
-
report.message =
|
|
742
|
-
`已抢占:杀掉持有者 PID ${killTarget}(${holder.reason}),并成功接管会话。` +
|
|
743
|
-
`\n⚠️ 如果那个进程当时正在跑回合,那一轮的工作会中断(会话日志会记为 interrupted)。` +
|
|
744
|
-
(sharedVictims.length > 0
|
|
745
|
-
? `\n⚠️ 它是共享进程,另外 ${sharedVictims.length} 个会话也被一起断掉了(各自 resume 即可)。`
|
|
746
|
-
: '');
|
|
747
|
-
} catch (e) {
|
|
748
|
-
report.message = `杀掉 PID ${killTarget} 之后仍无法接管:${e.message}`;
|
|
749
|
-
}
|
|
750
|
-
return report;
|
|
751
|
-
}
|
|
752
|
-
|
|
753
|
-
// ── 流式更新收集 ────────────────────────────────────────────────
|
|
754
|
-
|
|
755
|
-
onUpdate(params) {
|
|
756
|
-
const c = this.collector;
|
|
757
|
-
if (!c) return;
|
|
758
|
-
const u = params?.update;
|
|
759
|
-
if (!u) return;
|
|
760
|
-
switch (u.sessionUpdate) {
|
|
761
|
-
case 'agent_message_chunk':
|
|
762
|
-
c.answer += u.content?.text ?? '';
|
|
763
|
-
this.pushOut('text', u.content?.text ?? '');
|
|
764
|
-
break;
|
|
765
|
-
case 'agent_thought_chunk':
|
|
766
|
-
// 思考内容只进这条回合的临时收集器(用于统计/可选返回),**不进环形缓冲**
|
|
767
|
-
c.reasoning += u.content?.text ?? '';
|
|
768
|
-
c.reasoningChunks += 1;
|
|
769
|
-
if (c.reasoningStartedAt === 0) c.reasoningStartedAt = now();
|
|
770
|
-
break;
|
|
771
|
-
case 'tool_call':
|
|
772
|
-
case 'tool_call_update': {
|
|
773
|
-
const id = u.toolCallId ?? 'unknown';
|
|
774
|
-
const prev = c.tools.get(id) ?? {};
|
|
775
|
-
const merged = {
|
|
776
|
-
...prev,
|
|
777
|
-
id,
|
|
778
|
-
title: u.title ?? prev.title,
|
|
779
|
-
status: u.status ?? prev.status,
|
|
780
|
-
kind: u.kind ?? prev.kind,
|
|
781
|
-
name: u.name ?? prev.name,
|
|
782
|
-
input: u.rawInput !== undefined ? u.rawInput : prev.input,
|
|
783
|
-
};
|
|
784
|
-
c.tools.set(id, merged);
|
|
785
|
-
const label = merged.name ?? merged.title ?? id;
|
|
786
|
-
this.currentTool = merged.status === 'completed' || merged.status === 'failed' ? null : label;
|
|
787
|
-
const tag = merged.status ? ` [${merged.status}]` : '';
|
|
788
|
-
if (u.title !== undefined || u.status !== undefined || u.name !== undefined) {
|
|
789
|
-
this.pushOut('tool', `${label}${tag}`, { tool_call_id: id });
|
|
790
|
-
}
|
|
791
|
-
break;
|
|
792
|
-
}
|
|
793
|
-
case 'usage_update':
|
|
794
|
-
c.usage = u;
|
|
795
|
-
break;
|
|
796
|
-
case 'plan':
|
|
797
|
-
case 'plan_update':
|
|
798
|
-
c.plan = u;
|
|
799
|
-
break;
|
|
800
|
-
default:
|
|
801
|
-
c.otherUpdates.push(u.sessionUpdate);
|
|
802
|
-
}
|
|
803
|
-
}
|
|
804
|
-
|
|
805
|
-
async onPermissionRequest(params) {
|
|
806
|
-
const options = params?.options ?? [];
|
|
807
|
-
const rawToolCall = params?.toolCall ?? {};
|
|
808
|
-
// 真实 DSH 的 request_permission 只带 toolCallId,不带 rawInput;
|
|
809
|
-
// 同一 toolCallId 的参数通常已经在 tool_call 更新里记录过,按 id 关联回来,
|
|
810
|
-
// 否则审批详情里没有命令/路径,调用方无法裁决。
|
|
811
|
-
const known = this.collector?.tools?.get(rawToolCall.toolCallId) ?? null;
|
|
812
|
-
const toolCall = known
|
|
813
|
-
? {
|
|
814
|
-
...rawToolCall,
|
|
815
|
-
name: rawToolCall.name ?? known.name,
|
|
816
|
-
title: rawToolCall.title ?? known.title,
|
|
817
|
-
kind: rawToolCall.kind ?? known.kind,
|
|
818
|
-
rawInput: rawToolCall.rawInput ?? rawToolCall.input ?? known.input,
|
|
819
|
-
locations: rawToolCall.locations ?? known.locations,
|
|
820
|
-
}
|
|
821
|
-
: rawToolCall;
|
|
822
|
-
const desc = sanitizeApprovalContext(String(toolCall.title ?? toolCall.name ?? toolCall.toolCallId ?? '未知操作'));
|
|
823
|
-
const allowId = pickOption(options, 'allow');
|
|
824
|
-
const rejectId = pickOption(options, 'reject');
|
|
825
|
-
|
|
826
|
-
// ★ read-only 是硬边界:即使会话被旧版本/显式参数配成 auto-allow,
|
|
827
|
-
// 这里也绝不放行任何审批 —— DSH 会借审批把 sandbox 提权到可写。
|
|
828
|
-
if (this.permission === 'read-only') {
|
|
829
|
-
this.#hub.log.info(`[conv ${this.id}] read-only 强制拒绝审批: ${desc}`);
|
|
830
|
-
return rejectId
|
|
831
|
-
? { outcome: { outcome: 'selected', optionId: rejectId } }
|
|
832
|
-
: { outcome: { outcome: 'cancelled' } };
|
|
833
|
-
}
|
|
834
|
-
if (this.onApproval === 'auto-allow') {
|
|
835
|
-
this.#hub.log.info(`[conv ${this.id}] 审批放行: ${desc}`);
|
|
836
|
-
// ★ 自动放行必须对调用方可见:DSH 会借审批把沙箱提升到 danger-full-access,
|
|
837
|
-
// 不记下来的话 workspace-write 会话事后看起来像从未越界(实测一轮 6 次提权无人知晓)。
|
|
838
|
-
if (allowId) this.#recordAutoApproval(params, toolCall, desc, options);
|
|
839
|
-
return allowId
|
|
840
|
-
? { outcome: { outcome: 'selected', optionId: allowId } }
|
|
841
|
-
: { outcome: { outcome: 'cancelled' } };
|
|
842
|
-
}
|
|
843
|
-
if (this.onApproval === 'auto-deny') {
|
|
844
|
-
this.#hub.log.info(`[conv ${this.id}] 审批拒绝: ${desc}`);
|
|
845
|
-
return rejectId
|
|
846
|
-
? { outcome: { outcome: 'selected', optionId: rejectId } }
|
|
847
|
-
: { outcome: { outcome: 'cancelled' } };
|
|
848
|
-
}
|
|
849
|
-
const approvalId = `ap-${Math.random().toString(36).slice(2, 10)}`;
|
|
850
|
-
this.#hub.log.info(`[conv ${this.id}] 审批待裁决: ${approvalId}`);
|
|
851
|
-
return await new Promise((resolve) => {
|
|
852
|
-
const timer = setTimeout(() => {
|
|
853
|
-
if (this.pendingApprovals.delete(approvalId)) {
|
|
854
|
-
this.#hub.log.info(`[conv ${this.id}] 审批 ${approvalId} 超时,按拒绝处理`);
|
|
855
|
-
resolve({ outcome: { outcome: 'cancelled' } });
|
|
856
|
-
}
|
|
857
|
-
}, this.#hub.approvalTimeoutMs);
|
|
858
|
-
this.pendingApprovals.set(approvalId, {
|
|
859
|
-
params: { tool: desc, details: approvalContext(params, toolCall, desc, options) },
|
|
860
|
-
createdAt: now(),
|
|
861
|
-
allowId,
|
|
862
|
-
rejectId,
|
|
863
|
-
resolve: (outcome) => {
|
|
864
|
-
clearTimeout(timer);
|
|
865
|
-
resolve(outcome);
|
|
866
|
-
},
|
|
867
|
-
});
|
|
868
|
-
});
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
/**
|
|
872
|
-
* 记一条"被自动放行的审批"到本回合收集器(只记摘要,且先脱敏)。
|
|
873
|
-
* 回合外到来的审批(没有收集器)不记 —— 没有结果可挂。
|
|
874
|
-
*/
|
|
875
|
-
#recordAutoApproval(params, toolCall, desc, options) {
|
|
876
|
-
const c = this.collector;
|
|
877
|
-
if (!c) return;
|
|
878
|
-
c.autoApprovalTotal = (c.autoApprovalTotal ?? 0) + 1;
|
|
879
|
-
const input = toolCall.rawInput ?? toolCall.input ?? null;
|
|
880
|
-
const escalation =
|
|
881
|
-
input && typeof input === 'object' && typeof input.sandbox_permissions === 'string'
|
|
882
|
-
? input.sandbox_permissions
|
|
883
|
-
: null;
|
|
884
|
-
if (escalation) c.autoEscalationTotal = (c.autoEscalationTotal ?? 0) + 1;
|
|
885
|
-
if (!Array.isArray(c.autoApprovals)) c.autoApprovals = [];
|
|
886
|
-
if (c.autoApprovals.length >= MAX_AUTO_APPROVALS_KEPT) return;
|
|
887
|
-
const ctx = approvalContext(params, toolCall, desc, options);
|
|
888
|
-
const cmd = input && typeof input === 'object' ? input.command ?? input.cmd ?? null : null;
|
|
889
|
-
c.autoApprovals.push({
|
|
890
|
-
tool: ctx.tool ?? desc,
|
|
891
|
-
tool_call_id: ctx.tool_call_id ?? null,
|
|
892
|
-
escalation,
|
|
893
|
-
reason: typeof ctx.reason === 'string' ? ctx.reason.slice(0, 300) : null,
|
|
894
|
-
command_preview:
|
|
895
|
-
typeof cmd === 'string' ? sanitizeApprovalContext(cmd, 'command').slice(0, MAX_AUTO_APPROVAL_PREVIEW) : null,
|
|
896
|
-
at: new Date().toISOString(),
|
|
897
|
-
});
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
/** 调用方裁决一个待决审批。 */
|
|
901
|
-
decideApproval(approvalId, decision) {
|
|
902
|
-
const p = this.pendingApprovals.get(approvalId);
|
|
903
|
-
if (!p) throw new Error(`没有待决审批: ${approvalId}`);
|
|
904
|
-
this.pendingApprovals.delete(approvalId);
|
|
905
|
-
const chosen = decision === 'allow' ? p.allowId : p.rejectId;
|
|
906
|
-
// ★ 没有对应 option 时实际发出的是 cancelled。必须把 effective 如实告诉调用方,
|
|
907
|
-
// 否则 dsh_approval_decide 会渲染成"已裁决为 allow",而 DSH 收到的是取消(假成功)。
|
|
908
|
-
const effective = chosen ? 'selected' : 'cancelled';
|
|
909
|
-
p.resolve(chosen ? { outcome: { outcome: 'selected', optionId: chosen } } : { outcome: { outcome: 'cancelled' } });
|
|
910
|
-
return {
|
|
911
|
-
approval_id: approvalId,
|
|
912
|
-
decision,
|
|
913
|
-
effective,
|
|
914
|
-
...(chosen ? {} : { reason: 'no_option' }),
|
|
915
|
-
};
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
/**
|
|
919
|
-
* 起不来时的统一收尾:唤醒所有等本轮结束的插话请求,并落一条 error 哨兵。
|
|
920
|
-
*
|
|
921
|
-
* 这两件事必须都做、且互相独立:
|
|
922
|
-
* - 只写哨兵救不了 `dsh_interject`——它等的是 idleWaiters,不是文件;
|
|
923
|
-
* - 只唤醒不写哨兵,调用方事后 `dsh_get(run_id)` 就永远找不到这次失败。
|
|
924
|
-
*/
|
|
925
|
-
#settleFailedRun(runRecord, error) {
|
|
926
|
-
const waiters = this.idleWaiters;
|
|
927
|
-
this.idleWaiters = [];
|
|
928
|
-
for (const w of waiters) {
|
|
929
|
-
try {
|
|
930
|
-
w();
|
|
931
|
-
} catch {}
|
|
932
|
-
}
|
|
933
|
-
this.#scheduleDrain();
|
|
934
|
-
if (this.currentRun === runRecord) this.currentRun = null;
|
|
935
|
-
runRecord.ended_at = now();
|
|
936
|
-
runRecord.status = 'error';
|
|
937
|
-
runRecord.error = error?.message ?? String(error);
|
|
938
|
-
runRecord.elapsed_ms = 0;
|
|
939
|
-
runRecord.result = {
|
|
940
|
-
conversation_id: this.id,
|
|
941
|
-
run_id: runRecord.run_id,
|
|
942
|
-
stop_reason: 'error',
|
|
943
|
-
error: runRecord.error,
|
|
944
|
-
answer: '',
|
|
945
|
-
thinking_stats: { hidden: true, chars: 0, chunks: 0, ms: 0 },
|
|
946
|
-
tools_used: [],
|
|
947
|
-
workspace_changed: false,
|
|
948
|
-
elapsed_ms: 0,
|
|
949
|
-
};
|
|
950
|
-
const reviewMode = runRecord.review_mode ?? this.review;
|
|
951
|
-
if (reviewMode !== 'off') {
|
|
952
|
-
runRecord.result.review = buildReviewTicket({
|
|
953
|
-
conversationId: this.id,
|
|
954
|
-
runId: runRecord.run_id,
|
|
955
|
-
promptPreview: runRecord.prompt_preview,
|
|
956
|
-
result: runRecord.result,
|
|
957
|
-
mode: reviewMode,
|
|
958
|
-
permission: this.permission,
|
|
959
|
-
});
|
|
960
|
-
}
|
|
961
|
-
try {
|
|
962
|
-
if (typeof this.#hub.writeRunSentinel === 'function') {
|
|
963
|
-
const file = this.#hub.writeRunSentinel(this.id, runRecord);
|
|
964
|
-
runRecord.result.sentinel_file = file ?? this.#hub.runSentinelPath?.(this.id, runRecord.run_id) ?? null;
|
|
965
|
-
if (!file) runRecord.result.sentinel_error = runRecord.sentinel_error ?? '哨兵文件写入失败';
|
|
966
|
-
}
|
|
967
|
-
} catch (e) {
|
|
968
|
-
runRecord.result.sentinel_error = `写完成哨兵失败: ${e.message}`;
|
|
969
|
-
}
|
|
970
|
-
try {
|
|
971
|
-
this.#hub.save?.();
|
|
972
|
-
} catch {}
|
|
973
|
-
return runRecord.result;
|
|
974
|
-
}
|
|
975
|
-
|
|
976
|
-
/**
|
|
977
|
-
* 共享进程池的进程意外退出:把本会话在跑的回合标成失败并落哨兵,
|
|
978
|
-
* 然后解除 proc 引用,下次调用会重新 acquireHost + resume。
|
|
979
|
-
*/
|
|
980
|
-
onProcessExit(reason) {
|
|
981
|
-
this.proc = null;
|
|
982
|
-
this.recycling = null;
|
|
983
|
-
if (this.busy && this.currentRun) {
|
|
984
|
-
this.busy = false;
|
|
985
|
-
this.#settleFailedRun(this.currentRun, new Error(String(reason)));
|
|
986
|
-
}
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
// ── 跑任务 ──────────────────────────────────────────────────────
|
|
990
|
-
|
|
991
|
-
/** 发一个回合并等到结束。 */
|
|
992
|
-
/**
|
|
993
|
-
* 派一个回合。
|
|
994
|
-
*
|
|
995
|
-
* **超时语义(这里曾经把会话跑挂,改之前务必读完)**
|
|
996
|
-
*
|
|
997
|
-
* - **ACP 请求本身不设超时**:超时只决定"我们等不等",永远不掐断回合。
|
|
998
|
-
* 旧实现给 ACP 请求设超时,一到期就本地放弃并把 `busy` 置 false —— 那是在
|
|
999
|
-
* **谎报回合结束**:结果丢了、token 照烧,而且 reaper 看到 `busy=false` 就会
|
|
1000
|
-
* 把一个**正在干活**的进程回收掉 → 回合变 `interrupted` → 该会话此后 resume 失败、
|
|
1001
|
-
* 永久卡死(真实事故)。
|
|
1002
|
-
* - `wait=false`:压根不等,立即返回 `run_id`;回合真正结束时写哨兵通知调用方。
|
|
1003
|
-
* - `wait=true` :最多等 `timeoutMs`(`<=0` 表示一直等)。到期**不报错、不丢结果**,
|
|
1004
|
-
* 返回 `{still_running:true, run_id, sentinel_file}` —— 即把 wait=true 降级成 wait=false:
|
|
1005
|
-
* 回合继续跑,结束时照常写哨兵,调用方拿 `run_id` 事后验收。
|
|
1006
|
-
*
|
|
1007
|
-
* @returns 等到回合结束 → 完整 result;没等到(wait=false 或等超时)→ 收据对象
|
|
1008
|
-
*/
|
|
1009
|
-
async prompt(text, { reasoning = 'hide', timeoutMs = PROMPT_TIMEOUT_MS, run, wait = true, review } = {}) {
|
|
1010
|
-
if (this.busy) {
|
|
1011
|
-
throw new Error(
|
|
1012
|
-
`会话 ${this.id} 正在跑另一个回合。可以:dsh_interject 插话(打断并改口 / 排队),或 dsh_interrupt 打断。`,
|
|
1013
|
-
);
|
|
1014
|
-
}
|
|
1015
|
-
const runRecord = run ?? this.createRun(text, { review });
|
|
1016
|
-
const reviewMode = runRecord.review_mode ?? normalizeReviewMode(review ?? this.review);
|
|
1017
|
-
runRecord.review_mode = reviewMode;
|
|
1018
|
-
runRecord.status = 'running';
|
|
1019
|
-
runRecord.started_at = now();
|
|
1020
|
-
this.currentRun = runRecord;
|
|
1021
|
-
let before = null;
|
|
1022
|
-
let proc;
|
|
1023
|
-
// ★ `busy` 必须在 ensureAlive **之前**置位。
|
|
1024
|
-
// 否则"把进程拉起来"这段(spawn + initialize + resume,可能好几秒)是一个**裸奔窗口**:
|
|
1025
|
-
// ① reaper 看到 busy=false 且 lastUsedAt 还是上一轮的时刻(已过期),会把这个**刚拉起来**
|
|
1026
|
-
// 的进程当空闲回收掉 → 紧接着 resume 就报"DSH 进程未运行"(test/cycle.mjs 实测抓到);
|
|
1027
|
-
// ② 第二个 dsh_send 同样能通过 busy 检查,于是为同一会话再拉一个子进程 → 互相撞锁 + 泄漏。
|
|
1028
|
-
// ★ 同理,git 快照的 await 也必须放在 busy 置位**之后**(见下面的 Promise.all)。
|
|
1029
|
-
this.busy = true;
|
|
1030
|
-
this.lastUsedAt = now();
|
|
1031
|
-
const beforePromise = gitStatus(this.cwd);
|
|
1032
|
-
try {
|
|
1033
|
-
// 快照与拉起进程并行:快照只是读盘,不改变生命周期,早取晚取语义一致。
|
|
1034
|
-
const [beforeSnap, liveProc] = await Promise.all([beforePromise, this.ensureAlive()]);
|
|
1035
|
-
before = beforeSnap;
|
|
1036
|
-
proc = liveProc;
|
|
1037
|
-
} catch (e) {
|
|
1038
|
-
// 起不来(撞写锁 / 会话损坏)就**必须放开 busy**,否则这个会话被永久占住;
|
|
1039
|
-
// 同时别留一条永远 "running" 的幽灵记录。
|
|
1040
|
-
// ★ 与 finalize 对齐:失败也必须给等待者一个终态——唤醒插话队列 + 落 error 哨兵。
|
|
1041
|
-
// 只写哨兵救不了 dsh_interject:它等的是 idleWaiters,不是文件(默认 timeout=0 无 timer)。
|
|
1042
|
-
this.busy = false;
|
|
1043
|
-
this.#settleFailedRun(runRecord, e);
|
|
1044
|
-
e.runId = runRecord.run_id;
|
|
1045
|
-
e.status = 'error';
|
|
1046
|
-
e.sentinelFile = runRecord.result?.sentinel_file ?? null;
|
|
1047
|
-
throw e;
|
|
1048
|
-
}
|
|
1049
|
-
this.currentTurn += 1;
|
|
1050
|
-
this.turnStartedAt = now();
|
|
1051
|
-
this.turnOutChars = 0;
|
|
1052
|
-
this.currentTool = null;
|
|
1053
|
-
this.pushOut('user', text.length > MAX_PROMPT_ECHO ? `${text.slice(0, MAX_PROMPT_ECHO)}…(已截断)` : text);
|
|
1054
|
-
this.pushOut('turn', `第 ${this.currentTurn} 轮开始`, { phase: 'start' });
|
|
1055
|
-
const c = {
|
|
1056
|
-
answer: '',
|
|
1057
|
-
reasoning: '',
|
|
1058
|
-
reasoningChunks: 0,
|
|
1059
|
-
reasoningStartedAt: 0,
|
|
1060
|
-
tools: new Map(),
|
|
1061
|
-
usage: null,
|
|
1062
|
-
plan: null,
|
|
1063
|
-
otherUpdates: [],
|
|
1064
|
-
/** 本回合被 auto-allow 放行的审批摘要(最多 MAX_AUTO_APPROVALS_KEPT 条)与总数。 */
|
|
1065
|
-
autoApprovals: [],
|
|
1066
|
-
autoApprovalTotal: 0,
|
|
1067
|
-
autoEscalationTotal: 0,
|
|
1068
|
-
};
|
|
1069
|
-
this.collector = c;
|
|
1070
|
-
const t0 = now();
|
|
1071
|
-
let settled = false;
|
|
1072
|
-
|
|
1073
|
-
/**
|
|
1074
|
-
* 把"调用方审核单"挂到回合结果上。
|
|
1075
|
-
*
|
|
1076
|
-
* 审核单必须随结果一起返回(正文、structuredContent、哨兵都是同一份 result),
|
|
1077
|
-
* 否则调用方在异步收活时只会看到一段自述,很容易直接把它当成完成事实。
|
|
1078
|
-
*/
|
|
1079
|
-
const attachReview = (result) => {
|
|
1080
|
-
if (reviewMode !== 'off') {
|
|
1081
|
-
result.review = buildReviewTicket({
|
|
1082
|
-
conversationId: this.id,
|
|
1083
|
-
runId: runRecord.run_id,
|
|
1084
|
-
promptPreview: runRecord.prompt_preview,
|
|
1085
|
-
result,
|
|
1086
|
-
mode: reviewMode,
|
|
1087
|
-
permission: this.permission,
|
|
1088
|
-
});
|
|
1089
|
-
}
|
|
1090
|
-
return result;
|
|
1091
|
-
};
|
|
1092
|
-
|
|
1093
|
-
/**
|
|
1094
|
-
* 回合真正结束时收尾。无论有没有人在等,都**只执行一次**。
|
|
1095
|
-
*
|
|
1096
|
-
* 现在是异步的(git 快照走异步子进程),但保持了三个不变量:
|
|
1097
|
-
* 1. 幂等:重复调用返回同一个 Promise,不会重复写哨兵;
|
|
1098
|
-
* 2. 不拖累生命周期:`busy=false`、唤醒 idleWaiters、#scheduleDrain
|
|
1099
|
-
* 都在**第一个 await 之前**同步完成,git 最慢 8s 也影响不到 reaper/插话;
|
|
1100
|
-
* 3. 防弹:内部异常一律兜底成 error 结果 + 哨兵,返回的 Promise 不会 reject
|
|
1101
|
-
* (wait=false 的调用方不会 await 它,不能让 unhandled rejection 杀掉服务)。
|
|
1102
|
-
*/
|
|
1103
|
-
let finalizePromise = null;
|
|
1104
|
-
const finalize = (stopReason, failure) => {
|
|
1105
|
-
if (finalizePromise) return finalizePromise;
|
|
1106
|
-
settled = true;
|
|
1107
|
-
finalizePromise = (async () => {
|
|
1108
|
-
// ★ 先启动 after 快照:调用 gitStatus 时子进程立刻 spawn,但**不 await**;
|
|
1109
|
-
// 这样 snapshot 的时间点仍贴近回合结束,而 busy 释放/唤醒不必等 git。
|
|
1110
|
-
const afterPromise = gitStatus(this.cwd);
|
|
1111
|
-
try {
|
|
1112
|
-
this.busy = false;
|
|
1113
|
-
if (this.currentRun === runRecord) this.currentRun = null;
|
|
1114
|
-
this.collector = null;
|
|
1115
|
-
this.currentTool = null;
|
|
1116
|
-
// 回合结束后,任何仍挂着的审批都已失效:统一取消并清空,避免状态里
|
|
1117
|
-
// 留下一个 dsh_approval_decide 只会回"没有待决审批"的幽灵。
|
|
1118
|
-
for (const [, p] of this.pendingApprovals) {
|
|
1119
|
-
try {
|
|
1120
|
-
p.resolve({ outcome: { outcome: 'cancelled' } });
|
|
1121
|
-
} catch {}
|
|
1122
|
-
}
|
|
1123
|
-
this.pendingApprovals.clear();
|
|
1124
|
-
this.pushOut('turn', `第 ${this.currentTurn} 轮结束(${stopReason ?? 'unknown'})`, {
|
|
1125
|
-
phase: 'end',
|
|
1126
|
-
stop_reason: stopReason,
|
|
1127
|
-
error: failure ?? undefined,
|
|
1128
|
-
});
|
|
1129
|
-
this.turnCount += 1;
|
|
1130
|
-
this.lastUsedAt = now();
|
|
1131
|
-
this.#hub.save();
|
|
1132
|
-
// 唤醒所有"等本轮结束"的插话请求(busy 已置 false,它们可以安全地发下一轮)
|
|
1133
|
-
const waiters = this.idleWaiters;
|
|
1134
|
-
this.idleWaiters = [];
|
|
1135
|
-
for (const w of waiters) w();
|
|
1136
|
-
this.#scheduleDrain();
|
|
1137
|
-
// 到这里才允许被 git 拖慢;结果/审核单/哨兵必须等快照完成后再落。
|
|
1138
|
-
const after = await afterPromise;
|
|
1139
|
-
const result = {
|
|
1140
|
-
conversation_id: this.id,
|
|
1141
|
-
run_id: runRecord.run_id,
|
|
1142
|
-
stop_reason: stopReason,
|
|
1143
|
-
error: failure,
|
|
1144
|
-
answer: c.answer,
|
|
1145
|
-
thinking: formatReasoning(c, reasoning),
|
|
1146
|
-
thinking_stats: {
|
|
1147
|
-
hidden: reasoning === 'hide' || reasoning === 'marker',
|
|
1148
|
-
chars: c.reasoning.length,
|
|
1149
|
-
chunks: c.reasoningChunks,
|
|
1150
|
-
ms: c.reasoningStartedAt ? now() - c.reasoningStartedAt : 0,
|
|
1151
|
-
},
|
|
1152
|
-
tools_used: [...c.tools.values()],
|
|
1153
|
-
usage: c.usage,
|
|
1154
|
-
auto_approvals_total: c.autoApprovalTotal,
|
|
1155
|
-
auto_escalations_total: c.autoEscalationTotal,
|
|
1156
|
-
auto_approvals: c.autoApprovals,
|
|
1157
|
-
workspace_changed: !sameWorkspaceSnapshot(before, after),
|
|
1158
|
-
diff_stat: after,
|
|
1159
|
-
elapsed_ms: now() - t0,
|
|
1160
|
-
};
|
|
1161
|
-
attachReview(result);
|
|
1162
|
-
runRecord.ended_at = now();
|
|
1163
|
-
runRecord.elapsed_ms = result.elapsed_ms;
|
|
1164
|
-
runRecord.status = failure ? 'error' : stopReason === 'cancelled' ? 'cancelled' : 'done';
|
|
1165
|
-
runRecord.result = result;
|
|
1166
|
-
// ★ 无条件写哨兵:**一个回合 = 一个哨兵文件**,没有例外。
|
|
1167
|
-
// 之前只在"调用方没在原地等"时才写,于是插话那一轮没有哨兵 —— 调用方的
|
|
1168
|
-
// 等待逻辑(等文件出现)就永远空等,即使 run 早已 status=done(真实事故)。
|
|
1169
|
-
// 统一规则也消除了整类"有 run_id 却没有哨兵"的坑。
|
|
1170
|
-
const sentinelFile = this.#hub.writeRunSentinel(this.id, runRecord);
|
|
1171
|
-
result.sentinel_file = sentinelFile ?? this.#hub.runSentinelPath(this.id, runRecord.run_id);
|
|
1172
|
-
if (!sentinelFile) result.sentinel_error = runRecord.sentinel_error ?? '哨兵文件写入失败';
|
|
1173
|
-
return result;
|
|
1174
|
-
} catch (e) {
|
|
1175
|
-
this.#hub.log.info(`[conv ${this.id}] 回合收尾异常(已兜底): ${e.message}`);
|
|
1176
|
-
this.busy = false;
|
|
1177
|
-
if (this.currentRun === runRecord) this.currentRun = null;
|
|
1178
|
-
this.collector = null;
|
|
1179
|
-
const waiters = this.idleWaiters;
|
|
1180
|
-
this.idleWaiters = [];
|
|
1181
|
-
for (const w of waiters) w();
|
|
1182
|
-
this.#scheduleDrain();
|
|
1183
|
-
const fallback = {
|
|
1184
|
-
conversation_id: this.id,
|
|
1185
|
-
run_id: runRecord.run_id,
|
|
1186
|
-
stop_reason: 'error',
|
|
1187
|
-
error: `回合收尾异常: ${e.message}`,
|
|
1188
|
-
answer: c.answer ?? '',
|
|
1189
|
-
thinking_stats: { hidden: true, chars: 0, chunks: 0, ms: 0 },
|
|
1190
|
-
tools_used: [...(c.tools?.values?.() ?? [])],
|
|
1191
|
-
workspace_changed: false,
|
|
1192
|
-
elapsed_ms: now() - t0,
|
|
1193
|
-
};
|
|
1194
|
-
attachReview(fallback);
|
|
1195
|
-
runRecord.ended_at = now();
|
|
1196
|
-
runRecord.status = 'error';
|
|
1197
|
-
runRecord.error = fallback.error;
|
|
1198
|
-
runRecord.result = fallback;
|
|
1199
|
-
const sentinelFile = this.#hub.writeRunSentinel(this.id, runRecord);
|
|
1200
|
-
fallback.sentinel_file = sentinelFile ?? this.#hub.runSentinelPath(this.id, runRecord.run_id);
|
|
1201
|
-
if (!sentinelFile) fallback.sentinel_error = runRecord.sentinel_error ?? '哨兵文件写入失败';
|
|
1202
|
-
return fallback;
|
|
1203
|
-
}
|
|
1204
|
-
})().catch((e) => {
|
|
1205
|
-
// 最后一层保险:收尾连兜底都失败时也绝不把 rejection 丢给事件循环。
|
|
1206
|
-
this.#hub.log.info(`[conv ${this.id}] 回合收尾二次异常(已忽略): ${e.message}`);
|
|
1207
|
-
if (!runRecord.result) {
|
|
1208
|
-
runRecord.result = {
|
|
1209
|
-
conversation_id: this.id,
|
|
1210
|
-
run_id: runRecord.run_id,
|
|
1211
|
-
stop_reason: 'error',
|
|
1212
|
-
error: `回合收尾异常: ${e.message}`,
|
|
1213
|
-
answer: c.answer ?? '',
|
|
1214
|
-
thinking_stats: { hidden: true, chars: 0, chunks: 0, ms: 0 },
|
|
1215
|
-
tools_used: [],
|
|
1216
|
-
workspace_changed: false,
|
|
1217
|
-
elapsed_ms: now() - t0,
|
|
1218
|
-
};
|
|
1219
|
-
runRecord.status = 'error';
|
|
1220
|
-
}
|
|
1221
|
-
return runRecord.result;
|
|
1222
|
-
});
|
|
1223
|
-
return finalizePromise;
|
|
1224
|
-
};
|
|
1225
|
-
|
|
1226
|
-
/** 没等到结果时给调用方的收据(它靠 run_id + 哨兵文件事后验收)。 */
|
|
1227
|
-
const receipt = (waitedMs) => ({
|
|
1228
|
-
conversation_id: this.id,
|
|
1229
|
-
run_id: runRecord.run_id,
|
|
1230
|
-
status: 'running',
|
|
1231
|
-
still_running: true,
|
|
1232
|
-
accepted: true,
|
|
1233
|
-
background: true,
|
|
1234
|
-
sentinel_file: this.#hub.runSentinelPath(this.id, runRecord.run_id),
|
|
1235
|
-
waited_ms: waitedMs ?? 0,
|
|
1236
|
-
});
|
|
1237
|
-
|
|
1238
|
-
// 关键:第三个参数传 0 = **不设超时**。回合只能由它自己结束,或由 session/cancel 结束。
|
|
1239
|
-
const acp = proc.request('session/prompt', { sessionId: this.id, prompt: [{ type: 'text', text }] }, 0);
|
|
1240
|
-
// 这个 Promise 在 finalize **真正完成**(含 workspace_changed、审核单、哨兵)后才 settle。
|
|
1241
|
-
// 等待方必须等它,而不是只等 acp —— 否则会拿到还没填完的 runRecord.result。
|
|
1242
|
-
const finished = acp
|
|
1243
|
-
.then(
|
|
1244
|
-
(res) => {
|
|
1245
|
-
if (res?.usage) c.usage = c.usage ?? res.usage;
|
|
1246
|
-
return finalize(res?.stopReason ?? null, null);
|
|
1247
|
-
},
|
|
1248
|
-
(e) => finalize('error', e.message),
|
|
1249
|
-
)
|
|
1250
|
-
.catch((e) => {
|
|
1251
|
-
// finalize 自己是防弹的;这里只兜住同步抛出的极端情况。
|
|
1252
|
-
this.#hub.log.info(`[conv ${this.id}] 回合回调异常(已兜底): ${e.message}`);
|
|
1253
|
-
return runRecord.result ?? null;
|
|
1254
|
-
});
|
|
1255
|
-
|
|
1256
|
-
if (!wait) {
|
|
1257
|
-
// 同步走到这里时 finalize 还没开始(ACP 回调在微任务里),返回收据即可。
|
|
1258
|
-
// 若极端情况下已经收尾,则等它给出完整结果,不要返回半成品。
|
|
1259
|
-
return settled ? await (finalizePromise ?? runRecord.result) : receipt(0);
|
|
1260
|
-
}
|
|
1261
|
-
if (timeoutMs <= 0) {
|
|
1262
|
-
await finished;
|
|
1263
|
-
return runRecord.result;
|
|
1264
|
-
}
|
|
1265
|
-
const timedOut = await Promise.race([
|
|
1266
|
-
finished.then(
|
|
1267
|
-
() => false,
|
|
1268
|
-
() => false,
|
|
1269
|
-
),
|
|
1270
|
-
new Promise((r) => setTimeout(() => r(true), timeoutMs)),
|
|
1271
|
-
]);
|
|
1272
|
-
// 超时发生在 finalize 完成之前(包括 git 快照还在跑):给收据,不返回半成品结果。
|
|
1273
|
-
if (timedOut) return receipt(timeoutMs);
|
|
1274
|
-
return runRecord.result;
|
|
1275
|
-
}
|
|
1276
|
-
|
|
1277
|
-
/** 中断当前回合(ACP 原生,走的就是用户手动停止那条路径)。 */
|
|
1278
|
-
async interrupt() {
|
|
1279
|
-
if (!this.proc?.alive) return { interrupted: false, reason: 'DSH 进程未运行' };
|
|
1280
|
-
this.proc.notify('session/cancel', { sessionId: this.id });
|
|
1281
|
-
if (!this.busy) {
|
|
1282
|
-
return { interrupted: false, reason: '当前没有进行中的回合(已发送 cancel,用于清空排队输入)' };
|
|
1283
|
-
}
|
|
1284
|
-
return { interrupted: true };
|
|
1285
|
-
}
|
|
1286
|
-
|
|
1287
|
-
/** 运行状态:running(在跑回合)/ idle(进程活着但空闲)/ detached(进程未打开,可 resume)。 */
|
|
1288
|
-
state() {
|
|
1289
|
-
if (this.busy) return 'running';
|
|
1290
|
-
return this.proc?.alive ? 'idle' : 'detached';
|
|
1291
|
-
}
|
|
1292
|
-
|
|
1293
|
-
/** 追加一条输出记录;同类相邻文本会合并,避免每个 token 一条。 */
|
|
1294
|
-
pushOut(kind, text, extra = {}) {
|
|
1295
|
-
const last = this.out[this.out.length - 1];
|
|
1296
|
-
const cap = (s) => (s.length > MAX_OUT_TEXT ? `…[已截断,保留尾部 ${MAX_OUT_TEXT} 字]…\n${s.slice(-MAX_OUT_TEXT)}` : s);
|
|
1297
|
-
if (last && last.kind === kind && last.turn === this.currentTurn && text) {
|
|
1298
|
-
last.text = cap(last.text + text);
|
|
1299
|
-
last.at = now();
|
|
1300
|
-
// ★ 合并进已有条目也必须推进修订号。否则游标已经读过这条的调用方
|
|
1301
|
-
// 会永远看不到后续追加的正文(旧实现用 seq 当游标,seq 不变 = 漏读)。
|
|
1302
|
-
last.rev = ++this.outRev;
|
|
1303
|
-
} else {
|
|
1304
|
-
this.out.push({ seq: ++this.outSeq, rev: ++this.outRev, kind, turn: this.currentTurn, text: cap(text ?? ''), at: now(), ...extra });
|
|
1305
|
-
if (this.out.length > MAX_OUT_ENTRIES) this.out.shift();
|
|
1306
|
-
}
|
|
1307
|
-
if (kind === 'text') this.turnOutChars += text?.length ?? 0;
|
|
1308
|
-
}
|
|
1309
|
-
|
|
1310
|
-
/** 读输出:**按修订号**的游标式增量。条目被追加时会再次返回(按 seq 替换全文)。 */
|
|
1311
|
-
readOut({ cursor = 0, includeReasoning = false, limit = 200 } = {}) {
|
|
1312
|
-
const cur = Number.isFinite(cursor) ? cursor : 0;
|
|
1313
|
-
// limit 必须清洗:负数会被 slice 解释成“从头到尾少一条”,0 会让调用方原地空转。
|
|
1314
|
-
const n = Number.isFinite(limit) ? Math.max(1, Math.min(Math.floor(limit), MAX_OUT_ENTRIES)) : 200;
|
|
1315
|
-
const revOf = (e) => (typeof e.rev === 'number' ? e.rev : e.seq);
|
|
1316
|
-
const entries = this.out.filter((e) => revOf(e) > cur).slice(0, n);
|
|
1317
|
-
const next = entries.length ? revOf(entries[entries.length - 1]) : cur;
|
|
1318
|
-
const res = {
|
|
1319
|
-
conversation_id: this.id,
|
|
1320
|
-
state: this.state(),
|
|
1321
|
-
busy: this.busy,
|
|
1322
|
-
current_tool: this.currentTool,
|
|
1323
|
-
entries,
|
|
1324
|
-
cursor: next,
|
|
1325
|
-
has_more: this.out.some((e) => revOf(e) > next),
|
|
1326
|
-
/** 缓冲被环形覆盖时会为 true,表示你错过了中间一段。 */
|
|
1327
|
-
gap: entries.length > 0 && this.out.length > 0 && revOf(this.out[0]) > cur + 1,
|
|
1328
|
-
};
|
|
1329
|
-
// 思考内容只在显式要求时、且只针对"当前正在跑的回合"返回;不保留、不进缓冲
|
|
1330
|
-
if (includeReasoning && this.collector?.reasoning) {
|
|
1331
|
-
res.live_thinking = this.collector.reasoning;
|
|
1332
|
-
}
|
|
1333
|
-
return res;
|
|
1334
|
-
}
|
|
1335
|
-
|
|
1336
|
-
/** 建一条回合记录。派活时先建,好让调用方立刻拿到 run_id(异步模式的关键)。 */
|
|
1337
|
-
createRun(text, { clientRunId = null, review = this.review } = {}) {
|
|
1338
|
-
const run = {
|
|
1339
|
-
run_id: `run-${Date.now().toString(36)}-${++this.runSeq}`,
|
|
1340
|
-
conversation_id: this.id,
|
|
1341
|
-
client_run_id: clientRunId ?? null,
|
|
1342
|
-
prompt_preview: text.length > MAX_RUN_PREVIEW ? `${text.slice(0, MAX_RUN_PREVIEW)}…` : text,
|
|
1343
|
-
status: 'queued',
|
|
1344
|
-
/** 本回合的审核策略(off / advisory);由 dsh_send / dsh_interject 或会话默认决定。 */
|
|
1345
|
-
review_mode: normalizeReviewMode(review, this.review),
|
|
1346
|
-
started_at: null,
|
|
1347
|
-
ended_at: null,
|
|
1348
|
-
elapsed_ms: null,
|
|
1349
|
-
result: null,
|
|
1350
|
-
};
|
|
1351
|
-
this.runs.push(run);
|
|
1352
|
-
if (clientRunId) this.clientRuns.set(clientRunId, run.run_id);
|
|
1353
|
-
if (this.runs.length > MAX_RUNS) {
|
|
1354
|
-
const evicted = this.runs.shift();
|
|
1355
|
-
// 幂等键必须跟着 run 记录一起淘汰,否则会指向一个已经查不到的 run。
|
|
1356
|
-
for (const [k, v] of this.clientRuns) {
|
|
1357
|
-
if (v === evicted.run_id) this.clientRuns.delete(k);
|
|
1358
|
-
}
|
|
1359
|
-
}
|
|
1360
|
-
// 防御性清残留:正常情况下该 run_id 从未用过,但确保等待方不会被历史哨兵误触发。
|
|
1361
|
-
this.#hub.clearRunSentinel(this.id, run.run_id);
|
|
1362
|
-
return run;
|
|
1363
|
-
}
|
|
1364
|
-
|
|
1365
|
-
/** 按调用方幂等键取 run(不存在/已淘汰返回 null)。 */
|
|
1366
|
-
getRunByClientId(clientRunId) {
|
|
1367
|
-
if (!clientRunId) return null;
|
|
1368
|
-
const runId = this.clientRuns.get(clientRunId);
|
|
1369
|
-
if (!runId) return null;
|
|
1370
|
-
return this.runs.find((r) => r.run_id === runId) ?? null;
|
|
1371
|
-
}
|
|
1372
|
-
|
|
1373
|
-
// ── 会话级队列(忙时非阻塞入队)────────────────────────────────
|
|
1374
|
-
|
|
1375
|
-
/** 入队;返回 run 记录与 1-based 位置。队列只在内存,重启丢失。 */
|
|
1376
|
-
enqueue(text, { reasoning = 'hide', clientRunId = null, preview = null, review = this.review } = {}) {
|
|
1377
|
-
const MAX_QUEUE = 8;
|
|
1378
|
-
if (clientRunId) {
|
|
1379
|
-
const existing = this.getRunByClientId(clientRunId);
|
|
1380
|
-
if (existing) return { run: existing, position: 0, deduped: true };
|
|
1381
|
-
}
|
|
1382
|
-
if (this.queue.length >= MAX_QUEUE) {
|
|
1383
|
-
throw new Error(`队列已满(最多 ${MAX_QUEUE} 条);先用 dsh_queue list 清理,或等当前回合结束`);
|
|
1384
|
-
}
|
|
1385
|
-
// 预览用调用方原文,执行用可能已注入团队前缀的 text。
|
|
1386
|
-
const run = this.createRun(preview ?? text, { clientRunId, review });
|
|
1387
|
-
run.status = 'queued';
|
|
1388
|
-
this.queue.push({ run, text, reasoning, clientRunId, enqueued_at: now() });
|
|
1389
|
-
return { run, position: this.queue.length, deduped: false };
|
|
1390
|
-
}
|
|
1391
|
-
|
|
1392
|
-
queueList() {
|
|
1393
|
-
// 防御:snapshot 可能被测试用 prototype 替身调用(没有构造器初始化 queue)。
|
|
1394
|
-
return (this.queue ?? []).map((item, i) => ({
|
|
1395
|
-
position: i + 1,
|
|
1396
|
-
run_id: item.run.run_id,
|
|
1397
|
-
preview: item.run.prompt_preview,
|
|
1398
|
-
enqueued_at: item.enqueued_at,
|
|
1399
|
-
client_run_id: item.clientRunId ?? null,
|
|
1400
|
-
}));
|
|
1401
|
-
}
|
|
1402
|
-
|
|
1403
|
-
queueRemove(runId, { cancel = true } = {}) {
|
|
1404
|
-
const i = this.queue.findIndex((x) => x.run.run_id === runId);
|
|
1405
|
-
if (i < 0) throw new Error(`队列里没有 ${runId}`);
|
|
1406
|
-
const [item] = this.queue.splice(i, 1);
|
|
1407
|
-
if (cancel) {
|
|
1408
|
-
const run = item.run;
|
|
1409
|
-
run.status = 'cancelled';
|
|
1410
|
-
run.ended_at = now();
|
|
1411
|
-
run.elapsed_ms = 0;
|
|
1412
|
-
run.result = {
|
|
1413
|
-
conversation_id: this.id,
|
|
1414
|
-
run_id: run.run_id,
|
|
1415
|
-
stop_reason: 'cancelled',
|
|
1416
|
-
error: 'queued run cancelled before it started',
|
|
1417
|
-
answer: '',
|
|
1418
|
-
thinking_stats: { hidden: true, chars: 0, chunks: 0, ms: 0 },
|
|
1419
|
-
tools_used: [],
|
|
1420
|
-
workspace_changed: false,
|
|
1421
|
-
elapsed_ms: 0,
|
|
1422
|
-
};
|
|
1423
|
-
if (run.review_mode !== 'off') {
|
|
1424
|
-
run.result.review = buildReviewTicket({
|
|
1425
|
-
conversationId: this.id,
|
|
1426
|
-
runId: run.run_id,
|
|
1427
|
-
promptPreview: run.prompt_preview,
|
|
1428
|
-
result: run.result,
|
|
1429
|
-
mode: run.review_mode,
|
|
1430
|
-
permission: this.permission,
|
|
1431
|
-
});
|
|
1432
|
-
}
|
|
1433
|
-
// 取消也是终态 → 写哨兵,避免等待方永远空等。
|
|
1434
|
-
const file = this.#hub.writeRunSentinel(this.id, run);
|
|
1435
|
-
run.result.sentinel_file = file ?? this.#hub.runSentinelPath(this.id, run.run_id);
|
|
1436
|
-
}
|
|
1437
|
-
return { removed: true, run_id: runId };
|
|
1438
|
-
}
|
|
1439
|
-
|
|
1440
|
-
queueUpdate(runId, text) {
|
|
1441
|
-
const item = this.queue.find((x) => x.run.run_id === runId);
|
|
1442
|
-
if (!item) throw new Error(`队列里没有 ${runId}`);
|
|
1443
|
-
item.text = text;
|
|
1444
|
-
item.run.prompt_preview =
|
|
1445
|
-
text.length > MAX_RUN_PREVIEW ? `${text.slice(0, MAX_RUN_PREVIEW)}…` : text;
|
|
1446
|
-
return { updated: true, run_id: runId };
|
|
1447
|
-
}
|
|
1448
|
-
|
|
1449
|
-
queuePromote(runId) {
|
|
1450
|
-
const i = this.queue.findIndex((x) => x.run.run_id === runId);
|
|
1451
|
-
if (i < 0) throw new Error(`队列里没有 ${runId}`);
|
|
1452
|
-
const [item] = this.queue.splice(i, 1);
|
|
1453
|
-
this.queue.unshift(item);
|
|
1454
|
-
return { promoted: true, run_id: runId, position: 1 };
|
|
1455
|
-
}
|
|
1456
|
-
|
|
1457
|
-
/** 空闲时取队首执行。失败也要继续下一条(否则一条坏任务卡死整条队列)。 */
|
|
1458
|
-
#scheduleDrain() {
|
|
1459
|
-
if (this.busy || this.draining || this.queue.length === 0) return;
|
|
1460
|
-
this.draining = true;
|
|
1461
|
-
const timer = setTimeout(() => {
|
|
1462
|
-
this.draining = false;
|
|
1463
|
-
if (this.busy || this.queue.length === 0) return;
|
|
1464
|
-
const item = this.queue.shift();
|
|
1465
|
-
const run = item.run;
|
|
1466
|
-
run.status = 'running';
|
|
1467
|
-
this.prompt(item.text, { reasoning: item.reasoning, run, wait: false })
|
|
1468
|
-
.catch(() => {})
|
|
1469
|
-
.finally(() => this.#scheduleDrain());
|
|
1470
|
-
}, 0);
|
|
1471
|
-
timer.unref?.();
|
|
1472
|
-
}
|
|
1473
|
-
|
|
1474
|
-
/** 取回合记录;不传 run_id 就是最近一条。 */
|
|
1475
|
-
getRun(runId) {
|
|
1476
|
-
if (runId) {
|
|
1477
|
-
const r = this.runs.find((x) => x.run_id === runId);
|
|
1478
|
-
if (!r) {
|
|
1479
|
-
throw new Error(`未知 run_id: ${runId}(回合记录只在内存、只留最近 ${MAX_RUNS} 条;也可用 dsh_read 读输出缓冲)`);
|
|
1480
|
-
}
|
|
1481
|
-
return r;
|
|
1482
|
-
}
|
|
1483
|
-
return this.runs.length ? this.runs[this.runs.length - 1] : null;
|
|
1484
|
-
}
|
|
1485
|
-
|
|
1486
|
-
/**
|
|
1487
|
-
* 异步收据里的"待审核"审核单:回合还没结束,只有清单和 run_id,
|
|
1488
|
-
* 告诉调用方"拿到哨兵结果后先审核,再当完成"。
|
|
1489
|
-
*/
|
|
1490
|
-
pendingReviewTicket(runId) {
|
|
1491
|
-
const run = this.runs.find((x) => x.run_id === runId) ?? null;
|
|
1492
|
-
const mode = run?.review_mode ?? this.review;
|
|
1493
|
-
if (mode === 'off') return null;
|
|
1494
|
-
return buildReviewTicket({
|
|
1495
|
-
conversationId: this.id,
|
|
1496
|
-
runId,
|
|
1497
|
-
promptPreview: run?.prompt_preview ?? null,
|
|
1498
|
-
result: null,
|
|
1499
|
-
mode,
|
|
1500
|
-
permission: this.permission,
|
|
1501
|
-
pending: true,
|
|
1502
|
-
});
|
|
1503
|
-
}
|
|
1504
|
-
|
|
1505
|
-
/**
|
|
1506
|
-
* 记录调用方对某个已完成回合的审核裁决。
|
|
1507
|
-
*
|
|
1508
|
-
* 三种来源按优先级取事实:
|
|
1509
|
-
* 1) 内存里的 run(含完整 result);
|
|
1510
|
-
* 2) 哨兵文件(服务重启 / run 记录被挤出内存后仍然可审核);
|
|
1511
|
-
* 3) 已有的审核记录(允许修订裁决)。
|
|
1512
|
-
*
|
|
1513
|
-
* 注意:**不阻塞后续派活,也不自动回灌给 DSH**。审核只是把"调用方已经看过并给出结论"
|
|
1514
|
-
* 这件事落成可审计的事实;要修就由调用方再 dsh_send。
|
|
1515
|
-
*/
|
|
1516
|
-
submitReview(runId, { verdict, notes, findings } = {}) {
|
|
1517
|
-
if (typeof runId !== 'string' || runId.trim() === '') {
|
|
1518
|
-
throw new Error('run_id 不能为空');
|
|
1519
|
-
}
|
|
1520
|
-
assertSafeId(runId, 'run_id');
|
|
1521
|
-
if (!REVIEW_VERDICTS.includes(verdict)) {
|
|
1522
|
-
throw new Error(`verdict 必须是 ${REVIEW_VERDICTS.join(' | ')} 之一`);
|
|
1523
|
-
}
|
|
1524
|
-
const cleanNotes = typeof notes === 'string' ? notes.trim() : '';
|
|
1525
|
-
const cleanFindings = Array.isArray(findings)
|
|
1526
|
-
? findings.filter((f) => typeof f === 'string' && f.trim() !== '').map((f) => f.trim())
|
|
1527
|
-
: [];
|
|
1528
|
-
if (verdict !== 'pass' && !cleanNotes && !cleanFindings.length) {
|
|
1529
|
-
throw new Error('verdict=fail/partial 时必须提供 notes(或 findings)说明理由');
|
|
1530
|
-
}
|
|
1531
|
-
|
|
1532
|
-
const run = this.runs.find((x) => x.run_id === runId) ?? null;
|
|
1533
|
-
let result = run?.result ?? null;
|
|
1534
|
-
let promptPreview = run?.prompt_preview ?? null;
|
|
1535
|
-
let mode = run?.review_mode ?? this.review;
|
|
1536
|
-
|
|
1537
|
-
if (run) {
|
|
1538
|
-
if (run.status === 'running' || run.status === 'queued') {
|
|
1539
|
-
throw new Error(`回合 ${runId} 还没结束,不能审核(等哨兵文件出现或 dsh_get 显示完成后重试)`);
|
|
1540
|
-
}
|
|
1541
|
-
if (!result) throw new Error(`回合 ${runId} 没有可用结果,无法审核`);
|
|
1542
|
-
} else {
|
|
1543
|
-
// 内存里没有这条 run:以哨兵为准。它只在回合终止时写入,所以天然是"已完成"。
|
|
1544
|
-
let sentinel = null;
|
|
1545
|
-
try {
|
|
1546
|
-
sentinel = JSON.parse(readFileSync(this.#hub.runSentinelPath(this.id, runId), 'utf8'));
|
|
1547
|
-
} catch {
|
|
1548
|
-
/* 下面的审核记录回退会给出更准确的错误 */
|
|
1549
|
-
}
|
|
1550
|
-
if (sentinel) {
|
|
1551
|
-
result = sentinel.result ?? null;
|
|
1552
|
-
if (result?.review && typeof result.review === 'object') {
|
|
1553
|
-
promptPreview = result.review.prompt_preview ?? promptPreview;
|
|
1554
|
-
mode = result.review.mode ?? mode;
|
|
1555
|
-
}
|
|
1556
|
-
}
|
|
1557
|
-
}
|
|
1558
|
-
|
|
1559
|
-
const existing = readReviewRecord(this.id, runId);
|
|
1560
|
-
if (!result && !existing) {
|
|
1561
|
-
throw new Error(
|
|
1562
|
-
`未知 run_id: ${runId}(内存里没有、也没有哨兵/审核记录;请确认 run_id 是否正确,` +
|
|
1563
|
-
`或先用 dsh_get / 哨兵文件取回结果)`,
|
|
1564
|
-
);
|
|
1565
|
-
}
|
|
1566
|
-
|
|
1567
|
-
const ticket =
|
|
1568
|
-
result?.review && typeof result.review === 'object'
|
|
1569
|
-
? { ...result.review }
|
|
1570
|
-
: existing
|
|
1571
|
-
? { ...existing }
|
|
1572
|
-
: buildReviewTicket({
|
|
1573
|
-
conversationId: this.id,
|
|
1574
|
-
runId,
|
|
1575
|
-
promptPreview,
|
|
1576
|
-
result: result ?? {},
|
|
1577
|
-
mode,
|
|
1578
|
-
permission: this.permission,
|
|
1579
|
-
});
|
|
1580
|
-
if (!ticket.review_id) ticket.review_id = reviewIdForRun(runId);
|
|
1581
|
-
ticket.conversation_id = ticket.conversation_id ?? this.id;
|
|
1582
|
-
ticket.run_id = ticket.run_id ?? runId;
|
|
1583
|
-
ticket.mode = ticket.mode ?? mode;
|
|
1584
|
-
|
|
1585
|
-
const reviewedAt = new Date().toISOString();
|
|
1586
|
-
const { file, payload } = writeReviewRecord({
|
|
1587
|
-
ticket,
|
|
1588
|
-
verdict,
|
|
1589
|
-
notes: cleanNotes,
|
|
1590
|
-
findings: cleanFindings,
|
|
1591
|
-
reviewedAt,
|
|
1592
|
-
});
|
|
1593
|
-
const review = {
|
|
1594
|
-
...ticket,
|
|
1595
|
-
status: verdict,
|
|
1596
|
-
verdict,
|
|
1597
|
-
notes: payload.notes,
|
|
1598
|
-
findings: payload.findings,
|
|
1599
|
-
reviewed_at: reviewedAt,
|
|
1600
|
-
review_file: file,
|
|
1601
|
-
};
|
|
1602
|
-
if (run) {
|
|
1603
|
-
run.review = review;
|
|
1604
|
-
if (run.result && typeof run.result === 'object') run.result.review = review;
|
|
1605
|
-
}
|
|
1606
|
-
// 哨兵是调用方异步收活的来源;把裁决回写进去,避免它一直显示 pending。
|
|
1607
|
-
this.#hub.updateRunSentinelReview(this.id, runId, {
|
|
1608
|
-
status: review.status,
|
|
1609
|
-
verdict: review.verdict,
|
|
1610
|
-
notes: review.notes,
|
|
1611
|
-
findings: review.findings,
|
|
1612
|
-
reviewed_at: reviewedAt,
|
|
1613
|
-
review_file: file,
|
|
1614
|
-
});
|
|
1615
|
-
return {
|
|
1616
|
-
...review,
|
|
1617
|
-
review_file: file,
|
|
1618
|
-
sentinel_file: this.#hub.runSentinelPath(this.id, runId),
|
|
1619
|
-
};
|
|
1620
|
-
}
|
|
1621
|
-
|
|
1622
|
-
/** 等到当前回合结束(不忙则立即返回 true;`timeoutMs <= 0` 一直等;超时返回 false)。 */
|
|
1623
|
-
async waitIdle(timeoutMs = 30 * 60 * 1000) {
|
|
1624
|
-
if (!this.busy) return true;
|
|
1625
|
-
return await new Promise((resolve) => {
|
|
1626
|
-
let settled = false;
|
|
1627
|
-
let timer = null;
|
|
1628
|
-
const done = () => {
|
|
1629
|
-
if (settled) return;
|
|
1630
|
-
settled = true;
|
|
1631
|
-
if (timer) clearTimeout(timer);
|
|
1632
|
-
resolve(true);
|
|
1633
|
-
};
|
|
1634
|
-
if (timeoutMs > 0) {
|
|
1635
|
-
timer = setTimeout(() => {
|
|
1636
|
-
if (settled) return;
|
|
1637
|
-
settled = true;
|
|
1638
|
-
const i = this.idleWaiters.indexOf(done);
|
|
1639
|
-
if (i >= 0) this.idleWaiters.splice(i, 1);
|
|
1640
|
-
resolve(false);
|
|
1641
|
-
}, timeoutMs);
|
|
1642
|
-
}
|
|
1643
|
-
this.idleWaiters.push(done);
|
|
1644
|
-
});
|
|
1645
|
-
}
|
|
1646
|
-
|
|
1647
|
-
/**
|
|
1648
|
-
* 插话。
|
|
1649
|
-
*
|
|
1650
|
-
* ACP 明确拒绝并发 prompt(`a prompt is already in flight for this session`),
|
|
1651
|
-
* 所以做不到"把消息塞进正在跑的回合里让模型即时改向"——DSH 的 agent 本身有 steering
|
|
1652
|
-
* 队列,但被 ACP 桥接层挡住了。这里提供的是它的实用等价物:
|
|
1653
|
-
*
|
|
1654
|
-
* interject —— 打断当前回合,随即把新消息作为新回合发出("停一下,听我说")
|
|
1655
|
-
* queue —— 不打断,等当前回合自然结束后紧接着发出("你先把这轮干完,然后…")
|
|
1656
|
-
*
|
|
1657
|
-
* 之所以无竞态:dsh-acp 在让 prompt 请求返回**之前**就清空了 inflight 槽位,
|
|
1658
|
-
* 且只在 agent 完全 idle 后才 settle(lib/index.js:1003-1011)。
|
|
1659
|
-
*/
|
|
1660
|
-
async interject(message, { mode = 'interject', reasoning = 'hide', timeoutMs = PROMPT_TIMEOUT_MS, clientRunId = null, review } = {}) {
|
|
1661
|
-
// 先建 run:幂等键要在"等待上一轮"之前就登记,否则重试会造出第二个 run。
|
|
1662
|
-
const run = this.createRun(message, { clientRunId, review });
|
|
1663
|
-
if (!this.busy) {
|
|
1664
|
-
const r = await this.prompt(message, { reasoning, timeoutMs, run });
|
|
1665
|
-
return { ...r, interjected: 'idle', waited_ms: 0 };
|
|
1666
|
-
}
|
|
1667
|
-
const t0 = now();
|
|
1668
|
-
if (mode === 'interject') {
|
|
1669
|
-
this.proc?.notify('session/cancel', { sessionId: this.id });
|
|
1670
|
-
}
|
|
1671
|
-
// 排队等待的时长跟随调用方的 timeoutMs(0 = 一直等),不再固定 30 分钟。
|
|
1672
|
-
const idle = await this.waitIdle(timeoutMs);
|
|
1673
|
-
const waited = now() - t0;
|
|
1674
|
-
if (!idle) {
|
|
1675
|
-
const err = new Error('等待当前回合结束超时,插话未发送');
|
|
1676
|
-
err.runId = run.run_id;
|
|
1677
|
-
run.status = 'cancelled';
|
|
1678
|
-
run.ended_at = now();
|
|
1679
|
-
run.elapsed_ms = 0;
|
|
1680
|
-
run.result = {
|
|
1681
|
-
conversation_id: this.id,
|
|
1682
|
-
run_id: run.run_id,
|
|
1683
|
-
stop_reason: 'cancelled',
|
|
1684
|
-
error: err.message,
|
|
1685
|
-
answer: '',
|
|
1686
|
-
thinking_stats: { hidden: true, chars: 0, chunks: 0, ms: 0 },
|
|
1687
|
-
tools_used: [],
|
|
1688
|
-
workspace_changed: false,
|
|
1689
|
-
elapsed_ms: 0,
|
|
1690
|
-
};
|
|
1691
|
-
// 取消同样是一次终态结果:审核单照挂,调用方仍可记录"这次没跑成"。
|
|
1692
|
-
if (run.review_mode !== 'off') {
|
|
1693
|
-
run.result.review = buildReviewTicket({
|
|
1694
|
-
conversationId: this.id,
|
|
1695
|
-
runId: run.run_id,
|
|
1696
|
-
promptPreview: run.prompt_preview,
|
|
1697
|
-
result: run.result,
|
|
1698
|
-
mode: run.review_mode,
|
|
1699
|
-
permission: this.permission,
|
|
1700
|
-
});
|
|
1701
|
-
}
|
|
1702
|
-
const file = this.#hub.writeRunSentinel?.(this.id, run);
|
|
1703
|
-
run.result.sentinel_file = file ?? this.#hub.runSentinelPath?.(this.id, run.run_id) ?? null;
|
|
1704
|
-
throw err;
|
|
1705
|
-
}
|
|
1706
|
-
const r = await this.prompt(message, { reasoning, timeoutMs, run });
|
|
1707
|
-
return { ...r, interjected: mode, waited_ms: waited };
|
|
1708
|
-
}
|
|
1709
|
-
|
|
1710
|
-
/** 关闭会话(进程收尾;会话本体留在磁盘上,可再次 resume)。 */
|
|
1711
|
-
async close({ forget = false } = {}) {
|
|
1712
|
-
if (this.recycling) await this.recycling;
|
|
1713
|
-
const proc = this.proc;
|
|
1714
|
-
let stopped = true;
|
|
1715
|
-
if (proc?.alive) {
|
|
1716
|
-
if (typeof proc.detach === 'function') {
|
|
1717
|
-
// 共享进程:只把本会话摘出去(detach 内会 session/close);最后一个走了才停进程。
|
|
1718
|
-
this.recycling = proc.detach(this).finally(() => {
|
|
1719
|
-
if (this.proc === proc) this.proc = null;
|
|
1720
|
-
this.recycling = null;
|
|
1721
|
-
});
|
|
1722
|
-
stopped = await this.recycling;
|
|
1723
|
-
} else {
|
|
1724
|
-
try {
|
|
1725
|
-
await proc.request('session/close', { sessionId: this.id }, 10_000);
|
|
1726
|
-
} catch {}
|
|
1727
|
-
// 与 reaper 相同:stop 期间保持 proc 可见并登记 recycling,
|
|
1728
|
-
// 落定后再置空——否则并发 dsh_send 会拿到垂死进程或去抢尚未释放的锁。
|
|
1729
|
-
this.recycling = proc
|
|
1730
|
-
.stop()
|
|
1731
|
-
.catch(() => false)
|
|
1732
|
-
.finally(() => {
|
|
1733
|
-
if (this.proc === proc) this.proc = null;
|
|
1734
|
-
this.recycling = null;
|
|
1735
|
-
});
|
|
1736
|
-
stopped = await this.recycling;
|
|
1737
|
-
}
|
|
1738
|
-
} else if (this.proc === proc) {
|
|
1739
|
-
this.proc = null;
|
|
1740
|
-
}
|
|
1741
|
-
// 回合已经结束/进程正在关闭,任何仍挂着的审批都不再可裁决。
|
|
1742
|
-
for (const [, p] of this.pendingApprovals) {
|
|
1743
|
-
try {
|
|
1744
|
-
p.resolve({ outcome: { outcome: 'cancelled' } });
|
|
1745
|
-
} catch {}
|
|
1746
|
-
}
|
|
1747
|
-
this.pendingApprovals.clear();
|
|
1748
|
-
// 只有确认进程退出后才删登记;否则把登记留着,让 takeover/classifyHolder
|
|
1749
|
-
// 还能把这个仍握着写锁的孤儿归因出来。
|
|
1750
|
-
if (stopped) removeMarker(this.id);
|
|
1751
|
-
else {
|
|
1752
|
-
this.#hub.log.info(`[conv ${this.id}] 进程未能确认退出,保留写锁登记以便后续归因`);
|
|
1753
|
-
// 进程还活着时不能 forget:墓碑会让按 id 查询直接失败,而活着的进程
|
|
1754
|
-
// 仍握着写锁,之后连 takeover 都无法归因/接管。
|
|
1755
|
-
if (forget) return { closed: false, forgotten: false, error: '进程未能确认退出,拒绝 forget' };
|
|
1756
|
-
}
|
|
1757
|
-
if (forget) {
|
|
1758
|
-
// 先写墓碑再从注册表移除:之后按 id 查询会明确报"已 forget",
|
|
1759
|
-
// 而不是从 DSH 会话存储把它重建回来(否则 forget 形同虚设)。
|
|
1760
|
-
writeTombstone(this.id);
|
|
1761
|
-
this.#hub.remove(this.id);
|
|
1762
|
-
} else this.#hub.save();
|
|
1763
|
-
return { closed: stopped, forgotten: forget, ...(stopped ? {} : { error: '进程未能确认退出,登记已保留' }) };
|
|
1764
|
-
}
|
|
1765
|
-
}
|
|
1766
|
-
|
|
1767
|
-
/**
|
|
1768
|
-
* 两次 workspace 快照是否表示同一组工作区变更。
|
|
1769
|
-
*
|
|
1770
|
-
* 优先比较 `fingerprint`(覆盖**全量**条目:状态行 + 每个路径的 size/mtime,
|
|
1771
|
-
* 未跟踪目录递归展开)。只比较前 40 条 entries 会把"同一状态行但内容变了"
|
|
1772
|
-
* 和第 41 条之后的改动都漏掉(workspace_changed 假阴性)。
|
|
1773
|
-
*/
|
|
1774
|
-
export function sameWorkspaceSnapshot(a, b) {
|
|
1775
|
-
if (!a && !b) return true;
|
|
1776
|
-
if (!a || !b) return false;
|
|
1777
|
-
if (typeof a.fingerprint === 'string' && typeof b.fingerprint === 'string') {
|
|
1778
|
-
return a.fingerprint === b.fingerprint;
|
|
1779
|
-
}
|
|
1780
|
-
// 兼容旧快照/测试里手写的 { changed_files, entries }。
|
|
1781
|
-
return JSON.stringify(a.entries ?? []) === JSON.stringify(b.entries ?? []);
|
|
1782
|
-
}
|
|
1783
|
-
|
|
1784
|
-
const GIT_TIMEOUT_MS = 8000;
|
|
1785
|
-
const GIT_MAX_BUFFER = 64 * 1024 * 1024;
|
|
1786
|
-
const MAX_UNTRACKED_DIR_ENTRIES = 2000;
|
|
1787
|
-
const execFileAsync = promisify(execFile);
|
|
1788
|
-
|
|
1789
|
-
/**
|
|
1790
|
-
* 安全 git 参数(放 subcommand 之前)。
|
|
1791
|
-
*
|
|
1792
|
-
* `--no-optional-locks` 是 **git 全局选项**,放在 `status` 之后会被 git 当成
|
|
1793
|
-
* 未知参数直接退出 129 —— 那样快照永远是 null,workspace_changed 恒为 false。
|
|
1794
|
-
*/
|
|
1795
|
-
const GIT_SAFE = ['--no-optional-locks', '-c', 'core.fsmonitor=false'];
|
|
1796
|
-
|
|
1797
|
-
function gitEnv() {
|
|
1798
|
-
return { ...process.env, GIT_OPTIONAL_LOCKS: '0' };
|
|
1799
|
-
}
|
|
1800
|
-
|
|
1801
|
-
/** 异步跑一次 git 并取 stdout;非 git 目录/超时/输出过大都抛给调用方。 */
|
|
1802
|
-
async function gitOutput(cwd, args) {
|
|
1803
|
-
const { stdout } = await execFileAsync('git', args, {
|
|
1804
|
-
cwd,
|
|
1805
|
-
encoding: 'utf8',
|
|
1806
|
-
timeout: GIT_TIMEOUT_MS,
|
|
1807
|
-
maxBuffer: GIT_MAX_BUFFER,
|
|
1808
|
-
windowsHide: true,
|
|
1809
|
-
env: gitEnv(),
|
|
1810
|
-
});
|
|
1811
|
-
return stdout;
|
|
1812
|
-
}
|
|
1813
|
-
|
|
1814
|
-
/** 解析 porcelain v1 -z 状态行;rename/copy 的“原路径”是下一个 NUL 段。 */
|
|
1815
|
-
function parsePorcelainZ(stdout) {
|
|
1816
|
-
const tokens = String(stdout ?? '').split('\0');
|
|
1817
|
-
const rows = [];
|
|
1818
|
-
for (let i = 0; i < tokens.length; i++) {
|
|
1819
|
-
const token = tokens[i];
|
|
1820
|
-
if (!token) continue;
|
|
1821
|
-
// 形如 "XY path";posix 允许路径里有空格,所以只切前三个字符。
|
|
1822
|
-
if (token.length < 4 || token[2] !== ' ') continue;
|
|
1823
|
-
const status = token.slice(0, 2);
|
|
1824
|
-
const path = token.slice(3);
|
|
1825
|
-
let orig = null;
|
|
1826
|
-
if (/[RC]/.test(status)) orig = tokens[++i] || null;
|
|
1827
|
-
rows.push({ status, path, orig });
|
|
1828
|
-
}
|
|
1829
|
-
return rows;
|
|
1830
|
-
}
|
|
1831
|
-
|
|
1832
|
-
/**
|
|
1833
|
-
* 把一个路径的当前状态(类型 + size + mtimeMs)混进指纹。
|
|
1834
|
-
*
|
|
1835
|
-
* - 普通文件:size + mtimeMs 足以识别"状态行没变、内容变了";
|
|
1836
|
-
* - 未跟踪目录(`?? dir/`):目录 mtime 只在直接子项增删时变,光看它不够,
|
|
1837
|
-
* 所以递归展开(只用 lstat,不跟随符号链接/不钻 .git,且有预算上限);
|
|
1838
|
-
* - 已跟踪的目录条目(子模块):只看它自己的 lstat,避免把子模块整个走一遍。
|
|
1839
|
-
*/
|
|
1840
|
-
function hashPathStat(hash, full, label, budget) {
|
|
1841
|
-
let st;
|
|
1842
|
-
try {
|
|
1843
|
-
st = lstatSync(full);
|
|
1844
|
-
} catch {
|
|
1845
|
-
hash.update(`${label}\0missing\0`);
|
|
1846
|
-
return;
|
|
1847
|
-
}
|
|
1848
|
-
if (st.isDirectory() && budget) {
|
|
1849
|
-
hash.update(`${label}\0dir\0${st.mtimeMs}\0`);
|
|
1850
|
-
let entries;
|
|
1851
|
-
try {
|
|
1852
|
-
entries = readdirSync(full, { withFileTypes: true });
|
|
1853
|
-
} catch {
|
|
1854
|
-
hash.update('unreadable\0');
|
|
1855
|
-
return;
|
|
1856
|
-
}
|
|
1857
|
-
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
1858
|
-
for (const ent of entries) {
|
|
1859
|
-
if (ent.name === '.git') continue;
|
|
1860
|
-
if (budget.left <= 0) {
|
|
1861
|
-
hash.update(`${label}\0truncated\0`);
|
|
1862
|
-
return;
|
|
1863
|
-
}
|
|
1864
|
-
budget.left -= 1;
|
|
1865
|
-
hashPathStat(hash, join(full, ent.name), `${label}/${ent.name}`, budget);
|
|
1866
|
-
}
|
|
1867
|
-
return;
|
|
1868
|
-
}
|
|
1869
|
-
const kind = st.isSymbolicLink() ? 'link' : st.isFile() ? 'file' : st.isDirectory() ? 'dir' : 'other';
|
|
1870
|
-
hash.update(`${label}\0${kind}\0${st.size}\0${st.mtimeMs}\0`);
|
|
1871
|
-
}
|
|
1872
|
-
|
|
1873
|
-
/**
|
|
1874
|
-
* 收集当前工作区的 git 变更摘要(非 git 目录返回 null)。
|
|
1875
|
-
*
|
|
1876
|
-
* 异步实现:`execFile` 不占用事件循环 —— 一个 MCP 服务进程承载多个会话,
|
|
1877
|
-
* 同步 `spawnSync` 最长 8s 会把所有会话的 ACP 消息/审批一起卡住。
|
|
1878
|
-
*
|
|
1879
|
-
* 返回对象向后兼容:`changed_files` = 全量状态条目数,`entries` = 前 40 条
|
|
1880
|
-
* 展示用状态行;新增 `fingerprint` 覆盖**全量**条目(含逐路径 size/mtime),
|
|
1881
|
-
* 供 `sameWorkspaceSnapshot` 判断"内容是否真的变了"。
|
|
1882
|
-
*/
|
|
1883
|
-
export async function gitStatus(cwd) {
|
|
1884
|
-
if (process.env.DSH_MCP_GIT_STATUS === '0') return null;
|
|
1885
|
-
try {
|
|
1886
|
-
// ★ 在不可信仓库里 `git status` 可能触发仓库配置的 fsmonitor/filter;这些
|
|
1887
|
-
// 命令发生在 DSH 沙箱与审批层之外。禁用可选锁与 fsmonitor,并允许用
|
|
1888
|
-
// DSH_MCP_GIT_STATUS=0 完全关掉。
|
|
1889
|
-
// porcelain 的路径相对 **仓库根**(cwd 是子目录时也是),所以同时取 top-level。
|
|
1890
|
-
const [statusOut, rootOut] = await Promise.all([
|
|
1891
|
-
gitOutput(cwd, [...GIT_SAFE, 'status', '--porcelain', '-z']),
|
|
1892
|
-
gitOutput(cwd, [...GIT_SAFE, 'rev-parse', '--show-toplevel']),
|
|
1893
|
-
]);
|
|
1894
|
-
const rows = parsePorcelainZ(statusOut);
|
|
1895
|
-
if (!rows.length) return null;
|
|
1896
|
-
// git 输出的是 `C:/...` 风格,统一成原生分隔符,后面 startsWith 才不会误判越界。
|
|
1897
|
-
const root = resolve(String(rootOut ?? '').trim() || cwd);
|
|
1898
|
-
|
|
1899
|
-
// 状态行按路径排序后参与指纹,避免依赖 git 的输出顺序。
|
|
1900
|
-
const sorted = rows
|
|
1901
|
-
.slice()
|
|
1902
|
-
.sort((a, b) =>
|
|
1903
|
-
a.path === b.path
|
|
1904
|
-
? a.status < b.status
|
|
1905
|
-
? -1
|
|
1906
|
-
: a.status > b.status
|
|
1907
|
-
? 1
|
|
1908
|
-
: 0
|
|
1909
|
-
: a.path < b.path
|
|
1910
|
-
? -1
|
|
1911
|
-
: 1,
|
|
1912
|
-
);
|
|
1913
|
-
|
|
1914
|
-
const hash = createHash('sha256');
|
|
1915
|
-
const budget = { left: MAX_UNTRACKED_DIR_ENTRIES };
|
|
1916
|
-
const entries = [];
|
|
1917
|
-
const rootPrefix = root.endsWith(sep) ? root : root + sep;
|
|
1918
|
-
for (const row of sorted) {
|
|
1919
|
-
const display = row.orig ? `${row.status} ${row.orig} -> ${row.path}` : `${row.status} ${row.path}`;
|
|
1920
|
-
if (entries.length < 40) entries.push(display);
|
|
1921
|
-
hash.update(display);
|
|
1922
|
-
hash.update('\0');
|
|
1923
|
-
|
|
1924
|
-
const cleanPath = row.path.endsWith('/') ? row.path.slice(0, -1) : row.path;
|
|
1925
|
-
const full = resolve(root, cleanPath);
|
|
1926
|
-
if (full !== root && !full.startsWith(rootPrefix)) {
|
|
1927
|
-
hash.update('outside-worktree\0');
|
|
1928
|
-
continue;
|
|
1929
|
-
}
|
|
1930
|
-
// 只有未跟踪目录需要递归;已跟踪条目(含子模块)看 lstat 即可。
|
|
1931
|
-
hashPathStat(hash, full, cleanPath, row.status === '??' ? budget : null);
|
|
1932
|
-
}
|
|
1933
|
-
|
|
1934
|
-
return {
|
|
1935
|
-
changed_files: rows.length,
|
|
1936
|
-
entries,
|
|
1937
|
-
fingerprint: hash.digest('hex'),
|
|
1938
|
-
};
|
|
1939
|
-
} catch {
|
|
1940
|
-
return null;
|
|
1941
|
-
}
|
|
1942
|
-
}
|
|
1943
|
-
|
|
1944
|
-
/** 按档位渲染思考内容。 */
|
|
1945
|
-
export function formatReasoning(c, mode) {
|
|
1946
|
-
if (mode === 'full') return c.reasoning;
|
|
1947
|
-
if (mode === 'summary') {
|
|
1948
|
-
const t = c.reasoning.trim();
|
|
1949
|
-
if (!t) return '';
|
|
1950
|
-
if (t.length <= 400) return t;
|
|
1951
|
-
return `${t.slice(0, 240)}\n…\n${t.slice(-120)}`;
|
|
1952
|
-
}
|
|
1953
|
-
if (mode === 'marker') {
|
|
1954
|
-
return c.reasoning ? `[思考已隐藏:${c.reasoning.length} 字]` : '';
|
|
1955
|
-
}
|
|
1956
|
-
return ''; // hide
|
|
1957
|
-
}
|
|
90
|
+
const now = () => Date.now();
|
|
1958
91
|
|
|
1959
92
|
// ── 注册表 ────────────────────────────────────────────────────────
|
|
1960
93
|
|
|
1961
|
-
/**
|
|
1962
|
-
* 工作区能不能用在家目录本身?
|
|
1963
|
-
*
|
|
1964
|
-
* DSH 自己的 ACL 沙箱要求临时目录在工作区**之外**。Windows 上 %TEMP% 是
|
|
1965
|
-
* C:/Users/<name>/AppData/Local/Temp,正好在用户家目录里面,所以把家目录
|
|
1966
|
-
* 当作工作区时,沙箱初始化会失败:
|
|
1967
|
-
* Windows ACL: temp root must be outside the workspace
|
|
1968
|
-
* 症状出现在第一次起 shell 的时候,看起来像这个服务坏了,其实只是 cwd
|
|
1969
|
-
* 选得太大。read-only 档不写盘、也不起 ACL 沙箱,所以不受影响。
|
|
1970
|
-
*
|
|
1971
|
-
* @returns {string|null} null 表示没问题,否则返回一段可直接抛出的说明。
|
|
1972
|
-
*/
|
|
1973
|
-
/**
|
|
1974
|
-
* 当前文件系统是否大小写不敏感(缓存一次)。
|
|
1975
|
-
*
|
|
1976
|
-
* 不能按平台猜:GitHub 的 `macos-latest` 是**大小写敏感的 APFS**,而普通 macOS
|
|
1977
|
-
* 默认又不敏感;Windows 一定不敏感;Linux 一定敏感。用临时文件探针最稳。
|
|
1978
|
-
*/
|
|
1979
|
-
let caseInsensitiveCache = null;
|
|
1980
|
-
export function fsCaseInsensitive() {
|
|
1981
|
-
if (caseInsensitiveCache !== null) return caseInsensitiveCache;
|
|
1982
|
-
if (process.platform === 'win32') {
|
|
1983
|
-
caseInsensitiveCache = true;
|
|
1984
|
-
return true;
|
|
1985
|
-
}
|
|
1986
|
-
try {
|
|
1987
|
-
const dir = mkdtempSync(join(tmpdir(), 'dsh-any-agent-case-'));
|
|
1988
|
-
writeFileSync(join(dir, 'probe'), '');
|
|
1989
|
-
caseInsensitiveCache = existsSync(join(dir, 'PROBE'));
|
|
1990
|
-
rmSync(dir, { recursive: true, force: true });
|
|
1991
|
-
} catch {
|
|
1992
|
-
caseInsensitiveCache = false;
|
|
1993
|
-
}
|
|
1994
|
-
return caseInsensitiveCache;
|
|
1995
|
-
}
|
|
1996
|
-
|
|
1997
|
-
export function homeWorkspaceProblem(cwd, permission) {
|
|
1998
|
-
if (permission === 'read-only') return null;
|
|
1999
|
-
const home = homedir();
|
|
2000
|
-
if (!home) return null;
|
|
2001
|
-
const caseInsensitive = process.platform === 'win32' || fsCaseInsensitive();
|
|
2002
|
-
const norm = (p) => {
|
|
2003
|
-
// 解析 symlink:一个指向家目录/根目录的软链不能绕过这两个边界。
|
|
2004
|
-
// 路径不存在时(测试里的 "some-project")回落到 resolve。
|
|
2005
|
-
let r;
|
|
2006
|
-
try {
|
|
2007
|
-
r = realpathSync(p);
|
|
2008
|
-
} catch {
|
|
2009
|
-
r = resolve(p);
|
|
2010
|
-
}
|
|
2011
|
-
return caseInsensitive ? r.toLowerCase() : r;
|
|
2012
|
-
};
|
|
2013
|
-
// 根目录 = 整个磁盘。workspace-write 的沙箱根会跟着 cwd 走,
|
|
2014
|
-
// 于是“只写工作区”退化成“可写全盘”,必须显式要求 danger-full-access。
|
|
2015
|
-
const fsRoot = resolve(parse(cwd).root);
|
|
2016
|
-
if (norm(cwd) === norm(fsRoot)) {
|
|
2017
|
-
return [
|
|
2018
|
-
'cwd 不能是文件系统根目录: ' + cwd,
|
|
2019
|
-
' workspace-write 的沙箱根会变成整个磁盘,等于绕过“只写工作区”的权限档。',
|
|
2020
|
-
' 请传一个具体的项目目录;确实要全盘访问请显式用 permission=danger-full-access。',
|
|
2021
|
-
].join('\n');
|
|
2022
|
-
}
|
|
2023
|
-
if (norm(cwd) !== norm(home)) return null;
|
|
2024
|
-
|
|
2025
|
-
const temp = tmpdir();
|
|
2026
|
-
const tempInside = norm(temp) === norm(home) || norm(temp).startsWith(norm(home) + sep);
|
|
2027
|
-
const lines = ['cwd 不能是用户家目录本身: ' + cwd];
|
|
2028
|
-
if (tempInside) lines.push(' 临时目录 ' + temp + ' 就在它里面,');
|
|
2029
|
-
lines.push(
|
|
2030
|
-
' DSH 的 ACL 沙箱要求临时目录在工作区之外,否则第一次起 shell 就会报:',
|
|
2031
|
-
' Windows ACL: temp root must be outside the workspace',
|
|
2032
|
-
'',
|
|
2033
|
-
' 换个具体项目目录最省事,或者改权限档:',
|
|
2034
|
-
' permission=read-only 不写盘,不需要该沙箱',
|
|
2035
|
-
' permission=danger-full-access 不受该沙箱约束',
|
|
2036
|
-
);
|
|
2037
|
-
return lines.join('\n');
|
|
2038
|
-
}
|
|
2039
|
-
|
|
2040
94
|
export class Hub {
|
|
2041
95
|
constructor({ log } = {}) {
|
|
2042
96
|
this.conversations = new Map();
|