lark-remote 0.5.7 → 0.5.9

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 (57) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/bridge/index.d.ts +47 -7
  3. package/dist/bridge/index.js +300 -129
  4. package/dist/bridge/queue-manager.d.ts +6 -0
  5. package/dist/bridge/queue-manager.js +61 -63
  6. package/dist/card/approval-render.js +2 -1
  7. package/dist/card/bash-card-session.d.ts +0 -1
  8. package/dist/card/bash-card-session.js +0 -8
  9. package/dist/card/bash-renderer.js +2 -1
  10. package/dist/card/card-budget.js +15 -19
  11. package/dist/card/card-session.d.ts +3 -13
  12. package/dist/card/card-session.js +4 -18
  13. package/dist/card/card-shared.d.ts +11 -0
  14. package/dist/card/card-shared.js +23 -13
  15. package/dist/card/run-card-session.d.ts +0 -1
  16. package/dist/card/run-card-session.js +0 -6
  17. package/dist/card/run-renderer.d.ts +11 -10
  18. package/dist/card/run-renderer.js +198 -35
  19. package/dist/card/run-state.d.ts +29 -2
  20. package/dist/card/run-state.js +23 -4
  21. package/dist/card/text-truncate.d.ts +3 -5
  22. package/dist/card/text-truncate.js +3 -5
  23. package/dist/card/tool-render.js +7 -21
  24. package/dist/common/tool-summary.d.ts +8 -0
  25. package/dist/common/tool-summary.js +38 -20
  26. package/dist/common/turn-failure.d.ts +13 -0
  27. package/dist/common/turn-failure.js +18 -0
  28. package/dist/index.js +13 -14
  29. package/dist/order/index.d.ts +2 -0
  30. package/dist/order/index.js +2 -0
  31. package/dist/router/card-helpers.js +23 -42
  32. package/dist/router/config/codex.js +2 -3
  33. package/dist/router/config/common/model-patch.d.ts +8 -0
  34. package/dist/router/config/common/model-patch.js +13 -0
  35. package/dist/router/config/common/render.js +30 -77
  36. package/dist/router/config/dsh.js +2 -1
  37. package/dist/router/config/kimi.js +3 -3
  38. package/dist/router/index.d.ts +0 -11
  39. package/dist/router/index.js +119 -360
  40. package/dist/router/order-exec-dispatch.js +2 -1
  41. package/dist/router/usage-format.d.ts +5 -0
  42. package/dist/router/usage-format.js +1 -1
  43. package/dist/runner/common/defaults.d.ts +2 -0
  44. package/dist/runner/common/defaults.js +3 -0
  45. package/dist/runner/common/jsonrpc/connection-manager.js +1 -1
  46. package/dist/runner/dsh/client.d.ts +15 -0
  47. package/dist/runner/dsh/client.js +28 -2
  48. package/dist/runner/dsh/goal-view.d.ts +36 -0
  49. package/dist/runner/dsh/goal-view.js +106 -0
  50. package/dist/runner/dsh/runner.js +51 -2
  51. package/dist/runner/index.d.ts +1 -1
  52. package/dist/runner/types.d.ts +59 -1
  53. package/dist/runner/types.js +9 -0
  54. package/dist/session/claude/session-index.d.ts +0 -4
  55. package/dist/session/claude/session-index.js +0 -8
  56. package/dist/session/dsh/sessions.js +58 -2
  57. package/package.json +1 -1
@@ -1,11 +1,13 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { sleep } from '../common/sleep.js';
3
+ import { isContentFilterFailure } from '../common/turn-failure.js';
3
4
  import { resolveFinalUsage, errorMessage } from './final-usage.js';
4
5
  import { getLogger } from '../logger/index.js';
5
6
  import { RunCardSession } from '../card/run-card-session.js';
6
7
  import { renderRunCard } from '../card/run-renderer.js';
7
8
  import { renderBashCard } from '../card/bash-renderer.js';
8
9
  import { BashCardSession, capBashOutput } from '../card/bash-card-session.js';
10
+ import { isRunTerminal } from '../card/run-state.js';
9
11
  import { buildSessionHistoryCard } from '../router/card-helpers.js';
10
12
  import { agentDisplayName, resumeUseButton } from '../card/card-shared.js';
11
13
  import { enforceCardBudget } from '../card/card-budget.js';
