lark-remote 0.5.8 → 0.5.10
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 +32 -2
- package/dist/bridge/index.d.ts +41 -7
- package/dist/bridge/index.js +255 -129
- package/dist/bridge/queue-manager.d.ts +37 -0
- package/dist/bridge/queue-manager.js +98 -5
- package/dist/card/card-shared.js +4 -0
- package/dist/card/run-renderer.d.ts +8 -1
- package/dist/card/run-renderer.js +100 -9
- package/dist/card/run-state.d.ts +29 -2
- package/dist/card/run-state.js +23 -4
- package/dist/index.js +8 -7
- package/dist/router/index.js +139 -124
- package/dist/router/usage-format.d.ts +5 -0
- package/dist/router/usage-format.js +1 -1
- package/dist/runner/common/jsonrpc/connection-manager.d.ts +6 -0
- package/dist/runner/common/jsonrpc/connection-manager.js +23 -8
- package/dist/runner/dsh/client.d.ts +15 -0
- package/dist/runner/dsh/client.js +27 -0
- package/dist/runner/dsh/goal-view.d.ts +36 -0
- package/dist/runner/dsh/goal-view.js +106 -0
- package/dist/runner/dsh/runner.js +51 -2
- package/dist/runner/index.d.ts +1 -1
- package/dist/runner/types.d.ts +40 -1
- package/dist/session/dsh/sessions.js +2 -1
- package/package.json +1 -1
package/dist/router/index.js
CHANGED
|
@@ -783,9 +783,8 @@ export class CommandRouter {
|
|
|
783
783
|
const { cwd, messageId } = payload;
|
|
784
784
|
const removed = this.bridge.removeFromQueue(cwd, messageId);
|
|
785
785
|
if (removed) {
|
|
786
|
-
//
|
|
787
|
-
|
|
788
|
-
// Send a brief confirmation (user can see the card has been updated)
|
|
786
|
+
// 队列卡的「已撤销」更新由 removeFromQueue 统一触发(单一收口);
|
|
787
|
+
// 这里只负责给点击者一个即时反馈。
|
|
789
788
|
await this.bridge.sendResult({ text: '✅ 已从队列中撤销' }, ctx);
|
|
790
789
|
}
|
|
791
790
|
else {
|
|
@@ -823,134 +822,143 @@ export class CommandRouter {
|
|
|
823
822
|
await this.bridge.sendResult({ text: '⚠️ 正在压缩会话,消息将在压缩完成后按顺序执行。' }, ctx);
|
|
824
823
|
return;
|
|
825
824
|
}
|
|
826
|
-
// 1.
|
|
827
|
-
//
|
|
828
|
-
//
|
|
829
|
-
//
|
|
830
|
-
//
|
|
831
|
-
//
|
|
832
|
-
//
|
|
833
|
-
//
|
|
834
|
-
//
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
//
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
825
|
+
// 1.4. Hold the target's run-card start until this handler has sent its
|
|
826
|
+
// final feedback. The queue chain can advance during the awaits below
|
|
827
|
+
// (interruptCurrentRun / the queue-card PATCH): the target may begin and
|
|
828
|
+
// call forwardToClaude before the toast is sent. Without this hold the
|
|
829
|
+
// run card would land first, leaving the toast as the newest message —
|
|
830
|
+
// the chat list then previews the toast instead of the run card's title
|
|
831
|
+
// (which is how the user follows a run without opening the conversation).
|
|
832
|
+
// Registered before any await, released in finally on every exit path;
|
|
833
|
+
// forwardToClaude waits for it before creating the run card.
|
|
834
|
+
const releaseRunStart = this.bridge.holdQueueTaskRun(cwd, messageId);
|
|
835
|
+
try {
|
|
836
|
+
// 1.5. If the user edited the message, the original closure (captured at
|
|
837
|
+
// enqueue time with stale content) must NOT run. Register a one-shot
|
|
838
|
+
// replacement closure on the task's EXISTING queue slot BEFORE any await:
|
|
839
|
+
// interruptCurrentRun / markQueueCardExecuting can let the chain advance to
|
|
840
|
+
// this task's begin, and begin consumes the replacement at that point — a
|
|
841
|
+
// registration after those awaits would arrive too late (the stale closure
|
|
842
|
+
// runs and the replacement leaks as a permanent dead closure). Registering
|
|
843
|
+
// on the existing slot (instead of removeFromQueue + re-enqueue) preserves
|
|
844
|
+
// the queue position: enqueue can only append to the promise chain tail,
|
|
845
|
+
// which would put the edited task behind tasks queued after it.
|
|
846
|
+
if (targetTask.editedMessage) {
|
|
847
|
+
const editedContent = targetTask.editedMessage;
|
|
848
|
+
///Step3: 替换闭包复用原任务的 binding(不重新快照)+ 恢复丢失的
|
|
849
|
+
// cwdOverride(lane cwd 同源)。否则编辑后重新执行的 run 会丢 cwd 与绑定。
|
|
850
|
+
this.bridge.setTaskReplacement(cwd, messageId, async () => {
|
|
851
|
+
await this.handle(editedContent, ctx, {
|
|
852
|
+
cwdOverride: cwd,
|
|
853
|
+
binding: targetTask.binding,
|
|
854
|
+
});
|
|
844
855
|
});
|
|
856
|
+
}
|
|
857
|
+
// 2. Stop current running process
|
|
858
|
+
// (Note: queue executing count is reset inside Bridge.interruptCurrentRun
|
|
859
|
+
// so all stop paths — /stop, /t, card stop, queue.immediate — are covered.)
|
|
860
|
+
const interrupt = await this.bridge.interruptCurrentRunDetailed({
|
|
861
|
+
userId: ctx.userId,
|
|
862
|
+
chatId: ctx.chatId,
|
|
863
|
+
cwd,
|
|
845
864
|
});
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
// be removed before yielding to the event loop — otherwise a task the user
|
|
873
|
-
// asked to clear can begin executing while its cancellation card is still
|
|
874
|
-
// in flight.
|
|
875
|
-
for (let i = 0; i < targetIdx; i++) {
|
|
876
|
-
const task = tasks[i];
|
|
877
|
-
if (this.bridge.removeFromQueue(cwd, task.messageId)) {
|
|
878
|
-
removedCount++;
|
|
879
|
-
removedIds.push(task.messageId);
|
|
865
|
+
const stopped = interrupt.stopped;
|
|
866
|
+
// A remote (DSH) stop is only a request; when it cannot be confirmed the
|
|
867
|
+
// target must not be promised "immediate" execution — its prompt would sit
|
|
868
|
+
// behind the still-running turn with no visible progress.
|
|
869
|
+
const stopUnconfirmed = interrupt.stop?.status === 'unconfirmed';
|
|
870
|
+
getLogger().info(`[router] queue.immediate: stopped=${stopped} unconfirmed=${stopUnconfirmed}`);
|
|
871
|
+
// 3. Remove all tasks BEFORE this one (by position in the current queue).
|
|
872
|
+
// The target may have begun while interruptCurrentRun was awaiting (the chain
|
|
873
|
+
// advances concurrently): then it is already out of the snapshot and every
|
|
874
|
+
// remaining queued task is BEHIND it — clearing must stop, not run off the
|
|
875
|
+
// end and delete tasks queued after the target.
|
|
876
|
+
const tasks = this.bridge.getQueuedTasks(cwd);
|
|
877
|
+
const targetIdx = tasks.findIndex((t) => t.messageId === messageId);
|
|
878
|
+
let removedCount = 0;
|
|
879
|
+
// Phase 1: remove synchronously. The queue chain advances on any await
|
|
880
|
+
// (card PATCH round trips included), so every task before the target must
|
|
881
|
+
// be removed before yielding to the event loop — otherwise a task the user
|
|
882
|
+
// asked to clear can begin executing while its cancellation card is still
|
|
883
|
+
// in flight.
|
|
884
|
+
for (let i = 0; i < targetIdx; i++) {
|
|
885
|
+
const task = tasks[i];
|
|
886
|
+
// 撤销卡片由 removeFromQueue 统一触发(fire-and-forget),保持本循环
|
|
887
|
+
// 同步推进的既有不变式。
|
|
888
|
+
if (this.bridge.removeFromQueue(cwd, task.messageId)) {
|
|
889
|
+
removedCount++;
|
|
890
|
+
}
|
|
880
891
|
}
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
const editedContent = latestTask.editedMessage;
|
|
900
|
-
///Step3: 复用原任务 binding + 恢复 cwdOverride(同上)。
|
|
901
|
-
this.bridge.setTaskReplacement(cwd, messageId, async () => {
|
|
902
|
-
await this.handle(editedContent, ctx, {
|
|
903
|
-
cwdOverride: cwd,
|
|
904
|
-
binding: latestTask.binding,
|
|
892
|
+
if (targetIdx < 0) {
|
|
893
|
+
getLogger().debug(`[router] queue.immediate: target no longer queued (began/cancelled), not clearing tasks behind it`);
|
|
894
|
+
}
|
|
895
|
+
// 4. DO NOT remove the target task - keep it in queue to execute immediately
|
|
896
|
+
// The queue will naturally execute it after the current task (which we just stopped)
|
|
897
|
+
// 4.4. Refresh the replacement with the latest edited content (idempotent:
|
|
898
|
+
// re-registering over the same slot). Covers the user editing the message
|
|
899
|
+
// again while interruptCurrentRun was awaiting; skipped automatically once
|
|
900
|
+
// the task has begun, because getQueuedTask then returns undefined.
|
|
901
|
+
const latestTask = this.bridge.getQueuedTask(cwd, messageId);
|
|
902
|
+
if (latestTask?.editedMessage) {
|
|
903
|
+
const editedContent = latestTask.editedMessage;
|
|
904
|
+
///Step3: 复用原任务 binding + 恢复 cwdOverride(同上)。
|
|
905
|
+
this.bridge.setTaskReplacement(cwd, messageId, async () => {
|
|
906
|
+
await this.handle(editedContent, ctx, {
|
|
907
|
+
cwdOverride: cwd,
|
|
908
|
+
binding: latestTask.binding,
|
|
909
|
+
});
|
|
905
910
|
});
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
911
|
+
}
|
|
912
|
+
// 4.5/6. The target must only be marked executing (and the toast may only
|
|
913
|
+
// promise immediate execution) when it is really the next task after the
|
|
914
|
+
// stop. While interruptCurrentRun was awaiting, a task ahead of the target
|
|
915
|
+
// may have begun and now occupy the cwd — the target is still queued
|
|
916
|
+
// but is NOT next, so its card must stay queued and the toast must say so.
|
|
917
|
+
const targetStillQueued = this.bridge.getQueuedTask(cwd, messageId) !== undefined;
|
|
918
|
+
if (!targetStillQueued) {
|
|
919
|
+
// The target began or was cancelled while earlier awaits were in flight —
|
|
920
|
+
// a success toast must never promise execution of a task that will not
|
|
921
|
+
// run. The two missing-reason states get distinct feedback: a target
|
|
922
|
+
// that began while the interrupt was in flight is already executing, so
|
|
923
|
+
// the toast must acknowledge that instead of telling the user nothing
|
|
924
|
+
// was scheduled; only a target that was actually cancelled (never began)
|
|
925
|
+
// gets the "未安排执行" wording.
|
|
926
|
+
if (this.bridge.hasTaskBegan(messageId)) {
|
|
927
|
+
await this.bridge.sendResult({ text: `ℹ️ 目标消息已开始执行,无需重复操作。已清除 ${removedCount} 条排队消息。` }, ctx);
|
|
928
|
+
}
|
|
929
|
+
else {
|
|
930
|
+
await this.bridge.sendResult({
|
|
931
|
+
text: `⚠️ 目标消息已不在队列中(可能已被撤销),未安排执行。已清除 ${removedCount} 条排队消息。`,
|
|
932
|
+
}, ctx);
|
|
933
|
+
}
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
const workspaceBusy = this.bridge.isBusyFor(cwd);
|
|
937
|
+
if (stopUnconfirmed) {
|
|
938
|
+
const name = interrupt.agentKind ? agentDisplayName(interrupt.agentKind) : 'agent';
|
|
939
|
+
await this.bridge.sendResult({
|
|
940
|
+
text: `⚠️ 未能确认 ${name} 的 turn 已停止,它可能仍在后台运行。\n` +
|
|
941
|
+
`已清除 ${removedCount} 条排队消息;您的消息已提交,但可能要等该 turn 结束后才开始。`,
|
|
942
|
+
}, ctx);
|
|
943
|
+
}
|
|
944
|
+
else if (!workspaceBusy) {
|
|
945
|
+
// Normal path: nothing new began during the stop, the target is next.
|
|
946
|
+
// Mark its own card as executing now so buttons grey out immediately.
|
|
947
|
+
// Done BEFORE any removal so getQueuedTask still returns the task with
|
|
948
|
+
// its (possibly edited) messagePreview for the card. The queue callback's
|
|
949
|
+
// later updateQueueCardToExecuting call becomes a no-op (idempotent).
|
|
950
|
+
await this.bridge.markQueueCardExecuting(cwd, messageId);
|
|
951
|
+
const stopPrefix = stopped ? '⚡ 已停止当前任务,' : '';
|
|
952
|
+
await this.bridge.sendResult({ text: `${stopPrefix}清除了 ${removedCount} 条排队消息。您的消息将立即执行。` }, ctx);
|
|
924
953
|
}
|
|
925
954
|
else {
|
|
926
955
|
await this.bridge.sendResult({
|
|
927
|
-
text: `⚠️
|
|
956
|
+
text: `⚠️ 排在目标之前的任务已开始执行,目标消息保持排队。已清除 ${removedCount} 条排队消息。`,
|
|
928
957
|
}, ctx);
|
|
929
958
|
}
|
|
930
|
-
return;
|
|
931
959
|
}
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
const name = interrupt.agentKind ? agentDisplayName(interrupt.agentKind) : 'agent';
|
|
935
|
-
await this.bridge.sendResult({
|
|
936
|
-
text: `⚠️ 未能确认 ${name} 的 turn 已停止,它可能仍在后台运行。\n` +
|
|
937
|
-
`已清除 ${removedCount} 条排队消息;您的消息已提交,但可能要等该 turn 结束后才开始。`,
|
|
938
|
-
}, ctx);
|
|
939
|
-
}
|
|
940
|
-
else if (!workspaceBusy) {
|
|
941
|
-
// Normal path: nothing new began during the stop, the target is next.
|
|
942
|
-
// Mark its own card as executing now so buttons grey out immediately.
|
|
943
|
-
// Done BEFORE any removal so getQueuedTask still returns the task with
|
|
944
|
-
// its (possibly edited) messagePreview for the card. The queue callback's
|
|
945
|
-
// later updateQueueCardToExecuting call becomes a no-op (idempotent).
|
|
946
|
-
await this.bridge.markQueueCardExecuting(cwd, messageId);
|
|
947
|
-
const stopPrefix = stopped ? '⚡ 已停止当前任务,' : '';
|
|
948
|
-
await this.bridge.sendResult({ text: `${stopPrefix}清除了 ${removedCount} 条排队消息。您的消息将立即执行。` }, ctx);
|
|
949
|
-
}
|
|
950
|
-
else {
|
|
951
|
-
await this.bridge.sendResult({
|
|
952
|
-
text: `⚠️ 排在目标之前的任务已开始执行,目标消息保持排队。已清除 ${removedCount} 条排队消息。`,
|
|
953
|
-
}, ctx);
|
|
960
|
+
finally {
|
|
961
|
+
releaseRunStart();
|
|
954
962
|
}
|
|
955
963
|
}
|
|
956
964
|
/**
|
|
@@ -2491,6 +2499,11 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
2491
2499
|
'请在 DSH Web UI 检查;需要终止时再发一次 /stop。',
|
|
2492
2500
|
};
|
|
2493
2501
|
}
|
|
2502
|
+
// 多个 workspace 并行时 /stop 会停掉全部;把这件事说出来,避免用户以为
|
|
2503
|
+
// 只停了当前那个(旧实现只停一条且无提示)。
|
|
2504
|
+
const count = interrupt.stoppedCount ?? 1;
|
|
2505
|
+
if (count > 1)
|
|
2506
|
+
return { text: `✅ 已停止 ${count} 个运行中的任务` };
|
|
2494
2507
|
return null;
|
|
2495
2508
|
}
|
|
2496
2509
|
async cmdExit() {
|
|
@@ -2628,6 +2641,7 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
2628
2641
|
activeRun.terminal !== 'error' &&
|
|
2629
2642
|
activeRun.terminal !== 'interrupted' &&
|
|
2630
2643
|
activeRun.terminal !== 'idle_timeout' &&
|
|
2644
|
+
activeRun.terminal !== 'goal_continuing' &&
|
|
2631
2645
|
activeRun.sessionId === sessionId;
|
|
2632
2646
|
const activeUsage = activeRunUsage(activeRun);
|
|
2633
2647
|
return {
|
|
@@ -3538,9 +3552,10 @@ ${sessionCwdLine}${agentLines.map((l) => `- ${l}`).join('\n')}
|
|
|
3538
3552
|
const prefetchCount = Math.min(RESUME_CONTENT_PREFETCH, allSessions.length);
|
|
3539
3553
|
for (const s of allSessions.slice(0, prefetchCount)) {
|
|
3540
3554
|
// 行上已经带了服务端投影预览(DSH:title + turnOutline 末条)→ 跳过预取。
|
|
3541
|
-
// DSH 的 readSessionContent
|
|
3542
|
-
// session/
|
|
3543
|
-
//
|
|
3555
|
+
// DSH 的 readSessionContent 每行走 3 次 unary:`session/list`(cwd 守卫)、
|
|
3556
|
+
// `session/projections`(取 throughSeq)、再 `session/page`(maxMessages=10000,
|
|
3557
|
+
// 拉整段历史)。按前 5 行预取等于 15 次同步 HTTP,而这些信息一次
|
|
3558
|
+
// `session/list` 就已经给全了。
|
|
3544
3559
|
if (s.title || s.lastUserMessage || s.lastAssistantMessage)
|
|
3545
3560
|
continue;
|
|
3546
3561
|
// 优先走轻量摘要(只取标题/用量,不构造 events)——codex 实现了
|
|
@@ -40,7 +40,7 @@ function formatTokenK(n) {
|
|
|
40
40
|
export function formatUsageStats(usage, options) {
|
|
41
41
|
const lines = [];
|
|
42
42
|
// 1. Success 状态(通用格式,无具体结果值)
|
|
43
|
-
lines.push('✅ 已完成');
|
|
43
|
+
lines.push(options?.completedLabel ?? '✅ 已完成');
|
|
44
44
|
// 1.4. 实际生效模型(非空字符串才显示)
|
|
45
45
|
if (usage?.model) {
|
|
46
46
|
lines.push(`Model - ${usage.model}`);
|
|
@@ -147,6 +147,12 @@ export declare class ConnectionManager<TClient extends ConnectionClient = JsonRp
|
|
|
147
147
|
*
|
|
148
148
|
* `idleTtlMs: 0` = 不回收(与 claude 会话层的 `armIdleTimer` 同口径;按 0
|
|
149
149
|
* setTimeout 会秒删刚建好的连接,等于每条消息重起一次进程)。
|
|
150
|
+
*
|
|
151
|
+
* 定时器回调带 **slot 身份校验**:武装之后 slot 可能已被替换——进程自退走
|
|
152
|
+
* baseOnClose(删 slot 但不经过 disposeSlot)、或同 cwd 已 acquire 了新连接。
|
|
153
|
+
* 这两种情况下本定时器都已过期,不得回收「当时恰好挂在该 cwd 上」的连接:
|
|
154
|
+
* 2026-10-24 线上事故即一条泄漏的过期 TTL 杀掉继任连接的在途 turn,卡片
|
|
155
|
+
* 误报 "Codex app-server connection closed"。
|
|
150
156
|
*/
|
|
151
157
|
notifyIdle(cwd: string): void;
|
|
152
158
|
private createClient;
|
|
@@ -183,18 +183,27 @@ export class ConnectionManager {
|
|
|
183
183
|
*
|
|
184
184
|
* `idleTtlMs: 0` = 不回收(与 claude 会话层的 `armIdleTimer` 同口径;按 0
|
|
185
185
|
* setTimeout 会秒删刚建好的连接,等于每条消息重起一次进程)。
|
|
186
|
+
*
|
|
187
|
+
* 定时器回调带 **slot 身份校验**:武装之后 slot 可能已被替换——进程自退走
|
|
188
|
+
* baseOnClose(删 slot 但不经过 disposeSlot)、或同 cwd 已 acquire 了新连接。
|
|
189
|
+
* 这两种情况下本定时器都已过期,不得回收「当时恰好挂在该 cwd 上」的连接:
|
|
190
|
+
* 2026-10-24 线上事故即一条泄漏的过期 TTL 杀掉继任连接的在途 turn,卡片
|
|
191
|
+
* 误报 "Codex app-server connection closed"。
|
|
186
192
|
*/
|
|
187
193
|
notifyIdle(cwd) {
|
|
188
194
|
if (this.idleTtlMs <= 0)
|
|
189
195
|
return;
|
|
190
196
|
const slot = this.slots.get(cwd);
|
|
191
|
-
if (slot)
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
197
|
+
if (!slot)
|
|
198
|
+
return;
|
|
199
|
+
this.clearIdleTimer(slot);
|
|
200
|
+
slot.idleTimer = setTimeout(() => {
|
|
201
|
+
slot.idleTimer = null;
|
|
202
|
+
if (this.slots.get(cwd) !== slot)
|
|
203
|
+
return;
|
|
204
|
+
getLogger().info(`[${this.logTag}] idle timeout for cwd=${cwd}, releasing connection`);
|
|
205
|
+
this.disposeSlot(cwd);
|
|
206
|
+
}, this.idleTtlMs);
|
|
198
207
|
}
|
|
199
208
|
// =========================================================================
|
|
200
209
|
// Internal
|
|
@@ -222,7 +231,13 @@ export class ConnectionManager {
|
|
|
222
231
|
getLogger().info(`[${this.logTag}] connection closed cwd=${cwd}`);
|
|
223
232
|
// 只清理自己创建的 slot:并发 release + 重新 acquire 后 slot 可能已被
|
|
224
233
|
// 新连接占据,误删会破坏新连接。
|
|
225
|
-
|
|
234
|
+
const current = this.slots.get(cwd);
|
|
235
|
+
if (current?.client === client) {
|
|
236
|
+
// 进程自退(codex app-server 偶发 exit 0,非本程序发起,不经
|
|
237
|
+
// disposeSlot)也要撤掉挂在旧 slot 上的 idle TTL:定时器不随 slot
|
|
238
|
+
// 删除而取消,到期时会按 cwd 误杀继任连接的在途 turn(2026-10-24
|
|
239
|
+
// 线上事故,见 notifyIdle 的身份校验与对应回归测试)。
|
|
240
|
+
this.clearIdleTimer(current);
|
|
226
241
|
this.slots.delete(cwd);
|
|
227
242
|
this.onConnectionLost?.(cwd);
|
|
228
243
|
}
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
* replay, and seq-based dedup.
|
|
21
21
|
*/
|
|
22
22
|
import type { DshApprovalStreamItem, DshCommandResult, DshModelCatalogValue, DshModelSelection, DshOpenTurnState, DshPermissionCatalogValue, DshJobView, DshPresetListValue, DshProjectionItem, DshProjectionSnapshot, DshQuestion, DshRemoteEventOutcome, DshSessionPageValue, DshStreamItem, DshTokenUsage } from './types.js';
|
|
23
|
+
import type { GoalView } from '../types.js';
|
|
23
24
|
interface WebSocketMessageEvent {
|
|
24
25
|
data: unknown;
|
|
25
26
|
}
|
|
@@ -193,6 +194,20 @@ export declare class DshClient {
|
|
|
193
194
|
signal?: AbortSignal;
|
|
194
195
|
timeoutMs?: number;
|
|
195
196
|
}): Promise<DshProjectionSnapshot | null>;
|
|
197
|
+
/**
|
|
198
|
+
* Read the session's current goal (one unary, authoritative at call time).
|
|
199
|
+
*
|
|
200
|
+
* 收尾判定必须用宿主状态:本轮 turn 结束不代表任务结束——goal 仍 active 时
|
|
201
|
+
* driver 会在 quiescence 立刻起下一轮。`known:false` 表示读不到,调用方不得
|
|
202
|
+
* 据此断言"已完成"(探测失败降级为收尾,正是"任务还在跑却写完成"的来源)。
|
|
203
|
+
*/
|
|
204
|
+
goalState(sessionId: string, opts?: {
|
|
205
|
+
signal?: AbortSignal;
|
|
206
|
+
timeoutMs?: number;
|
|
207
|
+
}): Promise<{
|
|
208
|
+
known: boolean;
|
|
209
|
+
goal: GoalView | null;
|
|
210
|
+
}>;
|
|
196
211
|
/**
|
|
197
212
|
* Find this run's prompt if it is still sitting in the host's inbox.
|
|
198
213
|
*
|
|
@@ -24,6 +24,7 @@ import WebSocket from 'ws';
|
|
|
24
24
|
import { getLogger } from '../../logger/index.js';
|
|
25
25
|
import { DSH_COOKIE_LIFETIME_MS, isDshCredentialsNotFound, mintDshBrowserCookie, readDshBrowserSecret, resolveDshCredentialsFile, } from './auth.js';
|
|
26
26
|
import { DEFAULT_REQUEST_TIMEOUT_MS } from '../common/defaults.js';
|
|
27
|
+
import { goalFromProjection } from './goal-view.js';
|
|
27
28
|
function defaultWebSocketFactory(url, options) {
|
|
28
29
|
const socket = new WebSocket(url, {
|
|
29
30
|
...(options?.headers ? { headers: options.headers } : {}),
|
|
@@ -449,6 +450,32 @@ export class DshClient {
|
|
|
449
450
|
: {},
|
|
450
451
|
};
|
|
451
452
|
}
|
|
453
|
+
/**
|
|
454
|
+
* Read the session's current goal (one unary, authoritative at call time).
|
|
455
|
+
*
|
|
456
|
+
* 收尾判定必须用宿主状态:本轮 turn 结束不代表任务结束——goal 仍 active 时
|
|
457
|
+
* driver 会在 quiescence 立刻起下一轮。`known:false` 表示读不到,调用方不得
|
|
458
|
+
* 据此断言"已完成"(探测失败降级为收尾,正是"任务还在跑却写完成"的来源)。
|
|
459
|
+
*/
|
|
460
|
+
async goalState(sessionId, opts) {
|
|
461
|
+
let snapshot;
|
|
462
|
+
try {
|
|
463
|
+
snapshot = await this.projectionSnapshot(sessionId, opts);
|
|
464
|
+
}
|
|
465
|
+
catch (err) {
|
|
466
|
+
if (opts?.signal?.aborted)
|
|
467
|
+
throw err;
|
|
468
|
+
getLogger().warn(`[dsh] goal probe failed session=${sessionId}: ${err.message}`);
|
|
469
|
+
return { known: false, goal: null };
|
|
470
|
+
}
|
|
471
|
+
// 拿不到投影 = 宿主没这个会话的投影,不等于"没有 goal"。
|
|
472
|
+
if (!snapshot)
|
|
473
|
+
return { known: false, goal: null };
|
|
474
|
+
const parsed = goalFromProjection(snapshot.values.goal);
|
|
475
|
+
if (parsed === undefined)
|
|
476
|
+
return { known: false, goal: null };
|
|
477
|
+
return { known: true, goal: parsed };
|
|
478
|
+
}
|
|
452
479
|
/**
|
|
453
480
|
* Find this run's prompt if it is still sitting in the host's inbox.
|
|
454
481
|
*
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSH `goal` 域 → 卡片用的 {@link GoalView} 解析。
|
|
3
|
+
*
|
|
4
|
+
* 两个来源、同一个目标类型:
|
|
5
|
+
* 1. 投影值:`session/projections` 的 `values.goal` 与 `session/control` 的
|
|
6
|
+
* `{key:'goal'}` 帧,宿主状态包一层 `current`(`{current:{goal,roundsStarted
|
|
7
|
+
* ,createdAt,updatedAt},seenGoalIds,failure}`);
|
|
8
|
+
* 2. 会话事件 `goal/change`:create/edit/pause/resume/complete/block 携带完整
|
|
9
|
+
* snapshot + roundsStarted;clear 是墓碑,表示"现在没有 goal"。
|
|
10
|
+
*
|
|
11
|
+
* 解析失败返回 `undefined`,表示**读不懂**——它与"没有 goal"(`null`)必须区分:
|
|
12
|
+
* 把读不懂当成没有 goal,就会在 goal 还在自动续跑时收尾卡片。
|
|
13
|
+
*/
|
|
14
|
+
import type { GoalView } from '../types.js';
|
|
15
|
+
/**
|
|
16
|
+
* 解析投影里的 goal 状态。
|
|
17
|
+
*
|
|
18
|
+
* @returns `GoalView` = 有 goal;`null` = 宿主明确没有 goal;`undefined` = 读不懂。
|
|
19
|
+
*/
|
|
20
|
+
export declare function goalFromProjection(value: unknown): GoalView | null | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* 解析 `goal/change` 事件 payload。
|
|
23
|
+
*
|
|
24
|
+
* @returns `GoalView` = 变更后的 goal;`null` = clear(现在没有 goal);
|
|
25
|
+
* `undefined` = 读不懂。
|
|
26
|
+
*/
|
|
27
|
+
export declare function goalFromChange(data: unknown): GoalView | null | undefined;
|
|
28
|
+
/** 便利构造:把解析结果包成 runner 事件。 */
|
|
29
|
+
export declare function goalStateEvent(goal: GoalView | null, known: boolean): {
|
|
30
|
+
type: 'goal_state';
|
|
31
|
+
goal: GoalView | null;
|
|
32
|
+
known: boolean;
|
|
33
|
+
timestamp: string;
|
|
34
|
+
};
|
|
35
|
+
/** 两次观测是否等价(用于抑制重复帧,不触发重渲染)。 */
|
|
36
|
+
export declare function sameGoal(a: GoalView | null | undefined, b: GoalView | null | undefined): boolean;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSH `goal` 域 → 卡片用的 {@link GoalView} 解析。
|
|
3
|
+
*
|
|
4
|
+
* 两个来源、同一个目标类型:
|
|
5
|
+
* 1. 投影值:`session/projections` 的 `values.goal` 与 `session/control` 的
|
|
6
|
+
* `{key:'goal'}` 帧,宿主状态包一层 `current`(`{current:{goal,roundsStarted
|
|
7
|
+
* ,createdAt,updatedAt},seenGoalIds,failure}`);
|
|
8
|
+
* 2. 会话事件 `goal/change`:create/edit/pause/resume/complete/block 携带完整
|
|
9
|
+
* snapshot + roundsStarted;clear 是墓碑,表示"现在没有 goal"。
|
|
10
|
+
*
|
|
11
|
+
* 解析失败返回 `undefined`,表示**读不懂**——它与"没有 goal"(`null`)必须区分:
|
|
12
|
+
* 把读不懂当成没有 goal,就会在 goal 还在自动续跑时收尾卡片。
|
|
13
|
+
*/
|
|
14
|
+
const PHASES = new Set(['active', 'paused', 'blocked', 'complete']);
|
|
15
|
+
function isRecord(value) {
|
|
16
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
17
|
+
}
|
|
18
|
+
function nonNegativeInt(value) {
|
|
19
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : undefined;
|
|
20
|
+
}
|
|
21
|
+
function parseSnapshot(value, roundsStarted) {
|
|
22
|
+
if (!isRecord(value))
|
|
23
|
+
return undefined;
|
|
24
|
+
const phase = value.phase;
|
|
25
|
+
if (typeof phase !== 'string' || !PHASES.has(phase))
|
|
26
|
+
return undefined;
|
|
27
|
+
if (typeof value.objective !== 'string')
|
|
28
|
+
return undefined;
|
|
29
|
+
if (typeof value.id !== 'string' || value.id.length === 0)
|
|
30
|
+
return undefined;
|
|
31
|
+
const max = nonNegativeInt(value.maxGoalRounds);
|
|
32
|
+
if (max === undefined)
|
|
33
|
+
return undefined;
|
|
34
|
+
const reason = value.blockedReason;
|
|
35
|
+
const blockedReason = isRecord(reason) && typeof reason.code === 'string' && typeof reason.message === 'string'
|
|
36
|
+
? { code: reason.code, message: reason.message }
|
|
37
|
+
: undefined;
|
|
38
|
+
return {
|
|
39
|
+
id: value.id,
|
|
40
|
+
objective: value.objective,
|
|
41
|
+
// 上面已按白名单校验,这里收窄回联合类型。
|
|
42
|
+
phase: phase,
|
|
43
|
+
roundsStarted: nonNegativeInt(roundsStarted) ?? 0,
|
|
44
|
+
maxGoalRounds: max,
|
|
45
|
+
...(blockedReason ? { blockedReason } : {}),
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* 解析投影里的 goal 状态。
|
|
50
|
+
*
|
|
51
|
+
* @returns `GoalView` = 有 goal;`null` = 宿主明确没有 goal;`undefined` = 读不懂。
|
|
52
|
+
*/
|
|
53
|
+
export function goalFromProjection(value) {
|
|
54
|
+
if (value === null)
|
|
55
|
+
return null;
|
|
56
|
+
if (!isRecord(value))
|
|
57
|
+
return undefined;
|
|
58
|
+
// 宿主状态信封:{current:{goal,roundsStarted,...}, seenGoalIds, failure}
|
|
59
|
+
if ('current' in value) {
|
|
60
|
+
const current = value.current;
|
|
61
|
+
if (current === null)
|
|
62
|
+
return null;
|
|
63
|
+
if (!isRecord(current))
|
|
64
|
+
return undefined;
|
|
65
|
+
if (current.goal === undefined || current.goal === null)
|
|
66
|
+
return null;
|
|
67
|
+
return parseSnapshot(current.goal, current.roundsStarted);
|
|
68
|
+
}
|
|
69
|
+
// 兼容直接投递 GoalProjection 的形态:{goal, roundsStarted, ...}
|
|
70
|
+
if (value.goal === undefined || value.goal === null)
|
|
71
|
+
return null;
|
|
72
|
+
return parseSnapshot(value.goal, value.roundsStarted);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* 解析 `goal/change` 事件 payload。
|
|
76
|
+
*
|
|
77
|
+
* @returns `GoalView` = 变更后的 goal;`null` = clear(现在没有 goal);
|
|
78
|
+
* `undefined` = 读不懂。
|
|
79
|
+
*/
|
|
80
|
+
export function goalFromChange(data) {
|
|
81
|
+
if (!isRecord(data))
|
|
82
|
+
return undefined;
|
|
83
|
+
if (data.operation === 'clear')
|
|
84
|
+
return null;
|
|
85
|
+
if (typeof data.operation !== 'string')
|
|
86
|
+
return undefined;
|
|
87
|
+
return parseSnapshot(data.goal, data.roundsStarted);
|
|
88
|
+
}
|
|
89
|
+
/** 便利构造:把解析结果包成 runner 事件。 */
|
|
90
|
+
export function goalStateEvent(goal, known) {
|
|
91
|
+
return { type: 'goal_state', goal, known, timestamp: new Date().toISOString() };
|
|
92
|
+
}
|
|
93
|
+
/** 两次观测是否等价(用于抑制重复帧,不触发重渲染)。 */
|
|
94
|
+
export function sameGoal(a, b) {
|
|
95
|
+
if (a === b)
|
|
96
|
+
return true;
|
|
97
|
+
if (a === null || a === undefined || b === null || b === undefined)
|
|
98
|
+
return false;
|
|
99
|
+
return (a.id === b.id &&
|
|
100
|
+
a.phase === b.phase &&
|
|
101
|
+
a.roundsStarted === b.roundsStarted &&
|
|
102
|
+
a.maxGoalRounds === b.maxGoalRounds &&
|
|
103
|
+
a.objective === b.objective &&
|
|
104
|
+
a.blockedReason?.code === b.blockedReason?.code);
|
|
105
|
+
}
|
|
106
|
+
//# sourceMappingURL=goal-view.js.map
|