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
178 changes: 158 additions & 20 deletions apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,23 @@ import {
type OrchestrationV2TurnItem,
ProviderDriverKind,
ProviderInstanceId,
type ProviderThreadId,
ProviderThreadId,
ProviderTurnId,
RunAttemptId,
RunId,
RuntimeRequestId,
TurnItemId,
} from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import * as DateTime from "effect/DateTime";
import * as Deferred from "effect/Deferred";
import * as Fiber from "effect/Fiber";
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as SqlClient from "effect/sql/SqlClient";
import * as SqlError from "effect/sql/SqlError";
import * as Stream from "effect/Stream";

import { toolOutputImages } from "@t3tools/shared/toolOutput";
Expand Down Expand Up @@ -65,25 +68,63 @@ const layerGit = GitVcsDriver.layer.pipe(
Layer.provideMerge(NodeServices.layer),
);

const layerTest = Layer.mergeAll(
layerTestDatabase,
layerTestStores,
layerTestEventSink,
IdAllocator.layer,
ThreadCommandExecutor.layer,
layerGit,
ProviderEventIngestor.layer.pipe(
Layer.provide(
Layer.mergeAll(
layerTestStores,
layerTestEventSink,
IdAllocator.layer,
ThreadCommandExecutor.layer,
layerGit,
const makeLayerTest = <E>(ingestorEventSink: Layer.Layer<EventSink.EventSinkV2, E>) =>
Layer.mergeAll(
layerTestDatabase,
layerTestStores,
layerTestEventSink,
IdAllocator.layer,
ThreadCommandExecutor.layer,
layerGit,
ProviderEventIngestor.layer.pipe(
Layer.provide(
Layer.mergeAll(
layerTestStores,
ingestorEventSink,
IdAllocator.layer,
ThreadCommandExecutor.layer,
layerGit,
),
),
),
),
);
);
const layerTest = makeLayerTest(layerTestEventSink);

// EventSink reports a lock that outlasted busy_timeout at BEGIN IMMEDIATE this way.
const lockTimeout = new EventSink.EventSinkWriteError({
eventCount: 1,
cause: new SqlError.SqlError({ reason: new SqlError.LockTimeoutError({ cause: null }) }),
});
const constraintFailure = new EventSink.EventSinkWriteError({
eventCount: 1,
cause: new SqlError.SqlError({ reason: new SqlError.ConstraintError({ cause: null }) }),
});

/** The ingestor's next sink writes of `events` fail with `errors`, in order; the rest go through. */
const layerFailingWrites = (
errors: ReadonlyArray<EventSink.EventSinkWriteError>,
matches: (events: ReadonlyArray<OrchestrationV2DomainEvent>) => boolean = () => true,
) =>
makeLayerTest(
Layer.effect(
EventSink.EventSinkV2,
Effect.gen(function* () {
const eventSink = yield* EventSink.EventSinkV2;
const pending = [...errors];
const failNext = <A, R>(
events: ReadonlyArray<OrchestrationV2DomainEvent>,
write: Effect.Effect<A, EventSink.EventSinkV2Error, R>,
) => Effect.suspend(() => (matches(events) ? (pending.shift() ?? write) : write));
return EventSink.EventSinkV2.of({
...eventSink,
write: (input) => failNext(input.events, eventSink.write(input)),
writeIfRunCurrent: (input) => failNext(input.events, eventSink.writeIfRunCurrent(input)),
writeIfProviderThreadOwner: (input) =>
failNext(input.events, eventSink.writeIfProviderThreadOwner(input)),
});
}),
).pipe(Layer.provide(layerTestEventSink)),
);

const makeSharedWorktree = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
Expand Down Expand Up @@ -1995,7 +2036,7 @@ layer("ProviderEventIngestorV2", (it) => {
}),
);

it.effect("moves a native subagent's thread to the model its provider reports later", () =>
it.effect("moves a native subagent's thread to its later model through a database lock", () =>
Effect.gen(function* () {
const now = yield* DateTime.now;
const eventSink = yield* EventSink.EventSinkV2;
Expand Down Expand Up @@ -2114,6 +2155,103 @@ layer("ProviderEventIngestorV2", (it) => {
(yield* projectionStore.getThread(childThreadId)).modelSelection,
reportedSelection,
);
}),
}).pipe(
// Fresh: the shared test layer has already built the ingestor this replaces.
Effect.provide(
Layer.fresh(
layerFailingWrites([lockTimeout, lockTimeout], (events) =>
events.some((event) => event.type === "thread.model-selection-updated"),
),
),
),
),
);
});

