lark-remote 0.5.6 → 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 +30 -6
- package/README.en.md +2 -2
- package/README.md +2 -2
- package/dist/bridge/index.d.ts +48 -1
- package/dist/bridge/index.js +86 -14
- 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 +61 -6
- 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 +100 -3
- package/dist/runner/dsh/client.js +289 -4
- 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 +76 -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
|
@@ -52,6 +52,59 @@ function isDshAuthError(error) {
|
|
|
52
52
|
const RECONNECT_BACKOFF_MS = 200;
|
|
53
53
|
/** Follow snapshot budget. `session/follow` also stays live after the snapshot. */
|
|
54
54
|
const FOLLOW_MAX_MESSAGES = 200;
|
|
55
|
+
/**
|
|
56
|
+
* 只在 turn 内部落盘的会话事件类型:看到它们说明 turn 还没结束。
|
|
57
|
+
* 名单刻意保守——`user/message`(压缩 checkpoint 会以 turn:null 落盘)、
|
|
58
|
+
* `job/*`(后台任务可在 turn 外结算)、`turn/end`、`command/*` 等都不算,
|
|
59
|
+
* 判不出来时宁可放行,让 DSH 自己拒绝。
|
|
60
|
+
*/
|
|
61
|
+
const IN_TURN_EVENT_TYPES = new Set([
|
|
62
|
+
'step/start',
|
|
63
|
+
'step/end',
|
|
64
|
+
'assistant/chunk',
|
|
65
|
+
'assistant/message',
|
|
66
|
+
'assistant/attempt',
|
|
67
|
+
'tool/call',
|
|
68
|
+
'tool/result',
|
|
69
|
+
'request/header',
|
|
70
|
+
]);
|
|
71
|
+
/**
|
|
72
|
+
* Parse the `inbox` projection into the pending items this repo consumes.
|
|
73
|
+
*
|
|
74
|
+
* The projection's items are wire data (typed `{}` by the server schema); keep
|
|
75
|
+
* this defensive: one malformed row must not hide the others.
|
|
76
|
+
*/
|
|
77
|
+
function inboxItemsOf(values) {
|
|
78
|
+
const inbox = values.inbox;
|
|
79
|
+
if (!inbox || typeof inbox !== 'object' || Array.isArray(inbox))
|
|
80
|
+
return [];
|
|
81
|
+
const out = [];
|
|
82
|
+
for (const target of ['next-turn', 'next-step']) {
|
|
83
|
+
const list = inbox[target];
|
|
84
|
+
if (!Array.isArray(list))
|
|
85
|
+
continue;
|
|
86
|
+
for (const raw of list) {
|
|
87
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
88
|
+
continue;
|
|
89
|
+
const item = raw;
|
|
90
|
+
if (typeof item.id !== 'string' || item.id.length === 0)
|
|
91
|
+
continue;
|
|
92
|
+
const source = item.source;
|
|
93
|
+
const parsedSource = source && typeof source === 'object' && !Array.isArray(source)
|
|
94
|
+
? {
|
|
95
|
+
...(typeof source.kind === 'string'
|
|
96
|
+
? { kind: source.kind }
|
|
97
|
+
: {}),
|
|
98
|
+
...(typeof source.rpcId === 'string'
|
|
99
|
+
? { rpcId: source.rpcId }
|
|
100
|
+
: {}),
|
|
101
|
+
}
|
|
102
|
+
: undefined;
|
|
103
|
+
out.push({ id: item.id, ...(parsedSource ? { source: parsedSource } : {}) });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
55
108
|
/** Default unary timeout: long prompts are unbounded on the server side. */
|
|
56
109
|
const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
|
|
57
110
|
/** Extract the `token` query parameter from a pasted `dsh web` URL. */
|
|
@@ -85,6 +138,9 @@ const DSH_ARGS_KEY = {
|
|
|
85
138
|
'session/projections': 'request',
|
|
86
139
|
'session/page': 'request',
|
|
87
140
|
'session/follow': 'request',
|
|
141
|
+
'session/updateQueue': 'request',
|
|
142
|
+
'job/list': 'request',
|
|
143
|
+
'job/kill': 'request',
|
|
88
144
|
'session/modelCatalog': null,
|
|
89
145
|
'agentPresets/list': null,
|
|
90
146
|
'permissionPresets/catalog': null,
|
|
@@ -364,7 +420,7 @@ export class DshClient {
|
|
|
364
420
|
* `session/prompt` so a follow snapshot can skip already-completed turns.
|
|
365
421
|
*/
|
|
366
422
|
async latestEventSeq(sessionId, opts) {
|
|
367
|
-
const value =
|
|
423
|
+
const value = await this.projectionSnapshot(sessionId, opts);
|
|
368
424
|
return typeof value?.asOfSeq === 'number' ? value.asOfSeq : -1;
|
|
369
425
|
}
|
|
370
426
|
/** Read one cold history page. `throughSeq` normally comes from
|
|
@@ -376,6 +432,110 @@ export class DshClient {
|
|
|
376
432
|
maxMessages,
|
|
377
433
|
}, opts));
|
|
378
434
|
}
|
|
435
|
+
/**
|
|
436
|
+
* Read the session's exact projection values.
|
|
437
|
+
*
|
|
438
|
+
* Returns `null` when the host has no projections for the session (e.g. it was
|
|
439
|
+
* deleted). A failed unary still throws — callers that only need a
|
|
440
|
+
* best-effort probe catch it themselves.
|
|
441
|
+
*/
|
|
442
|
+
async projectionSnapshot(sessionId, opts) {
|
|
443
|
+
const value = (await this.unary('session/projections', { sessionId }, opts));
|
|
444
|
+
if (!value || typeof value !== 'object')
|
|
445
|
+
return null;
|
|
446
|
+
return {
|
|
447
|
+
...(typeof value.asOfSeq === 'number' ? { asOfSeq: value.asOfSeq } : {}),
|
|
448
|
+
values: value.values && typeof value.values === 'object' && !Array.isArray(value.values)
|
|
449
|
+
? value.values
|
|
450
|
+
: {},
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Find this run's prompt if it is still sitting in the host's inbox.
|
|
455
|
+
*
|
|
456
|
+
* DSH 的 prompt 受理后先进入 inbox,被 turn 取走(claim)就不再出现;停止
|
|
457
|
+
* 时用它区分“还没开始 → 可以从队列里撤掉”和“已经在跑 → 只能 cancel”。
|
|
458
|
+
* `source.rpcId` 是 `session/prompt` 的 requestId,归属不会认错。
|
|
459
|
+
*/
|
|
460
|
+
async findPendingPrompt(sessionId, requestId, opts) {
|
|
461
|
+
const snapshot = await this.projectionSnapshot(sessionId, opts);
|
|
462
|
+
if (!snapshot)
|
|
463
|
+
return undefined;
|
|
464
|
+
return inboxItemsOf(snapshot.values).find((item) => item.source?.kind === 'user' && item.source.rpcId === requestId)?.id;
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Remove one not-yet-claimed inbox occurrence (`session/updateQueue`).
|
|
468
|
+
*
|
|
469
|
+
* @returns true when the host removed it; false when it was claimed between
|
|
470
|
+
* the projection read and this call (`session/queue-item-not-found`).
|
|
471
|
+
*/
|
|
472
|
+
async removeQueuedPrompt(sessionId, itemId, opts) {
|
|
473
|
+
try {
|
|
474
|
+
await this.unary('session/updateQueue', { sessionId, itemId, action: { kind: 'remove' } }, opts);
|
|
475
|
+
return true;
|
|
476
|
+
}
|
|
477
|
+
catch (err) {
|
|
478
|
+
if (err instanceof DshError && err.code === 'session/queue-item-not-found')
|
|
479
|
+
return false;
|
|
480
|
+
throw err;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Whether the session still has a running turn.
|
|
485
|
+
*
|
|
486
|
+
* 主信号是 `session/list` 的 `running` 字段:服务端权威(会话列表页"运行中"
|
|
487
|
+
* 用的就是它),一次 unary,投票不受桥接进程内存影响。名单里没有该会话或
|
|
488
|
+
* 服务端不返回 `running` 时,回退到事件探测({@link probeOpenTurnByEvents}),
|
|
489
|
+
* 两者都判不出来就返回 false(fail open,仍由 DSH 自己拒绝)。
|
|
490
|
+
*/
|
|
491
|
+
async hasOpenTurn(sessionId, opts) {
|
|
492
|
+
return (await this.openTurnState(sessionId, opts)) === 'open';
|
|
493
|
+
}
|
|
494
|
+
/**
|
|
495
|
+
* Tri-state version of {@link hasOpenTurn}.
|
|
496
|
+
*
|
|
497
|
+
* Stop verification must distinguish "the host says idle" from "the probe
|
|
498
|
+
* could not tell" — reporting `closed` on a failed probe would let a still
|
|
499
|
+
* running turn be reported as stopped. `unknown` is the honest answer there.
|
|
500
|
+
*/
|
|
501
|
+
async openTurnState(sessionId, opts) {
|
|
502
|
+
try {
|
|
503
|
+
const list = (await this.unary('session/list', {}, opts));
|
|
504
|
+
const row = list?.items?.find((item) => String(item.sessionId) === sessionId);
|
|
505
|
+
if (row && typeof row.running === 'boolean')
|
|
506
|
+
return row.running ? 'open' : 'closed';
|
|
507
|
+
}
|
|
508
|
+
catch (err) {
|
|
509
|
+
getLogger().warn(`[dsh] open-turn probe: session/list failed for ${sessionId}: ${err.message}`);
|
|
510
|
+
}
|
|
511
|
+
return this.probeOpenTurnByEvents(sessionId, opts);
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Fallback probe: the newest durable event's type tells whether a turn is open.
|
|
515
|
+
*
|
|
516
|
+
* 只在拿不到 `running` 时用。名单刻意保守——`job/*`(后台任务可在 turn 外
|
|
517
|
+
* 结算)、`user/message`、`turn/end` 都不算,判不出来就报告 `unknown`。
|
|
518
|
+
*/
|
|
519
|
+
async probeOpenTurnByEvents(sessionId, opts) {
|
|
520
|
+
try {
|
|
521
|
+
// A missing projection snapshot is not "idle": the host simply cannot
|
|
522
|
+
// tell us. Only a known watermark with no events (or a closed tail) is
|
|
523
|
+
// evidence that no turn is open.
|
|
524
|
+
const snapshot = await this.projectionSnapshot(sessionId, opts);
|
|
525
|
+
if (!snapshot || typeof snapshot.asOfSeq !== 'number' || snapshot.asOfSeq < 0) {
|
|
526
|
+
return 'unknown';
|
|
527
|
+
}
|
|
528
|
+
const value = await this.page(sessionId, snapshot.asOfSeq, 1, opts);
|
|
529
|
+
const last = value.records.at(-1)?.event;
|
|
530
|
+
if (last === undefined)
|
|
531
|
+
return 'closed';
|
|
532
|
+
return last.type === 'turn/start' || IN_TURN_EVENT_TYPES.has(last.type) ? 'open' : 'closed';
|
|
533
|
+
}
|
|
534
|
+
catch (err) {
|
|
535
|
+
getLogger().warn(`[dsh] open-turn probe failed for session ${sessionId}: ${err.message}`);
|
|
536
|
+
return 'unknown';
|
|
537
|
+
}
|
|
538
|
+
}
|
|
379
539
|
/**
|
|
380
540
|
* One logical stream over `/api/remote.mux`. The WebSocket must carry the
|
|
381
541
|
* same auth cookie as unary calls. Ends on the server's `end` frame, socket
|
|
@@ -675,10 +835,41 @@ export class DshClient {
|
|
|
675
835
|
* Per-session subscription: yields session events, assistant stream chunks
|
|
676
836
|
* and approval notices for `sessionId`, auto-reconnecting on stream
|
|
677
837
|
* drop/error and replaying the follow snapshot (dedup by seq). Terminates on
|
|
678
|
-
* turn/end or `signal` abort.
|
|
838
|
+
* the run's own turn/end or `signal` abort.
|
|
839
|
+
*
|
|
840
|
+
* `promptRequestId` binds the stream to this run's prompt. `mode:'queue'`
|
|
841
|
+
* prompts are accepted while the agent is mid-turn and start a turn only
|
|
842
|
+
* later, so the first `turn/end` after `initialSeq` can belong to that OTHER
|
|
843
|
+
* turn. Everything recorded before the prompt's own durable `user/message`
|
|
844
|
+
* (whose source echoes the request id as `rpcId`) is foreign: dropping it
|
|
845
|
+
* keeps the card "running" until this run's turn actually finishes.
|
|
679
846
|
*/
|
|
680
|
-
async *sessionEvents(sessionId, signal, initialSeq = -1) {
|
|
681
|
-
const state = { lastSeq: initialSeq };
|
|
847
|
+
async *sessionEvents(sessionId, signal, initialSeq = -1, promptRequestId) {
|
|
848
|
+
const state = { lastSeq: initialSeq, owns: promptRequestId === undefined };
|
|
849
|
+
let foreignTailReported = false;
|
|
850
|
+
/** Mark this run as the owner once its own prompt message is recorded. */
|
|
851
|
+
const ownsEvent = (ev) => {
|
|
852
|
+
if (state.owns)
|
|
853
|
+
return true;
|
|
854
|
+
if (ev.type !== 'user/message')
|
|
855
|
+
return false;
|
|
856
|
+
const source = ev.data
|
|
857
|
+
?.source;
|
|
858
|
+
if (source?.kind === 'user' && source.rpcId === promptRequestId) {
|
|
859
|
+
state.owns = true;
|
|
860
|
+
return true;
|
|
861
|
+
}
|
|
862
|
+
return false;
|
|
863
|
+
};
|
|
864
|
+
const admit = (ev) => {
|
|
865
|
+
if (ownsEvent(ev))
|
|
866
|
+
return true;
|
|
867
|
+
if (!foreignTailReported) {
|
|
868
|
+
foreignTailReported = true;
|
|
869
|
+
getLogger().info(`[dsh] prompt ${promptRequestId ?? '(none)'} is queued behind a running turn for session ${sessionId}; holding the card open until its own turn starts`);
|
|
870
|
+
}
|
|
871
|
+
return false;
|
|
872
|
+
};
|
|
682
873
|
let authRefreshAttempted = false;
|
|
683
874
|
while (!signal.aborted) {
|
|
684
875
|
try {
|
|
@@ -692,6 +883,8 @@ export class DshClient {
|
|
|
692
883
|
if (!ev || ev.seq <= state.lastSeq)
|
|
693
884
|
continue;
|
|
694
885
|
state.lastSeq = ev.seq;
|
|
886
|
+
if (!admit(ev))
|
|
887
|
+
continue;
|
|
695
888
|
const item = this.eventItem(sessionId, ev);
|
|
696
889
|
if (item)
|
|
697
890
|
yield item;
|
|
@@ -705,6 +898,8 @@ export class DshClient {
|
|
|
705
898
|
if (ev.seq <= state.lastSeq)
|
|
706
899
|
continue;
|
|
707
900
|
state.lastSeq = ev.seq;
|
|
901
|
+
if (!admit(ev))
|
|
902
|
+
continue;
|
|
708
903
|
const item = this.eventItem(sessionId, ev);
|
|
709
904
|
if (item)
|
|
710
905
|
yield item;
|
|
@@ -713,6 +908,9 @@ export class DshClient {
|
|
|
713
908
|
continue;
|
|
714
909
|
}
|
|
715
910
|
if (frame.type === 'assistant-stream') {
|
|
911
|
+
// Mid-turn deltas of the turn this prompt is queued behind.
|
|
912
|
+
if (!state.owns)
|
|
913
|
+
continue;
|
|
716
914
|
yield { kind: 'assistant-stream', sessionId, frame: frame.frame };
|
|
717
915
|
}
|
|
718
916
|
}
|
|
@@ -739,6 +937,93 @@ export class DshClient {
|
|
|
739
937
|
await new Promise((resolve) => setTimeout(resolve, RECONNECT_BACKOFF_MS));
|
|
740
938
|
}
|
|
741
939
|
}
|
|
940
|
+
/**
|
|
941
|
+
* Host-wide live projection stream (`session/control`).
|
|
942
|
+
*
|
|
943
|
+
* 一份 baseline(当前所有活跃 session 的完整投影值)+ 之后每个投影的变化帧。
|
|
944
|
+
* 调用方按 sessionId 过滤:这是 DSH 上唯一"服务端变一次推一次"的通道,
|
|
945
|
+
* 不需要轮询 `session/projections`。
|
|
946
|
+
*
|
|
947
|
+
* 失败必须降级为"没有状态条",不能影响 run:控制流是增强通道,断了运行
|
|
948
|
+
* 照常(异常在此吞掉并记日志,不向 merge 抛)。
|
|
949
|
+
*/
|
|
950
|
+
async *controlFrames(signal) {
|
|
951
|
+
try {
|
|
952
|
+
// mux 的 payload 与 unary 同信封:`{args:{...}}`。control 无参数,故空 args;
|
|
953
|
+
// 少了这层包装网关直接拒绝(live 实测 0 帧,测试用假服务端不会发现)。
|
|
954
|
+
for await (const raw of this.muxStream('session/control', { args: {} }, signal)) {
|
|
955
|
+
const frame = raw;
|
|
956
|
+
if (!frame || typeof frame !== 'object')
|
|
957
|
+
continue;
|
|
958
|
+
if (frame.type === 'baseline') {
|
|
959
|
+
const projections = frame.value?.projections ??
|
|
960
|
+
{};
|
|
961
|
+
for (const [sessionId, snapshot] of Object.entries(projections)) {
|
|
962
|
+
const values = snapshot?.values ?? {};
|
|
963
|
+
for (const [key, value] of Object.entries(values)) {
|
|
964
|
+
yield { kind: 'projection', sessionId, key, value };
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
continue;
|
|
968
|
+
}
|
|
969
|
+
if (frame.type === 'projection' && frame.sessionId && frame.key) {
|
|
970
|
+
yield {
|
|
971
|
+
kind: 'projection',
|
|
972
|
+
sessionId: frame.sessionId,
|
|
973
|
+
key: frame.key,
|
|
974
|
+
value: frame.value,
|
|
975
|
+
};
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
catch (err) {
|
|
980
|
+
if (signal.aborted)
|
|
981
|
+
return;
|
|
982
|
+
getLogger().warn(`[dsh] session/control stream unavailable (${err.message}); run continues without live status`);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* One session's current background-job roster.
|
|
987
|
+
*
|
|
988
|
+
* `job/list` 是流(生命周期变化时推整份名单),但"此刻有哪些任务在跑"只需
|
|
989
|
+
* 要第一帧:文档明确首帧就是当前真相(whole-set replacement)。取到即断开,
|
|
990
|
+
* 不长期持有连接——任务列表只用于收尾时的一句话与停止按钮。
|
|
991
|
+
*
|
|
992
|
+
* 失败返回 undefined(与"没有任务"区分):拿不到就别在卡片上写"0 个后台任务"。
|
|
993
|
+
*/
|
|
994
|
+
async jobRows(sessionId, opts = {}) {
|
|
995
|
+
const timer = new AbortController();
|
|
996
|
+
const timeout = setTimeout(() => timer.abort(), opts.timeoutMs ?? 2_000);
|
|
997
|
+
const signal = opts.signal ? AbortSignal.any([opts.signal, timer.signal]) : timer.signal;
|
|
998
|
+
try {
|
|
999
|
+
for await (const frame of this.muxStream('job/list', { args: dshArgs('job/list', { sessionId }) }, signal)) {
|
|
1000
|
+
const rows = frame;
|
|
1001
|
+
if (rows?.type === 'rows') {
|
|
1002
|
+
return Array.isArray(rows.jobs) ? rows.jobs : [];
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
return undefined;
|
|
1006
|
+
}
|
|
1007
|
+
catch (err) {
|
|
1008
|
+
getLogger().debug(`[dsh] job/list unavailable: ${err.message}`);
|
|
1009
|
+
return undefined;
|
|
1010
|
+
}
|
|
1011
|
+
finally {
|
|
1012
|
+
clearTimeout(timeout);
|
|
1013
|
+
timer.abort();
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
/** Kill one background job on the user's behalf (`job/kill`). */
|
|
1017
|
+
async jobKill(sessionId, jobId, opts = {}) {
|
|
1018
|
+
try {
|
|
1019
|
+
await this.unary('job/kill', { sessionId, jobId }, opts);
|
|
1020
|
+
return true;
|
|
1021
|
+
}
|
|
1022
|
+
catch (err) {
|
|
1023
|
+
getLogger().warn(`[dsh] job/kill failed job=${jobId}: ${err.message}`);
|
|
1024
|
+
return false;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
742
1027
|
/** Extract TokenUsage from an assistant/message data payload (absent → undefined). */
|
|
743
1028
|
static usageOf(data) {
|
|
744
1029
|
return data.usage ?? undefined;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synchronous `session/cancel` for the process-exit path.
|
|
3
|
+
*
|
|
4
|
+
* A DSH turn does not live in a local child process: it runs inside the shared
|
|
5
|
+
* `dsh web` host (`DshDaemon`). So the usual "kill my spawned child" exit
|
|
6
|
+
* cleanup does nothing for it, and a bridge that exits mid-turn leaves the
|
|
7
|
+
* turn running with no subscriber — an orphan turn that keeps consuming tokens
|
|
8
|
+
* and mutating the workspace invisibly (2026-10-19: one ran 2h21m / 345 steps
|
|
9
|
+
* after its bridge died).
|
|
10
|
+
*
|
|
11
|
+
* `process.on('exit')` cannot await, so this path uses the same synchronous
|
|
12
|
+
* curl transport the session reader already uses: bounded by `timeoutMs` and
|
|
13
|
+
* never throws.
|
|
14
|
+
*/
|
|
15
|
+
/** Upper bound on the exit-path cancel:退出不能被一个卡住的 host 拖住。 */
|
|
16
|
+
export declare const EXIT_CANCEL_TIMEOUT_MS = 2000;
|
|
17
|
+
export interface SyncCancelOptions {
|
|
18
|
+
baseUrl: string;
|
|
19
|
+
sessionId: string;
|
|
20
|
+
/** Configured `agents.dsh.dshHome` (selects `.credentials.yaml`). */
|
|
21
|
+
dshHome?: string;
|
|
22
|
+
/** Explicit credentials file; wins over `dshHome`/`$DSH_HOME`. */
|
|
23
|
+
credentialsFile?: string;
|
|
24
|
+
timeoutMs?: number;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Fire one `session/cancel` synchronously. Never throws: a failed cancel must
|
|
28
|
+
* not block the exit path or mask the real exit reason.
|
|
29
|
+
*
|
|
30
|
+
* @returns true when the host accepted the request.
|
|
31
|
+
*/
|
|
32
|
+
export declare function cancelSessionSync(opts: SyncCancelOptions): boolean;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synchronous `session/cancel` for the process-exit path.
|
|
3
|
+
*
|
|
4
|
+
* A DSH turn does not live in a local child process: it runs inside the shared
|
|
5
|
+
* `dsh web` host (`DshDaemon`). So the usual "kill my spawned child" exit
|
|
6
|
+
* cleanup does nothing for it, and a bridge that exits mid-turn leaves the
|
|
7
|
+
* turn running with no subscriber — an orphan turn that keeps consuming tokens
|
|
8
|
+
* and mutating the workspace invisibly (2026-10-19: one ran 2h21m / 345 steps
|
|
9
|
+
* after its bridge died).
|
|
10
|
+
*
|
|
11
|
+
* `process.on('exit')` cannot await, so this path uses the same synchronous
|
|
12
|
+
* curl transport the session reader already uses: bounded by `timeoutMs` and
|
|
13
|
+
* never throws.
|
|
14
|
+
*/
|
|
15
|
+
import { getLogger } from '../../logger/index.js';
|
|
16
|
+
import { isWin32 } from '../../platform/select.js';
|
|
17
|
+
import { spawnProcessSync } from '../../platform/spawn.js';
|
|
18
|
+
import { DSH_COOKIE_LIFETIME_MS, mintDshBrowserCookie, readDshBrowserSecretSync, resolveDshCredentialsFile, } from './auth.js';
|
|
19
|
+
/** Upper bound on the exit-path cancel:退出不能被一个卡住的 host 拖住。 */
|
|
20
|
+
export const EXIT_CANCEL_TIMEOUT_MS = 2_000;
|
|
21
|
+
/**
|
|
22
|
+
* Fire one `session/cancel` synchronously. Never throws: a failed cancel must
|
|
23
|
+
* not block the exit path or mask the real exit reason.
|
|
24
|
+
*
|
|
25
|
+
* @returns true when the host accepted the request.
|
|
26
|
+
*/
|
|
27
|
+
export function cancelSessionSync(opts) {
|
|
28
|
+
const logger = getLogger();
|
|
29
|
+
const timeoutMs = opts.timeoutMs ?? EXIT_CANCEL_TIMEOUT_MS;
|
|
30
|
+
let cookie;
|
|
31
|
+
try {
|
|
32
|
+
const file = opts.credentialsFile?.trim() || resolveDshCredentialsFile(process.env, opts.dshHome);
|
|
33
|
+
cookie = mintDshBrowserCookie(opts.baseUrl, readDshBrowserSecretSync(file), Date.now(), DSH_COOKIE_LIFETIME_MS);
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
// 没有持久密钥就签不出 cookie;此时只能记一笔,放弃取消(启动 token 的
|
|
37
|
+
// /connect 换 cookie 是异步的,退出路径上等不起)。
|
|
38
|
+
logger.warn(`[dsh] exit-cancel skipped: cannot mint cookie (${err.message})`);
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
const body = JSON.stringify({
|
|
42
|
+
type: 'client-request',
|
|
43
|
+
rpcId: `rpc-exit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
44
|
+
method: 'session/cancel',
|
|
45
|
+
payload: { args: { request: { sessionId: opts.sessionId } } },
|
|
46
|
+
});
|
|
47
|
+
const res = spawnProcessSync('curl', [
|
|
48
|
+
'-s',
|
|
49
|
+
'-o',
|
|
50
|
+
isWin32() ? 'NUL' : '/dev/null',
|
|
51
|
+
'-w',
|
|
52
|
+
'%{http_code}',
|
|
53
|
+
'-X',
|
|
54
|
+
'POST',
|
|
55
|
+
`${opts.baseUrl.replace(/\/$/, '')}/api/session/cancel`,
|
|
56
|
+
'-H',
|
|
57
|
+
'content-type: application/json',
|
|
58
|
+
'-H',
|
|
59
|
+
`Cookie: ${cookie}`,
|
|
60
|
+
'--max-time',
|
|
61
|
+
String(Math.max(1, Math.ceil(timeoutMs / 1000))),
|
|
62
|
+
'--data',
|
|
63
|
+
body,
|
|
64
|
+
], { encoding: 'utf-8', timeout: timeoutMs, maxBuffer: 1024 * 1024 });
|
|
65
|
+
const status = String(res.stdout ?? '').trim();
|
|
66
|
+
const ok = status === '200';
|
|
67
|
+
if (!ok) {
|
|
68
|
+
logger.warn(`[dsh] exit-cancel failed for ${opts.sessionId}: ${res.error?.message ?? `http ${status || 'n/a'}`}`);
|
|
69
|
+
}
|
|
70
|
+
return ok;
|
|
71
|
+
}
|
|
72
|
+
//# sourceMappingURL=exit-cancel.js.map
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable record of DSH prompts that may outlive the bridge process.
|
|
3
|
+
*
|
|
4
|
+
* A DSH turn runs inside the shared `dsh web` host, not in a child process. If
|
|
5
|
+
* the bridge dies (SIGKILL, crash) the turn keeps running with no subscriber —
|
|
6
|
+
* the 2026-10-19 incident ran 2h21m / 345 steps after its bridge exited. The
|
|
7
|
+
* runner writes one entry when it submits a prompt and removes it once the run
|
|
8
|
+
* (or a verified stop) accounts for that prompt; the next startup reconciles
|
|
9
|
+
* anything left behind:
|
|
10
|
+
*
|
|
11
|
+
* 1. a still-pending prompt (identified by `source.rpcId`) is removed from the
|
|
12
|
+
* host inbox, so it can never start a turn nobody is watching;
|
|
13
|
+
* 2. a session that still reports an open turn is reported to the owner once —
|
|
14
|
+
* we deliberately do not cancel it, because the open turn may belong to the
|
|
15
|
+
* DSH Web UI rather than to the dead bridge.
|
|
16
|
+
*
|
|
17
|
+
* This is a best-effort safety net, never a correctness dependency: every
|
|
18
|
+
* method swallows storage errors so a broken state file cannot block a run.
|
|
19
|
+
*/
|
|
20
|
+
import type { DshClient } from './client.js';
|
|
21
|
+
/** Single source for the marker file shared by the runner and startup reconcile. */
|
|
22
|
+
export declare function dshInflightPath(configDir: string): string;
|
|
23
|
+
export interface DshInflightEntry {
|
|
24
|
+
sessionId: string;
|
|
25
|
+
promptRequestId: string;
|
|
26
|
+
baseUrl: string;
|
|
27
|
+
cwd?: string;
|
|
28
|
+
updatedAt: number;
|
|
29
|
+
}
|
|
30
|
+
export declare class DshInflightStore {
|
|
31
|
+
private readonly filePath;
|
|
32
|
+
constructor(filePath: string);
|
|
33
|
+
list(): DshInflightEntry[];
|
|
34
|
+
record(entry: Omit<DshInflightEntry, 'updatedAt'>): void;
|
|
35
|
+
clear(sessionId: string, promptRequestId: string): void;
|
|
36
|
+
private write;
|
|
37
|
+
}
|
|
38
|
+
export interface DshInflightReconcileOptions {
|
|
39
|
+
store: DshInflightStore;
|
|
40
|
+
client: Pick<DshClient, 'baseUrl' | 'findPendingPrompt' | 'removeQueuedPrompt' | 'openTurnState'>;
|
|
41
|
+
/** Owner notification; omitted when no p2p recipient is known yet. */
|
|
42
|
+
notify?: (text: string) => Promise<void>;
|
|
43
|
+
timeoutMs?: number;
|
|
44
|
+
}
|
|
45
|
+
export interface DshInflightReconcileResult {
|
|
46
|
+
/** Pending prompts removed from the host inbox. */
|
|
47
|
+
removed: number;
|
|
48
|
+
/** Sessions that still reported an open turn (or an unreadable state). */
|
|
49
|
+
open: number;
|
|
50
|
+
/** True when the host was unreachable; entries are kept for the next start. */
|
|
51
|
+
skipped: boolean;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Reconcile entries left by a previous process.
|
|
55
|
+
*
|
|
56
|
+
* Only entries for the currently configured host are considered; entries for
|
|
57
|
+
* another host are kept untouched. On a transport failure the remaining
|
|
58
|
+
* entries are left in the file so a later startup can retry.
|
|
59
|
+
*/
|
|
60
|
+
export declare function reconcileInflightDshRuns(opts: DshInflightReconcileOptions): Promise<DshInflightReconcileResult>;
|
|
61
|
+
/** One short owner-facing notice; session ids are truncated, not paths. */
|
|
62
|
+
export declare function formatInflightWarning(entries: DshInflightEntry[]): string;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable record of DSH prompts that may outlive the bridge process.
|
|
3
|
+
*
|
|
4
|
+
* A DSH turn runs inside the shared `dsh web` host, not in a child process. If
|
|
5
|
+
* the bridge dies (SIGKILL, crash) the turn keeps running with no subscriber —
|
|
6
|
+
* the 2026-10-19 incident ran 2h21m / 345 steps after its bridge exited. The
|
|
7
|
+
* runner writes one entry when it submits a prompt and removes it once the run
|
|
8
|
+
* (or a verified stop) accounts for that prompt; the next startup reconciles
|
|
9
|
+
* anything left behind:
|
|
10
|
+
*
|
|
11
|
+
* 1. a still-pending prompt (identified by `source.rpcId`) is removed from the
|
|
12
|
+
* host inbox, so it can never start a turn nobody is watching;
|
|
13
|
+
* 2. a session that still reports an open turn is reported to the owner once —
|
|
14
|
+
* we deliberately do not cancel it, because the open turn may belong to the
|
|
15
|
+
* DSH Web UI rather than to the dead bridge.
|
|
16
|
+
*
|
|
17
|
+
* This is a best-effort safety net, never a correctness dependency: every
|
|
18
|
+
* method swallows storage errors so a broken state file cannot block a run.
|
|
19
|
+
*/
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { getLogger } from '../../logger/index.js';
|
|
22
|
+
import { atomicWriteJson } from '../../persistence/atomic-write.js';
|
|
23
|
+
import { loadJsonFile } from '../../persistence/load-json-file.js';
|
|
24
|
+
/** Single source for the marker file shared by the runner and startup reconcile. */
|
|
25
|
+
export function dshInflightPath(configDir) {
|
|
26
|
+
return path.join(configDir, 'dsh-inflight.json');
|
|
27
|
+
}
|
|
28
|
+
/** Keep the file bounded; stale entries are pruned by age and count. */
|
|
29
|
+
const MAX_ENTRIES = 50;
|
|
30
|
+
/** A turn cannot plausibly live this long; drop the record instead of nagging. */
|
|
31
|
+
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
32
|
+
function isEntry(value) {
|
|
33
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
34
|
+
return false;
|
|
35
|
+
const entry = value;
|
|
36
|
+
return (typeof entry.sessionId === 'string' &&
|
|
37
|
+
entry.sessionId.length > 0 &&
|
|
38
|
+
typeof entry.promptRequestId === 'string' &&
|
|
39
|
+
entry.promptRequestId.length > 0 &&
|
|
40
|
+
typeof entry.baseUrl === 'string' &&
|
|
41
|
+
entry.baseUrl.length > 0 &&
|
|
42
|
+
typeof entry.updatedAt === 'number');
|
|
43
|
+
}
|
|
44
|
+
function sameEntry(left, right) {
|
|
45
|
+
return left.sessionId === right.sessionId && left.promptRequestId === right.promptRequestId;
|
|
46
|
+
}
|
|
47
|
+
export class DshInflightStore {
|
|
48
|
+
filePath;
|
|
49
|
+
constructor(filePath) {
|
|
50
|
+
this.filePath = filePath;
|
|
51
|
+
}
|
|
52
|
+
list() {
|
|
53
|
+
const parsed = loadJsonFile(this.filePath, undefined);
|
|
54
|
+
const raw = Array.isArray(parsed?.entries) ? parsed.entries : [];
|
|
55
|
+
const now = Date.now();
|
|
56
|
+
return raw
|
|
57
|
+
.filter(isEntry)
|
|
58
|
+
.filter((entry) => now - entry.updatedAt <= MAX_AGE_MS)
|
|
59
|
+
.slice(-MAX_ENTRIES);
|
|
60
|
+
}
|
|
61
|
+
record(entry) {
|
|
62
|
+
const next = this.list().filter((existing) => !sameEntry(existing, entry));
|
|
63
|
+
next.push({ ...entry, updatedAt: Date.now() });
|
|
64
|
+
this.write(next);
|
|
65
|
+
}
|
|
66
|
+
clear(sessionId, promptRequestId) {
|
|
67
|
+
const next = this.list().filter((entry) => !sameEntry(entry, { sessionId, promptRequestId }));
|
|
68
|
+
this.write(next);
|
|
69
|
+
}
|
|
70
|
+
write(entries) {
|
|
71
|
+
try {
|
|
72
|
+
atomicWriteJson(this.filePath, { entries: entries.slice(-MAX_ENTRIES) });
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
// The marker is a safety net; losing it must never fail the run.
|
|
76
|
+
getLogger().warn(`[dsh] inflight marker write failed: ${err.message}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Reconcile entries left by a previous process.
|
|
82
|
+
*
|
|
83
|
+
* Only entries for the currently configured host are considered; entries for
|
|
84
|
+
* another host are kept untouched. On a transport failure the remaining
|
|
85
|
+
* entries are left in the file so a later startup can retry.
|
|
86
|
+
*/
|
|
87
|
+
export async function reconcileInflightDshRuns(opts) {
|
|
88
|
+
const timeoutMs = opts.timeoutMs ?? 3_000;
|
|
89
|
+
const entries = opts.store.list().filter((entry) => entry.baseUrl === opts.client.baseUrl);
|
|
90
|
+
if (entries.length === 0)
|
|
91
|
+
return { removed: 0, open: 0, skipped: false };
|
|
92
|
+
let removed = 0;
|
|
93
|
+
const open = [];
|
|
94
|
+
for (const entry of entries.slice(0, 10)) {
|
|
95
|
+
try {
|
|
96
|
+
const itemId = await opts.client.findPendingPrompt(entry.sessionId, entry.promptRequestId, {
|
|
97
|
+
timeoutMs,
|
|
98
|
+
});
|
|
99
|
+
if (itemId) {
|
|
100
|
+
const ok = await opts.client.removeQueuedPrompt(entry.sessionId, itemId, { timeoutMs });
|
|
101
|
+
if (ok)
|
|
102
|
+
removed++;
|
|
103
|
+
}
|
|
104
|
+
const state = await opts.client.openTurnState(entry.sessionId, { timeoutMs });
|
|
105
|
+
if (state !== 'closed')
|
|
106
|
+
open.push(entry);
|
|
107
|
+
// Handled once: do not nag on every later startup.
|
|
108
|
+
opts.store.clear(entry.sessionId, entry.promptRequestId);
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
getLogger().warn(`[dsh] inflight reconcile skipped (host unreachable?): ${err.message}`);
|
|
112
|
+
return { removed, open: open.length, skipped: true };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (open.length > 0) {
|
|
116
|
+
getLogger().warn(`[dsh] inflight reconcile: ${open.length} session(s) may still have an open turn`);
|
|
117
|
+
if (opts.notify) {
|
|
118
|
+
try {
|
|
119
|
+
await opts.notify(formatInflightWarning(open));
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
getLogger().warn(`[dsh] inflight notice failed: ${err.message}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return { removed, open: open.length, skipped: false };
|
|
127
|
+
}
|
|
128
|
+
/** One short owner-facing notice; session ids are truncated, not paths. */
|
|
129
|
+
export function formatInflightWarning(entries) {
|
|
130
|
+
const lines = entries.slice(0, 3).map((entry) => {
|
|
131
|
+
const id = entry.sessionId.length > 8 ? entry.sessionId.slice(0, 8) : entry.sessionId;
|
|
132
|
+
return `- session ${id}(${entry.cwd ?? '未知目录'})`;
|
|
133
|
+
});
|
|
134
|
+
if (entries.length > 3)
|
|
135
|
+
lines.push(`- …另有 ${entries.length - 3} 个`);
|
|
136
|
+
return (`⚠️ 上次退出时可能留下了未结束的 DSH turn(${entries.length} 个):\n` +
|
|
137
|
+
`${lines.join('\n')}\n` +
|
|
138
|
+
'请到 DSH Web UI 检查并终止;本机的 /stop 只能停止当前由 lark-remote 发起的 run。');
|
|
139
|
+
}
|
|
140
|
+
//# sourceMappingURL=inflight.js.map
|