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
|
@@ -0,0 +1,1600 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话(Conversation):一个会话 = 一个 DSH ACP 进程。
|
|
3
|
+
*
|
|
4
|
+
* 从 hub.mjs 拆出来:Conversation 只拿到 `hub` 的引用(登记 / 哨兵 / 共享宿主),
|
|
5
|
+
* 不 import Hub,所以两个模块之间没有环。hub.mjs 仍然重新导出 `Conversation` 与
|
|
6
|
+
* `formatReasoning`,外部的导入路径不变。
|
|
7
|
+
*
|
|
8
|
+
* 进程只是"缓存",不是"身份"——对话的身份是 DSH 的 sessionId,落盘在 DSH 的会话目录里。
|
|
9
|
+
* 因此进程死掉/被回收/整体重启后,用 session/resume 就能接着聊。
|
|
10
|
+
*
|
|
11
|
+
* 持久化红线同 hub.mjs 文件头:只存元数据;思考内容不写日志、默认也不落盘。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import {
|
|
17
|
+
DEFAULT_PERMISSION,
|
|
18
|
+
DEFAULT_REASONING_EFFORT,
|
|
19
|
+
DEFAULT_REVIEW,
|
|
20
|
+
PERMISSION_TIERS,
|
|
21
|
+
PROMPT_TIMEOUT_MS,
|
|
22
|
+
REVIEW_MODES,
|
|
23
|
+
SHARED_PROCESS,
|
|
24
|
+
DSH_HOME,
|
|
25
|
+
defaultApprovalPolicy,
|
|
26
|
+
} from './config.mjs';
|
|
27
|
+
import {
|
|
28
|
+
REVIEW_VERDICTS,
|
|
29
|
+
buildReviewTicket,
|
|
30
|
+
normalizeReviewMode,
|
|
31
|
+
readReviewRecord,
|
|
32
|
+
reviewIdForRun,
|
|
33
|
+
writeReviewRecord,
|
|
34
|
+
} from './review.mjs';
|
|
35
|
+
import { sanitizeApprovalContext } from './redact.mjs';
|
|
36
|
+
import { assertSafeId } from './ids.mjs';
|
|
37
|
+
import { AcpProcess } from './acp.mjs';
|
|
38
|
+
import { applySessionConfig, approvalContext, pickOption } from './acp-session.mjs';
|
|
39
|
+
import { rssMb } from './procstat.mjs';
|
|
40
|
+
import { readConversationMeta } from './titles.mjs';
|
|
41
|
+
import { gitStatus, sameWorkspaceSnapshot } from './git-snapshot.mjs';
|
|
42
|
+
import { writeTombstone } from './hub-store.mjs';
|
|
43
|
+
import {
|
|
44
|
+
classifyHolder,
|
|
45
|
+
describeLeaseConflict,
|
|
46
|
+
isLeaseError,
|
|
47
|
+
isPidAlive,
|
|
48
|
+
killHolderChild,
|
|
49
|
+
removeMarker,
|
|
50
|
+
writeMarker,
|
|
51
|
+
} from './locks.mjs';
|
|
52
|
+
|
|
53
|
+
const now = () => Date.now();
|
|
54
|
+
|
|
55
|
+
/** 运行期输出环形缓冲的条目上限(仅内存,超出丢最旧的)。 */
|
|
56
|
+
const MAX_OUT_ENTRIES = 500;
|
|
57
|
+
/** 单条输出正文的字符上限;超出保留尾部,避免一条超长输出把内存撑爆。 */
|
|
58
|
+
const MAX_OUT_TEXT = 200_000;
|
|
59
|
+
/** 记录用户发话时的截断长度(避免一条把缓冲撑爆)。 */
|
|
60
|
+
const MAX_PROMPT_ECHO = 4000;
|
|
61
|
+
/** 保留的回合记录条数(run 结果含正文,所以只留最近若干条,仅内存)。 */
|
|
62
|
+
const MAX_RUNS = 20;
|
|
63
|
+
/** 回合记录里 prompt 预览的长度。 */
|
|
64
|
+
const MAX_RUN_PREVIEW = 200;
|
|
65
|
+
/** 每个回合最多保留多少条"自动放行审批"摘要(总数另计,不受此限)。 */
|
|
66
|
+
const MAX_AUTO_APPROVALS_KEPT = 20;
|
|
67
|
+
/** 自动放行审批里命令预览的长度。 */
|
|
68
|
+
const MAX_AUTO_APPROVAL_PREVIEW = 200;
|
|
69
|
+
|
|
70
|
+
export class Conversation {
|
|
71
|
+
constructor(state, hub) {
|
|
72
|
+
this.id = state.id;
|
|
73
|
+
this.cwd = state.cwd;
|
|
74
|
+
this.permission = state.permission ?? DEFAULT_PERMISSION;
|
|
75
|
+
this.onApproval = state.onApproval ?? defaultApprovalPolicy(this.permission);
|
|
76
|
+
this.createdAt = state.createdAt ?? now();
|
|
77
|
+
this.lastUsedAt = state.lastUsedAt ?? now();
|
|
78
|
+
this.turnCount = state.turnCount ?? 0;
|
|
79
|
+
/** 建会话时给的提示词(仅在 DSH 还没生成原生标题前当兜底显示)。 */
|
|
80
|
+
this.titleHint = state.titleHint ?? null;
|
|
81
|
+
/** true = 不受空闲 TTL / MAX_LIVE 回收(用于"必须一直握住写锁"的长期会话)。 */
|
|
82
|
+
this.keepAlive = state.keepAlive === true;
|
|
83
|
+
/** 会话级 ACP 配置(思考深度 / 模型)。新进程不记得,resume 后要重套。 */
|
|
84
|
+
this.cfg = {
|
|
85
|
+
reasoningEffort: state.reasoningEffort ?? DEFAULT_REASONING_EFFORT,
|
|
86
|
+
provider: state.provider ?? null,
|
|
87
|
+
model: state.model ?? null,
|
|
88
|
+
};
|
|
89
|
+
/** 调用方审核策略(off / advisory)。可在 dsh_send 单次覆盖。 */
|
|
90
|
+
this.review = REVIEW_MODES.includes(state.review) ? state.review : DEFAULT_REVIEW;
|
|
91
|
+
this.lastConfigApplied = null;
|
|
92
|
+
/** 是否已回读并确认会话记录的权限档(null=尚未核对,true/false=结果)。 */
|
|
93
|
+
this.permissionVerified = null;
|
|
94
|
+
/** 工作区登记结果(决定你的 DSH GUI 里能不能看到这个会话)。 */
|
|
95
|
+
this.workspace = state.workspace ?? null;
|
|
96
|
+
this.#hub = hub;
|
|
97
|
+
|
|
98
|
+
this.proc = null;
|
|
99
|
+
this.busy = false;
|
|
100
|
+
/** 正在进行的进程回收(`proc.stop()` 的 promise)。非 null 时不得为同一会话再拉起进程。 */
|
|
101
|
+
this.recycling = null;
|
|
102
|
+
/** 本进程为这个会话拉起过的最后一个子进程 PID(登记被删后仍能归因锁的归属)。 */
|
|
103
|
+
this.lastChildPid = null;
|
|
104
|
+
/** 最近一次撞写锁的记录 `{at, holder_kind, reason, hint}`(见 snapshot())。 */
|
|
105
|
+
this.lockConflict = null;
|
|
106
|
+
this.pendingApprovals = new Map();
|
|
107
|
+
/** 正在跑的 run 记录(进程意外退出时要用它写 error 哨兵)。 */
|
|
108
|
+
this.currentRun = null;
|
|
109
|
+
/** 等待"当前回合结束"的回调队列,供插话排队使用。 */
|
|
110
|
+
this.idleWaiters = [];
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 运行期输出环形缓冲(**仅内存、有界、不落盘**),供"查看正在跑的会话输出"用。
|
|
114
|
+
* 只记三类:用户发的话、agent 的正文、工具调用状态;**思考内容一律不进这里**。
|
|
115
|
+
*/
|
|
116
|
+
this.out = [];
|
|
117
|
+
this.outSeq = 0;
|
|
118
|
+
/** 输出缓冲的**修订号**:每次追加(含合并进已有条目)都 +1,供游标式增量读使用。 */
|
|
119
|
+
this.outRev = 0;
|
|
120
|
+
/** 被环形缓冲挤掉的条目里最大的修订号:游标落在它之下就说明有内容没读到(gap)。 */
|
|
121
|
+
this.outDroppedRev = 0;
|
|
122
|
+
this.turnOutChars = 0;
|
|
123
|
+
this.currentTurn = 0;
|
|
124
|
+
this.turnStartedAt = 0;
|
|
125
|
+
this.currentTool = null;
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 回合记录(run):让"先派活、回头再验收"成为一等公民。
|
|
129
|
+
* 每条含 {run_id, status, 时间, prompt 预览, result}——**仅内存**,只留最近 MAX_RUNS 条。
|
|
130
|
+
*/
|
|
131
|
+
this.runs = [];
|
|
132
|
+
this.runSeq = 0;
|
|
133
|
+
/** 调用方提供的幂等键 → run_id(与 runs 同步淘汰)。 */
|
|
134
|
+
this.clientRuns = new Map();
|
|
135
|
+
/** 忙时入队的待执行任务(仅内存;重启丢失)。 */
|
|
136
|
+
this.queue = [];
|
|
137
|
+
this.draining = false;
|
|
138
|
+
/** 最近一次回合结果,仅内存(进程退出即消失),不落盘。 */
|
|
139
|
+
this.lastResult = null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
#hub;
|
|
143
|
+
|
|
144
|
+
/** 序列化进注册表的最小状态——**只有元数据,没有对话内容**。 */
|
|
145
|
+
toState() {
|
|
146
|
+
return {
|
|
147
|
+
id: this.id,
|
|
148
|
+
cwd: this.cwd,
|
|
149
|
+
permission: this.permission,
|
|
150
|
+
onApproval: this.onApproval,
|
|
151
|
+
createdAt: this.createdAt,
|
|
152
|
+
lastUsedAt: this.lastUsedAt,
|
|
153
|
+
turnCount: this.turnCount,
|
|
154
|
+
titleHint: this.titleHint,
|
|
155
|
+
keepAlive: this.keepAlive,
|
|
156
|
+
reasoningEffort: this.cfg.reasoningEffort,
|
|
157
|
+
provider: this.cfg.provider,
|
|
158
|
+
model: this.cfg.model,
|
|
159
|
+
review: this.review,
|
|
160
|
+
workspace: this.workspace,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 侧车元数据:与注册表同字段,但独立落盘,forget 之后仍可恢复。 */
|
|
165
|
+
toMeta() {
|
|
166
|
+
return { version: 1, ...this.toState() };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** 合规的公开快照(含磁盘上的元信息)。 */
|
|
170
|
+
snapshot() {
|
|
171
|
+
const meta = readConversationMeta(this.id);
|
|
172
|
+
const nativeTitle = meta?.title ?? null;
|
|
173
|
+
const holder = this.proc?.alive
|
|
174
|
+
? { kind: 'self', reason: '本进程持有该会话的写锁' }
|
|
175
|
+
: classifyHolder(this.id, { knownChildPid: this.lastChildPid });
|
|
176
|
+
return {
|
|
177
|
+
conversation_id: this.id,
|
|
178
|
+
/** 对话名来自 DSH 的 session/title 事件(自动生成);未生成时用建会话提示兜底。 */
|
|
179
|
+
title: nativeTitle ?? this.titleHint ?? null,
|
|
180
|
+
/** native = DSH 自动标题;title_hint = 建会话时给的提示;null = 还没有名字。 */
|
|
181
|
+
title_source: nativeTitle ? 'native' : this.titleHint ? 'title_hint' : null,
|
|
182
|
+
/**
|
|
183
|
+
* DSH 自动生成的标题是**首条消息的截断前缀**(实测 13–15 个字符,
|
|
184
|
+
* 例如「这是一次连通性冒烟测试,请」),信息量很低。
|
|
185
|
+
* 所以调用方给了 title_hint 时,把两者分开返回,由调用方自己选:
|
|
186
|
+
* 想按 DSH 原生标题认会话就看 title,想要可读标签就看 title_hint。
|
|
187
|
+
*/
|
|
188
|
+
title_hint: this.titleHint ?? null,
|
|
189
|
+
cwd: this.cwd,
|
|
190
|
+
permission: this.permission,
|
|
191
|
+
/** 会话默认审核策略;单次 dsh_send 可以覆盖。 */
|
|
192
|
+
review: this.review,
|
|
193
|
+
/** true=已回读会话记录且一致;false/null=缓存暂时没有记录(read-only 仍由审批层兜底)。 */
|
|
194
|
+
permission_verified: this.permissionVerified,
|
|
195
|
+
on_approval: this.onApproval,
|
|
196
|
+
alive: !!this.proc?.alive,
|
|
197
|
+
busy: this.busy,
|
|
198
|
+
keep_alive: this.keepAlive,
|
|
199
|
+
/** 子进程 RSS(MB);量不到就是 null,不影响任何决策。 */
|
|
200
|
+
rss_mb: rssMb(this.proc?.pid),
|
|
201
|
+
/** running = 正在跑回合;idle = 进程活着但空闲;detached = 进程未打开(可 resume)。 */
|
|
202
|
+
state: this.state(),
|
|
203
|
+
current_tool: this.currentTool,
|
|
204
|
+
out_chars: this.turnOutChars,
|
|
205
|
+
running_ms: this.busy && this.turnStartedAt ? now() - this.turnStartedAt : 0,
|
|
206
|
+
turns: this.turnCount,
|
|
207
|
+
created_at: new Date(this.createdAt).toISOString(),
|
|
208
|
+
last_used_at: new Date(this.lastUsedAt).toISOString(),
|
|
209
|
+
model: meta?.model ?? null,
|
|
210
|
+
reasoning_effort: this.cfg.reasoningEffort,
|
|
211
|
+
model_config: this.cfg.provider ? `${this.cfg.provider}/${this.cfg.model}` : null,
|
|
212
|
+
config_applied: this.lastConfigApplied?.applied ?? null,
|
|
213
|
+
config_failed: this.lastConfigApplied?.failed?.length ? this.lastConfigApplied.failed : null,
|
|
214
|
+
workspace_registered: this.workspace?.registered ?? null,
|
|
215
|
+
usage: meta?.usage?.totals ?? null,
|
|
216
|
+
context_pressure: meta?.pressure ?? null,
|
|
217
|
+
pending_approvals: [...this.pendingApprovals.keys()],
|
|
218
|
+
/** 忙时排队的任务(仅内存;重启丢失)。 */
|
|
219
|
+
queue_length: this.queue?.length ?? 0,
|
|
220
|
+
queue: this.queueList(),
|
|
221
|
+
// Keep the ID list compatible; details are ephemeral and excluded from toState().
|
|
222
|
+
pending_approval_details: [...this.pendingApprovals].map(([approvalId, pending]) => ({
|
|
223
|
+
approval_id: approvalId,
|
|
224
|
+
created_at: pending.createdAt ?? null,
|
|
225
|
+
...pending.params.details,
|
|
226
|
+
})),
|
|
227
|
+
/**
|
|
228
|
+
* 不在本进程手里时,写锁在谁手上:
|
|
229
|
+
* self / stale-mcp / live-mcp / none。none 只表示"没有本服务可识别的持有者",
|
|
230
|
+
* 可能是空闲/已释放,也可能被 GUI 持有;原因见 lock_holder_reason。
|
|
231
|
+
*/
|
|
232
|
+
lock_holder: holder.kind,
|
|
233
|
+
lock_holder_reason: holder.reason ?? null,
|
|
234
|
+
/** ★ 最近一次"撞写锁"的持久记录。
|
|
235
|
+
* 只报一次错是不够的:撞锁往往**一次就长期成立**(实测:在 GUI 里点开一次,
|
|
236
|
+
* dsh web 会一直持有写锁,切走/等待都不释放),所以要把"谁占用、怎么解"留在状态里,
|
|
237
|
+
* 否则用户下次只会看到一句没有上下文的报错。 */
|
|
238
|
+
lock_conflict: this.lockConflict,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// ── 进程生命周期 ────────────────────────────────────────────────
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* 确保有一个可用的 DSH 进程;必要时新建并 resume 回该会话。
|
|
246
|
+
*
|
|
247
|
+
* ★ 冷启动 resume 的"请求超时"最多**自动重试一次**(`retried` 仅供内部递归使用)。
|
|
248
|
+
*
|
|
249
|
+
* 排查记录(test/multi.mjs [3] 偶发 `ACP 请求超时: session/resume`,已知一次、复现不出来):
|
|
250
|
+
* - 排除写锁:DSH 的写锁是 Windows 命名内核信号量,`WaitForSingleObject(handle, 0)` **零超时**,
|
|
251
|
+
* 被占用时**立刻**回 SessionAlreadyOwnedError,绝不会挂着等;而且 `dsh_release` 要等持有者
|
|
252
|
+
* 进程真的退出才返回,内核在进程死亡时同步释放它。所以"A 还没放锁 → B 的 resume 挂住"不成立
|
|
253
|
+
* (撞锁走的是下面 isLeaseError 那条快速失败分支,报错文本也完全不同)。
|
|
254
|
+
* - 冷 resume 的固定成本:DSH 0.2.2 在 session/new 与 session/resume 的**响应里**同步等待模型目录
|
|
255
|
+
* 刷新(deepseek-account / deepseek-api-key 两个 provider 各请求一次 api.deepseek.com/models,
|
|
256
|
+
* 截止 5 秒,并行)。本机该端点常年不可达,于是每个新进程的第一次 new/resume 稳定要 ~5.2 秒
|
|
257
|
+
* 再加 ~1.3s 的 Electron 冷启动。它是**有界**的(5s deadline),不会单独吃满 60s。
|
|
258
|
+
* - 排除注册表 / 共享宿主:走读 SessionHost、AcpProcess、LineDecoder 与 B 的 adopt 路径没有"回包丢失"
|
|
259
|
+
* 或"请求被吞"的分支;resume 的每一步在 DSH 侧都是有界 I/O(目录扫描、读日志、零超时拿锁)。
|
|
260
|
+
* - 复现不出来:同一场景(A 建会话 → A 释放 → 新进程 resume)顺序 30 次、5 路并发 40 次
|
|
261
|
+
* (同时还有整套 multi.mjs 在跑),resume 一直 4.3–5.4s,没有一次离群;另外整套 multi.mjs 连跑 6 次全过。
|
|
262
|
+
* - 所以 60s 的"无响应"最可能是 DSH 进程自身的**偶发停滞**(杀软/磁盘/系统调度、同机并发的其它
|
|
263
|
+
* DSH 进程……),**具体触发点没有找到**,这里不假装知道。超时当下的 DSH stderr 尾部现在会经
|
|
264
|
+
* dshStderr 记下(DSH_MCP_LOG_STDERR=1 时可见),下次撞上就有证据。
|
|
265
|
+
* 对策不是单纯把超时调大(真卡死了等再久也没用),而是利用 resume 的两个性质:
|
|
266
|
+
* ① 幂等 —— 会话本体在磁盘上,resume 失败不改变它;
|
|
267
|
+
* ② 失败路径已经杀掉并等到这个进程退出(写锁随之释放),重建进程是干净的重来。
|
|
268
|
+
* 所以只对 `ACP_TIMEOUT`(DSH 没回话)且确认旧进程已退出时重试一次;撞写锁、参数错误、DSH 明确拒绝
|
|
269
|
+
* 都不重试(重试只会重复同一个错误)。第二次仍超时就照旧报错,不无限循环。
|
|
270
|
+
*/
|
|
271
|
+
async ensureAlive({ retried = false } = {}) {
|
|
272
|
+
// ★ 上一代进程正在回收时必须先等它停完:否则会为同一会话再拉起一个进程,
|
|
273
|
+
// 去抢一个**尚未释放**的写锁(reaper/release 里 await stop() 的那个窗口)。
|
|
274
|
+
if (this.recycling) await this.recycling;
|
|
275
|
+
if (this.proc?.alive) return this.proc;
|
|
276
|
+
const shared = SHARED_PROCESS;
|
|
277
|
+
let proc;
|
|
278
|
+
if (shared) {
|
|
279
|
+
proc = this.#hub.acquireHost(this.cwd, this.permission);
|
|
280
|
+
proc.attach(this);
|
|
281
|
+
this.proc = proc;
|
|
282
|
+
} else {
|
|
283
|
+
proc = new AcpProcess({
|
|
284
|
+
cwd: this.cwd,
|
|
285
|
+
permission: this.permission,
|
|
286
|
+
log: this.#hub.log,
|
|
287
|
+
onNotification: (params) => this.onUpdate(params),
|
|
288
|
+
onPermissionRequest: (params) => this.onPermissionRequest(params),
|
|
289
|
+
});
|
|
290
|
+
proc.start();
|
|
291
|
+
this.proc = proc;
|
|
292
|
+
}
|
|
293
|
+
// ★ lastChildPid **推迟到 resume 真正成功后**再写(见 writeMarker 处)。
|
|
294
|
+
// 若在这里就覆盖,一次注定撞锁失败的 resume 会把"上一代还活着的子进程"
|
|
295
|
+
// 这条抢占线索换成刚拉起、注定失败的新进程,takeover 就归因不到真正的持有者。
|
|
296
|
+
// ★ 登记**推迟到真正拿到写锁之后**(见下面 resume 成功处)。
|
|
297
|
+
// 登记的含义是"我正持有这个会话的写锁",不是"我拉起了一个进程"。
|
|
298
|
+
// 若在这里就写,一次注定撞锁失败的 resume 会先把"谁持有"的线索覆盖掉、
|
|
299
|
+
// 失败路径再把它删掉 —— 抢占逻辑就只能看到 none,无法判定持有者(真实 bug)。
|
|
300
|
+
try {
|
|
301
|
+
await proc.initialize();
|
|
302
|
+
// 能走到这里说明本会话刚新建了一个 DSH 进程(Hub.create 之后进程一直活着,不会进这个分支),
|
|
303
|
+
// 而会话本体已经存在于磁盘上,所以必须 resume 把它接回来。
|
|
304
|
+
await proc.request('session/resume', {
|
|
305
|
+
sessionId: this.id,
|
|
306
|
+
cwd: this.cwd,
|
|
307
|
+
mcpServers: [],
|
|
308
|
+
});
|
|
309
|
+
// 新进程不记得会话级配置(思考深度/模型),resume 后必须重套
|
|
310
|
+
this.lastConfigApplied = await applySessionConfig(proc, this.id, this.cfg);
|
|
311
|
+
for (const f of this.lastConfigApplied.failed) this.#hub.log.info(`[conv ${this.id}] ${f}`);
|
|
312
|
+
// ★ 回读会话自己的权限记录,防止 profile defaultPreset 无声提权。
|
|
313
|
+
await this.verifyPermissionRecorded();
|
|
314
|
+
// 锁到手了,这时才登记;从此别人能查到"锁在我们手上"
|
|
315
|
+
writeMarker(this.id, { childPid: proc.pid, cwd: this.cwd });
|
|
316
|
+
// 只有真正拿到写锁后才更新"我拉起的最后一个子进程":失败路径需要保留上一代的 PID。
|
|
317
|
+
this.lastChildPid = proc.pid;
|
|
318
|
+
this.lockConflict = null; // 拿到锁了,之前的撞锁记录作废
|
|
319
|
+
this.#hub.log.info(`[conv ${this.id}] 已 resume`);
|
|
320
|
+
return proc;
|
|
321
|
+
} catch (e) {
|
|
322
|
+
// 超时当下 DSH 在 stderr 说了什么,是下次再撞上这个偶发停滞时唯一的证据;
|
|
323
|
+
// stop() 之后共享宿主就拿不到它了,所以先取。只走 dshStderr(默认静默,
|
|
324
|
+
// DSH_MCP_LOG_STDERR=1 才打印 —— DSH 的 stderr 可能夹带模型输出,不能默认外泄)。
|
|
325
|
+
if (e?.code === 'ACP_TIMEOUT') {
|
|
326
|
+
const tail = proc.stderrTail;
|
|
327
|
+
if (tail) this.#hub.log.dshStderr?.(`[conv ${this.id}] ${e.method} 超时时 DSH stderr 尾部:\n${tail}`);
|
|
328
|
+
}
|
|
329
|
+
// ★ 半死的进程必须作废:它 alive 但**没有加载这个会话**,一旦留在 this.proc 上,
|
|
330
|
+
// 下次调用会走上面 `this.proc?.alive` 的快路径,把 session/prompt 发给一个
|
|
331
|
+
// 不认识该会话的进程 —— 会话就永久卡死了(重派也不会自愈)。
|
|
332
|
+
// 只作废**自己这一次**拉起的进程:窄窗口里另一个调用可能已经换上了新一代。
|
|
333
|
+
if (this.proc === proc) this.proc = null;
|
|
334
|
+
// ★ 必须 **await**:不等它真的退出,紧接着的 classifyHolder 就会把"正在退出的自己人"
|
|
335
|
+
// 当成持锁者(表现为"上一代子进程还活着并握着锁"),于是每次失败都误报锁冲突 ——
|
|
336
|
+
// 更糟的是旧代码没有 lastChildPid 兜底,会把这种自伤报成"多半是你自己的 GUI",
|
|
337
|
+
// 而 dsh_takeover 对 none 又拒绝接管,会话就卡住了(test/cycle.mjs 实测抓到)。
|
|
338
|
+
const stopped = shared
|
|
339
|
+
? await proc.detach(this).catch(() => false)
|
|
340
|
+
: await proc.stop().catch(() => false);
|
|
341
|
+
// 只删**自己的**登记(removeMarker 会校验 token);进程没确认退出时保留登记,
|
|
342
|
+
// 让 classifyHolder/takeover 仍能归因。
|
|
343
|
+
if (stopped) removeMarker(this.id);
|
|
344
|
+
// 写锁冲突是最常见也最需要"说人话"的失败:DSH 的 SessionAlreadyOwnedError 会被
|
|
345
|
+
// ACP 包成 -32603 "Internal error",只说这句调用方根本不知道该怎么办。
|
|
346
|
+
if (isLeaseError(e.message)) {
|
|
347
|
+
const holder = classifyHolder(this.id, { knownChildPid: this.lastChildPid });
|
|
348
|
+
// ★ 留下持久记录(snapshot().lock_conflict)。撞锁常常**一次就长期成立**:
|
|
349
|
+
// 实测在 GUI 里点开一次,dsh web 就会一直持有写锁(切走、等十几分钟都不释放)。
|
|
350
|
+
// 只报一次错的话,用户下次只会看到一句没有上下文的失败。
|
|
351
|
+
this.lockConflict = {
|
|
352
|
+
at: now(),
|
|
353
|
+
holder_kind: holder.kind,
|
|
354
|
+
holder: holder.marker ?? null,
|
|
355
|
+
reason: holder.reason,
|
|
356
|
+
hint:
|
|
357
|
+
holder.kind === 'none'
|
|
358
|
+
? '持有者不是本服务拉起的进程 —— 多半是你自己的 DSH GUI。解法(无损、可逆):在那个会话的菜单里点「归档会话」让它释放 reference,需要时再「取消归档」;实在不行就重启 dsh web。'
|
|
359
|
+
: holder.kind === 'stale-mcp'
|
|
360
|
+
? '持有者已失联(心跳过期)或是本进程的残留子进程 —— 直接 dsh_takeover,无需 force。'
|
|
361
|
+
: holder.kind === 'live-mcp'
|
|
362
|
+
? '另一个活着的 dsh-any-agent 实例持有它:在那边 dsh_release,或用 dsh_takeover(force=true) 夺过来。'
|
|
363
|
+
: '稍后重试。',
|
|
364
|
+
};
|
|
365
|
+
const err = new Error(describeLeaseConflict(this.id, holder));
|
|
366
|
+
err.code = 'session-writer-held';
|
|
367
|
+
err.holder = holder;
|
|
368
|
+
throw err;
|
|
369
|
+
}
|
|
370
|
+
// ★ 瞬时停滞:DSH 没回话,且那个进程已经确认退出(写锁随之释放)→ 重建进程重来一次。
|
|
371
|
+
// 共享宿主上还挂着别的会话时 `proc.alive` 仍为真(detach 只摘掉本会话),那个进程里
|
|
372
|
+
// 可能还有这条 resume 在跑,重试会撞上它自己 —— 此时不重试,照旧报错。
|
|
373
|
+
if (e?.code === 'ACP_TIMEOUT' && !retried && stopped && !proc.alive) {
|
|
374
|
+
this.#hub.log.info(
|
|
375
|
+
`[conv ${this.id}] ${e.method ?? 'ACP'} 超时(${e.timeoutMs ?? '?'}ms),已回收该进程;重建进程重试一次`,
|
|
376
|
+
);
|
|
377
|
+
return this.ensureAlive({ retried: true });
|
|
378
|
+
}
|
|
379
|
+
throw new Error(`恢复会话失败(已作废该进程,下次调用会重建):${e.message}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* 核对**会话自己记录**的权限档是否与请求一致。
|
|
385
|
+
*
|
|
386
|
+
* 为什么不能只信入参:DSH profile 里的 `permission.defaultPreset` 会在
|
|
387
|
+
* session/new 时覆盖 `DSH_PERMISSION_MODE`(test/permission.mjs 实测抓到过
|
|
388
|
+
* read-only 被静默提升)。投影缓存可能稍后才刷盘;read-only 必须 fail-closed:
|
|
389
|
+
* 等待至少一个刷盘周期,仍读不到就拒绝继续,不能依赖“审批层兜底”
|
|
390
|
+
* (danger sandbox 下根本不会产生审批请求)。
|
|
391
|
+
*/
|
|
392
|
+
async verifyPermissionRecorded({ attempts, delayMs = 250 } = {}) {
|
|
393
|
+
// read-only 需要覆盖 DSH 约 5 秒的投影缓存刷新周期;其它档位保持较短等待。
|
|
394
|
+
const tries = Number.isFinite(attempts) ? attempts : this.permission === 'read-only' ? 40 : 6;
|
|
395
|
+
for (let i = 0; i < tries; i++) {
|
|
396
|
+
const meta = readConversationMeta(this.id);
|
|
397
|
+
const recorded = meta?.permissions?.preset;
|
|
398
|
+
if (recorded) {
|
|
399
|
+
if (!PERMISSION_TIERS.includes(recorded) || recorded !== this.permission) {
|
|
400
|
+
throw new Error(
|
|
401
|
+
`会话实际权限档为 ${JSON.stringify(recorded)},与请求的 ${this.permission} 不一致。\n` +
|
|
402
|
+
' 这通常意味着 DSH profile 里写了 permission.defaultPreset,覆盖了启动时传入的权限档。\n' +
|
|
403
|
+
` 请检查 ${join(DSH_HOME, 'profiles', process.env.DSH_MCP_PROFILE ?? 'dsh-mcp', 'cordis.patch.yml')},` +
|
|
404
|
+
'删掉 permission.defaultPreset 后重试。',
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
this.permissionVerified = true;
|
|
408
|
+
return recorded;
|
|
409
|
+
}
|
|
410
|
+
if (i < tries - 1) await new Promise((r) => setTimeout(r, delayMs));
|
|
411
|
+
}
|
|
412
|
+
this.permissionVerified = false;
|
|
413
|
+
if (this.permission === 'read-only') {
|
|
414
|
+
throw new Error(
|
|
415
|
+
`无法在 ${(tries * delayMs) / 1000}s 内确认会话实际为 read-only(投影缓存无权限记录)。\n` +
|
|
416
|
+
' 为避免 profile 的 defaultPreset 把只读会话静默提升为完全访问,本次调用已拒绝。\n' +
|
|
417
|
+
' 请确认 DSH 会话存储可读,或稍后重试;非只读档位不受此限制。',
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
this.#hub.log.info(
|
|
421
|
+
`[conv ${this.id}] 投影缓存里暂时没有权限记录,跳过运行期核对(非只读档)`,
|
|
422
|
+
);
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* 抢占写锁。
|
|
428
|
+
*
|
|
429
|
+
* 为什么需要"杀进程":DSH 的写锁是跨进程内核信号量,**没有 API 能从活着的持有者手里夺走**。
|
|
430
|
+
* 所以抢锁 == 杀掉持有者,然后重新 resume。既然要杀,就必须先确认那是谁:
|
|
431
|
+
*
|
|
432
|
+
* - `stale-mcp`(本服务自己实例的孤儿残留)→ **自动杀**,安全;
|
|
433
|
+
* - `live-mcp`(另一个活着的 dsh-any-agent 实例)→ 默认拒绝,`force=true` 才杀;
|
|
434
|
+
* - `none`(没有登记,多半是你自己的 DSH GUI)→ **绝不杀**(会连你的界面和正在看的对话一起杀掉),只报告。
|
|
435
|
+
*/
|
|
436
|
+
async takeover({ force = false } = {}) {
|
|
437
|
+
const report = { conversation_id: this.id, ok: false, holder_kind: null, killed_pid: null, message: '' };
|
|
438
|
+
|
|
439
|
+
if (this.proc?.alive) {
|
|
440
|
+
report.ok = true;
|
|
441
|
+
report.message = '本进程已经持有该会话,无需抢占。';
|
|
442
|
+
return report;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// 1) 先走正常路径:原持有者可能早已释放
|
|
446
|
+
try {
|
|
447
|
+
await this.ensureAlive();
|
|
448
|
+
report.ok = true;
|
|
449
|
+
report.message = '无需抢占:正常 resume 就成功了(锁是空的,原持有者已释放)。';
|
|
450
|
+
return report;
|
|
451
|
+
} catch (e) {
|
|
452
|
+
if (e.code !== 'session-writer-held') {
|
|
453
|
+
report.message = `不是写锁问题,抢占帮不上忙:${e.message}`;
|
|
454
|
+
return report;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// 2) 分类处置
|
|
459
|
+
const holder = classifyHolder(this.id, { knownChildPid: this.lastChildPid });
|
|
460
|
+
report.holder_kind = holder.kind;
|
|
461
|
+
report.holder = holder.marker;
|
|
462
|
+
// 登记可能已随回收被删,但"我们自己的残留子进程"仍可归因(见 locks.classifyHolder)
|
|
463
|
+
const markerChild = holder.marker?.child_pid ?? null;
|
|
464
|
+
const killTarget = isPidAlive(markerChild) ? markerChild : (holder.known_child_pid ?? markerChild);
|
|
465
|
+
|
|
466
|
+
if (holder.kind === 'none' || !holder.childAlive) {
|
|
467
|
+
report.message =
|
|
468
|
+
`抢占未执行:${holder.reason}\n` +
|
|
469
|
+
'本服务**不会**杀非本服务拉起的进程(那可能是你的 DSH GUI,杀它会连你正在看的对话一起没)。\n' +
|
|
470
|
+
'请在那个窗口里关掉该会话(或切走),然后重试。';
|
|
471
|
+
return report;
|
|
472
|
+
}
|
|
473
|
+
if (holder.kind === 'live-mcp' && !force) {
|
|
474
|
+
report.message =
|
|
475
|
+
`抢占被拒绝:${holder.reason}\n` +
|
|
476
|
+
'如果确认那边已经不用它了,重试时带 force=true 强行夺过来。';
|
|
477
|
+
return report;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// 3) 共享进程:杀它会连坐同组会话,先把代价说清楚,别让对方在不知情下按下去。
|
|
481
|
+
// 共享进程池(0.5.0 默认开启)让一个 PID 载着多个会话,takeover 只看"谁握着锁"
|
|
482
|
+
// 会做出一个很贵的决定 —— 杀掉持有者 = 同组所有会话的进行中回合一起断。
|
|
483
|
+
const sharedVictims = this.#hub.hostVictimsOf(killTarget, this.id);
|
|
484
|
+
if (sharedVictims.length > 0) {
|
|
485
|
+
report.shared_sessions = sharedVictims;
|
|
486
|
+
if (!force) {
|
|
487
|
+
report.message =
|
|
488
|
+
`抢占被拒绝:PID ${killTarget} 是共享进程,上面还挂着 ${sharedVictims.length} 个其它会话` +
|
|
489
|
+
`(${sharedVictims.map((id) => id.slice(0, 8)).join(', ')})。\n` +
|
|
490
|
+
'杀掉它会连坐这些会话:各自进行中的回合会中断(会话本体无损,之后照常 resume)。\n' +
|
|
491
|
+
'确认可以接受就带 force=true 重试;不想连坐就先 dsh_release 那些会话,或等它们空闲。';
|
|
492
|
+
return report;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// 4) 杀 + 重试(内核在持有者进程退出时释放信号量)
|
|
497
|
+
const kill = killHolderChild(killTarget, { expectedSpawnedAt: holder.marker?.spawned_at });
|
|
498
|
+
report.killed_pid = killTarget;
|
|
499
|
+
report.kill = kill;
|
|
500
|
+
if (!kill.killed) {
|
|
501
|
+
report.message = `抢占失败:${kill.reason}`;
|
|
502
|
+
return report;
|
|
503
|
+
}
|
|
504
|
+
removeMarker(this.id, { force: true });
|
|
505
|
+
// 等它**真的**退出再接管:只发 kill 就立刻 resume 会撞上尚未释放的锁
|
|
506
|
+
// (进程退出与内核回收信号量之间有一小段窗口)。
|
|
507
|
+
for (let i = 0; i < 20; i++) {
|
|
508
|
+
if (!isPidAlive(killTarget)) break;
|
|
509
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
510
|
+
}
|
|
511
|
+
try {
|
|
512
|
+
await this.ensureAlive();
|
|
513
|
+
report.ok = true;
|
|
514
|
+
report.message =
|
|
515
|
+
`已抢占:杀掉持有者 PID ${killTarget}(${holder.reason}),并成功接管会话。` +
|
|
516
|
+
`\n⚠️ 如果那个进程当时正在跑回合,那一轮的工作会中断(会话日志会记为 interrupted)。` +
|
|
517
|
+
(sharedVictims.length > 0
|
|
518
|
+
? `\n⚠️ 它是共享进程,另外 ${sharedVictims.length} 个会话也被一起断掉了(各自 resume 即可)。`
|
|
519
|
+
: '');
|
|
520
|
+
} catch (e) {
|
|
521
|
+
report.message = `杀掉 PID ${killTarget} 之后仍无法接管:${e.message}`;
|
|
522
|
+
}
|
|
523
|
+
return report;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// ── 流式更新收集 ────────────────────────────────────────────────
|
|
527
|
+
|
|
528
|
+
onUpdate(params) {
|
|
529
|
+
const c = this.collector;
|
|
530
|
+
if (!c) return;
|
|
531
|
+
const u = params?.update;
|
|
532
|
+
if (!u) return;
|
|
533
|
+
switch (u.sessionUpdate) {
|
|
534
|
+
case 'agent_message_chunk': {
|
|
535
|
+
const chunk = u.content?.text ?? '';
|
|
536
|
+
// ★ 被工具调用隔开的两段正文是两条消息,不能首尾相接:以前会拼成
|
|
537
|
+
// “…where possible.1) package.json…”。在新一段开头补一个空行
|
|
538
|
+
// (上一段已以换行结尾就不重复补)。dsh_read 里它们本来就是两条,不受影响。
|
|
539
|
+
if (chunk && c.answerBreak) {
|
|
540
|
+
if (c.answer && !/\n\s*$/.test(c.answer) && !/^\s*\n/.test(chunk)) c.answer += '\n\n';
|
|
541
|
+
c.answerBreak = false;
|
|
542
|
+
}
|
|
543
|
+
c.answer += chunk;
|
|
544
|
+
this.pushOut('text', chunk);
|
|
545
|
+
break;
|
|
546
|
+
}
|
|
547
|
+
case 'agent_thought_chunk':
|
|
548
|
+
// 思考内容只进这条回合的临时收集器(用于统计/可选返回),**不进环形缓冲**
|
|
549
|
+
c.reasoning += u.content?.text ?? '';
|
|
550
|
+
c.reasoningChunks += 1;
|
|
551
|
+
if (c.reasoningStartedAt === 0) c.reasoningStartedAt = now();
|
|
552
|
+
break;
|
|
553
|
+
case 'tool_call':
|
|
554
|
+
case 'tool_call_update': {
|
|
555
|
+
const id = u.toolCallId ?? 'unknown';
|
|
556
|
+
const prev = c.tools.get(id) ?? {};
|
|
557
|
+
const merged = {
|
|
558
|
+
...prev,
|
|
559
|
+
id,
|
|
560
|
+
title: u.title ?? prev.title,
|
|
561
|
+
status: u.status ?? prev.status,
|
|
562
|
+
kind: u.kind ?? prev.kind,
|
|
563
|
+
name: u.name ?? prev.name,
|
|
564
|
+
input: u.rawInput !== undefined ? u.rawInput : prev.input,
|
|
565
|
+
};
|
|
566
|
+
c.tools.set(id, merged);
|
|
567
|
+
if (c.answer) c.answerBreak = true;
|
|
568
|
+
const label = merged.name ?? merged.title ?? id;
|
|
569
|
+
this.currentTool = merged.status === 'completed' || merged.status === 'failed' ? null : label;
|
|
570
|
+
const tag = merged.status ? ` [${merged.status}]` : '';
|
|
571
|
+
if (u.title !== undefined || u.status !== undefined || u.name !== undefined) {
|
|
572
|
+
this.pushOut('tool', `${label}${tag}`, { tool_call_id: id });
|
|
573
|
+
}
|
|
574
|
+
break;
|
|
575
|
+
}
|
|
576
|
+
case 'usage_update':
|
|
577
|
+
c.usage = u;
|
|
578
|
+
break;
|
|
579
|
+
case 'plan':
|
|
580
|
+
case 'plan_update':
|
|
581
|
+
c.plan = u;
|
|
582
|
+
break;
|
|
583
|
+
default:
|
|
584
|
+
c.otherUpdates.push(u.sessionUpdate);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async onPermissionRequest(params) {
|
|
589
|
+
const options = params?.options ?? [];
|
|
590
|
+
const rawToolCall = params?.toolCall ?? {};
|
|
591
|
+
// 真实 DSH 的 request_permission 只带 toolCallId,不带 rawInput;
|
|
592
|
+
// 同一 toolCallId 的参数通常已经在 tool_call 更新里记录过,按 id 关联回来,
|
|
593
|
+
// 否则审批详情里没有命令/路径,调用方无法裁决。
|
|
594
|
+
const known = this.collector?.tools?.get(rawToolCall.toolCallId) ?? null;
|
|
595
|
+
const toolCall = known
|
|
596
|
+
? {
|
|
597
|
+
...rawToolCall,
|
|
598
|
+
name: rawToolCall.name ?? known.name,
|
|
599
|
+
title: rawToolCall.title ?? known.title,
|
|
600
|
+
kind: rawToolCall.kind ?? known.kind,
|
|
601
|
+
rawInput: rawToolCall.rawInput ?? rawToolCall.input ?? known.input,
|
|
602
|
+
locations: rawToolCall.locations ?? known.locations,
|
|
603
|
+
}
|
|
604
|
+
: rawToolCall;
|
|
605
|
+
const desc = sanitizeApprovalContext(String(toolCall.title ?? toolCall.name ?? toolCall.toolCallId ?? '未知操作'));
|
|
606
|
+
const allowId = pickOption(options, 'allow');
|
|
607
|
+
const rejectId = pickOption(options, 'reject');
|
|
608
|
+
|
|
609
|
+
// ★ read-only 是硬边界:即使会话被旧版本/显式参数配成 auto-allow,
|
|
610
|
+
// 这里也绝不放行任何审批 —— DSH 会借审批把 sandbox 提权到可写。
|
|
611
|
+
if (this.permission === 'read-only') {
|
|
612
|
+
this.#hub.log.info(`[conv ${this.id}] read-only 强制拒绝审批: ${desc}`);
|
|
613
|
+
return rejectId
|
|
614
|
+
? { outcome: { outcome: 'selected', optionId: rejectId } }
|
|
615
|
+
: { outcome: { outcome: 'cancelled' } };
|
|
616
|
+
}
|
|
617
|
+
if (this.onApproval === 'auto-allow') {
|
|
618
|
+
this.#hub.log.info(`[conv ${this.id}] 审批放行: ${desc}`);
|
|
619
|
+
// ★ 自动放行必须对调用方可见:DSH 会借审批把沙箱提升到 danger-full-access,
|
|
620
|
+
// 不记下来的话 workspace-write 会话事后看起来像从未越界(实测一轮 6 次提权无人知晓)。
|
|
621
|
+
if (allowId) this.#recordAutoApproval(params, toolCall, desc, options);
|
|
622
|
+
return allowId
|
|
623
|
+
? { outcome: { outcome: 'selected', optionId: allowId } }
|
|
624
|
+
: { outcome: { outcome: 'cancelled' } };
|
|
625
|
+
}
|
|
626
|
+
if (this.onApproval === 'auto-deny') {
|
|
627
|
+
this.#hub.log.info(`[conv ${this.id}] 审批拒绝: ${desc}`);
|
|
628
|
+
return rejectId
|
|
629
|
+
? { outcome: { outcome: 'selected', optionId: rejectId } }
|
|
630
|
+
: { outcome: { outcome: 'cancelled' } };
|
|
631
|
+
}
|
|
632
|
+
const approvalId = `ap-${Math.random().toString(36).slice(2, 10)}`;
|
|
633
|
+
this.#hub.log.info(`[conv ${this.id}] 审批待裁决: ${approvalId}`);
|
|
634
|
+
return await new Promise((resolve) => {
|
|
635
|
+
const timer = setTimeout(() => {
|
|
636
|
+
if (this.pendingApprovals.delete(approvalId)) {
|
|
637
|
+
this.#hub.log.info(`[conv ${this.id}] 审批 ${approvalId} 超时,按拒绝处理`);
|
|
638
|
+
resolve({ outcome: { outcome: 'cancelled' } });
|
|
639
|
+
}
|
|
640
|
+
}, this.#hub.approvalTimeoutMs);
|
|
641
|
+
this.pendingApprovals.set(approvalId, {
|
|
642
|
+
params: { tool: desc, details: approvalContext(params, toolCall, desc, options) },
|
|
643
|
+
createdAt: now(),
|
|
644
|
+
allowId,
|
|
645
|
+
rejectId,
|
|
646
|
+
resolve: (outcome) => {
|
|
647
|
+
clearTimeout(timer);
|
|
648
|
+
resolve(outcome);
|
|
649
|
+
},
|
|
650
|
+
});
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* 记一条"被自动放行的审批"到本回合收集器(只记摘要,且先脱敏)。
|
|
656
|
+
* 回合外到来的审批(没有收集器)不记 —— 没有结果可挂。
|
|
657
|
+
*/
|
|
658
|
+
#recordAutoApproval(params, toolCall, desc, options) {
|
|
659
|
+
const c = this.collector;
|
|
660
|
+
if (!c) return;
|
|
661
|
+
c.autoApprovalTotal = (c.autoApprovalTotal ?? 0) + 1;
|
|
662
|
+
const input = toolCall.rawInput ?? toolCall.input ?? null;
|
|
663
|
+
const escalation =
|
|
664
|
+
input && typeof input === 'object' && typeof input.sandbox_permissions === 'string'
|
|
665
|
+
? input.sandbox_permissions
|
|
666
|
+
: null;
|
|
667
|
+
if (escalation) c.autoEscalationTotal = (c.autoEscalationTotal ?? 0) + 1;
|
|
668
|
+
if (!Array.isArray(c.autoApprovals)) c.autoApprovals = [];
|
|
669
|
+
if (c.autoApprovals.length >= MAX_AUTO_APPROVALS_KEPT) return;
|
|
670
|
+
const ctx = approvalContext(params, toolCall, desc, options);
|
|
671
|
+
const cmd = input && typeof input === 'object' ? input.command ?? input.cmd ?? null : null;
|
|
672
|
+
c.autoApprovals.push({
|
|
673
|
+
tool: ctx.tool ?? desc,
|
|
674
|
+
tool_call_id: ctx.tool_call_id ?? null,
|
|
675
|
+
escalation,
|
|
676
|
+
reason: typeof ctx.reason === 'string' ? ctx.reason.slice(0, 300) : null,
|
|
677
|
+
command_preview:
|
|
678
|
+
typeof cmd === 'string' ? sanitizeApprovalContext(cmd, 'command').slice(0, MAX_AUTO_APPROVAL_PREVIEW) : null,
|
|
679
|
+
at: new Date().toISOString(),
|
|
680
|
+
});
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/** 调用方裁决一个待决审批。 */
|
|
684
|
+
decideApproval(approvalId, decision) {
|
|
685
|
+
const p = this.pendingApprovals.get(approvalId);
|
|
686
|
+
if (!p) throw new Error(`没有待决审批: ${approvalId}`);
|
|
687
|
+
this.pendingApprovals.delete(approvalId);
|
|
688
|
+
const chosen = decision === 'allow' ? p.allowId : p.rejectId;
|
|
689
|
+
// ★ 没有对应 option 时实际发出的是 cancelled。必须把 effective 如实告诉调用方,
|
|
690
|
+
// 否则 dsh_approval_decide 会渲染成"已裁决为 allow",而 DSH 收到的是取消(假成功)。
|
|
691
|
+
const effective = chosen ? 'selected' : 'cancelled';
|
|
692
|
+
p.resolve(chosen ? { outcome: { outcome: 'selected', optionId: chosen } } : { outcome: { outcome: 'cancelled' } });
|
|
693
|
+
return {
|
|
694
|
+
approval_id: approvalId,
|
|
695
|
+
decision,
|
|
696
|
+
effective,
|
|
697
|
+
...(chosen ? {} : { reason: 'no_option' }),
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
/**
|
|
702
|
+
* 起不来时的统一收尾:唤醒所有等本轮结束的插话请求,并落一条 error 哨兵。
|
|
703
|
+
*
|
|
704
|
+
* 这两件事必须都做、且互相独立:
|
|
705
|
+
* - 只写哨兵救不了 `dsh_interject`——它等的是 idleWaiters,不是文件;
|
|
706
|
+
* - 只唤醒不写哨兵,调用方事后 `dsh_get(run_id)` 就永远找不到这次失败。
|
|
707
|
+
*/
|
|
708
|
+
#settleFailedRun(runRecord, error) {
|
|
709
|
+
const waiters = this.idleWaiters;
|
|
710
|
+
this.idleWaiters = [];
|
|
711
|
+
for (const w of waiters) {
|
|
712
|
+
try {
|
|
713
|
+
w();
|
|
714
|
+
} catch {}
|
|
715
|
+
}
|
|
716
|
+
this.#scheduleDrain();
|
|
717
|
+
if (this.currentRun === runRecord) this.currentRun = null;
|
|
718
|
+
runRecord.ended_at = now();
|
|
719
|
+
runRecord.status = 'error';
|
|
720
|
+
runRecord.error = error?.message ?? String(error);
|
|
721
|
+
runRecord.elapsed_ms = 0;
|
|
722
|
+
runRecord.result = {
|
|
723
|
+
conversation_id: this.id,
|
|
724
|
+
run_id: runRecord.run_id,
|
|
725
|
+
stop_reason: 'error',
|
|
726
|
+
error: runRecord.error,
|
|
727
|
+
answer: '',
|
|
728
|
+
thinking_stats: { hidden: true, chars: 0, chunks: 0, ms: 0 },
|
|
729
|
+
tools_used: [],
|
|
730
|
+
workspace_changed: false,
|
|
731
|
+
elapsed_ms: 0,
|
|
732
|
+
};
|
|
733
|
+
const reviewMode = runRecord.review_mode ?? this.review;
|
|
734
|
+
if (reviewMode !== 'off') {
|
|
735
|
+
runRecord.result.review = buildReviewTicket({
|
|
736
|
+
conversationId: this.id,
|
|
737
|
+
runId: runRecord.run_id,
|
|
738
|
+
promptPreview: runRecord.prompt_preview,
|
|
739
|
+
result: runRecord.result,
|
|
740
|
+
mode: reviewMode,
|
|
741
|
+
permission: this.permission,
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
try {
|
|
745
|
+
if (typeof this.#hub.writeRunSentinel === 'function') {
|
|
746
|
+
const file = this.#hub.writeRunSentinel(this.id, runRecord);
|
|
747
|
+
runRecord.result.sentinel_file = file ?? this.#hub.runSentinelPath?.(this.id, runRecord.run_id) ?? null;
|
|
748
|
+
if (!file) runRecord.result.sentinel_error = runRecord.sentinel_error ?? '哨兵文件写入失败';
|
|
749
|
+
}
|
|
750
|
+
} catch (e) {
|
|
751
|
+
runRecord.result.sentinel_error = `写完成哨兵失败: ${e.message}`;
|
|
752
|
+
}
|
|
753
|
+
try {
|
|
754
|
+
this.#hub.save?.();
|
|
755
|
+
} catch {}
|
|
756
|
+
return runRecord.result;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* 共享进程池的进程意外退出:把本会话在跑的回合标成失败并落哨兵,
|
|
761
|
+
* 然后解除 proc 引用,下次调用会重新 acquireHost + resume。
|
|
762
|
+
*/
|
|
763
|
+
onProcessExit(reason) {
|
|
764
|
+
this.proc = null;
|
|
765
|
+
this.recycling = null;
|
|
766
|
+
if (this.busy && this.currentRun) {
|
|
767
|
+
this.busy = false;
|
|
768
|
+
this.#settleFailedRun(this.currentRun, new Error(String(reason)));
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// ── 跑任务 ──────────────────────────────────────────────────────
|
|
773
|
+
|
|
774
|
+
/** 发一个回合并等到结束。 */
|
|
775
|
+
/**
|
|
776
|
+
* 派一个回合。
|
|
777
|
+
*
|
|
778
|
+
* **超时语义(这里曾经把会话跑挂,改之前务必读完)**
|
|
779
|
+
*
|
|
780
|
+
* - **ACP 请求本身不设超时**:超时只决定"我们等不等",永远不掐断回合。
|
|
781
|
+
* 旧实现给 ACP 请求设超时,一到期就本地放弃并把 `busy` 置 false —— 那是在
|
|
782
|
+
* **谎报回合结束**:结果丢了、token 照烧,而且 reaper 看到 `busy=false` 就会
|
|
783
|
+
* 把一个**正在干活**的进程回收掉 → 回合变 `interrupted` → 该会话此后 resume 失败、
|
|
784
|
+
* 永久卡死(真实事故)。
|
|
785
|
+
* - `wait=false`:压根不等,立即返回 `run_id`;回合真正结束时写哨兵通知调用方。
|
|
786
|
+
* - `wait=true` :最多等 `timeoutMs`(`<=0` 表示一直等)。到期**不报错、不丢结果**,
|
|
787
|
+
* 返回 `{still_running:true, run_id, sentinel_file}` —— 即把 wait=true 降级成 wait=false:
|
|
788
|
+
* 回合继续跑,结束时照常写哨兵,调用方拿 `run_id` 事后验收。
|
|
789
|
+
*
|
|
790
|
+
* @returns 等到回合结束 → 完整 result;没等到(wait=false 或等超时)→ 收据对象
|
|
791
|
+
*/
|
|
792
|
+
async prompt(text, { reasoning = 'hide', timeoutMs = PROMPT_TIMEOUT_MS, run, wait = true, review } = {}) {
|
|
793
|
+
if (this.busy) {
|
|
794
|
+
throw new Error(
|
|
795
|
+
`会话 ${this.id} 正在跑另一个回合。可以:dsh_interject 插话(打断并改口 / 排队),或 dsh_interrupt 打断。`,
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
const runRecord = run ?? this.createRun(text, { review });
|
|
799
|
+
const reviewMode = runRecord.review_mode ?? normalizeReviewMode(review ?? this.review);
|
|
800
|
+
runRecord.review_mode = reviewMode;
|
|
801
|
+
runRecord.status = 'running';
|
|
802
|
+
runRecord.started_at = now();
|
|
803
|
+
this.currentRun = runRecord;
|
|
804
|
+
let before = null;
|
|
805
|
+
let proc;
|
|
806
|
+
// ★ `busy` 必须在 ensureAlive **之前**置位。
|
|
807
|
+
// 否则"把进程拉起来"这段(spawn + initialize + resume,可能好几秒)是一个**裸奔窗口**:
|
|
808
|
+
// ① reaper 看到 busy=false 且 lastUsedAt 还是上一轮的时刻(已过期),会把这个**刚拉起来**
|
|
809
|
+
// 的进程当空闲回收掉 → 紧接着 resume 就报"DSH 进程未运行"(test/cycle.mjs 实测抓到);
|
|
810
|
+
// ② 第二个 dsh_send 同样能通过 busy 检查,于是为同一会话再拉一个子进程 → 互相撞锁 + 泄漏。
|
|
811
|
+
// ★ 同理,git 快照的 await 也必须放在 busy 置位**之后**(见下面的 Promise.all)。
|
|
812
|
+
this.busy = true;
|
|
813
|
+
this.lastUsedAt = now();
|
|
814
|
+
const beforePromise = gitStatus(this.cwd);
|
|
815
|
+
try {
|
|
816
|
+
// 快照与拉起进程并行:快照只是读盘,不改变生命周期,早取晚取语义一致。
|
|
817
|
+
const [beforeSnap, liveProc] = await Promise.all([beforePromise, this.ensureAlive()]);
|
|
818
|
+
before = beforeSnap;
|
|
819
|
+
proc = liveProc;
|
|
820
|
+
} catch (e) {
|
|
821
|
+
// 起不来(撞写锁 / 会话损坏)就**必须放开 busy**,否则这个会话被永久占住;
|
|
822
|
+
// 同时别留一条永远 "running" 的幽灵记录。
|
|
823
|
+
// ★ 与 finalize 对齐:失败也必须给等待者一个终态——唤醒插话队列 + 落 error 哨兵。
|
|
824
|
+
// 只写哨兵救不了 dsh_interject:它等的是 idleWaiters,不是文件(默认 timeout=0 无 timer)。
|
|
825
|
+
this.busy = false;
|
|
826
|
+
this.#settleFailedRun(runRecord, e);
|
|
827
|
+
e.runId = runRecord.run_id;
|
|
828
|
+
e.status = 'error';
|
|
829
|
+
e.sentinelFile = runRecord.result?.sentinel_file ?? null;
|
|
830
|
+
throw e;
|
|
831
|
+
}
|
|
832
|
+
this.currentTurn += 1;
|
|
833
|
+
this.turnStartedAt = now();
|
|
834
|
+
this.turnOutChars = 0;
|
|
835
|
+
this.currentTool = null;
|
|
836
|
+
this.pushOut('user', text.length > MAX_PROMPT_ECHO ? `${text.slice(0, MAX_PROMPT_ECHO)}…(已截断)` : text);
|
|
837
|
+
this.pushOut('turn', `第 ${this.currentTurn} 轮开始`, { phase: 'start' });
|
|
838
|
+
const c = {
|
|
839
|
+
answer: '',
|
|
840
|
+
/** 上一段正文之后出现过工具调用:下一段正文前要补段落分隔(见 onUpdate)。 */
|
|
841
|
+
answerBreak: false,
|
|
842
|
+
reasoning: '',
|
|
843
|
+
reasoningChunks: 0,
|
|
844
|
+
reasoningStartedAt: 0,
|
|
845
|
+
tools: new Map(),
|
|
846
|
+
usage: null,
|
|
847
|
+
plan: null,
|
|
848
|
+
otherUpdates: [],
|
|
849
|
+
/** 本回合被 auto-allow 放行的审批摘要(最多 MAX_AUTO_APPROVALS_KEPT 条)与总数。 */
|
|
850
|
+
autoApprovals: [],
|
|
851
|
+
autoApprovalTotal: 0,
|
|
852
|
+
autoEscalationTotal: 0,
|
|
853
|
+
};
|
|
854
|
+
this.collector = c;
|
|
855
|
+
const t0 = now();
|
|
856
|
+
let settled = false;
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* 把"调用方审核单"挂到回合结果上。
|
|
860
|
+
*
|
|
861
|
+
* 审核单必须随结果一起返回(正文、structuredContent、哨兵都是同一份 result),
|
|
862
|
+
* 否则调用方在异步收活时只会看到一段自述,很容易直接把它当成完成事实。
|
|
863
|
+
*/
|
|
864
|
+
const attachReview = (result) => {
|
|
865
|
+
if (reviewMode !== 'off') {
|
|
866
|
+
result.review = buildReviewTicket({
|
|
867
|
+
conversationId: this.id,
|
|
868
|
+
runId: runRecord.run_id,
|
|
869
|
+
promptPreview: runRecord.prompt_preview,
|
|
870
|
+
result,
|
|
871
|
+
mode: reviewMode,
|
|
872
|
+
permission: this.permission,
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
return result;
|
|
876
|
+
};
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* 回合真正结束时收尾。无论有没有人在等,都**只执行一次**。
|
|
880
|
+
*
|
|
881
|
+
* 现在是异步的(git 快照走异步子进程),但保持了三个不变量:
|
|
882
|
+
* 1. 幂等:重复调用返回同一个 Promise,不会重复写哨兵;
|
|
883
|
+
* 2. 不拖累生命周期:`busy=false`、唤醒 idleWaiters、#scheduleDrain
|
|
884
|
+
* 都在**第一个 await 之前**同步完成,git 最慢 8s 也影响不到 reaper/插话;
|
|
885
|
+
* 3. 防弹:内部异常一律兜底成 error 结果 + 哨兵,返回的 Promise 不会 reject
|
|
886
|
+
* (wait=false 的调用方不会 await 它,不能让 unhandled rejection 杀掉服务)。
|
|
887
|
+
*/
|
|
888
|
+
let finalizePromise = null;
|
|
889
|
+
const finalize = (stopReason, failure) => {
|
|
890
|
+
if (finalizePromise) return finalizePromise;
|
|
891
|
+
settled = true;
|
|
892
|
+
finalizePromise = (async () => {
|
|
893
|
+
// ★ 先启动 after 快照:调用 gitStatus 时子进程立刻 spawn,但**不 await**;
|
|
894
|
+
// 这样 snapshot 的时间点仍贴近回合结束,而 busy 释放/唤醒不必等 git。
|
|
895
|
+
const afterPromise = gitStatus(this.cwd);
|
|
896
|
+
try {
|
|
897
|
+
this.busy = false;
|
|
898
|
+
if (this.currentRun === runRecord) this.currentRun = null;
|
|
899
|
+
this.collector = null;
|
|
900
|
+
this.currentTool = null;
|
|
901
|
+
// 回合结束后,任何仍挂着的审批都已失效:统一取消并清空,避免状态里
|
|
902
|
+
// 留下一个 dsh_approval_decide 只会回"没有待决审批"的幽灵。
|
|
903
|
+
for (const [, p] of this.pendingApprovals) {
|
|
904
|
+
try {
|
|
905
|
+
p.resolve({ outcome: { outcome: 'cancelled' } });
|
|
906
|
+
} catch {}
|
|
907
|
+
}
|
|
908
|
+
this.pendingApprovals.clear();
|
|
909
|
+
this.pushOut('turn', `第 ${this.currentTurn} 轮结束(${stopReason ?? 'unknown'})`, {
|
|
910
|
+
phase: 'end',
|
|
911
|
+
stop_reason: stopReason,
|
|
912
|
+
error: failure ?? undefined,
|
|
913
|
+
});
|
|
914
|
+
this.turnCount += 1;
|
|
915
|
+
this.lastUsedAt = now();
|
|
916
|
+
this.#hub.save();
|
|
917
|
+
// 唤醒所有"等本轮结束"的插话请求(busy 已置 false,它们可以安全地发下一轮)
|
|
918
|
+
const waiters = this.idleWaiters;
|
|
919
|
+
this.idleWaiters = [];
|
|
920
|
+
for (const w of waiters) w();
|
|
921
|
+
this.#scheduleDrain();
|
|
922
|
+
// 到这里才允许被 git 拖慢;结果/审核单/哨兵必须等快照完成后再落。
|
|
923
|
+
const after = await afterPromise;
|
|
924
|
+
const result = {
|
|
925
|
+
conversation_id: this.id,
|
|
926
|
+
run_id: runRecord.run_id,
|
|
927
|
+
stop_reason: stopReason,
|
|
928
|
+
error: failure,
|
|
929
|
+
answer: c.answer,
|
|
930
|
+
thinking: formatReasoning(c, reasoning),
|
|
931
|
+
thinking_stats: {
|
|
932
|
+
hidden: reasoning === 'hide' || reasoning === 'marker',
|
|
933
|
+
chars: c.reasoning.length,
|
|
934
|
+
chunks: c.reasoningChunks,
|
|
935
|
+
ms: c.reasoningStartedAt ? now() - c.reasoningStartedAt : 0,
|
|
936
|
+
},
|
|
937
|
+
tools_used: [...c.tools.values()],
|
|
938
|
+
usage: c.usage,
|
|
939
|
+
auto_approvals_total: c.autoApprovalTotal,
|
|
940
|
+
auto_escalations_total: c.autoEscalationTotal,
|
|
941
|
+
auto_approvals: c.autoApprovals,
|
|
942
|
+
workspace_changed: !sameWorkspaceSnapshot(before, after),
|
|
943
|
+
diff_stat: after,
|
|
944
|
+
elapsed_ms: now() - t0,
|
|
945
|
+
};
|
|
946
|
+
attachReview(result);
|
|
947
|
+
runRecord.ended_at = now();
|
|
948
|
+
runRecord.elapsed_ms = result.elapsed_ms;
|
|
949
|
+
runRecord.status = failure ? 'error' : stopReason === 'cancelled' ? 'cancelled' : 'done';
|
|
950
|
+
runRecord.result = result;
|
|
951
|
+
// ★ 无条件写哨兵:**一个回合 = 一个哨兵文件**,没有例外。
|
|
952
|
+
// 之前只在"调用方没在原地等"时才写,于是插话那一轮没有哨兵 —— 调用方的
|
|
953
|
+
// 等待逻辑(等文件出现)就永远空等,即使 run 早已 status=done(真实事故)。
|
|
954
|
+
// 统一规则也消除了整类"有 run_id 却没有哨兵"的坑。
|
|
955
|
+
const sentinelFile = this.#hub.writeRunSentinel(this.id, runRecord);
|
|
956
|
+
result.sentinel_file = sentinelFile ?? this.#hub.runSentinelPath(this.id, runRecord.run_id);
|
|
957
|
+
if (!sentinelFile) result.sentinel_error = runRecord.sentinel_error ?? '哨兵文件写入失败';
|
|
958
|
+
return result;
|
|
959
|
+
} catch (e) {
|
|
960
|
+
this.#hub.log.info(`[conv ${this.id}] 回合收尾异常(已兜底): ${e.message}`);
|
|
961
|
+
this.busy = false;
|
|
962
|
+
if (this.currentRun === runRecord) this.currentRun = null;
|
|
963
|
+
this.collector = null;
|
|
964
|
+
const waiters = this.idleWaiters;
|
|
965
|
+
this.idleWaiters = [];
|
|
966
|
+
for (const w of waiters) w();
|
|
967
|
+
this.#scheduleDrain();
|
|
968
|
+
const fallback = {
|
|
969
|
+
conversation_id: this.id,
|
|
970
|
+
run_id: runRecord.run_id,
|
|
971
|
+
stop_reason: 'error',
|
|
972
|
+
error: `回合收尾异常: ${e.message}`,
|
|
973
|
+
answer: c.answer ?? '',
|
|
974
|
+
thinking_stats: { hidden: true, chars: 0, chunks: 0, ms: 0 },
|
|
975
|
+
tools_used: [...(c.tools?.values?.() ?? [])],
|
|
976
|
+
workspace_changed: false,
|
|
977
|
+
elapsed_ms: now() - t0,
|
|
978
|
+
};
|
|
979
|
+
attachReview(fallback);
|
|
980
|
+
runRecord.ended_at = now();
|
|
981
|
+
runRecord.status = 'error';
|
|
982
|
+
runRecord.error = fallback.error;
|
|
983
|
+
runRecord.result = fallback;
|
|
984
|
+
const sentinelFile = this.#hub.writeRunSentinel(this.id, runRecord);
|
|
985
|
+
fallback.sentinel_file = sentinelFile ?? this.#hub.runSentinelPath(this.id, runRecord.run_id);
|
|
986
|
+
if (!sentinelFile) fallback.sentinel_error = runRecord.sentinel_error ?? '哨兵文件写入失败';
|
|
987
|
+
return fallback;
|
|
988
|
+
}
|
|
989
|
+
})().catch((e) => {
|
|
990
|
+
// 最后一层保险:收尾连兜底都失败时也绝不把 rejection 丢给事件循环。
|
|
991
|
+
this.#hub.log.info(`[conv ${this.id}] 回合收尾二次异常(已忽略): ${e.message}`);
|
|
992
|
+
if (!runRecord.result) {
|
|
993
|
+
runRecord.result = {
|
|
994
|
+
conversation_id: this.id,
|
|
995
|
+
run_id: runRecord.run_id,
|
|
996
|
+
stop_reason: 'error',
|
|
997
|
+
error: `回合收尾异常: ${e.message}`,
|
|
998
|
+
answer: c.answer ?? '',
|
|
999
|
+
thinking_stats: { hidden: true, chars: 0, chunks: 0, ms: 0 },
|
|
1000
|
+
tools_used: [],
|
|
1001
|
+
workspace_changed: false,
|
|
1002
|
+
elapsed_ms: now() - t0,
|
|
1003
|
+
};
|
|
1004
|
+
runRecord.status = 'error';
|
|
1005
|
+
}
|
|
1006
|
+
return runRecord.result;
|
|
1007
|
+
});
|
|
1008
|
+
return finalizePromise;
|
|
1009
|
+
};
|
|
1010
|
+
|
|
1011
|
+
/** 没等到结果时给调用方的收据(它靠 run_id + 哨兵文件事后验收)。 */
|
|
1012
|
+
const receipt = (waitedMs) => ({
|
|
1013
|
+
conversation_id: this.id,
|
|
1014
|
+
run_id: runRecord.run_id,
|
|
1015
|
+
status: 'running',
|
|
1016
|
+
still_running: true,
|
|
1017
|
+
accepted: true,
|
|
1018
|
+
background: true,
|
|
1019
|
+
sentinel_file: this.#hub.runSentinelPath(this.id, runRecord.run_id),
|
|
1020
|
+
waited_ms: waitedMs ?? 0,
|
|
1021
|
+
});
|
|
1022
|
+
|
|
1023
|
+
// 关键:第三个参数传 0 = **不设超时**。回合只能由它自己结束,或由 session/cancel 结束。
|
|
1024
|
+
const acp = proc.request('session/prompt', { sessionId: this.id, prompt: [{ type: 'text', text }] }, 0);
|
|
1025
|
+
// 这个 Promise 在 finalize **真正完成**(含 workspace_changed、审核单、哨兵)后才 settle。
|
|
1026
|
+
// 等待方必须等它,而不是只等 acp —— 否则会拿到还没填完的 runRecord.result。
|
|
1027
|
+
const finished = acp
|
|
1028
|
+
.then(
|
|
1029
|
+
(res) => {
|
|
1030
|
+
if (res?.usage) c.usage = c.usage ?? res.usage;
|
|
1031
|
+
return finalize(res?.stopReason ?? null, null);
|
|
1032
|
+
},
|
|
1033
|
+
(e) => finalize('error', e.message),
|
|
1034
|
+
)
|
|
1035
|
+
.catch((e) => {
|
|
1036
|
+
// finalize 自己是防弹的;这里只兜住同步抛出的极端情况。
|
|
1037
|
+
this.#hub.log.info(`[conv ${this.id}] 回合回调异常(已兜底): ${e.message}`);
|
|
1038
|
+
return runRecord.result ?? null;
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
if (!wait) {
|
|
1042
|
+
// 同步走到这里时 finalize 还没开始(ACP 回调在微任务里),返回收据即可。
|
|
1043
|
+
// 若极端情况下已经收尾,则等它给出完整结果,不要返回半成品。
|
|
1044
|
+
return settled ? await (finalizePromise ?? runRecord.result) : receipt(0);
|
|
1045
|
+
}
|
|
1046
|
+
if (timeoutMs <= 0) {
|
|
1047
|
+
await finished;
|
|
1048
|
+
return runRecord.result;
|
|
1049
|
+
}
|
|
1050
|
+
const timedOut = await Promise.race([
|
|
1051
|
+
finished.then(
|
|
1052
|
+
() => false,
|
|
1053
|
+
() => false,
|
|
1054
|
+
),
|
|
1055
|
+
new Promise((r) => setTimeout(() => r(true), timeoutMs)),
|
|
1056
|
+
]);
|
|
1057
|
+
// 超时发生在 finalize 完成之前(包括 git 快照还在跑):给收据,不返回半成品结果。
|
|
1058
|
+
if (timedOut) return receipt(timeoutMs);
|
|
1059
|
+
return runRecord.result;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/** 中断当前回合(ACP 原生,走的就是用户手动停止那条路径)。 */
|
|
1063
|
+
async interrupt() {
|
|
1064
|
+
if (!this.proc?.alive) return { interrupted: false, reason: 'DSH 进程未运行' };
|
|
1065
|
+
this.proc.notify('session/cancel', { sessionId: this.id });
|
|
1066
|
+
if (!this.busy) {
|
|
1067
|
+
return { interrupted: false, reason: '当前没有进行中的回合(已发送 cancel,用于清空排队输入)' };
|
|
1068
|
+
}
|
|
1069
|
+
return { interrupted: true };
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/** 运行状态:running(在跑回合)/ idle(进程活着但空闲)/ detached(进程未打开,可 resume)。 */
|
|
1073
|
+
state() {
|
|
1074
|
+
if (this.busy) return 'running';
|
|
1075
|
+
return this.proc?.alive ? 'idle' : 'detached';
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/** 追加一条输出记录;同类相邻文本会合并,避免每个 token 一条。 */
|
|
1079
|
+
pushOut(kind, text, extra = {}) {
|
|
1080
|
+
const last = this.out[this.out.length - 1];
|
|
1081
|
+
const cap = (s) => (s.length > MAX_OUT_TEXT ? `…[已截断,保留尾部 ${MAX_OUT_TEXT} 字]…\n${s.slice(-MAX_OUT_TEXT)}` : s);
|
|
1082
|
+
// 工具更新是**快照**(`read [completed]`),不是增量:同一个 tool_call_id 原地替换,
|
|
1083
|
+
// 不同的工具调用各占一条。旧实现按 kind 拼接,三个工具会挤成
|
|
1084
|
+
// `read [in_progress]read [completed]pwsh …` 一条,且只剩第一个工具的 id。
|
|
1085
|
+
if (kind === 'tool' && extra.tool_call_id) {
|
|
1086
|
+
const same = this.#findToolEntry(extra.tool_call_id);
|
|
1087
|
+
if (same) {
|
|
1088
|
+
same.text = cap(text ?? '');
|
|
1089
|
+
same.at = now();
|
|
1090
|
+
same.rev = ++this.outRev;
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
// 只有正文是流式增量,才需要合并;user/turn/tool 都是独立事件,合并会把
|
|
1095
|
+
// “第 N 轮开始”与“第 N 轮结束”拼成一条(空回合时)。
|
|
1096
|
+
if (kind === 'text' && last && last.kind === kind && last.turn === this.currentTurn && text) {
|
|
1097
|
+
last.text = cap(last.text + text);
|
|
1098
|
+
last.at = now();
|
|
1099
|
+
// ★ 合并进已有条目也必须推进修订号。否则游标已经读过这条的调用方
|
|
1100
|
+
// 会永远看不到后续追加的正文(旧实现用 seq 当游标,seq 不变 = 漏读)。
|
|
1101
|
+
last.rev = ++this.outRev;
|
|
1102
|
+
} else {
|
|
1103
|
+
this.out.push({ seq: ++this.outSeq, rev: ++this.outRev, kind, turn: this.currentTurn, text: cap(text ?? ''), at: now(), ...extra });
|
|
1104
|
+
if (this.out.length > MAX_OUT_ENTRIES) {
|
|
1105
|
+
const dropped = this.out.shift();
|
|
1106
|
+
const droppedRev = typeof dropped.rev === 'number' ? dropped.rev : dropped.seq;
|
|
1107
|
+
if (droppedRev > this.outDroppedRev) this.outDroppedRev = droppedRev;
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
if (kind === 'text') this.turnOutChars += text?.length ?? 0;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
/** 本轮里同一 tool_call_id 的工具条目(从尾部往回找,跨轮不复用)。 */
|
|
1114
|
+
#findToolEntry(toolCallId) {
|
|
1115
|
+
for (let i = this.out.length - 1; i >= 0; i--) {
|
|
1116
|
+
const e = this.out[i];
|
|
1117
|
+
if (e.turn !== this.currentTurn) return null;
|
|
1118
|
+
if (e.kind === 'tool' && e.tool_call_id === toolCallId) return e;
|
|
1119
|
+
}
|
|
1120
|
+
return null;
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/** 读输出:**按修订号**的游标式增量。条目被追加时会再次返回(按 seq 替换全文)。 */
|
|
1124
|
+
readOut({ cursor = 0, includeReasoning = false, limit = 200 } = {}) {
|
|
1125
|
+
const cur = Number.isFinite(cursor) ? cursor : 0;
|
|
1126
|
+
// limit 必须清洗:负数会被 slice 解释成“从头到尾少一条”,0 会让调用方原地空转。
|
|
1127
|
+
const n = Number.isFinite(limit) ? Math.max(1, Math.min(Math.floor(limit), MAX_OUT_ENTRIES)) : 200;
|
|
1128
|
+
const revOf = (e) => (typeof e.rev === 'number' ? e.rev : e.seq);
|
|
1129
|
+
// ★ 分页必须按**修订号**取,不能按数组顺序:工具条目的状态更新是原地替换并推进修订号,
|
|
1130
|
+
// 数组里靠前的条目可能比靠后的修订号更大(追加工具 A → 追加正文 → 更新 A)。按数组顺序
|
|
1131
|
+
// 切 limit 条、再拿最后一条的修订号当游标,就会越过没读到的正文,之后永远读不到它。
|
|
1132
|
+
// 按修订号升序取前 n 条,游标 = 本页最大修订号,于是"修订号 ≤ 游标"的条目一定都已返回。
|
|
1133
|
+
const eligible = this.out.filter((e) => revOf(e) > cur).sort((a, b) => revOf(a) - revOf(b));
|
|
1134
|
+
const page = eligible.slice(0, n);
|
|
1135
|
+
const next = page.length ? revOf(page[page.length - 1]) : cur;
|
|
1136
|
+
// 本页内部再按 seq 排回时间顺序,方便调用方直接展示。
|
|
1137
|
+
const entries = page.slice().sort((a, b) => a.seq - b.seq);
|
|
1138
|
+
const res = {
|
|
1139
|
+
conversation_id: this.id,
|
|
1140
|
+
state: this.state(),
|
|
1141
|
+
busy: this.busy,
|
|
1142
|
+
current_tool: this.currentTool,
|
|
1143
|
+
entries,
|
|
1144
|
+
cursor: next,
|
|
1145
|
+
has_more: eligible.length > page.length,
|
|
1146
|
+
/** 缓冲被环形覆盖、且被挤掉的条目里有游标之后的内容时为 true:你错过了中间一段。 */
|
|
1147
|
+
gap: this.outDroppedRev > cur,
|
|
1148
|
+
};
|
|
1149
|
+
// 思考内容只在显式要求时、且只针对"当前正在跑的回合"返回;不保留、不进缓冲
|
|
1150
|
+
if (includeReasoning && this.collector?.reasoning) {
|
|
1151
|
+
res.live_thinking = this.collector.reasoning;
|
|
1152
|
+
}
|
|
1153
|
+
return res;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/** 建一条回合记录。派活时先建,好让调用方立刻拿到 run_id(异步模式的关键)。 */
|
|
1157
|
+
createRun(text, { clientRunId = null, review = this.review } = {}) {
|
|
1158
|
+
const run = {
|
|
1159
|
+
run_id: `run-${Date.now().toString(36)}-${++this.runSeq}`,
|
|
1160
|
+
conversation_id: this.id,
|
|
1161
|
+
client_run_id: clientRunId ?? null,
|
|
1162
|
+
prompt_preview: text.length > MAX_RUN_PREVIEW ? `${text.slice(0, MAX_RUN_PREVIEW)}…` : text,
|
|
1163
|
+
status: 'queued',
|
|
1164
|
+
/** 本回合的审核策略(off / advisory);由 dsh_send / dsh_interject 或会话默认决定。 */
|
|
1165
|
+
review_mode: normalizeReviewMode(review, this.review),
|
|
1166
|
+
started_at: null,
|
|
1167
|
+
ended_at: null,
|
|
1168
|
+
elapsed_ms: null,
|
|
1169
|
+
result: null,
|
|
1170
|
+
};
|
|
1171
|
+
this.runs.push(run);
|
|
1172
|
+
if (clientRunId) this.clientRuns.set(clientRunId, run.run_id);
|
|
1173
|
+
if (this.runs.length > MAX_RUNS) {
|
|
1174
|
+
const evicted = this.runs.shift();
|
|
1175
|
+
// 幂等键必须跟着 run 记录一起淘汰,否则会指向一个已经查不到的 run。
|
|
1176
|
+
for (const [k, v] of this.clientRuns) {
|
|
1177
|
+
if (v === evicted.run_id) this.clientRuns.delete(k);
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
// 防御性清残留:正常情况下该 run_id 从未用过,但确保等待方不会被历史哨兵误触发。
|
|
1181
|
+
this.#hub.clearRunSentinel(this.id, run.run_id);
|
|
1182
|
+
return run;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/** 按调用方幂等键取 run(不存在/已淘汰返回 null)。 */
|
|
1186
|
+
getRunByClientId(clientRunId) {
|
|
1187
|
+
if (!clientRunId) return null;
|
|
1188
|
+
const runId = this.clientRuns.get(clientRunId);
|
|
1189
|
+
if (!runId) return null;
|
|
1190
|
+
return this.runs.find((r) => r.run_id === runId) ?? null;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
// ── 会话级队列(忙时非阻塞入队)────────────────────────────────
|
|
1194
|
+
|
|
1195
|
+
/** 入队;返回 run 记录与 1-based 位置。队列只在内存,重启丢失。 */
|
|
1196
|
+
enqueue(text, { reasoning = 'hide', clientRunId = null, preview = null, review = this.review } = {}) {
|
|
1197
|
+
const MAX_QUEUE = 8;
|
|
1198
|
+
if (clientRunId) {
|
|
1199
|
+
const existing = this.getRunByClientId(clientRunId);
|
|
1200
|
+
if (existing) return { run: existing, position: 0, deduped: true };
|
|
1201
|
+
}
|
|
1202
|
+
if (this.queue.length >= MAX_QUEUE) {
|
|
1203
|
+
throw new Error(`队列已满(最多 ${MAX_QUEUE} 条);先用 dsh_queue list 清理,或等当前回合结束`);
|
|
1204
|
+
}
|
|
1205
|
+
// 预览用调用方原文,执行用可能已注入团队前缀的 text。
|
|
1206
|
+
const run = this.createRun(preview ?? text, { clientRunId, review });
|
|
1207
|
+
run.status = 'queued';
|
|
1208
|
+
this.queue.push({ run, text, reasoning, clientRunId, enqueued_at: now() });
|
|
1209
|
+
return { run, position: this.queue.length, deduped: false };
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
queueList() {
|
|
1213
|
+
// 防御:snapshot 可能被测试用 prototype 替身调用(没有构造器初始化 queue)。
|
|
1214
|
+
return (this.queue ?? []).map((item, i) => ({
|
|
1215
|
+
position: i + 1,
|
|
1216
|
+
run_id: item.run.run_id,
|
|
1217
|
+
preview: item.run.prompt_preview,
|
|
1218
|
+
enqueued_at: item.enqueued_at,
|
|
1219
|
+
client_run_id: item.clientRunId ?? null,
|
|
1220
|
+
}));
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
queueRemove(runId, { cancel = true } = {}) {
|
|
1224
|
+
const i = this.queue.findIndex((x) => x.run.run_id === runId);
|
|
1225
|
+
if (i < 0) throw new Error(`队列里没有 ${runId}`);
|
|
1226
|
+
const [item] = this.queue.splice(i, 1);
|
|
1227
|
+
if (cancel) {
|
|
1228
|
+
const run = item.run;
|
|
1229
|
+
run.status = 'cancelled';
|
|
1230
|
+
run.ended_at = now();
|
|
1231
|
+
run.elapsed_ms = 0;
|
|
1232
|
+
run.result = {
|
|
1233
|
+
conversation_id: this.id,
|
|
1234
|
+
run_id: run.run_id,
|
|
1235
|
+
stop_reason: 'cancelled',
|
|
1236
|
+
error: 'queued run cancelled before it started',
|
|
1237
|
+
answer: '',
|
|
1238
|
+
thinking_stats: { hidden: true, chars: 0, chunks: 0, ms: 0 },
|
|
1239
|
+
tools_used: [],
|
|
1240
|
+
workspace_changed: false,
|
|
1241
|
+
elapsed_ms: 0,
|
|
1242
|
+
};
|
|
1243
|
+
if (run.review_mode !== 'off') {
|
|
1244
|
+
run.result.review = buildReviewTicket({
|
|
1245
|
+
conversationId: this.id,
|
|
1246
|
+
runId: run.run_id,
|
|
1247
|
+
promptPreview: run.prompt_preview,
|
|
1248
|
+
result: run.result,
|
|
1249
|
+
mode: run.review_mode,
|
|
1250
|
+
permission: this.permission,
|
|
1251
|
+
});
|
|
1252
|
+
}
|
|
1253
|
+
// 取消也是终态 → 写哨兵,避免等待方永远空等。
|
|
1254
|
+
const file = this.#hub.writeRunSentinel(this.id, run);
|
|
1255
|
+
run.result.sentinel_file = file ?? this.#hub.runSentinelPath(this.id, run.run_id);
|
|
1256
|
+
}
|
|
1257
|
+
return { removed: true, run_id: runId };
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
queueUpdate(runId, text) {
|
|
1261
|
+
const item = this.queue.find((x) => x.run.run_id === runId);
|
|
1262
|
+
if (!item) throw new Error(`队列里没有 ${runId}`);
|
|
1263
|
+
item.text = text;
|
|
1264
|
+
item.run.prompt_preview =
|
|
1265
|
+
text.length > MAX_RUN_PREVIEW ? `${text.slice(0, MAX_RUN_PREVIEW)}…` : text;
|
|
1266
|
+
return { updated: true, run_id: runId };
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
queuePromote(runId) {
|
|
1270
|
+
const i = this.queue.findIndex((x) => x.run.run_id === runId);
|
|
1271
|
+
if (i < 0) throw new Error(`队列里没有 ${runId}`);
|
|
1272
|
+
const [item] = this.queue.splice(i, 1);
|
|
1273
|
+
this.queue.unshift(item);
|
|
1274
|
+
return { promoted: true, run_id: runId, position: 1 };
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
/** 空闲时取队首执行。失败也要继续下一条(否则一条坏任务卡死整条队列)。 */
|
|
1278
|
+
#scheduleDrain() {
|
|
1279
|
+
if (this.busy || this.draining || this.queue.length === 0) return;
|
|
1280
|
+
this.draining = true;
|
|
1281
|
+
const timer = setTimeout(() => {
|
|
1282
|
+
this.draining = false;
|
|
1283
|
+
if (this.busy || this.queue.length === 0) return;
|
|
1284
|
+
const item = this.queue.shift();
|
|
1285
|
+
const run = item.run;
|
|
1286
|
+
run.status = 'running';
|
|
1287
|
+
this.prompt(item.text, { reasoning: item.reasoning, run, wait: false })
|
|
1288
|
+
.catch(() => {})
|
|
1289
|
+
.finally(() => this.#scheduleDrain());
|
|
1290
|
+
}, 0);
|
|
1291
|
+
timer.unref?.();
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
/** 取回合记录;不传 run_id 就是最近一条。 */
|
|
1295
|
+
getRun(runId) {
|
|
1296
|
+
if (runId) {
|
|
1297
|
+
const r = this.runs.find((x) => x.run_id === runId);
|
|
1298
|
+
if (!r) {
|
|
1299
|
+
throw new Error(`未知 run_id: ${runId}(回合记录只在内存、只留最近 ${MAX_RUNS} 条;也可用 dsh_read 读输出缓冲)`);
|
|
1300
|
+
}
|
|
1301
|
+
return r;
|
|
1302
|
+
}
|
|
1303
|
+
return this.runs.length ? this.runs[this.runs.length - 1] : null;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
/**
|
|
1307
|
+
* 异步收据里的"待审核"审核单:回合还没结束,只有清单和 run_id,
|
|
1308
|
+
* 告诉调用方"拿到哨兵结果后先审核,再当完成"。
|
|
1309
|
+
*/
|
|
1310
|
+
pendingReviewTicket(runId) {
|
|
1311
|
+
const run = this.runs.find((x) => x.run_id === runId) ?? null;
|
|
1312
|
+
const mode = run?.review_mode ?? this.review;
|
|
1313
|
+
if (mode === 'off') return null;
|
|
1314
|
+
return buildReviewTicket({
|
|
1315
|
+
conversationId: this.id,
|
|
1316
|
+
runId,
|
|
1317
|
+
promptPreview: run?.prompt_preview ?? null,
|
|
1318
|
+
result: null,
|
|
1319
|
+
mode,
|
|
1320
|
+
permission: this.permission,
|
|
1321
|
+
pending: true,
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
/**
|
|
1326
|
+
* 记录调用方对某个已完成回合的审核裁决。
|
|
1327
|
+
*
|
|
1328
|
+
* 三种来源按优先级取事实:
|
|
1329
|
+
* 1) 内存里的 run(含完整 result);
|
|
1330
|
+
* 2) 哨兵文件(服务重启 / run 记录被挤出内存后仍然可审核);
|
|
1331
|
+
* 3) 已有的审核记录(允许修订裁决)。
|
|
1332
|
+
*
|
|
1333
|
+
* 注意:**不阻塞后续派活,也不自动回灌给 DSH**。审核只是把"调用方已经看过并给出结论"
|
|
1334
|
+
* 这件事落成可审计的事实;要修就由调用方再 dsh_send。
|
|
1335
|
+
*/
|
|
1336
|
+
submitReview(runId, { verdict, notes, findings } = {}) {
|
|
1337
|
+
if (typeof runId !== 'string' || runId.trim() === '') {
|
|
1338
|
+
throw new Error('run_id 不能为空');
|
|
1339
|
+
}
|
|
1340
|
+
assertSafeId(runId, 'run_id');
|
|
1341
|
+
if (!REVIEW_VERDICTS.includes(verdict)) {
|
|
1342
|
+
throw new Error(`verdict 必须是 ${REVIEW_VERDICTS.join(' | ')} 之一`);
|
|
1343
|
+
}
|
|
1344
|
+
const cleanNotes = typeof notes === 'string' ? notes.trim() : '';
|
|
1345
|
+
const cleanFindings = Array.isArray(findings)
|
|
1346
|
+
? findings.filter((f) => typeof f === 'string' && f.trim() !== '').map((f) => f.trim())
|
|
1347
|
+
: [];
|
|
1348
|
+
if (verdict !== 'pass' && !cleanNotes && !cleanFindings.length) {
|
|
1349
|
+
throw new Error('verdict=fail/partial 时必须提供 notes(或 findings)说明理由');
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
const run = this.runs.find((x) => x.run_id === runId) ?? null;
|
|
1353
|
+
let result = run?.result ?? null;
|
|
1354
|
+
let promptPreview = run?.prompt_preview ?? null;
|
|
1355
|
+
let mode = run?.review_mode ?? this.review;
|
|
1356
|
+
|
|
1357
|
+
if (run) {
|
|
1358
|
+
if (run.status === 'running' || run.status === 'queued') {
|
|
1359
|
+
throw new Error(`回合 ${runId} 还没结束,不能审核(等哨兵文件出现或 dsh_get 显示完成后重试)`);
|
|
1360
|
+
}
|
|
1361
|
+
if (!result) throw new Error(`回合 ${runId} 没有可用结果,无法审核`);
|
|
1362
|
+
} else {
|
|
1363
|
+
// 内存里没有这条 run:以哨兵为准。它只在回合终止时写入,所以天然是"已完成"。
|
|
1364
|
+
let sentinel = null;
|
|
1365
|
+
try {
|
|
1366
|
+
sentinel = JSON.parse(readFileSync(this.#hub.runSentinelPath(this.id, runId), 'utf8'));
|
|
1367
|
+
} catch {
|
|
1368
|
+
/* 下面的审核记录回退会给出更准确的错误 */
|
|
1369
|
+
}
|
|
1370
|
+
if (sentinel) {
|
|
1371
|
+
result = sentinel.result ?? null;
|
|
1372
|
+
if (result?.review && typeof result.review === 'object') {
|
|
1373
|
+
promptPreview = result.review.prompt_preview ?? promptPreview;
|
|
1374
|
+
mode = result.review.mode ?? mode;
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
const existing = readReviewRecord(this.id, runId);
|
|
1380
|
+
if (!result && !existing) {
|
|
1381
|
+
throw new Error(
|
|
1382
|
+
`未知 run_id: ${runId}(内存里没有、也没有哨兵/审核记录;请确认 run_id 是否正确,` +
|
|
1383
|
+
`或先用 dsh_get / 哨兵文件取回结果)`,
|
|
1384
|
+
);
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
const ticket =
|
|
1388
|
+
result?.review && typeof result.review === 'object'
|
|
1389
|
+
? { ...result.review }
|
|
1390
|
+
: existing
|
|
1391
|
+
? { ...existing }
|
|
1392
|
+
: buildReviewTicket({
|
|
1393
|
+
conversationId: this.id,
|
|
1394
|
+
runId,
|
|
1395
|
+
promptPreview,
|
|
1396
|
+
result: result ?? {},
|
|
1397
|
+
mode,
|
|
1398
|
+
permission: this.permission,
|
|
1399
|
+
});
|
|
1400
|
+
if (!ticket.review_id) ticket.review_id = reviewIdForRun(runId);
|
|
1401
|
+
ticket.conversation_id = ticket.conversation_id ?? this.id;
|
|
1402
|
+
ticket.run_id = ticket.run_id ?? runId;
|
|
1403
|
+
ticket.mode = ticket.mode ?? mode;
|
|
1404
|
+
|
|
1405
|
+
const reviewedAt = new Date().toISOString();
|
|
1406
|
+
const { file, payload } = writeReviewRecord({
|
|
1407
|
+
ticket,
|
|
1408
|
+
verdict,
|
|
1409
|
+
notes: cleanNotes,
|
|
1410
|
+
findings: cleanFindings,
|
|
1411
|
+
reviewedAt,
|
|
1412
|
+
});
|
|
1413
|
+
const review = {
|
|
1414
|
+
...ticket,
|
|
1415
|
+
status: verdict,
|
|
1416
|
+
verdict,
|
|
1417
|
+
notes: payload.notes,
|
|
1418
|
+
findings: payload.findings,
|
|
1419
|
+
reviewed_at: reviewedAt,
|
|
1420
|
+
review_file: file,
|
|
1421
|
+
};
|
|
1422
|
+
if (run) {
|
|
1423
|
+
run.review = review;
|
|
1424
|
+
if (run.result && typeof run.result === 'object') run.result.review = review;
|
|
1425
|
+
}
|
|
1426
|
+
// 哨兵是调用方异步收活的来源;把裁决回写进去,避免它一直显示 pending。
|
|
1427
|
+
this.#hub.updateRunSentinelReview(this.id, runId, {
|
|
1428
|
+
status: review.status,
|
|
1429
|
+
verdict: review.verdict,
|
|
1430
|
+
notes: review.notes,
|
|
1431
|
+
findings: review.findings,
|
|
1432
|
+
reviewed_at: reviewedAt,
|
|
1433
|
+
review_file: file,
|
|
1434
|
+
});
|
|
1435
|
+
return {
|
|
1436
|
+
...review,
|
|
1437
|
+
review_file: file,
|
|
1438
|
+
sentinel_file: this.#hub.runSentinelPath(this.id, runId),
|
|
1439
|
+
};
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
/** 等到当前回合结束(不忙则立即返回 true;`timeoutMs <= 0` 一直等;超时返回 false)。 */
|
|
1443
|
+
async waitIdle(timeoutMs = 30 * 60 * 1000) {
|
|
1444
|
+
if (!this.busy) return true;
|
|
1445
|
+
return await new Promise((resolve) => {
|
|
1446
|
+
let settled = false;
|
|
1447
|
+
let timer = null;
|
|
1448
|
+
const done = () => {
|
|
1449
|
+
if (settled) return;
|
|
1450
|
+
settled = true;
|
|
1451
|
+
if (timer) clearTimeout(timer);
|
|
1452
|
+
resolve(true);
|
|
1453
|
+
};
|
|
1454
|
+
if (timeoutMs > 0) {
|
|
1455
|
+
timer = setTimeout(() => {
|
|
1456
|
+
if (settled) return;
|
|
1457
|
+
settled = true;
|
|
1458
|
+
const i = this.idleWaiters.indexOf(done);
|
|
1459
|
+
if (i >= 0) this.idleWaiters.splice(i, 1);
|
|
1460
|
+
resolve(false);
|
|
1461
|
+
}, timeoutMs);
|
|
1462
|
+
}
|
|
1463
|
+
this.idleWaiters.push(done);
|
|
1464
|
+
});
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
/**
|
|
1468
|
+
* 插话。
|
|
1469
|
+
*
|
|
1470
|
+
* ACP 明确拒绝并发 prompt(`a prompt is already in flight for this session`),
|
|
1471
|
+
* 所以做不到"把消息塞进正在跑的回合里让模型即时改向"——DSH 的 agent 本身有 steering
|
|
1472
|
+
* 队列,但被 ACP 桥接层挡住了。这里提供的是它的实用等价物:
|
|
1473
|
+
*
|
|
1474
|
+
* interject —— 打断当前回合,随即把新消息作为新回合发出("停一下,听我说")
|
|
1475
|
+
* queue —— 不打断,等当前回合自然结束后紧接着发出("你先把这轮干完,然后…")
|
|
1476
|
+
*
|
|
1477
|
+
* 之所以无竞态:dsh-acp 在让 prompt 请求返回**之前**就清空了 inflight 槽位,
|
|
1478
|
+
* 且只在 agent 完全 idle 后才 settle(lib/index.js:1003-1011)。
|
|
1479
|
+
*/
|
|
1480
|
+
async interject(message, { mode = 'interject', reasoning = 'hide', timeoutMs = PROMPT_TIMEOUT_MS, clientRunId = null, review } = {}) {
|
|
1481
|
+
// 先建 run:幂等键要在"等待上一轮"之前就登记,否则重试会造出第二个 run。
|
|
1482
|
+
const run = this.createRun(message, { clientRunId, review });
|
|
1483
|
+
if (!this.busy) {
|
|
1484
|
+
const r = await this.prompt(message, { reasoning, timeoutMs, run });
|
|
1485
|
+
return { ...r, interjected: 'idle', waited_ms: 0 };
|
|
1486
|
+
}
|
|
1487
|
+
const t0 = now();
|
|
1488
|
+
if (mode === 'interject') {
|
|
1489
|
+
this.proc?.notify('session/cancel', { sessionId: this.id });
|
|
1490
|
+
}
|
|
1491
|
+
// 排队等待的时长跟随调用方的 timeoutMs(0 = 一直等),不再固定 30 分钟。
|
|
1492
|
+
const idle = await this.waitIdle(timeoutMs);
|
|
1493
|
+
const waited = now() - t0;
|
|
1494
|
+
if (!idle) {
|
|
1495
|
+
const err = new Error('等待当前回合结束超时,插话未发送');
|
|
1496
|
+
err.runId = run.run_id;
|
|
1497
|
+
run.status = 'cancelled';
|
|
1498
|
+
run.ended_at = now();
|
|
1499
|
+
run.elapsed_ms = 0;
|
|
1500
|
+
run.result = {
|
|
1501
|
+
conversation_id: this.id,
|
|
1502
|
+
run_id: run.run_id,
|
|
1503
|
+
stop_reason: 'cancelled',
|
|
1504
|
+
error: err.message,
|
|
1505
|
+
answer: '',
|
|
1506
|
+
thinking_stats: { hidden: true, chars: 0, chunks: 0, ms: 0 },
|
|
1507
|
+
tools_used: [],
|
|
1508
|
+
workspace_changed: false,
|
|
1509
|
+
elapsed_ms: 0,
|
|
1510
|
+
};
|
|
1511
|
+
// 取消同样是一次终态结果:审核单照挂,调用方仍可记录"这次没跑成"。
|
|
1512
|
+
if (run.review_mode !== 'off') {
|
|
1513
|
+
run.result.review = buildReviewTicket({
|
|
1514
|
+
conversationId: this.id,
|
|
1515
|
+
runId: run.run_id,
|
|
1516
|
+
promptPreview: run.prompt_preview,
|
|
1517
|
+
result: run.result,
|
|
1518
|
+
mode: run.review_mode,
|
|
1519
|
+
permission: this.permission,
|
|
1520
|
+
});
|
|
1521
|
+
}
|
|
1522
|
+
const file = this.#hub.writeRunSentinel?.(this.id, run);
|
|
1523
|
+
run.result.sentinel_file = file ?? this.#hub.runSentinelPath?.(this.id, run.run_id) ?? null;
|
|
1524
|
+
throw err;
|
|
1525
|
+
}
|
|
1526
|
+
const r = await this.prompt(message, { reasoning, timeoutMs, run });
|
|
1527
|
+
return { ...r, interjected: mode, waited_ms: waited };
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
/** 关闭会话(进程收尾;会话本体留在磁盘上,可再次 resume)。 */
|
|
1531
|
+
async close({ forget = false } = {}) {
|
|
1532
|
+
if (this.recycling) await this.recycling;
|
|
1533
|
+
const proc = this.proc;
|
|
1534
|
+
let stopped = true;
|
|
1535
|
+
if (proc?.alive) {
|
|
1536
|
+
if (typeof proc.detach === 'function') {
|
|
1537
|
+
// 共享进程:只把本会话摘出去(detach 内会 session/close);最后一个走了才停进程。
|
|
1538
|
+
this.recycling = proc.detach(this).finally(() => {
|
|
1539
|
+
if (this.proc === proc) this.proc = null;
|
|
1540
|
+
this.recycling = null;
|
|
1541
|
+
});
|
|
1542
|
+
stopped = await this.recycling;
|
|
1543
|
+
} else {
|
|
1544
|
+
try {
|
|
1545
|
+
await proc.request('session/close', { sessionId: this.id }, 10_000);
|
|
1546
|
+
} catch {}
|
|
1547
|
+
// 与 reaper 相同:stop 期间保持 proc 可见并登记 recycling,
|
|
1548
|
+
// 落定后再置空——否则并发 dsh_send 会拿到垂死进程或去抢尚未释放的锁。
|
|
1549
|
+
this.recycling = proc
|
|
1550
|
+
.stop()
|
|
1551
|
+
.catch(() => false)
|
|
1552
|
+
.finally(() => {
|
|
1553
|
+
if (this.proc === proc) this.proc = null;
|
|
1554
|
+
this.recycling = null;
|
|
1555
|
+
});
|
|
1556
|
+
stopped = await this.recycling;
|
|
1557
|
+
}
|
|
1558
|
+
} else if (this.proc === proc) {
|
|
1559
|
+
this.proc = null;
|
|
1560
|
+
}
|
|
1561
|
+
// 回合已经结束/进程正在关闭,任何仍挂着的审批都不再可裁决。
|
|
1562
|
+
for (const [, p] of this.pendingApprovals) {
|
|
1563
|
+
try {
|
|
1564
|
+
p.resolve({ outcome: { outcome: 'cancelled' } });
|
|
1565
|
+
} catch {}
|
|
1566
|
+
}
|
|
1567
|
+
this.pendingApprovals.clear();
|
|
1568
|
+
// 只有确认进程退出后才删登记;否则把登记留着,让 takeover/classifyHolder
|
|
1569
|
+
// 还能把这个仍握着写锁的孤儿归因出来。
|
|
1570
|
+
if (stopped) removeMarker(this.id);
|
|
1571
|
+
else {
|
|
1572
|
+
this.#hub.log.info(`[conv ${this.id}] 进程未能确认退出,保留写锁登记以便后续归因`);
|
|
1573
|
+
// 进程还活着时不能 forget:墓碑会让按 id 查询直接失败,而活着的进程
|
|
1574
|
+
// 仍握着写锁,之后连 takeover 都无法归因/接管。
|
|
1575
|
+
if (forget) return { closed: false, forgotten: false, error: '进程未能确认退出,拒绝 forget' };
|
|
1576
|
+
}
|
|
1577
|
+
if (forget) {
|
|
1578
|
+
// 先写墓碑再从注册表移除:之后按 id 查询会明确报"已 forget",
|
|
1579
|
+
// 而不是从 DSH 会话存储把它重建回来(否则 forget 形同虚设)。
|
|
1580
|
+
writeTombstone(this.id);
|
|
1581
|
+
this.#hub.remove(this.id);
|
|
1582
|
+
} else this.#hub.save();
|
|
1583
|
+
return { closed: stopped, forgotten: forget, ...(stopped ? {} : { error: '进程未能确认退出,登记已保留' }) };
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
/** 按档位渲染思考内容。 */
|
|
1588
|
+
export function formatReasoning(c, mode) {
|
|
1589
|
+
if (mode === 'full') return c.reasoning;
|
|
1590
|
+
if (mode === 'summary') {
|
|
1591
|
+
const t = c.reasoning.trim();
|
|
1592
|
+
if (!t) return '';
|
|
1593
|
+
if (t.length <= 400) return t;
|
|
1594
|
+
return `${t.slice(0, 240)}\n…\n${t.slice(-120)}`;
|
|
1595
|
+
}
|
|
1596
|
+
if (mode === 'marker') {
|
|
1597
|
+
return c.reasoning ? `[思考已隐藏:${c.reasoning.length} 字]` : '';
|
|
1598
|
+
}
|
|
1599
|
+
return ''; // hide
|
|
1600
|
+
}
|