lark-remote 0.5.17 → 0.5.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,35 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.5.19] - 2026-11-03
9
+
10
+ ### 修复
11
+
12
+ - **run 结束后卡片不再停在「运行中」**:卡片上的最后一帧只是交给飞书通道的节流队列,`controller.update()` 返回时 `message.patch` 还没发出;流若在下一次节流到期前关闭,这一帧可能永远不出门——run 早就结束,卡上仍画着运行态、底部「⏹ 停止」按钮一直挂着,点下去也不会生效。现在终态之后补一次整卡 patch 把它钉死(幂等替换,一次 run 只多一次请求,运行卡与后台任务卡共用同一份实现),并在日志里区分「帧没发出去」与「发出去了飞书没刷新」
13
+ - **卡片上的「⏹ 停止」按钮不再因为按钮值过期而空转**:停止动作携带的是本次 run 的 id,一旦这个值缺失或过期,点击就匹配不到任何 run。现在 id 精确匹配仍是权威路径,匹配不上时只在「被点的这张卡就是当前活跃的 run 卡」的前提下按卡片身份兜底;历史卡片和 `/active` 这类汇总卡片依旧不接受兜底,防止借一张旧卡停掉新 run。匹配失败时会把请求的 run id、卡片标识与当前活跃 run 记入日志,便于复盘
14
+ - **终态卡的「后台任务」小节跟着任务真实状态更新**:这一小节原本是结果那一刻的名单快照,之后运行卡停止接收事件、卡片定稿成静态内容,任务后来结束没有任何更新来源——卡上一直写「运行中」并留着必然失败的停止按钮,用户只能靠点一下看它报错才知道任务没了。现在收尾后按 run 订阅整份任务名单,只有名单变化才更新同一张卡片;已结束的任务显式标出并撤掉停止按钮,计数按 运行中 / 状态未知 / 已结束 分列;订阅中断而任务尚未结算时标「状态未知」(监控中断不等于任务结束);更新失败只退订,绝不因此补发新卡刷屏
15
+
16
+ ### 维护
17
+
18
+ - **Windows 的参数直传分支补上用例,CI 两个 job 都跑覆盖率**:shell 后端此前只测了拼接命令行那条路径,argv 原样透传(不拼 shell 字符串)、找不到 Git Bash 时报专用错误而不是泛化执行原命令、以及强制隐藏控制台窗口都没有守卫。顺带让 CI 的 Linux 与 Windows 两个 job 都以覆盖率方式运行测试。无行为变化
19
+ - **解释故障为什么存在的注释改为自描述**:若干注释用「某次实机反馈 + 隔了多久还能点」来论证改动的必要性,这类叙述对读者既不是可复现步骤也不是阈值,改成直接描述故障形态与后果;通道契约、根因和退订条件原样保留。无行为变化
20
+
21
+ ## [0.5.18] - 2026-11-02
22
+
23
+ ### 修复
24
+
25
+ - **超长最终回答不再在卡片里被拦腰截断**:运行卡的状态层对正文有 12,000 字符的窗口,此前这个窗口对**最新一条**正文同样生效,超出窗口的回答只剩尾部一截、还从句子中间开始。现在最新一条正文不设窗口(它就是要展示的输出),只有被更晚的正文顶替之后才按窗口截断留痕;回合结束时优先采用运行器手里的完整正文——DSH 在回合结束交出整条 assistant 消息,不依赖增量拼接,重连丢帧也不缺头。六个 agent 各用自己的真实转换器回放同一篇超长回答做回归
26
+ - **表格超限不再被误判成「卡片装不下」而重复补发完整回答**:最终回答里的表格超过单卡上限时,渲染器只是省略最旧的几张并在原位留一句提示,正文其实完整地画在了卡上;此前这句提示被当成溢出信号,卡片照常渲染正文之外又补发一条「完整回答」,同一篇回答用户看到两遍。现在正文仍在卡内一律按「完整」上报,只有正文真被占位替代时才走补发通道
27
+ - **DSH 自动恢复不再选中子 agent 派生会话**:DSH 0.2.x 把子 agent 派生的会话也列进会话列表(同一个工作目录、更新时间取委托结束时刻,常比父会话还新),按「最新会话」自动恢复于是选中了它;这类会话不接受按顶层会话寻址,读取被服务端拒绝后又被吞成空事件——切换工作目录后的恢复卡只剩一行标题,还绑在一个续跑不了的会话上,下一条消息必然报错。现在列表与寻址统一排除这类会话(判定只认来源标记:会话 id 的形态和父会话字段都会误伤正常会话)
28
+
29
+ ### 变更
30
+
31
+ - **完整回答溢出改走 Markdown 文件,最终回答不再发纯文本**:正文装不进一张卡时,把完整回答逐字写成临时 `.md` 文件随卡片发出,发送后尽力清理;卡上的提示改为「完整内容见随附的 Markdown 文件」。原先拆成「完整回答(1/N)」多条纯文本有两个硬伤:与卡片正文重复,且丢掉表格与代码块结构。文件发送失败时只发提示卡,并在日志里留落盘路径
32
+
33
+ ### 维护
34
+
35
+ - **用例头注释不再点名已删除的测试文件**:15 个锚点用例头部的「合并自 …」列的是早前测试收敛时删掉的旧文件名,读者按名去找会落到不存在的路径;只保留「本文件覆盖什么」,需要指路的改指向仍然存在的共享 fixture。无行为变化
36
+
8
37
  ## [0.5.17] - 2026-11-01
