lark-remote 0.5.5 → 0.5.7
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 +48 -7
- package/README.en.md +2 -2
- package/README.md +2 -2
- package/dist/bridge/approval-coordinator.d.ts +13 -0
- package/dist/bridge/approval-coordinator.js +34 -1
- package/dist/bridge/index.d.ts +48 -1
- package/dist/bridge/index.js +86 -14
- package/dist/card/approval-render.js +23 -1
- package/dist/card/card-budget.d.ts +37 -0
- package/dist/card/card-budget.js +128 -5
- package/dist/card/run-renderer.d.ts +24 -0
- package/dist/card/run-renderer.js +256 -60
- package/dist/card/run-state.d.ts +26 -0
- package/dist/card/run-state.js +45 -0
- package/dist/card/time.d.ts +14 -0
- package/dist/card/time.js +30 -0
- package/dist/card/tool-render.d.ts +15 -2
- package/dist/card/tool-render.js +40 -66
- package/dist/common/text-width.d.ts +24 -0
- package/dist/common/text-width.js +93 -0
- package/dist/common/tool-summary.d.ts +11 -0
- package/dist/common/tool-summary.js +153 -0
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.js +1 -1
- package/dist/connector/index.js +1 -1
- package/dist/index.js +63 -8
- package/dist/platform/command.js +1 -1
- package/dist/platform/shell.d.ts +2 -2
- package/dist/platform/spawn.d.ts +1 -1
- package/dist/platform/spawn.js +2 -2
- package/dist/platform/types.d.ts +1 -1
- package/dist/router/card-helpers.d.ts +18 -16
- package/dist/router/card-helpers.js +65 -4
- package/dist/router/index.d.ts +9 -0
- package/dist/router/index.js +57 -7
- package/dist/runner/claude/session.d.ts +1 -1
- package/dist/runner/claude/session.js +3 -3
- package/dist/runner/common/jsonrpc/transport.d.ts +1 -1
- package/dist/runner/common/jsonrpc/transport.js +3 -3
- package/dist/runner/common/spawning-runner.d.ts +1 -1
- package/dist/runner/common/spawning-runner.js +2 -2
- package/dist/runner/dsh/auth.js +2 -1
- package/dist/runner/dsh/client.d.ts +124 -6
- package/dist/runner/dsh/client.js +398 -12
- package/dist/runner/dsh/exit-cancel.d.ts +32 -0
- package/dist/runner/dsh/exit-cancel.js +72 -0
- package/dist/runner/dsh/inflight.d.ts +62 -0
- package/dist/runner/dsh/inflight.js +140 -0
- package/dist/runner/dsh/runner.d.ts +117 -7
- package/dist/runner/dsh/runner.js +607 -21
- package/dist/runner/dsh/translator.d.ts +1 -1
- package/dist/runner/dsh/translator.js +103 -1
- package/dist/runner/dsh/types.d.ts +91 -1
- package/dist/runner/types.d.ts +86 -2
- package/dist/session/claude/sessions.js +2 -2
- package/dist/session/dsh/sessions.js +100 -15
- package/dist/update/install.js +1 -1
- package/package.json +1 -1
|
@@ -6,17 +6,25 @@
|
|
|
6
6
|
* alignment → synthetic system/init (DSH has no init event) → session/prompt
|
|
7
7
|
* (mode:'queue') → consume `session/follow` + `$events` until turn/end.
|
|
8
8
|
*
|
|
9
|
-
* stop() → session/cancel + abort the mux subscription. killOrphan
|
|
10
|
-
*
|
|
9
|
+
* stop() → session/cancel + abort the mux subscription. killOrphan is a no-op
|
|
10
|
+
* (no local process); registerExitHandlers cancels the in-flight turn
|
|
11
|
+
* synchronously on process exit, because the turn lives in the shared host and
|
|
12
|
+
* would otherwise keep running with no subscriber. DSH approvals are
|
|
11
13
|
* two-channel: durable `approval/asked` / `approval/decided` drive the Feishu
|
|
12
14
|
* card, while the answerable `$events` waterfall supplies eventId/clientId.
|
|
13
|
-
*
|
|
15
|
+
* `ask_user_question` prompts arrive only as a `user-questions/request`
|
|
16
|
+
* waterfall (no durable event); they are rendered as a `kind:'question'`
|
|
17
|
+
* approval on the same card. If `$events` is unavailable, the runner falls
|
|
18
|
+
* back to the Web UI prompt.
|
|
14
19
|
*/
|
|
15
20
|
import { DshClient, DshError, newPromptRequestId } from './client.js';
|
|
16
21
|
import { DshTranslator } from './translator.js';
|
|
17
22
|
import { DshApprovalTracker } from './approval.js';
|
|
18
23
|
import { mergeAsync } from './merge.js';
|
|
19
24
|
import { getLogger } from '../../logger/index.js';
|
|
25
|
+
import { registerExitCleanup, unregisterExitCleanup } from '../common/spawning-runner.js';
|
|
26
|
+
import { cancelSessionSync } from './exit-cancel.js';
|
|
27
|
+
import { DshInflightStore } from './inflight.js';
|
|
20
28
|
/**
|
|
21
29
|
* Legacy provider for configs written before `agents.dsh.provider` existed.
|
|
22
30
|
* New configs pin the provider explicitly; the card reads the live catalog.
|
|
@@ -24,8 +32,132 @@ import { getLogger } from '../../logger/index.js';
|
|
|
24
32
|
const DSH_MODEL_PROVIDER = 'deepseek-official';
|
|
25
33
|
/** DSH 内建手动压缩命令(dsh-command-compact,无参数、仅空闲可执行)。 */
|
|
26
34
|
const DSH_COMPACT_COMMAND = '/compact';
|
|
27
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* 飞书提问卡的有效窗口。
|
|
37
|
+
*
|
|
38
|
+
* DSH 的 legacy `ask_user_question` 会无限等待(`timed:false`),Web UI 那侧
|
|
39
|
+
* 也一直在等。飞书卡超时后只把这一路的应答权交回(outcome `next`),不取消
|
|
40
|
+
* turn,所以窗口比审批默认的 5 分钟给得宽:用户通常要先读完上一条长回答。
|
|
41
|
+
* 超时后问题仍可在 DSH Web UI 回答;之后由 idle 看门狗兜底(默认 15 分钟
|
|
42
|
+
* 静默,见 `config.idle.watchdogMinutes`,等待提问期间暂停计时)。
|
|
43
|
+
*/
|
|
44
|
+
const DSH_QUESTION_TIMEOUT_MS = 30 * 60 * 1000;
|
|
45
|
+
/**
|
|
46
|
+
* 卡片上实际生效的多选语义:无选项的 multiSelect 没有可勾内容,共享渲染层把它
|
|
47
|
+
* 当自由文本题,coordinator 的 answerCustom 又拒绝 multiSelect——所以按单选处理,
|
|
48
|
+
* 视图与答案编码必须用同一个判定,否则会编码出错答案。
|
|
49
|
+
*/
|
|
50
|
+
function isCardMultiSelect(question) {
|
|
51
|
+
return question.multiSelect === true && (question.options?.length ?? 0) > 0;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* 把 coordinator 回传的「问题文本 → 答案」映射成 DSH 的 `{id, selected,
|
|
55
|
+
* custom}` 批次。
|
|
56
|
+
*
|
|
57
|
+
* 契约见 `UserQuestion`:答案按问题文本回传;本函数按 id 回编。单选且文本
|
|
58
|
+
* 不等于任何选项 label 时,按 DSH Web UI 的语义记为自由答案(`selected: []`
|
|
59
|
+
* + `custom`)。
|
|
60
|
+
*/
|
|
61
|
+
function buildQuestionAnswers(questions, response) {
|
|
62
|
+
const answers = response
|
|
63
|
+
?.answers;
|
|
64
|
+
if (!answers)
|
|
65
|
+
throw new Error('DSH 提问回答缺少 answers 载荷');
|
|
66
|
+
return questions.map((question) => {
|
|
67
|
+
const value = answers[question.question];
|
|
68
|
+
if (value === undefined)
|
|
69
|
+
throw new Error(`DSH 提问缺少答案: ${question.question}`);
|
|
70
|
+
const values = (Array.isArray(value) ? value : [value]).filter((item) => typeof item === 'string' && item.length > 0);
|
|
71
|
+
if (values.length === 0)
|
|
72
|
+
throw new Error(`DSH 提问答案为空: ${question.question}`);
|
|
73
|
+
if (isCardMultiSelect(question))
|
|
74
|
+
return { id: question.id, selected: values };
|
|
75
|
+
const selected = values[0];
|
|
76
|
+
const isOption = (question.options ?? []).some((option) => option.label === selected);
|
|
77
|
+
return isOption
|
|
78
|
+
? { id: question.id, selected: [selected] }
|
|
79
|
+
: { id: question.id, selected: [], custom: selected };
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/** Budget for one session/cancel attempt on stop. */
|
|
28
83
|
const CANCEL_TIMEOUT_MS = 5_000;
|
|
84
|
+
/** Total stop budget: withdraw + cancel + verify must not hold the lane forever. */
|
|
85
|
+
const STOP_BUDGET_MS = 5_000;
|
|
86
|
+
/** A prompt request that was still in flight can land after stop's abort. */
|
|
87
|
+
const STOP_ADMIT_ATTEMPTS = 5;
|
|
88
|
+
const STOP_ADMIT_DELAY_MS = 250;
|
|
89
|
+
/** Cancel is applied synchronously; give the turn a moment to unwind. */
|
|
90
|
+
const STOP_VERIFY_ATTEMPTS = 4;
|
|
91
|
+
const STOP_VERIFY_DELAY_MS = 350;
|
|
92
|
+
/** Per-read budget for inbox/status probes on the stop path. */
|
|
93
|
+
const STOP_PROBE_TIMEOUT_MS = 2_000;
|
|
94
|
+
function delay(ms) {
|
|
95
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* 把一帧 `session/control` 投影写进状态条累计值。
|
|
99
|
+
* @returns 本仓库消费的字段是否真的变了(未变不必刷新卡片)。
|
|
100
|
+
*/
|
|
101
|
+
function applyProjection(live, key, value) {
|
|
102
|
+
if (!value || typeof value !== 'object')
|
|
103
|
+
return false;
|
|
104
|
+
if (key === 'contextPressure') {
|
|
105
|
+
const pressure = value;
|
|
106
|
+
// 官方 UI 的口径:优先 projectedTokens(下一次请求的预估),退回实际占用。
|
|
107
|
+
const used = pressure.projectedTokens ?? pressure.pressureTokens;
|
|
108
|
+
let changed = false;
|
|
109
|
+
if (typeof used === 'number' && used !== live.contextUsed) {
|
|
110
|
+
live.contextUsed = used;
|
|
111
|
+
changed = true;
|
|
112
|
+
}
|
|
113
|
+
if (typeof pressure.contextWindow === 'number' &&
|
|
114
|
+
pressure.contextWindow > 0 &&
|
|
115
|
+
pressure.contextWindow !== live.contextLimit) {
|
|
116
|
+
live.contextLimit = pressure.contextWindow;
|
|
117
|
+
changed = true;
|
|
118
|
+
}
|
|
119
|
+
return changed;
|
|
120
|
+
}
|
|
121
|
+
if (key === 'sessionStats') {
|
|
122
|
+
const stats = value;
|
|
123
|
+
let changed = false;
|
|
124
|
+
if (typeof stats.steps === 'number' && stats.steps !== live.steps) {
|
|
125
|
+
live.steps = stats.steps;
|
|
126
|
+
changed = true;
|
|
127
|
+
}
|
|
128
|
+
if (typeof stats.toolMs === 'number' && stats.toolMs !== live.toolMs) {
|
|
129
|
+
live.toolMs = stats.toolMs;
|
|
130
|
+
changed = true;
|
|
131
|
+
}
|
|
132
|
+
return changed;
|
|
133
|
+
}
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
/** Build the `run_status` event for the card's status strip. */
|
|
137
|
+
function statusEventOf(live) {
|
|
138
|
+
return {
|
|
139
|
+
type: 'run_status',
|
|
140
|
+
...(live.contextUsed === undefined ? {} : { contextUsed: live.contextUsed }),
|
|
141
|
+
...(live.contextLimit === undefined ? {} : { contextLimit: live.contextLimit }),
|
|
142
|
+
...(live.steps === undefined ? {} : { steps: live.steps }),
|
|
143
|
+
...(live.toolMs === undefined ? {} : { toolMs: live.toolMs }),
|
|
144
|
+
timestamp: new Date().toISOString(),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Attach the context window to the result usage.
|
|
149
|
+
*
|
|
150
|
+
* DSH 的会话读取端只产出占用、不产出窗口上限,终态卡因此只能显示绝对量;
|
|
151
|
+
* 控制流拿到的 `contextWindow` 是唯一的百分比来源。已有上限(未来 DSH 自带)
|
|
152
|
+
* 时不覆盖。
|
|
153
|
+
*/
|
|
154
|
+
function attachContextLimit(ev, limit) {
|
|
155
|
+
if (ev.type !== 'result' || limit === undefined || !ev.usage)
|
|
156
|
+
return ev;
|
|
157
|
+
if (ev.usage.context_limit !== undefined)
|
|
158
|
+
return ev;
|
|
159
|
+
return { ...ev, usage: { ...ev.usage, context_limit: limit } };
|
|
160
|
+
}
|
|
29
161
|
/** Parse tool-call arguments without letting malformed JSON crash the run. */
|
|
30
162
|
function parseToolArguments(raw) {
|
|
31
163
|
if (!raw)
|
|
@@ -62,6 +194,19 @@ export class DshRunner {
|
|
|
62
194
|
activeAbort = null;
|
|
63
195
|
activeSessionId = null;
|
|
64
196
|
running = false;
|
|
197
|
+
/** This run's prompt identity (`source.rpcId`); null outside a submitted prompt. */
|
|
198
|
+
activePromptRequestId = null;
|
|
199
|
+
/** Whether the current prompt request was accepted by the host. */
|
|
200
|
+
promptAdmitted = false;
|
|
201
|
+
/**
|
|
202
|
+
* Releases the next run's prompt only after this stop's withdraw/cancel has
|
|
203
|
+
* been applied. Aborting the local mux lets the queue advance immediately;
|
|
204
|
+
* without the gate the successor's prompt can reach the host before the
|
|
205
|
+
* cancel, and a session-scoped cancel would then hit the *new* turn.
|
|
206
|
+
*/
|
|
207
|
+
pendingStopGate = null;
|
|
208
|
+
pendingStopSessionId = null;
|
|
209
|
+
inflight;
|
|
65
210
|
daemon;
|
|
66
211
|
/**
|
|
67
212
|
* 已对齐模型的 session 集合:同一 session 二次 run 不再重发 selectModel
|
|
@@ -74,10 +219,19 @@ export class DshRunner {
|
|
|
74
219
|
permissionAlignedSessions = new Set();
|
|
75
220
|
/** `$events` approval waterfall state for the current run. */
|
|
76
221
|
approvalTracker = new DshApprovalTracker();
|
|
222
|
+
/**
|
|
223
|
+
* eventId → 待回答的 DSH `ask_user_question` waterfall。
|
|
224
|
+
*
|
|
225
|
+
* 提问没有 durable 审计事件,eventId 本身就是卡片 requestId;这里保存应答
|
|
226
|
+
* 所需的 clientId 与题面(DSH 回编按题目 id)。每轮 run 清空。
|
|
227
|
+
*/
|
|
228
|
+
pendingQuestions = new Map();
|
|
77
229
|
/** callId → tool call, used to render the command in the approval card. */
|
|
78
230
|
toolCalls = new Map();
|
|
79
231
|
/** False after `$events` is unavailable; approval/asked then falls back to text. */
|
|
80
232
|
approvalChannelAvailable = true;
|
|
233
|
+
/** 退出路径的取消实现(默认同步 curl,见 `cancelSessionSync`)。 */
|
|
234
|
+
cancelOnExit;
|
|
81
235
|
constructor(opts) {
|
|
82
236
|
this.sessionReader = opts.sessionReader;
|
|
83
237
|
this.daemon = opts.daemon;
|
|
@@ -86,6 +240,7 @@ export class DshRunner {
|
|
|
86
240
|
this.agentPreset = opts.agentPreset;
|
|
87
241
|
this.configuredPermissionPreset = opts.permissionPreset;
|
|
88
242
|
this.reasoningEffort = opts.reasoningEffort;
|
|
243
|
+
this.inflight = opts.inflightPath ? new DshInflightStore(opts.inflightPath) : null;
|
|
89
244
|
this.client = new DshClient(opts.host ?? 'http://127.0.0.1:3080', {
|
|
90
245
|
dshHome: opts.dshHome,
|
|
91
246
|
token: opts.token,
|
|
@@ -94,6 +249,16 @@ export class DshRunner {
|
|
|
94
249
|
webSocketImpl: opts.webSocketImpl,
|
|
95
250
|
requestTimeoutMs: opts.requestTimeoutMs,
|
|
96
251
|
});
|
|
252
|
+
this.cancelOnExit =
|
|
253
|
+
opts.cancelOnExit ??
|
|
254
|
+
(({ baseUrl, sessionId }) => {
|
|
255
|
+
cancelSessionSync({
|
|
256
|
+
baseUrl,
|
|
257
|
+
sessionId,
|
|
258
|
+
dshHome: opts.dshHome,
|
|
259
|
+
credentialsFile: opts.credentialsFile,
|
|
260
|
+
});
|
|
261
|
+
});
|
|
97
262
|
}
|
|
98
263
|
get isRunning() {
|
|
99
264
|
return this.running;
|
|
@@ -159,8 +324,13 @@ export class DshRunner {
|
|
|
159
324
|
const abort = new AbortController();
|
|
160
325
|
this.activeAbort = abort;
|
|
161
326
|
this.running = true;
|
|
327
|
+
// A stop releases the local lane before its cancel has been applied; the
|
|
328
|
+
// next prompt must not race that cancel (see `pendingStopGate`). State is
|
|
329
|
+
// already published above so an immediate stop can abort this run too.
|
|
330
|
+
await this.awaitStopGate(opts.sessionId);
|
|
162
331
|
let sessionId = opts.sessionId;
|
|
163
332
|
let terminalEmitted = false;
|
|
333
|
+
let promptRequestId = null;
|
|
164
334
|
try {
|
|
165
335
|
// DSH is a shared, machine-wide process. Reuse it when it is already
|
|
166
336
|
// listening; otherwise start it detached so lark-remote exiting does not
|
|
@@ -212,7 +382,7 @@ export class DshRunner {
|
|
|
212
382
|
};
|
|
213
383
|
return;
|
|
214
384
|
}
|
|
215
|
-
// provider
|
|
385
|
+
// provider/模型/推理强度对齐:会话首次 run 时 selectModel 一次
|
|
216
386
|
// (该调用同时写服务端全局默认,故不重复写);同一 session 后续 run
|
|
217
387
|
// 跳过。显式配置 provider 时失败必须 fail closed:静默回退到其他
|
|
218
388
|
// provider 会让用户以为切换成功,实际模型与账单都错。
|
|
@@ -271,12 +441,28 @@ export class DshRunner {
|
|
|
271
441
|
const prePromptSeq = await this.client.latestEventSeq(sessionId, {
|
|
272
442
|
signal: abort.signal,
|
|
273
443
|
});
|
|
444
|
+
// Keep the prompt identity: DSH echoes it into the durable user/message
|
|
445
|
+
// source.rpcId, which is how runTurn tells this run's turn from a turn
|
|
446
|
+
// it is merely queued behind.
|
|
447
|
+
promptRequestId = newPromptRequestId();
|
|
448
|
+
// Publish the identity before the request leaves: a stop that arrives
|
|
449
|
+
// while `session/prompt` is in flight must still know which inbox item
|
|
450
|
+
// (if any) to withdraw once the host admits it.
|
|
451
|
+
this.activePromptRequestId = promptRequestId;
|
|
452
|
+
this.promptAdmitted = false;
|
|
453
|
+
this.inflight?.record({
|
|
454
|
+
sessionId,
|
|
455
|
+
promptRequestId,
|
|
456
|
+
baseUrl: this.client.baseUrl,
|
|
457
|
+
cwd: opts.cwd,
|
|
458
|
+
});
|
|
274
459
|
await this.client.unary('session/prompt', {
|
|
275
|
-
requestId:
|
|
460
|
+
requestId: promptRequestId,
|
|
276
461
|
sessionId,
|
|
277
462
|
mode: 'queue',
|
|
278
463
|
content: [{ type: 'text', text: message }],
|
|
279
464
|
}, { signal: abort.signal });
|
|
465
|
+
this.promptAdmitted = true;
|
|
280
466
|
if (abort.signal.aborted) {
|
|
281
467
|
// stop() 在 prompt 已受理后到达:cancel 由 stop() 发出,这里直接收尾,
|
|
282
468
|
// 不再打开 mux(否则会看到 cancel 产生的 turn/end 事件)。
|
|
@@ -290,7 +476,7 @@ export class DshRunner {
|
|
|
290
476
|
return;
|
|
291
477
|
}
|
|
292
478
|
try {
|
|
293
|
-
for await (const ev of this.runTurn(sessionId, opts.cwd, prePromptSeq, abort)) {
|
|
479
|
+
for await (const ev of this.runTurn(sessionId, opts.cwd, prePromptSeq, promptRequestId, abort)) {
|
|
294
480
|
yield ev;
|
|
295
481
|
if (ev.type === 'result') {
|
|
296
482
|
terminalEmitted = true;
|
|
@@ -341,6 +527,14 @@ export class DshRunner {
|
|
|
341
527
|
this.running = false;
|
|
342
528
|
this.activeSessionId = null;
|
|
343
529
|
this.activeAbort = null;
|
|
530
|
+
this.activePromptRequestId = null;
|
|
531
|
+
this.promptAdmitted = false;
|
|
532
|
+
// On abort the stop path decides whether the marker is settled (verified
|
|
533
|
+
// stop) or must survive for startup reconciliation (cancel failed). A
|
|
534
|
+
// naturally finished run owns its terminal result, so drop it here.
|
|
535
|
+
if (!abort.signal.aborted && sessionId && promptRequestId) {
|
|
536
|
+
this.inflight?.clear(sessionId, promptRequestId);
|
|
537
|
+
}
|
|
344
538
|
}
|
|
345
539
|
if (!terminalEmitted) {
|
|
346
540
|
yield {
|
|
@@ -356,22 +550,41 @@ export class DshRunner {
|
|
|
356
550
|
* events; `$events` owns the answerable approval waterfall. The merged
|
|
357
551
|
* stream keeps either transport from starving the other.
|
|
358
552
|
*/
|
|
359
|
-
async *runTurn(sessionId, cwd, prePromptSeq, abort) {
|
|
553
|
+
async *runTurn(sessionId, cwd, prePromptSeq, promptRequestId, abort) {
|
|
360
554
|
const streamAbort = new AbortController();
|
|
361
555
|
const signal = AbortSignal.any([abort.signal, streamAbort.signal]);
|
|
362
556
|
const translator = new DshTranslator();
|
|
363
557
|
const emittedApprovals = new Set();
|
|
364
558
|
this.approvalTracker.clear();
|
|
559
|
+
this.pendingQuestions.clear();
|
|
365
560
|
this.toolCalls.clear();
|
|
366
561
|
this.approvalChannelAvailable = true;
|
|
562
|
+
// 状态条累计值(contextPressure / sessionStats 投影)。`contextWindow` 同时
|
|
563
|
+
// 用于给 result.usage.context_limit 兜底:DSH 的会话读取端不产出上限,
|
|
564
|
+
// 没有它终态卡只能显示绝对量、算不出百分比。
|
|
565
|
+
const live = {};
|
|
367
566
|
try {
|
|
368
567
|
const sources = [
|
|
369
|
-
this.client.sessionEvents(sessionId, signal, prePromptSeq),
|
|
568
|
+
this.client.sessionEvents(sessionId, signal, prePromptSeq, promptRequestId),
|
|
370
569
|
this.client.approvalEvents(sessionId, signal),
|
|
570
|
+
this.client.controlFrames(signal),
|
|
371
571
|
];
|
|
372
572
|
for await (const item of mergeAsync(sources)) {
|
|
373
573
|
if (abort.signal.aborted)
|
|
374
574
|
return;
|
|
575
|
+
if (item.kind === 'projection') {
|
|
576
|
+
// 全 Host 流:只认本 session。未知 key 直接忽略(投影是能力发现式
|
|
577
|
+
// 扩展的,不能因为多了个 key 就报错)。
|
|
578
|
+
if (item.sessionId !== sessionId)
|
|
579
|
+
continue;
|
|
580
|
+
// 只在消费字段真的变了时才产出事件:投影帧本身按 key 去重推送,
|
|
581
|
+
// 这里再挡掉"值没变"的重复帧。不需要时间节流——卡片更新本来就有
|
|
582
|
+
// 100ms 合批窗口,与正文 delta 共用同一条节流路径。
|
|
583
|
+
if (!applyProjection(live, item.key, item.value))
|
|
584
|
+
continue;
|
|
585
|
+
yield statusEventOf(live);
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
375
588
|
if (item.kind === 'ready') {
|
|
376
589
|
this.approvalChannelAvailable = true;
|
|
377
590
|
continue;
|
|
@@ -391,7 +604,25 @@ export class DshRunner {
|
|
|
391
604
|
});
|
|
392
605
|
continue;
|
|
393
606
|
}
|
|
607
|
+
if (item.kind === 'question') {
|
|
608
|
+
// 同一 waterfall 在重连后会带新的 clientId 重投:覆盖写回,卡片的
|
|
609
|
+
// requestId(eventId)不变,coordinator 按重投语义刷新视图。
|
|
610
|
+
this.pendingQuestions.set(item.eventId, {
|
|
611
|
+
clientId: item.clientId,
|
|
612
|
+
questions: item.questions,
|
|
613
|
+
});
|
|
614
|
+
getLogger().info(`[dsh] user question surfaced eventId=${item.eventId} questions=${item.questions.length}`);
|
|
615
|
+
yield this.buildQuestionRequested(sessionId, item.eventId, item.questions);
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
394
618
|
if (item.kind === 'cancelled') {
|
|
619
|
+
// 提问与审批共用 cancel 帧:提问在别处(DSH Web UI)被回答或超时后
|
|
620
|
+
// 也要清掉飞书卡片,否则上面还留着可点但已无 responder 的问题区。
|
|
621
|
+
if (this.pendingQuestions.delete(item.eventId)) {
|
|
622
|
+
getLogger().info(`[dsh] user question resolved elsewhere eventId=${item.eventId}`);
|
|
623
|
+
yield this.approvalResolved(item.eventId);
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
395
626
|
const binding = this.approvalTracker.onCancelled(item.eventId);
|
|
396
627
|
if (binding)
|
|
397
628
|
yield this.approvalResolved(binding.audit.approvalId);
|
|
@@ -437,7 +668,9 @@ export class DshRunner {
|
|
|
437
668
|
this.toolCalls.delete(oldest);
|
|
438
669
|
}
|
|
439
670
|
}
|
|
440
|
-
|
|
671
|
+
// 记账后继续往下走:tool/call 同时是卡片工具行的来源(translator 翻
|
|
672
|
+
// 译成 assistant.tool_use)。早期版本在这里 `continue`,把调用轨迹整个
|
|
673
|
+
// 吞掉——卡片因此只有正文、没有任何工具行。
|
|
441
674
|
}
|
|
442
675
|
if (event.type === 'approval/decided') {
|
|
443
676
|
const data = event.data;
|
|
@@ -448,18 +681,57 @@ export class DshRunner {
|
|
|
448
681
|
continue;
|
|
449
682
|
}
|
|
450
683
|
for (const ev of translator.eventToAgentEvents(event, sessionId)) {
|
|
451
|
-
|
|
452
|
-
|
|
684
|
+
if (ev.type === 'result') {
|
|
685
|
+
if (!abort.signal.aborted) {
|
|
686
|
+
for (const jobsEvent of await this.collectBackgroundJobs(sessionId, abort.signal)) {
|
|
687
|
+
yield jobsEvent;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
yield attachContextLimit(ev, live.contextLimit);
|
|
453
691
|
return;
|
|
692
|
+
}
|
|
693
|
+
yield ev;
|
|
454
694
|
}
|
|
455
695
|
}
|
|
456
696
|
}
|
|
457
697
|
finally {
|
|
458
698
|
streamAbort.abort();
|
|
459
699
|
this.approvalTracker.clear();
|
|
700
|
+
this.pendingQuestions.clear();
|
|
460
701
|
this.toolCalls.clear();
|
|
461
702
|
}
|
|
462
703
|
}
|
|
704
|
+
/**
|
|
705
|
+
* 收尾时仍在跑的后台任务。
|
|
706
|
+
*
|
|
707
|
+
* 只保留非终态(running/stopping):已完成的任务由 agent 自己的 output 呈
|
|
708
|
+
* 现,卡片列出来只会误导"还没跑完"。`job/list` 拿不到时返回空数组——没有
|
|
709
|
+
* 任务列表不等于有任务在跑。
|
|
710
|
+
*/
|
|
711
|
+
async collectBackgroundJobs(sessionId, signal) {
|
|
712
|
+
const rows = await this.client.jobRows(sessionId, { signal });
|
|
713
|
+
if (!rows)
|
|
714
|
+
return [];
|
|
715
|
+
const live = rows.filter((job) => job.status === 'running' || job.status === 'stopping');
|
|
716
|
+
if (live.length === 0)
|
|
717
|
+
return [];
|
|
718
|
+
return [
|
|
719
|
+
{
|
|
720
|
+
type: 'background_jobs',
|
|
721
|
+
jobs: live.map((job) => ({
|
|
722
|
+
id: job.id,
|
|
723
|
+
label: job.label ?? job.id,
|
|
724
|
+
...(job.status === undefined ? {} : { status: job.status }),
|
|
725
|
+
...(job.progress === undefined ? {} : { progress: job.progress }),
|
|
726
|
+
})),
|
|
727
|
+
timestamp: new Date().toISOString(),
|
|
728
|
+
},
|
|
729
|
+
];
|
|
730
|
+
}
|
|
731
|
+
/** Kill one background job of this runner's session (card button path). */
|
|
732
|
+
async killJob(sessionId, jobId) {
|
|
733
|
+
return await this.client.jobKill(sessionId, jobId);
|
|
734
|
+
}
|
|
463
735
|
/** Apply the configured permission preset once per session, before prompt. */
|
|
464
736
|
async alignPermissionPreset(sessionId, signal) {
|
|
465
737
|
const preset = this.configuredPermissionPreset;
|
|
@@ -482,11 +754,22 @@ export class DshRunner {
|
|
|
482
754
|
getLogger().info(`[dsh] permission preset updated to ${settings.permissionPreset ?? '(follow default)'}; applies to the next run`);
|
|
483
755
|
}
|
|
484
756
|
/**
|
|
485
|
-
* Answer one DSH approval
|
|
486
|
-
*
|
|
757
|
+
* Answer one DSH approval or `ask_user_question` prompt.
|
|
758
|
+
*
|
|
759
|
+
* Approvals: `accept` / `decline` are the only user decisions exposed by the
|
|
760
|
+
* DSH Web UI; `cancel` stops the turn through session/cancel.
|
|
761
|
+
*
|
|
762
|
+
* Questions: `answer` / `decline`(跳过回答)resolve the `user-questions/request`
|
|
763
|
+
* waterfall with an answer batch; `cancel`(飞书卡超时)只把这一路交回
|
|
764
|
+
* `next`,让 DSH Web UI 仍可回答,不取消 turn。
|
|
487
765
|
*/
|
|
488
766
|
async respondApproval(requestId, response) {
|
|
489
767
|
const approvalId = String(requestId);
|
|
768
|
+
const question = this.pendingQuestions.get(approvalId);
|
|
769
|
+
if (question) {
|
|
770
|
+
await this.respondQuestion(approvalId, question, response);
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
490
773
|
const binding = this.approvalTracker.getBinding(approvalId) ??
|
|
491
774
|
(await this.approvalTracker.waitForBinding(approvalId, 2_000, this.activeAbort?.signal));
|
|
492
775
|
if (!binding) {
|
|
@@ -508,6 +791,41 @@ export class DshRunner {
|
|
|
508
791
|
this.approvalTracker.onDecided(approvalId);
|
|
509
792
|
getLogger().info(`[dsh] approval responded approvalId=${approvalId} action=${String(action)} eventId=${binding.waterfall.eventId}`);
|
|
510
793
|
}
|
|
794
|
+
/** Resolve one pending `ask_user_question` waterfall from a card action. */
|
|
795
|
+
async respondQuestion(eventId, pending, response) {
|
|
796
|
+
const action = response?.action;
|
|
797
|
+
let outcome;
|
|
798
|
+
if (action === 'answer') {
|
|
799
|
+
outcome = {
|
|
800
|
+
kind: 'result',
|
|
801
|
+
value: { answers: buildQuestionAnswers(pending.questions, response) },
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
else if (action === 'decline') {
|
|
805
|
+
// 「跳过回答」:与 DSH Web UI 的 skip 同语义,逐题回空 selected,
|
|
806
|
+
// 工具把这次提问当作已跳过,agent 拿到结果后继续。
|
|
807
|
+
outcome = {
|
|
808
|
+
kind: 'result',
|
|
809
|
+
value: {
|
|
810
|
+
answers: pending.questions.map((question) => ({ id: question.id, selected: [] })),
|
|
811
|
+
},
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
else if (action === 'cancel') {
|
|
815
|
+
// 飞书卡超时/放弃:只交回这一路的应答权(outcome next)。DSH Web UI
|
|
816
|
+
// 仍是 owner Client 且持有该 waterfall,可以继续回答;没有其他 Client
|
|
817
|
+
// 时才 settle 为 next(工具报 no user-questions answerer)。
|
|
818
|
+
outcome = { kind: 'next' };
|
|
819
|
+
}
|
|
820
|
+
else {
|
|
821
|
+
throw new Error(`DSH 提问不支持审批决策: ${String(action)}`);
|
|
822
|
+
}
|
|
823
|
+
await this.client.answerRemoteEvent(pending.clientId, eventId, outcome, {
|
|
824
|
+
signal: this.activeAbort?.signal,
|
|
825
|
+
});
|
|
826
|
+
this.pendingQuestions.delete(eventId);
|
|
827
|
+
getLogger().info(`[dsh] user question responded eventId=${eventId} action=${String(action)}`);
|
|
828
|
+
}
|
|
511
829
|
buildApprovalRequested(sessionId, audit, binding, cwd) {
|
|
512
830
|
const call = audit.callId ? this.toolCalls.get(audit.callId) : undefined;
|
|
513
831
|
const args = parseToolArguments(call?.arguments);
|
|
@@ -538,6 +856,51 @@ export class DshRunner {
|
|
|
538
856
|
timestamp: new Date().toISOString(),
|
|
539
857
|
};
|
|
540
858
|
}
|
|
859
|
+
/**
|
|
860
|
+
* Render one `user-questions/request` waterfall as a `kind:'question'`
|
|
861
|
+
* approval area. The card renderer is agent-agnostic: it draws options,
|
|
862
|
+
* the Other input, multi-select submit and skip from this view alone.
|
|
863
|
+
*/
|
|
864
|
+
buildQuestionRequested(sessionId, eventId, questions) {
|
|
865
|
+
const view = {
|
|
866
|
+
requestId: eventId,
|
|
867
|
+
kind: 'question',
|
|
868
|
+
note: '仅影响本次回答;若飞书提交失败,请到 DSH Web UI 处理。',
|
|
869
|
+
// 提问不是审批:超时只是把这一路交回 Web UI,卡上要如实说清还能去哪答。
|
|
870
|
+
expiredHint: '仍可在 DSH Web UI 继续回答;本卡不再接收回答。',
|
|
871
|
+
questions: questions.map((question) => {
|
|
872
|
+
const options = (question.options ?? []).map((option) => ({
|
|
873
|
+
label: option.label,
|
|
874
|
+
...(option.description ? { description: option.description } : {}),
|
|
875
|
+
}));
|
|
876
|
+
return {
|
|
877
|
+
id: question.id,
|
|
878
|
+
question: question.question,
|
|
879
|
+
// plan-review 提问把计划全文放在 detail;丢了就成了「看不见计划就批准」。
|
|
880
|
+
...(question.detail ? { detail: question.detail } : {}),
|
|
881
|
+
...(question.header ? { header: question.header } : {}),
|
|
882
|
+
// 共享渲染层把「无选项」当自由文本题,而 coordinator 的 answerCustom
|
|
883
|
+
// 拒绝 multiSelect——无选项的多选在卡片上等于无解,按单选渲染。
|
|
884
|
+
...(isCardMultiSelect(question) ? { multiSelect: true } : {}),
|
|
885
|
+
options,
|
|
886
|
+
};
|
|
887
|
+
}),
|
|
888
|
+
availableDecisions: [],
|
|
889
|
+
};
|
|
890
|
+
return {
|
|
891
|
+
type: 'approval_requested',
|
|
892
|
+
requestId: eventId,
|
|
893
|
+
kind: 'question',
|
|
894
|
+
threadId: sessionId,
|
|
895
|
+
turnId: '',
|
|
896
|
+
itemId: eventId,
|
|
897
|
+
view,
|
|
898
|
+
// legacy ask_user_question 没有服务端期限;给飞书卡一个宽窗口,
|
|
899
|
+
// 超时只交回应答权、不取消 turn(见 respondQuestion)。
|
|
900
|
+
timeoutMs: DSH_QUESTION_TIMEOUT_MS,
|
|
901
|
+
timestamp: new Date().toISOString(),
|
|
902
|
+
};
|
|
903
|
+
}
|
|
541
904
|
approvalResolved(requestId) {
|
|
542
905
|
return {
|
|
543
906
|
type: 'approval_resolved',
|
|
@@ -583,6 +946,9 @@ export class DshRunner {
|
|
|
583
946
|
this.running = true;
|
|
584
947
|
this.activeSessionId = sessionId;
|
|
585
948
|
let terminalEmitted = false;
|
|
949
|
+
// Same barrier as run(): a stop of this session must reach the host before
|
|
950
|
+
// the next compact is submitted.
|
|
951
|
+
await this.awaitStopGate(sessionId);
|
|
586
952
|
try {
|
|
587
953
|
if (this.daemon)
|
|
588
954
|
await this.daemon.ensure();
|
|
@@ -593,6 +959,19 @@ export class DshRunner {
|
|
|
593
959
|
cwd: opts.cwd,
|
|
594
960
|
model: this.configuredModel ?? 'DSH',
|
|
595
961
|
};
|
|
962
|
+
// 桥接重启后内存里没有在途 run,但服务端可能还在跑上一轮:先用只读
|
|
963
|
+
// 探测兜底,别把注定被拒的 /compact 发出去(DSH 只回一句把「有压缩
|
|
964
|
+
// 在跑」和「agent 不空闲」合并在一起的文案,用户看不出真实原因)。
|
|
965
|
+
if (await this.client.hasOpenTurn(sessionId, { signal: abort.signal })) {
|
|
966
|
+
terminalEmitted = true;
|
|
967
|
+
yield {
|
|
968
|
+
type: 'result',
|
|
969
|
+
subtype: 'error',
|
|
970
|
+
session_id: sessionId,
|
|
971
|
+
errorMessage: '当前会话还有一轮在跑(DSH 服务端未空闲),等它结束后再压缩',
|
|
972
|
+
};
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
596
975
|
const outcome = await this.client.executeCommand(sessionId, DSH_COMPACT_COMMAND, {
|
|
597
976
|
signal: abort.signal,
|
|
598
977
|
// 压缩是一次长 unary(服务端等压缩结束才回),60s 默认预算会误杀;
|
|
@@ -703,26 +1082,233 @@ export class DshRunner {
|
|
|
703
1082
|
getLogger().warn(`[dsh] session/cancel failed for ${sessionId}: ${err.message}`);
|
|
704
1083
|
}
|
|
705
1084
|
}
|
|
1085
|
+
/**
|
|
1086
|
+
* Stop this run's work in the shared host.
|
|
1087
|
+
*
|
|
1088
|
+
* Two distinct things can be running, and DSH needs a different call for each:
|
|
1089
|
+
*
|
|
1090
|
+
* 1. A prompt that was accepted but not claimed yet still sits in the host
|
|
1091
|
+
* inbox. `session/cancel` would NOT drop it (`keepInbox`), so the turn would
|
|
1092
|
+
* start later with no subscriber — an orphan. Withdraw it by `rpcId` via
|
|
1093
|
+
* `session/updateQueue` instead, and leave the (foreign) active turn alone.
|
|
1094
|
+
* 2. An already-claimed turn (or a compact) can only be stopped with
|
|
1095
|
+
* `session/cancel`. Since that request is session-scoped and only an
|
|
1096
|
+
* acknowledgement, verify the session went idle before reporting success.
|
|
1097
|
+
*
|
|
1098
|
+
* The gate is held until the host-side action has landed. For a cancel that
|
|
1099
|
+
* includes the verification (and possible retry), because a retry cancel is
|
|
1100
|
+
* session-scoped and would otherwise hit the successor's new turn.
|
|
1101
|
+
*/
|
|
706
1102
|
async stop(_opts) {
|
|
707
1103
|
const sessionId = this.activeSessionId;
|
|
1104
|
+
const promptRequestId = this.activePromptRequestId;
|
|
1105
|
+
const promptAdmitted = this.promptAdmitted;
|
|
708
1106
|
const abort = this.activeAbort;
|
|
1107
|
+
// Free the local mux/queue first; the host-side stop continues below.
|
|
709
1108
|
if (abort && !abort.signal.aborted)
|
|
710
1109
|
abort.abort();
|
|
711
|
-
if (sessionId)
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
1110
|
+
if (!sessionId)
|
|
1111
|
+
return { status: 'noop' };
|
|
1112
|
+
let releaseGate = () => { };
|
|
1113
|
+
const gate = new Promise((resolve) => {
|
|
1114
|
+
releaseGate = resolve;
|
|
1115
|
+
});
|
|
1116
|
+
this.pendingStopGate = gate;
|
|
1117
|
+
this.pendingStopSessionId = sessionId;
|
|
1118
|
+
let settled = false;
|
|
1119
|
+
const release = () => {
|
|
1120
|
+
if (settled)
|
|
1121
|
+
return;
|
|
1122
|
+
settled = true;
|
|
1123
|
+
releaseGate();
|
|
1124
|
+
};
|
|
1125
|
+
try {
|
|
1126
|
+
return await this.performStop(sessionId, promptRequestId, promptAdmitted, release);
|
|
1127
|
+
}
|
|
1128
|
+
finally {
|
|
1129
|
+
// Safety net for early returns/throws; performStop normally releases first.
|
|
1130
|
+
release();
|
|
1131
|
+
if (this.pendingStopGate === gate) {
|
|
1132
|
+
this.pendingStopGate = null;
|
|
1133
|
+
this.pendingStopSessionId = null;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
/** Wait for a concurrent stop to reach the host before submitting a new prompt. */
|
|
1138
|
+
async awaitStopGate(sessionId) {
|
|
1139
|
+
const gate = this.pendingStopGate;
|
|
1140
|
+
if (!gate)
|
|
1141
|
+
return;
|
|
1142
|
+
if (sessionId === undefined ||
|
|
1143
|
+
this.pendingStopSessionId === null ||
|
|
1144
|
+
sessionId !== this.pendingStopSessionId) {
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
await gate;
|
|
1148
|
+
}
|
|
1149
|
+
async performStop(sessionId, promptRequestId, promptAdmitted, releaseGate) {
|
|
1150
|
+
const deadline = Date.now() + STOP_BUDGET_MS;
|
|
1151
|
+
try {
|
|
1152
|
+
if (promptRequestId) {
|
|
1153
|
+
const itemId = await this.findPendingPromptWithin(sessionId, promptRequestId, promptAdmitted, deadline);
|
|
1154
|
+
if (itemId && (await this.removePendingPrompt(sessionId, itemId, deadline))) {
|
|
1155
|
+
releaseGate();
|
|
1156
|
+
this.inflight?.clear(sessionId, promptRequestId);
|
|
1157
|
+
getLogger().info(`[dsh] stop withdrew queued prompt session=${sessionId}`);
|
|
1158
|
+
return { status: 'withdrawn', sessionId };
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
// Nothing (or nothing claimable) in the inbox: the turn/compact is live.
|
|
1162
|
+
if (!(await this.cancelWithRetry(sessionId, deadline))) {
|
|
1163
|
+
releaseGate();
|
|
1164
|
+
getLogger().warn(`[dsh] stop unconfirmed session=${sessionId}: cancel not accepted`);
|
|
1165
|
+
return { status: 'unconfirmed', sessionId, detail: 'session/cancel was not accepted' };
|
|
1166
|
+
}
|
|
1167
|
+
const state = await this.waitForClosed(sessionId, deadline);
|
|
1168
|
+
if (state === 'closed') {
|
|
1169
|
+
releaseGate();
|
|
1170
|
+
if (promptRequestId)
|
|
1171
|
+
this.inflight?.clear(sessionId, promptRequestId);
|
|
1172
|
+
getLogger().info(`[dsh] cancelled session ${sessionId}`);
|
|
1173
|
+
return { status: 'cancelled', sessionId };
|
|
1174
|
+
}
|
|
1175
|
+
if (state === 'unknown') {
|
|
1176
|
+
releaseGate();
|
|
1177
|
+
return { status: 'unconfirmed', sessionId, detail: 'turn state unavailable after cancel' };
|
|
1178
|
+
}
|
|
1179
|
+
// The host still reports an open turn (slow unwind or lost cancel): one
|
|
1180
|
+
// more attempt, then report honestly instead of pretending it stopped.
|
|
1181
|
+
// Keep the gate closed through the retry: it is session-scoped, so the
|
|
1182
|
+
// successor must not submit its prompt until this attempt has landed.
|
|
1183
|
+
await this.cancelWithRetry(sessionId, deadline);
|
|
1184
|
+
const retryState = await this.waitForClosed(sessionId, deadline);
|
|
1185
|
+
releaseGate();
|
|
1186
|
+
if (retryState === 'closed') {
|
|
1187
|
+
if (promptRequestId)
|
|
1188
|
+
this.inflight?.clear(sessionId, promptRequestId);
|
|
1189
|
+
return { status: 'cancelled', sessionId };
|
|
1190
|
+
}
|
|
1191
|
+
getLogger().warn(`[dsh] stop unconfirmed session=${sessionId}: turn still open`);
|
|
1192
|
+
return { status: 'unconfirmed', sessionId, detail: 'turn still open after cancel' };
|
|
1193
|
+
}
|
|
1194
|
+
catch (err) {
|
|
1195
|
+
releaseGate();
|
|
1196
|
+
getLogger().warn(`[dsh] stop failed session=${sessionId}: ${err.message}`);
|
|
1197
|
+
return { status: 'unconfirmed', sessionId, detail: err.message };
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
/**
|
|
1201
|
+
* Locate this run's unclaimed prompt.
|
|
1202
|
+
*
|
|
1203
|
+
* An already-admitted prompt is visible immediately. A request still in
|
|
1204
|
+
* flight can be admitted after stop aborted it, so poll the inbox briefly —
|
|
1205
|
+
* that is the only client-side protection against a late orphan turn.
|
|
1206
|
+
*/
|
|
1207
|
+
async findPendingPromptWithin(sessionId, requestId, admitted, deadline) {
|
|
1208
|
+
const attempts = admitted ? 1 : STOP_ADMIT_ATTEMPTS;
|
|
1209
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
1210
|
+
if (attempt > 0 && !(await this.sleepWithin(deadline, STOP_ADMIT_DELAY_MS)))
|
|
1211
|
+
return undefined;
|
|
1212
|
+
try {
|
|
1213
|
+
const itemId = await this.client.findPendingPrompt(sessionId, requestId, {
|
|
1214
|
+
timeoutMs: this.stopProbeTimeout(deadline),
|
|
1215
|
+
});
|
|
1216
|
+
if (itemId)
|
|
1217
|
+
return itemId;
|
|
1218
|
+
if (admitted)
|
|
1219
|
+
return undefined;
|
|
1220
|
+
}
|
|
1221
|
+
catch (err) {
|
|
1222
|
+
getLogger().warn(`[dsh] stop inbox probe failed session=${sessionId}: ${err.message}`);
|
|
1223
|
+
return undefined;
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
return undefined;
|
|
1227
|
+
}
|
|
1228
|
+
async removePendingPrompt(sessionId, itemId, deadline) {
|
|
1229
|
+
try {
|
|
1230
|
+
return await this.client.removeQueuedPrompt(sessionId, itemId, {
|
|
1231
|
+
timeoutMs: this.stopProbeTimeout(deadline),
|
|
1232
|
+
});
|
|
716
1233
|
}
|
|
1234
|
+
catch (err) {
|
|
1235
|
+
getLogger().warn(`[dsh] stop withdraw failed session=${sessionId}: ${err.message}`);
|
|
1236
|
+
return false;
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
/** One cancel attempt, then one retry (the request is idempotent when idle). */
|
|
1240
|
+
async cancelWithRetry(sessionId, deadline) {
|
|
1241
|
+
if (await this.cancelOnce(sessionId, deadline))
|
|
1242
|
+
return true;
|
|
1243
|
+
if (!(await this.sleepWithin(deadline, 150)))
|
|
1244
|
+
return false;
|
|
1245
|
+
return this.cancelOnce(sessionId, deadline);
|
|
1246
|
+
}
|
|
1247
|
+
async cancelOnce(sessionId, deadline) {
|
|
1248
|
+
const timeoutMs = Math.max(1, Math.min(CANCEL_TIMEOUT_MS, deadline - Date.now()));
|
|
1249
|
+
try {
|
|
1250
|
+
await this.client.unary('session/cancel', { sessionId }, { timeoutMs });
|
|
1251
|
+
return true;
|
|
1252
|
+
}
|
|
1253
|
+
catch (err) {
|
|
1254
|
+
getLogger().warn(`[dsh] session/cancel failed for ${sessionId}: ${err.message}`);
|
|
1255
|
+
return false;
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
/** Poll the host until the session reports idle, or the budget runs out. */
|
|
1259
|
+
async waitForClosed(sessionId, deadline) {
|
|
1260
|
+
let state = 'unknown';
|
|
1261
|
+
for (let attempt = 0; attempt < STOP_VERIFY_ATTEMPTS; attempt++) {
|
|
1262
|
+
if (attempt > 0 && !(await this.sleepWithin(deadline, STOP_VERIFY_DELAY_MS)))
|
|
1263
|
+
break;
|
|
1264
|
+
state = await this.client.openTurnState(sessionId, {
|
|
1265
|
+
timeoutMs: this.stopProbeTimeout(deadline),
|
|
1266
|
+
});
|
|
1267
|
+
if (state === 'closed')
|
|
1268
|
+
return 'closed';
|
|
1269
|
+
}
|
|
1270
|
+
return state;
|
|
1271
|
+
}
|
|
1272
|
+
/** Sleep, but never past the stop deadline. Returns false when out of budget. */
|
|
1273
|
+
async sleepWithin(deadline, ms) {
|
|
1274
|
+
const remaining = deadline - Date.now();
|
|
1275
|
+
if (remaining <= 0)
|
|
1276
|
+
return false;
|
|
1277
|
+
await delay(Math.min(ms, remaining));
|
|
1278
|
+
return Date.now() < deadline;
|
|
1279
|
+
}
|
|
1280
|
+
stopProbeTimeout(deadline) {
|
|
1281
|
+
return Math.max(1, Math.min(STOP_PROBE_TIMEOUT_MS, deadline - Date.now()));
|
|
717
1282
|
}
|
|
718
1283
|
killOrphan() {
|
|
719
1284
|
// No local subprocess for DSH — nothing to reap.
|
|
720
1285
|
}
|
|
1286
|
+
/**
|
|
1287
|
+
* 退出前取消在途 turn。
|
|
1288
|
+
*
|
|
1289
|
+
* turn 跑在常驻共享的 `dsh web` host 里,不在本进程的子进程树上;所以
|
|
1290
|
+
* "杀自己的子进程"这套退出清理对它无效,桥接一退出,turn 继续无人接收地跑
|
|
1291
|
+
* (2026-10-19 实测:一个孤儿 turn 在桥接死后又跑了 2h21m / 345 个 step)。
|
|
1292
|
+
* 这里复用进程级退出分发器,用同步 curl 发一次 `session/cancel`。
|
|
1293
|
+
*/
|
|
721
1294
|
registerExitHandlers() {
|
|
722
|
-
|
|
1295
|
+
registerExitCleanup(this);
|
|
723
1296
|
}
|
|
724
1297
|
unregisterExitHandlers() {
|
|
725
|
-
|
|
1298
|
+
unregisterExitCleanup(this);
|
|
1299
|
+
}
|
|
1300
|
+
/** Exit dispatcher hook: cancel the in-flight turn, bounded and never throwing. */
|
|
1301
|
+
cleanupOnExit() {
|
|
1302
|
+
if (!this.running || !this.activeSessionId)
|
|
1303
|
+
return;
|
|
1304
|
+
const sessionId = this.activeSessionId;
|
|
1305
|
+
try {
|
|
1306
|
+
this.cancelOnExit({ baseUrl: this.client.baseUrl, sessionId });
|
|
1307
|
+
getLogger().info(`[dsh] exit-cancel sent session=${sessionId}`);
|
|
1308
|
+
}
|
|
1309
|
+
catch (err) {
|
|
1310
|
+
getLogger().warn(`[dsh] exit-cancel failed session=${sessionId}: ${err.message}`);
|
|
1311
|
+
}
|
|
726
1312
|
}
|
|
727
1313
|
}
|
|
728
1314
|
//# sourceMappingURL=runner.js.map
|