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.
- package/CHANGELOG.md +32 -2
- package/dist/bridge/index.d.ts +41 -7
- package/dist/bridge/index.js +255 -129
- package/dist/bridge/queue-manager.d.ts +37 -0
- package/dist/bridge/queue-manager.js +98 -5
- package/dist/card/card-shared.js +4 -0
- package/dist/card/run-renderer.d.ts +8 -1
- package/dist/card/run-renderer.js +100 -9
- package/dist/card/run-state.d.ts +29 -2
- package/dist/card/run-state.js +23 -4
- package/dist/index.js +8 -7
- package/dist/router/index.js +139 -124
- package/dist/router/usage-format.d.ts +5 -0
- package/dist/router/usage-format.js +1 -1
- package/dist/runner/common/jsonrpc/connection-manager.d.ts +6 -0
- package/dist/runner/common/jsonrpc/connection-manager.js +23 -8
- package/dist/runner/dsh/client.d.ts +15 -0
- package/dist/runner/dsh/client.js +27 -0
- package/dist/runner/dsh/goal-view.d.ts +36 -0
- package/dist/runner/dsh/goal-view.js +106 -0
- package/dist/runner/dsh/runner.js +51 -2
- package/dist/runner/index.d.ts +1 -1
- package/dist/runner/types.d.ts +40 -1
- package/dist/session/dsh/sessions.js +2 -1
- package/package.json +1 -1
package/dist/bridge/index.js
CHANGED
|
@@ -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
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
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
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
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
|
-
|
|
692
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
/**
|