9
38
 
10
39
  ### 维护
@@ -172,6 +172,20 @@ export declare class Bridge {
172
172
  */
173
173
  private runCardSnapshots;
174
174
  private static readonly RUN_CARD_SNAPSHOT_MAX;
175
+ /**
176
+ * 终态卡后台任务订阅(runId → 中止句柄)。
177
+ *
178
+ * 与卡片快照同生命周期:快照被淘汰、任务全结束、patch 失败时退订。上限天然
179
+ * 受 RUN_CARD_SNAPSHOT_MAX 约束(一张卡最多一条流)。
180
+ */
181
+ private jobWatches;
182
+ /**
183
+ * 同一张卡的 patch 串行链(runId → 链尾)。
184
+ *
185
+ * 「停止按钮」和「订阅帧」都会改同一张终态卡:不串行化时两条 PATCH 并发,
186
+ * 后发先至的那条会把卡片写回旧内容(任务又变回「运行中」)。
187
+ */
188
+ private jobCardPatches;
175
189
  /**
176
190
  * Active `!` bash runs, keyed by runId (NOT cwd). Bash runs bypass the serial
177
191
  * queue and may run in parallel with claude runs / other bash runs in the same
@@ -344,6 +358,8 @@ export declare class Bridge {
344
358
  userId: string;
345
359
  chatId: string;
346
360
  runId?: string;
361
+ /** Card callback's server-provided message id. */
362
+ cardMessageId?: string;
347
363
  /** Restrict the match to a single cwd (queue.immediate). When
348
364
  * omitted, all workspaces are candidates (used by /stop, /t, and card stop button which don't know which cwd to target). */
349
365
  cwd?: string;
@@ -360,8 +376,20 @@ export declare class Bridge {
360
376
  userId: string;
361
377
  chatId: string;
362
378
  runId?: string;
379
+ /**
380
+ * Card callback's server-provided message id. Used only as a narrow fallback
381
+ * when the behavior-value runId is missing or stale: the clicked card must
382
+ * be the one active run's card. A dashboard or historical card never matches.
383
+ */
384
+ cardMessageId?: string;
363
385
  cwd?: string;
364
386
  }): Promise<InterruptOutcome>;
387
+ /**
388
+ * Resolve a scoped card click by card identity. Empty input or multiple
389
+ * matches fail closed; production message ids are unique, but refusing an
390
+ * ambiguous stop is safer than killing several workspaces.
391
+ */
392
+ private activeRunsForCard;
365
393
  /**
366
394
  * 停掉一条已注册的 active run:置终态、停 runner、摘槽位、标 stale、
367
395
  * 归零该 cwd 的队列执行计数。返回 runner 的停止结果(DSH 才有)。
@@ -370,14 +398,24 @@ export declare class Bridge {
370
398
  * 出现「停第一条就 return」的漏停。
371
399
  */
372
400
  private interruptActiveRun;
373
- /** Send a result message to the user (§7). Returns true on success, false on failure. */
374
- sendResult(result: CommandResult, ctx: CommandContext): Promise<boolean>;
375
401
  /**
376
- * Deliver a final answer that cannot fit in the run card. Plain text avoids
377
- * markdown parser limits and preserves every character; sequence labels make
378
- * multi-message delivery unambiguous.
402
+ * Send a result message to the user (§7). Returns true on success, false on failure.
403
+ *
404
+ * `opts.onCardSent` 回传卡片的 messageId:终态卡收尾后还要靠它接收后台任务
405
+ * 更新(流式卡从 `cardSession.cardMessageId` 取,兜底发送只能从这里拿)。
379
406
  */
380
- private sendFinalAnswerOverflow;
407
+ sendResult(result: CommandResult, ctx: CommandContext, opts?: {
408
+ onCardSent?: (messageId: string) => void;
409
+ }): Promise<boolean>;
410
+ /**
411
+ * Deliver a final answer that cannot fit in the run card as a Markdown file
412
+ * attachment. 卡片正文已被标记替代,文件是唯一的内容通道;此处绝不发纯文本
413
+ * (2026-11-02 定规:纯文本与卡片重复,也丢 Markdown 结构)。
414
+ *
415
+ * 失败时发一张提示卡(不是纯文本),并把完整回答留在临时文件里记日志,
416
+ * 便于事后找回。
417
+ */
418
+ private sendFinalAnswerFile;
381
419
  /**
382
420
  * Update an existing card in place (原地更新) instead of sending a new one.
383
421
  *
@@ -410,6 +448,8 @@ export declare class Bridge {
410
448
  * 与 activeRuns 生命周期在同一个地方收口。
411
449
  */
412
450
  private rememberRunCardSnapshot;
