lark-remote 0.5.6 → 0.5.8

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.
Files changed (85) hide show
  1. package/CHANGELOG.md +46 -6
  2. package/README.en.md +2 -2
  3. package/README.md +2 -2
  4. package/dist/bridge/index.d.ts +63 -1
  5. package/dist/bridge/index.js +147 -14
  6. package/dist/bridge/queue-manager.js +41 -58
  7. package/dist/card/approval-render.js +2 -1
  8. package/dist/card/bash-card-session.d.ts +0 -1
  9. package/dist/card/bash-card-session.js +0 -8
  10. package/dist/card/bash-renderer.js +2 -1
  11. package/dist/card/card-budget.d.ts +37 -0
  12. package/dist/card/card-budget.js +143 -24
  13. package/dist/card/card-session.d.ts +3 -13
  14. package/dist/card/card-session.js +4 -18
  15. package/dist/card/card-shared.d.ts +11 -0
  16. package/dist/card/card-shared.js +19 -13
  17. package/dist/card/run-card-session.d.ts +0 -1
  18. package/dist/card/run-card-session.js +0 -6
  19. package/dist/card/run-renderer.d.ts +18 -0
  20. package/dist/card/run-renderer.js +330 -62
  21. package/dist/card/run-state.d.ts +26 -0
  22. package/dist/card/run-state.js +45 -0
  23. package/dist/card/text-truncate.d.ts +3 -5
  24. package/dist/card/text-truncate.js +3 -5
  25. package/dist/card/time.d.ts +14 -0
  26. package/dist/card/time.js +30 -0
  27. package/dist/card/tool-render.d.ts +15 -2
  28. package/dist/card/tool-render.js +42 -82
  29. package/dist/common/text-width.d.ts +24 -0
  30. package/dist/common/text-width.js +93 -0
  31. package/dist/common/tool-summary.d.ts +19 -0
  32. package/dist/common/tool-summary.js +171 -0
  33. package/dist/common/turn-failure.d.ts +13 -0
  34. package/dist/common/turn-failure.js +18 -0
  35. package/dist/config/index.d.ts +1 -1
  36. package/dist/config/index.js +1 -1
  37. package/dist/connector/index.js +1 -1
  38. package/dist/index.js +66 -13
  39. package/dist/order/index.d.ts +2 -0
  40. package/dist/order/index.js +2 -0
  41. package/dist/platform/command.js +1 -1
  42. package/dist/platform/shell.d.ts +2 -2
  43. package/dist/platform/spawn.d.ts +1 -1
  44. package/dist/platform/spawn.js +2 -2
  45. package/dist/platform/types.d.ts +1 -1
  46. package/dist/router/card-helpers.d.ts +18 -16
  47. package/dist/router/card-helpers.js +88 -46
  48. package/dist/router/config/codex.js +2 -3
  49. package/dist/router/config/common/model-patch.d.ts +8 -0
  50. package/dist/router/config/common/model-patch.js +13 -0
  51. package/dist/router/config/common/render.js +30 -77
  52. package/dist/router/config/dsh.js +2 -1
  53. package/dist/router/config/kimi.js +3 -3
  54. package/dist/router/index.d.ts +9 -11
  55. package/dist/router/index.js +165 -356
  56. package/dist/router/order-exec-dispatch.js +2 -1
  57. package/dist/runner/claude/session.d.ts +1 -1
  58. package/dist/runner/claude/session.js +3 -3
  59. package/dist/runner/common/defaults.d.ts +2 -0
  60. package/dist/runner/common/defaults.js +3 -0
  61. package/dist/runner/common/jsonrpc/connection-manager.js +1 -1
  62. package/dist/runner/common/jsonrpc/transport.d.ts +1 -1
  63. package/dist/runner/common/jsonrpc/transport.js +3 -3
  64. package/dist/runner/common/spawning-runner.d.ts +1 -1
  65. package/dist/runner/common/spawning-runner.js +2 -2
  66. package/dist/runner/dsh/auth.js +2 -1
  67. package/dist/runner/dsh/client.d.ts +100 -3
  68. package/dist/runner/dsh/client.js +290 -6
  69. package/dist/runner/dsh/exit-cancel.d.ts +32 -0
  70. package/dist/runner/dsh/exit-cancel.js +72 -0
  71. package/dist/runner/dsh/inflight.d.ts +62 -0
  72. package/dist/runner/dsh/inflight.js +140 -0
  73. package/dist/runner/dsh/runner.d.ts +90 -4
  74. package/dist/runner/dsh/runner.js +436 -18
  75. package/dist/runner/dsh/translator.d.ts +1 -1
  76. package/dist/runner/dsh/translator.js +103 -1
  77. package/dist/runner/dsh/types.d.ts +60 -0
  78. package/dist/runner/types.d.ts +95 -2
  79. package/dist/runner/types.js +9 -0
  80. package/dist/session/claude/session-index.d.ts +0 -4
  81. package/dist/session/claude/session-index.js +0 -8
  82. package/dist/session/claude/sessions.js +2 -2
  83. package/dist/session/dsh/sessions.js +157 -17
  84. package/dist/update/install.js +1 -1
  85. package/package.json +1 -1
