openclaw-lark-multi-agent 1.4.9 → 1.4.11
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/README.md +13 -3
- package/README.zh-CN.md +10 -2
- package/dist/feishu-bot.js +6 -3
- package/dist/live-status.d.ts +0 -3
- package/dist/live-status.js +3 -12
- package/dist/openclaw-client.d.ts +2 -0
- package/dist/openclaw-client.js +166 -48
- package/dist/session-status.d.ts +5 -3
- package/dist/session-status.js +11 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -601,10 +601,20 @@ State and context usage come from one bounded Gateway status lookup. K values
|
|
|
601
601
|
are rounded integers (1000 tokens per K); unavailable/stale values are not shown
|
|
602
602
|
as zero. The footer is a send-time snapshot, not a live status widget.
|
|
603
603
|
|
|
604
|
-
LMA
|
|
605
|
-
|
|
606
|
-
|
|
604
|
+
LMA pauses foreground waiting after **10 consecutive minutes without effective
|
|
605
|
+
task activity**, not after ten minutes of total runtime. New owned assistant
|
|
606
|
+
text or distinct tool start/completion resets the collector's idle clock;
|
|
607
|
+
card elapsed ticks, status polling, usage-only and duplicate events do not.
|
|
608
|
+
There is no independent card lifetime, and time before chat.send admission /
|
|
609
|
+
collector start is not charged as task silence. A write completion at 9:35 means
|
|
610
|
+
no silence freeze before 19:35, not a freeze at 10:00.
|
|
611
|
+
LMA sends a normal wait notice rather than claiming execution failed, displaying
|
|
612
|
+
the actual status even if unavailable. Confirmed chat-final `yielded: true`
|
|
613
|
+
still pauses immediately. Once frozen, the old card does not auto-resume after
|
|
614
|
+
later tool activity (same published policy for silence and yield); background
|
|
607
615
|
result handling and the original queue remain, with no automatic abort/replay.
|
|
608
616
|
Real results are sent separately; necessary terminal card cleanup is retained.
|
|
609
617
|
This does not shorten the Gateway's execution limit. Unverified steer V2 and
|
|
610
618
|
question adapters are not included in this release.
|
|
619
|
+
|
|
620
|
+
Timing/API parameters and limitations: [foreground idle semantics](docs/foreground-idle-2026-09-11.md).
|
package/README.zh-CN.md
CHANGED
|
@@ -509,8 +509,16 @@ MIT
|
|
|
509
509
|
1000 token 四舍五入为整数。缺失或明确过期的数据不冒充 0。尾部反映发送前
|
|
510
510
|
查询的状态,不会持续自动刷新历史回复。
|
|
511
511
|
|
|
512
|
-
|
|
513
|
-
|
|
512
|
+
前台等待改为**连续 10 分钟没有有效任务活动才暂停**,不是任务总时长上限。
|
|
513
|
+
已归属本次任务的新 assistant 文本、不同工具调用的开始/结束等会重置 collector
|
|
514
|
+
的静默计时;卡片 elapsed ticker、状态轮询、usage-only 与重复事件不会续时。
|
|
515
|
+
卡片没有独立总时长计时器;chat.send 接受及 collector 启动前的排队/内部耗时不算
|
|
516
|
+
任务静默。9:35 的 write 完成后,最早 19:35 静默到期,不能在 10:00 冻结。
|
|
517
|
+
到期发送普通等待提示,显示实际状态(不可用也不冒充 running),不判任务失败。
|
|
518
|
+
真正的 chat-final `yielded: true` 仍立即提示并冻结,不等 10 分钟。
|
|
519
|
+
普通静默暂停与 yield 都沿用已发布策略:后续工具活动不会自动解冻旧卡。
|
|
514
520
|
后台仍监听原任务,保留原队列,不因此停止或自动重发请求。真实结果另行回复,
|
|
515
521
|
保留必要的终态收尾。本改动不缩短 Gateway 的执行时限,也不包含未验收的
|
|
516
522
|
steer V2 与问答适配器。
|
|
523
|
+
|
|
524
|
+
计时、API 参数及限制详见 [前台静默计时说明](docs/foreground-idle-2026-09-11.md)。
|
package/dist/feishu-bot.js
CHANGED
|
@@ -1361,8 +1361,8 @@ export class FeishuBot {
|
|
|
1361
1361
|
currentMessage: mergedContent,
|
|
1362
1362
|
currentSenderName: lastHuman.senderName,
|
|
1363
1363
|
deliver: false,
|
|
1364
|
-
//
|
|
1365
|
-
//
|
|
1364
|
+
// Pause foreground only after consecutive effective-activity silence;
|
|
1365
|
+
// active long-running tool loops are not a failure.
|
|
1366
1366
|
timeoutMs: FOREGROUND_WAIT_MS,
|
|
1367
1367
|
includeContext: !isNativeCommandTrigger,
|
|
1368
1368
|
includeBridgeAttachmentHint: !isNativeCommandTrigger,
|
|
@@ -1388,7 +1388,10 @@ export class FeishuBot {
|
|
|
1388
1388
|
onProgress: (event) => liveStatus?.progress(event),
|
|
1389
1389
|
onWaitPaused: async (notice) => {
|
|
1390
1390
|
await liveStatus?.showWaitingForResult(notice.message);
|
|
1391
|
-
|
|
1391
|
+
// Yield is a confirmed handoff, not the timeout heuristic. A
|
|
1392
|
+
// recent unrelated reply must not suppress this turn's notice.
|
|
1393
|
+
if (notice.reason === "yield" ? !this.store.hasDeliveredReply(this.config.name, chatId, triggerId)
|
|
1394
|
+
: Date.now() - (this.lastRealDeliveryAt.get(chatId) || 0) >= 5000)
|
|
1392
1395
|
await this.sendWaitPaused(chatId, triggerId, lastHuman.messageId, notice);
|
|
1393
1396
|
},
|
|
1394
1397
|
}));
|
package/dist/live-status.d.ts
CHANGED
|
@@ -50,8 +50,6 @@ export type LiveStatusOptions = {
|
|
|
50
50
|
historySize?: number;
|
|
51
51
|
/** Footer/auto-refresh cadence in ms. */
|
|
52
52
|
tickMs?: number;
|
|
53
|
-
/** Fixed card refresh lifetime; activity does not extend it. */
|
|
54
|
-
refreshBudgetMs?: number;
|
|
55
53
|
};
|
|
56
54
|
export declare class LiveStatusController {
|
|
57
55
|
private readonly callbacks;
|
|
@@ -63,7 +61,6 @@ export declare class LiveStatusController {
|
|
|
63
61
|
private startedAt;
|
|
64
62
|
private createTimer?;
|
|
65
63
|
private tickTimer?;
|
|
66
|
-
private refreshBudgetTimer?;
|
|
67
64
|
private createPromise?;
|
|
68
65
|
private finalized;
|
|
69
66
|
private waitingForResult;
|
package/dist/live-status.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { FOREGROUND_WAIT_MS } from "./session-status.js";
|
|
2
1
|
const DEFAULT_DELAY_MS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_DELAY_MS || 800);
|
|
3
2
|
const DEFAULT_MAX_CHARS = Number(process.env.OPENCLAW_LARK_MULTI_AGENT_LIVE_STATUS_MAX_CHARS || 120);
|
|
4
3
|
// How many recent activity lines to keep in the card content area.
|
|
@@ -23,7 +22,6 @@ export class LiveStatusController {
|
|
|
23
22
|
startedAt = Date.now();
|
|
24
23
|
createTimer;
|
|
25
24
|
tickTimer;
|
|
26
|
-
refreshBudgetTimer;
|
|
27
25
|
createPromise;
|
|
28
26
|
finalized = false;
|
|
29
27
|
waitingForResult = false;
|
|
@@ -49,12 +47,9 @@ export class LiveStatusController {
|
|
|
49
47
|
if (this.disabled || this.finalized || this.updatesFrozen || this.createTimer || this.messageId)
|
|
50
48
|
return;
|
|
51
49
|
this.startedAt = Date.now();
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
: "前台刷新时间已到,过程卡停止实时刷新;后台继续接收结果。").catch(err => this.callbacks.warn?.("live status freeze failed", err));
|
|
56
|
-
}, this.opts.refreshBudgetMs ?? FOREGROUND_WAIT_MS);
|
|
57
|
-
this.refreshBudgetTimer.unref?.();
|
|
50
|
+
// Presentation may start before chat.send admission. Only the collector's
|
|
51
|
+
// effective-activity idle decision (or confirmed yield) freezes this card;
|
|
52
|
+
// elapsed ticks are presentation, never evidence of task progress.
|
|
58
53
|
if (initialDetail && initialDetail.trim()) {
|
|
59
54
|
this.pushLine("lifecycle", initialDetail.trim());
|
|
60
55
|
}
|
|
@@ -253,10 +248,6 @@ export class LiveStatusController {
|
|
|
253
248
|
this.lines = this.lines.slice(this.lines.length - limit);
|
|
254
249
|
}
|
|
255
250
|
stopTimers() {
|
|
256
|
-
if (this.refreshBudgetTimer) {
|
|
257
|
-
clearTimeout(this.refreshBudgetTimer);
|
|
258
|
-
this.refreshBudgetTimer = undefined;
|
|
259
|
-
}
|
|
260
251
|
if (this.createTimer) {
|
|
261
252
|
clearTimeout(this.createTimer);
|
|
262
253
|
this.createTimer = undefined;
|
|
@@ -226,6 +226,7 @@ export declare class OpenClawClient {
|
|
|
226
226
|
message: string;
|
|
227
227
|
attachments?: ChatAttachment[];
|
|
228
228
|
deliver?: boolean;
|
|
229
|
+
/** Consecutive effective-activity silence after admission/collector start; default 10 minutes, not total run time. */
|
|
229
230
|
timeoutMs?: number;
|
|
230
231
|
emptyFinalAsNoReply?: boolean;
|
|
231
232
|
/** Called immediately before issuing chat.send RPC; use for at-most-once bookkeeping. */
|
|
@@ -256,6 +257,7 @@ export declare class OpenClawClient {
|
|
|
256
257
|
currentMessage: string;
|
|
257
258
|
currentSenderName: string;
|
|
258
259
|
deliver?: boolean;
|
|
260
|
+
/** Consecutive effective-activity silence after admission/collector start; default 10 minutes, not total run time. */
|
|
259
261
|
timeoutMs?: number;
|
|
260
262
|
emptyFinalAsNoReply?: boolean;
|
|
261
263
|
/** Native escaped commands (//status -> /status) should not receive catch-up context. */
|
package/dist/openclaw-client.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import WebSocket from "ws";
|
|
2
2
|
import { SessionWaitPaused, normalizeSessionRuntimeStatus, isWaitTimeout, FOREGROUND_WAIT_MS } from "./session-status.js";
|
|
3
|
-
import { randomUUID } from "crypto";
|
|
3
|
+
import { createHash, randomUUID } from "crypto";
|
|
4
4
|
import { mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
|
|
5
5
|
import { basename, extname, join, resolve, sep } from "path";
|
|
6
6
|
import { getBridgeAttachmentsDir, getDataDir } from "./paths.js";
|
|
@@ -231,7 +231,9 @@ export class OpenClawClient {
|
|
|
231
231
|
this.agentEvents.get(sk).push({
|
|
232
232
|
...frame.payload,
|
|
233
233
|
stream: "chatFinal",
|
|
234
|
-
|
|
234
|
+
// Only the Gateway chat-final envelope confirms a handoff.
|
|
235
|
+
// Never infer it from tool names, arguments or raw tool output.
|
|
236
|
+
data: { text: textParts.join("\n"), gatewayYielded: frame.payload?.yielded === true },
|
|
235
237
|
});
|
|
236
238
|
}
|
|
237
239
|
else if (state === "error") {
|
|
@@ -467,6 +469,11 @@ export class OpenClawClient {
|
|
|
467
469
|
/** Render protocol-4 structured tool args/results without `[object Object]`.
|
|
468
470
|
* Prefer useful, non-secret fields and keep the fallback bounded. */
|
|
469
471
|
formatToolValue(value, toolName, phase) {
|
|
472
|
+
// sessions_yield.message is private resumed context. Do not mirror its
|
|
473
|
+
// arguments/results (including JSON strings) into process cards or verbose
|
|
474
|
+
// tool replies. This is redaction only, never evidence of a handoff.
|
|
475
|
+
if (toolName === "sessions_yield")
|
|
476
|
+
return "";
|
|
470
477
|
if (value === null || value === undefined || value === "")
|
|
471
478
|
return "";
|
|
472
479
|
if (typeof value === "string")
|
|
@@ -704,7 +711,7 @@ export class OpenClawClient {
|
|
|
704
711
|
*/
|
|
705
712
|
collectReply(runId, timeoutMs = FOREGROUND_WAIT_MS, targetSessionKey, options) {
|
|
706
713
|
let cleanupObservation = () => { };
|
|
707
|
-
return new Promise((
|
|
714
|
+
return new Promise((resolveResult, rejectResult) => {
|
|
708
715
|
// Whether we were connected when the run started. We only treat a later
|
|
709
716
|
// disconnect as a mid-run gateway drop (fail fast) if we began connected;
|
|
710
717
|
// this keeps unit tests that drive collectReply without a live socket from
|
|
@@ -713,6 +720,7 @@ export class OpenClawClient {
|
|
|
713
720
|
let text = "";
|
|
714
721
|
let chatDeltaText = "";
|
|
715
722
|
let chatFinalText = "";
|
|
723
|
+
let chatFinalSeen = false;
|
|
716
724
|
let transcriptAssistantText = "";
|
|
717
725
|
let sessionKey = targetSessionKey ? `agent:main:${targetSessionKey.replace(/^agent:[^:]+:/, "")}` : "";
|
|
718
726
|
let shortSessionKey = targetSessionKey ? targetSessionKey.replace(/^agent:[^:]+:/, "") : "";
|
|
@@ -739,13 +747,18 @@ export class OpenClawClient {
|
|
|
739
747
|
};
|
|
740
748
|
let idleTimer;
|
|
741
749
|
let observationClosed = false;
|
|
750
|
+
// Close synchronously: a deferred yield notice must not overtake a final
|
|
751
|
+
// or error just because Promise.finally cleanup has not run yet.
|
|
752
|
+
const resolve = (value) => { observationClosed = true; resolveResult(value); };
|
|
753
|
+
const reject = (error) => { observationClosed = true; rejectResult(error); };
|
|
742
754
|
let checkingState = false;
|
|
743
755
|
let lastStateCheckAt = -Infinity;
|
|
744
756
|
let quietNotified = false;
|
|
745
757
|
let decidingTimeout = false;
|
|
746
758
|
let waitNoticeSent = false;
|
|
747
759
|
let foregroundPaused = false;
|
|
748
|
-
let
|
|
760
|
+
let foregroundYielded = false;
|
|
761
|
+
let turnYielded = false;
|
|
749
762
|
const handleTimeoutResult = async (error) => {
|
|
750
763
|
if (observationClosed)
|
|
751
764
|
return;
|
|
@@ -757,8 +770,6 @@ export class OpenClawClient {
|
|
|
757
770
|
// Foreground notice, background observation. Keep the original queue
|
|
758
771
|
// owner/progress path so later input still steers into the same run.
|
|
759
772
|
foregroundPaused = true;
|
|
760
|
-
if (foregroundWaitTimer)
|
|
761
|
-
clearTimeout(foregroundWaitTimer);
|
|
762
773
|
if (!waitNoticeSent) {
|
|
763
774
|
waitNoticeSent = true;
|
|
764
775
|
try {
|
|
@@ -775,7 +786,7 @@ export class OpenClawClient {
|
|
|
775
786
|
}
|
|
776
787
|
reject(error);
|
|
777
788
|
};
|
|
778
|
-
const checkRunState = async () => {
|
|
789
|
+
const checkRunState = async (idleStillCurrent = () => true) => {
|
|
779
790
|
if (observationClosed || checkingState || Date.now() - lastStateCheckAt < 60_000)
|
|
780
791
|
return;
|
|
781
792
|
checkingState = true;
|
|
@@ -801,8 +812,10 @@ export class OpenClawClient {
|
|
|
801
812
|
else if (snapshot.status === "error" || snapshot.status === "timeout") {
|
|
802
813
|
if (snapshot.status === "timeout" || isWaitTimeout(String(snapshot.error || ""))) {
|
|
803
814
|
const status = await this.getSessionRuntimeStatus(targetSessionKey || sessionKey);
|
|
815
|
+
collectEvents();
|
|
804
816
|
if (!observationClosed && status.running) {
|
|
805
|
-
|
|
817
|
+
if (idleStillCurrent())
|
|
818
|
+
await handleTimeoutResult(new SessionWaitPaused(status, true));
|
|
806
819
|
return;
|
|
807
820
|
}
|
|
808
821
|
}
|
|
@@ -817,33 +830,102 @@ export class OpenClawClient {
|
|
|
817
830
|
checkingState = false;
|
|
818
831
|
}
|
|
819
832
|
};
|
|
833
|
+
// One clock for foreground silence and subsequent low-frequency observation.
|
|
834
|
+
// A generation fences status RPCs that complete after newer real activity.
|
|
835
|
+
let idleGeneration = 0;
|
|
820
836
|
const resetIdleTimer = (delayMs = timeoutMs, activity = true) => {
|
|
821
837
|
if (activity) {
|
|
822
838
|
if (!foregroundPaused)
|
|
823
839
|
waitNoticeSent = false;
|
|
824
840
|
quietNotified = false;
|
|
825
841
|
}
|
|
842
|
+
const generation = ++idleGeneration;
|
|
843
|
+
const idleStillCurrent = () => !observationClosed && generation === idleGeneration;
|
|
826
844
|
if (idleTimer)
|
|
827
845
|
clearTimeout(idleTimer);
|
|
828
846
|
if (observationClosed)
|
|
829
847
|
return;
|
|
830
848
|
idleTimer = setTimeout(async () => {
|
|
849
|
+
// Drain already-received frames before judging silence: the 50ms
|
|
850
|
+
// collection poll may otherwise run just after this deadline.
|
|
851
|
+
collectEvents();
|
|
852
|
+
if (!idleStillCurrent())
|
|
853
|
+
return;
|
|
831
854
|
if (!quietNotified) {
|
|
832
855
|
quietNotified = true;
|
|
833
856
|
console.warn(`[OpenClaw] quiet run ${runId}; checking Gateway state without stopping execution`);
|
|
834
857
|
}
|
|
835
|
-
await checkRunState();
|
|
836
|
-
if (!
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
858
|
+
await checkRunState(idleStillCurrent);
|
|
859
|
+
if (!idleStillCurrent())
|
|
860
|
+
return;
|
|
861
|
+
const status = await this.getSessionRuntimeStatus(targetSessionKey || sessionKey);
|
|
862
|
+
collectEvents();
|
|
863
|
+
if (!idleStillCurrent())
|
|
864
|
+
return;
|
|
865
|
+
// With a foreground callback, silence freezes presentation even when
|
|
866
|
+
// status is unknown/idle; never invent running. Without one, retain
|
|
867
|
+
// the published running-only typed outcome and background checks.
|
|
868
|
+
if (status.running || options?.onWaitPaused) {
|
|
869
|
+
await handleTimeoutResult(new SessionWaitPaused(status));
|
|
870
|
+
return;
|
|
842
871
|
}
|
|
843
|
-
|
|
844
|
-
resetIdleTimer(60_000, false);
|
|
872
|
+
resetIdleTimer(60_000, false);
|
|
845
873
|
}, delayMs);
|
|
846
874
|
};
|
|
875
|
+
// Activity filtering is deliberately separate from reply assembly/matching:
|
|
876
|
+
// legacy session fallback must keep delivering replies as before, but an
|
|
877
|
+
// unrelated/unidentified run must not extend this run's foreground wait.
|
|
878
|
+
const activityKeys = new Set();
|
|
879
|
+
const activityText = new Map();
|
|
880
|
+
const claimActivity = (value) => {
|
|
881
|
+
// Retain fingerprints rather than copies of cumulative/private text.
|
|
882
|
+
const key = createHash("sha256").update(value).digest("hex");
|
|
883
|
+
if (activityKeys.has(key))
|
|
884
|
+
return false;
|
|
885
|
+
activityKeys.add(key);
|
|
886
|
+
return true;
|
|
887
|
+
};
|
|
888
|
+
const hasEffectiveActivity = (ev) => {
|
|
889
|
+
if (!ev.runId || !activeRunIds.has(ev.runId))
|
|
890
|
+
return false;
|
|
891
|
+
const data = ev.data || {};
|
|
892
|
+
const run = String(ev.runId);
|
|
893
|
+
if (ev.stream === "tool" || (ev.stream === "item" && data.kind === "tool")) {
|
|
894
|
+
const phase = data.phase === "result" ? (data.isError || data.error ? "error" : "end") : data.phase;
|
|
895
|
+
if (phase !== "start" && phase !== "end" && phase !== "error")
|
|
896
|
+
return false;
|
|
897
|
+
// Canonical tool and legacy item mirrors share identity and phase.
|
|
898
|
+
// Without a call id, conservatively recognize each name/phase once.
|
|
899
|
+
const id = data.toolCallId || data.itemId || data.id || data.name || data.toolName;
|
|
900
|
+
return Boolean(id) && claimActivity(JSON.stringify([run, "tool", id, phase]));
|
|
901
|
+
}
|
|
902
|
+
if (ev.stream === "lifecycle" && data.phase === "start") {
|
|
903
|
+
return claimActivity(JSON.stringify([run, "start"]));
|
|
904
|
+
}
|
|
905
|
+
if (ev.stream === "assistant" || ev.stream === "chatDelta" || ev.stream === "transcriptAssistant") {
|
|
906
|
+
const fullText = typeof data.text === "string" ? data.text : "";
|
|
907
|
+
const chunk = fullText || data.deltaText || data.delta;
|
|
908
|
+
const replace = Boolean(fullText || data.replace);
|
|
909
|
+
if (typeof chunk !== "string" || !chunk.trim())
|
|
910
|
+
return false;
|
|
911
|
+
const source = JSON.stringify([run, ev.stream]);
|
|
912
|
+
// seq identifies repeated incremental frames, not progress by itself.
|
|
913
|
+
// No-seq identical fragments are ambiguous: do not let replay extend idle.
|
|
914
|
+
if (!claimActivity(JSON.stringify([source, ev.seq ?? null, replace, chunk])))
|
|
915
|
+
return false;
|
|
916
|
+
const previous = activityText.get(source) || "";
|
|
917
|
+
const next = replace ? chunk : previous + chunk;
|
|
918
|
+
activityText.set(source, next);
|
|
919
|
+
if (next === previous || (replace && previous.includes(next)))
|
|
920
|
+
return false;
|
|
921
|
+
// Replayed replacement snapshots and assistant/chat/transcript mirrors
|
|
922
|
+
// with the same accumulated text are not additional task progress.
|
|
923
|
+
return claimActivity(JSON.stringify([run, "text", next]));
|
|
924
|
+
}
|
|
925
|
+
// Usage, ticks, user receipts, status/wait responses and unknown streams
|
|
926
|
+
// are not verified task progress. Tool output is counted at completion.
|
|
927
|
+
return false;
|
|
928
|
+
};
|
|
847
929
|
const summarizeActivity = (ev) => {
|
|
848
930
|
if (ev.stream === "item") {
|
|
849
931
|
const data = ev.data || {};
|
|
@@ -906,10 +988,12 @@ export class OpenClawClient {
|
|
|
906
988
|
clearTimeout(genericChatErrorTimer);
|
|
907
989
|
resolve(finalText);
|
|
908
990
|
};
|
|
909
|
-
const
|
|
991
|
+
const collectEvents = () => {
|
|
992
|
+
if (observationClosed)
|
|
993
|
+
return;
|
|
910
994
|
// Gateway connection dropped mid-run (e.g. gateway restart): the run will
|
|
911
995
|
// never deliver a reply on this connection, so stop waiting instead of
|
|
912
|
-
// hanging until the
|
|
996
|
+
// hanging until the idle timeout. Failing fast lets the bridge
|
|
913
997
|
// release its busy lock so new messages are not stuck "waiting".
|
|
914
998
|
if (startedConnected && !this.connected) {
|
|
915
999
|
clearInterval(poller);
|
|
@@ -960,7 +1044,6 @@ export class OpenClawClient {
|
|
|
960
1044
|
bucket.splice(i, 1);
|
|
961
1045
|
if (!anchorSeen && isExpectedUserText(ev.data?.text || "")) {
|
|
962
1046
|
anchorSeen = true;
|
|
963
|
-
resetIdleTimer();
|
|
964
1047
|
for (const pendingEv of preAnchorEvents) {
|
|
965
1048
|
const pendingRunId = typeof pendingEv.runId === "string" ? pendingEv.runId : "";
|
|
966
1049
|
if (pendingRunId && pendingEv.stream === "lifecycle" && pendingEv.data?.phase === "start") {
|
|
@@ -982,6 +1065,16 @@ export class OpenClawClient {
|
|
|
982
1065
|
}
|
|
983
1066
|
continue;
|
|
984
1067
|
}
|
|
1068
|
+
// After handoff, an unowned new run (including requester-settle)
|
|
1069
|
+
// retains its existing proactive delivery route. A session/lifecycle
|
|
1070
|
+
// start alone must not turn it into the old run's final. Discussion's
|
|
1071
|
+
// transcript mute marks scheduler-owned collection; retain its existing
|
|
1072
|
+
// anchored collector path rather than changing discussion routing.
|
|
1073
|
+
if (eventSessionMatches && foregroundYielded && evRunId && !matchesRun
|
|
1074
|
+
&& !this.mutedProactiveSessions.has(sessionKey) && !this.mutedProactiveSessions.has(shortSessionKey)) {
|
|
1075
|
+
bucket.splice(i, 1);
|
|
1076
|
+
continue;
|
|
1077
|
+
}
|
|
985
1078
|
if (eventSessionMatches && expectedUserText && !anchorSeen && !matchesRun) {
|
|
986
1079
|
bucket.splice(i, 1);
|
|
987
1080
|
preAnchorEvents.push(ev);
|
|
@@ -1021,10 +1114,8 @@ export class OpenClawClient {
|
|
|
1021
1114
|
continue;
|
|
1022
1115
|
}
|
|
1023
1116
|
bucket.splice(i, 1);
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
// wall-clock timeout, so long tool-heavy tasks are not killed while active.
|
|
1027
|
-
resetIdleTimer();
|
|
1117
|
+
if (hasEffectiveActivity(ev))
|
|
1118
|
+
resetIdleTimer();
|
|
1028
1119
|
rememberActivity(ev);
|
|
1029
1120
|
// If more events arrive after a replay-invalid lifecycle end, that lifecycle
|
|
1030
1121
|
// was not terminal for the user-visible run. Keep waiting for the real final.
|
|
@@ -1035,6 +1126,10 @@ export class OpenClawClient {
|
|
|
1035
1126
|
if (ev.stream !== "lifecycle") {
|
|
1036
1127
|
pendingRuntimeFailureText = "";
|
|
1037
1128
|
}
|
|
1129
|
+
// A matched resumed turn can again complete via the existing
|
|
1130
|
+
// assistant/transcript + lifecycle path, even without chat final.
|
|
1131
|
+
if (ev.stream === "lifecycle" && ev.data?.phase === "start")
|
|
1132
|
+
turnYielded = false;
|
|
1038
1133
|
if (ev.stream === "lifecycle" && ev.data?.phase === "start" && !lifecycleStartedLogged) {
|
|
1039
1134
|
lifecycleStartedLogged = true;
|
|
1040
1135
|
console.log(`[OpenClaw] lifecycle start for runId=${runId} after ${Date.now() - collectStartedAt}ms`);
|
|
@@ -1067,17 +1162,48 @@ export class OpenClawClient {
|
|
|
1067
1162
|
}
|
|
1068
1163
|
}
|
|
1069
1164
|
if (ev.stream === "chatFinal") {
|
|
1165
|
+
chatFinalSeen = true;
|
|
1166
|
+
if (ev.data?.gatewayYielded === true) {
|
|
1167
|
+
// A yielded turn is not a terminal answer, even if its buffered
|
|
1168
|
+
// message contains a preface. Preserve the collector/queue owner
|
|
1169
|
+
// and final-delivery key; only pause foreground presentation.
|
|
1170
|
+
if (chatFinalText.trim())
|
|
1171
|
+
continue; // an actual final wins
|
|
1172
|
+
foregroundYielded = true;
|
|
1173
|
+
turnYielded = true;
|
|
1174
|
+
text = chatDeltaText = transcriptAssistantText = "";
|
|
1175
|
+
if (chatFinalTimer) {
|
|
1176
|
+
clearTimeout(chatFinalTimer);
|
|
1177
|
+
chatFinalTimer = null;
|
|
1178
|
+
}
|
|
1179
|
+
if (lifecycleEndTimer) {
|
|
1180
|
+
clearTimeout(lifecycleEndTimer);
|
|
1181
|
+
lifecycleEndTimer = null;
|
|
1182
|
+
}
|
|
1183
|
+
// Finish scanning this batch first so an already-arrived real
|
|
1184
|
+
// final/error wins over a stale waiting notice. No status RPC
|
|
1185
|
+
// (or ten-minute silence) is needed to confirm this wire fact.
|
|
1186
|
+
if (options?.onWaitPaused)
|
|
1187
|
+
void Promise.resolve().then(() => handleTimeoutResult(new SessionWaitPaused(normalizeSessionRuntimeStatus(undefined), false, "yield")));
|
|
1188
|
+
continue;
|
|
1189
|
+
}
|
|
1070
1190
|
chatFinalText = ev.data?.text || "";
|
|
1191
|
+
if (chatFinalText.trim())
|
|
1192
|
+
turnYielded = false;
|
|
1193
|
+
else if (turnYielded)
|
|
1194
|
+
continue;
|
|
1071
1195
|
// Fallback: if lifecycle end doesn't arrive within 5s, resolve
|
|
1072
1196
|
if (!chatFinalTimer) {
|
|
1073
1197
|
chatFinalTimer = setTimeout(() => {
|
|
1074
1198
|
console.warn(`[OpenClaw] collectReply: lifecycle end missing, using chatFinal fallback`);
|
|
1075
|
-
this.abortChat(targetSessionKey || sessionKey, runId).catch((err) => {
|
|
1076
|
-
console.warn(`[OpenClaw] abort after chatFinal fallback failed:`, err.message);
|
|
1077
|
-
});
|
|
1078
1199
|
// Prefer final chat message over accumulated deltas: some providers may
|
|
1079
1200
|
// emit only partial deltas (e.g. "N") while final contains "NO_REPLY".
|
|
1080
1201
|
const latestFinalText = this.pickBestCollectedText(chatFinalText, text, chatDeltaText, transcriptAssistantText);
|
|
1202
|
+
// Never abort a yielded original run, nor an empty final.
|
|
1203
|
+
if (!foregroundYielded && latestFinalText)
|
|
1204
|
+
this.abortChat(targetSessionKey || sessionKey, runId).catch((err) => {
|
|
1205
|
+
console.warn(`[OpenClaw] abort after chatFinal fallback failed:`, err.message);
|
|
1206
|
+
});
|
|
1081
1207
|
if (latestFinalText) {
|
|
1082
1208
|
finish(latestFinalText);
|
|
1083
1209
|
}
|
|
@@ -1092,6 +1218,11 @@ export class OpenClawClient {
|
|
|
1092
1218
|
}
|
|
1093
1219
|
}
|
|
1094
1220
|
if (ev.stream === "lifecycle" && ev.data?.phase === "end") {
|
|
1221
|
+
// paused/end_turn can beat the confirming chat event, including
|
|
1222
|
+
// discussion's emptyFinalAsNoReply path. It is neither success
|
|
1223
|
+
// nor failure and alone does NOT authorize a handoff notice.
|
|
1224
|
+
if (!chatFinalText.trim() && (turnYielded || (ev.data?.livenessState === "paused" && ev.data?.stopReason === "end_turn")))
|
|
1225
|
+
continue;
|
|
1095
1226
|
// Prefer final chat message over accumulated deltas: some providers may
|
|
1096
1227
|
// emit only partial deltas (e.g. "N") while final contains "NO_REPLY".
|
|
1097
1228
|
const finalText = this.pickBestCollectedText(chatFinalText, text, chatDeltaText, transcriptAssistantText);
|
|
@@ -1143,7 +1274,12 @@ export class OpenClawClient {
|
|
|
1143
1274
|
};
|
|
1144
1275
|
// If lifecycle end beats chat final, a short delta like "N" can be a truncated
|
|
1145
1276
|
// final reply. Wait for chatFinal before resolving; otherwise suppress lone "N".
|
|
1146
|
-
if (!options?.emptyFinalAsNoReply && ev.data?.livenessState === "working" && !chatFinalText && text.length <= 1)
|
|
1277
|
+
if ((!options?.emptyFinalAsNoReply && ev.data?.livenessState === "working" && !chatFinalText && text.length <= 1)
|
|
1278
|
+
|| (options?.emptyFinalAsNoReply && !chatFinalSeen && !finalText)) {
|
|
1279
|
+
// A lifecycle-only empty discussion end also needs this small
|
|
1280
|
+
// window for a confirming yielded chat final, not instant NO_REPLY.
|
|
1281
|
+
if (lifecycleEndTimer)
|
|
1282
|
+
clearTimeout(lifecycleEndTimer);
|
|
1147
1283
|
lifecycleEndTimer = setTimeout(finishFromLifecycle, 5000);
|
|
1148
1284
|
}
|
|
1149
1285
|
else {
|
|
@@ -1164,7 +1300,6 @@ export class OpenClawClient {
|
|
|
1164
1300
|
// back to this text.
|
|
1165
1301
|
if (this.isRecoverableAgentError(errText)) {
|
|
1166
1302
|
pendingRuntimeFailureText = `⚠️ Agent 未正常完成\n原因: ${errText}`;
|
|
1167
|
-
resetIdleTimer();
|
|
1168
1303
|
console.warn(`[OpenClaw] recoverable lifecycle error for runId=${evRunId || runId}; deferring (agent may auto-recover): ${errText.slice(0, 120)}`);
|
|
1169
1304
|
return;
|
|
1170
1305
|
}
|
|
@@ -1222,7 +1357,6 @@ export class OpenClawClient {
|
|
|
1222
1357
|
}
|
|
1223
1358
|
if (this.isRecoverableAgentError(errText)) {
|
|
1224
1359
|
pendingRuntimeFailureText = `⚠️ Agent 未正常完成\n原因: ${errText}`;
|
|
1225
|
-
resetIdleTimer();
|
|
1226
1360
|
console.warn(`[OpenClaw] recoverable chat error for runId=${evRunId || runId}; deferring: ${errText.slice(0, 120)}`);
|
|
1227
1361
|
bucket.splice(i, 1);
|
|
1228
1362
|
continue;
|
|
@@ -1254,7 +1388,8 @@ export class OpenClawClient {
|
|
|
1254
1388
|
}
|
|
1255
1389
|
}
|
|
1256
1390
|
}
|
|
1257
|
-
}
|
|
1391
|
+
};
|
|
1392
|
+
const poller = setInterval(collectEvents, 50);
|
|
1258
1393
|
const observationKey = targetSessionKey ? this.canonicalSessionKey(targetSessionKey) : "";
|
|
1259
1394
|
const checkAfterNoActiveRun = async () => {
|
|
1260
1395
|
await checkRunState();
|
|
@@ -1273,29 +1408,12 @@ export class OpenClawClient {
|
|
|
1273
1408
|
};
|
|
1274
1409
|
if (observationKey)
|
|
1275
1410
|
this.runStateChecks.set(observationKey, checkAfterNoActiveRun);
|
|
1276
|
-
// Fixed foreground budget: tool/assistant activity must not keep a card
|
|
1277
|
-
// refreshing indefinitely. The original result observer stays alive.
|
|
1278
|
-
if (options?.onWaitPaused) {
|
|
1279
|
-
foregroundWaitTimer = setTimeout(async () => {
|
|
1280
|
-
if (observationClosed || foregroundPaused)
|
|
1281
|
-
return;
|
|
1282
|
-
await checkRunState();
|
|
1283
|
-
if (observationClosed || foregroundPaused)
|
|
1284
|
-
return;
|
|
1285
|
-
const status = await this.getSessionRuntimeStatus(targetSessionKey || sessionKey);
|
|
1286
|
-
if (!observationClosed && !foregroundPaused)
|
|
1287
|
-
await handleTimeoutResult(new SessionWaitPaused(status));
|
|
1288
|
-
}, timeoutMs);
|
|
1289
|
-
foregroundWaitTimer.unref?.();
|
|
1290
|
-
}
|
|
1291
1411
|
// Bound local resources without cancelling an execution we cannot prove stuck.
|
|
1292
1412
|
const observationHorizon = setTimeout(() => reject(new Error("LMA 本地监听达到 24 小时上限,结果未确认;未发送停止请求,请检查原运行。")), 24 * 60 * 60_000);
|
|
1293
1413
|
observationHorizon.unref?.();
|
|
1294
1414
|
cleanupObservation = () => {
|
|
1295
1415
|
observationClosed = true;
|
|
1296
1416
|
clearTimeout(idleTimer);
|
|
1297
|
-
if (foregroundWaitTimer)
|
|
1298
|
-
clearTimeout(foregroundWaitTimer);
|
|
1299
1417
|
clearTimeout(observationHorizon);
|
|
1300
1418
|
clearInterval(poller);
|
|
1301
1419
|
if (chatFinalTimer)
|
package/dist/session-status.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** Consecutive effective-activity silence, not a total foreground lifetime.
|
|
2
|
+
* Starts with accepted-request collection; does not change Gateway execution limits. */
|
|
2
3
|
export declare const FOREGROUND_WAIT_MS: number;
|
|
3
4
|
export type SessionRuntimeStatus = {
|
|
4
5
|
status: string;
|
|
@@ -11,8 +12,9 @@ export declare const isWaitTimeout: (text: string) => boolean;
|
|
|
11
12
|
export declare function normalizeSessionRuntimeStatus(value: any): SessionRuntimeStatus;
|
|
12
13
|
/** Compact presentation only; never replaces the actual status/model values. */
|
|
13
14
|
export declare function formatSessionFooter(snapshot: SessionRuntimeStatus): string;
|
|
14
|
-
/**
|
|
15
|
+
/** Foreground waiting paused, NOT an assertion that execution ended. */
|
|
15
16
|
export declare class SessionWaitPaused extends Error {
|
|
16
17
|
readonly snapshot: SessionRuntimeStatus;
|
|
17
|
-
|
|
18
|
+
readonly reason: "timeout" | "yield";
|
|
19
|
+
constructor(snapshot: SessionRuntimeStatus, reportedTimeout?: boolean, reason?: "timeout" | "yield");
|
|
18
20
|
}
|
package/dist/session-status.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/** Consecutive effective-activity silence, not a total foreground lifetime.
|
|
2
|
+
* Starts with accepted-request collection; does not change Gateway execution limits. */
|
|
2
3
|
export const FOREGROUND_WAIT_MS = 10 * 60_000;
|
|
3
4
|
export const isWaitTimeout = (text) => /\btimeout\b|timed\s*out|超时/i.test(text) && !/invalid|validation|schema|malformed|permission denied|forbidden|unauthorized|authentication failed|quota|rate limit|无效参数|参数错误|校验失败/i.test(text);
|
|
4
5
|
export function normalizeSessionRuntimeStatus(value) {
|
|
@@ -26,14 +27,18 @@ export function formatSessionFooter(snapshot) {
|
|
|
26
27
|
const limitK = limit === undefined ? "?" : Math.max(1, Math.round(limit / 1000));
|
|
27
28
|
return `${snapshot.status} · ${usedK}K/${limitK}K`;
|
|
28
29
|
}
|
|
29
|
-
/**
|
|
30
|
+
/** Foreground waiting paused, NOT an assertion that execution ended. */
|
|
30
31
|
export class SessionWaitPaused extends Error {
|
|
31
32
|
snapshot;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
reason;
|
|
34
|
+
constructor(snapshot, reportedTimeout = false, reason = "timeout") {
|
|
35
|
+
super(reason === "yield"
|
|
36
|
+
? "⏳ 本轮已转入后台等待;若有后续结果,将继续在此回复。未发送停止请求,也未自动重发请求。"
|
|
37
|
+
: reportedTimeout
|
|
38
|
+
? "⏳ 本轮返回了超时,LMA 已暂停本次等待,但不据此认定整个会话已经结束。会话状态见下方;你可以继续等待后续结果。"
|
|
39
|
+
: "⏳ 本轮连续无有效进展的等待时间已到,LMA 暂停本轮等待,先返回会话状态供你判断。\n没有因此停止运行或自动重发本次请求;你可以继续等待后续结果。");
|
|
36
40
|
this.snapshot = snapshot;
|
|
41
|
+
this.reason = reason;
|
|
37
42
|
this.name = "SessionWaitPaused";
|
|
38
43
|
}
|
|
39
44
|
}
|