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
38 changes: 38 additions & 0 deletions apps/server/src/persistence/ProviderSessionRuntime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ export type GetProviderSessionRuntimeInput = typeof GetProviderSessionRuntimeInp
export const DeleteProviderSessionRuntimeInput = Schema.Struct({ threadId: ThreadId });
export type DeleteProviderSessionRuntimeInput = typeof DeleteProviderSessionRuntimeInput.Type;

export const TouchProviderSessionRuntimeInput = Schema.Struct({
threadId: ThreadId,
lastSeenAt: IsoDateTime,
});
export type TouchProviderSessionRuntimeInput = typeof TouchProviderSessionRuntimeInput.Type;

/**
* ProviderSessionRuntimeRepository - Service tag for provider runtime persistence.
*/
Expand Down Expand Up @@ -97,6 +103,16 @@ export class ProviderSessionRuntimeRepository extends Context.Service<
readonly deleteByThreadId: (
input: DeleteProviderSessionRuntimeInput,
) => Effect.Effect<void, ProviderSessionRuntimeRepositoryError>;

/**
* Bump `last_seen_at` for an existing runtime row.
*
* No-op when no row matches. Cheaper than `upsert` for high-frequency
* activity signals because it skips the SELECT and JSON re-encoding.
*/
readonly touchByThreadId: (
input: TouchProviderSessionRuntimeInput,
) => Effect.Effect<void, ProviderSessionRuntimeRepositoryError>;
}
>()("t3/persistence/ProviderSessionRuntime/ProviderSessionRuntimeRepository") {}

Expand Down Expand Up @@ -233,6 +249,16 @@ export const make = Effect.gen(function* () {
`,
});

const touchRuntimeByThreadId = SqlSchema.void({
Request: TouchProviderSessionRuntimeInput,
execute: ({ threadId, lastSeenAt }) =>
sql`
UPDATE provider_session_runtime
SET last_seen_at = ${lastSeenAt}
WHERE thread_id = ${threadId}
`,
});

const upsert: ProviderSessionRuntimeRepository["Service"]["upsert"] = (runtime) =>
upsertRuntimeRow(runtime).pipe(
Effect.mapError(
Expand Down Expand Up @@ -308,11 +334,23 @@ export const make = Effect.gen(function* () {
),
);

const touchByThreadId: ProviderSessionRuntimeRepository["Service"]["touchByThreadId"] = (input) =>
touchRuntimeByThreadId(input).pipe(
Effect.mapError(
toPersistenceSqlOrDecodeError(
"ProviderSessionRuntimeRepository.touchByThreadId:query",
"ProviderSessionRuntimeRepository.touchByThreadId:encodeRequest",
{ threadId: input.threadId },
),
),
);

return {
upsert,
getByThreadId,
list,
deleteByThreadId,
touchByThreadId,
} satisfies ProviderSessionRuntimeRepository["Service"];
});

Expand Down
1 change: 1 addition & 0 deletions apps/server/src/provider/Layers/CodexAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ function makeScopedRuntimeFactory(options?: { readonly failConstruction?: boolea

const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, {
upsert: () => Effect.void,
touch: () => Effect.void,
getProvider: () =>
Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")),
getBinding: () => Effect.succeed(Option.none()),
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/provider/Layers/OpenCodeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {

const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, {
upsert: () => Effect.void,
touch: () => Effect.void,
getProvider: () =>
Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")),
getBinding: () => Effect.succeed(Option.none()),
Expand Down
30 changes: 29 additions & 1 deletion apps/server/src/provider/Layers/ProviderService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
type ProviderSession,
} from "@t3tools/contracts";
import { causeErrorTag } from "@t3tools/shared/observability";
import * as Cause from "effect/Cause";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
Expand Down Expand Up @@ -281,6 +282,30 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
});
});

// Runtime event types that must NOT count as session activity.
// `session.exited` means the provider process is gone — bumping `lastSeenAt`
// would keep the directory binding alive past the runtime that backs it,
// defeating the reaper. Extend this set if new event types turn out to fire
// after a session is effectively dead.
const SESSION_ACTIVITY_DENY_LIST: ReadonlySet<ProviderRuntimeEvent["type"]> = new Set([
"session.exited",
]);

const bumpSessionActivity = (event: ProviderRuntimeEvent): Effect.Effect<void> =>
SESSION_ACTIVITY_DENY_LIST.has(event.type)
? Effect.void
: directory.touch(event.threadId).pipe(
Effect.catchCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.interrupt
: Effect.logDebug("provider.session.activity.touch-failed", {
threadId: event.threadId,
eventType: event.type,
cause,
}),
),
);
Comment thread
ozppupbg marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.

const processRuntimeEvent = (
source: {
readonly instanceId: ProviderInstanceId;
Expand All @@ -293,7 +318,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
increment(providerRuntimeEventsTotal, {
provider: canonicalEvent.provider,
eventType: canonicalEvent.type,
}).pipe(Effect.andThen(publishRuntimeEvent(canonicalEvent))),
}).pipe(
Effect.andThen(publishRuntimeEvent(canonicalEvent)),
Effect.andThen(bumpSessionActivity(canonicalEvent)),
),
),
);

Expand Down
8 changes: 8 additions & 0 deletions apps/server/src/provider/Layers/ProviderSessionDirectory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,13 @@ const makeProviderSessionDirectory = Effect.gen(function* () {
.pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsert")));
});

const touch: ProviderSessionDirectoryShape["touch"] = Effect.fn(function* (threadId) {
const now = DateTime.formatIso(yield* DateTime.now);
yield* repository
.touchByThreadId({ threadId, lastSeenAt: now })
.pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.touch:touchByThreadId")));
});

const getProvider: ProviderSessionDirectoryShape["getProvider"] = (threadId) =>
getBinding(threadId).pipe(
Effect.flatMap((binding) =>
Expand Down Expand Up @@ -184,6 +191,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () {

return {
upsert,
touch,
getProvider,
getBinding,
listThreadIds,
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/provider/Services/ProviderSessionDirectory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ export interface ProviderSessionDirectoryShape {
binding: ProviderRuntimeBinding,
) => Effect.Effect<void, ProviderSessionDirectoryWriteError>;

readonly touch: (
threadId: ThreadId,
) => Effect.Effect<void, ProviderSessionDirectoryPersistenceError>;

readonly getProvider: (
threadId: ThreadId,
) => Effect.Effect<ProviderDriverKind, ProviderSessionDirectoryReadError>;
Expand Down
Loading