@syengup/friday-channel-next 1.0.4 → 1.0.5
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 +3 -0
- package/dist/src/agent/abort-run.d.ts +19 -6
- package/dist/src/agent/abort-run.js +37 -14
- package/dist/src/agent/recent-aborts.d.ts +6 -0
- package/dist/src/agent/recent-aborts.js +35 -0
- package/dist/src/http/handlers/cancel.js +6 -1
- package/dist/src/http/handlers/messages.js +11 -0
- package/package.json +1 -1
- package/src/agent/abort-run.test.ts +39 -0
- package/src/agent/abort-run.ts +45 -15
- package/src/agent/recent-aborts.test.ts +29 -0
- package/src/agent/recent-aborts.ts +36 -0
- package/src/http/handlers/cancel.ts +5 -1
- package/src/http/handlers/messages.ts +12 -0
package/README.md
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
- File upload/download (`POST /friday-next/files`, `GET /friday-next/files/:id`)
|
|
12
12
|
- Cancel (`POST /friday-next/cancel`) and status with `activeRuns` (`GET /friday-next/status`)
|
|
13
13
|
- **Agent config editing** (mirrors ControlUI, no core changes): model / core `.md` files / tool permissions / skills, per agent — via `agents.list[]` (`mutateConfigFile`) + workspace fs
|
|
14
|
+
- **Codex backend support**: asserts `model_reasoning_summary` in each agent's `codex-home/config.toml` so ChatGPT/OAuth (Codex app-server) models stream reasoning text
|
|
15
|
+
- **Exec / plugin approvals**: forwards approval requests as `event: approval` and accepts decisions at `POST /friday-next/approvals/{approvalId}`
|
|
14
16
|
- History sync, link-preview cards, and in-app plugin self-upgrade
|
|
15
17
|
|
|
16
18
|
## Endpoints
|
|
@@ -22,6 +24,7 @@
|
|
|
22
24
|
- `GET /friday-next/models` · `GET /friday-next/agents` · `GET|PUT /friday-next/sessions/settings`
|
|
23
25
|
- `GET|PUT /friday-next/agents/{id}/config` · `GET|PUT /friday-next/agents/{id}/files[/{name}]` · `GET /friday-next/agents/{id}/tools/catalog`
|
|
24
26
|
- `GET /friday-next/history/sessions` · `GET /friday-next/history/messages` · `GET /friday-next/link-preview`
|
|
27
|
+
- `POST /friday-next/device-approve` · `POST /friday-next/nodes-approve` · `POST /friday-next/approvals/{approvalId}`
|
|
25
28
|
- `GET /friday-next/plugin/info` · `POST /friday-next/plugin/upgrade`
|
|
26
29
|
|
|
27
30
|
See **`API.md`** (English) and **`API.zh-CN.md`** (Chinese) for payloads, event shapes, offline queue paths, and breaking changes.
|
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
export type AbortRunResult = {
|
|
2
2
|
aborted: boolean;
|
|
3
|
-
|
|
3
|
+
};
|
|
4
|
+
export type AbortRunDeps = {
|
|
5
|
+
resolveActiveEmbeddedRunSessionId: (sessionKey: string) => string | undefined;
|
|
6
|
+
abortAgentHarnessRun: (sessionId: string) => boolean;
|
|
4
7
|
};
|
|
5
8
|
/**
|
|
6
|
-
* Abort the active run for a channel `sessionKey
|
|
9
|
+
* Abort the active run for a channel `sessionKey` — the CANONICAL OpenClaw stop.
|
|
10
|
+
*
|
|
11
|
+
* Mirrors how ControlUI (`chat.abort` → `abortChatRunById`) and the voice/`/compact`/
|
|
12
|
+
* steer paths stop a run: resolve sessionKey → internal sessionId (active runs are keyed
|
|
13
|
+
* by sessionId, not the channel runId), then fire a PLAIN abort (`abortAgentHarnessRun`
|
|
14
|
+
* = `abortEmbeddedAgentRun` → `handle.abort()`). That sets the run's `externalAbort` flag
|
|
15
|
+
* so it unwinds to a clean `"aborted_by_user"` terminal — a SILENT reply plus a
|
|
16
|
+
* `status:"cancelled"` lifecycle — with NO error event.
|
|
7
17
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
18
|
+
* Deliberately NOT abort-and-drain with `forceClear`: `forceClear` force-fails the reply
|
|
19
|
+
* operation (`operation.fail("run_failed", new Error("Embedded run force-cleared by …"))`)
|
|
20
|
+
* and never fires the abort signal, so (a) the failed operation surfaces as a spurious
|
|
21
|
+
* `dispatch_error`, and (b) without `externalAbort` the interrupted LLM call is classified
|
|
22
|
+
* as a real failure → `"LLM request failed."`. Both show up as an error toast in the app.
|
|
23
|
+
* `forceClear` is OpenClaw's stuck/cron-timeout recovery hammer, not a user-initiated stop.
|
|
11
24
|
*/
|
|
12
|
-
export declare function abortRunForSessionKey(sessionKey: string): Promise<AbortRunResult>;
|
|
25
|
+
export declare function abortRunForSessionKey(sessionKey: string, deps?: AbortRunDeps): Promise<AbortRunResult>;
|
|
@@ -1,26 +1,49 @@
|
|
|
1
|
+
const NO_OP = { aborted: false };
|
|
2
|
+
async function loadAbortRunDeps() {
|
|
3
|
+
// The SDK is optional at runtime and unavailable/unmockable under Vitest; tests
|
|
4
|
+
// inject `deps` directly to exercise the real abort path.
|
|
5
|
+
if (process.env.VITEST === "true")
|
|
6
|
+
return null;
|
|
7
|
+
try {
|
|
8
|
+
return await import("openclaw/plugin-sdk/agent-harness");
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
1
14
|
/**
|
|
2
|
-
* Abort the active run for a channel `sessionKey
|
|
15
|
+
* Abort the active run for a channel `sessionKey` — the CANONICAL OpenClaw stop.
|
|
3
16
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
17
|
+
* Mirrors how ControlUI (`chat.abort` → `abortChatRunById`) and the voice/`/compact`/
|
|
18
|
+
* steer paths stop a run: resolve sessionKey → internal sessionId (active runs are keyed
|
|
19
|
+
* by sessionId, not the channel runId), then fire a PLAIN abort (`abortAgentHarnessRun`
|
|
20
|
+
* = `abortEmbeddedAgentRun` → `handle.abort()`). That sets the run's `externalAbort` flag
|
|
21
|
+
* so it unwinds to a clean `"aborted_by_user"` terminal — a SILENT reply plus a
|
|
22
|
+
* `status:"cancelled"` lifecycle — with NO error event.
|
|
23
|
+
*
|
|
24
|
+
* Deliberately NOT abort-and-drain with `forceClear`: `forceClear` force-fails the reply
|
|
25
|
+
* operation (`operation.fail("run_failed", new Error("Embedded run force-cleared by …"))`)
|
|
26
|
+
* and never fires the abort signal, so (a) the failed operation surfaces as a spurious
|
|
27
|
+
* `dispatch_error`, and (b) without `externalAbort` the interrupted LLM call is classified
|
|
28
|
+
* as a real failure → `"LLM request failed."`. Both show up as an error toast in the app.
|
|
29
|
+
* `forceClear` is OpenClaw's stuck/cron-timeout recovery hammer, not a user-initiated stop.
|
|
7
30
|
*/
|
|
8
|
-
export async function abortRunForSessionKey(sessionKey) {
|
|
9
|
-
if (process.env.VITEST === "true")
|
|
10
|
-
return { aborted: false, drained: false };
|
|
31
|
+
export async function abortRunForSessionKey(sessionKey, deps) {
|
|
11
32
|
const key = sessionKey.trim();
|
|
12
33
|
if (!key)
|
|
13
|
-
return
|
|
34
|
+
return NO_OP;
|
|
35
|
+
const resolved = deps ?? (await loadAbortRunDeps());
|
|
36
|
+
if (!resolved)
|
|
37
|
+
return NO_OP;
|
|
14
38
|
try {
|
|
15
|
-
const
|
|
16
|
-
const sessionId = resolveActiveEmbeddedRunSessionId(key);
|
|
39
|
+
const sessionId = resolved.resolveActiveEmbeddedRunSessionId(key);
|
|
17
40
|
if (!sessionId)
|
|
18
|
-
return
|
|
19
|
-
const
|
|
20
|
-
return { aborted
|
|
41
|
+
return NO_OP;
|
|
42
|
+
const aborted = resolved.abortAgentHarnessRun(sessionId);
|
|
43
|
+
return { aborted };
|
|
21
44
|
}
|
|
22
45
|
catch {
|
|
23
46
|
// optional at runtime
|
|
24
|
-
return
|
|
47
|
+
return NO_OP;
|
|
25
48
|
}
|
|
26
49
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** How long after a user stop to treat surfaced errors as abort-noise. */
|
|
2
|
+
export declare const RECENT_ABORT_SUPPRESSION_MS = 20000;
|
|
3
|
+
export declare function markUserAbort(sessionKey: string, nowMs?: number): void;
|
|
4
|
+
export declare function wasRecentlyUserAborted(sessionKey: string, nowMs?: number): boolean;
|
|
5
|
+
/** Test/maintenance hook. */
|
|
6
|
+
export declare function clearRecentAborts(): void;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracks recent user-initiated aborts per channel sessionKey.
|
|
3
|
+
*
|
|
4
|
+
* When the user stops a run, OpenClaw's embedded runner / Codex backend can still surface an
|
|
5
|
+
* error as a consequence of the interruption — the aborted run's own failover ("LLM request
|
|
6
|
+
* failed.") or a generic failure on the very next turn ("Something went wrong …"). Those are
|
|
7
|
+
* abort-noise, not real failures the user should see as an error toast. The channel records the
|
|
8
|
+
* abort here so the deliver/error forwarders can drop `isError` payloads that land within a short
|
|
9
|
+
* window of a stop the user explicitly requested.
|
|
10
|
+
*/
|
|
11
|
+
const recentAbortAtMs = new Map();
|
|
12
|
+
/** How long after a user stop to treat surfaced errors as abort-noise. */
|
|
13
|
+
export const RECENT_ABORT_SUPPRESSION_MS = 20_000;
|
|
14
|
+
export function markUserAbort(sessionKey, nowMs = Date.now()) {
|
|
15
|
+
const key = sessionKey.trim();
|
|
16
|
+
if (key)
|
|
17
|
+
recentAbortAtMs.set(key, nowMs);
|
|
18
|
+
}
|
|
19
|
+
export function wasRecentlyUserAborted(sessionKey, nowMs = Date.now()) {
|
|
20
|
+
const key = sessionKey.trim();
|
|
21
|
+
if (!key)
|
|
22
|
+
return false;
|
|
23
|
+
const at = recentAbortAtMs.get(key);
|
|
24
|
+
if (at === undefined)
|
|
25
|
+
return false;
|
|
26
|
+
if (nowMs - at > RECENT_ABORT_SUPPRESSION_MS) {
|
|
27
|
+
recentAbortAtMs.delete(key);
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
/** Test/maintenance hook. */
|
|
33
|
+
export function clearRecentAborts() {
|
|
34
|
+
recentAbortAtMs.clear();
|
|
35
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { abortRunForSessionKey } from "../../agent/abort-run.js";
|
|
2
|
+
import { markUserAbort } from "../../agent/recent-aborts.js";
|
|
2
3
|
import { getRunRoute } from "../../run-metadata.js";
|
|
3
4
|
import { sseEmitter } from "../../sse/emitter.js";
|
|
4
5
|
import { readJsonBody } from "../middleware/body.js";
|
|
@@ -31,7 +32,11 @@ export async function handleCancel(req, res) {
|
|
|
31
32
|
}
|
|
32
33
|
const result = sessionKey
|
|
33
34
|
? await abortRunForSessionKey(sessionKey)
|
|
34
|
-
: { aborted: false
|
|
35
|
+
: { aborted: false };
|
|
36
|
+
// Record the user stop so abort-induced error deliveries are suppressed for a short window
|
|
37
|
+
// (the aborted run's own failover, or a generic failure on the immediate next turn).
|
|
38
|
+
if (sessionKey)
|
|
39
|
+
markUserAbort(sessionKey);
|
|
35
40
|
if (runId)
|
|
36
41
|
sseEmitter.untrackRun(runId);
|
|
37
42
|
res.statusCode = 200;
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import crypto from "node:crypto";
|
|
13
13
|
import { resolveFridayNextConfig } from "../../config.js";
|
|
14
|
+
import { wasRecentlyUserAborted } from "../../agent/recent-aborts.js";
|
|
14
15
|
import { getHostOpenClawConfigSnapshot } from "../../host-config.js";
|
|
15
16
|
import { getFridayNextRuntime } from "../../runtime.js";
|
|
16
17
|
import { resolveAgentDefaults, setSessionSettings, splitModelRef, toSessionStoreKey, } from "../../session/session-manager.js";
|
|
@@ -429,6 +430,13 @@ export async function handleMessages(req, res) {
|
|
|
429
430
|
}
|
|
430
431
|
}
|
|
431
432
|
const payload = translateDeliverPayload(pl, info.kind, meta);
|
|
433
|
+
const deliverIsError = payload?.isError || pl?.isError;
|
|
434
|
+
// Drop error deliveries that are a direct consequence of a user stop (the aborted
|
|
435
|
+
// run's own failover, e.g. "LLM request failed."). The user already chose to stop;
|
|
436
|
+
// surfacing it as an error toast is abort-noise, not a real failure.
|
|
437
|
+
if (deliverIsError && wasRecentlyUserAborted(baseSessionKey)) {
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
432
440
|
log("EVENT_SENT", normalizedDeviceId, runId, `deliver kind=${info.kind}`);
|
|
433
441
|
sseEmitter.broadcastToRun(runId, {
|
|
434
442
|
type: "deliver",
|
|
@@ -449,6 +457,9 @@ export async function handleMessages(req, res) {
|
|
|
449
457
|
}
|
|
450
458
|
},
|
|
451
459
|
onError: (err) => {
|
|
460
|
+
if (wasRecentlyUserAborted(baseSessionKey)) {
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
452
463
|
log("RUN_ERROR", normalizedDeviceId, runId, String(err), "error");
|
|
453
464
|
sseEmitter.broadcastToRun(runId, {
|
|
454
465
|
type: "outbound",
|
package/package.json
CHANGED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { abortRunForSessionKey } from "./abort-run.js";
|
|
3
|
+
|
|
4
|
+
describe("abortRunForSessionKey", () => {
|
|
5
|
+
it("resolves sessionKey → sessionId then fires a PLAIN abort (canonical clean stop)", async () => {
|
|
6
|
+
const abortAgentHarnessRun = vi.fn().mockReturnValue(true);
|
|
7
|
+
const resolveActiveEmbeddedRunSessionId = vi.fn().mockReturnValue("sid-9");
|
|
8
|
+
|
|
9
|
+
const result = await abortRunForSessionKey("sk-9", {
|
|
10
|
+
resolveActiveEmbeddedRunSessionId,
|
|
11
|
+
abortAgentHarnessRun,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
expect(resolveActiveEmbeddedRunSessionId).toHaveBeenCalledWith("sk-9");
|
|
15
|
+
// Plain abort by internal sessionId — fires handle.abort(), no drain/forceClear.
|
|
16
|
+
expect(abortAgentHarnessRun).toHaveBeenCalledWith("sid-9");
|
|
17
|
+
expect(result).toEqual({ aborted: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("returns a no-op without aborting when there is no active run for the sessionKey", async () => {
|
|
21
|
+
const abortAgentHarnessRun = vi.fn();
|
|
22
|
+
const result = await abortRunForSessionKey("sk-x", {
|
|
23
|
+
resolveActiveEmbeddedRunSessionId: () => undefined,
|
|
24
|
+
abortAgentHarnessRun,
|
|
25
|
+
});
|
|
26
|
+
expect(abortAgentHarnessRun).not.toHaveBeenCalled();
|
|
27
|
+
expect(result).toEqual({ aborted: false });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("returns a no-op for an empty sessionKey", async () => {
|
|
31
|
+
const abortAgentHarnessRun = vi.fn();
|
|
32
|
+
const result = await abortRunForSessionKey(" ", {
|
|
33
|
+
resolveActiveEmbeddedRunSessionId: () => "sid",
|
|
34
|
+
abortAgentHarnessRun,
|
|
35
|
+
});
|
|
36
|
+
expect(abortAgentHarnessRun).not.toHaveBeenCalled();
|
|
37
|
+
expect(result).toEqual({ aborted: false });
|
|
38
|
+
});
|
|
39
|
+
});
|
package/src/agent/abort-run.ts
CHANGED
|
@@ -1,25 +1,55 @@
|
|
|
1
|
-
export type AbortRunResult = { aborted: boolean
|
|
1
|
+
export type AbortRunResult = { aborted: boolean };
|
|
2
|
+
|
|
3
|
+
export type AbortRunDeps = {
|
|
4
|
+
resolveActiveEmbeddedRunSessionId: (sessionKey: string) => string | undefined;
|
|
5
|
+
abortAgentHarnessRun: (sessionId: string) => boolean;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const NO_OP: AbortRunResult = { aborted: false };
|
|
9
|
+
|
|
10
|
+
async function loadAbortRunDeps(): Promise<AbortRunDeps | null> {
|
|
11
|
+
// The SDK is optional at runtime and unavailable/unmockable under Vitest; tests
|
|
12
|
+
// inject `deps` directly to exercise the real abort path.
|
|
13
|
+
if (process.env.VITEST === "true") return null;
|
|
14
|
+
try {
|
|
15
|
+
return await import("openclaw/plugin-sdk/agent-harness");
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
2
20
|
|
|
3
21
|
/**
|
|
4
|
-
* Abort the active run for a channel `sessionKey
|
|
22
|
+
* Abort the active run for a channel `sessionKey` — the CANONICAL OpenClaw stop.
|
|
23
|
+
*
|
|
24
|
+
* Mirrors how ControlUI (`chat.abort` → `abortChatRunById`) and the voice/`/compact`/
|
|
25
|
+
* steer paths stop a run: resolve sessionKey → internal sessionId (active runs are keyed
|
|
26
|
+
* by sessionId, not the channel runId), then fire a PLAIN abort (`abortAgentHarnessRun`
|
|
27
|
+
* = `abortEmbeddedAgentRun` → `handle.abort()`). That sets the run's `externalAbort` flag
|
|
28
|
+
* so it unwinds to a clean `"aborted_by_user"` terminal — a SILENT reply plus a
|
|
29
|
+
* `status:"cancelled"` lifecycle — with NO error event.
|
|
5
30
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
31
|
+
* Deliberately NOT abort-and-drain with `forceClear`: `forceClear` force-fails the reply
|
|
32
|
+
* operation (`operation.fail("run_failed", new Error("Embedded run force-cleared by …"))`)
|
|
33
|
+
* and never fires the abort signal, so (a) the failed operation surfaces as a spurious
|
|
34
|
+
* `dispatch_error`, and (b) without `externalAbort` the interrupted LLM call is classified
|
|
35
|
+
* as a real failure → `"LLM request failed."`. Both show up as an error toast in the app.
|
|
36
|
+
* `forceClear` is OpenClaw's stuck/cron-timeout recovery hammer, not a user-initiated stop.
|
|
9
37
|
*/
|
|
10
|
-
export async function abortRunForSessionKey(
|
|
11
|
-
|
|
38
|
+
export async function abortRunForSessionKey(
|
|
39
|
+
sessionKey: string,
|
|
40
|
+
deps?: AbortRunDeps,
|
|
41
|
+
): Promise<AbortRunResult> {
|
|
12
42
|
const key = sessionKey.trim();
|
|
13
|
-
if (!key) return
|
|
43
|
+
if (!key) return NO_OP;
|
|
44
|
+
const resolved = deps ?? (await loadAbortRunDeps());
|
|
45
|
+
if (!resolved) return NO_OP;
|
|
14
46
|
try {
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
const result = await abortAndDrainAgentHarnessRun({ sessionId, sessionKey: key });
|
|
20
|
-
return { aborted: result.aborted, drained: result.drained };
|
|
47
|
+
const sessionId = resolved.resolveActiveEmbeddedRunSessionId(key);
|
|
48
|
+
if (!sessionId) return NO_OP;
|
|
49
|
+
const aborted = resolved.abortAgentHarnessRun(sessionId);
|
|
50
|
+
return { aborted };
|
|
21
51
|
} catch {
|
|
22
52
|
// optional at runtime
|
|
23
|
-
return
|
|
53
|
+
return NO_OP;
|
|
24
54
|
}
|
|
25
55
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
clearRecentAborts,
|
|
4
|
+
markUserAbort,
|
|
5
|
+
RECENT_ABORT_SUPPRESSION_MS,
|
|
6
|
+
wasRecentlyUserAborted,
|
|
7
|
+
} from "./recent-aborts.js";
|
|
8
|
+
|
|
9
|
+
afterEach(() => clearRecentAborts());
|
|
10
|
+
|
|
11
|
+
describe("recent-aborts", () => {
|
|
12
|
+
it("reports a sessionKey as recently aborted within the window", () => {
|
|
13
|
+
markUserAbort("sk-1", 1_000);
|
|
14
|
+
expect(wasRecentlyUserAborted("sk-1", 1_000)).toBe(true);
|
|
15
|
+
expect(wasRecentlyUserAborted("sk-1", 1_000 + RECENT_ABORT_SUPPRESSION_MS)).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("expires after the window", () => {
|
|
19
|
+
markUserAbort("sk-2", 1_000);
|
|
20
|
+
expect(wasRecentlyUserAborted("sk-2", 1_000 + RECENT_ABORT_SUPPRESSION_MS + 1)).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("is false for an unknown or empty sessionKey", () => {
|
|
24
|
+
expect(wasRecentlyUserAborted("never", 1_000)).toBe(false);
|
|
25
|
+
expect(wasRecentlyUserAborted(" ", 1_000)).toBe(false);
|
|
26
|
+
markUserAbort(" ", 1_000);
|
|
27
|
+
expect(wasRecentlyUserAborted(" ", 1_000)).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracks recent user-initiated aborts per channel sessionKey.
|
|
3
|
+
*
|
|
4
|
+
* When the user stops a run, OpenClaw's embedded runner / Codex backend can still surface an
|
|
5
|
+
* error as a consequence of the interruption — the aborted run's own failover ("LLM request
|
|
6
|
+
* failed.") or a generic failure on the very next turn ("Something went wrong …"). Those are
|
|
7
|
+
* abort-noise, not real failures the user should see as an error toast. The channel records the
|
|
8
|
+
* abort here so the deliver/error forwarders can drop `isError` payloads that land within a short
|
|
9
|
+
* window of a stop the user explicitly requested.
|
|
10
|
+
*/
|
|
11
|
+
const recentAbortAtMs = new Map<string, number>();
|
|
12
|
+
|
|
13
|
+
/** How long after a user stop to treat surfaced errors as abort-noise. */
|
|
14
|
+
export const RECENT_ABORT_SUPPRESSION_MS = 20_000;
|
|
15
|
+
|
|
16
|
+
export function markUserAbort(sessionKey: string, nowMs: number = Date.now()): void {
|
|
17
|
+
const key = sessionKey.trim();
|
|
18
|
+
if (key) recentAbortAtMs.set(key, nowMs);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function wasRecentlyUserAborted(sessionKey: string, nowMs: number = Date.now()): boolean {
|
|
22
|
+
const key = sessionKey.trim();
|
|
23
|
+
if (!key) return false;
|
|
24
|
+
const at = recentAbortAtMs.get(key);
|
|
25
|
+
if (at === undefined) return false;
|
|
26
|
+
if (nowMs - at > RECENT_ABORT_SUPPRESSION_MS) {
|
|
27
|
+
recentAbortAtMs.delete(key);
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Test/maintenance hook. */
|
|
34
|
+
export function clearRecentAborts(): void {
|
|
35
|
+
recentAbortAtMs.clear();
|
|
36
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
import { abortRunForSessionKey } from "../../agent/abort-run.js";
|
|
3
|
+
import { markUserAbort } from "../../agent/recent-aborts.js";
|
|
3
4
|
import { getRunRoute } from "../../run-metadata.js";
|
|
4
5
|
import { sseEmitter } from "../../sse/emitter.js";
|
|
5
6
|
import { readJsonBody } from "../middleware/body.js";
|
|
@@ -34,7 +35,10 @@ export async function handleCancel(req: IncomingMessage, res: ServerResponse): P
|
|
|
34
35
|
}
|
|
35
36
|
const result = sessionKey
|
|
36
37
|
? await abortRunForSessionKey(sessionKey)
|
|
37
|
-
: { aborted: false
|
|
38
|
+
: { aborted: false };
|
|
39
|
+
// Record the user stop so abort-induced error deliveries are suppressed for a short window
|
|
40
|
+
// (the aborted run's own failover, or a generic failure on the immediate next turn).
|
|
41
|
+
if (sessionKey) markUserAbort(sessionKey);
|
|
38
42
|
if (runId) sseEmitter.untrackRun(runId);
|
|
39
43
|
res.statusCode = 200;
|
|
40
44
|
res.setHeader("Content-Type", "application/json");
|
|
@@ -25,6 +25,7 @@ export type FridayReplyPayload = {
|
|
|
25
25
|
channelData?: unknown;
|
|
26
26
|
};
|
|
27
27
|
import { resolveFridayNextConfig } from "../../config.js";
|
|
28
|
+
import { wasRecentlyUserAborted } from "../../agent/recent-aborts.js";
|
|
28
29
|
import { getHostOpenClawConfigSnapshot } from "../../host-config.js";
|
|
29
30
|
import { getFridayNextRuntime } from "../../runtime.js";
|
|
30
31
|
import {
|
|
@@ -589,6 +590,14 @@ export async function handleMessages(req: IncomingMessage, res: ServerResponse):
|
|
|
589
590
|
}
|
|
590
591
|
}
|
|
591
592
|
const payload = translateDeliverPayload(pl, info.kind, meta);
|
|
593
|
+
const deliverIsError =
|
|
594
|
+
(payload as { isError?: boolean })?.isError || (pl as { isError?: boolean })?.isError;
|
|
595
|
+
// Drop error deliveries that are a direct consequence of a user stop (the aborted
|
|
596
|
+
// run's own failover, e.g. "LLM request failed."). The user already chose to stop;
|
|
597
|
+
// surfacing it as an error toast is abort-noise, not a real failure.
|
|
598
|
+
if (deliverIsError && wasRecentlyUserAborted(baseSessionKey)) {
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
592
601
|
log("EVENT_SENT", normalizedDeviceId, runId, `deliver kind=${info.kind}`);
|
|
593
602
|
sseEmitter.broadcastToRun(
|
|
594
603
|
runId,
|
|
@@ -613,6 +622,9 @@ export async function handleMessages(req: IncomingMessage, res: ServerResponse):
|
|
|
613
622
|
}
|
|
614
623
|
},
|
|
615
624
|
onError: (err: unknown) => {
|
|
625
|
+
if (wasRecentlyUserAborted(baseSessionKey)) {
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
616
628
|
log("RUN_ERROR", normalizedDeviceId, runId, String(err), "error");
|
|
617
629
|
sseEmitter.broadcastToRun(
|
|
618
630
|
runId,
|