451
+ /** 退订一张终态卡的后台任务名单(幂等)。 */
452
+ private stopJobWatch;
413
453
  /**
414
454
  * 停止终态卡上的一个后台任务。
415
455
  *
@@ -419,6 +459,26 @@ export declare class Bridge {
419
459
  * 会留在卡片上而任务早已结束。
420
460
  */
421
461
  killBackgroundJob(runId: string, jobId: string, ctx: CommandContext): Promise<boolean>;
462
+ /**
463
+ * 让终态卡的后台任务小节跟着宿主继续更新。
464
+ *
465
+ * run 的卡片在 `result` 那一帧就关闭了,但后台任务还能再跑很久:不订阅,
466
+ * 卡片会一直写「运行中」并留着停止按钮,点了必然失败。退订条件写
467
+ * 在 {@link consumeJobRoster} 里——名单清空、patch 失败、订阅中断、快照被
468
+ * 淘汰,任一命中即退出;**绝不因为 patch 失败补发新卡**。
469
+ */
470
+ private startBackgroundJobWatch;
471
+ /** 消费宿主推来的整份任务名单,直到全结束 / 流断 / patch 失败 / 外部退订。 */
472
+ private consumeJobRoster;
473
+ /** 订阅断了但任务仍未结算:把在跑的任务标成「状态未知」并重渲染一次。 */
474
+ private markJobsUnknown;
475
+ /**
476
+ * 一张终态卡的 PATCH 串行链。
477
+ *
478
+ * 渲染放在链内执行(`task` 里读 `snapshot.state`),后入链的任务必然看到更新
479
+ * 后的状态;否则「停止」与「订阅帧」并发时卡片可能回退。
480
+ */
481
+ private enqueueJobCardPatch;
422
482
  /** Send a file to the user via Feishu. */
423
483
  sendFile(filePath: string, ctx: CommandContext): Promise<void>;
424
484
  /**
@@ -5,10 +5,11 @@ import { resolveFinalUsage, errorMessage } from './final-usage.js';
5
5
  import { getLogger } from '../logger/index.js';
6
6
  import { RunCardSession } from '../card/run-card-session.js';
7
7
  import { renderRunCard, renderRunCardDetailed, } from '../card/run-renderer.js';
8
- import { splitFinalAnswer } from '../card/final-answer-overflow.js';
8
+ import { writeFinalAnswerFile, finalAnswerFileFailureCard } from '../card/final-answer-file.js';
9
+ import { bestEffortUnlink } from '../common/fs.js';
9
10
  import { renderBashCard } from '../card/bash-renderer.js';
10
11
  import { BashCardSession, capBashOutput } from '../card/bash-card-session.js';
11
- import { isRunActive, isRunTerminal, removeBackgroundJob, } from '../card/run-state.js';
12
+ import { isLiveBackgroundJob, isRunActive, isRunTerminal, markBackgroundJobsUnknown, removeBackgroundJob, syncBackgroundJobs, } from '../card/run-state.js';
12
13
  import { buildSessionHistoryCard } from '../card/card-helpers.js';
13
14
  import { agentDisplayName, resumeUseButton } from '../card/card-shared.js';
14
15
  import { enforceCardBudget } from '../card/card-budget.js';
@@ -193,6 +194,20 @@ export class Bridge {
193
194
  */
194
195
  runCardSnapshots = new Map();
195
196
  static RUN_CARD_SNAPSHOT_MAX = 8;
