import { CommandId, type OrchestrationV2ThreadShell } from "@t3tools/contracts";import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";import * as Cause from "effect/Cause";import * as Context from "effect/Context";import * as Crypto from "effect/Crypto";import * as DateTime from "effect/DateTime";import * as Effect from "effect/Effect";import * as FileSystem from "effect/FileSystem";import * as Layer from "effect/Layer";import * as Schedule from "effect/Schedule";import type * as Scope from "effect/Scope";import * as Stream from "effect/Stream"; import * as GitManager from "../git/GitManager.ts";import * as PullRequestService from "../pullRequest/PullRequestService.ts";import * as ServerSettings from "../serverSettings.ts";import { forkParked } from "../serverActivation.ts";import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";import { OrchestratorV2 } from "./Orchestrator.ts";import { ProjectionStoreV2, type ProjectionSettlementCandidate } from "./ProjectionStore.ts"; export interface SettlementPullRequest { readonly state: "open" | "closed" | "merged"; readonly updatedAt: string | null;} const DAY_MS = 24 * 60 * 60 * 1_000;export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; function toMillis(value: DateTime.Utc | null | undefined): number | null { return value == null ? null : DateTime.toEpochMillis(value);} function latestMillis(values: ReadonlyArray<number | null>): number | null { let latest: number | null = null; for (const value of values) { if (value === null) continue; if (latest === null || value > latest) latest = value; } return latest;} /** * A recent user message stays queued until a run adopts its timestamp. * Absolute age bounds client clock skew in both directions and stops stale * pre-adoption data from blocking the thread forever. A failed run start * clears the block immediately (mirrors the v1 session "error" rule). */export function threadHasQueuedTurnStart( thread: Pick< OrchestrationV2ThreadShell, | "latestUserMessageAt" | "latestRunRequestedAt" | "latestRunStartedAt" | "latestRunCompletedAt" | "latestRunId" | "status" >, nowMs: number,): boolean { const messageAtMs = toMillis(thread.latestUserMessageAt); if (messageAtMs === null || thread.status === "failed") return false; const age = nowMs - messageAtMs; if (Number.isNaN(age) || Math.abs(age) > QUEUED_TURN_START_GRACE_MS) return false; if (thread.latestRunId === null) return true; return [ toMillis(thread.latestRunRequestedAt), toMillis(thread.latestRunStartedAt), toMillis(thread.latestRunCompletedAt), ].every((value) => value === null || value < messageAtMs);} function pullRequestSettles( thread: Pick< OrchestrationV2ThreadShell, "createdAt" | "latestUserMessageAt" | "latestRunRequestedAt" >, pullRequest: SettlementPullRequest, autoSettleOnMerge: boolean,): boolean { if (pullRequest.state !== "closed" && (pullRequest.state !== "merged" || !autoSettleOnMerge)) { return false; } if (pullRequest.updatedAt === null) return false; const userAnchorMs = latestMillis([ toMillis(thread.createdAt), toMillis(thread.latestUserMessageAt), toMillis(thread.latestRunRequestedAt), ]); if (userAnchorMs === null) return false; const pullRequestAtMs = Date.parse(pullRequest.updatedAt); if (Number.isNaN(pullRequestAtMs)) return false; return pullRequestAtMs >= userAnchorMs;} /** Cheap checks that run before any source control lookup. */export function isAutoSettlementCandidate( thread: ProjectionSettlementCandidate, nowMs: number,): boolean { if (thread.archivedAt !== null || thread.settledOverride !== null) return false; if (thread.pinnedAt != null) return false; // Blocked-on-you work must never park behind a settled override. if (thread.pendingRuntimeRequest !== null) return false; // A live run — or post-settlement background work — is not staleness. if (thread.activityRunStatus != null) return false; if ((thread.pendingBackgroundTasks?.length ?? 0) > 0) return false; if (threadHasQueuedTurnStart(thread, nowMs)) return false; const snoozedUntilMs = toMillis(thread.snoozedUntil); if (snoozedUntilMs === null || snoozedUntilMs <= nowMs) return true; // A snoozed thread that woke early (error or completed work) can settle; // one still parked on its wake time keeps its stronger statement. const snoozedAtMs = toMillis(thread.snoozedAt); const completedAtMs = toMillis(thread.latestRunCompletedAt); const wokeOnError = thread.status === "failed" && (snoozedAtMs === null || (completedAtMs !== null && completedAtMs > snoozedAtMs)); const wokeOnCompletion = snoozedAtMs !== null && completedAtMs !== null && completedAtMs > snoozedAtMs; return wokeOnError || wokeOnCompletion;} export function resolveAutoSettlementAt(input: { readonly thread: ProjectionSettlementCandidate; readonly pullRequest: SettlementPullRequest | null; readonly nowMs: number; readonly autoSettleAfterDays: number | null; readonly autoSettleOnMerge: boolean;}): DateTime.Utc | null { const { thread, pullRequest } = input; if (!isAutoSettlementCandidate(thread, input.nowMs)) return null; const activityAtMs = latestMillis([ toMillis(thread.latestUserMessageAt), toMillis(thread.latestRunRequestedAt), toMillis(thread.latestRunStartedAt), toMillis(thread.latestRunCompletedAt), ]); if (pullRequest !== null && pullRequestSettles(thread, pullRequest, input.autoSettleOnMerge)) { return activityAtMs === null ? thread.createdAt : DateTime.makeUnsafe(activityAtMs); } if (input.autoSettleAfterDays === null || activityAtMs === null) return null; return activityAtMs < input.nowMs - input.autoSettleAfterDays * DAY_MS ? DateTime.makeUnsafe(activityAtMs) : null;} export class ThreadSettlementServiceV2 extends Context.Service< ThreadSettlementServiceV2, { readonly start: () => Effect.Effect<void, never, Scope.Scope>; readonly drain: Effect.Effect<void>; }>()("t3/orchestration-v2/ThreadSettlementService/ThreadSettlementServiceV2") {} export const make = Effect.gen(function* () { const orchestrator = yield* OrchestratorV2; const projections = yield* ProjectionStoreV2; const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const settingsService = yield* ServerSettings.ServerSettingsService; const git = yield* GitManager.GitManager; const pullRequests = yield* PullRequestService.PullRequestService; const crypto = yield* Crypto.Crypto; const fileSystem = yield* FileSystem.FileSystem; const sweep = Effect.fn("ThreadSettlementServiceV2.sweep")(function* ( mergedPullRequest: PullRequestService.PullRequestMergeEvent | null, ) { const threads = yield* projections.getSettlementCandidates(); const projectShells = yield* snapshots.getProjectShellsWithoutEnrichment(); const nowMs = DateTime.toEpochMillis(yield* DateTime.now); const projects = new Map(projectShells.map((project) => [project.id, project])); // A merge event re-sweeps every candidate, not just the threads linked to // the merged pull request: most threads carry no link and settle from // their branch lookup, which would otherwise wait for the next minute's // sweep on a possibly stale cached answer. const candidates = threads.filter((thread) => isAutoSettlementCandidate(thread, nowMs)); // Use the same cwd as the sidebar so both paths share GitManager's PR cache. const lookupCwdByThreadId = new Map<string, string>(); yield* Effect.forEach( candidates, (thread) => Effect.gen(function* () { const project = projects.get(thread.projectId); if (project === undefined || thread.linkedPullRequest != null) return; const worktreeExists = thread.worktreePath !== null && (yield* fileSystem.exists(thread.worktreePath).pipe(Effect.orElseSucceed(() => false))); lookupCwdByThreadId.set( thread.id, worktreeExists && thread.worktreePath !== null ? thread.worktreePath : project.workspaceRoot, ); }), { concurrency: 8, discard: true }, ); if (mergedPullRequest !== null) { // The merge just confirmed a terminal state the lookup caches can still // call open (branch answers live two minutes, the sweep runs every // minute). Drop the swept checkouts' cached answers so the merge settles // its branch threads now instead of on a later sweep. Threads linked to // the merged pull request settle from the event itself below and need no // lookup, so they are absent from this map by construction. const cwds = [...new Set(lookupCwdByThreadId.values())]; yield* Effect.forEach(cwds, (cwd) => git.invalidateStatus(cwd), { concurrency: 8, discard: true, }); } const lookupKey = (thread: (typeof candidates)[number]) => { if (thread.linkedPullRequest != null) { return JSON.stringify([ "linked", thread.linkedPullRequest.projectId, thread.linkedPullRequest.repository, thread.linkedPullRequest.number, ]); } if (thread.branch === null) return JSON.stringify(["none", thread.id]); const cwd = lookupCwdByThreadId.get(thread.id); return JSON.stringify( cwd === undefined ? ["missing-project", thread.id] : ["branch", cwd, thread.branch], ); }; const groups = Map.groupBy(candidates, lookupKey); const pullRequestFor = Effect.fn("ThreadSettlementServiceV2.pullRequestFor")(function* ( thread: (typeof candidates)[number], ) { if (thread.linkedPullRequest != null) { // The event carries the merged state, so only the threads linked to // that exact pull request settle from it. Every other linked thread // falls through to a fresh summary lookup below: the merge sweep // covers all candidates, and an unrelated merge must never settle // them. if ( mergedPullRequest !== null && thread.linkedPullRequest.projectId === mergedPullRequest.projectId && thread.linkedPullRequest.repository.toLowerCase() === mergedPullRequest.repository.toLowerCase() && thread.linkedPullRequest.number === mergedPullRequest.number ) { return { state: "merged", updatedAt: mergedPullRequest.mergedAt, } satisfies SettlementPullRequest; } if (!projects.has(thread.linkedPullRequest.projectId)) { return yield* Effect.die(new Error("linked pull request project not found")); } const summary = yield* pullRequests.summary( { projectId: thread.linkedPullRequest.projectId, repository: thread.linkedPullRequest.repository, number: thread.linkedPullRequest.number, }, { recoverTransientFailure: false }, ); return { state: summary.state, updatedAt: summary.updatedAt, } satisfies SettlementPullRequest; } if (thread.branch === null) return null; const cwd = lookupCwdByThreadId.get(thread.id); if (cwd === undefined) { return yield* Effect.die(new Error("thread project not found")); } return yield* git.branchPullRequest({ cwd, branch: thread.branch }); }); yield* Effect.forEach( groups.values(), (group) => Effect.gen(function* () { const pullRequest = yield* pullRequestFor(group[0]!); yield* Effect.forEach( group, (thread) => Effect.gen(function* () { const settings = yield* settingsService.getSettings; const decisionNow = yield* DateTime.now; const settledAt = resolveAutoSettlementAt({ thread, pullRequest, nowMs: DateTime.toEpochMillis(decisionNow), autoSettleAfterDays: settings.sidebarAutoSettleAfterDays, autoSettleOnMerge: settings.sidebarAutoSettleOnMerge, }); if (settledAt === null) { return; } const uuid = yield* crypto.randomUUIDv4; yield* orchestrator.dispatch({ type: "thread.auto-settle", commandId: CommandId.make(`server:auto-settle:${thread.id}:${uuid}`), threadId: thread.id, // An in-flight user action wins over a stale sweep. snapshotAt: thread.updatedAt, settledAt, }); }).pipe( Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Effect.logWarning("automatic thread settlement skipped", { threadId: thread.id, cause: Cause.pretty(cause), }), ), ), { discard: true }, ); }).pipe( Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Effect.logWarning("automatic thread settlement skipped", { threadIds: group.map((thread) => thread.id), cause: Cause.pretty(cause), }), ), ), { concurrency: 8, discard: true }, ); }); const runSweep = (mergedPullRequest: PullRequestService.PullRequestMergeEvent | null) => sweep(mergedPullRequest).pipe( Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Effect.logWarning("automatic thread settlement sweep failed", { cause: Cause.pretty(cause), }), ), ); const worker = yield* makeDrainableWorker(() => runSweep(null)); const start: ThreadSettlementServiceV2["Service"]["start"] = Effect.fn( "ThreadSettlementServiceV2.start", )(function* () { const settingsChanges = yield* settingsService.subscribeChanges; const mergedPullRequests = yield* pullRequests.subscribeMerges; const initialSettings = yield* settingsService.getSettings.pipe(Effect.orDie); let lastAfterDays = initialSettings.sidebarAutoSettleAfterDays; let lastOnMerge = initialSettings.sidebarAutoSettleOnMerge; yield* forkParked( Effect.gen(function* () { yield* worker.enqueue(undefined); yield* worker.drain; }).pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.asVoid), ); yield* forkParked( Stream.runForEach(settingsChanges, (settings) => { if ( settings.sidebarAutoSettleAfterDays === lastAfterDays && settings.sidebarAutoSettleOnMerge === lastOnMerge ) { return Effect.void; } lastAfterDays = settings.sidebarAutoSettleAfterDays; lastOnMerge = settings.sidebarAutoSettleOnMerge; return worker.enqueue(undefined); }), ); yield* forkParked(Stream.runForEach(mergedPullRequests, runSweep)); }); return { start, drain: worker.drain } satisfies ThreadSettlementServiceV2["Service"];}); export const layer = Layer.effect(ThreadSettlementServiceV2, make);
import { CommandId, type OrchestrationV2ThreadShell } from "@t3tools/contracts";
import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
import * as Cause from "effect/Cause";
import * as Context from "effect/Context";
import * as Crypto from "effect/Crypto";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Schedule from "effect/Schedule";
import type * as Scope from "effect/Scope";
import * as Stream from "effect/Stream";
import * as GitManager from "../git/GitManager.ts";
import * as PullRequestService from "../pullRequest/PullRequestService.ts";
import * as ServerSettings from "../serverSettings.ts";
import { forkParked } from "../serverActivation.ts";
import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
import { OrchestratorV2 } from "./Orchestrator.ts";
import { ProjectionStoreV2, type ProjectionSettlementCandidate } from "./ProjectionStore.ts";
export interface SettlementPullRequest {
readonly state: "open" | "closed" | "merged";
readonly updatedAt: string | null;
}
const DAY_MS = 24 * 60 * 60 * 1_000;
export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000;
function toMillis(value: DateTime.Utc | null | undefined): number | null {
return value == null ? null : DateTime.toEpochMillis(value);
}
function latestMillis(values: ReadonlyArray<number | null>): number | null {
let latest: number | null = null;
for (const value of values) {
if (value === null) continue;
if (latest === null || value > latest) latest = value;
}
return latest;
}
/**
* A recent user message stays queued until a run adopts its timestamp.
* Absolute age bounds client clock skew in both directions and stops stale
* pre-adoption data from blocking the thread forever. A failed run start
* clears the block immediately (mirrors the v1 session "error" rule).
*/
export function threadHasQueuedTurnStart(
thread: Pick<
OrchestrationV2ThreadShell,
| "latestUserMessageAt"
| "latestRunRequestedAt"
| "latestRunStartedAt"
| "latestRunCompletedAt"
| "latestRunId"
| "status"
>,
nowMs: number,
): boolean {
const messageAtMs = toMillis(thread.latestUserMessageAt);
if (messageAtMs === null || thread.status === "failed") return false;
const age = nowMs - messageAtMs;
if (Number.isNaN(age) || Math.abs(age) > QUEUED_TURN_START_GRACE_MS) return false;
if (thread.latestRunId === null) return true;
return [
toMillis(thread.latestRunRequestedAt),
toMillis(thread.latestRunStartedAt),
toMillis(thread.latestRunCompletedAt),
].every((value) => value === null || value < messageAtMs);
}
function pullRequestSettles(
thread: Pick<
OrchestrationV2ThreadShell,
"createdAt" | "latestUserMessageAt" | "latestRunRequestedAt"
>,
pullRequest: SettlementPullRequest,
autoSettleOnMerge: boolean,
): boolean {
if (pullRequest.state !== "closed" && (pullRequest.state !== "merged" || !autoSettleOnMerge)) {
return false;
}
if (pullRequest.updatedAt === null) return false;
const userAnchorMs = latestMillis([
toMillis(thread.createdAt),
toMillis(thread.latestUserMessageAt),
toMillis(thread.latestRunRequestedAt),
]);
if (userAnchorMs === null) return false;
const pullRequestAtMs = Date.parse(pullRequest.updatedAt);
if (Number.isNaN(pullRequestAtMs)) return false;
return pullRequestAtMs >= userAnchorMs;
}
/** Cheap checks that run before any source control lookup. */
export function isAutoSettlementCandidate(
thread: ProjectionSettlementCandidate,
nowMs: number,
): boolean {
if (thread.archivedAt !== null || thread.settledOverride !== null) return false;
if (thread.pinnedAt != null) return false;
// Blocked-on-you work must never park behind a settled override.
if (thread.pendingRuntimeRequest !== null) return false;
// A live run — or post-settlement background work — is not staleness.
if (thread.activityRunStatus != null) return false;
if ((thread.pendingBackgroundTasks?.length ?? 0) > 0) return false;
if (threadHasQueuedTurnStart(thread, nowMs)) return false;
const snoozedUntilMs = toMillis(thread.snoozedUntil);
if (snoozedUntilMs === null || snoozedUntilMs <= nowMs) return true;
// A snoozed thread that woke early (error or completed work) can settle;
// one still parked on its wake time keeps its stronger statement.
const snoozedAtMs = toMillis(thread.snoozedAt);
const completedAtMs = toMillis(thread.latestRunCompletedAt);
const wokeOnError =
thread.status === "failed" &&
(snoozedAtMs === null || (completedAtMs !== null && completedAtMs > snoozedAtMs));
const wokeOnCompletion =
snoozedAtMs !== null && completedAtMs !== null && completedAtMs > snoozedAtMs;
return wokeOnError || wokeOnCompletion;
}
export function resolveAutoSettlementAt(input: {
readonly thread: ProjectionSettlementCandidate;
readonly pullRequest: SettlementPullRequest | null;
readonly nowMs: number;
readonly autoSettleAfterDays: number | null;
readonly autoSettleOnMerge: boolean;
}): DateTime.Utc | null {
const { thread, pullRequest } = input;
if (!isAutoSettlementCandidate(thread, input.nowMs)) return null;
const activityAtMs = latestMillis([
toMillis(thread.latestUserMessageAt),
toMillis(thread.latestRunRequestedAt),
toMillis(thread.latestRunStartedAt),
toMillis(thread.latestRunCompletedAt),
]);
if (pullRequest !== null && pullRequestSettles(thread, pullRequest, input.autoSettleOnMerge)) {
return activityAtMs === null ? thread.createdAt : DateTime.makeUnsafe(activityAtMs);
}
if (input.autoSettleAfterDays === null || activityAtMs === null) return null;
return activityAtMs < input.nowMs - input.autoSettleAfterDays * DAY_MS
? DateTime.makeUnsafe(activityAtMs)
: null;
}
export class ThreadSettlementServiceV2 extends Context.Service<
ThreadSettlementServiceV2,
{
readonly start: () => Effect.Effect<void, never, Scope.Scope>;
readonly drain: Effect.Effect<void>;
}
>()("t3/orchestration-v2/ThreadSettlementService/ThreadSettlementServiceV2") {}
export const make = Effect.gen(function* () {
const orchestrator = yield* OrchestratorV2;
const projections = yield* ProjectionStoreV2;
const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
const settingsService = yield* ServerSettings.ServerSettingsService;
const git = yield* GitManager.GitManager;
const pullRequests = yield* PullRequestService.PullRequestService;
const crypto = yield* Crypto.Crypto;
const fileSystem = yield* FileSystem.FileSystem;
const sweep = Effect.fn("ThreadSettlementServiceV2.sweep")(function* (
mergedPullRequest: PullRequestService.PullRequestMergeEvent | null,
) {
const threads = yield* projections.getSettlementCandidates();
const projectShells = yield* snapshots.getProjectShellsWithoutEnrichment();
const nowMs = DateTime.toEpochMillis(yield* DateTime.now);
const projects = new Map(projectShells.map((project) => [project.id, project]));
// A merge event re-sweeps every candidate, not just the threads linked to
// the merged pull request: most threads carry no link and settle from
// their branch lookup, which would otherwise wait for the next minute's
// sweep on a possibly stale cached answer.
const candidates = threads.filter((thread) => isAutoSettlementCandidate(thread, nowMs));
// Use the same cwd as the sidebar so both paths share GitManager's PR cache.
const lookupCwdByThreadId = new Map<string, string>();
yield* Effect.forEach(
candidates,
(thread) =>
Effect.gen(function* () {
const project = projects.get(thread.projectId);
if (project === undefined || thread.linkedPullRequest != null) return;
const worktreeExists =
thread.worktreePath !== null &&
(yield* fileSystem.exists(thread.worktreePath).pipe(Effect.orElseSucceed(() => false)));
lookupCwdByThreadId.set(
thread.id,
worktreeExists && thread.worktreePath !== null
? thread.worktreePath
: project.workspaceRoot,
);
}),
{ concurrency: 8, discard: true },
);
if (mergedPullRequest !== null) {
// The merge just confirmed a terminal state the lookup caches can still
// call open (branch answers live two minutes, the sweep runs every
// minute). Drop the swept checkouts' cached answers so the merge settles
// its branch threads now instead of on a later sweep. Threads linked to
// the merged pull request settle from the event itself below and need no
// lookup, so they are absent from this map by construction.
const cwds = [...new Set(lookupCwdByThreadId.values())];
yield* Effect.forEach(cwds, (cwd) => git.invalidateStatus(cwd), {
concurrency: 8,
discard: true,
});
}
const lookupKey = (thread: (typeof candidates)[number]) => {
if (thread.linkedPullRequest != null) {
return JSON.stringify([
"linked",
thread.linkedPullRequest.projectId,
thread.linkedPullRequest.repository,
thread.linkedPullRequest.number,
]);
}
if (thread.branch === null) return JSON.stringify(["none", thread.id]);
const cwd = lookupCwdByThreadId.get(thread.id);
return JSON.stringify(
cwd === undefined ? ["missing-project", thread.id] : ["branch", cwd, thread.branch],
);
};
const groups = Map.groupBy(candidates, lookupKey);
const pullRequestFor = Effect.fn("ThreadSettlementServiceV2.pullRequestFor")(function* (
thread: (typeof candidates)[number],
) {
if (thread.linkedPullRequest != null) {
// The event carries the merged state, so only the threads linked to
// that exact pull request settle from it. Every other linked thread
// falls through to a fresh summary lookup below: the merge sweep
// covers all candidates, and an unrelated merge must never settle
// them.
if (
mergedPullRequest !== null &&
thread.linkedPullRequest.projectId === mergedPullRequest.projectId &&
thread.linkedPullRequest.repository.toLowerCase() ===
mergedPullRequest.repository.toLowerCase() &&
thread.linkedPullRequest.number === mergedPullRequest.number
) {
return {
state: "merged",
updatedAt: mergedPullRequest.mergedAt,
} satisfies SettlementPullRequest;
}
if (!projects.has(thread.linkedPullRequest.projectId)) {
return yield* Effect.die(new Error("linked pull request project not found"));
}
const summary = yield* pullRequests.summary(
{
projectId: thread.linkedPullRequest.projectId,
repository: thread.linkedPullRequest.repository,
number: thread.linkedPullRequest.number,
},
{ recoverTransientFailure: false },
);
return {
state: summary.state,
updatedAt: summary.updatedAt,
} satisfies SettlementPullRequest;
}
if (thread.branch === null) return null;
const cwd = lookupCwdByThreadId.get(thread.id);
if (cwd === undefined) {
return yield* Effect.die(new Error("thread project not found"));
}
return yield* git.branchPullRequest({ cwd, branch: thread.branch });
});
yield* Effect.forEach(
groups.values(),
(group) =>
Effect.gen(function* () {
const pullRequest = yield* pullRequestFor(group[0]!);
yield* Effect.forEach(
group,
(thread) =>
Effect.gen(function* () {
const settings = yield* settingsService.getSettings;
const decisionNow = yield* DateTime.now;
const settledAt = resolveAutoSettlementAt({
thread,
pullRequest,
nowMs: DateTime.toEpochMillis(decisionNow),
autoSettleAfterDays: settings.sidebarAutoSettleAfterDays,
autoSettleOnMerge: settings.sidebarAutoSettleOnMerge,
});
if (settledAt === null) {
return;
}
const uuid = yield* crypto.randomUUIDv4;
yield* orchestrator.dispatch({
type: "thread.auto-settle",
commandId: CommandId.make(`server:auto-settle:${thread.id}:${uuid}`),
threadId: thread.id,
// An in-flight user action wins over a stale sweep.
snapshotAt: thread.updatedAt,
settledAt,
});
}).pipe(
Effect.catchCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.failCause(cause)
: Effect.logWarning("automatic thread settlement skipped", {
threadId: thread.id,
cause: Cause.pretty(cause),
}),
),
),
{ discard: true },
);
}).pipe(
Effect.catchCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.failCause(cause)
: Effect.logWarning("automatic thread settlement skipped", {
threadIds: group.map((thread) => thread.id),
cause: Cause.pretty(cause),
}),
),
),
{ concurrency: 8, discard: true },
);
});
const runSweep = (mergedPullRequest: PullRequestService.PullRequestMergeEvent | null) =>
sweep(mergedPullRequest).pipe(
Effect.catchCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.failCause(cause)
: Effect.logWarning("automatic thread settlement sweep failed", {
cause: Cause.pretty(cause),
}),
),
);
const worker = yield* makeDrainableWorker(() => runSweep(null));
const start: ThreadSettlementServiceV2["Service"]["start"] = Effect.fn(
"ThreadSettlementServiceV2.start",
)(function* () {
const settingsChanges = yield* settingsService.subscribeChanges;
const mergedPullRequests = yield* pullRequests.subscribeMerges;
const initialSettings = yield* settingsService.getSettings.pipe(Effect.orDie);
let lastAfterDays = initialSettings.sidebarAutoSettleAfterDays;
let lastOnMerge = initialSettings.sidebarAutoSettleOnMerge;
yield* forkParked(
Effect.gen(function* () {
yield* worker.enqueue(undefined);
yield* worker.drain;
}).pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.asVoid),
);
yield* forkParked(
Stream.runForEach(settingsChanges, (settings) => {
if (
settings.sidebarAutoSettleAfterDays === lastAfterDays &&
settings.sidebarAutoSettleOnMerge === lastOnMerge
) {
return Effect.void;
}
lastAfterDays = settings.sidebarAutoSettleAfterDays;
lastOnMerge = settings.sidebarAutoSettleOnMerge;
return worker.enqueue(undefined);
}),
);
yield* forkParked(Stream.runForEach(mergedPullRequests, runSweep));
});
return { start, drain: worker.drain } satisfies ThreadSettlementServiceV2["Service"];
});
export const layer = Layer.effect(ThreadSettlementServiceV2, make);