fix(gateway): harden chat abort semantics

This commit is contained in:
Peter Steinberger
2026-01-10 17:23:16 +01:00
parent 84d64f9395
commit a1533a17f7
12 changed files with 456 additions and 111 deletions

View File

@@ -11,6 +11,7 @@
- Auth: update Claude Code keychain credentials in-place during refresh sync; share JSON file helpers; add CLI fallback coverage. - Auth: update Claude Code keychain credentials in-place during refresh sync; share JSON file helpers; add CLI fallback coverage.
- Onboarding/Gateway: persist non-interactive gateway token auth in config; add WS wizard + gateway tool-calling regression coverage. - Onboarding/Gateway: persist non-interactive gateway token auth in config; add WS wizard + gateway tool-calling regression coverage.
- Gateway/Control UI: make `chat.send` non-blocking, wire Stop to `chat.abort`, and treat `/stop` as an out-of-band abort. (#653) - Gateway/Control UI: make `chat.send` non-blocking, wire Stop to `chat.abort`, and treat `/stop` as an out-of-band abort. (#653)
- Gateway/Control UI: allow `chat.abort` without `runId` (abort active runs), suppress post-abort chat streaming, and prune stuck chat runs. (#653)
- CLI: `clawdbot sessions` now includes `elev:*` + `usage:*` flags in the table output. - CLI: `clawdbot sessions` now includes `elev:*` + `usage:*` flags in the table output.
- CLI/Pairing: accept positional provider for `pairing list|approve` (npm-run compatible); update docs/bot hints. - CLI/Pairing: accept positional provider for `pairing list|approve` (npm-run compatible); update docs/bot hints.
- Branding: normalize user-facing “ClawdBot”/“CLAWDBOT” → “Clawdbot” (CLI, status, docs). - Branding: normalize user-facing “ClawdBot”/“CLAWDBOT” → “Clawdbot” (CLI, status, docs).

View File

@@ -42,6 +42,15 @@ The dashboard settings panel lets you store a token; passwords are not persisted
- Logs: live tail of gateway file logs with filter/export (`logs.tail`) - Logs: live tail of gateway file logs with filter/export (`logs.tail`)
- Update: run a package/git update + restart (`update.run`) with a restart report - Update: run a package/git update + restart (`update.run`) with a restart report
## Chat behavior
- `chat.send` is **non-blocking**: it acks immediately with `{ runId, status: "started" }` and the response streams via `chat` events.
- Re-sending with the same `idempotencyKey` returns `{ status: "in_flight" }` while running, and `{ status: "ok" }` after completion.
- Stop:
- Click **Stop** (calls `chat.abort`)
- Type `/stop` (or `stop|esc|abort|wait|exit`) to abort out-of-band
- `chat.abort` supports `{ sessionKey }` (no `runId`) to abort all active runs for that session
## Tailnet access (recommended) ## Tailnet access (recommended)
### Integrated Tailscale Serve (preferred) ### Integrated Tailscale Serve (preferred)

119
src/gateway/chat-abort.ts Normal file
View File

@@ -0,0 +1,119 @@
import { isAbortTrigger } from "../auto-reply/reply/abort.js";
export type ChatAbortControllerEntry = {
controller: AbortController;
sessionId: string;
sessionKey: string;
startedAtMs: number;
expiresAtMs: number;
};
export function isChatStopCommandText(text: string): boolean {
const trimmed = text.trim();
if (!trimmed) return false;
return trimmed.toLowerCase() === "/stop" || isAbortTrigger(trimmed);
}
export function resolveChatRunExpiresAtMs(params: {
now: number;
timeoutMs: number;
graceMs?: number;
minMs?: number;
maxMs?: number;
}): number {
const {
now,
timeoutMs,
graceMs = 60_000,
minMs = 2 * 60_000,
maxMs = 24 * 60 * 60_000,
} = params;
const boundedTimeoutMs = Math.max(0, timeoutMs);
const target = now + boundedTimeoutMs + graceMs;
const min = now + minMs;
const max = now + maxMs;
return Math.min(max, Math.max(min, target));
}
export type ChatAbortOps = {
chatAbortControllers: Map<string, ChatAbortControllerEntry>;
chatRunBuffers: Map<string, string>;
chatDeltaSentAt: Map<string, number>;
chatAbortedRuns: Map<string, number>;
removeChatRun: (
sessionId: string,
clientRunId: string,
sessionKey?: string,
) => { sessionKey: string; clientRunId: string } | undefined;
agentRunSeq: Map<string, number>;
broadcast: (
event: string,
payload: unknown,
opts?: { dropIfSlow?: boolean },
) => void;
bridgeSendToSession: (
sessionKey: string,
event: string,
payload: unknown,
) => void;
};
function broadcastChatAborted(
ops: ChatAbortOps,
params: {
runId: string;
sessionKey: string;
stopReason?: string;
},
) {
const { runId, sessionKey, stopReason } = params;
const payload = {
runId,
sessionKey,
seq: (ops.agentRunSeq.get(runId) ?? 0) + 1,
state: "aborted" as const,
stopReason,
};
ops.broadcast("chat", payload);
ops.bridgeSendToSession(sessionKey, "chat", payload);
}
export function abortChatRunById(
ops: ChatAbortOps,
params: {
runId: string;
sessionKey: string;
stopReason?: string;
},
): { aborted: boolean } {
const { runId, sessionKey, stopReason } = params;
const active = ops.chatAbortControllers.get(runId);
if (!active) return { aborted: false };
if (active.sessionKey !== sessionKey) return { aborted: false };
ops.chatAbortedRuns.set(runId, Date.now());
active.controller.abort();
ops.chatAbortControllers.delete(runId);
ops.chatRunBuffers.delete(runId);
ops.chatDeltaSentAt.delete(runId);
ops.removeChatRun(runId, runId, sessionKey);
broadcastChatAborted(ops, { runId, sessionKey, stopReason });
return { aborted: true };
}
export function abortChatRunsForSessionKey(
ops: ChatAbortOps,
params: {
sessionKey: string;
stopReason?: string;
},
): { aborted: boolean; runIds: string[] } {
const { sessionKey, stopReason } = params;
const runIds: string[] = [];
for (const [runId, active] of ops.chatAbortControllers) {
if (active.sessionKey !== sessionKey) continue;
const res = abortChatRunById(ops, { runId, sessionKey, stopReason });
if (res.aborted) runIds.push(runId);
}
return { aborted: runIds.length > 0, runIds };
}

View File

@@ -903,7 +903,7 @@ export const ChatSendParamsSchema = Type.Object(
export const ChatAbortParamsSchema = Type.Object( export const ChatAbortParamsSchema = Type.Object(
{ {
sessionKey: NonEmptyString, sessionKey: NonEmptyString,
runId: NonEmptyString, runId: Type.Optional(NonEmptyString),
}, },
{ additionalProperties: false }, { additionalProperties: false },
); );

View File

@@ -9,7 +9,6 @@ import {
waitForEmbeddedPiRunEnd, waitForEmbeddedPiRunEnd,
} from "../agents/pi-embedded.js"; } from "../agents/pi-embedded.js";
import { resolveAgentTimeoutMs } from "../agents/timeout.js"; import { resolveAgentTimeoutMs } from "../agents/timeout.js";
import { isAbortTrigger } from "../auto-reply/reply/abort.js";
import type { CliDeps } from "../cli/deps.js"; import type { CliDeps } from "../cli/deps.js";
import { agentCommand } from "../commands/agent.js"; import { agentCommand } from "../commands/agent.js";
import type { HealthSummary } from "../commands/health.js"; import type { HealthSummary } from "../commands/health.js";
@@ -37,6 +36,13 @@ import {
import { clearCommandLane } from "../process/command-queue.js"; import { clearCommandLane } from "../process/command-queue.js";
import { normalizeMainKey } from "../routing/session-key.js"; import { normalizeMainKey } from "../routing/session-key.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import {
abortChatRunById,
abortChatRunsForSessionKey,
type ChatAbortControllerEntry,
isChatStopCommandText,
resolveChatRunExpiresAtMs,
} from "./chat-abort.js";
import { buildMessageWithAttachments } from "./chat-attachments.js"; import { buildMessageWithAttachments } from "./chat-attachments.js";
import { import {
ErrorCodes, ErrorCodes,
@@ -107,10 +113,8 @@ export type BridgeHandlersContext = {
clientRunId: string, clientRunId: string,
sessionKey?: string, sessionKey?: string,
) => ChatRunEntry | undefined; ) => ChatRunEntry | undefined;
chatAbortControllers: Map< chatAbortControllers: Map<string, ChatAbortControllerEntry>;
string, chatAbortedRuns: Map<string, number>;
{ controller: AbortController; sessionId: string; sessionKey: string }
>;
chatRunBuffers: Map<string, string>; chatRunBuffers: Map<string, string>;
chatDeltaSentAt: Map<string, number>; chatDeltaSentAt: Map<string, number>;
dedupe: Map<string, DedupeEntry>; dedupe: Map<string, DedupeEntry>;
@@ -701,13 +705,41 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
const { sessionKey, runId } = params as { const { sessionKey, runId } = params as {
sessionKey: string; sessionKey: string;
runId: string; runId?: string;
}; };
const ops = {
chatAbortControllers: ctx.chatAbortControllers,
chatRunBuffers: ctx.chatRunBuffers,
chatDeltaSentAt: ctx.chatDeltaSentAt,
chatAbortedRuns: ctx.chatAbortedRuns,
removeChatRun: ctx.removeChatRun,
agentRunSeq: ctx.agentRunSeq,
broadcast: ctx.broadcast,
bridgeSendToSession: ctx.bridgeSendToSession,
};
if (!runId) {
const res = abortChatRunsForSessionKey(ops, {
sessionKey,
stopReason: "rpc",
});
return {
ok: true,
payloadJSON: JSON.stringify({
ok: true,
aborted: res.aborted,
runIds: res.runIds,
}),
};
}
const active = ctx.chatAbortControllers.get(runId); const active = ctx.chatAbortControllers.get(runId);
if (!active) { if (!active) {
return { return {
ok: true, ok: true,
payloadJSON: JSON.stringify({ ok: true, aborted: false }), payloadJSON: JSON.stringify({
ok: true,
aborted: false,
runIds: [],
}),
}; };
} }
if (active.sessionKey !== sessionKey) { if (active.sessionKey !== sessionKey) {
@@ -719,24 +751,18 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
}, },
}; };
} }
const res = abortChatRunById(ops, {
active.controller.abort();
ctx.chatAbortControllers.delete(runId);
ctx.chatRunBuffers.delete(runId);
ctx.chatDeltaSentAt.delete(runId);
ctx.removeChatRun(runId, runId, sessionKey);
const payload = {
runId, runId,
sessionKey, sessionKey,
seq: (ctx.agentRunSeq.get(runId) ?? 0) + 1, stopReason: "rpc",
state: "aborted" as const, });
};
ctx.broadcast("chat", payload);
ctx.bridgeSendToSession(sessionKey, "chat", payload);
return { return {
ok: true, ok: true,
payloadJSON: JSON.stringify({ ok: true, aborted: true }), payloadJSON: JSON.stringify({
ok: true,
aborted: res.aborted,
runIds: res.aborted ? [runId] : [],
}),
}; };
} }
case "chat.send": { case "chat.send": {
@@ -765,12 +791,7 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
timeoutMs?: number; timeoutMs?: number;
idempotencyKey: string; idempotencyKey: string;
}; };
const stopCommand = (() => { const stopCommand = isChatStopCommandText(p.message);
const msg = p.message.trim();
if (!msg) return false;
const normalized = msg.toLowerCase();
return normalized === "/stop" || isAbortTrigger(msg);
})();
const normalizedAttachments = const normalizedAttachments =
p.attachments?.map((a) => ({ p.attachments?.map((a) => ({
type: typeof a?.type === "string" ? a.type : undefined, type: typeof a?.type === "string" ? a.type : undefined,
@@ -826,30 +847,25 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
registerAgentRunContext(clientRunId, { sessionKey: p.sessionKey }); registerAgentRunContext(clientRunId, { sessionKey: p.sessionKey });
if (stopCommand) { if (stopCommand) {
const runIds: string[] = []; const res = abortChatRunsForSessionKey(
for (const [runId, active] of ctx.chatAbortControllers) { {
if (active.sessionKey !== p.sessionKey) continue; chatAbortControllers: ctx.chatAbortControllers,
active.controller.abort(); chatRunBuffers: ctx.chatRunBuffers,
ctx.chatAbortControllers.delete(runId); chatDeltaSentAt: ctx.chatDeltaSentAt,
ctx.chatRunBuffers.delete(runId); chatAbortedRuns: ctx.chatAbortedRuns,
ctx.chatDeltaSentAt.delete(runId); removeChatRun: ctx.removeChatRun,
ctx.removeChatRun(runId, runId, p.sessionKey); agentRunSeq: ctx.agentRunSeq,
const payload = { broadcast: ctx.broadcast,
runId, bridgeSendToSession: ctx.bridgeSendToSession,
sessionKey: p.sessionKey, },
seq: (ctx.agentRunSeq.get(runId) ?? 0) + 1, { sessionKey: p.sessionKey, stopReason: "stop" },
state: "aborted" as const, );
};
ctx.broadcast("chat", payload);
ctx.bridgeSendToSession(p.sessionKey, "chat", payload);
runIds.push(runId);
}
return { return {
ok: true, ok: true,
payloadJSON: JSON.stringify({ payloadJSON: JSON.stringify({
ok: true, ok: true,
aborted: runIds.length > 0, aborted: res.aborted,
runIds, runIds: res.runIds,
}), }),
}; };
} }
@@ -885,6 +901,8 @@ export function createBridgeHandlers(ctx: BridgeHandlersContext) {
controller: abortController, controller: abortController,
sessionId, sessionId,
sessionKey: p.sessionKey, sessionKey: p.sessionKey,
startedAtMs: now,
expiresAtMs: resolveChatRunExpiresAtMs({ now, timeoutMs }),
}); });
ctx.addChatRun(clientRunId, { ctx.addChatRun(clientRunId, {
sessionKey: p.sessionKey, sessionKey: p.sessionKey,

View File

@@ -74,6 +74,7 @@ export type ChatRunState = {
registry: ChatRunRegistry; registry: ChatRunRegistry;
buffers: Map<string, string>; buffers: Map<string, string>;
deltaSentAt: Map<string, number>; deltaSentAt: Map<string, number>;
abortedRuns: Map<string, number>;
clear: () => void; clear: () => void;
}; };
@@ -81,17 +82,20 @@ export function createChatRunState(): ChatRunState {
const registry = createChatRunRegistry(); const registry = createChatRunRegistry();
const buffers = new Map<string, string>(); const buffers = new Map<string, string>();
const deltaSentAt = new Map<string, number>(); const deltaSentAt = new Map<string, number>();
const abortedRuns = new Map<string, number>();
const clear = () => { const clear = () => {
registry.clear(); registry.clear();
buffers.clear(); buffers.clear();
deltaSentAt.clear(); deltaSentAt.clear();
abortedRuns.clear();
}; };
return { return {
registry, registry,
buffers, buffers,
deltaSentAt, deltaSentAt,
abortedRuns,
clear, clear,
}; };
} }
@@ -212,6 +216,10 @@ export function createAgentEventHandler({
const chatLink = chatRunState.registry.peek(evt.runId); const chatLink = chatRunState.registry.peek(evt.runId);
const sessionKey = const sessionKey =
chatLink?.sessionKey ?? resolveSessionKeyForRun(evt.runId); chatLink?.sessionKey ?? resolveSessionKeyForRun(evt.runId);
const clientRunId = chatLink?.clientRunId ?? evt.runId;
const isAborted =
chatRunState.abortedRuns.has(clientRunId) ||
chatRunState.abortedRuns.has(evt.runId);
// Include sessionKey so Control UI can filter tool streams per session. // Include sessionKey so Control UI can filter tool streams per session.
const agentPayload = sessionKey ? { ...evt, sessionKey } : evt; const agentPayload = sessionKey ? { ...evt, sessionKey } : evt;
const last = agentRunSeq.get(evt.runId) ?? 0; const last = agentRunSeq.get(evt.runId) ?? 0;
@@ -242,10 +250,16 @@ export function createAgentEventHandler({
if (sessionKey) { if (sessionKey) {
bridgeSendToSession(sessionKey, "agent", agentPayload); bridgeSendToSession(sessionKey, "agent", agentPayload);
if (evt.stream === "assistant" && typeof evt.data?.text === "string") { if (
const clientRunId = chatLink?.clientRunId ?? evt.runId; !isAborted &&
evt.stream === "assistant" &&
typeof evt.data?.text === "string"
) {
emitChatDelta(sessionKey, clientRunId, evt.seq, evt.data.text); emitChatDelta(sessionKey, clientRunId, evt.seq, evt.data.text);
} else if (lifecyclePhase === "end" || lifecyclePhase === "error") { } else if (
!isAborted &&
(lifecyclePhase === "end" || lifecyclePhase === "error")
) {
if (chatLink) { if (chatLink) {
const finished = chatRunState.registry.shift(evt.runId); const finished = chatRunState.registry.shift(evt.runId);
if (!finished) { if (!finished) {
@@ -268,6 +282,17 @@ export function createAgentEventHandler({
evt.data?.error, evt.data?.error,
); );
} }
} else if (
isAborted &&
(lifecyclePhase === "end" || lifecyclePhase === "error")
) {
chatRunState.abortedRuns.delete(clientRunId);
chatRunState.abortedRuns.delete(evt.runId);
chatRunState.buffers.delete(clientRunId);
chatRunState.deltaSentAt.delete(clientRunId);
if (chatLink) {
chatRunState.registry.remove(evt.runId, clientRunId, sessionKey);
}
} }
} }

View File

@@ -2,12 +2,17 @@ import { randomUUID } from "node:crypto";
import { resolveThinkingDefault } from "../../agents/model-selection.js"; import { resolveThinkingDefault } from "../../agents/model-selection.js";
import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
import { isAbortTrigger } from "../../auto-reply/reply/abort.js";
import { agentCommand } from "../../commands/agent.js"; import { agentCommand } from "../../commands/agent.js";
import { mergeSessionEntry, saveSessionStore } from "../../config/sessions.js"; import { mergeSessionEntry, saveSessionStore } from "../../config/sessions.js";
import { registerAgentRunContext } from "../../infra/agent-events.js"; import { registerAgentRunContext } from "../../infra/agent-events.js";
import { defaultRuntime } from "../../runtime.js"; import { defaultRuntime } from "../../runtime.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js"; import { resolveSendPolicy } from "../../sessions/send-policy.js";
import {
abortChatRunById,
abortChatRunsForSessionKey,
isChatStopCommandText,
resolveChatRunExpiresAtMs,
} from "../chat-abort.js";
import { buildMessageWithAttachments } from "../chat-attachments.js"; import { buildMessageWithAttachments } from "../chat-attachments.js";
import { import {
ErrorCodes, ErrorCodes,
@@ -97,11 +102,32 @@ export const chatHandlers: GatewayRequestHandlers = {
} }
const { sessionKey, runId } = params as { const { sessionKey, runId } = params as {
sessionKey: string; sessionKey: string;
runId: string; runId?: string;
}; };
const ops = {
chatAbortControllers: context.chatAbortControllers,
chatRunBuffers: context.chatRunBuffers,
chatDeltaSentAt: context.chatDeltaSentAt,
chatAbortedRuns: context.chatAbortedRuns,
removeChatRun: context.removeChatRun,
agentRunSeq: context.agentRunSeq,
broadcast: context.broadcast,
bridgeSendToSession: context.bridgeSendToSession,
};
if (!runId) {
const res = abortChatRunsForSessionKey(ops, {
sessionKey,
stopReason: "rpc",
});
respond(true, { ok: true, aborted: res.aborted, runIds: res.runIds });
return;
}
const active = context.chatAbortControllers.get(runId); const active = context.chatAbortControllers.get(runId);
if (!active) { if (!active) {
respond(true, { ok: true, aborted: false }); respond(true, { ok: true, aborted: false, runIds: [] });
return; return;
} }
if (active.sessionKey !== sessionKey) { if (active.sessionKey !== sessionKey) {
@@ -116,21 +142,16 @@ export const chatHandlers: GatewayRequestHandlers = {
return; return;
} }
active.controller.abort(); const res = abortChatRunById(ops, {
context.chatAbortControllers.delete(runId);
context.chatRunBuffers.delete(runId);
context.chatDeltaSentAt.delete(runId);
context.removeChatRun(runId, runId, sessionKey);
const payload = {
runId, runId,
sessionKey, sessionKey,
seq: (context.agentRunSeq.get(runId) ?? 0) + 1, stopReason: "rpc",
state: "aborted" as const, });
}; respond(true, {
context.broadcast("chat", payload); ok: true,
context.bridgeSendToSession(sessionKey, "chat", payload); aborted: res.aborted,
respond(true, { ok: true, aborted: true }); runIds: res.aborted ? [runId] : [],
});
}, },
"chat.send": async ({ params, respond, context }) => { "chat.send": async ({ params, respond, context }) => {
if (!validateChatSendParams(params)) { if (!validateChatSendParams(params)) {
@@ -158,12 +179,7 @@ export const chatHandlers: GatewayRequestHandlers = {
timeoutMs?: number; timeoutMs?: number;
idempotencyKey: string; idempotencyKey: string;
}; };
const stopCommand = (() => { const stopCommand = isChatStopCommandText(p.message);
const msg = p.message.trim();
if (!msg) return false;
const normalized = msg.toLowerCase();
return normalized === "/stop" || isAbortTrigger(msg);
})();
const normalizedAttachments = const normalizedAttachments =
p.attachments?.map((a) => ({ p.attachments?.map((a) => ({
type: typeof a?.type === "string" ? a.type : undefined, type: typeof a?.type === "string" ? a.type : undefined,
@@ -231,29 +247,20 @@ export const chatHandlers: GatewayRequestHandlers = {
} }
if (stopCommand) { if (stopCommand) {
const runIds: string[] = []; const res = abortChatRunsForSessionKey(
for (const [runId, active] of context.chatAbortControllers) { {
if (active.sessionKey !== p.sessionKey) continue; chatAbortControllers: context.chatAbortControllers,
active.controller.abort(); chatRunBuffers: context.chatRunBuffers,
context.chatAbortControllers.delete(runId); chatDeltaSentAt: context.chatDeltaSentAt,
context.chatRunBuffers.delete(runId); chatAbortedRuns: context.chatAbortedRuns,
context.chatDeltaSentAt.delete(runId); removeChatRun: context.removeChatRun,
context.removeChatRun(runId, runId, p.sessionKey); agentRunSeq: context.agentRunSeq,
const payload = { broadcast: context.broadcast,
runId, bridgeSendToSession: context.bridgeSendToSession,
sessionKey: p.sessionKey, },
seq: (context.agentRunSeq.get(runId) ?? 0) + 1, { sessionKey: p.sessionKey, stopReason: "stop" },
state: "aborted" as const, );
}; respond(true, { ok: true, aborted: res.aborted, runIds: res.runIds });
context.broadcast("chat", payload);
context.bridgeSendToSession(p.sessionKey, "chat", payload);
runIds.push(runId);
}
respond(true, {
ok: true,
aborted: runIds.length > 0,
runIds,
});
return; return;
} }
@@ -282,6 +289,8 @@ export const chatHandlers: GatewayRequestHandlers = {
controller: abortController, controller: abortController,
sessionId, sessionId,
sessionKey: p.sessionKey, sessionKey: p.sessionKey,
startedAtMs: now,
expiresAtMs: resolveChatRunExpiresAtMs({ now, timeoutMs }),
}); });
context.addChatRun(clientRunId, { context.addChatRun(clientRunId, {
sessionKey: p.sessionKey, sessionKey: p.sessionKey,

View File

@@ -4,6 +4,7 @@ import type { HealthSummary } from "../../commands/health.js";
import type { CronService } from "../../cron/service.js"; import type { CronService } from "../../cron/service.js";
import type { startNodeBridgeServer } from "../../infra/bridge/server.js"; import type { startNodeBridgeServer } from "../../infra/bridge/server.js";
import type { WizardSession } from "../../wizard/session.js"; import type { WizardSession } from "../../wizard/session.js";
import type { ChatAbortControllerEntry } from "../chat-abort.js";
import type { import type {
ConnectParams, ConnectParams,
ErrorShape, ErrorShape,
@@ -49,10 +50,8 @@ export type GatewayRequestContext = {
) => void; ) => void;
hasConnectedMobileNode: () => boolean; hasConnectedMobileNode: () => boolean;
agentRunSeq: Map<string, number>; agentRunSeq: Map<string, number>;
chatAbortControllers: Map< chatAbortControllers: Map<string, ChatAbortControllerEntry>;
string, chatAbortedRuns: Map<string, number>;
{ controller: AbortController; sessionId: string; sessionKey: string }
>;
chatRunBuffers: Map<string, string>; chatRunBuffers: Map<string, string>;
chatDeltaSentAt: Map<string, number>; chatDeltaSentAt: Map<string, number>;
addChatRun: ( addChatRun: (

View File

@@ -836,6 +836,136 @@ describe("gateway server chat", () => {
}, },
); );
test("chat.send idempotency returns started → in_flight → ok", async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const spy = vi.mocked(agentCommand);
let resolveRun: (() => void) | undefined;
const runDone = new Promise<void>((resolve) => {
resolveRun = resolve;
});
spy.mockImplementationOnce(async () => {
await runDone;
});
const started = await rpcReq<{ runId?: string; status?: string }>(
ws,
"chat.send",
{
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-status-1",
},
);
expect(started.ok).toBe(true);
expect(started.payload?.status).toBe("started");
const inFlight = await rpcReq<{ runId?: string; status?: string }>(
ws,
"chat.send",
{
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-status-1",
},
);
expect(inFlight.ok).toBe(true);
expect(inFlight.payload?.status).toBe("in_flight");
resolveRun?.();
let completed = false;
for (let i = 0; i < 50; i++) {
const again = await rpcReq<{ runId?: string; status?: string }>(
ws,
"chat.send",
{
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-status-1",
},
);
if (again.ok && again.payload?.status === "ok") {
completed = true;
break;
}
await new Promise((r) => setTimeout(r, 10));
}
expect(completed).toBe(true);
ws.close();
await server.close();
});
test("chat.abort without runId aborts active runs and suppresses chat events after abort", async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const spy = vi.mocked(agentCommand);
spy.mockImplementationOnce(async (opts) => {
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
await new Promise<void>((resolve) => {
if (!signal) return resolve();
if (signal.aborted) return resolve();
signal.addEventListener("abort", () => resolve(), { once: true });
});
});
const abortedEventP = onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "chat" &&
o.payload?.state === "aborted" &&
o.payload?.runId === "idem-abort-all-1",
);
const started = await rpcReq(ws, "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-abort-all-1",
});
expect(started.ok).toBe(true);
const abortRes = await rpcReq<{
ok?: boolean;
aborted?: boolean;
runIds?: string[];
}>(ws, "chat.abort", { sessionKey: "main" });
expect(abortRes.ok).toBe(true);
expect(abortRes.payload?.aborted).toBe(true);
expect(abortRes.payload?.runIds ?? []).toContain("idem-abort-all-1");
await abortedEventP;
const noDeltaP = onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "chat" &&
(o.payload?.state === "delta" || o.payload?.state === "final") &&
o.payload?.runId === "idem-abort-all-1",
250,
);
emitAgentEvent({
runId: "idem-abort-all-1",
stream: "assistant",
data: { text: "should be suppressed" },
});
emitAgentEvent({
runId: "idem-abort-all-1",
stream: "lifecycle",
data: { phase: "end" },
});
await expect(noDeltaP).rejects.toThrow(/timeout/i);
ws.close();
await server.close();
});
test("chat.abort returns aborted=false for unknown runId", async () => { test("chat.abort returns aborted=false for unknown runId", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json"); testState.sessionStorePath = path.join(dir, "sessions.json");

View File

@@ -110,6 +110,10 @@ import {
type ResolvedGatewayAuth, type ResolvedGatewayAuth,
resolveGatewayAuth, resolveGatewayAuth,
} from "./auth.js"; } from "./auth.js";
import {
abortChatRunById,
type ChatAbortControllerEntry,
} from "./chat-abort.js";
import { import {
type GatewayReloadPlan, type GatewayReloadPlan,
type ProviderKind, type ProviderKind,
@@ -685,10 +689,7 @@ export async function startGatewayServer(
} }
return sessionKey; return sessionKey;
}; };
const chatAbortControllers = new Map< const chatAbortControllers = new Map<string, ChatAbortControllerEntry>();
string,
{ controller: AbortController; sessionId: string; sessionKey: string }
>();
setCommandLaneConcurrency("cron", cfgAtStart.cron?.maxConcurrentRuns ?? 1); setCommandLaneConcurrency("cron", cfgAtStart.cron?.maxConcurrentRuns ?? 1);
setCommandLaneConcurrency( setCommandLaneConcurrency(
"main", "main",
@@ -967,6 +968,7 @@ export async function startGatewayServer(
addChatRun, addChatRun,
removeChatRun, removeChatRun,
chatAbortControllers, chatAbortControllers,
chatAbortedRuns: chatRunState.abortedRuns,
chatRunBuffers, chatRunBuffers,
chatDeltaSentAt, chatDeltaSentAt,
dedupe, dedupe,
@@ -1192,6 +1194,31 @@ export async function startGatewayServer(
dedupe.delete(entries[i][0]); dedupe.delete(entries[i][0]);
} }
} }
for (const [runId, entry] of chatAbortControllers) {
if (now <= entry.expiresAtMs) continue;
abortChatRunById(
{
chatAbortControllers,
chatRunBuffers,
chatDeltaSentAt,
chatAbortedRuns: chatRunState.abortedRuns,
removeChatRun,
agentRunSeq,
broadcast,
bridgeSendToSession,
},
{ runId, sessionKey: entry.sessionKey, stopReason: "timeout" },
);
}
const ABORTED_RUN_TTL_MS = 60 * 60_000;
for (const [runId, abortedAt] of chatRunState.abortedRuns) {
if (now - abortedAt <= ABORTED_RUN_TTL_MS) continue;
chatRunState.abortedRuns.delete(runId);
chatRunBuffers.delete(runId);
chatDeltaSentAt.delete(runId);
}
}, 60_000); }, 60_000);
const agentUnsub = onAgentEvent( const agentUnsub = onAgentEvent(
@@ -1647,6 +1674,7 @@ export async function startGatewayServer(
hasConnectedMobileNode, hasConnectedMobileNode,
agentRunSeq, agentRunSeq,
chatAbortControllers, chatAbortControllers,
chatAbortedRuns: chatRunState.abortedRuns,
chatRunBuffers, chatRunBuffers,
chatDeltaSentAt, chatDeltaSentAt,
addChatRun, addChatRun,

View File

@@ -1033,13 +1033,19 @@ export class ClawdbotApp extends LitElement {
const trimmed = text.trim(); const trimmed = text.trim();
if (!trimmed) return false; if (!trimmed) return false;
const normalized = trimmed.toLowerCase(); const normalized = trimmed.toLowerCase();
return normalized === "/stop" || normalized === "stop" || normalized === "abort"; if (normalized === "/stop") return true;
return (
normalized === "stop" ||
normalized === "esc" ||
normalized === "abort" ||
normalized === "wait" ||
normalized === "exit"
);
} }
async handleAbortChat() { async handleAbortChat() {
if (!this.connected) return; if (!this.connected) return;
this.chatMessage = ""; this.chatMessage = "";
if (!this.chatRunId) return;
await abortChatRun(this); await abortChatRun(this);
} }

View File

@@ -95,12 +95,13 @@ export async function sendChatMessage(state: ChatState, message: string): Promis
export async function abortChatRun(state: ChatState): Promise<boolean> { export async function abortChatRun(state: ChatState): Promise<boolean> {
if (!state.client || !state.connected) return false; if (!state.client || !state.connected) return false;
const runId = state.chatRunId; const runId = state.chatRunId;
if (!runId) return false;
try { try {
await state.client.request("chat.abort", { await state.client.request(
sessionKey: state.sessionKey, "chat.abort",
runId, runId
}); ? { sessionKey: state.sessionKey, runId }
: { sessionKey: state.sessionKey },
);
return true; return true;
} catch (err) { } catch (err) {
state.lastError = String(err); state.lastError = String(err);