lark-remote 0.5.18 → 0.5.20
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 -0
- package/dist/bridge/approval-actions.d.ts +1 -1
- package/dist/bridge/approval-actions.js +1 -1
- package/dist/bridge/bash-run.d.ts +71 -0
- package/dist/bridge/bash-run.js +217 -0
- package/dist/bridge/compact.d.ts +155 -0
- package/dist/bridge/compact.js +450 -0
- package/dist/bridge/final-usage.js +4 -4
- package/dist/bridge/inbound-media.js +1 -1
- package/dist/bridge/index.d.ts +120 -119
- package/dist/bridge/index.js +257 -695
- package/dist/bridge/job-watch.d.ts +68 -0
- package/dist/bridge/job-watch.js +146 -0
- package/dist/bridge/queue-manager.d.ts +3 -3
- package/dist/bridge/queue-manager.js +10 -29
- package/dist/card/approval-render.d.ts +2 -2
- package/dist/card/approval-render.js +109 -97
- package/dist/card/bash-card-session.js +2 -0
- package/dist/card/card-budget.js +1 -1
- package/dist/card/card-helpers.d.ts +3 -3
- package/dist/card/card-helpers.js +6 -6
- package/dist/card/card-session.d.ts +17 -0
- package/dist/card/card-session.js +35 -0
- package/dist/card/final-answer-file.js +1 -1
- package/dist/card/run-card-session.js +2 -0
- package/dist/card/run-renderer.d.ts +3 -3
- package/dist/card/run-renderer.js +77 -102
- package/dist/card/run-state.d.ts +35 -8
- package/dist/card/run-state.js +117 -90
- package/dist/card/tool-render.d.ts +1 -1
- package/dist/card/tool-render.js +1 -1
- package/dist/card/usage-format.d.ts +12 -0
- package/dist/card/usage-format.js +8 -3
- package/dist/cli.js +15 -3
- package/dist/clone.js +2 -2
- package/dist/config/codex-config.js +130 -106
- package/dist/config/index.d.ts +16 -2
- package/dist/config/index.js +23 -8
- package/dist/connector/index.js +6 -6
- package/dist/error-classification.js +3 -2
- package/dist/inbound/placeholder.d.ts +1 -1
- package/dist/inbound/placeholder.js +1 -1
- package/dist/index.js +203 -133
- package/dist/logger/index.js +6 -6
- package/dist/message-handlers.d.ts +0 -5
- package/dist/message-handlers.js +4 -2
- package/dist/model-catalog.d.ts +2 -2
- package/dist/model-catalog.js +3 -8
- package/dist/platform/identity.js +1 -1
- package/dist/platform/path.d.ts +1 -1
- package/dist/platform/path.js +1 -1
- package/dist/platform/sleep-blocker.js +1 -1
- package/dist/platform/terminator-win32.js +2 -1
- package/dist/router/commands/resume.js +2 -20
- package/dist/router/config/dsh.d.ts +4 -8
- package/dist/router/config/dsh.js +3 -10
- package/dist/router/config/kimi.js +1 -1
- package/dist/router/config/types.d.ts +1 -1
- package/dist/router/config-diff.js +1 -1
- package/dist/router/index.d.ts +56 -11
- package/dist/router/index.js +222 -202
- package/dist/runner/claude/session.d.ts +1 -1
- package/dist/runner/claude/session.js +6 -4
- package/dist/runner/codex/app-server/runner.d.ts +9 -2
- package/dist/runner/codex/app-server/runner.js +30 -25
- package/dist/runner/codex/app-server/translator.d.ts +0 -5
- package/dist/runner/codex/app-server/translator.js +4 -17
- package/dist/runner/common/acp/acp-approval.d.ts +1 -1
- package/dist/runner/common/acp/acp-approval.js +1 -1
- package/dist/runner/common/acp/base-acp-runner.d.ts +5 -2
- package/dist/runner/common/acp/base-acp-runner.js +26 -65
- package/dist/runner/common/acp/base-translator.d.ts +12 -2
- package/dist/runner/common/acp/base-translator.js +31 -9
- package/dist/runner/common/acp/protocol-types.d.ts +1 -1
- package/dist/runner/common/acp/protocol-types.js +2 -2
- package/dist/runner/common/connection-based-runner.d.ts +32 -1
- package/dist/runner/common/connection-based-runner.js +32 -2
- package/dist/runner/common/jsonrpc/connection-manager.d.ts +2 -2
- package/dist/runner/common/jsonrpc/connection-manager.js +4 -4
- package/dist/runner/common/spawning-runner.d.ts +1 -1
- package/dist/runner/common/spawning-runner.js +1 -1
- package/dist/runner/dsh/catalog-probe.d.ts +17 -0
- package/dist/runner/dsh/catalog-probe.js +10 -0
- package/dist/runner/dsh/client.d.ts +13 -8
- package/dist/runner/dsh/client.js +23 -15
- package/dist/runner/dsh/exit-cancel.d.ts +2 -2
- package/dist/runner/dsh/exit-cancel.js +2 -2
- package/dist/runner/dsh/inflight.d.ts +1 -1
- package/dist/runner/dsh/inflight.js +1 -1
- package/dist/runner/dsh/runner.d.ts +48 -2
- package/dist/runner/dsh/runner.js +288 -199
- package/dist/runner/dsh/translator.d.ts +1 -1
- package/dist/runner/dsh/translator.js +1 -1
- package/dist/runner/index.d.ts +1 -1
- package/dist/runner/kimi/acp/runner.d.ts +1 -2
- package/dist/runner/kimi/acp/runner.js +1 -2
- package/dist/runner/kimi/acp/translator.d.ts +1 -1
- package/dist/runner/kimi/acp/translator.js +3 -4
- package/dist/runner/opencode/acp/runner.d.ts +2 -2
- package/dist/runner/opencode/acp/runner.js +4 -4
- package/dist/runner/pi/rpc/protocol-types.d.ts +1 -1
- package/dist/runner/pi/rpc/protocol-types.js +1 -1
- package/dist/runner/pi/rpc/runner.d.ts +3 -0
- package/dist/runner/pi/rpc/runner.js +27 -42
- package/dist/runner/types.d.ts +25 -8
- package/dist/runner/types.js +1 -1
- package/dist/session/claude/sessions.d.ts +45 -0
- package/dist/session/claude/sessions.js +97 -71
- package/dist/session/codex/rollout-reader.js +4 -4
- package/dist/session/common/throw-session-list-error.d.ts +10 -0
- package/dist/session/common/throw-session-list-error.js +16 -0
- package/dist/session/dsh/sessions.js +11 -5
- package/dist/session/kimi/sessions.d.ts +40 -1
- package/dist/session/kimi/sessions.js +244 -192
- package/dist/session/opencode/sessions.d.ts +153 -1
- package/dist/session/opencode/sessions.js +354 -321
- package/dist/session/pi/sessions.js +4 -5
- package/dist/session/session-store.d.ts +1 -1
- package/dist/session/session-store.js +1 -1
- package/dist/update/version-check.js +2 -1
- package/dist/workspace/index.d.ts +1 -1
- package/dist/workspace/index.js +1 -1
- package/package.json +1 -1
package/dist/bridge/index.js
CHANGED
|
@@ -1,22 +1,19 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { sleep } from '../common/sleep.js';
|
|
3
2
|
import { isContentFilterFailure } from '../common/turn-failure.js';
|
|
4
3
|
import { resolveFinalUsage, errorMessage } from './final-usage.js';
|
|
5
4
|
import { getLogger } from '../logger/index.js';
|
|
6
5
|
import { RunCardSession } from '../card/run-card-session.js';
|
|
7
|
-
import {
|
|
6
|
+
import { renderRunCardDetailed } from '../card/run-renderer.js';
|
|
8
7
|
import { writeFinalAnswerFile, finalAnswerFileFailureCard } from '../card/final-answer-file.js';
|
|
9
8
|
import { bestEffortUnlink } from '../common/fs.js';
|
|
10
|
-
import {
|
|
11
|
-
import { BashCardSession, capBashOutput } from '../card/bash-card-session.js';
|
|
12
|
-
import { isRunActive, isRunTerminal, removeBackgroundJob, } from '../card/run-state.js';
|
|
9
|
+
import { isRunActive, isRunTerminal } from '../card/run-state.js';
|
|
13
10
|
import { buildSessionHistoryCard } from '../card/card-helpers.js';
|
|
14
11
|
import { agentDisplayName, resumeUseButton } from '../card/card-shared.js';
|
|
15
12
|
import { enforceCardBudget } from '../card/card-budget.js';
|
|
16
13
|
import { RunUsageCollector } from './run-usage.js';
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
14
|
+
import { CompactLifecycle, runnerSupportsCompact, terminalReactionEmoji } from './compact.js';
|
|
15
|
+
import { JobWatchLifecycle } from './job-watch.js';
|
|
16
|
+
import { BashRunLifecycle } from './bash-run.js';
|
|
20
17
|
import { QueueManager, } from './queue-manager.js';
|
|
21
18
|
import { ApprovalActions } from './approval-actions.js';
|
|
22
19
|
import { createRunApprovalCoordinator, remindPendingApproval } from './run-approval.js';
|
|
@@ -42,21 +39,6 @@ const MAX_CACHED_RUNNER_WORKSPACES = 10;
|
|
|
42
39
|
* message forever.
|
|
43
40
|
*/
|
|
44
41
|
const IDLE_TIMEOUT_MS = 15 * 60 * 1000;
|
|
45
|
-
/**
|
|
46
|
-
* Emoji reaction per run terminal state, so a glance at the user's original
|
|
47
|
-
* message distinguishes how the run ended. Unknown/non-terminal states fall
|
|
48
|
-
* back to 'Done' (default).
|
|
49
|
-
*/
|
|
50
|
-
const TERMINAL_REACTION_EMOJI = {
|
|
51
|
-
error: 'ERROR',
|
|
52
|
-
idle_timeout: 'Alarm',
|
|
53
|
-
interrupted: 'SHHH',
|
|
54
|
-
// 本轮结束但目标继续:任务还没完,不能打 ✓(Done)。
|
|
55
|
-
goal_continuing: 'OnIt',
|
|
56
|
-
};
|
|
57
|
-
function terminalReactionEmoji(terminal) {
|
|
58
|
-
return TERMINAL_REACTION_EMOJI[terminal] ?? 'Done';
|
|
59
|
-
}
|
|
60
42
|
/**
|
|
61
43
|
* 完成通知卡的标题 / 配色 / 结果行。
|
|
62
44
|
*
|
|
@@ -81,40 +63,6 @@ function completionNotificationPresentation(terminal) {
|
|
|
81
63
|
return { title: '✅ 会话已完成', template: 'green', result: 'completed' };
|
|
82
64
|
}
|
|
83
65
|
}
|
|
84
|
-
/**
|
|
85
|
-
* 单点化:`'runCompact' in runner` 鸭子探测
|
|
86
|
-
* codex app-server + kimi/opencode ACP runner 满足。
|
|
87
|
-
*/
|
|
88
|
-
function runnerHasRunCompact(runner) {
|
|
89
|
-
return ('runCompact' in runner && typeof runner.runCompact === 'function');
|
|
90
|
-
}
|
|
91
|
-
/**
|
|
92
|
-
* 已告警过的能力不一致 kind。能力表(`COMPACT_CAPABILITY`)是声明,runner 方法
|
|
93
|
-
* 是实现;两者不一致是开发期缺陷,只在首次冲突时打 error,避免每次渲染刷屏。
|
|
94
|
-
*/
|
|
95
|
-
const compactMismatchWarned = new Set();
|
|
96
|
-
/**
|
|
97
|
-
* Compact 能力的双重门控:声明的能力表 && runner 实际实现。
|
|
98
|
-
*
|
|
99
|
-
* 只做鸭子探测会把「漏实现」降级成静默隐藏按钮(dsh 就这样漏了),所以声明表
|
|
100
|
-
* 是必需项;只信声明表则可能渲染出点不动的死按钮,所以实现检查也保留。两者
|
|
101
|
-
* 不一致时打 error 日志,让缺陷在运行日志里可见。
|
|
102
|
-
*/
|
|
103
|
-
function runnerSupportsCompact(runner, kind) {
|
|
104
|
-
const declared = supportsCompact(kind);
|
|
105
|
-
const implemented = runnerHasRunCompact(runner);
|
|
106
|
-
if (declared !== implemented && !compactMismatchWarned.has(kind)) {
|
|
107
|
-
compactMismatchWarned.add(kind);
|
|
108
|
-
getLogger().error(`[lark-remote] compact capability mismatch kind=${kind} declared=${declared} implemented=${implemented}`);
|
|
109
|
-
}
|
|
110
|
-
return declared && implemented;
|
|
111
|
-
}
|
|
112
|
-
/**
|
|
113
|
-
* 每个 cwd 保留的「可压缩 run」条数上限。Compact 按钮挂在终态 run 卡片上,
|
|
114
|
-
* 卡片会被后续消息不断顶上去;无上限会让长跑进程随 run 数无界增长,而用户
|
|
115
|
-
* 实际能点到的只有最近几张卡,20 条足以覆盖。
|
|
116
|
-
*/
|
|
117
|
-
const MAX_COMPACTABLE_RUNS_PER_CWD = 20;
|
|
118
66
|
/**
|
|
119
67
|
* Bridge owns the per-workspace serial processing queue (§9.6) and the
|
|
120
68
|
* single-card streaming forwarding loop (§9.12 watchdog + session sync).
|
|
@@ -149,7 +97,7 @@ export class Bridge {
|
|
|
149
97
|
mediaHandler;
|
|
150
98
|
/**
|
|
151
99
|
* Per-workspace runner instances, keyed by (cwd, agentKind).
|
|
152
|
-
*
|
|
100
|
+
* Each agentKind has its own cache slot, so switching
|
|
153
101
|
* defaultAgent creates a NEW runner without evicting the old one. This
|
|
154
102
|
* eliminates the need for clearRunners() on agent switch, preventing the
|
|
155
103
|
* regression where clearRunners() orphaned a running subprocess and
|
|
@@ -175,16 +123,11 @@ export class Bridge {
|
|
|
175
123
|
coordinatorFor: (runId) => this.approvalCoordinators.get(runId),
|
|
176
124
|
});
|
|
177
125
|
/**
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
* 为什么按 runId 存而不是「每 cwd 只留最新一条」:compact 卡片动作走串行
|
|
183
|
-
* 队列(会被正在跑的 run 挡住),点击到执行之间可能夹着别的 run 收尾。
|
|
184
|
-
* 单槽会被覆盖 —— 旧卡片的压缩必然报「该任务已结束或不属于当前会话」,
|
|
185
|
-
* 与 session 是否还在、能否压缩无关(2026-10-08 线上缺陷)。
|
|
126
|
+
* Compact 生命周期(见 compact.ts):可压缩 run 记账、终态卡 / resume 卡
|
|
127
|
+
* 两条 Compact 入口、kimi 在途检测与压缩流式卡。activeRuns 槽位表按引用
|
|
128
|
+
* 共用——压缩注册前确认槽位空闲,否则 /stop 停错对象。
|
|
186
129
|
*/
|
|
187
|
-
|
|
130
|
+
compact;
|
|
188
131
|
/**
|
|
189
132
|
* 终态卡片的渲染快照(runId → state + options):run 结束后 activeRuns 已
|
|
190
133
|
* 清空,而卡片上的「⏹ 停止」后台任务按钮(job.kill)还活着,需要按 runId
|
|
@@ -195,12 +138,17 @@ export class Bridge {
|
|
|
195
138
|
runCardSnapshots = new Map();
|
|
196
139
|
static RUN_CARD_SNAPSHOT_MAX = 8;
|
|
197
140
|
/**
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
|
|
141
|
+
* 终态卡后台任务订阅与停止(见 job-watch.ts):job.kill、名单订阅与
|
|
142
|
+
* PATCH 串行链收在该模块;jobCardPatches 链内渲染读 snapshot.state,
|
|
143
|
+
* 「停止」与「订阅帧」并发不会把卡片写回旧内容。
|
|
144
|
+
*/
|
|
145
|
+
jobWatch;
|
|
146
|
+
/**
|
|
147
|
+
* `!` bash 命令执行(见 bash-run.ts):独立 runId 空间、独立登记表,不进
|
|
148
|
+
* activeRuns;绝不 re-chain 到 QueueManager(会自等待死锁)。/stop 的 bash
|
|
149
|
+
* 侧匹配与中断也收在该模块,避免误杀并行跑的 `!` 任务。
|
|
202
150
|
*/
|
|
203
|
-
|
|
151
|
+
bashRuns;
|
|
204
152
|
constructor(deps) {
|
|
205
153
|
this.connector = deps.connector;
|
|
206
154
|
this.sessionStore = deps.sessionStore;
|
|
@@ -214,11 +162,36 @@ export class Bridge {
|
|
|
214
162
|
resolveCwd: (userId) => this.resolveCwd(userId),
|
|
215
163
|
getConfig: () => this.config,
|
|
216
164
|
});
|
|
165
|
+
this.compact = new CompactLifecycle({
|
|
166
|
+
activeRuns: this.activeRuns,
|
|
167
|
+
connector: this.connector,
|
|
168
|
+
sessionStore: this.sessionStore,
|
|
169
|
+
sessionReaderRegistry: this.sessionReaderRegistry,
|
|
170
|
+
getConfig: () => this.config,
|
|
171
|
+
getRunner: (cwd, kind, opts) => this.getRunner(cwd, kind, opts),
|
|
172
|
+
sendResult: (result, ctx, opts) => this.sendResult(result, ctx, opts),
|
|
173
|
+
startIdleWatchdog: (opts) => this.startIdleWatchdog(opts),
|
|
174
|
+
finishTurnReactions: (ctx, emoji) => this.finishTurnReactions(ctx, emoji),
|
|
175
|
+
resolveCwd: (userId) => this.resolveCwd(userId),
|
|
176
|
+
});
|
|
177
|
+
this.jobWatch = new JobWatchLifecycle({
|
|
178
|
+
getRunner: (cwd, kind) => this.getRunner(cwd, kind),
|
|
179
|
+
getRunCardSnapshot: (runId) => this.runCardSnapshots.get(runId),
|
|
180
|
+
updateCardInPlace: (card, ctx) => this.updateCardInPlace(card, ctx),
|
|
181
|
+
patchCard: (messageId, card) => this.patchCard(messageId, card),
|
|
182
|
+
});
|
|
183
|
+
this.bashRuns = new BashRunLifecycle({
|
|
184
|
+
connector: this.connector,
|
|
185
|
+
sessionStore: this.sessionStore,
|
|
186
|
+
resolveCwd: (userId) => this.resolveCwd(userId),
|
|
187
|
+
sendResult: (result, ctx) => this.sendResult(result, ctx),
|
|
188
|
+
finishTurnReactions: (ctx, emoji) => this.finishTurnReactions(ctx, emoji),
|
|
189
|
+
});
|
|
217
190
|
}
|
|
218
191
|
/**
|
|
219
192
|
* Get or create a runner for the given cwd, keyed by current defaultAgent.
|
|
220
193
|
*
|
|
221
|
-
*
|
|
194
|
+
* Cache key includes agentKind, so switching defaultAgent
|
|
222
195
|
* creates a NEW runner under a different key without evicting the old one.
|
|
223
196
|
* This eliminates the need for clearRunners() on agent switch.
|
|
224
197
|
*/
|
|
@@ -365,9 +338,9 @@ export class Bridge {
|
|
|
365
338
|
*/
|
|
366
339
|
clearRunners() {
|
|
367
340
|
// Skip workspaces with an active run: evicting their runner would orphan
|
|
368
|
-
// the instance that owns the live subprocess
|
|
369
|
-
//
|
|
370
|
-
//
|
|
341
|
+
// the instance that owns the live subprocess — the running runner would
|
|
342
|
+
// become unreachable, stop() would hit a fresh empty runner, the real
|
|
343
|
+
// subprocess would never be killed and the queue chain would block forever.
|
|
371
344
|
// The running run already started with its (possibly now-stale) config, so
|
|
372
345
|
// keeping it is correct; finalizeRun deletes the cache entry when the run
|
|
373
346
|
// ends, and the next run picks up the new config/agent via getRunner.
|
|
@@ -631,25 +604,7 @@ export class Bridge {
|
|
|
631
604
|
}
|
|
632
605
|
/** Get all active bash runs with their state for /active command. */
|
|
633
606
|
getActiveBashRuns() {
|
|
634
|
-
|
|
635
|
-
for (const [runId, bashRun] of this.activeBashRuns) {
|
|
636
|
-
const state = bashRun.cardSession.currentState;
|
|
637
|
-
// Only include non-terminal runs (bash has no finalizing state)
|
|
638
|
-
if (state.terminal === 'running') {
|
|
639
|
-
result.push({
|
|
640
|
-
runId,
|
|
641
|
-
cwd: bashRun.cwd,
|
|
642
|
-
userId: bashRun.userId,
|
|
643
|
-
chatId: bashRun.chatId,
|
|
644
|
-
terminal: state.terminal,
|
|
645
|
-
command: bashRun.command,
|
|
646
|
-
});
|
|
647
|
-
}
|
|
648
|
-
}
|
|
649
|
-
return result;
|
|
650
|
-
}
|
|
651
|
-
async interruptCurrentRun(input) {
|
|
652
|
-
return (await this.interruptCurrentRunDetailed(input)).stopped;
|
|
607
|
+
return this.bashRuns.getActiveBashRuns();
|
|
653
608
|
}
|
|
654
609
|
/**
|
|
655
610
|
* Same as {@link interruptCurrentRun}, plus what the runner's stop actually
|
|
@@ -664,15 +619,31 @@ export class Bridge {
|
|
|
664
619
|
// 并行跑;旧实现停在「第一条命中」就 return —— 两个 workspace 并行时
|
|
665
620
|
// `/stop` 只停一个却回「已停止」,另一个无声继续。
|
|
666
621
|
// 限定 cwd(queue.immediate)或带 runId(停止卡按钮)时仍只有一条命中。
|
|
667
|
-
const
|
|
622
|
+
const ownedActiveRuns = [];
|
|
668
623
|
for (const [cwd, active] of this.activeRuns) {
|
|
669
624
|
if (input.cwd !== undefined && cwd !== input.cwd)
|
|
670
625
|
continue;
|
|
671
626
|
if (active.userId !== input.userId || active.chatId !== input.chatId)
|
|
672
627
|
continue;
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
628
|
+
ownedActiveRuns.push([cwd, active]);
|
|
629
|
+
}
|
|
630
|
+
// Card stop prefers the authoritative runId. If the click is scoped to a
|
|
631
|
+
// run card but that value is stale/absent, accept only the active run whose
|
|
632
|
+
// server-provided card messageId is exactly the clicked card. This repairs
|
|
633
|
+
// callbacks that lose behavior-value without ever stopping a historical or
|
|
634
|
+
// dashboard card against a newer run.
|
|
635
|
+
let activeTargets;
|
|
636
|
+
if (input.runId) {
|
|
637
|
+
activeTargets = ownedActiveRuns.filter(([, active]) => active.runId === input.runId);
|
|
638
|
+
if (activeTargets.length === 0) {
|
|
639
|
+
activeTargets = this.activeRunsForCard(ownedActiveRuns, input.cardMessageId);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
else if (input.cardMessageId) {
|
|
643
|
+
activeTargets = this.activeRunsForCard(ownedActiveRuns, input.cardMessageId);
|
|
644
|
+
}
|
|
645
|
+
else {
|
|
646
|
+
activeTargets = ownedActiveRuns;
|
|
676
647
|
}
|
|
677
648
|
let stoppedCount = 0;
|
|
678
649
|
let agentKind;
|
|
@@ -689,37 +660,16 @@ export class Bridge {
|
|
|
689
660
|
stop = stopResult;
|
|
690
661
|
}
|
|
691
662
|
}
|
|
692
|
-
// Then check active bash runs (! commands).
|
|
693
|
-
//
|
|
694
|
-
//
|
|
695
|
-
// `!`
|
|
696
|
-
|
|
697
|
-
// 没有 claude run 命中时才回退到 bash,避免误杀用户并行跑的 `!` 任务。
|
|
698
|
-
const includeBashRuns = (input.runId === undefined && input.cwd === undefined) || activeTargets.length === 0;
|
|
699
|
-
const bashTargets = includeBashRuns
|
|
700
|
-
? [...this.activeBashRuns.entries()].filter(([runId, b]) => {
|
|
701
|
-
if (input.cwd !== undefined && b.cwd !== input.cwd)
|
|
702
|
-
return false;
|
|
703
|
-
if (b.userId !== input.userId || b.chatId !== input.chatId)
|
|
704
|
-
return false;
|
|
705
|
-
if (input.runId && runId !== input.runId)
|
|
706
|
-
return false;
|
|
707
|
-
return true;
|
|
708
|
-
})
|
|
709
|
-
: [];
|
|
710
|
-
for (const [runId, b] of bashTargets) {
|
|
711
|
-
getLogger().info(`[lark-remote] interrupt hit bash runId=${runId} cwd=${b.cwd} userId=${input.userId}`);
|
|
712
|
-
await b.bashRunner.stop({ immediate: true });
|
|
713
|
-
// Update card to show interrupted state. exitCode 不传(保持 null):
|
|
714
|
-
// bash-renderer 的「已手动终止」文案要求 exitCode === null;传 -1 会让
|
|
715
|
-
// 该分支不可达,中断卡退化成「退出码: -1」的失败样式。
|
|
716
|
-
await b.cardSession.finish('interrupted');
|
|
717
|
-
this.activeBashRuns.delete(runId);
|
|
718
|
-
getLogger().info(`[lark-remote] interrupt bash stop done runId=${runId}`);
|
|
719
|
-
stoppedCount++;
|
|
720
|
-
}
|
|
663
|
+
// Then check active bash runs (! commands). 实现在 bash-run.ts
|
|
664
|
+
// (bridge 结构治理第 3 刀):bash 用独立 runId 空间、独立登记表,匹配
|
|
665
|
+
// 与停止整体收在 stopMatchingBashRuns 里——`/stop` 语义与「不误杀并行
|
|
666
|
+
// `!` 任务」的守卫注释随迁。
|
|
667
|
+
stoppedCount += await this.bashRuns.stopMatchingBashRuns(input, activeTargets.length);
|
|
721
668
|
if (stoppedCount === 0) {
|
|
722
|
-
getLogger().
|
|
669
|
+
getLogger().warn(`[lark-remote] interrupt miss userId=${input.userId} ` +
|
|
670
|
+
`requestedRunId=${input.runId ?? '(none)'} ` +
|
|
671
|
+
`cardMessageId=${input.cardMessageId ?? '(none)'} ` +
|
|
672
|
+
`active=${ownedActiveRuns.map(([, active]) => active.runId).join(',') || '(none)'}`);
|
|
723
673
|
return { stopped: false };
|
|
724
674
|
}
|
|
725
675
|
return {
|
|
@@ -729,6 +679,26 @@ export class Bridge {
|
|
|
729
679
|
...(stop ? { stop } : {}),
|
|
730
680
|
};
|
|
731
681
|
}
|
|
682
|
+
/**
|
|
683
|
+
* Resolve a scoped card click by card identity. Empty input or multiple
|
|
684
|
+
* matches fail closed; production message ids are unique, but refusing an
|
|
685
|
+
* ambiguous stop is safer than killing several workspaces.
|
|
686
|
+
*/
|
|
687
|
+
activeRunsForCard(ownedActiveRuns, cardMessageId) {
|
|
688
|
+
if (!cardMessageId)
|
|
689
|
+
return [];
|
|
690
|
+
const matches = ownedActiveRuns.filter(([, active]) => active.session.cardMessageId === cardMessageId);
|
|
691
|
+
if (matches.length === 0)
|
|
692
|
+
return [];
|
|
693
|
+
if (matches.length > 1) {
|
|
694
|
+
getLogger().warn(`[lark-remote] interrupt ambiguous cardMessageId=${cardMessageId} ` +
|
|
695
|
+
`active=${matches.map(([, active]) => active.runId).join(',')}`);
|
|
696
|
+
return [];
|
|
697
|
+
}
|
|
698
|
+
getLogger().warn(`[lark-remote] interrupt using active card fallback ` +
|
|
699
|
+
`runId=${matches[0][1].runId} cardMessageId=${cardMessageId}`);
|
|
700
|
+
return matches;
|
|
701
|
+
}
|
|
732
702
|
/**
|
|
733
703
|
* 停掉一条已注册的 active run:置终态、停 runner、摘槽位、标 stale、
|
|
734
704
|
* 归零该 cwd 的队列执行计数。返回 runner 的停止结果(DSH 才有)。
|
|
@@ -741,8 +711,8 @@ export class Bridge {
|
|
|
741
711
|
// Stop the EXACT runner instance that is running (carried by activeRun),
|
|
742
712
|
// not whatever getRunner(cwd) would return now. clearRunners() may have
|
|
743
713
|
// emptied the cache mid-run, in which case getRunner creates a fresh empty
|
|
744
|
-
// runner whose stop() is a no-op and the real subprocess is never killed
|
|
745
|
-
//
|
|
714
|
+
// runner whose stop() is a no-op and the real subprocess is never killed,
|
|
715
|
+
// blocking the queue chain forever.
|
|
746
716
|
const runner = active.runner;
|
|
747
717
|
// Bind the queue reset to THIS task's execution slot: the stop window
|
|
748
718
|
// below may outlive the task's settle, letting the serial chain advance
|
|
@@ -750,18 +720,28 @@ export class Bridge {
|
|
|
750
720
|
// unconditional reset would then zero the successor's count and mark
|
|
751
721
|
// its slot interrupted, hiding it from queue cards.
|
|
752
722
|
const stoppedSlot = this.queueManager.getExecutingSlot(cwd);
|
|
753
|
-
const [, stopResult] = await Promise.allSettled([
|
|
723
|
+
const [finishResult, stopResult] = await Promise.allSettled([
|
|
754
724
|
// 用户主动中断(/stop、stop 卡按钮、queue.immediate)显式打标
|
|
755
725
|
// interruptedReason,与审批超时自动取消(approval_timeout)、审批
|
|
756
726
|
// 卡取消(approval_cancelled)在终态数据上可区分(方案 A)。
|
|
757
727
|
active.session.finish('interrupted', { interruptedReason: 'user_stop' }),
|
|
758
728
|
runner.stop({ immediate: true }),
|
|
759
729
|
]);
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
:
|
|
730
|
+
// allSettled 的两个结果都必须检查:finish 被飞书拒绝时卡片会永久停在
|
|
731
|
+
// 「运行中」;runner.stop 失败时进程可能没死而槽位已被释放——两者都
|
|
732
|
+
// 不能静默吞掉(此前第一个结果整体丢弃、stop 失败仍记 "stop done")。
|
|
733
|
+
if (finishResult.status === 'rejected') {
|
|
734
|
+
getLogger().error(`[lark-remote] stop: terminal card finish failed runId=${active.runId}:`, finishResult.reason);
|
|
735
|
+
}
|
|
736
|
+
let stop;
|
|
737
|
+
if (stopResult.status === 'rejected') {
|
|
738
|
+
getLogger().error(`[lark-remote] stop: runner.stop failed runId=${active.runId} — 运行可能未终止:`, stopResult.reason);
|
|
739
|
+
// 上报「可能仍在跑」,调用方按 unconfirmed 口径聚合(不承诺已停止)。
|
|
740
|
+
stop = { status: 'unconfirmed', detail: errorMessage(stopResult.reason) };
|
|
741
|
+
}
|
|
742
|
+
else {
|
|
743
|
+
stop = stopResult.value ?? undefined;
|
|
744
|
+
}
|
|
765
745
|
// The chain may have advanced while the stop was in flight: a new run
|
|
766
746
|
// (from a task that began during the stop window) may now occupy this
|
|
767
747
|
// cwd. Only delete the entry we actually stopped.
|
|
@@ -777,8 +757,8 @@ export class Bridge {
|
|
|
777
757
|
// interruptCurrentRun 的 await 窗口内 queue 链会推进下一条消息,其
|
|
778
758
|
// getRunner 可能刚拿走本实例(缓存命中先于本行执行)——立即 evict 会
|
|
779
759
|
// 让那个 run 的长驻连接脱管:进程残留至 idle TTL 才回收,期间持有
|
|
780
|
-
// agent 侧会话写锁(codex "thread already has an active writer"
|
|
781
|
-
//
|
|
760
|
+
// agent 侧会话写锁(codex "thread already has an active writer")。
|
|
761
|
+
// 改为标 stale:下一次「即将开跑」的 getRunner 在槽位空闲时
|
|
782
762
|
// 换新实例(见 getRunner 的 replaceStale),本次 run 的 finalize 收尾时
|
|
783
763
|
// 也会消费该标记;两者都保证不会有活跃 run 使用被 dispose 的实例。
|
|
784
764
|
// run-per-message runner 无长驻连接,保持立即 evict 不变。
|
|
@@ -800,11 +780,18 @@ export class Bridge {
|
|
|
800
780
|
if (stoppedSlot !== undefined) {
|
|
801
781
|
this.queueManager.resetExecutingCount(cwd, stoppedSlot);
|
|
802
782
|
}
|
|
803
|
-
|
|
783
|
+
if (stop?.status !== 'unconfirmed') {
|
|
784
|
+
getLogger().info(`[lark-remote] interrupt stop done runId=${active.runId}`);
|
|
785
|
+
}
|
|
804
786
|
return stop;
|
|
805
787
|
}
|
|
806
|
-
/**
|
|
807
|
-
|
|
788
|
+
/**
|
|
789
|
+
* Send a result message to the user (§7). Returns true on success, false on failure.
|
|
790
|
+
*
|
|
791
|
+
* `opts.onCardSent` 回传卡片的 messageId:终态卡收尾后还要靠它接收后台任务
|
|
792
|
+
* 更新(流式卡从 `cardSession.cardMessageId` 取,兜底发送只能从这里拿)。
|
|
793
|
+
*/
|
|
794
|
+
async sendResult(result, ctx, opts) {
|
|
808
795
|
try {
|
|
809
796
|
if (result.card) {
|
|
810
797
|
// 静态卡片体积保护:确保卡片不超过飞书大小限制
|
|
@@ -813,7 +800,8 @@ export class Bridge {
|
|
|
813
800
|
getLogger().info(`[lark-remote] card size exceeded limit, applied budget enforcement: reason=${reason} bytesBefore=${bytesBefore} bytesAfter=${bytesAfter}`);
|
|
814
801
|
}
|
|
815
802
|
getLogger().debug(`[lark-remote] sendResult kind=card chatId=${ctx.chatId}`);
|
|
816
|
-
await this.connector.sendWithRetry(ctx.chatId, { card: safeCard }, { replyTo: ctx.messageId });
|
|
803
|
+
const messageId = await this.connector.sendWithRetry(ctx.chatId, { card: safeCard }, { replyTo: ctx.messageId });
|
|
804
|
+
opts?.onCardSent?.(messageId);
|
|
817
805
|
return true;
|
|
818
806
|
}
|
|
819
807
|
else if (result.markdown) {
|
|
@@ -845,25 +833,22 @@ export class Bridge {
|
|
|
845
833
|
/**
|
|
846
834
|
* Deliver a final answer that cannot fit in the run card as a Markdown file
|
|
847
835
|
* attachment. 卡片正文已被标记替代,文件是唯一的内容通道;此处绝不发纯文本
|
|
848
|
-
*
|
|
836
|
+
* (纯文本与卡片重复,也丢 Markdown 结构)。
|
|
849
837
|
*
|
|
850
|
-
*
|
|
851
|
-
*
|
|
838
|
+
* 发送成功即清理临时文件;失败时发一张提示卡(不是纯文本),完整回答
|
|
839
|
+
* 留在临时文件里(路径见错误日志),便于事后找回。
|
|
852
840
|
*/
|
|
853
841
|
async sendFinalAnswerFile(text, ctx) {
|
|
854
842
|
let filePath;
|
|
855
843
|
try {
|
|
856
844
|
filePath = writeFinalAnswerFile(text);
|
|
857
845
|
await this.connector.sendFile(ctx.chatId, filePath);
|
|
846
|
+
bestEffortUnlink(filePath);
|
|
858
847
|
}
|
|
859
848
|
catch (err) {
|
|
860
849
|
getLogger().error(`[lark-remote] failed to deliver complete final answer as file (kept at ${filePath ?? 'not written'}):`, err);
|
|
861
850
|
await this.sendResult({ card: finalAnswerFileFailureCard(errorMessage(err)) }, ctx);
|
|
862
851
|
}
|
|
863
|
-
finally {
|
|
864
|
-
if (filePath)
|
|
865
|
-
bestEffortUnlink(filePath);
|
|
866
|
-
}
|
|
867
852
|
}
|
|
868
853
|
/**
|
|
869
854
|
* Update an existing card in place (原地更新) instead of sending a new one.
|
|
@@ -925,37 +910,34 @@ export class Bridge {
|
|
|
925
910
|
* 不放在 finish() 里:bridge 才知道 hasRunCompact 等渲染选项,且 eviction
|
|
926
911
|
* 与 activeRuns 生命周期在同一个地方收口。
|
|
927
912
|
*/
|
|
928
|
-
rememberRunCardSnapshot(runId, state, options, cwd, agentKind) {
|
|
929
|
-
|
|
913
|
+
rememberRunCardSnapshot(runId, state, options, cwd, agentKind, messageId) {
|
|
914
|
+
const snapshot = {
|
|
915
|
+
state,
|
|
916
|
+
options,
|
|
917
|
+
cwd,
|
|
918
|
+
agentKind,
|
|
919
|
+
...(messageId === undefined ? {} : { messageId }),
|
|
920
|
+
};
|
|
921
|
+
this.runCardSnapshots.set(runId, snapshot);
|
|
930
922
|
while (this.runCardSnapshots.size > Bridge.RUN_CARD_SNAPSHOT_MAX) {
|
|
931
923
|
const oldest = this.runCardSnapshots.keys().next().value;
|
|
932
|
-
if (oldest === undefined)
|
|
933
|
-
break;
|
|
934
924
|
this.runCardSnapshots.delete(oldest);
|
|
925
|
+
// 卡片已经不在窗口里:继续订阅只是往一个没人看的 messageId 打 PATCH。
|
|
926
|
+
this.jobWatch.stopJobWatch(oldest);
|
|
935
927
|
}
|
|
928
|
+
return snapshot;
|
|
936
929
|
}
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
930
|
+
// =============================================================================
|
|
931
|
+
// 后台任务停止与订阅
|
|
932
|
+
// =============================================================================
|
|
933
|
+
// 实现住在 job-watch.ts(bridge 结构治理第 2 刀):job.kill、名单订阅、
|
|
934
|
+
// markJobsUnknown 与 PATCH 串行链整体搬过去。这里只留 killBackgroundJob
|
|
935
|
+
// 薄转发——router 的 job.kill 卡片动作按名字分发到 bridge 公开 API;
|
|
936
|
+
// 快照淘汰(rememberRunCardSnapshot)与收尾订阅(finalizeRun)直接调
|
|
937
|
+
// this.jobWatch 的同名方法。
|
|
938
|
+
/** 停止终态卡上的一个后台任务(job.kill 卡片动作)。 */
|
|
945
939
|
async killBackgroundJob(runId, jobId, ctx) {
|
|
946
|
-
|
|
947
|
-
const sessionId = snapshot?.state.sessionId;
|
|
948
|
-
if (!snapshot || !sessionId)
|
|
949
|
-
return false;
|
|
950
|
-
const runner = this.getRunner(snapshot.cwd, snapshot.agentKind);
|
|
951
|
-
if (typeof runner.killJob !== 'function')
|
|
952
|
-
return false;
|
|
953
|
-
const killed = await runner.killJob(sessionId, jobId);
|
|
954
|
-
if (!killed)
|
|
955
|
-
return false;
|
|
956
|
-
snapshot.state = removeBackgroundJob(snapshot.state, jobId);
|
|
957
|
-
await this.updateCardInPlace(renderRunCard(snapshot.state, snapshot.options), ctx);
|
|
958
|
-
return true;
|
|
940
|
+
return this.jobWatch.killBackgroundJob(runId, jobId, ctx);
|
|
959
941
|
}
|
|
960
942
|
/** Send a file to the user via Feishu. */
|
|
961
943
|
async sendFile(filePath, ctx) {
|
|
@@ -1029,7 +1011,7 @@ export class Bridge {
|
|
|
1029
1011
|
// sessionId 钉死语义——binding 有 sessionId 时用它(入队时刻快照),
|
|
1030
1012
|
// 否则跟随 live store(无 binding 或入队时无 session 的正常路径)。
|
|
1031
1013
|
const sessionId = opts?.binding?.sessionId ?? this.sessionStore.getSessionId(ctx.userId, agentKind);
|
|
1032
|
-
//
|
|
1014
|
+
// 会话代际快照:/new、/cd、/resume 会 bump
|
|
1033
1015
|
// epoch,该 run 后续 system.init 的 sessionId 写回即判 stale 跳过。
|
|
1034
1016
|
// 快照点优先取 binding.sessionEpoch(入队/commit 时刻,与 sessionId 同刻):
|
|
1035
1017
|
// 排队消息在「commit 后、开跑前」被 reset 时,执行起点捕获会漏判(epoch 已是
|
|
@@ -1039,7 +1021,7 @@ export class Bridge {
|
|
|
1039
1021
|
// Resolve the runner BEFORE creating the run session so the exact instance
|
|
1040
1022
|
// that will run is captured into activeRun. interruptCurrentRun stops THAT
|
|
1041
1023
|
// instance directly -- never a fresh runner re-resolved from a cache that
|
|
1042
|
-
// clearRunners() may have emptied mid-run
|
|
1024
|
+
// clearRunners() may have emptied mid-run.
|
|
1043
1025
|
const runner = this.getRunner(cwd, agentKind, { replaceStale: true });
|
|
1044
1026
|
// Step 1: Create run session (runId, cardSession, activeRun tracking)
|
|
1045
1027
|
const { runId, cardSession, activeRun } = this.createRunSession(ctx, cwd, runner, agentKind);
|
|
@@ -1071,7 +1053,7 @@ export class Bridge {
|
|
|
1071
1053
|
*
|
|
1072
1054
|
* DSH 把上游 `finish_reason: content_filter` 归一成 PI_AI_ERROR:既不在默认
|
|
1073
1055
|
* 重试码里(EMPTY_RESPONSE/RATE_LIMIT/SERVER/TIMEOUT/TRANSPORT),也不会换模型,
|
|
1074
|
-
* 于是一次(多数是误报的)判定就把整个 turn 判死。
|
|
1056
|
+
* 于是一次(多数是误报的)判定就把整个 turn 判死。60 步、
|
|
1075
1057
|
* 10 分钟的 turn 在最后一步被截断,已写入的文件还在,用户只看到「运行出错」
|
|
1076
1058
|
* 加一句英文报文。
|
|
1077
1059
|
*
|
|
@@ -1190,12 +1172,23 @@ export class Bridge {
|
|
|
1190
1172
|
}
|
|
1191
1173
|
stop();
|
|
1192
1174
|
getLogger().warn(`[lark-remote] claude idle timeout, stopping process runId=${opts.runId}`);
|
|
1175
|
+
// 看门狗清理的两个结果都必须留痕:卡片 patch 失败时终态会停在
|
|
1176
|
+
// 「运行中」;runner.stop 失败时挂死的 run 没被杀、cwd 槽位未释放,
|
|
1177
|
+
// 串行队列从此永久堵死(用户视角「机器人不回消息」,日志却只有
|
|
1178
|
+
// 前面一条 warn)。对照 claude session.ts idle timer 的 .catch(log)。
|
|
1193
1179
|
void Promise.allSettled([
|
|
1194
1180
|
opts.session.finish('idle_timeout', {
|
|
1195
1181
|
idleTimeoutMinutes: Math.max(1, Math.round(this.idleTimeoutMs / 60_000)),
|
|
1196
1182
|
}),
|
|
1197
1183
|
opts.runner.stop(),
|
|
1198
|
-
])
|
|
1184
|
+
]).then(([finishResult, stopResult]) => {
|
|
1185
|
+
if (finishResult.status === 'rejected') {
|
|
1186
|
+
getLogger().error(`[lark-remote] idle-timeout cleanup failed runId=${opts.runId} finish:`, finishResult.reason);
|
|
1187
|
+
}
|
|
1188
|
+
if (stopResult.status === 'rejected') {
|
|
1189
|
+
getLogger().error(`[lark-remote] idle-timeout cleanup failed runId=${opts.runId} stop:`, stopResult.reason);
|
|
1190
|
+
}
|
|
1191
|
+
});
|
|
1199
1192
|
}, tickMs);
|
|
1200
1193
|
}
|
|
1201
1194
|
return {
|
|
@@ -1214,7 +1207,7 @@ export class Bridge {
|
|
|
1214
1207
|
* result 在 system.init 之后才到。
|
|
1215
1208
|
*
|
|
1216
1209
|
* 例外:app-server runner(usageAuthority==='live')的 init 是 runner 自己补发
|
|
1217
|
-
* 的 synthetic init
|
|
1210
|
+
* 的 synthetic init。此分支保留为防御性兜底:live runner 若
|
|
1218
1211
|
* 在无 init 时发出 result(旧构建等),session 写回照常执行,但 sawResult 不置
|
|
1219
1212
|
* 真——卡片不会把半初始化的 run 当作已收尾。
|
|
1220
1213
|
*
|
|
@@ -1293,7 +1286,7 @@ export class Bridge {
|
|
|
1293
1286
|
const { cardSession, usage, sawResult, sawAnyResult, pendingPreInitTerminal, agentKind, runId, } = opts;
|
|
1294
1287
|
const finalState = cardSession.currentState;
|
|
1295
1288
|
if (finalState.terminal === 'finalizing') {
|
|
1296
|
-
//
|
|
1289
|
+
// 三态终态映射:interrupted(审批超时/取消、/stop)是独立
|
|
1297
1290
|
// 终态,不再归因于 Agent 错误;error 保持「运行出错」;其余为 done。
|
|
1298
1291
|
const subtype = finalState.resultSubtype;
|
|
1299
1292
|
const isError = subtype === 'error' || !!finalState.errorMsg;
|
|
@@ -1431,29 +1424,13 @@ export class Bridge {
|
|
|
1431
1424
|
if (isRunTerminal(terminal)) {
|
|
1432
1425
|
continue;
|
|
1433
1426
|
}
|
|
1434
|
-
|
|
1427
|
+
// 归一化:init/turn_started 的 session 写回 + compact_boundary 用量观察。
|
|
1428
|
+
// 返回 true = 本轮 system.init 已到(调用方置 sawInit,供 pre-init 守卫用)。
|
|
1429
|
+
if (this.maybeSyncSession(event, { activeRun, writeback, usage, runId })) {
|
|
1435
1430
|
sawInit = true;
|
|
1436
|
-
// 本轮真实使用的 session(不可变身份):指针在 run 中被 /new 等移动时,
|
|
1437
|
-
// 结束后的 usage 读取仍以它为准。
|
|
1438
|
-
activeRun.sessionId = event.session_id;
|
|
1439
|
-
writeBackInitSession(writeback, event);
|
|
1440
|
-
getLogger().info(`[lark-remote] system.init received runId=${runId} sessionId=${event.session_id} cwd=${event.cwd}`);
|
|
1441
|
-
}
|
|
1442
|
-
if (event.type === 'turn_started') {
|
|
1443
|
-
// app-server runner 在 turn setup 成功后补发 synthetic system.init
|
|
1444
|
-
// (§9.22 守卫前提),此处 turn_started 写回保留为双保险:两者都用
|
|
1445
|
-
// threadId 写回 sessionId(代际守卫一致),幂等。缺失会导致每次消息
|
|
1446
|
-
// 都新建线程(thread/start 而非 thread/resume),会话无法延续。
|
|
1447
|
-
// init 缺失时以 threadId 作为本轮身份兜底。
|
|
1448
|
-
activeRun.sessionId ??= event.threadId;
|
|
1449
|
-
writeBackTurnStarted(writeback, event);
|
|
1450
|
-
}
|
|
1451
|
-
// Track context length from compaction events
|
|
1452
|
-
if (event.type === 'system' &&
|
|
1453
|
-
event.subtype === 'compact_boundary' &&
|
|
1454
|
-
event.compactMetadata) {
|
|
1455
|
-
usage.observeCompaction(event.compactMetadata.postTokens);
|
|
1456
1431
|
}
|
|
1432
|
+
// pre-init result 守卫(§9.22,observeResultEvent):resume 重放的陈旧
|
|
1433
|
+
// result 不置 sawResult、不进卡片。
|
|
1457
1434
|
if (event.type === 'result') {
|
|
1458
1435
|
const outcome = this.observeResultEvent(event, {
|
|
1459
1436
|
sawInit,
|
|
@@ -1471,11 +1448,12 @@ export class Bridge {
|
|
|
1471
1448
|
// 跳过,在 bridge 层就拦掉可以省下一次无谓的 push + render。
|
|
1472
1449
|
if (outcome.dropFromCard) {
|
|
1473
1450
|
// 仍然重置 idle 计时——CLI 活着并且在产事件。
|
|
1474
|
-
watchdog
|
|
1451
|
+
this.handleEventForWatchdog(watchdog);
|
|
1475
1452
|
continue;
|
|
1476
1453
|
}
|
|
1477
1454
|
}
|
|
1478
|
-
// Approval events (Codex app-server mode)
|
|
1455
|
+
// Approval events (Codex app-server mode)。审批先到、item/started 后到的
|
|
1456
|
+
// 乱序流也在此补全(dispatchApprovalEvent 内 updateView)。
|
|
1479
1457
|
if (event.type === 'approval_requested' ||
|
|
1480
1458
|
event.type === 'approval_resolved' ||
|
|
1481
1459
|
event.type === 'approval_view_updated') {
|
|
@@ -1485,7 +1463,8 @@ export class Bridge {
|
|
|
1485
1463
|
// lastEventTs 重新武装看门狗。result 后 CLI 进程做 jsonl flush/清理(finalizing
|
|
1486
1464
|
// 过渡)期间,宽限期恢复为完整的 idleTimeoutMs,避免已产出结果的 turn 被误标
|
|
1487
1465
|
// idle_timeout。旧实现 `else { resetIdle(); }` 漏掉 result 分支。
|
|
1488
|
-
watchdog
|
|
1466
|
+
this.handleEventForWatchdog(watchdog);
|
|
1467
|
+
// 渲染:reducer(run-state)在 cardSession.push 内部。
|
|
1489
1468
|
await cardSession.push(event);
|
|
1490
1469
|
}
|
|
1491
1470
|
getLogger().info(`[lark-remote] runner stream end runId=${runId} sawResult=${sawResult}`);
|
|
@@ -1556,6 +1535,48 @@ export class Bridge {
|
|
|
1556
1535
|
}
|
|
1557
1536
|
}
|
|
1558
1537
|
}
|
|
1538
|
+
/**
|
|
1539
|
+
* 事件循环的看门狗喂狗(runAgentStreamToEnd 抽出的纯向方法)。
|
|
1540
|
+
*
|
|
1541
|
+
* §9.12:事件到达只 `touch()` 刷新时间戳(无 timer 操作),interval 周期检查
|
|
1542
|
+
* 静默时长;喂狗点与红线口径见 runAgentStreamToEnd 循环内两处调用点注释
|
|
1543
|
+
* (每个事件含 result 都必须喂,被守卫丢弃的 result 也喂——CLI 活着就在产事件)。
|
|
1544
|
+
*/
|
|
1545
|
+
handleEventForWatchdog(watchdog) {
|
|
1546
|
+
watchdog?.touch();
|
|
1547
|
+
}
|
|
1548
|
+
/**
|
|
1549
|
+
* 事件循环的 session 同步(runAgentStreamToEnd 抽出的纯向方法):
|
|
1550
|
+
* init / turn_started 两写回点 + compact_boundary 用量观察。
|
|
1551
|
+
*
|
|
1552
|
+
* @returns 事件是否为本轮 `system.init`(调用方据此置 sawInit,供 pre-init
|
|
1553
|
+
* result 守卫判定)。init 分支的全部副作用完成后才返回,与原内联顺序一致。
|
|
1554
|
+
*/
|
|
1555
|
+
maybeSyncSession(event, opts) {
|
|
1556
|
+
const { activeRun, writeback, usage, runId } = opts;
|
|
1557
|
+
if (event.type === 'system' && event.subtype === 'init') {
|
|
1558
|
+
// 本轮真实使用的 session(不可变身份):指针在 run 中被 /new 等移动时,
|
|
1559
|
+
// 结束后的 usage 读取仍以它为准。
|
|
1560
|
+
activeRun.sessionId = event.session_id;
|
|
1561
|
+
writeBackInitSession(writeback, event);
|
|
1562
|
+
getLogger().info(`[lark-remote] system.init received runId=${runId} sessionId=${event.session_id} cwd=${event.cwd}`);
|
|
1563
|
+
return true;
|
|
1564
|
+
}
|
|
1565
|
+
if (event.type === 'turn_started') {
|
|
1566
|
+
// app-server runner 在 turn setup 成功后补发 synthetic system.init
|
|
1567
|
+
// (§9.22 守卫前提),此处 turn_started 写回保留为双保险:两者都用
|
|
1568
|
+
// threadId 写回 sessionId(代际守卫一致),幂等。缺失会导致每次消息
|
|
1569
|
+
// 都新建线程(thread/start 而非 thread/resume),会话无法延续。
|
|
1570
|
+
// init 缺失时以 threadId 作为本轮身份兜底。
|
|
1571
|
+
activeRun.sessionId ??= event.threadId;
|
|
1572
|
+
writeBackTurnStarted(writeback, event);
|
|
1573
|
+
}
|
|
1574
|
+
// Track context length from compaction events
|
|
1575
|
+
if (event.type === 'system' && event.subtype === 'compact_boundary' && event.compactMetadata) {
|
|
1576
|
+
usage.observeCompaction(event.compactMetadata.postTokens);
|
|
1577
|
+
}
|
|
1578
|
+
return false;
|
|
1579
|
+
}
|
|
1559
1580
|
/**
|
|
1560
1581
|
* 终态 reaction 收尾:撤掉 Typing、打上下方终态表情。
|
|
1561
1582
|
*
|
|
@@ -1597,10 +1618,15 @@ export class Bridge {
|
|
|
1597
1618
|
this.sessionStore.getSessionId(ctx.userId, activeRun.agentKind)),
|
|
1598
1619
|
};
|
|
1599
1620
|
const finalRender = renderRunCardDetailed(cardSession.currentState, finalRenderOptions);
|
|
1600
|
-
this.rememberRunCardSnapshot(runId, cardSession.currentState, finalRenderOptions, cwd, activeRun.agentKind);
|
|
1621
|
+
const snapshot = this.rememberRunCardSnapshot(runId, cardSession.currentState, finalRenderOptions, cwd, activeRun.agentKind, cardSession.cardMessageId);
|
|
1601
1622
|
let runCardSent = false;
|
|
1602
1623
|
if (settleResult === 'unsent') {
|
|
1603
|
-
const sent = await this.sendResult({ card: finalRender.card }, ctx
|
|
1624
|
+
const sent = await this.sendResult({ card: finalRender.card }, ctx, {
|
|
1625
|
+
// 兜底发送的卡也要能接收后台任务更新 —— 流式卡没建起来时这是唯一来源。
|
|
1626
|
+
onCardSent: (messageId) => {
|
|
1627
|
+
snapshot.messageId = messageId;
|
|
1628
|
+
},
|
|
1629
|
+
});
|
|
1604
1630
|
runCardSent = sent;
|
|
1605
1631
|
}
|
|
1606
1632
|
else {
|
|
@@ -1618,6 +1644,9 @@ export class Bridge {
|
|
|
1618
1644
|
}
|
|
1619
1645
|
// Add a terminal-state emoji reaction to the user's original message
|
|
1620
1646
|
this.finishTurnReactions(ctx, terminalReactionEmoji(cardSession.currentState.terminal));
|
|
1647
|
+
// 后台任务跨 turn:卡片收尾只是"这一轮的投影结束了",任务本身可能还在跑。
|
|
1648
|
+
// 订阅放在最后:卡片已落地,patch 才有落点。
|
|
1649
|
+
this.jobWatch.startBackgroundJobWatch(runId, snapshot, activeRun.runner);
|
|
1621
1650
|
}
|
|
1622
1651
|
finally {
|
|
1623
1652
|
// cleanup must survive mid-finalize errors. The only unguarded
|
|
@@ -1633,7 +1662,7 @@ export class Bridge {
|
|
|
1633
1662
|
// runId,只能回「该任务已结束或不属于当前会话」。守卫只回答「cwd 槽位还
|
|
1634
1663
|
// 归不归本 run」(决定能不能删槽位/evict runner),记账回答「这次 run
|
|
1635
1664
|
// 结束了吗、用的哪个 sessionId」——后者与所有权无关,放在守卫外。
|
|
1636
|
-
this.recordCompactableRunForTerminal(cardSession, activeRun, ctx);
|
|
1665
|
+
this.compact.recordCompactableRunForTerminal(cardSession, activeRun, ctx);
|
|
1637
1666
|
if (this.activeRuns.get(cwd) === activeRun) {
|
|
1638
1667
|
this.activeRuns.delete(cwd);
|
|
1639
1668
|
// Workspace-lifetime runners (codex app-server) keep their persistent
|
|
@@ -1731,498 +1760,31 @@ export class Bridge {
|
|
|
1731
1760
|
async handleApprovalAnswerNote(opts) {
|
|
1732
1761
|
return this.approvalActions.handleApprovalAnswerNote(opts);
|
|
1733
1762
|
}
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
if (!byRunId) {
|
|
1743
|
-
byRunId = new Map();
|
|
1744
|
-
this.compactableRuns.set(cwd, byRunId);
|
|
1745
|
-
}
|
|
1746
|
-
byRunId.delete(runId);
|
|
1747
|
-
byRunId.set(runId, { sessionId, agentKind, cwd });
|
|
1748
|
-
while (byRunId.size > MAX_COMPACTABLE_RUNS_PER_CWD) {
|
|
1749
|
-
const oldest = byRunId.keys().next().value;
|
|
1750
|
-
if (oldest === undefined)
|
|
1751
|
-
break;
|
|
1752
|
-
byRunId.delete(oldest);
|
|
1753
|
-
}
|
|
1754
|
-
}
|
|
1755
|
-
/**
|
|
1756
|
-
* 终态 Compact 记账:把本次 run 实际使用的 sessionId 记进
|
|
1757
|
-
* {@link compactableRuns},供终态卡上的 Compact 按钮按 runId 反查。
|
|
1758
|
-
*
|
|
1759
|
-
* 调用点固定在 `finalizeRun` 的 `finally` 里、**槽位所有权守卫之外**。
|
|
1760
|
-
* 所有权守卫只回答「cwd 槽位还归不归本 run」(决定能否删槽位 / evict
|
|
1761
|
-
* runner);记账回答「本次 run 结束了吗、用的哪个 sessionId」。两者在不同
|
|
1762
|
-
* 维度上:外部中断(`/stop`、停止卡按钮、`queue.immediate`)会先摘槽位,
|
|
1763
|
-
* 记账若跟着守卫走就永远不会落,中断卡片的按钮必然反查不到(中断
|
|
1764
|
-
* 后点 Compact 一律回「该任务已结束或不属于当前会话」)。
|
|
1765
|
-
*
|
|
1766
|
-
* 覆盖所有终态入口(正常结束、error、interrupted、idle_timeout、
|
|
1767
|
-
* goal_continuing)——这些卡片都渲染 Compact 按钮(`goal` 仍 active 时
|
|
1768
|
-
* 除外)。记账失败不能影响清理,故内部兜错。
|
|
1769
|
-
*/
|
|
1770
|
-
recordCompactableRunForTerminal(cardSession, activeRun, ctx) {
|
|
1771
|
-
try {
|
|
1772
|
-
if (!runnerSupportsCompact(activeRun.runner, activeRun.agentKind))
|
|
1773
|
-
return;
|
|
1774
|
-
if (activeRun.runner.lifetime !== 'workspace')
|
|
1775
|
-
return;
|
|
1776
|
-
const terminal = cardSession.currentState.terminal;
|
|
1777
|
-
// 与事件消费守卫、Compact 按钮共用同一份终态集合(run-state.ts)。
|
|
1778
|
-
if (!isRunTerminal(terminal))
|
|
1779
|
-
return;
|
|
1780
|
-
// 卡片状态优先(本轮 init/turn_started 写入);缺失时回退 store——
|
|
1781
|
-
// 启动失败但已 /stop 的 run 没有自己的 init,压缩此前会话仍是有意义
|
|
1782
|
-
// 的动作。
|
|
1783
|
-
const compactSessionId = cardSession.currentState.sessionId ??
|
|
1784
|
-
this.sessionStore.getSessionId(ctx.userId, activeRun.agentKind);
|
|
1785
|
-
if (!compactSessionId)
|
|
1786
|
-
return;
|
|
1787
|
-
this.recordCompactableRun(activeRun.cwd, activeRun.runId, compactSessionId, activeRun.agentKind);
|
|
1788
|
-
}
|
|
1789
|
-
catch (err) {
|
|
1790
|
-
getLogger().warn(`[lark-remote] compact record failed runId=${activeRun.runId}: ${errorMessage(err)}`);
|
|
1791
|
-
}
|
|
1792
|
-
}
|
|
1793
|
-
/**
|
|
1794
|
-
* 按 runId 反查可压缩 run 记录(跨 cwd 扫描)。
|
|
1795
|
-
*
|
|
1796
|
-
* 为什么不带 cwd 过滤:按钮属于「那一次 run」,与用户此刻在哪个工作目录、
|
|
1797
|
-
* 排队期间有没有 /cd 无关。`/stop` 的 interruptCurrentRun 同样是全局按 runId
|
|
1798
|
-
* 找(从不查当前 cwd),compact 与它保持一致。用当前 cwd 收窄会让「切了工作区
|
|
1799
|
-
* 再点旧卡片」和「排队期间 /cd」两种情况误报「该任务已结束或不属于当前会话」。
|
|
1800
|
-
*/
|
|
1801
|
-
findCompactableRun(runId) {
|
|
1802
|
-
for (const byRunId of this.compactableRuns.values()) {
|
|
1803
|
-
const record = byRunId.get(runId);
|
|
1804
|
-
if (record)
|
|
1805
|
-
return record;
|
|
1806
|
-
}
|
|
1807
|
-
return undefined;
|
|
1808
|
-
}
|
|
1809
|
-
/**
|
|
1810
|
-
* Handle compact card action — trigger a compaction request for any
|
|
1811
|
-
* runCompact-capable runner(codex/kimi/opencode/pi/claude)。按钮带的是
|
|
1812
|
-
* 那次 run 的 runId,据此反查该 run 实际使用的 sessionId + agentKind
|
|
1813
|
-
* + cwd(compactableRuns,按 runId 记录),再调 runner 的 runCompact()。
|
|
1814
|
-
*/
|
|
1763
|
+
// =============================================================================
|
|
1764
|
+
// Compact 动作入口
|
|
1765
|
+
// =============================================================================
|
|
1766
|
+
// 实现住在 compact.ts(bridge 结构治理第 1 刀):可压缩 run 记账、终态卡 /
|
|
1767
|
+
// resume 卡两条入口、kimi 在途检测与压缩流式卡全部搬过去;这里只留薄转发,
|
|
1768
|
+
// router 的卡片动作分发(handleCardAction → bridge.handleCompactAction /
|
|
1769
|
+
// handleResumeCompact)与 finalizeRun 的终态记账调用路径不变。
|
|
1770
|
+
/** 终态卡 Compact 按钮:按 runId 反查那次 run 的会话并发起压缩。 */
|
|
1815
1771
|
async handleCompactAction(value, ctx) {
|
|
1816
|
-
|
|
1817
|
-
log.info(`[lark-remote] handleCompactAction userId=${ctx.userId} runId=${value.runId?.slice(0, 8)}...`);
|
|
1818
|
-
const { runId } = value;
|
|
1819
|
-
if (!runId) {
|
|
1820
|
-
await this.sendResult({ text: '⚠️ 无效的 Compact 请求,缺少 runId' }, ctx);
|
|
1821
|
-
return;
|
|
1822
|
-
}
|
|
1823
|
-
// 校验:run 结束后 activeRuns 已清空,只能靠按钮自带的 runId 反查记录。
|
|
1824
|
-
// 按 runId 存(不按「最新的那一条」),点击到执行之间夹了新 run 收尾也不失效;
|
|
1825
|
-
// 查找也不按「当前 cwd」收窄(见 findCompactableRun)。
|
|
1826
|
-
const target = this.findCompactableRun(runId);
|
|
1827
|
-
if (!target) {
|
|
1828
|
-
await this.sendResult({ text: '⚠️ 该任务已结束或不属于当前会话' }, ctx);
|
|
1829
|
-
return;
|
|
1830
|
-
}
|
|
1831
|
-
// 压缩必须用那次 run 自己的 cwd(session 文件 + runner 实例都按 cwd 解析)。
|
|
1832
|
-
const cwd = target.cwd;
|
|
1833
|
-
// §5.3: kimi 压缩在途检测(wire 状态机)。第二次点击在本地毫秒级如实回答,
|
|
1834
|
-
// 不开卡、不发 session/prompt——否则 kimi 服务端拒绝并产生「秒回」假完成。
|
|
1835
|
-
if (await this.rejectIfCompactionInFlight(target.sessionId, cwd, target.agentKind, ctx)) {
|
|
1836
|
-
return;
|
|
1837
|
-
}
|
|
1838
|
-
// Check that the runner has runCompact method(声明表 && runner 实现)
|
|
1839
|
-
const runner = this.getRunner(cwd, target.agentKind, { replaceStale: true });
|
|
1840
|
-
if (!runnerSupportsCompact(runner, target.agentKind)) {
|
|
1841
|
-
await this.sendResult({ text: '⚠️ 当前运行模式不支持 Compact' }, ctx);
|
|
1842
|
-
return;
|
|
1843
|
-
}
|
|
1844
|
-
log.info(`[lark-remote] handleCompactAction executing runCompact for runId=${runId} cwd=${cwd}`);
|
|
1845
|
-
await this.streamCompact({
|
|
1846
|
-
sessionId: target.sessionId,
|
|
1847
|
-
cwd,
|
|
1848
|
-
agentKind: target.agentKind,
|
|
1849
|
-
runner,
|
|
1850
|
-
ctx,
|
|
1851
|
-
});
|
|
1852
|
-
}
|
|
1853
|
-
/**
|
|
1854
|
-
* §5.3: kimi 压缩在途检测——reader 实现契约的 `readCompactionState` 时,若
|
|
1855
|
-
* wire 状态机判定当前有未终结的压缩(最后一次 begin 无 terminal 记录),
|
|
1856
|
-
* 直接回文本「已有一次 Compact 正在进行」并返回 true(调用方不开卡、不发
|
|
1857
|
-
* session/prompt)。codex/claude 等 reader 无此方法 → 返回 false,行为不变。
|
|
1858
|
-
*/
|
|
1859
|
-
async rejectIfCompactionInFlight(sessionId, cwd, agentKind, ctx) {
|
|
1860
|
-
const reader = this.sessionReaderRegistry.get(agentKind);
|
|
1861
|
-
if (!reader?.readCompactionState)
|
|
1862
|
-
return false;
|
|
1863
|
-
try {
|
|
1864
|
-
const state = reader.readCompactionState(sessionId, cwd);
|
|
1865
|
-
if (state?.inFlight) {
|
|
1866
|
-
const since = state.inFlightSince;
|
|
1867
|
-
let timeLabel = '未知时刻';
|
|
1868
|
-
if (since !== undefined) {
|
|
1869
|
-
const d = new Date(since);
|
|
1870
|
-
const hh = String(d.getHours()).padStart(2, '0');
|
|
1871
|
-
const mm = String(d.getMinutes()).padStart(2, '0');
|
|
1872
|
-
timeLabel = `${hh}:${mm}`;
|
|
1873
|
-
}
|
|
1874
|
-
await this.sendResult({ text: `⚠️ 已有一次 Compact 正在进行(开始于 ${timeLabel}),请等待完成` }, ctx);
|
|
1875
|
-
return true;
|
|
1876
|
-
}
|
|
1877
|
-
}
|
|
1878
|
-
catch (err) {
|
|
1879
|
-
getLogger().warn(`[lark-remote] readCompactionState failed sessionId=${sessionId}: ${errorMessage(err)}`);
|
|
1880
|
-
}
|
|
1881
|
-
return false;
|
|
1772
|
+
await this.compact.handleCompactAction(value, ctx);
|
|
1882
1773
|
}
|
|
1883
|
-
/**
|
|
1884
|
-
* Stream a compaction to a card: start a RunCardSession with
|
|
1885
|
-
* operationKind='compaction' (no recursive Compact button), consume
|
|
1886
|
-
* runner.runCompact() events, read authoritative jsonl usage, and finish
|
|
1887
|
-
* the card. Shared by handleCompactAction (run card button) and
|
|
1888
|
-
* handleResumeCompact (resume cards) so both flows stay in sync.
|
|
1889
|
-
*
|
|
1890
|
-
* Precondition: callers have already validated that `runner` implements
|
|
1891
|
-
* runCompact and that the session exists in `cwd`.
|
|
1892
|
-
*/
|
|
1893
|
-
async streamCompact(opts) {
|
|
1894
|
-
const log = getLogger();
|
|
1895
|
-
const { sessionId, cwd, agentKind, runner, ctx } = opts;
|
|
1896
|
-
log.info(`[lark-remote] streamCompact sessionId=${sessionId} cwd=${cwd}`);
|
|
1897
|
-
// 防御性能力检查:调用方已校验(runId / resume 两条路径),此处双保险并
|
|
1898
|
-
// 让 TS 收窄 runner 类型('runCompact' in runner 之后的 cast 才合法)。
|
|
1899
|
-
if (!runnerSupportsCompact(runner, agentKind) ||
|
|
1900
|
-
!('runCompact' in runner) ||
|
|
1901
|
-
typeof runner.runCompact !== 'function') {
|
|
1902
|
-
await this.sendResult({ text: '⚠️ 当前运行模式不支持 Compact' }, ctx);
|
|
1903
|
-
return;
|
|
1904
|
-
}
|
|
1905
|
-
// activeRuns 以 cwd 为键:注册前先确认槽位空闲,否则压缩会覆盖在跑的 run 的
|
|
1906
|
-
// 记录,令 /stop 只停到最后注册的那个。
|
|
1907
|
-
const busyWith = this.activeRuns.get(cwd);
|
|
1908
|
-
if (busyWith) {
|
|
1909
|
-
log.info(`[lark-remote] compact refused: cwd busy cwd=${cwd} runId=${busyWith.runId}`);
|
|
1910
|
-
await this.sendResult({ text: '⚠️ 该工作区正在运行任务,请等待结束或先 /stop' }, ctx);
|
|
1911
|
-
return;
|
|
1912
|
-
}
|
|
1913
|
-
await this.sendResult({ text: '🗜 Compact 已触发,正在压缩会话…' }, ctx);
|
|
1914
|
-
const compactRunId = randomUUID();
|
|
1915
|
-
// 渲染选项同时用于流式卡与「卡片流没建起来」时的静态兜底卡——两者必须
|
|
1916
|
-
// 同门控,否则兜底卡会渲染出与本次 run 不一致的按钮。
|
|
1917
|
-
const renderOptions = { agentKind };
|
|
1918
|
-
const cardSession = new RunCardSession({
|
|
1919
|
-
connector: this.connector,
|
|
1920
|
-
chatId: ctx.chatId,
|
|
1921
|
-
replyTo: ctx.messageId,
|
|
1922
|
-
runId: compactRunId,
|
|
1923
|
-
renderOptions,
|
|
1924
|
-
});
|
|
1925
|
-
// 压缩与普通 run 共用同一套生命周期——注册进 activeRuns(/stop 有对象可停、
|
|
1926
|
-
// /active 看得见)、挂 §9.12 空闲看门狗、终态后补 reaction。缺任何一环,卡住的
|
|
1927
|
-
// 压缩都只能等 runner 级超时,用户既停不掉也看不出它还在跑。
|
|
1928
|
-
const activeRun = {
|
|
1929
|
-
runId: compactRunId,
|
|
1930
|
-
userId: ctx.userId,
|
|
1931
|
-
chatId: ctx.chatId,
|
|
1932
|
-
session: cardSession,
|
|
1933
|
-
cwd,
|
|
1934
|
-
runner,
|
|
1935
|
-
agentKind,
|
|
1936
|
-
};
|
|
1937
|
-
this.activeRuns.set(cwd, activeRun);
|
|
1938
|
-
getLogger().info(`[lark-remote] activeRuns.set cwd=${cwd} runId=${compactRunId} compact=true`);
|
|
1939
|
-
let watchdog = null;
|
|
1940
|
-
try {
|
|
1941
|
-
await cardSession.start();
|
|
1942
|
-
watchdog = this.startIdleWatchdog({
|
|
1943
|
-
runId: compactRunId,
|
|
1944
|
-
session: cardSession,
|
|
1945
|
-
runner,
|
|
1946
|
-
});
|
|
1947
|
-
// operationKind=compaction:卡片不渲染 Compact 按钮(防递归 Compact)。
|
|
1948
|
-
await cardSession.push({
|
|
1949
|
-
type: 'turn_started',
|
|
1950
|
-
threadId: sessionId,
|
|
1951
|
-
turnId: '',
|
|
1952
|
-
operationKind: 'compaction',
|
|
1953
|
-
});
|
|
1954
|
-
let sawResult = false;
|
|
1955
|
-
let resultSubtype;
|
|
1956
|
-
let resultError;
|
|
1957
|
-
for await (const event of runner.runCompact('', { cwd, sessionId })) {
|
|
1958
|
-
// 已终态(/stop、空闲看门狗)后不再消费事件:与 runAgentStreamToEnd 同口径,
|
|
1959
|
-
// 否则迟到的 result 会把终态卡改写成「已完成」。
|
|
1960
|
-
const cardTerminal = cardSession.currentState.terminal;
|
|
1961
|
-
if (isRunTerminal(cardTerminal)) {
|
|
1962
|
-
continue;
|
|
1963
|
-
}
|
|
1964
|
-
if (event.type === 'result') {
|
|
1965
|
-
sawResult = true;
|
|
1966
|
-
resultSubtype = event.subtype;
|
|
1967
|
-
resultError = event.errorMessage;
|
|
1968
|
-
}
|
|
1969
|
-
watchdog?.touch();
|
|
1970
|
-
await cardSession.push(event);
|
|
1971
|
-
}
|
|
1972
|
-
// §5.3: 卡片终态必须等于引擎真实终态。旧实现只看 sawResult 布尔——error
|
|
1973
|
-
// subtype 的 result 也被 finish 'done'(放大器 bug,2026-08-31 事故)。
|
|
1974
|
-
const terminal = resultSubtype === 'success'
|
|
1975
|
-
? 'done'
|
|
1976
|
-
: resultSubtype === 'interrupted'
|
|
1977
|
-
? 'interrupted'
|
|
1978
|
-
: 'error';
|
|
1979
|
-
// 压缩结束后从会话 jsonl 读权威统计(压缩后上下文、压缩次数、本次压缩
|
|
1980
|
-
// token 消耗与会话累计)。thread/compacted 通知与 jsonl 落盘几乎同时
|
|
1981
|
-
// (实测差 ~9ms),compactCount 未读到则短重试,仍失败优雅降级(无统计)。
|
|
1982
|
-
// 不支持 compactCount 的 reader(opencode/dsh)不重试——否则每次 compact
|
|
1983
|
-
// 都会固定多跑两次完整 export/读取,且永远等不到该字段。
|
|
1984
|
-
const compactCountSupported = this.sessionReaderRegistry.tryGet(agentKind)?.supportsCompactCount !== false;
|
|
1985
|
-
let finalUsage = resolveFinalUsage(this.sessionReaderRegistry, this.config.defaultAgent, sessionId, cwd, agentKind);
|
|
1986
|
-
for (let attempt = 0; compactCountSupported && !finalUsage?.compactCount && attempt < 2; attempt++) {
|
|
1987
|
-
await sleep(150);
|
|
1988
|
-
finalUsage = resolveFinalUsage(this.sessionReaderRegistry, this.config.defaultAgent, sessionId, cwd, agentKind);
|
|
1989
|
-
}
|
|
1990
|
-
await cardSession.finish(terminal, {
|
|
1991
|
-
resultSubtype: resultSubtype ?? 'error',
|
|
1992
|
-
errorMsg: terminal === 'error' ? (resultError ?? '未收到压缩结果') : undefined,
|
|
1993
|
-
// finalUsage 的字段形态就是 FinishMeta 的用量部分(同为 UsageSnapshot),
|
|
1994
|
-
// 逐字段抄的那 14 行随单源一起去掉。
|
|
1995
|
-
...(finalUsage ?? {}),
|
|
1996
|
-
});
|
|
1997
|
-
log.info(`[lark-remote] streamCompact finished sessionId=${sessionId} sawResult=${sawResult} subtype=${resultSubtype ?? 'none'}`);
|
|
1998
|
-
}
|
|
1999
|
-
catch (err) {
|
|
2000
|
-
log.error(`[lark-remote] streamCompact failed: ${errorMessage(err)}`);
|
|
2001
|
-
// 终态守卫(与 runAgentStreamToEnd 同口径):running/finalizing 才转 error,
|
|
2002
|
-
// 已终态(interrupted/idle_timeout)保留首终态,不被迟到的异常改写。
|
|
2003
|
-
const catchTerminal = cardSession.currentState.terminal;
|
|
2004
|
-
if (!isRunTerminal(catchTerminal)) {
|
|
2005
|
-
await cardSession.finish('error', { errorMsg: errorMessage(err) });
|
|
2006
|
-
}
|
|
2007
|
-
else {
|
|
2008
|
-
log.info(`[lark-remote] skip compact error finish: state already terminal (${catchTerminal}) runId=${compactRunId}`);
|
|
2009
|
-
}
|
|
2010
|
-
}
|
|
2011
|
-
finally {
|
|
2012
|
-
watchdog?.stop();
|
|
2013
|
-
// 与 run 路径同口径的降级:卡片流没建起来(start 失败 / stream 未收尾)时,
|
|
2014
|
-
// 终态只存在于内存里。settle() 释放 producer 并等 stream 收尾;'unsent'
|
|
2015
|
-
// 表示用户手里没有卡,必须补一张静态终态卡,否则只剩「Compact 已触发」
|
|
2016
|
-
// 和一条 reaction,用户既不知道失败也不知道原因。
|
|
2017
|
-
const settled = await cardSession.settle();
|
|
2018
|
-
if (settled === 'unsent') {
|
|
2019
|
-
const finalRender = renderRunCardDetailed(cardSession.currentState, renderOptions);
|
|
2020
|
-
await this.sendResult({ card: finalRender.card }, ctx);
|
|
2021
|
-
}
|
|
2022
|
-
// 只摘自己的槽位:/stop 可能已删掉本条并让排队的下一条占住同一 cwd。
|
|
2023
|
-
if (this.activeRuns.get(cwd) === activeRun) {
|
|
2024
|
-
this.activeRuns.delete(cwd);
|
|
2025
|
-
getLogger().info(`[lark-remote] activeRuns.delete cwd=${cwd} runId=${compactRunId}`);
|
|
2026
|
-
}
|
|
2027
|
-
// 终态 reaction:与普通 run 同一口径(done→Done / error→ERROR /
|
|
2028
|
-
// interrupted→SHHH / idle_timeout→Alarm)。
|
|
2029
|
-
this.finishTurnReactions(ctx, terminalReactionEmoji(cardSession.currentState.terminal));
|
|
2030
|
-
}
|
|
2031
|
-
}
|
|
2032
|
-
/**
|
|
2033
|
-
* Handle resume.compact card action — compact a session directly from a
|
|
2034
|
-
* resume card (auto-resume / `/resume <id>`), without a runId.
|
|
2035
|
-
*
|
|
2036
|
-
* Unlike handleCompactAction (which validates against the last finished run's
|
|
2037
|
-
* runId), the resume card carries the sessionId + agent it was rendered for.
|
|
2038
|
-
* Validation: the session must exist in the current cwd (same rule as
|
|
2039
|
-
* cmdResume / resume.use) and the runner must implement runCompact.
|
|
2040
|
-
*/
|
|
1774
|
+
/** resume 卡 Compact 按钮:直接压缩卡上指定的 session(无需 runId)。 */
|
|
2041
1775
|
async handleResumeCompact(value, ctx) {
|
|
2042
|
-
|
|
2043
|
-
log.info(`[lark-remote] handleResumeCompact userId=${ctx.userId} sessionId=${value.sessionId?.slice(0, 16)} agent=${value.agent ?? 'default'}`);
|
|
2044
|
-
const { sessionId } = value;
|
|
2045
|
-
if (!sessionId) {
|
|
2046
|
-
await this.sendResult({ text: '⚠️ 无效的 Compact 请求,缺少 sessionId' }, ctx);
|
|
2047
|
-
return;
|
|
2048
|
-
}
|
|
2049
|
-
const cwd = this.resolveCwd(ctx.userId);
|
|
2050
|
-
if (!cwd) {
|
|
2051
|
-
await this.sendResult({ text: '⚠️ 未设置工作目录' }, ctx);
|
|
2052
|
-
return;
|
|
2053
|
-
}
|
|
2054
|
-
// Resolve agent kind the same way resume.use does: card value wins,
|
|
2055
|
-
// otherwise fall back to defaultAgent. 名单来自单源 AGENT_KINDS —— 手抄一份
|
|
2056
|
-
// 必然再次漂移(这份曾经漏 dsh:带 dsh 的卡片会静默退回 defaultAgent,
|
|
2057
|
-
// 用错 reader 报「未找到 session」)。
|
|
2058
|
-
const agentKind = value.agent && AGENT_KINDS.includes(value.agent)
|
|
2059
|
-
? value.agent
|
|
2060
|
-
: this.config.defaultAgent;
|
|
2061
|
-
// Session must exist in the current cwd (same rule as cmdResume /
|
|
2062
|
-
// resume.use): reject ghosts so a stale card cannot compact a session
|
|
2063
|
-
// from another cwd. Read failures get visible feedback (card click
|
|
2064
|
-
// 红线:不许静默失败)。
|
|
2065
|
-
let content;
|
|
2066
|
-
try {
|
|
2067
|
-
content = this.sessionReaderRegistry.get(agentKind).readSessionContent(sessionId, cwd);
|
|
2068
|
-
}
|
|
2069
|
-
catch (err) {
|
|
2070
|
-
await this.sendResult({ text: `⚠️ 读取 session ${sessionId} 失败: ${errorMessage(err)}` }, ctx);
|
|
2071
|
-
return;
|
|
2072
|
-
}
|
|
2073
|
-
if (content.events.length === 0 &&
|
|
2074
|
-
!content.usage &&
|
|
2075
|
-
!content.aiTitle &&
|
|
2076
|
-
!content.recap &&
|
|
2077
|
-
!content.displayTitle) {
|
|
2078
|
-
await this.sendResult({ text: `⚠️ 未找到 session ${sessionId}(当前目录: ${cwd})` }, ctx);
|
|
2079
|
-
return;
|
|
2080
|
-
}
|
|
2081
|
-
// §5.3: kimi 压缩在途检测(与 handleCompactAction 同一入口)。
|
|
2082
|
-
if (await this.rejectIfCompactionInFlight(sessionId, cwd, agentKind, ctx))
|
|
2083
|
-
return;
|
|
2084
|
-
// Check that the runner has runCompact(声明表 && runner 实现;
|
|
2085
|
-
// codex/kimi/opencode/pi/claude/dsh 均已实现)。
|
|
2086
|
-
const runner = this.getRunner(cwd, agentKind);
|
|
2087
|
-
if (!runnerSupportsCompact(runner, agentKind)) {
|
|
2088
|
-
await this.sendResult({ text: '⚠️ 当前运行模式不支持 Compact' }, ctx);
|
|
2089
|
-
return;
|
|
2090
|
-
}
|
|
2091
|
-
log.info(`[lark-remote] handleResumeCompact executing runCompact sessionId=${sessionId} cwd=${cwd}`);
|
|
2092
|
-
await this.streamCompact({ sessionId, cwd, agentKind, runner, ctx });
|
|
1776
|
+
await this.compact.handleResumeCompact(value, ctx);
|
|
2093
1777
|
}
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
1778
|
+
// =============================================================================
|
|
1779
|
+
// `!` bash 命令
|
|
1780
|
+
// =============================================================================
|
|
1781
|
+
// 实现住在 bash-run.ts(bridge 结构治理第 3 刀):executeBash 薄壳、事件流
|
|
1782
|
+
// 消费、独立登记表(activeBashRuns,不进 activeRuns)与 /stop 的 bash 侧
|
|
1783
|
+
// 匹配全部搬过去。这里只留薄转发——router 的 `!` 分发(executeBash)与
|
|
1784
|
+
// /active 投影(getActiveBashRuns)按 bridge 公开 API 调用。
|
|
1785
|
+
/** Execute a bash command and stream output to a card(`!` 命令入口)。 */
|
|
2100
1786
|
async executeBash(command, ctx) {
|
|
2101
|
-
|
|
2102
|
-
getLogger().info(`[lark-remote] executeBash start userId=${ctx.userId} sessionCwd=${this.sessionStore.get(ctx.userId)?.cwd} command="${command.slice(0, 30)}..."`);
|
|
2103
|
-
if (!cwd) {
|
|
2104
|
-
getLogger().info(`[lark-remote] executeBash no cwd, sending error`);
|
|
2105
|
-
await this.sendResult({ text: '请先使用 /cd <path> 设置工作目录,或保存一个 workspace (/ws save <name>)' }, ctx);
|
|
2106
|
-
return;
|
|
2107
|
-
}
|
|
2108
|
-
getLogger().info(`[lark-remote] executeBash proceeding cwd=${cwd}`);
|
|
2109
|
-
// `!` commands bypass the serial queue entirely (design.md §9.6):
|
|
2110
|
-
// index.ts dispatches `!` to router.handle → executeBash directly, NOT via
|
|
2111
|
-
// bridge.enqueue. bash runs in parallel with same-workspace claude runs.
|
|
2112
|
-
// bash runs are tracked in `activeBashRuns` (keyed by runId), separate from
|
|
2113
|
-
// claude's `activeRuns` (keyed by cwd) — they must not collide or `/stop`
|
|
2114
|
-
// would kill the wrong task. Do NOT re-chain onto the serial queue
|
|
2115
|
-
// (QueueManager) here: that would block on the still-pending outer promise
|
|
2116
|
-
// and deadlock self-wait.
|
|
2117
|
-
await this.executeBashInternal(command, ctx, cwd);
|
|
2118
|
-
}
|
|
2119
|
-
/**
|
|
2120
|
-
* Internal execution of bash command (called directly from executeBash,
|
|
2121
|
-
* bypassing the serial queue — see executeBash).
|
|
2122
|
-
*/
|
|
2123
|
-
async executeBashInternal(command, ctx, cwd) {
|
|
2124
|
-
getLogger().debug(`[lark-remote] executeBashInternal userId=${ctx.userId} chatId=${ctx.chatId} cwd=${cwd} command="${command.slice(0, 50)}..."`);
|
|
2125
|
-
// Note: We don't check activeRuns here because bash bypasses the serial
|
|
2126
|
-
// queue and runs in parallel with same-workspace agent runs (§9.6). The
|
|
2127
|
-
// activeRuns check in forwardToClaude is for a different purpose (detecting
|
|
2128
|
-
// concurrent agent runs that would corrupt session state). Bash commands
|
|
2129
|
-
// don't have that issue.
|
|
2130
|
-
const runId = randomUUID();
|
|
2131
|
-
const bashRunner = new BashProcessRunner();
|
|
2132
|
-
// Single streaming card across the whole run (initial → output patches →
|
|
2133
|
-
// terminal), instead of sending many independent cards. BashCardSession is
|
|
2134
|
-
// the bash analogue of RunCardSession.
|
|
2135
|
-
const cardSession = new BashCardSession({
|
|
2136
|
-
connector: this.connector,
|
|
2137
|
-
chatId: ctx.chatId,
|
|
2138
|
-
replyTo: ctx.messageId,
|
|
2139
|
-
runId,
|
|
2140
|
-
command,
|
|
2141
|
-
renderOptions: {},
|
|
2142
|
-
});
|
|
2143
|
-
// Track this bash run independently from claude's `activeRuns`. Bash runs
|
|
2144
|
-
// bypass the serial queue and may run in parallel with a claude run in the
|
|
2145
|
-
// same cwd, so they must NOT share activeRuns (which is keyed by cwd
|
|
2146
|
-
// and would let bash/claude overwrite each other). Keyed by runId so
|
|
2147
|
-
// multiple `!` commands can run concurrently. Tracked so /stop can reach
|
|
2148
|
-
// the bashRunner (which is otherwise a local) via interruptCurrentRun.
|
|
2149
|
-
this.activeBashRuns.set(runId, {
|
|
2150
|
-
bashRunner,
|
|
2151
|
-
cardSession,
|
|
2152
|
-
userId: ctx.userId,
|
|
2153
|
-
chatId: ctx.chatId,
|
|
2154
|
-
cwd,
|
|
2155
|
-
command,
|
|
2156
|
-
});
|
|
2157
|
-
let output = '';
|
|
2158
|
-
let stderr = '';
|
|
2159
|
-
let exitCode = null;
|
|
2160
|
-
try {
|
|
2161
|
-
getLogger().info(`[lark-remote] executeBashInternal about to start stream chatId=${ctx.chatId} messageId=${ctx.messageId}`);
|
|
2162
|
-
try {
|
|
2163
|
-
await cardSession.start();
|
|
2164
|
-
getLogger().info(`[lark-remote] executeBash stream started runId=${runId}`);
|
|
2165
|
-
}
|
|
2166
|
-
catch (err) {
|
|
2167
|
-
getLogger().warn('[lark-remote] bash card stream unavailable:', err);
|
|
2168
|
-
}
|
|
2169
|
-
for await (const event of bashRunner.run(command, { cwd })) {
|
|
2170
|
-
if (event.type === 'stdout') {
|
|
2171
|
-
// 层④:bridge 本地 output 也必须 store-time 截断——仅 session state
|
|
2172
|
-
// 有界并不会让 `output +=` 自然有界(「store 有界则本地拼接自然有界」
|
|
2173
|
-
// 不成立),!yes 洪峰下本地字符串仍全量驻留。capBashOutput 增量调用
|
|
2174
|
-
// O(CAP+chunk),不会带回 O(n²) 截断成本(output 恒 ≤ CAP,拼接后
|
|
2175
|
-
// ≤ CAP+chunk 再 slice 尾部)。
|
|
2176
|
-
output = capBashOutput(output + event.content);
|
|
2177
|
-
await cardSession.update({ output, stderr });
|
|
2178
|
-
}
|
|
2179
|
-
else if (event.type === 'stderr') {
|
|
2180
|
-
stderr = capBashOutput(stderr + event.content);
|
|
2181
|
-
await cardSession.update({ output, stderr });
|
|
2182
|
-
}
|
|
2183
|
-
else if (event.type === 'exit') {
|
|
2184
|
-
exitCode = event.exitCode ?? 0;
|
|
2185
|
-
}
|
|
2186
|
-
}
|
|
2187
|
-
await cardSession.finish(exitCode === 0 ? 'done' : 'error', { exitCode, output, stderr });
|
|
2188
|
-
}
|
|
2189
|
-
catch (err) {
|
|
2190
|
-
getLogger().error(`[lark-remote] bash run error runId=${runId}:`, err);
|
|
2191
|
-
// If for-await threw mid-stream the bash child may still be running.
|
|
2192
|
-
// Stop it before finalizing the card — otherwise the finally block
|
|
2193
|
-
// removes the activeBashRuns entry and /stop can no longer reach it,
|
|
2194
|
-
// leaving an orphan process.
|
|
2195
|
-
try {
|
|
2196
|
-
await bashRunner.stop();
|
|
2197
|
-
}
|
|
2198
|
-
catch (stopErr) {
|
|
2199
|
-
getLogger().warn(`[lark-remote] bash stop failed:`, stopErr);
|
|
2200
|
-
}
|
|
2201
|
-
await cardSession.finish('error', {
|
|
2202
|
-
exitCode: 1,
|
|
2203
|
-
output,
|
|
2204
|
-
stderr: stderr + `\n执行错误: ${errorMessage(err)}`,
|
|
2205
|
-
});
|
|
2206
|
-
}
|
|
2207
|
-
finally {
|
|
2208
|
-
const settleResult = await cardSession.settle();
|
|
2209
|
-
if (settleResult === 'unsent') {
|
|
2210
|
-
// Stream never established a card — send a static terminal card as fallback.
|
|
2211
|
-
// Use 2.0 schema to match the streaming card (avoid 2.0→1.x downgrade).
|
|
2212
|
-
try {
|
|
2213
|
-
await this.connector.sendWithRetry(ctx.chatId, { card: renderBashCard(cardSession.currentState, {}) }, { replyTo: ctx.messageId });
|
|
2214
|
-
}
|
|
2215
|
-
catch (fallbackErr) {
|
|
2216
|
-
getLogger().error(`[lark-remote] bash terminal fallback card failed runId=${runId} chatId=${ctx.chatId}:`, fallbackErr);
|
|
2217
|
-
}
|
|
2218
|
-
}
|
|
2219
|
-
if (this.activeBashRuns.has(runId)) {
|
|
2220
|
-
this.activeBashRuns.delete(runId);
|
|
2221
|
-
getLogger().info(`[lark-remote] executeBash done runId=${runId} exitCode=${exitCode}`);
|
|
2222
|
-
}
|
|
2223
|
-
// Add a Done emoji reaction to the user's original message to signal completion
|
|
2224
|
-
this.finishTurnReactions(ctx, 'Done');
|
|
2225
|
-
}
|
|
1787
|
+
await this.bashRuns.executeBash(command, ctx);
|
|
2226
1788
|
}
|
|
2227
1789
|
}
|
|
2228
1790
|
//# sourceMappingURL=index.js.map
|