Skip to content

Commit e80cbcf

Browse files
authored
fix(abort): prevent stale busy state after interruption (#126)
1 parent ae492e9 commit e80cbcf

13 files changed

Lines changed: 759 additions & 54 deletions

File tree

src/bot/commands/abort.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import { logger } from "../../utils/logger.js";
66
import { t } from "../../i18n/index.js";
77
import { foregroundSessionState } from "../../scheduled-task/foreground-state.js";
88
import { assistantRunState } from "../assistant-run-state.js";
9+
import { markAttachedSessionIdle } from "../../attach/service.js";
10+
import { clearPromptResponseMode } from "../handlers/prompt.js";
11+
import { markUserAbortRequested } from "../utils/abort-error-suppression.js";
912

1013
type SessionState = "idle" | "busy" | "not-found";
1114

@@ -19,6 +22,13 @@ function abortLocalStreaming(): void {
1922
clearAllInteractionState("abort_command");
2023
}
2124

25+
async function releaseAbortBusyState(sessionId: string, reason: string): Promise<void> {
26+
foregroundSessionState.markIdle(sessionId);
27+
assistantRunState.clearRun(sessionId, reason);
28+
await markAttachedSessionIdle(sessionId);
29+
clearPromptResponseMode(sessionId);
30+
}
31+
2232
async function pollSessionStatus(
2333
sessionId: string,
2434
directory: string,
@@ -92,6 +102,7 @@ export async function abortCurrentOperation(
92102

93103
const controller = new AbortController();
94104
const timeoutId = setTimeout(() => controller.abort(), 5000);
105+
markUserAbortRequested(currentSession.id);
95106

96107
try {
97108
const { data: abortResult, error: abortError } = await opencodeClient.session.abort(
@@ -106,13 +117,15 @@ export async function abortCurrentOperation(
106117

107118
if (abortError) {
108119
logger.warn("[Abort] Abort request failed:", abortError);
120+
await releaseAbortBusyState(currentSession.id, "abort_unconfirmed");
109121
if (notifyUser && chatId !== null && waitingMessageId !== null) {
110122
await ctx.api.editMessageText(chatId, waitingMessageId, t("stop.warn_unconfirmed"));
111123
}
112124
return;
113125
}
114126

115127
if (abortResult !== true) {
128+
await releaseAbortBusyState(currentSession.id, "abort_maybe_finished");
116129
if (notifyUser && chatId !== null && waitingMessageId !== null) {
117130
await ctx.api.editMessageText(chatId, waitingMessageId, t("stop.warn_maybe_finished"));
118131
}
@@ -126,8 +139,7 @@ export async function abortCurrentOperation(
126139
);
127140

128141
if (finalStatus === "idle" || finalStatus === "not-found") {
129-
foregroundSessionState.markIdle(currentSession.id);
130-
assistantRunState.clearRun(currentSession.id, "abort_confirmed");
142+
await releaseAbortBusyState(currentSession.id, "abort_confirmed");
131143
if (notifyUser && chatId !== null && waitingMessageId !== null) {
132144
await ctx.api.editMessageText(chatId, waitingMessageId, t("stop.success"));
133145
}
@@ -138,6 +150,7 @@ export async function abortCurrentOperation(
138150
}
139151
} catch (error) {
140152
clearTimeout(timeoutId);
153+
await releaseAbortBusyState(currentSession.id, "abort_error");
141154

142155
if (error instanceof Error && error.name === "AbortError") {
143156
if (notifyUser && chatId !== null && waitingMessageId !== null) {

src/bot/commands/opencode-start.ts

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,27 +7,65 @@ import { logger } from "../../utils/logger.js";
77
import { t } from "../../i18n/index.js";
88
import { editBotText } from "../utils/telegram-text.js";
99

10+
const SERVER_READY_TIMEOUT_MS = 10_000;
11+
const SERVER_READY_POLL_INTERVAL_MS = 500;
12+
const HEALTH_CHECK_TIMEOUT_MS = 3_000;
13+
const HEALTH_CHECK_TIMED_OUT = Symbol("health-check-timed-out");
14+
15+
type HealthCheckResult = Awaited<ReturnType<typeof opencodeClient.global.health>>;
16+
17+
async function healthWithTimeout(
18+
timeoutMs: number = HEALTH_CHECK_TIMEOUT_MS,
19+
): Promise<HealthCheckResult | typeof HEALTH_CHECK_TIMED_OUT> {
20+
const controller = new AbortController();
21+
let timeout: ReturnType<typeof setTimeout> | undefined;
22+
23+
try {
24+
return await Promise.race([
25+
opencodeClient.global.health({ signal: controller.signal }),
26+
new Promise<typeof HEALTH_CHECK_TIMED_OUT>((resolve) => {
27+
timeout = setTimeout(() => {
28+
controller.abort();
29+
resolve(HEALTH_CHECK_TIMED_OUT);
30+
}, timeoutMs);
31+
}),
32+
]);
33+
} finally {
34+
if (timeout) {
35+
clearTimeout(timeout);
36+
}
37+
}
38+
}
39+
40+
async function getHealthIfAvailable(): Promise<HealthCheckResult | null> {
41+
try {
42+
const result = await healthWithTimeout();
43+
if (result === HEALTH_CHECK_TIMED_OUT) {
44+
logger.warn(`[Bot] OpenCode health check timed out after ${HEALTH_CHECK_TIMEOUT_MS}ms`);
45+
return null;
46+
}
47+
48+
return result;
49+
} catch {
50+
return null;
51+
}
52+
}
53+
1054
/**
1155
* Wait for OpenCode server to become ready by polling health endpoint
1256
* @param maxWaitMs Maximum time to wait in milliseconds
1357
* @returns true if server became ready, false if timeout
1458
*/
1559
async function waitForServerReady(maxWaitMs: number = 10000): Promise<boolean> {
1660
const startTime = Date.now();
17-
const pollInterval = 500;
1861

1962
while (Date.now() - startTime < maxWaitMs) {
20-
try {
21-
const { data, error } = await opencodeClient.global.health();
22-
23-
if (!error && data?.healthy) {
24-
return true;
25-
}
26-
} catch {
27-
// Server not ready yet
63+
const health = await getHealthIfAvailable();
64+
if (health?.data?.healthy) {
65+
return true;
2866
}
2967

30-
await new Promise((resolve) => setTimeout(resolve, pollInterval));
68+
await new Promise((resolve) => setTimeout(resolve, SERVER_READY_POLL_INTERVAL_MS));
3169
}
3270

3371
return false;
@@ -47,9 +85,10 @@ export async function opencodeStartCommand(ctx: CommandContext<Context>) {
4785

4886
// Check if server is already accessible.
4987
try {
50-
const { data, error } = await opencodeClient.global.health();
88+
const health = await getHealthIfAvailable();
89+
const data = health?.data;
5190

52-
if (!error && data?.healthy) {
91+
if (data?.healthy) {
5392
await ctx.reply(
5493
t("opencode_start.already_running", { version: data.version || t("common.unknown") }),
5594
);
@@ -82,7 +121,7 @@ export async function opencodeStartCommand(ctx: CommandContext<Context>) {
82121
childProcess.unref();
83122

84123
logger.info("[Bot] Waiting for OpenCode server to become ready...");
85-
const ready = await waitForServerReady(10000);
124+
const ready = await waitForServerReady(SERVER_READY_TIMEOUT_MS);
86125

87126
if (!ready) {
88127
await editBotText({
@@ -96,7 +135,7 @@ export async function opencodeStartCommand(ctx: CommandContext<Context>) {
96135
return;
97136
}
98137

99-
const { data: health } = await opencodeClient.global.health();
138+
const health = (await getHealthIfAvailable())?.data;
100139
await editBotText({
101140
api: ctx.api,
102141
chatId: ctx.chat.id,

src/bot/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ import { reconcileBusyState } from "./utils/busy-reconciliation.js";
8383
import { finalizeAssistantResponse } from "./utils/finalize-assistant-response.js";
8484
import { sendTtsResponseForSession } from "./utils/send-tts-response.js";
8585
import { deliverThinkingMessage } from "./utils/thinking-message.js";
86+
import { shouldSuppressUserAbortSessionError } from "./utils/abort-error-suppression.js";
8687
import {
8788
editRenderedBotPart,
8889
getTelegramRenderedPartSignature,
@@ -940,6 +941,13 @@ async function ensureEventSubscription(directory: string): Promise<void> {
940941
]);
941942

942943
const normalizedMessage = message.trim() || t("common.unknown_error");
944+
if (shouldSuppressUserAbortSessionError(sessionId, normalizedMessage)) {
945+
logger.debug(`[Bot] Suppressed user-initiated abort error: session=${sessionId}`);
946+
foregroundSessionState.markIdle(sessionId);
947+
await scheduledTaskRuntime.flushDeferredDeliveries();
948+
return;
949+
}
950+
943951
const truncatedMessage =
944952
normalizedMessage.length > 3500
945953
? `${normalizedMessage.slice(0, 3497)}...`

src/bot/middleware/interaction-guard.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Context, NextFunction } from "grammy";
22
import { resolveInteractionGuardDecision } from "../../interaction/guard.js";
33
import type { BlockReason, InteractionKind } from "../../interaction/types.js";
4+
import { reconcileForegroundBusyState } from "../utils/busy-guard.js";
45
import { logger } from "../../utils/logger.js";
56
import { t } from "../../i18n/index.js";
67

@@ -84,7 +85,12 @@ function getInteractionBlockedMessage(
8485
}
8586

8687
export async function interactionGuardMiddleware(ctx: Context, next: NextFunction): Promise<void> {
87-
const decision = resolveInteractionGuardDecision(ctx);
88+
let decision = resolveInteractionGuardDecision(ctx);
89+
90+
if (!decision.allow && decision.busy) {
91+
await reconcileForegroundBusyState();
92+
decision = resolveInteractionGuardDecision(ctx);
93+
}
8894

8995
if (decision.allow) {
9096
await next();
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Covers abort request timeout, post-abort status polling, and delayed SSE reconnect delivery.
2+
const USER_ABORT_SUPPRESSION_WINDOW_MS = 90_000;
3+
4+
const userAbortRequestedAtBySession = new Map<string, number>();
5+
6+
function deleteExpiredAbortRequests(now: number = Date.now()): void {
7+
for (const [sessionId, requestedAt] of userAbortRequestedAtBySession) {
8+
if (now - requestedAt > USER_ABORT_SUPPRESSION_WINDOW_MS) {
9+
userAbortRequestedAtBySession.delete(sessionId);
10+
}
11+
}
12+
}
13+
14+
export function markUserAbortRequested(sessionId: string): void {
15+
const now = Date.now();
16+
deleteExpiredAbortRequests(now);
17+
userAbortRequestedAtBySession.set(sessionId, now);
18+
}
19+
20+
export function shouldSuppressUserAbortSessionError(sessionId: string, message: string): boolean {
21+
if (message.trim().toLowerCase() !== "aborted") {
22+
return false;
23+
}
24+
25+
const requestedAt = userAbortRequestedAtBySession.get(sessionId);
26+
if (requestedAt === undefined) {
27+
return false;
28+
}
29+
30+
userAbortRequestedAtBySession.delete(sessionId);
31+
return Date.now() - requestedAt <= USER_ABORT_SUPPRESSION_WINDOW_MS;
32+
}
33+
34+
export function __resetUserAbortErrorSuppressionForTests(): void {
35+
userAbortRequestedAtBySession.clear();
36+
}
37+
38+
export function __getUserAbortErrorSuppressionSizeForTests(): number {
39+
return userAbortRequestedAtBySession.size;
40+
}

src/bot/utils/busy-guard.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,43 @@
11
import type { Context } from "grammy";
22
import { foregroundSessionState } from "../../scheduled-task/foreground-state.js";
33
import { attachManager } from "../../attach/manager.js";
4+
import { reconcileBusyStateNow } from "./busy-reconciliation.js";
45
import { t } from "../../i18n/index.js";
6+
import { logger } from "../../utils/logger.js";
57

68
export function isForegroundBusy(): boolean {
79
return foregroundSessionState.isBusy() || attachManager.isBusy();
810
}
911

12+
function getBusyDirectories(): string[] {
13+
const directories = new Set<string>();
14+
15+
for (const session of foregroundSessionState.getBusySessions()) {
16+
directories.add(session.directory);
17+
}
18+
19+
const attached = attachManager.getSnapshot();
20+
if (attached?.busy) {
21+
directories.add(attached.directory);
22+
}
23+
24+
return [...directories];
25+
}
26+
27+
export async function reconcileForegroundBusyState(): Promise<void> {
28+
if (!isForegroundBusy()) {
29+
return;
30+
}
31+
32+
for (const directory of getBusyDirectories()) {
33+
try {
34+
await reconcileBusyStateNow(directory);
35+
} catch (error) {
36+
logger.warn("[BusyGuard] Failed to reconcile foreground busy state", error);
37+
}
38+
}
39+
}
40+
1041
export async function replyBusyBlocked(ctx: Context): Promise<void> {
1142
const message = t("bot.session_busy");
1243

0 commit comments

Comments
 (0)