const ingestCompletedProviderTurn = (
guard: Pick<
Parameters<ProviderEventIngestor.ProviderEventIngestorV2Shape["ingestNormalized"]>[0],
"writeIfRunCurrent" | "writeIfProviderThreadOwner"
>,
) =>
Effect.gen(function* () {
const now = yield* DateTime.now;
const eventSink = yield* EventSink.EventSinkV2;
const projectionStore = yield* ProjectionStore.ProjectionStoreV2;
const ingestor = yield* ProviderEventIngestor.ProviderEventIngestorV2;
const idAllocator = yield* IdAllocator.IdAllocatorV2;
const threadEvent = yield* threadCreatedEvent(now);
yield* eventSink.write({ events: [threadEvent] });
const providerTurnId = ProviderTurnId.make("provider-turn:locked");
const exit = yield* ingestor
.ingestNormalized({
providerSessionId: yield* idAllocator.allocate.providerSession({
providerInstanceId: modelSelection.instanceId,
threadId: threadEvent.threadId,
}),
providerInstanceId: modelSelection.instanceId,
threadId: threadEvent.threadId,
event: {
type: "provider_turn.updated",
driver: CODEX_DRIVER,
providerTurn: {
id: providerTurnId,
providerThreadId: ProviderThreadId.make("provider-thread:locked"),
nodeId: NodeId.make("node:locked-turn"),
runAttemptId: null,
nativeTurnRef: null,
ordinal: 1,
status: "completed",
startedAt: now,
completedAt: now,
},
},
...guard,
})
.pipe(Effect.exit);
const projection = yield* projectionStore.getThreadProjection(threadEvent.threadId);
return { exit, providerTurnId, persisted: projection.providerTurns.map((turn) => turn.id) };
});

// Neither guard matches a stored run, so a guarded write correctly commits nothing.
const runId = RunId.make("run:locked");
const activeAttemptId = RunAttemptId.make("attempt:locked");
it.effect.each([
{ route: "write", guard: {} },
{
route: "writeIfRunCurrent",
guard: { writeIfRunCurrent: { runId, activeAttemptId, expectedStatus: "running" } },
},
{
route: "writeIfProviderThreadOwner",
guard: {
writeIfProviderThreadOwner: {
providerThreadId: ProviderThreadId.make("provider-thread:locked"),
runId,
activeAttemptId,
expectedLastRunOrdinal: 1,
},
},
},
] as const)("a provider event $route outlasts a brief database lock", ({ route, guard }) =>
Effect.gen(function* () {
const { exit, providerTurnId, persisted } = yield* ingestCompletedProviderTurn(guard);
assert.isTrue(Exit.isSuccess(exit));
assert.deepEqual(persisted, route === "write" ? [providerTurnId] : []);
}).pipe(Effect.provide(layerFailingWrites([lockTimeout, lockTimeout]))),
);

// The write after the queued errors would succeed, so failing proves the retries stopped.
it.effect.each([
{ failure: "stays locked", errors: [lockTimeout, lockTimeout, lockTimeout] },
{ failure: "fails for another reason", errors: [constraintFailure] },
])("fails a provider event write that $failure", ({ errors }) =>
Effect.gen(function* () {
const { exit, persisted } = yield* ingestCompletedProviderTurn({});
assert.instanceOf(
Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined,
ProviderEventIngestor.ProviderEventPublishError,
);
assert.isEmpty(persisted);
}).pipe(Effect.provide(layerFailingWrites(errors))),
);
17 changes: 13 additions & 4 deletions apps/server/src/orchestration-v2/ProviderEventIngestor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
import * as SqlError from "effect/sql/SqlError";

import { AgentScope } from "@t3tools/shared/AgentScope";
import { isTemporaryWorktreeBranch } from "@t3tools/shared/git";
Expand Down Expand Up @@ -67,6 +68,14 @@ export class ProviderEventPublishError extends Schema.TaggedError<ProviderEventP
}
}

// A write that outlasts busy_timeout at BEGIN IMMEDIATE fails before anything
// commits, and the sink publishes only after a commit, so repeating the whole
// write is safe. Failing it would fail the run over a brief lock.
const isLockTimeout = (error: EventSink.EventSinkV2Error) =>
SqlError.isSqlError(error.cause) && error.cause.reason._tag === "LockTimeoutError";
const retryLockTimeout = <A, R>(write: Effect.Effect<A, EventSink.EventSinkV2Error, R>) =>
Effect.retry(write, { while: isLockTimeout, times: 2 });

export const ProviderEventIngestorV2Error = Schema.Union([
ProviderEventNormalizeError,
ProviderEventPublishError,
Expand Down Expand Up @@ -434,7 +443,7 @@ export const layer: Layer.Layer<
},
occurredAt: now,
});
return yield* eventSink.write({ events: [event] });
return yield* eventSink.write({ events: [event] }).pipe(retryLockTimeout);
}),
);
},
Expand Down Expand Up @@ -751,7 +760,7 @@ export const layer: Layer.Layer<
...input.writeIfProviderThreadOwner,
events,
})
.pipe(Effect.mapError(mapWriteError));
.pipe(retryLockTimeout, Effect.mapError(mapWriteError));
return ownerResult.storedEvents;
}
if (input.writeIfRunCurrent === undefined) {
Expand All @@ -761,7 +770,7 @@ export const layer: Layer.Layer<
...(input.commandId === undefined ? {} : { commandId: input.commandId }),
events,
})
.pipe(Effect.mapError(mapWriteError));
.pipe(retryLockTimeout, Effect.mapError(mapWriteError));
}
const result = yield* eventSink
.writeIfRunCurrent({
Expand All @@ -771,7 +780,7 @@ export const layer: Layer.Layer<
...input.writeIfRunCurrent,
events,
})
.pipe(Effect.mapError(mapWriteError));
.pipe(retryLockTimeout, Effect.mapError(mapWriteError));
return result.storedEvents;
},
(effect, input) =>
Expand Down
Loading