lark-remote 0.5.10 → 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 +38 -0
- package/dist/binder.js +7 -1
- package/dist/bridge/index.js +12 -3
- 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 +52 -10
- 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.js +11 -1
- 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
|
@@ -269,6 +269,10 @@ export class BaseAcpTranslator {
|
|
|
269
269
|
}
|
|
270
270
|
/**
|
|
271
271
|
* tool_call_update → user/tool_result. status:'failed' → is_error:true.
|
|
272
|
+
*
|
|
273
|
+
* 终态(completed/failed)产出 tool_result;非终态的 tool_result 由
|
|
274
|
+
* {@link emitToolResultForNonTerminalUpdate} 裁定(reducer 见到 tool_result
|
|
275
|
+
* 即置 status:'ok'——无内容的非终态 tool_result 会把运行中的工具提前翻成 ✅)。
|
|
272
276
|
*/
|
|
273
277
|
handleToolCallUpdate(event, _sessionId) {
|
|
274
278
|
const isError = event.status === 'failed';
|
|
@@ -293,6 +297,13 @@ export class BaseAcpTranslator {
|
|
|
293
297
|
else if (event.rawInput !== undefined && event.rawInput !== null) {
|
|
294
298
|
this.trackToolCallArgs(event.toolCallId, stringifyUnknown(event.rawInput));
|
|
295
299
|
}
|
|
300
|
+
// 非终态是否发 tool_result 由子类裁定(emitToolResultForNonTerminalUpdate):
|
|
301
|
+
// opencode 的 in-progress 带输出快照,seam 契约钉死「patch + tool_result」
|
|
302
|
+
// 都要发(面板在工具运行时显示部分输出);kimi 的 in-progress 只带流式
|
|
303
|
+
// args,空 tool_result 会让 reducer 把运行中的工具提前置 ✅ + 空输出。
|
|
304
|
+
if (!this.emitToolResultForNonTerminalUpdate(content)) {
|
|
305
|
+
return useUpdate ? [useUpdate] : [];
|
|
306
|
+
}
|
|
296
307
|
}
|
|
297
308
|
const events = [];
|
|
298
309
|
if (useUpdate)
|
|
@@ -390,6 +401,17 @@ export class BaseAcpTranslator {
|
|
|
390
401
|
extractToolResultContent(event) {
|
|
391
402
|
return event.rawOutput ?? '';
|
|
392
403
|
}
|
|
404
|
+
/**
|
|
405
|
+
* 非终态 tool_call_update 是否附带 tool_result。默认 true(opencode seam
|
|
406
|
+
* 契约:in-progress 的输出快照随 tool_result 进面板)。kimi 覆写为
|
|
407
|
+
* 「有内容才发」——它的 in-progress 只带流式 args,空 tool_result 会让
|
|
408
|
+
* reducer 把运行中的工具提前置 ✅ + 空输出(终态丢失时面板停在错误的
|
|
409
|
+
* 「已完成」)。
|
|
410
|
+
*/
|
|
411
|
+
emitToolResultForNonTerminalUpdate(content) {
|
|
412
|
+
void content;
|
|
413
|
+
return true;
|
|
414
|
+
}
|
|
393
415
|
/**
|
|
394
416
|
* usage_update → context occupancy bookkeeping. The wire shape is
|
|
395
417
|
* {used, size} (+ opencode: cost:{amount}) — context occupancy, not
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getLogger } from '../../logger/index.js';
|
|
2
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
2
3
|
/**
|
|
3
4
|
* Parse JSONL (newline-delimited JSON) from a readable stream.
|
|
4
5
|
*
|
|
@@ -30,6 +31,8 @@ export function createJSONLStream(stdout, options) {
|
|
|
30
31
|
// and push complete lines; the residual tail stays as array fragments until
|
|
31
32
|
// the next newline or stream end. Net allocation: O(n) instead of O(n²).
|
|
32
33
|
const partialFragments = [];
|
|
34
|
+
// 跨 chunk 多字节字符的边界安全解码器(见 stdout 'data' 处理)。
|
|
35
|
+
const decoder = new StringDecoder('utf8');
|
|
33
36
|
// accumulated byte length of partialFragments. Backpressure (above)
|
|
34
37
|
// only counts COMPLETE lines in lineQueue; a never-newline stream (malformed
|
|
35
38
|
// output, binary garbage) accumulates fragments without ever completing a
|
|
@@ -60,6 +63,11 @@ export function createJSONLStream(stdout, options) {
|
|
|
60
63
|
// Flush any trailing partial line and mark the stream finished. Shared by the
|
|
61
64
|
// 'end'/'close'/'error' handlers so flush logic lives in one place (nit).
|
|
62
65
|
function flushAndFinish() {
|
|
66
|
+
// decoder.end() 吐出被 chunk 边界切开的最后一个多字节字符的残余字节
|
|
67
|
+
//(无残尾时返回空串):不加这句,末尾被切开的字符会凭空丢失。
|
|
68
|
+
const tail = decoder.end();
|
|
69
|
+
if (tail)
|
|
70
|
+
partialFragments.push(tail);
|
|
63
71
|
if (partialFragments.length > 0) {
|
|
64
72
|
const trimmed = partialFragments.join('').trim();
|
|
65
73
|
if (trimmed)
|
|
@@ -109,7 +117,10 @@ export function createJSONLStream(stdout, options) {
|
|
|
109
117
|
}
|
|
110
118
|
}
|
|
111
119
|
stdout.on('data', (chunk) => {
|
|
112
|
-
|
|
120
|
+
// StringDecoder 按字节边界解码:多字节字符(中文/emoji)被 chunk 边界
|
|
121
|
+
// 切开时,裸 toString 会产出不可逆的 U+FFFD(同族修复见
|
|
122
|
+
// jsonrpc/transport.ts,claude 的中文输出走本流)。
|
|
123
|
+
const text = decoder.write(chunk);
|
|
113
124
|
// split on newlines. The first segment continues the pending partial
|
|
114
125
|
// line (joined with accumulated fragments — one join per chunk, not per
|
|
115
126
|
// byte); subsequent segments are complete lines. The last segment is the
|
|
@@ -84,7 +84,17 @@ export class ConnectionManager {
|
|
|
84
84
|
if (existing) {
|
|
85
85
|
// If a create is in flight, wait for it
|
|
86
86
|
if (existing.createPromise) {
|
|
87
|
-
|
|
87
|
+
const client = await existing.createPromise;
|
|
88
|
+
// 快速路径复核(与下方建槽方的竞态复核对称):await 期间槽位被
|
|
89
|
+
// release()/disposeAll() 删掉时,建槽方会 dispose 该连接并重试——
|
|
90
|
+
// 直接返回会把「已被建槽方 dispose 的连接」交给本调用方(表现为
|
|
91
|
+
// setupTurn 抛 ConnectionLostError 走 respawn-retry 自愈)。槽位
|
|
92
|
+
// 不再持有该 client 时与本轮创建同路:重试。
|
|
93
|
+
const current = this.slots.get(cwd);
|
|
94
|
+
if (current && current.client === client) {
|
|
95
|
+
return client;
|
|
96
|
+
}
|
|
97
|
+
continue;
|
|
88
98
|
}
|
|
89
99
|
// Reuse when the (session-aware) reuse predicate holds — e.g. the client
|
|
90
100
|
// is ready and its transport is alive (default), or it is bound to the
|
|
@@ -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, {
|