@@ -6,8 +6,10 @@
6
6
  * alignment → synthetic system/init (DSH has no init event) → session/prompt
7
7
  * (mode:'queue') → consume `session/follow` + `$events` until turn/end.
8
8
  *
9
- * stop() → session/cancel + abort the mux subscription. killOrphan /
10
- * registerExitHandlers are no-ops (no local process). DSH approvals are
9
+ * stop() → session/cancel + abort the mux subscription. killOrphan is a no-op
10
+ * (no local process); registerExitHandlers cancels the in-flight turn
11
+ * synchronously on process exit, because the turn lives in the shared host and
12
+ * would otherwise keep running with no subscriber. DSH approvals are
11
13
  * two-channel: durable `approval/asked` / `approval/decided` drive the Feishu
12
14
  * card, while the answerable `$events` waterfall supplies eventId/clientId.
13
15
  * `ask_user_question` prompts arrive only as a `user-questions/request`
@@ -20,6 +22,9 @@ import { DshTranslator } from './translator.js';
20
22
  import { DshApprovalTracker } from './approval.js';
21
23
  import { mergeAsync } from './merge.js';
22
24
  import { getLogger } from '../../logger/index.js';
25
+ import { registerExitCleanup, unregisterExitCleanup } from '../common/spawning-runner.js';
26
+ import { cancelSessionSync } from './exit-cancel.js';
27
+ import { DshInflightStore } from './inflight.js';
23
28
  /**
24
29
  * Legacy provider for configs written before `agents.dsh.provider` existed.
25
30
  * New configs pin the provider explicitly; the card reads the live catalog.
@@ -74,8 +79,85 @@ function buildQuestionAnswers(questions, response) {
74
79
  : { id: question.id, selected: [], custom: selected };
75
80
  });
76
81
  }
77
- /** Budget for the fire-and-forget session/cancel on stop (never block /stop). */
82
+ /** Budget for one session/cancel attempt on stop. */
78
83
  const CANCEL_TIMEOUT_MS = 5_000;
84
+ /** Total stop budget: withdraw + cancel + verify must not hold the lane forever. */
85
+ const STOP_BUDGET_MS = 5_000;
86
+ /** A prompt request that was still in flight can land after stop's abort. */
87
+ const STOP_ADMIT_ATTEMPTS = 5;
88
+ const STOP_ADMIT_DELAY_MS = 250;
89
+ /** Cancel is applied synchronously; give the turn a moment to unwind. */
90
+ const STOP_VERIFY_ATTEMPTS = 4;
91
+ const STOP_VERIFY_DELAY_MS = 350;
92
+ /** Per-read budget for inbox/status probes on the stop path. */
93
+ const STOP_PROBE_TIMEOUT_MS = 2_000;
94
+ function delay(ms) {
95
+ return new Promise((resolve) => setTimeout(resolve, ms));
96
+ }
97
+ /**
98
+ * 把一帧 `session/control` 投影写进状态条累计值。
99
+ * @returns 本仓库消费的字段是否真的变了(未变不必刷新卡片)。
100
+ */
101
+ function applyProjection(live, key, value) {
102
+ if (!value || typeof value !== 'object')
103
+ return false;
104
+ if (key === 'contextPressure') {
105
+ const pressure = value;
106
+ // 官方 UI 的口径:优先 projectedTokens(下一次请求的预估),退回实际占用。
107
+ const used = pressure.projectedTokens ?? pressure.pressureTokens;
108
+ let changed = false;
109
+ if (typeof used === 'number' && used !== live.contextUsed) {
110
+ live.contextUsed = used;
111
+ changed = true;
112
+ }
113
+ if (typeof pressure.contextWindow === 'number' &&
114
+ pressure.contextWindow > 0 &&
115
+ pressure.contextWindow !== live.contextLimit) {
116
+ live.contextLimit = pressure.contextWindow;
117
+ changed = true;
118
+ }
119
+ return changed;
120
+ }
121
+ if (key === 'sessionStats') {
122
+ const stats = value;
123
+ let changed = false;
124
+ if (typeof stats.steps === 'number' && stats.steps !== live.steps) {
125
+ live.steps = stats.steps;
126
+ changed = true;
127
+ }
128
+ if (typeof stats.toolMs === 'number' && stats.toolMs !== live.toolMs) {
129
+ live.toolMs = stats.toolMs;
130
+ changed = true;
131
+ }
132
+ return changed;
133
+ }
134
+ return false;
135
+ }
136
+ /** Build the `run_status` event for the card's status strip. */
137
+ function statusEventOf(live) {
138
+ return {
139
+ type: 'run_status',
140
+ ...(live.contextUsed === undefined ? {} : { contextUsed: live.contextUsed }),
141
+ ...(live.contextLimit === undefined ? {} : { contextLimit: live.contextLimit }),
142
+ ...(live.steps === undefined ? {} : { steps: live.steps }),
143
+ ...(live.toolMs === undefined ? {} : { toolMs: live.toolMs }),
144
+ timestamp: new Date().toISOString(),
145
+ };
146
+ }
147
+ /**
148
+ * Attach the context window to the result usage.
149
+ *
150
+ * DSH 的会话读取端只产出占用、不产出窗口上限,终态卡因此只能显示绝对量;
151
+ * 控制流拿到的 `contextWindow` 是唯一的百分比来源。已有上限(未来 DSH 自带)
152
+ * 时不覆盖。
153
+ */
154
+ function attachContextLimit(ev, limit) {
155
+ if (ev.type !== 'result' || limit === undefined || !ev.usage)
156
+ return ev;
157
+ if (ev.usage.context_limit !== undefined)
158
+ return ev;
159
+ return { ...ev, usage: { ...ev.usage, context_limit: limit } };
160
+ }
79
161
  /** Parse tool-call arguments without letting malformed JSON crash the run. */
