dsh-any-agent 0.5.0 → 0.5.2
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 +109 -0
- package/DESIGN.md +10 -1
- package/README.md +5 -3
- package/README.zh.md +5 -3
- package/USAGE-NOTES.en.md +6 -5
- package/USAGE-NOTES.md +6 -5
- package/bin/dsh-any-agent.mjs +7 -2
- package/docs/en/architecture.md +3 -3
- package/docs/en/compatibility.md +2 -1
- package/docs/en/reference.md +3 -1
- package/docs/en/security.md +11 -2
- package/docs/en/tests.md +9 -8
- package/docs/en/usage.md +21 -5
- package/docs/zh/architecture.md +3 -3
- package/docs/zh/compatibility.md +2 -1
- package/docs/zh/limits.md +1 -1
- package/docs/zh/reference.md +3 -1
- package/docs/zh/security.md +11 -2
- package/docs/zh/tests.md +9 -8
- package/docs/zh/usage.md +21 -5
- package/package.json +1 -1
- package/src/config.mjs +69 -0
- package/src/hub.mjs +713 -185
- package/src/ids.mjs +21 -0
- package/src/mcp.mjs +18 -7
- package/src/redact.mjs +34 -0
- package/src/review.mjs +210 -0
- package/src/tools.mjs +254 -16
package/src/hub.mjs
CHANGED
|
@@ -19,21 +19,26 @@ import {
|
|
|
19
19
|
mkdirSync,
|
|
20
20
|
mkdtempSync,
|
|
21
21
|
readFileSync,
|
|
22
|
+
realpathSync,
|
|
22
23
|
writeFileSync,
|
|
23
24
|
existsSync,
|
|
24
25
|
renameSync,
|
|
25
26
|
statSync,
|
|
27
|
+
lstatSync,
|
|
26
28
|
rmSync,
|
|
27
29
|
readdirSync,
|
|
28
30
|
} from 'node:fs';
|
|
29
|
-
import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
|
|
31
|
+
import { dirname, isAbsolute, join, parse, resolve, sep } from 'node:path';
|
|
30
32
|
import { homedir, tmpdir } from 'node:os';
|
|
31
|
-
import {
|
|
33
|
+
import { execFile } from 'node:child_process';
|
|
34
|
+
import { createHash } from 'node:crypto';
|
|
35
|
+
import { promisify } from 'node:util';
|
|
32
36
|
import {
|
|
33
37
|
ALLOW_FULL_ACCESS,
|
|
34
38
|
APPROVAL_POLICIES,
|
|
35
39
|
DEFAULT_PERMISSION,
|
|
36
40
|
DEFAULT_REASONING_EFFORT,
|
|
41
|
+
DEFAULT_REVIEW,
|
|
37
42
|
IDLE_TTL_MS,
|
|
38
43
|
LIST_PROBE_TTL_MS,
|
|
39
44
|
MAX_LIVE,
|
|
@@ -42,6 +47,8 @@ import {
|
|
|
42
47
|
PROMPT_TIMEOUT_MS,
|
|
43
48
|
REAP_INTERVAL_MS,
|
|
44
49
|
REASONING_EFFORTS,
|
|
50
|
+
REVIEWS_DIR,
|
|
51
|
+
REVIEW_MODES,
|
|
45
52
|
RUNS_DIR,
|
|
46
53
|
SENTINEL_INCLUDE_REASONING,
|
|
47
54
|
SENTINEL_TTL_MS,
|
|
@@ -53,6 +60,16 @@ import {
|
|
|
53
60
|
assertWritableDir,
|
|
54
61
|
defaultApprovalPolicy,
|
|
55
62
|
} from './config.mjs';
|
|
63
|
+
import {
|
|
64
|
+
REVIEW_VERDICTS,
|
|
65
|
+
buildReviewTicket,
|
|
66
|
+
normalizeReviewMode,
|
|
67
|
+
readReviewRecord,
|
|
68
|
+
reviewIdForRun,
|
|
69
|
+
writeReviewRecord,
|
|
70
|
+
} from './review.mjs';
|
|
71
|
+
import { sanitizeApprovalContext } from './redact.mjs';
|
|
72
|
+
import { assertSafeId } from './ids.mjs';
|
|
56
73
|
import { AcpProcess } from './acp.mjs';
|
|
57
74
|
import { SessionHost } from './host.mjs';
|
|
58
75
|
import { rssMb } from './procstat.mjs';
|
|
@@ -176,6 +193,50 @@ const MAX_PROMPT_ECHO = 4000;
|
|
|
176
193
|
const MAX_RUNS = 20;
|
|
177
194
|
/** 回合记录里 prompt 预览的长度。 */
|
|
178
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
|
+
}
|
|
179
240
|
|
|
180
241
|
/** 按 ACP 权限应答的规范形状挑选项。 */
|
|
181
242
|
function pickOption(options, want) {
|
|
@@ -184,26 +245,6 @@ function pickOption(options, want) {
|
|
|
184
245
|
return match?.optionId;
|
|
185
246
|
}
|
|
186
247
|
|
|
187
|
-
/** Only expose review context in memory; redact common credential fields and literals. */
|
|
188
|
-
function sanitizeApprovalContext(value, key = '') {
|
|
189
|
-
if (/^(?:api[_-]?key|authorization|proxy[_-]?authorization|cookie|set[_-]?cookie|password|passwd|secret|token|access[_-]?token|refresh[_-]?token|credentials?|headers|env|environment)$/i.test(key)) {
|
|
190
|
-
return '[REDACTED]';
|
|
191
|
-
}
|
|
192
|
-
if (typeof value === 'string') {
|
|
193
|
-
return value
|
|
194
|
-
.replace(/\b(?:sk|ak)-[A-Za-z0-9_-]{8,}/g, '[REDACTED]')
|
|
195
|
-
.replace(/\bBearer\s+[^\s'";]+/gi, 'Bearer [REDACTED]')
|
|
196
|
-
.replace(/((?:api[_-]?key|password|passwd|secret|(?:access[_-]?)?token)\s*(?:=|:)\s*)(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s;]+)/gi, '$1[REDACTED]')
|
|
197
|
-
.replace(/(--(?:api[_-]?key|password|passwd|secret|token)\s+)(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s;]+)/gi, '$1[REDACTED]')
|
|
198
|
-
.replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/gi, '$1[REDACTED]@');
|
|
199
|
-
}
|
|
200
|
-
if (Array.isArray(value)) return value.map((item) => sanitizeApprovalContext(item));
|
|
201
|
-
if (value && typeof value === 'object') {
|
|
202
|
-
return Object.fromEntries(Object.entries(value).map(([name, item]) => [name, sanitizeApprovalContext(item, name)]));
|
|
203
|
-
}
|
|
204
|
-
return value ?? null;
|
|
205
|
-
}
|
|
206
|
-
|
|
207
248
|
function approvalContext(params, toolCall, desc, options) {
|
|
208
249
|
const input = toolCall.rawInput ?? toolCall.input ?? null;
|
|
209
250
|
return sanitizeApprovalContext({
|
|
@@ -276,6 +317,21 @@ export function sanitizeResultForSentinel(result) {
|
|
|
276
317
|
return input === undefined ? safeMeta : { ...safeMeta, input: sanitizeApprovalContext(input) };
|
|
277
318
|
});
|
|
278
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
|
+
}
|
|
279
335
|
// 始终给出布尔值:调用方才能区分“思考被剥离”与“本来没有思考”。
|
|
280
336
|
const out = { ...rest, thinking_omitted: Boolean(thinking) && !SENTINEL_INCLUDE_REASONING };
|
|
281
337
|
if (SENTINEL_INCLUDE_REASONING && thinking !== undefined) out.thinking = thinking;
|
|
@@ -301,6 +357,8 @@ export class Conversation {
|
|
|
301
357
|
provider: state.provider ?? null,
|
|
302
358
|
model: state.model ?? null,
|
|
303
359
|
};
|
|
360
|
+
/** 调用方审核策略(off / advisory)。可在 dsh_send 单次覆盖。 */
|
|
361
|
+
this.review = REVIEW_MODES.includes(state.review) ? state.review : DEFAULT_REVIEW;
|
|
304
362
|
this.lastConfigApplied = null;
|
|
305
363
|
/** 是否已回读并确认会话记录的权限档(null=尚未核对,true/false=结果)。 */
|
|
306
364
|
this.permissionVerified = null;
|
|
@@ -367,6 +425,7 @@ export class Conversation {
|
|
|
367
425
|
reasoningEffort: this.cfg.reasoningEffort,
|
|
368
426
|
provider: this.cfg.provider,
|
|
369
427
|
model: this.cfg.model,
|
|
428
|
+
review: this.review,
|
|
370
429
|
workspace: this.workspace,
|
|
371
430
|
};
|
|
372
431
|
}
|
|
@@ -398,6 +457,8 @@ export class Conversation {
|
|
|
398
457
|
title_hint: this.titleHint ?? null,
|
|
399
458
|
cwd: this.cwd,
|
|
400
459
|
permission: this.permission,
|
|
460
|
+
/** 会话默认审核策略;单次 dsh_send 可以覆盖。 */
|
|
461
|
+
review: this.review,
|
|
401
462
|
/** true=已回读会话记录且一致;false/null=缓存暂时没有记录(read-only 仍由审批层兜底)。 */
|
|
402
463
|
permission_verified: this.permissionVerified,
|
|
403
464
|
on_approval: this.onApproval,
|
|
@@ -772,6 +833,9 @@ export class Conversation {
|
|
|
772
833
|
}
|
|
773
834
|
if (this.onApproval === 'auto-allow') {
|
|
774
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);
|
|
775
839
|
return allowId
|
|
776
840
|
? { outcome: { outcome: 'selected', optionId: allowId } }
|
|
777
841
|
: { outcome: { outcome: 'cancelled' } };
|
|
@@ -804,6 +868,35 @@ export class Conversation {
|
|
|
804
868
|
});
|
|
805
869
|
}
|
|
806
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
|
+
|
|
807
900
|
/** 调用方裁决一个待决审批。 */
|
|
808
901
|
decideApproval(approvalId, decision) {
|
|
809
902
|
const p = this.pendingApprovals.get(approvalId);
|
|
@@ -854,6 +947,17 @@ export class Conversation {
|
|
|
854
947
|
workspace_changed: false,
|
|
855
948
|
elapsed_ms: 0,
|
|
856
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
|
+
}
|
|
857
961
|
try {
|
|
858
962
|
if (typeof this.#hub.writeRunSentinel === 'function') {
|
|
859
963
|
const file = this.#hub.writeRunSentinel(this.id, runRecord);
|
|
@@ -902,27 +1006,34 @@ export class Conversation {
|
|
|
902
1006
|
*
|
|
903
1007
|
* @returns 等到回合结束 → 完整 result;没等到(wait=false 或等超时)→ 收据对象
|
|
904
1008
|
*/
|
|
905
|
-
async prompt(text, { reasoning = 'hide', timeoutMs = PROMPT_TIMEOUT_MS, run, wait = true } = {}) {
|
|
1009
|
+
async prompt(text, { reasoning = 'hide', timeoutMs = PROMPT_TIMEOUT_MS, run, wait = true, review } = {}) {
|
|
906
1010
|
if (this.busy) {
|
|
907
1011
|
throw new Error(
|
|
908
1012
|
`会话 ${this.id} 正在跑另一个回合。可以:dsh_interject 插话(打断并改口 / 排队),或 dsh_interrupt 打断。`,
|
|
909
1013
|
);
|
|
910
1014
|
}
|
|
911
|
-
const runRecord = run ?? this.createRun(text);
|
|
1015
|
+
const runRecord = run ?? this.createRun(text, { review });
|
|
1016
|
+
const reviewMode = runRecord.review_mode ?? normalizeReviewMode(review ?? this.review);
|
|
1017
|
+
runRecord.review_mode = reviewMode;
|
|
912
1018
|
runRecord.status = 'running';
|
|
913
1019
|
runRecord.started_at = now();
|
|
914
1020
|
this.currentRun = runRecord;
|
|
915
|
-
|
|
1021
|
+
let before = null;
|
|
916
1022
|
let proc;
|
|
917
1023
|
// ★ `busy` 必须在 ensureAlive **之前**置位。
|
|
918
1024
|
// 否则"把进程拉起来"这段(spawn + initialize + resume,可能好几秒)是一个**裸奔窗口**:
|
|
919
1025
|
// ① reaper 看到 busy=false 且 lastUsedAt 还是上一轮的时刻(已过期),会把这个**刚拉起来**
|
|
920
1026
|
// 的进程当空闲回收掉 → 紧接着 resume 就报"DSH 进程未运行"(test/cycle.mjs 实测抓到);
|
|
921
1027
|
// ② 第二个 dsh_send 同样能通过 busy 检查,于是为同一会话再拉一个子进程 → 互相撞锁 + 泄漏。
|
|
1028
|
+
// ★ 同理,git 快照的 await 也必须放在 busy 置位**之后**(见下面的 Promise.all)。
|
|
922
1029
|
this.busy = true;
|
|
923
1030
|
this.lastUsedAt = now();
|
|
1031
|
+
const beforePromise = gitStatus(this.cwd);
|
|
924
1032
|
try {
|
|
925
|
-
|
|
1033
|
+
// 快照与拉起进程并行:快照只是读盘,不改变生命周期,早取晚取语义一致。
|
|
1034
|
+
const [beforeSnap, liveProc] = await Promise.all([beforePromise, this.ensureAlive()]);
|
|
1035
|
+
before = beforeSnap;
|
|
1036
|
+
proc = liveProc;
|
|
926
1037
|
} catch (e) {
|
|
927
1038
|
// 起不来(撞写锁 / 会话损坏)就**必须放开 busy**,否则这个会话被永久占住;
|
|
928
1039
|
// 同时别留一条永远 "running" 的幽灵记录。
|
|
@@ -950,105 +1061,166 @@ export class Conversation {
|
|
|
950
1061
|
usage: null,
|
|
951
1062
|
plan: null,
|
|
952
1063
|
otherUpdates: [],
|
|
1064
|
+
/** 本回合被 auto-allow 放行的审批摘要(最多 MAX_AUTO_APPROVALS_KEPT 条)与总数。 */
|
|
1065
|
+
autoApprovals: [],
|
|
1066
|
+
autoApprovalTotal: 0,
|
|
1067
|
+
autoEscalationTotal: 0,
|
|
953
1068
|
};
|
|
954
1069
|
this.collector = c;
|
|
955
1070
|
const t0 = now();
|
|
956
1071
|
let settled = false;
|
|
957
1072
|
|
|
958
|
-
/**
|
|
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;
|
|
959
1104
|
const finalize = (stopReason, failure) => {
|
|
960
|
-
if (
|
|
1105
|
+
if (finalizePromise) return finalizePromise;
|
|
961
1106
|
settled = true;
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
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;
|
|
976
1203
|
}
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
answer: c.answer,
|
|
998
|
-
thinking: formatReasoning(c, reasoning),
|
|
999
|
-
thinking_stats: {
|
|
1000
|
-
hidden: reasoning === 'hide' || reasoning === 'marker',
|
|
1001
|
-
chars: c.reasoning.length,
|
|
1002
|
-
chunks: c.reasoningChunks,
|
|
1003
|
-
ms: c.reasoningStartedAt ? now() - c.reasoningStartedAt : 0,
|
|
1004
|
-
},
|
|
1005
|
-
tools_used: [...c.tools.values()],
|
|
1006
|
-
usage: c.usage,
|
|
1007
|
-
workspace_changed: !sameWorkspaceSnapshot(before, after),
|
|
1008
|
-
diff_stat: after,
|
|
1009
|
-
elapsed_ms: now() - t0,
|
|
1010
|
-
};
|
|
1011
|
-
runRecord.ended_at = now();
|
|
1012
|
-
runRecord.elapsed_ms = result.elapsed_ms;
|
|
1013
|
-
runRecord.status = failure ? 'error' : stopReason === 'cancelled' ? 'cancelled' : 'done';
|
|
1014
|
-
runRecord.result = result;
|
|
1015
|
-
// ★ 无条件写哨兵:**一个回合 = 一个哨兵文件**,没有例外。
|
|
1016
|
-
// 之前只在"调用方没在原地等"时才写,于是插话那一轮没有哨兵 —— 调用方的
|
|
1017
|
-
// 等待逻辑(等文件出现)就永远空等,即使 run 早已 status=done(真实事故)。
|
|
1018
|
-
// 统一规则也消除了整类"有 run_id 却没有哨兵"的坑。
|
|
1019
|
-
const sentinelFile = this.#hub.writeRunSentinel(this.id, runRecord);
|
|
1020
|
-
result.sentinel_file = sentinelFile ?? this.#hub.runSentinelPath(this.id, runRecord.run_id);
|
|
1021
|
-
if (!sentinelFile) result.sentinel_error = runRecord.sentinel_error ?? '哨兵文件写入失败';
|
|
1022
|
-
return result;
|
|
1023
|
-
} catch (e) {
|
|
1024
|
-
this.#hub.log.info(`[conv ${this.id}] 回合收尾异常(已兜底): ${e.message}`);
|
|
1025
|
-
this.busy = false;
|
|
1026
|
-
if (this.currentRun === runRecord) this.currentRun = null;
|
|
1027
|
-
this.collector = null;
|
|
1028
|
-
const waiters = this.idleWaiters;
|
|
1029
|
-
this.idleWaiters = [];
|
|
1030
|
-
for (const w of waiters) w();
|
|
1031
|
-
this.#scheduleDrain();
|
|
1032
|
-
const fallback = {
|
|
1033
|
-
conversation_id: this.id,
|
|
1034
|
-
run_id: runRecord.run_id,
|
|
1035
|
-
stop_reason: 'error',
|
|
1036
|
-
error: `回合收尾异常: ${e.message}`,
|
|
1037
|
-
answer: c.answer ?? '',
|
|
1038
|
-
thinking_stats: { hidden: true, chars: 0, chunks: 0, ms: 0 },
|
|
1039
|
-
tools_used: [...(c.tools?.values?.() ?? [])],
|
|
1040
|
-
workspace_changed: false,
|
|
1041
|
-
elapsed_ms: now() - t0,
|
|
1042
|
-
};
|
|
1043
|
-
runRecord.ended_at = now();
|
|
1044
|
-
runRecord.status = 'error';
|
|
1045
|
-
runRecord.error = fallback.error;
|
|
1046
|
-
runRecord.result = fallback;
|
|
1047
|
-
const sentinelFile = this.#hub.writeRunSentinel(this.id, runRecord);
|
|
1048
|
-
fallback.sentinel_file = sentinelFile ?? this.#hub.runSentinelPath(this.id, runRecord.run_id);
|
|
1049
|
-
if (!sentinelFile) fallback.sentinel_error = runRecord.sentinel_error ?? '哨兵文件写入失败';
|
|
1050
|
-
return fallback;
|
|
1051
|
-
}
|
|
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;
|
|
1052
1224
|
};
|
|
1053
1225
|
|
|
1054
1226
|
/** 没等到结果时给调用方的收据(它靠 run_id + 哨兵文件事后验收)。 */
|
|
@@ -1065,30 +1237,40 @@ export class Conversation {
|
|
|
1065
1237
|
|
|
1066
1238
|
// 关键:第三个参数传 0 = **不设超时**。回合只能由它自己结束,或由 session/cancel 结束。
|
|
1067
1239
|
const acp = proc.request('session/prompt', { sessionId: this.id, prompt: [{ type: 'text', text }] }, 0);
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
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
|
+
});
|
|
1075
1255
|
|
|
1076
|
-
if (!wait)
|
|
1256
|
+
if (!wait) {
|
|
1257
|
+
// 同步走到这里时 finalize 还没开始(ACP 回调在微任务里),返回收据即可。
|
|
1258
|
+
// 若极端情况下已经收尾,则等它给出完整结果,不要返回半成品。
|
|
1259
|
+
return settled ? await (finalizePromise ?? runRecord.result) : receipt(0);
|
|
1260
|
+
}
|
|
1077
1261
|
if (timeoutMs <= 0) {
|
|
1078
|
-
await
|
|
1079
|
-
() => {},
|
|
1080
|
-
() => {},
|
|
1081
|
-
);
|
|
1262
|
+
await finished;
|
|
1082
1263
|
return runRecord.result;
|
|
1083
1264
|
}
|
|
1084
1265
|
const timedOut = await Promise.race([
|
|
1085
|
-
|
|
1266
|
+
finished.then(
|
|
1086
1267
|
() => false,
|
|
1087
1268
|
() => false,
|
|
1088
1269
|
),
|
|
1089
1270
|
new Promise((r) => setTimeout(() => r(true), timeoutMs)),
|
|
1090
1271
|
]);
|
|
1091
|
-
|
|
1272
|
+
// 超时发生在 finalize 完成之前(包括 git 快照还在跑):给收据,不返回半成品结果。
|
|
1273
|
+
if (timedOut) return receipt(timeoutMs);
|
|
1092
1274
|
return runRecord.result;
|
|
1093
1275
|
}
|
|
1094
1276
|
|
|
@@ -1152,13 +1334,15 @@ export class Conversation {
|
|
|
1152
1334
|
}
|
|
1153
1335
|
|
|
1154
1336
|
/** 建一条回合记录。派活时先建,好让调用方立刻拿到 run_id(异步模式的关键)。 */
|
|
1155
|
-
createRun(text, { clientRunId = null } = {}) {
|
|
1337
|
+
createRun(text, { clientRunId = null, review = this.review } = {}) {
|
|
1156
1338
|
const run = {
|
|
1157
1339
|
run_id: `run-${Date.now().toString(36)}-${++this.runSeq}`,
|
|
1158
1340
|
conversation_id: this.id,
|
|
1159
1341
|
client_run_id: clientRunId ?? null,
|
|
1160
1342
|
prompt_preview: text.length > MAX_RUN_PREVIEW ? `${text.slice(0, MAX_RUN_PREVIEW)}…` : text,
|
|
1161
1343
|
status: 'queued',
|
|
1344
|
+
/** 本回合的审核策略(off / advisory);由 dsh_send / dsh_interject 或会话默认决定。 */
|
|
1345
|
+
review_mode: normalizeReviewMode(review, this.review),
|
|
1162
1346
|
started_at: null,
|
|
1163
1347
|
ended_at: null,
|
|
1164
1348
|
elapsed_ms: null,
|
|
@@ -1189,7 +1373,7 @@ export class Conversation {
|
|
|
1189
1373
|
// ── 会话级队列(忙时非阻塞入队)────────────────────────────────
|
|
1190
1374
|
|
|
1191
1375
|
/** 入队;返回 run 记录与 1-based 位置。队列只在内存,重启丢失。 */
|
|
1192
|
-
enqueue(text, { reasoning = 'hide', clientRunId = null, preview = null } = {}) {
|
|
1376
|
+
enqueue(text, { reasoning = 'hide', clientRunId = null, preview = null, review = this.review } = {}) {
|
|
1193
1377
|
const MAX_QUEUE = 8;
|
|
1194
1378
|
if (clientRunId) {
|
|
1195
1379
|
const existing = this.getRunByClientId(clientRunId);
|
|
@@ -1199,7 +1383,7 @@ export class Conversation {
|
|
|
1199
1383
|
throw new Error(`队列已满(最多 ${MAX_QUEUE} 条);先用 dsh_queue list 清理,或等当前回合结束`);
|
|
1200
1384
|
}
|
|
1201
1385
|
// 预览用调用方原文,执行用可能已注入团队前缀的 text。
|
|
1202
|
-
const run = this.createRun(preview ?? text, { clientRunId });
|
|
1386
|
+
const run = this.createRun(preview ?? text, { clientRunId, review });
|
|
1203
1387
|
run.status = 'queued';
|
|
1204
1388
|
this.queue.push({ run, text, reasoning, clientRunId, enqueued_at: now() });
|
|
1205
1389
|
return { run, position: this.queue.length, deduped: false };
|
|
@@ -1236,6 +1420,16 @@ export class Conversation {
|
|
|
1236
1420
|
workspace_changed: false,
|
|
1237
1421
|
elapsed_ms: 0,
|
|
1238
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
|
+
}
|
|
1239
1433
|
// 取消也是终态 → 写哨兵,避免等待方永远空等。
|
|
1240
1434
|
const file = this.#hub.writeRunSentinel(this.id, run);
|
|
1241
1435
|
run.result.sentinel_file = file ?? this.#hub.runSentinelPath(this.id, run.run_id);
|
|
@@ -1289,6 +1483,142 @@ export class Conversation {
|
|
|
1289
1483
|
return this.runs.length ? this.runs[this.runs.length - 1] : null;
|
|
1290
1484
|
}
|
|
1291
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
|
+
|
|
1292
1622
|
/** 等到当前回合结束(不忙则立即返回 true;`timeoutMs <= 0` 一直等;超时返回 false)。 */
|
|
1293
1623
|
async waitIdle(timeoutMs = 30 * 60 * 1000) {
|
|
1294
1624
|
if (!this.busy) return true;
|
|
@@ -1327,9 +1657,9 @@ export class Conversation {
|
|
|
1327
1657
|
* 之所以无竞态:dsh-acp 在让 prompt 请求返回**之前**就清空了 inflight 槽位,
|
|
1328
1658
|
* 且只在 agent 完全 idle 后才 settle(lib/index.js:1003-1011)。
|
|
1329
1659
|
*/
|
|
1330
|
-
async interject(message, { mode = 'interject', reasoning = 'hide', timeoutMs = PROMPT_TIMEOUT_MS, clientRunId = null } = {}) {
|
|
1660
|
+
async interject(message, { mode = 'interject', reasoning = 'hide', timeoutMs = PROMPT_TIMEOUT_MS, clientRunId = null, review } = {}) {
|
|
1331
1661
|
// 先建 run:幂等键要在"等待上一轮"之前就登记,否则重试会造出第二个 run。
|
|
1332
|
-
const run = this.createRun(message, { clientRunId });
|
|
1662
|
+
const run = this.createRun(message, { clientRunId, review });
|
|
1333
1663
|
if (!this.busy) {
|
|
1334
1664
|
const r = await this.prompt(message, { reasoning, timeoutMs, run });
|
|
1335
1665
|
return { ...r, interjected: 'idle', waited_ms: 0 };
|
|
@@ -1338,7 +1668,7 @@ export class Conversation {
|
|
|
1338
1668
|
if (mode === 'interject') {
|
|
1339
1669
|
this.proc?.notify('session/cancel', { sessionId: this.id });
|
|
1340
1670
|
}
|
|
1341
|
-
// 排队等待的时长跟随调用方的
|
|
1671
|
+
// 排队等待的时长跟随调用方的 timeoutMs(0 = 一直等),不再固定 30 分钟。
|
|
1342
1672
|
const idle = await this.waitIdle(timeoutMs);
|
|
1343
1673
|
const waited = now() - t0;
|
|
1344
1674
|
if (!idle) {
|
|
@@ -1358,6 +1688,17 @@ export class Conversation {
|
|
|
1358
1688
|
workspace_changed: false,
|
|
1359
1689
|
elapsed_ms: 0,
|
|
1360
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
|
+
}
|
|
1361
1702
|
const file = this.#hub.writeRunSentinel?.(this.id, run);
|
|
1362
1703
|
run.result.sentinel_file = file ?? this.#hub.runSentinelPath?.(this.id, run.run_id) ?? null;
|
|
1363
1704
|
throw err;
|
|
@@ -1423,30 +1764,178 @@ export class Conversation {
|
|
|
1423
1764
|
}
|
|
1424
1765
|
}
|
|
1425
1766
|
|
|
1426
|
-
/**
|
|
1767
|
+
/**
|
|
1768
|
+
* 两次 workspace 快照是否表示同一组工作区变更。
|
|
1769
|
+
*
|
|
1770
|
+
* 优先比较 `fingerprint`(覆盖**全量**条目:状态行 + 每个路径的 size/mtime,
|
|
1771
|
+
* 未跟踪目录递归展开)。只比较前 40 条 entries 会把"同一状态行但内容变了"
|
|
1772
|
+
* 和第 41 条之后的改动都漏掉(workspace_changed 假阴性)。
|
|
1773
|
+
*/
|
|
1427
1774
|
export function sameWorkspaceSnapshot(a, b) {
|
|
1428
1775
|
if (!a && !b) return true;
|
|
1429
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 }。
|
|
1430
1781
|
return JSON.stringify(a.entries ?? []) === JSON.stringify(b.entries ?? []);
|
|
1431
1782
|
}
|
|
1432
1783
|
|
|
1433
|
-
|
|
1434
|
-
|
|
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) {
|
|
1435
1884
|
if (process.env.DSH_MCP_GIT_STATUS === '0') return null;
|
|
1436
1885
|
try {
|
|
1437
1886
|
// ★ 在不可信仓库里 `git status` 可能触发仓库配置的 fsmonitor/filter;这些
|
|
1438
1887
|
// 命令发生在 DSH 沙箱与审批层之外。禁用可选锁与 fsmonitor,并允许用
|
|
1439
1888
|
// DSH_MCP_GIT_STATUS=0 完全关掉。
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
if (
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
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
|
+
};
|
|
1450
1939
|
} catch {
|
|
1451
1940
|
return null;
|
|
1452
1941
|
}
|
|
@@ -1511,9 +2000,26 @@ export function homeWorkspaceProblem(cwd, permission) {
|
|
|
1511
2000
|
if (!home) return null;
|
|
1512
2001
|
const caseInsensitive = process.platform === 'win32' || fsCaseInsensitive();
|
|
1513
2002
|
const norm = (p) => {
|
|
1514
|
-
|
|
2003
|
+
// 解析 symlink:一个指向家目录/根目录的软链不能绕过这两个边界。
|
|
2004
|
+
// 路径不存在时(测试里的 "some-project")回落到 resolve。
|
|
2005
|
+
let r;
|
|
2006
|
+
try {
|
|
2007
|
+
r = realpathSync(p);
|
|
2008
|
+
} catch {
|
|
2009
|
+
r = resolve(p);
|
|
2010
|
+
}
|
|
1515
2011
|
return caseInsensitive ? r.toLowerCase() : r;
|
|
1516
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
|
+
}
|
|
1517
2023
|
if (norm(cwd) !== norm(home)) return null;
|
|
1518
2024
|
|
|
1519
2025
|
const temp = tmpdir();
|
|
@@ -1557,6 +2063,7 @@ export class Hub {
|
|
|
1557
2063
|
// 看起来正常,但注册表/哨兵/锁登记全部丢失,异步等待永远醒不过来。
|
|
1558
2064
|
assertWritableDir(dirname(STATE_FILE), '状态目录');
|
|
1559
2065
|
assertWritableDir(RUNS_DIR, '哨兵目录');
|
|
2066
|
+
assertWritableDir(REVIEWS_DIR, '审核记录目录');
|
|
1560
2067
|
assertWritableDir(LOCKS_DIR, '锁登记目录');
|
|
1561
2068
|
|
|
1562
2069
|
// 旧版本的默认状态文件在包安装目录里;一次性迁移到新的用户可写目录。
|
|
@@ -1631,6 +2138,8 @@ export class Hub {
|
|
|
1631
2138
|
|
|
1632
2139
|
/** 某个 run 的完成哨兵文件路径。 */
|
|
1633
2140
|
runSentinelPath(conversationId, runId) {
|
|
2141
|
+
assertSafeId(conversationId, 'conversation_id');
|
|
2142
|
+
assertSafeId(runId, 'run_id');
|
|
1634
2143
|
return join(RUNS_DIR, conversationId, `${runId}.json`);
|
|
1635
2144
|
}
|
|
1636
2145
|
|
|
@@ -1666,6 +2175,34 @@ export class Hub {
|
|
|
1666
2175
|
}
|
|
1667
2176
|
}
|
|
1668
2177
|
|
|
2178
|
+
/**
|
|
2179
|
+
* 把调用方的审核裁决回写进该 run 的哨兵文件(best-effort)。
|
|
2180
|
+
*
|
|
2181
|
+
* 为什么回写:异步调用方收活主要靠读哨兵。若审核后哨兵仍写着 `review.status=pending`,
|
|
2182
|
+
* 下一次读文件会得到一个与事实相反的状态。原子重写不破坏闩锁语义:文件始终存在,
|
|
2183
|
+
* 内容要么是旧的完整版本,要么是新的完整版本。
|
|
2184
|
+
*/
|
|
2185
|
+
updateRunSentinelReview(conversationId, runId, review) {
|
|
2186
|
+
const file = this.runSentinelPath(conversationId, runId);
|
|
2187
|
+
try {
|
|
2188
|
+
const payload = JSON.parse(readFileSync(file, 'utf8'));
|
|
2189
|
+
const result = payload.result && typeof payload.result === 'object' ? payload.result : {};
|
|
2190
|
+
result.review = {
|
|
2191
|
+
...(result.review && typeof result.review === 'object' ? result.review : {}),
|
|
2192
|
+
...review,
|
|
2193
|
+
};
|
|
2194
|
+
payload.result = result;
|
|
2195
|
+
payload.reviewed_at = review.reviewed_at ?? null;
|
|
2196
|
+
const tmp = `${file}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`;
|
|
2197
|
+
writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8');
|
|
2198
|
+
renameSync(tmp, file);
|
|
2199
|
+
return true;
|
|
2200
|
+
} catch (e) {
|
|
2201
|
+
this.log.info(`[hub] 回写哨兵审核状态失败(${file}): ${e.message}`);
|
|
2202
|
+
return false;
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
|
|
1669
2206
|
/**
|
|
1670
2207
|
* 启动时清理过期哨兵(默认 7 天前)与崩溃残留的 .tmp 文件。
|
|
1671
2208
|
*
|
|
@@ -1673,39 +2210,16 @@ export class Hub {
|
|
|
1673
2210
|
* 只在服务启动时做一次,因此绝不会删掉"当前正在等待的"哨兵(那时它还不需要存在)。
|
|
1674
2211
|
*/
|
|
1675
2212
|
pruneSentinels({ ttlMs = SENTINEL_TTL_MS } = {}) {
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
const
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
const fp = join(dir, f.name);
|
|
1687
|
-
try {
|
|
1688
|
-
if (statSync(fp).mtimeMs < cutoff) {
|
|
1689
|
-
rmSync(fp, { force: true });
|
|
1690
|
-
pruned++;
|
|
1691
|
-
}
|
|
1692
|
-
} catch {
|
|
1693
|
-
/* 单个文件失败就跳过 */
|
|
1694
|
-
}
|
|
1695
|
-
}
|
|
1696
|
-
try {
|
|
1697
|
-
if (readdirSync(dir).length === 0) {
|
|
1698
|
-
rmSync(dir, { recursive: true, force: true });
|
|
1699
|
-
dirsRemoved++;
|
|
1700
|
-
}
|
|
1701
|
-
} catch {
|
|
1702
|
-
/* 目录清理失败无所谓 */
|
|
1703
|
-
}
|
|
1704
|
-
}
|
|
1705
|
-
} catch (e) {
|
|
1706
|
-
this.log.info(`[hub] 清理哨兵失败: ${e.message}`);
|
|
1707
|
-
}
|
|
1708
|
-
return { pruned, dirs_removed: dirsRemoved };
|
|
2213
|
+
const runs = pruneDirTree(RUNS_DIR, ttlMs);
|
|
2214
|
+
// 审核记录同样含正文/理由,按同一 TTL 清理;目录分开是为了语义清晰,
|
|
2215
|
+
// 也避免一次清理把"刚提交的裁决"和"已完成的哨兵"混为一谈。
|
|
2216
|
+
const reviews = pruneDirTree(REVIEWS_DIR, ttlMs);
|
|
2217
|
+
return {
|
|
2218
|
+
pruned: runs.pruned,
|
|
2219
|
+
dirs_removed: runs.dirs_removed,
|
|
2220
|
+
reviews_pruned: reviews.pruned,
|
|
2221
|
+
review_dirs_removed: reviews.dirs_removed,
|
|
2222
|
+
};
|
|
1709
2223
|
}
|
|
1710
2224
|
|
|
1711
2225
|
/** 删除某个 run 的哨兵(起跑前防御性清残留;调用方消费后也可自行删)。 */
|
|
@@ -1718,6 +2232,8 @@ export class Hub {
|
|
|
1718
2232
|
}
|
|
1719
2233
|
|
|
1720
2234
|
async get(id) {
|
|
2235
|
+
// 会话 id 会参与 meta/tombstone/会话存储的路径拼接;先挡住路径分隔符与 `..`。
|
|
2236
|
+
assertSafeId(id, 'conversation_id');
|
|
1721
2237
|
// ★ forget 是终态:墓碑存在时,任何按 id 的查询都必须明确失败,
|
|
1722
2238
|
// 否则 DSH 会话存储里的记录会让它悄悄复活("forget 不持久")。
|
|
1723
2239
|
if (isForgotten(id)) {
|
|
@@ -1793,6 +2309,7 @@ export class Hub {
|
|
|
1793
2309
|
createdAt: persisted?.createdAt,
|
|
1794
2310
|
lastUsedAt: persisted?.lastUsedAt,
|
|
1795
2311
|
turnCount: persisted?.turnCount,
|
|
2312
|
+
review: REVIEW_MODES.includes(persisted?.review) ? persisted.review : DEFAULT_REVIEW,
|
|
1796
2313
|
workspace: persisted?.workspace ?? null,
|
|
1797
2314
|
},
|
|
1798
2315
|
this,
|
|
@@ -1880,7 +2397,7 @@ export class Hub {
|
|
|
1880
2397
|
}
|
|
1881
2398
|
|
|
1882
2399
|
/** 新建一个会话:拉起进程 + ACP 初始化 + session/new。 */
|
|
1883
|
-
async create({ cwd, permission = DEFAULT_PERMISSION, onApproval, titleHint, reasoningEffort, provider, model, keepAlive = false } = {}) {
|
|
2400
|
+
async create({ cwd, permission = DEFAULT_PERMISSION, onApproval, titleHint, reasoningEffort, provider, model, keepAlive = false, review } = {}) {
|
|
1884
2401
|
if (!cwd) {
|
|
1885
2402
|
throw new Error(
|
|
1886
2403
|
'没有可用的工作目录。你既没传 cwd,客户端也没有提供当前打开的项目(MCP roots)。\n' +
|
|
@@ -1898,6 +2415,12 @@ export class Hub {
|
|
|
1898
2415
|
if (!PERMISSION_TIERS.includes(permission)) {
|
|
1899
2416
|
throw new Error(`permission 必须是 ${PERMISSION_TIERS.join(' | ')} 之一`);
|
|
1900
2417
|
}
|
|
2418
|
+
// title_hint 会写进注册表与侧车,每一轮 save() 都会重复落盘;不设上限就是一个
|
|
2419
|
+
// 稳定的磁盘/内存放大点(调用方可以传任意长度)。可读标签 200 字足够。
|
|
2420
|
+
if (titleHint !== undefined && titleHint !== null) {
|
|
2421
|
+
if (typeof titleHint !== 'string') throw new Error('title_hint 必须是字符串');
|
|
2422
|
+
if (titleHint.length > 200) throw new Error('title_hint 最长 200 字符');
|
|
2423
|
+
}
|
|
1901
2424
|
// ★ 0.5.0:danger-full-access 不再默认可用。它把整机写权限交给调用方,
|
|
1902
2425
|
// 而本服务可能被任何 MCP 客户端拉起;必须由机器所有者显式放行。
|
|
1903
2426
|
if (permission === 'danger-full-access' && !ALLOW_FULL_ACCESS) {
|
|
@@ -1922,6 +2445,10 @@ export class Hub {
|
|
|
1922
2445
|
if (!REASONING_EFFORTS.includes(effort)) {
|
|
1923
2446
|
throw new Error(`reasoning_effort 必须是 ${REASONING_EFFORTS.join(' | ')} 之一`);
|
|
1924
2447
|
}
|
|
2448
|
+
const reviewMode = normalizeReviewMode(review ?? DEFAULT_REVIEW);
|
|
2449
|
+
if (!REVIEW_MODES.includes(reviewMode)) {
|
|
2450
|
+
throw new Error(`review 必须是 ${REVIEW_MODES.join(' | ')} 之一`);
|
|
2451
|
+
}
|
|
1925
2452
|
if (model && !provider) {
|
|
1926
2453
|
throw new Error('指定 model 时必须同时给 provider(ACP 的模型标识是 [provider, model] 二元组)');
|
|
1927
2454
|
}
|
|
@@ -1957,6 +2484,7 @@ export class Hub {
|
|
|
1957
2484
|
reasoningEffort: effort,
|
|
1958
2485
|
provider: provider ?? null,
|
|
1959
2486
|
model: model ?? null,
|
|
2487
|
+
review: reviewMode,
|
|
1960
2488
|
},
|
|
1961
2489
|
this,
|
|
1962
2490
|
);
|