lark-remote 0.5.8 → 0.5.10

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.
@@ -7,6 +7,7 @@ import { RunCardSession } from '../card/run-card-session.js';
7
7
  import { renderRunCard } from '../card/run-renderer.js';
8
8
  import { renderBashCard } from '../card/bash-renderer.js';
9
9
  import { BashCardSession, capBashOutput } from '../card/bash-card-session.js';
10
+ import { isRunTerminal } from '../card/run-state.js';
10
11
  import { buildSessionHistoryCard } from '../router/card-helpers.js';
11
12
  import { agentDisplayName, resumeUseButton } from '../card/card-shared.js';
12
13
  import { enforceCardBudget } from '../card/card-budget.js';
@@ -152,12 +153,11 @@ export class Bridge {
152
153
  */
153
154
  compactableRuns = new Map();
154
155
  /**
155
- * 终态卡片的渲染快照(runId → state + options),供「详情」在 run 结束后
156
- * 仍能二次渲染。
157
- *
158
- * run 结束时 activeRuns 被清空,而卡片上的「详情」按钮还活着;没有快照,
159
- * 点终态卡上的「详情」只能得到一句「已失效」。只保留最近若干条:单条快照
160
- * 受 MAX_BLOCKS 约束(几十 KB 以内),用户只会点刚收到的卡。
156
+ * 终态卡片的渲染快照(runId → state + options):run 结束后 activeRuns 已
157
+ * 清空,而卡片上的「⏹ 停止」后台任务按钮(job.kill)还活着,需要按 runId
158
+ * 反查该 run 的 sessionId/cwd 才能停对任务。只保留最近若干条:单条快照受
159
+ * MAX_BLOCKS 约束(几十 KB 以内),用户只会点刚收到的卡;超出窗口的旧卡
160
+ * 会得到明确的「记录已回收」提示,不假装任务仍在。
161
161
  */
162
162
  runCardSnapshots = new Map();
163
163
  static RUN_CARD_SNAPSHOT_MAX = 8;
@@ -189,11 +189,23 @@ export class Bridge {
189
189
  * creates a NEW runner under a different key without evicting the old one.
190
190
  * This eliminates the need for clearRunners() on agent switch.
191
191
  */
192
- getRunner(cwd, kind = this.config.defaultAgent) {
192
+ getRunner(cwd, kind = this.config.defaultAgent, opts) {
193
193
  // Fix 4: Check for runner under (cwd, kind) key
194
194
  const workspaceMap = this.runners.get(cwd);
195
- if (workspaceMap?.has(kind)) {
196
- 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);
197
209
  }
198
210
  // 缓存超过上限且是新工作区时才回收死槽位——刚创建未 run
199
211
  // 的 runner 同样是 isRunning=false,小缓存下立即回收会破坏「创建即注册」
@@ -218,12 +230,16 @@ export class Bridge {
218
230
  if (runner && typeof runner.registerExitHandlers === 'function') {
219
231
  runner.registerExitHandlers();
220
232
  }
221
- // Store in nested map: Map<cwd, Map<kind, Runner>>
222
- 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) {
223
239
  this.runners.set(cwd, new Map([[kind, runner]]));
224
240
  }
225
241
  else {
226
- workspaceMap.set(kind, runner);
242
+ currentWorkspaceMap.set(kind, runner);
227
243
  }
228
244
  return runner;
229
245
  }
@@ -358,7 +374,7 @@ export class Bridge {
358
374
  if (evicted && typeof evicted.unregisterExitHandlers === 'function') {
359
375
  evicted.unregisterExitHandlers();
360
376
  }
361
- // 2026-10-11:先标记 orphan 再 dispose。evict 与「run 刚拿走本实例」竞态时
377
+ // 先标记 orphan 再 dispose:evict 与「run 刚拿走本实例」竞态时
362
378
  // (queue 链在 interruptCurrentRun await 期间推进),dispose 只能关掉当时
363
379
  // 已存在的连接,run 后续 acquire 的新连接会挂在无人托管的实例上——orphan
364
380
  // 标记让 executeTurn 在 run 结束时主动 dispose 这批连接,而不是挂 idle
@@ -454,7 +470,15 @@ export class Bridge {
454
470
  }
455
471
  /** Remove a task from the queue by messageId. Returns true if found and removed. */
456
472
  removeFromQueue(cwd, messageId) {
457
- 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;
458
482
  }
459
483
  /**
460
484
  * One-shot: replace the execution closure of an existing queued task in
@@ -464,6 +488,17 @@ export class Bridge {
464
488
  setTaskReplacement(cwd, messageId, task) {
465
489
  this.queueManager.setTaskReplacement(cwd, messageId, task);
466
490
  }
491
+ /**
492
+ * One-shot: hold the target's run-card start until the returned release is
493
+ * called. queue.immediate uses it so its final toast is sent before the run
494
+ * card — the toast would otherwise be the newest message and the chat list
495
+ * would stop previewing the run card's title changes. The queue slot itself
496
+ * still begins normally; only run-card creation waits (see
497
+ * QueueManager.holdTaskRunStart).
498
+ */
499
+ holdQueueTaskRun(cwd, messageId) {
500
+ return this.queueManager.holdTaskRunStart(cwd, messageId);
501
+ }
467
502
  /** Update queue card to "cancelled" status when user clicks 撤销. */
468
503
  async updateQueueCardToCancelled(cwd, messageId) {
469
504
  await this.queueManager.updateQueueCardToCancelled(cwd, messageId);
@@ -597,99 +632,146 @@ export class Bridge {
597
632
  * "executing immediately" when a turn may still be running in the host.
598
633
  */
599
634
  async interruptCurrentRunDetailed(input) {
600
- // Find the active claude run matching this user/chat
635
+ // 先收集全部命中项再逐个停。activeRuns 以 cwd 为键、多个 workspace 可以
636
+ // 并行跑;旧实现停在「第一条命中」就 return —— 两个 workspace 并行时
637
+ // `/stop` 只停一个却回「已停止」,另一个无声继续。
638
+ // 限定 cwd(queue.immediate)或带 runId(停止卡按钮)时仍只有一条命中。
639
+ const activeTargets = [];
601
640
  for (const [cwd, active] of this.activeRuns) {
602
641
  if (input.cwd !== undefined && cwd !== input.cwd)
603
642
  continue;
604
- if (active.userId === input.userId && active.chatId === input.chatId) {
605
- if (input.runId && active.runId !== input.runId)
606
- continue;
607
- getLogger().info(`[lark-remote] interrupt hit runId=${active.runId} cwd=${cwd} userId=${input.userId}`);
608
- // Stop the EXACT runner instance that is running (carried by activeRun),
609
- // not whatever getRunner(cwd) would return now. clearRunners() may have
610
- // emptied the cache mid-run, in which case getRunner creates a fresh empty
611
- // runner whose stop() is a no-op and the real subprocess is never killed
612
- // (regression 2026-07-18: queue chain blocked forever).
613
- const runner = active.runner;
614
- // Bind the queue reset to THIS task's execution slot: the stop window
615
- // below may outlive the task's settle, letting the serial chain advance
616
- // to a successor that begins executing before we resume. An
617
- // unconditional reset would then zero the successor's count and mark
618
- // its slot interrupted, hiding it from queue cards.
619
- const stoppedSlot = this.queueManager.getExecutingSlot(cwd);
620
- const [, stopResult] = await Promise.allSettled([
621
- // 用户主动中断(/stop、stop 卡按钮、queue.immediate)显式打标
622
- // interruptedReason,与审批超时自动取消(approval_timeout)、审批
623
- // 卡取消(approval_cancelled)在终态数据上可区分(方案 A)。
624
- active.session.finish('interrupted', { interruptedReason: 'user_stop' }),
625
- runner.stop({ immediate: true }),
626
- ]);
627
- const stop = stopResult.status === 'fulfilled' &&
628
- stopResult.value !== null &&
629
- typeof stopResult.value === 'object'
630
- ? stopResult.value
631
- : undefined;
632
- // The chain may have advanced while the stop was in flight: a new run
633
- // (from a task that began during the stop window) may now occupy this
634
- // cwd. Only delete the entry we actually stopped.
635
- if (this.activeRuns.get(cwd) === active) {
636
- this.activeRuns.delete(cwd);
637
- }
638
- // Fix 4: Clean up this specific (cwd, agentKind) runner slot
639
- // (eviction must also unregister the runner from the exit dispatcher)
640
- // evict by the run's captured agentKind, not the live
641
- // defaultAgent (may have changed mid-run via /config).
642
- //
643
- // 2026-10-11:workspace-lifetime runner 不能在这里立即 evict+dispose。
644
- // interruptCurrentRun 的 await 窗口内 queue 链会推进下一条消息,其
645
- // getRunner 可能刚拿走本实例(缓存命中先于本行执行)——立即 evict 会
646
- // 让那个 run 的长驻连接脱管:进程残留至 idle TTL 才回收,期间持有
647
- // agent 侧会话写锁(codex "thread already has an active writer",
648
- // 线上实测)。改为标 stale,由 finalizeRun 在下一次 run 结束时统一
649
- // evict+dispose;「stop 后下一 run 换新实例」的语义
650
- // 保留(stale 命中即最后一轮复用)。run-per-message runner 无长驻
651
- // 连接,保持立即 evict 不变。
652
- if (active.runner.lifetime === 'workspace') {
653
- this.staleRunnerSlots.add(`${cwd}\u0000${active.agentKind}`);
654
- }
655
- else {
656
- this.evictRunnerSlot(cwd, active.agentKind);
657
- }
658
- // Reset queue executing count: the task was interrupted externally, so
659
- // its promise-chain settle (.then/.catch) may lag behind the process
660
- // kill. Without this reset, subsequent messages would incorrectly show
661
- // as "排队中". resetExecutingCount also grants a skip-credit so the
662
- // stale settle does not double-decrement the count. Centralized here so
663
- // ALL interrupt paths (/stop, /t, card stop, queue.immediate) benefit.
664
- // When stoppedSlot is undefined (no active run matched), skip the reset —
665
- // there is no queue slot to reconcile. All production interrupt paths
666
- // (/stop, /t, card stop, queue.immediate) provide a defined stoppedSlot.
667
- if (stoppedSlot !== undefined) {
668
- this.queueManager.resetExecutingCount(cwd, stoppedSlot);
669
- }
670
- getLogger().info(`[lark-remote] interrupt stop done runId=${active.runId}`);
671
- return { stopped: true, agentKind: active.agentKind, ...(stop ? { stop } : {}) };
643
+ if (active.userId !== input.userId || active.chatId !== input.chatId)
644
+ continue;
645
+ if (input.runId && active.runId !== input.runId)
646
+ continue;
647
+ activeTargets.push([cwd, active]);
648
+ }
649
+ let stoppedCount = 0;
650
+ let agentKind;
651
+ let stop;
652
+ for (const [cwd, active] of activeTargets) {
653
+ const stopResult = await this.interruptActiveRun(cwd, active);
654
+ stoppedCount++;
655
+ agentKind ??= active.agentKind;
656
+ // 聚合口径:只要有一次 remote stop 没能确认,就按「可能仍在跑」上报,
657
+ // 不让后面的已确认结果盖掉它(调用方据此决定是否承诺"已停止")。
658
+ if (stopResult &&
659
+ (stop === undefined ||
660
+ (stop.status !== 'unconfirmed' && stopResult.status === 'unconfirmed'))) {
661
+ stop = stopResult;
672
662
  }
673
663
  }
674
664
  // Then check active bash runs (! commands). These bypass the serial queue
675
665
  // and are tracked separately; stop the per-run BashRunner directly.
676
- for (const [runId, b] of this.activeBashRuns) {
677
- if (input.cwd !== undefined && b.cwd !== input.cwd)
678
- continue;
679
- if (b.userId === input.userId && b.chatId === input.chatId) {
666
+ // `/stop`(无 runId、无 cwd)语义是「停掉这个会话的一切」,所以并行跑的
667
+ // `!` 命令也要停——旧实现命中 claude run 就 return,这些 bash 会被漏掉。
668
+ // 带范围的中断(停止卡 runId、queue.immediate 的 cwd)保持旧语义:只有
669
+ // 没有 claude run 命中时才回退到 bash,避免误杀用户并行跑的 `!` 任务。
670
+ const includeBashRuns = (input.runId === undefined && input.cwd === undefined) || activeTargets.length === 0;
671
+ const bashTargets = includeBashRuns
672
+ ? [...this.activeBashRuns.entries()].filter(([runId, b]) => {
673
+ if (input.cwd !== undefined && b.cwd !== input.cwd)
674
+ return false;
675
+ if (b.userId !== input.userId || b.chatId !== input.chatId)
676
+ return false;
680
677
  if (input.runId && runId !== input.runId)
681
- continue;
682
- getLogger().info(`[lark-remote] interrupt hit bash runId=${runId} cwd=${b.cwd} userId=${input.userId}`);
683
- await b.bashRunner.stop({ immediate: true });
684
- // Update card to show interrupted state
685
- await b.cardSession.finish('interrupted', { exitCode: -1 });
686
- this.activeBashRuns.delete(runId);
687
- getLogger().info(`[lark-remote] interrupt bash stop done runId=${runId}`);
688
- return { stopped: true };
689
- }
678
+ return false;
679
+ return true;
680
+ })
681
+ : [];
682
+ for (const [runId, b] of bashTargets) {
683
+ getLogger().info(`[lark-remote] interrupt hit bash runId=${runId} cwd=${b.cwd} userId=${input.userId}`);
684
+ await b.bashRunner.stop({ immediate: true });
685
+ // Update card to show interrupted state
686
+ await b.cardSession.finish('interrupted', { exitCode: -1 });
687
+ this.activeBashRuns.delete(runId);
688
+ getLogger().info(`[lark-remote] interrupt bash stop done runId=${runId}`);
689
+ stoppedCount++;
690
+ }
691
+ if (stoppedCount === 0) {
692
+ getLogger().debug(`[lark-remote] interrupt miss userId=${input.userId}`);
693
+ return { stopped: false };
694
+ }
695
+ return {
696
+ stopped: true,
697
+ stoppedCount,
698
+ ...(agentKind ? { agentKind } : {}),
699
+ ...(stop ? { stop } : {}),
700
+ };
701
+ }
702
+ /**
703
+ * 停掉一条已注册的 active run:置终态、停 runner、摘槽位、标 stale、
704
+ * 归零该 cwd 的队列执行计数。返回 runner 的停止结果(DSH 才有)。
705
+ *
706
+ * 只做「这一条 run」的事——跨多个 workspace 的遍历在调用方,避免再次
707
+ * 出现「停第一条就 return」的漏停。
708
+ */
709
+ async interruptActiveRun(cwd, active) {
710
+ getLogger().info(`[lark-remote] interrupt hit runId=${active.runId} cwd=${cwd} userId=${active.userId}`);
711
+ // Stop the EXACT runner instance that is running (carried by activeRun),
712
+ // not whatever getRunner(cwd) would return now. clearRunners() may have
713
+ // emptied the cache mid-run, in which case getRunner creates a fresh empty
714
+ // runner whose stop() is a no-op and the real subprocess is never killed
715
+ // (regression 2026-07-18: queue chain blocked forever).
716
+ const runner = active.runner;
717
+ // Bind the queue reset to THIS task's execution slot: the stop window
718
+ // below may outlive the task's settle, letting the serial chain advance
719
+ // to a successor that begins executing before we resume. An
720
+ // unconditional reset would then zero the successor's count and mark
721
+ // its slot interrupted, hiding it from queue cards.
722
+ const stoppedSlot = this.queueManager.getExecutingSlot(cwd);
723
+ const [, stopResult] = await Promise.allSettled([
724
+ // 用户主动中断(/stop、stop 卡按钮、queue.immediate)显式打标
725
+ // interruptedReason,与审批超时自动取消(approval_timeout)、审批
726
+ // 卡取消(approval_cancelled)在终态数据上可区分(方案 A)。
727
+ active.session.finish('interrupted', { interruptedReason: 'user_stop' }),
728
+ runner.stop({ immediate: true }),
729
+ ]);
730
+ const stop = stopResult.status === 'fulfilled' &&
731
+ stopResult.value !== null &&
732
+ typeof stopResult.value === 'object'
733
+ ? stopResult.value
734
+ : undefined;
735
+ // The chain may have advanced while the stop was in flight: a new run
736
+ // (from a task that began during the stop window) may now occupy this
737
+ // cwd. Only delete the entry we actually stopped.
738
+ if (this.activeRuns.get(cwd) === active) {
739
+ this.activeRuns.delete(cwd);
740
+ }
741
+ // Fix 4: Clean up this specific (cwd, agentKind) runner slot
742
+ // (eviction must also unregister the runner from the exit dispatcher)
743
+ // evict by the run's captured agentKind, not the live
744
+ // defaultAgent (may have changed mid-run via /config).
745
+ //
746
+ // workspace-lifetime runner 不能在中断路径里立即 evict+dispose。
747
+ // interruptCurrentRun 的 await 窗口内 queue 链会推进下一条消息,其
748
+ // getRunner 可能刚拿走本实例(缓存命中先于本行执行)——立即 evict 会
749
+ // 让那个 run 的长驻连接脱管:进程残留至 idle TTL 才回收,期间持有
750
+ // agent 侧会话写锁(codex "thread already has an active writer",
751
+ // 线上实测)。改为标 stale:下一次「即将开跑」的 getRunner 在槽位空闲时
752
+ // 换新实例(见 getRunner 的 replaceStale),本次 run 的 finalize 收尾时
753
+ // 也会消费该标记;两者都保证不会有活跃 run 使用被 dispose 的实例。
754
+ // run-per-message runner 无长驻连接,保持立即 evict 不变。
755
+ if (active.runner.lifetime === 'workspace') {
756
+ this.staleRunnerSlots.add(`${cwd}\u0000${active.agentKind}`);
690
757
  }
691
- getLogger().debug(`[lark-remote] interrupt miss userId=${input.userId}`);
692
- return { stopped: false };
758
+ else {
759
+ this.evictRunnerSlot(cwd, active.agentKind);
760
+ }
761
+ // Reset queue executing count: the task was interrupted externally, so
762
+ // its promise-chain settle (.then/.catch) may lag behind the process
763
+ // kill. Without this reset, subsequent messages would incorrectly show
764
+ // as "排队中". resetExecutingCount also grants a skip-credit so the
765
+ // stale settle does not double-decrement the count. Centralized here so
766
+ // ALL interrupt paths (/stop, /t, card stop, queue.immediate) benefit.
767
+ // When stoppedSlot is undefined (no active run matched), skip the reset —
768
+ // there is no queue slot to reconcile. All production interrupt paths
769
+ // (/stop, /t, card stop, queue.immediate) provide a defined stoppedSlot.
770
+ if (stoppedSlot !== undefined) {
771
+ this.queueManager.resetExecutingCount(cwd, stoppedSlot);
772
+ }
773
+ getLogger().info(`[lark-remote] interrupt stop done runId=${active.runId}`);
774
+ return stop;
693
775
  }
694
776
  /** Send a result message to the user (§7). Returns true on success, false on failure. */
695
777
  async sendResult(result, ctx) {
@@ -907,7 +989,7 @@ export class Bridge {
907
989
  // that will run is captured into activeRun. interruptCurrentRun stops THAT
908
990
  // instance directly -- never a fresh runner re-resolved from a cache that
909
991
  // clearRunners() may have emptied mid-run (regression 2026-07-18).
910
- const runner = this.getRunner(cwd, agentKind);
992
+ const runner = this.getRunner(cwd, agentKind, { replaceStale: true });
911
993
  // Step 1: Create run session (runId, cardSession, activeRun tracking)
912
994
  const { runId, cardSession, activeRun } = this.createRunSession(ctx, cwd, runner, agentKind);
913
995
  // Step 2: Consume the agent stream (runner.run loop, event processing, state updates)
@@ -1001,6 +1083,10 @@ export class Bridge {
1001
1083
  // Compact 按钮能力门控与 resume 卡 / auto-resume 卡一致(hasRunCompact
1002
1084
  // 鸭子探测):claude(stream-json,无 turn_started)也能拿到按钮。
1003
1085
  compactSupported: this.hasRunCompact(cwd, agentKind),
1086
+ // 本轮 init 到达前,按钮是否仍有可压缩目标:开跑时 store 里已有会话
1087
+ // 才置 true。false 时按钮只在 state.sessionId 出现后渲染——否则
1088
+ // init 前被 /stop 的卡片会留下一个注定失败的按钮。
1089
+ sessionFallbackAvailable: !!this.sessionStore.getSessionId(ctx.userId, agentKind),
1004
1090
  },
1005
1091
  });
1006
1092
  const activeRun = {
@@ -1134,6 +1220,11 @@ export class Bridge {
1134
1220
  });
1135
1221
  try {
1136
1222
  try {
1223
+ // queue.immediate 的 run-start hold:目标槽位可能在 handler 发最终
1224
+ // toast 之前就 begin(interrupt/PATCH 的 await 窗口)。activeRun 已
1225
+ // 登记(队列语义照旧),只把建卡这一步挡到 toast 之后——run 卡片才是
1226
+ // 点击后最后一条新消息,聊天列表预览跟随它的标题变化。
1227
+ await this.queueManager.awaitTaskRunStart(cwd);
1137
1228
  getLogger().debug(`[lark-remote] cardSession.start() begin runId=${runId}`);
1138
1229
  await cardSession.start();
1139
1230
  getLogger().info(`[lark-remote] cardSession.start() ok runId=${runId}`);
@@ -1171,10 +1262,7 @@ export class Bridge {
1171
1262
  // 只有终态才阻止事件处理。finalizing 是非终态,应该继续处理
1172
1263
  // 这样在 result 事件后,后台任务输出仍能被正确处理
1173
1264
  const terminal = cardSession.currentState.terminal;
1174
- if (terminal === 'done' ||
1175
- terminal === 'error' ||
1176
- terminal === 'interrupted' ||
1177
- terminal === 'idle_timeout') {
1265
+ if (isRunTerminal(terminal)) {
1178
1266
  continue;
1179
1267
  }
1180
1268
  if (event.type === 'system' && event.subtype === 'init') {
@@ -1445,7 +1533,16 @@ export class Bridge {
1445
1533
  // 终态,不再归因于 Agent 错误;error 保持「运行出错」;其余为 done。
1446
1534
  const subtype = finalState.resultSubtype;
1447
1535
  const isError = subtype === 'error' || !!finalState.errorMsg;
1448
- const terminal = isError ? 'error' : subtype === 'interrupted' ? 'interrupted' : 'done';
1536
+ // goal 仍 active = 宿主会自动起下一轮:这不是"完成",而是"本轮结束"。
1537
+ // 用独立终态而不是 done,既不改 header 文案也不用谎报。
1538
+ const goalContinues = !isError && subtype !== 'interrupted' && finalState.goal?.phase === 'active';
1539
+ const terminal = isError
1540
+ ? 'error'
1541
+ : subtype === 'interrupted'
1542
+ ? 'interrupted'
1543
+ : goalContinues
1544
+ ? 'goal_continuing'
1545
+ : 'done';
1449
1546
  await cardSession.finish(terminal, {
1450
1547
  resultSubtype: subtype,
1451
1548
  errorMsg: finalState.errorMsg,
@@ -1548,6 +1645,10 @@ export class Bridge {
1548
1645
  // runner 能力门控 Compact 按钮(缺省 undefined = 显示按钮,
1549
1646
  // DSH 等无 runCompact 的 agent 会渲染出点了报错的死按钮)。
1550
1647
  compactSupported: this.hasRunCompact(cwd, activeRun.agentKind),
1648
+ // 终态卡按「此刻是否真的有可压缩会话」渲染按钮,与记账用的是同一
1649
+ // 判据(本轮 sessionId 或 store 回退),避免按钮可见但记录不存在。
1650
+ sessionFallbackAvailable: !!(cardSession.currentState.sessionId ??
1651
+ this.sessionStore.getSessionId(ctx.userId, activeRun.agentKind)),
1551
1652
  };
1552
1653
  this.rememberRunCardSnapshot(runId, cardSession.currentState, finalRenderOptions, cwd, activeRun.agentKind);
1553
1654
  let runCardSent = false;
@@ -1574,6 +1675,15 @@ export class Bridge {
1574
1675
  // at the sendResult call site; if it throws, the run must still release
1575
1676
  // its activeRuns slot — otherwise the cwd is permanently busy
1576
1677
  // (every later message busy-dropped until a manual /stop).
1678
+ //
1679
+ // 记账与槽位所有权必须分开:/stop、停止卡按钮、
1680
+ // queue.immediate 都会先在 interruptCurrentRunDetailed 里删掉
1681
+ // activeRuns[cwd] 并把卡片置 interrupted,等收尾走到这里时下面的守卫
1682
+ // 已为 false。记录若跟着守卫走,中断后的终态卡上 Compact 按钮必然查不到
1683
+ // runId,只能回「该任务已结束或不属于当前会话」。守卫只回答「cwd 槽位还
1684
+ // 归不归本 run」(决定能不能删槽位/evict runner),记账回答「这次 run
1685
+ // 结束了吗、用的哪个 sessionId」——后者与所有权无关,放在守卫外。
1686
+ this.recordCompactableRunForTerminal(cardSession, activeRun, ctx);
1577
1687
  if (this.activeRuns.get(cwd) === activeRun) {
1578
1688
  this.activeRuns.delete(cwd);
1579
1689
  // Workspace-lifetime runners (codex app-server) keep their persistent
@@ -1589,25 +1699,6 @@ export class Bridge {
1589
1699
  this.evictRunnerSlot(cwd, activeRun.agentKind);
1590
1700
  }
1591
1701
  this.staleRunnerSlots.delete(slotKey);
1592
- // Compact 卡片需要 runId + sessionId;record 供终态卡上的 Compact 按钮
1593
- // 校验与取参(activeRuns 已清空,无法再查到)。异常退出(error/
1594
- // interrupted/idle_timeout)同样记录——上下文往往更大,压缩后再续。
1595
- // 写入条件从「codex 终态」放宽为「runner 有 runCompact 的终态」
1596
- // (codex app-server + kimi acp 均满足)。
1597
- const runTerminal = cardSession.currentState.terminal;
1598
- const runnerHasCompact = runnerSupportsCompact(activeRun.runner, activeRun.agentKind);
1599
- if (runnerHasCompact &&
1600
- activeRun.runner.lifetime === 'workspace' &&
1601
- (runTerminal === 'done' ||
1602
- runTerminal === 'error' ||
1603
- runTerminal === 'interrupted' ||
1604
- runTerminal === 'idle_timeout')) {
1605
- const compactSessionId = cardSession.currentState.sessionId ??
1606
- this.sessionStore.getSessionId(ctx.userId, activeRun.agentKind);
1607
- if (compactSessionId) {
1608
- this.recordCompactableRun(cwd, activeRun.runId, compactSessionId, activeRun.agentKind);
1609
- }
1610
- }
1611
1702
  getLogger().info(`[lark-remote] activeRuns.delete cwd=${cwd} runId=${runId}`);
1612
1703
  }
1613
1704
  }
@@ -1768,6 +1859,44 @@ export class Bridge {
1768
1859
  byRunId.delete(oldest);
1769
1860
  }
1770
1861
  }
1862
+ /**
1863
+ * 终态 Compact 记账:把本次 run 实际使用的 sessionId 记进
1864
+ * {@link compactableRuns},供终态卡上的 Compact 按钮按 runId 反查。
1865
+ *
1866
+ * 调用点固定在 `finalizeRun` 的 `finally` 里、**槽位所有权守卫之外**。
1867
+ * 所有权守卫只回答「cwd 槽位还归不归本 run」(决定能否删槽位 / evict
1868
+ * runner);记账回答「本次 run 结束了吗、用的哪个 sessionId」。两者在不同
1869
+ * 维度上:外部中断(`/stop`、停止卡按钮、`queue.immediate`)会先摘槽位,
1870
+ * 记账若跟着守卫走就永远不会落,中断卡片的按钮必然反查不到(中断
1871
+ * 后点 Compact 一律回「该任务已结束或不属于当前会话」)。
1872
+ *
1873
+ * 覆盖所有终态入口(正常结束、error、interrupted、idle_timeout、
1874
+ * goal_continuing)——这些卡片都渲染 Compact 按钮(`goal` 仍 active 时
1875
+ * 除外)。记账失败不能影响清理,故内部兜错。
1876
+ */
1877
+ recordCompactableRunForTerminal(cardSession, activeRun, ctx) {
1878
+ try {
1879
+ if (!runnerSupportsCompact(activeRun.runner, activeRun.agentKind))
1880
+ return;
1881
+ if (activeRun.runner.lifetime !== 'workspace')
1882
+ return;
1883
+ const terminal = cardSession.currentState.terminal;
1884
+ // 与事件消费守卫、Compact 按钮共用同一份终态集合(run-state.ts)。
1885
+ if (!isRunTerminal(terminal))
1886
+ return;
1887
+ // 卡片状态优先(本轮 init/turn_started 写入);缺失时回退 store——
1888
+ // 启动失败但已 /stop 的 run 没有自己的 init,压缩此前会话仍是有意义
1889
+ // 的动作。
1890
+ const compactSessionId = cardSession.currentState.sessionId ??
1891
+ this.sessionStore.getSessionId(ctx.userId, activeRun.agentKind);
1892
+ if (!compactSessionId)
1893
+ return;
1894
+ this.recordCompactableRun(activeRun.cwd, activeRun.runId, compactSessionId, activeRun.agentKind);
1895
+ }
1896
+ catch (err) {
1897
+ getLogger().warn(`[lark-remote] compact record failed runId=${activeRun.runId}: ${errorMessage(err)}`);
1898
+ }
1899
+ }
1771
1900
  /**
1772
1901
  * 按 runId 反查可压缩 run 记录(跨 cwd 扫描)。
1773
1902
  *
@@ -1814,7 +1943,7 @@ export class Bridge {
1814
1943
  return;
1815
1944
  }
1816
1945
  // Check that the runner has runCompact method(声明表 && runner 实现)
1817
- const runner = this.getRunner(cwd, target.agentKind);
1946
+ const runner = this.getRunner(cwd, target.agentKind, { replaceStale: true });
1818
1947
  if (!runnerSupportsCompact(runner, target.agentKind)) {
1819
1948
  await this.sendResult({ text: '⚠️ 当前运行模式不支持 Compact' }, ctx);
1820
1949
  return;
@@ -1935,10 +2064,7 @@ export class Bridge {
1935
2064
  // 已终态(/stop、空闲看门狗)后不再消费事件:与 runAgentStreamToEnd 同口径,
1936
2065
  // 否则迟到的 result 会把终态卡改写成「已完成」。
1937
2066
  const cardTerminal = cardSession.currentState.terminal;
1938
- if (cardTerminal === 'done' ||
1939
- cardTerminal === 'error' ||
1940
- cardTerminal === 'interrupted' ||
1941
- cardTerminal === 'idle_timeout') {
2067
+ if (isRunTerminal(cardTerminal)) {
1942
2068
  continue;
1943
2069
  }
1944
2070
  if (event.type === 'result') {
@@ -1989,7 +2115,7 @@ export class Bridge {
1989
2115
  // 终态守卫(与 runAgentStreamToEnd 同口径):running/finalizing 才转 error,
1990
2116
  // 已终态(interrupted/idle_timeout)保留首终态,不被迟到的异常改写。
1991
2117
  const catchTerminal = cardSession.currentState.terminal;
1992
- if (catchTerminal === 'running' || catchTerminal === 'finalizing') {
2118
+ if (!isRunTerminal(catchTerminal)) {
1993
2119
  await cardSession.finish('error', { errorMsg: errorMessage(err) });
1994
2120
  }
1995
2121
  else {
@@ -69,6 +69,11 @@ export interface ExecutingInfo {
69
69
  * settle 清掉后继任务的占用者(身份不用外部 id,同 §9.24 口径)。
70
70
  */
71
71
  slotId: number;
72
+ /**
73
+ * 本次执行条目自己的队列 key(排队卡身份,见 §9.24)。run 起始闸门按它
74
+ * 找到「当前 lane 占用者」持有的 hold;无 taskMeta 的任务为 undefined。
75
+ */
76
+ messageId?: string;
72
77
  kind: LaneTaskKind;
73
78
  /** 展示标签,例如 '💬 消息' / '🗜 Compact' / '📋 config.save'。 */
74
79
  label: string;
@@ -172,6 +177,17 @@ export declare class QueueManager {
172
177
  private interruptedSlots;
173
178
  /** Per-workspace messageId → replacement closure, set by queue.edit + immediate. */
174
179
  private taskReplacements;
180
+ /**
181
+ * Per-workspace messageId → run 起始 hold,单次消费。
182
+ *
183
+ * queue.immediate 在入口注册、finally 释放;目标任务的 begin 路径照常推进
184
+ * (槽位移除、排队卡翻执行态),但 `forwardToClaude` 在建 run 卡片前会等
185
+ * 当前 lane 占用者的 hold。这样「先 toast、后 run 卡片」与队列链推进速度
186
+ * 无关:run 卡片是点击后最后一条新消息,聊天列表预览才会跟随它的标题变化。
187
+ * hold 按队列 key 存(order.exec 的内部 key 也能命中),通过 executingInfo
188
+ * 找到当前占用者,不需要把队列 key 穿过 router/forwardToClaude 的参数。
189
+ */
190
+ private taskRunHolds;
175
191
  /**
176
192
  * MessageIds of tasks that have actually begun executing (cancellation check
177
193
  * passed, task removed from the queue). Entries are sticky: they remain
@@ -212,6 +228,21 @@ export declare class QueueManager {
212
228
  * Used for / commands that should respond immediately.
213
229
  */
214
230
  enqueueImmediate(cwd: string, task: () => Promise<void>): void;
231
+ /**
232
+ * 单次:hold 住某个排队任务的 run 起始,直到返回的 release 被调用。
233
+ *
234
+ * 与「hold 任务闭包」不同,这里只挡 `forwardToClaude` 建 run 卡片之前的
235
+ * 那一步:队列槽位照常 begin(排队卡翻执行态、began 记账都发生),所以
236
+ * queue.immediate 的成员资格判断不受影响,只是目标 run 卡片会等 toast。
237
+ * release 幂等;若任务在 begin 前被撤销,release 会清掉未消费的 hold。
238
+ */
239
+ holdTaskRunStart(cwd: string, messageId: string): () => void;
240
+ /**
241
+ * 等待当前 lane 占用者的 run 起始 hold。由 `forwardToClaude` 在建 run 卡片
242
+ * 前调用;hold 在当前调用里同步摘除,之后才注册的 hold 不会延长本次 run
243
+ * (release 仍会 settle 已捕获的 promise)。
244
+ */
245
+ awaitTaskRunStart(cwd: string): Promise<void>;
215
246
  /**
216
247
  * One-shot: replace the execution closure of an existing queued task in
217
248
  * place, preserving its queue position (tasks queued behind it still run
@@ -261,6 +292,12 @@ export declare class QueueManager {
261
292
  removeFromQueue(cwd: string, messageId: string): boolean;
262
293
  /** Update queue card to "cancelled" status when user clicks 撤销. */
263
294
  updateQueueCardToCancelled(cwd: string, messageId: string): Promise<void>;
295
+ /**
296
+ * 卡片 PATCH 带一次重试:一次网络抖动不该让排队卡永久停在旧状态——映射
297
+ * 会在本次调用结束时被清理,之后没有任何自动修正机会。
298
+ * 第二次仍失败则向上抛,由调用方 catch 记日志(行为与改前一致)。
299
+ */
300
+ private updateCardWithRetry;
264
301
  /** Get task metadata from queue. */
265
302
  getQueuedTask(cwd: string, messageId: string): QueuedTask | undefined;
266
303
  /**