Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
404b655
fix(server): scheduled runs record how their turn ended
voida6 Oct 21, 2026
0e9db72
fix(server): scheduled run outcomes survive early ends, promoted stee…
voida6 Oct 21, 2026
2cbe737
fix(server): scheduled run listener resumes forever and settles each …
voida6 Oct 21, 2026
229911c
fix(server): scheduled run catch-up no longer strands a run it cannot…
voida6 Oct 22, 2026
f5ae53f
fix(server): giving up on a catch-up read forgets only that run
voida6 Oct 22, 2026
beceabd
test(server): make the catch-up give-up test independent of fiber ord…
voida6 Oct 22, 2026
9a871f7
Merge upstream/main into fix/scheduled-run-real-outcome
voida6 Oct 25, 2026
206d24f
fix(server): trim the TrackedRuns comment to the four-line limit
voida6 Oct 25, 2026
e5e454d
Merge remote-tracking branch 'github/main' into fix/scheduled-run-rea…
maria-rcks Oct 26, 2026
db4166b
fix(server): settle every scheduled task sharing a run
maria-rcks Oct 26, 2026
c0e79f3
fix(server): reconcile scheduled dispatches across reconnects
maria-rcks Oct 26, 2026
b950979
fix(server): preserve live run bindings during catch-up
maria-rcks Oct 26, 2026
1c4a18d
Merge upstream/main into fix/scheduled-run-real-outcome
voida6 Oct 29, 2026
91157b9
Merge upstream/main into fix/scheduled-run-real-outcome
voida6 Nov 1, 2026
ffc6a84
merge: reconcile main with scheduled run outcomes
maria-rcks Nov 1, 2026
da5ae45
fix(server): preserve scheduled run ownership on interruption
maria-rcks Nov 1, 2026
0e655e0
merge: preserve contributor scheduled run reconciliation
maria-rcks Nov 1, 2026
dcba153
merge: reconcile main policy with scheduled run outcomes
maria-rcks Nov 2, 2026
7512e90
test(server): provide policy to scheduled outcome integration
maria-rcks Nov 2, 2026
551f9ad
fix(server): reconcile replayed webhook run outcomes
maria-rcks Nov 2, 2026
d0ea7a0
test(server): simplify webhook projection mock
maria-rcks Nov 2, 2026
940ef6e
merge: reconcile main with scheduled run outcomes
maria-rcks Nov 3, 2026
eedff92
fix(server): preserve scheduled outcomes across recovery gaps
maria-rcks Nov 3, 2026
8d5084f
fix(server): retry unreadable scheduled run outcomes
maria-rcks Nov 3, 2026
ce7adad
Merge remote-tracking branch 'github/main' into fix/scheduled-run-rea…
maria-rcks Nov 5, 2026
017184d
test(server): wait for the scheduled run listener before forcing read…
maria-rcks Nov 5, 2026
7e22f25
Merge remote-tracking branch 'origin/main' into fix/scheduled-run-rea…
juliusmarminge Nov 11, 2026
dddda26
test(server): mock the destination thread read in scheduled run outco…
juliusmarminge Nov 11, 2026
cb5ef56
refactor(server): settle deleted-thread runs without a synthetic event
juliusmarminge Nov 11, 2026
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
1 change: 1 addition & 0 deletions apps/server/src/mcp/OrchestratorMcpService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,7 @@ function scheduledTaskSummary(task: ScheduledTask, mayRun: boolean): Orchestrato
schedule: task.schedule,
nextRunAt: task.nextRunAt,
lastRunStatus: task.lastRunStatus,
...(task.lastRunError === null ? {} : { lastRunError: task.lastRunError }),
...(task.webhook?.url == null || !mayRun || task.blockedByPolicy !== undefined
? {}
: { webhookUrl: task.webhook.url }),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ it.effect.each(deliveries)(
}),
);
const ran = yield* service.runNow({ id: task.id });
assert.equal(ran.task.lastRunStatus, "succeeded");
assert.equal(ran.task.lastRunStatus, "running");
}).pipe(
Effect.provide(
ScheduledTaskService.layer.pipe(
Expand Down
31 changes: 17 additions & 14 deletions apps/server/src/orchestration-v2/Orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,10 @@ export interface OrchestratorV2Shape {
readonly eventType?: OrchestrationV2DomainEvent["type"];
}) => Stream.Stream<OrchestrationV2StoredEvent, OrchestratorV2Error>;
readonly streamDomainEvents: Stream.Stream<OrchestrationV2DomainEvent, OrchestratorV2Error>;
readonly openDomainEventStream: Effect.Effect<
Stream.Stream<OrchestrationV2DomainEvent, OrchestratorV2Error>,
OrchestratorV2Error
>;
}

export class OrchestratorV2 extends Context.Service<OrchestratorV2, OrchestratorV2Shape>()(
Expand Down Expand Up @@ -14484,6 +14488,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
const shellProjectionError = (cause: unknown) =>
new OrchestratorProjectionError({ threadId: ThreadId.make("thread:shell"), cause });

const openDomainEventStream = Effect.fn("OrchestratorV2.openDomainEventStream")(
function* () {
const latest = yield* eventSink.latestSequence();
return eventSink.stream({ afterSequence: latest }).pipe(
Stream.map((stored) => stored.event),
Stream.mapError((cause) => new OrchestratorDomainEventStreamError({ cause })),
);
},
Effect.mapError((cause) => new OrchestratorDomainEventStreamError({ cause })),
);

return OrchestratorV2.of({
resumeQueuedRuns,
recoverDelegatedTasks,
Expand Down Expand Up @@ -14689,20 +14704,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
}),
),
),
// Start at the latest sequence; awareness needs no historical replay.
streamDomainEvents: Stream.unwrap(
eventSink
.latestSequence()
.pipe(Effect.map((latest) => eventSink.stream({ afterSequence: latest }))),
).pipe(
Stream.map((stored) => stored.event),
Stream.mapError(
(cause) =>
new OrchestratorDomainEventStreamError({
cause,
}),
),
),
streamDomainEvents: Stream.unwrap(openDomainEventStream()),
openDomainEventStream: openDomainEventStream(),
});
});

