Skip to content
Merged
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
27 changes: 21 additions & 6 deletions src/app/services/scheduled-task-executor-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export const SCHEDULED_TASK_AGENT = "build";
const SCHEDULED_TASK_SESSION_TITLE = "Scheduled task run";
const EXECUTION_POLL_INTERVAL_MS = 2000;
const MAX_IDLE_POLLS_WITHOUT_RESULT = 3;
// Grace period for the server to start the session before any activity is seen.
const MAX_STARTUP_POLLS_WITHOUT_ACTIVITY = 45;
const COMPLETED_EMPTY_RESULT_RECHECK_INTERVAL_MS = 500;
const MAX_COMPLETED_EMPTY_RESULT_RECHECKS = 3;
const MODELS_DOCS_URL = "https://opencode.ai/docs/config/#models";
Expand Down Expand Up @@ -426,6 +428,8 @@ async function waitForScheduledTaskResult(
const startedAtMs = Date.now();
const executionTimeoutMs = getExecutionTimeoutMs();
let idlePollsWithoutResult = 0;
let startupPollsWithoutActivity = 0;
let hasObservedActivity = false;
let completedEmptyResultReadCount = 0;

while (true) {
Expand Down Expand Up @@ -472,7 +476,13 @@ async function waitForScheduledTaskResult(
}

const sessionStatus = statuses[sessionId];
if (!sessionStatus || sessionStatus.type === "idle") {
const sessionIsActive = sessionStatus !== undefined && sessionStatus.type !== "idle";

if (sessionIsActive) {
hasObservedActivity = true;
idlePollsWithoutResult = 0;
startupPollsWithoutActivity = 0;
} else {
const confirmedAssistantResult = await loadAssistantResult(sessionId, directory);

if (confirmedAssistantResult.errorMessage) {
Expand Down Expand Up @@ -500,12 +510,17 @@ async function waitForScheduledTaskResult(
continue;
}

idlePollsWithoutResult += 1;
if (idlePollsWithoutResult >= MAX_IDLE_POLLS_WITHOUT_RESULT) {
throw new Error("Scheduled task finished without a completed assistant response");
if (hasObservedActivity) {
idlePollsWithoutResult += 1;
if (idlePollsWithoutResult >= MAX_IDLE_POLLS_WITHOUT_RESULT) {
throw new Error("Scheduled task finished without a completed assistant response");
}
} else {
startupPollsWithoutActivity += 1;
if (startupPollsWithoutActivity >= MAX_STARTUP_POLLS_WITHOUT_ACTIVITY) {
throw new Error("Scheduled task did not start producing a response in time");
}
}
} else {
idlePollsWithoutResult = 0;
}

await sleep(EXECUTION_POLL_INTERVAL_MS);
Expand Down
45 changes: 45 additions & 0 deletions tests/app/services/scheduled-task-executor-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,51 @@ describe("app/services/scheduled-task-executor-service", () => {
expect(mocked.deleteMock).toHaveBeenCalledWith({ sessionID: "session-1" });
});

it("waits through startup before the server registers the session as active", async () => {
const { executeScheduledTask } = await import("../../../src/app/services/scheduled-task-executor-service.js");

mocked.createMock.mockResolvedValueOnce({
data: { id: "session-1", directory: "D:\\Projects\\Repo", title: "Scheduled task run" },
error: null,
});
mocked.promptAsyncMock.mockResolvedValueOnce({ data: undefined, error: null });

mocked.messagesMock.mockResolvedValue({
data: [createAssistantMessage("Started late but finished", { completed: true })],
error: null,
});
for (let index = 0; index < 7; index += 1) {
mocked.messagesMock.mockResolvedValueOnce({ data: [], error: null });
}

mocked.statusMock.mockResolvedValue({
data: { "session-1": { type: "busy" } },
error: null,
});
mocked.statusMock
.mockResolvedValueOnce({ data: {}, error: null })
.mockResolvedValueOnce({ data: {}, error: null })
.mockResolvedValueOnce({ data: {}, error: null });

vi.useFakeTimers();

const resultPromise = executeScheduledTask(createTask());

await vi.advanceTimersByTimeAsync(12000);

await expect(resultPromise).resolves.toMatchObject({
taskId: "task-1",
status: "success",
resultText: "Started late but finished",
errorMessage: null,
});
expect(mocked.statusMock.mock.calls.length).toBeGreaterThan(3);
expect(mocked.loggerWarnMock).not.toHaveBeenCalledWith(
expect.stringContaining("Scheduled task finished without a completed assistant response"),
);
expect(mocked.deleteMock).toHaveBeenCalledWith({ sessionID: "session-1" });
});

it("treats an empty completed assistant reply as an execution error", async () => {
const { executeScheduledTask } = await import("../../../src/app/services/scheduled-task-executor-service.js");

Expand Down
Loading