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 +11 -0
- package/dist/bridge/index.d.ts +9 -0
- package/dist/bridge/index.js +16 -0
- package/dist/bridge/queue-manager.d.ts +31 -0
- package/dist/bridge/queue-manager.js +78 -0
- package/dist/router/index.js +127 -112
- package/dist/runner/common/jsonrpc/connection-manager.d.ts +6 -0
- package/dist/runner/common/jsonrpc/connection-manager.js +23 -8
- package/package.json +1 -1
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
|
### 新增
|
package/dist/bridge/index.d.ts
CHANGED
|
@@ -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).
|
package/dist/bridge/index.js
CHANGED
|
@@ -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
|
package/dist/router/index.js
CHANGED
|
@@ -822,128 +822,143 @@ export class CommandRouter {
|
|
|
822
822
|
await this.bridge.sendResult({ text: '⚠️ 正在压缩会话,消息将在压缩完成后按顺序执行。' }, ctx);
|
|
823
823
|
return;
|
|
824
824
|
}
|
|
825
|
-
// 1.
|
|
826
|
-
//
|
|
827
|
-
//
|
|
828
|
-
//
|
|
829
|
-
//
|
|
830
|
-
//
|
|
831
|
-
//
|
|
832
|
-
//
|
|
833
|
-
//
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
//
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
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
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
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
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
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: `⚠️
|
|
956
|
+
text: `⚠️ 排在目标之前的任务已开始执行,目标消息保持排队。已清除 ${removedCount} 条排队消息。`,
|
|
921
957
|
}, ctx);
|
|
922
958
|
}
|
|
923
|
-
return;
|
|
924
959
|
}
|
|
925
|
-
|
|
926
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|