lark-remote 0.5.9 → 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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,17 @@ 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.10] - 2026-10-24
9
+
10
+ ### 修复
11
+
12
+ - **点「立即执行」后,run 卡片不再抢在最终 toast 前面**:立即执行的 run 起始会等当前排队卡完成交接再建卡。否则 toast 会成为点击后最后一条新消息,聊天列表预览停在 toast 文案上,而 run 卡片后续的「思考中 / 工具 / 完成」变化不点进会话就看不到
13
+ - **长时间运行不再被过期的空闲定时器误杀**:工作区连接自行退出(如 codex app-server 偶发 exit 0)后,挂在旧连接上的空闲回收定时器此前不会被取消,约 30 分钟后会按工作目录回收后来接替的新连接,正在跑的回合被杀、卡片误报「连接已关闭」。现在定时器只回收自己武装时那个连接,进程自退时同时撤掉定时器,继任连接自己的空闲回收仍然照常生效
14
+
15
+ ### 变更
16
+
17
+ - **英文架构文档补齐排队卡叙述**:立即执行时的 run 起始闸门与 toast 排序,中英两侧一致
18
+
8
19
  ## [0.5.9] - 2026-10-22
9
20
 
10
21
  ### 新增
@@ -270,6 +270,15 @@ export declare class Bridge {
270
270
  * user edits a queued message (see router handleQueueImmediate).
271
271
  */
272
272
  setTaskReplacement(cwd: string, messageId: string, task: () => Promise<void>): void;
273
+ /**
274
+ * One-shot: hold the target's run-card start until the returned release is
275
+ * called. queue.immediate uses it so its final toast is sent before the run
276
+ * card — the toast would otherwise be the newest message and the chat list
277
+ * would stop previewing the run card's title changes. The queue slot itself
278
+ * still begins normally; only run-card creation waits (see
279
+ * QueueManager.holdTaskRunStart).
280
+ */
281
+ holdQueueTaskRun(cwd: string, messageId: string): () => void;
273
282
  /** Update queue card to "cancelled" status when user clicks 撤销. */
274
283
  updateQueueCardToCancelled(cwd: string, messageId: string): Promise<void>;
275
284
  /** Mark a queued task's card as executing immediately (grey out buttons).
@@ -488,6 +488,17 @@ export class Bridge {
488
488
  setTaskReplacement(cwd, messageId, task) {
489
489
  this.queueManager.setTaskReplacement(cwd, messageId, task);
490
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
+ }
491
502
  /** Update queue card to "cancelled" status when user clicks 撤销. */
492
503
  async updateQueueCardToCancelled(cwd, messageId) {
493
504
  await this.queueManager.updateQueueCardToCancelled(cwd, messageId);
@@ -1209,6 +1220,11 @@ export class Bridge {
1209
1220
  });
1210
1221
  try {
1211
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);
1212
1228
  getLogger().debug(`[lark-remote] cardSession.start() begin runId=${runId}`);
1213
1229
  await cardSession.start();
1214
1230
  getLogger().info(`[lark-remote] cardSession.start() ok runId=${runId}`);
@@ -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
@@ -87,6 +87,17 @@ export class QueueManager {
87
87
  interruptedSlots = new Map();
88
88
  /** Per-workspace messageId → replacement closure, set by queue.edit + immediate. */
89
89
  taskReplacements = new Map();
90
+ /**
91
+ * Per-workspace messageId → run 起始 hold,单次消费。
92
+ *
93
+ * queue.immediate 在入口注册、finally 释放;目标任务的 begin 路径照常推进
94
+ * (槽位移除、排队卡翻执行态),但 `forwardToClaude` 在建 run 卡片前会等
95
+ * 当前 lane 占用者的 hold。这样「先 toast、后 run 卡片」与队列链推进速度
96
+ * 无关:run 卡片是点击后最后一条新消息,聊天列表预览才会跟随它的标题变化。
97
+ * hold 按队列 key 存(order.exec 的内部 key 也能命中),通过 executingInfo
98
+ * 找到当前占用者,不需要把队列 key 穿过 router/forwardToClaude 的参数。
99
+ */
100
+ taskRunHolds = new Map();
90
101
  /**
91
102
  * MessageIds of tasks that have actually begun executing (cancellation check
92
103
  * passed, task removed from the queue). Entries are sticky: they remain
@@ -308,6 +319,7 @@ export class QueueManager {
308
319
  // is waiting for instead of reporting "前面还有 0 条".
309
320
  this.executingInfo.set(cwd, {
310
321
  slotId,
322
+ messageId,
311
323
  kind: taskMeta?.kind ?? 'message',
312
324
  label: laneOccupantLabel(taskMeta?.kind ?? 'message', livePreview),
313
325
  preview: livePreview,
@@ -339,6 +351,72 @@ export class QueueManager {
339
351
  getLogger().debug(`[queue-manager] enqueueImmediate cwd=${cwd}`);
340
352
  void task().catch((err) => getLogger().error('[queue-manager] immediate task error:', err));
341
353
  }
354
+ /**
355
+ * 单次:hold 住某个排队任务的 run 起始,直到返回的 release 被调用。
356
+ *
357
+ * 与「hold 任务闭包」不同,这里只挡 `forwardToClaude` 建 run 卡片之前的
358
+ * 那一步:队列槽位照常 begin(排队卡翻执行态、began 记账都发生),所以
359
+ * queue.immediate 的成员资格判断不受影响,只是目标 run 卡片会等 toast。
360
+ * release 幂等;若任务在 begin 前被撤销,release 会清掉未消费的 hold。
361
+ */
362
+ holdTaskRunStart(cwd, messageId) {
363
+ let resolve;
364
+ const hold = {
365
+ promise: new Promise((r) => {
366
+ resolve = r;
367
+ }),
368
+ release: () => resolve(),
369
+ };
370
+ let workspaceHolds = this.taskRunHolds.get(cwd);
371
+ if (!workspaceHolds) {
372
+ workspaceHolds = new Map();
373
+ this.taskRunHolds.set(cwd, workspaceHolds);
374
+ }
375
+ const holds = workspaceHolds.get(messageId);
376
+ if (holds) {
377
+ holds.push(hold);
378
+ }
379
+ else {
380
+ workspaceHolds.set(messageId, [hold]);
381
+ }
382
+ getLogger().debug(`[queue-manager] hold task run start cwd=${cwd} messageId=${messageId}`);
383
+ return () => {
384
+ // Resolve before mutating the map: `awaitTaskRunStart` may already have
385
+ // captured (and deleted) this entry, and the captured promise still needs
386
+ // to settle.
387
+ hold.release();
388
+ const current = this.taskRunHolds.get(cwd)?.get(messageId);
389
+ if (!current)
390
+ return;
391
+ const idx = current.indexOf(hold);
392
+ if (idx >= 0)
393
+ current.splice(idx, 1);
394
+ if (current.length > 0)
395
+ return;
396
+ this.taskRunHolds.get(cwd)?.delete(messageId);
397
+ if (this.taskRunHolds.get(cwd)?.size === 0)
398
+ this.taskRunHolds.delete(cwd);
399
+ };
400
+ }
401
+ /**
402
+ * 等待当前 lane 占用者的 run 起始 hold。由 `forwardToClaude` 在建 run 卡片
403
+ * 前调用;hold 在当前调用里同步摘除,之后才注册的 hold 不会延长本次 run
404
+ * (release 仍会 settle 已捕获的 promise)。
405
+ */
406
+ async awaitTaskRunStart(cwd) {
407
+ const messageId = this.executingInfo.get(cwd)?.messageId;
408
+ if (!messageId)
409
+ return;
410
+ const workspaceHolds = this.taskRunHolds.get(cwd);
411
+ const holds = workspaceHolds?.get(messageId);
412
+ if (!holds || holds.length === 0)
413
+ return;
414
+ workspaceHolds?.delete(messageId);
415
+ if (workspaceHolds?.size === 0)
416
+ this.taskRunHolds.delete(cwd);
417
+ getLogger().debug(`[queue-manager] awaiting task run start cwd=${cwd} messageId=${messageId} holds=${holds.length}`);
418
+ await Promise.all(holds.map((h) => h.promise));
419
+ }
342
420
  /**
343
421
  * One-shot: replace the execution closure of an existing queued task in
344
422
  * place, preserving its queue position (tasks queued behind it still run
@@ -822,128 +822,143 @@ export class CommandRouter {
822
822
  await this.bridge.sendResult({ text: '⚠️ 正在压缩会话,消息将在压缩完成后按顺序执行。' }, ctx);
823
823
  return;
824
824
  }
825
- // 1.5. If the user edited the message, the original closure (captured at
826
- // enqueue time with stale content) must NOT run. Register a one-shot
827
- // replacement closure on the task's EXISTING queue slot BEFORE any await:
828
- // interruptCurrentRun / markQueueCardExecuting can let the chain advance to
829
- // this task's begin, and begin consumes the replacement at that point — a
830
- // registration after those awaits would arrive too late (the stale closure
831
- // runs and the replacement leaks as a permanent dead closure). Registering
832
- // on the existing slot (instead of removeFromQueue + re-enqueue) preserves
833
- // the queue position: enqueue can only append to the promise chain tail,
834
- // which would put the edited task behind tasks queued after it.
835
- if (targetTask.editedMessage) {
836
- const editedContent = targetTask.editedMessage;
837
- ///Step3: 替换闭包复用原任务的 binding(不重新快照)+ 恢复丢失的
838
- // cwdOverride(lane cwd 同源)。否则编辑后重新执行的 run 会丢 cwd 与绑定。
839
- this.bridge.setTaskReplacement(cwd, messageId, async () => {
840
- await this.handle(editedContent, ctx, {
841
- cwdOverride: cwd,
842
- binding: targetTask.binding,
825
+ // 1.4. Hold the target's run-card start until this handler has sent its
826
+ // final feedback. The queue chain can advance during the awaits below
827
+ // (interruptCurrentRun / the queue-card PATCH): the target may begin and
828
+ // call forwardToClaude before the toast is sent. Without this hold the
829
+ // run card would land first, leaving the toast as the newest message —
830
+ // the chat list then previews the toast instead of the run card's title
831
+ // (which is how the user follows a run without opening the conversation).
832
+ // Registered before any await, released in finally on every exit path;
833
+ // forwardToClaude waits for it before creating the run card.
834
+ const releaseRunStart = this.bridge.holdQueueTaskRun(cwd, messageId);
835
+ try {
836
+ // 1.5. If the user edited the message, the original closure (captured at
837
+ // enqueue time with stale content) must NOT run. Register a one-shot
838
+ // replacement closure on the task's EXISTING queue slot BEFORE any await:
839
+ // interruptCurrentRun / markQueueCardExecuting can let the chain advance to
840
+ // this task's begin, and begin consumes the replacement at that point — a
841
+ // registration after those awaits would arrive too late (the stale closure
842
+ // runs and the replacement leaks as a permanent dead closure). Registering
843
+ // on the existing slot (instead of removeFromQueue + re-enqueue) preserves
844
+ // the queue position: enqueue can only append to the promise chain tail,
845
+ // which would put the edited task behind tasks queued after it.
846
+ if (targetTask.editedMessage) {
847
+ const editedContent = targetTask.editedMessage;
848
+ ///Step3: 替换闭包复用原任务的 binding(不重新快照)+ 恢复丢失的
849
+ // cwdOverride(lane cwd 同源)。否则编辑后重新执行的 run 会丢 cwd 与绑定。
850
+ this.bridge.setTaskReplacement(cwd, messageId, async () => {
851
+ await this.handle(editedContent, ctx, {
852
+ cwdOverride: cwd,
853
+ binding: targetTask.binding,
854
+ });
843
855
  });
856
+ }
857
+ // 2. Stop current running process
858
+ // (Note: queue executing count is reset inside Bridge.interruptCurrentRun
859
+ // so all stop paths — /stop, /t, card stop, queue.immediate — are covered.)
860
+ const interrupt = await this.bridge.interruptCurrentRunDetailed({
861
+ userId: ctx.userId,
862
+ chatId: ctx.chatId,
863
+ cwd,
844
864
  });
845
- }
846
- // 2. Stop current running process
847
- // (Note: queue executing count is reset inside Bridge.interruptCurrentRun
848
- // so all stop paths — /stop, /t, card stop, queue.immediate — are covered.)
849
- const interrupt = await this.bridge.interruptCurrentRunDetailed({
850
- userId: ctx.userId,
851
- chatId: ctx.chatId,
852
- cwd,
853
- });
854
- const stopped = interrupt.stopped;
855
- // A remote (DSH) stop is only a request; when it cannot be confirmed the
856
- // target must not be promised "immediate" execution — its prompt would sit
857
- // behind the still-running turn with no visible progress.
858
- const stopUnconfirmed = interrupt.stop?.status === 'unconfirmed';
859
- getLogger().info(`[router] queue.immediate: stopped=${stopped} unconfirmed=${stopUnconfirmed}`);
860
- // 3. Remove all tasks BEFORE this one (by position in the current queue).
861
- // The target may have begun while interruptCurrentRun was awaiting (the chain
862
- // advances concurrently): then it is already out of the snapshot and every
863
- // remaining queued task is BEHIND it — clearing must stop, not run off the
864
- // end and delete tasks queued after the target.
865
- const tasks = this.bridge.getQueuedTasks(cwd);
866
- const targetIdx = tasks.findIndex((t) => t.messageId === messageId);
867
- let removedCount = 0;
868
- // Phase 1: remove synchronously. The queue chain advances on any await
869
- // (card PATCH round trips included), so every task before the target must
870
- // be removed before yielding to the event loop — otherwise a task the user
871
- // asked to clear can begin executing while its cancellation card is still
872
- // in flight.
873
- for (let i = 0; i < targetIdx; i++) {
874
- const task = tasks[i];
875
- // 撤销卡片由 removeFromQueue 统一触发(fire-and-forget),保持本循环
876
- // 同步推进的既有不变式。
877
- if (this.bridge.removeFromQueue(cwd, task.messageId)) {
878
- removedCount++;
865
+ const stopped = interrupt.stopped;
866
+ // A remote (DSH) stop is only a request; when it cannot be confirmed the
867
+ // target must not be promised "immediate" execution — its prompt would sit
868
+ // behind the still-running turn with no visible progress.
869
+ const stopUnconfirmed = interrupt.stop?.status === 'unconfirmed';
870
+ getLogger().info(`[router] queue.immediate: stopped=${stopped} unconfirmed=${stopUnconfirmed}`);
871
+ // 3. Remove all tasks BEFORE this one (by position in the current queue).
872
+ // The target may have begun while interruptCurrentRun was awaiting (the chain
873
+ // advances concurrently): then it is already out of the snapshot and every
874
+ // remaining queued task is BEHIND it — clearing must stop, not run off the
875
+ // end and delete tasks queued after the target.
876
+ const tasks = this.bridge.getQueuedTasks(cwd);
877
+ const targetIdx = tasks.findIndex((t) => t.messageId === messageId);
878
+ let removedCount = 0;
879
+ // Phase 1: remove synchronously. The queue chain advances on any await
880
+ // (card PATCH round trips included), so every task before the target must
881
+ // be removed before yielding to the event loop — otherwise a task the user
882
+ // asked to clear can begin executing while its cancellation card is still
883
+ // in flight.
884
+ for (let i = 0; i < targetIdx; i++) {
885
+ const task = tasks[i];
886
+ // 撤销卡片由 removeFromQueue 统一触发(fire-and-forget),保持本循环
887
+ // 同步推进的既有不变式。
888
+ if (this.bridge.removeFromQueue(cwd, task.messageId)) {
889
+ removedCount++;
890
+ }
879
891
  }
880
- }
881
- if (targetIdx < 0) {
882
- getLogger().debug(`[router] queue.immediate: target no longer queued (began/cancelled), not clearing tasks behind it`);
883
- }
884
- // 4. DO NOT remove the target task - keep it in queue to execute immediately
885
- // The queue will naturally execute it after the current task (which we just stopped)
886
- // 4.4. Refresh the replacement with the latest edited content (idempotent:
887
- // re-registering over the same slot). Covers the user editing the message
888
- // again while interruptCurrentRun was awaiting; skipped automatically once
889
- // the task has begun, because getQueuedTask then returns undefined.
890
- const latestTask = this.bridge.getQueuedTask(cwd, messageId);
891
- if (latestTask?.editedMessage) {
892
- const editedContent = latestTask.editedMessage;
893
- ///Step3: 复用原任务 binding + 恢复 cwdOverride(同上)。
894
- this.bridge.setTaskReplacement(cwd, messageId, async () => {
895
- await this.handle(editedContent, ctx, {
896
- cwdOverride: cwd,
897
- binding: latestTask.binding,
892
+ if (targetIdx < 0) {
893
+ getLogger().debug(`[router] queue.immediate: target no longer queued (began/cancelled), not clearing tasks behind it`);
894
+ }
895
+ // 4. DO NOT remove the target task - keep it in queue to execute immediately
896
+ // The queue will naturally execute it after the current task (which we just stopped)
897
+ // 4.4. Refresh the replacement with the latest edited content (idempotent:
898
+ // re-registering over the same slot). Covers the user editing the message
899
+ // again while interruptCurrentRun was awaiting; skipped automatically once
900
+ // the task has begun, because getQueuedTask then returns undefined.
901
+ const latestTask = this.bridge.getQueuedTask(cwd, messageId);
902
+ if (latestTask?.editedMessage) {
903
+ const editedContent = latestTask.editedMessage;
904
+ ///Step3: 复用原任务 binding + 恢复 cwdOverride(同上)。
905
+ this.bridge.setTaskReplacement(cwd, messageId, async () => {
906
+ await this.handle(editedContent, ctx, {
907
+ cwdOverride: cwd,
908
+ binding: latestTask.binding,
909
+ });
898
910
  });
899
- });
900
- }
901
- // 4.5/6. The target must only be marked executing (and the toast may only
902
- // promise immediate execution) when it is really the next task after the
903
- // stop. While interruptCurrentRun was awaiting, a task ahead of the target
904
- // may have begun and now occupy the cwd — the target is still queued
905
- // but is NOT next, so its card must stay queued and the toast must say so.
906
- const targetStillQueued = this.bridge.getQueuedTask(cwd, messageId) !== undefined;
907
- if (!targetStillQueued) {
908
- // The target began or was cancelled while earlier awaits were in flight —
909
- // a success toast must never promise execution of a task that will not
910
- // run. The two missing-reason states get distinct feedback: a target
911
- // that began while the interrupt was in flight is already executing, so
912
- // the toast must acknowledge that instead of telling the user nothing
913
- // was scheduled; only a target that was actually cancelled (never began)
914
- // gets the "未安排执行" wording.
915
- if (this.bridge.hasTaskBegan(messageId)) {
916
- await this.bridge.sendResult({ text: `ℹ️ 目标消息已开始执行,无需重复操作。已清除 ${removedCount} 条排队消息。` }, ctx);
911
+ }
912
+ // 4.5/6. The target must only be marked executing (and the toast may only
913
+ // promise immediate execution) when it is really the next task after the
914
+ // stop. While interruptCurrentRun was awaiting, a task ahead of the target
915
+ // may have begun and now occupy the cwd — the target is still queued
916
+ // but is NOT next, so its card must stay queued and the toast must say so.
917
+ const targetStillQueued = this.bridge.getQueuedTask(cwd, messageId) !== undefined;
918
+ if (!targetStillQueued) {
919
+ // The target began or was cancelled while earlier awaits were in flight —
920
+ // a success toast must never promise execution of a task that will not
921
+ // run. The two missing-reason states get distinct feedback: a target
922
+ // that began while the interrupt was in flight is already executing, so
923
+ // the toast must acknowledge that instead of telling the user nothing
924
+ // was scheduled; only a target that was actually cancelled (never began)
925
+ // gets the "未安排执行" wording.
926
+ if (this.bridge.hasTaskBegan(messageId)) {
927
+ await this.bridge.sendResult({ text: `ℹ️ 目标消息已开始执行,无需重复操作。已清除 ${removedCount} 条排队消息。` }, ctx);
928
+ }
929
+ else {
930
+ await this.bridge.sendResult({
931
+ text: `⚠️ 目标消息已不在队列中(可能已被撤销),未安排执行。已清除 ${removedCount} 条排队消息。`,
932
+ }, ctx);
933
+ }
934
+ return;
935
+ }
936
+ const workspaceBusy = this.bridge.isBusyFor(cwd);
937
+ if (stopUnconfirmed) {
938
+ const name = interrupt.agentKind ? agentDisplayName(interrupt.agentKind) : 'agent';
939
+ await this.bridge.sendResult({
940
+ text: `⚠️ 未能确认 ${name} 的 turn 已停止,它可能仍在后台运行。\n` +
941
+ `已清除 ${removedCount} 条排队消息;您的消息已提交,但可能要等该 turn 结束后才开始。`,
942
+ }, ctx);
943
+ }
944
+ else if (!workspaceBusy) {
945
+ // Normal path: nothing new began during the stop, the target is next.
946
+ // Mark its own card as executing now so buttons grey out immediately.
947
+ // Done BEFORE any removal so getQueuedTask still returns the task with
948
+ // its (possibly edited) messagePreview for the card. The queue callback's
949
+ // later updateQueueCardToExecuting call becomes a no-op (idempotent).
950
+ await this.bridge.markQueueCardExecuting(cwd, messageId);
951
+ const stopPrefix = stopped ? '⚡ 已停止当前任务,' : '';
952
+ await this.bridge.sendResult({ text: `${stopPrefix}清除了 ${removedCount} 条排队消息。您的消息将立即执行。` }, ctx);
917
953
  }
918
954
  else {
919
955
  await this.bridge.sendResult({
920
- text: `⚠️ 目标消息已不在队列中(可能已被撤销),未安排执行。已清除 ${removedCount} 条排队消息。`,
956
+ text: `⚠️ 排在目标之前的任务已开始执行,目标消息保持排队。已清除 ${removedCount} 条排队消息。`,
921
957
  }, ctx);
922
958
  }
923
- return;
924
959
  }
925
- const workspaceBusy = this.bridge.isBusyFor(cwd);
926
- if (stopUnconfirmed) {
927
- const name = interrupt.agentKind ? agentDisplayName(interrupt.agentKind) : 'agent';
928
- await this.bridge.sendResult({
929
- text: `⚠️ 未能确认 ${name} 的 turn 已停止,它可能仍在后台运行。\n` +
930
- `已清除 ${removedCount} 条排队消息;您的消息已提交,但可能要等该 turn 结束后才开始。`,
931
- }, ctx);
932
- }
933
- else if (!workspaceBusy) {
934
- // Normal path: nothing new began during the stop, the target is next.
935
- // Mark its own card as executing now so buttons grey out immediately.
936
- // Done BEFORE any removal so getQueuedTask still returns the task with
937
- // its (possibly edited) messagePreview for the card. The queue callback's
938
- // later updateQueueCardToExecuting call becomes a no-op (idempotent).
939
- await this.bridge.markQueueCardExecuting(cwd, messageId);
940
- const stopPrefix = stopped ? '⚡ 已停止当前任务,' : '';
941
- await this.bridge.sendResult({ text: `${stopPrefix}清除了 ${removedCount} 条排队消息。您的消息将立即执行。` }, ctx);
942
- }
943
- else {
944
- await this.bridge.sendResult({
945
- text: `⚠️ 排在目标之前的任务已开始执行,目标消息保持排队。已清除 ${removedCount} 条排队消息。`,
946
- }, ctx);
960
+ finally {
961
+ releaseRunStart();
947
962
  }
948
963
  }
949
964
  /**
@@ -147,6 +147,12 @@ export declare class ConnectionManager<TClient extends ConnectionClient = JsonRp
147
147
  *
148
148
  * `idleTtlMs: 0` = 不回收(与 claude 会话层的 `armIdleTimer` 同口径;按 0
149
149
  * setTimeout 会秒删刚建好的连接,等于每条消息重起一次进程)。
150
+ *
151
+ * 定时器回调带 **slot 身份校验**:武装之后 slot 可能已被替换——进程自退走
152
+ * baseOnClose(删 slot 但不经过 disposeSlot)、或同 cwd 已 acquire 了新连接。
153
+ * 这两种情况下本定时器都已过期,不得回收「当时恰好挂在该 cwd 上」的连接:
154
+ * 2026-10-24 线上事故即一条泄漏的过期 TTL 杀掉继任连接的在途 turn,卡片
155
+ * 误报 "Codex app-server connection closed"。
150
156
  */
151
157
  notifyIdle(cwd: string): void;
152
158
  private createClient;
@@ -183,18 +183,27 @@ export class ConnectionManager {
183
183
  *
184
184
  * `idleTtlMs: 0` = 不回收(与 claude 会话层的 `armIdleTimer` 同口径;按 0
185
185
  * setTimeout 会秒删刚建好的连接,等于每条消息重起一次进程)。
186
+ *
187
+ * 定时器回调带 **slot 身份校验**:武装之后 slot 可能已被替换——进程自退走
188
+ * baseOnClose(删 slot 但不经过 disposeSlot)、或同 cwd 已 acquire 了新连接。
189
+ * 这两种情况下本定时器都已过期,不得回收「当时恰好挂在该 cwd 上」的连接:
190
+ * 2026-10-24 线上事故即一条泄漏的过期 TTL 杀掉继任连接的在途 turn,卡片
191
+ * 误报 "Codex app-server connection closed"。
186
192
  */
187
193
  notifyIdle(cwd) {
188
194
  if (this.idleTtlMs <= 0)
189
195
  return;
190
196
  const slot = this.slots.get(cwd);
191
- if (slot) {
192
- this.clearIdleTimer(slot);
193
- slot.idleTimer = setTimeout(() => {
194
- getLogger().info(`[${this.logTag}] idle timeout for cwd=${cwd}, releasing connection`);
195
- this.disposeSlot(cwd);
196
- }, this.idleTtlMs);
197
- }
197
+ if (!slot)
198
+ return;
199
+ this.clearIdleTimer(slot);
200
+ slot.idleTimer = setTimeout(() => {
201
+ slot.idleTimer = null;
202
+ if (this.slots.get(cwd) !== slot)
203
+ return;
204
+ getLogger().info(`[${this.logTag}] idle timeout for cwd=${cwd}, releasing connection`);
205
+ this.disposeSlot(cwd);
206
+ }, this.idleTtlMs);
198
207
  }
199
208
  // =========================================================================
200
209
  // Internal
@@ -222,7 +231,13 @@ export class ConnectionManager {
222
231
  getLogger().info(`[${this.logTag}] connection closed cwd=${cwd}`);
223
232
  // 只清理自己创建的 slot:并发 release + 重新 acquire 后 slot 可能已被
224
233
  // 新连接占据,误删会破坏新连接。
225
- if (this.slots.get(cwd)?.client === client) {
234
+ const current = this.slots.get(cwd);
235
+ if (current?.client === client) {
236
+ // 进程自退(codex app-server 偶发 exit 0,非本程序发起,不经
237
+ // disposeSlot)也要撤掉挂在旧 slot 上的 idle TTL:定时器不随 slot
238
+ // 删除而取消,到期时会按 cwd 误杀继任连接的在途 turn(2026-10-24
239
+ // 线上事故,见 notifyIdle 的身份校验与对应回归测试)。
240
+ this.clearIdleTimer(current);
226
241
  this.slots.delete(cwd);
227
242
  this.onConnectionLost?.(cwd);
228
243
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lark-remote",
3
- "version": "0.5.9",
3
+ "version": "0.5.10",
4
4
  "description": "Feishu (Lark) private-chat bridge to local coding agents — Claude Code / Codex / opencode / pi / kimi, with live-streaming CardKit 2.0 cards",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",