lark-remote 0.5.6 → 0.5.8
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 +46 -6
- package/README.en.md +2 -2
- package/README.md +2 -2
- package/dist/bridge/index.d.ts +63 -1
- package/dist/bridge/index.js +147 -14
- package/dist/bridge/queue-manager.js +41 -58
- package/dist/card/approval-render.js +2 -1
- package/dist/card/bash-card-session.d.ts +0 -1
- package/dist/card/bash-card-session.js +0 -8
- package/dist/card/bash-renderer.js +2 -1
- package/dist/card/card-budget.d.ts +37 -0
- package/dist/card/card-budget.js +143 -24
- package/dist/card/card-session.d.ts +3 -13
- package/dist/card/card-session.js +4 -18
- package/dist/card/card-shared.d.ts +11 -0
- package/dist/card/card-shared.js +19 -13
- package/dist/card/run-card-session.d.ts +0 -1
- package/dist/card/run-card-session.js +0 -6
- package/dist/card/run-renderer.d.ts +18 -0
- package/dist/card/run-renderer.js +330 -62
- package/dist/card/run-state.d.ts +26 -0
- package/dist/card/run-state.js +45 -0
- package/dist/card/text-truncate.d.ts +3 -5
- package/dist/card/text-truncate.js +3 -5
- 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 +42 -82
- package/dist/common/text-width.d.ts +24 -0
- package/dist/common/text-width.js +93 -0
- package/dist/common/tool-summary.d.ts +19 -0
- package/dist/common/tool-summary.js +171 -0
- package/dist/common/turn-failure.d.ts +13 -0
- package/dist/common/turn-failure.js +18 -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 +66 -13
- package/dist/order/index.d.ts +2 -0
- package/dist/order/index.js +2 -0
- 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 +88 -46
- package/dist/router/config/codex.js +2 -3
- package/dist/router/config/common/model-patch.d.ts +8 -0
- package/dist/router/config/common/model-patch.js +13 -0
- package/dist/router/config/common/render.js +30 -77
- package/dist/router/config/dsh.js +2 -1
- package/dist/router/config/kimi.js +3 -3
- package/dist/router/index.d.ts +9 -11
- package/dist/router/index.js +165 -356
- package/dist/router/order-exec-dispatch.js +2 -1
- package/dist/runner/claude/session.d.ts +1 -1
- package/dist/runner/claude/session.js +3 -3
- package/dist/runner/common/defaults.d.ts +2 -0
- package/dist/runner/common/defaults.js +3 -0
- package/dist/runner/common/jsonrpc/connection-manager.js +1 -1
- 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 +100 -3
- package/dist/runner/dsh/client.js +290 -6
- 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 +90 -4
- package/dist/runner/dsh/runner.js +436 -18
- package/dist/runner/dsh/translator.d.ts +1 -1
- package/dist/runner/dsh/translator.js +103 -1
- package/dist/runner/dsh/types.d.ts +60 -0
- package/dist/runner/types.d.ts +95 -2
- package/dist/runner/types.js +9 -0
- package/dist/session/claude/session-index.d.ts +0 -4
- package/dist/session/claude/session-index.js +0 -8
- package/dist/session/claude/sessions.js +2 -2
- package/dist/session/dsh/sessions.js +157 -17
- package/dist/update/install.js +1 -1
- package/package.json +1 -1
|
@@ -6,8 +6,10 @@
|
|
|
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`
|
|
@@ -20,6 +22,9 @@ import { DshTranslator } from './translator.js';
|
|
|
20
22
|
import { DshApprovalTracker } from './approval.js';
|
|
21
23
|
import { mergeAsync } from './merge.js';
|
|
22
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';
|
|
23
28
|
/**
|
|
24
29
|
* Legacy provider for configs written before `agents.dsh.provider` existed.
|
|
25
30
|
* New configs pin the provider explicitly; the card reads the live catalog.
|
|
@@ -74,8 +79,85 @@ function buildQuestionAnswers(questions, response) {
|
|
|
74
79
|
: { id: question.id, selected: [], custom: selected };
|
|
75
80
|
});
|
|
76
81
|
}
|
|
77
|
-
/** Budget for
|
|
82
|
+
/** Budget for one session/cancel attempt on stop. */
|
|
78
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
|
+
}
|
|
79
161
|
/** Parse tool-call arguments without letting malformed JSON crash the run. */
|
|
80
162
|
function parseToolArguments(raw) {
|
|
81
163
|
if (!raw)
|
|
@@ -112,6 +194,19 @@ export class DshRunner {
|
|
|
112
194
|
activeAbort = null;
|
|
113
195
|
activeSessionId = null;
|
|
114
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;
|
|
115
210
|
daemon;
|
|
116
211
|
/**
|
|
117
212
|
* 已对齐模型的 session 集合:同一 session 二次 run 不再重发 selectModel
|
|
@@ -135,6 +230,8 @@ export class DshRunner {
|
|
|
135
230
|
toolCalls = new Map();
|
|
136
231
|
/** False after `$events` is unavailable; approval/asked then falls back to text. */
|
|
137
232
|
approvalChannelAvailable = true;
|
|
233
|
+
/** 退出路径的取消实现(默认同步 curl,见 `cancelSessionSync`)。 */
|
|
234
|
+
cancelOnExit;
|
|
138
235
|
constructor(opts) {
|
|
139
236
|
this.sessionReader = opts.sessionReader;
|
|
140
237
|
this.daemon = opts.daemon;
|
|
@@ -143,6 +240,7 @@ export class DshRunner {
|
|
|
143
240
|
this.agentPreset = opts.agentPreset;
|
|
144
241
|
this.configuredPermissionPreset = opts.permissionPreset;
|
|
145
242
|
this.reasoningEffort = opts.reasoningEffort;
|
|
243
|
+
this.inflight = opts.inflightPath ? new DshInflightStore(opts.inflightPath) : null;
|
|
146
244
|
this.client = new DshClient(opts.host ?? 'http://127.0.0.1:3080', {
|
|
147
245
|
dshHome: opts.dshHome,
|
|
148
246
|
token: opts.token,
|
|
@@ -151,6 +249,16 @@ export class DshRunner {
|
|
|
151
249
|
webSocketImpl: opts.webSocketImpl,
|
|
152
250
|
requestTimeoutMs: opts.requestTimeoutMs,
|
|
153
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
|
+
});
|
|
154
262
|
}
|
|
155
263
|
get isRunning() {
|
|
156
264
|
return this.running;
|
|
@@ -216,8 +324,13 @@ export class DshRunner {
|
|
|
216
324
|
const abort = new AbortController();
|
|
217
325
|
this.activeAbort = abort;
|
|
218
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);
|
|
219
331
|
let sessionId = opts.sessionId;
|
|
220
332
|
let terminalEmitted = false;
|
|
333
|
+
let promptRequestId = null;
|
|
221
334
|
try {
|
|
222
335
|
// DSH is a shared, machine-wide process. Reuse it when it is already
|
|
223
336
|
// listening; otherwise start it detached so lark-remote exiting does not
|
|
@@ -269,7 +382,7 @@ export class DshRunner {
|
|
|
269
382
|
};
|
|
270
383
|
return;
|
|
271
384
|
}
|
|
272
|
-
// provider
|
|
385
|
+
// provider/模型/推理强度对齐:会话首次 run 时 selectModel 一次
|
|
273
386
|
// (该调用同时写服务端全局默认,故不重复写);同一 session 后续 run
|
|
274
387
|
// 跳过。显式配置 provider 时失败必须 fail closed:静默回退到其他
|
|
275
388
|
// provider 会让用户以为切换成功,实际模型与账单都错。
|
|
@@ -328,12 +441,28 @@ export class DshRunner {
|
|
|
328
441
|
const prePromptSeq = await this.client.latestEventSeq(sessionId, {
|
|
329
442
|
signal: abort.signal,
|
|
330
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
|
+
});
|
|
331
459
|
await this.client.unary('session/prompt', {
|
|
332
|
-
requestId:
|
|
460
|
+
requestId: promptRequestId,
|
|
333
461
|
sessionId,
|
|
334
462
|
mode: 'queue',
|
|
335
463
|
content: [{ type: 'text', text: message }],
|
|
336
464
|
}, { signal: abort.signal });
|
|
465
|
+
this.promptAdmitted = true;
|
|
337
466
|
if (abort.signal.aborted) {
|
|
338
467
|
// stop() 在 prompt 已受理后到达:cancel 由 stop() 发出,这里直接收尾,
|
|
339
468
|
// 不再打开 mux(否则会看到 cancel 产生的 turn/end 事件)。
|
|
@@ -347,7 +476,7 @@ export class DshRunner {
|
|
|
347
476
|
return;
|
|
348
477
|
}
|
|
349
478
|
try {
|
|
350
|
-
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)) {
|
|
351
480
|
yield ev;
|
|
352
481
|
if (ev.type === 'result') {
|
|
353
482
|
terminalEmitted = true;
|
|
@@ -398,6 +527,14 @@ export class DshRunner {
|
|
|
398
527
|
this.running = false;
|
|
399
528
|
this.activeSessionId = null;
|
|
400
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
|
+
}
|
|
401
538
|
}
|
|
402
539
|
if (!terminalEmitted) {
|
|
403
540
|
yield {
|
|
@@ -413,7 +550,7 @@ export class DshRunner {
|
|
|
413
550
|
* events; `$events` owns the answerable approval waterfall. The merged
|
|
414
551
|
* stream keeps either transport from starving the other.
|
|
415
552
|
*/
|
|
416
|
-
async *runTurn(sessionId, cwd, prePromptSeq, abort) {
|
|
553
|
+
async *runTurn(sessionId, cwd, prePromptSeq, promptRequestId, abort) {
|
|
417
554
|
const streamAbort = new AbortController();
|
|
418
555
|
const signal = AbortSignal.any([abort.signal, streamAbort.signal]);
|
|
419
556
|
const translator = new DshTranslator();
|
|
@@ -422,14 +559,32 @@ export class DshRunner {
|
|
|
422
559
|
this.pendingQuestions.clear();
|
|
423
560
|
this.toolCalls.clear();
|
|
424
561
|
this.approvalChannelAvailable = true;
|
|
562
|
+
// 状态条累计值(contextPressure / sessionStats 投影)。`contextWindow` 同时
|
|
563
|
+
// 用于给 result.usage.context_limit 兜底:DSH 的会话读取端不产出上限,
|
|
564
|
+
// 没有它终态卡只能显示绝对量、算不出百分比。
|
|
565
|
+
const live = {};
|
|
425
566
|
try {
|
|
426
567
|
const sources = [
|
|
427
|
-
this.client.sessionEvents(sessionId, signal, prePromptSeq),
|
|
568
|
+
this.client.sessionEvents(sessionId, signal, prePromptSeq, promptRequestId),
|
|
428
569
|
this.client.approvalEvents(sessionId, signal),
|
|
570
|
+
this.client.controlFrames(signal),
|
|
429
571
|
];
|
|
430
572
|
for await (const item of mergeAsync(sources)) {
|
|
431
573
|
if (abort.signal.aborted)
|
|
432
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
|
+
}
|
|
433
588
|
if (item.kind === 'ready') {
|
|
434
589
|
this.approvalChannelAvailable = true;
|
|
435
590
|
continue;
|
|
@@ -513,7 +668,9 @@ export class DshRunner {
|
|
|
513
668
|
this.toolCalls.delete(oldest);
|
|
514
669
|
}
|
|
515
670
|
}
|
|
516
|
-
|
|
671
|
+
// 记账后继续往下走:tool/call 同时是卡片工具行的来源(translator 翻
|
|
672
|
+
// 译成 assistant.tool_use)。早期版本在这里 `continue`,把调用轨迹整个
|
|
673
|
+
// 吞掉——卡片因此只有正文、没有任何工具行。
|
|
517
674
|
}
|
|
518
675
|
if (event.type === 'approval/decided') {
|
|
519
676
|
const data = event.data;
|
|
@@ -524,9 +681,16 @@ export class DshRunner {
|
|
|
524
681
|
continue;
|
|
525
682
|
}
|
|
526
683
|
for (const ev of translator.eventToAgentEvents(event, sessionId)) {
|
|
527
|
-
|
|
528
|
-
|
|
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);
|
|
529
691
|
return;
|
|
692
|
+
}
|
|
693
|
+
yield ev;
|
|
530
694
|
}
|
|
531
695
|
}
|
|
532
696
|
}
|
|
@@ -537,6 +701,37 @@ export class DshRunner {
|
|
|
537
701
|
this.toolCalls.clear();
|
|
538
702
|
}
|
|
539
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
|
+
}
|
|
540
735
|
/** Apply the configured permission preset once per session, before prompt. */
|
|
541
736
|
async alignPermissionPreset(sessionId, signal) {
|
|
542
737
|
const preset = this.configuredPermissionPreset;
|
|
@@ -751,6 +946,9 @@ export class DshRunner {
|
|
|
751
946
|
this.running = true;
|
|
752
947
|
this.activeSessionId = sessionId;
|
|
753
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);
|
|
754
952
|
try {
|
|
755
953
|
if (this.daemon)
|
|
756
954
|
await this.daemon.ensure();
|
|
@@ -761,6 +959,19 @@ export class DshRunner {
|
|
|
761
959
|
cwd: opts.cwd,
|
|
762
960
|
model: this.configuredModel ?? 'DSH',
|
|
763
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
|
+
}
|
|
764
975
|
const outcome = await this.client.executeCommand(sessionId, DSH_COMPACT_COMMAND, {
|
|
765
976
|
signal: abort.signal,
|
|
766
977
|
// 压缩是一次长 unary(服务端等压缩结束才回),60s 默认预算会误杀;
|
|
@@ -871,26 +1082,233 @@ export class DshRunner {
|
|
|
871
1082
|
getLogger().warn(`[dsh] session/cancel failed for ${sessionId}: ${err.message}`);
|
|
872
1083
|
}
|
|
873
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
|
+
*/
|
|
874
1102
|
async stop(_opts) {
|
|
875
1103
|
const sessionId = this.activeSessionId;
|
|
1104
|
+
const promptRequestId = this.activePromptRequestId;
|
|
1105
|
+
const promptAdmitted = this.promptAdmitted;
|
|
876
1106
|
const abort = this.activeAbort;
|
|
1107
|
+
// Free the local mux/queue first; the host-side stop continues below.
|
|
877
1108
|
if (abort && !abort.signal.aborted)
|
|
878
1109
|
abort.abort();
|
|
879
|
-
if (sessionId)
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
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);
|
|
884
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
|
+
});
|
|
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()));
|
|
885
1282
|
}
|
|
886
1283
|
killOrphan() {
|
|
887
1284
|
// No local subprocess for DSH — nothing to reap.
|
|
888
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
|
+
*/
|
|
889
1294
|
registerExitHandlers() {
|
|
890
|
-
|
|
1295
|
+
registerExitCleanup(this);
|
|
891
1296
|
}
|
|
892
1297
|
unregisterExitHandlers() {
|
|
893
|
-
|
|
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
|
+
}
|
|
894
1312
|
}
|
|
895
1313
|
}
|
|
896
1314
|
//# sourceMappingURL=runner.js.map
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* completed → success; aborted/interrupted → interrupted;
|
|
13
13
|
* blocked/error/max-tokens → error (errorMessage carries the reason)
|
|
14
14
|
*
|
|
15
|
-
* Token accounting (ccusage
|
|
15
|
+
* Token accounting (ccusage 口径): DSH emits ONE usage per model
|
|
16
16
|
* call (per step) — a tool-looping turn has several `assistant/message` events.
|
|
17
17
|
* The result event therefore carries **the sum of the turn's samplings**, not the
|
|
18
18
|
* last one (codex `token_count` has the same shape). `lastUsage` is kept only as
|