80
162
  function parseToolArguments(raw) {
81
163
  if (!raw)
@@ -112,6 +194,19 @@ export class DshRunner {
112
194
  activeAbort = null;
113
195
  activeSessionId = null;
114
196
  running = false;
197
+ /** This run's prompt identity (`source.rpcId`); null outside a submitted prompt. */
198
+ activePromptRequestId = null;
199
+ /** Whether the current prompt request was accepted by the host. */
200
+ promptAdmitted = false;
201
+ /**
202
+ * Releases the next run's prompt only after this stop's withdraw/cancel has
203
+ * been applied. Aborting the local mux lets the queue advance immediately;
204
+ * without the gate the successor's prompt can reach the host before the
205
+ * cancel, and a session-scoped cancel would then hit the *new* turn.
206
+ */
207
+ pendingStopGate = null;
208
+ pendingStopSessionId = null;
209
+ inflight;
115
210
  daemon;
116
211
  /**
117
212
  * 已对齐模型的 session 集合:同一 session 二次 run 不再重发 selectModel
@@ -135,6 +230,8 @@ export class DshRunner {
135
230
  toolCalls = new Map();
136
231
  /** False after `$events` is unavailable; approval/asked then falls back to text. */
137
232
  approvalChannelAvailable = true;
233
+ /** 退出路径的取消实现(默认同步 curl,见 `cancelSessionSync`)。 */
234
+ cancelOnExit;
138
235
  constructor(opts) {
139
236
  this.sessionReader = opts.sessionReader;
140
237
  this.daemon = opts.daemon;
@@ -143,6 +240,7 @@ export class DshRunner {
143
240
  this.agentPreset = opts.agentPreset;
144
241
  this.configuredPermissionPreset = opts.permissionPreset;
145
242
  this.reasoningEffort = opts.reasoningEffort;
243
+ this.inflight = opts.inflightPath ? new DshInflightStore(opts.inflightPath) : null;
146
244
  this.client = new DshClient(opts.host ?? 'http://127.0.0.1:3080', {
147
245
  dshHome: opts.dshHome,
148
246
  token: opts.token,
@@ -151,6 +249,16 @@ export class DshRunner {
151
249
  webSocketImpl: opts.webSocketImpl,
152
250
  requestTimeoutMs: opts.requestTimeoutMs,
153
251
  });
252
+ this.cancelOnExit =
253
+ opts.cancelOnExit ??
254
+ (({ baseUrl, sessionId }) => {
255
+ cancelSessionSync({
256
+ baseUrl,
257
+ sessionId,
258
+ dshHome: opts.dshHome,
259
+ credentialsFile: opts.credentialsFile,
260
+ });
261
+ });
154
262
  }
155
263
  get isRunning() {
156
264
  return this.running;
@@ -216,8 +324,13 @@ export class DshRunner {
216
324
  const abort = new AbortController();
217
325
  this.activeAbort = abort;
218
326
  this.running = true;
327
+ // A stop releases the local lane before its cancel has been applied; the
328
+ // next prompt must not race that cancel (see `pendingStopGate`). State is
329
+ // already published above so an immediate stop can abort this run too.
330
+ await this.awaitStopGate(opts.sessionId);
219
331
  let sessionId = opts.sessionId;
220
332
  let terminalEmitted = false;
333
+ let promptRequestId = null;
221
334
  try {
222
335
  // DSH is a shared, machine-wide process. Reuse it when it is already
223
336
  // listening; otherwise start it detached so lark-remote exiting does not
@@ -269,7 +382,7 @@ export class DshRunner {
269
382
  };
270
383
  return;
271
384
  }
272
- // provider/模型/推理强度对齐(§4.3):会话首次 run 时 selectModel 一次
385
+ // provider/模型/推理强度对齐:会话首次 run 时 selectModel 一次
273
386
  // (该调用同时写服务端全局默认,故不重复写);同一 session 后续 run
274
387
  // 跳过。显式配置 provider 时失败必须 fail closed:静默回退到其他
275
388
  // provider 会让用户以为切换成功,实际模型与账单都错。
@@ -328,12 +441,28 @@ export class DshRunner {
328
441
  const prePromptSeq = await this.client.latestEventSeq(sessionId, {
329
442
  signal: abort.signal,
330
443
  });
444
+ // Keep the prompt identity: DSH echoes it into the durable user/message
445
+ // source.rpcId, which is how runTurn tells this run's turn from a turn
446
+ // it is merely queued behind.
447
+ promptRequestId = newPromptRequestId();
448
+ // Publish the identity before the request leaves: a stop that arrives
449
+ // while `session/prompt` is in flight must still know which inbox item
450
+ // (if any) to withdraw once the host admits it.
451
+ this.activePromptRequestId = promptRequestId;
452
+ this.promptAdmitted = false;
453
+ this.inflight?.record({
454
+ sessionId,
455
+ promptRequestId,
456
+ baseUrl: this.client.baseUrl,
457
+ cwd: opts.cwd,
458
+ });
331
459
  await this.client.unary('session/prompt', {
332
- requestId: newPromptRequestId(),
460
+ requestId: promptRequestId,
333
461
  sessionId,
334
462
  mode: 'queue',
335
463
  content: [{ type: 'text', text: message }],
336
464
  }, { signal: abort.signal });
465
+ this.promptAdmitted = true;
337
466
  if (abort.signal.aborted) {
338
467
  // stop() 在 prompt 已受理后到达:cancel 由 stop() 发出,这里直接收尾,
339
468
  // 不再打开 mux(否则会看到 cancel 产生的 turn/end 事件)。
@@ -347,7 +476,7 @@ export class DshRunner {
347
476
  return;
348
477
  }
349
478
  try {
350
- for await (const ev of this.runTurn(sessionId, opts.cwd, prePromptSeq, abort)) {
479
+ for await (const ev of this.runTurn(sessionId, opts.cwd, prePromptSeq, promptRequestId, abort)) {
351
480
  yield ev;
352
481
  if (ev.type === 'result') {
353
482
  terminalEmitted = true;
@@ -398,6 +527,14 @@ export class DshRunner {
398
527
  this.running = false;
399
528
  this.activeSessionId = null;
400
529
  this.activeAbort = null;
530
+ this.activePromptRequestId = null;
531
+ this.promptAdmitted = false;
532
+ // On abort the stop path decides whether the marker is settled (verified
533
+ // stop) or must survive for startup reconciliation (cancel failed). A
534
+ // naturally finished run owns its terminal result, so drop it here.
535
+ if (!abort.signal.aborted && sessionId && promptRequestId) {
536
+ this.inflight?.clear(sessionId, promptRequestId);
537
+ }
401
538
  }
402
539
  if (!terminalEmitted) {
403
540
  yield {
@@ -413,7 +550,7 @@ export class DshRunner {
413
550
  * events; `$events` owns the answerable approval waterfall. The merged
414
551
  * stream keeps either transport from starving the other.
415
552
  */
416
- async *runTurn(sessionId, cwd, prePromptSeq, abort) {
553
+ async *runTurn(sessionId, cwd, prePromptSeq, promptRequestId, abort) {
417
554
  const streamAbort = new AbortController();
418
555
  const signal = AbortSignal.any([abort.signal, streamAbort.signal]);
419
556
  const translator = new DshTranslator();
@@ -422,14 +559,32 @@ export class DshRunner {
422
559
  this.pendingQuestions.clear();
423
560
  this.toolCalls.clear();
424
561
  this.approvalChannelAvailable = true;
562
+ // 状态条累计值(contextPressure / sessionStats 投影)。`contextWindow` 同时
563
+ // 用于给 result.usage.context_limit 兜底:DSH 的会话读取端不产出上限,
564
+ // 没有它终态卡只能显示绝对量、算不出百分比。
565
+ const live = {};
425
566
  try {
426
567
  const sources = [
427
- this.client.sessionEvents(sessionId, signal, prePromptSeq),
568
+ this.client.sessionEvents(sessionId, signal, prePromptSeq, promptRequestId),
428
569
  this.client.approvalEvents(sessionId, signal),
570
+ this.client.controlFrames(signal),
429
571
  ];
430
572
  for await (const item of mergeAsync(sources)) {
431
573
  if (abort.signal.aborted)
432
574
  return;
575
+ if (item.kind === 'projection') {
576
+ // 全 Host 流:只认本 session。未知 key 直接忽略(投影是能力发现式
577
+ // 扩展的,不能因为多了个 key 就报错)。
578
+ if (item.sessionId !== sessionId)
579
+ continue;
580
+ // 只在消费字段真的变了时才产出事件:投影帧本身按 key 去重推送,
581
+ // 这里再挡掉"值没变"的重复帧。不需要时间节流——卡片更新本来就有
582
+ // 100ms 合批窗口,与正文 delta 共用同一条节流路径。
583
+ if (!applyProjection(live, item.key, item.value))
584
+ continue;
585
+ yield statusEventOf(live);
586
+ continue;
587
+ }
433
588
  if (item.kind === 'ready') {
434
589
  this.approvalChannelAvailable = true;
435
590
  continue;
@@ -513,7 +668,9 @@ export class DshRunner {
513
668
  this.toolCalls.delete(oldest);
514
669
  }
515
670
  }
516
- continue;
671
+ // 记账后继续往下走:tool/call 同时是卡片工具行的来源(translator 翻
672
+ // 译成 assistant.tool_use)。早期版本在这里 `continue`,把调用轨迹整个
673
+ // 吞掉——卡片因此只有正文、没有任何工具行。
517
674
  }
518
675
  if (event.type === 'approval/decided') {
519
676
  const data = event.data;
@@ -524,9 +681,16 @@ export class DshRunner {
524
681
  continue;
525
682
  }
526
683
  for (const ev of translator.eventToAgentEvents(event, sessionId)) {
527
- yield ev;
528
- if (ev.type === 'result')
684
+ if (ev.type === 'result') {
685
+ if (!abort.signal.aborted) {
686
+ for (const jobsEvent of await this.collectBackgroundJobs(sessionId, abort.signal)) {
687
+ yield jobsEvent;
688
+ }
689
+ }
690
+ yield attachContextLimit(ev, live.contextLimit);
529
691
  return;
692
+ }
693
+ yield ev;
530
694
  }
531
695
  }
532
696
  }
@@ -537,6 +701,37 @@ export class DshRunner {
537
701
  this.toolCalls.clear();
538
702
  }
539
703
  }
704
+ /**
705
+ * 收尾时仍在跑的后台任务。
706
+ *
707
+ * 只保留非终态(running/stopping):已完成的任务由 agent 自己的 output 呈
708
+ * 现,卡片列出来只会误导"还没跑完"。`job/list` 拿不到时返回空数组——没有
709
+ * 任务列表不等于有任务在跑。
710
+ */
711
+ async collectBackgroundJobs(sessionId, signal) {
712
+ const rows = await this.client.jobRows(sessionId, { signal });
713
+ if (!rows)
714
+ return [];
715
+ const live = rows.filter((job) => job.status === 'running' || job.status === 'stopping');
716
+ if (live.length === 0)
717
+ return [];
718
+ return [
719
+ {
720
+ type: 'background_jobs',
721
+ jobs: live.map((job) => ({
722
+ id: job.id,
723
+ label: job.label ?? job.id,
724
+ ...(job.status === undefined ? {} : { status: job.status }),
725
+ ...(job.progress === undefined ? {} : { progress: job.progress }),
726
+ })),
727
+ timestamp: new Date().toISOString(),
728
+ },
729
+ ];
730
+ }
731
+ /** Kill one background job of this runner's session (card button path). */
732
+ async killJob(sessionId, jobId) {
733
+ return await this.client.jobKill(sessionId, jobId);
734
+ }
540
735
  /** Apply the configured permission preset once per session, before prompt. */
541
736
  async alignPermissionPreset(sessionId, signal) {
542
737
  const preset = this.configuredPermissionPreset;
@@ -751,6 +946,9 @@ export class DshRunner {
751
946
  this.running = true;
752
947
  this.activeSessionId = sessionId;
753
948
  let terminalEmitted = false;
949
+ // Same barrier as run(): a stop of this session must reach the host before
950
+ // the next compact is submitted.
951
+ await this.awaitStopGate(sessionId);
754
952
  try {
755
953
  if (this.daemon)
756
954
  await this.daemon.ensure();
@@ -761,6 +959,19 @@ export class DshRunner {
761
959
  cwd: opts.cwd,
762
960
  model: this.configuredModel ?? 'DSH',
763
961
  };
962
+ // 桥接重启后内存里没有在途 run,但服务端可能还在跑上一轮:先用只读
963
+ // 探测兜底,别把注定被拒的 /compact 发出去(DSH 只回一句把「有压缩
964
+ // 在跑」和「agent 不空闲」合并在一起的文案,用户看不出真实原因)。
965
+ if (await this.client.hasOpenTurn(sessionId, { signal: abort.signal })) {
966
+ terminalEmitted = true;
967
+ yield {
968
+ type: 'result',
969
+ subtype: 'error',
970
+ session_id: sessionId,
971
+ errorMessage: '当前会话还有一轮在跑(DSH 服务端未空闲),等它结束后再压缩',
972
+ };
973
+ return;
974
+ }
764
975
  const outcome = await this.client.executeCommand(sessionId, DSH_COMPACT_COMMAND, {
765
976
  signal: abort.signal,
766
977
  // 压缩是一次长 unary(服务端等压缩结束才回),60s 默认预算会误杀;
@@ -871,26 +1082,233 @@ export class DshRunner {
871
1082
  getLogger().warn(`[dsh] session/cancel failed for ${sessionId}: ${err.message}`);
872
1083
  }
873
1084
  }
1085
+ /**
1086
+ * Stop this run's work in the shared host.
1087
+ *
1088
+ * Two distinct things can be running, and DSH needs a different call for each:
1089
+ *
1090
+ * 1. A prompt that was accepted but not claimed yet still sits in the host
1091
+ * inbox. `session/cancel` would NOT drop it (`keepInbox`), so the turn would
1092
+ * start later with no subscriber — an orphan. Withdraw it by `rpcId` via
1093
+ * `session/updateQueue` instead, and leave the (foreign) active turn alone.
1094
+ * 2. An already-claimed turn (or a compact) can only be stopped with
1095
+ * `session/cancel`. Since that request is session-scoped and only an
1096
+ * acknowledgement, verify the session went idle before reporting success.
1097
+ *
1098
+ * The gate is held until the host-side action has landed. For a cancel that
1099
+ * includes the verification (and possible retry), because a retry cancel is
1100
+ * session-scoped and would otherwise hit the successor's new turn.
1101
+ */
874
1102
  async stop(_opts) {
875
1103
  const sessionId = this.activeSessionId;
1104
+ const promptRequestId = this.activePromptRequestId;
1105
+ const promptAdmitted = this.promptAdmitted;
876
1106
  const abort = this.activeAbort;
1107
+ // Free the local mux/queue first; the host-side stop continues below.
877
1108
  if (abort && !abort.signal.aborted)
878
1109
  abort.abort();
879
- if (sessionId) {
880
- // Do NOT reuse the run's (already aborted) signal: cancel must be sent,
881
- // but bounded — a hung server must not block /stop or the queue.
882
- await this.cancelSessionQuietly(sessionId);
883
- getLogger().info(`[dsh] cancelled session ${sessionId}`);
1110
+ if (!sessionId)
1111
+ return { status: 'noop' };
1112
+ let releaseGate = () => { };
1113
+ const gate = new Promise((resolve) => {
1114
+ releaseGate = resolve;
1115
+ });
1116
+ this.pendingStopGate = gate;
1117
+ this.pendingStopSessionId = sessionId;
1118
+ let settled = false;
1119
+ const release = () => {
1120
+ if (settled)
1121
+ return;
1122
+ settled = true;
1123
+ releaseGate();
1124
+ };
1125
+ try {
1126
+ return await this.performStop(sessionId, promptRequestId, promptAdmitted, release);
884
1127
  }
1128
+ finally {
1129
+ // Safety net for early returns/throws; performStop normally releases first.
1130
+ release();
1131
+ if (this.pendingStopGate === gate) {
1132
+ this.pendingStopGate = null;
1133
+ this.pendingStopSessionId = null;
1134
+ }
1135
+ }
1136
+ }
1137
+ /** Wait for a concurrent stop to reach the host before submitting a new prompt. */
1138
+ async awaitStopGate(sessionId) {
1139
+ const gate = this.pendingStopGate;
1140
+ if (!gate)
1141
+ return;
1142
+ if (sessionId === undefined ||
1143
+ this.pendingStopSessionId === null ||
1144
+ sessionId !== this.pendingStopSessionId) {
1145
+ return;
1146
+ }
1147
+ await gate;
1148
+ }
1149
+ async performStop(sessionId, promptRequestId, promptAdmitted, releaseGate) {
1150
+ const deadline = Date.now() + STOP_BUDGET_MS;
1151
+ try {
1152
+ if (promptRequestId) {
1153
+ const itemId = await this.findPendingPromptWithin(sessionId, promptRequestId, promptAdmitted, deadline);
1154
+ if (itemId && (await this.removePendingPrompt(sessionId, itemId, deadline))) {
1155
+ releaseGate();
1156
+ this.inflight?.clear(sessionId, promptRequestId);
1157
+ getLogger().info(`[dsh] stop withdrew queued prompt session=${sessionId}`);
1158
+ return { status: 'withdrawn', sessionId };
1159
+ }
1160
+ }
1161
+ // Nothing (or nothing claimable) in the inbox: the turn/compact is live.
1162
+ if (!(await this.cancelWithRetry(sessionId, deadline))) {
1163
+ releaseGate();
1164
+ getLogger().warn(`[dsh] stop unconfirmed session=${sessionId}: cancel not accepted`);
1165
+ return { status: 'unconfirmed', sessionId, detail: 'session/cancel was not accepted' };
1166
+ }
1167
+ const state = await this.waitForClosed(sessionId, deadline);
1168
+ if (state === 'closed') {
1169
+ releaseGate();
1170
+ if (promptRequestId)
1171
+ this.inflight?.clear(sessionId, promptRequestId);
1172
+ getLogger().info(`[dsh] cancelled session ${sessionId}`);
1173
+ return { status: 'cancelled', sessionId };
1174
+ }
1175
+ if (state === 'unknown') {
1176
+ releaseGate();
1177
+ return { status: 'unconfirmed', sessionId, detail: 'turn state unavailable after cancel' };
1178
+ }
1179
+ // The host still reports an open turn (slow unwind or lost cancel): one
1180
+ // more attempt, then report honestly instead of pretending it stopped.
1181
+ // Keep the gate closed through the retry: it is session-scoped, so the
1182
+ // successor must not submit its prompt until this attempt has landed.
1183
+ await this.cancelWithRetry(sessionId, deadline);
1184
+ const retryState = await this.waitForClosed(sessionId, deadline);
1185
+ releaseGate();
1186
+ if (retryState === 'closed') {
1187
+ if (promptRequestId)
1188
+ this.inflight?.clear(sessionId, promptRequestId);
1189
+ return { status: 'cancelled', sessionId };
1190
+ }
1191
+ getLogger().warn(`[dsh] stop unconfirmed session=${sessionId}: turn still open`);
1192
+ return { status: 'unconfirmed', sessionId, detail: 'turn still open after cancel' };
1193
+ }
1194
+ catch (err) {
1195
+ releaseGate();
1196
+ getLogger().warn(`[dsh] stop failed session=${sessionId}: ${err.message}`);
1197
+ return { status: 'unconfirmed', sessionId, detail: err.message };
1198
+ }
1199
+ }
1200
+ /**
1201
+ * Locate this run's unclaimed prompt.
1202
+ *
1203
+ * An already-admitted prompt is visible immediately. A request still in
1204
+ * flight can be admitted after stop aborted it, so poll the inbox briefly —
1205
+ * that is the only client-side protection against a late orphan turn.
1206
+ */
1207
+ async findPendingPromptWithin(sessionId, requestId, admitted, deadline) {
1208
+ const attempts = admitted ? 1 : STOP_ADMIT_ATTEMPTS;
1209
+ for (let attempt = 0; attempt < attempts; attempt++) {
1210
+ if (attempt > 0 && !(await this.sleepWithin(deadline, STOP_ADMIT_DELAY_MS)))
1211
+ return undefined;
1212
+ try {
1213
+ const itemId = await this.client.findPendingPrompt(sessionId, requestId, {
1214
+ timeoutMs: this.stopProbeTimeout(deadline),
1215
+ });
1216
+ if (itemId)
1217
+ return itemId;
1218
+ if (admitted)
1219
+ return undefined;
1220
+ }
1221
+ catch (err) {
1222
+ getLogger().warn(`[dsh] stop inbox probe failed session=${sessionId}: ${err.message}`);
1223
+ return undefined;
1224
+ }
1225
+ }
1226
+ return undefined;
1227
+ }
1228
+ async removePendingPrompt(sessionId, itemId, deadline) {
1229
+ try {
1230
+ return await this.client.removeQueuedPrompt(sessionId, itemId, {
1231
+ timeoutMs: this.stopProbeTimeout(deadline),
1232
+ });
1233
+ }
1234
+ catch (err) {
1235
+ getLogger().warn(`[dsh] stop withdraw failed session=${sessionId}: ${err.message}`);
1236
+ return false;
1237
+ }
1238
+ }
1239
+ /** One cancel attempt, then one retry (the request is idempotent when idle). */
1240
+ async cancelWithRetry(sessionId, deadline) {
1241
+ if (await this.cancelOnce(sessionId, deadline))
1242
+ return true;
1243
+ if (!(await this.sleepWithin(deadline, 150)))
1244
+ return false;
1245
+ return this.cancelOnce(sessionId, deadline);
1246
+ }
1247
+ async cancelOnce(sessionId, deadline) {
1248
+ const timeoutMs = Math.max(1, Math.min(CANCEL_TIMEOUT_MS, deadline - Date.now()));
1249
+ try {
1250
+ await this.client.unary('session/cancel', { sessionId }, { timeoutMs });
1251
+ return true;
1252
+ }
1253
+ catch (err) {
1254
+ getLogger().warn(`[dsh] session/cancel failed for ${sessionId}: ${err.message}`);
1255
+ return false;
1256
+ }
1257
+ }
1258
+ /** Poll the host until the session reports idle, or the budget runs out. */
1259
+ async waitForClosed(sessionId, deadline) {
1260
+ let state = 'unknown';
1261
+ for (let attempt = 0; attempt < STOP_VERIFY_ATTEMPTS; attempt++) {
1262
+ if (attempt > 0 && !(await this.sleepWithin(deadline, STOP_VERIFY_DELAY_MS)))
1263
+ break;
1264
+ state = await this.client.openTurnState(sessionId, {
1265
+ timeoutMs: this.stopProbeTimeout(deadline),
1266
+ });
1267
+ if (state === 'closed')
1268
+ return 'closed';
1269
+ }
1270
+ return state;
1271
+ }
1272
+ /** Sleep, but never past the stop deadline. Returns false when out of budget. */
1273
+ async sleepWithin(deadline, ms) {
1274
+ const remaining = deadline - Date.now();
1275
+ if (remaining <= 0)
1276
+ return false;
1277
+ await delay(Math.min(ms, remaining));
1278
+ return Date.now() < deadline;
1279
+ }
1280
+ stopProbeTimeout(deadline) {
1281
+ return Math.max(1, Math.min(STOP_PROBE_TIMEOUT_MS, deadline - Date.now()));
885
1282
  }
886
1283
  killOrphan() {
887
1284
  // No local subprocess for DSH — nothing to reap.
888
1285
  }
1286
+ /**
1287
+ * 退出前取消在途 turn。
1288
+ *
1289
+ * turn 跑在常驻共享的 `dsh web` host 里,不在本进程的子进程树上;所以
1290
+ * "杀自己的子进程"这套退出清理对它无效,桥接一退出,turn 继续无人接收地跑
1291
+ * (2026-10-19 实测:一个孤儿 turn 在桥接死后又跑了 2h21m / 345 个 step)。
1292
+ * 这里复用进程级退出分发器,用同步 curl 发一次 `session/cancel`。
1293
+ */
889
1294
  registerExitHandlers() {
890
- // No local subprocess for DSH — nothing to register.
1295
+ registerExitCleanup(this);
891
1296
  }
892
1297
  unregisterExitHandlers() {
893
- // No-op (see registerExitHandlers).
1298
+ unregisterExitCleanup(this);
1299
+ }
1300
+ /** Exit dispatcher hook: cancel the in-flight turn, bounded and never throwing. */
1301
+ cleanupOnExit() {
1302
+ if (!this.running || !this.activeSessionId)
1303
+ return;
1304
+ const sessionId = this.activeSessionId;
1305
+ try {
1306
+ this.cancelOnExit({ baseUrl: this.client.baseUrl, sessionId });
1307
+ getLogger().info(`[dsh] exit-cancel sent session=${sessionId}`);
1308
+ }
1309
+ catch (err) {
1310
+ getLogger().warn(`[dsh] exit-cancel failed session=${sessionId}: ${err.message}`);
1311
+ }
894
1312
  }
895
1313
  }
896
1314
  //# sourceMappingURL=runner.js.map
@@ -12,7 +12,7 @@
12
12
  * completed → success; aborted/interrupted → interrupted;
13
13
  * blocked/error/max-tokens → error (errorMessage carries the reason)
14
14
  *
15
- * Token accounting (ccusage 口径, AGENTS.md §9.21): DSH emits ONE usage per model
15
+ * Token accounting (ccusage 口径): DSH emits ONE usage per model
16
16
  * call (per step) — a tool-looping turn has several `assistant/message` events.
17
17
  * The result event therefore carries **the sum of the turn's samplings**, not the
18
18
  * last one (codex `token_count` has the same shape). `lastUsage` is kept only as