| author | |
| committer | |
| log | 740d7a321602963c4fa9da1b25f05c992333620d |
| tree | 82f767fe266c0aa06b4fef9d1255177cbd05eb30 |
| parent | cd7980b4574a611f9f721d0463755684653b65f2 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>14 files changed, 176 insertions(+), 55 deletions(-)
apps/server/integration/OrchestrationEngineHarness.integration.ts+3-3| ... | @@ -48,7 +48,7 @@ import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointRea | ... | @@ -48,7 +48,7 @@ import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointRea |
| 48 | import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts"; | 48 | import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts"; |
| 49 | import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts"; | 49 | import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts"; |
| 50 | import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; | 50 | import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; |
| 51 | import { RuntimeReceiptBusLive } from "../src/orchestration/Layers/RuntimeReceiptBus.ts"; | 51 | import { RuntimeReceiptBusTest } from "../src/orchestration/Layers/RuntimeReceiptBus.ts"; |
| 52 | import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts"; | 52 | import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts"; |
| 53 | import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts"; | 53 | import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts"; |
| 54 | import { ProviderRuntimeIngestionLive } from "../src/orchestration/Layers/ProviderRuntimeIngestion.ts"; | 54 | import { ProviderRuntimeIngestionLive } from "../src/orchestration/Layers/ProviderRuntimeIngestion.ts"; |
| ... | @@ -297,7 +297,7 @@ export const makeOrchestrationIntegrationHarness = ( | ... | @@ -297,7 +297,7 @@ export const makeOrchestrationIntegrationHarness = ( |
| 297 | ProjectionPendingApprovalRepositoryLive, | 297 | ProjectionPendingApprovalRepositoryLive, |
| 298 | checkpointStoreLayer, | 298 | checkpointStoreLayer, |
| 299 | providerLayer, | 299 | providerLayer, |
| 300 | RuntimeReceiptBusLive, | 300 | RuntimeReceiptBusTest, |
| 301 | ); | 301 | ); |
| 302 | const serverSettingsLayer = ServerSettingsService.layerTest(); | 302 | const serverSettingsLayer = ServerSettingsService.layerTest(); |
| 303 | const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( | 303 | const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe( |
| ... | @@ -376,7 +376,7 @@ export const makeOrchestrationIntegrationHarness = ( | ... | @@ -376,7 +376,7 @@ export const makeOrchestrationIntegrationHarness = ( |
| 376 | runtime.runPromise(reactor.start().pipe(Scope.provide(scope))), | 376 | runtime.runPromise(reactor.start().pipe(Scope.provide(scope))), |
| 377 | ).pipe(Effect.orDie); | 377 | ).pipe(Effect.orDie); |
| 378 | const receiptHistory = yield* Ref.make<ReadonlyArray<OrchestrationRuntimeReceipt>>([]); | 378 | const receiptHistory = yield* Ref.make<ReadonlyArray<OrchestrationRuntimeReceipt>>([]); |
| 379 | yield* Stream.runForEach(runtimeReceiptBus.stream, (receipt) => | 379 | yield* Stream.runForEach(runtimeReceiptBus.streamEventsForTest, (receipt) => |
| 380 | Ref.update(receiptHistory, (history) => [...history, receipt]).pipe(Effect.asVoid), | 380 | Ref.update(receiptHistory, (history) => [...history, receipt]).pipe(Effect.asVoid), |
| 381 | ).pipe(Effect.forkIn(scope)); | 381 | ).pipe(Effect.forkIn(scope)); |
| 382 | yield* Effect.sleep(10); | 382 | yield* Effect.sleep(10); |
apps/server/integration/providerService.integration.test.ts+5-5| ... | @@ -86,6 +86,7 @@ const collectEventsDuring = <A, E, R>( | ... | @@ -86,6 +86,7 @@ const collectEventsDuring = <A, E, R>( |
| 86 | Effect.forkScoped, | 86 | Effect.forkScoped, |
| 87 | ); | 87 | ); |
| 88 | 88 | ||
| 89 | yield* Effect.sleep("50 millis"); | ||
| 89 | yield* action; | 90 | yield* action; |
| 90 | 91 | ||
| 91 | return yield* Effect.forEach( | 92 | return yield* Effect.forEach( |
| ... | @@ -104,7 +105,6 @@ const runTurn = (input: { | ... | @@ -104,7 +105,6 @@ const runTurn = (input: { |
| 104 | }) => | 105 | }) => |
| 105 | Effect.gen(function* () { | 106 | Effect.gen(function* () { |
| 106 | yield* input.harness.queueTurnResponse(input.threadId, input.response); | 107 | yield* input.harness.queueTurnResponse(input.threadId, input.response); |
| 107 | |||
| 108 | return yield* collectEventsDuring( | 108 | return yield* collectEventsDuring( |
| 109 | input.provider.streamEvents, | 109 | input.provider.streamEvents, |
| 110 | input.response.events.length, | 110 | input.response.events.length, |
| ... | @@ -116,7 +116,7 @@ const runTurn = (input: { | ... | @@ -116,7 +116,7 @@ const runTurn = (input: { |
| 116 | ); | 116 | ); |
| 117 | }); | 117 | }); |
| 118 | 118 | ||
| 119 | it.effect("replays typed runtime fixture events", () => | 119 | it.live("replays typed runtime fixture events", () => |
| 120 | Effect.gen(function* () { | 120 | Effect.gen(function* () { |
| 121 | const fixture = yield* makeIntegrationFixture; | 121 | const fixture = yield* makeIntegrationFixture; |
| 122 | 122 | ||
| ... | @@ -149,7 +149,7 @@ it.effect("replays typed runtime fixture events", () => | ... | @@ -149,7 +149,7 @@ it.effect("replays typed runtime fixture events", () => |
| 149 | }).pipe(Effect.provide(NodeServices.layer)), | 149 | }).pipe(Effect.provide(NodeServices.layer)), |
| 150 | ); | 150 | ); |
| 151 | 151 | ||
| 152 | it.effect("replays file-changing fixture turn events", () => | 152 | it.live("replays file-changing fixture turn events", () => |
| 153 | Effect.gen(function* () { | 153 | Effect.gen(function* () { |
| 154 | const fixture = yield* makeIntegrationFixture; | 154 | const fixture = yield* makeIntegrationFixture; |
| 155 | const { join } = yield* Path.Path; | 155 | const { join } = yield* Path.Path; |
| ... | @@ -188,7 +188,7 @@ it.effect("replays file-changing fixture turn events", () => | ... | @@ -188,7 +188,7 @@ it.effect("replays file-changing fixture turn events", () => |
| 188 | }).pipe(Effect.provide(NodeServices.layer)), | 188 | }).pipe(Effect.provide(NodeServices.layer)), |
| 189 | ); | 189 | ); |
| 190 | 190 | ||
| 191 | it.effect("runs multi-turn tool/approval flow", () => | 191 | it.live("runs multi-turn tool/approval flow", () => |
| 192 | Effect.gen(function* () { | 192 | Effect.gen(function* () { |
| 193 | const fixture = yield* makeIntegrationFixture; | 193 | const fixture = yield* makeIntegrationFixture; |
| 194 | const { join } = yield* Path.Path; | 194 | const { join } = yield* Path.Path; |
| ... | @@ -242,7 +242,7 @@ it.effect("runs multi-turn tool/approval flow", () => | ... | @@ -242,7 +242,7 @@ it.effect("runs multi-turn tool/approval flow", () => |
| 242 | }).pipe(Effect.provide(NodeServices.layer)), | 242 | }).pipe(Effect.provide(NodeServices.layer)), |
| 243 | ); | 243 | ); |
| 244 | 244 | ||
| 245 | it.effect("rolls back provider conversation state only", () => | 245 | it.live("rolls back provider conversation state only", () => |
| 246 | Effect.gen(function* () { | 246 | Effect.gen(function* () { |
| 247 | const fixture = yield* makeIntegrationFixture; | 247 | const fixture = yield* makeIntegrationFixture; |
| 248 | const { join } = yield* Path.Path; | 248 | const { join } = yield* Path.Path; |
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+3-1| ... | @@ -96,7 +96,9 @@ function createProviderServiceHarness( | ... | @@ -96,7 +96,9 @@ function createProviderServiceHarness( |
| 96 | listSessions, | 96 | listSessions, |
| 97 | getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), | 97 | getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), |
| 98 | rollbackConversation, | 98 | rollbackConversation, |
| 99 | streamEvents: Stream.fromPubSub(runtimeEventPubSub), | 99 | get streamEvents() { |
| 100 | return Stream.fromPubSub(runtimeEventPubSub); | ||
| 101 | }, | ||
| 100 | }; | 102 | }; |
| 101 | 103 | ||
| 102 | const emit = (event: LegacyProviderRuntimeEvent): void => { | 104 | const emit = (event: LegacyProviderRuntimeEvent): void => { |
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+3-1| ... | @@ -208,7 +208,9 @@ describe("ProviderCommandReactor", () => { | ... | @@ -208,7 +208,9 @@ describe("ProviderCommandReactor", () => { |
| 208 | sessionModelSwitch: input?.sessionModelSwitch ?? "in-session", | 208 | sessionModelSwitch: input?.sessionModelSwitch ?? "in-session", |
| 209 | }), | 209 | }), |
| 210 | rollbackConversation: () => unsupported(), | 210 | rollbackConversation: () => unsupported(), |
| 211 | streamEvents: Stream.fromPubSub(runtimeEventPubSub), | 211 | get streamEvents() { |
| 212 | return Stream.fromPubSub(runtimeEventPubSub); | ||
| 213 | }, | ||
| 212 | }; | 214 | }; |
| 213 | 215 | ||
| 214 | const orchestrationLayer = OrchestrationEngineLive.pipe( | 216 | const orchestrationLayer = OrchestrationEngineLive.pipe( |
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+3-1| ... | @@ -98,7 +98,9 @@ function createProviderServiceHarness() { | ... | @@ -98,7 +98,9 @@ function createProviderServiceHarness() { |
| 98 | listSessions: () => Effect.succeed([...runtimeSessions]), | 98 | listSessions: () => Effect.succeed([...runtimeSessions]), |
| 99 | getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), | 99 | getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }), |
| 100 | rollbackConversation: () => unsupported(), | 100 | rollbackConversation: () => unsupported(), |
| 101 | streamEvents: Stream.fromPubSub(runtimeEventPubSub), | 101 | get streamEvents() { |
| 102 | return Stream.fromPubSub(runtimeEventPubSub); | ||
| 103 | }, | ||
| 102 | }; | 104 | }; |
| 103 | 105 | ||
| 104 | const setSession = (session: ProviderSession): void => { | 106 | const setSession = (session: ProviderSession): void => { |
apps/server/src/orchestration/Layers/RuntimeReceiptBus.ts+20-2| ... | @@ -1,3 +1,13 @@ | ... | @@ -1,3 +1,13 @@ |
| 1 | /** | ||
| 2 | * RuntimeReceiptBus layers. | ||
| 3 | * | ||
| 4 | * `RuntimeReceiptBusLive` is the production default and intentionally does not | ||
| 5 | * retain or broadcast receipts. `RuntimeReceiptBusTest` installs the in-memory | ||
| 6 | * PubSub-backed implementation used by integration tests that need to await | ||
| 7 | * checkpoint-reactor milestones precisely. | ||
| 8 | * | ||
| 9 | * @module RuntimeReceiptBus | ||
| 10 | */ | ||
| 1 | import { Effect, Layer, PubSub, Stream } from "effect"; | 11 | import { Effect, Layer, PubSub, Stream } from "effect"; |
| 2 | 12 | ||
| 3 | import { | 13 | import { |
| ... | @@ -6,13 +16,21 @@ import { | ... | @@ -6,13 +16,21 @@ import { |
| 6 | type OrchestrationRuntimeReceipt, | 16 | type OrchestrationRuntimeReceipt, |
| 7 | } from "../Services/RuntimeReceiptBus.ts"; | 17 | } from "../Services/RuntimeReceiptBus.ts"; |
| 8 | 18 | ||
| 9 | const makeRuntimeReceiptBus = Effect.gen(function* () { | 19 | const makeRuntimeReceiptBus = Effect.succeed({ |
| 20 | publish: () => Effect.void, | ||
| 21 | streamEventsForTest: Stream.empty, | ||
| 22 | } satisfies RuntimeReceiptBusShape); | ||
| 23 | |||
| 24 | const makeRuntimeReceiptBusTest = Effect.gen(function* () { | ||
| 10 | const pubSub = yield* PubSub.unbounded<OrchestrationRuntimeReceipt>(); | 25 | const pubSub = yield* PubSub.unbounded<OrchestrationRuntimeReceipt>(); |
| 11 | 26 | ||
| 12 | return { | 27 | return { |
| 13 | publish: (receipt) => PubSub.publish(pubSub, receipt).pipe(Effect.asVoid), | 28 | publish: (receipt) => PubSub.publish(pubSub, receipt).pipe(Effect.asVoid), |
| 14 | stream: Stream.fromPubSub(pubSub), | 29 | get streamEventsForTest() { |
| 30 | return Stream.fromPubSub(pubSub); | ||
| 31 | }, | ||
| 15 | } satisfies RuntimeReceiptBusShape; | 32 | } satisfies RuntimeReceiptBusShape; |
| 16 | }); | 33 | }); |
| 17 | 34 | ||
| 18 | export const RuntimeReceiptBusLive = Layer.effect(RuntimeReceiptBus, makeRuntimeReceiptBus); | 35 | export const RuntimeReceiptBusLive = Layer.effect(RuntimeReceiptBus, makeRuntimeReceiptBus); |
| 36 | export const RuntimeReceiptBusTest = Layer.effect(RuntimeReceiptBus, makeRuntimeReceiptBusTest); |
apps/server/src/orchestration/Services/RuntimeReceiptBus.ts+17-1| ... | @@ -1,3 +1,19 @@ | ... | @@ -1,3 +1,19 @@ |
| 1 | /** | ||
| 2 | * RuntimeReceiptBus - Internal checkpoint-reactor synchronization receipts. | ||
| 3 | * | ||
| 4 | * This service exists to expose short-lived orchestration milestones that are | ||
| 5 | * useful in tests and harnesses but are not part of the production runtime | ||
| 6 | * event model. `CheckpointReactor` publishes receipts such as baseline capture, | ||
| 7 | * diff finalization, and turn-processing quiescence so integration tests can | ||
| 8 | * wait for those exact points without inferring them indirectly from persisted | ||
| 9 | * state. | ||
| 10 | * | ||
| 11 | * Production code should only call `publish`. Test code may subscribe via | ||
| 12 | * `streamEventsForTest`, which is intentionally named to make the intended | ||
| 13 | * usage explicit. | ||
| 14 | * | ||
| 15 | * @module RuntimeReceiptBus | ||
| 16 | */ | ||
| 1 | import { CheckpointRef, IsoDateTime, NonNegativeInt, ThreadId, TurnId } from "@t3tools/contracts"; | 17 | import { CheckpointRef, IsoDateTime, NonNegativeInt, ThreadId, TurnId } from "@t3tools/contracts"; |
| 2 | import { Schema, ServiceMap } from "effect"; | 18 | import { Schema, ServiceMap } from "effect"; |
| 3 | import type { Effect, Stream } from "effect"; | 19 | import type { Effect, Stream } from "effect"; |
| ... | @@ -40,7 +56,7 @@ export type OrchestrationRuntimeReceipt = typeof OrchestrationRuntimeReceipt.Typ | ... | @@ -40,7 +56,7 @@ export type OrchestrationRuntimeReceipt = typeof OrchestrationRuntimeReceipt.Typ |
| 40 | 56 | ||
| 41 | export interface RuntimeReceiptBusShape { | 57 | export interface RuntimeReceiptBusShape { |
| 42 | readonly publish: (receipt: OrchestrationRuntimeReceipt) => Effect.Effect<void>; | 58 | readonly publish: (receipt: OrchestrationRuntimeReceipt) => Effect.Effect<void>; |
| 43 | readonly stream: Stream.Stream<OrchestrationRuntimeReceipt>; | 59 | readonly streamEventsForTest: Stream.Stream<OrchestrationRuntimeReceipt>; |
| 44 | } | 60 | } |
| 45 | 61 | ||
| 46 | export class RuntimeReceiptBus extends ServiceMap.Service< | 62 | export class RuntimeReceiptBus extends ServiceMap.Service< |
apps/server/src/provider/Layers/ClaudeAdapter.ts+3-1| ... | @@ -3054,7 +3054,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -3054,7 +3054,9 @@ const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 3054 | listSessions, | 3054 | listSessions, |
| 3055 | hasSession, | 3055 | hasSession, |
| 3056 | stopAll, | 3056 | stopAll, |
| 3057 | streamEvents: Stream.fromQueue(runtimeEventQueue), | 3057 | get streamEvents() { |
| 3058 | return Stream.fromQueue(runtimeEventQueue); | ||
| 3059 | }, | ||
| 3058 | } satisfies ClaudeAdapterShape; | 3060 | } satisfies ClaudeAdapterShape; |
| 3059 | }); | 3061 | }); |
| 3060 | 3062 |
apps/server/src/provider/Layers/CodexAdapter.ts+3-1| ... | @@ -1631,7 +1631,9 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( | ... | @@ -1631,7 +1631,9 @@ const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( |
| 1631 | listSessions, | 1631 | listSessions, |
| 1632 | hasSession, | 1632 | hasSession, |
| 1633 | stopAll, | 1633 | stopAll, |
| 1634 | streamEvents: Stream.fromQueue(runtimeEventQueue), | 1634 | get streamEvents() { |
| 1635 | return Stream.fromQueue(runtimeEventQueue); | ||
| 1636 | }, | ||
| 1635 | } satisfies CodexAdapterShape; | 1637 | } satisfies CodexAdapterShape; |
| 1636 | }); | 1638 | }); |
| 1637 | 1639 |
apps/server/src/provider/Layers/EventNdjsonLogger.test.ts+43| ... | @@ -112,6 +112,49 @@ describe("EventNdjsonLogger", () => { | ... | @@ -112,6 +112,49 @@ describe("EventNdjsonLogger", () => { |
| 112 | }), | 112 | }), |
| 113 | ); | 113 | ); |
| 114 | 114 | ||
| 115 | it.effect("serializes concurrent first writes for the same segment", () => | ||
| 116 | Effect.gen(function* () { | ||
| 117 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-log-")); | ||
| 118 | const basePath = path.join(tempDir, "provider-canonical.ndjson"); | ||
| 119 | |||
| 120 | try { | ||
| 121 | const logger = yield* makeEventNdjsonLogger(basePath, { | ||
| 122 | stream: "canonical", | ||
| 123 | batchWindowMs: 0, | ||
| 124 | }); | ||
| 125 | assert.notEqual(logger, undefined); | ||
| 126 | if (!logger) { | ||
| 127 | return; | ||
| 128 | } | ||
| 129 | |||
| 130 | yield* Effect.all( | ||
| 131 | [ | ||
| 132 | logger.write({ id: "evt-concurrent-1" }, null), | ||
| 133 | logger.write({ id: "evt-concurrent-2" }, null), | ||
| 134 | ], | ||
| 135 | { concurrency: "unbounded" }, | ||
| 136 | ); | ||
| 137 | yield* logger.close(); | ||
| 138 | |||
| 139 | const globalPath = path.join(tempDir, "_global.log"); | ||
| 140 | assert.equal(fs.existsSync(globalPath), true); | ||
| 141 | const lines = fs | ||
| 142 | .readFileSync(globalPath, "utf8") | ||
| 143 | .trim() | ||
| 144 | .split("\n") | ||
| 145 | .map((line) => parseLogLine(line)); | ||
| 146 | |||
| 147 | assert.equal(lines.length, 2); | ||
| 148 | assert.deepEqual(lines.map((line) => line.payload).toSorted(), [ | ||
| 149 | '{"id":"evt-concurrent-1"}', | ||
| 150 | '{"id":"evt-concurrent-2"}', | ||
| 151 | ]); | ||
| 152 | } finally { | ||
| 153 | fs.rmSync(tempDir, { recursive: true, force: true }); | ||
| 154 | } | ||
| 155 | }), | ||
| 156 | ); | ||
| 157 | |||
| 115 | it.effect("rotates per-thread files when max size is exceeded", () => | 158 | it.effect("rotates per-thread files when max size is exceeded", () => |
| 116 | Effect.gen(function* () { | 159 | Effect.gen(function* () { |
| 117 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-log-")); | 160 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-provider-log-")); |
apps/server/src/provider/Layers/EventNdjsonLogger.ts+65-27| ... | @@ -10,7 +10,7 @@ import path from "node:path"; | ... | @@ -10,7 +10,7 @@ import path from "node:path"; |
| 10 | 10 | ||
| 11 | import type { ThreadId } from "@t3tools/contracts"; | 11 | import type { ThreadId } from "@t3tools/contracts"; |
| 12 | import { RotatingFileSink } from "@t3tools/shared/logging"; | 12 | import { RotatingFileSink } from "@t3tools/shared/logging"; |
| 13 | import { Effect, Exit, Logger, Scope } from "effect"; | 13 | import { Effect, Exit, Logger, Scope, SynchronizedRef } from "effect"; |
| 14 | 14 | ||
| 15 | import { toSafeThreadAttachmentSegment } from "../../attachmentStore.ts"; | 15 | import { toSafeThreadAttachmentSegment } from "../../attachmentStore.ts"; |
| 16 | 16 | ||
| ... | @@ -40,6 +40,11 @@ interface ThreadWriter { | ... | @@ -40,6 +40,11 @@ interface ThreadWriter { |
| 40 | close: () => Effect.Effect<void>; | 40 | close: () => Effect.Effect<void>; |
| 41 | } | 41 | } |
| 42 | 42 | ||
| 43 | interface LoggerState { | ||
| 44 | readonly threadWriters: Map<string, ThreadWriter>; | ||
| 45 | readonly failedSegments: Set<string>; | ||
| 46 | } | ||
| 47 | |||
| 43 | function logWarning(message: string, context: Record<string, unknown>): Effect.Effect<void> { | 48 | function logWarning(message: string, context: Record<string, unknown>): Effect.Effect<void> { |
| 44 | return Effect.logWarning(message, context).pipe(Effect.annotateLogs({ scope: LOG_SCOPE })); | 49 | return Effect.logWarning(message, context).pipe(Effect.annotateLogs({ scope: LOG_SCOPE })); |
| 45 | } | 50 | } |
| ... | @@ -193,34 +198,56 @@ export const makeEventNdjsonLogger = Effect.fn("makeEventNdjsonLogger")(function | ... | @@ -193,34 +198,56 @@ export const makeEventNdjsonLogger = Effect.fn("makeEventNdjsonLogger")(function |
| 193 | return undefined; | 198 | return undefined; |
| 194 | } | 199 | } |
| 195 | 200 | ||
| 196 | const threadWriters = new Map<string, ThreadWriter>(); | 201 | const stateRef = yield* SynchronizedRef.make<LoggerState>({ |
| 197 | const failedSegments = new Set<string>(); | 202 | threadWriters: new Map(), |
| 203 | failedSegments: new Set(), | ||
| 204 | }); | ||
| 198 | 205 | ||
| 199 | const resolveThreadWriter = Effect.fn("resolveThreadWriter")(function* ( | 206 | const resolveThreadWriter = Effect.fn("resolveThreadWriter")(function* ( |
| 200 | threadSegment: string, | 207 | threadSegment: string, |
| 201 | ): Effect.fn.Return<ThreadWriter | undefined> { | 208 | ): Effect.fn.Return<ThreadWriter | undefined> { |
| 202 | if (failedSegments.has(threadSegment)) { | 209 | return yield* SynchronizedRef.modifyEffect(stateRef, (state) => { |
| 203 | return undefined; | 210 | if (state.failedSegments.has(threadSegment)) { |
| 204 | } | 211 | return Effect.succeed([undefined, state] as const); |
| 205 | const existing = threadWriters.get(threadSegment); | 212 | } |
| 206 | if (existing) { | ||
| 207 | return existing; | ||
| 208 | } | ||
| 209 | 213 | ||
| 210 | const writer = yield* makeThreadWriter({ | 214 | const existing = state.threadWriters.get(threadSegment); |
| 211 | filePath: path.join(path.dirname(filePath), `${threadSegment}.log`), | 215 | if (existing) { |
| 212 | maxBytes, | 216 | return Effect.succeed([existing, state] as const); |
| 213 | maxFiles, | 217 | } |
| 214 | batchWindowMs, | 218 | |
| 215 | streamLabel, | 219 | return makeThreadWriter({ |
| 216 | }); | 220 | filePath: path.join(path.dirname(filePath), `${threadSegment}.log`), |
| 217 | if (!writer) { | 221 | maxBytes, |
| 218 | failedSegments.add(threadSegment); | 222 | maxFiles, |
| 219 | return undefined; | 223 | batchWindowMs, |
| 220 | } | 224 | streamLabel, |
| 225 | }).pipe( | ||
| 226 | Effect.map((writer) => { | ||
| 227 | if (!writer) { | ||
| 228 | const nextFailedSegments = new Set(state.failedSegments); | ||
| 229 | nextFailedSegments.add(threadSegment); | ||
| 230 | return [ | ||
| 231 | undefined, | ||
| 232 | { | ||
| 233 | ...state, | ||
| 234 | failedSegments: nextFailedSegments, | ||
| 235 | }, | ||
| 236 | ] as const; | ||
| 237 | } | ||
| 221 | 238 | ||
| 222 | threadWriters.set(threadSegment, writer); | 239 | const nextThreadWriters = new Map(state.threadWriters); |
| 223 | return writer; | 240 | nextThreadWriters.set(threadSegment, writer); |
| 241 | return [ | ||
| 242 | writer, | ||
| 243 | { | ||
| 244 | ...state, | ||
| 245 | threadWriters: nextThreadWriters, | ||
| 246 | }, | ||
| 247 | ] as const; | ||
| 248 | }), | ||
| 249 | ); | ||
| 250 | }); | ||
| 224 | }); | 251 | }); |
| 225 | 252 | ||
| 226 | const write = Effect.fn("write")(function* (event: unknown, threadId: ThreadId | null) { | 253 | const write = Effect.fn("write")(function* (event: unknown, threadId: ThreadId | null) { |
| ... | @@ -239,10 +266,21 @@ export const makeEventNdjsonLogger = Effect.fn("makeEventNdjsonLogger")(function | ... | @@ -239,10 +266,21 @@ export const makeEventNdjsonLogger = Effect.fn("makeEventNdjsonLogger")(function |
| 239 | }); | 266 | }); |
| 240 | 267 | ||
| 241 | const close = Effect.fn("close")(function* () { | 268 | const close = Effect.fn("close")(function* () { |
| 242 | for (const writer of threadWriters.values()) { | 269 | yield* SynchronizedRef.modifyEffect(stateRef, (state) => |
| 243 | yield* writer.close(); | 270 | Effect.gen(function* () { |
| 244 | } | 271 | for (const writer of state.threadWriters.values()) { |
| 245 | threadWriters.clear(); | 272 | yield* writer.close(); |
| 273 | } | ||
| 274 | |||
| 275 | return [ | ||
| 276 | undefined, | ||
| 277 | { | ||
| 278 | threadWriters: new Map<string, ThreadWriter>(), | ||
| 279 | failedSegments: new Set<string>(), | ||
| 280 | }, | ||
| 281 | ] as const; | ||
| 282 | }), | ||
| 283 | ); | ||
| 246 | }); | 284 | }); |
| 247 | 285 | ||
| 248 | return { | 286 | return { |
apps/server/src/provider/Layers/ProviderRegistry.test.ts+3-1| ... | @@ -115,7 +115,9 @@ function makeMutableServerSettingsService( | ... | @@ -115,7 +115,9 @@ function makeMutableServerSettingsService( |
| 115 | yield* PubSub.publish(changes, next); | 115 | yield* PubSub.publish(changes, next); |
| 116 | return next; | 116 | return next; |
| 117 | }), | 117 | }), |
| 118 | streamChanges: Stream.fromPubSub(changes), | 118 | get streamChanges() { |
| 119 | return Stream.fromPubSub(changes); | ||
| 120 | }, | ||
| 119 | } satisfies ServerSettingsShape; | 121 | } satisfies ServerSettingsShape; |
| 120 | }); | 122 | }); |
| 121 | } | 123 | } |
apps/server/src/provider/Layers/ProviderService.test.ts+3-1| ... | @@ -191,7 +191,9 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") { | ... | @@ -191,7 +191,9 @@ function makeFakeCodexAdapter(provider: ProviderKind = "codex") { |
| 191 | readThread, | 191 | readThread, |
| 192 | rollbackThread, | 192 | rollbackThread, |
| 193 | stopAll, | 193 | stopAll, |
| 194 | streamEvents: Stream.fromPubSub(runtimeEventPubSub), | 194 | get streamEvents() { |
| 195 | return Stream.fromPubSub(runtimeEventPubSub); | ||
| 196 | }, | ||
| 195 | }; | 197 | }; |
| 196 | 198 | ||
| 197 | const emit = (event: LegacyProviderRuntimeEvent): void => { | 199 | const emit = (event: LegacyProviderRuntimeEvent): void => { |
apps/server/src/provider/Layers/ProviderService.ts+2-10| ... | @@ -22,7 +22,7 @@ import { | ... | @@ -22,7 +22,7 @@ import { |
| 22 | type ProviderRuntimeEvent, | 22 | type ProviderRuntimeEvent, |
| 23 | type ProviderSession, | 23 | type ProviderSession, |
| 24 | } from "@t3tools/contracts"; | 24 | } from "@t3tools/contracts"; |
| 25 | import { Effect, Layer, Option, PubSub, Queue, Schema, SchemaIssue, Stream } from "effect"; | 25 | import { Effect, Layer, Option, PubSub, Schema, SchemaIssue, Stream } from "effect"; |
| 26 | 26 | ||
| 27 | import { | 27 | import { |
| 28 | increment, | 28 | increment, |
| ... | @@ -156,7 +156,6 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( | ... | @@ -156,7 +156,6 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 156 | 156 | ||
| 157 | const registry = yield* ProviderAdapterRegistry; | 157 | const registry = yield* ProviderAdapterRegistry; |
| 158 | const directory = yield* ProviderSessionDirectory; | 158 | const directory = yield* ProviderSessionDirectory; |
| 159 | const runtimeEventQueue = yield* Queue.unbounded<ProviderRuntimeEvent>(); | ||
| 160 | const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>(); | 159 | const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>(); |
| 161 | 160 | ||
| 162 | const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> => | 161 | const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> => |
| ... | @@ -194,15 +193,8 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( | ... | @@ -194,15 +193,8 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 194 | eventType: event.type, | 193 | eventType: event.type, |
| 195 | }).pipe(Effect.andThen(publishRuntimeEvent(event))); | 194 | }).pipe(Effect.andThen(publishRuntimeEvent(event))); |
| 196 | 195 | ||
| 197 | const worker = Effect.forever( | ||
| 198 | Queue.take(runtimeEventQueue).pipe(Effect.flatMap(processRuntimeEvent)), | ||
| 199 | ); | ||
| 200 | yield* Effect.forkScoped(worker); | ||
| 201 | |||
| 202 | yield* Effect.forEach(adapters, (adapter) => | 196 | yield* Effect.forEach(adapters, (adapter) => |
| 203 | Stream.runForEach(adapter.streamEvents, (event) => | 197 | Stream.runForEach(adapter.streamEvents, processRuntimeEvent).pipe(Effect.forkScoped), |
| 204 | Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid), | ||
| 205 | ).pipe(Effect.forkScoped), | ||
| 206 | ).pipe(Effect.asVoid); | 198 | ).pipe(Effect.asVoid); |
| 207 | 199 | ||
| 208 | const recoverSessionForThread = Effect.fn("recoverSessionForThread")(function* (input: { | 200 | const recoverSessionForThread = Effect.fn("recoverSessionForThread")(function* (input: { |