openclaw-lark-multi-agent 1.4.12 → 1.5.0
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/dist/compact-progress.d.ts +3 -1
- package/dist/compact-progress.js +6 -0
- package/dist/config.d.ts +2 -0
- package/dist/feishu-bot.d.ts +20 -11
- package/dist/feishu-bot.js +298 -61
- package/dist/live-status.d.ts +5 -0
- package/dist/live-status.js +2 -0
- package/dist/message-store.d.ts +30 -0
- package/dist/message-store.js +94 -0
- package/dist/openclaw-client.d.ts +16 -4
- package/dist/openclaw-client.js +151 -22
- package/dist/terminal-errors.d.ts +49 -0
- package/dist/terminal-errors.js +116 -0
- package/package.json +1 -1
package/dist/message-store.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ErrorProvenance, type ErrorVerdict } from "./terminal-errors.js";
|
|
1
2
|
export type BotChatMode = "normal" | "free" | "mute";
|
|
2
3
|
export interface ChatInfo {
|
|
3
4
|
chatId: string;
|
|
@@ -60,9 +61,38 @@ export interface DeliveryOutboxItem {
|
|
|
60
61
|
createdAt: number;
|
|
61
62
|
updatedAt: number;
|
|
62
63
|
}
|
|
64
|
+
export interface PendingErrorNotice {
|
|
65
|
+
botName: string;
|
|
66
|
+
chatId: string;
|
|
67
|
+
noticeId: string;
|
|
68
|
+
provenance: ErrorProvenance;
|
|
69
|
+
checks: number;
|
|
70
|
+
nextCheckAt: number;
|
|
71
|
+
createdAt: number;
|
|
72
|
+
status: "pending" | "parked" | "terminal" | "suppressed" | "stopped";
|
|
73
|
+
verdict?: ErrorVerdict;
|
|
74
|
+
hasObservedContinuingTask?: boolean;
|
|
75
|
+
recoveryProbeUsed?: boolean;
|
|
76
|
+
}
|
|
63
77
|
export declare class MessageStore {
|
|
64
78
|
private db;
|
|
65
79
|
constructor(dbPath?: string);
|
|
80
|
+
observeRunError(botName: string, chatId: string, provenance: ErrorProvenance): PendingErrorNotice;
|
|
81
|
+
getErrorNotice(botName: string, chatId: string, noticeId: string): PendingErrorNotice | undefined;
|
|
82
|
+
saveErrorNotice(record: PendingErrorNotice): void;
|
|
83
|
+
listPendingErrorNotices(botName: string, chatId: string): PendingErrorNotice[];
|
|
84
|
+
/** A later exact final may be content-deduped against its own transcript.
|
|
85
|
+
* Upgrade only identical payloads with the same canonical session/run; never
|
|
86
|
+
* infer identity from text similarity or another run's successful answer. */
|
|
87
|
+
promoteExactRunFinal(botName: string, chatId: string, sessionKey: string, runId: string, contentHash: string, content: string, attachmentsJson: string): void;
|
|
88
|
+
hasExactRunFinalPayload(botName: string, chatId: string, p: {
|
|
89
|
+
sessionKey: string;
|
|
90
|
+
runId: string;
|
|
91
|
+
}, content: string, attachmentsJson: string): boolean;
|
|
92
|
+
hasRunFinalDelivery(botName: string, chatId: string, p: ErrorProvenance, confirmed: boolean): boolean;
|
|
93
|
+
renewUnsentErrorDelivery(id: number, content: string, contentHash: string, metadata: string): void;
|
|
94
|
+
/** Withdraw only an unsent error notice. Never claim it was delivered or touch a result row. */
|
|
95
|
+
withdrawErrorDelivery(id: number): void;
|
|
66
96
|
private init;
|
|
67
97
|
/**
|
|
68
98
|
* Insert a message. Returns the auto-increment id, or -1 if duplicate.
|
package/dist/message-store.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { errorIdentity } from "./terminal-errors.js";
|
|
1
2
|
import Database from "better-sqlite3";
|
|
2
3
|
import { resolve } from "path";
|
|
3
4
|
import { mkdirSync } from "fs";
|
|
@@ -11,8 +12,101 @@ export class MessageStore {
|
|
|
11
12
|
this.db.pragma("journal_mode = WAL");
|
|
12
13
|
this.init();
|
|
13
14
|
}
|
|
15
|
+
observeRunError(botName, chatId, provenance) {
|
|
16
|
+
const noticeId = errorIdentity(provenance.sessionKey, provenance.runId);
|
|
17
|
+
const previous = this.getErrorNotice(botName, chatId, noticeId);
|
|
18
|
+
if (previous) {
|
|
19
|
+
if (provenance.userStop) {
|
|
20
|
+
previous.provenance.userStop = true;
|
|
21
|
+
previous.status = "stopped";
|
|
22
|
+
previous.verdict = { state: "stopped", reason: "explicit_bridge_stop" };
|
|
23
|
+
this.saveErrorNotice(previous);
|
|
24
|
+
}
|
|
25
|
+
// A committed transcript abort is weaker than a gateway terminal error.
|
|
26
|
+
// One stronger envelope can wake a parked observation; repeats cannot.
|
|
27
|
+
const terminalChat = (p) => p.source === "chat" && (p.state === "error" || (p.state === "final" && p.stopReason === "error"));
|
|
28
|
+
if (!provenance.userStop && !previous.provenance.userStop && terminalChat(provenance) && !terminalChat(previous.provenance)
|
|
29
|
+
&& ["pending", "parked"].includes(previous.status)) {
|
|
30
|
+
previous.provenance = provenance;
|
|
31
|
+
previous.status = "pending";
|
|
32
|
+
previous.checks = Math.min(previous.checks, 5);
|
|
33
|
+
previous.nextCheckAt = Date.now();
|
|
34
|
+
this.saveErrorNotice(previous);
|
|
35
|
+
}
|
|
36
|
+
return previous; // duplicate/late events never reset the budget
|
|
37
|
+
}
|
|
38
|
+
const record = { botName, chatId, noticeId, provenance, checks: 0,
|
|
39
|
+
nextCheckAt: Date.now(), createdAt: Date.now(), status: "pending" };
|
|
40
|
+
this.saveErrorNotice(record);
|
|
41
|
+
return record;
|
|
42
|
+
}
|
|
43
|
+
getErrorNotice(botName, chatId, noticeId) {
|
|
44
|
+
const row = this.db.prepare("SELECT record_json FROM error_notices WHERE bot_name=? AND chat_id=? AND notice_id=?")
|
|
45
|
+
.get(botName, chatId, noticeId);
|
|
46
|
+
return row ? JSON.parse(row.record_json) : undefined;
|
|
47
|
+
}
|
|
48
|
+
saveErrorNotice(record) {
|
|
49
|
+
this.db.prepare(`INSERT INTO error_notices (bot_name,chat_id,notice_id,record_json,next_check_at,status)
|
|
50
|
+
VALUES (?,?,?,?,?,?) ON CONFLICT(bot_name,chat_id,notice_id) DO UPDATE SET
|
|
51
|
+
record_json=excluded.record_json,next_check_at=excluded.next_check_at,status=excluded.status`)
|
|
52
|
+
.run(record.botName, record.chatId, record.noticeId, JSON.stringify(record), record.nextCheckAt, record.status);
|
|
53
|
+
}
|
|
54
|
+
listPendingErrorNotices(botName, chatId) {
|
|
55
|
+
return this.db.prepare("SELECT record_json FROM error_notices WHERE bot_name=? AND chat_id=? AND status='pending' ORDER BY next_check_at LIMIT 100")
|
|
56
|
+
.all(botName, chatId).map(row => JSON.parse(row.record_json));
|
|
57
|
+
}
|
|
58
|
+
/** A later exact final may be content-deduped against its own transcript.
|
|
59
|
+
* Upgrade only identical payloads with the same canonical session/run; never
|
|
60
|
+
* infer identity from text similarity or another run's successful answer. */
|
|
61
|
+
promoteExactRunFinal(botName, chatId, sessionKey, runId, contentHash, content, attachmentsJson) {
|
|
62
|
+
this.db.prepare(`UPDATE delivery_outbox
|
|
63
|
+
SET delivery_meta_json=json_set(delivery_meta_json,'$.provenance.final',json('true'))
|
|
64
|
+
WHERE bot_name=? AND chat_id=? AND source_type='assistant_visible' AND content_hash=?
|
|
65
|
+
AND content=? AND attachments_json=?
|
|
66
|
+
AND json_valid(delivery_meta_json)
|
|
67
|
+
AND json_extract(delivery_meta_json,'$.provenance.sessionKey')=?
|
|
68
|
+
AND json_extract(delivery_meta_json,'$.provenance.runId')=?`).run(botName, chatId, contentHash, content, attachmentsJson, sessionKey, runId);
|
|
69
|
+
}
|
|
70
|
+
hasExactRunFinalPayload(botName, chatId, p, content, attachmentsJson) {
|
|
71
|
+
return Boolean(this.db.prepare(`SELECT 1 FROM delivery_outbox
|
|
72
|
+
WHERE bot_name=? AND chat_id=? AND source_type='assistant_visible'
|
|
73
|
+
AND content=? AND attachments_json=? AND status IN ('pending','delivering','delivered')
|
|
74
|
+
AND json_valid(delivery_meta_json) AND json_extract(delivery_meta_json,'$.provenance.final')=1
|
|
75
|
+
AND json_extract(delivery_meta_json,'$.provenance.sessionKey')=?
|
|
76
|
+
AND json_extract(delivery_meta_json,'$.provenance.runId')=? LIMIT 1`)
|
|
77
|
+
.get(botName, chatId, content, attachmentsJson, p.sessionKey, p.runId));
|
|
78
|
+
}
|
|
79
|
+
hasRunFinalDelivery(botName, chatId, p, confirmed) {
|
|
80
|
+
return Boolean(p.runId && this.db.prepare(`SELECT 1 FROM delivery_outbox
|
|
81
|
+
WHERE bot_name=? AND chat_id=? AND source_type='assistant_visible'
|
|
82
|
+
AND json_valid(delivery_meta_json)
|
|
83
|
+
AND json_extract(delivery_meta_json,'$.provenance.final')=1
|
|
84
|
+
AND json_extract(delivery_meta_json,'$.provenance.sessionKey')=?
|
|
85
|
+
AND json_extract(delivery_meta_json,'$.provenance.runId')=?
|
|
86
|
+
AND ${confirmed ? "status='delivered'" : "status IN ('pending','delivering')"} LIMIT 1`)
|
|
87
|
+
.get(botName, chatId, p.sessionKey, p.runId));
|
|
88
|
+
}
|
|
89
|
+
renewUnsentErrorDelivery(id, content, contentHash, metadata) {
|
|
90
|
+
this.db.prepare(`UPDATE delivery_outbox SET status='pending',content=?,content_hash=?,delivery_meta_json=?,updated_at=?
|
|
91
|
+
WHERE id=? AND source_type='run_error' AND status='failed' AND text_delivered=0`)
|
|
92
|
+
.run(content, contentHash, metadata, Date.now(), id);
|
|
93
|
+
}
|
|
94
|
+
/** Withdraw only an unsent error notice. Never claim it was delivered or touch a result row. */
|
|
95
|
+
withdrawErrorDelivery(id) {
|
|
96
|
+
// claimDelivery reserves a send attempt before the asynchronous evidence
|
|
97
|
+
// guard. Withdrawing here means no platform send was attempted: refund only
|
|
98
|
+
// that current claim, never an earlier failed/ambiguous platform attempt.
|
|
99
|
+
this.db.prepare("UPDATE delivery_outbox SET status='failed', attempts=MAX(0,attempts-1), updated_at=? WHERE id=? AND source_type='run_error' AND status='delivering' AND text_delivered=0")
|
|
100
|
+
.run(Date.now(), id);
|
|
101
|
+
}
|
|
14
102
|
init() {
|
|
15
103
|
this.db.exec(`
|
|
104
|
+
CREATE TABLE IF NOT EXISTS error_notices (
|
|
105
|
+
bot_name TEXT NOT NULL, chat_id TEXT NOT NULL, notice_id TEXT NOT NULL,
|
|
106
|
+
record_json TEXT NOT NULL, next_check_at INTEGER NOT NULL,
|
|
107
|
+
status TEXT NOT NULL, PRIMARY KEY(bot_name, chat_id, notice_id)
|
|
108
|
+
);
|
|
109
|
+
CREATE INDEX IF NOT EXISTS idx_error_notices_due ON error_notices(bot_name, chat_id, status, next_check_at);
|
|
16
110
|
CREATE TABLE IF NOT EXISTS messages (
|
|
17
111
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
18
112
|
chat_id TEXT NOT NULL,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ErrorProvenance, type ProactiveMessageMeta } from "./terminal-errors.js";
|
|
1
2
|
import { SessionWaitPaused, type SessionRuntimeStatus } from "./session-status.js";
|
|
2
3
|
import { OpenClawConfig } from "./config.js";
|
|
3
4
|
import { ChatMessage } from "./message-store.js";
|
|
@@ -76,6 +77,11 @@ export declare class OpenClawClient {
|
|
|
76
77
|
private mutedProactiveSessionCounts;
|
|
77
78
|
/** Sessions force-aborted by /stop; collectReply should finish immediately instead of waiting for idle timeout. */
|
|
78
79
|
private forceAbortedSessions;
|
|
80
|
+
/** Explicit-stop evidence comes from exact authorized abort response run IDs,
|
|
81
|
+
* never the persistent session flag used by the original collector. */
|
|
82
|
+
private explicitlyStoppedRuns;
|
|
83
|
+
private pendingExplicitStops;
|
|
84
|
+
private isExplicitlyStoppedRun;
|
|
79
85
|
/** Global limiter for chat.send RPC calls; large multi-bot fan-out can
|
|
80
86
|
* saturate the Gateway before collectReply even starts. The slot is released
|
|
81
87
|
* as soon as the chat.send RPC returns a runId; collectReply does not hold it.
|
|
@@ -137,6 +143,14 @@ export declare class OpenClawClient {
|
|
|
137
143
|
model?: string;
|
|
138
144
|
label?: string;
|
|
139
145
|
}): Promise<any>;
|
|
146
|
+
/** True when an RPC error is the Gateway's "session is archived" refusal. */
|
|
147
|
+
isArchivedSessionError(err: unknown): boolean;
|
|
148
|
+
/**
|
|
149
|
+
* Restore an archived session (e.g. auto-archived by the Gateway's
|
|
150
|
+
* active-session-cap) so new work can start. sessions.patch requires
|
|
151
|
+
* expectedSessionId, so describe first, then patch archived=false.
|
|
152
|
+
*/
|
|
153
|
+
unarchiveSession(key: string): Promise<boolean>;
|
|
140
154
|
getSessionStatus(key: string): Promise<any>;
|
|
141
155
|
injectAssistantMessage(params: {
|
|
142
156
|
sessionKey: string;
|
|
@@ -208,6 +222,7 @@ export declare class OpenClawClient {
|
|
|
208
222
|
abortChat(sessionKey: string, runId?: string): Promise<any>;
|
|
209
223
|
private sessionKeyVariants;
|
|
210
224
|
private trackChatEventSession;
|
|
225
|
+
inspectRunError(provenance: ErrorProvenance): Promise<import("./terminal-errors.js").ErrorVerdict>;
|
|
211
226
|
private extractTextFromChatMessage;
|
|
212
227
|
private emitProactiveForSession;
|
|
213
228
|
private suppressSessionKeys;
|
|
@@ -279,10 +294,7 @@ export declare class OpenClawClient {
|
|
|
279
294
|
* Subscribe to message events for a session.
|
|
280
295
|
* When the agent proactively produces a message, the callback fires.
|
|
281
296
|
*/
|
|
282
|
-
subscribeSession(sessionKey: string, onMessage: (text: string, meta?:
|
|
283
|
-
sourceType?: string;
|
|
284
|
-
runId?: string;
|
|
285
|
-
}) => void): Promise<void>;
|
|
297
|
+
subscribeSession(sessionKey: string, onMessage: (text: string, meta?: ProactiveMessageMeta) => void): Promise<void>;
|
|
286
298
|
setVerboseTranscriptDelivery(sessionKey: string, enabled: boolean): void;
|
|
287
299
|
unsubscribeSession(sessionKey: string): Promise<void>;
|
|
288
300
|
/**
|
package/dist/openclaw-client.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import WebSocket from "ws";
|
|
2
|
+
import { inspectError, genericFailure } from "./terminal-errors.js";
|
|
2
3
|
import { SessionWaitPaused, normalizeSessionRuntimeStatus, isWaitTimeout, FOREGROUND_WAIT_MS } from "./session-status.js";
|
|
3
4
|
import { createHash, randomUUID } from "crypto";
|
|
4
5
|
import { mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
|
|
@@ -80,6 +81,23 @@ export class OpenClawClient {
|
|
|
80
81
|
mutedProactiveSessionCounts = new Map();
|
|
81
82
|
/** Sessions force-aborted by /stop; collectReply should finish immediately instead of waiting for idle timeout. */
|
|
82
83
|
forceAbortedSessions = new Set();
|
|
84
|
+
/** Explicit-stop evidence comes from exact authorized abort response run IDs,
|
|
85
|
+
* never the persistent session flag used by the original collector. */
|
|
86
|
+
explicitlyStoppedRuns = new Map();
|
|
87
|
+
pendingExplicitStops = new Map();
|
|
88
|
+
isExplicitlyStoppedRun(sessionKey, runId) {
|
|
89
|
+
if (!runId)
|
|
90
|
+
return false;
|
|
91
|
+
const key = JSON.stringify([this.canonicalSessionKey(sessionKey), runId]);
|
|
92
|
+
const at = this.explicitlyStoppedRuns.get(key);
|
|
93
|
+
if (at === undefined)
|
|
94
|
+
return false;
|
|
95
|
+
if (Date.now() - at > 24 * 60 * 60_000) {
|
|
96
|
+
this.explicitlyStoppedRuns.delete(key);
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
83
101
|
/** Global limiter for chat.send RPC calls; large multi-bot fan-out can
|
|
84
102
|
* saturate the Gateway before collectReply even starts. The slot is released
|
|
85
103
|
* as soon as the chat.send RPC returns a runId; collectReply does not hold it.
|
|
@@ -639,6 +657,18 @@ export class OpenClawClient {
|
|
|
639
657
|
// structured content arrays rather than a plain string. Extract only visible
|
|
640
658
|
// text parts and ignore thinking/tool blocks so the bridge can deliver final
|
|
641
659
|
// cron results via the bot.
|
|
660
|
+
if (msg?.role === "assistant" && ["error", "aborted"].includes(msg?.stopReason)) {
|
|
661
|
+
if (this.mutedProactiveSessions.has(rawKey) || this.mutedProactiveSessions.has(shortKey))
|
|
662
|
+
return false;
|
|
663
|
+
if (runId && this.ownedDeliveryRuns.has(runId))
|
|
664
|
+
return false;
|
|
665
|
+
return this.emitProactiveForSession(rawKey, String(msg.errorMessage || "Agent run failed"), {
|
|
666
|
+
sourceType: "run_error", runId,
|
|
667
|
+
error: { sessionKey: this.canonicalSessionKey(rawKey), runId: runId || "", source: "session.message",
|
|
668
|
+
state: "final", stopReason: msg.stopReason, detail: String(msg.errorMessage || ""),
|
|
669
|
+
...(this.isExplicitlyStoppedRun(rawKey, runId) ? { userStop: true } : {}) },
|
|
670
|
+
});
|
|
671
|
+
}
|
|
642
672
|
const allowVerboseTranscript = this.isVerboseTranscriptEnabled(rawKey);
|
|
643
673
|
const proactiveText = this.extractVisibleAssistantText(msg, { allowMixedToolText: allowVerboseTranscript });
|
|
644
674
|
if (!proactiveText)
|
|
@@ -658,7 +688,8 @@ export class OpenClawClient {
|
|
|
658
688
|
const cb = this.sessionMessageCallbacks.get(rawKey) || this.sessionMessageCallbacks.get(shortKey);
|
|
659
689
|
if (cb) {
|
|
660
690
|
if (runId)
|
|
661
|
-
cb(proactiveText, { runId
|
|
691
|
+
cb(proactiveText, { runId, sessionKey: this.canonicalSessionKey(rawKey), final: msg?.stopReason === "stop",
|
|
692
|
+
...(!msg?.stopReason && genericFailure(proactiveText) ? { genericErrorCandidate: true } : {}) });
|
|
662
693
|
else
|
|
663
694
|
cb(proactiveText);
|
|
664
695
|
}
|
|
@@ -1514,6 +1545,34 @@ export class OpenClawClient {
|
|
|
1514
1545
|
async patchSession(params) {
|
|
1515
1546
|
return this.rpc("sessions.patch", { ...params, key: this.canonicalSessionKey(params.key) }, 10000);
|
|
1516
1547
|
}
|
|
1548
|
+
/** True when an RPC error is the Gateway's "session is archived" refusal. */
|
|
1549
|
+
isArchivedSessionError(err) {
|
|
1550
|
+
const msg = err instanceof Error ? err.message : String(err ?? "");
|
|
1551
|
+
return /is archived[.,]|Restore it before starting new work/i.test(msg);
|
|
1552
|
+
}
|
|
1553
|
+
/**
|
|
1554
|
+
* Restore an archived session (e.g. auto-archived by the Gateway's
|
|
1555
|
+
* active-session-cap) so new work can start. sessions.patch requires
|
|
1556
|
+
* expectedSessionId, so describe first, then patch archived=false.
|
|
1557
|
+
*/
|
|
1558
|
+
async unarchiveSession(key) {
|
|
1559
|
+
const canonical = this.canonicalSessionKey(key);
|
|
1560
|
+
try {
|
|
1561
|
+
const info = await this.rpc("sessions.describe", { key: canonical }, 15000);
|
|
1562
|
+
const sessionId = info?.session?.sessionId;
|
|
1563
|
+
if (!sessionId)
|
|
1564
|
+
return false;
|
|
1565
|
+
if (info?.session?.archived !== true)
|
|
1566
|
+
return true; // already active
|
|
1567
|
+
const res = await this.rpc("sessions.patch", { key: canonical, expectedSessionId: sessionId, archived: false }, 15000);
|
|
1568
|
+
console.log(`[OpenClaw] unarchived session ${key}: ${res?.ok === true ? "ok" : "unexpected response"}`);
|
|
1569
|
+
return res?.ok === true;
|
|
1570
|
+
}
|
|
1571
|
+
catch (err) {
|
|
1572
|
+
console.warn(`[OpenClaw] unarchive failed for ${key}:`, err instanceof Error ? err.message : err);
|
|
1573
|
+
return false;
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1517
1576
|
async getSessionStatus(key) {
|
|
1518
1577
|
return this.rpc("sessions.describe", { key: this.canonicalSessionKey(key) });
|
|
1519
1578
|
}
|
|
@@ -1770,8 +1829,20 @@ export class OpenClawClient {
|
|
|
1770
1829
|
const maxLines = Number.isFinite(options.maxLines)
|
|
1771
1830
|
? Math.max(1, Math.floor(options.maxLines))
|
|
1772
1831
|
: undefined;
|
|
1832
|
+
const sendCompact = () => this.rpc("sessions.compact", { key: this.canonicalSessionKey(key), ...(maxLines !== undefined ? { maxLines } : {}) }, 10 * 60 * 1000);
|
|
1773
1833
|
try {
|
|
1774
|
-
|
|
1834
|
+
try {
|
|
1835
|
+
return await sendCompact();
|
|
1836
|
+
}
|
|
1837
|
+
catch (err) {
|
|
1838
|
+
if (!this.isArchivedSessionError(err))
|
|
1839
|
+
throw err;
|
|
1840
|
+
console.warn(`[OpenClaw] sessions.compact hit archived session for ${key}; restoring and retrying once`);
|
|
1841
|
+
const restored = await this.unarchiveSession(key);
|
|
1842
|
+
if (!restored)
|
|
1843
|
+
throw err;
|
|
1844
|
+
return await sendCompact();
|
|
1845
|
+
}
|
|
1775
1846
|
}
|
|
1776
1847
|
finally {
|
|
1777
1848
|
const mode = maxLines === undefined ? "semantic" : `maxLines=${maxLines}`;
|
|
@@ -1789,13 +1860,39 @@ export class OpenClawClient {
|
|
|
1789
1860
|
// chat.abort supports { sessionKey } with no runId to abort ALL active runs
|
|
1790
1861
|
// for that session. Used by /stop to force-clear a stuck run.
|
|
1791
1862
|
if (!runId) {
|
|
1863
|
+
for (const [identity, at] of this.explicitlyStoppedRuns)
|
|
1864
|
+
if (Date.now() - at > 24 * 60 * 60_000)
|
|
1865
|
+
this.explicitlyStoppedRuns.delete(identity);
|
|
1792
1866
|
// Mark the session so any in-flight collectReply finishes immediately
|
|
1793
1867
|
// instead of treating the cancelled lifecycle as a transient state.
|
|
1794
|
-
this.
|
|
1795
|
-
|
|
1868
|
+
if (this.runStateChecks.has(key)) {
|
|
1869
|
+
this.forceAbortedSessions.add(key);
|
|
1870
|
+
this.forceAbortedSessions.add(this.shortKey(key));
|
|
1871
|
+
}
|
|
1796
1872
|
}
|
|
1797
1873
|
const params = runId ? { sessionKey: key, runId } : { sessionKey: key };
|
|
1798
|
-
|
|
1874
|
+
if (runId)
|
|
1875
|
+
return this.rpc("chat.abort", params, 5000).catch(() => { });
|
|
1876
|
+
let resolveStop;
|
|
1877
|
+
const barrier = new Promise(resolve => { resolveStop = resolve; });
|
|
1878
|
+
this.pendingExplicitStops.set(key, barrier);
|
|
1879
|
+
try {
|
|
1880
|
+
const result = await this.rpc("chat.abort", params, 5000).catch(() => undefined);
|
|
1881
|
+
// The authorized abort response names the exact runs actually stopped;
|
|
1882
|
+
// it also covers queued/unseen runs with no local status/delta event.
|
|
1883
|
+
if (result?.aborted === true && Array.isArray(result.runIds)) {
|
|
1884
|
+
for (const stopped of result.runIds)
|
|
1885
|
+
if (typeof stopped === "string" && stopped) {
|
|
1886
|
+
this.explicitlyStoppedRuns.set(JSON.stringify([key, stopped]), Date.now());
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
return result;
|
|
1890
|
+
}
|
|
1891
|
+
finally {
|
|
1892
|
+
resolveStop();
|
|
1893
|
+
if (this.pendingExplicitStops.get(key) === barrier)
|
|
1894
|
+
this.pendingExplicitStops.delete(key);
|
|
1895
|
+
}
|
|
1799
1896
|
}
|
|
1800
1897
|
sessionKeyVariants(key) {
|
|
1801
1898
|
const shortKey = this.shortKey(key);
|
|
@@ -1812,39 +1909,55 @@ export class OpenClawClient {
|
|
|
1812
1909
|
// still reach the proactive callback.
|
|
1813
1910
|
return;
|
|
1814
1911
|
}
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1912
|
+
const stopReason = [payload?.stopReason, payload?.message?.stopReason].find(reason => reason === "error" || reason === "aborted")
|
|
1913
|
+
|| payload?.stopReason || payload?.message?.stopReason;
|
|
1914
|
+
const isError = state === "error" || state === "aborted" || stopReason === "error" || stopReason === "aborted";
|
|
1915
|
+
if (isError) {
|
|
1916
|
+
const detail = String(payload?.errorMessage || payload?.message?.errorMessage || payload?.error || payload?.reason || "").trim();
|
|
1917
|
+
const aborted = state === "aborted" || stopReason === "aborted";
|
|
1918
|
+
const text = `⚠️ Agent run ${aborted ? "was aborted" : "failed"}${detail ? `: ${detail}` : ""}`;
|
|
1919
|
+
this.emitProactiveForSession(sessionKey, text, {
|
|
1920
|
+
sourceType: "run_error", runId,
|
|
1921
|
+
error: { sessionKey: this.canonicalSessionKey(sessionKey), runId, source: "chat", state: state || "final",
|
|
1922
|
+
...(typeof stopReason === "string" ? { stopReason } : {}), detail,
|
|
1923
|
+
...(this.isExplicitlyStoppedRun(sessionKey, runId) ? { userStop: true } : {}) },
|
|
1924
|
+
});
|
|
1925
|
+
this.releaseSuppressedSessionKeysAfter(keys, 0);
|
|
1926
|
+
}
|
|
1927
|
+
else if (state === "delta") {
|
|
1818
1928
|
this.suppressSessionKeys(keys);
|
|
1819
1929
|
}
|
|
1820
1930
|
else if (state === "final") {
|
|
1821
1931
|
const text = this.extractTextFromChatMessage(payload?.message);
|
|
1822
1932
|
if (text)
|
|
1823
|
-
this.emitProactiveForSession(sessionKey, text,
|
|
1933
|
+
this.emitProactiveForSession(sessionKey, text, {
|
|
1934
|
+
runId, sessionKey: this.canonicalSessionKey(sessionKey), final: payload?.yielded !== true,
|
|
1935
|
+
...(!stopReason && genericFailure(text) ? { genericErrorCandidate: true } : {}),
|
|
1936
|
+
});
|
|
1824
1937
|
this.releaseSuppressedSessionKeysAfter(keys, 30000);
|
|
1825
1938
|
}
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
this.emitProactiveForSession(sessionKey, text, runId, "run_error");
|
|
1832
|
-
this.releaseSuppressedSessionKeysAfter(keys, 0);
|
|
1939
|
+
}
|
|
1940
|
+
async inspectRunError(provenance) {
|
|
1941
|
+
await this.pendingExplicitStops.get(this.canonicalSessionKey(provenance.sessionKey));
|
|
1942
|
+
if (this.isExplicitlyStoppedRun(provenance.sessionKey, provenance.runId)) {
|
|
1943
|
+
return { state: "stopped", reason: "exact_run_abort_receipt" };
|
|
1833
1944
|
}
|
|
1945
|
+
const verdict = await inspectError(provenance, (method, params, timeout) => this.rpc(method, params, timeout));
|
|
1946
|
+
// A stop request can begin while task/wait evidence is in flight.
|
|
1947
|
+
await this.pendingExplicitStops.get(this.canonicalSessionKey(provenance.sessionKey));
|
|
1948
|
+
return this.isExplicitlyStoppedRun(provenance.sessionKey, provenance.runId)
|
|
1949
|
+
? { state: "stopped", reason: "exact_run_abort_receipt" } : verdict;
|
|
1834
1950
|
}
|
|
1835
1951
|
extractTextFromChatMessage(message) {
|
|
1836
1952
|
const parts = Array.isArray(message?.content) ? message.content : [];
|
|
1837
1953
|
return parts.filter((part) => part?.type === "text" && typeof part.text === "string").map((part) => part.text).join("\n").trim();
|
|
1838
1954
|
}
|
|
1839
|
-
emitProactiveForSession(sessionKey, text,
|
|
1955
|
+
emitProactiveForSession(sessionKey, text, meta) {
|
|
1840
1956
|
const [shortKey, fullKey] = this.sessionKeyVariants(sessionKey);
|
|
1841
1957
|
const cb = this.sessionMessageCallbacks.get(fullKey) || this.sessionMessageCallbacks.get(shortKey);
|
|
1842
1958
|
if (!cb)
|
|
1843
1959
|
return false;
|
|
1844
|
-
|
|
1845
|
-
cb(text, { ...(runId ? { runId } : {}), ...(sourceType ? { sourceType } : {}) });
|
|
1846
|
-
else
|
|
1847
|
-
cb(text);
|
|
1960
|
+
cb(text, meta);
|
|
1848
1961
|
return true;
|
|
1849
1962
|
}
|
|
1850
1963
|
suppressSessionKeys(keys) {
|
|
@@ -1979,13 +2092,28 @@ export class OpenClawClient {
|
|
|
1979
2092
|
const sendStartedAt = Date.now();
|
|
1980
2093
|
try {
|
|
1981
2094
|
await params.onSendAttempt?.();
|
|
1982
|
-
|
|
2095
|
+
const sendChat = () => this.rpc("chat.send", {
|
|
1983
2096
|
sessionKey: this.canonicalSessionKey(sk),
|
|
1984
2097
|
message: params.message,
|
|
1985
2098
|
attachments: params.attachments,
|
|
1986
2099
|
deliver: params.deliver ?? false,
|
|
1987
2100
|
idempotencyKey: randomUUID(),
|
|
1988
2101
|
});
|
|
2102
|
+
try {
|
|
2103
|
+
result = await sendChat();
|
|
2104
|
+
}
|
|
2105
|
+
catch (err) {
|
|
2106
|
+
// The Gateway auto-archives sessions past the active-session cap; an
|
|
2107
|
+
// archived session refuses new work. Restore it once and retry — the
|
|
2108
|
+
// user should never have to fix this by hand.
|
|
2109
|
+
if (!this.isArchivedSessionError(err))
|
|
2110
|
+
throw err;
|
|
2111
|
+
console.warn(`[OpenClaw] chat.send hit archived session for ${sk}; restoring and retrying once`);
|
|
2112
|
+
const restored = await this.unarchiveSession(sk);
|
|
2113
|
+
if (!restored)
|
|
2114
|
+
throw err;
|
|
2115
|
+
result = await sendChat();
|
|
2116
|
+
}
|
|
1989
2117
|
}
|
|
1990
2118
|
finally {
|
|
1991
2119
|
releaseChatSendSlot();
|
|
@@ -2221,6 +2349,7 @@ export class OpenClawClient {
|
|
|
2221
2349
|
this.pendingSteerTexts.clear();
|
|
2222
2350
|
this.steerConsumedCallbacks.clear();
|
|
2223
2351
|
this.ownedDeliveryRuns.clear();
|
|
2352
|
+
this.explicitlyStoppedRuns.clear();
|
|
2224
2353
|
if (this.ws) {
|
|
2225
2354
|
this.ws.close();
|
|
2226
2355
|
this.ws = null;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/** Only gateway envelopes / committed assistant metadata populate this record. */
|
|
2
|
+
export interface ErrorProvenance {
|
|
3
|
+
sessionKey: string;
|
|
4
|
+
runId: string;
|
|
5
|
+
source: "chat" | "session.message";
|
|
6
|
+
state: string;
|
|
7
|
+
stopReason?: string;
|
|
8
|
+
detail?: string;
|
|
9
|
+
userStop?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface ProactiveMessageMeta {
|
|
12
|
+
sourceType?: string;
|
|
13
|
+
runId?: string;
|
|
14
|
+
sessionKey?: string;
|
|
15
|
+
final?: boolean;
|
|
16
|
+
error?: ErrorProvenance;
|
|
17
|
+
terminalEvidence?: ErrorVerdict;
|
|
18
|
+
/** Missing stopReason: only a persisted error for this exact run may qualify. */
|
|
19
|
+
genericErrorCandidate?: boolean;
|
|
20
|
+
}
|
|
21
|
+
export interface TaskEvidence {
|
|
22
|
+
id: string;
|
|
23
|
+
kind?: string;
|
|
24
|
+
runId?: string;
|
|
25
|
+
sourceId?: string;
|
|
26
|
+
sessionKey?: string;
|
|
27
|
+
ownerKey?: string;
|
|
28
|
+
childSessionKey?: string;
|
|
29
|
+
parentTaskId?: string;
|
|
30
|
+
status: string;
|
|
31
|
+
deliveryStatus?: string;
|
|
32
|
+
terminalOutcome?: string;
|
|
33
|
+
}
|
|
34
|
+
export interface ErrorVerdict {
|
|
35
|
+
state: "pending" | "suppressed" | "terminal" | "stopped";
|
|
36
|
+
reason: string;
|
|
37
|
+
task?: TaskEvidence;
|
|
38
|
+
terminalKey?: string;
|
|
39
|
+
text?: string;
|
|
40
|
+
}
|
|
41
|
+
export type ReadRPC = (method: string, params: Record<string, unknown>, timeoutMs: number) => Promise<any>;
|
|
42
|
+
export declare const errorIdentity: (sessionKey: string, runId: string) => string;
|
|
43
|
+
export declare const genericFailure: (text: string) => boolean;
|
|
44
|
+
export declare function announceCandidate(p: ErrorProvenance): {
|
|
45
|
+
runId: string;
|
|
46
|
+
childSessionKey?: string;
|
|
47
|
+
} | undefined;
|
|
48
|
+
/** Read-only and bounded: no model invocation, replay, cancellation or session-status heuristic. */
|
|
49
|
+
export declare function inspectError(p: ErrorProvenance, rpc: ReadRPC): Promise<ErrorVerdict>;
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
export const errorIdentity = (sessionKey, runId) => createHash("sha256").update(JSON.stringify([sessionKey, runId])).digest("hex");
|
|
3
|
+
export const genericFailure = (text) => text.trim() === "The agent run failed before producing a reply.";
|
|
4
|
+
// These are candidate locators, NOT business identities. Verify every component
|
|
5
|
+
// against tasks.list + a fresh tasks.get. Unsupported/batch forms stay unknown.
|
|
6
|
+
export function announceCandidate(p) {
|
|
7
|
+
const uuid = "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}";
|
|
8
|
+
const direct = p.runId.match(new RegExp(`^announce:v1:(agent:[^:]+:.+):(${uuid})$`));
|
|
9
|
+
if (direct)
|
|
10
|
+
return { childSessionKey: direct[1], runId: direct[2] };
|
|
11
|
+
const agent = p.sessionKey.match(/^agent:([^:]+):/)?.[1];
|
|
12
|
+
const prefix = `announce:requester-settle:${agent}:${p.sessionKey}:`;
|
|
13
|
+
if (!agent || !p.runId.startsWith(prefix))
|
|
14
|
+
return;
|
|
15
|
+
const wake = p.runId.slice(prefix.length).match(new RegExp(`^(${uuid})(?::yield-\\d+)?(?::retry-\\d+)?$`));
|
|
16
|
+
if (wake)
|
|
17
|
+
return { runId: wake[1] };
|
|
18
|
+
}
|
|
19
|
+
function projectTask(t) {
|
|
20
|
+
const out = {};
|
|
21
|
+
for (const k of ["id", "kind", "runId", "sourceId", "sessionKey", "ownerKey", "childSessionKey", "parentTaskId", "status", "deliveryStatus", "terminalOutcome"]) {
|
|
22
|
+
if (typeof t?.[k] === "string")
|
|
23
|
+
out[k] = t[k];
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
function matches(t, p, c) {
|
|
28
|
+
return Boolean(t.id && t.kind === "subagent" && t.runId === c.runId
|
|
29
|
+
&& t.sessionKey === p.sessionKey && t.ownerKey === p.sessionKey
|
|
30
|
+
&& t.childSessionKey && (!c.childSessionKey || t.childSessionKey === c.childSessionKey));
|
|
31
|
+
}
|
|
32
|
+
/** Read-only and bounded: no model invocation, replay, cancellation or session-status heuristic. */
|
|
33
|
+
export async function inspectError(p, rpc) {
|
|
34
|
+
if (p.userStop)
|
|
35
|
+
return { state: "stopped", reason: "explicit_bridge_stop" };
|
|
36
|
+
const pending = (reason, task) => ({ state: "pending", reason, task });
|
|
37
|
+
const announce = p.runId.startsWith("announce:");
|
|
38
|
+
if (!p.runId)
|
|
39
|
+
return pending("missing_run_identity");
|
|
40
|
+
if (!announce) {
|
|
41
|
+
// Ordinary chat.error is a gateway terminal envelope (after gateway retry
|
|
42
|
+
// grace). Do not rewrite normal collector/auth/parameter/tool-error handling.
|
|
43
|
+
if (p.source === "chat" && (p.state === "error" || (p.state === "final" && p.stopReason === "error")))
|
|
44
|
+
return {
|
|
45
|
+
state: "terminal", reason: "ordinary_gateway_error", terminalKey: `run-error:${errorIdentity(p.sessionKey, p.runId)}`,
|
|
46
|
+
text: `⚠️ Agent run failed${p.detail ? `: ${p.detail}` : ""}`,
|
|
47
|
+
};
|
|
48
|
+
try {
|
|
49
|
+
const wait = await rpc("agent.wait", { runId: p.runId, timeoutMs: 1 }, 2000);
|
|
50
|
+
const ended = wait?.endedAt;
|
|
51
|
+
const started = wait?.startedAt;
|
|
52
|
+
const terminalTime = typeof ended === "number" && Number.isFinite(ended) && ended > 0
|
|
53
|
+
&& (started === undefined || (typeof started === "number" && Number.isFinite(started) && started >= 0 && ended >= started));
|
|
54
|
+
if (wait?.runId === p.runId && wait?.status === "error" && wait?.pendingError !== true
|
|
55
|
+
&& wait?.yielded !== true && terminalTime)
|
|
56
|
+
return {
|
|
57
|
+
state: "terminal", reason: "exact_run_terminal_error", terminalKey: `run-error:${errorIdentity(p.sessionKey, p.runId)}`,
|
|
58
|
+
text: "⚠️ 该次运行已终止,未能完成回复。",
|
|
59
|
+
};
|
|
60
|
+
return pending("wait_not_terminal_failure");
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return pending("wait_unavailable");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const candidate = announceCandidate(p);
|
|
67
|
+
if (!candidate)
|
|
68
|
+
return pending("unsupported_announce_lineage");
|
|
69
|
+
try {
|
|
70
|
+
// At most two pages; absence or truncation is never evidence of failure.
|
|
71
|
+
let cursor;
|
|
72
|
+
const matchesFound = [];
|
|
73
|
+
for (let page = 0; page < 2; page++) {
|
|
74
|
+
const result = await rpc("tasks.list", { sessionKey: p.sessionKey, limit: 100, ...(cursor ? { cursor } : {}) }, 2000);
|
|
75
|
+
if (!Array.isArray(result?.tasks))
|
|
76
|
+
return pending("unsupported_task_response");
|
|
77
|
+
for (const raw of result.tasks) {
|
|
78
|
+
const task = projectTask(raw);
|
|
79
|
+
if (matches(task, p, candidate))
|
|
80
|
+
matchesFound.push(task);
|
|
81
|
+
}
|
|
82
|
+
cursor = typeof result.nextCursor === "string" ? result.nextCursor : undefined;
|
|
83
|
+
if (!cursor)
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
const ids = [...new Set(matchesFound.map(t => t.id))];
|
|
87
|
+
if (ids.length !== 1 || cursor)
|
|
88
|
+
return pending("task_lineage_missing_ambiguous_or_truncated");
|
|
89
|
+
const task = projectTask((await rpc("tasks.get", { taskId: ids[0] }, 2000))?.task);
|
|
90
|
+
if (task.id !== ids[0] || !matches(task, p, candidate))
|
|
91
|
+
return pending("task_lineage_changed");
|
|
92
|
+
if (["queued", "running"].includes(task.status) || ["pending", "session_queued"].includes(task.deliveryStatus || "")) {
|
|
93
|
+
return pending("matching_task_or_delivery_continuing", task);
|
|
94
|
+
}
|
|
95
|
+
if (task.status === "completed" && task.deliveryStatus === "delivered" && task.terminalOutcome !== "blocked") {
|
|
96
|
+
return { state: "suppressed", reason: "matching_task_completed_delivery_confirmed", task };
|
|
97
|
+
}
|
|
98
|
+
const terminalKey = `task-error:${errorIdentity(p.sessionKey, task.id)}`;
|
|
99
|
+
// A failed child is not the whole user request. Do not borrow another child's
|
|
100
|
+
// successful result, or substitute raw child output for the parent answer.
|
|
101
|
+
if (["failed", "timed_out"].includes(task.status) && ["delivered", "failed", "not_applicable"].includes(task.deliveryStatus || "")) {
|
|
102
|
+
return { state: "terminal", reason: "matching_child_terminal_failure", task, terminalKey,
|
|
103
|
+
text: `⚠️ 子任务 ${task.id} ${task.status === "timed_out" ? "已超时终止" : "已失败"};这不代表同会话其他任务失败。` };
|
|
104
|
+
}
|
|
105
|
+
// Public schema has no requester-settle retry counter. Only the explicit
|
|
106
|
+
// blocked + failed delivery projection proves that continuation is blocked.
|
|
107
|
+
if (task.status === "completed" && task.deliveryStatus === "failed" && task.terminalOutcome === "blocked") {
|
|
108
|
+
return { state: "terminal", reason: "matching_completion_delivery_blocked", task, terminalKey,
|
|
109
|
+
text: `⚠️ 子任务 ${task.id} 已完成,但结果回传已终止,需要人工处理;并非任务执行失败。` };
|
|
110
|
+
}
|
|
111
|
+
return pending("task_does_not_prove_attempt_chain_terminal", task);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return pending("task_lookup_unavailable");
|
|
115
|
+
}
|
|
116
|
+
}
|