dsh-any-agent 0.3.9 → 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/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,6 +52,8 @@ 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
58
  import { registerWorkspace, findSessionHeaderStatus } from './workspace.mjs';
55
59
  import {
@@ -287,6 +291,8 @@ export class Conversation {
287
291
  this.turnCount = state.turnCount ?? 0;
288
292
  /** 建会话时给的提示词(仅在 DSH 还没生成原生标题前当兜底显示)。 */
289
293
  this.titleHint = state.titleHint ?? null;
294
+ /** true = 不受空闲 TTL / MAX_LIVE 回收(用于"必须一直握住写锁"的长期会话)。 */
295
+ this.keepAlive = state.keepAlive === true;
290
296
  /** 会话级 ACP 配置(思考深度 / 模型)。新进程不记得,resume 后要重套。 */
291
297
  this.cfg = {
292
298
  reasoningEffort: state.reasoningEffort ?? DEFAULT_REASONING_EFFORT,
@@ -302,11 +308,15 @@ export class Conversation {
302
308
 
303
309
  this.proc = null;
304
310
  this.busy = false;
311
+ /** 正在进行的进程回收(`proc.stop()` 的 promise)。非 null 时不得为同一会话再拉起进程。 */
312
+ this.recycling = null;
305
313
  /** 本进程为这个会话拉起过的最后一个子进程 PID(登记被删后仍能归因锁的归属)。 */
306
314
  this.lastChildPid = null;
307
315
  /** 最近一次撞写锁的记录 `{at, holder_kind, reason, hint}`(见 snapshot())。 */
308
316
  this.lockConflict = null;
309
317
  this.pendingApprovals = new Map();
318
+ /** 正在跑的 run 记录(进程意外退出时要用它写 error 哨兵)。 */
319
+ this.currentRun = null;
310
320
  /** 等待"当前回合结束"的回调队列,供插话排队使用。 */
311
321
  this.idleWaiters = [];
312
322
 
@@ -329,6 +339,11 @@ export class Conversation {
329
339
  */
330
340
  this.runs = [];
331
341
  this.runSeq = 0;
342
+ /** 调用方提供的幂等键 → run_id(与 runs 同步淘汰)。 */
343
+ this.clientRuns = new Map();
344
+ /** 忙时入队的待执行任务(仅内存;重启丢失)。 */
345
+ this.queue = [];
346
+ this.draining = false;
332
347
  /** 最近一次回合结果,仅内存(进程退出即消失),不落盘。 */
333
348
  this.lastResult = null;
334
349
  }
@@ -346,6 +361,7 @@ export class Conversation {
346
361
  lastUsedAt: this.lastUsedAt,
347
362
  turnCount: this.turnCount,
348
363
  titleHint: this.titleHint,
364
+ keepAlive: this.keepAlive,
349
365
  reasoningEffort: this.cfg.reasoningEffort,
350
366
  provider: this.cfg.provider,
351
367
  model: this.cfg.model,
@@ -385,6 +401,9 @@ export class Conversation {
385
401
  on_approval: this.onApproval,
386
402
  alive: !!this.proc?.alive,
387
403
  busy: this.busy,
404
+ keep_alive: this.keepAlive,
405
+ /** 子进程 RSS(MB);量不到就是 null,不影响任何决策。 */
406
+ rss_mb: rssMb(this.proc?.pid),
388
407
  /** running = 正在跑回合;idle = 进程活着但空闲;detached = 进程未打开(可 resume)。 */
389
408
  state: this.state(),
390
409
  current_tool: this.currentTool,
@@ -402,9 +421,13 @@ export class Conversation {
402
421
  usage: meta?.usage?.totals ?? null,
403
422
  context_pressure: meta?.pressure ?? null,
404
423
  pending_approvals: [...this.pendingApprovals.keys()],
424
+ /** 忙时排队的任务(仅内存;重启丢失)。 */
425
+ queue_length: this.queue?.length ?? 0,
426
+ queue: this.queueList(),
405
427
  // Keep the ID list compatible; details are ephemeral and excluded from toState().
406
428
  pending_approval_details: [...this.pendingApprovals].map(([approvalId, pending]) => ({
407
429
  approval_id: approvalId,
430
+ created_at: pending.createdAt ?? null,
408
431
  ...pending.params.details,
409
432
  })),
410
433
  /**
@@ -426,19 +449,30 @@ export class Conversation {
426
449
 
427
450
  /** 确保有一个可用的 DSH 进程;必要时新建并 resume 回该会话。 */
428
451
  async ensureAlive() {
452
+ // ★ 上一代进程正在回收时必须先等它停完:否则会为同一会话再拉起一个进程,
453
+ // 去抢一个**尚未释放**的写锁(reaper/release 里 await stop() 的那个窗口)。
454
+ if (this.recycling) await this.recycling;
429
455
  if (this.proc?.alive) return this.proc;
430
- const proc = new AcpProcess({
431
- cwd: this.cwd,
432
- permission: this.permission,
433
- log: this.#hub.log,
434
- onNotification: (params) => this.onUpdate(params),
435
- onPermissionRequest: (params) => this.onPermissionRequest(params),
436
- });
437
- proc.start();
438
- this.proc = proc;
439
- // 记住"本进程为这个会话拉起过的最后一个子进程"。回收/重派时若登记已被删掉、
440
- // 而这个 PID 还活着,锁就能被正确归因成"我们自己的残留"(否则只能判 none → 无法抢占)。
441
- this.lastChildPid = proc.pid;
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 就归因不到真正的持有者。
442
476
  // ★ 登记**推迟到真正拿到写锁之后**(见下面 resume 成功处)。
443
477
  // 登记的含义是"我正持有这个会话的写锁",不是"我拉起了一个进程"。
444
478
  // 若在这里就写,一次注定撞锁失败的 resume 会先把"谁持有"的线索覆盖掉、
@@ -459,6 +493,8 @@ export class Conversation {
459
493
  await this.verifyPermissionRecorded();
460
494
  // 锁到手了,这时才登记;从此别人能查到"锁在我们手上"
461
495
  writeMarker(this.id, { childPid: proc.pid, cwd: this.cwd });
496
+ // 只有真正拿到写锁后才更新"我拉起的最后一个子进程":失败路径需要保留上一代的 PID。
497
+ this.lastChildPid = proc.pid;
462
498
  this.lockConflict = null; // 拿到锁了,之前的撞锁记录作废
463
499
  this.#hub.log.info(`[conv ${this.id}] 已 resume`);
464
500
  return proc;
@@ -466,12 +502,15 @@ export class Conversation {
466
502
  // ★ 半死的进程必须作废:它 alive 但**没有加载这个会话**,一旦留在 this.proc 上,
467
503
  // 下次调用会走上面 `this.proc?.alive` 的快路径,把 session/prompt 发给一个
468
504
  // 不认识该会话的进程 —— 会话就永久卡死了(重派也不会自愈)。
469
- this.proc = null;
505
+ // 只作废**自己这一次**拉起的进程:窄窗口里另一个调用可能已经换上了新一代。
506
+ if (this.proc === proc) this.proc = null;
470
507
  // ★ 必须 **await**:不等它真的退出,紧接着的 classifyHolder 就会把"正在退出的自己人"
471
508
  // 当成持锁者(表现为"上一代子进程还活着并握着锁"),于是每次失败都误报锁冲突 ——
472
509
  // 更糟的是旧代码没有 lastChildPid 兜底,会把这种自伤报成"多半是你自己的 GUI",
473
510
  // 而 dsh_takeover 对 none 又拒绝接管,会话就卡住了(test/cycle.mjs 实测抓到)。
474
- const stopped = await proc.stop().catch(() => false);
511
+ const stopped = shared
512
+ ? await proc.detach(this).catch(() => false)
513
+ : await proc.stop().catch(() => false);
475
514
  // 只删**自己的**登记(removeMarker 会校验 token);进程没确认退出时保留登记,
476
515
  // 让 classifyHolder/takeover 仍能归因。
477
516
  if (stopped) removeMarker(this.id);
@@ -733,6 +772,7 @@ export class Conversation {
733
772
  }, this.#hub.approvalTimeoutMs);
734
773
  this.pendingApprovals.set(approvalId, {
735
774
  params: { tool: desc, details: approvalContext(params, toolCall, desc, options) },
775
+ createdAt: now(),
736
776
  allowId,
737
777
  rejectId,
738
778
  resolve: (outcome) => {
@@ -749,8 +789,76 @@ export class Conversation {
749
789
  if (!p) throw new Error(`没有待决审批: ${approvalId}`);
750
790
  this.pendingApprovals.delete(approvalId);
751
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';
752
795
  p.resolve(chosen ? { outcome: { outcome: 'selected', optionId: chosen } } : { outcome: { outcome: 'cancelled' } });
753
- return { approval_id: approvalId, decision };
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
+ }
754
862
  }
755
863
 
756
864
  // ── 跑任务 ──────────────────────────────────────────────────────
@@ -782,6 +890,7 @@ export class Conversation {
782
890
  const runRecord = run ?? this.createRun(text);
783
891
  runRecord.status = 'running';
784
892
  runRecord.started_at = now();
893
+ this.currentRun = runRecord;
785
894
  const before = gitStatus(this.cwd);
786
895
  let proc;
787
896
  // ★ `busy` 必须在 ensureAlive **之前**置位。
@@ -796,19 +905,13 @@ export class Conversation {
796
905
  } catch (e) {
797
906
  // 起不来(撞写锁 / 会话损坏)就**必须放开 busy**,否则这个会话被永久占住;
798
907
  // 同时别留一条永远 "running" 的幽灵记录。
799
- // 调用方这次拿到的是 isError、并没有 run_id,所以这里不写哨兵。
908
+ // ★ 与 finalize 对齐:失败也必须给等待者一个终态——唤醒插话队列 + 落 error 哨兵。
909
+ // 只写哨兵救不了 dsh_interject:它等的是 idleWaiters,不是文件(默认 timeout=0 无 timer)。
800
910
  this.busy = false;
801
- runRecord.status = 'error';
802
- runRecord.ended_at = now();
803
- runRecord.error = e.message;
804
- runRecord.result = {
805
- conversation_id: this.id,
806
- run_id: runRecord.run_id,
807
- stop_reason: 'error',
808
- error: e.message,
809
- answer: '',
810
- elapsed_ms: 0,
811
- };
911
+ this.#settleFailedRun(runRecord, e);
912
+ e.runId = runRecord.run_id;
913
+ e.status = 'error';
914
+ e.sentinelFile = runRecord.result?.sentinel_file ?? null;
812
915
  throw e;
813
916
  }
814
917
  this.currentTurn += 1;
@@ -840,6 +943,7 @@ export class Conversation {
840
943
  // 而且调用方会永远等不到哨兵。所以出错也要落一个"收尾异常"的哨兵。
841
944
  try {
842
945
  this.busy = false;
946
+ if (this.currentRun === runRecord) this.currentRun = null;
843
947
  this.collector = null;
844
948
  this.currentTool = null;
845
949
  // 回合结束后,任何仍挂着的审批都已失效:统一取消并清空,避免状态里
@@ -862,6 +966,7 @@ export class Conversation {
862
966
  const waiters = this.idleWaiters;
863
967
  this.idleWaiters = [];
864
968
  for (const w of waiters) w();
969
+ this.#scheduleDrain();
865
970
  const after = gitStatus(this.cwd);
866
971
  const result = {
867
972
  conversation_id: this.id,
@@ -897,10 +1002,12 @@ export class Conversation {
897
1002
  } catch (e) {
898
1003
  this.#hub.log.info(`[conv ${this.id}] 回合收尾异常(已兜底): ${e.message}`);
899
1004
  this.busy = false;
1005
+ if (this.currentRun === runRecord) this.currentRun = null;
900
1006
  this.collector = null;
901
1007
  const waiters = this.idleWaiters;
902
1008
  this.idleWaiters = [];
903
1009
  for (const w of waiters) w();
1010
+ this.#scheduleDrain();
904
1011
  const fallback = {
905
1012
  conversation_id: this.id,
906
1013
  run_id: runRecord.run_id,
@@ -1024,10 +1131,11 @@ export class Conversation {
1024
1131
  }
1025
1132
 
1026
1133
  /** 建一条回合记录。派活时先建,好让调用方立刻拿到 run_id(异步模式的关键)。 */
1027
- createRun(text) {
1134
+ createRun(text, { clientRunId = null } = {}) {
1028
1135
  const run = {
1029
1136
  run_id: `run-${Date.now().toString(36)}-${++this.runSeq}`,
1030
1137
  conversation_id: this.id,
1138
+ client_run_id: clientRunId ?? null,
1031
1139
  prompt_preview: text.length > MAX_RUN_PREVIEW ? `${text.slice(0, MAX_RUN_PREVIEW)}…` : text,
1032
1140
  status: 'queued',
1033
1141
  started_at: null,
@@ -1036,12 +1144,118 @@ export class Conversation {
1036
1144
  result: null,
1037
1145
  };
1038
1146
  this.runs.push(run);
1039
- if (this.runs.length > MAX_RUNS) this.runs.shift();
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
+ }
1040
1155
  // 防御性清残留:正常情况下该 run_id 从未用过,但确保等待方不会被历史哨兵误触发。
1041
1156
  this.#hub.clearRunSentinel(this.id, run.run_id);
1042
1157
  return run;
1043
1158
  }
1044
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
+
1045
1259
  /** 取回合记录;不传 run_id 就是最近一条。 */
1046
1260
  getRun(runId) {
1047
1261
  if (runId) {
@@ -1092,9 +1306,11 @@ export class Conversation {
1092
1306
  * 之所以无竞态:dsh-acp 在让 prompt 请求返回**之前**就清空了 inflight 槽位,
1093
1307
  * 且只在 agent 完全 idle 后才 settle(lib/index.js:1003-1011)。
1094
1308
  */
1095
- 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 });
1096
1312
  if (!this.busy) {
1097
- const r = await this.prompt(message, { reasoning, timeoutMs });
1313
+ const r = await this.prompt(message, { reasoning, timeoutMs, run });
1098
1314
  return { ...r, interjected: 'idle', waited_ms: 0 };
1099
1315
  }
1100
1316
  const t0 = now();
@@ -1104,21 +1320,61 @@ export class Conversation {
1104
1320
  // 排队等待的时长跟随调用方的 timeout_ms(0 = 一直等),不再固定 30 分钟。
1105
1321
  const idle = await this.waitIdle(timeoutMs);
1106
1322
  const waited = now() - t0;
1107
- if (!idle) throw new Error('等待当前回合结束超时,插话未发送');
1108
- const r = await this.prompt(message, { reasoning, timeoutMs });
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 });
1109
1345
  return { ...r, interjected: mode, waited_ms: waited };
1110
1346
  }
1111
1347
 
1112
1348
  /** 关闭会话(进程收尾;会话本体留在磁盘上,可再次 resume)。 */
1113
1349
  async close({ forget = false } = {}) {
1350
+ if (this.recycling) await this.recycling;
1114
1351
  const proc = this.proc;
1115
- this.proc = null;
1116
1352
  let stopped = true;
1117
1353
  if (proc?.alive) {
1118
- try {
1119
- await proc.request('session/close', { sessionId: this.id }, 10_000);
1120
- } catch {}
1121
- stopped = await proc.stop().catch(() => false);
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;
1122
1378
  }
1123
1379
  // 回合已经结束/进程正在关闭,任何仍挂着的审批都不再可裁决。
1124
1380
  for (const [, p] of this.pendingApprovals) {
@@ -1232,6 +1488,8 @@ export function homeWorkspaceProblem(cwd, permission) {
1232
1488
  export class Hub {
1233
1489
  constructor({ log } = {}) {
1234
1490
  this.conversations = new Map();
1491
+ /** 共享 ACP 进程池:key = `${cwd}\0${permission}`(实验性,DSH_MCP_SHARED_PROCESS=1)。 */
1492
+ this.hosts = new Map();
1235
1493
  this.log = log ?? NOOP_LOG;
1236
1494
  /** 最近一次 dsh_list 磁盘探测的结果:{ok, error, cached, at}。 */
1237
1495
  this.lastProbe = { ok: null, error: null, cached: false, at: 0 };
@@ -1344,6 +1602,7 @@ export class Hub {
1344
1602
  const payload = {
1345
1603
  conversation_id: conversationId,
1346
1604
  run_id: run.run_id,
1605
+ client_run_id: run.client_run_id ?? null,
1347
1606
  status: run.status ?? 'unknown',
1348
1607
  ended_at: run.ended_at ?? Date.now(),
1349
1608
  error: sanitizeApprovalContext(run.error ?? run.result?.error ?? null, 'error'),
@@ -1540,8 +1799,25 @@ export class Hub {
1540
1799
  this.save();
1541
1800
  }
1542
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
+
1543
1819
  /** 新建一个会话:拉起进程 + ACP 初始化 + session/new。 */
1544
- 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 } = {}) {
1545
1821
  if (!cwd) {
1546
1822
  throw new Error(
1547
1823
  '没有可用的工作目录。你既没传 cwd,客户端也没有提供当前打开的项目(MCP roots)。\n' +
@@ -1582,26 +1858,31 @@ export class Hub {
1582
1858
  // 与其让用户以为服务坏了,不如在这里把原因和出路一起说清楚。
1583
1859
  const homeProblem = homeWorkspaceProblem(cwd, permission);
1584
1860
  if (homeProblem) throw new Error(homeProblem);
1585
- const proc = new AcpProcess({
1586
- cwd,
1587
- permission,
1588
- log: this.log,
1589
- onNotification: () => {},
1590
- onPermissionRequest: () => ({ outcome: { outcome: 'cancelled' } }),
1591
- });
1592
- proc.start();
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();
1593
1872
  let createdSessionId = null;
1873
+ let conv = null;
1594
1874
  try {
1595
1875
  await proc.initialize();
1596
1876
  const res = await proc.request('session/new', { cwd, mcpServers: [] });
1597
1877
  createdSessionId = res.sessionId;
1598
- const conv = new Conversation(
1878
+ conv = new Conversation(
1599
1879
  {
1600
1880
  id: res.sessionId,
1601
1881
  cwd,
1602
1882
  permission,
1603
1883
  onApproval: policy,
1604
1884
  titleHint,
1885
+ keepAlive: keepAlive === true,
1605
1886
  reasoningEffort: effort,
1606
1887
  provider: provider ?? null,
1607
1888
  model: model ?? null,
@@ -1613,10 +1894,13 @@ export class Hub {
1613
1894
  // ★ 新建会话的进程也要登记持有者 —— 走的是 Hub.create 这条独立路径,
1614
1895
  // 不登记的话 dsh_takeover 会把"本服务自己持有的会话"误判成 none 而拒绝抢占。
1615
1896
  writeMarker(res.sessionId, { childPid: proc.pid, cwd });
1616
- proc.attachRoutes({
1617
- onNotification: (params) => conv.onUpdate(params),
1618
- onPermissionRequest: (params) => conv.onPermissionRequest(params),
1619
- });
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
+ }
1620
1904
  // 套用会话级配置(思考深度默认 max;ACP 自己的默认是 Provider default)
1621
1905
  conv.lastConfigApplied = await applySessionConfig(proc, res.sessionId, conv.cfg);
1622
1906
  // ★ 无效的 provider/model 必须 fail fast:旧行为是建出会话、静默回退
@@ -1651,7 +1935,11 @@ export class Hub {
1651
1935
  // 失败路径也要清掉写锁登记:session/new 可能已经成功、登记已写入,
1652
1936
  // 但会话最终没有注册(例如无效 provider/model 被 fail-fast)。留下 marker
1653
1937
  // 会被 dsh_list/dsh_takeover 误报成“孤儿持有者”,也会在 locks 目录里积垃圾。
1654
- const stopped = await proc.stop().catch(() => false);
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);
1655
1943
  if (createdSessionId && stopped) removeMarker(createdSessionId);
1656
1944
  throw e;
1657
1945
  }
@@ -1768,30 +2056,75 @@ export class Hub {
1768
2056
  reapable.push(c);
1769
2057
  }
1770
2058
 
1771
- // 回收哪些:① 空闲超过 TTL 的;② 超过 MAX_LIVE 上限时,最久没用过的那些
2059
+ // 回收哪些:① 空闲超过 TTL;② 超过 MAX_LIVE;③ 超过全局 RSS 预算。
2060
+ // keep_alive 会话不受 ① ② 约束;③ 是系统级硬上限,最后仍可能回收它。
1772
2061
  const victims = new Set();
2062
+ const reapableNormal = reapable.filter((c) => !c.keepAlive);
1773
2063
  if (IDLE_TTL_MS > 0) {
1774
- for (const c of reapable) if (c.lastUsedAt <= cutoff) victims.add(c);
2064
+ for (const c of reapableNormal) if (c.lastUsedAt <= cutoff) victims.add(c);
1775
2065
  }
1776
2066
  if (MAX_LIVE > 0) {
1777
2067
  // 保活窗口 = 最近用过的 MAX_LIVE 个;超出的按"最久没用"先回收
1778
- const byAge = reapable.slice().sort((a, b) => a.lastUsedAt - b.lastUsedAt);
2068
+ const byAge = reapableNormal.slice().sort((a, b) => a.lastUsedAt - b.lastUsedAt);
1779
2069
  const excess = byAge.length - MAX_LIVE;
1780
2070
  for (let i = 0; i < excess; i++) victims.add(byAge[i]);
1781
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
+ }
1782
2101
 
1783
2102
  for (const c of victims) {
2103
+ if (c.recycling) await c.recycling;
1784
2104
  if (!c.proc?.alive) continue;
1785
2105
  this.log.info(
1786
2106
  `[hub] 回收会话 ${c.id} 的进程(${IDLE_TTL_MS > 0 && c.lastUsedAt <= cutoff ? '空闲超时' : `保活上限 ${MAX_LIVE}`};可 resume 复活)`,
1787
2107
  );
1788
2108
  const proc = c.proc;
1789
- c.proc = null;
1790
- // ★ 顺序要紧:**先确认子进程真的退出(写锁真的释放),再删登记**。
1791
- // 反过来的话,一旦它没死透,锁的持有者就变成"无登记的未知进程" ——
1792
- // 既报不清是谁,连 dsh_takeover 都会拒绝(判成 none)。
1793
- const stopped = await proc.stop().catch(() => false);
1794
- if (stopped) removeMarker(c.id);
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;
1795
2128
  }
1796
2129
  } catch (e) {
1797
2130
  this.log.info(`[hub] 回收周期异常(已忽略,下个周期继续): ${e.message}`);
@@ -1811,6 +2144,7 @@ export class Hub {
1811
2144
  const skippedBusy = [];
1812
2145
  const failed = [];
1813
2146
  for (const c of this.conversations.values()) {
2147
+ if (c.recycling) await c.recycling;
1814
2148
  if (!c.proc?.alive) continue;
1815
2149
  // ★ 默认不打断正在跑的回合:all=true 是"腾内存",不是"取消任务"。
1816
2150
  // 确实要连跑着的也停掉时传 force=true。
@@ -1819,8 +2153,15 @@ export class Hub {
1819
2153
  continue;
1820
2154
  }
1821
2155
  const proc = c.proc;
1822
- c.proc = null;
1823
- const stopped = await proc.stop().catch(() => false);
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;
1824
2165
  if (stopped) {
1825
2166
  removeMarker(c.id);
1826
2167
  released.push(c.id);
@@ -1838,12 +2179,21 @@ export class Hub {
1838
2179
  if (this.#reaper) clearInterval(this.#reaper);
1839
2180
  this.#reaper = null;
1840
2181
  for (const c of this.conversations.values()) {
2182
+ if (c.recycling) await c.recycling;
1841
2183
  const proc = c.proc;
1842
- c.proc = null;
1843
- const stopped = await proc?.stop().catch(() => false);
1844
- // 只有确认子进程退出、锁真的释放后才删登记;否则留给下个实例归因。
1845
- if (stopped) removeMarker(c.id);
1846
- else this.log.info(`[hub] 关闭 ${c.id} 时进程未确认退出,保留写锁登记`);
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
+ }
1847
2197
  }
1848
2198
  this.save();
1849
2199
  }