197
+ /**
198
+ * 终态卡后台任务订阅(runId → 中止句柄)。
199
+ *
200
+ * 与卡片快照同生命周期:快照被淘汰、任务全结束、patch 失败时退订。上限天然
201
+ * 受 RUN_CARD_SNAPSHOT_MAX 约束(一张卡最多一条流)。
202
+ */
203
+ jobWatches = new Map();
204
+ /**
205
+ * 同一张卡的 patch 串行链(runId → 链尾)。
206
+ *
207
+ * 「停止按钮」和「订阅帧」都会改同一张终态卡:不串行化时两条 PATCH 并发,
208
+ * 后发先至的那条会把卡片写回旧内容(任务又变回「运行中」)。
209
+ */
210
+ jobCardPatches = new Map();
196
211
  /**
197
212
  * Active `!` bash runs, keyed by runId (NOT cwd). Bash runs bypass the serial
198
213
  * queue and may run in parallel with claude runs / other bash runs in the same
@@ -663,15 +678,31 @@ export class Bridge {
663
678
  // 并行跑;旧实现停在「第一条命中」就 return —— 两个 workspace 并行时
664
679
  // `/stop` 只停一个却回「已停止」,另一个无声继续。
665
680
  // 限定 cwd(queue.immediate)或带 runId(停止卡按钮)时仍只有一条命中。
666
- const activeTargets = [];
681
+ const ownedActiveRuns = [];
667
682
  for (const [cwd, active] of this.activeRuns) {
668
683
  if (input.cwd !== undefined && cwd !== input.cwd)
669
684
  continue;
670
685
  if (active.userId !== input.userId || active.chatId !== input.chatId)
671
686
  continue;
672
- if (input.runId && active.runId !== input.runId)
673
- continue;
674
- activeTargets.push([cwd, active]);
687
+ ownedActiveRuns.push([cwd, active]);
688
+ }
689
+ // Card stop prefers the authoritative runId. If the click is scoped to a
690
+ // run card but that value is stale/absent, accept only the active run whose
691
+ // server-provided card messageId is exactly the clicked card. This repairs
692
+ // callbacks that lose behavior-value without ever stopping a historical or
693
+ // dashboard card against a newer run.
694
+ let activeTargets;
695
+ if (input.runId) {
696
+ activeTargets = ownedActiveRuns.filter(([, active]) => active.runId === input.runId);
697
+ if (activeTargets.length === 0) {
698
+ activeTargets = this.activeRunsForCard(ownedActiveRuns, input.cardMessageId);
699
+ }
700
+ }
701
+ else if (input.cardMessageId) {
702
+ activeTargets = this.activeRunsForCard(ownedActiveRuns, input.cardMessageId);
703
+ }
704
+ else {
705
+ activeTargets = ownedActiveRuns;
675
706
  }
676
707
  let stoppedCount = 0;
677
708
  let agentKind;
@@ -694,15 +725,25 @@ export class Bridge {
694
725
  // `!` 命令也要停——旧实现命中 claude run 就 return,这些 bash 会被漏掉。
695
726
  // 带范围的中断(停止卡 runId、queue.immediate 的 cwd)保持旧语义:只有
696
727
  // 没有 claude run 命中时才回退到 bash,避免误杀用户并行跑的 `!` 任务。
697
- const includeBashRuns = (input.runId === undefined && input.cwd === undefined) || activeTargets.length === 0;
728
+ const cardScoped = input.runId !== undefined || input.cardMessageId !== undefined;
729
+ const includeBashRuns = (input.runId === undefined && input.cardMessageId === undefined && input.cwd === undefined) ||
730
+ activeTargets.length === 0;
698
731
  const bashTargets = includeBashRuns
699
732
  ? [...this.activeBashRuns.entries()].filter(([runId, b]) => {
700
733
  if (input.cwd !== undefined && b.cwd !== input.cwd)
701
734
  return false;
702
735
  if (b.userId !== input.userId || b.chatId !== input.chatId)
703
736
  return false;
704
- if (input.runId && runId !== input.runId)
705
- return false;
737
+ if (input.runId) {
738
+ if (runId === input.runId)
739
+ return true;
740
+ // Scoped card clicks may fall back only through their own card id.
741
+ if (cardScoped)
742
+ return false;
743
+ }
744
+ if (input.cardMessageId) {
745
+ return b.cardSession.cardMessageId === input.cardMessageId;
746
+ }
706
747
  return true;
707
748
  })
708
749
  : [];
@@ -718,7 +759,10 @@ export class Bridge {
718
759
  stoppedCount++;
719
760
  }
720
761
  if (stoppedCount === 0) {
721
- getLogger().debug(`[lark-remote] interrupt miss userId=${input.userId}`);
762
+ getLogger().warn(`[lark-remote] interrupt miss userId=${input.userId} ` +
763
+ `requestedRunId=${input.runId ?? '(none)'} ` +
764
+ `cardMessageId=${input.cardMessageId ?? '(none)'} ` +
765
+ `active=${ownedActiveRuns.map(([, active]) => active.runId).join(',') || '(none)'}`);
722
766
  return { stopped: false };
723
767
  }
724
768
  return {
@@ -728,6 +772,26 @@ export class Bridge {
728
772
  ...(stop ? { stop } : {}),
729
773
  };
730
774
  }
775
+ /**
776
+ * Resolve a scoped card click by card identity. Empty input or multiple
777
+ * matches fail closed; production message ids are unique, but refusing an
778
+ * ambiguous stop is safer than killing several workspaces.
779
+ */
780
+ activeRunsForCard(ownedActiveRuns, cardMessageId) {
781
+ if (!cardMessageId)
782
+ return [];
783
+ const matches = ownedActiveRuns.filter(([, active]) => active.session.cardMessageId === cardMessageId);
784
+ if (matches.length === 0)
785
+ return [];
786
+ if (matches.length > 1) {
787
+ getLogger().warn(`[lark-remote] interrupt ambiguous cardMessageId=${cardMessageId} ` +
788
+ `active=${matches.map(([, active]) => active.runId).join(',')}`);
789
+ return [];
790
+ }
791
+ getLogger().warn(`[lark-remote] interrupt using active card fallback ` +
792
+ `runId=${matches[0][1].runId} cardMessageId=${cardMessageId}`);
793
+ return matches;
794
+ }
731
795
  /**
732
796
  * 停掉一条已注册的 active run:置终态、停 runner、摘槽位、标 stale、
733
797
  * 归零该 cwd 的队列执行计数。返回 runner 的停止结果(DSH 才有)。
@@ -802,8 +866,13 @@ export class Bridge {
802
866
  getLogger().info(`[lark-remote] interrupt stop done runId=${active.runId}`);
803
867
  return stop;
804
868
  }
805
- /** Send a result message to the user (§7). Returns true on success, false on failure. */
806
- async sendResult(result, ctx) {
869
+ /**
870
+ * Send a result message to the user (§7). Returns true on success, false on failure.
871
+ *
872
+ * `opts.onCardSent` 回传卡片的 messageId:终态卡收尾后还要靠它接收后台任务
873
+ * 更新(流式卡从 `cardSession.cardMessageId` 取,兜底发送只能从这里拿)。
874
+ */
875
+ async sendResult(result, ctx, opts) {
807
876
  try {
808
877
  if (result.card) {
809
878
  // 静态卡片体积保护:确保卡片不超过飞书大小限制
@@ -812,7 +881,8 @@ export class Bridge {
812
881
  getLogger().info(`[lark-remote] card size exceeded limit, applied budget enforcement: reason=${reason} bytesBefore=${bytesBefore} bytesAfter=${bytesAfter}`);
813
882
  }
814
883
  getLogger().debug(`[lark-remote] sendResult kind=card chatId=${ctx.chatId}`);
815
- await this.connector.sendWithRetry(ctx.chatId, { card: safeCard }, { replyTo: ctx.messageId });
884
+ const messageId = await this.connector.sendWithRetry(ctx.chatId, { card: safeCard }, { replyTo: ctx.messageId });
885
+ opts?.onCardSent?.(messageId);
816
886
  return true;
817
887
  }
818
888
  else if (result.markdown) {
@@ -842,26 +912,26 @@ export class Bridge {
842
912
  }
843
913
  }
844
914
  /**
845
- * Deliver a final answer that cannot fit in the run card. Plain text avoids
846
- * markdown parser limits and preserves every character; sequence labels make
847
- * multi-message delivery unambiguous.
915
+ * Deliver a final answer that cannot fit in the run card as a Markdown file
916
+ * attachment. 卡片正文已被标记替代,文件是唯一的内容通道;此处绝不发纯文本
917
+ * (2026-11-02 定规:纯文本与卡片重复,也丢 Markdown 结构)。
918
+ *
919
+ * 失败时发一张提示卡(不是纯文本),并把完整回答留在临时文件里记日志,
920
+ * 便于事后找回。
848
921
  */
849
- async sendFinalAnswerOverflow(text, ctx) {
850
- const chunks = splitFinalAnswer(text);
922
+ async sendFinalAnswerFile(text, ctx) {
923
+ let filePath;
851
924
  try {
852
- for (let i = 0; i < chunks.length; i++) {
853
- const prefix = chunks.length > 1 ? `完整回答(${i + 1}/${chunks.length})\n\n` : '完整回答\n\n';
854
- await this.connector.sendWithRetry(ctx.chatId, { text: prefix + chunks[i] }, { replyTo: ctx.messageId });
855
- }
925
+ filePath = writeFinalAnswerFile(text);
926
+ await this.connector.sendFile(ctx.chatId, filePath);
856
927
  }
857
928
  catch (err) {
858
- getLogger().error('[lark-remote] failed to deliver complete final answer:', err);
859
- try {
860
- await this.connector.sendWithRetry(ctx.chatId, { text: `⚠️ 完整回答补发失败:${errorMessage(err)}` }, { replyTo: ctx.messageId });
861
- }
862
- catch (fallbackErr) {
863
- getLogger().error('[lark-remote] final-answer failure notice also failed:', fallbackErr);
864
- }
929
+ getLogger().error(`[lark-remote] failed to deliver complete final answer as file (kept at ${filePath ?? 'not written'}):`, err);
930
+ await this.sendResult({ card: finalAnswerFileFailureCard(errorMessage(err)) }, ctx);
931
+ }
932
+ finally {
933
+ if (filePath)
934
+ bestEffortUnlink(filePath);
865
935
  }
866
936
  }
867
937
  /**
@@ -924,14 +994,30 @@ export class Bridge {
924
994
  * 不放在 finish() 里:bridge 才知道 hasRunCompact 等渲染选项,且 eviction
925
995
  * 与 activeRuns 生命周期在同一个地方收口。
926
996
  */
927
- rememberRunCardSnapshot(runId, state, options, cwd, agentKind) {
928
- this.runCardSnapshots.set(runId, { state, options, cwd, agentKind });
997
+ rememberRunCardSnapshot(runId, state, options, cwd, agentKind, messageId) {
998
+ const snapshot = {
999
+ state,
1000
+ options,
1001
+ cwd,
1002
+ agentKind,
1003
+ ...(messageId === undefined ? {} : { messageId }),
1004
+ };
1005
+ this.runCardSnapshots.set(runId, snapshot);
929
1006
  while (this.runCardSnapshots.size > Bridge.RUN_CARD_SNAPSHOT_MAX) {
930
1007
  const oldest = this.runCardSnapshots.keys().next().value;
931
1008
  if (oldest === undefined)
932
1009
  break;
933
1010
  this.runCardSnapshots.delete(oldest);
1011
+ // 卡片已经不在窗口里:继续订阅只是往一个没人看的 messageId 打 PATCH。
1012
+ this.stopJobWatch(oldest);
934
1013
  }
1014
+ return snapshot;
1015
+ }
1016
+ /** 退订一张终态卡的后台任务名单(幂等)。 */
1017
+ stopJobWatch(runId) {
1018
+ this.jobWatches.get(runId)?.abort();
1019
+ this.jobWatches.delete(runId);
1020
+ this.jobCardPatches.delete(runId);
935
1021
  }
936
1022
  /**
937
1023
  * 停止终态卡上的一个后台任务。
@@ -953,9 +1039,105 @@ export class Bridge {
953
1039
  if (!killed)
954
1040
  return false;
955
1041
  snapshot.state = removeBackgroundJob(snapshot.state, jobId);
956
- await this.updateCardInPlace(renderRunCard(snapshot.state, snapshot.options), ctx);
1042
+ // 与订阅帧共用一条串行链:两条 PATCH 并发时,后发先至的那条会把卡片写回
1043
+ // 旧内容(刚停掉的任务又显示成「运行中」)。
1044
+ await this.enqueueJobCardPatch(runId, async () => {
1045
+ await this.updateCardInPlace(renderRunCard(snapshot.state, snapshot.options), ctx);
1046
+ return true;
1047
+ });
1048
+ // 卡片上已经没有在跑的任务:不必再等宿主推下一帧,直接退订。
1049
+ if (!(snapshot.state.backgroundJobs ?? []).some(isLiveBackgroundJob)) {
1050
+ this.stopJobWatch(runId);
1051
+ }
957
1052
  return true;
958
1053
  }
1054
+ /**
1055
+ * 让终态卡的后台任务小节跟着宿主继续更新。
1056
+ *
1057
+ * run 的卡片在 `result` 那一帧就关闭了,但后台任务还能再跑很久:不订阅,
1058
+ * 卡片会一直写「运行中」并留着停止按钮,点了必然失败。退订条件写
1059
+ * 在 {@link consumeJobRoster} 里——名单清空、patch 失败、订阅中断、快照被
1060
+ * 淘汰,任一命中即退出;**绝不因为 patch 失败补发新卡**。
1061
+ */
1062
+ startBackgroundJobWatch(runId, snapshot, runner) {
1063
+ const live = (snapshot.state.backgroundJobs ?? []).some(isLiveBackgroundJob);
1064
+ if (!live)
1065
+ return;
1066
+ const sessionId = snapshot.state.sessionId;
1067
+ const messageId = snapshot.messageId;
1068
+ const watchJobs = runner.watchJobs;
1069
+ if (!sessionId || !messageId || typeof watchJobs !== 'function') {
1070
+ // 没有卡 id 就没有 patch 的落点:保留收尾快照,不另发一张卡。
1071
+ getLogger().info(`[lark-remote] background jobs stay on the turn snapshot runId=${runId} ` +
1072
+ `(sessionId=${sessionId ?? '-'} messageId=${messageId ?? '-'} watchable=${typeof watchJobs === 'function'})`);
1073
+ return;
1074
+ }
1075
+ const controller = new AbortController();
1076
+ this.jobWatches.set(runId, controller);
1077
+ void this.consumeJobRoster(runId, snapshot, controller, (signal) => watchJobs.call(runner, sessionId, signal));
1078
+ }
1079
+ /** 消费宿主推来的整份任务名单,直到全结束 / 流断 / patch 失败 / 外部退订。 */
1080
+ async consumeJobRoster(runId, snapshot, controller, watch) {
1081
+ const messageId = snapshot.messageId;
1082
+ if (!messageId)
1083
+ return;
1084
+ let allEnded = false;
1085
+ try {
1086
+ for await (const roster of watch(controller.signal)) {
1087
+ const next = syncBackgroundJobs(snapshot.state, roster);
1088
+ // 名单没变化就不打 PATCH:宿主可能重复推同一份。
1089
+ if (next === snapshot.state)
1090
+ continue;
1091
+ snapshot.state = next;
1092
+ const ok = await this.enqueueJobCardPatch(runId, () => this.patchCard(messageId, renderRunCard(snapshot.state, snapshot.options)));
1093
+ if (!ok) {
1094
+ getLogger().warn(`[lark-remote] background job card update failed, stop watching runId=${runId}`);
1095
+ return;
1096
+ }
1097
+ if (!(snapshot.state.backgroundJobs ?? []).some(isLiveBackgroundJob)) {
1098
+ allEnded = true;
1099
+ return;
1100
+ }
1101
+ }
1102
+ }
1103
+ catch (err) {
1104
+ if (!controller.signal.aborted) {
1105
+ getLogger().warn(`[lark-remote] background job watch failed runId=${runId}: ${errorMessage(err)}`);
1106
+ }
1107
+ }
1108
+ finally {
1109
+ const externallyAborted = controller.signal.aborted;
1110
+ controller.abort();
1111
+ // 流断了而任务没结算:监控中断 ≠ 任务结束,标成未知而不是继续写「运行中」。
1112
+ if (!allEnded && !externallyAborted)
1113
+ await this.markJobsUnknown(runId, snapshot);
1114
+ this.jobWatches.delete(runId);
1115
+ this.jobCardPatches.delete(runId);
1116
+ }
1117
+ }
1118
+ /** 订阅断了但任务仍未结算:把在跑的任务标成「状态未知」并重渲染一次。 */
1119
+ async markJobsUnknown(runId, snapshot) {
1120
+ const next = markBackgroundJobsUnknown(snapshot.state);
1121
+ if (next === snapshot.state)
1122
+ return;
1123
+ snapshot.state = next;
1124
+ const messageId = snapshot.messageId;
1125
+ if (!messageId)
1126
+ return;
1127
+ await this.enqueueJobCardPatch(runId, () => this.patchCard(messageId, renderRunCard(snapshot.state, snapshot.options)));
1128
+ }
1129
+ /**
1130
+ * 一张终态卡的 PATCH 串行链。
1131
+ *
1132
+ * 渲染放在链内执行(`task` 里读 `snapshot.state`),后入链的任务必然看到更新
1133
+ * 后的状态;否则「停止」与「订阅帧」并发时卡片可能回退。
1134
+ */
1135
+ enqueueJobCardPatch(runId, task) {
1136
+ const previous = this.jobCardPatches.get(runId) ?? Promise.resolve();
1137
+ const next = previous.then(task, task);
1138
+ this.jobCardPatches.set(runId, next.then(() => undefined, () => undefined));
1139
+ return next;
1140
+ }
959
1141
  /** Send a file to the user via Feishu. */
960
1142
  async sendFile(filePath, ctx) {
961
1143
  try {
@@ -1596,10 +1778,15 @@ export class Bridge {
1596
1778
  this.sessionStore.getSessionId(ctx.userId, activeRun.agentKind)),
1597
1779
  };
1598
1780
  const finalRender = renderRunCardDetailed(cardSession.currentState, finalRenderOptions);
1599
- this.rememberRunCardSnapshot(runId, cardSession.currentState, finalRenderOptions, cwd, activeRun.agentKind);
1781
+ const snapshot = this.rememberRunCardSnapshot(runId, cardSession.currentState, finalRenderOptions, cwd, activeRun.agentKind, cardSession.cardMessageId);
1600
1782
  let runCardSent = false;
1601
1783
  if (settleResult === 'unsent') {
1602
- const sent = await this.sendResult({ card: finalRender.card }, ctx);
1784
+ const sent = await this.sendResult({ card: finalRender.card }, ctx, {
1785
+ // 兜底发送的卡也要能接收后台任务更新 —— 流式卡没建起来时这是唯一来源。
1786
+ onCardSent: (messageId) => {
1787
+ snapshot.messageId = messageId;
1788
+ },
1789
+ });
1603
1790
  runCardSent = sent;
1604
1791
  }
1605
1792
  else {
@@ -1613,10 +1800,13 @@ export class Bridge {
1613
1800
  await this.sendCompletionNotificationCard(sessionId, cwd, ctx, activeRun.agentKind, cardSession.currentState.terminal);
1614
1801
  }
1615
1802
  if (finalRender.finalAnswerStatus === 'overflow' && finalRender.finalAnswerText) {
1616
- await this.sendFinalAnswerOverflow(finalRender.finalAnswerText, ctx);
1803
+ await this.sendFinalAnswerFile(finalRender.finalAnswerText, ctx);
1617
1804
  }
1618
1805
  // Add a terminal-state emoji reaction to the user's original message
1619
1806
  this.finishTurnReactions(ctx, terminalReactionEmoji(cardSession.currentState.terminal));
1807
+ // 后台任务跨 turn:卡片收尾只是"这一轮的投影结束了",任务本身可能还在跑。
1808
+ // 订阅放在最后:卡片已落地,patch 才有落点。
1809
+ this.startBackgroundJobWatch(runId, snapshot, activeRun.runner);
1620
1810
  }
1621
1811
  finally {
1622
1812
  // cleanup must survive mid-finalize errors. The only unguarded
@@ -86,6 +86,8 @@ export class BashCardSession extends CardSession {
86
86
  ...(meta.output !== undefined ? { output: capBashOutput(meta.output) } : {}),
87
87
  };
88
88
  await this.updateCard();
89
+ // 同 run 卡:终态帧必须真的落地,否则停止后卡片仍显示停止按钮。
90
+ await this.confirmTerminalFrame(`runId=${this.state.runId} terminal=${terminal}`);
89
91
  this.release();
90
92
  }
91
93
  /**
@@ -99,6 +99,9 @@ export declare abstract class CardSession<S, RO = unknown> {
99
99
  * in-flight flush 完成后该事件无 follow-up render,停留为 stale 中间帧。
100
100
  * finally 检查此标志后重新 scheduleFlush,保证 in-flight 期间累积的事件最终被渲染。 */
101
101
  private pendingReschedule;
102
+ /** 终态帧是否已由 confirmTerminalFrame 确认落到飞书。settle 的兜底 patch
103
+ * 据此去重:流异常结束时若终态帧已经确认过,再 patch 一次只是幂等重发。 */
104
+ private terminalFrameConfirmed;
102
105
  /** 当前 in-flight flush 的 promise(若 flush 正在 await updateCard)。
103
106
  * finish() 在终态 patch 前 await 它,保证 in-flight 的 pre-terminal patch 先落地、
104
107
  * terminal patch 后落地——否则两条 controller.update 并发无 FIFO,
@@ -121,5 +124,19 @@ export declare abstract class CardSession<S, RO = unknown> {
121
124
  protected updateCard(): Promise<void>;
122
125
  private waitForControllerReady;
123
126
  protected prepareTerminalUpdate(): Promise<void>;
127
+ /**
128
+ * 把终态帧真正钉到飞书上。
129
+ *
130
+ * `controller.update()` 只把帧交给 @larksuite/channel 的 throttle/queue,
131
+ * resolve 时 `message.patch` 还没发出(见 `connector/index.ts` 的
132
+ * patchWithRetry 注释)。流若在下一次 throttle 到期前关闭,这一帧就可能
133
+ * 永远不出门——卡片停在运行态、底部「⏹ 停止」按钮还在:停止其实已经
134
+ * 生效,按钮仍可点,点了是空转。
135
+ *
136
+ * 终态后再走一次整卡 patch(幂等替换;一次 run 只多一次请求),成功打
137
+ * info 便于排障时区分「帧没发出去」与「发出去了飞书没刷」;失败只 warn,
138
+ * 不参与收尾决策——流本身已结束,不能让一次兜底 patch 把 finish 拖成异常。
139
+ */
140
+ protected confirmTerminalFrame(label: string): Promise<void>;
124
141
  protected release(): void;
125
142
  }
@@ -128,6 +128,9 @@ export class CardSession {
128
128
  * in-flight flush 完成后该事件无 follow-up render,停留为 stale 中间帧。
129
129
  * finally 检查此标志后重新 scheduleFlush,保证 in-flight 期间累积的事件最终被渲染。 */
130
130
  pendingReschedule = false;
131
+ /** 终态帧是否已由 confirmTerminalFrame 确认落到飞书。settle 的兜底 patch
132
+ * 据此去重:流异常结束时若终态帧已经确认过,再 patch 一次只是幂等重发。 */
133
+ terminalFrameConfirmed = false;
131
134
  /** 当前 in-flight flush 的 promise(若 flush 正在 await updateCard)。
132
135
  * finish() 在终态 patch 前 await 它,保证 in-flight 的 pre-terminal patch 先落地、
133
136
  * terminal patch 后落地——否则两条 controller.update 并发无 FIFO,
@@ -185,6 +188,11 @@ export class CardSession {
185
188
  getLogger().warn(`${this.logPrefix} stream did not complete cleanly:`, outcome.error);
186
189
  if (!this.messageId)
187
190
  return 'unsent';
191
+ // 终态帧已在 finish 里确认落地:兜底 patch 是幂等重发,跳过。
192
+ // 仍返回 'streamed' 而非 'unsent'——卡片确实已带终态内容,调用方不能
193
+ // 因此再发一张 fallback 卡(bridge 只对 'unsent' 兜底)。
194
+ if (this.terminalFrameConfirmed)
195
+ return 'streamed';
188
196
  try {
189
197
  await this.connector.updateCard(this.messageId, this.renderCurrentCard());
190
198
  return 'updated';
@@ -247,6 +255,33 @@ export class CardSession {
247
255
  if (this.flushP)
248
256
  await this.flushP;
249
257
  }
258
+ /**
259
+ * 把终态帧真正钉到飞书上。
260
+ *
261
+ * `controller.update()` 只把帧交给 @larksuite/channel 的 throttle/queue,
262
+ * resolve 时 `message.patch` 还没发出(见 `connector/index.ts` 的
263
+ * patchWithRetry 注释)。流若在下一次 throttle 到期前关闭,这一帧就可能
264
+ * 永远不出门——卡片停在运行态、底部「⏹ 停止」按钮还在:停止其实已经
265
+ * 生效,按钮仍可点,点了是空转。
266
+ *
267
+ * 终态后再走一次整卡 patch(幂等替换;一次 run 只多一次请求),成功打
268
+ * info 便于排障时区分「帧没发出去」与「发出去了飞书没刷」;失败只 warn,
269
+ * 不参与收尾决策——流本身已结束,不能让一次兜底 patch 把 finish 拖成异常。
270
+ */
271
+ async confirmTerminalFrame(label) {
272
+ const messageId = this.messageId;
273
+ // 没拿到 messageId(流未建立/已降级)时无从 patch,交回 settle() 的兜底。
274
+ if (!messageId)
275
+ return;
276
+ try {
277
+ await this.connector.updateCard(messageId, this.renderCurrentCard());
278
+ this.terminalFrameConfirmed = true;
279
+ getLogger().info(`${this.logPrefix} terminal frame confirmed ${label} messageId=${messageId}`);
280
+ }
281
+ catch (error) {
282
+ getLogger().warn(`${this.logPrefix} terminal frame confirm failed ${label}:`, error);
283
+ }
284
+ }
250
285
  release() {
251
286
  if (this.released)
252
287
  return;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * 把完整回答写进系统临时目录里的 `.md` 文件,返回绝对路径。
3
+ *
4
+ * 文件名带时间戳与随机后缀:同一分钟内的多个 run 不会互相覆盖,也不会在
5
+ * 飞书里显示成同名附件。内容逐字写入,不做任何截断或转义。
6
+ */
7
+ export declare function writeFinalAnswerFile(text: string, now?: Date): string;
8
+ /** 文件通道失败时的提示卡——终态反馈也只走卡片,不发纯文本。 */
9
+ export declare function finalAnswerFileFailureCard(message: string): object;