lark-remote 0.5.9 → 0.5.12
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 +49 -0
- package/dist/binder.js +7 -1
- package/dist/bridge/index.d.ts +9 -0
- package/dist/bridge/index.js +28 -3
- package/dist/bridge/queue-manager.d.ts +31 -0
- package/dist/bridge/queue-manager.js +78 -0
- package/dist/card/approval-render.d.ts +14 -0
- package/dist/card/approval-render.js +155 -29
- package/dist/card/bash-card-session.js +8 -0
- package/dist/card/bash-renderer.js +11 -23
- package/dist/card/card-budget.js +24 -4
- package/dist/card/run-renderer.js +31 -9
- package/dist/common/text-width.js +5 -0
- package/dist/common/tool-summary.js +4 -2
- package/dist/config/codex-config.js +5 -3
- package/dist/config/index.js +13 -1
- package/dist/index.js +42 -24
- package/dist/instance-lock.d.ts +9 -1
- package/dist/instance-lock.js +42 -7
- package/dist/platform/identity.js +4 -3
- package/dist/platform/path.js +3 -1
- package/dist/platform/terminator-win32.d.ts +13 -1
- package/dist/platform/terminator-win32.js +4 -14
- package/dist/router/index.js +179 -122
- package/dist/runner/codex/app-server/runner.js +12 -0
- package/dist/runner/common/acp/base-translator.d.ts +12 -0
- package/dist/runner/common/acp/base-translator.js +22 -0
- package/dist/runner/common/jsonl-stream.js +12 -1
- package/dist/runner/common/jsonrpc/connection-manager.d.ts +6 -0
- package/dist/runner/common/jsonrpc/connection-manager.js +34 -9
- package/dist/runner/common/jsonrpc/transport.d.ts +2 -1
- package/dist/runner/common/jsonrpc/transport.js +7 -1
- package/dist/runner/dsh/approval.js +16 -3
- package/dist/runner/dsh/client.d.ts +31 -1
- package/dist/runner/dsh/client.js +126 -6
- package/dist/runner/dsh/exit-cancel.js +62 -23
- package/dist/runner/dsh/runner.d.ts +7 -0
- package/dist/runner/dsh/runner.js +42 -5
- package/dist/runner/kimi/acp/runner.js +42 -5
- package/dist/runner/kimi/acp/translator.d.ts +8 -0
- package/dist/runner/kimi/acp/translator.js +10 -0
- package/dist/runner/pi/rpc/runner.js +33 -5
- package/dist/runner/resolve-agent-choices.js +5 -1
- package/dist/runner/sync-agent-choices.js +4 -1
- package/dist/session/claude/session-index.js +9 -2
- package/dist/session/codex/rollout-reader.js +51 -26
- package/dist/session/common/usage-accumulator.d.ts +10 -1
- package/dist/session/common/usage-accumulator.js +10 -2
- package/dist/session/dsh/sessions.js +29 -9
- package/dist/session/kimi/sessions.js +9 -5
- package/dist/session/opencode/sessions.js +13 -6
- package/dist/session/session-store.js +4 -3
- package/package.json +1 -1
|
@@ -54,7 +54,8 @@ export declare class JsonlRpcTransport {
|
|
|
54
54
|
get closed(): boolean;
|
|
55
55
|
/**
|
|
56
56
|
* Spawn the child process and start reading its stdout.
|
|
57
|
-
*
|
|
57
|
+
* Resolves once the process has spawned and listeners are wired — it does
|
|
58
|
+
* NOT wait for the process to close(关闭经 events.onClose 上报)。
|
|
58
59
|
*/
|
|
59
60
|
start(events: TransportEvents): Promise<void>;
|
|
60
61
|
/**
|
|
@@ -62,7 +62,8 @@ export class JsonlRpcTransport {
|
|
|
62
62
|
}
|
|
63
63
|
/**
|
|
64
64
|
* Spawn the child process and start reading its stdout.
|
|
65
|
-
*
|
|
65
|
+
* Resolves once the process has spawned and listeners are wired — it does
|
|
66
|
+
* NOT wait for the process to close(关闭经 events.onClose 上报)。
|
|
66
67
|
*/
|
|
67
68
|
async start(events) {
|
|
68
69
|
this.events = events;
|
|
@@ -121,6 +122,11 @@ export class JsonlRpcTransport {
|
|
|
121
122
|
const decoder = new StringDecoder('utf8');
|
|
122
123
|
let remainder = '';
|
|
123
124
|
proc.stdout?.on('data', (chunk) => {
|
|
125
|
+
// handleClose(超长行/EPIPE/进程 error)判死通道后不再分发:子进程被
|
|
126
|
+
// terminator 停止前还会吐几帧,把这些帧交给 onMessage 等于向已判死的
|
|
127
|
+
// run 注入事件。
|
|
128
|
+
if (this._closed)
|
|
129
|
+
return;
|
|
124
130
|
remainder += decoder.write(chunk);
|
|
125
131
|
const lines = remainder.split('\n');
|
|
126
132
|
// Lines except the last (incomplete) one
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* `callId`, with an order-independent pairing so either transport may arrive
|
|
9
9
|
* first.
|
|
10
10
|
*/
|
|
11
|
+
import { getLogger } from '../../logger/index.js';
|
|
11
12
|
export class DshApprovalTracker {
|
|
12
13
|
audits = new Map();
|
|
13
14
|
waterfallByCallId = new Map();
|
|
@@ -22,8 +23,17 @@ export class DshApprovalTracker {
|
|
|
22
23
|
this.audits.set(audit.approvalId, audit);
|
|
23
24
|
if (audit.callId) {
|
|
24
25
|
const waterfall = this.waterfallByCallId.get(audit.callId);
|
|
25
|
-
if (waterfall)
|
|
26
|
-
|
|
26
|
+
if (waterfall) {
|
|
27
|
+
const boundTo = this.approvalIdByEventId.get(waterfall.eventId);
|
|
28
|
+
if (!boundTo || boundTo === audit.approvalId) {
|
|
29
|
+
return this.bind(audit, waterfall);
|
|
30
|
+
}
|
|
31
|
+
// 该 waterfall 已被兜底配对绑给另一张卡(「无 callId 的 audit 先到 +
|
|
32
|
+
// waterfall 先到」的乱序组合,见 onWaterfall 的 toolName 启发式):
|
|
33
|
+
// 不再二次绑定——两张卡指向同一可答 waterfall 时,先答的生效、
|
|
34
|
+
// 后答的服务端报错;单卡可答严格好于双卡可答。
|
|
35
|
+
getLogger().debug(`[dsh] waterfall already bound to ${boundTo}; skip rebind for ${audit.approvalId}`);
|
|
36
|
+
}
|
|
27
37
|
}
|
|
28
38
|
return undefined;
|
|
29
39
|
}
|
|
@@ -86,8 +96,11 @@ export class DshApprovalTracker {
|
|
|
86
96
|
this.waterfallByCallId.delete(binding.waterfall.callId);
|
|
87
97
|
}
|
|
88
98
|
}
|
|
89
|
-
this.resolveWaiters(approvalId, undefined);
|
|
90
99
|
}
|
|
100
|
+
// 无论有无 binding 都要唤醒 waiter:approval/decided 落在「audit 已到、
|
|
101
|
+
// waterfall 未到」的窗口时,waitForBinding 的点击会白等满超时才收到
|
|
102
|
+
// 「审批通道尚未就绪」——而该审批实际上已被外部决策结束。
|
|
103
|
+
this.resolveWaiters(approvalId, undefined);
|
|
91
104
|
return binding;
|
|
92
105
|
}
|
|
93
106
|
/** Mark a waterfall cancelled by the Host (another client answered it). */
|
|
@@ -256,7 +256,20 @@ export declare class DshClient {
|
|
|
256
256
|
* Fallback probe: the newest durable event's type tells whether a turn is open.
|
|
257
257
|
*
|
|
258
258
|
* 只在拿不到 `running` 时用。名单刻意保守——`job/*`(后台任务可在 turn 外
|
|
259
|
-
* 结算)、`
|
|
259
|
+
* 结算)、`turn/end` 都不算,判不出来就报告 `unknown`。
|
|
260
|
+
*
|
|
261
|
+
* 落盘顺序已从 DSH 源码验证(packages/core/agent-loop:agent.ts 的 turn()/
|
|
262
|
+
* step() + inbox.ts 的 mutate()):prompt 提交只落 `agent/inbox/spliced`,
|
|
263
|
+
* `turn/start` 由 turn() 在认领前 append,`user/message` 在 step() 认领时才
|
|
264
|
+
* append——顺序是 spliced → turn/start → user/message。两个边界:
|
|
265
|
+
*
|
|
266
|
+
* - 尾部 `agent/inbox/spliced` → closed 是**对的**:turn 尚未打开,cancel
|
|
267
|
+
* 空闲 session 无害,随后 withdraw 探测会撤走排队 prompt;
|
|
268
|
+
* - 尾部 `user/message` → **unknown**:step() 里 user/message append 后要等
|
|
269
|
+
* 一整个 LLM 往返才有下一条 durable 事件(assistant 流帧是 process-local
|
|
270
|
+
* 的),这个秒级窗口内 turn 已打开;且压缩 checkpoint 也以 turn:null 落
|
|
271
|
+
* user/message——两种解释并存时只能报 unknown(stop 按 unconfirmed 处理,
|
|
272
|
+
* 不假报已停止)。此前报 closed 会让一个 LLM 往返宽的窗口假报干净停止。
|
|
260
273
|
*/
|
|
261
274
|
private probeOpenTurnByEvents;
|
|
262
275
|
/**
|
|
@@ -290,6 +303,23 @@ export declare class DshClient {
|
|
|
290
303
|
* keeps the card "running" until this run's turn actually finishes.
|
|
291
304
|
*/
|
|
292
305
|
sessionEvents(sessionId: string, signal: AbortSignal, initialSeq?: number, promptRequestId?: string): AsyncGenerator<DshStreamItem>;
|
|
306
|
+
/**
|
|
307
|
+
* 快照缺口回填(经 DSH 源码验证后实现)。
|
|
308
|
+
*
|
|
309
|
+
* follow 开场快照按**消息**截取:host 的 paginate 只数 user/assistant
|
|
310
|
+
* message(工具事件随所在消息组搭车进快照,不占预算),`maxMessages` 内
|
|
311
|
+
* 装不下的更早前缀被截掉并以 `hasMore: true` 标记。断连期间积压超过
|
|
312
|
+
* FOLLOW_MAX_MESSAGES 条消息时,`lastSeq` 与快照起点之间的缺口若不补,
|
|
313
|
+
* 这些事件对本次 follow 永久缺失(卡片丢中段工具行;seq 去重只防重放
|
|
314
|
+
* 不补缺)。`session/page` 的 `throughSeq` 游标正是服务端为此设计的回填
|
|
315
|
+
* 通道——从缺口尾部向前翻页直到盖住 lastSeq、翻到日志头或达到页数上限。
|
|
316
|
+
*
|
|
317
|
+
* best-effort:翻页失败保留已取回的部分(缺口剩余与旧行为一致地丢失,
|
|
318
|
+
* 但只发生在超出 BACKFILL_MAX_PAGES×PAGE_MESSAGES 条消息的极端断连)。
|
|
319
|
+
* 返回 seq 升序的完整回放序列(回填 + 快照记录;与 lastSeq 重叠的条目
|
|
320
|
+
* 由回放循环的 seq 去重兜住)。
|
|
321
|
+
*/
|
|
322
|
+
private replaySnapshotGap;
|
|
293
323
|
/**
|
|
294
324
|
* Host-wide live projection stream (`session/control`).
|
|
295
325
|
*
|
|
@@ -52,8 +52,19 @@ function isDshAuthError(error) {
|
|
|
52
52
|
}
|
|
53
53
|
/** Backoff between mux reconnects to avoid a hot loop on a dropping server. */
|
|
54
54
|
const RECONNECT_BACKOFF_MS = 200;
|
|
55
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* Follow snapshot budget. `session/follow` also stays live after the snapshot.
|
|
57
|
+
*
|
|
58
|
+
* 语义(经 DSH 源码验证,packages/api/session-controller/src/history.ts
|
|
59
|
+
* 的 paginate):按**消息**计数——只有 user/assistant message 占预算,工具
|
|
60
|
+
* 事件随所在消息组搭进快照。断连期间积压超过该预算时快照首条会越过
|
|
61
|
+
* lastSeq,缺口经 {@link replaySnapshotGap} 用 session/page 回填。
|
|
62
|
+
*/
|
|
56
63
|
const FOLLOW_MAX_MESSAGES = 200;
|
|
64
|
+
/** 快照缺口回填的单页消息数(session/page 的 maxMessages)。 */
|
|
65
|
+
const FOLLOW_GAP_BACKFILL_PAGE_MESSAGES = 200;
|
|
66
|
+
/** 快照缺口回填的页数上限(×PAGE_MESSAGES = 单次重连最多回补 4000 条消息)。 */
|
|
67
|
+
const FOLLOW_GAP_BACKFILL_MAX_PAGES = 20;
|
|
57
68
|
/**
|
|
58
69
|
* 只在 turn 内部落盘的会话事件类型:看到它们说明 turn 还没结束。
|
|
59
70
|
* 名单刻意保守——`user/message`(压缩 checkpoint 会以 turn:null 落盘)、
|
|
@@ -116,7 +127,15 @@ export function extractDshToken(raw) {
|
|
|
116
127
|
return value;
|
|
117
128
|
try {
|
|
118
129
|
const url = new URL(value.startsWith('?') ? `http://dsh.invalid/${value}` : value);
|
|
119
|
-
|
|
130
|
+
const token = url.searchParams.get('token');
|
|
131
|
+
if (token)
|
|
132
|
+
return token;
|
|
133
|
+
// 带 scheme 却没有 token 参数的输入是误贴的 host URL(如
|
|
134
|
+
// `http://127.0.0.1:3080`):整串不是合法 token,参与握手只会得到
|
|
135
|
+
// 指向性缺失的 401。返回空串让上游走「token 缺失」的诊断路径。
|
|
136
|
+
if (value.includes('://'))
|
|
137
|
+
return '';
|
|
138
|
+
return value;
|
|
120
139
|
}
|
|
121
140
|
catch {
|
|
122
141
|
return value;
|
|
@@ -540,7 +559,20 @@ export class DshClient {
|
|
|
540
559
|
* Fallback probe: the newest durable event's type tells whether a turn is open.
|
|
541
560
|
*
|
|
542
561
|
* 只在拿不到 `running` 时用。名单刻意保守——`job/*`(后台任务可在 turn 外
|
|
543
|
-
* 结算)、`
|
|
562
|
+
* 结算)、`turn/end` 都不算,判不出来就报告 `unknown`。
|
|
563
|
+
*
|
|
564
|
+
* 落盘顺序已从 DSH 源码验证(packages/core/agent-loop:agent.ts 的 turn()/
|
|
565
|
+
* step() + inbox.ts 的 mutate()):prompt 提交只落 `agent/inbox/spliced`,
|
|
566
|
+
* `turn/start` 由 turn() 在认领前 append,`user/message` 在 step() 认领时才
|
|
567
|
+
* append——顺序是 spliced → turn/start → user/message。两个边界:
|
|
568
|
+
*
|
|
569
|
+
* - 尾部 `agent/inbox/spliced` → closed 是**对的**:turn 尚未打开,cancel
|
|
570
|
+
* 空闲 session 无害,随后 withdraw 探测会撤走排队 prompt;
|
|
571
|
+
* - 尾部 `user/message` → **unknown**:step() 里 user/message append 后要等
|
|
572
|
+
* 一整个 LLM 往返才有下一条 durable 事件(assistant 流帧是 process-local
|
|
573
|
+
* 的),这个秒级窗口内 turn 已打开;且压缩 checkpoint 也以 turn:null 落
|
|
574
|
+
* user/message——两种解释并存时只能报 unknown(stop 按 unconfirmed 处理,
|
|
575
|
+
* 不假报已停止)。此前报 closed 会让一个 LLM 往返宽的窗口假报干净停止。
|
|
544
576
|
*/
|
|
545
577
|
async probeOpenTurnByEvents(sessionId, opts) {
|
|
546
578
|
try {
|
|
@@ -555,6 +587,8 @@ export class DshClient {
|
|
|
555
587
|
const last = value.records.at(-1)?.event;
|
|
556
588
|
if (last === undefined)
|
|
557
589
|
return 'closed';
|
|
590
|
+
if (last.type === 'user/message')
|
|
591
|
+
return 'unknown';
|
|
558
592
|
return last.type === 'turn/start' || IN_TURN_EVENT_TYPES.has(last.type) ? 'open' : 'closed';
|
|
559
593
|
}
|
|
560
594
|
catch (err) {
|
|
@@ -575,6 +609,7 @@ export class DshClient {
|
|
|
575
609
|
let waiter = null;
|
|
576
610
|
let ended = false;
|
|
577
611
|
let failure;
|
|
612
|
+
let opened = false;
|
|
578
613
|
const settle = (frame) => {
|
|
579
614
|
if (waiter) {
|
|
580
615
|
const resolve = waiter;
|
|
@@ -587,7 +622,27 @@ export class DshClient {
|
|
|
587
622
|
else
|
|
588
623
|
ended = true;
|
|
589
624
|
};
|
|
625
|
+
// 建连/首帧超时:服务器接受 TCP 但不完成 WS 握手时 onopen/onerror/onclose
|
|
626
|
+
// 都不触发,generator 会永远停在 await next()——unary 挂起有 60s 预算与
|
|
627
|
+
// 回归测试(test_anchor_dsh_stop_with_hung_unary_releases_run),mux 的
|
|
628
|
+
// 同型问题此前只能靠 idle 看门狗兜底。超时按传输错误走既有重连路径。
|
|
629
|
+
const connectTimer = setTimeout(() => {
|
|
630
|
+
if (opened || ended)
|
|
631
|
+
return;
|
|
632
|
+
failure = new DshError('transport', 'DSH remote mux WebSocket connect timeout');
|
|
633
|
+
ended = true;
|
|
634
|
+
settle(undefined);
|
|
635
|
+
try {
|
|
636
|
+
socket.close();
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
/* ignore close error */
|
|
640
|
+
}
|
|
641
|
+
}, this.requestTimeoutMs);
|
|
642
|
+
connectTimer.unref?.();
|
|
590
643
|
socket.onopen = () => {
|
|
644
|
+
opened = true;
|
|
645
|
+
clearTimeout(connectTimer);
|
|
591
646
|
if (ended)
|
|
592
647
|
return;
|
|
593
648
|
try {
|
|
@@ -694,6 +749,7 @@ export class DshClient {
|
|
|
694
749
|
}
|
|
695
750
|
}
|
|
696
751
|
finally {
|
|
752
|
+
clearTimeout(connectTimer);
|
|
697
753
|
signal.removeEventListener('abort', onAbort);
|
|
698
754
|
try {
|
|
699
755
|
socket.close();
|
|
@@ -734,6 +790,12 @@ export class DshClient {
|
|
|
734
790
|
const frame = raw;
|
|
735
791
|
if (frame.type === 'ready') {
|
|
736
792
|
authRefreshAttempted = false;
|
|
793
|
+
// 成功建连后补充重连预算:goal 驱动的 turn 可跑数小时,DSH 重启 +
|
|
794
|
+
// 网络抖动累计掉线超过 3 次后,旧实现会让 $events 永久 unavailable
|
|
795
|
+
// ——已渲染的提问卡持有过期 clientId(点答案必失败)、Host 的 cancel
|
|
796
|
+
// 帧也收不到,违反「同一 waterfall 在 $events 重连后会被重投」的
|
|
797
|
+
// 不变量。预算语义是「单次断连窗口内的连续失败上限」,不是终身配额。
|
|
798
|
+
reconnectAttempts = 0;
|
|
737
799
|
sawReady = true;
|
|
738
800
|
streamClientId = frame.clientId;
|
|
739
801
|
yield { kind: 'ready', clientId: streamClientId };
|
|
@@ -904,9 +966,10 @@ export class DshClient {
|
|
|
904
966
|
return;
|
|
905
967
|
authRefreshAttempted = false;
|
|
906
968
|
if (frame.type === 'snapshot') {
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
969
|
+
// 快照可能只覆盖尾部窗口(见 replaySnapshotGap):先补缺口再回放,
|
|
970
|
+
// 回放循环与原逻辑逐条相同(去重 / admit / turn/end 早退)。
|
|
971
|
+
for (const ev of await this.replaySnapshotGap(sessionId, signal, state.lastSeq, frame)) {
|
|
972
|
+
if (ev.seq <= state.lastSeq)
|
|
910
973
|
continue;
|
|
911
974
|
state.lastSeq = ev.seq;
|
|
912
975
|
if (!admit(ev))
|
|
@@ -963,6 +1026,63 @@ export class DshClient {
|
|
|
963
1026
|
await new Promise((resolve) => setTimeout(resolve, RECONNECT_BACKOFF_MS));
|
|
964
1027
|
}
|
|
965
1028
|
}
|
|
1029
|
+
/**
|
|
1030
|
+
* 快照缺口回填(经 DSH 源码验证后实现)。
|
|
1031
|
+
*
|
|
1032
|
+
* follow 开场快照按**消息**截取:host 的 paginate 只数 user/assistant
|
|
1033
|
+
* message(工具事件随所在消息组搭车进快照,不占预算),`maxMessages` 内
|
|
1034
|
+
* 装不下的更早前缀被截掉并以 `hasMore: true` 标记。断连期间积压超过
|
|
1035
|
+
* FOLLOW_MAX_MESSAGES 条消息时,`lastSeq` 与快照起点之间的缺口若不补,
|
|
1036
|
+
* 这些事件对本次 follow 永久缺失(卡片丢中段工具行;seq 去重只防重放
|
|
1037
|
+
* 不补缺)。`session/page` 的 `throughSeq` 游标正是服务端为此设计的回填
|
|
1038
|
+
* 通道——从缺口尾部向前翻页直到盖住 lastSeq、翻到日志头或达到页数上限。
|
|
1039
|
+
*
|
|
1040
|
+
* best-effort:翻页失败保留已取回的部分(缺口剩余与旧行为一致地丢失,
|
|
1041
|
+
* 但只发生在超出 BACKFILL_MAX_PAGES×PAGE_MESSAGES 条消息的极端断连)。
|
|
1042
|
+
* 返回 seq 升序的完整回放序列(回填 + 快照记录;与 lastSeq 重叠的条目
|
|
1043
|
+
* 由回放循环的 seq 去重兜住)。
|
|
1044
|
+
*/
|
|
1045
|
+
async replaySnapshotGap(sessionId, signal, lastSeq, frame) {
|
|
1046
|
+
const records = (frame.records ?? [])
|
|
1047
|
+
.map((record) => record.event)
|
|
1048
|
+
.filter((ev) => !!ev);
|
|
1049
|
+
if (frame.hasMore !== true || signal.aborted)
|
|
1050
|
+
return records;
|
|
1051
|
+
const firstSeq = records[0]?.seq;
|
|
1052
|
+
const gapEnd = firstSeq !== undefined ? firstSeq - 1 : frame.cursor;
|
|
1053
|
+
if (gapEnd <= lastSeq)
|
|
1054
|
+
return records;
|
|
1055
|
+
const pages = [];
|
|
1056
|
+
try {
|
|
1057
|
+
let throughSeq = gapEnd;
|
|
1058
|
+
for (let page = 0; page < FOLLOW_GAP_BACKFILL_MAX_PAGES; page++) {
|
|
1059
|
+
const value = await this.page(sessionId, throughSeq, FOLLOW_GAP_BACKFILL_PAGE_MESSAGES, {
|
|
1060
|
+
signal,
|
|
1061
|
+
});
|
|
1062
|
+
const events = (value.records ?? [])
|
|
1063
|
+
.map((record) => record.event)
|
|
1064
|
+
.filter((ev) => !!ev);
|
|
1065
|
+
if (events.length > 0)
|
|
1066
|
+
pages.push(events);
|
|
1067
|
+
const oldest = events[0]?.seq;
|
|
1068
|
+
// hasMore=false(日志头)或本页已盖住 lastSeq:缺口补齐,停。
|
|
1069
|
+
if (value.hasMore !== true || oldest === undefined || oldest <= lastSeq + 1)
|
|
1070
|
+
break;
|
|
1071
|
+
throughSeq = oldest - 1;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
catch (err) {
|
|
1075
|
+
getLogger().warn(`[dsh] follow snapshot gap backfill incomplete sessionId=${sessionId}: ${err.message}`);
|
|
1076
|
+
}
|
|
1077
|
+
// pages 是「从新到旧」收集的;倒序拼接得到 seq 升序回放。
|
|
1078
|
+
const backfill = [];
|
|
1079
|
+
for (let index = pages.length - 1; index >= 0; index--)
|
|
1080
|
+
backfill.push(...pages[index]);
|
|
1081
|
+
if (backfill.length > 0) {
|
|
1082
|
+
getLogger().info(`[dsh] backfilled ${backfill.length} events before follow snapshot sessionId=${sessionId} gapEnd=${gapEnd} lastSeq=${lastSeq}`);
|
|
1083
|
+
}
|
|
1084
|
+
return [...backfill, ...records];
|
|
1085
|
+
}
|
|
966
1086
|
/**
|
|
967
1087
|
* Host-wide live projection stream (`session/control`).
|
|
968
1088
|
*
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
* curl transport the session reader already uses: bounded by `timeoutMs` and
|
|
13
13
|
* never throws.
|
|
14
14
|
*/
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import path from 'node:path';
|
|
15
18
|
import { getLogger } from '../../logger/index.js';
|
|
16
19
|
import { isWin32 } from '../../platform/select.js';
|
|
17
20
|
import { spawnProcessSync } from '../../platform/spawn.js';
|
|
@@ -44,29 +47,65 @@ export function cancelSessionSync(opts) {
|
|
|
44
47
|
method: 'session/cancel',
|
|
45
48
|
payload: { args: { request: { sessionId: opts.sessionId } } },
|
|
46
49
|
});
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
'
|
|
58
|
-
'
|
|
59
|
-
`Cookie: ${cookie}`,
|
|
60
|
-
'
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
50
|
+
// Cookie 不走 argv(`-H "Cookie: …"` 会在 curl 运行期间对本机任何进程的
|
|
51
|
+
// ps 可见——1 小时有效的 v1 凭据泄露面):写 0600 临时文件用 `-H @file`
|
|
52
|
+
// 传入,用完即删。临时目录必须用 mkdtemp(0700、随机名):Linux 的 /tmp
|
|
53
|
+
// 所有用户可写,固定名(如 `<pid>.hdr`)可被本地用户预建软链,而
|
|
54
|
+
// writeFileSync 会跟随软链把 cookie 写进攻击者可读的文件;后续 chmod
|
|
55
|
+
// 也救不回已被打开的 fd。文件写失败时回退 argv——退出路径的 cancel
|
|
56
|
+
// 不能因此放弃。
|
|
57
|
+
let headerDir;
|
|
58
|
+
let cookieHeaderArgs;
|
|
59
|
+
try {
|
|
60
|
+
headerDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lark-remote-exit-cancel-'));
|
|
61
|
+
const headerFile = path.join(headerDir, 'cookie.hdr');
|
|
62
|
+
fs.writeFileSync(headerFile, `Cookie: ${cookie}\n`, { mode: 0o600, flag: 'wx' });
|
|
63
|
+
cookieHeaderArgs = ['-H', `@${headerFile}`];
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
logger.warn(`[dsh] exit-cancel header file unavailable: ${err.message}`);
|
|
67
|
+
removeDir(headerDir);
|
|
68
|
+
headerDir = undefined;
|
|
69
|
+
cookieHeaderArgs = ['-H', `Cookie: ${cookie}`];
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const res = spawnProcessSync('curl', [
|
|
73
|
+
'-s',
|
|
74
|
+
'-o',
|
|
75
|
+
isWin32() ? 'NUL' : '/dev/null',
|
|
76
|
+
'-w',
|
|
77
|
+
'%{http_code}',
|
|
78
|
+
'-X',
|
|
79
|
+
'POST',
|
|
80
|
+
`${opts.baseUrl.replace(/\/$/, '')}/api/session/cancel`,
|
|
81
|
+
'-H',
|
|
82
|
+
'content-type: application/json',
|
|
83
|
+
...cookieHeaderArgs,
|
|
84
|
+
'--max-time',
|
|
85
|
+
String(Math.max(1, Math.ceil(timeoutMs / 1000))),
|
|
86
|
+
'--data',
|
|
87
|
+
body,
|
|
88
|
+
], { encoding: 'utf-8', timeout: timeoutMs, maxBuffer: 1024 * 1024 });
|
|
89
|
+
const status = String(res.stdout ?? '').trim();
|
|
90
|
+
const ok = status === '200';
|
|
91
|
+
if (!ok) {
|
|
92
|
+
logger.warn(`[dsh] exit-cancel failed for ${opts.sessionId}: ${res.error?.message ?? `http ${status || 'n/a'}`}`);
|
|
93
|
+
}
|
|
94
|
+
return ok;
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
removeDir(headerDir);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** 删除 cookie 临时目录(best-effort);目录未创建时是空操作。 */
|
|
101
|
+
function removeDir(dir) {
|
|
102
|
+
if (!dir)
|
|
103
|
+
return;
|
|
104
|
+
try {
|
|
105
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
/* best-effort cleanup */
|
|
69
109
|
}
|
|
70
|
-
return ok;
|
|
71
110
|
}
|
|
72
111
|
//# sourceMappingURL=exit-cancel.js.map
|
|
@@ -230,6 +230,13 @@ export declare class DshRunner implements AgentRunner {
|
|
|
230
230
|
* that is the only client-side protection against a late orphan turn.
|
|
231
231
|
*/
|
|
232
232
|
private findPendingPromptWithin;
|
|
233
|
+
/**
|
|
234
|
+
* cancel 确认 closed 后的收尾:cancel 是 keepInbox,prompt 可能仍在 inbox。
|
|
235
|
+
* 在则撤回(防被下一个 turn 隐形认领),确认不在(已被本 turn 消费)或
|
|
236
|
+
* 撤回成功才清 inflight 标记;探测/撤回失败保留标记,交给下次启动的
|
|
237
|
+
* reconcile(findPendingPrompt 落空即自然清除,无副作用)。
|
|
238
|
+
*/
|
|
239
|
+
private withdrawPendingPromptAfterCancel;
|
|
233
240
|
private removePendingPrompt;
|
|
234
241
|
/** One cancel attempt, then one retry (the request is idempotent when idle). */
|
|
235
242
|
private cancelWithRetry;
|
|
@@ -1216,8 +1216,14 @@ export class DshRunner {
|
|
|
1216
1216
|
const state = await this.waitForClosed(sessionId, deadline);
|
|
1217
1217
|
if (state === 'closed') {
|
|
1218
1218
|
releaseGate();
|
|
1219
|
-
|
|
1220
|
-
|
|
1219
|
+
// cancel 是 keepInbox:prompt 可能仍在 inbox 里,等下一个无人订阅的
|
|
1220
|
+
// turn 认领(探测超时/迟到 admit 退到 cancel 分支时,
|
|
1221
|
+
// 旧实现直接清 inflight 标记——被停的 prompt 会被后续 turn 隐形执行
|
|
1222
|
+
//(耗 token、改工作区,卡片不可见),启动对账安全网也被解除)。
|
|
1223
|
+
// closed 后补一次撤回:在 inbox 就撤走,确认不在才清标记。
|
|
1224
|
+
if (promptRequestId) {
|
|
1225
|
+
await this.withdrawPendingPromptAfterCancel(sessionId, promptRequestId, deadline);
|
|
1226
|
+
}
|
|
1221
1227
|
getLogger().info(`[dsh] cancelled session ${sessionId}`);
|
|
1222
1228
|
return { status: 'cancelled', sessionId };
|
|
1223
1229
|
}
|
|
@@ -1233,8 +1239,9 @@ export class DshRunner {
|
|
|
1233
1239
|
const retryState = await this.waitForClosed(sessionId, deadline);
|
|
1234
1240
|
releaseGate();
|
|
1235
1241
|
if (retryState === 'closed') {
|
|
1236
|
-
if (promptRequestId)
|
|
1237
|
-
this.
|
|
1242
|
+
if (promptRequestId) {
|
|
1243
|
+
await this.withdrawPendingPromptAfterCancel(sessionId, promptRequestId, deadline);
|
|
1244
|
+
}
|
|
1238
1245
|
return { status: 'cancelled', sessionId };
|
|
1239
1246
|
}
|
|
1240
1247
|
getLogger().warn(`[dsh] stop unconfirmed session=${sessionId}: turn still open`);
|
|
@@ -1268,12 +1275,42 @@ export class DshRunner {
|
|
|
1268
1275
|
return undefined;
|
|
1269
1276
|
}
|
|
1270
1277
|
catch (err) {
|
|
1278
|
+
// 单次探测错误只消耗一次尝试(不提前放弃):探测失败就 return 会把
|
|
1279
|
+
// 仍在 inbox 的 prompt 交给 cancel 分支处理——cancel 是 keepInbox,
|
|
1280
|
+
// prompt 会留到下一个无人订阅的 turn 被隐形执行。
|
|
1271
1281
|
getLogger().warn(`[dsh] stop inbox probe failed session=${sessionId}: ${err.message}`);
|
|
1272
|
-
|
|
1282
|
+
continue;
|
|
1273
1283
|
}
|
|
1274
1284
|
}
|
|
1275
1285
|
return undefined;
|
|
1276
1286
|
}
|
|
1287
|
+
/**
|
|
1288
|
+
* cancel 确认 closed 后的收尾:cancel 是 keepInbox,prompt 可能仍在 inbox。
|
|
1289
|
+
* 在则撤回(防被下一个 turn 隐形认领),确认不在(已被本 turn 消费)或
|
|
1290
|
+
* 撤回成功才清 inflight 标记;探测/撤回失败保留标记,交给下次启动的
|
|
1291
|
+
* reconcile(findPendingPrompt 落空即自然清除,无副作用)。
|
|
1292
|
+
*/
|
|
1293
|
+
async withdrawPendingPromptAfterCancel(sessionId, promptRequestId, deadline) {
|
|
1294
|
+
try {
|
|
1295
|
+
const itemId = await this.client.findPendingPrompt(sessionId, promptRequestId, {
|
|
1296
|
+
timeoutMs: this.stopProbeTimeout(deadline),
|
|
1297
|
+
});
|
|
1298
|
+
if (itemId) {
|
|
1299
|
+
const removed = await this.client.removeQueuedPrompt(sessionId, itemId, {
|
|
1300
|
+
timeoutMs: this.stopProbeTimeout(deadline),
|
|
1301
|
+
});
|
|
1302
|
+
if (removed) {
|
|
1303
|
+
getLogger().info(`[dsh] stop withdrew inbox prompt left behind by cancel session=${sessionId}`);
|
|
1304
|
+
this.inflight?.clear(sessionId, promptRequestId);
|
|
1305
|
+
}
|
|
1306
|
+
return;
|
|
1307
|
+
}
|
|
1308
|
+
this.inflight?.clear(sessionId, promptRequestId);
|
|
1309
|
+
}
|
|
1310
|
+
catch (err) {
|
|
1311
|
+
getLogger().warn(`[dsh] post-cancel withdraw probe failed session=${sessionId}: ${err.message}`);
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1277
1314
|
async removePendingPrompt(sessionId, itemId, deadline) {
|
|
1278
1315
|
try {
|
|
1279
1316
|
return await this.client.removeQueuedPrompt(sessionId, itemId, {
|
|
@@ -144,6 +144,11 @@ export class KimiAcpRunner extends BaseAcpRunner {
|
|
|
144
144
|
*/
|
|
145
145
|
compactBaselineRecords = [];
|
|
146
146
|
/** 存活 terminal(kimi 把 Bash 执行下放客户端):terminalId → 本地句柄。 */
|
|
147
|
+
// 已知限制(待验证 kimi 0.4x 契约):句柄只在
|
|
148
|
+
// terminal/release 与连接关闭(cleanupTerminals)时删除。若服务端只 kill
|
|
149
|
+
// 不 release(中断 turn 的场景),已退出进程的句柄(含 ≤4MB output 缓冲)
|
|
150
|
+
// 会滞留到连接释放。kimi 契约上工具结束会发 release(mock 测试同驱动);
|
|
151
|
+
// 若真机确认存在只 kill 不 release 的路径,再补 exit 后延迟回收。
|
|
147
152
|
terminals = new Map();
|
|
148
153
|
/** terminalId 自增序号(term-1, term-2, …)。 */
|
|
149
154
|
terminalSeq = 0;
|
|
@@ -422,6 +427,11 @@ export class KimiAcpRunner extends BaseAcpRunner {
|
|
|
422
427
|
while (true) {
|
|
423
428
|
if (this.forceFinish || this.stopRequested)
|
|
424
429
|
return 'stopped';
|
|
430
|
+
// turn 已死亡(failTurn,如连接关闭)时不再轮询:forceFinish 与
|
|
431
|
+
// stopRequested 均为 false 的死亡路径会让循环空转到 10 分钟沉默超时,
|
|
432
|
+
// 结果还被 isCurrent() 丢弃——纯浪费的每秒文件读。
|
|
433
|
+
if (!this._isRunning)
|
|
434
|
+
return 'unknown';
|
|
425
435
|
const records = reader.call(this.sessionReader, sessionId, opts.cwd);
|
|
426
436
|
if (records.length !== lastTotalCount) {
|
|
427
437
|
lastTotalCount = records.length;
|
|
@@ -506,8 +516,15 @@ export class KimiAcpRunner extends BaseAcpRunner {
|
|
|
506
516
|
// (error = "rejected"), otherwise the server hangs waiting for a
|
|
507
517
|
// response. For question elicitation (§5.4), auto-respond cancelled.
|
|
508
518
|
const rpParams = params;
|
|
509
|
-
|
|
510
|
-
|
|
519
|
+
// elicitation/create 的表单属性全不可解析时(translator 返回 [])同样回
|
|
520
|
+
// cancelled 而不是 METHOD_NOT_FOUND:§5.4 的不变量是「识别到非工具审批
|
|
521
|
+
// 形态 → 自动回应 cancelled,防止服务端挂起」;错误响应虽也能解悬挂,
|
|
522
|
+
// 但 kimi 侧可能把 JSON-RPC error 当工具级失败让整个 turn 报错,而不是
|
|
523
|
+
// 跳过提问继续。
|
|
524
|
+
const isQuestionElicitation = method === ServerRequestMethod.REQUEST_PERMISSION &&
|
|
525
|
+
(rpParams.isQuestion || !rpParams.toolCall);
|
|
526
|
+
const isUnparseableElicitationForm = method === ServerRequestMethod.ELICITATION_CREATE;
|
|
527
|
+
if (isQuestionElicitation || isUnparseableElicitationForm) {
|
|
511
528
|
this.currentClient?.respond(id, { outcome: { outcome: 'cancelled' } });
|
|
512
529
|
getLogger().info(`[${this.logTag}] auto-responded cancelled to question elicitation requestId=${id}`);
|
|
513
530
|
}
|
|
@@ -633,8 +650,27 @@ export class KimiAcpRunner extends BaseAcpRunner {
|
|
|
633
650
|
proc.once('exit', (code, signal) => {
|
|
634
651
|
handle.exitCode = code;
|
|
635
652
|
handle.signal = signal;
|
|
636
|
-
|
|
637
|
-
|
|
653
|
+
// 'exit' 可先于 stdout 缓冲派发:立刻 emit 会按旧快照推送 tool_result,
|
|
654
|
+
// 卡片面板丢尾部输出(terminal/output 反向 RPC 读到的是补齐后的全量,
|
|
655
|
+
// 只影响卡片)。等流 end 或 200ms 宽限——不等 'close':它要等所有共享
|
|
656
|
+
// 管道的子孙进程退出,`nohup &` 类后台任务会把它拖到永远。
|
|
657
|
+
//
|
|
658
|
+
// wait_for_exit 必须在 tool_result 入队之后才 resolve:服务端拿到响应
|
|
659
|
+
// 后会立刻 release + end_turn,先 resolve 会让回合结果早于延迟推送的
|
|
660
|
+
// tool_result 到达,Bash 输出被丢弃(卡片面板空输出)。
|
|
661
|
+
const flushThenEmit = () => {
|
|
662
|
+
clearTimeout(emitTimer);
|
|
663
|
+
emitResult();
|
|
664
|
+
resolveExit?.();
|
|
665
|
+
};
|
|
666
|
+
const emitTimer = setTimeout(flushThenEmit, 200);
|
|
667
|
+
emitTimer.unref?.();
|
|
668
|
+
if (proc.stdout && !proc.stdout.destroyed) {
|
|
669
|
+
proc.stdout.once('end', flushThenEmit);
|
|
670
|
+
}
|
|
671
|
+
else {
|
|
672
|
+
flushThenEmit();
|
|
673
|
+
}
|
|
638
674
|
});
|
|
639
675
|
proc.once('error', (err) => {
|
|
640
676
|
getLogger().warn(`[${this.logTag}] terminal process error: ${err.message}`);
|
|
@@ -642,8 +678,9 @@ export class KimiAcpRunner extends BaseAcpRunner {
|
|
|
642
678
|
handle.exitCode = 1;
|
|
643
679
|
handle.signal = null;
|
|
644
680
|
}
|
|
645
|
-
|
|
681
|
+
// 同 exit:tool_result 先入队,再放行 wait_for_exit。
|
|
646
682
|
emitResult();
|
|
683
|
+
resolveExit?.();
|
|
647
684
|
});
|
|
648
685
|
const terminalId = `term-${++this.terminalSeq}`;
|
|
649
686
|
this.terminals.set(terminalId, handle);
|
|
@@ -29,6 +29,14 @@ import { type RequestPermissionParams, type AgentMessageChunkEvent, type AgentTh
|
|
|
29
29
|
import { BaseAcpTranslator, type AcpTranslatorEvent } from '../../common/acp/base-translator.js';
|
|
30
30
|
export declare class KimiAcpTranslator extends BaseAcpTranslator {
|
|
31
31
|
protected readonly logTag = "kimi-acp-translator";
|
|
32
|
+
/**
|
|
33
|
+
* 非终态 tool_call_update 不附带空 tool_result:kimi 非 Bash 工具的
|
|
34
|
+
* in-progress 只带流式 args(rawOutput 缺省 → content ''),空 tool_result
|
|
35
|
+
* 会让 reducer 把运行中的工具提前置 ✅ + 空输出(终态到达后自愈,但终态
|
|
36
|
+
* 丢失时面板停在错误的「已完成」)。opencode 走基类默认 true(其
|
|
37
|
+
* in-progress 带输出快照,seam 契约钉死要发)。
|
|
38
|
+
*/
|
|
39
|
+
protected emitToolResultForNonTerminalUpdate(content: unknown): boolean;
|
|
32
40
|
/** Accumulated text per tool-call or message (keyed by a logical item id). */
|
|
33
41
|
private textByItem;
|
|
34
42
|
/** Accumulated thinking per item. */
|
|
@@ -42,6 +42,16 @@ const THINKING_ITEM_ID = 'thinking';
|
|
|
42
42
|
// =============================================================================
|
|
43
43
|
export class KimiAcpTranslator extends BaseAcpTranslator {
|
|
44
44
|
logTag = 'kimi-acp-translator';
|
|
45
|
+
/**
|
|
46
|
+
* 非终态 tool_call_update 不附带空 tool_result:kimi 非 Bash 工具的
|
|
47
|
+
* in-progress 只带流式 args(rawOutput 缺省 → content ''),空 tool_result
|
|
48
|
+
* 会让 reducer 把运行中的工具提前置 ✅ + 空输出(终态到达后自愈,但终态
|
|
49
|
+
* 丢失时面板停在错误的「已完成」)。opencode 走基类默认 true(其
|
|
50
|
+
* in-progress 带输出快照,seam 契约钉死要发)。
|
|
51
|
+
*/
|
|
52
|
+
emitToolResultForNonTerminalUpdate(content) {
|
|
53
|
+
return !(content === undefined || content === null || content === '');
|
|
54
|
+
}
|
|
45
55
|
/** Accumulated text per tool-call or message (keyed by a logical item id). */
|
|
46
56
|
textByItem = new Map();
|
|
47
57
|
/** Accumulated thinking per item. */
|