Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 19 additions & 19 deletions packages/gateway/__tests__/handlers/watchdog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ vi.mock("@aws-sdk/client-dynamodb", () => ({
vi.mock("@aws-sdk/client-ecs", () => ({
ECSClient: vi.fn(() => ({ send: mockEcsSend })),
StopTaskCommand: vi.fn((params: unknown) => ({ input: params, _tag: "StopTaskCommand" })),
DescribeTasksCommand: vi.fn((params: unknown) => ({ input: params, _tag: "DescribeTasksCommand" })),
DescribeTasksCommand: vi.fn((params: unknown) => ({
input: params,
_tag: "DescribeTasksCommand",
})),
}));

vi.mock("@aws-sdk/client-cloudwatch", () => ({
Expand All @@ -31,21 +34,21 @@ describe("watchdog handler", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv("ECS_CLUSTER_ARN", "arn:cluster");
// Default: CW returns no data → fallback 15-min timeout
// Default: CW returns no data → fallback 30-min timeout
mockCloudWatchSend.mockResolvedValue({ Datapoints: [] });
});

it("should stop tasks inactive for more than 15 minutes", async () => {
it("should stop tasks inactive for more than 30 minutes", async () => {
const { handler } = await import("../../src/handlers/watchdog.js");

const oldTime = new Date(Date.now() - 20 * 60 * 1000).toISOString();
const oldTime = new Date(Date.now() - 35 * 60 * 1000).toISOString();
mockDynamoSend.mockResolvedValueOnce({
Items: [
{
PK: "USER#user-1",
taskArn: "arn:task-1",
status: "Running",
startedAt: new Date(Date.now() - 30 * 60 * 1000).toISOString(),
startedAt: new Date(Date.now() - 40 * 60 * 1000).toISOString(),
lastActivity: oldTime,
},
],
Expand Down Expand Up @@ -303,15 +306,15 @@ describe("watchdog handler", () => {
it("should stop tasks with expired prewarmUntil when inactive", async () => {
const { handler } = await import("../../src/handlers/watchdog.js");

const oldTime = new Date(Date.now() - 20 * 60 * 1000).toISOString();
const oldTime = new Date(Date.now() - 35 * 60 * 1000).toISOString();
mockDynamoSend.mockResolvedValueOnce({
Items: [
{
PK: "USER#system:prewarm",
taskArn: "arn:prewarm-task",
status: "Running",
publicIp: "1.2.3.4",
startedAt: new Date(Date.now() - 30 * 60 * 1000).toISOString(),
startedAt: new Date(Date.now() - 40 * 60 * 1000).toISOString(),
lastActivity: oldTime,
prewarmUntil: Date.now() - 5 * 60 * 1000, // expired 5 min ago
},
Expand Down Expand Up @@ -380,26 +383,23 @@ describe("watchdog handler", () => {
);
});

it("should use 10-min timeout during inactive hours (< 2 total datapoints at current hour)", async () => {
it("should use 30-min timeout during inactive hours (< 2 total datapoints at current hour)", async () => {
const { handler } = await import("../../src/handlers/watchdog.js");

const now = new Date();
const currentHourKST = (now.getUTCHours() + 9) % 24;

// First channel (telegram): 1 datapoint at current hour
mockCloudWatchSend.mockResolvedValueOnce({
Datapoints: [
{ Timestamp: createTimestampForKSTHour(currentHourKST, 1), SampleCount: 1 },
],
Datapoints: [{ Timestamp: createTimestampForKSTHour(currentHourKST, 1), SampleCount: 1 }],
});
// Second channel (web): 0 datapoints → total = 1, below threshold of 2
mockCloudWatchSend.mockResolvedValueOnce({
Datapoints: [],
});

// Task inactive for 12 min — would NOT be stopped with 15-min default,
// but SHOULD be stopped with 10-min inactive timeout
const lastActivity = new Date(Date.now() - 12 * 60 * 1000).toISOString();
// Task inactive for 35 min — exceeds the 30-min inactive timeout
const lastActivity = new Date(Date.now() - 35 * 60 * 1000).toISOString();
mockDynamoSend.mockResolvedValueOnce({
Items: [
{
Expand All @@ -420,7 +420,7 @@ describe("watchdog handler", () => {

await handler();

// Should stop — 12 min > 10 min inactive timeout
// Should stop — 35 min > 30 min inactive timeout
expect(mockEcsSend).toHaveBeenCalledWith(
expect.objectContaining({
input: expect.objectContaining({
Expand Down Expand Up @@ -463,13 +463,13 @@ describe("watchdog handler", () => {
);
});

it("should fall back to 15-min timeout when CW returns empty data", async () => {
it("should fall back to 30-min timeout when CW returns empty data", async () => {
const { handler } = await import("../../src/handlers/watchdog.js");

mockCloudWatchSend.mockResolvedValue({ Datapoints: [] });

// Task inactive for 20 min — should be stopped with 15-min fallback
const lastActivity = new Date(Date.now() - 20 * 60 * 1000).toISOString();
// Task inactive for 35 min — should be stopped with 30-min fallback
const lastActivity = new Date(Date.now() - 35 * 60 * 1000).toISOString();
mockDynamoSend.mockResolvedValueOnce({
Items: [
{
Expand All @@ -490,7 +490,7 @@ describe("watchdog handler", () => {

await handler();

// Should stop — 20 min > 15 min fallback
// Should stop — 35 min > 30 min fallback
expect(mockEcsSend).toHaveBeenCalledWith(
expect.objectContaining({
input: expect.objectContaining({
Expand Down
2 changes: 2 additions & 0 deletions packages/gateway/__tests__/services/message.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ describe("message service", () => {
getTaskState: vi.fn().mockResolvedValue(null),
startTask: vi.fn().mockResolvedValue("arn:new-task"),
putTaskState: vi.fn(),
updateLastActivity: vi.fn(),
savePendingMessage: vi.fn(),
deleteTaskState: vi.fn(),
...overrides,
Expand All @@ -151,6 +152,7 @@ describe("message service", () => {
expect(mockFetch).toHaveBeenCalled();
expect(deps.startTask).not.toHaveBeenCalled();
expect(deps.deleteTaskState).not.toHaveBeenCalled();
expect(deps.updateLastActivity).toHaveBeenCalledWith("user-123");
});

it("should save pending + start task when no active task", async () => {
Expand Down
26 changes: 25 additions & 1 deletion packages/gateway/__tests__/services/task-state.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { getTaskState, putTaskState } from "../../src/services/task-state.js";
import { getTaskState, putTaskState, updateLastActivity } from "../../src/services/task-state.js";

vi.mock("@aws-sdk/lib-dynamodb", () => ({
GetCommand: vi.fn((params: unknown) => ({ input: params, _tag: "GetCommand" })),
PutCommand: vi.fn((params: unknown) => ({ input: params, _tag: "PutCommand" })),
DeleteCommand: vi.fn((params: unknown) => ({ input: params, _tag: "DeleteCommand" })),
UpdateCommand: vi.fn((params: unknown) => ({ input: params, _tag: "UpdateCommand" })),
}));

describe("task-state service", () => {
Expand Down Expand Up @@ -109,4 +111,26 @@ describe("task-state service", () => {
);
});
});

describe("updateLastActivity", () => {
it("should issue an UpdateCommand touching only lastActivity", async () => {
mockSend.mockResolvedValueOnce({});

await updateLastActivity(mockSend, "user-123");

expect(mockSend).toHaveBeenCalledWith(
expect.objectContaining({
_tag: "UpdateCommand",
input: expect.objectContaining({
TableName: expect.stringContaining("TaskState"),
Key: { PK: "USER#user-123" },
UpdateExpression: "SET lastActivity = :la",
ExpressionAttributeValues: expect.objectContaining({
":la": expect.any(String),
}),
}),
}),
);
});
});
});
8 changes: 7 additions & 1 deletion packages/gateway/src/handlers/telegram-webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
import { ECSClient } from "@aws-sdk/client-ecs";

import { getTaskState, putTaskState, deleteTaskState } from "../services/task-state.js";
import {
getTaskState,
putTaskState,
deleteTaskState,
updateLastActivity,
} from "../services/task-state.js";
import { routeMessage, savePendingMessage } from "../services/message.js";
import { startTask } from "../services/container.js";
import { sendTelegramMessage } from "../services/telegram.js";
Expand Down Expand Up @@ -169,6 +174,7 @@ export async function handler(event: {
getTaskState: (uid) => getTaskState(dynamoSend, uid),
startTask: (params) => startTask(ecsSend, params),
putTaskState: (item) => putTaskState(dynamoSend, item),
updateLastActivity: (uid) => updateLastActivity(dynamoSend, uid),
savePendingMessage: (item) => savePendingMessage(dynamoSend, item),
deleteTaskState: (uid) => deleteTaskState(dynamoSend, uid),
startTaskParams: {
Expand Down
8 changes: 7 additions & 1 deletion packages/gateway/src/handlers/ws-message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ import {

import type { ClientMessage, ServerMessage } from "@serverless-openclaw/shared";
import { getConnection } from "../services/connections.js";
import { getTaskState, putTaskState, deleteTaskState } from "../services/task-state.js";
import {
getTaskState,
putTaskState,
deleteTaskState,
updateLastActivity,
} from "../services/task-state.js";
import { routeMessage, savePendingMessage } from "../services/message.js";
import { startTask } from "../services/container.js";
import { resolveSecrets } from "../services/secrets.js";
Expand Down Expand Up @@ -89,6 +94,7 @@ export async function handler(event: {
getTaskState: (uid) => getTaskState(dynamoSend, uid),
startTask: (params) => startTask(ecsSend, params),
putTaskState: (item) => putTaskState(dynamoSend, item),
updateLastActivity: (uid) => updateLastActivity(dynamoSend, uid),
savePendingMessage: (item) => savePendingMessage(dynamoSend, item),
deleteTaskState: (uid) => deleteTaskState(dynamoSend, uid),
startTaskParams: {
Expand Down
9 changes: 9 additions & 0 deletions packages/gateway/src/services/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export interface RouteDeps {
getTaskState: (userId: string) => Promise<TaskStateItem | null>;
startTask: (params: StartTaskParams) => Promise<string>;
putTaskState: (item: TaskStateItem) => Promise<void>;
updateLastActivity: (userId: string) => Promise<void>;
savePendingMessage: (item: PendingMessageItem) => Promise<void>;
deleteTaskState: (userId: string) => Promise<void>;
startTaskParams: StartTaskParams;
Expand Down Expand Up @@ -92,6 +93,14 @@ async function routeFargate(
connectionId: deps.connectionId,
callbackUrl: deps.callbackUrl,
});
// Refresh lastActivity so the watchdog doesn't kill an active container.
// UpdateCommand (single attribute) avoids clobbering concurrent writes
// from the container's lifecycle.updateTaskState().
try {
await deps.updateLastActivity(deps.userId);
} catch (err) {
console.warn("Failed to refresh lastActivity, continuing", err);
}
return "sent";
} catch (err) {
console.warn(
Expand Down
33 changes: 20 additions & 13 deletions packages/gateway/src/services/task-state.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import { GetCommand, PutCommand, DeleteCommand } from "@aws-sdk/lib-dynamodb";
import { GetCommand, PutCommand, DeleteCommand, UpdateCommand } from "@aws-sdk/lib-dynamodb";
import { TABLE_NAMES, KEY_PREFIX } from "@serverless-openclaw/shared";
import type { TaskStateItem } from "@serverless-openclaw/shared";

type Send = (command: unknown) => Promise<unknown>;

export async function getTaskState(
send: Send,
userId: string,
): Promise<TaskStateItem | null> {
export async function getTaskState(send: Send, userId: string): Promise<TaskStateItem | null> {
const result = (await send(
new GetCommand({
TableName: TABLE_NAMES.TASK_STATE,
Expand All @@ -20,10 +17,7 @@ export async function getTaskState(
return item;
}

export async function putTaskState(
send: Send,
item: TaskStateItem,
): Promise<void> {
export async function putTaskState(send: Send, item: TaskStateItem): Promise<void> {
await send(
new PutCommand({
TableName: TABLE_NAMES.TASK_STATE,
Expand All @@ -32,14 +26,27 @@ export async function putTaskState(
);
}

export async function deleteTaskState(
send: Send,
userId: string,
): Promise<void> {
export async function deleteTaskState(send: Send, userId: string): Promise<void> {
await send(
new DeleteCommand({
TableName: TABLE_NAMES.TASK_STATE,
Key: { PK: `${KEY_PREFIX.USER}${userId}` },
}),
);
}

/**
* Atomically refresh `lastActivity` on an existing TaskState row.
* Uses UpdateCommand (single-attribute) to avoid clobbering concurrent writes
* from the container (e.g. `updateTaskState("Running", publicIp)` at boot).
*/
export async function updateLastActivity(send: Send, userId: string): Promise<void> {
await send(
new UpdateCommand({
TableName: TABLE_NAMES.TASK_STATE,
Key: { PK: `${KEY_PREFIX.USER}${userId}` },
UpdateExpression: "SET lastActivity = :la",
ExpressionAttributeValues: { ":la": new Date().toISOString() },
}),
);
}
4 changes: 2 additions & 2 deletions packages/shared/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export const BRIDGE_HTTP_TIMEOUT_MS = 3000;
export const GATEWAY_PORT = 18789;

// Timeouts (ms)
export const INACTIVITY_TIMEOUT_MS = 15 * 60 * 1000;
export const INACTIVITY_TIMEOUT_MS = 30 * 60 * 1000;
export const PENDING_MESSAGE_TTL_SEC = 5 * 60;
export const CONNECTION_TTL_SEC = 24 * 60 * 60;
export const PERIODIC_BACKUP_INTERVAL_MS = 5 * 60 * 1000;
Expand All @@ -46,7 +46,7 @@ export const DEFAULT_PREWARM_DURATION_MIN = 60;

// Dynamic Timeout
export const ACTIVE_TIMEOUT_MS = 30 * 60 * 1000; // 30 min — active hours
export const INACTIVE_TIMEOUT_MS = 10 * 60 * 1000; // 10 min — inactive hours
export const INACTIVE_TIMEOUT_MS = 30 * 60 * 1000; // 30 min — inactive hours (raised to match ACTIVE after investigation 2026-04-22)
export const ACTIVITY_LOOKBACK_DAYS = 7;
export const ACTIVE_HOUR_THRESHOLD = 2; // >= 2 days with activity at this hour
export const METRICS_NAMESPACE = "ServerlessOpenClaw";
Loading