Expand Down
75 changes: 72 additions & 3 deletions apps/server/src/orchestration-v2/ThreadLaunchService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ it.effect.each(
creationSource: createdBy === "agent" ? "mcp" : "web",
});
const result = yield* tasks.runNow({ id: task.id });
assert.equal(result.task.lastRunStatus, "succeeded");
assert.equal(result.task.lastRunStatus, "running");
const projectThreads = yield* threads.listProjectThreads({
projectId,
includeSubagents: false,
Expand Down Expand Up @@ -488,7 +488,7 @@ it.effect("runs a thread-bound automation on the provider the thread uses now",
});

const result = yield* tasks.runNow({ id: task.id });
assert.equal(result.task.lastRunStatus, "succeeded");
assert.equal(result.task.lastRunStatus, "running");
const projection = yield* threads.getThreadProjection(launched.threadId);
const message = projection.messages.find((item) => item.scheduledTaskId === task.id);
const run = projection.runs.find((item) => item.id === message?.runId);
Expand Down Expand Up @@ -549,6 +549,75 @@ it.effect("retains automation and sender attribution while a message waits in th
}).pipe(Effect.provide(harness.layer));
});

it.effect("an automation queued behind a running turn records how its own turn ends", () => {
const harness = makeHarness({ runSetup: () => Effect.never });
const layerScheduledTasks = ScheduledTasks.layer.pipe(
Layer.provide(
Layer.mergeAll(
ManagedPolicy.layerTest(),
harness.layer,
NodeCrypto.layer,
Scheduler.layer,
Layer.mock(SecretRequests.SecretRequests)({}),
),
),
);
return Effect.gen(function* () {
const tasks = yield* ScheduledTasks.ScheduledTaskService;
const launches = yield* ThreadLaunch.ThreadLaunchService;
const threads = yield* ThreadManagement.ThreadManagementService;
const launched = yield* launches.launch(
launchInput({
command: "command:automation:outcome",
thread: "thread:automation:outcome",
message: "First message",
workspace: { type: "worktree", baseRef: "main" },
}),
);
const { task } = yield* tasks.upsert({
id: ScheduledTaskId.make("scheduled-task:outcome"),
title: "Daily audit",
prompt: "Audit performance and crashes.",
enabled: false,
schedule: { type: "interval", everyMs: 60_000 },
projectId,
threadId: launched.threadId,
workspaceStrategy: { type: "root" },
modelSelection,
runtimeMode: "full-access",
interactionMode: "default",
createdBy: "user",
creationSource: "web",
});
const ran = yield* tasks.runNow({ id: task.id });
assert.equal(ran.task.lastRunStatus, "running");
const projection = yield* threads.getThreadProjection(launched.threadId);
const queued = projection.runs.find((run) => run.status === "queued");
assert.isDefined(queued);
const events = yield* threads.openDomainEventStream;

yield* threads.dispatch({
type: "queued-run.cancel",
commandId: CommandId.make("command:automation:outcome:cancel"),
threadId: launched.threadId,
runId: queued!.id,
});
const cancelled = yield* events.pipe(
Stream.filter((event) => event.type === "run.updated" && event.payload.id === queued!.id),
Stream.runHead,
);
assert.isTrue(Option.isSome(cancelled));
yield* tasks.subscribeList().pipe(
Stream.filter(({ tasks: listed }) => listed[0]?.lastRunStatus === "failed"),
Stream.take(1),
Stream.runDrain,
);
const [settled] = (yield* tasks.list()).tasks;
assert.equal(settled?.lastRunError, "The run was cancelled.");
assert.equal(settled?.runCount, 1);
}).pipe(Effect.provide(Layer.mergeAll(harness.layer, layerScheduledTasks)));
});

