dsh-any-agent 0.3.8 → 0.4.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 +175 -2
- package/DESIGN.md +6 -6
- package/README.md +105 -27
- package/README.zh.md +90 -27
- package/USAGE-MIGRATION.en.md +7 -5
- package/USAGE-MIGRATION.md +3 -3
- package/USAGE-NOTES.en.md +4 -4
- package/USAGE-NOTES.md +9 -9
- package/bin/restore.mjs +6 -2
- package/bin/setup.mjs +6 -0
- package/package.json +1 -1
- package/src/acp.mjs +39 -13
- package/src/audit.mjs +32 -0
- package/src/bootstrap.mjs +26 -1
- package/src/clients.mjs +13 -2
- package/src/config.mjs +137 -6
- package/src/host.mjs +148 -0
- package/src/hub.mjs +508 -92
- package/src/line-decoder.mjs +41 -0
- package/src/locks.mjs +6 -1
- package/src/mcp.mjs +3 -6
- package/src/procstat.mjs +72 -0
- package/src/tools.mjs +546 -29
- package/src/workspace.mjs +18 -7
package/src/hub.mjs
CHANGED
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
IDLE_TTL_MS,
|
|
36
36
|
LIST_PROBE_TTL_MS,
|
|
37
37
|
MAX_LIVE,
|
|
38
|
+
MAX_RSS_MB,
|
|
38
39
|
PERMISSION_TIERS,
|
|
39
40
|
PROMPT_TIMEOUT_MS,
|
|
40
41
|
REAP_INTERVAL_MS,
|
|
@@ -42,6 +43,7 @@ import {
|
|
|
42
43
|
RUNS_DIR,
|
|
43
44
|
SENTINEL_INCLUDE_REASONING,
|
|
44
45
|
SENTINEL_TTL_MS,
|
|
46
|
+
SHARED_PROCESS,
|
|
45
47
|
STATE_FILE,
|
|
46
48
|
LEGACY_STATE_FILE,
|
|
47
49
|
USING_DEFAULT_STATE,
|
|
@@ -50,8 +52,10 @@ import {
|
|
|
50
52
|
defaultApprovalPolicy,
|
|
51
53
|
} from './config.mjs';
|
|
52
54
|
import { AcpProcess } from './acp.mjs';
|
|
55
|
+
import { SessionHost } from './host.mjs';
|
|
56
|
+
import { rssMb } from './procstat.mjs';
|
|
53
57
|
import { readConversationMeta } from './titles.mjs';
|
|
54
|
-
import { registerWorkspace,
|
|
58
|
+
import { registerWorkspace, findSessionHeaderStatus } from './workspace.mjs';
|
|
55
59
|
import {
|
|
56
60
|
LOCKS_DIR,
|
|
57
61
|
classifyHolder,
|
|
@@ -134,7 +138,7 @@ function isForgotten(id) {
|
|
|
134
138
|
* 留着只会让测试/临时会话在 `$DSH_HOME/.dsh-any-agent-forgotten` 里堆积。
|
|
135
139
|
* 会话文件还在的墓碑必须保留 —— 那正是 forget 的效力所在。
|
|
136
140
|
*/
|
|
137
|
-
async function pruneStaleTombstones() {
|
|
141
|
+
async function pruneStaleTombstones(log = NOOP_LOG) {
|
|
138
142
|
let removed = 0;
|
|
139
143
|
try {
|
|
140
144
|
if (!existsSync(FORGOTTEN_DIR)) return 0;
|
|
@@ -142,7 +146,13 @@ async function pruneStaleTombstones() {
|
|
|
142
146
|
if (!f.endsWith('.json')) continue;
|
|
143
147
|
const id = f.slice(0, -5);
|
|
144
148
|
// 异步读会话头:启动路径不能被大日志的同步解压卡住。
|
|
145
|
-
|
|
149
|
+
// ★ 只有确认会话文件不存在时才删墓碑;读超时/损坏时保留,
|
|
150
|
+
// 否则一次慢盘/挂载异常就会让 forget 失效。
|
|
151
|
+
const found = await findSessionHeaderStatus(id);
|
|
152
|
+
if (found.status === 'found' || found.status === 'error') {
|
|
153
|
+
if (found.status === 'error') log.info(`[hub] 墓碑 ${id} 暂不清理:${found.reason}`);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
146
156
|
try {
|
|
147
157
|
rmSync(join(FORGOTTEN_DIR, f), { force: true });
|
|
148
158
|
removed++;
|
|
@@ -247,8 +257,9 @@ async function applySessionConfig(proc, sessionId, cfg) {
|
|
|
247
257
|
*/
|
|
248
258
|
export function sanitizeResultForSentinel(result) {
|
|
249
259
|
if (!result || typeof result !== 'object') return result ?? null;
|
|
250
|
-
if (SENTINEL_INCLUDE_REASONING) return result;
|
|
251
260
|
const { thinking, ...rest } = result;
|
|
261
|
+
// ★ 即使 DSH_MCP_SENTINEL_INCLUDE_REASONING=1,也只额外保留 thinking;
|
|
262
|
+
// answer/error/title/tools_used 仍然必须脱敏(以前这个开关会跳过整段清洗)。
|
|
252
263
|
// 正文/错误/标题同样可能夹带密钥;哨兵是落盘副本,统一套脱敏。
|
|
253
264
|
for (const key of ['answer', 'error', 'title']) {
|
|
254
265
|
if (typeof rest[key] === 'string') rest[key] = sanitizeApprovalContext(rest[key], key);
|
|
@@ -264,7 +275,9 @@ export function sanitizeResultForSentinel(result) {
|
|
|
264
275
|
});
|
|
265
276
|
}
|
|
266
277
|
// 始终给出布尔值:调用方才能区分“思考被剥离”与“本来没有思考”。
|
|
267
|
-
|
|
278
|
+
const out = { ...rest, thinking_omitted: Boolean(thinking) && !SENTINEL_INCLUDE_REASONING };
|
|
279
|
+
if (SENTINEL_INCLUDE_REASONING && thinking !== undefined) out.thinking = thinking;
|
|
280
|
+
return out;
|
|
268
281
|
}
|
|
269
282
|
|
|
270
283
|
export class Conversation {
|
|
@@ -278,6 +291,8 @@ export class Conversation {
|
|
|
278
291
|
this.turnCount = state.turnCount ?? 0;
|
|
279
292
|
/** 建会话时给的提示词(仅在 DSH 还没生成原生标题前当兜底显示)。 */
|
|
280
293
|
this.titleHint = state.titleHint ?? null;
|
|
294
|
+
/** true = 不受空闲 TTL / MAX_LIVE 回收(用于"必须一直握住写锁"的长期会话)。 */
|
|
295
|
+
this.keepAlive = state.keepAlive === true;
|
|
281
296
|
/** 会话级 ACP 配置(思考深度 / 模型)。新进程不记得,resume 后要重套。 */
|
|
282
297
|
this.cfg = {
|
|
283
298
|
reasoningEffort: state.reasoningEffort ?? DEFAULT_REASONING_EFFORT,
|
|
@@ -293,11 +308,15 @@ export class Conversation {
|
|
|
293
308
|
|
|
294
309
|
this.proc = null;
|
|
295
310
|
this.busy = false;
|
|
311
|
+
/** 正在进行的进程回收(`proc.stop()` 的 promise)。非 null 时不得为同一会话再拉起进程。 */
|
|
312
|
+
this.recycling = null;
|
|
296
313
|
/** 本进程为这个会话拉起过的最后一个子进程 PID(登记被删后仍能归因锁的归属)。 */
|
|
297
314
|
this.lastChildPid = null;
|
|
298
315
|
/** 最近一次撞写锁的记录 `{at, holder_kind, reason, hint}`(见 snapshot())。 */
|
|
299
316
|
this.lockConflict = null;
|
|
300
317
|
this.pendingApprovals = new Map();
|
|
318
|
+
/** 正在跑的 run 记录(进程意外退出时要用它写 error 哨兵)。 */
|
|
319
|
+
this.currentRun = null;
|
|
301
320
|
/** 等待"当前回合结束"的回调队列,供插话排队使用。 */
|
|
302
321
|
this.idleWaiters = [];
|
|
303
322
|
|
|
@@ -320,6 +339,11 @@ export class Conversation {
|
|
|
320
339
|
*/
|
|
321
340
|
this.runs = [];
|
|
322
341
|
this.runSeq = 0;
|
|
342
|
+
/** 调用方提供的幂等键 → run_id(与 runs 同步淘汰)。 */
|
|
343
|
+
this.clientRuns = new Map();
|
|
344
|
+
/** 忙时入队的待执行任务(仅内存;重启丢失)。 */
|
|
345
|
+
this.queue = [];
|
|
346
|
+
this.draining = false;
|
|
323
347
|
/** 最近一次回合结果,仅内存(进程退出即消失),不落盘。 */
|
|
324
348
|
this.lastResult = null;
|
|
325
349
|
}
|
|
@@ -337,6 +361,7 @@ export class Conversation {
|
|
|
337
361
|
lastUsedAt: this.lastUsedAt,
|
|
338
362
|
turnCount: this.turnCount,
|
|
339
363
|
titleHint: this.titleHint,
|
|
364
|
+
keepAlive: this.keepAlive,
|
|
340
365
|
reasoningEffort: this.cfg.reasoningEffort,
|
|
341
366
|
provider: this.cfg.provider,
|
|
342
367
|
model: this.cfg.model,
|
|
@@ -376,6 +401,9 @@ export class Conversation {
|
|
|
376
401
|
on_approval: this.onApproval,
|
|
377
402
|
alive: !!this.proc?.alive,
|
|
378
403
|
busy: this.busy,
|
|
404
|
+
keep_alive: this.keepAlive,
|
|
405
|
+
/** 子进程 RSS(MB);量不到就是 null,不影响任何决策。 */
|
|
406
|
+
rss_mb: rssMb(this.proc?.pid),
|
|
379
407
|
/** running = 正在跑回合;idle = 进程活着但空闲;detached = 进程未打开(可 resume)。 */
|
|
380
408
|
state: this.state(),
|
|
381
409
|
current_tool: this.currentTool,
|
|
@@ -393,9 +421,13 @@ export class Conversation {
|
|
|
393
421
|
usage: meta?.usage?.totals ?? null,
|
|
394
422
|
context_pressure: meta?.pressure ?? null,
|
|
395
423
|
pending_approvals: [...this.pendingApprovals.keys()],
|
|
424
|
+
/** 忙时排队的任务(仅内存;重启丢失)。 */
|
|
425
|
+
queue_length: this.queue?.length ?? 0,
|
|
426
|
+
queue: this.queueList(),
|
|
396
427
|
// Keep the ID list compatible; details are ephemeral and excluded from toState().
|
|
397
428
|
pending_approval_details: [...this.pendingApprovals].map(([approvalId, pending]) => ({
|
|
398
429
|
approval_id: approvalId,
|
|
430
|
+
created_at: pending.createdAt ?? null,
|
|
399
431
|
...pending.params.details,
|
|
400
432
|
})),
|
|
401
433
|
/**
|
|
@@ -417,19 +449,30 @@ export class Conversation {
|
|
|
417
449
|
|
|
418
450
|
/** 确保有一个可用的 DSH 进程;必要时新建并 resume 回该会话。 */
|
|
419
451
|
async ensureAlive() {
|
|
452
|
+
// ★ 上一代进程正在回收时必须先等它停完:否则会为同一会话再拉起一个进程,
|
|
453
|
+
// 去抢一个**尚未释放**的写锁(reaper/release 里 await stop() 的那个窗口)。
|
|
454
|
+
if (this.recycling) await this.recycling;
|
|
420
455
|
if (this.proc?.alive) return this.proc;
|
|
421
|
-
const
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
456
|
+
const shared = SHARED_PROCESS;
|
|
457
|
+
let proc;
|
|
458
|
+
if (shared) {
|
|
459
|
+
proc = this.#hub.acquireHost(this.cwd, this.permission);
|
|
460
|
+
proc.attach(this);
|
|
461
|
+
this.proc = proc;
|
|
462
|
+
} else {
|
|
463
|
+
proc = new AcpProcess({
|
|
464
|
+
cwd: this.cwd,
|
|
465
|
+
permission: this.permission,
|
|
466
|
+
log: this.#hub.log,
|
|
467
|
+
onNotification: (params) => this.onUpdate(params),
|
|
468
|
+
onPermissionRequest: (params) => this.onPermissionRequest(params),
|
|
469
|
+
});
|
|
470
|
+
proc.start();
|
|
471
|
+
this.proc = proc;
|
|
472
|
+
}
|
|
473
|
+
// ★ lastChildPid **推迟到 resume 真正成功后**再写(见 writeMarker 处)。
|
|
474
|
+
// 若在这里就覆盖,一次注定撞锁失败的 resume 会把"上一代还活着的子进程"
|
|
475
|
+
// 这条抢占线索换成刚拉起、注定失败的新进程,takeover 就归因不到真正的持有者。
|
|
433
476
|
// ★ 登记**推迟到真正拿到写锁之后**(见下面 resume 成功处)。
|
|
434
477
|
// 登记的含义是"我正持有这个会话的写锁",不是"我拉起了一个进程"。
|
|
435
478
|
// 若在这里就写,一次注定撞锁失败的 resume 会先把"谁持有"的线索覆盖掉、
|
|
@@ -450,6 +493,8 @@ export class Conversation {
|
|
|
450
493
|
await this.verifyPermissionRecorded();
|
|
451
494
|
// 锁到手了,这时才登记;从此别人能查到"锁在我们手上"
|
|
452
495
|
writeMarker(this.id, { childPid: proc.pid, cwd: this.cwd });
|
|
496
|
+
// 只有真正拿到写锁后才更新"我拉起的最后一个子进程":失败路径需要保留上一代的 PID。
|
|
497
|
+
this.lastChildPid = proc.pid;
|
|
453
498
|
this.lockConflict = null; // 拿到锁了,之前的撞锁记录作废
|
|
454
499
|
this.#hub.log.info(`[conv ${this.id}] 已 resume`);
|
|
455
500
|
return proc;
|
|
@@ -457,14 +502,18 @@ export class Conversation {
|
|
|
457
502
|
// ★ 半死的进程必须作废:它 alive 但**没有加载这个会话**,一旦留在 this.proc 上,
|
|
458
503
|
// 下次调用会走上面 `this.proc?.alive` 的快路径,把 session/prompt 发给一个
|
|
459
504
|
// 不认识该会话的进程 —— 会话就永久卡死了(重派也不会自愈)。
|
|
460
|
-
|
|
505
|
+
// 只作废**自己这一次**拉起的进程:窄窗口里另一个调用可能已经换上了新一代。
|
|
506
|
+
if (this.proc === proc) this.proc = null;
|
|
461
507
|
// ★ 必须 **await**:不等它真的退出,紧接着的 classifyHolder 就会把"正在退出的自己人"
|
|
462
508
|
// 当成持锁者(表现为"上一代子进程还活着并握着锁"),于是每次失败都误报锁冲突 ——
|
|
463
509
|
// 更糟的是旧代码没有 lastChildPid 兜底,会把这种自伤报成"多半是你自己的 GUI",
|
|
464
510
|
// 而 dsh_takeover 对 none 又拒绝接管,会话就卡住了(test/cycle.mjs 实测抓到)。
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
511
|
+
const stopped = shared
|
|
512
|
+
? await proc.detach(this).catch(() => false)
|
|
513
|
+
: await proc.stop().catch(() => false);
|
|
514
|
+
// 只删**自己的**登记(removeMarker 会校验 token);进程没确认退出时保留登记,
|
|
515
|
+
// 让 classifyHolder/takeover 仍能归因。
|
|
516
|
+
if (stopped) removeMarker(this.id);
|
|
468
517
|
// 写锁冲突是最常见也最需要"说人话"的失败:DSH 的 SessionAlreadyOwnedError 会被
|
|
469
518
|
// ACP 包成 -32603 "Internal error",只说这句调用方根本不知道该怎么办。
|
|
470
519
|
if (isLeaseError(e.message)) {
|
|
@@ -500,11 +549,14 @@ export class Conversation {
|
|
|
500
549
|
*
|
|
501
550
|
* 为什么不能只信入参:DSH profile 里的 `permission.defaultPreset` 会在
|
|
502
551
|
* session/new 时覆盖 `DSH_PERMISSION_MODE`(test/permission.mjs 实测抓到过
|
|
503
|
-
* read-only
|
|
504
|
-
*
|
|
552
|
+
* read-only 被静默提升)。投影缓存可能稍后才刷盘;read-only 必须 fail-closed:
|
|
553
|
+
* 等待至少一个刷盘周期,仍读不到就拒绝继续,不能依赖“审批层兜底”
|
|
554
|
+
* (danger sandbox 下根本不会产生审批请求)。
|
|
505
555
|
*/
|
|
506
|
-
async verifyPermissionRecorded({ attempts
|
|
507
|
-
|
|
556
|
+
async verifyPermissionRecorded({ attempts, delayMs = 250 } = {}) {
|
|
557
|
+
// read-only 需要覆盖 DSH 约 5 秒的投影缓存刷新周期;其它档位保持较短等待。
|
|
558
|
+
const tries = Number.isFinite(attempts) ? attempts : this.permission === 'read-only' ? 40 : 6;
|
|
559
|
+
for (let i = 0; i < tries; i++) {
|
|
508
560
|
const meta = readConversationMeta(this.id);
|
|
509
561
|
const recorded = meta?.permissions?.preset;
|
|
510
562
|
if (recorded) {
|
|
@@ -519,11 +571,18 @@ export class Conversation {
|
|
|
519
571
|
this.permissionVerified = true;
|
|
520
572
|
return recorded;
|
|
521
573
|
}
|
|
522
|
-
if (i <
|
|
574
|
+
if (i < tries - 1) await new Promise((r) => setTimeout(r, delayMs));
|
|
523
575
|
}
|
|
524
576
|
this.permissionVerified = false;
|
|
577
|
+
if (this.permission === 'read-only') {
|
|
578
|
+
throw new Error(
|
|
579
|
+
`无法在 ${(tries * delayMs) / 1000}s 内确认会话实际为 read-only(投影缓存无权限记录)。\n` +
|
|
580
|
+
' 为避免 profile 的 defaultPreset 把只读会话静默提升为完全访问,本次调用已拒绝。\n' +
|
|
581
|
+
' 请确认 DSH 会话存储可读,或稍后重试;非只读档位不受此限制。',
|
|
582
|
+
);
|
|
583
|
+
}
|
|
525
584
|
this.#hub.log.info(
|
|
526
|
-
`[conv ${this.id}]
|
|
585
|
+
`[conv ${this.id}] 投影缓存里暂时没有权限记录,跳过运行期核对(非只读档)`,
|
|
527
586
|
);
|
|
528
587
|
return null;
|
|
529
588
|
}
|
|
@@ -565,7 +624,8 @@ export class Conversation {
|
|
|
565
624
|
report.holder_kind = holder.kind;
|
|
566
625
|
report.holder = holder.marker;
|
|
567
626
|
// 登记可能已随回收被删,但"我们自己的残留子进程"仍可归因(见 locks.classifyHolder)
|
|
568
|
-
const
|
|
627
|
+
const markerChild = holder.marker?.child_pid ?? null;
|
|
628
|
+
const killTarget = isPidAlive(markerChild) ? markerChild : (holder.known_child_pid ?? markerChild);
|
|
569
629
|
|
|
570
630
|
if (holder.kind === 'none' || !holder.childAlive) {
|
|
571
631
|
report.message =
|
|
@@ -712,6 +772,7 @@ export class Conversation {
|
|
|
712
772
|
}, this.#hub.approvalTimeoutMs);
|
|
713
773
|
this.pendingApprovals.set(approvalId, {
|
|
714
774
|
params: { tool: desc, details: approvalContext(params, toolCall, desc, options) },
|
|
775
|
+
createdAt: now(),
|
|
715
776
|
allowId,
|
|
716
777
|
rejectId,
|
|
717
778
|
resolve: (outcome) => {
|
|
@@ -728,8 +789,76 @@ export class Conversation {
|
|
|
728
789
|
if (!p) throw new Error(`没有待决审批: ${approvalId}`);
|
|
729
790
|
this.pendingApprovals.delete(approvalId);
|
|
730
791
|
const chosen = decision === 'allow' ? p.allowId : p.rejectId;
|
|
792
|
+
// ★ 没有对应 option 时实际发出的是 cancelled。必须把 effective 如实告诉调用方,
|
|
793
|
+
// 否则 dsh_approval_decide 会渲染成"已裁决为 allow",而 DSH 收到的是取消(假成功)。
|
|
794
|
+
const effective = chosen ? 'selected' : 'cancelled';
|
|
731
795
|
p.resolve(chosen ? { outcome: { outcome: 'selected', optionId: chosen } } : { outcome: { outcome: 'cancelled' } });
|
|
732
|
-
return {
|
|
796
|
+
return {
|
|
797
|
+
approval_id: approvalId,
|
|
798
|
+
decision,
|
|
799
|
+
effective,
|
|
800
|
+
...(chosen ? {} : { reason: 'no_option' }),
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* 起不来时的统一收尾:唤醒所有等本轮结束的插话请求,并落一条 error 哨兵。
|
|
806
|
+
*
|
|
807
|
+
* 这两件事必须都做、且互相独立:
|
|
808
|
+
* - 只写哨兵救不了 `dsh_interject`——它等的是 idleWaiters,不是文件;
|
|
809
|
+
* - 只唤醒不写哨兵,调用方事后 `dsh_get(run_id)` 就永远找不到这次失败。
|
|
810
|
+
*/
|
|
811
|
+
#settleFailedRun(runRecord, error) {
|
|
812
|
+
const waiters = this.idleWaiters;
|
|
813
|
+
this.idleWaiters = [];
|
|
814
|
+
for (const w of waiters) {
|
|
815
|
+
try {
|
|
816
|
+
w();
|
|
817
|
+
} catch {}
|
|
818
|
+
}
|
|
819
|
+
this.#scheduleDrain();
|
|
820
|
+
if (this.currentRun === runRecord) this.currentRun = null;
|
|
821
|
+
runRecord.ended_at = now();
|
|
822
|
+
runRecord.status = 'error';
|
|
823
|
+
runRecord.error = error?.message ?? String(error);
|
|
824
|
+
runRecord.elapsed_ms = 0;
|
|
825
|
+
runRecord.result = {
|
|
826
|
+
conversation_id: this.id,
|
|
827
|
+
run_id: runRecord.run_id,
|
|
828
|
+
stop_reason: 'error',
|
|
829
|
+
error: runRecord.error,
|
|
830
|
+
answer: '',
|
|
831
|
+
thinking_stats: { hidden: true, chars: 0, chunks: 0, ms: 0 },
|
|
832
|
+
tools_used: [],
|
|
833
|
+
workspace_changed: false,
|
|
834
|
+
elapsed_ms: 0,
|
|
835
|
+
};
|
|
836
|
+
try {
|
|
837
|
+
if (typeof this.#hub.writeRunSentinel === 'function') {
|
|
838
|
+
const file = this.#hub.writeRunSentinel(this.id, runRecord);
|
|
839
|
+
runRecord.result.sentinel_file = file ?? this.#hub.runSentinelPath?.(this.id, runRecord.run_id) ?? null;
|
|
840
|
+
if (!file) runRecord.result.sentinel_error = runRecord.sentinel_error ?? '哨兵文件写入失败';
|
|
841
|
+
}
|
|
842
|
+
} catch (e) {
|
|
843
|
+
runRecord.result.sentinel_error = `写完成哨兵失败: ${e.message}`;
|
|
844
|
+
}
|
|
845
|
+
try {
|
|
846
|
+
this.#hub.save?.();
|
|
847
|
+
} catch {}
|
|
848
|
+
return runRecord.result;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* 共享进程池的进程意外退出:把本会话在跑的回合标成失败并落哨兵,
|
|
853
|
+
* 然后解除 proc 引用,下次调用会重新 acquireHost + resume。
|
|
854
|
+
*/
|
|
855
|
+
onProcessExit(reason) {
|
|
856
|
+
this.proc = null;
|
|
857
|
+
this.recycling = null;
|
|
858
|
+
if (this.busy && this.currentRun) {
|
|
859
|
+
this.busy = false;
|
|
860
|
+
this.#settleFailedRun(this.currentRun, new Error(String(reason)));
|
|
861
|
+
}
|
|
733
862
|
}
|
|
734
863
|
|
|
735
864
|
// ── 跑任务 ──────────────────────────────────────────────────────
|
|
@@ -761,6 +890,7 @@ export class Conversation {
|
|
|
761
890
|
const runRecord = run ?? this.createRun(text);
|
|
762
891
|
runRecord.status = 'running';
|
|
763
892
|
runRecord.started_at = now();
|
|
893
|
+
this.currentRun = runRecord;
|
|
764
894
|
const before = gitStatus(this.cwd);
|
|
765
895
|
let proc;
|
|
766
896
|
// ★ `busy` 必须在 ensureAlive **之前**置位。
|
|
@@ -775,19 +905,13 @@ export class Conversation {
|
|
|
775
905
|
} catch (e) {
|
|
776
906
|
// 起不来(撞写锁 / 会话损坏)就**必须放开 busy**,否则这个会话被永久占住;
|
|
777
907
|
// 同时别留一条永远 "running" 的幽灵记录。
|
|
778
|
-
//
|
|
908
|
+
// ★ 与 finalize 对齐:失败也必须给等待者一个终态——唤醒插话队列 + 落 error 哨兵。
|
|
909
|
+
// 只写哨兵救不了 dsh_interject:它等的是 idleWaiters,不是文件(默认 timeout=0 无 timer)。
|
|
779
910
|
this.busy = false;
|
|
780
|
-
runRecord
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
runRecord.result
|
|
784
|
-
conversation_id: this.id,
|
|
785
|
-
run_id: runRecord.run_id,
|
|
786
|
-
stop_reason: 'error',
|
|
787
|
-
error: e.message,
|
|
788
|
-
answer: '',
|
|
789
|
-
elapsed_ms: 0,
|
|
790
|
-
};
|
|
911
|
+
this.#settleFailedRun(runRecord, e);
|
|
912
|
+
e.runId = runRecord.run_id;
|
|
913
|
+
e.status = 'error';
|
|
914
|
+
e.sentinelFile = runRecord.result?.sentinel_file ?? null;
|
|
791
915
|
throw e;
|
|
792
916
|
}
|
|
793
917
|
this.currentTurn += 1;
|
|
@@ -819,6 +943,7 @@ export class Conversation {
|
|
|
819
943
|
// 而且调用方会永远等不到哨兵。所以出错也要落一个"收尾异常"的哨兵。
|
|
820
944
|
try {
|
|
821
945
|
this.busy = false;
|
|
946
|
+
if (this.currentRun === runRecord) this.currentRun = null;
|
|
822
947
|
this.collector = null;
|
|
823
948
|
this.currentTool = null;
|
|
824
949
|
// 回合结束后,任何仍挂着的审批都已失效:统一取消并清空,避免状态里
|
|
@@ -841,6 +966,7 @@ export class Conversation {
|
|
|
841
966
|
const waiters = this.idleWaiters;
|
|
842
967
|
this.idleWaiters = [];
|
|
843
968
|
for (const w of waiters) w();
|
|
969
|
+
this.#scheduleDrain();
|
|
844
970
|
const after = gitStatus(this.cwd);
|
|
845
971
|
const result = {
|
|
846
972
|
conversation_id: this.id,
|
|
@@ -857,7 +983,7 @@ export class Conversation {
|
|
|
857
983
|
},
|
|
858
984
|
tools_used: [...c.tools.values()],
|
|
859
985
|
usage: c.usage,
|
|
860
|
-
workspace_changed: before
|
|
986
|
+
workspace_changed: !sameWorkspaceSnapshot(before, after),
|
|
861
987
|
diff_stat: after,
|
|
862
988
|
elapsed_ms: now() - t0,
|
|
863
989
|
};
|
|
@@ -876,10 +1002,12 @@ export class Conversation {
|
|
|
876
1002
|
} catch (e) {
|
|
877
1003
|
this.#hub.log.info(`[conv ${this.id}] 回合收尾异常(已兜底): ${e.message}`);
|
|
878
1004
|
this.busy = false;
|
|
1005
|
+
if (this.currentRun === runRecord) this.currentRun = null;
|
|
879
1006
|
this.collector = null;
|
|
880
1007
|
const waiters = this.idleWaiters;
|
|
881
1008
|
this.idleWaiters = [];
|
|
882
1009
|
for (const w of waiters) w();
|
|
1010
|
+
this.#scheduleDrain();
|
|
883
1011
|
const fallback = {
|
|
884
1012
|
conversation_id: this.id,
|
|
885
1013
|
run_id: runRecord.run_id,
|
|
@@ -1003,10 +1131,11 @@ export class Conversation {
|
|
|
1003
1131
|
}
|
|
1004
1132
|
|
|
1005
1133
|
/** 建一条回合记录。派活时先建,好让调用方立刻拿到 run_id(异步模式的关键)。 */
|
|
1006
|
-
createRun(text) {
|
|
1134
|
+
createRun(text, { clientRunId = null } = {}) {
|
|
1007
1135
|
const run = {
|
|
1008
1136
|
run_id: `run-${Date.now().toString(36)}-${++this.runSeq}`,
|
|
1009
1137
|
conversation_id: this.id,
|
|
1138
|
+
client_run_id: clientRunId ?? null,
|
|
1010
1139
|
prompt_preview: text.length > MAX_RUN_PREVIEW ? `${text.slice(0, MAX_RUN_PREVIEW)}…` : text,
|
|
1011
1140
|
status: 'queued',
|
|
1012
1141
|
started_at: null,
|
|
@@ -1015,12 +1144,118 @@ export class Conversation {
|
|
|
1015
1144
|
result: null,
|
|
1016
1145
|
};
|
|
1017
1146
|
this.runs.push(run);
|
|
1018
|
-
if (
|
|
1147
|
+
if (clientRunId) this.clientRuns.set(clientRunId, run.run_id);
|
|
1148
|
+
if (this.runs.length > MAX_RUNS) {
|
|
1149
|
+
const evicted = this.runs.shift();
|
|
1150
|
+
// 幂等键必须跟着 run 记录一起淘汰,否则会指向一个已经查不到的 run。
|
|
1151
|
+
for (const [k, v] of this.clientRuns) {
|
|
1152
|
+
if (v === evicted.run_id) this.clientRuns.delete(k);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1019
1155
|
// 防御性清残留:正常情况下该 run_id 从未用过,但确保等待方不会被历史哨兵误触发。
|
|
1020
1156
|
this.#hub.clearRunSentinel(this.id, run.run_id);
|
|
1021
1157
|
return run;
|
|
1022
1158
|
}
|
|
1023
1159
|
|
|
1160
|
+
/** 按调用方幂等键取 run(不存在/已淘汰返回 null)。 */
|
|
1161
|
+
getRunByClientId(clientRunId) {
|
|
1162
|
+
if (!clientRunId) return null;
|
|
1163
|
+
const runId = this.clientRuns.get(clientRunId);
|
|
1164
|
+
if (!runId) return null;
|
|
1165
|
+
return this.runs.find((r) => r.run_id === runId) ?? null;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
// ── 会话级队列(忙时非阻塞入队)────────────────────────────────
|
|
1169
|
+
|
|
1170
|
+
/** 入队;返回 run 记录与 1-based 位置。队列只在内存,重启丢失。 */
|
|
1171
|
+
enqueue(text, { reasoning = 'hide', clientRunId = null, preview = null } = {}) {
|
|
1172
|
+
const MAX_QUEUE = 8;
|
|
1173
|
+
if (clientRunId) {
|
|
1174
|
+
const existing = this.getRunByClientId(clientRunId);
|
|
1175
|
+
if (existing) return { run: existing, position: 0, deduped: true };
|
|
1176
|
+
}
|
|
1177
|
+
if (this.queue.length >= MAX_QUEUE) {
|
|
1178
|
+
throw new Error(`队列已满(最多 ${MAX_QUEUE} 条);先用 dsh_queue list 清理,或等当前回合结束`);
|
|
1179
|
+
}
|
|
1180
|
+
// 预览用调用方原文,执行用可能已注入团队前缀的 text。
|
|
1181
|
+
const run = this.createRun(preview ?? text, { clientRunId });
|
|
1182
|
+
run.status = 'queued';
|
|
1183
|
+
this.queue.push({ run, text, reasoning, clientRunId, enqueued_at: now() });
|
|
1184
|
+
return { run, position: this.queue.length, deduped: false };
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
queueList() {
|
|
1188
|
+
// 防御:snapshot 可能被测试用 prototype 替身调用(没有构造器初始化 queue)。
|
|
1189
|
+
return (this.queue ?? []).map((item, i) => ({
|
|
1190
|
+
position: i + 1,
|
|
1191
|
+
run_id: item.run.run_id,
|
|
1192
|
+
preview: item.run.prompt_preview,
|
|
1193
|
+
enqueued_at: item.enqueued_at,
|
|
1194
|
+
client_run_id: item.clientRunId ?? null,
|
|
1195
|
+
}));
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
queueRemove(runId, { cancel = true } = {}) {
|
|
1199
|
+
const i = this.queue.findIndex((x) => x.run.run_id === runId);
|
|
1200
|
+
if (i < 0) throw new Error(`队列里没有 ${runId}`);
|
|
1201
|
+
const [item] = this.queue.splice(i, 1);
|
|
1202
|
+
if (cancel) {
|
|
1203
|
+
const run = item.run;
|
|
1204
|
+
run.status = 'cancelled';
|
|
1205
|
+
run.ended_at = now();
|
|
1206
|
+
run.elapsed_ms = 0;
|
|
1207
|
+
run.result = {
|
|
1208
|
+
conversation_id: this.id,
|
|
1209
|
+
run_id: run.run_id,
|
|
1210
|
+
stop_reason: 'cancelled',
|
|
1211
|
+
error: 'queued run cancelled before it started',
|
|
1212
|
+
answer: '',
|
|
1213
|
+
thinking_stats: { hidden: true, chars: 0, chunks: 0, ms: 0 },
|
|
1214
|
+
tools_used: [],
|
|
1215
|
+
workspace_changed: false,
|
|
1216
|
+
elapsed_ms: 0,
|
|
1217
|
+
};
|
|
1218
|
+
// 取消也是终态 → 写哨兵,避免等待方永远空等。
|
|
1219
|
+
const file = this.#hub.writeRunSentinel(this.id, run);
|
|
1220
|
+
run.result.sentinel_file = file ?? this.#hub.runSentinelPath(this.id, run.run_id);
|
|
1221
|
+
}
|
|
1222
|
+
return { removed: true, run_id: runId };
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
queueUpdate(runId, text) {
|
|
1226
|
+
const item = this.queue.find((x) => x.run.run_id === runId);
|
|
1227
|
+
if (!item) throw new Error(`队列里没有 ${runId}`);
|
|
1228
|
+
item.text = text;
|
|
1229
|
+
item.run.prompt_preview =
|
|
1230
|
+
text.length > MAX_RUN_PREVIEW ? `${text.slice(0, MAX_RUN_PREVIEW)}…` : text;
|
|
1231
|
+
return { updated: true, run_id: runId };
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
queuePromote(runId) {
|
|
1235
|
+
const i = this.queue.findIndex((x) => x.run.run_id === runId);
|
|
1236
|
+
if (i < 0) throw new Error(`队列里没有 ${runId}`);
|
|
1237
|
+
const [item] = this.queue.splice(i, 1);
|
|
1238
|
+
this.queue.unshift(item);
|
|
1239
|
+
return { promoted: true, run_id: runId, position: 1 };
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/** 空闲时取队首执行。失败也要继续下一条(否则一条坏任务卡死整条队列)。 */
|
|
1243
|
+
#scheduleDrain() {
|
|
1244
|
+
if (this.busy || this.draining || this.queue.length === 0) return;
|
|
1245
|
+
this.draining = true;
|
|
1246
|
+
const timer = setTimeout(() => {
|
|
1247
|
+
this.draining = false;
|
|
1248
|
+
if (this.busy || this.queue.length === 0) return;
|
|
1249
|
+
const item = this.queue.shift();
|
|
1250
|
+
const run = item.run;
|
|
1251
|
+
run.status = 'running';
|
|
1252
|
+
this.prompt(item.text, { reasoning: item.reasoning, run, wait: false })
|
|
1253
|
+
.catch(() => {})
|
|
1254
|
+
.finally(() => this.#scheduleDrain());
|
|
1255
|
+
}, 0);
|
|
1256
|
+
timer.unref?.();
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1024
1259
|
/** 取回合记录;不传 run_id 就是最近一条。 */
|
|
1025
1260
|
getRun(runId) {
|
|
1026
1261
|
if (runId) {
|
|
@@ -1071,9 +1306,11 @@ export class Conversation {
|
|
|
1071
1306
|
* 之所以无竞态:dsh-acp 在让 prompt 请求返回**之前**就清空了 inflight 槽位,
|
|
1072
1307
|
* 且只在 agent 完全 idle 后才 settle(lib/index.js:1003-1011)。
|
|
1073
1308
|
*/
|
|
1074
|
-
async interject(message, { mode = 'interject', reasoning = 'hide', timeoutMs = PROMPT_TIMEOUT_MS } = {}) {
|
|
1309
|
+
async interject(message, { mode = 'interject', reasoning = 'hide', timeoutMs = PROMPT_TIMEOUT_MS, clientRunId = null } = {}) {
|
|
1310
|
+
// 先建 run:幂等键要在"等待上一轮"之前就登记,否则重试会造出第二个 run。
|
|
1311
|
+
const run = this.createRun(message, { clientRunId });
|
|
1075
1312
|
if (!this.busy) {
|
|
1076
|
-
const r = await this.prompt(message, { reasoning, timeoutMs });
|
|
1313
|
+
const r = await this.prompt(message, { reasoning, timeoutMs, run });
|
|
1077
1314
|
return { ...r, interjected: 'idle', waited_ms: 0 };
|
|
1078
1315
|
}
|
|
1079
1316
|
const t0 = now();
|
|
@@ -1083,20 +1320,61 @@ export class Conversation {
|
|
|
1083
1320
|
// 排队等待的时长跟随调用方的 timeout_ms(0 = 一直等),不再固定 30 分钟。
|
|
1084
1321
|
const idle = await this.waitIdle(timeoutMs);
|
|
1085
1322
|
const waited = now() - t0;
|
|
1086
|
-
if (!idle)
|
|
1087
|
-
|
|
1323
|
+
if (!idle) {
|
|
1324
|
+
const err = new Error('等待当前回合结束超时,插话未发送');
|
|
1325
|
+
err.runId = run.run_id;
|
|
1326
|
+
run.status = 'cancelled';
|
|
1327
|
+
run.ended_at = now();
|
|
1328
|
+
run.elapsed_ms = 0;
|
|
1329
|
+
run.result = {
|
|
1330
|
+
conversation_id: this.id,
|
|
1331
|
+
run_id: run.run_id,
|
|
1332
|
+
stop_reason: 'cancelled',
|
|
1333
|
+
error: err.message,
|
|
1334
|
+
answer: '',
|
|
1335
|
+
thinking_stats: { hidden: true, chars: 0, chunks: 0, ms: 0 },
|
|
1336
|
+
tools_used: [],
|
|
1337
|
+
workspace_changed: false,
|
|
1338
|
+
elapsed_ms: 0,
|
|
1339
|
+
};
|
|
1340
|
+
const file = this.#hub.writeRunSentinel?.(this.id, run);
|
|
1341
|
+
run.result.sentinel_file = file ?? this.#hub.runSentinelPath?.(this.id, run.run_id) ?? null;
|
|
1342
|
+
throw err;
|
|
1343
|
+
}
|
|
1344
|
+
const r = await this.prompt(message, { reasoning, timeoutMs, run });
|
|
1088
1345
|
return { ...r, interjected: mode, waited_ms: waited };
|
|
1089
1346
|
}
|
|
1090
1347
|
|
|
1091
1348
|
/** 关闭会话(进程收尾;会话本体留在磁盘上,可再次 resume)。 */
|
|
1092
1349
|
async close({ forget = false } = {}) {
|
|
1350
|
+
if (this.recycling) await this.recycling;
|
|
1093
1351
|
const proc = this.proc;
|
|
1094
|
-
|
|
1352
|
+
let stopped = true;
|
|
1095
1353
|
if (proc?.alive) {
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1354
|
+
if (typeof proc.detach === 'function') {
|
|
1355
|
+
// 共享进程:只把本会话摘出去(detach 内会 session/close);最后一个走了才停进程。
|
|
1356
|
+
this.recycling = proc.detach(this).finally(() => {
|
|
1357
|
+
if (this.proc === proc) this.proc = null;
|
|
1358
|
+
this.recycling = null;
|
|
1359
|
+
});
|
|
1360
|
+
stopped = await this.recycling;
|
|
1361
|
+
} else {
|
|
1362
|
+
try {
|
|
1363
|
+
await proc.request('session/close', { sessionId: this.id }, 10_000);
|
|
1364
|
+
} catch {}
|
|
1365
|
+
// 与 reaper 相同:stop 期间保持 proc 可见并登记 recycling,
|
|
1366
|
+
// 落定后再置空——否则并发 dsh_send 会拿到垂死进程或去抢尚未释放的锁。
|
|
1367
|
+
this.recycling = proc
|
|
1368
|
+
.stop()
|
|
1369
|
+
.catch(() => false)
|
|
1370
|
+
.finally(() => {
|
|
1371
|
+
if (this.proc === proc) this.proc = null;
|
|
1372
|
+
this.recycling = null;
|
|
1373
|
+
});
|
|
1374
|
+
stopped = await this.recycling;
|
|
1375
|
+
}
|
|
1376
|
+
} else if (this.proc === proc) {
|
|
1377
|
+
this.proc = null;
|
|
1100
1378
|
}
|
|
1101
1379
|
// 回合已经结束/进程正在关闭,任何仍挂着的审批都不再可裁决。
|
|
1102
1380
|
for (const [, p] of this.pendingApprovals) {
|
|
@@ -1105,18 +1383,32 @@ export class Conversation {
|
|
|
1105
1383
|
} catch {}
|
|
1106
1384
|
}
|
|
1107
1385
|
this.pendingApprovals.clear();
|
|
1108
|
-
//
|
|
1109
|
-
|
|
1386
|
+
// 只有确认进程退出后才删登记;否则把登记留着,让 takeover/classifyHolder
|
|
1387
|
+
// 还能把这个仍握着写锁的孤儿归因出来。
|
|
1388
|
+
if (stopped) removeMarker(this.id);
|
|
1389
|
+
else {
|
|
1390
|
+
this.#hub.log.info(`[conv ${this.id}] 进程未能确认退出,保留写锁登记以便后续归因`);
|
|
1391
|
+
// 进程还活着时不能 forget:墓碑会让按 id 查询直接失败,而活着的进程
|
|
1392
|
+
// 仍握着写锁,之后连 takeover 都无法归因/接管。
|
|
1393
|
+
if (forget) return { closed: false, forgotten: false, error: '进程未能确认退出,拒绝 forget' };
|
|
1394
|
+
}
|
|
1110
1395
|
if (forget) {
|
|
1111
1396
|
// 先写墓碑再从注册表移除:之后按 id 查询会明确报"已 forget",
|
|
1112
1397
|
// 而不是从 DSH 会话存储把它重建回来(否则 forget 形同虚设)。
|
|
1113
1398
|
writeTombstone(this.id);
|
|
1114
1399
|
this.#hub.remove(this.id);
|
|
1115
1400
|
} else this.#hub.save();
|
|
1116
|
-
return { closed:
|
|
1401
|
+
return { closed: stopped, forgotten: forget, ...(stopped ? {} : { error: '进程未能确认退出,登记已保留' }) };
|
|
1117
1402
|
}
|
|
1118
1403
|
}
|
|
1119
1404
|
|
|
1405
|
+
/** 两次 git status 快照是否表示同一组工作区变更。 */
|
|
1406
|
+
export function sameWorkspaceSnapshot(a, b) {
|
|
1407
|
+
if (!a && !b) return true;
|
|
1408
|
+
if (!a || !b) return false;
|
|
1409
|
+
return JSON.stringify(a.entries ?? []) === JSON.stringify(b.entries ?? []);
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1120
1412
|
/** 收集当前工作区的 git 变更摘要(非 git 目录返回 null)。 */
|
|
1121
1413
|
export function gitStatus(cwd) {
|
|
1122
1414
|
if (process.env.DSH_MCP_GIT_STATUS === '0') return null;
|
|
@@ -1196,6 +1488,8 @@ export function homeWorkspaceProblem(cwd, permission) {
|
|
|
1196
1488
|
export class Hub {
|
|
1197
1489
|
constructor({ log } = {}) {
|
|
1198
1490
|
this.conversations = new Map();
|
|
1491
|
+
/** 共享 ACP 进程池:key = `${cwd}\0${permission}`(实验性,DSH_MCP_SHARED_PROCESS=1)。 */
|
|
1492
|
+
this.hosts = new Map();
|
|
1199
1493
|
this.log = log ?? NOOP_LOG;
|
|
1200
1494
|
/** 最近一次 dsh_list 磁盘探测的结果:{ok, error, cached, at}。 */
|
|
1201
1495
|
this.lastProbe = { ok: null, error: null, cached: false, at: 0 };
|
|
@@ -1207,6 +1501,8 @@ export class Hub {
|
|
|
1207
1501
|
}
|
|
1208
1502
|
|
|
1209
1503
|
#reaper;
|
|
1504
|
+
/** 同一 id 的磁盘接管 in-flight Promise,防止并发请求各自构造一个 Conversation。 */
|
|
1505
|
+
#adopting = new Map();
|
|
1210
1506
|
/** `dsh_list` 磁盘探测的短缓存(探测要 spawn 一个 DSH 进程,实测约 1 秒)。 */
|
|
1211
1507
|
#probeCache = null;
|
|
1212
1508
|
|
|
@@ -1235,7 +1531,7 @@ export class Hub {
|
|
|
1235
1531
|
}
|
|
1236
1532
|
}
|
|
1237
1533
|
|
|
1238
|
-
const stale = await pruneStaleTombstones();
|
|
1534
|
+
const stale = await pruneStaleTombstones(this.log);
|
|
1239
1535
|
if (stale) this.log.info(`[hub] 清理 ${stale} 个失效的 forget 墓碑`);
|
|
1240
1536
|
if (!existsSync(STATE_FILE)) return;
|
|
1241
1537
|
try {
|
|
@@ -1306,6 +1602,7 @@ export class Hub {
|
|
|
1306
1602
|
const payload = {
|
|
1307
1603
|
conversation_id: conversationId,
|
|
1308
1604
|
run_id: run.run_id,
|
|
1605
|
+
client_run_id: run.client_run_id ?? null,
|
|
1309
1606
|
status: run.status ?? 'unknown',
|
|
1310
1607
|
ended_at: run.ended_at ?? Date.now(),
|
|
1311
1608
|
error: sanitizeApprovalContext(run.error ?? run.result?.error ?? null, 'error'),
|
|
@@ -1390,10 +1687,26 @@ export class Hub {
|
|
|
1390
1687
|
this.adoptNewFromDisk();
|
|
1391
1688
|
c = this.conversations.get(id);
|
|
1392
1689
|
}
|
|
1690
|
+
if (!c) {
|
|
1691
|
+
// 同一个磁盘态会话的并发查询共用一次 adopt,避免各自构造 Conversation。
|
|
1692
|
+
const inflight = this.#adopting.get(id);
|
|
1693
|
+
if (inflight) {
|
|
1694
|
+
await inflight;
|
|
1695
|
+
c = this.conversations.get(id);
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1393
1698
|
if (!c) {
|
|
1394
1699
|
// 最后一道:注册表里没有,但会话可能就在磁盘上 —— 别的实例建的、注册表被并发写覆盖丢的、
|
|
1395
1700
|
// 或者根本是别的 profile / GUI 建的。**会话存储才是权威,注册表只是缓存。**
|
|
1396
|
-
|
|
1701
|
+
let release;
|
|
1702
|
+
const gate = new Promise((resolve) => { release = resolve; });
|
|
1703
|
+
this.#adopting.set(id, gate);
|
|
1704
|
+
try {
|
|
1705
|
+
const found = await findSessionHeaderStatus(id);
|
|
1706
|
+
if (found.status === 'error') {
|
|
1707
|
+
throw new Error(`无法读取会话 ${id} 的会话头:${found.reason}`);
|
|
1708
|
+
}
|
|
1709
|
+
const header = found.header;
|
|
1397
1710
|
if (header?.cwd) {
|
|
1398
1711
|
// ★ 权限档必须取自**会话自己的记录**,不能一律套默认值:
|
|
1399
1712
|
// 一个当初用 read-only 建的会话,若注册表丢了、被我们以默认档接管,
|
|
@@ -1442,6 +1755,10 @@ export class Hub {
|
|
|
1442
1755
|
this.save();
|
|
1443
1756
|
this.log.info(`[hub] 从会话存储吸收了 ${id}(注册表里没有,但磁盘上有;权限档沿用 ${permission})`);
|
|
1444
1757
|
}
|
|
1758
|
+
} finally {
|
|
1759
|
+
this.#adopting.delete(id);
|
|
1760
|
+
release();
|
|
1761
|
+
}
|
|
1445
1762
|
}
|
|
1446
1763
|
if (!c) {
|
|
1447
1764
|
throw new Error(
|
|
@@ -1482,8 +1799,25 @@ export class Hub {
|
|
|
1482
1799
|
this.save();
|
|
1483
1800
|
}
|
|
1484
1801
|
|
|
1802
|
+
/** 取(或建)同 `(cwd, permission)` 的共享 ACP 进程宿主。 */
|
|
1803
|
+
acquireHost(cwd, permission) {
|
|
1804
|
+
const key = `${cwd}\u0000${permission}`;
|
|
1805
|
+
let host = this.hosts.get(key);
|
|
1806
|
+
if (!host) {
|
|
1807
|
+
host = new SessionHost({ cwd, permission, log: this.log, hub: this });
|
|
1808
|
+
this.hosts.set(key, host);
|
|
1809
|
+
}
|
|
1810
|
+
return host;
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
forgetHost(host) {
|
|
1814
|
+
for (const [k, v] of this.hosts) {
|
|
1815
|
+
if (v === host) this.hosts.delete(k);
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1485
1819
|
/** 新建一个会话:拉起进程 + ACP 初始化 + session/new。 */
|
|
1486
|
-
async create({ cwd, permission = DEFAULT_PERMISSION, onApproval, titleHint, reasoningEffort, provider, model } = {}) {
|
|
1820
|
+
async create({ cwd, permission = DEFAULT_PERMISSION, onApproval, titleHint, reasoningEffort, provider, model, keepAlive = false } = {}) {
|
|
1487
1821
|
if (!cwd) {
|
|
1488
1822
|
throw new Error(
|
|
1489
1823
|
'没有可用的工作目录。你既没传 cwd,客户端也没有提供当前打开的项目(MCP roots)。\n' +
|
|
@@ -1524,26 +1858,31 @@ export class Hub {
|
|
|
1524
1858
|
// 与其让用户以为服务坏了,不如在这里把原因和出路一起说清楚。
|
|
1525
1859
|
const homeProblem = homeWorkspaceProblem(cwd, permission);
|
|
1526
1860
|
if (homeProblem) throw new Error(homeProblem);
|
|
1527
|
-
const
|
|
1528
|
-
|
|
1529
|
-
permission
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1861
|
+
const shared = SHARED_PROCESS;
|
|
1862
|
+
const proc = shared
|
|
1863
|
+
? this.acquireHost(cwd, permission)
|
|
1864
|
+
: new AcpProcess({
|
|
1865
|
+
cwd,
|
|
1866
|
+
permission,
|
|
1867
|
+
log: this.log,
|
|
1868
|
+
onNotification: () => {},
|
|
1869
|
+
onPermissionRequest: () => ({ outcome: { outcome: 'cancelled' } }),
|
|
1870
|
+
});
|
|
1871
|
+
if (!shared) proc.start();
|
|
1535
1872
|
let createdSessionId = null;
|
|
1873
|
+
let conv = null;
|
|
1536
1874
|
try {
|
|
1537
1875
|
await proc.initialize();
|
|
1538
1876
|
const res = await proc.request('session/new', { cwd, mcpServers: [] });
|
|
1539
1877
|
createdSessionId = res.sessionId;
|
|
1540
|
-
|
|
1878
|
+
conv = new Conversation(
|
|
1541
1879
|
{
|
|
1542
1880
|
id: res.sessionId,
|
|
1543
1881
|
cwd,
|
|
1544
1882
|
permission,
|
|
1545
1883
|
onApproval: policy,
|
|
1546
1884
|
titleHint,
|
|
1885
|
+
keepAlive: keepAlive === true,
|
|
1547
1886
|
reasoningEffort: effort,
|
|
1548
1887
|
provider: provider ?? null,
|
|
1549
1888
|
model: model ?? null,
|
|
@@ -1555,10 +1894,13 @@ export class Hub {
|
|
|
1555
1894
|
// ★ 新建会话的进程也要登记持有者 —— 走的是 Hub.create 这条独立路径,
|
|
1556
1895
|
// 不登记的话 dsh_takeover 会把"本服务自己持有的会话"误判成 none 而拒绝抢占。
|
|
1557
1896
|
writeMarker(res.sessionId, { childPid: proc.pid, cwd });
|
|
1558
|
-
proc.
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1897
|
+
if (shared) proc.attach(conv);
|
|
1898
|
+
else {
|
|
1899
|
+
proc.attachRoutes({
|
|
1900
|
+
onNotification: (params) => conv.onUpdate(params),
|
|
1901
|
+
onPermissionRequest: (params) => conv.onPermissionRequest(params),
|
|
1902
|
+
});
|
|
1903
|
+
}
|
|
1562
1904
|
// 套用会话级配置(思考深度默认 max;ACP 自己的默认是 Provider default)
|
|
1563
1905
|
conv.lastConfigApplied = await applySessionConfig(proc, res.sessionId, conv.cfg);
|
|
1564
1906
|
// ★ 无效的 provider/model 必须 fail fast:旧行为是建出会话、静默回退
|
|
@@ -1593,8 +1935,12 @@ export class Hub {
|
|
|
1593
1935
|
// 失败路径也要清掉写锁登记:session/new 可能已经成功、登记已写入,
|
|
1594
1936
|
// 但会话最终没有注册(例如无效 provider/model 被 fail-fast)。留下 marker
|
|
1595
1937
|
// 会被 dsh_list/dsh_takeover 误报成“孤儿持有者”,也会在 locks 目录里积垃圾。
|
|
1596
|
-
|
|
1597
|
-
|
|
1938
|
+
const stopped = shared
|
|
1939
|
+
? conv
|
|
1940
|
+
? await proc.detach(conv).catch(() => false)
|
|
1941
|
+
: await proc.stop().catch(() => false)
|
|
1942
|
+
: await proc.stop().catch(() => false);
|
|
1943
|
+
if (createdSessionId && stopped) removeMarker(createdSessionId);
|
|
1598
1944
|
throw e;
|
|
1599
1945
|
}
|
|
1600
1946
|
}
|
|
@@ -1710,30 +2056,75 @@ export class Hub {
|
|
|
1710
2056
|
reapable.push(c);
|
|
1711
2057
|
}
|
|
1712
2058
|
|
|
1713
|
-
// 回收哪些:① 空闲超过 TTL
|
|
2059
|
+
// 回收哪些:① 空闲超过 TTL;② 超过 MAX_LIVE;③ 超过全局 RSS 预算。
|
|
2060
|
+
// keep_alive 会话不受 ① ② 约束;③ 是系统级硬上限,最后仍可能回收它。
|
|
1714
2061
|
const victims = new Set();
|
|
2062
|
+
const reapableNormal = reapable.filter((c) => !c.keepAlive);
|
|
1715
2063
|
if (IDLE_TTL_MS > 0) {
|
|
1716
|
-
for (const c of
|
|
2064
|
+
for (const c of reapableNormal) if (c.lastUsedAt <= cutoff) victims.add(c);
|
|
1717
2065
|
}
|
|
1718
2066
|
if (MAX_LIVE > 0) {
|
|
1719
2067
|
// 保活窗口 = 最近用过的 MAX_LIVE 个;超出的按"最久没用"先回收
|
|
1720
|
-
const byAge =
|
|
2068
|
+
const byAge = reapableNormal.slice().sort((a, b) => a.lastUsedAt - b.lastUsedAt);
|
|
1721
2069
|
const excess = byAge.length - MAX_LIVE;
|
|
1722
2070
|
for (let i = 0; i < excess; i++) victims.add(byAge[i]);
|
|
1723
2071
|
}
|
|
2072
|
+
if (MAX_RSS_MB > 0) {
|
|
2073
|
+
// 按实际 RSS 而不是会话数:上下文大小差异很大,按个数限制经常低估。
|
|
2074
|
+
let total = 0;
|
|
2075
|
+
const rss = new Map();
|
|
2076
|
+
const seenPid = new Set();
|
|
2077
|
+
for (const c of this.conversations.values()) {
|
|
2078
|
+
if (!c.proc?.alive) continue;
|
|
2079
|
+
const pid = c.proc.pid;
|
|
2080
|
+
const mb = rssMb(pid) ?? 0;
|
|
2081
|
+
rss.set(c, mb);
|
|
2082
|
+
// 共享进程只算一次
|
|
2083
|
+
if (pid && seenPid.has(pid)) continue;
|
|
2084
|
+
if (pid) seenPid.add(pid);
|
|
2085
|
+
total += mb;
|
|
2086
|
+
}
|
|
2087
|
+
if (total > MAX_RSS_MB) {
|
|
2088
|
+
// 先收非 keep_alive 的最久未用者;仍然超预算才动 keep_alive。
|
|
2089
|
+
const pool = reapable
|
|
2090
|
+
.filter((c) => !victims.has(c))
|
|
2091
|
+
.sort((a, b) =>
|
|
2092
|
+
a.keepAlive === b.keepAlive ? a.lastUsedAt - b.lastUsedAt : a.keepAlive ? 1 : -1,
|
|
2093
|
+
);
|
|
2094
|
+
for (const c of pool) {
|
|
2095
|
+
victims.add(c);
|
|
2096
|
+
total -= rss.get(c) ?? 0;
|
|
2097
|
+
if (total <= MAX_RSS_MB) break;
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
1724
2101
|
|
|
1725
2102
|
for (const c of victims) {
|
|
2103
|
+
if (c.recycling) await c.recycling;
|
|
1726
2104
|
if (!c.proc?.alive) continue;
|
|
1727
2105
|
this.log.info(
|
|
1728
2106
|
`[hub] 回收会话 ${c.id} 的进程(${IDLE_TTL_MS > 0 && c.lastUsedAt <= cutoff ? '空闲超时' : `保活上限 ${MAX_LIVE}`};可 resume 复活)`,
|
|
1729
2107
|
);
|
|
1730
2108
|
const proc = c.proc;
|
|
1731
|
-
|
|
1732
|
-
//
|
|
1733
|
-
//
|
|
1734
|
-
//
|
|
1735
|
-
|
|
1736
|
-
|
|
2109
|
+
// ★ 先把 stop 登记成 recycling,再 await:stop 可能要几秒,这个窗口里到达的
|
|
2110
|
+
// dsh_send 会在 ensureAlive 里 await 它,而不是为同一会话再拉一个进程去抢
|
|
2111
|
+
// **尚未释放**的写锁。proc 也要等 stop 落定后才置空,否则快路径会拿到垂死进程。
|
|
2112
|
+
// 共享进程:detach 只摘掉这个会话(内部 session/close),最后一个离开才停进程。
|
|
2113
|
+
const stopping = typeof proc.detach === 'function' ? proc.detach(c) : proc.stop();
|
|
2114
|
+
c.recycling = stopping
|
|
2115
|
+
.catch(() => false)
|
|
2116
|
+
.then((stopped) => {
|
|
2117
|
+
// ★ 顺序要紧:**先确认子进程真的退出(写锁真的释放),再删登记**。
|
|
2118
|
+
// 反过来的话,一旦它没死透,锁的持有者就变成"无登记的未知进程" ——
|
|
2119
|
+
// 既报不清是谁,连 dsh_takeover 都会拒绝(判成 none)。
|
|
2120
|
+
if (stopped) removeMarker(c.id);
|
|
2121
|
+
return stopped;
|
|
2122
|
+
})
|
|
2123
|
+
.finally(() => {
|
|
2124
|
+
if (c.proc === proc) c.proc = null;
|
|
2125
|
+
c.recycling = null;
|
|
2126
|
+
});
|
|
2127
|
+
await c.recycling;
|
|
1737
2128
|
}
|
|
1738
2129
|
} catch (e) {
|
|
1739
2130
|
this.log.info(`[hub] 回收周期异常(已忽略,下个周期继续): ${e.message}`);
|
|
@@ -1751,7 +2142,9 @@ export class Hub {
|
|
|
1751
2142
|
async releaseAll({ force = false } = {}) {
|
|
1752
2143
|
const released = [];
|
|
1753
2144
|
const skippedBusy = [];
|
|
2145
|
+
const failed = [];
|
|
1754
2146
|
for (const c of this.conversations.values()) {
|
|
2147
|
+
if (c.recycling) await c.recycling;
|
|
1755
2148
|
if (!c.proc?.alive) continue;
|
|
1756
2149
|
// ★ 默认不打断正在跑的回合:all=true 是"腾内存",不是"取消任务"。
|
|
1757
2150
|
// 确实要连跑着的也停掉时传 force=true。
|
|
@@ -1760,24 +2153,47 @@ export class Hub {
|
|
|
1760
2153
|
continue;
|
|
1761
2154
|
}
|
|
1762
2155
|
const proc = c.proc;
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
2156
|
+
// stop 期间保持 proc 可见 + recycling 屏障,落定后再置空。
|
|
2157
|
+
const stopping = typeof proc.detach === 'function' ? proc.detach(c) : proc.stop();
|
|
2158
|
+
c.recycling = stopping
|
|
2159
|
+
.catch(() => false)
|
|
2160
|
+
.finally(() => {
|
|
2161
|
+
if (c.proc === proc) c.proc = null;
|
|
2162
|
+
c.recycling = null;
|
|
2163
|
+
});
|
|
2164
|
+
const stopped = await c.recycling;
|
|
2165
|
+
if (stopped) {
|
|
2166
|
+
removeMarker(c.id);
|
|
2167
|
+
released.push(c.id);
|
|
2168
|
+
} else {
|
|
2169
|
+
// 进程没确认退出:保留登记,让 takeover 还能归因这个孤儿。
|
|
2170
|
+
failed.push(c.id);
|
|
2171
|
+
this.log.info(`[hub] 释放 ${c.id} 时进程未确认退出,保留写锁登记`);
|
|
2172
|
+
}
|
|
1767
2173
|
}
|
|
1768
2174
|
this.save();
|
|
1769
|
-
return { released: released.length, conversations: released, skipped_busy: skippedBusy };
|
|
2175
|
+
return { released: released.length, conversations: released, skipped_busy: skippedBusy, failed };
|
|
1770
2176
|
}
|
|
1771
2177
|
|
|
1772
2178
|
async shutdown() {
|
|
1773
2179
|
if (this.#reaper) clearInterval(this.#reaper);
|
|
1774
2180
|
this.#reaper = null;
|
|
1775
2181
|
for (const c of this.conversations.values()) {
|
|
2182
|
+
if (c.recycling) await c.recycling;
|
|
1776
2183
|
const proc = c.proc;
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
2184
|
+
if (proc) {
|
|
2185
|
+
const stopping = typeof proc.detach === 'function' ? proc.detach(c) : proc.stop();
|
|
2186
|
+
c.recycling = stopping
|
|
2187
|
+
.catch(() => false)
|
|
2188
|
+
.finally(() => {
|
|
2189
|
+
if (c.proc === proc) c.proc = null;
|
|
2190
|
+
c.recycling = null;
|
|
2191
|
+
});
|
|
2192
|
+
const stopped = await c.recycling;
|
|
2193
|
+
// 只有确认子进程退出、锁真的释放后才删登记;否则留给下个实例归因。
|
|
2194
|
+
if (stopped) removeMarker(c.id);
|
|
2195
|
+
else this.log.info(`[hub] 关闭 ${c.id} 时进程未确认退出,保留写锁登记`);
|
|
2196
|
+
}
|
|
1781
2197
|
}
|
|
1782
2198
|
this.save();
|
|
1783
2199
|
}
|