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
119 changes: 118 additions & 1 deletion apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import * as Clock from "effect/Clock";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
import * as ManagedRuntime from "effect/ManagedRuntime";
import * as Option from "effect/Option";
Expand All @@ -42,6 +43,8 @@ import { afterEach, describe, expect, it } from "vite-plus/test";
import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts";
import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts";
import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts";
import { ProjectionTurnRepository } from "../../persistence/Services/ProjectionTurns.ts";
import {
ProviderService,
type ProviderServiceShape,
Expand Down Expand Up @@ -267,6 +270,7 @@ describe("ProviderRuntimeIngestion", () => {
serverSettings?: Partial<ServerSettings>;
threadTitle?: string;
workspaceSubdirectory?: string;
isGitRepository?: CheckpointStore.CheckpointStore["Service"]["isGitRepository"];
}) {
const repositoryRoot = makeTempDir("t3-provider-project-");
NodeChildProcess.execFileSync("git", ["init", "--initial-branch=main"], {
Expand Down Expand Up @@ -327,7 +331,15 @@ describe("ProviderRuntimeIngestion", () => {
Layer.provideMerge(SqlitePersistenceMemory),
Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
Layer.provideMerge(makeTestServerSettingsLayer(options?.serverSettings)),
Layer.provideMerge(CheckpointStore.layer.pipe(Layer.provide(VcsDriverRegistry.layer))),
Layer.provideMerge(
Layer.effect(
CheckpointStore.CheckpointStore,
Effect.map(CheckpointStore.CheckpointStore, (store) => ({
...store,
isGitRepository: options?.isGitRepository ?? store.isGitRepository,
})),
).pipe(Layer.provide(CheckpointStore.layer.pipe(Layer.provide(VcsDriverRegistry.layer)))),
),
Layer.provideMerge(VcsProcess.layer),
Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
Layer.provideMerge(NodeServices.layer),
Expand Down Expand Up @@ -405,6 +417,12 @@ describe("ProviderRuntimeIngestion", () => {
engine,
dispatch,
readModel: () => testRuntime.runPromise(snapshotQuery.getSnapshot()),
readTurn: (turnId: TurnId) =>
testRuntime.runPromise(
Effect.flatMap(ProjectionTurnRepository, (turns) =>
turns.getByTurnId({ threadId: asThreadId("thread-1"), turnId }),
).pipe(Effect.map(Option.getOrUndefined), Effect.provide(ProjectionTurnRepositoryLive)),
),
readThreadShell: () =>
testRuntime.runPromise(
snapshotQuery
Expand Down Expand Up @@ -3749,6 +3767,105 @@ describe("ProviderRuntimeIngestion", () => {
});
});

effectIt.effect("settles the turn while repository detection for a diff is blocked", () =>
Effect.gen(function* () {
const detectionStarted = yield* Deferred.make<void>();
const releaseDetection = yield* Deferred.make<boolean>();
const harness = yield* Effect.promise(() =>
createHarness({
isGitRepository: () =>
Deferred.succeed(detectionStarted, undefined).pipe(
Effect.andThen(Deferred.await(releaseDetection)),
),
}),
);
yield* Effect.addFinalizer(() => Deferred.succeed(releaseDetection, true));
const base = {
provider: ProviderDriverKind.make("codex"),
threadId: asThreadId("thread-1"),
turnId: asTurnId("blocked-diff-turn"),
createdAt: "2026-01-01T00:00:00.000Z",
};
yield* Effect.promise(() =>
harness.emitAndDrain([
{ ...base, type: "turn.started", eventId: asEventId("evt-blocked-turn-start") },
]),
);
harness.emit({
...base,
type: "turn.diff.updated",
eventId: asEventId("evt-blocked-diff"),
payload: { unifiedDiff: "diff --git a/file.ts b/file.ts\n+new\n" },
});
yield* Deferred.await(detectionStarted);

const settled = yield* harness.engine.streamDomainEvents.pipe(
Stream.filter(
(event) =>
event.type === "thread.session-set" &&
event.payload.threadId === base.threadId &&
event.payload.session.status === "error",
),
Stream.runHead,
Effect.forkScoped({ startImmediately: true }),
);
harness.emit({
...base,
type: "item.completed",
eventId: asEventId("evt-blocked-final-reply"),
itemId: asItemId("blocked-final-reply"),
payload: { itemType: "assistant_message", status: "completed", detail: "Work finished." },
});
harness.emit({
...base,
type: "turn.completed",
eventId: asEventId("evt-blocked-turn-completed"),
payload: { state: "failed" },
});
// Resolves only if turn.completed is processed while detection is still blocked.
yield* Fiber.join(settled);
const blocked = yield* Effect.promise(harness.readModel);
expect(blocked.threads[0]?.session).toMatchObject({ status: "error", activeTurnId: null });
expect(blocked.threads[0]?.messages).toEqual(
expect.arrayContaining([expect.objectContaining({ text: "Work finished." })]),
);
expect(blocked.threads[0]?.checkpoints).toEqual([]);

// A newer turn starts before detection returns. The late placeholder
// must neither settle the failed turn as completed nor move the
// latest-turn pointer back to it.
const nextTurnId = asTurnId("next-turn");
const nextTurnStarted = yield* harness.engine.streamDomainEvents.pipe(
Stream.filter(
(event) =>
event.type === "thread.session-set" &&
event.payload.session.activeTurnId === nextTurnId,
),
Stream.runHead,
Effect.forkScoped({ startImmediately: true }),
);
harness.emit({
...base,
type: "turn.started",
turnId: nextTurnId,
eventId: asEventId("evt-next-turn-start"),
});
yield* Fiber.join(nextTurnStarted);
yield* Deferred.succeed(releaseDetection, true);
yield* Effect.promise(harness.drain);
const released = yield* Effect.promise(harness.readModel);
expect(released.threads[0]?.checkpoints).toEqual([]);
expect(released.threads[0]?.latestTurn).toMatchObject({
turnId: nextTurnId,
state: "running",
});
expect(yield* Effect.promise(() => harness.readTurn(base.turnId))).toMatchObject({
state: "error",
checkpointRef: null,
});
}),
);

effectIt.effect("tracks provider diff updates from a nested Git workspace", () =>
Effect.gen(function* () {
const harness = yield* Effect.promise(() =>
Expand Down
157 changes: 96 additions & 61 deletions apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ type TurnStartRequestedDomainEvent = Extract<
{ type: "thread.turn-start-requested" }
>;

type ProviderDiffEvent = Extract<ProviderRuntimeEvent, { type: "turn.diff.updated" }>;

type RuntimeIngestionInput =
| {
source: "runtime";
Expand All @@ -129,6 +131,11 @@ type RuntimeIngestionInput =
| {
source: "domain";
event: TurnStartRequestedDomainEvent;
}
| {
/** A diff whose workspace the diff worker confirmed is a Git repository. */
source: "diff";
event: ProviderDiffEvent;
};

function toTurnId(value: TurnId | string | undefined): TurnId | undefined {
Expand Down Expand Up @@ -2078,48 +2085,6 @@ const make = Effect.gen(function* () {
}
}

if (event.type === "turn.diff.updated") {
const turnId = toTurnId(event.turnId);
const checkpointContext = turnId
? yield* projectionSnapshotQuery
.getThreadCheckpointContext(thread.id)
.pipe(Effect.map(Option.getOrUndefined))
: undefined;
const workspaceCwd =
checkpointContext?.worktreePath ?? checkpointContext?.workspaceRoot ?? undefined;
if (
turnId &&
checkpointContext &&
workspaceCwd &&
(yield* checkpointStore.isGitRepository(workspaceCwd))
) {
// Skip if a checkpoint already exists for this turn. A real
// (non-placeholder) capture from CheckpointReactor should not
// be clobbered, and dispatching a duplicate placeholder for the
// same turnId would produce an unstable checkpointTurnCount.
if (hasCheckpointForTurn(checkpointContext.checkpoints, turnId)) {
// Already tracked; no-op.
} else {
const assistantMessageId = MessageId.make(
`assistant:${event.itemId ?? event.turnId ?? event.eventId}`,
);
yield* orchestrationEngine.dispatch({
type: "thread.turn.diff.complete",
commandId: yield* providerCommandId(event, "thread-turn-diff-complete"),
threadId: thread.id,
turnId,
completedAt: now,
checkpointRef: CheckpointRef.make(`provider-diff:${event.eventId}`),
status: "missing",
files: [],
assistantMessageId,
checkpointTurnCount: maxCheckpointTurnCount(checkpointContext.checkpoints) + 1,
createdAt: now,
});
}
}
}

if (event.type === "task.started" || event.type === "task.progress") {
const description = event.payload.description?.trim();
if (description) {
Expand Down Expand Up @@ -2268,31 +2233,100 @@ const make = Effect.gen(function* () {

const processDomainEvent = (_event: TurnStartRequestedDomainEvent) => Effect.void;

const processInput = (input: RuntimeIngestionInput) =>
input.source === "runtime" ? processRuntimeEvent(input.event) : processDomainEvent(input.event);
// Records a mid-turn placeholder checkpoint for a provider diff. Runs on the
// lifecycle worker, after repository detection, so the running-turn check
// and the dispatch are ordered with the turn's terminal events: a diff that
// resolved after turn.completed must not rewrite the settled turn's state or
// move the latest-turn pointer back.
const recordProviderDiff = Effect.fn("recordProviderDiff")(function* (event: ProviderDiffEvent) {
const thread = yield* resolveThreadRuntimeContext(event.threadId);
const turnId = toTurnId(event.turnId);
if (!thread || !turnId) return;
const turn = yield* projectionTurnRepository.getByTurnId({ threadId: thread.id, turnId });
if (Option.isSome(turn) && turn.value.state !== "running") return;
const checkpointContext = yield* projectionSnapshotQuery
.getThreadCheckpointContext(thread.id)
.pipe(Effect.map(Option.getOrUndefined));
// Skip if a checkpoint already exists for this turn. A real
// (non-placeholder) capture from CheckpointReactor should not
// be clobbered, and dispatching a duplicate placeholder for the
// same turnId would produce an unstable checkpointTurnCount.
if (!checkpointContext || hasCheckpointForTurn(checkpointContext.checkpoints, turnId)) return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const now = event.createdAt;
yield* orchestrationEngine.dispatch({
type: "thread.turn.diff.complete",
commandId: yield* providerCommandId(event, "thread-turn-diff-complete"),
threadId: thread.id,
turnId,
completedAt: now,
checkpointRef: CheckpointRef.make(`provider-diff:${event.eventId}`),
status: "missing",
files: [],
assistantMessageId: MessageId.make(
`assistant:${event.itemId ?? event.turnId ?? event.eventId}`,
),
checkpointTurnCount: maxCheckpointTurnCount(checkpointContext.checkpoints) + 1,
createdAt: now,
});
});

const processInput = (input: RuntimeIngestionInput) => {
switch (input.source) {
case "runtime":
return processRuntimeEvent(input.event);
case "domain":
return processDomainEvent(input.event);
case "diff":
return recordProviderDiff(input.event);
}
};

const processInputSafely = (input: RuntimeIngestionInput) =>
processInput(input).pipe(
Effect.catchCause((cause) => {
if (Cause.hasInterruptsOnly(cause)) {
return Effect.failCause(cause);
}
return Effect.logWarning("provider runtime ingestion failed to process event", {
source: input.source,
eventId: input.event.eventId,
eventType: input.event.type,
cause: Cause.pretty(cause),
});
}),
);
const logIngestionFailure =
(source: string, event: { readonly eventId: string; readonly type: string }) =>
<E, R>(effect: Effect.Effect<void, E, R>) =>
effect.pipe(
Effect.catchCause((cause) => {
if (Cause.hasInterruptsOnly(cause)) {
return Effect.failCause(cause);
}
return Effect.logWarning("provider runtime ingestion failed to process event", {
source,
eventId: event.eventId,
eventType: event.type,
cause: Cause.pretty(cause),
});
}),
);

const worker = yield* makeDrainableWorker((input: RuntimeIngestionInput) =>
processInput(input).pipe(logIngestionFailure(input.source, input.event)),
);

const worker = yield* makeDrainableWorker(processInputSafely);
// Repository detection for a diff goes through VCS subprocesses, which can
// stall behind slow or hung git. It runs on its own worker so a stuck diff
// never delays the lifecycle worker; confirmed diffs are handed back to it.
const detectProviderDiffRepository = Effect.fn("detectProviderDiffRepository")(function* (
event: ProviderDiffEvent,
) {
if (!toTurnId(event.turnId)) return;
const checkpointContext = yield* projectionSnapshotQuery
.getThreadCheckpointContext(event.threadId)
.pipe(Effect.map(Option.getOrUndefined));
const workspaceCwd = checkpointContext?.worktreePath ?? checkpointContext?.workspaceRoot;
if (!workspaceCwd || !(yield* checkpointStore.isGitRepository(workspaceCwd))) return;
yield* worker.enqueue({ source: "diff", event });
});
const diffWorker = yield* makeDrainableWorker((event: ProviderDiffEvent) =>
detectProviderDiffRepository(event).pipe(logIngestionFailure("diff", event)),
);

const start: ProviderRuntimeIngestionShape["start"] = () =>
Effect.gen(function* () {
yield* forkParked(
Stream.runForEach(providerService.streamEvents, (event) =>
worker.enqueue({ source: "runtime", event }),
event.type === "turn.diff.updated"
? diffWorker.enqueue(event)
: worker.enqueue({ source: "runtime", event }),
),
);
yield* forkParked(
Expand All @@ -2307,7 +2341,8 @@ const make = Effect.gen(function* () {

return {
start,
drain: worker.drain,
// The diff worker feeds the lifecycle worker, so drain it first.
drain: diffWorker.drain.pipe(Effect.andThen(worker.drain)),
} satisfies ProviderRuntimeIngestionShape;
});

Expand Down
20 changes: 19 additions & 1 deletion apps/server/src/orchestration/decider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2062,11 +2062,29 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand"
}

case "thread.turn.diff.complete": {
yield* requireThread({
const thread = yield* requireThread({
readModel,
command,
threadId: command.threadId,
});
// A placeholder (status "missing") must never replace a checkpoint that
// was already captured with a real git ref. Provider diff ingestion
// checks this before dispatching, but CheckpointReactor can commit the
// real capture in between; the decider runs under the engine's command
// lock, so rejecting here closes that window.
const existingCheckpoint = thread.checkpoints.find(
(checkpoint) => checkpoint.turnId === command.turnId,
);
if (
command.status === "missing" &&
existingCheckpoint !== undefined &&
existingCheckpoint.status !== "missing"
) {
return yield* new OrchestrationCommandInvariantError({
commandType: command.type,
detail: `turn ${command.turnId} already has a captured checkpoint`,
});
}
return {
...(yield* withEventBase({
aggregateKind: "thread",
Expand Down
Loading
Loading