lark-remote 0.5.19 → 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 +19 -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 +97 -153
- package/dist/bridge/index.js +182 -809
- 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/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/final-answer-file.js +1 -1
- package/dist/card/run-renderer.d.ts +3 -3
- package/dist/card/run-renderer.js +34 -93
- package/dist/card/run-state.d.ts +1 -1
- package/dist/card/run-state.js +28 -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 +1 -1
- 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 +219 -200
- 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 +1 -3
- package/dist/runner/dsh/client.js +2 -4
- 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 +40 -1
- package/dist/runner/dsh/runner.js +272 -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 +2 -2
- 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 { isLiveBackgroundJob, isRunActive, isRunTerminal, markBackgroundJobsUnknown, removeBackgroundJob, syncBackgroundJobs, } 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,26 +138,17 @@ export class Bridge {
|
|
|
195
138
|
runCardSnapshots = new Map();
|
|
196
139
|
static RUN_CARD_SNAPSHOT_MAX = 8;
|
|
197
140
|
/**
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
* 受 RUN_CARD_SNAPSHOT_MAX 约束(一张卡最多一条流)。
|
|
141
|
+
* 终态卡后台任务订阅与停止(见 job-watch.ts):job.kill、名单订阅与
|
|
142
|
+
* PATCH 串行链收在该模块;jobCardPatches 链内渲染读 snapshot.state,
|
|
143
|
+
* 「停止」与「订阅帧」并发不会把卡片写回旧内容。
|
|
202
144
|
*/
|
|
203
|
-
|
|
145
|
+
jobWatch;
|
|
204
146
|
/**
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
* 后发先至的那条会把卡片写回旧内容(任务又变回「运行中」)。
|
|
147
|
+
* `!` bash 命令执行(见 bash-run.ts):独立 runId 空间、独立登记表,不进
|
|
148
|
+
* activeRuns;绝不 re-chain 到 QueueManager(会自等待死锁)。/stop 的 bash
|
|
149
|
+
* 侧匹配与中断也收在该模块,避免误杀并行跑的 `!` 任务。
|
|
209
150
|
*/
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* Active `!` bash runs, keyed by runId (NOT cwd). Bash runs bypass the serial
|
|
213
|
-
* queue and may run in parallel with claude runs / other bash runs in the same
|
|
214
|
-
* cwd, so they live outside `activeRuns`. Tracked so `/stop` can reach
|
|
215
|
-
* the per-run BashRunner instance via interruptCurrentRun.
|
|
216
|
-
*/
|
|
217
|
-
activeBashRuns = new Map();
|
|
151
|
+
bashRuns;
|
|
218
152
|
constructor(deps) {
|
|
219
153
|
this.connector = deps.connector;
|
|
220
154
|
this.sessionStore = deps.sessionStore;
|
|
@@ -228,11 +162,36 @@ export class Bridge {
|
|
|
228
162
|
resolveCwd: (userId) => this.resolveCwd(userId),
|
|
229
163
|
getConfig: () => this.config,
|
|
230
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
|
+
});
|
|
231
190
|
}
|
|
232
191
|
/**
|
|
233
192
|
* Get or create a runner for the given cwd, keyed by current defaultAgent.
|
|
234
193
|
*
|
|
235
|
-
*
|
|
194
|
+
* Cache key includes agentKind, so switching defaultAgent
|
|
236
195
|
* creates a NEW runner under a different key without evicting the old one.
|
|
237
196
|
* This eliminates the need for clearRunners() on agent switch.
|
|
238
197
|
*/
|
|
@@ -379,9 +338,9 @@ export class Bridge {
|
|
|
379
338
|
*/
|
|
380
339
|
clearRunners() {
|
|
381
340
|
// Skip workspaces with an active run: evicting their runner would orphan
|
|
382
|
-
// the instance that owns the live subprocess
|
|
383
|
-
//
|
|
384
|
-
//
|
|
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.
|
|
385
344
|
// The running run already started with its (possibly now-stale) config, so
|
|
386
345
|
// keeping it is correct; finalizeRun deletes the cache entry when the run
|
|
387
346
|
// ends, and the next run picks up the new config/agent via getRunner.
|
|
@@ -645,25 +604,7 @@ export class Bridge {
|
|
|
645
604
|
}
|
|
646
605
|
/** Get all active bash runs with their state for /active command. */
|
|
647
606
|
getActiveBashRuns() {
|
|
648
|
-
|
|
649
|
-
for (const [runId, bashRun] of this.activeBashRuns) {
|
|
650
|
-
const state = bashRun.cardSession.currentState;
|
|
651
|
-
// Only include non-terminal runs (bash has no finalizing state)
|
|
652
|
-
if (state.terminal === 'running') {
|
|
653
|
-
result.push({
|
|
654
|
-
runId,
|
|
655
|
-
cwd: bashRun.cwd,
|
|
656
|
-
userId: bashRun.userId,
|
|
657
|
-
chatId: bashRun.chatId,
|
|
658
|
-
terminal: state.terminal,
|
|
659
|
-
command: bashRun.command,
|
|
660
|
-
});
|
|
661
|
-
}
|
|
662
|
-
}
|
|
663
|
-
return result;
|
|
664
|
-
}
|
|
665
|
-
async interruptCurrentRun(input) {
|
|
666
|
-
return (await this.interruptCurrentRunDetailed(input)).stopped;
|
|
607
|
+
return this.bashRuns.getActiveBashRuns();
|
|
667
608
|
}
|
|
668
609
|
/**
|
|
669
610
|
* Same as {@link interruptCurrentRun}, plus what the runner's stop actually
|
|
@@ -719,45 +660,11 @@ export class Bridge {
|
|
|
719
660
|
stop = stopResult;
|
|
720
661
|
}
|
|
721
662
|
}
|
|
722
|
-
// Then check active bash runs (! commands).
|
|
723
|
-
//
|
|
724
|
-
//
|
|
725
|
-
// `!`
|
|
726
|
-
|
|
727
|
-
// 没有 claude run 命中时才回退到 bash,避免误杀用户并行跑的 `!` 任务。
|
|
728
|
-
const cardScoped = input.runId !== undefined || input.cardMessageId !== undefined;
|
|
729
|
-
const includeBashRuns = (input.runId === undefined && input.cardMessageId === undefined && input.cwd === undefined) ||
|
|
730
|
-
activeTargets.length === 0;
|
|
731
|
-
const bashTargets = includeBashRuns
|
|
732
|
-
? [...this.activeBashRuns.entries()].filter(([runId, b]) => {
|
|
733
|
-
if (input.cwd !== undefined && b.cwd !== input.cwd)
|
|
734
|
-
return false;
|
|
735
|
-
if (b.userId !== input.userId || b.chatId !== input.chatId)
|
|
736
|
-
return false;
|
|
737
|
-
if (input.runId) {
|
|
738
|
-
if (runId === input.runId)
|
|
739
|
-
return true;
|
|
740
|
-
// Scoped card clicks may fall back only through their own card id.
|
|
741
|
-
if (cardScoped)
|
|
742
|
-
return false;
|
|
743
|
-
}
|
|
744
|
-
if (input.cardMessageId) {
|
|
745
|
-
return b.cardSession.cardMessageId === input.cardMessageId;
|
|
746
|
-
}
|
|
747
|
-
return true;
|
|
748
|
-
})
|
|
749
|
-
: [];
|
|
750
|
-
for (const [runId, b] of bashTargets) {
|
|
751
|
-
getLogger().info(`[lark-remote] interrupt hit bash runId=${runId} cwd=${b.cwd} userId=${input.userId}`);
|
|
752
|
-
await b.bashRunner.stop({ immediate: true });
|
|
753
|
-
// Update card to show interrupted state. exitCode 不传(保持 null):
|
|
754
|
-
// bash-renderer 的「已手动终止」文案要求 exitCode === null;传 -1 会让
|
|
755
|
-
// 该分支不可达,中断卡退化成「退出码: -1」的失败样式。
|
|
756
|
-
await b.cardSession.finish('interrupted');
|
|
757
|
-
this.activeBashRuns.delete(runId);
|
|
758
|
-
getLogger().info(`[lark-remote] interrupt bash stop done runId=${runId}`);
|
|
759
|
-
stoppedCount++;
|
|
760
|
-
}
|
|
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);
|
|
761
668
|
if (stoppedCount === 0) {
|
|
762
669
|
getLogger().warn(`[lark-remote] interrupt miss userId=${input.userId} ` +
|
|
763
670
|
`requestedRunId=${input.runId ?? '(none)'} ` +
|
|
@@ -804,8 +711,8 @@ export class Bridge {
|
|
|
804
711
|
// Stop the EXACT runner instance that is running (carried by activeRun),
|
|
805
712
|
// not whatever getRunner(cwd) would return now. clearRunners() may have
|
|
806
713
|
// emptied the cache mid-run, in which case getRunner creates a fresh empty
|
|
807
|
-
// runner whose stop() is a no-op and the real subprocess is never killed
|
|
808
|
-
//
|
|
714
|
+
// runner whose stop() is a no-op and the real subprocess is never killed,
|
|
715
|
+
// blocking the queue chain forever.
|
|
809
716
|
const runner = active.runner;
|
|
810
717
|
// Bind the queue reset to THIS task's execution slot: the stop window
|
|
811
718
|
// below may outlive the task's settle, letting the serial chain advance
|
|
@@ -813,18 +720,28 @@ export class Bridge {
|
|
|
813
720
|
// unconditional reset would then zero the successor's count and mark
|
|
814
721
|
// its slot interrupted, hiding it from queue cards.
|
|
815
722
|
const stoppedSlot = this.queueManager.getExecutingSlot(cwd);
|
|
816
|
-
const [, stopResult] = await Promise.allSettled([
|
|
723
|
+
const [finishResult, stopResult] = await Promise.allSettled([
|
|
817
724
|
// 用户主动中断(/stop、stop 卡按钮、queue.immediate)显式打标
|
|
818
725
|
// interruptedReason,与审批超时自动取消(approval_timeout)、审批
|
|
819
726
|
// 卡取消(approval_cancelled)在终态数据上可区分(方案 A)。
|
|
820
727
|
active.session.finish('interrupted', { interruptedReason: 'user_stop' }),
|
|
821
728
|
runner.stop({ immediate: true }),
|
|
822
729
|
]);
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
:
|
|
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
|
+
}
|
|
828
745
|
// The chain may have advanced while the stop was in flight: a new run
|
|
829
746
|
// (from a task that began during the stop window) may now occupy this
|
|
830
747
|
// cwd. Only delete the entry we actually stopped.
|
|
@@ -840,8 +757,8 @@ export class Bridge {
|
|
|
840
757
|
// interruptCurrentRun 的 await 窗口内 queue 链会推进下一条消息,其
|
|
841
758
|
// getRunner 可能刚拿走本实例(缓存命中先于本行执行)——立即 evict 会
|
|
842
759
|
// 让那个 run 的长驻连接脱管:进程残留至 idle TTL 才回收,期间持有
|
|
843
|
-
// agent 侧会话写锁(codex "thread already has an active writer"
|
|
844
|
-
//
|
|
760
|
+
// agent 侧会话写锁(codex "thread already has an active writer")。
|
|
761
|
+
// 改为标 stale:下一次「即将开跑」的 getRunner 在槽位空闲时
|
|
845
762
|
// 换新实例(见 getRunner 的 replaceStale),本次 run 的 finalize 收尾时
|
|
846
763
|
// 也会消费该标记;两者都保证不会有活跃 run 使用被 dispose 的实例。
|
|
847
764
|
// run-per-message runner 无长驻连接,保持立即 evict 不变。
|
|
@@ -863,7 +780,9 @@ export class Bridge {
|
|
|
863
780
|
if (stoppedSlot !== undefined) {
|
|
864
781
|
this.queueManager.resetExecutingCount(cwd, stoppedSlot);
|
|
865
782
|
}
|
|
866
|
-
|
|
783
|
+
if (stop?.status !== 'unconfirmed') {
|
|
784
|
+
getLogger().info(`[lark-remote] interrupt stop done runId=${active.runId}`);
|
|
785
|
+
}
|
|
867
786
|
return stop;
|
|
868
787
|
}
|
|
869
788
|
/**
|
|
@@ -914,25 +833,22 @@ export class Bridge {
|
|
|
914
833
|
/**
|
|
915
834
|
* Deliver a final answer that cannot fit in the run card as a Markdown file
|
|
916
835
|
* attachment. 卡片正文已被标记替代,文件是唯一的内容通道;此处绝不发纯文本
|
|
917
|
-
*
|
|
836
|
+
* (纯文本与卡片重复,也丢 Markdown 结构)。
|
|
918
837
|
*
|
|
919
|
-
*
|
|
920
|
-
*
|
|
838
|
+
* 发送成功即清理临时文件;失败时发一张提示卡(不是纯文本),完整回答
|
|
839
|
+
* 留在临时文件里(路径见错误日志),便于事后找回。
|
|
921
840
|
*/
|
|
922
841
|
async sendFinalAnswerFile(text, ctx) {
|
|
923
842
|
let filePath;
|
|
924
843
|
try {
|
|
925
844
|
filePath = writeFinalAnswerFile(text);
|
|
926
845
|
await this.connector.sendFile(ctx.chatId, filePath);
|
|
846
|
+
bestEffortUnlink(filePath);
|
|
927
847
|
}
|
|
928
848
|
catch (err) {
|
|
929
849
|
getLogger().error(`[lark-remote] failed to deliver complete final answer as file (kept at ${filePath ?? 'not written'}):`, err);
|
|
930
850
|
await this.sendResult({ card: finalAnswerFileFailureCard(errorMessage(err)) }, ctx);
|
|
931
851
|
}
|
|
932
|
-
finally {
|
|
933
|
-
if (filePath)
|
|
934
|
-
bestEffortUnlink(filePath);
|
|
935
|
-
}
|
|
936
852
|
}
|
|
937
853
|
/**
|
|
938
854
|
* Update an existing card in place (原地更新) instead of sending a new one.
|
|
@@ -1005,138 +921,23 @@ export class Bridge {
|
|
|
1005
921
|
this.runCardSnapshots.set(runId, snapshot);
|
|
1006
922
|
while (this.runCardSnapshots.size > Bridge.RUN_CARD_SNAPSHOT_MAX) {
|
|
1007
923
|
const oldest = this.runCardSnapshots.keys().next().value;
|
|
1008
|
-
if (oldest === undefined)
|
|
1009
|
-
break;
|
|
1010
924
|
this.runCardSnapshots.delete(oldest);
|
|
1011
925
|
// 卡片已经不在窗口里:继续订阅只是往一个没人看的 messageId 打 PATCH。
|
|
1012
|
-
this.stopJobWatch(oldest);
|
|
926
|
+
this.jobWatch.stopJobWatch(oldest);
|
|
1013
927
|
}
|
|
1014
928
|
return snapshot;
|
|
1015
929
|
}
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
* 后台任务的生命周期跨 run(这就是它需要单独入口的原因),所以解析路径是
|
|
1026
|
-
* 终态快照(runId → sessionId/cwd/agentKind)+ 该工作区的 runner 实例,
|
|
1027
|
-
* 而不是 activeRuns。成功后同步摘掉 state 里的任务并就地重渲染,否则按钮
|
|
1028
|
-
* 会留在卡片上而任务早已结束。
|
|
1029
|
-
*/
|
|
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 卡片动作)。 */
|
|
1030
939
|
async killBackgroundJob(runId, jobId, ctx) {
|
|
1031
|
-
|
|
1032
|
-
const sessionId = snapshot?.state.sessionId;
|
|
1033
|
-
if (!snapshot || !sessionId)
|
|
1034
|
-
return false;
|
|
1035
|
-
const runner = this.getRunner(snapshot.cwd, snapshot.agentKind);
|
|
1036
|
-
if (typeof runner.killJob !== 'function')
|
|
1037
|
-
return false;
|
|
1038
|
-
const killed = await runner.killJob(sessionId, jobId);
|
|
1039
|
-
if (!killed)
|
|
1040
|
-
return false;
|
|
1041
|
-
snapshot.state = removeBackgroundJob(snapshot.state, jobId);
|
|
1042
|
-
// 与订阅帧共用一条串行链:两条 PATCH 并发时,后发先至的那条会把卡片写回
|
|
1043
|
-
// 旧内容(刚停掉的任务又显示成「运行中」)。
|
|
1044
|
-
await this.enqueueJobCardPatch(runId, async () => {
|
|
1045
|
-
await this.updateCardInPlace(renderRunCard(snapshot.state, snapshot.options), ctx);
|
|
1046
|
-
return true;
|
|
1047
|
-
});
|
|
1048
|
-
// 卡片上已经没有在跑的任务:不必再等宿主推下一帧,直接退订。
|
|
1049
|
-
if (!(snapshot.state.backgroundJobs ?? []).some(isLiveBackgroundJob)) {
|
|
1050
|
-
this.stopJobWatch(runId);
|
|
1051
|
-
}
|
|
1052
|
-
return true;
|
|
1053
|
-
}
|
|
1054
|
-
/**
|
|
1055
|
-
* 让终态卡的后台任务小节跟着宿主继续更新。
|
|
1056
|
-
*
|
|
1057
|
-
* run 的卡片在 `result` 那一帧就关闭了,但后台任务还能再跑很久:不订阅,
|
|
1058
|
-
* 卡片会一直写「运行中」并留着停止按钮,点了必然失败。退订条件写
|
|
1059
|
-
* 在 {@link consumeJobRoster} 里——名单清空、patch 失败、订阅中断、快照被
|
|
1060
|
-
* 淘汰,任一命中即退出;**绝不因为 patch 失败补发新卡**。
|
|
1061
|
-
*/
|
|
1062
|
-
startBackgroundJobWatch(runId, snapshot, runner) {
|
|
1063
|
-
const live = (snapshot.state.backgroundJobs ?? []).some(isLiveBackgroundJob);
|
|
1064
|
-
if (!live)
|
|
1065
|
-
return;
|
|
1066
|
-
const sessionId = snapshot.state.sessionId;
|
|
1067
|
-
const messageId = snapshot.messageId;
|
|
1068
|
-
const watchJobs = runner.watchJobs;
|
|
1069
|
-
if (!sessionId || !messageId || typeof watchJobs !== 'function') {
|
|
1070
|
-
// 没有卡 id 就没有 patch 的落点:保留收尾快照,不另发一张卡。
|
|
1071
|
-
getLogger().info(`[lark-remote] background jobs stay on the turn snapshot runId=${runId} ` +
|
|
1072
|
-
`(sessionId=${sessionId ?? '-'} messageId=${messageId ?? '-'} watchable=${typeof watchJobs === 'function'})`);
|
|
1073
|
-
return;
|
|
1074
|
-
}
|
|
1075
|
-
const controller = new AbortController();
|
|
1076
|
-
this.jobWatches.set(runId, controller);
|
|
1077
|
-
void this.consumeJobRoster(runId, snapshot, controller, (signal) => watchJobs.call(runner, sessionId, signal));
|
|
1078
|
-
}
|
|
1079
|
-
/** 消费宿主推来的整份任务名单,直到全结束 / 流断 / patch 失败 / 外部退订。 */
|
|
1080
|
-
async consumeJobRoster(runId, snapshot, controller, watch) {
|
|
1081
|
-
const messageId = snapshot.messageId;
|
|
1082
|
-
if (!messageId)
|
|
1083
|
-
return;
|
|
1084
|
-
let allEnded = false;
|
|
1085
|
-
try {
|
|
1086
|
-
for await (const roster of watch(controller.signal)) {
|
|
1087
|
-
const next = syncBackgroundJobs(snapshot.state, roster);
|
|
1088
|
-
// 名单没变化就不打 PATCH:宿主可能重复推同一份。
|
|
1089
|
-
if (next === snapshot.state)
|
|
1090
|
-
continue;
|
|
1091
|
-
snapshot.state = next;
|
|
1092
|
-
const ok = await this.enqueueJobCardPatch(runId, () => this.patchCard(messageId, renderRunCard(snapshot.state, snapshot.options)));
|
|
1093
|
-
if (!ok) {
|
|
1094
|
-
getLogger().warn(`[lark-remote] background job card update failed, stop watching runId=${runId}`);
|
|
1095
|
-
return;
|
|
1096
|
-
}
|
|
1097
|
-
if (!(snapshot.state.backgroundJobs ?? []).some(isLiveBackgroundJob)) {
|
|
1098
|
-
allEnded = true;
|
|
1099
|
-
return;
|
|
1100
|
-
}
|
|
1101
|
-
}
|
|
1102
|
-
}
|
|
1103
|
-
catch (err) {
|
|
1104
|
-
if (!controller.signal.aborted) {
|
|
1105
|
-
getLogger().warn(`[lark-remote] background job watch failed runId=${runId}: ${errorMessage(err)}`);
|
|
1106
|
-
}
|
|
1107
|
-
}
|
|
1108
|
-
finally {
|
|
1109
|
-
const externallyAborted = controller.signal.aborted;
|
|
1110
|
-
controller.abort();
|
|
1111
|
-
// 流断了而任务没结算:监控中断 ≠ 任务结束,标成未知而不是继续写「运行中」。
|
|
1112
|
-
if (!allEnded && !externallyAborted)
|
|
1113
|
-
await this.markJobsUnknown(runId, snapshot);
|
|
1114
|
-
this.jobWatches.delete(runId);
|
|
1115
|
-
this.jobCardPatches.delete(runId);
|
|
1116
|
-
}
|
|
1117
|
-
}
|
|
1118
|
-
/** 订阅断了但任务仍未结算:把在跑的任务标成「状态未知」并重渲染一次。 */
|
|
1119
|
-
async markJobsUnknown(runId, snapshot) {
|
|
1120
|
-
const next = markBackgroundJobsUnknown(snapshot.state);
|
|
1121
|
-
if (next === snapshot.state)
|
|
1122
|
-
return;
|
|
1123
|
-
snapshot.state = next;
|
|
1124
|
-
const messageId = snapshot.messageId;
|
|
1125
|
-
if (!messageId)
|
|
1126
|
-
return;
|
|
1127
|
-
await this.enqueueJobCardPatch(runId, () => this.patchCard(messageId, renderRunCard(snapshot.state, snapshot.options)));
|
|
1128
|
-
}
|
|
1129
|
-
/**
|
|
1130
|
-
* 一张终态卡的 PATCH 串行链。
|
|
1131
|
-
*
|
|
1132
|
-
* 渲染放在链内执行(`task` 里读 `snapshot.state`),后入链的任务必然看到更新
|
|
1133
|
-
* 后的状态;否则「停止」与「订阅帧」并发时卡片可能回退。
|
|
1134
|
-
*/
|
|
1135
|
-
enqueueJobCardPatch(runId, task) {
|
|
1136
|
-
const previous = this.jobCardPatches.get(runId) ?? Promise.resolve();
|
|
1137
|
-
const next = previous.then(task, task);
|
|
1138
|
-
this.jobCardPatches.set(runId, next.then(() => undefined, () => undefined));
|
|
1139
|
-
return next;
|
|
940
|
+
return this.jobWatch.killBackgroundJob(runId, jobId, ctx);
|
|
1140
941
|
}
|
|
1141
942
|
/** Send a file to the user via Feishu. */
|
|
1142
943
|
async sendFile(filePath, ctx) {
|
|
@@ -1210,7 +1011,7 @@ export class Bridge {
|
|
|
1210
1011
|
// sessionId 钉死语义——binding 有 sessionId 时用它(入队时刻快照),
|
|
1211
1012
|
// 否则跟随 live store(无 binding 或入队时无 session 的正常路径)。
|
|
1212
1013
|
const sessionId = opts?.binding?.sessionId ?? this.sessionStore.getSessionId(ctx.userId, agentKind);
|
|
1213
|
-
//
|
|
1014
|
+
// 会话代际快照:/new、/cd、/resume 会 bump
|
|
1214
1015
|
// epoch,该 run 后续 system.init 的 sessionId 写回即判 stale 跳过。
|
|
1215
1016
|
// 快照点优先取 binding.sessionEpoch(入队/commit 时刻,与 sessionId 同刻):
|
|
1216
1017
|
// 排队消息在「commit 后、开跑前」被 reset 时,执行起点捕获会漏判(epoch 已是
|
|
@@ -1220,7 +1021,7 @@ export class Bridge {
|
|
|
1220
1021
|
// Resolve the runner BEFORE creating the run session so the exact instance
|
|
1221
1022
|
// that will run is captured into activeRun. interruptCurrentRun stops THAT
|
|
1222
1023
|
// instance directly -- never a fresh runner re-resolved from a cache that
|
|
1223
|
-
// clearRunners() may have emptied mid-run
|
|
1024
|
+
// clearRunners() may have emptied mid-run.
|
|
1224
1025
|
const runner = this.getRunner(cwd, agentKind, { replaceStale: true });
|
|
1225
1026
|
// Step 1: Create run session (runId, cardSession, activeRun tracking)
|
|
1226
1027
|
const { runId, cardSession, activeRun } = this.createRunSession(ctx, cwd, runner, agentKind);
|
|
@@ -1252,7 +1053,7 @@ export class Bridge {
|
|
|
1252
1053
|
*
|
|
1253
1054
|
* DSH 把上游 `finish_reason: content_filter` 归一成 PI_AI_ERROR:既不在默认
|
|
1254
1055
|
* 重试码里(EMPTY_RESPONSE/RATE_LIMIT/SERVER/TIMEOUT/TRANSPORT),也不会换模型,
|
|
1255
|
-
* 于是一次(多数是误报的)判定就把整个 turn 判死。
|
|
1056
|
+
* 于是一次(多数是误报的)判定就把整个 turn 判死。60 步、
|
|
1256
1057
|
* 10 分钟的 turn 在最后一步被截断,已写入的文件还在,用户只看到「运行出错」
|
|
1257
1058
|
* 加一句英文报文。
|
|
1258
1059
|
*
|
|
@@ -1371,12 +1172,23 @@ export class Bridge {
|
|
|
1371
1172
|
}
|
|
1372
1173
|
stop();
|
|
1373
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)。
|
|
1374
1179
|
void Promise.allSettled([
|
|
1375
1180
|
opts.session.finish('idle_timeout', {
|
|
1376
1181
|
idleTimeoutMinutes: Math.max(1, Math.round(this.idleTimeoutMs / 60_000)),
|
|
1377
1182
|
}),
|
|
1378
1183
|
opts.runner.stop(),
|
|
1379
|
-
])
|
|
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
|
+
});
|
|
1380
1192
|
}, tickMs);
|
|
1381
1193
|
}
|
|
1382
1194
|
return {
|
|
@@ -1395,7 +1207,7 @@ export class Bridge {
|
|
|
1395
1207
|
* result 在 system.init 之后才到。
|
|
1396
1208
|
*
|
|
1397
1209
|
* 例外:app-server runner(usageAuthority==='live')的 init 是 runner 自己补发
|
|
1398
|
-
* 的 synthetic init
|
|
1210
|
+
* 的 synthetic init。此分支保留为防御性兜底:live runner 若
|
|
1399
1211
|
* 在无 init 时发出 result(旧构建等),session 写回照常执行,但 sawResult 不置
|
|
1400
1212
|
* 真——卡片不会把半初始化的 run 当作已收尾。
|
|
1401
1213
|
*
|
|
@@ -1474,7 +1286,7 @@ export class Bridge {
|
|
|
1474
1286
|
const { cardSession, usage, sawResult, sawAnyResult, pendingPreInitTerminal, agentKind, runId, } = opts;
|
|
1475
1287
|
const finalState = cardSession.currentState;
|
|
1476
1288
|
if (finalState.terminal === 'finalizing') {
|
|
1477
|
-
//
|
|
1289
|
+
// 三态终态映射:interrupted(审批超时/取消、/stop)是独立
|
|
1478
1290
|
// 终态,不再归因于 Agent 错误;error 保持「运行出错」;其余为 done。
|
|
1479
1291
|
const subtype = finalState.resultSubtype;
|
|
1480
1292
|
const isError = subtype === 'error' || !!finalState.errorMsg;
|
|
@@ -1612,29 +1424,13 @@ export class Bridge {
|
|
|
1612
1424
|
if (isRunTerminal(terminal)) {
|
|
1613
1425
|
continue;
|
|
1614
1426
|
}
|
|
1615
|
-
|
|
1427
|
+
// 归一化:init/turn_started 的 session 写回 + compact_boundary 用量观察。
|
|
1428
|
+
// 返回 true = 本轮 system.init 已到(调用方置 sawInit,供 pre-init 守卫用)。
|
|
1429
|
+
if (this.maybeSyncSession(event, { activeRun, writeback, usage, runId })) {
|
|
1616
1430
|
sawInit = true;
|
|
1617
|
-
// 本轮真实使用的 session(不可变身份):指针在 run 中被 /new 等移动时,
|
|
1618
|
-
// 结束后的 usage 读取仍以它为准。
|
|
1619
|
-
activeRun.sessionId = event.session_id;
|
|
1620
|
-
writeBackInitSession(writeback, event);
|
|
1621
|
-
getLogger().info(`[lark-remote] system.init received runId=${runId} sessionId=${event.session_id} cwd=${event.cwd}`);
|
|
1622
|
-
}
|
|
1623
|
-
if (event.type === 'turn_started') {
|
|
1624
|
-
// app-server runner 在 turn setup 成功后补发 synthetic system.init
|
|
1625
|
-
// (§9.22 守卫前提),此处 turn_started 写回保留为双保险:两者都用
|
|
1626
|
-
// threadId 写回 sessionId(代际守卫一致),幂等。缺失会导致每次消息
|
|
1627
|
-
// 都新建线程(thread/start 而非 thread/resume),会话无法延续。
|
|
1628
|
-
// init 缺失时以 threadId 作为本轮身份兜底。
|
|
1629
|
-
activeRun.sessionId ??= event.threadId;
|
|
1630
|
-
writeBackTurnStarted(writeback, event);
|
|
1631
|
-
}
|
|
1632
|
-
// Track context length from compaction events
|
|
1633
|
-
if (event.type === 'system' &&
|
|
1634
|
-
event.subtype === 'compact_boundary' &&
|
|
1635
|
-
event.compactMetadata) {
|
|
1636
|
-
usage.observeCompaction(event.compactMetadata.postTokens);
|
|
1637
1431
|
}
|
|
1432
|
+
// pre-init result 守卫(§9.22,observeResultEvent):resume 重放的陈旧
|
|
1433
|
+
// result 不置 sawResult、不进卡片。
|
|
1638
1434
|
if (event.type === 'result') {
|
|
1639
1435
|
const outcome = this.observeResultEvent(event, {
|
|
1640
1436
|
sawInit,
|
|
@@ -1652,11 +1448,12 @@ export class Bridge {
|
|
|
1652
1448
|
// 跳过,在 bridge 层就拦掉可以省下一次无谓的 push + render。
|
|
1653
1449
|
if (outcome.dropFromCard) {
|
|
1654
1450
|
// 仍然重置 idle 计时——CLI 活着并且在产事件。
|
|
1655
|
-
watchdog
|
|
1451
|
+
this.handleEventForWatchdog(watchdog);
|
|
1656
1452
|
continue;
|
|
1657
1453
|
}
|
|
1658
1454
|
}
|
|
1659
|
-
// Approval events (Codex app-server mode)
|
|
1455
|
+
// Approval events (Codex app-server mode)。审批先到、item/started 后到的
|
|
1456
|
+
// 乱序流也在此补全(dispatchApprovalEvent 内 updateView)。
|
|
1660
1457
|
if (event.type === 'approval_requested' ||
|
|
1661
1458
|
event.type === 'approval_resolved' ||
|
|
1662
1459
|
event.type === 'approval_view_updated') {
|
|
@@ -1666,7 +1463,8 @@ export class Bridge {
|
|
|
1666
1463
|
// lastEventTs 重新武装看门狗。result 后 CLI 进程做 jsonl flush/清理(finalizing
|
|
1667
1464
|
// 过渡)期间,宽限期恢复为完整的 idleTimeoutMs,避免已产出结果的 turn 被误标
|
|
1668
1465
|
// idle_timeout。旧实现 `else { resetIdle(); }` 漏掉 result 分支。
|
|
1669
|
-
watchdog
|
|
1466
|
+
this.handleEventForWatchdog(watchdog);
|
|
1467
|
+
// 渲染:reducer(run-state)在 cardSession.push 内部。
|
|
1670
1468
|
await cardSession.push(event);
|
|
1671
1469
|
}
|
|
1672
1470
|
getLogger().info(`[lark-remote] runner stream end runId=${runId} sawResult=${sawResult}`);
|
|
@@ -1737,6 +1535,48 @@ export class Bridge {
|
|
|
1737
1535
|
}
|
|
1738
1536
|
}
|
|
1739
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
|
+
}
|
|
1740
1580
|
/**
|
|
1741
1581
|
* 终态 reaction 收尾:撤掉 Typing、打上下方终态表情。
|
|
1742
1582
|
*
|
|
@@ -1806,7 +1646,7 @@ export class Bridge {
|
|
|
1806
1646
|
this.finishTurnReactions(ctx, terminalReactionEmoji(cardSession.currentState.terminal));
|
|
1807
1647
|
// 后台任务跨 turn:卡片收尾只是"这一轮的投影结束了",任务本身可能还在跑。
|
|
1808
1648
|
// 订阅放在最后:卡片已落地,patch 才有落点。
|
|
1809
|
-
this.startBackgroundJobWatch(runId, snapshot, activeRun.runner);
|
|
1649
|
+
this.jobWatch.startBackgroundJobWatch(runId, snapshot, activeRun.runner);
|
|
1810
1650
|
}
|
|
1811
1651
|
finally {
|
|
1812
1652
|
// cleanup must survive mid-finalize errors. The only unguarded
|
|
@@ -1822,7 +1662,7 @@ export class Bridge {
|
|
|
1822
1662
|
// runId,只能回「该任务已结束或不属于当前会话」。守卫只回答「cwd 槽位还
|
|
1823
1663
|
// 归不归本 run」(决定能不能删槽位/evict runner),记账回答「这次 run
|
|
1824
1664
|
// 结束了吗、用的哪个 sessionId」——后者与所有权无关,放在守卫外。
|
|
1825
|
-
this.recordCompactableRunForTerminal(cardSession, activeRun, ctx);
|
|
1665
|
+
this.compact.recordCompactableRunForTerminal(cardSession, activeRun, ctx);
|
|
1826
1666
|
if (this.activeRuns.get(cwd) === activeRun) {
|
|
1827
1667
|
this.activeRuns.delete(cwd);
|
|
1828
1668
|
// Workspace-lifetime runners (codex app-server) keep their persistent
|
|
@@ -1920,498 +1760,31 @@ export class Bridge {
|
|
|
1920
1760
|
async handleApprovalAnswerNote(opts) {
|
|
1921
1761
|
return this.approvalActions.handleApprovalAnswerNote(opts);
|
|
1922
1762
|
}
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
if (!byRunId) {
|
|
1932
|
-
byRunId = new Map();
|
|
1933
|
-
this.compactableRuns.set(cwd, byRunId);
|
|
1934
|
-
}
|
|
1935
|
-
byRunId.delete(runId);
|
|
1936
|
-
byRunId.set(runId, { sessionId, agentKind, cwd });
|
|
1937
|
-
while (byRunId.size > MAX_COMPACTABLE_RUNS_PER_CWD) {
|
|
1938
|
-
const oldest = byRunId.keys().next().value;
|
|
1939
|
-
if (oldest === undefined)
|
|
1940
|
-
break;
|
|
1941
|
-
byRunId.delete(oldest);
|
|
1942
|
-
}
|
|
1943
|
-
}
|
|
1944
|
-
/**
|
|
1945
|
-
* 终态 Compact 记账:把本次 run 实际使用的 sessionId 记进
|
|
1946
|
-
* {@link compactableRuns},供终态卡上的 Compact 按钮按 runId 反查。
|
|
1947
|
-
*
|
|
1948
|
-
* 调用点固定在 `finalizeRun` 的 `finally` 里、**槽位所有权守卫之外**。
|
|
1949
|
-
* 所有权守卫只回答「cwd 槽位还归不归本 run」(决定能否删槽位 / evict
|
|
1950
|
-
* runner);记账回答「本次 run 结束了吗、用的哪个 sessionId」。两者在不同
|
|
1951
|
-
* 维度上:外部中断(`/stop`、停止卡按钮、`queue.immediate`)会先摘槽位,
|
|
1952
|
-
* 记账若跟着守卫走就永远不会落,中断卡片的按钮必然反查不到(中断
|
|
1953
|
-
* 后点 Compact 一律回「该任务已结束或不属于当前会话」)。
|
|
1954
|
-
*
|
|
1955
|
-
* 覆盖所有终态入口(正常结束、error、interrupted、idle_timeout、
|
|
1956
|
-
* goal_continuing)——这些卡片都渲染 Compact 按钮(`goal` 仍 active 时
|
|
1957
|
-
* 除外)。记账失败不能影响清理,故内部兜错。
|
|
1958
|
-
*/
|
|
1959
|
-
recordCompactableRunForTerminal(cardSession, activeRun, ctx) {
|
|
1960
|
-
try {
|
|
1961
|
-
if (!runnerSupportsCompact(activeRun.runner, activeRun.agentKind))
|
|
1962
|
-
return;
|
|
1963
|
-
if (activeRun.runner.lifetime !== 'workspace')
|
|
1964
|
-
return;
|
|
1965
|
-
const terminal = cardSession.currentState.terminal;
|
|
1966
|
-
// 与事件消费守卫、Compact 按钮共用同一份终态集合(run-state.ts)。
|
|
1967
|
-
if (!isRunTerminal(terminal))
|
|
1968
|
-
return;
|
|
1969
|
-
// 卡片状态优先(本轮 init/turn_started 写入);缺失时回退 store——
|
|
1970
|
-
// 启动失败但已 /stop 的 run 没有自己的 init,压缩此前会话仍是有意义
|
|
1971
|
-
// 的动作。
|
|
1972
|
-
const compactSessionId = cardSession.currentState.sessionId ??
|
|
1973
|
-
this.sessionStore.getSessionId(ctx.userId, activeRun.agentKind);
|
|
1974
|
-
if (!compactSessionId)
|
|
1975
|
-
return;
|
|
1976
|
-
this.recordCompactableRun(activeRun.cwd, activeRun.runId, compactSessionId, activeRun.agentKind);
|
|
1977
|
-
}
|
|
1978
|
-
catch (err) {
|
|
1979
|
-
getLogger().warn(`[lark-remote] compact record failed runId=${activeRun.runId}: ${errorMessage(err)}`);
|
|
1980
|
-
}
|
|
1981
|
-
}
|
|
1982
|
-
/**
|
|
1983
|
-
* 按 runId 反查可压缩 run 记录(跨 cwd 扫描)。
|
|
1984
|
-
*
|
|
1985
|
-
* 为什么不带 cwd 过滤:按钮属于「那一次 run」,与用户此刻在哪个工作目录、
|
|
1986
|
-
* 排队期间有没有 /cd 无关。`/stop` 的 interruptCurrentRun 同样是全局按 runId
|
|
1987
|
-
* 找(从不查当前 cwd),compact 与它保持一致。用当前 cwd 收窄会让「切了工作区
|
|
1988
|
-
* 再点旧卡片」和「排队期间 /cd」两种情况误报「该任务已结束或不属于当前会话」。
|
|
1989
|
-
*/
|
|
1990
|
-
findCompactableRun(runId) {
|
|
1991
|
-
for (const byRunId of this.compactableRuns.values()) {
|
|
1992
|
-
const record = byRunId.get(runId);
|
|
1993
|
-
if (record)
|
|
1994
|
-
return record;
|
|
1995
|
-
}
|
|
1996
|
-
return undefined;
|
|
1997
|
-
}
|
|
1998
|
-
/**
|
|
1999
|
-
* Handle compact card action — trigger a compaction request for any
|
|
2000
|
-
* runCompact-capable runner(codex/kimi/opencode/pi/claude)。按钮带的是
|
|
2001
|
-
* 那次 run 的 runId,据此反查该 run 实际使用的 sessionId + agentKind
|
|
2002
|
-
* + cwd(compactableRuns,按 runId 记录),再调 runner 的 runCompact()。
|
|
2003
|
-
*/
|
|
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 的会话并发起压缩。 */
|
|
2004
1771
|
async handleCompactAction(value, ctx) {
|
|
2005
|
-
|
|
2006
|
-
log.info(`[lark-remote] handleCompactAction userId=${ctx.userId} runId=${value.runId?.slice(0, 8)}...`);
|
|
2007
|
-
const { runId } = value;
|
|
2008
|
-
if (!runId) {
|
|
2009
|
-
await this.sendResult({ text: '⚠️ 无效的 Compact 请求,缺少 runId' }, ctx);
|
|
2010
|
-
return;
|
|
2011
|
-
}
|
|
2012
|
-
// 校验:run 结束后 activeRuns 已清空,只能靠按钮自带的 runId 反查记录。
|
|
2013
|
-
// 按 runId 存(不按「最新的那一条」),点击到执行之间夹了新 run 收尾也不失效;
|
|
2014
|
-
// 查找也不按「当前 cwd」收窄(见 findCompactableRun)。
|
|
2015
|
-
const target = this.findCompactableRun(runId);
|
|
2016
|
-
if (!target) {
|
|
2017
|
-
await this.sendResult({ text: '⚠️ 该任务已结束或不属于当前会话' }, ctx);
|
|
2018
|
-
return;
|
|
2019
|
-
}
|
|
2020
|
-
// 压缩必须用那次 run 自己的 cwd(session 文件 + runner 实例都按 cwd 解析)。
|
|
2021
|
-
const cwd = target.cwd;
|
|
2022
|
-
// §5.3: kimi 压缩在途检测(wire 状态机)。第二次点击在本地毫秒级如实回答,
|
|
2023
|
-
// 不开卡、不发 session/prompt——否则 kimi 服务端拒绝并产生「秒回」假完成。
|
|
2024
|
-
if (await this.rejectIfCompactionInFlight(target.sessionId, cwd, target.agentKind, ctx)) {
|
|
2025
|
-
return;
|
|
2026
|
-
}
|
|
2027
|
-
// Check that the runner has runCompact method(声明表 && runner 实现)
|
|
2028
|
-
const runner = this.getRunner(cwd, target.agentKind, { replaceStale: true });
|
|
2029
|
-
if (!runnerSupportsCompact(runner, target.agentKind)) {
|
|
2030
|
-
await this.sendResult({ text: '⚠️ 当前运行模式不支持 Compact' }, ctx);
|
|
2031
|
-
return;
|
|
2032
|
-
}
|
|
2033
|
-
log.info(`[lark-remote] handleCompactAction executing runCompact for runId=${runId} cwd=${cwd}`);
|
|
2034
|
-
await this.streamCompact({
|
|
2035
|
-
sessionId: target.sessionId,
|
|
2036
|
-
cwd,
|
|
2037
|
-
agentKind: target.agentKind,
|
|
2038
|
-
runner,
|
|
2039
|
-
ctx,
|
|
2040
|
-
});
|
|
1772
|
+
await this.compact.handleCompactAction(value, ctx);
|
|
2041
1773
|
}
|
|
2042
|
-
/**
|
|
2043
|
-
* §5.3: kimi 压缩在途检测——reader 实现契约的 `readCompactionState` 时,若
|
|
2044
|
-
* wire 状态机判定当前有未终结的压缩(最后一次 begin 无 terminal 记录),
|
|
2045
|
-
* 直接回文本「已有一次 Compact 正在进行」并返回 true(调用方不开卡、不发
|
|
2046
|
-
* session/prompt)。codex/claude 等 reader 无此方法 → 返回 false,行为不变。
|
|
2047
|
-
*/
|
|
2048
|
-
async rejectIfCompactionInFlight(sessionId, cwd, agentKind, ctx) {
|
|
2049
|
-
const reader = this.sessionReaderRegistry.get(agentKind);
|
|
2050
|
-
if (!reader?.readCompactionState)
|
|
2051
|
-
return false;
|
|
2052
|
-
try {
|
|
2053
|
-
const state = reader.readCompactionState(sessionId, cwd);
|
|
2054
|
-
if (state?.inFlight) {
|
|
2055
|
-
const since = state.inFlightSince;
|
|
2056
|
-
let timeLabel = '未知时刻';
|
|
2057
|
-
if (since !== undefined) {
|
|
2058
|
-
const d = new Date(since);
|
|
2059
|
-
const hh = String(d.getHours()).padStart(2, '0');
|
|
2060
|
-
const mm = String(d.getMinutes()).padStart(2, '0');
|
|
2061
|
-
timeLabel = `${hh}:${mm}`;
|
|
2062
|
-
}
|
|
2063
|
-
await this.sendResult({ text: `⚠️ 已有一次 Compact 正在进行(开始于 ${timeLabel}),请等待完成` }, ctx);
|
|
2064
|
-
return true;
|
|
2065
|
-
}
|
|
2066
|
-
}
|
|
2067
|
-
catch (err) {
|
|
2068
|
-
getLogger().warn(`[lark-remote] readCompactionState failed sessionId=${sessionId}: ${errorMessage(err)}`);
|
|
2069
|
-
}
|
|
2070
|
-
return false;
|
|
2071
|
-
}
|
|
2072
|
-
/**
|
|
2073
|
-
* Stream a compaction to a card: start a RunCardSession with
|
|
2074
|
-
* operationKind='compaction' (no recursive Compact button), consume
|
|
2075
|
-
* runner.runCompact() events, read authoritative jsonl usage, and finish
|
|
2076
|
-
* the card. Shared by handleCompactAction (run card button) and
|
|
2077
|
-
* handleResumeCompact (resume cards) so both flows stay in sync.
|
|
2078
|
-
*
|
|
2079
|
-
* Precondition: callers have already validated that `runner` implements
|
|
2080
|
-
* runCompact and that the session exists in `cwd`.
|
|
2081
|
-
*/
|
|
2082
|
-
async streamCompact(opts) {
|
|
2083
|
-
const log = getLogger();
|
|
2084
|
-
const { sessionId, cwd, agentKind, runner, ctx } = opts;
|
|
2085
|
-
log.info(`[lark-remote] streamCompact sessionId=${sessionId} cwd=${cwd}`);
|
|
2086
|
-
// 防御性能力检查:调用方已校验(runId / resume 两条路径),此处双保险并
|
|
2087
|
-
// 让 TS 收窄 runner 类型('runCompact' in runner 之后的 cast 才合法)。
|
|
2088
|
-
if (!runnerSupportsCompact(runner, agentKind) ||
|
|
2089
|
-
!('runCompact' in runner) ||
|
|
2090
|
-
typeof runner.runCompact !== 'function') {
|
|
2091
|
-
await this.sendResult({ text: '⚠️ 当前运行模式不支持 Compact' }, ctx);
|
|
2092
|
-
return;
|
|
2093
|
-
}
|
|
2094
|
-
// activeRuns 以 cwd 为键:注册前先确认槽位空闲,否则压缩会覆盖在跑的 run 的
|
|
2095
|
-
// 记录,令 /stop 只停到最后注册的那个。
|
|
2096
|
-
const busyWith = this.activeRuns.get(cwd);
|
|
2097
|
-
if (busyWith) {
|
|
2098
|
-
log.info(`[lark-remote] compact refused: cwd busy cwd=${cwd} runId=${busyWith.runId}`);
|
|
2099
|
-
await this.sendResult({ text: '⚠️ 该工作区正在运行任务,请等待结束或先 /stop' }, ctx);
|
|
2100
|
-
return;
|
|
2101
|
-
}
|
|
2102
|
-
await this.sendResult({ text: '🗜 Compact 已触发,正在压缩会话…' }, ctx);
|
|
2103
|
-
const compactRunId = randomUUID();
|
|
2104
|
-
// 渲染选项同时用于流式卡与「卡片流没建起来」时的静态兜底卡——两者必须
|
|
2105
|
-
// 同门控,否则兜底卡会渲染出与本次 run 不一致的按钮。
|
|
2106
|
-
const renderOptions = { agentKind };
|
|
2107
|
-
const cardSession = new RunCardSession({
|
|
2108
|
-
connector: this.connector,
|
|
2109
|
-
chatId: ctx.chatId,
|
|
2110
|
-
replyTo: ctx.messageId,
|
|
2111
|
-
runId: compactRunId,
|
|
2112
|
-
renderOptions,
|
|
2113
|
-
});
|
|
2114
|
-
// 压缩与普通 run 共用同一套生命周期——注册进 activeRuns(/stop 有对象可停、
|
|
2115
|
-
// /active 看得见)、挂 §9.12 空闲看门狗、终态后补 reaction。缺任何一环,卡住的
|
|
2116
|
-
// 压缩都只能等 runner 级超时,用户既停不掉也看不出它还在跑。
|
|
2117
|
-
const activeRun = {
|
|
2118
|
-
runId: compactRunId,
|
|
2119
|
-
userId: ctx.userId,
|
|
2120
|
-
chatId: ctx.chatId,
|
|
2121
|
-
session: cardSession,
|
|
2122
|
-
cwd,
|
|
2123
|
-
runner,
|
|
2124
|
-
agentKind,
|
|
2125
|
-
};
|
|
2126
|
-
this.activeRuns.set(cwd, activeRun);
|
|
2127
|
-
getLogger().info(`[lark-remote] activeRuns.set cwd=${cwd} runId=${compactRunId} compact=true`);
|
|
2128
|
-
let watchdog = null;
|
|
2129
|
-
try {
|
|
2130
|
-
await cardSession.start();
|
|
2131
|
-
watchdog = this.startIdleWatchdog({
|
|
2132
|
-
runId: compactRunId,
|
|
2133
|
-
session: cardSession,
|
|
2134
|
-
runner,
|
|
2135
|
-
});
|
|
2136
|
-
// operationKind=compaction:卡片不渲染 Compact 按钮(防递归 Compact)。
|
|
2137
|
-
await cardSession.push({
|
|
2138
|
-
type: 'turn_started',
|
|
2139
|
-
threadId: sessionId,
|
|
2140
|
-
turnId: '',
|
|
2141
|
-
operationKind: 'compaction',
|
|
2142
|
-
});
|
|
2143
|
-
let sawResult = false;
|
|
2144
|
-
let resultSubtype;
|
|
2145
|
-
let resultError;
|
|
2146
|
-
for await (const event of runner.runCompact('', { cwd, sessionId })) {
|
|
2147
|
-
// 已终态(/stop、空闲看门狗)后不再消费事件:与 runAgentStreamToEnd 同口径,
|
|
2148
|
-
// 否则迟到的 result 会把终态卡改写成「已完成」。
|
|
2149
|
-
const cardTerminal = cardSession.currentState.terminal;
|
|
2150
|
-
if (isRunTerminal(cardTerminal)) {
|
|
2151
|
-
continue;
|
|
2152
|
-
}
|
|
2153
|
-
if (event.type === 'result') {
|
|
2154
|
-
sawResult = true;
|
|
2155
|
-
resultSubtype = event.subtype;
|
|
2156
|
-
resultError = event.errorMessage;
|
|
2157
|
-
}
|
|
2158
|
-
watchdog?.touch();
|
|
2159
|
-
await cardSession.push(event);
|
|
2160
|
-
}
|
|
2161
|
-
// §5.3: 卡片终态必须等于引擎真实终态。旧实现只看 sawResult 布尔——error
|
|
2162
|
-
// subtype 的 result 也被 finish 'done'(放大器 bug,2026-08-31 事故)。
|
|
2163
|
-
const terminal = resultSubtype === 'success'
|
|
2164
|
-
? 'done'
|
|
2165
|
-
: resultSubtype === 'interrupted'
|
|
2166
|
-
? 'interrupted'
|
|
2167
|
-
: 'error';
|
|
2168
|
-
// 压缩结束后从会话 jsonl 读权威统计(压缩后上下文、压缩次数、本次压缩
|
|
2169
|
-
// token 消耗与会话累计)。thread/compacted 通知与 jsonl 落盘几乎同时
|
|
2170
|
-
// (实测差 ~9ms),compactCount 未读到则短重试,仍失败优雅降级(无统计)。
|
|
2171
|
-
// 不支持 compactCount 的 reader(opencode/dsh)不重试——否则每次 compact
|
|
2172
|
-
// 都会固定多跑两次完整 export/读取,且永远等不到该字段。
|
|
2173
|
-
const compactCountSupported = this.sessionReaderRegistry.tryGet(agentKind)?.supportsCompactCount !== false;
|
|
2174
|
-
let finalUsage = resolveFinalUsage(this.sessionReaderRegistry, this.config.defaultAgent, sessionId, cwd, agentKind);
|
|
2175
|
-
for (let attempt = 0; compactCountSupported && !finalUsage?.compactCount && attempt < 2; attempt++) {
|
|
2176
|
-
await sleep(150);
|
|
2177
|
-
finalUsage = resolveFinalUsage(this.sessionReaderRegistry, this.config.defaultAgent, sessionId, cwd, agentKind);
|
|
2178
|
-
}
|
|
2179
|
-
await cardSession.finish(terminal, {
|
|
2180
|
-
resultSubtype: resultSubtype ?? 'error',
|
|
2181
|
-
errorMsg: terminal === 'error' ? (resultError ?? '未收到压缩结果') : undefined,
|
|
2182
|
-
// finalUsage 的字段形态就是 FinishMeta 的用量部分(同为 UsageSnapshot),
|
|
2183
|
-
// 逐字段抄的那 14 行随单源一起去掉。
|
|
2184
|
-
...(finalUsage ?? {}),
|
|
2185
|
-
});
|
|
2186
|
-
log.info(`[lark-remote] streamCompact finished sessionId=${sessionId} sawResult=${sawResult} subtype=${resultSubtype ?? 'none'}`);
|
|
2187
|
-
}
|
|
2188
|
-
catch (err) {
|
|
2189
|
-
log.error(`[lark-remote] streamCompact failed: ${errorMessage(err)}`);
|
|
2190
|
-
// 终态守卫(与 runAgentStreamToEnd 同口径):running/finalizing 才转 error,
|
|
2191
|
-
// 已终态(interrupted/idle_timeout)保留首终态,不被迟到的异常改写。
|
|
2192
|
-
const catchTerminal = cardSession.currentState.terminal;
|
|
2193
|
-
if (!isRunTerminal(catchTerminal)) {
|
|
2194
|
-
await cardSession.finish('error', { errorMsg: errorMessage(err) });
|
|
2195
|
-
}
|
|
2196
|
-
else {
|
|
2197
|
-
log.info(`[lark-remote] skip compact error finish: state already terminal (${catchTerminal}) runId=${compactRunId}`);
|
|
2198
|
-
}
|
|
2199
|
-
}
|
|
2200
|
-
finally {
|
|
2201
|
-
watchdog?.stop();
|
|
2202
|
-
// 与 run 路径同口径的降级:卡片流没建起来(start 失败 / stream 未收尾)时,
|
|
2203
|
-
// 终态只存在于内存里。settle() 释放 producer 并等 stream 收尾;'unsent'
|
|
2204
|
-
// 表示用户手里没有卡,必须补一张静态终态卡,否则只剩「Compact 已触发」
|
|
2205
|
-
// 和一条 reaction,用户既不知道失败也不知道原因。
|
|
2206
|
-
const settled = await cardSession.settle();
|
|
2207
|
-
if (settled === 'unsent') {
|
|
2208
|
-
const finalRender = renderRunCardDetailed(cardSession.currentState, renderOptions);
|
|
2209
|
-
await this.sendResult({ card: finalRender.card }, ctx);
|
|
2210
|
-
}
|
|
2211
|
-
// 只摘自己的槽位:/stop 可能已删掉本条并让排队的下一条占住同一 cwd。
|
|
2212
|
-
if (this.activeRuns.get(cwd) === activeRun) {
|
|
2213
|
-
this.activeRuns.delete(cwd);
|
|
2214
|
-
getLogger().info(`[lark-remote] activeRuns.delete cwd=${cwd} runId=${compactRunId}`);
|
|
2215
|
-
}
|
|
2216
|
-
// 终态 reaction:与普通 run 同一口径(done→Done / error→ERROR /
|
|
2217
|
-
// interrupted→SHHH / idle_timeout→Alarm)。
|
|
2218
|
-
this.finishTurnReactions(ctx, terminalReactionEmoji(cardSession.currentState.terminal));
|
|
2219
|
-
}
|
|
2220
|
-
}
|
|
2221
|
-
/**
|
|
2222
|
-
* Handle resume.compact card action — compact a session directly from a
|
|
2223
|
-
* resume card (auto-resume / `/resume <id>`), without a runId.
|
|
2224
|
-
*
|
|
2225
|
-
* Unlike handleCompactAction (which validates against the last finished run's
|
|
2226
|
-
* runId), the resume card carries the sessionId + agent it was rendered for.
|
|
2227
|
-
* Validation: the session must exist in the current cwd (same rule as
|
|
2228
|
-
* cmdResume / resume.use) and the runner must implement runCompact.
|
|
2229
|
-
*/
|
|
1774
|
+
/** resume 卡 Compact 按钮:直接压缩卡上指定的 session(无需 runId)。 */
|
|
2230
1775
|
async handleResumeCompact(value, ctx) {
|
|
2231
|
-
|
|
2232
|
-
log.info(`[lark-remote] handleResumeCompact userId=${ctx.userId} sessionId=${value.sessionId?.slice(0, 16)} agent=${value.agent ?? 'default'}`);
|
|
2233
|
-
const { sessionId } = value;
|
|
2234
|
-
if (!sessionId) {
|
|
2235
|
-
await this.sendResult({ text: '⚠️ 无效的 Compact 请求,缺少 sessionId' }, ctx);
|
|
2236
|
-
return;
|
|
2237
|
-
}
|
|
2238
|
-
const cwd = this.resolveCwd(ctx.userId);
|
|
2239
|
-
if (!cwd) {
|
|
2240
|
-
await this.sendResult({ text: '⚠️ 未设置工作目录' }, ctx);
|
|
2241
|
-
return;
|
|
2242
|
-
}
|
|
2243
|
-
// Resolve agent kind the same way resume.use does: card value wins,
|
|
2244
|
-
// otherwise fall back to defaultAgent. 名单来自单源 AGENT_KINDS —— 手抄一份
|
|
2245
|
-
// 必然再次漂移(这份曾经漏 dsh:带 dsh 的卡片会静默退回 defaultAgent,
|
|
2246
|
-
// 用错 reader 报「未找到 session」)。
|
|
2247
|
-
const agentKind = value.agent && AGENT_KINDS.includes(value.agent)
|
|
2248
|
-
? value.agent
|
|
2249
|
-
: this.config.defaultAgent;
|
|
2250
|
-
// Session must exist in the current cwd (same rule as cmdResume /
|
|
2251
|
-
// resume.use): reject ghosts so a stale card cannot compact a session
|
|
2252
|
-
// from another cwd. Read failures get visible feedback (card click
|
|
2253
|
-
// 红线:不许静默失败)。
|
|
2254
|
-
let content;
|
|
2255
|
-
try {
|
|
2256
|
-
content = this.sessionReaderRegistry.get(agentKind).readSessionContent(sessionId, cwd);
|
|
2257
|
-
}
|
|
2258
|
-
catch (err) {
|
|
2259
|
-
await this.sendResult({ text: `⚠️ 读取 session ${sessionId} 失败: ${errorMessage(err)}` }, ctx);
|
|
2260
|
-
return;
|
|
2261
|
-
}
|
|
2262
|
-
if (content.events.length === 0 &&
|
|
2263
|
-
!content.usage &&
|
|
2264
|
-
!content.aiTitle &&
|
|
2265
|
-
!content.recap &&
|
|
2266
|
-
!content.displayTitle) {
|
|
2267
|
-
await this.sendResult({ text: `⚠️ 未找到 session ${sessionId}(当前目录: ${cwd})` }, ctx);
|
|
2268
|
-
return;
|
|
2269
|
-
}
|
|
2270
|
-
// §5.3: kimi 压缩在途检测(与 handleCompactAction 同一入口)。
|
|
2271
|
-
if (await this.rejectIfCompactionInFlight(sessionId, cwd, agentKind, ctx))
|
|
2272
|
-
return;
|
|
2273
|
-
// Check that the runner has runCompact(声明表 && runner 实现;
|
|
2274
|
-
// codex/kimi/opencode/pi/claude/dsh 均已实现)。
|
|
2275
|
-
const runner = this.getRunner(cwd, agentKind);
|
|
2276
|
-
if (!runnerSupportsCompact(runner, agentKind)) {
|
|
2277
|
-
await this.sendResult({ text: '⚠️ 当前运行模式不支持 Compact' }, ctx);
|
|
2278
|
-
return;
|
|
2279
|
-
}
|
|
2280
|
-
log.info(`[lark-remote] handleResumeCompact executing runCompact sessionId=${sessionId} cwd=${cwd}`);
|
|
2281
|
-
await this.streamCompact({ sessionId, cwd, agentKind, runner, ctx });
|
|
1776
|
+
await this.compact.handleResumeCompact(value, ctx);
|
|
2282
1777
|
}
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
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(`!` 命令入口)。 */
|
|
2289
1786
|
async executeBash(command, ctx) {
|
|
2290
|
-
|
|
2291
|
-
getLogger().info(`[lark-remote] executeBash start userId=${ctx.userId} sessionCwd=${this.sessionStore.get(ctx.userId)?.cwd} command="${command.slice(0, 30)}..."`);
|
|
2292
|
-
if (!cwd) {
|
|
2293
|
-
getLogger().info(`[lark-remote] executeBash no cwd, sending error`);
|
|
2294
|
-
await this.sendResult({ text: '请先使用 /cd <path> 设置工作目录,或保存一个 workspace (/ws save <name>)' }, ctx);
|
|
2295
|
-
return;
|
|
2296
|
-
}
|
|
2297
|
-
getLogger().info(`[lark-remote] executeBash proceeding cwd=${cwd}`);
|
|
2298
|
-
// `!` commands bypass the serial queue entirely (design.md §9.6):
|
|
2299
|
-
// index.ts dispatches `!` to router.handle → executeBash directly, NOT via
|
|
2300
|
-
// bridge.enqueue. bash runs in parallel with same-workspace claude runs.
|
|
2301
|
-
// bash runs are tracked in `activeBashRuns` (keyed by runId), separate from
|
|
2302
|
-
// claude's `activeRuns` (keyed by cwd) — they must not collide or `/stop`
|
|
2303
|
-
// would kill the wrong task. Do NOT re-chain onto the serial queue
|
|
2304
|
-
// (QueueManager) here: that would block on the still-pending outer promise
|
|
2305
|
-
// and deadlock self-wait.
|
|
2306
|
-
await this.executeBashInternal(command, ctx, cwd);
|
|
2307
|
-
}
|
|
2308
|
-
/**
|
|
2309
|
-
* Internal execution of bash command (called directly from executeBash,
|
|
2310
|
-
* bypassing the serial queue — see executeBash).
|
|
2311
|
-
*/
|
|
2312
|
-
async executeBashInternal(command, ctx, cwd) {
|
|
2313
|
-
getLogger().debug(`[lark-remote] executeBashInternal userId=${ctx.userId} chatId=${ctx.chatId} cwd=${cwd} command="${command.slice(0, 50)}..."`);
|
|
2314
|
-
// Note: We don't check activeRuns here because bash bypasses the serial
|
|
2315
|
-
// queue and runs in parallel with same-workspace agent runs (§9.6). The
|
|
2316
|
-
// activeRuns check in forwardToClaude is for a different purpose (detecting
|
|
2317
|
-
// concurrent agent runs that would corrupt session state). Bash commands
|
|
2318
|
-
// don't have that issue.
|
|
2319
|
-
const runId = randomUUID();
|
|
2320
|
-
const bashRunner = new BashProcessRunner();
|
|
2321
|
-
// Single streaming card across the whole run (initial → output patches →
|
|
2322
|
-
// terminal), instead of sending many independent cards. BashCardSession is
|
|
2323
|
-
// the bash analogue of RunCardSession.
|
|
2324
|
-
const cardSession = new BashCardSession({
|
|
2325
|
-
connector: this.connector,
|
|
2326
|
-
chatId: ctx.chatId,
|
|
2327
|
-
replyTo: ctx.messageId,
|
|
2328
|
-
runId,
|
|
2329
|
-
command,
|
|
2330
|
-
renderOptions: {},
|
|
2331
|
-
});
|
|
2332
|
-
// Track this bash run independently from claude's `activeRuns`. Bash runs
|
|
2333
|
-
// bypass the serial queue and may run in parallel with a claude run in the
|
|
2334
|
-
// same cwd, so they must NOT share activeRuns (which is keyed by cwd
|
|
2335
|
-
// and would let bash/claude overwrite each other). Keyed by runId so
|
|
2336
|
-
// multiple `!` commands can run concurrently. Tracked so /stop can reach
|
|
2337
|
-
// the bashRunner (which is otherwise a local) via interruptCurrentRun.
|
|
2338
|
-
this.activeBashRuns.set(runId, {
|
|
2339
|
-
bashRunner,
|
|
2340
|
-
cardSession,
|
|
2341
|
-
userId: ctx.userId,
|
|
2342
|
-
chatId: ctx.chatId,
|
|
2343
|
-
cwd,
|
|
2344
|
-
command,
|
|
2345
|
-
});
|
|
2346
|
-
let output = '';
|
|
2347
|
-
let stderr = '';
|
|
2348
|
-
let exitCode = null;
|
|
2349
|
-
try {
|
|
2350
|
-
getLogger().info(`[lark-remote] executeBashInternal about to start stream chatId=${ctx.chatId} messageId=${ctx.messageId}`);
|
|
2351
|
-
try {
|
|
2352
|
-
await cardSession.start();
|
|
2353
|
-
getLogger().info(`[lark-remote] executeBash stream started runId=${runId}`);
|
|
2354
|
-
}
|
|
2355
|
-
catch (err) {
|
|
2356
|
-
getLogger().warn('[lark-remote] bash card stream unavailable:', err);
|
|
2357
|
-
}
|
|
2358
|
-
for await (const event of bashRunner.run(command, { cwd })) {
|
|
2359
|
-
if (event.type === 'stdout') {
|
|
2360
|
-
// 层④:bridge 本地 output 也必须 store-time 截断——仅 session state
|
|
2361
|
-
// 有界并不会让 `output +=` 自然有界(「store 有界则本地拼接自然有界」
|
|
2362
|
-
// 不成立),!yes 洪峰下本地字符串仍全量驻留。capBashOutput 增量调用
|
|
2363
|
-
// O(CAP+chunk),不会带回 O(n²) 截断成本(output 恒 ≤ CAP,拼接后
|
|
2364
|
-
// ≤ CAP+chunk 再 slice 尾部)。
|
|
2365
|
-
output = capBashOutput(output + event.content);
|
|
2366
|
-
await cardSession.update({ output, stderr });
|
|
2367
|
-
}
|
|
2368
|
-
else if (event.type === 'stderr') {
|
|
2369
|
-
stderr = capBashOutput(stderr + event.content);
|
|
2370
|
-
await cardSession.update({ output, stderr });
|
|
2371
|
-
}
|
|
2372
|
-
else if (event.type === 'exit') {
|
|
2373
|
-
exitCode = event.exitCode ?? 0;
|
|
2374
|
-
}
|
|
2375
|
-
}
|
|
2376
|
-
await cardSession.finish(exitCode === 0 ? 'done' : 'error', { exitCode, output, stderr });
|
|
2377
|
-
}
|
|
2378
|
-
catch (err) {
|
|
2379
|
-
getLogger().error(`[lark-remote] bash run error runId=${runId}:`, err);
|
|
2380
|
-
// If for-await threw mid-stream the bash child may still be running.
|
|
2381
|
-
// Stop it before finalizing the card — otherwise the finally block
|
|
2382
|
-
// removes the activeBashRuns entry and /stop can no longer reach it,
|
|
2383
|
-
// leaving an orphan process.
|
|
2384
|
-
try {
|
|
2385
|
-
await bashRunner.stop();
|
|
2386
|
-
}
|
|
2387
|
-
catch (stopErr) {
|
|
2388
|
-
getLogger().warn(`[lark-remote] bash stop failed:`, stopErr);
|
|
2389
|
-
}
|
|
2390
|
-
await cardSession.finish('error', {
|
|
2391
|
-
exitCode: 1,
|
|
2392
|
-
output,
|
|
2393
|
-
stderr: stderr + `\n执行错误: ${errorMessage(err)}`,
|
|
2394
|
-
});
|
|
2395
|
-
}
|
|
2396
|
-
finally {
|
|
2397
|
-
const settleResult = await cardSession.settle();
|
|
2398
|
-
if (settleResult === 'unsent') {
|
|
2399
|
-
// Stream never established a card — send a static terminal card as fallback.
|
|
2400
|
-
// Use 2.0 schema to match the streaming card (avoid 2.0→1.x downgrade).
|
|
2401
|
-
try {
|
|
2402
|
-
await this.connector.sendWithRetry(ctx.chatId, { card: renderBashCard(cardSession.currentState, {}) }, { replyTo: ctx.messageId });
|
|
2403
|
-
}
|
|
2404
|
-
catch (fallbackErr) {
|
|
2405
|
-
getLogger().error(`[lark-remote] bash terminal fallback card failed runId=${runId} chatId=${ctx.chatId}:`, fallbackErr);
|
|
2406
|
-
}
|
|
2407
|
-
}
|
|
2408
|
-
if (this.activeBashRuns.has(runId)) {
|
|
2409
|
-
this.activeBashRuns.delete(runId);
|
|
2410
|
-
getLogger().info(`[lark-remote] executeBash done runId=${runId} exitCode=${exitCode}`);
|
|
2411
|
-
}
|
|
2412
|
-
// Add a Done emoji reaction to the user's original message to signal completion
|
|
2413
|
-
this.finishTurnReactions(ctx, 'Done');
|
|
2414
|
-
}
|
|
1787
|
+
await this.bashRuns.executeBash(command, ctx);
|
|
2415
1788
|
}
|
|
2416
1789
|
}
|
|
2417
1790
|
//# sourceMappingURL=index.js.map
|