Skip to content
Open
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
2 changes: 1 addition & 1 deletion apps/server/src/mcp/toolkits/thread/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ const ThreadSearchTool = Tool.make("t3_thread_search", {
const ScheduledTaskRunTool = Tool.make("run_scheduled_task_now", {
...commandTool,
description:
"Run a scheduled task now through the existing scheduler. Requires a full-access/default caller. Each call is a new manual run; completion means dispatch/bookkeeping completed, not that the provider turn finished.",
"Run a scheduled task now through the existing scheduler. Requires a full-access/default caller. Each call is a new manual run; completion means dispatch/bookkeeping completed, not that the provider turn finished: lastRunStatus stays 'running' until the turn ends, then becomes 'succeeded' or 'failed'.",
parameters: Schema.Struct({ taskId: ScheduledTaskId }),
success: Schema.Struct({
taskId: ScheduledTaskId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,8 @@ it.effect.each(["child completion", "scheduled message", "user steering"] as con
}),
);
const ran = yield* service.runNow({ id: task.id });
assert.equal(ran.task.lastRunStatus, "succeeded");
// Queued behind the active run; the task records the turn's outcome later.
assert.equal(ran.task.lastRunStatus, "running");
}).pipe(
Effect.provide(
ScheduledTaskService.layer.pipe(
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/orchestration-v2/ThreadLaunchService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,8 @@ it.effect.each(
creationSource: createdBy === "agent" ? "mcp" : "web",
});
const result = yield* tasks.runNow({ id: task.id });
assert.equal(result.task.lastRunStatus, "succeeded");
// Dispatched; the task records the turn's outcome when it ends.
assert.equal(result.task.lastRunStatus, "running");
const projectThreads = yield* threads.listProjectThreads({
projectId,
includeSubagents: false,
Expand Down
45 changes: 45 additions & 0 deletions apps/server/src/scheduledTasks/RunOutcomes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { assert, it } from "@effect/vitest";
import { MessageId, ScheduledTaskId, type OrchestrationV2DomainEvent } from "@t3tools/contracts";

import { makeRunOutcomes } from "./RunOutcomes.ts";

const runEvent = (messageId: string, status: string): OrchestrationV2DomainEvent =>
({
type: "run.updated",
payload: { id: "run:scheduled", userMessageId: messageId, status },
}) as never;

it("forgets a deleted task's run before and after it was matched to a run id", () => {
const outcomes = makeRunOutcomes();
const taskId = ScheduledTaskId.make("task");
outcomes.track(MessageId.make("message:a"), taskId, "2026-09-09T12:00:00.000Z");
outcomes.forgetTask(taskId);
assert.isNull(outcomes.observe(runEvent("message:a", "completed")));

outcomes.track(MessageId.make("message:b"), taskId, "2026-09-09T12:01:00.000Z");
assert.isNull(outcomes.observe(runEvent("message:b", "running")));
outcomes.forgetTask(taskId);
assert.isNull(outcomes.observe(runEvent("message:b", "completed")));
});

it("reports only the first terminal outcome of a run", () => {
const outcomes = makeRunOutcomes();
outcomes.track(
MessageId.make("message:a"),
ScheduledTaskId.make("task"),
"2026-09-09T12:00:00.000Z",
);
assert.equal(outcomes.observe(runEvent("message:a", "completed"))?.status, "succeeded");
assert.isNull(outcomes.observe(runEvent("message:a", "rolled_back")));
});

it("forgets a failed dispatch whose run was already created", () => {
const outcomes = makeRunOutcomes();
const messageId = MessageId.make("message:a");
outcomes.track(messageId, ScheduledTaskId.make("task"), "2026-09-09T12:00:00.000Z");
assert.isNull(
outcomes.observe({ ...runEvent("message:a", "queued"), type: "run.created" } as never),
);
outcomes.forget(messageId);
assert.isNull(outcomes.observe(runEvent("message:a", "completed")));
});
96 changes: 96 additions & 0 deletions apps/server/src/scheduledTasks/RunOutcomes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import type {
MessageId,
OrchestrationV2DomainEvent,
OrchestrationV2RunStatus,
RunId,
ScheduledTaskId,
} from "@t3tools/contracts";

/** The terminal outcome of a dispatched scheduled run, keyed to the row that started it. */
export interface RunOutcome {
readonly taskId: ScheduledTaskId;
/** The task's `last_run_at` for this run; a newer run replaces it. */
readonly startedAtIso: string;
readonly status: "succeeded" | "failed";
readonly error: string | null;
}

interface PendingRun {
/** The message the dispatch stamped; a failed dispatch forgets the run by it. */
readonly messageId: MessageId;
readonly taskId: ScheduledTaskId;
readonly startedAtIso: string;
/** The latest provider error the run reported, used if it ends failed. */
error: string | null;
}

const FAILED_RUN_ERRORS: Partial<Record<OrchestrationV2RunStatus, string>> = {
failed: "The run failed.",
interrupted: "The run was interrupted.",
cancelled: "The run was cancelled.",
rolled_back: "The run was rolled back.",
};

/**
* Follows dispatched scheduled runs to their first terminal outcome. A run is
* found by the user message its dispatch stamped, then followed by its run
* id, which survives a steering restart that replaces the message. In memory
* only: a server restart forgets pending runs, and startup recovery records
* them as interrupted.
*/
export function makeRunOutcomes() {
const byMessage = new Map<MessageId, PendingRun>();
const byRun = new Map<RunId, PendingRun>();
const forgetTask = (taskId: ScheduledTaskId) => {
for (const [id, run] of byMessage) if (run.taskId === taskId) byMessage.delete(id);
for (const [id, run] of byRun) if (run.taskId === taskId) byRun.delete(id);
};
return {
track: (messageId: MessageId, taskId: ScheduledTaskId, startedAtIso: string) => {
// Only a task's latest run can update it, so an older run is dropped.
forgetTask(taskId);
byMessage.set(messageId, { messageId, taskId, startedAtIso, error: null });
},
forget: (messageId: MessageId) => {
byMessage.delete(messageId);
for (const [id, run] of byRun) if (run.messageId === messageId) byRun.delete(id);
},
/** Drops a deleted task's run, which may never report an end. */
forgetTask,
/** Folds in one domain event and returns the outcome once its run is terminal. */
observe: (event: OrchestrationV2DomainEvent): RunOutcome | null => {
if (byMessage.size === 0 && byRun.size === 0) return null;
if (event.type === "turn-item.updated") {
const item = event.payload;
if (item.type !== "error" || item.runId === null) return null;
const run = byRun.get(item.runId);
if (run !== undefined) run.error = item.failure.message;
return null;
}
if (event.type !== "run.created" && event.type !== "run.updated") return null;
const runId = event.payload.id;
let run = byRun.get(runId);
if (run === undefined) {
run = byMessage.get(event.payload.userMessageId);
if (run === undefined) return null;
byMessage.delete(event.payload.userMessageId);
byRun.set(runId, run);
}
const status = event.payload.status;
const settled = { taskId: run.taskId, startedAtIso: run.startedAtIso };
if (status === "completed") {
byRun.delete(runId);
return { ...settled, status: "succeeded", error: null };
}
const failure = FAILED_RUN_ERRORS[status];
if (failure === undefined) return null;
byRun.delete(runId);
// Only a failed run carries a provider error; an interrupt or cancel is the user's.
return {
...settled,
status: "failed",
error: status === "failed" ? (run.error ?? failure) : failure,
};
},
};
}
Loading
Loading