@@ -151,12 +153,11 @@ export class Bridge {
151
153
  */
152
154
  compactableRuns = new Map();
153
155
  /**
154
- * 终态卡片的渲染快照(runId → state + options),供「详情」在 run 结束后
155
- * 仍能二次渲染。
156
- *
157
- * run 结束时 activeRuns 被清空,而卡片上的「详情」按钮还活着;没有快照,
158
- * 点终态卡上的「详情」只能得到一句「已失效」。只保留最近若干条:单条快照
159
- * 受 MAX_BLOCKS 约束(几十 KB 以内),用户只会点刚收到的卡。
156
+ * 终态卡片的渲染快照(runId → state + options):run 结束后 activeRuns 已
157
+ * 清空,而卡片上的「⏹ 停止」后台任务按钮(job.kill)还活着,需要按 runId
158
+ * 反查该 run 的 sessionId/cwd 才能停对任务。只保留最近若干条:单条快照受
159
+ * MAX_BLOCKS 约束(几十 KB 以内),用户只会点刚收到的卡;超出窗口的旧卡
160
+ * 会得到明确的「记录已回收」提示,不假装任务仍在。
160
161
  */
161
162
  runCardSnapshots = new Map();
162
163
  static RUN_CARD_SNAPSHOT_MAX = 8;
@@ -188,11 +189,23 @@ export class Bridge {
188
189
  * creates a NEW runner under a different key without evicting the old one.
189
190
  * This eliminates the need for clearRunners() on agent switch.
190
191
  */
191
- getRunner(cwd, kind = this.config.defaultAgent) {
192
+ getRunner(cwd, kind = this.config.defaultAgent, opts) {
192
193
  // Fix 4: Check for runner under (cwd, kind) key
193
194
  const workspaceMap = this.runners.get(cwd);
194
- if (workspaceMap?.has(kind)) {
195
- return workspaceMap.get(kind);
195
+ const slotKey = `${cwd}\u0000${kind}`;
196
+ const cached = workspaceMap?.get(kind);
197
+ // 活跃 run 期间不替换:queue 链可能在 interrupt 的 await 窗口里推进,把
198
+ // 正在跑的那一轮手里的实例换掉会脱管长驻连接。
199
+ const replaceStaleInstance = cached !== undefined &&
200
+ opts?.replaceStale === true &&
201
+ this.staleRunnerSlots.has(slotKey) &&
202
+ !this.activeRuns.has(cwd);
203
+ if (cached && !replaceStaleInstance) {
204
+ return cached;
205
+ }
206
+ if (replaceStaleInstance) {
207
+ getLogger().info(`[lark-remote] replace stale runner before run cwd=${cwd} kind=${kind}`);
208
+ this.evictRunnerSlot(cwd, kind);
196
209
  }
197
210
  // 缓存超过上限且是新工作区时才回收死槽位——刚创建未 run
198
211
  // 的 runner 同样是 isRunning=false,小缓存下立即回收会破坏「创建即注册」
@@ -217,12 +230,16 @@ export class Bridge {
217
230
  if (runner && typeof runner.registerExitHandlers === 'function') {
218
231
  runner.registerExitHandlers();
219
232
  }
220
- // Store in nested map: Map<cwd, Map<kind, Runner>>
221
- if (!workspaceMap) {
233
+ // Store in nested map: Map<cwd, Map<kind, Runner>>。必须重新取一次:
234
+ // replaceStale 刚把槽位 evict 掉,上面的局部 workspaceMap 可能已随空槽
235
+ // 从 this.runners 移除——继续写它会生成一个「缓存里查不到」的 runner,
236
+ // 下一次 getRunner 会再建一个(实例数翻倍)。
237
+ const currentWorkspaceMap = this.runners.get(cwd);
238
+ if (!currentWorkspaceMap) {
222
239
  this.runners.set(cwd, new Map([[kind, runner]]));
223
240
  }
224
241
  else {
225
- workspaceMap.set(kind, runner);
242
+ currentWorkspaceMap.set(kind, runner);
226
243
  }
227
244
  return runner;
228
245
  }
@@ -357,7 +374,7 @@ export class Bridge {
357
374
  if (evicted && typeof evicted.unregisterExitHandlers === 'function') {
358
375
  evicted.unregisterExitHandlers();
359
376
  }
360
- // 2026-10-11:先标记 orphan 再 dispose。evict 与「run 刚拿走本实例」竞态时
377
+ // 先标记 orphan 再 dispose:evict 与「run 刚拿走本实例」竞态时
361
378
  // (queue 链在 interruptCurrentRun await 期间推进),dispose 只能关掉当时
362
379
  // 已存在的连接,run 后续 acquire 的新连接会挂在无人托管的实例上——orphan
363
380
  // 标记让 executeTurn 在 run 结束时主动 dispose 这批连接,而不是挂 idle
@@ -453,7 +470,15 @@ export class Bridge {
453
470
  }
454
471
  /** Remove a task from the queue by messageId. Returns true if found and removed. */
455
472
  removeFromQueue(cwd, messageId) {
456
- return this.queueManager.removeFromQueue(cwd, messageId);
473
+ const removed = this.queueManager.removeFromQueue(cwd, messageId);
474
+ // 单一收口:撤销卡片跟着移除动作一起发生,不再依赖调用方记得配对调
475
+ // updateQueueCardToCancelled(漏配对会让卡片永久停在「等待中」,而映射
476
+ // 已被消费,之后再也修不回来)。fire-and-forget 保持
477
+ // 「移除同步发生」的队列不变式——queue.immediate 依赖先清空再让出事件循环。
478
+ if (removed) {
479
+ void this.updateQueueCardToCancelled(cwd, messageId);
480
+ }
481
+ return removed;
457
482
  }
458
483
  /**
459
484
  * One-shot: replace the execution closure of an existing queued task in
@@ -596,99 +621,146 @@ export class Bridge {
596
621
  * "executing immediately" when a turn may still be running in the host.
597
622
  */
598
623
  async interruptCurrentRunDetailed(input) {
599
- // Find the active claude run matching this user/chat
624
+ // 先收集全部命中项再逐个停。activeRuns 以 cwd 为键、多个 workspace 可以
625
+ // 并行跑;旧实现停在「第一条命中」就 return —— 两个 workspace 并行时
626
+ // `/stop` 只停一个却回「已停止」,另一个无声继续。
627
+ // 限定 cwd(queue.immediate)或带 runId(停止卡按钮)时仍只有一条命中。
628
+ const activeTargets = [];
600
629
  for (const [cwd, active] of this.activeRuns) {
601
630
  if (input.cwd !== undefined && cwd !== input.cwd)
602
631
  continue;
603
- if (active.userId === input.userId && active.chatId === input.chatId) {
604
- if (input.runId && active.runId !== input.runId)
605
- continue;
606
- getLogger().info(`[lark-remote] interrupt hit runId=${active.runId} cwd=${cwd} userId=${input.userId}`);
607
- // Stop the EXACT runner instance that is running (carried by activeRun),
608
- // not whatever getRunner(cwd) would return now. clearRunners() may have
609
- // emptied the cache mid-run, in which case getRunner creates a fresh empty
610
- // runner whose stop() is a no-op and the real subprocess is never killed
611
- // (regression 2026-07-18: queue chain blocked forever).
612
- const runner = active.runner;
613
- // Bind the queue reset to THIS task's execution slot: the stop window
614
- // below may outlive the task's settle, letting the serial chain advance
615
- // to a successor that begins executing before we resume. An
616
- // unconditional reset would then zero the successor's count and mark
617
- // its slot interrupted, hiding it from queue cards.
618
- const stoppedSlot = this.queueManager.getExecutingSlot(cwd);
619
- const [, stopResult] = await Promise.allSettled([
620
- // 用户主动中断(/stop、stop 卡按钮、queue.immediate)显式打标
621
- // interruptedReason,与审批超时自动取消(approval_timeout)、审批
622
- // 卡取消(approval_cancelled)在终态数据上可区分(方案 A)。
623
- active.session.finish('interrupted', { interruptedReason: 'user_stop' }),
624
- runner.stop({ immediate: true }),
625
- ]);
626
- const stop = stopResult.status === 'fulfilled' &&
627
- stopResult.value !== null &&
628
- typeof stopResult.value === 'object'
629
- ? stopResult.value
630
- : undefined;
631
- // The chain may have advanced while the stop was in flight: a new run
632
- // (from a task that began during the stop window) may now occupy this
633
- // cwd. Only delete the entry we actually stopped.
634
- if (this.activeRuns.get(cwd) === active) {
635
- this.activeRuns.delete(cwd);
636
- }
637
- // Fix 4: Clean up this specific (cwd, agentKind) runner slot
638
- // (eviction must also unregister the runner from the exit dispatcher)
639
- // evict by the run's captured agentKind, not the live
640
- // defaultAgent (may have changed mid-run via /config).
641
- //
642
- // 2026-10-11:workspace-lifetime runner 不能在这里立即 evict+dispose。
643
- // interruptCurrentRun 的 await 窗口内 queue 链会推进下一条消息,其
644
- // getRunner 可能刚拿走本实例(缓存命中先于本行执行)——立即 evict 会
645
- // 让那个 run 的长驻连接脱管:进程残留至 idle TTL 才回收,期间持有
646
- // agent 侧会话写锁(codex "thread already has an active writer",
647
- // 线上实测)。改为标 stale,由 finalizeRun 在下一次 run 结束时统一
648
- // evict+dispose;「stop 后下一 run 换新实例」的语义
649
- // 保留(stale 命中即最后一轮复用)。run-per-message runner 无长驻
650
- // 连接,保持立即 evict 不变。
651
- if (active.runner.lifetime === 'workspace') {
652
- this.staleRunnerSlots.add(`${cwd}\u0000${active.agentKind}`);
653
- }
654
- else {
655
- this.evictRunnerSlot(cwd, active.agentKind);
656
- }
657
- // Reset queue executing count: the task was interrupted externally, so
658
- // its promise-chain settle (.then/.catch) may lag behind the process
659
- // kill. Without this reset, subsequent messages would incorrectly show
660
- // as "排队中". resetExecutingCount also grants a skip-credit so the
661
- // stale settle does not double-decrement the count. Centralized here so
662
- // ALL interrupt paths (/stop, /t, card stop, queue.immediate) benefit.
663
- // When stoppedSlot is undefined (no active run matched), skip the reset —
664
- // there is no queue slot to reconcile. All production interrupt paths
665
- // (/stop, /t, card stop, queue.immediate) provide a defined stoppedSlot.
666
- if (stoppedSlot !== undefined) {
667
- this.queueManager.resetExecutingCount(cwd, stoppedSlot);
668
- }
669
- getLogger().info(`[lark-remote] interrupt stop done runId=${active.runId}`);
670
- return { stopped: true, agentKind: active.agentKind, ...(stop ? { stop } : {}) };
632
+ if (active.userId !== input.userId || active.chatId !== input.chatId)
633
+ continue;
634
+ if (input.runId && active.runId !== input.runId)
635
+ continue;
636
+ activeTargets.push([cwd, active]);
637
+ }
638
+ let stoppedCount = 0;
639
+ let agentKind;
640
+ let stop;
641
+ for (const [cwd, active] of activeTargets) {
642
+ const stopResult = await this.interruptActiveRun(cwd, active);
643
+ stoppedCount++;
644
+ agentKind ??= active.agentKind;
645
+ // 聚合口径:只要有一次 remote stop 没能确认,就按「可能仍在跑」上报,
646
+ // 不让后面的已确认结果盖掉它(调用方据此决定是否承诺"已停止")。
647
+ if (stopResult &&
648
+ (stop === undefined ||
649
+ (stop.status !== 'unconfirmed' && stopResult.status === 'unconfirmed'))) {
650
+ stop = stopResult;
671
651
  }
672
652
  }
673
653
  // Then check active bash runs (! commands). These bypass the serial queue
674
654
  // and are tracked separately; stop the per-run BashRunner directly.
675
- for (const [runId, b] of this.activeBashRuns) {
676
- if (input.cwd !== undefined && b.cwd !== input.cwd)
677
- continue;
678
- if (b.userId === input.userId && b.chatId === input.chatId) {
655
+ // `/stop`(无 runId、无 cwd)语义是「停掉这个会话的一切」,所以并行跑的
656
+ // `!` 命令也要停——旧实现命中 claude run 就 return,这些 bash 会被漏掉。
657
+ // 带范围的中断(停止卡 runId、queue.immediate 的 cwd)保持旧语义:只有
658
+ // 没有 claude run 命中时才回退到 bash,避免误杀用户并行跑的 `!` 任务。
659
+ const includeBashRuns = (input.runId === undefined && input.cwd === undefined) || activeTargets.length === 0;
660
+ const bashTargets = includeBashRuns
661
+ ? [...this.activeBashRuns.entries()].filter(([runId, b]) => {
662
+ if (input.cwd !== undefined && b.cwd !== input.cwd)
663
+ return false;
664
+ if (b.userId !== input.userId || b.chatId !== input.chatId)
665
+ return false;
679
666
  if (input.runId && runId !== input.runId)
680
- continue;
681
- getLogger().info(`[lark-remote] interrupt hit bash runId=${runId} cwd=${b.cwd} userId=${input.userId}`);
682
- await b.bashRunner.stop({ immediate: true });
683
- // Update card to show interrupted state
684
- await b.cardSession.finish('interrupted', { exitCode: -1 });
685
- this.activeBashRuns.delete(runId);
686
- getLogger().info(`[lark-remote] interrupt bash stop done runId=${runId}`);
687
- return { stopped: true };
688
- }
667
+ return false;
668
+ return true;
669
+ })
670
+ : [];
671
+ for (const [runId, b] of bashTargets) {
672
+ getLogger().info(`[lark-remote] interrupt hit bash runId=${runId} cwd=${b.cwd} userId=${input.userId}`);
673
+ await b.bashRunner.stop({ immediate: true });
674
+ // Update card to show interrupted state
675
+ await b.cardSession.finish('interrupted', { exitCode: -1 });
676
+ this.activeBashRuns.delete(runId);
677
+ getLogger().info(`[lark-remote] interrupt bash stop done runId=${runId}`);
678
+ stoppedCount++;
679
+ }
680
+ if (stoppedCount === 0) {
681
+ getLogger().debug(`[lark-remote] interrupt miss userId=${input.userId}`);
682
+ return { stopped: false };
683
+ }
684
+ return {
685
+ stopped: true,
686
+ stoppedCount,
687
+ ...(agentKind ? { agentKind } : {}),
688
+ ...(stop ? { stop } : {}),
689
+ };
690
+ }
691
+ /**
692
+ * 停掉一条已注册的 active run:置终态、停 runner、摘槽位、标 stale、
693
+ * 归零该 cwd 的队列执行计数。返回 runner 的停止结果(DSH 才有)。
694
+ *
695
+ * 只做「这一条 run」的事——跨多个 workspace 的遍历在调用方,避免再次
696
+ * 出现「停第一条就 return」的漏停。
697
+ */
698
+ async interruptActiveRun(cwd, active) {
699
+ getLogger().info(`[lark-remote] interrupt hit runId=${active.runId} cwd=${cwd} userId=${active.userId}`);
700
+ // Stop the EXACT runner instance that is running (carried by activeRun),
701
+ // not whatever getRunner(cwd) would return now. clearRunners() may have
702
+ // emptied the cache mid-run, in which case getRunner creates a fresh empty
703
+ // runner whose stop() is a no-op and the real subprocess is never killed
704
+ // (regression 2026-07-18: queue chain blocked forever).
705
+ const runner = active.runner;
706
+ // Bind the queue reset to THIS task's execution slot: the stop window
707
+ // below may outlive the task's settle, letting the serial chain advance
708
+ // to a successor that begins executing before we resume. An
709
+ // unconditional reset would then zero the successor's count and mark
710
+ // its slot interrupted, hiding it from queue cards.
711
+ const stoppedSlot = this.queueManager.getExecutingSlot(cwd);
712
+ const [, stopResult] = await Promise.allSettled([
713
+ // 用户主动中断(/stop、stop 卡按钮、queue.immediate)显式打标
714
+ // interruptedReason,与审批超时自动取消(approval_timeout)、审批
715
+ // 卡取消(approval_cancelled)在终态数据上可区分(方案 A)。
716
+ active.session.finish('interrupted', { interruptedReason: 'user_stop' }),
717
+ runner.stop({ immediate: true }),
718
+ ]);
719
+ const stop = stopResult.status === 'fulfilled' &&
720
+ stopResult.value !== null &&
721
+ typeof stopResult.value === 'object'
722
+ ? stopResult.value
723
+ : undefined;
724
+ // The chain may have advanced while the stop was in flight: a new run
725
+ // (from a task that began during the stop window) may now occupy this
726
+ // cwd. Only delete the entry we actually stopped.
727
+ if (this.activeRuns.get(cwd) === active) {
728
+ this.activeRuns.delete(cwd);
729
+ }
730
+ // Fix 4: Clean up this specific (cwd, agentKind) runner slot
731
+ // (eviction must also unregister the runner from the exit dispatcher)
732
+ // evict by the run's captured agentKind, not the live
733
+ // defaultAgent (may have changed mid-run via /config).
734
+ //
735
+ // workspace-lifetime runner 不能在中断路径里立即 evict+dispose。
736
+ // interruptCurrentRun 的 await 窗口内 queue 链会推进下一条消息,其
737
+ // getRunner 可能刚拿走本实例(缓存命中先于本行执行)——立即 evict 会
738
+ // 让那个 run 的长驻连接脱管:进程残留至 idle TTL 才回收,期间持有
739
+ // agent 侧会话写锁(codex "thread already has an active writer",
740
+ // 线上实测)。改为标 stale:下一次「即将开跑」的 getRunner 在槽位空闲时
741
+ // 换新实例(见 getRunner 的 replaceStale),本次 run 的 finalize 收尾时
742
+ // 也会消费该标记;两者都保证不会有活跃 run 使用被 dispose 的实例。
743
+ // run-per-message runner 无长驻连接,保持立即 evict 不变。
744
+ if (active.runner.lifetime === 'workspace') {
745
+ this.staleRunnerSlots.add(`${cwd}\u0000${active.agentKind}`);
689
746
  }
690
- getLogger().debug(`[lark-remote] interrupt miss userId=${input.userId}`);
691
- return { stopped: false };
747
+ else {
748
+ this.evictRunnerSlot(cwd, active.agentKind);
749
+ }
750
+ // Reset queue executing count: the task was interrupted externally, so
751
+ // its promise-chain settle (.then/.catch) may lag behind the process
752
+ // kill. Without this reset, subsequent messages would incorrectly show
753
+ // as "排队中". resetExecutingCount also grants a skip-credit so the
754
+ // stale settle does not double-decrement the count. Centralized here so
755
+ // ALL interrupt paths (/stop, /t, card stop, queue.immediate) benefit.
756
+ // When stoppedSlot is undefined (no active run matched), skip the reset —
757
+ // there is no queue slot to reconcile. All production interrupt paths
758
+ // (/stop, /t, card stop, queue.immediate) provide a defined stoppedSlot.
759
+ if (stoppedSlot !== undefined) {
760
+ this.queueManager.resetExecutingCount(cwd, stoppedSlot);
761
+ }
762
+ getLogger().info(`[lark-remote] interrupt stop done runId=${active.runId}`);
763
+ return stop;
692
764
  }
693
765
  /** Send a result message to the user (§7). Returns true on success, false on failure. */
694
766
  async sendResult(result, ctx) {
@@ -906,7 +978,7 @@ export class Bridge {
906
978
  // that will run is captured into activeRun. interruptCurrentRun stops THAT
907
979
  // instance directly -- never a fresh runner re-resolved from a cache that
908
980
  // clearRunners() may have emptied mid-run (regression 2026-07-18).
909
- const runner = this.getRunner(cwd, agentKind);
981
+ const runner = this.getRunner(cwd, agentKind, { replaceStale: true });
910
982
  // Step 1: Create run session (runId, cardSession, activeRun tracking)
911
983
  const { runId, cardSession, activeRun } = this.createRunSession(ctx, cwd, runner, agentKind);
912
984
  // Step 2: Consume the agent stream (runner.run loop, event processing, state updates)
@@ -919,6 +991,66 @@ export class Bridge {
919
991
  // Step 3: Finalize the run (error handling, fallback card, completion notification, cleanup)
920
992
  await this.finalizeRun(cardSession, activeRun, ctx, cwd);
921
993
  }
994
+ // Step 4: 服务方内容审核截断(`finish_reason: content_filter`)后自动续跑一次。
995
+ // 放在 finalize 之后:activeRuns 槽位已释放,续跑能正常开新卡片。
996
+ await this.retryAfterContentFilter({
997
+ cardSession,
998
+ ctx,
999
+ cwd,
1000
+ agentKind,
1001
+ sessionId,
1002
+ sessionEpochAtStart,
1003
+ autoRetried: opts?.autoRetry === true,
1004
+ });
1005
+ }
1006
+ /**
1007
+ * 内容审核截断后的单次自动续跑。
1008
+ *
1009
+ * DSH 把上游 `finish_reason: content_filter` 归一成 PI_AI_ERROR:既不在默认
1010
+ * 重试码里(EMPTY_RESPONSE/RATE_LIMIT/SERVER/TIMEOUT/TRANSPORT),也不会换模型,
1011
+ * 于是一次(多数是误报的)判定就把整个 turn 判死。2026-10-21 现场:60 步、
1012
+ * 10 分钟的 turn 在最后一步被截断,已写入的文件还在,用户只看到「运行出错」
1013
+ * 加一句英文报文。
1014
+ *
1015
+ * 桥侧不改 dsh 的重试策略,改用「同一 session 补一条继续提示」:审核是概率
1016
+ * 判定,重采样通常能过;模型从历史里接着做,不必重跑已完成的工作。
1017
+ * 只续一次——续跑仍被截断就停在错误卡,不进入无限重试。
1018
+ */
1019
+ async retryAfterContentFilter(params) {
1020
+ const state = params.cardSession.currentState;
1021
+ if (state.terminal !== 'error' || !isContentFilterFailure(state.errorMsg))
1022
+ return;
1023
+ if (params.autoRetried) {
1024
+ getLogger().info(`[lark-remote] content_filter auto-retry already used runId=${state.runId} cwd=${params.cwd}`);
1025
+ return;
1026
+ }
1027
+ // 会话指针在 run 期间被 /new、/resume 等移动过:续跑会接到另一个会话上,
1028
+ // 「从断点继续」的语义不再成立,宁可不做(错误卡已经把话说清楚)。
1029
+ const epochNow = this.sessionStore.getSessionEpoch(params.ctx.userId, params.agentKind);
1030
+ if (epochNow !== params.sessionEpochAtStart) {
1031
+ getLogger().info(`[lark-remote] content_filter auto-retry skipped: session epoch moved runId=${state.runId}`);
1032
+ return;
1033
+ }
1034
+ getLogger().warn(`[lark-remote] content_filter detected, auto-retrying once runId=${state.runId} cwd=${params.cwd}`);
1035
+ await this.sendResult({
1036
+ text: '🔁 上一回合被服务方内容审核截断(content_filter,通常是误报),已自动重试一次。',
1037
+ }, params.ctx);
1038
+ try {
1039
+ await this.forwardToClaude('(自动继续)上一回合因服务方内容审核被截断(content_filter,通常是误报)。' +
1040
+ '请从上次中断的位置继续,不要重复已经完成的步骤。', params.ctx, {
1041
+ cwdOverride: params.cwd,
1042
+ binding: {
1043
+ agent: params.agentKind,
1044
+ sessionId: params.sessionId,
1045
+ sessionEpoch: params.sessionEpochAtStart,
1046
+ },
1047
+ autoRetry: true,
1048
+ });
1049
+ }
1050
+ catch (err) {
1051
+ // 续跑自身失败不应冒泡成未捕获异常:用户已经收到错误卡与重试通知。
1052
+ getLogger().error(`[lark-remote] content_filter auto-retry failed runId=${state.runId}`, err);
1053
+ }
922
1054
  }
923
1055
  /**
924
1056
  * Step 1 of forwardToClaude: Create run session components.
@@ -940,6 +1072,10 @@ export class Bridge {
940
1072
  // Compact 按钮能力门控与 resume 卡 / auto-resume 卡一致(hasRunCompact
941
1073
  // 鸭子探测):claude(stream-json,无 turn_started)也能拿到按钮。
942
1074
  compactSupported: this.hasRunCompact(cwd, agentKind),
1075
+ // 本轮 init 到达前,按钮是否仍有可压缩目标:开跑时 store 里已有会话
1076
+ // 才置 true。false 时按钮只在 state.sessionId 出现后渲染——否则
1077
+ // init 前被 /stop 的卡片会留下一个注定失败的按钮。
1078
+ sessionFallbackAvailable: !!this.sessionStore.getSessionId(ctx.userId, agentKind),
943
1079
  },
944
1080
  });
945
1081
  const activeRun = {
@@ -1110,10 +1246,7 @@ export class Bridge {
1110
1246
  // 只有终态才阻止事件处理。finalizing 是非终态,应该继续处理
1111
1247
  // 这样在 result 事件后,后台任务输出仍能被正确处理
1112
1248
  const terminal = cardSession.currentState.terminal;
1113
- if (terminal === 'done' ||
1114
- terminal === 'error' ||
1115
- terminal === 'interrupted' ||
1116
- terminal === 'idle_timeout') {
1249
+ if (isRunTerminal(terminal)) {
1117
1250
  continue;
1118
1251
  }
1119
1252
  if (event.type === 'system' && event.subtype === 'init') {
@@ -1384,7 +1517,16 @@ export class Bridge {
1384
1517
  // 终态,不再归因于 Agent 错误;error 保持「运行出错」;其余为 done。
1385
1518
  const subtype = finalState.resultSubtype;
1386
1519
  const isError = subtype === 'error' || !!finalState.errorMsg;
1387
- const terminal = isError ? 'error' : subtype === 'interrupted' ? 'interrupted' : 'done';
1520
+ // goal 仍 active = 宿主会自动起下一轮:这不是"完成",而是"本轮结束"。
1521
+ // 用独立终态而不是 done,既不改 header 文案也不用谎报。
1522
+ const goalContinues = !isError && subtype !== 'interrupted' && finalState.goal?.phase === 'active';
1523
+ const terminal = isError
1524
+ ? 'error'
1525
+ : subtype === 'interrupted'
1526
+ ? 'interrupted'
1527
+ : goalContinues
1528
+ ? 'goal_continuing'
1529
+ : 'done';
1388
1530
  await cardSession.finish(terminal, {
1389
1531
  resultSubtype: subtype,
1390
1532
  errorMsg: finalState.errorMsg,
@@ -1487,6 +1629,10 @@ export class Bridge {
1487
1629
  // runner 能力门控 Compact 按钮(缺省 undefined = 显示按钮,
1488
1630
  // DSH 等无 runCompact 的 agent 会渲染出点了报错的死按钮)。
1489
1631
  compactSupported: this.hasRunCompact(cwd, activeRun.agentKind),
1632
+ // 终态卡按「此刻是否真的有可压缩会话」渲染按钮,与记账用的是同一
1633
+ // 判据(本轮 sessionId 或 store 回退),避免按钮可见但记录不存在。
1634
+ sessionFallbackAvailable: !!(cardSession.currentState.sessionId ??
1635
+ this.sessionStore.getSessionId(ctx.userId, activeRun.agentKind)),
1490
1636
  };
1491
1637
  this.rememberRunCardSnapshot(runId, cardSession.currentState, finalRenderOptions, cwd, activeRun.agentKind);
1492
1638
  let runCardSent = false;
@@ -1513,6 +1659,15 @@ export class Bridge {
1513
1659
  // at the sendResult call site; if it throws, the run must still release
1514
1660
  // its activeRuns slot — otherwise the cwd is permanently busy
1515
1661
  // (every later message busy-dropped until a manual /stop).
1662
+ //
1663
+ // 记账与槽位所有权必须分开:/stop、停止卡按钮、
1664
+ // queue.immediate 都会先在 interruptCurrentRunDetailed 里删掉
1665
+ // activeRuns[cwd] 并把卡片置 interrupted,等收尾走到这里时下面的守卫
1666
+ // 已为 false。记录若跟着守卫走,中断后的终态卡上 Compact 按钮必然查不到
1667
+ // runId,只能回「该任务已结束或不属于当前会话」。守卫只回答「cwd 槽位还
1668
+ // 归不归本 run」(决定能不能删槽位/evict runner),记账回答「这次 run
1669
+ // 结束了吗、用的哪个 sessionId」——后者与所有权无关,放在守卫外。
1670
+ this.recordCompactableRunForTerminal(cardSession, activeRun, ctx);
1516
1671
  if (this.activeRuns.get(cwd) === activeRun) {
1517
1672
  this.activeRuns.delete(cwd);
1518
1673
  // Workspace-lifetime runners (codex app-server) keep their persistent
@@ -1528,25 +1683,6 @@ export class Bridge {
1528
1683
  this.evictRunnerSlot(cwd, activeRun.agentKind);
1529
1684
  }
1530
1685
  this.staleRunnerSlots.delete(slotKey);
1531
- // Compact 卡片需要 runId + sessionId;record 供终态卡上的 Compact 按钮
1532
- // 校验与取参(activeRuns 已清空,无法再查到)。异常退出(error/
1533
- // interrupted/idle_timeout)同样记录——上下文往往更大,压缩后再续。
1534
- // 写入条件从「codex 终态」放宽为「runner 有 runCompact 的终态」
1535
- // (codex app-server + kimi acp 均满足)。
1536
- const runTerminal = cardSession.currentState.terminal;
1537
- const runnerHasCompact = runnerSupportsCompact(activeRun.runner, activeRun.agentKind);
1538
- if (runnerHasCompact &&
1539
- activeRun.runner.lifetime === 'workspace' &&
1540
- (runTerminal === 'done' ||
1541
- runTerminal === 'error' ||
1542
- runTerminal === 'interrupted' ||
1543
- runTerminal === 'idle_timeout')) {
1544
- const compactSessionId = cardSession.currentState.sessionId ??
1545
- this.sessionStore.getSessionId(ctx.userId, activeRun.agentKind);
1546
- if (compactSessionId) {
1547
- this.recordCompactableRun(cwd, activeRun.runId, compactSessionId, activeRun.agentKind);
1548
- }
1549
- }
1550
1686
  getLogger().info(`[lark-remote] activeRuns.delete cwd=${cwd} runId=${runId}`);
1551
1687
  }
1552
1688
  }
@@ -1707,6 +1843,44 @@ export class Bridge {
1707
1843
  byRunId.delete(oldest);
1708
1844
  }
1709
1845
  }
1846
+ /**
1847
+ * 终态 Compact 记账:把本次 run 实际使用的 sessionId 记进
1848
+ * {@link compactableRuns},供终态卡上的 Compact 按钮按 runId 反查。
1849
+ *
1850
+ * 调用点固定在 `finalizeRun` 的 `finally` 里、**槽位所有权守卫之外**。
1851
+ * 所有权守卫只回答「cwd 槽位还归不归本 run」(决定能否删槽位 / evict
1852
+ * runner);记账回答「本次 run 结束了吗、用的哪个 sessionId」。两者在不同
1853
+ * 维度上:外部中断(`/stop`、停止卡按钮、`queue.immediate`)会先摘槽位,
1854
+ * 记账若跟着守卫走就永远不会落,中断卡片的按钮必然反查不到(中断
1855
+ * 后点 Compact 一律回「该任务已结束或不属于当前会话」)。
1856
+ *
1857
+ * 覆盖所有终态入口(正常结束、error、interrupted、idle_timeout、
1858
+ * goal_continuing)——这些卡片都渲染 Compact 按钮(`goal` 仍 active 时
1859
+ * 除外)。记账失败不能影响清理,故内部兜错。
1860
+ */
1861
+ recordCompactableRunForTerminal(cardSession, activeRun, ctx) {
1862
+ try {
1863
+ if (!runnerSupportsCompact(activeRun.runner, activeRun.agentKind))
1864
+ return;
1865
+ if (activeRun.runner.lifetime !== 'workspace')
1866
+ return;
1867
+ const terminal = cardSession.currentState.terminal;
1868
+ // 与事件消费守卫、Compact 按钮共用同一份终态集合(run-state.ts)。
1869
+ if (!isRunTerminal(terminal))
1870
+ return;
1871
+ // 卡片状态优先(本轮 init/turn_started 写入);缺失时回退 store——
1872
+ // 启动失败但已 /stop 的 run 没有自己的 init,压缩此前会话仍是有意义
1873
+ // 的动作。
1874
+ const compactSessionId = cardSession.currentState.sessionId ??
1875
+ this.sessionStore.getSessionId(ctx.userId, activeRun.agentKind);
1876
+ if (!compactSessionId)
1877
+ return;
1878
+ this.recordCompactableRun(activeRun.cwd, activeRun.runId, compactSessionId, activeRun.agentKind);
1879
+ }
1880
+ catch (err) {
1881
+ getLogger().warn(`[lark-remote] compact record failed runId=${activeRun.runId}: ${errorMessage(err)}`);
1882
+ }
1883
+ }
1710
1884
  /**
1711
1885
  * 按 runId 反查可压缩 run 记录(跨 cwd 扫描)。
1712
1886
  *
@@ -1753,7 +1927,7 @@ export class Bridge {
1753
1927
  return;
1754
1928
  }
1755
1929
  // Check that the runner has runCompact method(声明表 && runner 实现)
1756
- const runner = this.getRunner(cwd, target.agentKind);
1930
+ const runner = this.getRunner(cwd, target.agentKind, { replaceStale: true });
1757
1931
  if (!runnerSupportsCompact(runner, target.agentKind)) {
1758
1932
  await this.sendResult({ text: '⚠️ 当前运行模式不支持 Compact' }, ctx);
1759
1933
  return;
@@ -1874,10 +2048,7 @@ export class Bridge {
1874
2048
  // 已终态(/stop、空闲看门狗)后不再消费事件:与 runAgentStreamToEnd 同口径,
1875
2049
  // 否则迟到的 result 会把终态卡改写成「已完成」。
1876
2050
  const cardTerminal = cardSession.currentState.terminal;
1877
- if (cardTerminal === 'done' ||
1878
- cardTerminal === 'error' ||
1879
- cardTerminal === 'interrupted' ||
1880
- cardTerminal === 'idle_timeout') {
2051
+ if (isRunTerminal(cardTerminal)) {
1881
2052
  continue;
1882
2053
  }
1883
2054
  if (event.type === 'result') {
@@ -1928,7 +2099,7 @@ export class Bridge {
1928
2099
  // 终态守卫(与 runAgentStreamToEnd 同口径):running/finalizing 才转 error,
1929
2100
  // 已终态(interrupted/idle_timeout)保留首终态,不被迟到的异常改写。
1930
2101
  const catchTerminal = cardSession.currentState.terminal;
1931
- if (catchTerminal === 'running' || catchTerminal === 'finalizing') {
2102
+ if (!isRunTerminal(catchTerminal)) {
1932
2103
  await cardSession.finish('error', { errorMsg: errorMessage(err) });
1933
2104
  }
1934
2105
  else {
@@ -261,6 +261,12 @@ export declare class QueueManager {
261
261
  removeFromQueue(cwd: string, messageId: string): boolean;
262
262
  /** Update queue card to "cancelled" status when user clicks 撤销. */
263
263
  updateQueueCardToCancelled(cwd: string, messageId: string): Promise<void>;
264
+ /**
265
+ * 卡片 PATCH 带一次重试:一次网络抖动不该让排队卡永久停在旧状态——映射
266
+ * 会在本次调用结束时被清理,之后没有任何自动修正机会。
267
+ * 第二次仍失败则向上抛,由调用方 catch 记日志(行为与改前一致)。
268
+ */
269
+ private updateCardWithRetry;
264
270
  /** Get task metadata from queue. */
265
271
  getQueuedTask(cwd: string, messageId: string): QueuedTask | undefined;
266
272
  /**