diff --git a/apps/mobile/src/components/GitHubDiscussionIcon.tsx b/apps/mobile/src/components/GitHubDiscussionIcon.tsx
new file mode 100644
index 0000000000000..e463561ad75d3
--- /dev/null
+++ b/apps/mobile/src/components/GitHubDiscussionIcon.tsx
@@ -0,0 +1,24 @@
+import type { ColorValue } from "react-native";
+import Svg, { Path } from "react-native-svg";
+import { withUniwind } from "uniwind";
+
+const ThemedPath = withUniwind(Path);
+
+// GitHub Octicons comment-discussion-16. Copyright (c) 2026 GitHub Inc., MIT.
+// https://github.com/primer/octicons/blob/main/LICENSE
+export function GitHubDiscussionIcon(props: {
+ readonly size: number;
+ readonly color?: ColorValue;
+ readonly colorClassName?: string;
+}) {
+ return (
+
+ );
+}
diff --git a/apps/mobile/src/features/threads/GitHubReplySnooze.tsx b/apps/mobile/src/features/threads/GitHubReplySnooze.tsx
new file mode 100644
index 0000000000000..e8c1ab4feca6b
--- /dev/null
+++ b/apps/mobile/src/features/threads/GitHubReplySnooze.tsx
@@ -0,0 +1,110 @@
+import {
+ githubReplyConversationLabel,
+ githubReplyConversationsForCondition,
+ githubReplySnoozeFailureMessage,
+ type GitHubReplyConversation,
+} from "@t3tools/client-runtime/github-reply-conversations";
+import type { GitHubReplyWakeCondition } from "@t3tools/contracts";
+import { appAtomRegistry } from "../../state/atom-registry";
+import { environmentServerConfigsAtom } from "../../state/server";
+import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
+import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime";
+import type { MenuAction } from "@react-native-menu/menu";
+import { useCallback, useRef, useState } from "react";
+import { Alert, Linking, Pressable, View } from "react-native";
+import { AppText as Text } from "../../components/AppText";
+import { threadEnvironment } from "../../state/threads";
+import { useAtomCommand } from "../../state/use-atom-command";
+
+export function githubReplySnoozeMenuAction(
+ conversations: ReadonlyArray,
+ wakeCondition?: GitHubReplyWakeCondition,
+): MenuAction {
+ conversations = githubReplyConversationsForCondition(conversations, wakeCondition);
+ const changesRequested = wakeCondition === "changes-requested";
+ const prefix = changesRequested ? "snooze:github:changes-requested" : "snooze:github";
+ const only = conversations.length === 1 ? conversations[0] : undefined;
+ return {
+ id: only ? `${prefix}:${only.url}` : prefix,
+ title: changesRequested ? "Until changes are requested" : "Until a GitHub reply",
+ ...(conversations.length === 0
+ ? {
+ subtitle: changesRequested
+ ? "Link an open GitHub PR first"
+ : "Link a GitHub PR or discussion first",
+ attributes: { disabled: true },
+ }
+ : {}),
+ ...(conversations.length > 1
+ ? {
+ subactions: conversations.map((conversation) => ({
+ id: `${prefix}:${conversation.url}`,
+ title: githubReplyConversationLabel(conversation),
+ })),
+ }
+ : {}),
+ };
+}
+
+export function useGitHubReplySnooze(thread: EnvironmentThreadShell) {
+ const snooze = useAtomCommand(threadEnvironment.snooze);
+ const [pending, setPending] = useState(false);
+ const pendingRef = useRef(false);
+ const snoozeUrl = useCallback(
+ (url: string, wakeCondition?: GitHubReplyWakeCondition) => {
+ if (
+ pendingRef.current ||
+ (wakeCondition === "changes-requested" &&
+ appAtomRegistry.get(environmentServerConfigsAtom).get(thread.environmentId)?.environment
+ .capabilities.threadGitHubChangesRequestedSnooze !== true)
+ )
+ return;
+ pendingRef.current = true;
+ setPending(true);
+ void snooze({
+ environmentId: thread.environmentId,
+ input: { threadId: thread.id, url, wakeCondition },
+ }).then((result) => {
+ pendingRef.current = false;
+ setPending(false);
+ if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
+ Alert.alert("Could not snooze thread", githubReplySnoozeFailureMessage);
+ }
+ });
+ },
+ [snooze, thread.environmentId, thread.id],
+ );
+ return { pending, snoozeUrl };
+}
+
+export function GitHubReplySnoozeCard({ thread }: { readonly thread: EnvironmentThreadShell }) {
+ const unsnooze = useAtomCommand(threadEnvironment.unsnooze);
+ const watch = thread.githubReplySnooze;
+ if (!watch) return null;
+ return (
+
+
+ {watch.wakeCondition === "changes-requested"
+ ? "Waiting for changes to be requested"
+ : "Waiting for a GitHub reply"}
+
+
+ void Linking.openURL(watch.url)}>
+ View
+
+
+
+ void unsnooze({
+ environmentId: thread.environmentId,
+ input: { threadId: thread.id, reason: "user" },
+ })
+ }
+ >
+ Wake now
+
+
+
+ );
+}
diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
index b6930deb5e864..cd7f131e98d8f 100644
--- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
+++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx
@@ -1,3 +1,4 @@
+import { GitHubReplySnoozeCard } from "./GitHubReplySnooze";
import { useAtomValue } from "@effect/atom-react";
import { useThreadReportedModelSelection } from "../../state/entities";
import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard";
@@ -1238,6 +1239,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
/>
) : null}
+
onSettleThread(thread), [onSettleThread, thread]);
const [customSnoozeOpen, setCustomSnoozeOpen] = useState(false);
+ const { snoozeUrl } = useGitHubReplySnooze(thread);
+ const githubConversations = useMemo(() => eligibleThreadGitHubConversations(thread), [thread]);
+ const githubSnoozeSupported =
+ appAtomRegistry.get(environmentServerConfigsAtom).get(thread.environmentId)?.environment
+ .capabilities.threadGitHubReplySnooze === true;
+ const githubChangesRequestedSupported =
+ appAtomRegistry.get(environmentServerConfigsAtom).get(thread.environmentId)?.environment
+ .capabilities.threadGitHubChangesRequestedSnooze === true;
// A recycled cell reassigns this mounted row to a different thread without
// remounting it, and the render closure stops running while list equality
// says the item is unchanged — so any row-local UI state must be dismissed
@@ -698,17 +719,28 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
() => (swipeActions.secondary === "snooze" ? resolveSnoozePresets(new Date()) : ([] as const)),
[props.snoozePresetMinute, swipeActions.secondary],
);
- const snoozePresetActions = useMemo(
- () => [
+ const snoozePresetActions = useMemo(() => {
+ const githubActions = githubSnoozeSupported
+ ? [
+ githubReplySnoozeMenuAction(githubConversations),
+ ...(githubChangesRequestedSupported
+ ? [githubReplySnoozeMenuAction(githubConversations, "changes-requested")]
+ : []),
+ ]
+ : [];
+ return [
...snoozePresets.map((preset) => ({
id: `snooze:${preset.id}`,
title: preset.label,
subtitle: preset.whenLabel,
})),
+ // ios draws dividers around inline groups; android keeps its flat menu.
+ ...(Platform.OS === "ios" && githubActions.length > 0
+ ? [{ title: "", displayInline: true, subactions: githubActions }]
+ : githubActions),
{ id: "snooze:custom", title: "Custom…" },
- ],
- [snoozePresets],
- );
+ ];
+ }, [snoozePresets, githubSnoozeSupported, githubChangesRequestedSupported, githubConversations]);
// Pinned cards keep the full lifecycle menu; only the pin item flips to
// Unpin. (Settling a pinned thread clears the pin server-side; snoozing
// hides the card until wake with the pin intact.)
@@ -863,6 +895,11 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
copyTextWithHaptic(thread.id, { target: "thread-id" });
}
if (nativeEvent.event === "delete") handleDelete();
+ if (nativeEvent.event.startsWith("snooze:github:")) {
+ const choice = githubReplySnoozeChoice(nativeEvent.event, githubConversations);
+ if (choice) snoozeUrl(choice.url, choice.wakeCondition);
+ return;
+ }
if (nativeEvent.event === "snooze:custom") {
setCustomSnoozeOpen(true);
return;
@@ -890,6 +927,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
handlePin,
handleSettle,
handleSnooze,
+ githubConversations,
+ snoozeUrl,
handleSetAutoSettle,
handleUnpin,
handleUnsettle,
@@ -996,6 +1035,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: {
type="monochrome"
/>
) : null}
+ {showUnreadReplyDot ? (
+
+ ) : null}
{statusLabel ? (
+ );
+ }
if (props.icon === "t3-code") {
return (
@@ -1216,7 +1231,13 @@ export function ThreadWorkGroupToggle(props: {
readonly iconSubtleColor: import("react-native").ColorValue;
readonly summary: string;
readonly summaryKind: ToolGroupSummaryKind;
- readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request" | "brain";
+ readonly summaryToolIcon?:
+ | "browser"
+ | "device"
+ | "t3-code"
+ | "pull-request"
+ | "discussion"
+ | "brain";
readonly themeAppearance: "light" | "dark";
readonly toolSurface?: import("@t3tools/contracts").ToolActivitySurface;
readonly toolIcon?: ToolActivityIcon;
@@ -1549,8 +1570,10 @@ function ToolActivityImage(props: {
);
}
-function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName {
+function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): WorkContentIcon {
switch (kind) {
+ case "discussion":
+ return "discussion";
case "pull-request":
case "link-pr":
case "unlink-pr":
diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts
index d5946e62ca159..493a0bb32ce77 100644
--- a/apps/mobile/src/features/threads/threadListV2.test.ts
+++ b/apps/mobile/src/features/threads/threadListV2.test.ts
@@ -199,6 +199,24 @@ describe("resolveThreadListV2Status", () => {
},
);
+ it.each(["reply", "error"] as const)("marks a %s wake unread without a run", (type) => {
+ const thread = makeThread({
+ id: ThreadId.make("reply-wake"),
+ title: "Reply wake",
+ latestRun: null,
+ lastVisitedAt: "2026-06-01T23:59:00.000Z",
+ githubReplyNotice: {
+ type,
+ receivedAt: NOW,
+ url: "https://github.com/team/repo/pull/1",
+ text: "Feedback",
+ },
+ });
+ expect(threadHasUnseenCompletion(thread)).toBe(true);
+ expect(threadHasUnseenCompletion({ ...thread, lastVisitedAt: null })).toBe(true);
+ expect(threadHasUnseenCompletion({ ...thread, lastVisitedAt: NOW })).toBe(false);
+ });
+
it("resolves ready for quiescent threads", () => {
expect(resolveThreadListV2Status(makeThread({ id: ThreadId.make("t"), title: "t" }))).toBe(
"ready",
diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts
index b5aee8fb4b225..be02bb4aa512d 100644
--- a/apps/mobile/src/features/threads/threadListV2.ts
+++ b/apps/mobile/src/features/threads/threadListV2.ts
@@ -2,6 +2,7 @@ import { threadPullRequestSearchTerms } from "@t3tools/shared/threadPullRequests
import {
canSnooze,
effectiveSnoozed,
+ threadHasUnseenActivity,
hasQueuedTurnStart,
QUEUED_TURN_START_GRACE_MS,
resolveSnoozePresets,
@@ -162,24 +163,16 @@ export const THREAD_LIST_V2_SETTLED_INITIAL_COUNT = 10;
export const THREAD_LIST_V2_SETTLED_PAGE_COUNT = 25;
/**
- * Completed-but-not-yet-seen, mirroring the web sidebar's
+ * Unseen completions and GitHub wakes, mirroring the web sidebar's
* hasUnseenCompletion. The visited watermark is server state
* (thread.lastVisitedAt), so the marker agrees across web and mobile.
- * Never-visited threads count as read — a fresh environment must not light
- * up its whole history — and pre-tracking servers (field absent) never
- * report unread.
+ * Completion history without a visit watermark stays read. A GitHub notice
+ * counts as unread even if the thread has no runs or prior visits.
*/
export function threadHasUnseenCompletion(
- thread: Pick,
+ thread: Pick,
): boolean {
- const completedAt = thread.latestRun?.completedAt;
- if (!completedAt) return false;
- const completedAtMs = Date.parse(completedAt);
- if (Number.isNaN(completedAtMs)) return false;
- if (!thread.lastVisitedAt) return false;
- const lastVisitedAtMs = Date.parse(thread.lastVisitedAt);
- if (Number.isNaN(lastVisitedAtMs)) return true;
- return completedAtMs > lastVisitedAtMs;
+ return threadHasUnseenActivity(thread);
}
export function resolveThreadListV2Status(
@@ -465,7 +458,11 @@ function resolveThreadListV2ItemTimeLabel(
if (showSnoozeWakeLabel) return "";
if (
variant === "card" &&
- (resolveThreadListV2Status(thread) !== "ready" || threadHasUnseenCompletion(thread))
+ (resolveThreadListV2Status(thread) !== "ready" ||
+ threadHasUnseenCompletion({
+ latestRun: thread.latestRun,
+ lastVisitedAt: thread.lastVisitedAt,
+ }))
)
return "";
const settledTimestamp =
@@ -514,8 +511,11 @@ export function buildThreadListV2ListItems(input: {
// An empty set is treated as absent so `?.` skips building the key.
const queuedThreadKeys = input.queuedThreadKeys?.size ? input.queuedThreadKeys : undefined;
const threadItems = input.items.map((item): ThreadListV2ListItem => {
- const snoozeWakeLabelText =
- item.snoozed && item.thread.snoozedUntil != null && input.snoozeLabelNow !== undefined
+ const snoozeWakeLabelText = item.thread.githubReplySnooze
+ ? item.thread.githubReplySnooze.wakeCondition === "changes-requested"
+ ? "Until change request"
+ : "Until reply"
+ : item.snoozed && item.thread.snoozedUntil != null && input.snoozeLabelNow !== undefined
? snoozeWakeLabel(item.thread.snoozedUntil, { now: input.snoozeLabelNow })
: undefined;
// The minute clock belongs on the item, not the list's extraData, so the
diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts
index d06af828ee11f..288d5bb3d79de 100644
--- a/apps/mobile/src/lib/threadActivity.ts
+++ b/apps/mobile/src/lib/threadActivity.ts
@@ -210,7 +210,7 @@ type ThreadFeedEntryContent =
readonly summaryKind: ToolGroupSummaryKind;
readonly toolSurface?: WorkLogPresentationEntry["toolSurface"];
readonly toolIcon?: WorkLogPresentationEntry["toolIcon"];
- readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request";
+ readonly summaryToolIcon?: "browser" | "device" | "t3-code" | "pull-request" | "discussion";
readonly hasFailure: boolean;
readonly live: boolean;
readonly shimmer: boolean;
diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts
index 3624caebb0767..68558fdf0c8f6 100644
--- a/apps/mobile/src/state/use-thread-selection.ts
+++ b/apps/mobile/src/state/use-thread-selection.ts
@@ -84,6 +84,7 @@ function threadDetailToShell(
worktreePath: thread.worktreePath,
linkedPullRequest: thread.linkedPullRequest ?? null,
pullRequests: thread.pullRequests,
+ discussions: thread.discussions,
branchPullRequest: thread.branchPullRequest ?? null,
activeProviderThreadId: thread.activeProviderThreadId,
lineage: thread.lineage,
diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts
index 6381547468ae3..f59a064e3ace5 100644
--- a/apps/server/src/environment/ServerEnvironment.ts
+++ b/apps/server/src/environment/ServerEnvironment.ts
@@ -233,6 +233,8 @@ export const make = Effect.gen(function* () {
threadRestartContinuation: true,
projectSettingsOverrides: true,
threadSnooze: true,
+ threadGitHubReplySnooze: true,
+ threadGitHubChangesRequestedSnooze: true,
environmentThemes: true,
usageLimitSources: true,
usagePriceOverrides: true,
@@ -244,6 +246,7 @@ export const make = Effect.gen(function* () {
threadTitleRegeneration: true,
threadVisitedTracking: true,
threadPullRequests: true,
+ threadDiscussions: true,
threadPullRequestWatch: true,
pullRequestStackActions: true,
threadPullRequestLinking: true,
diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts
index f4da6e051a7c3..195019c23aed6 100644
--- a/apps/server/src/mcp/McpHttpServer.ts
+++ b/apps/server/src/mcp/McpHttpServer.ts
@@ -1,3 +1,6 @@
+import * as DiscussionsHandlers from "./toolkits/discussions/handlers.ts";
+import { DiscussionsToolkit } from "./toolkits/discussions/tools.ts";
+import * as ThreadDiscussionMcpService from "./ThreadDiscussionMcpService.ts";
import * as Cause from "effect/Cause";
import * as Clock from "effect/Clock";
import * as Context from "effect/Context";
@@ -833,6 +836,11 @@ const layerDeviceStandardToolkitRegistration = toolkitRegistration(
DeviceHandlers.layerStandard,
);
+const layerDiscussionsToolkit = toolkitRegistration(
+ DiscussionsToolkit,
+ DiscussionsHandlers.layer,
+).pipe(Layer.provide(ThreadDiscussionMcpService.layer));
+
const layerDeviceScreenshotRegistration = imageToolRegistration(
registerDeviceScreenshot(),
DeviceHandlers.layerScreenshot,
@@ -860,6 +868,7 @@ export const layer = Layer.mergeAll(
layerPreviewControlsRegistration,
layerWorktreeToolkitRegistration,
layerPullRequestsToolkit,
+ layerDiscussionsToolkit,
layerDeviceToolkit,
layerHtmlToolkit,
).pipe(Layer.provideMerge(layerMcpTransport));
diff --git a/apps/server/src/mcp/McpToolAccess.race.test.ts b/apps/server/src/mcp/McpToolAccess.race.test.ts
index 52458c8401e89..4c83194da8c08 100644
--- a/apps/server/src/mcp/McpToolAccess.race.test.ts
+++ b/apps/server/src/mcp/McpToolAccess.race.test.ts
@@ -22,6 +22,9 @@ import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
import type { ProviderAdapterV2Shape } from "@t3tools/provider-core/server/ProviderAdapter";
import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts";
import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts";
+import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts";
+import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
+import * as GitHubReplyReader from "../sourceControl/GitHubReplyReader.ts";
import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts";
import * as McpHttpServer from "./McpHttpServer.ts";
import * as McpInvocationContext from "./McpInvocationContext.ts";
@@ -40,6 +43,14 @@ const adapter = {
openSession: () => Effect.die("No provider process needed for metadata commands"),
} as ProviderAdapterV2Shape;
const layerDatabase = SqlitePersistence.layerMemory;
+const layerThreadManagement = ThreadManagement.layer.pipe(
+ Layer.provide(Layer.mock(GitHubReplyReader.GitHubReplyReader)({})),
+ Layer.provide(
+ Layer.mergeAll(CommandReceiptStore.layer, ProjectStore.layer).pipe(
+ Layer.provide(layerDatabase),
+ ),
+ ),
+);
const layerOrchestrator = Layer.mergeAll(
layerDatabase,
ProjectionStore.layer.pipe(Layer.provide(layerDatabase)),
@@ -184,7 +195,7 @@ it.layer(layerOrchestrator)("writesThreads against a mode raise", (it) => {
const shell = yield* projections.getThreadShell(threadId);
assert.equal(shell?.runtimeMode, "full-access");
assert.equal(shell?.title, "Before");
- }).pipe(Effect.provide(ThreadManagement.layer)),
+ }).pipe(Effect.provide(layerThreadManagement)),
);
it.effect("lets the write through when the thread stays within the caller's modes", () =>
@@ -195,6 +206,6 @@ it.layer(layerOrchestrator)("writesThreads against a mode raise", (it) => {
const outcome = yield* renameRacingTheUser(threadId, false);
assert.deepEqual(outcome, { renamed: true });
assert.equal((yield* projections.getThreadShell(threadId))?.title, "Renamed by the agent");
- }).pipe(Effect.provide(ThreadManagement.layer)),
+ }).pipe(Effect.provide(layerThreadManagement)),
);
});
diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts
index f4aa8bafbe666..af078105b6540 100644
--- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts
+++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts
@@ -1,3 +1,6 @@
+import * as GitHubReplyReader from "../sourceControl/GitHubReplyReader.ts";
+import * as ProjectStore from "../orchestration-v2/ProjectStore.ts";
+import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { describe, expect, it } from "@effect/vitest";
import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv";
@@ -612,7 +615,16 @@ describe("orchestrator MCP toolkit", () => {
).pipe(Layer.provide(layerContinuationProbe));
const layerOrchestration = Layer.merge(
layerOrchestrator,
- ThreadManagementService.layer.pipe(Layer.provide(layerOrchestrator)),
+ ThreadManagementService.layer.pipe(
+ Layer.provide(
+ Layer.mergeAll(
+ Layer.mock(GitHubReplyReader.GitHubReplyReader)({}),
+ Layer.mock(CommandReceiptStore.CommandReceiptStoreV2)({}),
+ Layer.mock(ProjectStore.ProjectStoreV2)({}),
+ ),
+ ),
+ Layer.provide(layerOrchestrator),
+ ),
);
const layerProviderRegistry = ProviderRegistryMock.layer([
makeProviderSnapshot({
@@ -3800,7 +3812,16 @@ describe("orchestrator MCP toolkit", () => {
);
const layerOrchestration = Layer.merge(
layerOrchestrator,
- ThreadManagementService.layer.pipe(Layer.provide(layerOrchestrator)),
+ ThreadManagementService.layer.pipe(
+ Layer.provide(
+ Layer.mergeAll(
+ Layer.mock(GitHubReplyReader.GitHubReplyReader)({}),
+ Layer.mock(CommandReceiptStore.CommandReceiptStoreV2)({}),
+ Layer.mock(ProjectStore.ProjectStoreV2)({}),
+ ),
+ ),
+ Layer.provide(layerOrchestrator),
+ ),
);
const layerProviderRegistry = ProviderRegistryMock.layer([
makeProviderSnapshot({
diff --git a/apps/server/src/mcp/ThreadDiscussionMcpService.test.ts b/apps/server/src/mcp/ThreadDiscussionMcpService.test.ts
new file mode 100644
index 0000000000000..a9adf4a0eebfc
--- /dev/null
+++ b/apps/server/src/mcp/ThreadDiscussionMcpService.test.ts
@@ -0,0 +1,224 @@
+import { assert, it } from "@effect/vitest";
+import {
+ EnvironmentId,
+ ProjectId,
+ ProviderInstanceId,
+ RunId,
+ ThreadId,
+ type OrchestrationV2ThreadShell,
+} from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Ref from "effect/Ref";
+import * as Stream from "effect/Stream";
+import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts";
+import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
+import * as ThreadDiscussionService from "../orchestration-v2/ThreadDiscussionService.ts";
+import { v2PullRequestThread } from "../orchestration-v2/testkit/pullRequestFixtures.ts";
+import type { McpInvocationScope } from "./McpInvocationContext.ts";
+import * as ThreadDiscussionMcpService from "./ThreadDiscussionMcpService.ts";
+import * as McpInvocationContext from "./McpInvocationContext.ts";
+import * as McpToolAccess from "./McpToolAccess.ts";
+import * as DiscussionsHandlers from "./toolkits/discussions/handlers.ts";
+import { DiscussionsToolkit } from "./toolkits/discussions/tools.ts";
+
+const threadId = ThreadId.make("caller");
+const scope: McpInvocationScope = {
+ environmentId: EnvironmentId.make("env"),
+ capabilities: new Set(["pull-requests"]),
+ issuedAt: 1,
+ requestNamespace: "session",
+ thread: {
+ threadId,
+ providerSessionId: "session",
+ providerInstanceId: ProviderInstanceId.make("codex"),
+ },
+ client: undefined,
+};
+const thread = (
+ id: ThreadId,
+ overrides: Partial = {},
+): OrchestrationV2ThreadShell => ({
+ ...v2PullRequestThread({
+ id,
+ projectId: ProjectId.make("project"),
+ title: "Thread",
+ modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" },
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ branch: null,
+ worktreePath: null,
+ pullRequests: [],
+ latestUserMessageAt: null,
+ createdAt: "2026-10-01T12:00:00Z",
+ updatedAt: "2026-10-01T12:00:00Z",
+ archivedAt: null,
+ settledAt: null,
+ settledOverride: null,
+ }),
+ ...overrides,
+});
+
+const harness = Effect.fn("discussionMcpTest.harness")(function* (
+ threads: ReadonlyArray,
+) {
+ const targets = yield* Ref.make>([]);
+ const record = (id: ThreadId) => Ref.update(targets, (ids) => [...ids, id]);
+ const identity = {
+ host: "github.com" as const,
+ repository: "team/repo",
+ number: 1,
+ url: "https://github.com/team/repo/discussions/1",
+ };
+ const service = yield* ThreadDiscussionMcpService.ThreadDiscussionMcpService.pipe(
+ Effect.provide(
+ ThreadDiscussionMcpService.layer.pipe(
+ Layer.provide(
+ Layer.mergeAll(
+ Layer.mock(Orchestrator.OrchestratorV2)({
+ getThreadShell: (id) =>
+ Effect.succeed(threads.find((thread) => thread.id === id) ?? null),
+ }),
+ Layer.mock(ThreadDiscussionService.ThreadDiscussionService)({
+ link: (input) =>
+ record(input.threadId).pipe(Effect.as({ ...identity, alreadyLinked: false })),
+ unlink: (input) =>
+ record(input.threadId).pipe(Effect.as({ ...identity, wasLinked: true })),
+ list: (id) => record(id).pipe(Effect.as({ discussions: [] })),
+ }),
+ ),
+ ),
+ ),
+ ),
+ );
+ const toolkit = yield* DiscussionsToolkit.pipe(
+ Effect.provide(
+ McpToolAccess.HandlersLayer.layer(DiscussionsHandlers.layer).pipe(
+ Layer.provide(
+ Layer.succeed(ThreadDiscussionMcpService.ThreadDiscussionMcpService, service),
+ ),
+ ),
+ ),
+ );
+ const call = (
+ name: Name,
+ input: Parameters>[1],
+ invocation: McpInvocationScope = scope,
+ ) =>
+ toolkit.handle(name, input).pipe(
+ Stream.unwrap,
+ Stream.runCollect,
+ Effect.provideService(McpInvocationContext.McpInvocationContext, invocation),
+ Effect.provideService(ThreadDiscussionMcpService.ThreadDiscussionMcpService, service),
+ Effect.provide(
+ Layer.mock(ThreadManagement.ThreadManagementService)({
+ getThreadShell: (id) =>
+ Effect.succeed(threads.find((thread) => thread.id === id) ?? null),
+ }),
+ ),
+ );
+ return { service, targets, call };
+});
+
+it.effect("uses the calling thread for link, list and unlink", () =>
+ Effect.gen(function* () {
+ const { service, targets } = yield* harness([thread(threadId)]);
+ yield* service.link(scope, { repository: "team/repo", number: 1 });
+ yield* service.list(scope, {});
+ yield* service.unlink(scope, { repository: "team/repo", number: 1 });
+ assert.deepEqual(yield* Ref.get(targets), [threadId, threadId, threadId]);
+ }),
+);
+
+it.effect("requires the PR capability and an explicit target for external clients", () =>
+ Effect.gen(function* () {
+ const { service, targets } = yield* harness([thread(threadId)]);
+ assert.equal(
+ (yield* service.list({ ...scope, capabilities: new Set() }, {}).pipe(Effect.flip))._tag,
+ "McpCapabilityUnavailableError",
+ );
+ assert.include(
+ (yield* service.list({ ...scope, thread: undefined }, {}).pipe(Effect.flip)).message,
+ "Pass threadId",
+ );
+ assert.include(
+ (yield* service.list(scope, { threadId: ThreadId.make("missing") }).pipe(Effect.flip))
+ .message,
+ "not found",
+ );
+ assert.deepEqual(yield* Ref.get(targets), []);
+ }),
+);
+
+it.effect(
+ "blocks cross-thread mutations without an owned active run or sufficient permission",
+ () =>
+ Effect.gen(function* () {
+ const other = ThreadId.make("other");
+ const { call, targets } = yield* harness([thread(threadId), thread(other)]);
+ assert.propertyVal(
+ yield* call("link_discussion", {
+ threadId: other,
+ repository: "team/repo",
+ number: 1,
+ }).pipe(Effect.flip),
+ "code",
+ "parent_not_active",
+ );
+ assert.propertyVal(
+ yield* call(
+ "unlink_discussion",
+ { threadId: other, repository: "team/repo", number: 1 },
+ { ...scope, thread: undefined },
+ ).pipe(Effect.flip),
+ "code",
+ "runtime_mode_escalation_denied",
+ );
+ assert.deepEqual(yield* Ref.get(targets), []);
+ const active = yield* harness([
+ thread(threadId, { activeRunId: RunId.make("run"), runtimeMode: "approval-required" }),
+ thread(other),
+ ]);
+ assert.propertyVal(
+ yield* active
+ .call("link_discussion", { threadId: other, repository: "team/repo", number: 1 })
+ .pipe(Effect.flip),
+ "code",
+ "runtime_mode_escalation_denied",
+ );
+ }),
+);
+
+it.effect("allows an owned active caller to link another thread within its permissions", () =>
+ Effect.gen(function* () {
+ const other = ThreadId.make("other");
+ const { call, targets } = yield* harness([
+ thread(threadId, { activeRunId: RunId.make("run") }),
+ thread(other),
+ ]);
+ yield* call("link_discussion", { threadId: other, repository: "team/repo", number: 1 });
+ assert.deepEqual(yield* Ref.get(targets), [other]);
+ }),
+);
+
+it.effect("read-only clients can list links but cannot link or unlink discussions", () =>
+ Effect.gen(function* () {
+ const { call, targets } = yield* harness([thread(threadId)]);
+ const readOnly: McpInvocationScope = {
+ ...scope,
+ thread: undefined,
+ client: { sessionId: "reader", label: "Reader", access: "read-only" },
+ };
+ for (const name of ["link_discussion", "unlink_discussion"] as const) {
+ const error = yield* call(
+ name,
+ { threadId, repository: "team/repo", number: 1 },
+ readOnly,
+ ).pipe(Effect.flip);
+ assert.propertyVal(error, "code", "capability_denied");
+ }
+ assert.deepEqual(yield* Ref.get(targets), []);
+ yield* call("list_thread_discussions", { threadId }, readOnly);
+ assert.deepEqual(yield* Ref.get(targets), [threadId]);
+ }),
+);
diff --git a/apps/server/src/mcp/ThreadDiscussionMcpService.ts b/apps/server/src/mcp/ThreadDiscussionMcpService.ts
new file mode 100644
index 0000000000000..d52a9d030807d
--- /dev/null
+++ b/apps/server/src/mcp/ThreadDiscussionMcpService.ts
@@ -0,0 +1,95 @@
+import { DiscussionOperationError, type ThreadId } from "@t3tools/contracts";
+import * as Context from "effect/Context";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as ThreadDiscussionService from "../orchestration-v2/ThreadDiscussionService.ts";
+import * as McpInvocationContext from "./McpInvocationContext.ts";
+import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
+
+type TargetInput = Omit & {
+ readonly threadId?: ThreadId | undefined;
+};
+
+type Failure =
+ | DiscussionOperationError
+ | import("@t3tools/contracts").McpCapabilityUnavailableError;
+
+export class ThreadDiscussionMcpService extends Context.Service<
+ ThreadDiscussionMcpService,
+ {
+ readonly link: (
+ scope: McpInvocationContext.McpInvocationScope,
+ input: TargetInput,
+ ) => Effect.Effect<
+ Effect.Success<
+ ReturnType
+ >,
+ Failure
+ >;
+ readonly unlink: (
+ scope: McpInvocationContext.McpInvocationScope,
+ input: TargetInput,
+ ) => Effect.Effect<
+ Effect.Success<
+ ReturnType
+ >,
+ Failure
+ >;
+ readonly list: (
+ scope: McpInvocationContext.McpInvocationScope,
+ input: { readonly threadId?: ThreadId | undefined },
+ ) => Effect.Effect<
+ Effect.Success<
+ ReturnType
+ >,
+ Failure
+ >;
+ }
+>()("t3/mcp/ThreadDiscussionMcpService") {}
+
+const make = Effect.gen(function* () {
+ const discussions = yield* ThreadDiscussionService.ThreadDiscussionService;
+ const engine = yield* Orchestrator.OrchestratorV2;
+
+ // MCP tool declarations enforce write permissions; this resolves the discussion target.
+ const requireThread = Effect.fn("ThreadDiscussionMcpService.requireThread")(function* (
+ scope: McpInvocationContext.McpInvocationScope,
+ requested: ThreadId | undefined,
+ ) {
+ yield* McpInvocationContext.requireMcpCapability("pull-requests").pipe(
+ Effect.provideService(McpInvocationContext.McpInvocationContext, scope),
+ );
+ const threadId = requested ?? scope.thread?.threadId;
+ if (!threadId)
+ return yield* new DiscussionOperationError({
+ message: "Pass threadId: this MCP client is not running inside a T3 thread.",
+ });
+ const read = (id: ThreadId) =>
+ engine
+ .getThreadShell(id)
+ .pipe(
+ Effect.mapError(
+ () => new DiscussionOperationError({ message: "Could not read the thread." }),
+ ),
+ );
+ const thread = yield* read(threadId);
+ if (!thread || thread.deletedAt !== null)
+ return yield* new DiscussionOperationError({ message: `Thread ${threadId} was not found.` });
+ return threadId;
+ });
+
+ return ThreadDiscussionMcpService.of({
+ link: (scope, input) =>
+ requireThread(scope, input.threadId).pipe(
+ Effect.flatMap((threadId) => discussions.link({ ...input, threadId })),
+ ),
+ unlink: (scope, input) =>
+ requireThread(scope, input.threadId).pipe(
+ Effect.flatMap((threadId) => discussions.unlink({ ...input, threadId })),
+ ),
+ list: (scope, input) =>
+ requireThread(scope, input.threadId).pipe(Effect.flatMap(discussions.list)),
+ });
+});
+
+export const layer = Layer.effect(ThreadDiscussionMcpService, make);
diff --git a/apps/server/src/mcp/toolkits/discussions/handlers.ts b/apps/server/src/mcp/toolkits/discussions/handlers.ts
new file mode 100644
index 0000000000000..5c591a4dceec1
--- /dev/null
+++ b/apps/server/src/mcp/toolkits/discussions/handlers.ts
@@ -0,0 +1,32 @@
+import * as McpInvocationContext from "../../McpInvocationContext.ts";
+import * as Effect from "effect/Effect";
+import * as ThreadDiscussionMcpService from "../../ThreadDiscussionMcpService.ts";
+import * as McpToolAccess from "../../McpToolAccess.ts";
+import { DiscussionsToolkit } from "./tools.ts";
+
+const make = Effect.gen(function* () {
+ const service = yield* ThreadDiscussionMcpService.ThreadDiscussionMcpService;
+ return {
+ link_discussion: McpToolAccess.writesThreads(
+ (input) => [input.threadId],
+ (input) =>
+ McpInvocationContext.McpInvocationContext.pipe(
+ Effect.flatMap((scope) => service.link(scope, input)),
+ ),
+ ),
+ unlink_discussion: McpToolAccess.writesThreads(
+ (input) => [input.threadId],
+ (input) =>
+ McpInvocationContext.McpInvocationContext.pipe(
+ Effect.flatMap((scope) => service.unlink(scope, input)),
+ ),
+ ),
+ list_thread_discussions: McpToolAccess.reads((input) =>
+ McpInvocationContext.McpInvocationContext.pipe(
+ Effect.flatMap((scope) => service.list(scope, input)),
+ ),
+ ),
+ } satisfies McpToolAccess.Handlers;
+});
+
+export const layer = McpToolAccess.toLayer(DiscussionsToolkit, make);
diff --git a/apps/server/src/mcp/toolkits/discussions/tools.ts b/apps/server/src/mcp/toolkits/discussions/tools.ts
new file mode 100644
index 0000000000000..7632ea46054c5
--- /dev/null
+++ b/apps/server/src/mcp/toolkits/discussions/tools.ts
@@ -0,0 +1,101 @@
+import {
+ DiscussionOperationError,
+ McpCapabilityUnavailableError,
+ OrchestratorMcpFailure,
+ PositiveInt,
+ ThreadDiscussionLink,
+ ThreadId,
+ TrimmedNonEmptyString,
+} from "@t3tools/contracts";
+import * as Schema from "effect/Schema";
+import * as Tool from "effect/ai/Tool";
+import * as Toolkit from "effect/ai/Toolkit";
+import * as McpInvocationContext from "../../McpInvocationContext.ts";
+import * as ThreadDiscussionMcpService from "../../ThreadDiscussionMcpService.ts";
+import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementService.ts";
+
+const dependencies = [
+ McpInvocationContext.McpInvocationContext,
+ ThreadDiscussionMcpService.ThreadDiscussionMcpService,
+ ThreadManagement.ThreadManagementService,
+];
+const target = Schema.Struct({
+ threadId: Schema.optional(
+ ThreadId.annotate({ description: "Thread to act on. Omit for this thread." }),
+ ),
+ url: Schema.optional(
+ TrimmedNonEmptyString.annotate({
+ description:
+ "GitHub discussion URL, for example https://github.com/owner/repo/discussions/123.",
+ }),
+ ),
+ repository: Schema.optional(
+ TrimmedNonEmptyString.annotate({
+ description: "owner/repo, required with number when url is omitted.",
+ }),
+ ),
+ number: Schema.optional(PositiveInt),
+ host: Schema.optional(
+ TrimmedNonEmptyString.annotate({
+ description: "Only github.com is supported. Defaults to github.com.",
+ }),
+ ),
+});
+const identity = {
+ host: Schema.Literal("github.com"),
+ repository: Schema.String,
+ number: PositiveInt,
+};
+const failure = Schema.Union([
+ DiscussionOperationError,
+ McpCapabilityUnavailableError,
+ OrchestratorMcpFailure,
+]);
+
+const LinkDiscussionTool = Tool.make("link_discussion", {
+ description:
+ "Link a GitHub discussion you create or work on to this thread, so the user can open it or choose it when snoozing until a reply. Call after creating or commenting on a discussion. Pass its URL, or repository plus number. Repeated links succeed with alreadyLinked=true. Linking does not start monitoring, wake an agent, or settle the thread.",
+ parameters: target,
+ success: Schema.Struct({ ...identity, url: Schema.String, alreadyLinked: Schema.Boolean }),
+ failure,
+ dependencies,
+})
+ .annotate(Tool.Title, "Link discussion to thread")
+ .annotate(Tool.Readonly, false)
+ .annotate(Tool.Destructive, false)
+ .annotate(Tool.Idempotent, true)
+ .annotate(Tool.OpenWorld, false);
+
+const UnlinkDiscussionTool = Tool.make("unlink_discussion", {
+ description:
+ "Remove a discussion link from this thread. Pass the URL, or repository plus number. An absent link succeeds with wasLinked=false. This does not delete anything on GitHub.",
+ parameters: target,
+ success: Schema.Struct({ ...identity, wasLinked: Schema.Boolean }),
+ failure,
+ dependencies,
+})
+ .annotate(Tool.Title, "Unlink discussion from thread")
+ .annotate(Tool.Readonly, false)
+ .annotate(Tool.Destructive, true)
+ .annotate(Tool.Idempotent, true)
+ .annotate(Tool.OpenWorld, false);
+
+const ListThreadDiscussionsTool = Tool.make("list_thread_discussions", {
+ description:
+ "List saved GitHub discussion links and their known titles. Omit threadId for this thread. Before finishing discussion work, link any discussion you created or commented on that is missing.",
+ parameters: Schema.Struct({ threadId: Schema.optional(ThreadId) }),
+ success: Schema.Struct({ discussions: Schema.Array(ThreadDiscussionLink) }),
+ failure,
+ dependencies,
+})
+ .annotate(Tool.Title, "List thread discussions")
+ .annotate(Tool.Readonly, true)
+ .annotate(Tool.Destructive, false)
+ .annotate(Tool.Idempotent, true)
+ .annotate(Tool.OpenWorld, false);
+
+export const DiscussionsToolkit = Toolkit.make(
+ LinkDiscussionTool,
+ UnlinkDiscussionTool,
+ ListThreadDiscussionsTool,
+);
diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts
index f54065c0824ef..225a59f39a7a9 100644
--- a/apps/server/src/mcp/toolkits/thread/handlers.ts
+++ b/apps/server/src/mcp/toolkits/thread/handlers.ts
@@ -301,6 +301,22 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, {
}
let command: OrchestrationV2Command;
switch (input.action) {
+ case "snooze_until_github_reply":
+ case "snooze_until_changes_requested":
+ if (input.conversationUrl === undefined)
+ return yield* new OrchestratorMcpFailure({
+ code: "invalid_request",
+ message: `${input.action} requires conversationUrl.`,
+ });
+ command = {
+ ...common,
+ type: "thread.github-reply.snooze",
+ url: input.conversationUrl,
+ ...(input.action === "snooze_until_changes_requested"
+ ? { wakeCondition: "changes-requested" as const }
+ : {}),
+ };
+ break;
case "snooze":
if (input.snoozedUntil === undefined) {
return yield* new OrchestratorMcpFailure({
diff --git a/apps/server/src/mcp/toolkits/thread/tools.ts b/apps/server/src/mcp/toolkits/thread/tools.ts
index b5fb7d6be3a68..7894a58b79c75 100644
--- a/apps/server/src/mcp/toolkits/thread/tools.ts
+++ b/apps/server/src/mcp/toolkits/thread/tools.ts
@@ -12,6 +12,7 @@ import {
RuntimeRequestId,
ProviderUserInputAnswers,
IsoDateTime,
+ GitHubConversationUrl,
OrchestratorMcpFailure,
OrchestrationV2DispatchCommandResult,
ThreadId,
@@ -30,13 +31,15 @@ import * as McpInvocationContext from "../../McpInvocationContext.ts";
const ThreadOrganizeTool = Tool.make("t3_thread_organize", {
description:
- "Pin, snooze, settle, archive, or mark a thread unread. Omit threadId for this thread. snooze requires snoozedUntil. Existing thread lifecycle rules apply. Settling this thread takes effect when your turn completes, returning settlesWhenTurnEnds=true; a turn that fails or is interrupted, or a queued message, leaves it active.",
+ "Pin, snooze, settle, archive, or mark a thread unread. Omit threadId for this thread. snooze requires snoozedUntil. snooze_until_github_reply requires conversationUrl, a github.com PR or discussion URL. Use it only when asked to wait for feedback. New comments by other accounts return the thread to the inbox without running an agent. PR issue comments are flat; a discussion comment anchor watches its replies. Open, merged and closed PRs are supported for reply snooze. snooze_until_changes_requested requires conversationUrl for a linked open github.com PR and waits only for a new submitted CHANGES_REQUESTED review by another account, including reviews with no text. It has no deadline and does not run an agent. Existing thread lifecycle rules apply; this does not schedule a future action. Settling this thread takes effect when your turn completes, returning settlesWhenTurnEnds=true; a turn that fails or is interrupted, or a queued message, leaves it active.",
parameters: Schema.Struct({
threadId: Schema.optional(ThreadId),
action: Schema.Literals([
"pin",
"unpin",
"snooze",
+ "snooze_until_github_reply",
+ "snooze_until_changes_requested",
"unsnooze",
"settle",
"unsettle",
@@ -45,6 +48,7 @@ const ThreadOrganizeTool = Tool.make("t3_thread_organize", {
"mark_unread",
]),
snoozedUntil: Schema.optional(IsoDateTime),
+ conversationUrl: Schema.optional(GitHubConversationUrl),
}),
success: Schema.Union([
OrchestrationV2DispatchCommandResult,
diff --git a/apps/server/src/mcp/toolkits/worktree/registration.test.ts b/apps/server/src/mcp/toolkits/worktree/registration.test.ts
index 588d11d9c7152..90adcb29a652e 100644
--- a/apps/server/src/mcp/toolkits/worktree/registration.test.ts
+++ b/apps/server/src/mcp/toolkits/worktree/registration.test.ts
@@ -1,3 +1,4 @@
+import * as ThreadDiscussionService from "../../../orchestration-v2/ThreadDiscussionService.ts";
import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts";
import * as ProjectionStore from "../../../orchestration-v2/ProjectionStore.ts";
import * as DeviceService from "../../../device/DeviceService.ts";
@@ -29,6 +30,7 @@ import * as PreviewBrowser from "../../../preview/PreviewBrowser.ts";
const layerStubServices = Layer.mergeAll(
Layer.mock(Orchestrator.OrchestratorV2)({}),
+ Layer.mock(ThreadDiscussionService.ThreadDiscussionService)({}),
Layer.mock(ProjectionStore.ProjectionStoreV2)({}),
Layer.mock(DeviceService.DeviceService)({}),
Layer.mock(ThreadManagementService.ThreadManagementService)({}),
@@ -127,6 +129,9 @@ it.effect("production mcp layer lists worktree tools over http", () =>
// than replacing them.
expect(toolNames).toContain("preview_status");
expect(toolNames).toContain("delegate_task");
+ expect(toolNames).toContain("link_discussion");
+ expect(toolNames).toContain("list_thread_discussions");
+ expect(toolNames).toContain("unlink_discussion");
// The handoff tool mutates thread state, reaches the network (origin
// fetch), and runs project setup scripts, so its MCP hints must not
diff --git a/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts
index 4f2ea3e2f3d61..44b52daf9ce11 100644
--- a/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts
+++ b/apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts
@@ -1,3 +1,4 @@
+import * as GitHubReplyReader from "../sourceControl/GitHubReplyReader.ts";
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
import * as NodeServices from "@effect/platform-node/NodeServices";
@@ -132,6 +133,7 @@ const layerProviderInstanceRegistry = ProviderInstanceRegistryHydration.layer.pi
);
const layerLive = RuntimeLayer.layer.pipe(
+ Layer.provide(Layer.mock(GitHubReplyReader.GitHubReplyReader)({})),
Layer.provide(McpSessionRegistryTestkit.layer),
Layer.provide(SqlitePersistence.layerMemory),
Layer.provide(layerCheckpointStore),
diff --git a/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts
index fe4340474bcb8..8fcaca0ae7b94 100644
--- a/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts
+++ b/apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts
@@ -1,3 +1,6 @@
+import * as GitHubReplyReader from "../sourceControl/GitHubReplyReader.ts";
+import * as ProjectStore from "./ProjectStore.ts";
+import * as CommandReceiptStore from "./CommandReceiptStore.ts";
import type { SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
import * as NodeServices from "@effect/platform-node/NodeServices";
@@ -310,7 +313,17 @@ it.effect.each(["child completion", "scheduled message", "user steering"] as con
}).pipe(
Effect.provide(
ScheduledTaskService.layer.pipe(
- Layer.provide(ThreadManagementService.layer),
+ Layer.provide(
+ ThreadManagementService.layer.pipe(
+ Layer.provide(
+ Layer.mergeAll(
+ Layer.mock(GitHubReplyReader.GitHubReplyReader)({}),
+ Layer.mock(CommandReceiptStore.CommandReceiptStoreV2)({}),
+ Layer.mock(ProjectStore.ProjectStoreV2)({}),
+ ),
+ ),
+ ),
+ ),
Layer.provide(
Layer.mock(LegacyV1ThreadImporter.LegacyV1ThreadImporter)({
ensureTranscript: () =>
diff --git a/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts
index 1c74ee18d7a92..2e90dd8b7962b 100644
--- a/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts
+++ b/apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts
@@ -1,3 +1,4 @@
+import * as GitHubReplyReader from "../sourceControl/GitHubReplyReader.ts";
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
import * as NodeServices from "@effect/platform-node/NodeServices";
@@ -112,6 +113,7 @@ const layerProviderInstanceRegistry = ProviderInstanceRegistryHydration.layer.pi
);
const layerLive = RuntimeLayer.layer.pipe(
+ Layer.provide(Layer.mock(GitHubReplyReader.GitHubReplyReader)({})),
Layer.provide(ProviderTurnStartServiceTestkit.layer),
Layer.provide(McpSessionRegistryTestkit.layer),
Layer.provide(SqlitePersistence.layerMemory),
diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts
index ed2ae984501e6..48239658af403 100644
--- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts
+++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts
@@ -1,3 +1,4 @@
+import * as GitHubReplyReader from "../sourceControl/GitHubReplyReader.ts";
import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
@@ -102,7 +103,10 @@ const layerTestProviderInstanceRegistry = Layer.succeed(
},
);
-const layerTest = Layer.mergeAll(RuntimeLayer.layer, RuntimeLayer.layerEventSink).pipe(
+const layerTest = Layer.mergeAll(
+ RuntimeLayer.layer.pipe(Layer.provide(Layer.mock(GitHubReplyReader.GitHubReplyReader)({}))),
+ RuntimeLayer.layerEventSink,
+).pipe(
Layer.provideMerge(RuntimeLayer.layerProjectService),
Layer.provide(
Layer.mock(WorkspacePaths.WorkspacePaths)({
diff --git a/apps/server/src/orchestration-v2/GitHubReplySnoozeWorker.ts b/apps/server/src/orchestration-v2/GitHubReplySnoozeWorker.ts
new file mode 100644
index 0000000000000..84601a580e6f5
--- /dev/null
+++ b/apps/server/src/orchestration-v2/GitHubReplySnoozeWorker.ts
@@ -0,0 +1,131 @@
+import {
+ CommandId,
+ type GitHubReplySnooze,
+ type OrchestrationV2AppThread,
+ type GitHubReplyNotice,
+} from "@t3tools/contracts";
+import * as Context from "effect/Context";
+import * as DateTime from "effect/DateTime";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Scheduler from "../scheduling/Scheduler.ts";
+import * as GitHubReplyReader from "../sourceControl/GitHubReplyReader.ts";
+import * as ProjectionStore from "./ProjectionStore.ts";
+import * as ProjectStore from "./ProjectStore.ts";
+import * as ThreadManagement from "./ThreadManagementService.ts";
+
+export class GitHubReplySnoozeWorker extends Context.Service<
+ GitHubReplySnoozeWorker,
+ {
+ readonly sweep: Effect.Effect;
+ }
+>()("t3/orchestration-v2/GitHubReplySnoozeWorker") {}
+
+const make = Effect.gen(function* () {
+ const projections = yield* ProjectionStore.ProjectionStoreV2;
+ const projects = yield* ProjectStore.ProjectStoreV2;
+ const threads = yield* ThreadManagement.ThreadManagementService;
+ const reader = yield* GitHubReplyReader.GitHubReplyReader;
+ const sweep = Effect.gen(function* () {
+ const now = yield* DateTime.now;
+ const candidates = yield* projections.getGitHubReplySnoozes();
+ yield* Effect.forEach(
+ candidates,
+ Effect.fnUntraced(
+ function* (thread: OrchestrationV2AppThread) {
+ const watch = thread.githubReplySnooze;
+ if (!watch || Date.parse(watch.nextCheckAt) > DateTime.toEpochMillis(now)) return;
+ const project = yield* projects.get(thread.projectId);
+ const result = yield* (
+ Option.isNone(project)
+ ? Effect.fail(new GitHubReplyReader.GitHubReplyReadError({ reason: "unavailable" }))
+ : reader.read({ cwd: project.value.workspaceRoot, watch })
+ ).pipe(Effect.result);
+ let next: GitHubReplySnooze | null;
+ let notice: GitHubReplyNotice | undefined;
+ if (result._tag === "Success") {
+ next = {
+ ...watch,
+ viewer: result.success.viewer,
+ ...(result.success.discussionCommentId === undefined
+ ? {}
+ : { discussionCommentId: result.success.discussionCommentId }),
+ status: "watching",
+ failures: 0,
+ nextCheckAt: DateTime.formatIso(DateTime.add(now, { minutes: 2 })),
+ };
+ if (result.success.reply) {
+ next = null;
+ notice = {
+ type: "reply",
+ receivedAt: DateTime.formatIso(now),
+ ...result.success.reply,
+ };
+ }
+ } else if (result.failure.reason === "rate-limited") {
+ next = {
+ ...watch,
+ status: "rate-limited",
+ nextCheckAt: DateTime.formatIso(
+ DateTime.makeUnsafe(
+ Math.max(result.failure.retryAt ?? 0, DateTime.toEpochMillis(now) + 120_000),
+ ),
+ ),
+ };
+ } else {
+ const failures = watch.failures + 1;
+ if (failures >= 3 || result.failure.reason !== "unavailable") {
+ next = null;
+ notice = {
+ type: "error",
+ receivedAt: DateTime.formatIso(now),
+ url: watch.url,
+ text: result.failure.message,
+ };
+ } else {
+ next = {
+ ...watch,
+ failures,
+ status: "retrying",
+ nextCheckAt: DateTime.formatIso(DateTime.add(now, { minutes: 2 ** failures })),
+ };
+ }
+ }
+ // The orchestrator compares requestId under its thread lock. Late results cannot wake a replacement snooze.
+ yield* threads.dispatch({
+ type: "thread.github-reply.sync",
+ threadId: thread.id,
+ commandId: CommandId.make(`github-reply:${watch.requestId}:${watch.nextCheckAt}`),
+ requestId: watch.requestId,
+ watch: next,
+ ...(notice === undefined ? {} : { notice }),
+ });
+ },
+ Effect.catchCause((cause) =>
+ Effect.logWarning("github-reply-snooze.check-failed", { cause }),
+ ),
+ ),
+ { concurrency: 2, discard: true },
+ );
+ });
+ return GitHubReplySnoozeWorker.of({ sweep });
+});
+
+export const layer = Layer.effect(GitHubReplySnoozeWorker, make);
+export const layerScheduled = Layer.effectDiscard(
+ Effect.gen(function* () {
+ const worker = yield* GitHubReplySnoozeWorker;
+ const scheduler = yield* Scheduler.Scheduler;
+ let nextSweepAt = 0;
+ yield* scheduler.register(
+ "github-reply-snooze",
+ Effect.gen(function* () {
+ const now = DateTime.toEpochMillis(yield* DateTime.now);
+ if (now < nextSweepAt) return;
+ nextSweepAt = now + 30_000;
+ yield* worker.sweep;
+ }),
+ );
+ }),
+);
diff --git a/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts
index 15d29d30f8acd..fea620a9e7f87 100644
--- a/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts
+++ b/apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts
@@ -1,3 +1,4 @@
+import * as GitHubReplyReader from "../sourceControl/GitHubReplyReader.ts";
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
import * as NodeServices from "@effect/platform-node/NodeServices";
@@ -111,6 +112,7 @@ const layerProviderInstanceRegistry = ProviderInstanceRegistryHydration.layer.pi
);
const layerLive = RuntimeLayer.layer.pipe(
+ Layer.provide(Layer.mock(GitHubReplyReader.GitHubReplyReader)({})),
Layer.provide(ProviderTurnStartServiceTestkit.layer),
Layer.provide(McpSessionRegistryTestkit.layer),
Layer.provide(SqlitePersistence.layerMemory),
diff --git a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts
index 55d93ded6e6e7..3f07886278340 100644
--- a/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts
+++ b/apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts
@@ -17,6 +17,7 @@
* stand-in one) and a turn cut off by a killed server. Each step waits up to
* `OPENCODE2_STEP_WAIT` seconds (120 by default).
*/
+import * as GitHubReplyReader from "../sourceControl/GitHubReplyReader.ts";
import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
import * as NodeServices from "@effect/platform-node/NodeServices";
@@ -204,6 +205,7 @@ const layerProviderInstanceRegistry = ProviderInstanceRegistryHydration.layer.pi
),
);
const layerOrchestration = RuntimeLayer.layer.pipe(
+ Layer.provide(Layer.mock(GitHubReplyReader.GitHubReplyReader)({})),
Layer.provide(ProviderTurnStartServiceTestkit.layer),
Layer.provide(layerMcpRegistry),
Layer.provide(SqlitePersistence.layerMemory),
diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts
index c85f11c568350..cb74fa7e484cc 100644
--- a/apps/server/src/orchestration-v2/Orchestrator.ts
+++ b/apps/server/src/orchestration-v2/Orchestrator.ts
@@ -1,3 +1,4 @@
+import { parseGitHubConversationUrl } from "@t3tools/contracts";
import type {
OrchestrationV2SearchThreadInput,
OrchestrationV2SearchThreadResult,
@@ -11,6 +12,7 @@ import {
} from "@t3tools/shared/orchestrationV2ThreadError";
import { threadPullRequestsOf } from "@t3tools/shared/threadPullRequests";
import {
+ isLinkedOpenGitHubPullRequest,
normalizeThreadPullRequestKey,
visibleThreadPullRequests,
threadPullRequestKeysEqual,
@@ -89,6 +91,7 @@ import { notificationTurnItem } from "@t3tools/provider-core/server/notification
import { isRestartNoteSource } from "./RestartBackgroundNote.ts";
import { isUndeliveredMailboxSteer } from "./NotificationMailbox.ts";
import { EventSinkV2 } from "./EventSink.ts";
+import * as EventStore from "./EventStore.ts";
import * as EffectOutbox from "./EffectOutbox.ts";
import type { OrchestrationEffectRequestV2, PendingOrchestrationEffectV2 } from "./EffectOutbox.ts";
import { IdAllocatorV2 } from "@t3tools/provider-core/server/IdAllocator";
@@ -285,6 +288,11 @@ export interface OrchestratorV2Shape {
) => Effect.Effect;
readonly dispatch: (
command: OrchestrationV2ServerCommand,
+ /** Baseline setup uses a server-owned cursor; clients cannot supply this precondition. */
+ preconditions?: {
+ readonly githubReplySnoozeAfterSequence: number;
+ readonly githubReplySnoozeRecoveryRequestId: CommandId | null;
+ },
) => Effect.Effect;
readonly searchThreadStream: (
input: OrchestrationV2SearchThreadInput,
@@ -433,6 +441,8 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId {
case "thread.settle":
case "thread.auto-settle":
case "thread.unsettle":
+ case "thread.github-reply.snooze":
+ case "thread.github-reply.sync":
case "thread.snooze":
case "thread.unsnooze":
case "thread.auto-settle.set":
@@ -443,6 +453,8 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId {
case "thread.visit":
case "thread.mark-unread":
case "thread.metadata.update":
+ case "thread.discussion.link":
+ case "thread.discussion.unlink":
case "thread.pull-request.link":
case "thread.pull-request.unlink":
case "thread.pull-request-link.sync":
@@ -799,6 +811,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
const commandPolicy = yield* CommandPolicyV2;
const contextHandoffService = yield* ContextHandoffServiceV2;
const eventSink = yield* EventSinkV2;
+ const eventStore = yield* EventStore.EventStoreV2;
const commandReceipts = yield* CommandReceiptStoreV2;
const idAllocator = yield* IdAllocatorV2;
const projects = yield* ProjectStore.ProjectStoreV2;
@@ -2335,6 +2348,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
thread.archivedAt !== null ||
thread.settledOverride === "settled" ||
thread.settledAt !== null ||
+ thread.githubReplySnooze != null ||
isProviderNativeSubagentThread(thread);
if (link?.watch?.startedAt !== command.startedAt || (command.wake && inactive)) {
return yield* new OrchestratorDispatchError({
@@ -2373,6 +2387,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
| "thread.unarchive"
| "thread.settle"
| "thread.unsettle"
+ | "thread.github-reply.snooze"
+ | "thread.github-reply.sync"
| "thread.snooze"
| "thread.unsnooze"
| "thread.auto-settle.set"
@@ -2382,6 +2398,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
| "thread.active.reorder"
| "thread.mark-unread"
| "thread.metadata.update"
+ | "thread.discussion.link"
+ | "thread.discussion.unlink"
| "thread.pull-request.link"
| "thread.pull-request.unlink"
| "thread.pull-request-link.sync"
@@ -2429,12 +2447,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
command.watching &&
(thread.settledOverride === "settled" ||
thread.settledAt !== null ||
- thread.archivedAt !== null)
+ thread.archivedAt !== null ||
+ thread.githubReplySnooze != null)
) {
return yield* new OrchestratorDispatchError({
commandId: command.commandId,
commandType: command.type,
- cause: `Thread ${command.threadId} is settled or archived and cannot watch pull requests.`,
+ cause: `Thread ${command.threadId} is settled, archived, or snoozed for a GitHub reply and cannot watch pull requests.`,
});
}
// A subagent or delegated task reports to its parent thread, which owns the pull request.
@@ -2511,6 +2530,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
if (
(command.type === "thread.settle" ||
command.type === "thread.unsettle" ||
+ command.type === "thread.github-reply.snooze" ||
command.type === "thread.snooze" ||
command.type === "thread.unsnooze" ||
command.type === "thread.auto-settle.set" ||
@@ -2712,6 +2732,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
!Number.isFinite(resetMs) ||
thread.archivedAt !== null ||
thread.settledOverride === "settled" ||
+ (command.automaticLimitRecovery === true &&
+ (thread.githubReplySnooze != null ||
+ thread.githubReplyAutoResumeBlockedRunId === command.limitRecovery.runId)) ||
run?.id !== command.limitRecovery.runId ||
failure?.class !== "usage_limit" ||
failure.resetAt !== command.limitRecovery.resetAt ||
@@ -2725,15 +2748,49 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
});
}
}
+ if (
+ command.type === "thread.github-reply.snooze" &&
+ command.resumeFrom &&
+ (command.resumeFrom.url !== command.url ||
+ (command.wakeCondition !== undefined &&
+ command.wakeCondition !== (command.resumeFrom.wakeCondition ?? "any-reply")))
+ ) {
+ return yield* new OrchestratorDispatchError({
+ commandId: command.commandId,
+ commandType: command.type,
+ cause: "The restored GitHub snooze must use the same conversation and wake condition.",
+ });
+ }
+ if (
+ command.type === "thread.github-reply.snooze" &&
+ (command.wakeCondition ?? command.resumeFrom?.wakeCondition) === "changes-requested" &&
+ !isLinkedOpenGitHubPullRequest(thread.pullRequests ?? [], command.url)
+ ) {
+ return yield* new OrchestratorDispatchError({
+ commandId: command.commandId,
+ commandType: command.type,
+ cause: "Changes-requested snooze requires a linked open github.com pull request.",
+ });
+ }
let snoozedUntil: DateTime.Utc | null = null;
- if (command.type === "thread.snooze") {
+ let githubReplyAutoResumeBlockedRunId = thread.githubReplyAutoResumeBlockedRunId ?? null;
+ if (
+ command.type === "thread.github-reply.sync" &&
+ (thread.githubReplySnooze?.requestId !== command.requestId ||
+ thread.archivedAt !== null ||
+ thread.settledOverride === "settled")
+ )
+ return;
+ if (command.type === "thread.snooze" || command.type === "thread.github-reply.snooze") {
const projection = yield* loadProjectionForCommand(command, ["runs", "runtimeRequests"], {
turnItemTypes: [],
});
- const parsedSnoozedUntil = DateTime.make(command.snoozedUntil);
+ const parsedSnoozedUntil =
+ command.type === "thread.snooze" ? DateTime.make(command.snoozedUntil) : Option.some(now);
if (
- Option.isNone(parsedSnoozedUntil) ||
- DateTime.toEpochMillis(parsedSnoozedUntil.value) <= DateTime.toEpochMillis(now)
+ command.type === "thread.snooze" &&
+ (Option.isNone(parsedSnoozedUntil) ||
+ DateTime.toEpochMillis(parsedSnoozedUntil.value) <= DateTime.toEpochMillis(now))
) {
return yield* new OrchestratorDispatchError({
commandId: command.commandId,
@@ -2755,7 +2812,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
cause: `Thread ${command.threadId} has a queued run and cannot be snoozed.`,
});
}
- snoozedUntil = parsedSnoozedUntil.value;
+ snoozedUntil = Option.getOrNull(parsedSnoozedUntil);
+ if (command.type === "thread.github-reply.snooze") {
+ // Keep this run suppressed after wake until an explicit send or recovery request.
+ githubReplyAutoResumeBlockedRunId = latestExecutedRun(projection.runs)?.id ?? null;
+ }
}
let markUnreadVisitedAt: DateTime.Utc | null = null;
if (command.type === "thread.mark-unread") {
@@ -2770,6 +2831,22 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
}
markUnreadVisitedAt = DateTime.subtract(latestRunCompletedAt, { milliseconds: 1 });
}
+ if (command.type === "thread.discussion.link") {
+ const target = command.discussion;
+ const parsed = parseGitHubConversationUrl(target.url);
+ if (
+ parsed?.kind !== "discussion" ||
+ parsed.number !== target.number ||
+ `${parsed.owner}/${parsed.repository}`.toLowerCase() !== target.repository.toLowerCase()
+ ) {
+ return yield* new OrchestratorDispatchError({
+ commandId: command.commandId,
+ commandType: command.type,
+ cause: "Discussion URL does not match its repository and number.",
+ });
+ }
+ }
+
const updatedThread: OrchestrationV2AppThread = (() => {
switch (command.type) {
case "thread.archive":
@@ -2777,6 +2854,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
return {
...thread,
archivedAt: now,
+ githubReplySnooze: null,
+ githubReplyNotice: null,
+ snoozedAt: thread.githubReplySnooze ? null : thread.snoozedAt,
titleRegeneration: null,
pullRequests: thread.pullRequests?.map((link) => withPullRequestWatch(link, undefined)),
updatedAt: now,
@@ -2792,6 +2872,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
return {
...thread,
settledOverride: "settled",
+ githubReplySnooze: null,
+ snoozedAt: thread.githubReplySnooze ? null : thread.snoozedAt,
settledAt: alreadySettled ? thread.settledAt : (command.settledAt ?? now),
pullRequests: thread.pullRequests?.map((link) => withPullRequestWatch(link, undefined)),
unsettledAt: null,
@@ -2811,6 +2893,50 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
updatedAt: alreadyPinnedActive ? thread.updatedAt : now,
};
}
+ case "thread.github-reply.snooze":
+ return {
+ ...thread,
+ githubReplySnooze: {
+ ...command.resumeFrom,
+ requestId: command.commandId,
+ url: command.url,
+ wakeCondition: command.wakeCondition ?? command.resumeFrom?.wakeCondition,
+ startedAt: command.resumeFrom?.startedAt ?? DateTime.formatIso(now),
+ nextCheckAt: DateTime.formatIso(now),
+ status: "pending",
+ failures: 0,
+ },
+ githubReplyNotice: null,
+ snoozedUntil: null,
+ snoozedAt: now,
+ settledOverride: "active",
+ settledAt: null,
+ limitRecovery: null,
+ githubReplyAutoResumeBlockedRunId,
+ pullRequests: thread.pullRequests?.map((link) => withPullRequestWatch(link, undefined)),
+ updatedAt: now,
+ };
+ case "thread.github-reply.sync":
+ return {
+ ...thread,
+ githubReplySnooze: command.watch,
+ ...(command.notice === undefined
+ ? {}
+ : {
+ githubReplyNotice: {
+ ...command.notice,
+ receivedAt: DateTime.formatIso(now),
+ wakeCondition: thread.githubReplySnooze?.wakeCondition,
+ conversationUrl:
+ thread.githubReplySnooze?.url ?? command.notice.conversationUrl,
+ },
+ snoozedAt: null,
+ settledOverride: "active" as const,
+ settledAt: null,
+ unsettledAt: now,
+ updatedAt: now,
+ }),
+ };
case "thread.snooze": {
const sameWakeTime =
thread.snoozedUntil != null &&
@@ -2820,16 +2946,23 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
return {
...thread,
snoozedUntil,
+ githubReplySnooze: null,
+ githubReplyNotice: null,
limitRecovery: thread.limitRecovery ? { ...thread.limitRecovery, snooze: false } : null,
snoozedAt: existingSnoozedAt ?? now,
updatedAt: existingSnoozedAt === null ? now : thread.updatedAt,
};
}
case "thread.unsnooze": {
- const alreadyAwake = thread.snoozedUntil == null;
+ const alreadyAwake =
+ thread.snoozedUntil == null &&
+ thread.githubReplySnooze == null &&
+ thread.githubReplyNotice == null;
return {
...thread,
snoozedUntil: null,
+ githubReplySnooze: null,
+ githubReplyNotice: null,
snoozedAt: null,
updatedAt: alreadyAwake ? thread.updatedAt : now,
};
@@ -2861,6 +2994,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
thread.settledOverride === "settled" ? "active" : thread.settledOverride,
settledAt: thread.settledOverride === "settled" ? null : thread.settledAt,
snoozedUntil: null,
+ githubReplySnooze: null,
snoozedAt: null,
updatedAt: alreadyPinned && !promotes ? thread.updatedAt : now,
};
@@ -2915,15 +3049,26 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
snooze: command.limitRecovery.snooze ?? previousRecovery?.snooze ?? false,
requestId: command.commandId,
};
+ const explicitlyResuming =
+ command.automaticLimitRecovery !== true && command.limitRecovery?.autoResume === true;
return {
...thread,
...(command.title === undefined ? {} : { title: command.title }),
...(command.limitRecovery === undefined ? {} : { limitRecovery }),
+ // Explicit recovery replaces a passive GitHub wait; automatic re-arms stay blocked.
+ ...(explicitlyResuming
+ ? {
+ githubReplyAutoResumeBlockedRunId: null,
+ githubReplySnooze: null,
+ ...(thread.githubReplySnooze != null ? { snoozedAt: null } : {}),
+ }
+ : {}),
...(command.limitRecovery !== undefined &&
limitRecovery?.snooze === true &&
Date.parse(limitRecovery.resetAt) > DateTime.toEpochMillis(now)
? {
snoozedUntil: DateTime.makeUnsafe(limitRecovery.resetAt),
+ githubReplySnooze: null,
// Recovery changes acknowledge the same stopped run; keep its
// metadata timestamp from appearing as a fresh failure wake.
snoozedAt: now,
@@ -2974,6 +3119,37 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
updatedAt: now,
};
}
+ case "thread.discussion.link":
+ case "thread.discussion.unlink": {
+ const target = command.discussion;
+ const links = thread.discussions ?? [];
+ const matches = (link: typeof target) =>
+ link.host === target.host &&
+ link.repository.toLowerCase() === target.repository.toLowerCase() &&
+ link.number === target.number;
+ const exists = links.some(matches);
+ if (command.type === "thread.discussion.unlink") {
+ return exists
+ ? { ...thread, discussions: links.filter((link) => !matches(link)), updatedAt: now }
+ : thread;
+ }
+ if (exists) return thread;
+ return {
+ ...thread,
+ discussions: [
+ ...links,
+ {
+ ...target,
+ repository: target.repository.toLowerCase(),
+ url: `https://github.com/${target.repository.toLowerCase()}/discussions/${target.number}`,
+ title: command.title ?? null,
+ source: command.source,
+ linkedAt: DateTime.formatIso(now),
+ },
+ ],
+ updatedAt: now,
+ };
+ }
case "thread.pull-request.link":
case "thread.pull-request.unlink":
case "thread.pull-request-link.sync": {
@@ -3189,8 +3365,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
return "thread.settled" as const;
case "thread.unsettle":
return "thread.unsettled" as const;
+ case "thread.github-reply.snooze":
case "thread.snooze":
return "thread.snoozed" as const;
+ case "thread.github-reply.sync":
+ return command.notice
+ ? ("thread.unsnoozed" as const)
+ : ("thread.metadata-updated" as const);
case "thread.unsnooze":
return "thread.unsnoozed" as const;
case "thread.auto-settle.set":
@@ -3206,6 +3387,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
case "thread.mark-unread":
return "thread.marked-unread" as const;
case "thread.metadata.update":
+ case "thread.discussion.link":
+ case "thread.discussion.unlink":
case "thread.title.regeneration.complete":
return "thread.metadata-updated" as const;
case "thread.pull-request.link":
@@ -4502,6 +4685,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
projection.thread.archivedAt !== null ||
projection.thread.deletedAt !== null ||
projection.thread.settledOverride === "settled" ||
+ projection.thread.githubReplySnooze != null ||
+ projection.thread.githubReplyAutoResumeBlockedRunId === run?.id ||
projection.thread.providerInstanceId !== run.providerInstanceId ||
projection.runtimeRequests.some((request) => request.status === "pending") ||
(projection.thread.snoozedUntil != null &&
@@ -4537,6 +4722,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
isRestartNoteSource(source, projection.providerTurns) ||
projection.thread.archivedAt !== null ||
projection.thread.deletedAt !== null ||
+ projection.thread.githubReplySnooze != null ||
+ projection.thread.githubReplyAutoResumeBlockedRunId === source.id ||
projection.thread.providerInstanceId !== source.providerInstanceId ||
// Held queued runs never started; they wait behind the continuation.
projection.runs.some(
@@ -4583,11 +4770,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
});
projection = yield* getProjectionWithPendingEvents(command.threadId, events);
}
- if (projection.thread.snoozedUntil != null) {
+ const clearReplyResumeBlock =
+ command.createdBy === "user" && projection.thread.githubReplyAutoResumeBlockedRunId != null;
+ if (
+ projection.thread.snoozedUntil != null ||
+ projection.thread.githubReplySnooze != null ||
+ clearReplyResumeBlock
+ ) {
const now = yield* DateTime.now;
const thread: OrchestrationV2AppThread = {
...projection.thread,
snoozedUntil: null,
+ githubReplySnooze: null,
+ ...(clearReplyResumeBlock ? { githubReplyAutoResumeBlockedRunId: null } : {}),
snoozedAt: null,
updatedAt: now,
};
@@ -10122,6 +10317,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
const dispatchOnce = Effect.fn("orchestrationV2.dispatch.once")(function* (
command: OrchestrationV2ServerCommand,
+ preconditions?: Parameters[1],
): Effect.fn.Return<
{
readonly events: ReadonlyArray;
@@ -10139,6 +10335,56 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
"orchestration_v2.thread_id": commandThreadId(command),
});
+ if (command.type === "thread.github-reply.snooze" && preconditions !== undefined) {
+ // Lifecycle events catch even a no-op Wake or changes within the same clock tick.
+ // Provider progress and completion can still finish during the baseline fetch.
+ const superseded = yield* eventStore
+ .read({
+ threadId: command.threadId,
+ afterSequence: preconditions.githubReplySnoozeAfterSequence,
+ throughSequence: yield* eventSink
+ .latestSequence({ threadId: command.threadId })
+ .pipe(mapDispatchError(command)),
+ })
+ .pipe(
+ Stream.filter(({ event, commandId: eventCommandId }) => {
+ switch (event.type) {
+ case "thread.snoozed":
+ case "thread.unsnoozed":
+ case "thread.settled":
+ case "thread.unsettled":
+ case "thread.archived":
+ case "thread.unarchived":
+ case "thread.deleted":
+ case "thread.pinned":
+ case "run.created":
+ return true;
+ case "thread.metadata-updated":
+ // Recovery writes carry their command ID; a rename keeps the older ID.
+ return (
+ (event.payload.limitRecovery?.requestId ?? null) !==
+ preconditions.githubReplySnoozeRecoveryRequestId ||
+ (eventCommandId !== null &&
+ event.payload.limitRecovery?.requestId === eventCommandId)
+ );
+ case "message.updated":
+ return event.payload.role === "user";
+ default:
+ return false;
+ }
+ }),
+ Stream.runHead,
+ mapDispatchError(command),
+ );
+ if (Option.isSome(superseded)) {
+ return yield* new OrchestratorDispatchError({
+ commandId: command.commandId,
+ commandType: command.type,
+ cause: "The thread changed while the GitHub reply snooze was being prepared.",
+ });
+ }
+ }
+
const events = yield* Ref.make>([]);
const effects = yield* Ref.make>([]);
let cancelUnsettledEffects:
@@ -10219,6 +10465,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
case "thread.unarchive":
case "thread.settle":
case "thread.unsettle":
+ case "thread.github-reply.snooze":
+ case "thread.github-reply.sync":
case "thread.snooze":
case "thread.unsnooze":
case "thread.auto-settle.set":
@@ -10228,6 +10476,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
case "thread.active.reorder":
case "thread.mark-unread":
case "thread.metadata.update":
+ case "thread.discussion.link":
+ case "thread.discussion.unlink":
case "thread.pull-request.link":
case "thread.pull-request.unlink":
case "thread.pull-request-link.sync":
@@ -10420,6 +10670,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
const dispatchWithReceiptEffect = Effect.fn("orchestrationV2.dispatch.withReceipt")(function* (
command: OrchestrationV2ServerCommand,
+ preconditions?: Parameters[1],
): Effect.fn.Return {
yield* Effect.annotateCurrentSpan({
"orchestration_v2.command_id": command.commandId,
@@ -10493,7 +10744,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
if (shell !== null) yield* refuseAboveDispatchModeLimit(command, threadId, shell);
}
- const plan = yield* dispatchOnce(command).pipe(
+ const plan = yield* dispatchOnce(command, preconditions).pipe(
Effect.flatMap((planned) =>
// A settle that finds the provider already ended everything, or a
// stop that finds nothing running, has nothing to record. That is
@@ -10624,8 +10875,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
} satisfies OrchestratorV2DispatchResult;
});
- const dispatchWithReceipt = (command: OrchestrationV2ServerCommand) =>
- threadDispatch.withLock(commandThreadId(command), dispatchWithReceiptEffect(command));
+ const dispatchWithReceipt: OrchestratorV2Shape["dispatch"] = (command, preconditions) =>
+ threadDispatch.withLock(
+ commandThreadId(command),
+ dispatchWithReceiptEffect(command, preconditions),
+ );
const handleTerminalRun = (stored: OrchestrationV2StoredEvent) =>
Effect.gen(function* () {
@@ -10955,6 +11209,7 @@ export const layer: Layer.Layer<
| ContextHandoffServiceV2
| EffectOutbox.EffectOutboxV2
| EventSinkV2
+ | EventStore.EventStoreV2
| IdAllocatorV2
| ProjectStore.ProjectStoreV2
| ProviderAdapterRegistryV2
diff --git a/apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts b/apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts
index 733f9467f10a9..746f020721c1d 100644
--- a/apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts
+++ b/apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts
@@ -1,3 +1,4 @@
+import * as GitHubReplyReader from "../sourceControl/GitHubReplyReader.ts";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import { ProjectId } from "@t3tools/contracts";
@@ -136,7 +137,10 @@ const layerRuntime = (dbPath: string) => {
),
);
return Layer.mergeAll(
- RuntimeLayer.layer.pipe(Layer.provide(RuntimeLayer.layerProjectService)),
+ RuntimeLayer.layer.pipe(
+ Layer.provide(Layer.mock(GitHubReplyReader.GitHubReplyReader)({})),
+ Layer.provide(RuntimeLayer.layerProjectService),
+ ),
RuntimeLayer.layerProjectService,
).pipe(
Layer.provide(
diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts
index a6a092ff84b7a..db8c3e6b63b9d 100644
--- a/apps/server/src/orchestration-v2/ProjectionStore.ts
+++ b/apps/server/src/orchestration-v2/ProjectionStore.ts
@@ -165,6 +165,8 @@ export type ProjectionLimitRecoveryCandidate = Pick<
| "updatedAt"
| "limitRecovery"
| "snoozedUntil"
+ | "githubReplySnooze"
+ | "githubReplyAutoResumeBlockedRunId"
>;
/** The thread fields pull request sync reads, for a thread with at least one link. */
@@ -194,6 +196,8 @@ export type ProjectionSettlementCandidate = Pick<
| "autoSettleDisabledAt"
| "snoozedUntil"
| "snoozedAt"
+ | "githubReplySnooze"
+ | "githubReplyNotice"
| "latestRunId"
| "latestRunRequestedAt"
| "latestRunStartedAt"
@@ -388,6 +392,10 @@ export interface ProjectionStoreV2Shape {
readonly getThread: (
threadId: ThreadId,
) => Effect.Effect;
+ readonly getGitHubReplySnoozes: () => Effect.Effect<
+ ReadonlyArray,
+ ProjectionStoreV2Error
+ >;
readonly getLimitRecoveryCandidates: (options: {
readonly now: DateTime.Utc;
readonly autoResume: boolean;
@@ -1443,6 +1451,7 @@ export function threadShellFromProjection(
branch: projection.thread.branch,
worktreePath: projection.thread.worktreePath,
pullRequests: threadPullRequestsOf(projection.thread),
+ discussions: projection.thread.discussions ?? [],
...(projection.thread.linkedPullRequest === undefined
? {}
: { linkedPullRequest: projection.thread.linkedPullRequest }),
@@ -1507,6 +1516,8 @@ export function threadShellFromProjection(
unsettledAt: projection.thread.unsettledAt ?? null,
snoozedUntil: projection.thread.snoozedUntil ?? null,
snoozedAt: projection.thread.snoozedAt ?? null,
+ githubReplySnooze: projection.thread.githubReplySnooze ?? null,
+ githubReplyNotice: projection.thread.githubReplyNotice ?? null,
pinnedAt: projection.thread.pinnedAt ?? null,
autoSettleDisabledAt: projection.thread.autoSettleDisabledAt ?? null,
@@ -1514,6 +1525,7 @@ export function threadShellFromProjection(
lastVisitedAt: projection.thread.lastVisitedAt,
titleRegeneration: projection.thread.titleRegeneration ?? null,
limitRecovery: projection.thread.limitRecovery ?? null,
+ githubReplyAutoResumeBlockedRunId: projection.thread.githubReplyAutoResumeBlockedRunId ?? null,
deletedAt: projection.thread.deletedAt,
};
}
@@ -1711,6 +1723,7 @@ function shellFromState(input: {
branch: input.state.thread.branch,
worktreePath: input.state.thread.worktreePath,
pullRequests: threadPullRequestsOf(input.state.thread),
+ discussions: input.state.thread.discussions ?? [],
...(input.state.thread.linkedPullRequest === undefined
? {}
: { linkedPullRequest: input.state.thread.linkedPullRequest }),
@@ -1762,6 +1775,8 @@ function shellFromState(input: {
unsettledAt: input.state.thread.unsettledAt ?? null,
snoozedUntil: input.state.thread.snoozedUntil ?? null,
snoozedAt: input.state.thread.snoozedAt ?? null,
+ githubReplySnooze: input.state.thread.githubReplySnooze ?? null,
+ githubReplyNotice: input.state.thread.githubReplyNotice ?? null,
pinnedAt: input.state.thread.pinnedAt ?? null,
autoSettleDisabledAt: input.state.thread.autoSettleDisabledAt ?? null,
@@ -1769,6 +1784,7 @@ function shellFromState(input: {
lastVisitedAt: input.state.thread.lastVisitedAt,
titleRegeneration: input.state.thread.titleRegeneration ?? null,
limitRecovery: input.state.thread.limitRecovery ?? null,
+ githubReplyAutoResumeBlockedRunId: input.state.thread.githubReplyAutoResumeBlockedRunId ?? null,
deletedAt: input.state.thread.deletedAt,
};
}
@@ -3436,6 +3452,20 @@ export const layer: Layer.Layer =
),
);
+ const getGitHubReplySnoozes = Effect.fn("ProjectionStore.getGitHubReplySnoozes")(
+ function* () {
+ const rows = yield* sql<{ readonly payload_json: string }>`
+ SELECT payload_json FROM orchestration_v2_projection_threads
+ WHERE deleted_at IS NULL
+ AND json_extract(payload_json, '$.archivedAt') IS NULL
+ AND json_extract(payload_json, '$.settledOverride') IS NOT 'settled'
+ AND json_extract(payload_json, '$.githubReplySnooze') IS NOT NULL
+ `;
+ return yield* Effect.forEach(rows, (row) => decodeThreadPayload(row.payload_json));
+ },
+ Effect.mapError((cause) => new ProjectionStoreSetupError({ cause })),
+ );
+
const getLimitRecoveryCandidates = Effect.fn("ProjectionStore.getLimitRecoveryCandidates")(
function* (options: Parameters[0]) {
// Indexed latest-run and root-error lookups avoid reading run histories,
@@ -3537,7 +3567,9 @@ export const layer: Layer.Layer =
settledOverride: thread.settledOverride,
pendingRuntimeRequest: null,
limitRecovery: thread.limitRecovery ?? null,
+ githubReplyAutoResumeBlockedRunId: thread.githubReplyAutoResumeBlockedRunId ?? null,
snoozedUntil: thread.snoozedUntil ?? null,
+ githubReplySnooze: thread.githubReplySnooze ?? null,
});
}
return candidates;
@@ -5494,6 +5526,8 @@ export const layer: Layer.Layer =
autoSettleDisabledAt: thread.autoSettleDisabledAt ?? null,
snoozedUntil: thread.snoozedUntil ?? null,
snoozedAt: thread.snoozedAt ?? null,
+ githubReplySnooze: thread.githubReplySnooze ?? null,
+ githubReplyNotice: thread.githubReplyNotice ?? null,
status,
latestRunId,
latestRunRequestedAt:
@@ -5923,6 +5957,7 @@ export const layer: Layer.Layer =
getRuntimeRequest,
getPlan,
getProviderControlContext,
+ getGitHubReplySnoozes,
getLimitRecoveryCandidates,
getRecoveryThreadIds,
getUnreadableThreadIds,
@@ -6071,6 +6106,20 @@ export const layerMemory: Layer.Layer = Layer.effect(
})),
),
),
+ getGitHubReplySnoozes: () =>
+ Ref.get(replayState).pipe(
+ Effect.map((state) =>
+ [...state.projections.values()]
+ .map(({ thread }) => thread)
+ .filter(
+ (thread) =>
+ thread.deletedAt === null &&
+ thread.archivedAt === null &&
+ thread.settledOverride !== "settled" &&
+ thread.githubReplySnooze != null,
+ ),
+ ),
+ ),
getLimitRecoveryCandidates: (options) =>
Ref.get(replayState).pipe(
Effect.map((state) =>
diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts
index 42b3943e71d0c..b83eaf16ec787 100644
--- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts
+++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts
@@ -211,6 +211,7 @@ it.effect(
ProjectionStore.ProjectionStoreV2,
ProjectionStore.ProjectionStoreV2.of({
apply: () => Effect.void,
+ getGitHubReplySnoozes: () => Effect.die("unused"),
getLimitRecoveryCandidates: () => Effect.die("unused getLimitRecoveryCandidates"),
getShellSnapshot: () => Effect.die("unused getShellSnapshot"),
readShellSnapshot: () => Effect.die("unused readShellSnapshot"),
diff --git a/apps/server/src/orchestration-v2/RestartContinuation.test.ts b/apps/server/src/orchestration-v2/RestartContinuation.test.ts
index dcbf4aa637284..46546fc6a4d44 100644
--- a/apps/server/src/orchestration-v2/RestartContinuation.test.ts
+++ b/apps/server/src/orchestration-v2/RestartContinuation.test.ts
@@ -1,5 +1,6 @@
import { assert, it } from "@effect/vitest";
import {
+ CommandId,
MessageId,
ProjectId,
ProviderDriverKind,
@@ -32,6 +33,14 @@ const driver = ProviderDriverKind.make("codex");
const providerThreadId = ProviderThreadId.make("provider-thread:restart");
const sessionId = ProviderSessionId.make("session:restart");
const attemptId = RunAttemptId.make("attempt:restart");
+const replySnooze = {
+ requestId: CommandId.make("reply-snooze:restart"),
+ url: "https://github.com/team/repo/pull/1",
+ startedAt: "2026-10-01T00:00:00Z",
+ nextCheckAt: "2026-10-01T00:00:00Z",
+ status: "pending" as const,
+ failures: 0,
+};
// "No project" threads belong to the environment's Scratch project.
const scratchProjectId = ProjectId.make("project:scratch");
@@ -139,6 +148,67 @@ it("continues a live turn whose session the adapter never marked running", () =>
assert.isUndefined(restartContinuationRun(withSessionStatus(status)), status);
});
+it.each(["watching", "reply-received", "prepared-continuation"] as const)(
+ "does not select GitHub reply snoozed work for restart: %s",
+ (scenario) => {
+ const base = makeProjection();
+ const prepared = scenario === "prepared-continuation";
+ const projection = {
+ ...base,
+ thread: {
+ ...base.thread,
+ githubReplySnooze: scenario === "watching" ? replySnooze : null,
+ githubReplyAutoResumeBlockedRunId: scenario === "watching" ? null : runId,
+ },
+ runs: prepared
+ ? [
+ {
+ ...base.runs[0]!,
+ id: RunId.make("run:prepared-continuation"),
+ status: "starting" as const,
+ restartContinuationOfRunId: runId,
+ },
+ ]
+ : base.runs,
+ };
+ assert.isUndefined(restartContinuationRun(projection));
+ },
+);
+
+it.effect.each(["watching", "reply-received"] as const)(
+ "does not deliver a pending restart after GitHub reply snooze: %s",
+ (scenario) =>
+ Effect.gen(function* () {
+ const base = makeProjection();
+ const projection = {
+ ...base,
+ thread: {
+ ...base.thread,
+ githubReplySnooze: scenario === "watching" ? replySnooze : null,
+ githubReplyAutoResumeBlockedRunId: scenario === "watching" ? null : runId,
+ },
+ runs: [{ ...base.runs[0]!, status: "cancelled" as const }],
+ };
+ let deliveries = 0;
+ yield* continueRestartedRun({ threadId, sourceRunId: runId }).pipe(
+ Effect.provide(
+ Layer.merge(
+ Layer.mock(ThreadManagementService.ThreadManagementService)({
+ getThreadRecords: () => Effect.succeed(projection),
+ recoverDelegatedTask: () => Effect.void,
+ dispatch: () => {
+ deliveries++;
+ return Effect.succeed({} as never);
+ },
+ }),
+ ServerSettings.layerTest({ continueThreadsAfterServerUpdate: true }),
+ ),
+ ),
+ );
+ assert.equal(deliveries, 0);
+ }),
+);
+
it("recovers an admitted continuation after another crash before provider start", () => {
const projection = makeProjection();
const starting = {
diff --git a/apps/server/src/orchestration-v2/RestartContinuation.ts b/apps/server/src/orchestration-v2/RestartContinuation.ts
index 013e7a07fbc45..14efc98250b4d 100644
--- a/apps/server/src/orchestration-v2/RestartContinuation.ts
+++ b/apps/server/src/orchestration-v2/RestartContinuation.ts
@@ -31,7 +31,12 @@ export function restartContinuationRun(
"thread" | "runs" | "providerThreads" | "providerSessions" | "providerTurns"
>,
): OrchestrationV2Run | undefined {
- if (projection.thread.archivedAt !== null || projection.thread.deletedAt !== null) return;
+ if (
+ projection.thread.archivedAt !== null ||
+ projection.thread.deletedAt !== null ||
+ projection.thread.githubReplySnooze != null
+ )
+ return;
// Queued runs never started; recovery holds them behind the cut run.
const run = projection.runs.reduce(
(latest, candidate) =>
@@ -41,6 +46,12 @@ export function restartContinuationRun(
undefined,
);
if (!run) return;
+ if (
+ projection.thread.githubReplyAutoResumeBlockedRunId === run.id ||
+ (run.restartContinuationOfRunId !== undefined &&
+ projection.thread.githubReplyAutoResumeBlockedRunId === run.restartContinuationOfRunId)
+ )
+ return;
const preparedContinuation =
run.status === "starting" && run.restartContinuationOfRunId !== undefined;
if (run.status !== "running" && !preparedContinuation) return;
@@ -104,7 +115,13 @@ export const continueRestartedRun = Effect.fn("RestartContinuation.continueResta
.continueThreadsAfterServerUpdate
)
return;
- if (projection.thread.archivedAt !== null || projection.thread.deletedAt !== null) return;
+ if (
+ projection.thread.archivedAt !== null ||
+ projection.thread.deletedAt !== null ||
+ projection.thread.githubReplySnooze != null ||
+ projection.thread.githubReplyAutoResumeBlockedRunId === input.sourceRunId
+ )
+ return;
if (projection.messages.some((message) => message.id === messageId)) return;
const source = projection.runs.find((run) => run.id === input.sourceRunId);
diff --git a/apps/server/src/orchestration-v2/ThreadDiscussionService.ts b/apps/server/src/orchestration-v2/ThreadDiscussionService.ts
new file mode 100644
index 0000000000000..3758686f9a8a1
--- /dev/null
+++ b/apps/server/src/orchestration-v2/ThreadDiscussionService.ts
@@ -0,0 +1,183 @@
+import {
+ CommandId,
+ DiscussionOperationError,
+ parseGitHubConversationUrl,
+ type ThreadDiscussionLink,
+ type ThreadId,
+} from "@t3tools/contracts";
+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 Option from "effect/Option";
+import * as Orchestrator from "./Orchestrator.ts";
+import * as ProjectStore from "./ProjectStore.ts";
+import * as GitHubDiscussions from "../sourceControl/GitHubDiscussions.ts";
+
+export interface DiscussionTargetInput {
+ readonly threadId: ThreadId;
+ readonly url?: string | undefined;
+ readonly host?: string | undefined;
+ readonly repository?: string | undefined;
+ readonly number?: number | undefined;
+}
+
+type DiscussionIdentity = Pick;
+
+export class ThreadDiscussionService extends Context.Service<
+ ThreadDiscussionService,
+ {
+ readonly link: (
+ input: DiscussionTargetInput,
+ ) => Effect.Effect<
+ DiscussionIdentity & { readonly alreadyLinked: boolean },
+ DiscussionOperationError
+ >;
+ readonly unlink: (
+ input: DiscussionTargetInput,
+ ) => Effect.Effect<
+ Omit & { readonly wasLinked: boolean },
+ DiscussionOperationError
+ >;
+ readonly list: (
+ threadId: ThreadId,
+ ) => Effect.Effect<
+ { readonly discussions: ReadonlyArray },
+ DiscussionOperationError
+ >;
+ }
+>()("t3/orchestration-v2/ThreadDiscussionService") {}
+
+const resolveTarget = Effect.fn("ThreadDiscussionService.resolveTarget")(function* (
+ input: DiscussionTargetInput,
+) {
+ if (input.host !== undefined && input.host.toLowerCase() !== "github.com")
+ return yield* new DiscussionOperationError({
+ message: "Only github.com discussions are supported.",
+ });
+ if (input.url === undefined && (input.repository === undefined || input.number === undefined))
+ return yield* new DiscussionOperationError({
+ message: "Pass either url, or both repository and number.",
+ });
+ const url = input.url ?? `https://github.com/${input.repository}/discussions/${input.number}`;
+ const parsed = parseGitHubConversationUrl(url);
+ if (parsed?.kind !== "discussion" || !Number.isSafeInteger(parsed.number))
+ return yield* new DiscussionOperationError({
+ message: "Use a github.com repository discussion URL.",
+ });
+ const repository = `${parsed.owner}/${parsed.repository}`.toLowerCase();
+ return {
+ host: "github.com" as const,
+ repository,
+ number: parsed.number,
+ url: `https://github.com/${repository}/discussions/${parsed.number}`,
+ };
+});
+
+const make = Effect.gen(function* () {
+ const engine = yield* Orchestrator.OrchestratorV2;
+ const projects = yield* ProjectStore.ProjectStoreV2;
+ const reader = yield* GitHubDiscussions.GitHubDiscussions;
+ const crypto = yield* Crypto.Crypto;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const failure = () =>
+ new DiscussionOperationError({ message: "Could not update this thread's discussions." });
+
+ const requireThread = Effect.fn("ThreadDiscussionService.requireThread")(function* (
+ threadId: ThreadId,
+ ) {
+ const thread = yield* engine.getThreadShell(threadId).pipe(Effect.mapError(failure));
+ if (!thread || thread.deletedAt !== null)
+ return yield* new DiscussionOperationError({ message: `Thread ${threadId} was not found.` });
+ return thread;
+ });
+
+ const workspace = Effect.fn("ThreadDiscussionService.workspace")(function* (threadId: ThreadId) {
+ const thread = yield* requireThread(threadId);
+ const project = yield* projects.getShell(thread.projectId).pipe(Effect.mapError(failure));
+ if (Option.isNone(project))
+ return yield* new DiscussionOperationError({
+ message: "This thread's project was not found.",
+ });
+ const cwd =
+ thread.worktreePath !== null &&
+ (yield* fileSystem.exists(thread.worktreePath).pipe(Effect.mapError(failure)))
+ ? thread.worktreePath
+ : project.value.workspaceRoot;
+ return cwd;
+ });
+
+ const link = Effect.fn("ThreadDiscussionService.link")(function* (input: DiscussionTargetInput) {
+ const thread = yield* requireThread(input.threadId);
+ const target = yield* resolveTarget(input);
+ if (
+ thread.discussions?.some(
+ (link) =>
+ link.repository.toLowerCase() === target.repository && link.number === target.number,
+ )
+ )
+ return { ...target, alreadyLinked: true };
+ const discussion = yield* reader.summary({ cwd: yield* workspace(thread.id), url: target.url });
+ const current = yield* requireThread(thread.id);
+ if (current.projectId !== thread.projectId || current.worktreePath !== thread.worktreePath)
+ return yield* new DiscussionOperationError({
+ message: "The thread's workspace changed. Link the discussion again.",
+ });
+ const alreadyLinked =
+ current.discussions?.some(
+ (link) =>
+ link.repository.toLowerCase() === target.repository && link.number === target.number,
+ ) ?? false;
+ if (!alreadyLinked) {
+ yield* engine
+ .dispatch({
+ type: "thread.discussion.link",
+ commandId: CommandId.make(
+ `server:discussion-link:${yield* crypto.randomUUIDv4.pipe(Effect.orDie)}`,
+ ),
+ threadId: thread.id,
+ discussion: target,
+ title: discussion.title,
+ source: "agent",
+ })
+ .pipe(Effect.mapError(failure));
+ }
+ return { ...target, alreadyLinked };
+ });
+
+ const unlink = Effect.fn("ThreadDiscussionService.unlink")(function* (
+ input: DiscussionTargetInput,
+ ) {
+ const thread = yield* requireThread(input.threadId);
+ const target = yield* resolveTarget(input);
+ const wasLinked =
+ thread.discussions?.some(
+ (link) =>
+ link.repository.toLowerCase() === target.repository && link.number === target.number,
+ ) ?? false;
+ if (wasLinked)
+ yield* engine
+ .dispatch({
+ type: "thread.discussion.unlink",
+ commandId: CommandId.make(
+ `server:discussion-unlink:${yield* crypto.randomUUIDv4.pipe(Effect.orDie)}`,
+ ),
+ threadId: thread.id,
+ discussion: target,
+ })
+ .pipe(Effect.mapError(failure));
+ return { host: target.host, repository: target.repository, number: target.number, wasLinked };
+ });
+
+ return ThreadDiscussionService.of({
+ link,
+ unlink,
+ list: (threadId) =>
+ requireThread(threadId).pipe(
+ Effect.map((thread) => ({ discussions: thread.discussions ?? [] })),
+ ),
+ });
+});
+
+export const layer = Layer.effect(ThreadDiscussionService, make);
diff --git a/apps/server/src/orchestration-v2/ThreadForkService.ts b/apps/server/src/orchestration-v2/ThreadForkService.ts
index 56c7aa81693de..4a2bef244c1ac 100644
--- a/apps/server/src/orchestration-v2/ThreadForkService.ts
+++ b/apps/server/src/orchestration-v2/ThreadForkService.ts
@@ -108,6 +108,8 @@ export const layer: Layer.Layer = Layer.succeed(
settledOverride: null,
settledAt: null,
snoozedUntil: null,
+ githubReplySnooze: null,
+ githubReplyNotice: null,
snoozedAt: null,
lastVisitedAt: null,
deletedAt: null,
diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts
index bfbd078e00c5d..aa9e7a0c9a33e 100644
--- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts
+++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts
@@ -1,3 +1,4 @@
+import * as GitHubReplyReader from "../sourceControl/GitHubReplyReader.ts";
import * as Scheduler from "../scheduling/Scheduler.ts";
import * as WorktreeSetupTracker from "../project/WorktreeSetupTracker.ts";
import * as ProjectCloneTracker from "../project/ProjectCloneTracker.ts";
@@ -117,7 +118,16 @@ function makeHarness(options: HarnessOptions = {}) {
layerRegistry,
{ databaseLayer: layerDatabase, runEffectWorker: false },
);
- const layerThreadManagement = ThreadManagement.layer.pipe(Layer.provide(layerOrchestrator));
+ const layerThreadManagement = ThreadManagement.layer.pipe(
+ Layer.provide(
+ Layer.mergeAll(
+ Layer.mock(GitHubReplyReader.GitHubReplyReader)({}),
+ Layer.mock(CommandReceiptStore.CommandReceiptStoreV2)({}),
+ Layer.mock(ProjectStore.ProjectStoreV2)({}),
+ ),
+ ),
+ Layer.provide(layerOrchestrator),
+ );
const layerReceipts = CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase));
const layerOutbox = EffectOutbox.layer.pipe(Layer.provide(layerDatabase));
const createWorktree = vi.fn(
diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts
index 94e17bdc567e7..8353123c285d0 100644
--- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts
+++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts
@@ -1,3 +1,6 @@
+import * as GitHubReplyReader from "../sourceControl/GitHubReplyReader.ts";
+import * as ProjectStore from "./ProjectStore.ts";
+import * as CommandReceiptStore from "./CommandReceiptStore.ts";
import { expect, it } from "@effect/vitest";
import {
CommandId,
@@ -269,6 +272,13 @@ it.effect("classifies projection infrastructure failures separately from a missi
cause: infrastructureCause,
});
const layerTest = ThreadManagementService.layer.pipe(
+ Layer.provide(
+ Layer.mergeAll(
+ Layer.mock(GitHubReplyReader.GitHubReplyReader)({}),
+ Layer.mock(CommandReceiptStore.CommandReceiptStoreV2)({}),
+ Layer.mock(ProjectStore.ProjectStoreV2)({}),
+ ),
+ ),
Layer.provide(
Layer.mock(Orchestrator.OrchestratorV2)({
getThreadProjection: () => Effect.fail(projectionError),
@@ -302,6 +312,13 @@ it.effect("uses thread-not-found only after a projection loads outside the proje
},
} as OrchestrationV2ThreadProjection;
const layerTest = ThreadManagementService.layer.pipe(
+ Layer.provide(
+ Layer.mergeAll(
+ Layer.mock(GitHubReplyReader.GitHubReplyReader)({}),
+ Layer.mock(CommandReceiptStore.CommandReceiptStoreV2)({}),
+ Layer.mock(ProjectStore.ProjectStoreV2)({}),
+ ),
+ ),
Layer.provide(
Layer.mock(Orchestrator.OrchestratorV2)({
getThreadProjection: () => Effect.succeed(projection),
@@ -327,6 +344,13 @@ it.effect("preserves failed legacy materialization when reading checkpoint conte
cause: new Error("checkpoint import failed"),
});
const layerTest = ThreadManagementService.layerWithLegacyImporter.pipe(
+ Layer.provide(
+ Layer.mergeAll(
+ Layer.mock(GitHubReplyReader.GitHubReplyReader)({}),
+ Layer.mock(CommandReceiptStore.CommandReceiptStoreV2)({}),
+ Layer.mock(ProjectStore.ProjectStoreV2)({}),
+ ),
+ ),
Layer.provide(
Layer.mergeAll(
Layer.mock(Orchestrator.OrchestratorV2)({
@@ -368,6 +392,13 @@ it.effect.each([
runs: status === "missing" ? [] : [{ id: runId, status }],
}) as unknown as OrchestrationV2ThreadProjection;
const layerTest = ThreadManagementService.layer.pipe(
+ Layer.provide(
+ Layer.mergeAll(
+ Layer.mock(GitHubReplyReader.GitHubReplyReader)({}),
+ Layer.mock(CommandReceiptStore.CommandReceiptStoreV2)({}),
+ Layer.mock(ProjectStore.ProjectStoreV2)({}),
+ ),
+ ),
Layer.provide(
Layer.mock(Orchestrator.OrchestratorV2)({
getThreadEventSequence: () => Effect.succeed(0),
@@ -434,6 +465,13 @@ it.effect("waitForThread reads the run again only when the run updates", () =>
const stored = (sequence: number, event: object) =>
({ sequence, event: { threadId, ...event } }) as unknown as OrchestrationV2StoredEvent;
const layerTest = ThreadManagementService.layer.pipe(
+ Layer.provide(
+ Layer.mergeAll(
+ Layer.mock(GitHubReplyReader.GitHubReplyReader)({}),
+ Layer.mock(CommandReceiptStore.CommandReceiptStoreV2)({}),
+ Layer.mock(ProjectStore.ProjectStoreV2)({}),
+ ),
+ ),
Layer.provide(
Layer.mock(Orchestrator.OrchestratorV2)({
getThreadEventSequence: () => Effect.succeed(0),
@@ -485,6 +523,13 @@ it.effect.each([
const runId = RunId.make("run:thread-management:settle-after-run");
const dispatched: Array = [];
const layerTest = ThreadManagementService.layer.pipe(
+ Layer.provide(
+ Layer.mergeAll(
+ Layer.mock(GitHubReplyReader.GitHubReplyReader)({}),
+ Layer.mock(CommandReceiptStore.CommandReceiptStoreV2)({}),
+ Layer.mock(ProjectStore.ProjectStoreV2)({}),
+ ),
+ ),
Layer.provide(
Layer.mock(Orchestrator.OrchestratorV2)({
getThreadEventSequence: () => Effect.succeed(0),
diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts
index a12e6680ca7e2..f71df3932d5ef 100644
--- a/apps/server/src/orchestration-v2/ThreadManagementService.ts
+++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts
@@ -9,6 +9,7 @@ import type {
} from "./ProjectionStore.ts";
import {
type ChatAttachment,
+ type GitHubReplySnooze,
CommandId,
MessageId,
type ModelSelection,
@@ -29,6 +30,7 @@ import {
ThreadId,
type TurnItemId,
} from "@t3tools/contracts";
+import { isLinkedOpenGitHubPullRequest } from "@t3tools/shared/threadPullRequests";
import * as Context from "effect/Context";
import * as DateTime from "effect/DateTime";
import * as Duration from "effect/Duration";
@@ -39,6 +41,9 @@ import * as Schema from "effect/Schema";
import * as Stream from "effect/Stream";
import * as Orchestrator from "./Orchestrator.ts";
+import * as CommandReceiptStore from "./CommandReceiptStore.ts";
+import * as ProjectStore from "./ProjectStore.ts";
+import * as GitHubReplyReader from "../sourceControl/GitHubReplyReader.ts";
import { projectTurnItemForDetail } from "./WireProjection.ts";
import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts";
@@ -441,6 +446,9 @@ const SETTLE_AFTER_RUN_WAIT_MS = 24 * 60 * 60 * 1_000;
const make = Effect.gen(function* () {
const orchestrator = yield* Orchestrator.OrchestratorV2;
+ const githubReplies = yield* GitHubReplyReader.GitHubReplyReader;
+ const receipts = yield* CommandReceiptStore.CommandReceiptStoreV2;
+ const projects = yield* ProjectStore.ProjectStoreV2;
const layerScope = yield* Effect.scope;
const legacyImporter = yield* LegacyV1ThreadImporter.LegacyV1ThreadImporter;
@@ -509,8 +517,101 @@ const make = Effect.gen(function* () {
Effect.andThen(orchestrator.getThreadSnapshotWindow(threadId, options)),
);
- const dispatch: ThreadManagementServiceShape["dispatch"] = (command) =>
- ensureCommandTranscripts(command).pipe(Effect.andThen(orchestrator.dispatch(command)));
+ const dispatch: ThreadManagementServiceShape["dispatch"] = Effect.fn(
+ "orchestrationV2.threadManagement.dispatch",
+ )(function* (command) {
+ yield* ensureCommandTranscripts(command);
+ if (command.type === "thread.github-reply.snooze" && command.resumeFrom === undefined) {
+ const receipt = yield* receipts.getByCommandId(command.commandId).pipe(
+ Effect.mapError(
+ (cause) =>
+ new Orchestrator.OrchestratorDispatchError({
+ commandId: command.commandId,
+ commandType: command.type,
+ cause,
+ }),
+ ),
+ );
+ // Let the orchestrator validate and replay the receipt even after the watch has woken.
+ if (Option.isSome(receipt)) return yield* orchestrator.dispatch(command);
+ const snapshotSequence = yield* orchestrator.getThreadEventSequence(command.threadId);
+ const thread = yield* orchestrator.getThreadShell(command.threadId);
+ const project =
+ thread === null
+ ? Option.none()
+ : yield* projects.get(thread.projectId).pipe(
+ Effect.mapError(
+ (cause) =>
+ new Orchestrator.OrchestratorDispatchError({
+ commandId: command.commandId,
+ commandType: command.type,
+ cause,
+ }),
+ ),
+ );
+ if (!thread || Option.isNone(project)) {
+ return yield* new Orchestrator.OrchestratorDispatchError({
+ commandId: command.commandId,
+ commandType: command.type,
+ cause: "The thread's project is unavailable.",
+ });
+ }
+ if (
+ command.wakeCondition === "changes-requested" &&
+ !isLinkedOpenGitHubPullRequest(thread.pullRequests ?? [], command.url)
+ ) {
+ return yield* new Orchestrator.OrchestratorDispatchError({
+ commandId: command.commandId,
+ commandType: command.type,
+ cause: "Changes-requested snooze requires a linked open github.com pull request.",
+ });
+ }
+ const now = DateTime.formatIso(yield* DateTime.now);
+ const watch: GitHubReplySnooze = {
+ requestId: command.commandId,
+ url: command.url,
+ wakeCondition: command.wakeCondition,
+ startedAt: now,
+ nextCheckAt: now,
+ status: "pending",
+ failures: 0,
+ };
+ const observed = yield* githubReplies
+ .read({
+ cwd: thread.worktreePath ?? project.value.workspaceRoot,
+ watch,
+ captureBaseline: true,
+ })
+ .pipe(
+ Effect.mapError(
+ (cause) =>
+ new Orchestrator.OrchestratorDispatchError({
+ commandId: command.commandId,
+ commandType: command.type,
+ cause,
+ }),
+ ),
+ );
+ return yield* orchestrator.dispatch(
+ {
+ ...command,
+ resumeFrom: {
+ ...watch,
+ baseline: observed.baseline,
+ viewer: observed.viewer,
+ ...(observed.discussionCommentId === undefined
+ ? {}
+ : { discussionCommentId: observed.discussionCommentId }),
+ },
+ },
+ {
+ githubReplySnoozeAfterSequence: snapshotSequence,
+ githubReplySnoozeRecoveryRequestId: thread.limitRecovery?.requestId ?? null,
+ },
+ );
+ }
+ return yield* orchestrator.dispatch(command);
+ });
const getProjectThread: ThreadManagementServiceShape["getProjectThread"] = (input) =>
getThreadProjection(input.threadId).pipe(
@@ -939,11 +1040,8 @@ const layerLegacyV1ThreadImporterNoop = Layer.succeed(
}),
);
-export const layer: Layer.Layer =
- Layer.effect(ThreadManagementService, make).pipe(Layer.provide(layerLegacyV1ThreadImporterNoop));
+export const layer = Layer.effect(ThreadManagementService, make).pipe(
+ Layer.provide(layerLegacyV1ThreadImporterNoop),
+);
-export const layerWithLegacyImporter: Layer.Layer<
- ThreadManagementService,
- never,
- LegacyV1ThreadImporter.LegacyV1ThreadImporter | Orchestrator.OrchestratorV2
-> = Layer.effect(ThreadManagementService, make);
+export const layerWithLegacyImporter = Layer.effect(ThreadManagementService, make);
diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts
index 4b0e451e0ae66..1b8089e1abe76 100644
--- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts
+++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts
@@ -2,6 +2,8 @@ import { assert, describe, expect, it } from "@effect/vitest";
import * as DateTime from "effect/DateTime";
import {
DEFAULT_SERVER_SETTINGS,
+ CommandId,
+ RuntimeRequestId,
ProjectId,
EventId,
ProviderInstanceId,
@@ -1254,6 +1256,63 @@ describe("ThreadSettlementServiceV2 single-thread sweeps", () => {
});
describe("isSnoozed", () => {
+ const replySnoozed = shell({
+ snoozedAt: at(-2 * DAY_MS),
+ snoozedUntil: null,
+ githubReplySnooze: {
+ requestId: CommandId.make("reply-watch"),
+ url: "https://github.com/team/repo/pull/1",
+ startedAt: DateTime.formatIso(at(-2 * DAY_MS)),
+ nextCheckAt: DateTime.formatIso(at(0)),
+ status: "watching",
+ failures: 0,
+ },
+ });
+ it("keeps a reply watch snoozed through completion and old failures", () => {
+ expect(ThreadSettlementService.isSnoozed(replySnoozed, NOW_MS)).toBe(true);
+ expect(
+ ThreadSettlementService.isSnoozed(
+ {
+ ...replySnoozed,
+ status: "completed",
+ latestRunCompletedAt: at(-DAY_MS),
+ },
+ NOW_MS,
+ ),
+ ).toBe(true);
+ expect(
+ ThreadSettlementService.isSnoozed(
+ {
+ ...replySnoozed,
+ status: "failed",
+ latestRunCompletedAt: at(-3 * DAY_MS),
+ },
+ NOW_MS,
+ ),
+ ).toBe(true);
+ expect(
+ ThreadSettlementService.isSnoozed(
+ {
+ ...replySnoozed,
+ status: "failed",
+ latestRunCompletedAt: at(-DAY_MS),
+ },
+ NOW_MS,
+ ),
+ ).toBe(false);
+ });
+ it.each(["command", "user_input"] as const)("wakes a reply watch for %s", (kind) => {
+ expect(
+ ThreadSettlementService.isSnoozed(
+ {
+ ...replySnoozed,
+ pendingRuntimeRequest: { id: RuntimeRequestId.make("request"), kind, createdAt: at(0) },
+ },
+ NOW_MS,
+ ),
+ ).toBe(false);
+ });
+
const snoozed = { snoozedAt: at(-2 * DAY_MS), snoozedUntil: at(DAY_MS) };
it("wakes for completed work after the snooze, but not an interrupted run", () => {
expect(ThreadSettlementService.isSnoozed(shell(snoozed), NOW_MS)).toBe(true);
diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts
index 5e3f7e7956006..dbd182475f420 100644
--- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts
+++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts
@@ -141,6 +141,7 @@ export function isAutoSettlementCandidate(
nowMs: number,
): boolean {
if (thread.archivedAt !== null || thread.settledOverride !== null) return false;
+ if (thread.githubReplySnooze != null) return false;
if (thread.pinnedAt != null || thread.autoSettleDisabledAt != null) return false;
// Blocked-on-you work must never park behind a settled override.
if (thread.pendingRuntimeRequest !== null) return false;
@@ -164,7 +165,7 @@ export function isAutoSettlementCandidate(
}
/**
- * Whether a thread is parked on its snooze: its wake time is in the future and
+ * Whether a thread is parked on a future wake time or a GitHub reply watch and
* it has not raised its hand with a pending request, a fresh failure, or work
* that completed after the snooze. Server twin of the client's
* `effectiveSnoozed`, so agents and the sidebar agree on what is snoozed. One
@@ -175,12 +176,18 @@ export function isAutoSettlementCandidate(
export function isSnoozed(
thread: Pick<
ProjectionStore.ProjectionSettlementCandidate,
- "snoozedUntil" | "snoozedAt" | "latestRunCompletedAt" | "status" | "pendingRuntimeRequest"
+ | "snoozedUntil"
+ | "snoozedAt"
+ | "githubReplySnooze"
+ | "latestRunCompletedAt"
+ | "status"
+ | "pendingRuntimeRequest"
>,
nowMs: number,
): boolean {
const snoozedUntilMs = toMillis(thread.snoozedUntil);
- if (snoozedUntilMs === null || snoozedUntilMs <= nowMs) return false;
+ if (thread.githubReplySnooze == null && (snoozedUntilMs === null || snoozedUntilMs <= nowMs))
+ return false;
if (thread.pendingRuntimeRequest !== null) return false;
const snoozedAtMs = toMillis(thread.snoozedAt);
const completedAtMs = toMillis(thread.latestRunCompletedAt);
@@ -189,6 +196,7 @@ export function isSnoozed(
(snoozedAtMs === null || (completedAtMs !== null && completedAtMs > snoozedAtMs));
// Like the client, only a run that completed wakes it; an interrupt or cancel does not.
const wokeOnCompletion =
+ thread.githubReplySnooze == null &&
thread.status === "completed" &&
snoozedAtMs !== null &&
completedAtMs !== null &&
diff --git a/apps/server/src/orchestration-v2/ThreadStop.test.ts b/apps/server/src/orchestration-v2/ThreadStop.test.ts
index 43a77a50e75c0..41ac4c3672fd0 100644
--- a/apps/server/src/orchestration-v2/ThreadStop.test.ts
+++ b/apps/server/src/orchestration-v2/ThreadStop.test.ts
@@ -1,3 +1,6 @@
+import * as GitHubReplyReader from "../sourceControl/GitHubReplyReader.ts";
+import * as ProjectStore from "./ProjectStore.ts";
+import * as CommandReceiptStore from "./CommandReceiptStore.ts";
import { assert, it } from "@effect/vitest";
import {
CommandId,
@@ -41,6 +44,13 @@ const adapter = {
const layerDatabase = SqlitePersistence.layerMemory;
// No effect worker: runs stay unstarted, so Stop ends them without a provider.
const layerTest = ThreadManagementService.layer.pipe(
+ Layer.provide(
+ Layer.mergeAll(
+ Layer.mock(GitHubReplyReader.GitHubReplyReader)({}),
+ Layer.mock(CommandReceiptStore.CommandReceiptStoreV2)({}),
+ Layer.mock(ProjectStore.ProjectStoreV2)({}),
+ ),
+ ),
Layer.provideMerge(
Layer.mergeAll(
layerDatabase,
diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts
index 392dc3fdaee75..1ba9bbcbec997 100644
--- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts
+++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts
@@ -1,3 +1,5 @@
+import * as GitHubReplyReader from "../sourceControl/GitHubReplyReader.ts";
+import * as CommandReceiptStore from "./CommandReceiptStore.ts";
import { assert, describe, it, vi } from "@effect/vitest";
import {
type ChatAttachment,
@@ -54,7 +56,16 @@ function makeHarness(
layerRegistry,
{ databaseLayer: layerDatabase, runEffectWorker: false },
);
- const layerThreadManagement = ThreadManagement.layer.pipe(Layer.provide(layerOrchestrator));
+ const layerThreadManagement = ThreadManagement.layer.pipe(
+ Layer.provide(
+ Layer.mergeAll(
+ Layer.mock(GitHubReplyReader.GitHubReplyReader)({}),
+ Layer.mock(CommandReceiptStore.CommandReceiptStoreV2)({}),
+ Layer.mock(ProjectStore.ProjectStoreV2)({}),
+ ),
+ ),
+ Layer.provide(layerOrchestrator),
+ );
const layerOutbox = EffectOutbox.layer.pipe(Layer.provide(layerDatabase));
const generateThreadTitle = vi.fn(
options.generateTitle ?? (() => Effect.succeed({ title: "Generated title" })),
diff --git a/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts b/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts
index a7fe7c2902ec5..739be12c9cc11 100644
--- a/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts
+++ b/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts
@@ -15,6 +15,8 @@ export function limitRecoveryCommand(
snooze = false,
): OrchestrationV2Command | null {
if (
+ thread.githubReplySnooze != null ||
+ thread.githubReplyAutoResumeBlockedRunId === thread.latestRunId ||
thread.status !== "failed" ||
thread.lastErrorClass !== "usage_limit" ||
!thread.latestRunId ||
@@ -39,6 +41,7 @@ export function limitRecoveryCommand(
type: "thread.metadata.update",
commandId: CommandId.make(`limit-arm:${identity}`),
threadId: thread.id,
+ automaticLimitRecovery: true,
limitRecovery: {
runId: thread.latestRunId,
resetAt: thread.usageLimitResetAt,
diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts
index aed7f73e6c0f7..a9fbd459b6317 100644
--- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts
+++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts
@@ -1,3 +1,8 @@
+import * as ThreadDiscussionService from "./ThreadDiscussionService.ts";
+import * as GitHubDiscussions from "../sourceControl/GitHubDiscussions.ts";
+import * as CommandReceiptStore from "./CommandReceiptStore.ts";
+import * as GitHubReplySnoozeWorker from "./GitHubReplySnoozeWorker.ts";
+import * as GitHubReplyReader from "../sourceControl/GitHubReplyReader.ts";
import { limitRecoveryCommand } from "./UsageLimitRecoveryWorker.ts";
import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
import * as NodeServices from "@effect/platform-node/NodeServices";
@@ -78,7 +83,8 @@ import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
import * as ThreadManagementService from "./ThreadManagementService.ts";
import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts";
-const layerPlatformTest = Layer.merge(
+const layerPlatformTest = Layer.mergeAll(
+ Layer.mock(GitHubReplyReader.GitHubReplyReader)({}),
NodeServices.layer,
Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({
resolveLink: () => Effect.die("unused title link"),
@@ -2116,6 +2122,172 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => {
}),
);
+ it.effect(
+ "persists discussion links independently of pull requests and keeps comment anchors idempotent",
+ () =>
+ Effect.gen(function* () {
+ const orchestrator = yield* Orchestrator.OrchestratorV2;
+ const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2;
+ const threadId = ThreadId.make("runtime-discussion-links");
+ yield* orchestrator.dispatch({
+ type: "thread.create",
+ createdBy: "user",
+ creationSource: "web",
+ commandId: CommandId.make("discussion-thread"),
+ threadId,
+ projectId: ProjectId.make("discussion-project"),
+ title: "Discussion links",
+ modelSelection,
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ branch: null,
+ worktreePath: null,
+ });
+ const discussion = {
+ host: "github.com" as const,
+ repository: "Team/Repo",
+ number: 1,
+ url: "https://github.com/Team/Repo/discussions/1#discussioncomment-10",
+ };
+ for (const [index, url] of [
+ discussion.url,
+ "https://github.com/team/repo/discussions/1#discussioncomment-20",
+ ].entries()) {
+ yield* orchestrator.dispatch({
+ type: "thread.discussion.link",
+ commandId: CommandId.make(`discussion-link-${index}`),
+ threadId,
+ discussion: { ...discussion, url },
+ title: "A discussion",
+ source: "agent",
+ });
+ }
+ yield* orchestrator.dispatch({
+ type: "thread.pull-request.link",
+ commandId: CommandId.make("discussion-pr-link"),
+ threadId,
+ host: "github.com",
+ repository: "team/repo",
+ number: 1,
+ url: "https://github.com/team/repo/pull/1",
+ source: "manual",
+ });
+ assert.isTrue((yield* maintenance.rebuild).valid);
+ const linked = yield* orchestrator.getThreadShell(threadId);
+ assert.equal(linked?.discussions?.length, 1);
+ assert.equal(linked?.discussions?.[0]?.url, "https://github.com/team/repo/discussions/1");
+ assert.equal(linked?.discussions?.[0]?.title, "A discussion");
+ assert.equal(linked?.pullRequests?.length, 1);
+ assert.isNull(linked?.activeRunId);
+ assert.isNull(linked?.settledAt);
+ for (let index = 0; index < 2; index++) {
+ yield* orchestrator.dispatch({
+ type: "thread.discussion.unlink",
+ commandId: CommandId.make(`discussion-unlink-${index}`),
+ threadId,
+ discussion,
+ });
+ }
+ assert.isTrue((yield* maintenance.rebuild).valid);
+ const unlinked = yield* orchestrator.getThreadShell(threadId);
+ assert.deepEqual(unlinked?.discussions, []);
+ assert.equal(unlinked?.pullRequests?.length, 1);
+ const mismatch = yield* orchestrator
+ .dispatch({
+ type: "thread.discussion.link",
+ commandId: CommandId.make("discussion-mismatch"),
+ threadId,
+ discussion: { ...discussion, repository: "other/repo" },
+ source: "agent",
+ })
+ .pipe(Effect.flip);
+ assert.equal(mismatch._tag, "OrchestratorDispatchError");
+ }),
+ );
+
+ it.effect(
+ "links, lists and unlinks discussions through the service with one lightweight GitHub read",
+ () => {
+ const reads: Array<{ readonly cwd: string; readonly url: string }> = [];
+ return Effect.gen(function* () {
+ const orchestrator = yield* Orchestrator.OrchestratorV2;
+ const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2;
+ const threadId = ThreadId.make("discussion-service-thread");
+ const projectId = ProjectId.make("discussion-service-project");
+ yield* seedProject({
+ projectId,
+ title: "Discussion project",
+ workspaceRoot: "/workspace/discussions",
+ defaultModelSelection: null,
+ createdAt: "2026-10-01T00:00:00Z",
+ });
+ yield* orchestrator.dispatch({
+ type: "thread.create",
+ createdBy: "user",
+ creationSource: "web",
+ commandId: CommandId.make("discussion-service-create"),
+ threadId,
+ projectId,
+ title: "Discussion",
+ modelSelection,
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ branch: null,
+ worktreePath: "/missing-discussion-worktree",
+ });
+ const service = yield* ThreadDiscussionService.ThreadDiscussionService;
+ const first = yield* service.link({
+ threadId,
+ url: "https://github.com/Team/Repo/discussions/1#discussioncomment-10",
+ });
+ assert.equal(first.alreadyLinked, false);
+ assert.equal(
+ (yield* service.link({ threadId, repository: "team/repo", number: 1 })).alreadyLinked,
+ true,
+ );
+ assert.deepEqual(reads, [
+ { cwd: "/workspace/discussions", url: "https://github.com/team/repo/discussions/1" },
+ ]);
+ assert.isTrue((yield* maintenance.rebuild).valid);
+ const listed = yield* service.list(threadId);
+ assert.equal(listed.discussions.length, 1);
+ assert.equal(listed.discussions[0]?.title, "A discussion");
+ assert.equal(
+ (yield* service.unlink({ threadId, repository: "Team/Repo", number: 1 })).wasLinked,
+ true,
+ );
+ assert.equal((yield* service.unlink({ threadId, url: first.url })).wasLinked, false);
+ assert.deepEqual((yield* service.list(threadId)).discussions, []);
+ for (const input of [
+ { repository: "team/repo", number: 1, host: "other.example" },
+ { repository: "team/repo/extra", number: 1 },
+ { repository: "team/repo" },
+ ]) {
+ assert.equal(
+ (yield* service.link({ threadId, ...input }).pipe(Effect.flip))._tag,
+ "DiscussionOperationError",
+ );
+ }
+ assert.equal(reads.length, 1);
+ }).pipe(
+ Effect.provide(
+ ThreadDiscussionService.layer.pipe(
+ Layer.provide(
+ Layer.mock(GitHubDiscussions.GitHubDiscussions)({
+ summary: (input) =>
+ Effect.sync(() => {
+ reads.push(input);
+ return { title: "A discussion" };
+ }),
+ }),
+ ),
+ Layer.provide(layerPlatformTest),
+ ),
+ ),
+ );
+ },
+ );
+
it.effect("retains multiple pull requests and dismissed stack members through rebuilds", () =>
Effect.gen(function* () {
const orchestrator = yield* Orchestrator.OrchestratorV2;
@@ -2302,6 +2474,150 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => {
}),
);
+ it.effect.each(["idle", "running"] as const)(
+ "GitHub reply snooze ends PR watches and rejects stale wakes: %s",
+ (scenario) =>
+ Effect.gen(function* () {
+ const orchestrator = yield* Orchestrator.OrchestratorV2;
+ const sink = yield* EventSink.EventSinkV2;
+ const threadId = ThreadId.make(`reply-snooze-pr-watch:${scenario}`);
+ const projectId = ProjectId.make(`${threadId}:project`);
+ yield* seedProject({
+ projectId,
+ title: "Reply snooze",
+ workspaceRoot: "/tmp/reply-snooze-pr-watch",
+ defaultModelSelection: null,
+ createdAt: DateTime.formatIso(yield* DateTime.now),
+ });
+ yield* orchestrator.dispatch({
+ type: "thread.create",
+ createdBy: "user",
+ creationSource: "web",
+ commandId: CommandId.make(`${threadId}:create`),
+ threadId,
+ projectId,
+ title: "Reply snooze",
+ modelSelection,
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ branch: null,
+ worktreePath: null,
+ });
+ const now = yield* DateTime.now;
+ if (scenario === "running") {
+ yield* orchestrator.dispatch({
+ type: "message.dispatch",
+ commandId: CommandId.make(`${threadId}:user-message`),
+ messageId: MessageId.make(`${threadId}:user-message`),
+ threadId,
+ text: "Original work",
+ attachments: [],
+ dispatchMode: { type: "start_immediately" },
+ createdBy: "user",
+ creationSource: "web",
+ });
+ const run = (yield* orchestrator.getThreadProjection(threadId)).runs[0]!;
+ yield* sink.write({
+ events: [
+ {
+ id: EventId.make(`${threadId}:running`),
+ type: "run.updated",
+ threadId,
+ runId: run.id,
+ occurredAt: now,
+ payload: { ...run, status: "running", startedAt: now },
+ },
+ ],
+ });
+ }
+ const key = { host: "github.com", repository: "team/repo", number: 1 };
+ const url = "https://github.com/team/repo/pull/1";
+ const watchCommand = {
+ type: "thread.pull-request.watch" as const,
+ commandId: CommandId.make(`${threadId}:watch`),
+ threadId,
+ ...key,
+ watching: true,
+ link: { url, source: "agent" as const },
+ };
+ yield* orchestrator.dispatch(watchCommand);
+ const watch = (yield* orchestrator.getThreadShell(threadId))!.pullRequests![0]!.watch!;
+ const before = yield* orchestrator.getThreadProjection(threadId);
+ const requestId = CommandId.make(`${threadId}:snooze`);
+ yield* orchestrator.dispatch({
+ type: "thread.github-reply.snooze",
+ commandId: requestId,
+ threadId,
+ url,
+ });
+ const snoozed = yield* orchestrator.getThreadProjection(threadId);
+ assert.isUndefined(snoozed.thread.pullRequests?.[0]?.watch);
+ assert.deepEqual(snoozed.runs, before.runs);
+ const register = yield* orchestrator
+ .dispatch({
+ ...watchCommand,
+ commandId: CommandId.make(`${threadId}:watch-after-snooze`),
+ })
+ .pipe(Effect.result);
+ assert.equal(register._tag, "Failure");
+ if (scenario === "running") {
+ const run = snoozed.runs[0]!;
+ yield* sink.write({
+ events: [
+ {
+ id: EventId.make(`${threadId}:completed`),
+ type: "run.updated",
+ threadId,
+ runId: run.id,
+ occurredAt: now,
+ payload: { ...run, status: "completed", completedAt: now },
+ },
+ ],
+ });
+ assert.equal(
+ (yield* orchestrator.getThreadProjection(threadId)).runs[0]?.status,
+ "completed",
+ );
+ }
+ for (const stage of ["snoozed", "reply-received"] as const) {
+ if (stage === "reply-received") {
+ yield* orchestrator.dispatch({
+ type: "thread.github-reply.sync",
+ commandId: CommandId.make(`${threadId}:reply`),
+ threadId,
+ requestId,
+ watch: null,
+ notice: { type: "reply", receivedAt: DateTime.formatIso(now), url, text: "Feedback" },
+ });
+ }
+ const wake = yield* orchestrator
+ .dispatch({
+ type: "thread.pull-request-watch.sync",
+ commandId: CommandId.make(`${threadId}:stale-watch:${stage}`),
+ threadId,
+ ...key,
+ startedAt: watch.startedAt,
+ watch: { ...watch, failedChecks: ["lint"], wakes: 1 },
+ wake: {
+ messageId: MessageId.make(`${threadId}:stale-wake:${stage}`),
+ text: "CI failed",
+ notification: { source: { kind: "monitor" }, outcome: "updated", summary: "#1" },
+ },
+ })
+ .pipe(Effect.result);
+ assert.equal(wake._tag, "Failure");
+ const after = yield* orchestrator.getThreadProjection(threadId);
+ assert.lengthOf(after.runs, before.runs.length);
+ assert.deepEqual(after.messages, before.messages);
+ assert.isUndefined(after.thread.pullRequests?.[0]?.watch);
+ assert.equal(
+ after.thread.githubReplySnooze?.requestId ?? null,
+ stage === "snoozed" ? requestId : null,
+ );
+ }
+ }),
+ );
+
it.effect.each(["manual", "automatic"])("settling ends every pull request watch: %s", (mode) =>
Effect.gen(function* () {
const orchestrator = yield* Orchestrator.OrchestratorV2;
@@ -3229,6 +3545,116 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => {
}),
);
+ it.effect.each(["snoozed", "reply-received"] as const)(
+ "atomically declines restart continuation after GitHub reply snooze: %s",
+ (scenario) =>
+ Effect.gen(function* () {
+ const orchestrator = yield* Orchestrator.OrchestratorV2;
+ const sink = yield* EventSink.EventSinkV2;
+ const threadId = ThreadId.make(`reply-snooze-restart:${scenario}`);
+ const projectId = ProjectId.make(`${threadId}:project`);
+ yield* seedProject({
+ projectId,
+ title: "Reply snooze",
+ workspaceRoot: "/tmp/reply-snooze-restart",
+ defaultModelSelection: null,
+ createdAt: DateTime.formatIso(yield* DateTime.now),
+ });
+ yield* orchestrator.dispatch({
+ type: "thread.create",
+ createdBy: "user",
+ creationSource: "web",
+ commandId: CommandId.make(`${threadId}:create`),
+ threadId,
+ projectId,
+ title: "Reply snooze",
+ modelSelection,
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ branch: null,
+ worktreePath: null,
+ });
+ yield* orchestrator.dispatch({
+ type: "message.dispatch",
+ commandId: CommandId.make(`${threadId}:user-message`),
+ messageId: MessageId.make(`${threadId}:user-message`),
+ threadId,
+ text: "Original work",
+ attachments: [],
+ dispatchMode: { type: "start_immediately" },
+ createdBy: "user",
+ creationSource: "web",
+ });
+ const original = (yield* orchestrator.getThreadProjection(threadId)).runs[0]!;
+ const url = "https://github.com/team/repo/pull/1";
+ const requestId = CommandId.make(`${threadId}:snooze`);
+ yield* orchestrator.dispatch({
+ type: "thread.github-reply.snooze",
+ commandId: requestId,
+ threadId,
+ url,
+ });
+ const now = yield* DateTime.now;
+ yield* sink.commitCommand({
+ commandId: CommandId.make(`${threadId}:cancel`),
+ threadId,
+ commandType: "provider-runtime.reconcile",
+ acceptedAt: now,
+ events: [
+ {
+ id: EventId.make(`${threadId}:cancelled`),
+ type: "run.updated",
+ threadId,
+ runId: original.id,
+ occurredAt: now,
+ payload: { ...original, status: "cancelled", completedAt: now },
+ },
+ ],
+ effects: [],
+ });
+ if (scenario === "reply-received") {
+ yield* orchestrator.dispatch({
+ type: "thread.github-reply.sync",
+ commandId: CommandId.make(`${threadId}:reply`),
+ threadId,
+ requestId,
+ watch: null,
+ notice: { type: "reply", receivedAt: DateTime.formatIso(now), url, text: "Feedback" },
+ });
+ }
+ const before = yield* orchestrator.getThreadProjection(threadId);
+ const automatic = {
+ type: "message.dispatch" as const,
+ commandId: CommandId.make(`${threadId}:automatic`),
+ messageId: MessageId.make(`${threadId}:automatic`),
+ threadId,
+ text: "Continue where you left off.",
+ attachments: [],
+ dispatchMode: { type: "start_immediately" as const },
+ restartContinuationOfRunId: original.id,
+ createdBy: "agent" as const,
+ creationSource: "server" as const,
+ };
+ yield* orchestrator.dispatch(automatic);
+ yield* orchestrator.dispatch(automatic);
+ const after = yield* orchestrator.getThreadProjection(threadId);
+ assert.deepEqual(after.thread, before.thread);
+ assert.deepEqual(after.runs, before.runs);
+ assert.deepEqual(after.messages, before.messages);
+ yield* orchestrator.dispatch({
+ ...automatic,
+ commandId: CommandId.make(`${threadId}:explicit`),
+ messageId: MessageId.make(`${threadId}:explicit`),
+ restartContinuationOfRunId: undefined,
+ createdBy: "user",
+ creationSource: "web",
+ });
+ const resumed = yield* orchestrator.getThreadProjection(threadId);
+ assert.lengthOf(resumed.runs, 2);
+ assert.isNull(resumed.thread.githubReplySnooze);
+ }),
+ );
+
it.effect.each(["failed", "completed", "waiting", "cancelled"] as const)(
"does not admit a restart continuation of a %s run with only lost background work",
(status) =>
@@ -4911,6 +5337,17 @@ it.layer(layerTest)("usage-limit recovery", (it) => {
it.effect.each([
"resume",
"queued-resume",
+ "github-running",
+ "github-steer-snoozed",
+ "github-steer-woke",
+ "github-restart-snoozed",
+ "github-restart-woke",
+ "github-limited",
+ "github-arm-while-snoozed",
+ "github-arm-after-wake",
+ "github-manual-rearm-while-snoozed",
+ "github-manual-rearm-after-wake",
+ "github-manual-rearm-after-reply",
"cancel",
"rearm",
"snooze-race",
@@ -4932,6 +5369,8 @@ it.layer(layerTest)("usage-limit recovery", (it) => {
Effect.gen(function* () {
const orchestrator = yield* Orchestrator.OrchestratorV2;
const events = yield* EventSink.EventSinkV2;
+ const sameRunReplyMessage =
+ scenario.startsWith("github-steer-") || scenario.startsWith("github-restart-");
const threadId = ThreadId.make(`recovery:${scenario}`);
const projectId = ProjectId.make(`recovery:project:${scenario}`);
yield* seedProject({
@@ -4962,7 +5401,7 @@ it.layer(layerTest)("usage-limit recovery", (it) => {
messageId: MessageId.make(`recovery:message:${scenario}`),
text: "Work on this.",
attachments: [],
- dispatchMode: { type: "defer_start" },
+ dispatchMode: { type: sameRunReplyMessage ? "start_immediately" : "defer_start" },
createdBy: "user",
creationSource: "web",
});
@@ -4980,39 +5419,165 @@ it.layer(layerTest)("usage-limit recovery", (it) => {
});
}
const projection = yield* orchestrator.getThreadProjection(threadId);
- const run = projection.runs[0]!;
- const now = yield* DateTime.now;
- const resetAt =
- scenario === "invalid-snooze"
- ? "not-a-date"
- : DateTime.formatIso(DateTime.add(now, { minutes: 1 })).replace(
- "Z",
- scenario === "wake" ? "+00:00" : "Z",
- );
- yield* events.write({
- commandId: CommandId.make(`recovery:failure:${scenario}`),
- events: [
- {
- id: EventId.make(`recovery:run:${scenario}`),
- type: "run.updated",
- threadId,
- occurredAt: now,
- payload: { ...run, status: "failed", completedAt: now },
- },
- {
- id: EventId.make(`recovery:error:${scenario}`),
- type: "turn-item.updated",
- threadId,
- occurredAt: now,
- payload: {
- id: TurnItemId.make(`recovery:error:${scenario}`),
- type: "error",
- threadId,
- runId: run.id,
- nodeId: run.rootNodeId,
- providerThreadId: null,
- providerTurnId: null,
- nativeItemRef: null,
+ let run = projection.runs[0]!;
+ const snoozeForReply = {
+ type: "thread.github-reply.snooze" as const,
+ commandId: CommandId.make(`recovery:github-snooze:${scenario}`),
+ threadId,
+ url: "https://github.com/team/repo/pull/1",
+ };
+ if (sameRunReplyMessage) {
+ const now = yield* DateTime.now;
+ const providerThread = projection.providerThreads[0]!;
+ const providerSession = {
+ id: providerThread.providerSessionId!,
+ driver,
+ providerInstanceId: modelSelection.instanceId,
+ status: "running" as const,
+ cwd: process.cwd(),
+ model: modelSelection.model,
+ capabilities: CodexProviderCapabilitiesV2,
+ createdAt: now,
+ updatedAt: now,
+ lastError: null,
+ };
+ yield* events.write({
+ events: [
+ {
+ id: EventId.make(`${threadId}:running`),
+ type: "run.updated",
+ threadId,
+ runId: run.id,
+ occurredAt: now,
+ payload: { ...run, status: "running", startedAt: now },
+ },
+ {
+ id: EventId.make(`${threadId}:session`),
+ type: "provider-session.attached",
+ threadId,
+ occurredAt: now,
+ payload: providerSession,
+ },
+ {
+ id: EventId.make(`${threadId}:turn`),
+ type: "provider-turn.updated",
+ threadId,
+ runId: run.id,
+ occurredAt: now,
+ payload: {
+ id: ProviderTurnId.make(`${threadId}:turn`),
+ providerThreadId: providerThread.id,
+ nodeId: run.rootNodeId!,
+ runAttemptId: run.activeAttemptId,
+ nativeTurnRef: null,
+ ordinal: 1,
+ status: "running",
+ startedAt: now,
+ completedAt: null,
+ },
+ },
+ ],
+ });
+ const sessions = yield* ProviderSessionManager.ProviderSessionManagerV2;
+ const sessionSpy = vi
+ .spyOn(sessions, "get")
+ .mockReturnValue(
+ Effect.succeed(Option.some({ providerSession } as ProviderAdapterV2SessionRuntime)),
+ );
+ yield* Effect.addFinalizer(() => Effect.sync(() => sessionSpy.mockRestore()));
+ yield* orchestrator.dispatch(snoozeForReply);
+ if (scenario.endsWith("-woke"))
+ yield* orchestrator.dispatch({
+ type: "thread.github-reply.sync",
+ commandId: CommandId.make(`${threadId}:reply`),
+ threadId,
+ requestId: snoozeForReply.commandId,
+ watch: null,
+ notice: {
+ type: "reply",
+ receivedAt: DateTime.formatIso(now),
+ url: snoozeForReply.url,
+ text: "Feedback",
+ },
+ });
+ assert.equal(
+ (yield* orchestrator.getThreadProjection(threadId)).thread
+ .githubReplyAutoResumeBlockedRunId,
+ run.id,
+ );
+ const message = {
+ type: "message.dispatch" as const,
+ threadId,
+ text: "Continue with this feedback",
+ attachments: [],
+ dispatchMode: {
+ type: scenario.startsWith("github-steer-")
+ ? ("steer_active" as const)
+ : ("restart_active" as const),
+ targetRunId: run.id,
+ },
+ createdBy: "user" as const,
+ creationSource: "web" as const,
+ };
+ const rejected = yield* orchestrator
+ .dispatch({
+ ...message,
+ commandId: CommandId.make(`${threadId}:invalid-message`),
+ messageId: MessageId.make(`${threadId}:invalid-message`),
+ dispatchMode: { ...message.dispatchMode, targetRunId: RunId.make("missing-run") },
+ })
+ .pipe(Effect.result);
+ assert.equal(rejected._tag, "Failure");
+ assert.equal(
+ (yield* orchestrator.getThreadProjection(threadId)).thread
+ .githubReplyAutoResumeBlockedRunId,
+ run.id,
+ );
+ yield* orchestrator.dispatch({
+ ...message,
+ commandId: CommandId.make(`${threadId}:explicit-message`),
+ messageId: MessageId.make(`${threadId}:explicit-message`),
+ });
+ const steered = yield* orchestrator.getThreadProjection(threadId);
+ assert.lengthOf(steered.runs, 1);
+ assert.equal(steered.runs[0]!.id, run.id);
+ assert.isNull(steered.thread.githubReplySnooze);
+ assert.isNull(steered.thread.githubReplyAutoResumeBlockedRunId);
+ run = steered.runs[0]!;
+ }
+ if (scenario === "github-running") yield* orchestrator.dispatch(snoozeForReply);
+ const now = yield* DateTime.now;
+ const resetAt =
+ scenario === "invalid-snooze"
+ ? "not-a-date"
+ : DateTime.formatIso(DateTime.add(now, { minutes: 1 })).replace(
+ "Z",
+ scenario === "wake" ? "+00:00" : "Z",
+ );
+ yield* events.write({
+ commandId: CommandId.make(`recovery:failure:${scenario}`),
+ events: [
+ {
+ id: EventId.make(`recovery:run:${scenario}`),
+ type: "run.updated",
+ threadId,
+ occurredAt: now,
+ payload: { ...run, status: "failed", completedAt: now },
+ },
+ {
+ id: EventId.make(`recovery:error:${scenario}`),
+ type: "turn-item.updated",
+ threadId,
+ occurredAt: now,
+ payload: {
+ id: TurnItemId.make(`recovery:error:${scenario}`),
+ type: "error",
+ threadId,
+ runId: run.id,
+ nodeId: run.rootNodeId,
+ providerThreadId: null,
+ providerTurnId: null,
+ nativeItemRef: null,
parentItemId: null,
ordinal: 2,
status: "failed",
@@ -5059,6 +5624,242 @@ it.layer(layerTest)("usage-limit recovery", (it) => {
(thread) => thread.id === threadId,
)!;
assert.isNull(limitRecoveryCommand(shell, false, DateTime.toEpochMillis(now)));
+ if (
+ scenario === "github-manual-rearm-while-snoozed" ||
+ scenario === "github-manual-rearm-after-wake" ||
+ scenario === "github-manual-rearm-after-reply"
+ ) {
+ yield* orchestrator.dispatch(
+ limitRecoveryCommand(shell, true, DateTime.toEpochMillis(now))!,
+ );
+ const staleResume = limitRecoveryCommand(
+ (yield* orchestrator.getThreadShell(threadId))!,
+ true,
+ Date.parse(resetAt),
+ );
+ assert.isNotNull(staleResume);
+ yield* orchestrator.dispatch(snoozeForReply);
+ if (scenario === "github-manual-rearm-after-wake") {
+ yield* orchestrator.dispatch({
+ type: "thread.unsnooze",
+ commandId: CommandId.make(`recovery:github-wake:${scenario}`),
+ threadId,
+ reason: "user",
+ });
+ } else if (scenario === "github-manual-rearm-after-reply") {
+ yield* orchestrator.dispatch({
+ type: "thread.github-reply.sync",
+ commandId: CommandId.make(`recovery:github-reply:${scenario}`),
+ threadId,
+ requestId: snoozeForReply.commandId,
+ watch: null,
+ notice: {
+ type: "reply",
+ url: snoozeForReply.url,
+ text: "Feedback",
+ receivedAt: DateTime.formatIso(now),
+ },
+ });
+ }
+ assert.equal(
+ (yield* orchestrator.getThreadProjection(threadId)).thread
+ .githubReplyAutoResumeBlockedRunId,
+ run.id,
+ );
+ yield* orchestrator.dispatch({
+ type: "thread.metadata.update",
+ commandId: CommandId.make(`recovery:github-manual-arm:${scenario}`),
+ threadId,
+ limitRecovery: { runId: run.id, resetAt, autoResume: true },
+ });
+ const armed = yield* orchestrator.getThreadProjection(threadId);
+ assert.isNull(armed.thread.githubReplyAutoResumeBlockedRunId);
+ assert.isNull(armed.thread.githubReplySnooze);
+ assert.isNull(armed.thread.snoozedAt);
+ assert.isTrue(armed.thread.limitRecovery?.autoResume);
+ assert.lengthOf(armed.runs, 1);
+ yield* TestClock.adjust("1 minute");
+ yield* orchestrator.dispatch(staleResume!);
+ assert.lengthOf((yield* orchestrator.getThreadProjection(threadId)).runs, 1);
+ const resume = limitRecoveryCommand(
+ (yield* orchestrator.getThreadShell(threadId))!,
+ true,
+ Date.parse(resetAt),
+ );
+ assert.isNotNull(resume);
+ yield* orchestrator.dispatch(resume!);
+ assert.lengthOf((yield* orchestrator.getThreadProjection(threadId)).runs, 2);
+ return;
+ }
+ if (scenario === "github-arm-while-snoozed" || scenario === "github-arm-after-wake") {
+ const staleArm = limitRecoveryCommand(shell, true, DateTime.toEpochMillis(now), true);
+ assert.isNotNull(staleArm);
+ yield* orchestrator.dispatch(snoozeForReply);
+ if (scenario === "github-arm-after-wake") {
+ yield* orchestrator.dispatch({
+ type: "thread.github-reply.sync",
+ commandId: CommandId.make(`recovery:github-wake:${scenario}`),
+ threadId,
+ requestId: snoozeForReply.commandId,
+ watch: null,
+ notice: {
+ type: "reply",
+ url: snoozeForReply.url,
+ text: "Feedback",
+ receivedAt: DateTime.formatIso(now),
+ },
+ });
+ }
+ const before = yield* orchestrator.getThreadProjection(threadId);
+ assert.equal(
+ before.thread.githubReplySnooze?.requestId ?? null,
+ scenario === "github-arm-while-snoozed" ? snoozeForReply.commandId : null,
+ );
+ assert.equal(before.thread.githubReplyAutoResumeBlockedRunId, run.id);
+ assert.isNull(before.thread.limitRecovery);
+ assert.isNull(before.thread.snoozedUntil);
+ const result = yield* orchestrator.dispatch(staleArm!).pipe(Effect.exit);
+ assert.equal(result._tag, "Failure");
+ const after = yield* orchestrator.getThreadProjection(threadId);
+ assert.deepEqual(after.thread, before.thread);
+ assert.deepEqual(after.runs, before.runs);
+ assert.deepEqual(after.messages, before.messages);
+ yield* orchestrator.dispatch({
+ type: "thread.metadata.update",
+ commandId: CommandId.make(`recovery:github-manual-snooze:${scenario}`),
+ threadId,
+ limitRecovery: { runId: run.id, resetAt, snooze: true },
+ });
+ const manuallySnoozed = yield* orchestrator.getThreadProjection(threadId);
+ assert.isNull(manuallySnoozed.thread.githubReplySnooze);
+ assert.equal(
+ DateTime.toEpochMillis(manuallySnoozed.thread.snoozedUntil!),
+ Date.parse(resetAt),
+ );
+ assert.isTrue(manuallySnoozed.thread.limitRecovery?.snooze);
+ assert.isFalse(manuallySnoozed.thread.limitRecovery?.autoResume);
+ assert.lengthOf(manuallySnoozed.runs, 1);
+ return;
+ }
+ if (scenario === "github-running" || scenario === "github-limited") {
+ let staleResume = null;
+ if (scenario === "github-limited") {
+ yield* orchestrator.dispatch(
+ limitRecoveryCommand(shell, true, DateTime.toEpochMillis(now))!,
+ );
+ staleResume = limitRecoveryCommand(
+ (yield* orchestrator.getThreadShell(threadId))!,
+ true,
+ Date.parse(resetAt),
+ );
+ assert.isNotNull(staleResume);
+ yield* orchestrator.dispatch(snoozeForReply);
+ }
+ yield* orchestrator.dispatch({
+ type: "thread.github-reply.sync",
+ commandId: CommandId.make(`recovery:github-wake:${scenario}`),
+ threadId,
+ requestId: snoozeForReply.commandId,
+ watch: null,
+ notice: {
+ type: "reply",
+ url: snoozeForReply.url,
+ text: "Feedback",
+ receivedAt: DateTime.formatIso(now),
+ },
+ });
+ yield* TestClock.adjust("1 minute");
+ const projections = yield* ProjectionStore.ProjectionStoreV2;
+ const getShell = orchestrator.getThreadShell(threadId);
+ const woke = (yield* getShell)!;
+ assert.isNull(woke.githubReplySnooze);
+ assert.equal(
+ (yield* projections.getThread(threadId)).githubReplyAutoResumeBlockedRunId,
+ run.id,
+ );
+ assert.isNull(limitRecoveryCommand(woke, true, Date.parse(resetAt)));
+ // a stale worker cannot re-arm this run after the reply, even after the reset.
+ const staleArm = yield* orchestrator
+ .dispatch({
+ type: "thread.metadata.update",
+ commandId: CommandId.make(`recovery:github-stale-arm:${scenario}`),
+ threadId,
+ automaticLimitRecovery: true,
+ limitRecovery: { runId: run.id, resetAt, autoResume: true },
+ })
+ .pipe(Effect.exit);
+ assert.equal(staleArm._tag, "Failure");
+ assert.isNull((yield* getShell)!.limitRecovery);
+ if (staleResume !== null) yield* orchestrator.dispatch(staleResume);
+ assert.lengthOf((yield* orchestrator.getThreadProjection(threadId)).runs, 1);
+ const candidates = yield* projections.getLimitRecoveryCandidates({
+ now: yield* DateTime.now,
+ autoResume: true,
+ snooze: false,
+ });
+ const candidate = candidates.find((candidate) => candidate.id === threadId);
+ if (candidate) assert.isNull(limitRecoveryCommand(candidate, true, Date.parse(resetAt)));
+ yield* orchestrator.dispatch({
+ type: "message.dispatch",
+ commandId: CommandId.make(`recovery:github-manual:${scenario}`),
+ messageId: MessageId.make(`recovery:github-manual:${scenario}`),
+ threadId,
+ manualContinuationOfRunId: run.id,
+ text: "Continue where you left off.",
+ attachments: [],
+ dispatchMode: { type: "start_immediately" },
+ createdBy: "user",
+ creationSource: "web",
+ });
+ const continued = yield* orchestrator.getThreadProjection(threadId);
+ assert.lengthOf(continued.runs, 2);
+ const nextRun = continued.runs[1]!;
+ const nextNow = yield* DateTime.now;
+ const nextReset = DateTime.formatIso(DateTime.add(nextNow, { minutes: 1 }));
+ const error = continued.turnItems.find((item) => item.type === "error")!;
+ if (error.type !== "error") throw new Error("Expected usage limit error");
+ yield* events.write({
+ events: [
+ {
+ id: EventId.make(`recovery:github-next-run:${scenario}`),
+ type: "run.updated",
+ threadId,
+ occurredAt: nextNow,
+ payload: { ...nextRun, status: "failed", completedAt: nextNow },
+ },
+ {
+ id: EventId.make(`recovery:github-next-error:${scenario}`),
+ type: "turn-item.updated",
+ threadId,
+ occurredAt: nextNow,
+ payload: {
+ ...error,
+ id: TurnItemId.make(`recovery:github-next-error:${scenario}`),
+ runId: nextRun.id,
+ nodeId: nextRun.rootNodeId,
+ ordinal: 4,
+ startedAt: nextNow,
+ completedAt: nextNow,
+ updatedAt: nextNow,
+ failure: { ...error.failure, resetAt: nextReset },
+ },
+ },
+ ],
+ });
+ const nextArm = limitRecoveryCommand(
+ (yield* getShell)!,
+ true,
+ DateTime.toEpochMillis(nextNow),
+ );
+ assert.isNotNull(nextArm);
+ yield* orchestrator.dispatch(nextArm!);
+ yield* TestClock.adjust("1 minute");
+ const nextResume = limitRecoveryCommand((yield* getShell)!, true, Date.parse(nextReset));
+ assert.isNotNull(nextResume);
+ yield* orchestrator.dispatch(nextResume!);
+ assert.lengthOf((yield* orchestrator.getThreadProjection(threadId)).runs, 3);
+ return;
+ }
if (scenario === "invalid-snooze") {
const result = yield* orchestrator
.dispatch({
@@ -5358,7 +6159,8 @@ it.layer(layerTest)("usage-limit recovery", (it) => {
assert.lengthOf(
after.runs,
before.runs.length +
- (scenario === "resume" ||
+ (sameRunReplyMessage ||
+ scenario === "resume" ||
scenario === "queued-resume" ||
scenario === "snooze-resume" ||
scenario === "wake-preserve-resume" ||
@@ -5370,7 +6172,8 @@ it.layer(layerTest)("usage-limit recovery", (it) => {
assert.lengthOf(
after.messages,
before.messages.length +
- (scenario === "resume" ||
+ (sameRunReplyMessage ||
+ scenario === "resume" ||
scenario === "queued-resume" ||
scenario === "snooze-resume" ||
scenario === "wake-preserve-resume" ||
@@ -5408,3 +6211,816 @@ it.layer(layerTest)("usage-limit recovery", (it) => {
}),
);
});
+
+it.layer(layerSharedApplicationDataPlaneTest)("GitHub reply snooze", (it) => {
+ it.effect.each([
+ "discussion",
+ "unlinked",
+ "closed",
+ "merged",
+ "unknown-state",
+ "other-host",
+ "mismatched-identity",
+ "dismissed",
+ "unsupported-url",
+ ] as const)(
+ "rejects changes-requested snooze for %s before reading GitHub, including restored watches",
+ (scenario) =>
+ Effect.gen(function* () {
+ const projects = yield* ProjectService.ProjectService;
+ const orchestrator = yield* Orchestrator.OrchestratorV2;
+ const projectId = ProjectId.make(`changes-invalid:${scenario}:project`);
+ const threadId = ThreadId.make(`changes-invalid:${scenario}:thread`);
+ yield* projects.create({
+ commandId: CommandId.make(`${projectId}:create`),
+ projectId,
+ title: "Invalid target",
+ workspaceRoot: `/tmp/${projectId}`,
+ });
+ yield* orchestrator.dispatch({
+ type: "thread.create",
+ commandId: CommandId.make(`${threadId}:create`),
+ threadId,
+ projectId,
+ title: "Invalid target",
+ modelSelection,
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ branch: null,
+ worktreePath: null,
+ createdBy: "user",
+ creationSource: "web",
+ });
+ const key = {
+ host: scenario === "other-host" ? "ghe.example" : "github.com",
+ repository: scenario === "mismatched-identity" ? "other/repo" : "team/repo",
+ number: 1,
+ };
+ if (scenario !== "unlinked") {
+ yield* orchestrator.dispatch({
+ type: "thread.pull-request.link",
+ commandId: CommandId.make(`${threadId}:link`),
+ threadId,
+ ...key,
+ url: "https://github.com/team/repo/pull/1",
+ source: scenario === "dismissed" ? "stack-dismissed" : "manual",
+ });
+ if (scenario !== "unknown-state")
+ yield* orchestrator.dispatch({
+ type: "thread.pull-request-link.sync",
+ commandId: CommandId.make(`${threadId}:sync`),
+ threadId,
+ ...key,
+ snapshot: {
+ state: scenario === "closed" || scenario === "merged" ? scenario : "open",
+ title: "PR",
+ headBranch: "work",
+ baseBranch: "main",
+ isDraft: false,
+ updatedAt: null,
+ syncedAt: DateTime.formatIso(yield* DateTime.now),
+ },
+ stack: null,
+ });
+ }
+ let reads = 0;
+ const service = yield* ThreadManagementService.ThreadManagementService.pipe(
+ Effect.provide(
+ ThreadManagementService.layer.pipe(
+ Layer.provide(
+ Layer.succeed(GitHubReplyReader.GitHubReplyReader, {
+ read: () =>
+ Effect.sync(() => {
+ reads++;
+ return { viewer: "me", baseline: { latestAt: null, ids: [] }, reply: null };
+ }),
+ }),
+ ),
+ Layer.provide(CommandReceiptStore.layerFromApplicationReceipts),
+ Layer.provide(ProjectStore.layer),
+ ),
+ ),
+ );
+ const command = {
+ type: "thread.github-reply.snooze" as const,
+ commandId: CommandId.make(`${threadId}:snooze`),
+ threadId,
+ url:
+ scenario === "discussion"
+ ? "https://github.com/team/repo/discussions/1"
+ : scenario === "unsupported-url"
+ ? "https://example.com/arbitrary"
+ : "https://github.com/team/repo/pull/1",
+ wakeCondition: "changes-requested" as const,
+ };
+ assert.equal((yield* service.dispatch(command).pipe(Effect.result))._tag, "Failure");
+ assert.equal(reads, 0);
+ const restored = yield* service
+ .dispatch({
+ ...command,
+ commandId: CommandId.make(`${threadId}:restore`),
+ resumeFrom: {
+ requestId: command.commandId,
+ url: command.url,
+ wakeCondition: command.wakeCondition,
+ startedAt: "2026-10-01T12:00:00Z",
+ nextCheckAt: "2026-10-01T12:00:00Z",
+ status: "watching",
+ failures: 0,
+ baseline: { latestAt: null, ids: [] },
+ },
+ })
+ .pipe(Effect.result);
+ assert.equal(restored._tag, "Failure");
+ assert.isNull((yield* orchestrator.getThreadShell(threadId))!.githubReplySnooze ?? null);
+ }),
+ );
+ it.effect.each([
+ "success",
+ "changes-requested",
+ "failure",
+ "discussion",
+ "discussion-comment",
+ "timed",
+ "settle",
+ "wake",
+ "replace",
+ "message",
+ "archive",
+ "delete",
+ "provider-completion",
+ "resume-reset",
+ "cancel-recovery",
+ "rename-with-recovery",
+ ] as const)(
+ "captures the baseline before committing reply snooze and gives later user actions precedence: %s",
+ (scenario) =>
+ Effect.gen(function* () {
+ const projects = yield* ProjectService.ProjectService;
+ const orchestrator = yield* Orchestrator.OrchestratorV2;
+ const projectId = ProjectId.make(`github-setup:${scenario}:project`);
+ const threadId = ThreadId.make(`github-setup:${scenario}:thread`);
+ yield* projects.create({
+ commandId: CommandId.make(`${projectId}:create`),
+ projectId,
+ title: "Setup",
+ workspaceRoot: `/tmp/${projectId}`,
+ });
+ yield* orchestrator.dispatch({
+ type: "thread.create",
+ commandId: CommandId.make(`${threadId}:create`),
+ threadId,
+ projectId,
+ title: "Setup",
+ modelSelection,
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ branch: null,
+ worktreePath: null,
+ createdBy: "user",
+ creationSource: "web",
+ });
+ if (scenario === "changes-requested") {
+ yield* orchestrator.dispatch({
+ type: "thread.pull-request.link",
+ commandId: CommandId.make(`${threadId}:link`),
+ threadId,
+ host: "github.com",
+ repository: "team/repo",
+ number: 1,
+ url: "https://github.com/team/repo/pull/1",
+ source: "manual",
+ });
+ yield* orchestrator.dispatch({
+ type: "thread.pull-request-link.sync",
+ commandId: CommandId.make(`${threadId}:sync`),
+ threadId,
+ host: "github.com",
+ repository: "team/repo",
+ number: 1,
+ snapshot: {
+ state: "open",
+ title: "Open PR",
+ headBranch: "work",
+ baseBranch: "main",
+ isDraft: false,
+ updatedAt: null,
+ syncedAt: DateTime.formatIso(yield* DateTime.now),
+ },
+ stack: null,
+ });
+ }
+ if (scenario === "provider-completion") {
+ yield* orchestrator.dispatch({
+ type: "message.dispatch",
+ commandId: CommandId.make(`${threadId}:original-work`),
+ messageId: MessageId.make(`${threadId}:original-work`),
+ threadId,
+ text: "Original work",
+ attachments: [],
+ dispatchMode: { type: "start_immediately" },
+ createdBy: "user",
+ creationSource: "web",
+ });
+ const sink = yield* EventSink.EventSinkV2;
+ const run = (yield* orchestrator.getThreadProjection(threadId)).runs[0]!;
+ const now = yield* DateTime.now;
+ yield* sink.write({
+ events: [
+ {
+ id: EventId.make(`${threadId}:running`),
+ type: "run.updated",
+ threadId,
+ runId: run.id,
+ occurredAt: now,
+ payload: { ...run, status: "running", startedAt: now },
+ },
+ ],
+ });
+ }
+ let recovery: { runId: RunId; resetAt: string; autoResume: boolean } | undefined;
+ if (
+ scenario === "resume-reset" ||
+ scenario === "cancel-recovery" ||
+ scenario === "rename-with-recovery"
+ ) {
+ yield* orchestrator.dispatch({
+ type: "message.dispatch",
+ commandId: CommandId.make(`${threadId}:limited-work`),
+ messageId: MessageId.make(`${threadId}:limited-work`),
+ threadId,
+ text: "Original work",
+ attachments: [],
+ dispatchMode: { type: "defer_start" },
+ createdBy: "user",
+ creationSource: "web",
+ });
+ const run = (yield* orchestrator.getThreadProjection(threadId)).runs[0]!;
+ const now = yield* DateTime.now;
+ recovery = {
+ runId: run.id,
+ resetAt: DateTime.formatIso(DateTime.add(now, { minutes: 1 })),
+ autoResume: true,
+ };
+ const sink = yield* EventSink.EventSinkV2;
+ yield* sink.write({
+ events: [
+ {
+ id: EventId.make(`${threadId}:limited`),
+ type: "run.updated",
+ threadId,
+ occurredAt: now,
+ payload: { ...run, status: "failed", completedAt: now },
+ },
+ {
+ id: EventId.make(`${threadId}:limit-error`),
+ type: "turn-item.updated",
+ threadId,
+ occurredAt: now,
+ payload: {
+ id: TurnItemId.make(`${threadId}:limit-error`),
+ type: "error",
+ threadId,
+ runId: run.id,
+ nodeId: run.rootNodeId,
+ providerThreadId: null,
+ providerTurnId: null,
+ nativeItemRef: null,
+ parentItemId: null,
+ ordinal: 2,
+ status: "failed",
+ title: "Usage limit reached",
+ startedAt: now,
+ completedAt: now,
+ updatedAt: now,
+ failure: {
+ class: "usage_limit",
+ message: "Plan limit reached.",
+ code: "usageLimitExceeded",
+ retryable: null,
+ resetAt: recovery.resetAt,
+ },
+ },
+ },
+ ],
+ });
+ if (scenario !== "resume-reset")
+ yield* orchestrator.dispatch({
+ type: "thread.metadata.update",
+ commandId: CommandId.make(`${threadId}:existing-recovery`),
+ threadId,
+ limitRecovery: recovery,
+ });
+ }
+ const entered = yield* Deferred.make();
+ const release = yield* Deferred.make();
+ let reads = 0;
+ const baseline = { latestAt: "2026-10-01T12:00:00Z", ids: ["existing"] };
+ const freshBaseline = { latestAt: "2026-10-01T13:00:00Z", ids: ["new-reply"] };
+ const service = yield* ThreadManagementService.ThreadManagementService.pipe(
+ Effect.provide(
+ ThreadManagementService.layer.pipe(
+ Layer.provide(
+ Layer.succeed(GitHubReplyReader.GitHubReplyReader, {
+ read: (input) =>
+ Effect.gen(function* () {
+ reads++;
+ assert.isTrue(input.captureBaseline);
+ assert.equal(
+ input.watch.wakeCondition,
+ scenario === "changes-requested" ? "changes-requested" : undefined,
+ );
+ assert.equal(input.cwd, `/tmp/${projectId}`);
+ yield* Deferred.succeed(entered, undefined);
+ yield* Deferred.await(release);
+ if (scenario === "failure")
+ return yield* new GitHubReplyReader.GitHubReplyReadError({
+ reason: "unavailable",
+ });
+ return {
+ viewer: "me",
+ baseline: reads === 1 ? baseline : freshBaseline,
+ reply: null,
+ };
+ }),
+ }),
+ ),
+ Layer.provide(CommandReceiptStore.layerFromApplicationReceipts),
+ Layer.provide(ProjectStore.layer),
+ ),
+ ),
+ );
+ const command = {
+ type: "thread.github-reply.snooze" as const,
+ commandId: CommandId.make(`${threadId}:snooze`),
+ threadId,
+ ...(scenario === "changes-requested"
+ ? { wakeCondition: "changes-requested" as const }
+ : {}),
+ url:
+ scenario === "discussion"
+ ? "https://github.com/team/repo/discussions/1"
+ : scenario === "discussion-comment"
+ ? "https://github.com/team/repo/discussions/1#discussioncomment-10"
+ : "https://github.com/team/repo/pull/1",
+ };
+ const fiber = yield* service.dispatch(command).pipe(Effect.result, Effect.forkChild);
+ yield* Deferred.await(entered);
+ assert.isNull(
+ (yield* orchestrator.getThreadProjection(threadId)).thread.githubReplySnooze ?? null,
+ );
+ switch (scenario) {
+ case "resume-reset":
+ case "cancel-recovery":
+ yield* orchestrator.dispatch({
+ type: "thread.metadata.update",
+ commandId: CommandId.make(`${threadId}:later-recovery`),
+ threadId,
+ limitRecovery: scenario === "cancel-recovery" ? null : recovery!,
+ });
+ break;
+ case "rename-with-recovery":
+ yield* orchestrator.dispatch({
+ type: "thread.metadata.update",
+ commandId: CommandId.make(`${threadId}:rename`),
+ threadId,
+ title: "Renamed while fetching baseline",
+ });
+ break;
+ case "timed":
+ yield* orchestrator.dispatch({
+ type: "thread.snooze",
+ commandId: CommandId.make(`${threadId}:later-snooze`),
+ threadId,
+ snoozedUntil: "2099-01-01T00:00:00Z",
+ });
+ break;
+ case "settle":
+ case "archive":
+ case "delete":
+ yield* orchestrator.dispatch({
+ type:
+ scenario === "settle"
+ ? "thread.settle"
+ : scenario === "archive"
+ ? "thread.archive"
+ : "thread.delete",
+ commandId: CommandId.make(`${threadId}:later-action`),
+ threadId,
+ });
+ break;
+ case "wake": {
+ const before = (yield* orchestrator.getThreadProjection(threadId)).thread;
+ yield* orchestrator.dispatch({
+ type: "thread.unsnooze",
+ commandId: CommandId.make(`${threadId}:later-wake`),
+ threadId,
+ reason: "user",
+ });
+ assert.equal(
+ DateTime.toEpochMillis(
+ (yield* orchestrator.getThreadProjection(threadId)).thread.updatedAt,
+ ),
+ DateTime.toEpochMillis(before.updatedAt),
+ );
+ break;
+ }
+ case "replace":
+ yield* orchestrator.dispatch({
+ type: "thread.github-reply.snooze",
+ commandId: CommandId.make(`${threadId}:later-reply-snooze`),
+ threadId,
+ url: "https://github.com/team/repo/discussions/2",
+ });
+ break;
+ case "message":
+ yield* orchestrator.dispatch({
+ type: "message.dispatch",
+ commandId: CommandId.make(`${threadId}:later-message`),
+ messageId: MessageId.make(`${threadId}:later-message`),
+ threadId,
+ text: "New work",
+ attachments: [],
+ dispatchMode: { type: "start_immediately" },
+ createdBy: "user",
+ creationSource: "web",
+ });
+ break;
+ case "provider-completion": {
+ const sink = yield* EventSink.EventSinkV2;
+ const run = (yield* orchestrator.getThreadProjection(threadId)).runs[0]!;
+ const now = yield* DateTime.now;
+ yield* sink.write({
+ events: [
+ {
+ id: EventId.make(`${threadId}:completed`),
+ type: "run.updated",
+ threadId,
+ runId: run.id,
+ occurredAt: now,
+ payload: { ...run, status: "completed", completedAt: now },
+ },
+ ],
+ });
+ break;
+ }
+ }
+ const latest = yield* orchestrator.getThreadProjection(threadId);
+ yield* Deferred.succeed(release, undefined);
+ const result = yield* Fiber.join(fiber);
+ if (
+ scenario !== "success" &&
+ scenario !== "changes-requested" &&
+ scenario !== "discussion" &&
+ scenario !== "discussion-comment" &&
+ scenario !== "rename-with-recovery" &&
+ scenario !== "provider-completion"
+ ) {
+ assert.equal(result._tag, "Failure");
+ const final = yield* orchestrator.getThreadProjection(threadId);
+ assert.deepEqual(final.thread, latest.thread);
+ assert.deepEqual(final.runs, latest.runs);
+ return;
+ }
+ assert.equal(result._tag, "Success");
+ const accepted = yield* orchestrator.getThreadProjection(threadId);
+ assert.deepEqual(accepted.runs, latest.runs);
+ const watch = accepted.thread.githubReplySnooze!;
+ assert.deepEqual(watch.baseline, baseline);
+ assert.equal(watch.wakeCondition, command.wakeCondition);
+ const restarted = yield* ProjectionStore.ProjectionStoreV2.pipe(
+ Effect.provide(ProjectionStore.layer),
+ );
+ assert.deepEqual(
+ (yield* restarted.getThread(threadId)).githubReplySnooze?.baseline,
+ baseline,
+ );
+ yield* orchestrator.dispatch({
+ type: "thread.github-reply.sync",
+ commandId: CommandId.make(`${threadId}:reply`),
+ threadId,
+ requestId: command.commandId,
+ watch: null,
+ notice: {
+ type: "reply",
+ receivedAt: DateTime.formatIso(yield* DateTime.now),
+ url: scenario.startsWith("discussion")
+ ? "https://github.com/team/repo/discussions/1#discussioncomment-20"
+ : "https://github.com/team/repo/pull/1#issuecomment-20",
+ text: "Feedback",
+ },
+ });
+ // Retrying an acknowledged command after wake must replay its receipt without a new API read.
+ yield* service.dispatch(command);
+ assert.equal(reads, 1);
+ assert.isNull((yield* restarted.getThread(threadId)).githubReplySnooze);
+ const notice = (yield* restarted.getThread(threadId)).githubReplyNotice!;
+ assert.equal(notice.conversationUrl, command.url);
+ assert.equal(notice.wakeCondition, command.wakeCondition);
+ assert.notEqual(notice.url, notice.conversationUrl);
+ yield* service.dispatch({
+ type: "thread.github-reply.snooze",
+ commandId: CommandId.make(`${threadId}:snooze-again`),
+ threadId,
+ url: notice.conversationUrl!,
+ wakeCondition: notice.wakeCondition,
+ });
+ assert.equal(reads, 2);
+ const resnoozed = yield* restarted.getThread(threadId);
+ assert.equal(resnoozed.githubReplySnooze?.url, command.url);
+ assert.deepEqual(resnoozed.githubReplySnooze?.baseline, freshBaseline);
+ assert.isNull(resnoozed.githubReplyNotice);
+ assert.equal(resnoozed.githubReplySnooze?.wakeCondition, command.wakeCondition);
+ }),
+ );
+ it.effect.each(
+ (
+ [
+ "wake",
+ "cancel",
+ "replace",
+ "undo",
+ "archive-undo",
+ "timed",
+ "settle",
+ "archive",
+ "failure",
+ "retry",
+ "rate-limit",
+ ] as const
+ ).flatMap((scenario) =>
+ ([undefined, "changes-requested"] as const).map((wakeCondition) => ({
+ scenario,
+ wakeCondition,
+ })),
+ ),
+ )("persists $scenario with $wakeCondition", ({ scenario, wakeCondition }) =>
+ Effect.gen(function* () {
+ const projects = yield* ProjectService.ProjectService;
+ const orchestrator = yield* Orchestrator.OrchestratorV2;
+ const projections = yield* ProjectionStore.ProjectionStoreV2;
+ const workerEffects = yield* EffectWorker.OrchestrationEffectWorkerV2;
+ const projectId = ProjectId.make(
+ `github-reply-${scenario}-${wakeCondition ?? "any"}-project`,
+ );
+ const threadId = ThreadId.make(`github-reply-${scenario}-${wakeCondition ?? "any"}-thread`);
+ yield* projects.create({
+ commandId: CommandId.make(`${projectId}:create`),
+ projectId,
+ title: "Reply snooze",
+ workspaceRoot: `/tmp/${projectId}`,
+ });
+ yield* orchestrator.dispatch({
+ type: "thread.create",
+ commandId: CommandId.make(`${threadId}:create`),
+ threadId,
+ projectId,
+ createdBy: "user",
+ creationSource: "web",
+ title: "Reply snooze",
+ modelSelection,
+ runtimeMode: "full-access",
+ interactionMode: "default",
+ branch: null,
+ worktreePath: null,
+ });
+ if (wakeCondition === "changes-requested") {
+ yield* orchestrator.dispatch({
+ type: "thread.pull-request.link",
+ commandId: CommandId.make(`${threadId}:link`),
+ threadId,
+ host: "github.com",
+ repository: "team/repo",
+ number: 1,
+ url: "https://github.com/team/repo/pull/1",
+ source: "manual",
+ });
+ yield* orchestrator.dispatch({
+ type: "thread.pull-request-link.sync",
+ commandId: CommandId.make(`${threadId}:sync`),
+ threadId,
+ host: "github.com",
+ repository: "team/repo",
+ number: 1,
+ snapshot: {
+ state: "open",
+ title: "Open PR",
+ headBranch: "work",
+ baseBranch: "main",
+ isDraft: false,
+ updatedAt: null,
+ syncedAt: DateTime.formatIso(yield* DateTime.now),
+ },
+ stack: null,
+ });
+ }
+ yield* workerEffects.drain();
+ yield* orchestrator.dispatch({
+ type: "thread.settle",
+ commandId: CommandId.make(`${threadId}:settle-before`),
+ threadId,
+ });
+ yield* orchestrator.dispatch({
+ type: "thread.github-reply.snooze",
+ commandId: CommandId.make(`${threadId}:snooze`),
+ threadId,
+ url: "https://github.com/team/repo/pull/1",
+ resumeFrom: {
+ wakeCondition,
+ requestId: CommandId.make(`${threadId}:snooze`),
+ url: "https://github.com/team/repo/pull/1",
+ startedAt: DateTime.formatIso(yield* DateTime.now),
+ nextCheckAt: DateTime.formatIso(yield* DateTime.now),
+ status: "pending",
+ failures: 0,
+ viewer: "me",
+ baseline: { latestAt: "2026-10-01T12:00:00Z", ids: ["existing"] },
+ },
+ });
+ const saved = yield* projections.getThread(threadId);
+ assert.isNotNull(saved.githubReplySnooze);
+ assert.equal(saved.githubReplySnooze?.wakeCondition, wakeCondition);
+ assert.isNull(saved.snoozedUntil);
+ assert.equal(saved.settledOverride, "active");
+ const shell = (yield* orchestrator.getShellSnapshot()).threads.find(
+ (thread) => thread.id === threadId,
+ )!;
+ assert.deepEqual(shell.githubReplySnooze, saved.githubReplySnooze);
+ // A fresh store and worker recover the subscription solely from committed SQLite state.
+ const restarted = yield* ProjectionStore.ProjectionStoreV2.pipe(
+ Effect.provide(ProjectionStore.layer),
+ );
+ assert.deepEqual(
+ (yield* restarted.getThread(threadId)).githubReplySnooze,
+ saved.githubReplySnooze,
+ );
+ const entered = yield* Deferred.make();
+ const release = yield* Deferred.make();
+ const read = Effect.gen(function* () {
+ yield* Deferred.succeed(entered, undefined);
+ yield* Deferred.await(release);
+ if (scenario === "failure")
+ return yield* new GitHubReplyReader.GitHubReplyReadError({
+ reason: "comment-not-found",
+ });
+ if (scenario === "retry")
+ return yield* new GitHubReplyReader.GitHubReplyReadError({ reason: "unavailable" });
+ if (scenario === "rate-limit")
+ return yield* new GitHubReplyReader.GitHubReplyReadError({
+ reason: "rate-limited",
+ retryAt: 9_999_999_999_999,
+ });
+ return {
+ viewer: "me",
+ baseline: { latestAt: null, ids: [] },
+ reply: {
+ url: "https://github.com/team/repo/pull/1#issuecomment-2",
+ text: "Useful feedback",
+ author: "reviewer",
+ },
+ };
+ });
+ const runSweep = Effect.gen(function* () {
+ const worker = yield* GitHubReplySnoozeWorker.GitHubReplySnoozeWorker;
+ yield* worker.sweep;
+ }).pipe(
+ Effect.provide(GitHubReplySnoozeWorker.layer),
+ Effect.provideService(GitHubReplyReader.GitHubReplyReader, {
+ read: ({ watch }) => {
+ assert.equal(watch.wakeCondition, wakeCondition);
+ return read;
+ },
+ }),
+ );
+ const fiber = yield* runSweep.pipe(Effect.forkChild);
+ yield* Deferred.await(entered);
+ if (scenario === "cancel")
+ yield* orchestrator.dispatch({
+ type: "thread.unsnooze",
+ commandId: CommandId.make(`${threadId}:cancel`),
+ threadId,
+ reason: "user",
+ });
+ if (scenario === "replace")
+ yield* orchestrator.dispatch({
+ type: "thread.github-reply.snooze",
+ commandId: CommandId.make(`${threadId}:replace`),
+ threadId,
+ url: "https://github.com/team/repo/discussions/2",
+ });
+ if (scenario === "timed")
+ yield* orchestrator.dispatch({
+ type: "thread.snooze",
+ commandId: CommandId.make(`${threadId}:timed`),
+ threadId,
+ snoozedUntil: "2099-10-01T00:00:00.000Z",
+ });
+ if (scenario === "settle")
+ yield* orchestrator.dispatch({
+ type: "thread.settle",
+ commandId: CommandId.make(`${threadId}:settle`),
+ threadId,
+ });
+ if (scenario === "archive")
+ yield* orchestrator.dispatch({
+ type: "thread.archive",
+ commandId: CommandId.make(`${threadId}:archive`),
+ threadId,
+ });
+ if (scenario === "undo" || scenario === "archive-undo") {
+ yield* orchestrator.dispatch({
+ type: scenario === "undo" ? "thread.settle" : "thread.archive",
+ commandId: CommandId.make(`${threadId}:settle`),
+ threadId,
+ });
+ yield* TestClock.adjust("30 seconds");
+ if (scenario === "archive-undo")
+ yield* orchestrator.dispatch({
+ type: "thread.unarchive",
+ commandId: CommandId.make(`${threadId}:unarchive`),
+ threadId,
+ });
+ yield* orchestrator.dispatch({
+ type: "thread.github-reply.snooze",
+ commandId: CommandId.make(`${threadId}:restore`),
+ threadId,
+ url: saved.githubReplySnooze!.url,
+ resumeFrom: saved.githubReplySnooze!,
+ });
+ }
+ if (scenario === "wake" || scenario === "failure") {
+ yield* TestClock.adjust("1 second");
+ yield* orchestrator.dispatch({
+ type: "thread.visit",
+ commandId: CommandId.make(`${threadId}:visit-during-poll`),
+ threadId,
+ visitedAt: DateTime.formatIso(yield* DateTime.now),
+ });
+ yield* TestClock.adjust("1 second");
+ }
+ yield* Deferred.succeed(release, undefined);
+ yield* Fiber.join(fiber);
+ yield* workerEffects.drain();
+ const final = yield* orchestrator.getThreadProjection(threadId);
+ assert.deepEqual(final.runs, []);
+ assert.deepEqual(final.messages, []);
+ assert.deepEqual(final.providerTurns, []);
+ if (scenario === "wake" || scenario === "failure") {
+ assert.equal(
+ final.thread.githubReplyNotice?.receivedAt,
+ DateTime.formatIso(yield* DateTime.now),
+ );
+ assert.isAbove(
+ Date.parse(final.thread.githubReplyNotice!.receivedAt),
+ DateTime.toEpochMillis(final.thread.lastVisitedAt!),
+ );
+ assert.isNull(final.thread.githubReplySnooze);
+ assert.equal(final.thread.githubReplyNotice?.wakeCondition, wakeCondition);
+ assert.equal(final.thread.githubReplyNotice?.type, scenario === "wake" ? "reply" : "error");
+ assert.equal(final.thread.settledOverride, "active");
+ yield* runSweep;
+ assert.deepEqual(
+ (yield* projections.getThread(threadId)).githubReplyNotice,
+ final.thread.githubReplyNotice,
+ );
+ } else if (scenario === "replace") {
+ assert.equal(
+ final.thread.githubReplySnooze?.url,
+ "https://github.com/team/repo/discussions/2",
+ );
+ assert.isNull(final.thread.githubReplyNotice);
+ } else if (scenario === "undo" || scenario === "archive-undo") {
+ assert.equal(final.thread.githubReplySnooze?.wakeCondition, wakeCondition);
+ assert.deepEqual(
+ final.thread.githubReplySnooze?.baseline,
+ saved.githubReplySnooze?.baseline,
+ );
+ assert.equal(final.thread.githubReplySnooze?.startedAt, saved.githubReplySnooze?.startedAt);
+ assert.notEqual(
+ final.thread.githubReplySnooze?.requestId,
+ saved.githubReplySnooze?.requestId,
+ );
+ assert.isNull(final.thread.githubReplyNotice);
+ yield* runSweep;
+ assert.equal((yield* projections.getThread(threadId)).githubReplyNotice?.type, "reply");
+ } else if (scenario === "retry") {
+ assert.equal(final.thread.githubReplySnooze?.status, "retrying");
+ assert.equal(final.thread.githubReplySnooze?.failures, 1);
+ yield* runSweep;
+ assert.equal((yield* projections.getThread(threadId)).githubReplySnooze?.failures, 1);
+ yield* TestClock.adjust("2 minutes");
+ yield* runSweep;
+ assert.equal((yield* projections.getThread(threadId)).githubReplySnooze?.failures, 2);
+ yield* TestClock.adjust("4 minutes");
+ yield* runSweep;
+ assert.equal((yield* projections.getThread(threadId)).githubReplyNotice?.type, "error");
+ } else if (scenario === "rate-limit") {
+ assert.equal(final.thread.githubReplySnooze?.status, "rate-limited");
+ assert.equal(final.thread.githubReplySnooze?.failures, 0);
+ } else {
+ assert.isNull(final.thread.githubReplySnooze);
+ assert.isNull(final.thread.githubReplyNotice);
+ }
+ }).pipe(Effect.provide(Layer.mergeAll(ProjectionStore.layer, ProjectStore.layer))),
+ );
+});
diff --git a/apps/server/src/orchestration-v2/runtimeLayer.ts b/apps/server/src/orchestration-v2/runtimeLayer.ts
index 5d736f1564beb..0d7cdae829b2a 100644
--- a/apps/server/src/orchestration-v2/runtimeLayer.ts
+++ b/apps/server/src/orchestration-v2/runtimeLayer.ts
@@ -1,3 +1,5 @@
+import * as GitHubReplySnoozeWorker from "./GitHubReplySnoozeWorker.ts";
+import * as GitHubReplyReader from "../sourceControl/GitHubReplyReader.ts";
import * as UsageLimitRecoveryWorker from "./UsageLimitRecoveryWorker.ts";
import * as Scheduler from "../scheduling/Scheduler.ts";
import * as Layer from "effect/Layer";
@@ -240,7 +242,14 @@ const layerAgentSessionImporterProvided = AgentSessionImporter.layer.pipe(
);
const layerThreadManagementProvided = ThreadManagementService.layerWithLegacyImporter.pipe(
- Layer.provide(Layer.merge(layerOrchestratorProvided, layerLegacyV1ThreadImporterProvided)),
+ Layer.provide(
+ Layer.mergeAll(
+ layerOrchestratorProvided,
+ layerLegacyV1ThreadImporterProvided,
+ layerCommandReceiptStoreProvided,
+ ProjectStore.layer,
+ ),
+ ),
);
export const layerProjectSetupScriptRunner = ProjectSetupScriptRunner.layer.pipe(
Layer.provide(layerProjectService),
@@ -349,10 +358,23 @@ export const layerProduction = Layer.mergeAll(
layerThreadLifecycleProvided,
layerScheduledTaskProvided,
layerSecretRequestsProvided,
+ GitHubReplySnoozeWorker.layerScheduled.pipe(
+ Layer.provide(
+ GitHubReplySnoozeWorker.layer.pipe(
+ Layer.provide(
+ Layer.mergeAll(ProjectionStore.layer, ProjectStore.layer, layerThreadManagementProvided),
+ ),
+ ),
+ ),
+ ),
UsageLimitRecoveryWorker.layer.pipe(
Layer.provide(Layer.mergeAll(ProjectionStore.layer, layerThreadManagementProvided)),
),
layerProviderContinuationWorkerProvided,
layerAgentSessionImporterProvided,
EffectOutbox.layerPruneWorker.pipe(Layer.provide(EffectOutbox.layer)),
-).pipe(Layer.provide(Scheduler.layer), Layer.provideMerge(layerEventInfrastructure));
+).pipe(
+ Layer.provide(GitHubReplyReader.layer),
+ Layer.provide(Scheduler.layer),
+ Layer.provideMerge(layerEventInfrastructure),
+);
diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts
index 3d8ef589050e6..0cc0a7ef91e2e 100644
--- a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts
+++ b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts
@@ -140,6 +140,7 @@ function commandThreadIds(command: OrchestrationV2Command): ReadonlyArray,
+ queries: Array[0]>,
+) {
+ let index = 0;
+ return GitHubDiscussions.layer.pipe(
+ Layer.provide(
+ Layer.mock(GitHubApi.GitHubApi)({
+ graphql: (input) =>
+ Effect.sync(() => {
+ queries.push(input);
+ assert.isBelow(index, responses.length, "unexpected GitHub request");
+ return encode(responses[index++]);
+ }),
+ }),
+ ),
+ );
+}
+
+it.effect("links with a title-only read without fetching discussion comments", () => {
+ const queries: Array[0]> = [];
+ return Effect.gen(function* () {
+ const reader = yield* GitHubDiscussions.GitHubDiscussions;
+ assert.deepEqual(yield* reader.summary({ cwd: "/repo", url }), { title: "A title" });
+ assert.equal(queries.length, 1);
+ assert.notInclude(queries[0]!.query, "comments");
+ }).pipe(
+ Effect.provide(
+ layer([{ data: { repository: { discussion: { title: "A title" } } } }], queries),
+ ),
+ );
+});
diff --git a/apps/server/src/sourceControl/GitHubDiscussions.ts b/apps/server/src/sourceControl/GitHubDiscussions.ts
new file mode 100644
index 0000000000000..e2cda1f15b76b
--- /dev/null
+++ b/apps/server/src/sourceControl/GitHubDiscussions.ts
@@ -0,0 +1,74 @@
+import {
+ DiscussionOperationError,
+ TrimmedNonEmptyString,
+ parseGitHubConversationUrl,
+} from "@t3tools/contracts";
+import * as Context from "effect/Context";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Schema from "effect/Schema";
+import * as GitHubApi from "./GitHubApi.ts";
+
+const SummaryResponse = Schema.Struct({
+ data: Schema.Struct({
+ repository: Schema.NullOr(
+ Schema.Struct({
+ discussion: Schema.NullOr(Schema.Struct({ title: TrimmedNonEmptyString })),
+ }),
+ ),
+ }),
+});
+const decodeSummary = Schema.decodeUnknownEffect(Schema.fromJsonString(SummaryResponse));
+
+export class GitHubDiscussions extends Context.Service<
+ GitHubDiscussions,
+ {
+ readonly summary: (input: {
+ readonly cwd: string;
+ readonly url: string;
+ }) => Effect.Effect<{ readonly title: string }, DiscussionOperationError>;
+ }
+>()("t3/sourceControl/GitHubDiscussions") {}
+
+const make = Effect.gen(function* () {
+ const api = yield* GitHubApi.GitHubApi;
+ const summary = Effect.fn("GitHubDiscussions.summary")(function* ({
+ url,
+ }: {
+ readonly cwd: string;
+ readonly url: string;
+ }) {
+ const target = parseGitHubConversationUrl(url);
+ if (target?.kind !== "discussion")
+ return yield* new DiscussionOperationError({
+ message: "Use a github.com repository discussion URL.",
+ });
+ const output = yield* api
+ .graphql({
+ operation: "discussionSummary",
+ allowReserve: false,
+ host: "github.com",
+ query:
+ "query($owner: String!, $name: String!, $number: Int!) { repository(owner: $owner, name: $name) { discussion(number: $number) { title } } }",
+ variables: { owner: target.owner, name: target.repository, number: target.number },
+ })
+ .pipe(Effect.mapError((cause) => new DiscussionOperationError({ message: cause.message })));
+ const result = yield* decodeSummary(output).pipe(
+ Effect.mapError(
+ () =>
+ new DiscussionOperationError({
+ message: "GitHub returned an invalid discussion response.",
+ }),
+ ),
+ );
+ const discussion = result.data.repository?.discussion;
+ if (!discussion)
+ return yield* new DiscussionOperationError({
+ message: "Discussion not found. Check GitHub sign-in and repository access.",
+ });
+ return discussion;
+ });
+ return GitHubDiscussions.of({ summary });
+});
+
+export const layer = Layer.effect(GitHubDiscussions, make);
diff --git a/apps/server/src/sourceControl/GitHubReplyReader.test.ts b/apps/server/src/sourceControl/GitHubReplyReader.test.ts
new file mode 100644
index 0000000000000..47bff64ab3a13
--- /dev/null
+++ b/apps/server/src/sourceControl/GitHubReplyReader.test.ts
@@ -0,0 +1,631 @@
+import { assert, it } from "@effect/vitest";
+import { CommandId, type GitHubReplySnooze } from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Schema from "effect/Schema";
+import * as Redacted from "effect/Redacted";
+import * as Ref from "effect/Ref";
+import * as SourceControlRateLimit from "./SourceControlRateLimit.ts";
+import * as GitHubApi from "./GitHubApi.ts";
+import * as GitHubReplyReader from "./GitHubReplyReader.ts";
+
+const watch: GitHubReplySnooze = {
+ requestId: CommandId.make("watch"),
+ url: "https://github.com/team/repo/pull/1",
+ startedAt: "2026-10-01T12:00:00.000Z",
+ nextCheckAt: "2026-10-01T12:00:00.000Z",
+ status: "pending",
+ failures: 0,
+ baseline: { latestAt: "2026-10-01T11:00:00Z", ids: ["existing", "root-1", "root-2", "root"] },
+};
+const old = "2026-10-01T11:00:00Z";
+const recent = "2026-10-01T12:01:00Z";
+const comment = (id: string, author = "someone", createdAt = recent, databaseId = 1) => ({
+ id,
+ author: { login: author },
+ createdAt,
+ databaseId,
+});
+const page = (nodes: ReadonlyArray, cursor: string | null = null) => ({
+ nodes,
+ pageInfo: { hasPreviousPage: cursor !== null, startCursor: cursor },
+});
+const response = (data: object) => ({ data: { viewer: { login: "me" }, ...data } });
+const pr = (nodes: ReadonlyArray>, cursor: string | null = null) =>
+ response({ repository: { pullRequest: { comments: page(nodes, cursor) } } });
+const emptyReviews = response({ repository: { pullRequest: { reviews: page([]) } } });
+const emptyThreads = response({
+ repository: {
+ pullRequest: {
+ reviewThreads: { nodes: [], pageInfo: { hasNextPage: false, endCursor: null } },
+ },
+ },
+});
+const body = response({
+ node: {
+ url: "https://github.com/team/repo/pull/1#issuecomment-2",
+ bodyText: "Please consider this.",
+ author: { login: "someone" },
+ },
+});
+const encode = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
+
+function layer(
+ responses: ReadonlyArray,
+ queries: string[] = [],
+ fingerprint: Effect.Effect = Effect.succeed("test-credential"),
+) {
+ let index = 0;
+ return GitHubReplyReader.layer.pipe(
+ Layer.provide(
+ Layer.mock(GitHubApi.GitHubApi)({
+ credential: () =>
+ fingerprint.pipe(
+ Effect.map((fingerprint) => ({ token: Redacted.make("test-token"), fingerprint })),
+ ),
+ graphql: (input) =>
+ Effect.sync(() => {
+ queries.push(input.query);
+ assert.isBelow(index, responses.length, "unexpected GitHub request");
+ return encode(responses[index++]);
+ }),
+ }),
+ ),
+ );
+}
+
+it.effect("reads new feedback without filtering out merged or closed PRs", () =>
+ Effect.gen(function* () {
+ const reader = yield* GitHubReplyReader.GitHubReplyReader;
+ const result = yield* reader.read({ cwd: "/repo", watch });
+ assert.equal(result.reply?.text, "Please consider this.");
+ assert.equal(result.viewer, "me");
+ }).pipe(Effect.provide(layer([pr([comment("existing", "someone", old), comment("new")]), body]))),
+);
+
+it.effect("ignores existing comments, own comments and CI-only updates", () =>
+ Effect.gen(function* () {
+ const reader = yield* GitHubReplyReader.GitHubReplyReader;
+ assert.isNull((yield* reader.read({ cwd: "/repo", watch })).reply);
+ }).pipe(
+ Effect.provide(
+ layer([
+ pr([comment("existing", "someone", old), comment("own", "me")]),
+ emptyReviews,
+ emptyThreads,
+ ]),
+ ),
+ ),
+);
+
+it.effect("pages past newer self comments to another person's response", () =>
+ Effect.gen(function* () {
+ const reader = yield* GitHubReplyReader.GitHubReplyReader;
+ assert.isNotNull((yield* reader.read({ cwd: "/repo", watch })).reply);
+ }).pipe(Effect.provide(layer([pr([comment("own", "me")], "older"), pr([comment("new")]), body]))),
+);
+
+it.effect("finds replies on an old discussion comment beyond the first page", () =>
+ Effect.gen(function* () {
+ const reader = yield* GitHubReplyReader.GitHubReplyReader;
+ assert.isNotNull(
+ (yield* reader.read({
+ cwd: "/repo",
+ watch: { ...watch, url: "https://github.com/team/repo/discussions/1" },
+ })).reply,
+ );
+ }).pipe(
+ Effect.provide(
+ layer([
+ response({
+ repository: {
+ discussion: {
+ comments: page([{ ...comment("root-1", "me", old), replies: page([]) }], "older"),
+ },
+ },
+ }),
+ response({
+ repository: {
+ discussion: {
+ comments: page([
+ { ...comment("root-2", "me", old), replies: page([comment("own", "me")], "more") },
+ ]),
+ },
+ },
+ }),
+ response({ node: { replies: page([comment("own", "me")], "older-replies") } }),
+ response({ node: { replies: page([comment("reply")]) } }),
+ body,
+ ]),
+ ),
+ ),
+);
+
+it.effect("honors a discussion comment anchor and persists the resolved parent", () =>
+ Effect.gen(function* () {
+ const reader = yield* GitHubReplyReader.GitHubReplyReader;
+ const result = yield* reader.read({
+ cwd: "/repo",
+ watch: { ...watch, url: "https://github.com/team/repo/discussions/1#discussioncomment-5" },
+ });
+ assert.equal(result.discussionCommentId, "root");
+ assert.isNull(result.reply);
+ }).pipe(
+ Effect.provide(
+ layer([
+ response({
+ repository: {
+ discussion: {
+ comments: page([{ ...comment("root", "me", old, 5), replies: page([]) }]),
+ },
+ },
+ }),
+ response({ node: { replies: page([]) } }),
+ ]),
+ ),
+ ),
+);
+
+it.effect("detects a review submitted after snoozing even if its draft is older", () =>
+ Effect.gen(function* () {
+ const reader = yield* GitHubReplyReader.GitHubReplyReader;
+ assert.isNotNull((yield* reader.read({ cwd: "/repo", watch })).reply);
+ }).pipe(
+ Effect.provide(
+ layer([
+ pr([]),
+ response({
+ repository: {
+ pullRequest: {
+ reviews: page([
+ {
+ ...comment("review", "someone", old),
+ submittedAt: recent,
+ state: "CHANGES_REQUESTED",
+ },
+ ]),
+ },
+ },
+ }),
+ body,
+ ]),
+ ),
+ ),
+);
+
+it.effect("detects inline review replies and pages past own replies", () =>
+ Effect.gen(function* () {
+ const reader = yield* GitHubReplyReader.GitHubReplyReader;
+ assert.isNotNull((yield* reader.read({ cwd: "/repo", watch })).reply);
+ }).pipe(
+ Effect.provide(
+ layer([
+ pr([]),
+ emptyReviews,
+ response({
+ repository: {
+ pullRequest: {
+ reviewThreads: {
+ nodes: [{ id: "review-thread", comments: page([comment("own", "me")]) }],
+ pageInfo: { hasNextPage: false, endCursor: null },
+ },
+ },
+ },
+ }),
+ response({ node: { comments: page([comment("other"), comment("own", "me")]) } }),
+ body,
+ ]),
+ ),
+ ),
+);
+
+it.effect("reports missing conversations instead of treating them as successfully watched", () =>
+ Effect.gen(function* () {
+ const reader = yield* GitHubReplyReader.GitHubReplyReader;
+ const result = yield* reader.read({ cwd: "/repo", watch }).pipe(Effect.result);
+ assert.equal(result._tag, "Failure");
+ }).pipe(Effect.provide(layer([response({ repository: { pullRequest: null } })]))),
+);
+
+it.effect("shares cached reads for threads watching the same conversation", () =>
+ Effect.gen(function* () {
+ const reader = yield* GitHubReplyReader.GitHubReplyReader;
+ yield* reader.read({ cwd: "/repo", watch });
+ yield* reader.read({ cwd: "/repo", watch: { ...watch, requestId: CommandId.make("other") } });
+ }).pipe(Effect.provide(layer([pr([]), emptyReviews, emptyThreads]))),
+);
+
+it.effect.each(["2026-10-01T12:00:00.100Z", "2026-10-01T14:00:00.100Z"])(
+ "tracks same-second reply IDs independently of local start time %s after restart",
+ (startedAt) =>
+ Effect.gen(function* () {
+ const boundary = "2026-10-01T12:00:00Z";
+ const setup = yield* Effect.gen(function* () {
+ const reader = yield* GitHubReplyReader.GitHubReplyReader;
+ return yield* reader.read({
+ cwd: "/repo",
+ watch: { ...watch, startedAt },
+ captureBaseline: true,
+ });
+ }).pipe(
+ Effect.provide(
+ layer([
+ pr([comment("existing-2", "someone", boundary)], "older"),
+ pr([comment("existing-1", "someone", boundary), comment("ancient", "someone", old)]),
+ emptyReviews,
+ emptyThreads,
+ ]),
+ ),
+ );
+ assert.deepEqual(setup.baseline, { latestAt: boundary, ids: ["existing-2", "existing-1"] });
+ assert.isNull(setup.reply);
+ // Recreate the reader from only the persisted watch. A known ID in the newest page
+ // must not stop pagination before another response in the same second.
+ const restored = { ...watch, startedAt, viewer: setup.viewer, baseline: setup.baseline };
+ const reply = yield* Effect.gen(function* () {
+ const reader = yield* GitHubReplyReader.GitHubReplyReader;
+ return yield* reader.read({ cwd: "/repo", watch: restored });
+ }).pipe(
+ Effect.provide(
+ layer([
+ pr(
+ [comment("existing-2", "someone", boundary), comment("own", "me", boundary)],
+ "older",
+ ),
+ pr([comment("existing-1", "someone", boundary), comment("new", "someone", boundary)]),
+ body,
+ ]),
+ ),
+ );
+ assert.isNotNull(reply.reply);
+ }),
+);
+
+it.effect("ignores existing boundary IDs and new own comments", () =>
+ Effect.gen(function* () {
+ const reader = yield* GitHubReplyReader.GitHubReplyReader;
+ const result = yield* reader.read({
+ cwd: "/repo",
+ watch: {
+ ...watch,
+ baseline: { latestAt: recent, ids: ["existing"] },
+ },
+ });
+ assert.isNull(result.reply);
+ }).pipe(
+ Effect.provide(
+ layer([pr([comment("existing"), comment("own", "me")]), emptyReviews, emptyThreads]),
+ ),
+ ),
+);
+
+it.effect("captures fresh setup data even when a polling result is cached", () =>
+ Effect.gen(function* () {
+ const reader = yield* GitHubReplyReader.GitHubReplyReader;
+ yield* reader.read({ cwd: "/repo", watch });
+ const setup = yield* reader.read({ cwd: "/repo", watch, captureBaseline: true });
+ assert.deepEqual(setup.baseline, { latestAt: recent, ids: ["arrived-before-setup"] });
+ }).pipe(
+ Effect.provide(
+ layer([
+ pr([]),
+ emptyReviews,
+ emptyThreads,
+ pr([comment("arrived-before-setup")]),
+ emptyReviews,
+ emptyThreads,
+ ]),
+ ),
+ ),
+);
+
+it.effect("uses review submission time and leaves pending drafts outside the baseline", () =>
+ Effect.gen(function* () {
+ const setup = yield* Effect.gen(function* () {
+ const reader = yield* GitHubReplyReader.GitHubReplyReader;
+ return yield* reader.read({ cwd: "/repo", watch, captureBaseline: true });
+ }).pipe(
+ Effect.provide(
+ layer([
+ pr([]),
+ response({
+ repository: {
+ pullRequest: {
+ reviews: page([
+ {
+ ...comment("submitted", "someone", old),
+ submittedAt: recent,
+ state: "COMMENTED",
+ },
+ { ...comment("draft", "someone", old), submittedAt: null, state: "PENDING" },
+ ]),
+ },
+ },
+ }),
+ emptyThreads,
+ ]),
+ ),
+ );
+ assert.deepEqual(setup.baseline, { latestAt: recent, ids: ["submitted"] });
+ const result = yield* Effect.gen(function* () {
+ const reader = yield* GitHubReplyReader.GitHubReplyReader;
+ return yield* reader.read({ cwd: "/repo", watch: { ...watch, baseline: setup.baseline } });
+ }).pipe(
+ Effect.provide(
+ layer([
+ pr([]),
+ response({
+ repository: {
+ pullRequest: {
+ reviews: page(
+ [
+ {
+ ...comment("submitted", "someone", old),
+ submittedAt: recent,
+ state: "COMMENTED",
+ },
+ ],
+ "older-draft",
+ ),
+ },
+ },
+ }),
+ response({
+ repository: {
+ pullRequest: {
+ reviews: page([
+ {
+ ...comment("draft", "someone", old),
+ submittedAt: recent,
+ state: "CHANGES_REQUESTED",
+ },
+ ]),
+ },
+ },
+ }),
+ body,
+ ]),
+ ),
+ );
+ assert.isNotNull(result.reply);
+ }),
+);
+
+it.effect("fails visibly for a persisted watch without an observation baseline", () =>
+ Effect.gen(function* () {
+ const reader = yield* GitHubReplyReader.GitHubReplyReader;
+ const { baseline: _baseline, ...legacyWatch } = watch;
+ const error = yield* reader.read({ cwd: "/repo", watch: legacyWatch }).pipe(Effect.flip);
+ assert.equal(error.reason, "baseline-missing");
+ }).pipe(Effect.provide(layer([]))),
+);
+
+it.effect("keeps the selected discussion parent and boundary reply IDs through setup", () =>
+ Effect.gen(function* () {
+ const reader = yield* GitHubReplyReader.GitHubReplyReader;
+ const setup = yield* reader.read({
+ cwd: "/repo",
+ captureBaseline: true,
+ watch: {
+ ...watch,
+ url: "https://github.com/team/repo/discussions/1#discussioncomment-5",
+ },
+ });
+ assert.equal(setup.discussionCommentId, "root");
+ assert.deepEqual(setup.baseline, { latestAt: recent, ids: ["reply-2", "reply-1"] });
+ assert.isNull(setup.reply);
+ }).pipe(
+ Effect.provide(
+ layer([
+ response({
+ repository: {
+ discussion: {
+ comments: page([
+ { ...comment("root", "me", old, 5), replies: page([comment("reply-2")]) },
+ ]),
+ },
+ },
+ }),
+ response({ node: { replies: page([comment("reply-2")], "older") } }),
+ response({ node: { replies: page([comment("old", "someone", old), comment("reply-1")]) } }),
+ ]),
+ ),
+ ),
+);
+
+const changesWatch: GitHubReplySnooze = { ...watch, wakeCondition: "changes-requested" };
+const review = (
+ id: string,
+ state = "CHANGES_REQUESTED",
+ author = "someone",
+ submittedAt: string | null = recent,
+ createdAt = old,
+) => ({
+ ...comment(id, author, createdAt),
+ state,
+ submittedAt,
+});
+const reviews = (nodes: ReadonlyArray>, cursor: string | null = null) =>
+ response({ repository: { pullRequest: { reviews: page(nodes, cursor) } } });
+const emptyReviewBody = response({
+ node: {
+ url: "https://github.com/team/repo/pull/1#pullrequestreview-2",
+ bodyText: "",
+ author: { login: "someone" },
+ },
+});
+
+it.effect(
+ "changes-requested snooze reads only reviews and ignores approvals, comments, own and pending reviews",
+ () => {
+ const queries: string[] = [];
+ return Effect.gen(function* () {
+ const reader = yield* GitHubReplyReader.GitHubReplyReader;
+ const result = yield* reader.read({ cwd: "/repo", watch: changesWatch });
+ assert.isNull(result.reply);
+ assert.equal(queries.length, 1);
+ assert.include(queries[0]!, "reviews(last:");
+ assert.notInclude(queries[0]!, "comments(");
+ assert.notInclude(queries[0]!, "reviewThreads(");
+ }).pipe(
+ Effect.provide(
+ layer(
+ [
+ reviews([
+ review("existing", "CHANGES_REQUESTED", "someone", old),
+ review("approval", "APPROVED"),
+ review("comment", "COMMENTED"),
+ review("self", "CHANGES_REQUESTED", "me"),
+ review("pending", "PENDING", "someone", null),
+ review("unsubmitted", "CHANGES_REQUESTED", "someone", null),
+ ]),
+ ],
+ queries,
+ ),
+ ),
+ );
+ },
+);
+
+it.effect(
+ "changes-requested snooze wakes for an empty review submitted from an older draft beyond the first page",
+ () =>
+ Effect.gen(function* () {
+ const reader = yield* GitHubReplyReader.GitHubReplyReader;
+ const result = yield* reader.read({ cwd: "/repo", watch: changesWatch });
+ assert.deepEqual(result.reply, {
+ url: "https://github.com/team/repo/pull/1#pullrequestreview-2",
+ text: "",
+ author: "someone",
+ });
+ }).pipe(
+ Effect.provide(
+ layer([
+ reviews(
+ [
+ review("approval", "APPROVED"),
+ review("existing", "CHANGES_REQUESTED", "someone", old),
+ ],
+ "older",
+ ),
+ reviews([review("draft-submitted-later")]),
+ emptyReviewBody,
+ ]),
+ ),
+ ),
+);
+
+it.effect(
+ "captures all existing requested-changes reviews and preserves same-second novelty across restart",
+ () =>
+ Effect.gen(function* () {
+ const setup = yield* Effect.gen(function* () {
+ const reader = yield* GitHubReplyReader.GitHubReplyReader;
+ return yield* reader.read({ cwd: "/repo", watch: changesWatch, captureBaseline: true });
+ }).pipe(
+ Effect.provide(
+ layer([
+ reviews([review("existing-2"), review("draft", "PENDING", "someone", null)], "older"),
+ reviews([review("existing-1"), review("ancient", "CHANGES_REQUESTED", "someone", old)]),
+ ]),
+ ),
+ );
+ assert.isNull(setup.reply);
+ assert.deepEqual(setup.baseline, { latestAt: recent, ids: ["existing-2", "existing-1"] });
+ const restored = { ...changesWatch, viewer: setup.viewer, baseline: setup.baseline };
+ const unchanged = yield* Effect.gen(function* () {
+ const reader = yield* GitHubReplyReader.GitHubReplyReader;
+ return yield* reader.read({ cwd: "/repo", watch: restored });
+ }).pipe(Effect.provide(layer([reviews([review("existing-1"), review("existing-2")])])));
+ assert.isNull(unchanged.reply);
+ const result = yield* Effect.gen(function* () {
+ const reader = yield* GitHubReplyReader.GitHubReplyReader;
+ return yield* reader.read({ cwd: "/repo", watch: restored });
+ }).pipe(
+ Effect.provide(
+ layer([
+ reviews([review("existing-2")], "older"),
+ reviews([review("existing-1"), review("draft")]),
+ emptyReviewBody,
+ ]),
+ ),
+ );
+ assert.isNotNull(result.reply);
+ }),
+);
+
+it.effect("rejects a discussion for changes-requested snooze without reading GitHub", () =>
+ Effect.gen(function* () {
+ const reader = yield* GitHubReplyReader.GitHubReplyReader;
+ const error = yield* reader
+ .read({
+ cwd: "/repo",
+ watch: { ...changesWatch, url: "https://github.com/team/repo/discussions/1" },
+ captureBaseline: true,
+ })
+ .pipe(Effect.flip);
+ assert.equal(error.reason, "unavailable");
+ }).pipe(Effect.provide(layer([]))),
+);
+
+it.effect("does not reuse another credential's cached viewer", () =>
+ Effect.gen(function* () {
+ const fingerprint = yield* Ref.make("first-account");
+ const queries: string[] = [];
+ const reader = yield* GitHubReplyReader.GitHubReplyReader.pipe(
+ Effect.provide(
+ layer(
+ [
+ pr([]),
+ emptyReviews,
+ emptyThreads,
+ { data: { ...pr([]).data, viewer: { login: "other-account" } } },
+ ],
+ queries,
+ Ref.get(fingerprint),
+ ),
+ ),
+ );
+ const saved = { ...watch, viewer: "me" };
+ assert.isNull((yield* reader.read({ cwd: "/repo", watch: saved })).reply);
+ yield* Ref.set(fingerprint, "second-account");
+ const error = yield* reader.read({ cwd: "/repo", watch: saved }).pipe(Effect.flip);
+ assert.equal(error.reason, "account-changed");
+ assert.lengthOf(queries, 4);
+ }),
+);
+
+it.effect.each([
+ new GitHubApi.GitHubApiRateLimitError({
+ host: "github.com",
+ operation: "readGitHubReplies",
+ retryAt: 1_800_000_000_000,
+ }),
+ new SourceControlRateLimit.SourceControlRateLimitPausedError({
+ provider: "github",
+ host: "github.com",
+ retryAt: 1_800_000_000_000,
+ }),
+])("preserves GitHub quota pauses: %s", (cause) =>
+ Effect.gen(function* () {
+ const reader = yield* GitHubReplyReader.GitHubReplyReader;
+ const error = yield* reader.read({ cwd: "/repo", watch }).pipe(Effect.flip);
+ assert.equal(error.reason, "rate-limited");
+ assert.equal(error.retryAt, cause.retryAt);
+ }).pipe(
+ Effect.provide(
+ GitHubReplyReader.layer.pipe(
+ Layer.provide(
+ Layer.mock(GitHubApi.GitHubApi)({
+ credential: () =>
+ Effect.succeed({
+ token: Redacted.make("test-token"),
+ fingerprint: "test-credential",
+ }),
+ graphql: () => Effect.fail(cause),
+ }),
+ ),
+ ),
+ ),
+ ),
+);
diff --git a/apps/server/src/sourceControl/GitHubReplyReader.ts b/apps/server/src/sourceControl/GitHubReplyReader.ts
new file mode 100644
index 0000000000000..6d0144704fc39
--- /dev/null
+++ b/apps/server/src/sourceControl/GitHubReplyReader.ts
@@ -0,0 +1,421 @@
+import { parseGitHubConversationUrl, type GitHubReplySnooze } from "@t3tools/contracts";
+import * as Cache from "effect/Cache";
+import * as Context from "effect/Context";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Schema from "effect/Schema";
+import * as GitHubApi from "./GitHubApi.ts";
+import * as SourceControlRateLimit from "./SourceControlRateLimit.ts";
+
+export class GitHubReplyReadError extends Schema.TaggedError()(
+ "GitHubReplyReadError",
+ {
+ reason: Schema.Literals([
+ "unavailable",
+ "account-changed",
+ "comment-not-found",
+ "rate-limited",
+ "baseline-missing",
+ ]),
+ retryAt: Schema.optional(Schema.Number),
+ cause: Schema.optional(Schema.Defect()),
+ },
+) {
+ override get message() {
+ switch (this.reason) {
+ case "rate-limited":
+ return "GitHub checks are paused until the API rate limit resets.";
+ case "account-changed":
+ return "The GitHub account changed. Snooze again using the intended account.";
+ case "comment-not-found":
+ return "The selected discussion comment is no longer available.";
+ case "baseline-missing":
+ return "This saved GitHub snooze needs to be renewed. Snooze again to continue checking replies.";
+ case "unavailable":
+ return "GitHub replies could not be checked. Check GitHub sign-in and repository access, then snooze again.";
+ }
+ }
+}
+
+const Author = Schema.NullOr(Schema.Struct({ login: Schema.String }));
+const Comment = Schema.Struct({
+ id: Schema.String,
+ databaseId: Schema.optional(Schema.NullOr(Schema.Number)),
+ createdAt: Schema.String,
+ author: Author,
+});
+const PageInfo = Schema.Struct({
+ hasPreviousPage: Schema.Boolean,
+ startCursor: Schema.NullOr(Schema.String),
+});
+const Comments = Schema.Struct({ nodes: Schema.Array(Comment), pageInfo: PageInfo });
+const DiscussionComments = Schema.Struct({
+ nodes: Schema.Array(Schema.Struct({ ...Comment.fields, replies: Comments })),
+ pageInfo: PageInfo,
+});
+const Reviews = Schema.Struct({
+ nodes: Schema.Array(
+ Schema.Struct({
+ ...Comment.fields,
+ submittedAt: Schema.NullOr(Schema.String),
+ state: Schema.String,
+ }),
+ ),
+ pageInfo: PageInfo,
+});
+const ReviewThreads = Schema.Struct({
+ nodes: Schema.Array(Schema.Struct({ id: Schema.String, comments: Comments })),
+ pageInfo: Schema.Struct({ hasNextPage: Schema.Boolean, endCursor: Schema.NullOr(Schema.String) }),
+});
+const commentFields = "id databaseId createdAt author { login }";
+const pageFields = "pageInfo { hasPreviousPage startCursor }";
+const Response = Schema.Struct({
+ data: Schema.Struct({
+ viewer: Schema.Struct({ login: Schema.String }),
+ repository: Schema.optional(
+ Schema.NullOr(
+ Schema.Struct({
+ pullRequest: Schema.optional(
+ Schema.NullOr(
+ Schema.Struct({
+ comments: Schema.optional(Comments),
+ reviews: Schema.optional(Reviews),
+ reviewThreads: Schema.optional(ReviewThreads),
+ }),
+ ),
+ ),
+ discussion: Schema.optional(
+ Schema.NullOr(Schema.Struct({ comments: DiscussionComments })),
+ ),
+ }),
+ ),
+ ),
+ node: Schema.optional(
+ Schema.NullOr(
+ Schema.Struct({
+ replies: Schema.optional(Comments),
+ comments: Schema.optional(Comments),
+ bodyText: Schema.optional(Schema.String),
+ url: Schema.optional(Schema.String),
+ author: Schema.optional(Author),
+ }),
+ ),
+ ),
+ }),
+});
+const Reply = Schema.Struct({ url: Schema.String, text: Schema.String, author: Schema.String });
+type Reply = typeof Reply.Type;
+
+export class GitHubReplyReader extends Context.Service<
+ GitHubReplyReader,
+ {
+ readonly read: (input: {
+ readonly cwd: string;
+ readonly watch: GitHubReplySnooze;
+ readonly captureBaseline?: boolean;
+ }) => Effect.Effect<
+ {
+ readonly viewer: string;
+ readonly discussionCommentId?: string;
+ readonly reply: Reply | null;
+ readonly baseline: NonNullable;
+ },
+ GitHubReplyReadError
+ >;
+ }
+>()("t3/sourceControl/GitHubReplyReader") {}
+
+const Variables = Schema.Record(
+ Schema.String,
+ Schema.Union([Schema.String, Schema.Number, Schema.Null]),
+);
+const QueryKey = Schema.fromJsonString(
+ Schema.Tuple([Schema.String, Schema.String, Schema.String, Variables]),
+);
+const encodeKey = Schema.encodeSync(QueryKey);
+const decodeKey = Schema.decodeUnknownEffect(QueryKey);
+const decodeResponse = Schema.decodeUnknownEffect(Schema.fromJsonString(Response));
+
+const make = Effect.gen(function* () {
+ const api = yield* GitHubApi.GitHubApi;
+ const mapApiError = (cause: GitHubApi.GitHubApiError) => {
+ const rateLimited =
+ cause._tag === "GitHubApiRateLimitError" ||
+ cause._tag === "SourceControlRateLimitPausedError";
+ return new GitHubReplyReadError({
+ reason: rateLimited ? "rate-limited" : "unavailable",
+ ...(rateLimited && cause.retryAt !== undefined ? { retryAt: cause.retryAt } : {}),
+ cause,
+ });
+ };
+ const queryRaw = Effect.fn("GitHubReplyReader.query")(function* (
+ _cwd: string,
+ document: string,
+ variables: Readonly>,
+ ) {
+ const result = yield* api
+ .graphql({
+ host: "github.com",
+ operation: "readGitHubReplies",
+ query: document,
+ variables,
+ allowReserve: false,
+ })
+ .pipe(Effect.mapError(mapApiError));
+ return yield* decodeResponse(result).pipe(
+ Effect.mapError((cause) => new GitHubReplyReadError({ reason: "unavailable", cause })),
+ );
+ });
+
+ const queries = yield* Cache.make({
+ capacity: 256,
+ timeToLive: "30 seconds",
+ lookup: (key: string) =>
+ decodeKey(key).pipe(
+ Effect.orDie,
+ Effect.flatMap(([, cwd, document, variables]) => queryRaw(cwd, document, variables)),
+ ),
+ });
+ const query = (
+ cwd: string,
+ document: string,
+ variables: Readonly>,
+ credentialFingerprint: string,
+ ) => Cache.get(queries, encodeKey([credentialFingerprint, cwd, document, variables]));
+
+ const readConversation = Effect.fnUntraced(function* (
+ { cwd, watch, captureBaseline = false }: Parameters[0],
+ credentialFingerprint: string,
+ ) {
+ const target = parseGitHubConversationUrl(watch.url);
+ const changesRequested = watch.wakeCondition === "changes-requested";
+ if (!target || (changesRequested && target.kind !== "pull"))
+ return yield* new GitHubReplyReadError({ reason: "unavailable" });
+ if (!captureBaseline && !watch.baseline)
+ return yield* new GitHubReplyReadError({ reason: "baseline-missing" });
+ // Setup must see fresh data. Only acknowledge snooze after this observation completes.
+ // GitHub has no snapshot across connections; responses observed during setup are existing.
+ const readQuery = (
+ cwd: string,
+ document: string,
+ variables: Readonly>,
+ ) =>
+ captureBaseline
+ ? queryRaw(cwd, document, variables)
+ : query(cwd, document, variables, credentialFingerprint);
+ let baseline = (captureBaseline ? undefined : watch.baseline) ?? { latestAt: null, ids: [] };
+ const existingIds = new Set(baseline.ids);
+ const observe = (comment: typeof Comment.Type) => {
+ if (!captureBaseline) return;
+ const timestamp = Date.parse(comment.createdAt);
+ const latest = baseline.latestAt === null ? -Infinity : Date.parse(baseline.latestAt);
+ if (timestamp > latest) baseline = { latestAt: comment.createdAt, ids: [comment.id] };
+ else if (timestamp === latest) baseline = { ...baseline, ids: [...baseline.ids, comment.id] };
+ };
+ let viewer = watch.viewer;
+ const checkViewer = (login: string) => {
+ if (viewer !== undefined && viewer !== login)
+ return Effect.fail(new GitHubReplyReadError({ reason: "account-changed" }));
+ viewer = login;
+ return Effect.void;
+ };
+ const isNew = (comment: typeof Comment.Type) => {
+ if (captureBaseline || baseline.latestAt === null) return true;
+ const timestamp = Date.parse(comment.createdAt);
+ const latest = Date.parse(baseline.latestAt);
+ return timestamp > latest || (timestamp === latest && !existingIds.has(comment.id));
+ };
+ const isBeforeBaseline = (comment: typeof Comment.Type) =>
+ baseline.latestAt !== null && Date.parse(comment.createdAt) < Date.parse(baseline.latestAt);
+ const mayContainResponse = (comment: typeof Comment.Type) =>
+ captureBaseline ? !isBeforeBaseline(comment) : isNew(comment);
+ const isResponse = (comment: typeof Comment.Type) => {
+ observe(comment);
+ return (
+ !captureBaseline &&
+ isNew(comment) &&
+ comment.author !== null &&
+ comment.author.login !== viewer
+ );
+ };
+ let responseId: string | undefined;
+ let discussionCommentId = watch.discussionCommentId;
+
+ // Replies are paged independently: a newer self-reply must not hide another person's response.
+ const readReplies = Effect.fn("GitHubReplyReader.replies")(function* (
+ id: string,
+ findAnchor?: number,
+ reviewThread = false,
+ ) {
+ let before: string | null = null;
+ while (true) {
+ const result: typeof Response.Type = yield* readQuery(
+ cwd,
+ `query($id: ID!, $before: String) { viewer { login } node(id: $id) { ... on ${reviewThread ? "PullRequestReviewThread" : "DiscussionComment"} { ${reviewThread ? "comments" : "replies"}(last: 100, before: $before) { nodes { ${commentFields} } ${pageFields} } } } }`,
+ { id, before },
+ );
+ yield* checkViewer(result.data.viewer.login);
+ const page: typeof Comments.Type | undefined = reviewThread
+ ? result.data.node?.comments
+ : result.data.node?.replies;
+ if (!page) return yield* new GitHubReplyReadError({ reason: "comment-not-found" });
+ if (findAnchor !== undefined) {
+ if (page.nodes.some((comment) => comment.databaseId === findAnchor)) return true;
+ } else {
+ responseId = page.nodes.find(isResponse)?.id;
+ if (responseId || page.nodes.some(isBeforeBaseline)) return false;
+ }
+ if (!page.pageInfo.hasPreviousPage) return false;
+ if (!page.pageInfo.startCursor || page.pageInfo.startCursor === before)
+ return yield* new GitHubReplyReadError({ reason: "unavailable" });
+ before = page.pageInfo.startCursor;
+ }
+ });
+
+ if (target.kind === "discussion" && discussionCommentId) {
+ yield* readReplies(discussionCommentId);
+ } else if (!changesRequested) {
+ let before: string | null = null;
+ while (true) {
+ const field = target.kind === "pull" ? "pullRequest" : "discussion";
+ const replies =
+ target.kind === "pull"
+ ? ""
+ : `replies(last: 1) { nodes { ${commentFields} } ${pageFields} }`;
+ const result: typeof Response.Type = yield* readQuery(
+ cwd,
+ `query($owner: String!, $name: String!, $number: Int!, $before: String) { viewer { login } repository(owner: $owner, name: $name) { ${field}(number: $number) { comments(last: 100, before: $before) { nodes { ${commentFields} ${replies} } ${pageFields} } } } }`,
+ { owner: target.owner, name: target.repository, number: target.number, before },
+ );
+ yield* checkViewer(result.data.viewer.login);
+ if (target.kind === "pull") {
+ const page: typeof Comments.Type | undefined =
+ result.data.repository?.pullRequest?.comments;
+ if (!page) return yield* new GitHubReplyReadError({ reason: "unavailable" });
+ responseId = page.nodes.find(isResponse)?.id;
+ if (responseId || page.nodes.some(isBeforeBaseline) || !page.pageInfo.hasPreviousPage)
+ break;
+ if (!page.pageInfo.startCursor || page.pageInfo.startCursor === before)
+ return yield* new GitHubReplyReadError({ reason: "unavailable" });
+ before = page.pageInfo.startCursor;
+ } else {
+ const page: typeof DiscussionComments.Type | undefined =
+ result.data.repository?.discussion?.comments;
+ if (!page) return yield* new GitHubReplyReadError({ reason: "unavailable" });
+ for (const comment of page.nodes) {
+ if (target.commentId !== null) {
+ if (
+ comment.databaseId === target.commentId ||
+ (yield* readReplies(comment.id, target.commentId))
+ ) {
+ discussionCommentId = comment.id;
+ yield* readReplies(comment.id);
+ break;
+ }
+ } else {
+ if (isResponse(comment)) responseId = comment.id;
+ if (!responseId && comment.replies.nodes.some(mayContainResponse))
+ yield* readReplies(comment.id);
+ if (responseId) break;
+ }
+ }
+ if (responseId || discussionCommentId || !page.pageInfo.hasPreviousPage) break;
+ if (!page.pageInfo.startCursor || page.pageInfo.startCursor === before)
+ return yield* new GitHubReplyReadError({ reason: "unavailable" });
+ before = page.pageInfo.startCursor;
+ }
+ }
+ }
+ // reviews and inline threads are independent connections. PR state and CI changes never enter this reader.
+ if (target.kind === "pull" && !responseId) {
+ let before: string | null = null;
+ while (true) {
+ const result: typeof Response.Type = yield* readQuery(
+ cwd,
+ `query($owner: String!, $name: String!, $number: Int!, $before: String) { viewer { login } repository(owner: $owner, name: $name) { pullRequest(number: $number) { reviews(last: 100, before: $before) { nodes { ${commentFields} submittedAt state } ${pageFields} } } } }`,
+ { owner: target.owner, name: target.repository, number: target.number, before },
+ );
+ yield* checkViewer(result.data.viewer.login);
+ const page: typeof Reviews.Type | undefined = result.data.repository?.pullRequest?.reviews;
+ if (!page) return yield* new GitHubReplyReadError({ reason: "unavailable" });
+ responseId = page.nodes.find(
+ (review) =>
+ (changesRequested
+ ? review.state === "CHANGES_REQUESTED"
+ : review.state !== "PENDING") &&
+ review.submittedAt !== null &&
+ isResponse({ ...review, createdAt: review.submittedAt }),
+ )?.id;
+ if (responseId || !page.pageInfo.hasPreviousPage) break;
+ // Reviews are ordered by creation, not submission. An old draft submitted today must still be read.
+ if (!page.pageInfo.startCursor || page.pageInfo.startCursor === before)
+ return yield* new GitHubReplyReadError({ reason: "unavailable" });
+ before = page.pageInfo.startCursor;
+ }
+ let after: string | null = null;
+ while (true) {
+ if (changesRequested || responseId) break;
+ const result: typeof Response.Type = yield* readQuery(
+ cwd,
+ `query($owner: String!, $name: String!, $number: Int!, $after: String) { viewer { login } repository(owner: $owner, name: $name) { pullRequest(number: $number) { reviewThreads(first: 100, after: $after) { nodes { id comments(last: 1) { nodes { ${commentFields} } ${pageFields} } } pageInfo { hasNextPage endCursor } } } } }`,
+ { owner: target.owner, name: target.repository, number: target.number, after },
+ );
+ yield* checkViewer(result.data.viewer.login);
+ const page: typeof ReviewThreads.Type | undefined =
+ result.data.repository?.pullRequest?.reviewThreads;
+ if (!page) return yield* new GitHubReplyReadError({ reason: "unavailable" });
+ for (const thread of page.nodes) {
+ if (thread.comments.nodes.some(mayContainResponse))
+ yield* readReplies(thread.id, undefined, true);
+ if (responseId) break;
+ }
+ if (responseId || !page.pageInfo.hasNextPage) break;
+ if (!page.pageInfo.endCursor || page.pageInfo.endCursor === after)
+ return yield* new GitHubReplyReadError({ reason: "unavailable" });
+ after = page.pageInfo.endCursor;
+ }
+ }
+ if (target.commentId !== null && !discussionCommentId)
+ return yield* new GitHubReplyReadError({ reason: "comment-not-found" });
+ let reply: Reply | null = null;
+ if (responseId) {
+ const result: typeof Response.Type = yield* readQuery(
+ cwd,
+ "query($id: ID!) { viewer { login } node(id: $id) { ... on IssueComment { bodyText url author { login } } ... on DiscussionComment { bodyText url author { login } } ... on PullRequestReview { bodyText url author { login } } ... on PullRequestReviewComment { bodyText url author { login } } } }",
+ { id: responseId },
+ );
+ yield* checkViewer(result.data.viewer.login);
+ const comment = result.data.node;
+ if (!comment?.url || comment.bodyText === undefined || !comment.author)
+ return yield* new GitHubReplyReadError({ reason: "unavailable" });
+ reply = {
+ url: comment.url,
+ text: comment.bodyText.slice(0, 6000),
+ author: comment.author.login,
+ };
+ }
+ if (!viewer) return yield* new GitHubReplyReadError({ reason: "unavailable" });
+ return {
+ viewer,
+ ...(discussionCommentId === undefined ? {} : { discussionCommentId }),
+ reply,
+ baseline,
+ };
+ });
+ const read = Effect.fn("GitHubReplyReader.read")(function* (
+ input: Parameters[0],
+ ) {
+ const credential = yield* api.credential("github.com").pipe(Effect.mapError(mapApiError));
+ return yield* readConversation(input, credential.fingerprint).pipe(
+ Effect.provideService(GitHubApi.PinnedGitHubCredential, {
+ host: "github.com",
+ token: credential.token,
+ credentialFingerprint: credential.fingerprint,
+ }),
+ Effect.provideService(SourceControlRateLimit.CredentialScope, credential.fingerprint),
+ );
+ });
+ return GitHubReplyReader.of({ read });
+});
+
+export const layer = Layer.effect(GitHubReplyReader, make);
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
index 69a204ccd8a35..b8508cc3ec855 100644
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -95,6 +95,7 @@ import { readPastedComposerContext } from "./composerInlineTokenPaste";
import { isPasteAsTextShortcut } from "@t3tools/client-runtime/text-paste";
import { effectiveSnoozed, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled";
import { useAcknowledgeThreadWoke, useThreadActions } from "../hooks/useThreadActions";
+import { useOpenChangeRequestLink } from "../lib/openPullRequestLink";
import {
deriveProviderSubagentStatus,
deriveReportedModelSelection,
@@ -153,6 +154,7 @@ import {
lazy,
memo,
type SetStateAction,
+ type MouseEvent,
Suspense,
useCallback,
useEffect,
@@ -1591,6 +1593,7 @@ export default function ChatView(props: ChatViewProps) {
[environmentId, threadId],
);
const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]);
+ const openChangeRequestLink = useOpenChangeRequestLink(routeThreadRef);
const currentRouteThreadKeyRef = useRef(routeThreadKey);
useLayoutEffect(() => {
currentRouteThreadKeyRef.current = routeThreadKey;
@@ -7268,6 +7271,26 @@ export default function ChatView(props: ChatViewProps) {
setUnsnoozingThreadKey((current) => (current === threadKey ? null : current));
}
}, [activeThreadRef, activeThreadShell, unsnoozeThreadMutation, updateThreadMetadata]);
+ const openGitHubConversation = useCallback(
+ (event: MouseEvent, url: string) => {
+ if (event.metaKey || event.ctrlKey) return;
+ event.preventDefault();
+ event.stopPropagation();
+ if (openChangeRequestLink(event, url) || !activeThreadRef) return;
+ void openUrlInPreview({ threadRef: activeThreadRef, url, openPreview }).then((result) => {
+ if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return;
+ const error = squashAtomCommandFailure(result);
+ toastManager.add(
+ stackedThreadToast({
+ type: "error",
+ title: "Unable to open GitHub conversation",
+ description: error instanceof Error ? error.message : "An error occurred.",
+ }),
+ );
+ });
+ },
+ [activeThreadRef, openChangeRequestLink, openPreview],
+ );
const [isRestoringThreadBranch, setIsRestoringThreadBranch] = useState(false);
const [branchRestoreConfirmOpen, setBranchRestoreConfirmOpen] = useState(false);
// Once revealed for a given mismatch, the banner stays mounted until the
@@ -7605,7 +7628,8 @@ export default function ChatView(props: ChatViewProps) {
]);
// Settled, snoozed, and woke are thread state, not composer actions: each
// gets one quiet line after the last message instead of a banner. A woken
- // thread announces itself here, not just in the sidebar pill. Dismissing
+ // timed snooze announces itself here as well as in the sidebar pill; GitHub
+ // feedback wakes use only the unread state. Dismissing a timed wake
// marks the wake as seen (same acknowledgment as the pill); sending a
// message clears it as a side effect of the send path.
// Memoized: it is the timeline's list footer, and a new element re-renders that footer.
@@ -7616,9 +7640,27 @@ export default function ChatView(props: ChatViewProps) {
}
label={
- activeThreadShell?.snoozedUntil
- ? `Snoozed, ${formatRelativeTimeUntilLabel(activeThreadShell.snoozedUntil)}`
- : "Snoozed"
+ activeThreadShell?.githubReplySnooze ? (
+ <>
+ {activeThreadShell.githubReplySnooze.wakeCondition === "changes-requested"
+ ? "Waiting for changes to be requested"
+ : "Waiting for a GitHub reply"}
+ {" · "}
+
+ openGitHubConversation(event, activeThreadShell.githubReplySnooze!.url)
+ }
+ >
+ View
+
+ >
+ ) : activeThreadShell?.snoozedUntil ? (
+ `Snoozed, ${formatRelativeTimeUntilLabel(activeThreadShell.snoozedUntil)}`
+ ) : (
+ "Snoozed"
+ )
}
actionLabel={isUnsnoozing ? "Waking..." : "Wake now"}
actionDisabled={!canOperateThread || isUnsnoozing}
@@ -7636,7 +7678,7 @@ export default function ChatView(props: ChatViewProps) {
actionDisabled={!canOperateThread || isUnsettling}
onAction={() => void handleUnsettleActiveThread()}
/>
- ) : activeThreadWokeVisible ? (
+ ) : activeThreadWokeVisible && !activeThreadShell?.githubReplyNotice ? (
}
label="Woke from snooze"
@@ -7647,8 +7689,8 @@ export default function ChatView(props: ChatViewProps) {
}, [
acknowledgeActiveThreadWoke,
activeThreadSettled,
- activeThreadShell?.settledAt,
- activeThreadShell?.snoozedUntil,
+ activeThreadShell,
+ openGitHubConversation,
activeThreadSnoozed,
activeThreadWokeVisible,
canOperateThread,
diff --git a/apps/web/src/components/GitHubReplySnoozeMenu.tsx b/apps/web/src/components/GitHubReplySnoozeMenu.tsx
new file mode 100644
index 0000000000000..f353ea41e81f2
--- /dev/null
+++ b/apps/web/src/components/GitHubReplySnoozeMenu.tsx
@@ -0,0 +1,71 @@
+import {
+ githubReplyConversationLabel,
+ githubReplyConversationsForCondition,
+ type GitHubReplySnoozeChoice,
+ type GitHubReplyConversation,
+} from "@t3tools/client-runtime/github-reply-conversations";
+import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
+import { MenuItem, MenuSub, MenuSubPopup, MenuSubTrigger } from "./ui/menu";
+
+/** Several saved conversations extend the current menu from this row. */
+export function GitHubReplySnoozeMenuItem({
+ conversations,
+ onSnooze,
+ wakeCondition,
+}: {
+ conversations: ReadonlyArray;
+ onSnooze: (choice: GitHubReplySnoozeChoice) => void;
+ wakeCondition?: GitHubReplySnoozeChoice["wakeCondition"];
+}) {
+ conversations = githubReplyConversationsForCondition(conversations, wakeCondition);
+ const label =
+ wakeCondition === "changes-requested" ? "Until changes are requested" : "Until a GitHub reply";
+ const only = conversations.length === 1 ? conversations[0] : undefined;
+ if (conversations.length < 2) {
+ return (
+
+ );
+ }
+ return (
+
+ event.stopPropagation()}>
+ {label}
+
+
+ {conversations.map((conversation) => (
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx
index 3e4d53d55b63f..d14386ddb08e8 100644
--- a/apps/web/src/components/Icons.tsx
+++ b/apps/web/src/components/Icons.tsx
@@ -69,6 +69,14 @@ export const LinuxIcon: Icon = ({ className, ...props }) => (
);
+// GitHub Octicons comment-discussion-16. Copyright (c) 2026 GitHub Inc., MIT.
+// https://github.com/primer/octicons/blob/main/LICENSE
+export const GitHubDiscussionIcon: Icon = (props) => (
+
+);
+
export const GitHubIcon: Icon = (props) => (