import { CommandId, type OrchestrationEvent, type OrchestrationProjectShell, type ThreadId, type ThreadLinkedPullRequest,} 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 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 RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";import { forkParked } from "../serverActivation.ts";import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts";import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts"; export class ThreadPullRequestReactor extends Context.Service< ThreadPullRequestReactor, { readonly start: () => Effect.Effect<void, never, Scope.Scope>; readonly drain: Effect.Effect<void>; }>()("t3/orchestration/ThreadPullRequestReactor") {} function samePullRequest( left: ThreadLinkedPullRequest | null | undefined, right: ThreadLinkedPullRequest | null,): boolean { if (left == null || right === null) return left == null && right === null; return ( left.projectId === right.projectId && left.repository.toLowerCase() === right.repository.toLowerCase() && left.number === right.number && left.url === right.url );} /** Startup lookups per settled thread before discovery gives up on it. */export const BACKFILL_ATTEMPTS = 5; interface RefreshRequest { readonly threadId: ThreadId | null; readonly refresh: boolean; readonly backfill?: boolean;} function canonicalRepositoryKey(key: string): string { return key .replace( /^(?:ssh\.dev\.azure\.com|vs-ssh\.visualstudio\.com)\/v3\/([^/]+)\/([^/]+)\/([^/]+)$/u, "dev.azure.com/$1/$2/_git/$3", ) .replace( /^([^.]+)\.visualstudio\.com\/(?:defaultcollection\/)?([^/]+)\/_git\/([^/]+)$/u, "dev.azure.com/$1/$2/_git/$3", );} export function pullRequestMatchesProject( pullRequest: GitManager.GitBranchPullRequest, project: OrchestrationProjectShell,): boolean { return ( pullRequest.repositoryKey !== null && project.repositoryIdentity != null && canonicalRepositoryKey(pullRequest.repositoryKey) === canonicalRepositoryKey(project.repositoryIdentity.canonicalKey) );} export const make = Effect.gen(function* () { const engine = yield* OrchestrationEngine.OrchestrationEngineService; const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; const git = yield* GitManager.GitManager; const pullRequests = yield* PullRequestService.PullRequestService; const repositoryIdentities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const crypto = yield* Crypto.Crypto; const fileSystem = yield* FileSystem.FileSystem; // Settled threads get one link discovery at startup. Failed lookups retry on // the periodic pass a few times, then stop until the thread changes or the // server restarts, so a missing or logged-out CLI cannot loop forever. const pendingBackfill = new Map<ThreadId, number>(); const finishBackfill = (threads: ReadonlyArray<{ readonly id: ThreadId }>) => { for (const thread of threads) pendingBackfill.delete(thread.id); }; const failBackfill = (threads: ReadonlyArray<{ readonly id: ThreadId }>) => { for (const thread of threads) { const remaining = pendingBackfill.get(thread.id); if (remaining === undefined) continue; if (remaining <= 1) pendingBackfill.delete(thread.id); else pendingBackfill.set(thread.id, remaining - 1); } }; const synchronize = Effect.fn("ThreadPullRequestReactor.synchronize")(function* ( request: RefreshRequest, ) { const snapshot = yield* snapshots.getShellSnapshot(); const projects = new Map(snapshot.projects.map((project) => [project.id, project])); if (request.backfill) { for (const thread of snapshot.threads) { if (thread.settledOverride === "settled" && thread.branchPullRequest == null) { pendingBackfill.set(thread.id, BACKFILL_ATTEMPTS); } } } const threadIds = new Set(snapshot.threads.map((thread) => thread.id)); for (const threadId of pendingBackfill.keys()) { if (!threadIds.has(threadId)) pendingBackfill.delete(threadId); } const threads = snapshot.threads.filter( (thread) => thread.archivedAt === null && (request.threadId === null || thread.id === request.threadId) && (thread.settledOverride !== "settled" || request.threadId !== null || pendingBackfill.has(thread.id)) && (thread.branch !== null || thread.branchPullRequest != null), ); const groups = Map.groupBy(threads, (thread) => JSON.stringify([thread.projectId, thread.worktreePath, thread.branch]), ); yield* Effect.forEach( groups.values(), (group) => Effect.gen(function* () { const first = group[0]!; const project = projects.get(first.projectId); if (project === undefined) return finishBackfill(group); const repository = PullRequestService.repositoryIdentityOf(project); if (first.branch !== null && repository === null) return finishBackfill(group); const worktreeExists = first.worktreePath !== null && (yield* fileSystem.exists(first.worktreePath)); const cwd = worktreeExists && first.worktreePath !== null ? first.worktreePath : project.workspaceRoot; const detected = first.branch === null ? null : yield* git.branchPullRequest( { cwd, branch: first.branch }, { refresh: request.refresh }, ); // A worktree can have different remotes, and the project identity // can lag a remote edit. Do not attach its PR to the wrong repository. if (detected !== null && !pullRequestMatchesProject(detected, project)) { return finishBackfill(group); } const detectedReference = detected !== null && repository !== null ? { projectId: project.id, repository, number: detected.number, url: detected.url, } : null; const plans = yield* Effect.forEach(group, (thread) => Effect.gen(function* () { let branchPullRequest = detectedReference; // Shared checkouts often return to the default branch after // a merge. Keep that thread's terminal PR across the change. if ( branchPullRequest === null && thread.branch !== null && thread.worktreePath === null && thread.branchPullRequest != null ) { const previous = yield* pullRequests.summary(thread.branchPullRequest, { recoverTransientFailure: false, }); if (previous.state === "merged" || previous.state === "closed") { branchPullRequest = thread.branchPullRequest; } } let replacement: ThreadLinkedPullRequest | undefined; if ( thread.linkedPullRequest != null && detected?.state === "open" && detectedReference !== null && !samePullRequest(thread.linkedPullRequest, detectedReference) ) { const linked = yield* pullRequests.summary(thread.linkedPullRequest, { recoverTransientFailure: false, }); if (linked.state === "merged" || linked.state === "closed") { replacement = detectedReference; } } if ( samePullRequest(thread.branchPullRequest, branchPullRequest) && replacement === undefined ) { pendingBackfill.delete(thread.id); return null; } return { thread, branchPullRequest, replacement }; }).pipe( Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Effect.logWarning("thread pull request discovery failed", { threadId: thread.id, cause: Cause.pretty(cause), }).pipe( Effect.tap(() => Effect.sync(() => failBackfill([thread]))), Effect.as(null), ), ), ), ); const updates = plans.filter((plan) => plan !== null); if (updates.length === 0) return; if (detected !== null && first.branch !== null) { // Summary reads can outlast a remote edit. Recheck the branch and // the project's primary remote before saving the group's links. const current = yield* git.branchPullRequest({ cwd, branch: first.branch }); const currentIdentity = yield* repositoryIdentities.resolve(project.workspaceRoot, { refresh: true, }); if ( current === null || current.number !== detected.number || current.url !== detected.url || current.state !== detected.state || current.repositoryKey !== detected.repositoryKey || !pullRequestMatchesProject(current, { ...project, repositoryIdentity: currentIdentity, }) ) { return failBackfill(updates.map((update) => update.thread)); } } yield* Effect.forEach( updates, ({ thread, branchPullRequest, replacement }) => Effect.gen(function* () { const uuid = yield* crypto.randomUUIDv4; yield* engine.dispatch({ type: "thread.pull-request.sync", commandId: CommandId.make(`server:thread-pull-request:${thread.id}:${uuid}`), threadId: thread.id, projectId: project.id, snapshotSequence: snapshot.snapshotSequence, expected: { workspaceRoot: project.workspaceRoot, branch: thread.branch, worktreePath: thread.worktreePath, linkedPullRequest: thread.linkedPullRequest ?? null, branchPullRequest: thread.branchPullRequest ?? null, }, branchPullRequest, ...(replacement !== undefined ? { linkedPullRequest: replacement } : {}), }); pendingBackfill.delete(thread.id); }).pipe( // The thread changed since the lookup. Its own events requeue it. Effect.catchTags({ OrchestrationCommandInvariantError: () => Effect.sync(() => finishBackfill([thread])), }), Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Effect.logWarning("thread pull request update failed", { threadId: thread.id, cause: Cause.pretty(cause), }).pipe(Effect.tap(() => Effect.sync(() => failBackfill([thread])))), ), ), { discard: true }, ); }).pipe( Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Effect.logWarning("thread branch pull request lookup failed", { threadIds: group.map((thread) => thread.id), cause: Cause.pretty(cause), }).pipe(Effect.tap(() => Effect.sync(() => failBackfill(group)))), ), ), { concurrency: 8, discard: true }, ); }); const worker = yield* makeDrainableWorker((request: RefreshRequest) => synchronize(request).pipe( Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Effect.logWarning("thread pull request refresh failed", { cause: Cause.pretty(cause), }), ), ), ); const processEvent = (event: OrchestrationEvent) => { switch (event.type) { case "thread.created": case "thread.unarchived": return worker.enqueue({ threadId: event.payload.threadId, refresh: false }); case "thread.meta-updated": if ( event.payload.branchPullRequest === undefined && (event.payload.branch !== undefined || event.payload.worktreePath !== undefined || event.payload.linkedPullRequest !== undefined) ) { return worker.enqueue({ threadId: event.payload.threadId, refresh: false }); } break; case "thread.session-set": if ( event.payload.session.status !== "running" && event.payload.session.status !== "starting" ) { return worker.enqueue({ threadId: event.payload.threadId, refresh: true }); } break; case "thread.turn-diff-completed": case "thread.unsettled": return worker.enqueue({ threadId: event.payload.threadId, refresh: true }); case "project.meta-updated": if (event.payload.workspaceRoot !== undefined) { return worker.enqueue({ threadId: null, refresh: false }); } break; } return Effect.void; }; const start = Effect.fn("ThreadPullRequestReactor.start")(function* () { const events = yield* engine.subscribeDomainEvents; yield* forkParked(Stream.runForEach(events, processEvent)); // Run without client demand. Saved branch lookups share GitManager's // provider cache and retry backoff with status and automatic settlement. yield* forkParked( Effect.gen(function* () { yield* worker.enqueue({ threadId: null, refresh: false, backfill: true }); yield* worker.drain; yield* Effect.gen(function* () { yield* worker.enqueue({ threadId: null, refresh: false }); yield* worker.drain; }).pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.delay("1 minute")); }).pipe(Effect.asVoid), ); }); return { start, drain: worker.drain } satisfies ThreadPullRequestReactor["Service"];}); export const layer = Layer.effect(ThreadPullRequestReactor, make);
import {
CommandId,
type OrchestrationEvent,
type OrchestrationProjectShell,
type ThreadId,
type ThreadLinkedPullRequest,
} 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 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 RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
import { forkParked } from "../serverActivation.ts";
import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts";
import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts";
export class ThreadPullRequestReactor extends Context.Service<
ThreadPullRequestReactor,
{
readonly start: () => Effect.Effect<void, never, Scope.Scope>;
readonly drain: Effect.Effect<void>;
}
>()("t3/orchestration/ThreadPullRequestReactor") {}
function samePullRequest(
left: ThreadLinkedPullRequest | null | undefined,
right: ThreadLinkedPullRequest | null,
): boolean {
if (left == null || right === null) return left == null && right === null;
return (
left.projectId === right.projectId &&
left.repository.toLowerCase() === right.repository.toLowerCase() &&
left.number === right.number &&
left.url === right.url
);
}
/** Startup lookups per settled thread before discovery gives up on it. */
export const BACKFILL_ATTEMPTS = 5;
interface RefreshRequest {
readonly threadId: ThreadId | null;
readonly refresh: boolean;
readonly backfill?: boolean;
}
function canonicalRepositoryKey(key: string): string {
return key
.replace(
/^(?:ssh\.dev\.azure\.com|vs-ssh\.visualstudio\.com)\/v3\/([^/]+)\/([^/]+)\/([^/]+)$/u,
"dev.azure.com/$1/$2/_git/$3",
)
.replace(
/^([^.]+)\.visualstudio\.com\/(?:defaultcollection\/)?([^/]+)\/_git\/([^/]+)$/u,
"dev.azure.com/$1/$2/_git/$3",
);
}
export function pullRequestMatchesProject(
pullRequest: GitManager.GitBranchPullRequest,
project: OrchestrationProjectShell,
): boolean {
return (
pullRequest.repositoryKey !== null &&
project.repositoryIdentity != null &&
canonicalRepositoryKey(pullRequest.repositoryKey) ===
canonicalRepositoryKey(project.repositoryIdentity.canonicalKey)
);
}
export const make = Effect.gen(function* () {
const engine = yield* OrchestrationEngine.OrchestrationEngineService;
const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
const git = yield* GitManager.GitManager;
const pullRequests = yield* PullRequestService.PullRequestService;
const repositoryIdentities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
const crypto = yield* Crypto.Crypto;
const fileSystem = yield* FileSystem.FileSystem;
// Settled threads get one link discovery at startup. Failed lookups retry on
// the periodic pass a few times, then stop until the thread changes or the
// server restarts, so a missing or logged-out CLI cannot loop forever.
const pendingBackfill = new Map<ThreadId, number>();
const finishBackfill = (threads: ReadonlyArray<{ readonly id: ThreadId }>) => {
for (const thread of threads) pendingBackfill.delete(thread.id);
};
const failBackfill = (threads: ReadonlyArray<{ readonly id: ThreadId }>) => {
for (const thread of threads) {
const remaining = pendingBackfill.get(thread.id);
if (remaining === undefined) continue;
if (remaining <= 1) pendingBackfill.delete(thread.id);
else pendingBackfill.set(thread.id, remaining - 1);
}
};
const synchronize = Effect.fn("ThreadPullRequestReactor.synchronize")(function* (
request: RefreshRequest,
) {
const snapshot = yield* snapshots.getShellSnapshot();
const projects = new Map(snapshot.projects.map((project) => [project.id, project]));
if (request.backfill) {
for (const thread of snapshot.threads) {
if (thread.settledOverride === "settled" && thread.branchPullRequest == null) {
pendingBackfill.set(thread.id, BACKFILL_ATTEMPTS);
}
}
}
const threadIds = new Set(snapshot.threads.map((thread) => thread.id));
for (const threadId of pendingBackfill.keys()) {
if (!threadIds.has(threadId)) pendingBackfill.delete(threadId);
}
const threads = snapshot.threads.filter(
(thread) =>
thread.archivedAt === null &&
(request.threadId === null || thread.id === request.threadId) &&
(thread.settledOverride !== "settled" ||
request.threadId !== null ||
pendingBackfill.has(thread.id)) &&
(thread.branch !== null || thread.branchPullRequest != null),
);
const groups = Map.groupBy(threads, (thread) =>
JSON.stringify([thread.projectId, thread.worktreePath, thread.branch]),
);
yield* Effect.forEach(
groups.values(),
(group) =>
Effect.gen(function* () {
const first = group[0]!;
const project = projects.get(first.projectId);
if (project === undefined) return finishBackfill(group);
const repository = PullRequestService.repositoryIdentityOf(project);
if (first.branch !== null && repository === null) return finishBackfill(group);
const worktreeExists =
first.worktreePath !== null && (yield* fileSystem.exists(first.worktreePath));
const cwd =
worktreeExists && first.worktreePath !== null
? first.worktreePath
: project.workspaceRoot;
const detected =
first.branch === null
? null
: yield* git.branchPullRequest(
{ cwd, branch: first.branch },
{ refresh: request.refresh },
);
// A worktree can have different remotes, and the project identity
// can lag a remote edit. Do not attach its PR to the wrong repository.
if (detected !== null && !pullRequestMatchesProject(detected, project)) {
return finishBackfill(group);
}
const detectedReference =
detected !== null && repository !== null
? {
projectId: project.id,
repository,
number: detected.number,
url: detected.url,
}
: null;
const plans = yield* Effect.forEach(group, (thread) =>
Effect.gen(function* () {
let branchPullRequest = detectedReference;
// Shared checkouts often return to the default branch after
// a merge. Keep that thread's terminal PR across the change.
if (
branchPullRequest === null &&
thread.branch !== null &&
thread.worktreePath === null &&
thread.branchPullRequest != null
) {
const previous = yield* pullRequests.summary(thread.branchPullRequest, {
recoverTransientFailure: false,
});
if (previous.state === "merged" || previous.state === "closed") {
branchPullRequest = thread.branchPullRequest;
}
}
let replacement: ThreadLinkedPullRequest | undefined;
if (
thread.linkedPullRequest != null &&
detected?.state === "open" &&
detectedReference !== null &&
!samePullRequest(thread.linkedPullRequest, detectedReference)
) {
const linked = yield* pullRequests.summary(thread.linkedPullRequest, {
recoverTransientFailure: false,
});
if (linked.state === "merged" || linked.state === "closed") {
replacement = detectedReference;
}
}
if (
samePullRequest(thread.branchPullRequest, branchPullRequest) &&
replacement === undefined
) {
pendingBackfill.delete(thread.id);
return null;
}
return { thread, branchPullRequest, replacement };
}).pipe(
Effect.catchCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.failCause(cause)
: Effect.logWarning("thread pull request discovery failed", {
threadId: thread.id,
cause: Cause.pretty(cause),
}).pipe(
Effect.tap(() => Effect.sync(() => failBackfill([thread]))),
Effect.as(null),
),
),
),
);
const updates = plans.filter((plan) => plan !== null);
if (updates.length === 0) return;
if (detected !== null && first.branch !== null) {
// Summary reads can outlast a remote edit. Recheck the branch and
// the project's primary remote before saving the group's links.
const current = yield* git.branchPullRequest({ cwd, branch: first.branch });
const currentIdentity = yield* repositoryIdentities.resolve(project.workspaceRoot, {
refresh: true,
});
if (
current === null ||
current.number !== detected.number ||
current.url !== detected.url ||
current.state !== detected.state ||
current.repositoryKey !== detected.repositoryKey ||
!pullRequestMatchesProject(current, {
...project,
repositoryIdentity: currentIdentity,
})
) {
return failBackfill(updates.map((update) => update.thread));
}
}
yield* Effect.forEach(
updates,
({ thread, branchPullRequest, replacement }) =>
Effect.gen(function* () {
const uuid = yield* crypto.randomUUIDv4;
yield* engine.dispatch({
type: "thread.pull-request.sync",
commandId: CommandId.make(`server:thread-pull-request:${thread.id}:${uuid}`),
threadId: thread.id,
projectId: project.id,
snapshotSequence: snapshot.snapshotSequence,
expected: {
workspaceRoot: project.workspaceRoot,
branch: thread.branch,
worktreePath: thread.worktreePath,
linkedPullRequest: thread.linkedPullRequest ?? null,
branchPullRequest: thread.branchPullRequest ?? null,
},
branchPullRequest,
...(replacement !== undefined ? { linkedPullRequest: replacement } : {}),
});
pendingBackfill.delete(thread.id);
}).pipe(
// The thread changed since the lookup. Its own events requeue it.
Effect.catchTags({
OrchestrationCommandInvariantError: () =>
Effect.sync(() => finishBackfill([thread])),
}),
Effect.catchCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.failCause(cause)
: Effect.logWarning("thread pull request update failed", {
threadId: thread.id,
cause: Cause.pretty(cause),
}).pipe(Effect.tap(() => Effect.sync(() => failBackfill([thread])))),
),
),
{ discard: true },
);
}).pipe(
Effect.catchCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.failCause(cause)
: Effect.logWarning("thread branch pull request lookup failed", {
threadIds: group.map((thread) => thread.id),
cause: Cause.pretty(cause),
}).pipe(Effect.tap(() => Effect.sync(() => failBackfill(group)))),
),
),
{ concurrency: 8, discard: true },
);
});
const worker = yield* makeDrainableWorker((request: RefreshRequest) =>
synchronize(request).pipe(
Effect.catchCause((cause) =>
Cause.hasInterruptsOnly(cause)
? Effect.failCause(cause)
: Effect.logWarning("thread pull request refresh failed", {
cause: Cause.pretty(cause),
}),
),
),
);
const processEvent = (event: OrchestrationEvent) => {
switch (event.type) {
case "thread.created":
case "thread.unarchived":
return worker.enqueue({ threadId: event.payload.threadId, refresh: false });
case "thread.meta-updated":
if (
event.payload.branchPullRequest === undefined &&
(event.payload.branch !== undefined ||
event.payload.worktreePath !== undefined ||
event.payload.linkedPullRequest !== undefined)
) {
return worker.enqueue({ threadId: event.payload.threadId, refresh: false });
}
break;
case "thread.session-set":
if (
event.payload.session.status !== "running" &&
event.payload.session.status !== "starting"
) {
return worker.enqueue({ threadId: event.payload.threadId, refresh: true });
}
break;
case "thread.turn-diff-completed":
case "thread.unsettled":
return worker.enqueue({ threadId: event.payload.threadId, refresh: true });
case "project.meta-updated":
if (event.payload.workspaceRoot !== undefined) {
return worker.enqueue({ threadId: null, refresh: false });
}
break;
}
return Effect.void;
};
const start = Effect.fn("ThreadPullRequestReactor.start")(function* () {
const events = yield* engine.subscribeDomainEvents;
yield* forkParked(Stream.runForEach(events, processEvent));
// Run without client demand. Saved branch lookups share GitManager's
// provider cache and retry backoff with status and automatic settlement.
yield* forkParked(
Effect.gen(function* () {
yield* worker.enqueue({ threadId: null, refresh: false, backfill: true });
yield* worker.drain;
yield* Effect.gen(function* () {
yield* worker.enqueue({ threadId: null, refresh: false });
yield* worker.drain;
}).pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.delay("1 minute"));
}).pipe(Effect.asVoid),
);
});
return { start, drain: worker.drain } satisfies ThreadPullRequestReactor["Service"];
});
export const layer = Layer.effect(ThreadPullRequestReactor, make);