Skip to content
1 change: 1 addition & 0 deletions apps/server/src/environment/ServerEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ export const make = Effect.gen(function* () {
threadSettlement: true,
threadAutoSettlement: true,
threadRestartContinuation: true,
projectSettingsOverrides: true,
threadSnooze: true,
environmentThemes: true,
usageLimitSources: true,
Expand Down
31 changes: 30 additions & 1 deletion apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,16 @@ import {
type VcsStatusRemoteResult,
VcsStatusResult,
ModelSelection,
type ProjectId,
SourceControlProviderError,
type SourceControlWritingStyleSettings,
type ThreadId,
} from "@t3tools/contracts";
import {
hasProjectSettingsOverrides,
resolveProjectSettings,
} from "@t3tools/shared/projectSettings";
import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
import {
detectSourceControlProviderFromGitRemoteUrl,
mergeGitStatusParts,
Expand Down Expand Up @@ -661,6 +668,28 @@ export const make = Effect.gen(function* () {

const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd });
const serverSettingsService = yield* ServerSettings.ServerSettingsService;
// Optional: git actions also run from the CLI and tests without orchestration.
const projectionQuery = yield* Effect.serviceOption(
ProjectionSnapshotQuery.ProjectionSnapshotQuery,
);
/** Environment settings with the acting project's overrides applied. */
const projectSettingsFor = Effect.fnUntraced(function* (input: {
readonly cwd: string;
readonly threadId?: ThreadId | undefined;
}) {
const settings = yield* serverSettingsService.getSettings;
if (!hasProjectSettingsOverrides(settings) || Option.isNone(projectionQuery)) return settings;
const projectId = yield* (
input.threadId !== undefined
? projectionQuery.value
.getThreadShellById(input.threadId)
.pipe(Effect.map(Option.map((thread) => thread.projectId)))
: projectionQuery.value
.getActiveProjectByWorkspaceRoot(input.cwd)
.pipe(Effect.map(Option.map((project) => project.id)))
).pipe(Effect.orElseSucceed(() => Option.none<ProjectId>()));
return resolveProjectSettings(settings, Option.getOrNull(projectId)).settings;
});
const readRepositoryInstructions = (cwd: string, fileName: string) =>
Effect.gen(function* () {
const root = yield* fileSystem.realPath(cwd);
Expand Down Expand Up @@ -2600,7 +2629,7 @@ export const make = Effect.gen(function* () {
let commitMessageForStep = input.commitMessage;
let preResolvedCommitSuggestion: CommitAndBranchSuggestion | undefined = undefined;

const textGenerationSettings = yield* serverSettingsService.getSettings.pipe(
const textGenerationSettings = yield* projectSettingsFor(input).pipe(
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Effect.flatMap((settings) =>
settings.sourceControlWriterModelSelection === null
? Effect.succeed({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -682,6 +682,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
if (context._tag === "Some") {
assert.deepEqual(context.value, {
id: ThreadId.make("thread-1"),
projectId: asProjectId("project-1"),
title: "Thread 1",
session: snapshot.threads[0]?.session,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ const ProjectionThreadActivityIdRowSchema = Schema.Struct({
const ProjectionThreadSessionDbRowSchema = ProjectionThreadSession;
const ProjectionThreadRuntimeContextDbRowSchema = Schema.Struct({
id: ThreadId,
projectId: ProjectId,
title: Schema.String,
session: Schema.NullOr(ProjectionThreadSessionDbRowSchema),
});
Expand Down Expand Up @@ -1231,6 +1232,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
sql`
SELECT
threads.thread_id AS id,
threads.project_id AS "projectId",
threads.title,
sessions.thread_id AS "threadId",
sessions.status,
Expand All @@ -1251,6 +1253,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
Effect.map((rows) =>
rows.map((row) => ({
id: row.id,
projectId: row.projectId,
title: row.title,
session: row.threadId === null ? null : row,
})),
Expand Down Expand Up @@ -3164,6 +3167,7 @@ pending_approval_requests AS (
);
return Option.map(context, (row) => ({
id: row.id,
projectId: row.projectId,
title: row.title,
session: row.session === null ? null : mapSessionRow(row.session),
}));
Expand Down
24 changes: 19 additions & 5 deletions apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
resolveSourceControlWriterModelSelection,
ServerSettingsService,
} from "../../serverSettings.ts";
import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts";
import { GitWorkflowService } from "../../git/GitWorkflowService.ts";
const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError);
Expand Down Expand Up @@ -329,6 +330,16 @@ const make = Effect.gen(function* () {
const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;
const textGeneration = yield* TextGeneration;
const serverSettingsService = yield* ServerSettingsService;
/** Environment settings with the thread's project overrides applied. */
const projectSettingsForThread = Effect.fnUntraced(function* (threadId: ThreadId) {
const settings = yield* serverSettingsService.getSettings;
if (Object.keys(settings.projectSettingsOverrides).length === 0) return settings;
const thread = yield* projectionSnapshotQuery
.getThreadShellById(threadId)
.pipe(Effect.orElseSucceed(() => Option.none()));
return resolveProjectSettings(settings, Option.isSome(thread) ? thread.value.projectId : null)
.settings;
});
const serverCommandId = (tag: string) =>
crypto.randomUUIDv4.pipe(Effect.map((uuid) => CommandId.make(`server:${tag}:${uuid}`)));
const serverEventId = () => crypto.randomUUIDv4.pipe(Effect.map(EventId.make));
Expand Down Expand Up @@ -996,7 +1007,7 @@ const make = Effect.gen(function* () {
const cwd = input.worktreePath;
const attachments = input.attachments ?? [];
yield* Effect.gen(function* () {
const settings = yield* serverSettingsService.getSettings;
const settings = yield* projectSettingsForThread(input.threadId);
const modelSelection =
settings.sourceControlWriterModelSelection === null
? settings.textGenerationModelSelection
Expand Down Expand Up @@ -1047,8 +1058,9 @@ const make = Effect.gen(function* () {
}) {
const attachments = input.attachments ?? [];
yield* Effect.gen(function* () {
const { textGenerationModelSelection: modelSelection } =
yield* serverSettingsService.getSettings;
const { textGenerationModelSelection: modelSelection } = yield* projectSettingsForThread(
input.threadId,
);

const generated = yield* textGeneration
.generateThreadTitle({
Expand Down Expand Up @@ -1117,8 +1129,10 @@ const make = Effect.gen(function* () {
thread,
projects: project ? [project] : [],
}) ?? process.cwd();
const { textGenerationModelSelection: modelSelection } =
yield* serverSettingsService.getSettings;
const { textGenerationModelSelection: modelSelection } = resolveProjectSettings(
yield* serverSettingsService.getSettings,
thread.projectId,
).settings;
const generated = yield* textGeneration.generateThreadTitle({
cwd,
message,
Expand Down
11 changes: 9 additions & 2 deletions apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
import { projectActivityPayload } from "../ActivityPayloadProjection.ts";
import { forkParked } from "../../serverActivation.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
import { resolveProjectSettings } from "@t3tools/shared/projectSettings";
import { canReplaceThreadTitle } from "../threadTitles.ts";

const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`;
Expand Down Expand Up @@ -1668,7 +1669,10 @@ const make = Effect.gen(function* () {

const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map(
serverSettingsService.getSettings,
(settings) => (settings.enableLegacyTokenStreaming ? "streaming" : "buffered"),
(settings) =>
resolveProjectSettings(settings, thread.projectId).settings.enableLegacyTokenStreaming
? "streaming"
: "buffered",
);
if (assistantDeliveryMode === "buffered") {
const spillChunk = yield* appendBufferedAssistantText(assistantMessageId, assistantDelta);
Expand Down Expand Up @@ -1709,7 +1713,10 @@ const make = Effect.gen(function* () {
});
const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map(
serverSettingsService.getSettings,
(settings) => (settings.enableLegacyTokenStreaming ? "streaming" : "buffered"),
(settings) =>
resolveProjectSettings(settings, thread.projectId).settings.enableLegacyTokenStreaming
? "streaming"
: "buffered",
);
const flushedMessageIds =
assistantDeliveryMode === "buffered"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ export interface ProjectionSnapshotQueryShape {
readonly getThreadRuntimeContext: (
threadId: ThreadId,
) => Effect.Effect<
Option.Option<Pick<OrchestrationThreadShell, "id" | "title" | "session">>,
Option.Option<Pick<OrchestrationThreadShell, "id" | "projectId" | "title" | "session">>,
ProjectionRepositoryError
>;

Expand Down
82 changes: 82 additions & 0 deletions apps/server/src/orchestration/ThreadSettlementReactor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,35 @@ const startHarness = Effect.fn("startThreadSettlementHarness")(function* (
});

describe("ThreadSettlementReactor", () => {
it("distinguishes a project that inherits the threshold from one that disables it", () => {
const inherits = ThreadSettlementReactor.autoSettlementSettingsKey({
...DEFAULT_SERVER_SETTINGS,
projectSettingsOverrides: { [PROJECT_ID]: { sidebarAutoSettleOnMerge: true } },
});
const never = ThreadSettlementReactor.autoSettlementSettingsKey({
...DEFAULT_SERVER_SETTINGS,
projectSettingsOverrides: {
[PROJECT_ID]: { sidebarAutoSettleOnMerge: true, sidebarAutoSettleAfterDays: null },
},
});
assert.notStrictEqual(inherits, never);
});

it("ignores project overrides that do not touch settlement", () => {
const base = ThreadSettlementReactor.autoSettlementSettingsKey({
...DEFAULT_SERVER_SETTINGS,
projectSettingsOverrides: { [PROJECT_ID]: { sidebarAutoSettleOnMerge: false } },
});
const unrelated = ThreadSettlementReactor.autoSettlementSettingsKey({
...DEFAULT_SERVER_SETTINGS,
projectSettingsOverrides: {
[LINKED_PROJECT_ID]: { defaultThreadEnvMode: "worktree" },
[PROJECT_ID]: { sidebarAutoSettleOnMerge: false, defaultAutoPull: true },
},
});
assert.strictEqual(base, unrelated);
});

it.effect(
"settles all-terminal links from snapshots and keeps open or unsynced links active",
() =>
Expand Down Expand Up @@ -486,6 +515,59 @@ describe("ThreadSettlementReactor", () => {
),
);

it.effect("a project override settles only that project's inactive threads", () =>
Effect.scoped(
Effect.gen(function* () {
yield* TestClock.setTime(Date.parse(NOW));
const overriddenProject = ProjectId.make("overridden-project");
const fixture = yield* makeHarness({
snapshot: makeSnapshot(
[
makeThread("inherits-thread"),
makeThread("overridden-thread", { projectId: overriddenProject }),
],
[makeProject(), makeProject(overriddenProject, "/workspace/overridden")],
),
settings: {
...DEFAULT_SERVER_SETTINGS,
sidebarAutoSettleAfterDays: null,
sidebarAutoSettleOnMerge: false,
projectSettingsOverrides: {
[overriddenProject]: { sidebarAutoSettleAfterDays: 1 },
},
},
});

yield* Effect.gen(function* () {
const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor;
yield* reactor.start();
yield* Queue.take(fixture.settingsReads);
yield* Deferred.succeed(fixture.activation, undefined);
yield* Queue.take(fixture.snapshotReads);
yield* reactor.drain;
assert.deepStrictEqual(
(yield* Ref.get(fixture.commands)).map((command) => command.threadId),
[ThreadId.make("overridden-thread")],
);

// Clearing the override is a settlement change, so the sweep re-arms.
yield* fixture.updateSettings({
projectSettingsOverrides: { [overriddenProject]: null },
sidebarAutoSettleAfterDays: 1,
});
yield* Queue.take(fixture.snapshotReads);
yield* reactor.drain;
// The static snapshot never records the first settlement, so the
// second sweep dispatches for both; the inheriting thread is new.
assert.include(
(yield* Ref.get(fixture.commands)).map((command) => command.threadId),
ThreadId.make("inherits-thread"),
);
}).pipe(Effect.provide(fixture.layer));
}),
),
);

it.effect("starts without clients and skips protected threads before pull request lookup", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
Loading
Loading