it.effect("provisions independent launches concurrently instead of behind a global semaphore", () =>
Effect.gen(function* () {
const setupCount = yield* Ref.make(0);
Expand Down Expand Up @@ -1051,7 +1120,7 @@ it.effect("runs a Scratch scheduled task saved with a worktree in its own folder
WHERE task_id = ${task.id}
`;
const ran = yield* tasks.runNow({ id: task.id });
assert.equal(ran.task.lastRunStatus, "succeeded");
assert.equal(ran.task.lastRunStatus, "running");
const [thread] = yield* threads.listProjectThreads({ projectId, includeSubagents: false });
assert.isDefined(thread);
const projection = yield* preparationSettled(thread!.id);
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/orchestration-v2/ThreadManagementService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,7 @@ export interface ThreadManagementServiceShape {
readonly streamStoredEvents: Orchestrator.OrchestratorV2["Service"]["streamStoredEvents"];
readonly streamStoredEventsFrom: Orchestrator.OrchestratorV2["Service"]["streamStoredEventsFrom"];
readonly streamDomainEvents: Orchestrator.OrchestratorV2["Service"]["streamDomainEvents"];
readonly openDomainEventStream: Orchestrator.OrchestratorV2["Service"]["openDomainEventStream"];
}

export class ThreadManagementService extends Context.Service<
Expand Down Expand Up @@ -1632,6 +1633,7 @@ const make = Effect.gen(function* () {
streamStoredEvents: orchestrator.streamStoredEvents,
streamStoredEventsFrom: orchestrator.streamStoredEventsFrom,
streamDomainEvents: orchestrator.streamDomainEvents,
openDomainEventStream: orchestrator.openDomainEventStream,
});
});

Expand Down
1 change: 1 addition & 0 deletions apps/server/src/relay/AgentAwarenessRelay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ const makeTestRelay = Effect.fnUntraced(function* (
streamStoredEvents: Stream.empty,
streamStoredEventsFrom: () => Stream.empty,
streamDomainEvents: options.domainEvents ?? Stream.empty,
openDomainEventStream: Effect.succeed(options.domainEvents ?? Stream.empty),
});
const publications: Array<{
readonly url: string;
Expand Down
Loading
Loading