diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 6381547468ae..36edc37b1a1e 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -233,6 +233,7 @@ export const make = Effect.gen(function* () { threadRestartContinuation: true, projectSettingsOverrides: true, threadSnooze: true, + threadSnoozeUntilAttention: true, environmentThemes: true, usageLimitSources: true, usagePriceOverrides: true, diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 3505e4e58633..d00de8133e70 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -1,4 +1,5 @@ import * as Orchestrator from "../orchestration-v2/Orchestrator.ts"; +import * as PullRequestWatchReactor from "../orchestration-v2/PullRequestWatchReactor.ts"; import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; import { expect, it } from "@effect/vitest"; import { NodeHttpServer } from "@effect/platform-node"; @@ -72,6 +73,7 @@ const layerPullRequestsTest = McpHttpServer.layerPullRequestsToolkit.pipe( Layer.mergeAll( Layer.mock(ProjectService.ProjectService)({}), Layer.mock(Orchestrator.OrchestratorV2)({}), + Layer.mock(PullRequestWatchReactor.PullRequestWatchReactor)({}), Layer.mock(ProjectionStore.ProjectionStoreV2)({}), NodeServices.layer, ), diff --git a/apps/server/src/mcp/McpProviderSession.ts b/apps/server/src/mcp/McpProviderSession.ts index 5bf76d85b7b5..3626b324e1ae 100644 --- a/apps/server/src/mcp/McpProviderSession.ts +++ b/apps/server/src/mcp/McpProviderSession.ts @@ -16,6 +16,11 @@ export interface McpProviderSessionConfig { readonly browserToolsAvailable: boolean; /** Capabilities the credential grants ("preview", "device"). */ readonly capabilities?: ReadonlySet; + /** + * The user's snoozePullRequestsAwaitingReview setting when the thread attached, which adapters + * pass to the runtime instructions. + */ + readonly snoozePullRequestsAwaitingReview?: boolean; /** * Set when the session may drive devices. Adapters spread this into the * provider subprocess environment so the `agent-device` CLI is on PATH and diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts index 6101727cd89c..4b070f7635c3 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts @@ -17,6 +17,7 @@ import * as Stream from "effect/Stream"; import type { Tool } from "effect/ai"; import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts"; +import * as PullRequestWatchReactor from "../../../orchestration-v2/PullRequestWatchReactor.ts"; import { type PullRequestTestThread, v2PullRequestThread, @@ -135,6 +136,8 @@ interface HarnessOptions { readonly project?: OrchestrationProjectShell | null; /** A rejection the orchestrator reports as the dispatch error's cause. */ readonly reject?: (command: OrchestrationCommand) => string | null; + /** The pull request a snooze cannot read. */ + readonly unreadable?: number; } const makeHarness = Effect.fn("makePullRequestsToolkitHarness")(function* ( @@ -164,6 +167,18 @@ const makeHarness = Effect.fn("makePullRequestsToolkitHarness")(function* ( Effect.succeed(id === THREAD_ID && thread ? v2PullRequestThread(thread) : null), dispatch, }), + Layer.mock(PullRequestWatchReactor.PullRequestWatchReactor)({ + snoozeUntilAttention: (command) => + options.unreadable === undefined + ? dispatch({ ...command, links: [], watches: [] }) + : Effect.fail( + new PullRequestWatchReactor.PullRequestAttentionReadError({ + threadId: command.threadId, + number: options.unreadable, + cause: "host unavailable", + }), + ), + }), Layer.succeed(Crypto.Crypto, testCrypto), McpToolAccessTestkit.liveThreadsLayer, ); @@ -302,6 +317,35 @@ describe("pull request toolkit handlers", () => { }), ); + it.effect("snoozes its own thread until a pull request needs attention", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ thread: makeThread([makeLink(4)]) }); + yield* harness.call("snooze_until_pull_request_needs_attention", {}); + expect(yield* Ref.get(harness.commands)).toMatchObject([ + { type: "thread.snooze-until-attention", threadId: THREAD_ID }, + ]); + + // The orchestrator's reason for refusing reaches the agent. + const refused = yield* makeHarness({ + reject: () => `Thread ${THREAD_ID} has a queued run and cannot be snoozed.`, + }); + const error = yield* refused + .call("snooze_until_pull_request_needs_attention", {}) + .pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "PullRequestSnoozeFailedError" }); + expect(error.message).toBe(`Thread ${THREAD_ID} has a queued run and cannot be snoozed.`); + + // So does a pull request it could not read. + const unreadable = yield* makeHarness({ thread: makeThread([makeLink(4)]), unreadable: 4 }); + const unread = yield* unreadable + .call("snooze_until_pull_request_needs_attention", {}) + .pipe(Effect.flip); + expect(unread.message).toBe( + "Could not read pull request #4, so the thread was not snoozed. Try again.", + ); + }), + ); + it.effect("refuses a watch from a subagent thread, whose parent owns the pull request", () => Effect.gen(function* () { const harness = yield* makeHarness({ diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts index cced33767054..f87afa47223a 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts @@ -24,6 +24,7 @@ import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts"; +import * as PullRequestWatchReactor from "../../../orchestration-v2/PullRequestWatchReactor.ts"; import * as ProjectService from "../../../project/ProjectService.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; @@ -36,6 +37,7 @@ import { PullRequestUnlinkFailedError, PullRequestListFailedError, PullRequestNotOpenError, + PullRequestSnoozeFailedError, type PullRequestTargetInput, PullRequestWatchFailedError, PullRequestWatchFromSubagentError, @@ -45,6 +47,21 @@ import { type ThreadPullRequestEntry, } from "./tools.ts"; +/** Why T3 Code refused a command, which the agent can act on. */ +function refusalOf( + cause: Cause.Cause< + Orchestrator.OrchestratorV2Error | PullRequestWatchReactor.PullRequestAttentionReadError + >, +): string | undefined { + const error = Option.getOrUndefined(Cause.findErrorOption(cause)); + if (error?._tag === "PullRequestAttentionReadError") return error.message; + return (error?._tag === "OrchestratorDispatchError" || + error?._tag === "OrchestratorCommandRejectedError") && + typeof error.cause === "string" + ? error.cause + : undefined; +} + interface ResolvedTarget { readonly host: string; readonly repository: string; @@ -155,6 +172,7 @@ export function listThreadPullRequests( const make = Effect.gen(function* () { const engine = yield* Orchestrator.OrchestratorV2; + const pullRequestWatch = yield* PullRequestWatchReactor.PullRequestWatchReactor; const projects = yield* ProjectService.ProjectService; const crypto = yield* Crypto.Crypto; @@ -170,7 +188,8 @@ const make = Effect.gen(function* () { | typeof PullRequestLinkFailedError | typeof PullRequestUnlinkFailedError | typeof PullRequestListFailedError - | typeof PullRequestWatchFailedError, + | typeof PullRequestWatchFailedError + | typeof PullRequestSnoozeFailedError, requested: ThreadId | undefined, ) { const scope = yield* McpInvocationContext.requireMcpCapability("pull-requests"); @@ -342,6 +361,42 @@ const make = Effect.gen(function* () { ), watch_pull_request: writesThread((input) => setWatching(input, true)), unwatch_pull_request: writesThread((input) => setWatching(input, false)), + snooze_until_pull_request_needs_attention: writesThread((input) => + Effect.gen(function* () { + const thread = yield* requireThread(PullRequestSnoozeFailedError, input.threadId); + yield* pullRequestWatch + .snoozeUntilAttention({ + type: "thread.snooze-until-attention", + commandId: yield* commandId("mcp-pr-snooze", thread.id), + threadId: thread.id, + createdBy: "agent", + }) + .pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause as Cause.Cause) + : Effect.fail( + new PullRequestSnoozeFailedError({ reason: refusalOf(cause), cause }), + ), + ), + ); + const after = yield* requireThread(PullRequestSnoozeFailedError, thread.id); + return { + watching: visibleThreadPullRequests(after.pullRequests ?? []).flatMap((link) => + link.watch === undefined + ? [] + : [ + { + host: normalizeThreadPullRequestKey(link).host, + repository: link.repository, + number: link.number, + url: link.url, + }, + ], + ), + }; + }), + ), } satisfies McpToolAccess.Handlers; }); diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts index e5c3d53503ff..38076be36c67 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -153,6 +153,19 @@ export class PullRequestWatchFromSubagentError extends Schema.TaggedError()( + "PullRequestSnoozeFailedError", + { + /** Why T3 Code refused, when it did. */ + reason: Schema.optional(Schema.String), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return this.reason ?? "Could not snooze the thread."; + } +} + export class PullRequestListFailedError extends Schema.TaggedError()( "PullRequestListFailedError", { cause: Schema.Defect() }, @@ -176,6 +189,7 @@ export const PullRequestToolError = Schema.Union([ PullRequestWatchFailedError, PullRequestNotOpenError, PullRequestWatchFromSubagentError, + PullRequestSnoozeFailedError, ]); export type PullRequestToolError = typeof PullRequestToolError.Type; @@ -215,6 +229,14 @@ export const WatchPullRequestResult = Schema.Struct({ }); export type WatchPullRequestResult = typeof WatchPullRequestResult.Type; +export const SnoozeUntilPullRequestNeedsAttentionResult = Schema.Struct({ + watching: Schema.Array(Schema.Struct(PullRequestIdentity)).annotate({ + description: "The pull requests T3 Code now watches for this thread.", + }), +}); +export type SnoozeUntilPullRequestNeedsAttentionResult = + typeof SnoozeUntilPullRequestNeedsAttentionResult.Type; + export const ThreadPullRequestEntry = Schema.Struct({ ...PullRequestIdentity, source: ThreadPullRequestLinkSource, @@ -319,10 +341,32 @@ const UnwatchPullRequestTool = Tool.make("unwatch_pull_request", { .annotate(Tool.Idempotent, true) .annotate(Tool.OpenWorld, false); +const SnoozeUntilPullRequestNeedsAttentionTool = Tool.make( + "snooze_until_pull_request_needs_attention", + { + description: + "Snooze a thread, by default this one, until one of its pull requests needs attention. T3 Code watches every open pull request linked to it, starting watches where needed, and snoozes the thread with no wake time. A watch wakes you as usual when a check fails, the required checks pass, someone else comments or reviews, or the branch starts to conflict, and the wake brings the thread back. Once no watched pull request is left open, because they merged or closed, the thread settles. It fails after the user stops the thread's run, while the thread has a queued message or a pending approval or question, when the thread has no open linked pull request, and in a subagent.", + parameters: Schema.Struct({ + threadId: Schema.optional( + ThreadId.annotate({ description: "Thread to snooze. Omit for this thread." }), + ), + }), + success: SnoozeUntilPullRequestNeedsAttentionResult, + failure: PullRequestToolError, + dependencies, + }, +) + .annotate(Tool.Title, "Snooze until a pull request needs attention") + .annotate(Tool.Readonly, false) + .annotate(Tool.Destructive, false) + .annotate(Tool.Idempotent, true) + .annotate(Tool.OpenWorld, false); + export const PullRequestsToolkit = Toolkit.make( LinkPullRequestTool, UnlinkPullRequestTool, ListThreadPullRequestsTool, WatchPullRequestTool, UnwatchPullRequestTool, + SnoozeUntilPullRequestNeedsAttentionTool, ); diff --git a/apps/server/src/mcp/toolkits/worktree/registration.test.ts b/apps/server/src/mcp/toolkits/worktree/registration.test.ts index 588d11d9c715..b7193e88efa6 100644 --- a/apps/server/src/mcp/toolkits/worktree/registration.test.ts +++ b/apps/server/src/mcp/toolkits/worktree/registration.test.ts @@ -1,4 +1,5 @@ import * as Orchestrator from "../../../orchestration-v2/Orchestrator.ts"; +import * as PullRequestWatchReactor from "../../../orchestration-v2/PullRequestWatchReactor.ts"; import * as ProjectionStore from "../../../orchestration-v2/ProjectionStore.ts"; import * as DeviceService from "../../../device/DeviceService.ts"; import * as ServerConfig from "../../../config.ts"; @@ -29,6 +30,7 @@ import * as PreviewBrowser from "../../../preview/PreviewBrowser.ts"; const layerStubServices = Layer.mergeAll( Layer.mock(Orchestrator.OrchestratorV2)({}), + Layer.mock(PullRequestWatchReactor.PullRequestWatchReactor)({}), Layer.mock(ProjectionStore.ProjectionStoreV2)({}), Layer.mock(DeviceService.DeviceService)({}), Layer.mock(ThreadManagementService.ThreadManagementService)({}), diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts index 179455cb64a8..b8b46e42e35b 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts @@ -6790,6 +6790,9 @@ export function makeAcpAdapterV2( text: buildRuntimeInstructions({ harness: flavor.runtimeHarness ?? driver, model: turnInput.modelSelection.model, + snoozePullRequestsAwaitingReview: McpProviderSession.readMcpProviderSession( + turnInput.threadId, + )?.snoozePullRequestsAwaitingReview, }), }); return { prompt, instructionState: text === messageText ? undefined : instructionState }; diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 81f9abc001a3..5b5a737b412d 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -818,6 +818,8 @@ export function makeClaudeQueryOptions(input: { readonly sdkSettings?: string | ClaudeSdkSettings; readonly environment?: NodeJS.ProcessEnv; readonly mcpServers?: ClaudeQueryOptions["mcpServers"]; + /** The user's snoozePullRequestsAwaitingReview setting, for the runtime instructions. */ + readonly snoozePullRequestsAwaitingReview?: boolean; readonly tools?: ClaudeAgentSdkQueryTools; readonly allowedTools?: ReadonlyArray; readonly disallowedTools?: ReadonlyArray; @@ -909,8 +911,10 @@ export function makeClaudeQueryOptions(input: { type: "preset" as const, preset: "claude_code" as const, append: - buildRuntimeInstructions({ harness: "Claude Code" }) + - (input.mcpServers === undefined ? "" : T3_CODE_ORCHESTRATION_INSTRUCTIONS), + buildRuntimeInstructions({ + harness: "Claude Code", + snoozePullRequestsAwaitingReview: input.snoozePullRequestsAwaitingReview, + }) + (input.mcpServers === undefined ? "" : T3_CODE_ORCHESTRATION_INSTRUCTIONS), }, ...(Object.keys(extraArgs).length === 0 ? {} : { extraArgs }), }; @@ -7131,6 +7135,9 @@ export function makeClaudeAdapterV2( environment: adapterOptions.environment, tools: queryPolicy.tools ?? CLAUDE_CODE_PRESET_TOOLS, ...mcpOverrides, + snoozePullRequestsAwaitingReview: + McpProviderSession.readMcpProviderSession(turnInput.threadId) + ?.snoozePullRequestsAwaitingReview === true, permissionMode: queryPolicy.permissionMode, ...(queryPolicy.allowDangerouslySkipPermissions === undefined ? {} diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index d562f876df49..1dbb3b5703cb 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -705,6 +705,7 @@ export function buildCodexTurnStartParams(input: { readonly hasT3Mcp?: boolean; readonly browserToolsAvailable?: boolean; readonly deviceToolsAvailable?: boolean; + readonly snoozePullRequestsAwaitingReview?: boolean; /** ChatGPT token sharing does not accept service tiers. */ readonly omitServiceTier?: boolean; }) { @@ -735,7 +736,11 @@ export function buildCodexTurnStartParams(input: { const additionalContext = input.hasT3Mcp === true ? buildCodexAdditionalContext( - { model: input.modelSelection.model, reasoningEffort: effort ?? "medium" }, + { + model: input.modelSelection.model, + reasoningEffort: effort ?? "medium", + snoozePullRequestsAwaitingReview: input.snoozePullRequestsAwaitingReview, + }, { browser: input.browserToolsAvailable ?? true, device: input.deviceToolsAvailable ?? false, @@ -5873,6 +5878,8 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi hasT3Mcp: mcpSession !== undefined, browserToolsAvailable: mcpSession?.browserToolsAvailable ?? true, deviceToolsAvailable: mcpSession?.capabilities?.has("device") ?? false, + snoozePullRequestsAwaitingReview: + mcpSession?.snoozePullRequestsAwaitingReview === true, omitServiceTier: adapterOptions.resolveRuntime !== undefined, }); yield* Ref.update(pendingRootTurns, (current) => { diff --git a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts index 5ade7c13911b..257698e48b51 100644 --- a/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CursorAdapterV2.ts @@ -2175,7 +2175,14 @@ export function makeCursorAdapterV2( detail: "Cursor turn requires non-empty text or attachments.", }); } - const text = `${userText}\n\n${buildRuntimeInstructions({ harness: "Cursor", model: turnInput.modelSelection.model })}`; + const runtimeInstructions = buildRuntimeInstructions({ + harness: "Cursor", + model: turnInput.modelSelection.model, + snoozePullRequestsAwaitingReview: McpProviderSession.readMcpProviderSession( + turnInput.threadId, + )?.snoozePullRequestsAwaitingReview, + }); + const text = `${userText}\n\n${runtimeInstructions}`; return images.length === 0 ? text : ({ diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts index 102eb721bb1a..c7a5b6cba39d 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.ts @@ -3285,7 +3285,11 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr if (added) state.mcp = wanted; } const instructions = [ - buildRuntimeInstructions({ harness: "OpenCode", model: turnInput.modelSelection.model }), + buildRuntimeInstructions({ + harness: "OpenCode", + model: turnInput.modelSelection.model, + snoozePullRequestsAwaitingReview: mcpSession?.snoozePullRequestsAwaitingReview, + }), t3OrchestrationSystemPrompt(state.mcp !== undefined), ] .filter((part) => part !== undefined && part.length > 0) diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts index 510a4c859d36..282faf1cdaa2 100644 --- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts @@ -3267,6 +3267,9 @@ export function makeOpenCodeAdapterV2( buildRuntimeInstructions({ harness: "OpenCode", model: turnInput.modelSelection.model, + snoozePullRequestsAwaitingReview: McpProviderSession.readMcpProviderSession( + turnInput.threadId, + )?.snoozePullRequestsAwaitingReview, }), ] .filter(Boolean) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 5d4b1cfda15f..c9a9caafa6ec 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -21,6 +21,7 @@ import { OrchestrationV2Command, type OrchestrationV2InternalCommand, type OrchestrationV2ServerCommand, + type ThreadPullRequestKey, type ThreadPullRequestLink, type ThreadPullRequestWatch, type OrchestrationV2AppThread, @@ -83,6 +84,11 @@ import { ContextHandoffServiceV2 } from "./ContextHandoffService.ts"; import { notificationTurnItem } from "./Notification.ts"; import { isRestartNoteSource } from "./RestartBackgroundNote.ts"; import { isUndeliveredMailboxSteer } from "./NotificationMailbox.ts"; +import { + newPullRequestWatch, + otherPullRequestOpen, + pullRequestWatchesEqual, +} from "./pullRequestWatch.ts"; import { EventSinkV2 } from "./EventSink.ts"; import * as EffectOutbox from "./EffectOutbox.ts"; import type { OrchestrationEffectRequestV2, PendingOrchestrationEffectV2 } from "./EffectOutbox.ts"; @@ -412,6 +418,7 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { case "thread.unsettle": case "thread.snooze": case "thread.unsnooze": + case "thread.snooze-until-attention": case "thread.auto-settle.set": case "thread.pin": case "thread.unpin": @@ -499,6 +506,16 @@ function isBlockingRun(run: OrchestrationV2Run): boolean { ); } +/** Nothing runs, waits to run, or waits on the user. */ +export function threadIdle( + records: Pick, +): boolean { + return ( + records.runs.every((run) => run.status !== "queued" && !isBlockingRun(run)) && + records.runtimeRequests.every((request) => request.status !== "pending") + ); +} + /** * A parent thread is "live" for wake purposes while a run is still producing * agent output. A run parked at "waiting" is post-terminal drain, so its agent @@ -519,6 +536,57 @@ function withPullRequestWatch( return watch === undefined ? rest : { ...rest, watch }; } +/** + * Pairs each watch a snooze until a pull request needs attention read with the link it primes. + * Undefined when the links or their watches changed while the host was read. + */ +function primedPullRequestLinks( + thread: OrchestrationV2AppThread, + command: Extract< + OrchestrationV2ServerCommand, + { readonly type: "thread.snooze-until-attention"; readonly watches: unknown } + >, +) { + const links = visibleThreadPullRequests(threadPullRequestsOf(thread)); + const linkOf = (key: ThreadPullRequestKey) => + links.find((link) => threadPullRequestKeysEqual(link, normalizeThreadPullRequestKey(key))); + if ( + links.length !== command.links.length || + command.links.some((key) => linkOf(key) === undefined) + ) { + return undefined; + } + const primed = command.watches.map((entry) => ({ link: linkOf(entry), entry })); + return primed.every(({ link, entry }) => + entry.previous === null + ? link?.watch === undefined + : link?.watch !== undefined && pullRequestWatchesEqual(link.watch, entry.previous), + ) + ? primed + : undefined; +} + +function watchesAnyPullRequest(thread: OrchestrationV2AppThread): boolean { + return visibleThreadPullRequests(thread.pullRequests ?? []).some( + (link) => link.watch !== undefined, + ); +} + +function withoutAttentionSnooze(thread: OrchestrationV2AppThread): OrchestrationV2AppThread { + const { snoozedUntilNeedsAttention: _held, ...rest } = thread; + return rest; +} + +/** + * A snooze until a pull request needs attention has no wake time, so it ends with the last watch + * that could wake it. + */ +function releaseUnheldAttentionSnooze(thread: OrchestrationV2AppThread): OrchestrationV2AppThread { + return thread.snoozedUntilNeedsAttention === true && !watchesAnyPullRequest(thread) + ? { ...withoutAttentionSnooze(thread), snoozedAt: null } + : thread; +} + /** A legacy single-PR link as a link entry. Re-linking a pull request keeps its watch. */ function legacyPullRequestLink( thread: OrchestrationV2AppThread, @@ -2285,7 +2353,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); // Checked under the thread lock: the watch or the thread can change while the host is read. - // The watch is recorded first so the wake's own thread events carry it. + // The watch is recorded first so the wake's own thread events carry it. A thread snoozed until + // its pull requests need attention settles once none of its linked pull requests is left open. const dispatchPullRequestWatchSync = Effect.fn("orchestrationV2.dispatch.pullRequestWatchSync")( function* ( command: Extract< @@ -2313,14 +2382,48 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio thread.settledOverride === "settled" || thread.settledAt !== null || isProviderNativeSubagentThread(thread); - if (link?.watch?.startedAt !== command.startedAt || (command.wake && inactive)) { + if ( + link?.watch === undefined || + !pullRequestWatchesEqual(link.watch, command.previous) || + (command.wake && inactive) + ) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, - cause: "The pull request watch ended or its thread settled while it was read.", + cause: "The pull request watch changed or its thread settled while it was read.", }); } yield* dispatchThreadMutation(command, events, effects); + if (thread.snoozedUntilNeedsAttention === true && command.ended !== undefined) { + const { thread: recorded } = yield* getProjectionWithPendingEvents( + command.threadId, + events, + ); + // The closed note would only bring back a thread that stays snoozed on the others. + if (watchesAnyPullRequest(recorded)) return; + // Another linked pull request is open, perhaps reopened after its watch ended, so the + // thread is not done: the snooze ended with the watch, and the agent hears of this one. + if (!otherPullRequestOpen(recorded.pullRequests ?? [], key)) { + // The snooze ends by settling the thread, which waits for its work to finish: until + // then the watch and the snooze stay, and the watch's next pass tries again. + const records = yield* projectionStore + .getThreadRecords(command.threadId, ["runs", "runtimeRequests"]) + .pipe(mapDispatchError(command)); + if (!threadIdle(records)) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.threadId} is still at work, so its snooze settles it later.`, + }); + } + yield* dispatchThreadMutation( + { type: "thread.settle", commandId: command.commandId, threadId: command.threadId }, + events, + effects, + ); + return; + } + } if (command.wake === undefined) return; yield* dispatchMessage( { @@ -2352,6 +2455,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | "thread.unsettle" | "thread.snooze" | "thread.unsnooze" + | "thread.snooze-until-attention" | "thread.auto-settle.set" | "thread.pin" | "thread.unpin" @@ -2375,15 +2479,18 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio events: Ref.Ref>, effects: Ref.Ref>, ) { - const thread = yield* projectionStore.getThread(command.threadId).pipe( - Effect.mapError( - (cause) => - new OrchestratorProjectionError({ - threadId: command.threadId, - cause, - }), - ), - ); + // A mutation that follows another in the same command builds on its events. + const thread = (yield* Ref.get(events)).some((event) => event.threadId === command.threadId) + ? (yield* getProjectionWithPendingEvents(command.threadId, events)).thread + : yield* projectionStore.getThread(command.threadId).pipe( + Effect.mapError( + (cause) => + new OrchestratorProjectionError({ + threadId: command.threadId, + cause, + }), + ), + ); if (thread.deletedAt !== null) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, @@ -2427,12 +2534,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: `Thread ${command.threadId} is a subagent; its parent thread watches pull requests.`, }); } - // Only the agent's watch_pull_request links as "agent". A call that raced a Stop may land - // after its run ended, so the latest run that executed decides. A user can still watch. + // Only the agent's watch_pull_request links as "agent", and only its snooze tool snoozes as + // one. A call that raced a Stop may land after its run ended, so the latest run that executed + // decides. A user can still watch and snooze. if ( - command.type === "thread.pull-request.watch" && - command.watching && - command.link?.source === "agent" + (command.type === "thread.pull-request.watch" && + command.watching && + command.link?.source === "agent") || + (command.type === "thread.snooze-until-attention" && command.createdBy === "agent") ) { const { runs } = yield* projectionStore .getThreadRecords(command.threadId, ["runs"]) @@ -2490,6 +2599,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio command.type === "thread.unsettle" || command.type === "thread.snooze" || command.type === "thread.unsnooze" || + command.type === "thread.snooze-until-attention" || command.type === "thread.auto-settle.set" || command.type === "thread.pin" || command.type === "thread.unpin" || @@ -2703,14 +2813,52 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio } } let snoozedUntil: DateTime.Utc | null = null; - if (command.type === "thread.snooze") { + if (command.type === "thread.snooze-until-attention") { + if (!("watches" in command)) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "A snooze until a pull request needs attention reads its pull requests first.", + }); + } + // The watches it starts follow the watch rules. + if ( + thread.settledOverride === "settled" || + thread.settledAt !== null || + thread.lineage.relationshipToParent === "subagent" || + isProviderNativeSubagentThread(thread) + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.threadId} is settled or a subagent and cannot watch pull requests.`, + }); + } + if (command.watches.length === 0) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.threadId} has no open linked pull request to wait on.`, + }); + } + if (primedPullRequestLinks(thread, command) === undefined) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: `Thread ${command.threadId}'s pull requests changed while they were read. Try again.`, + }); + } + } + if (command.type === "thread.snooze" || command.type === "thread.snooze-until-attention") { 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.none(); 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, @@ -2732,7 +2880,7 @@ 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); } let markUnreadVisitedAt: DateTime.Utc | null = null; if (command.type === "thread.mark-unread") { @@ -2795,7 +2943,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio DateTime.toEpochMillis(thread.snoozedUntil) === DateTime.toEpochMillis(snoozedUntil); const existingSnoozedAt = sameWakeTime ? (thread.snoozedAt ?? null) : null; return { - ...thread, + ...withoutAttentionSnooze(thread), snoozedUntil, limitRecovery: thread.limitRecovery ? { ...thread.limitRecovery, snooze: false } : null, snoozedAt: existingSnoozedAt ?? now, @@ -2803,14 +2951,36 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }; } case "thread.unsnooze": { - const alreadyAwake = thread.snoozedUntil == null; + const alreadyAwake = + thread.snoozedUntil == null && thread.snoozedUntilNeedsAttention !== true; return { - ...thread, + ...withoutAttentionSnooze(thread), snoozedUntil: null, snoozedAt: null, updatedAt: alreadyAwake ? thread.updatedAt : now, }; } + case "thread.snooze-until-attention": { + const primed = "watches" in command ? primedPullRequestLinks(thread, command) : []; + const alreadyHeld = thread.snoozedUntilNeedsAttention === true; + return { + ...thread, + pullRequests: threadPullRequestsOf(thread).map((link) => { + const read = primed?.find( + (candidate) => + link.source !== "stack-dismissed" && + candidate.link !== undefined && + threadPullRequestKeysEqual(candidate.link, link), + ); + return read === undefined ? link : withPullRequestWatch(link, read.entry.watch); + }), + snoozedUntil: null, + snoozedAt: alreadyHeld ? (thread.snoozedAt ?? now) : now, + snoozedUntilNeedsAttention: true, + limitRecovery: thread.limitRecovery ? { ...thread.limitRecovery, snooze: false } : null, + updatedAt: now, + }; + } case "thread.auto-settle.set": { const unchanged = command.enabled === (thread.autoSettleDisabledAt == null); return { @@ -2824,9 +2994,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio // silently outranking them — an explicit settle is un-settled and a // snooze's return ticket is spent (the thread is on top NOW). const alreadyPinned = thread.pinnedAt != null; - const promotes = thread.settledOverride === "settled" || thread.snoozedUntil != null; + const promotes = + thread.settledOverride === "settled" || + thread.snoozedUntil != null || + thread.snoozedUntilNeedsAttention === true; return { - ...thread, + ...withoutAttentionSnooze(thread), pinnedAt: alreadyPinned ? thread.pinnedAt : now, // A fresh pin takes the client's slot in the arranged order; on a // re-pin the existing key wins so raced duplicates cannot move a @@ -2880,6 +3053,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio thread.limitRecovery?.resetAt === command.limitRecovery?.resetAt ? thread.limitRecovery : null; + // The usage-limit worker arms a recovery with both choices set; a client changes one. + // Arming leaves a snooze until a pull request needs attention in place, without a + // snooze of its own. + const armsUnderAttentionSnooze = + thread.snoozedUntilNeedsAttention === true && + previousRecovery === null && + command.limitRecovery?.autoResume !== undefined && + command.limitRecovery.snooze !== undefined; const limitRecovery = command.limitRecovery === undefined ? thread.limitRecovery @@ -2889,16 +3070,20 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...command.limitRecovery, autoResume: command.limitRecovery.autoResume ?? previousRecovery?.autoResume ?? false, - snooze: command.limitRecovery.snooze ?? previousRecovery?.snooze ?? false, + snooze: + !armsUnderAttentionSnooze && + (command.limitRecovery.snooze ?? previousRecovery?.snooze ?? false), requestId: command.commandId, }; + const snoozesUntilReset = + command.limitRecovery !== undefined && + limitRecovery?.snooze === true && + Date.parse(limitRecovery.resetAt) > DateTime.toEpochMillis(now); return { - ...thread, + ...(snoozesUntilReset ? withoutAttentionSnooze(thread) : thread), ...(command.title === undefined ? {} : { title: command.title }), ...(command.limitRecovery === undefined ? {} : { limitRecovery }), - ...(command.limitRecovery !== undefined && - limitRecovery?.snooze === true && - Date.parse(limitRecovery.resetAt) > DateTime.toEpochMillis(now) + ...(snoozesUntilReset && limitRecovery ? { snoozedUntil: DateTime.makeUnsafe(limitRecovery.resetAt), // Recovery changes acknowledge the same stopped run; keep its @@ -3073,23 +3258,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if (existing === undefined) return thread; const watch = command.type === "thread.pull-request-watch.sync" - ? // Progress read before a stop or restart must not bring the old watch back. - existing.watch?.startedAt === command.startedAt + ? // Progress read before a stop, restart, or snooze must not bring the old watch back. + existing.watch !== undefined && + pullRequestWatchesEqual(existing.watch, command.previous) ? (command.watch ?? undefined) : existing.watch : !command.watching ? undefined - : (existing.watch ?? { - startedAt, - headSha: null, - failedChecks: [], - passed: false, - passedChecks: [], - remarksThrough: startedAt, - remarkIds: [], - conflicting: false, - wakes: 0, - }); + : (existing.watch ?? newPullRequestWatch(startedAt)); if (watch === existing.watch && links === linked) return thread; return { ...thread, @@ -3167,6 +3343,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "thread.unsettle": return "thread.unsettled" as const; case "thread.snooze": + case "thread.snooze-until-attention": return "thread.snoozed" as const; case "thread.unsnooze": return "thread.unsnoozed" as const; @@ -3212,7 +3389,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio threadId: command.threadId, providerInstanceId: updatedThread.providerInstanceId, occurredAt: now, - payload: updatedThread, + payload: releaseUnheldAttentionSnooze(updatedThread), }); if (command.type === "thread.metadata.update" && command.regenerateTitle === true) { @@ -4467,6 +4644,11 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio const failure = latestRootProviderFailure(run, projection.turnItems); const recovery = projection.thread.limitRecovery; const now = yield* DateTime.now; + // A snooze holds the continuation until the thread wakes. + const snoozed = + projection.thread.snoozedUntilNeedsAttention === true || + (projection.thread.snoozedUntil != null && + DateTime.toEpochMillis(projection.thread.snoozedUntil) > DateTime.toEpochMillis(now)); if ( run?.id !== command.usageLimitContinuationOfRunId || failure?.class !== "usage_limit" || @@ -4481,8 +4663,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio projection.thread.settledOverride === "settled" || projection.thread.providerInstanceId !== run.providerInstanceId || projection.runtimeRequests.some((request) => request.status === "pending") || - (projection.thread.snoozedUntil != null && - DateTime.toEpochMillis(projection.thread.snoozedUntil) > DateTime.toEpochMillis(now)) + snoozed ) { yield* emit( events, @@ -4496,8 +4677,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(recovery?.autoResume && recovery.requestId === command.usageLimitRecoveryRequestId && recovery.runId === command.usageLimitContinuationOfRunId && - projection.thread.snoozedUntil != null && - DateTime.toEpochMillis(projection.thread.snoozedUntil) > DateTime.toEpochMillis(now) + snoozed ? { limitRecovery: { ...recovery, requestId: command.commandId } } : {}), }, @@ -4560,10 +4740,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); projection = yield* getProjectionWithPendingEvents(command.threadId, events); } - if (projection.thread.snoozedUntil != null) { + if ( + projection.thread.snoozedUntil != null || + projection.thread.snoozedUntilNeedsAttention === true + ) { const now = yield* DateTime.now; const thread: OrchestrationV2AppThread = { - ...projection.thread, + ...withoutAttentionSnooze(projection.thread), snoozedUntil: null, snoozedAt: null, updatedAt: now, @@ -8488,10 +8671,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio threadId: thread.id, providerInstanceId: thread.providerInstanceId, occurredAt: input.now, - payload: { + payload: releaseUnheldAttentionSnooze({ ...thread, pullRequests: pullRequests.map((link) => withPullRequestWatch(link, undefined)), - }, + }), }); } const cohortRunIds = new Set(input.cohortRunIds); @@ -10198,6 +10381,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio case "thread.unsettle": case "thread.snooze": case "thread.unsnooze": + case "thread.snooze-until-attention": case "thread.auto-settle.set": case "thread.pin": case "thread.unpin": diff --git a/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts index f626d0c38583..5cfae3df586b 100644 --- a/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionSettlement.test.ts @@ -527,3 +527,20 @@ it.effect("shell failure lookups stay on the thread's own turn items", () => assert.include(secretLookups[0]!.detail, "turn_items_thread_run_idx"); }).pipe(Effect.provide(layerSql)), ); + +it.effect.each([ + ["sql", layerSql], + ["memory", ProjectionStore.layerMemory], +] as const)("%s exposes the attention snooze to the watch reactor", ([, testLayer]) => + Effect.gen(function* () { + const store = yield* ProjectionStore.ProjectionStoreV2; + const threadId = yield* createThread("attention-snooze", { + snoozedUntil: null, + snoozedAt: old, + snoozedUntilNeedsAttention: true, + pullRequests: [pullRequestLink(7)], + }); + const [thread] = yield* store.getThreadsWithPullRequests(threadId); + assert.isTrue(thread?.snoozedUntilNeedsAttention); + }).pipe(Effect.provide(testLayer)), +); diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index ec75937a9a62..bc11cfbf9555 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -152,12 +152,19 @@ export type ProjectionLimitRecoveryCandidate = Pick< | "updatedAt" | "limitRecovery" | "snoozedUntil" + | "snoozedUntilNeedsAttention" >; -/** The thread fields pull request sync reads, for a thread with at least one link. */ +/** The thread fields pull request sync and watches read, for a thread with at least one link. */ export type ProjectionThreadPullRequests = Pick< OrchestrationV2AppThread, - "id" | "projectId" | "lineage" | "settledOverride" | "settledAt" | "pullRequests" + | "id" + | "projectId" + | "lineage" + | "settledOverride" + | "settledAt" + | "pullRequests" + | "snoozedUntilNeedsAttention" >; /** @@ -181,6 +188,7 @@ export type ProjectionSettlementCandidate = Pick< | "autoSettleDisabledAt" | "snoozedUntil" | "snoozedAt" + | "snoozedUntilNeedsAttention" | "latestRunId" | "latestRunRequestedAt" | "latestRunStartedAt" @@ -1465,6 +1473,7 @@ export function threadShellFromProjection( unsettledAt: projection.thread.unsettledAt ?? null, snoozedUntil: projection.thread.snoozedUntil ?? null, snoozedAt: projection.thread.snoozedAt ?? null, + snoozedUntilNeedsAttention: projection.thread.snoozedUntilNeedsAttention === true, pinnedAt: projection.thread.pinnedAt ?? null, autoSettleDisabledAt: projection.thread.autoSettleDisabledAt ?? null, @@ -1720,6 +1729,7 @@ function shellFromState(input: { unsettledAt: input.state.thread.unsettledAt ?? null, snoozedUntil: input.state.thread.snoozedUntil ?? null, snoozedAt: input.state.thread.snoozedAt ?? null, + snoozedUntilNeedsAttention: input.state.thread.snoozedUntilNeedsAttention === true, pinnedAt: input.state.thread.pinnedAt ?? null, autoSettleDisabledAt: input.state.thread.autoSettleDisabledAt ?? null, @@ -3416,6 +3426,7 @@ export const layer: Layer.Layer = AND json_extract(t.payload_json, '$.limitRecovery.resetAt') IS json_extract(item.payload_json, '$.failure.resetAt') AND json_extract(t.payload_json, '$.limitRecovery.autoResume') = 1 AND julianday(json_extract(item.payload_json, '$.failure.resetAt')) <= julianday(${DateTime.formatIso(options.now)}) + AND json_extract(t.payload_json, '$.snoozedUntilNeedsAttention') IS NOT 1 AND ( json_extract(t.payload_json, '$.snoozedUntil') IS NULL OR julianday(json_extract(t.payload_json, '$.snoozedUntil')) <= julianday(${DateTime.formatIso(options.now)}) @@ -3461,6 +3472,7 @@ export const layer: Layer.Layer = pendingRuntimeRequest: null, limitRecovery: thread.limitRecovery ?? null, snoozedUntil: thread.snoozedUntil ?? null, + snoozedUntilNeedsAttention: thread.snoozedUntilNeedsAttention === true, }); } return candidates; @@ -5289,6 +5301,7 @@ export const layer: Layer.Layer = autoSettleDisabledAt: thread.autoSettleDisabledAt ?? null, snoozedUntil: thread.snoozedUntil ?? null, snoozedAt: thread.snoozedAt ?? null, + snoozedUntilNeedsAttention: thread.snoozedUntilNeedsAttention === true, status, latestRunId, latestRunRequestedAt: @@ -5353,6 +5366,7 @@ export const layer: Layer.Layer = settledOverride: thread.settledOverride, settledAt: thread.settledAt, pullRequests: thread.pullRequests ?? [], + snoozedUntilNeedsAttention: thread.snoozedUntilNeedsAttention === true, })), ), ); @@ -5835,6 +5849,7 @@ export const layerMemory: Layer.Layer = Layer.effect( settledOverride: thread.settledOverride, settledAt: thread.settledAt, pullRequests: thread.pullRequests ?? [], + snoozedUntilNeedsAttention: thread.snoozedUntilNeedsAttention === true, })), ), ), @@ -5873,6 +5888,7 @@ export const layerMemory: Layer.Layer = Layer.effect( return ( thread.limitRecovery.autoResume && resetMs <= nowMs && + thread.snoozedUntilNeedsAttention !== true && (thread.snoozedUntil == null || DateTime.toEpochMillis(thread.snoozedUntil) <= nowMs) ); diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index bfb076ee3bf3..433cf56e4a09 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -603,6 +603,7 @@ function runBrowserAccessScenario(input: { readonly deviceOverride?: boolean; readonly createThread?: boolean; readonly projectExists?: boolean; + readonly snoozePullRequestsAwaitingReview?: boolean; }) { return Effect.gen(function* () { const state = yield* Ref.make(emptyState); @@ -646,6 +647,7 @@ function runBrowserAccessScenario(input: { projectServiceLayer: layerProjectService, serverSettingsLayer: ServerSettings.layerTest({ enableAgentBrowserAccess: input.enableAgentBrowserAccess, + snoozePullRequestsAwaitingReview: input.snoozePullRequestsAwaitingReview ?? false, projectSettingsOverrides: { [projectId]: { enableAgentBrowserAccess: input.projectOverride, @@ -1874,6 +1876,19 @@ it.effect("ProviderSessionManagerV2 honors a project browser-access opt-in", () }), ); +it.effect.each([true, false])( + "ProviderSessionManagerV2 gives the agent the pull request snooze setting (%s)", + (snoozePullRequestsAwaitingReview) => + Effect.gen(function* () { + const captured = yield* runBrowserAccessScenario({ + enableAgentBrowserAccess: true, + projectOverride: true, + snoozePullRequestsAwaitingReview, + }); + assert.equal(captured?.snoozePullRequestsAwaitingReview, snoozePullRequestsAwaitingReview); + }), +); + it.effect("ProviderSessionManagerV2 fails browser access closed for a missing project", () => Effect.gen(function* () { const captured = yield* runBrowserAccessScenario({ diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index f84441129cc3..72116cf762cf 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -342,9 +342,12 @@ export const layerWithOptions = ( const projectionStore = yield* ProjectionStore.ProjectionStoreV2; const agentAccessSettings = Effect.fn("ProviderSessionManagerV2.agentAccessSettings")( function* (threadId: ThreadId) { - if (Option.isNone(serverSettings)) return { browser: true, device: false }; + if (Option.isNone(serverSettings)) { + return { browser: true, device: false, snoozePullRequestsAwaitingReview: false }; + } return yield* Effect.gen(function* () { const settings = yield* serverSettings.value.getSettings; + const { snoozePullRequestsAwaitingReview } = settings; const thread = yield* projectionStore.getThread(threadId); const entries = Object.values(settings.projectSettingsOverrides); const browserOverridden = entries.some( @@ -361,19 +364,27 @@ export const layerWithOptions = ( return { browser: browserOverridden ? false : settings.enableAgentBrowserAccess, device: deviceOverridden ? false : settings.enableAgentDeviceAccess, + snoozePullRequestsAwaitingReview, }; } const effective = resolveProjectSettings(settings, thread.projectId).settings; return { browser: effective.enableAgentBrowserAccess, device: effective.enableAgentDeviceAccess, + snoozePullRequestsAwaitingReview, }; }).pipe( Effect.catch((cause) => Effect.logWarning( "Could not resolve agent access; withholding browser and device tools.", { threadId, cause }, - ).pipe(Effect.as({ browser: false, device: false })), + ).pipe( + Effect.as({ + browser: false, + device: false, + snoozePullRequestsAwaitingReview: false, + }), + ), ), ); }, @@ -469,8 +480,11 @@ export const layerWithOptions = ( // the credential it started with, so a thread that detaches and // re-attaches across a workspace handoff must come back to the // same token or the process's tool calls fail auth. - const { browser: browserToolsAvailable, device: deviceToolsAvailable } = - yield* agentAccessSettings(threadId); + const { + browser: browserToolsAvailable, + device: deviceToolsAvailable, + snoozePullRequestsAwaitingReview, + } = yield* agentAccessSettings(threadId); const capabilities = new Set< import("../mcp/McpInvocationContext.ts").McpCapability >(["orchestration", "worktree", "pull-requests"]); @@ -502,6 +516,10 @@ export const layerWithOptions = ( resolved.capabilities.has("preview") === browserToolsAvailable && resolved.capabilities.has("device") === deviceToolsAvailable ) { + McpProviderSession.setMcpProviderSession({ + ...existing, + snoozePullRequestsAwaitingReview, + }); return { mcpCredentialId: existing.providerSessionId, issued: false }; } dropMcpCredentialReservation(threadId, existing.providerSessionId); @@ -513,7 +531,10 @@ export const layerWithOptions = ( browserToolsAvailable, capabilities, }); - McpProviderSession.setMcpProviderSession(credential.config); + McpProviderSession.setMcpProviderSession({ + ...credential.config, + snoozePullRequestsAwaitingReview, + }); reserveMcpCredential(threadId, credential.config.providerSessionId); return { mcpCredentialId: credential.config.providerSessionId, issued: true }; }), diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index 7005534f89ed..56cfcf2e0e49 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -1,6 +1,7 @@ import { CommandId, MessageId, + type OrchestrationV2Command, type OrchestrationV2Notification, type PullRequestActivity, type PullRequestComment, @@ -8,16 +9,19 @@ import { type PullRequestThreadCommentsResult, type ThreadPullRequestLink, type ThreadPullRequestWatch, + ThreadId, } from "@t3tools/contracts"; import { normalizeThreadPullRequestKey, threadPullRequestKeyOf, + threadPullRequestKeysEqual, visibleThreadPullRequests, } from "@t3tools/shared/threadPullRequests"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; @@ -32,15 +36,28 @@ import { } from "../pullRequest/PullRequestProvider.ts"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import { forkParked } from "../serverActivation.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as Orchestrator from "./Orchestrator.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; -import { evaluatePullRequestWatch, pullRequestWatchMessage } from "./pullRequestWatch.ts"; +import { + evaluatePullRequestWatch, + newPullRequestWatch, + otherPullRequestOpen, + primedPullRequestWatch, + pullRequestWatchesEqual, + pullRequestWatchMessage, +} from "./pullRequestWatch.ts"; /** * Minutes between passes. Checks take minutes, so a faster pass mostly spends the host's rate * limit, which every machine on the same account shares. */ const SWEEP_MINUTES = 2; +/** + * A pull request that only threads snoozed until it needs attention watch is checked this often: + * it waits on someone else, so news can wait a little longer. + */ +const SNOOZED_CHECK_MS = 10 * 60_000; /** Reads in a row that failed for a reason other than a rate limit before the watch ends. */ const READ_FAILURE_LIMIT = 8; /** @@ -57,6 +74,20 @@ const FINGERPRINT_REREAD_MS = 30 * 60_000; const isProviderError = Schema.is(PullRequestProviderError); +/** A snooze until a pull request needs attention could not read one of the thread's pull requests. */ +export class PullRequestAttentionReadError extends Schema.TaggedError()( + "PullRequestAttentionReadError", + { threadId: ThreadId, number: Schema.Number, cause: Schema.Defect() }, +) { + override get message(): string { + return `Could not read pull request #${this.number}, so the thread was not snoozed. Try again.`; + } +} + +/** A snooze primed the pull request after the pass read it, so the pass reports nothing. */ +class PrimedSinceRead extends Schema.TaggedError()("PrimedSinceRead", {}) {} +const isPrimedSinceRead = Schema.is(PrimedSinceRead); + /** A rate limit is the host asking us to wait, not a sign the pull request cannot be read. */ const isRateLimited = (cause: Cause.Cause) => Option.match(Cause.findErrorOption(cause), { @@ -72,12 +103,16 @@ const logFailure = (cause: Cause.Cause): Effect.Effect => Cause.hasInterruptsOnly(cause) ? Effect.interrupt - : Effect.logWarning(message, { ...fields, cause }); + : Option.exists(Cause.findErrorOption(cause), isPrimedSinceRead) + ? Effect.logDebug(message, { ...fields, cause }) + : Effect.logWarning(message, { ...fields, cause }); interface WatchTarget { readonly thread: ProjectionStore.ProjectionThreadPullRequests; readonly link: ThreadPullRequestLink; readonly watch: ThreadPullRequestWatch; + /** The snooze primings counted when the pass that read this began. */ + readonly pass: number; } /** Every thread of one project that watches one pull request, read once per pass. */ @@ -196,27 +231,16 @@ function planRead( return { detail: activity || last.status !== fingerprint.status || last.inFlight, activity }; } -function watchesEqual(left: ThreadPullRequestWatch, right: ThreadPullRequestWatch): boolean { - return ( - left.startedAt === right.startedAt && - left.headSha === right.headSha && - left.failedChecks.join("\n") === right.failedChecks.join("\n") && - left.passed === right.passed && - left.passedChecks.join("\n") === right.passedChecks.join("\n") && - left.remarksThrough === right.remarksThrough && - left.remarkIds.join("\n") === right.remarkIds.join("\n") && - left.conflicting === right.conflicting && - left.wakes === right.wakes - ); -} - /** * Wakes a thread's agent when a pull request it watches (`watch_pull_request`) needs a look: * checks finished on the head commit, someone else commented, or the branch started to * conflict. A pass every two minutes looks at each watched pull request once for all the threads * of a project that watch it. Where the host has a fingerprint, one batched read of those tells * the pass which pull requests moved, and only those are read; elsewhere a pull request is read - * unless its sync snapshot has not moved while nothing is in flight. + * unless its sync snapshot has not moved while nothing is in flight. A pull request watched only + * by threads snoozed until it needs attention is looked at every ten minutes instead; such a + * thread settles when its last watched pull request merges or closes and no other linked one is + * open, once its turn is over. * Settling or archiving a thread ends its watches, and a merged or closed pull request ends * its watch. Each ended watch is logged once with why it ended and how long it went without * a push, for debugging watches that live too long. @@ -227,6 +251,17 @@ export class PullRequestWatchReactor extends Context.Service< readonly start: () => Effect.Effect; /** One pass over every watched pull request. */ readonly sweep: Effect.Effect; + /** + * Reads each open pull request the thread links, then snoozes the thread until one needs + * attention, watching them from what the host showed: only what changes after the snooze + * wakes it. Refused when a pull request cannot be read. + */ + readonly snoozeUntilAttention: ( + command: Extract, + ) => Effect.Effect< + Orchestrator.OrchestratorV2DispatchResult, + Orchestrator.OrchestratorV2Error | PullRequestAttentionReadError + >; } >()("t3/orchestration-v2/PullRequestWatchReactor") {} @@ -235,6 +270,7 @@ export const make = Effect.gen(function* () { const engine = yield* Orchestrator.OrchestratorV2; const projections = yield* ProjectionStore.ProjectionStoreV2; const pullRequests = yield* PullRequestService.PullRequestService; + const settings = yield* ServerSettings.ServerSettingsService; const crypto = yield* Crypto.Crypto; const bootedAt = yield* Clock.currentTimeMillis; const lives = new Map(); @@ -285,6 +321,17 @@ export const make = Effect.gen(function* () { // Reads in a row that failed, per pull request. Kept in memory: a restart only delays the stop. const readFailures = new Map(); const lastReads = new Map(); + // When a snooze last primed each pull request's watches, numbered in order. A pass that began + // before it read the watches it replaced, or the pull request before the snooze did, so it must + // neither report what it saw nor record a read the next pass would trust. + let primings = 0; + const primedAt = new Map(); + const primedSince = (key: string, pass: number) => (primedAt.get(key) ?? 0) > pass; + // When each pull request was last checked, to space out those only snoozed threads watch. + const checkedAt = new Map(); + // Merged pull requests whose end waits for a thread's turn, so later passes end them without + // reading the host again. A closed one can reopen, so it is read again each pass until then. + const merged = new Set(); // Replies past each long thread's first page, per pull request, so a pass pages a thread // again only when the host's count of it moves. A restart pages each thread once more. const threadTails = new Map< @@ -298,28 +345,42 @@ export const make = Effect.gen(function* () { repository: link.repository, number: link.number, }); + // Grouped per project too: each project reads through its own checkout, so one that cannot read + // the pull request must not end another project's watches. + const groupKeyOf = (projectId: string, link: ThreadPullRequestLink) => + `${projectId} ${threadPullRequestKeyOf(link)}`; /** * Records what a pass saw, and wakes the agent with it. The orchestrator applies this only - * while the same watch is on, so a stop or restart that lands during the host read wins. + * while the watch is still the one the pass evaluated, so a stop, restart, or snooze that lands + * during the host read wins. + * `ended` says the pull request merged or closed, which settles a thread snoozed on it. */ const record = ( target: WatchTarget, next: ThreadPullRequestWatch | null, - wake?: { readonly text: string; readonly notification: OrchestrationV2Notification }, + options: { + readonly wake?: { readonly text: string; readonly notification: OrchestrationV2Notification }; + readonly ended?: "merged" | "closed"; + } = {}, ) => Effect.gen(function* () { const uuid = yield* crypto.randomUUIDv4; + // Checked last, so a snooze that dispatched before this sees no report from an older read. + if (primedSince(groupKeyOf(target.thread.projectId, target.link), target.pass)) { + return yield* new PrimedSinceRead(); + } yield* engine.dispatch({ type: "thread.pull-request-watch.sync", commandId: CommandId.make(`server:pr-watch:${target.thread.id}:${uuid}`), threadId: target.thread.id, ...identityOf(target.link), - startedAt: target.watch.startedAt, + previous: target.watch, watch: next, - ...(wake === undefined + ...(options.ended === undefined ? {} : { ended: options.ended }), + ...(options.wake === undefined ? {} - : { wake: { ...wake, messageId: MessageId.make(`message:pr-watch:${uuid}`) } }), + : { wake: { ...options.wake, messageId: MessageId.make(`message:pr-watch:${uuid}`) } }), }); }); @@ -327,11 +388,13 @@ export const make = Effect.gen(function* () { // "Watching" while it learns nothing. const giveUp = (target: WatchTarget) => record(target, null, { - text: `T3 Code stopped watching pull request #${target.link.number} (${target.link.url}) because it failed to read it from the host ${READ_FAILURE_LIMIT} times in a row. Check it yourself, and call watch_pull_request to watch it again.`, - notification: { - source: { kind: "monitor" }, - outcome: "failed", - summary: `#${target.link.number}: stopped watching, could not read it`, + wake: { + text: `T3 Code stopped watching pull request #${target.link.number} (${target.link.url}) because it failed to read it from the host ${READ_FAILURE_LIMIT} times in a row. Check it yourself, and call watch_pull_request to watch it again.`, + notification: { + source: { kind: "monitor" }, + outcome: "failed", + summary: `#${target.link.number}: stopped watching, could not read it`, + }, }, }).pipe( Effect.tap(() => woke(target)), @@ -395,17 +458,58 @@ export const make = Effect.gen(function* () { // A closed pull request can reopen, but the watch has nothing to report until then. const closed = (target: WatchTarget) => record(target, null, { - text: `Pull request #${target.link.number} (${target.link.url}) was closed, so T3 Code stopped watching it. Call watch_pull_request if it reopens.`, - notification: { - source: { kind: "monitor" }, - outcome: "updated", - summary: `#${target.link.number}: closed, stopped watching`, + ended: "closed", + wake: { + text: `Pull request #${target.link.number} (${target.link.url}) was closed, so T3 Code stopped watching it. Call watch_pull_request if it reopens.`, + notification: { + source: { kind: "monitor" }, + outcome: "updated", + summary: `#${target.link.number}: closed, stopped watching`, + }, }, }).pipe( Effect.tap(() => woke(target)), Effect.tap(() => ended(target, "closed")), ); + /** + * A thread snoozed until its pull requests need attention settles when its last watched one + * merges or closes and no other linked one is open, which waits until its turn is over. + */ + const waitsForTurn = (target: WatchTarget) => + target.thread.snoozedUntilNeedsAttention !== true || + visibleThreadPullRequests(target.thread.pullRequests ?? []).some( + (link) => link.watch !== undefined && !threadPullRequestKeysEqual(link, target.link), + ) || + otherPullRequestOpen(target.thread.pullRequests ?? [], target.link) + ? Effect.succeed(false) + : projections + .getThreadRecords(target.thread.id, ["runs", "runtimeRequests"]) + .pipe(Effect.map((records) => !Orchestrator.threadIdle(records))); + + /** Ends the watch on a pull request that merged or closed. Succeeds with whether it waits. */ + const finish = (target: WatchTarget, state: "merged" | "closed") => + Effect.gen(function* () { + if (yield* waitsForTurn(target)) return true; + yield* state === "closed" + ? closed(target) + : record(target, null, { ended: "merged" }).pipe(Effect.tap(() => ended(target, "merged"))); + return false; + }).pipe( + // The orchestrator checks again under the thread lock, and refuses while a turn that started + // since is still going. + Effect.catchTags({ + OrchestratorDispatchError: (error) => + target.thread.snoozedUntilNeedsAttention === true + ? Effect.logDebug("pull request watch end waits for the turn", { + threadId: target.thread.id, + pullRequest: threadPullRequestKeyOf(target.link), + error, + }).pipe(Effect.as(true)) + : Effect.fail(error), + }), + ); + /** Why a watch ends without a host read; the rest are read once per pull request. */ const endsWithoutRead = ({ thread, link }: WatchTarget): WatchEndReason | undefined => // A merged pull request cannot reopen. Settling and archiving end watches, and a subagent @@ -440,6 +544,21 @@ export const make = Effect.gen(function* () { ), ).pipe(Effect.map((landed) => landed.every(Boolean))); + /** Ends the watches on a pull request that merged or closed, remembering a merge that waits. */ + const endGroup = (group: WatchGroup, state: "merged" | "closed") => + Effect.gen(function* () { + let waiting = false; + yield* eachTarget(group, (target) => + finish(target, state).pipe( + Effect.map((waits) => { + waiting ||= waits; + }), + ), + ); + if (waiting && state === "merged") merged.add(group.key); + else merged.delete(group.key); + }); + const referenceOf = (group: WatchGroup) => { const first = group.targets[0]!; return { projectId: first.thread.projectId, ...identityOf(first.link) }; @@ -471,6 +590,7 @@ export const make = Effect.gen(function* () { const readGroup = Effect.fn("PullRequestWatchReactor.readGroup")(function* ( group: WatchGroup, fingerprint: ProviderChangeRequestWatchFingerprint | null, + snoozeAwaitingReview: boolean, ) { const now = yield* Clock.currentTimeMillis; const last = lastReads.get(group.key); @@ -493,6 +613,8 @@ export const make = Effect.gen(function* () { ); if (Exit.isFailure(read)) { if (Cause.hasInterruptsOnly(read.cause)) return yield* Effect.failCause(read.cause); + // A snooze read the pull request since, so this failure must not count against its watches. + if (primedSince(group.key, group.targets[0]!.pass)) return yield* new PrimedSinceRead(); lastReads.delete(group.key); // The host's pause refuses later reads without a request, so waiting it out is free. if (isRateLimited(read.cause)) return; @@ -523,11 +645,7 @@ export const make = Effect.gen(function* () { } if (detail.state !== "open") { lastReads.delete(group.key); - return yield* eachTarget(group, (target) => - detail.state === "closed" - ? closed(target) - : record(target, null).pipe(Effect.tap(() => ended(target, "merged"))), - ); + return yield* endGroup(group, detail.state === "closed" ? "closed" : "merged"); } // Never advance the remark watermark past comments an incomplete read could have missed. @@ -535,45 +653,49 @@ export const make = Effect.gen(function* () { const remarks = Option.isSome(activity) ? yield* readRemarks(group.key, reference, activity.value) : null; - lastReads.set(group.key, { - at: plan.activity || last === undefined ? now : last.at, - fingerprint: group.fingerprint, - inFlight: - detail.mergeability === "unknown" || - detail.checks.some((check) => check.status === "pending"), - // Comments a partial read could not see are read again next pass. - remarksComplete: plan.activity ? remarks !== null : (last?.remarksComplete ?? false), - watches: new Set(group.targets.map(watchKey)), - status: fingerprint?.status ?? null, - remarks: fingerprint?.remarks ?? null, - }); + if (!primedSince(group.key, group.targets[0]!.pass)) { + lastReads.set(group.key, { + at: plan.activity || last === undefined ? now : last.at, + fingerprint: group.fingerprint, + inFlight: + detail.mergeability === "unknown" || + detail.checks.some((check) => check.status === "pending"), + // Comments a partial read could not see are read again next pass. + remarksComplete: plan.activity ? remarks !== null : (last?.remarksComplete ?? false), + watches: new Set(group.targets.map(watchKey)), + status: fingerprint?.status ?? null, + remarks: fingerprint?.remarks ?? null, + }); + } yield* eachTarget(group, (target) => { const report = evaluatePullRequestWatch(target.watch, detail, remarks); if (report.changes.length > 0) { - return record( - target, - report.exhausted ? null : report.next, - pullRequestWatchMessage({ + return record(target, report.exhausted ? null : report.next, { + wake: pullRequestWatchMessage({ number: target.link.number, url: target.link.url, baseBranch: detail.baseBranch, headSha: report.next.headSha, report, + snoozeAwaitingReview, }), - ).pipe( + }).pipe( Effect.tap(() => woke(target)), Effect.tap(() => (report.exhausted ? ended(target, "comment-limit") : Effect.void)), ); } - return watchesEqual(report.next, target.watch) ? Effect.void : record(target, report.next); + return pullRequestWatchesEqual(report.next, target.watch) + ? Effect.void + : record(target, report.next); }); }); const sweep = Effect.gen(function* () { + const pass = primings; const threads = yield* projections.getThreadsWithPullRequests(); const targets = threads.flatMap((thread) => visibleThreadPullRequests(thread.pullRequests ?? []).flatMap((link) => - link.watch === undefined ? [] : [{ thread, link, watch: link.watch }], + link.watch === undefined ? [] : [{ thread, link, watch: link.watch, pass }], ), ); // A watch seen last pass and gone now was ended outside this reactor. @@ -592,9 +714,7 @@ export const make = Effect.gen(function* () { ending.push([target, reason]); continue; } - // Grouped per project too: each project reads through its own checkout, so one that cannot - // read the pull request must not end another project's watches. - const key = `${target.thread.projectId} ${threadPullRequestKeyOf(target.link)}`; + const key = groupKeyOf(target.thread.projectId, target.link); byPullRequest.set(key, [...(byPullRequest.get(key) ?? []), target]); } const groups = [...byPullRequest].map(([key, members]): WatchGroup => ({ @@ -602,14 +722,17 @@ export const make = Effect.gen(function* () { targets: members, fingerprint: [...new Set(members.map(snapshotFingerprint))].toSorted().join("\n"), })); - for (const cache of [readFailures, lastReads, threadTails]) { + for (const cache of [readFailures, lastReads, threadTails, checkedAt, merged, primedAt]) { for (const key of cache.keys()) if (!byPullRequest.has(key)) cache.delete(key); } yield* Effect.forEach( ending, ([target, reason]) => - record(target, null).pipe( - Effect.tap(() => ended(target, reason)), + Effect.gen(function* () { + if (reason === "merged") return yield* finish(target, "merged"); + yield* record(target, null); + yield* ended(target, reason); + }).pipe( Effect.catchCause( logFailure("pull request watch stop failed", { threadId: target.thread.id, @@ -619,16 +742,40 @@ export const make = Effect.gen(function* () { ), { discard: true }, ); - const fingerprints = yield* Effect.forEach(groups, fingerprintOf, { + // A merged pull request whose end waited for a turn is ended again without a read. + const waited = groups.filter((group) => merged.has(group.key)); + yield* Effect.forEach(waited, (group) => endGroup(group, "merged"), { discard: true }); + const now = yield* Clock.currentTimeMillis; + const due = groups.filter((group) => { + if (waited.includes(group)) return false; + if (!group.targets.every((target) => target.thread.snoozedUntilNeedsAttention === true)) { + return true; + } + // A watch still takes its first look on the next pass. + const last = lastReads.get(group.key); + const checked = checkedAt.get(group.key); + return ( + last === undefined || + checked === undefined || + group.targets.some((target) => !last.watches.has(watchKey(target))) || + now - checked >= SNOOZED_CHECK_MS + ); + }); + for (const group of due) checkedAt.set(group.key, now); + const snoozeAwaitingReview = yield* settings.getSettings.pipe( + Effect.map((current) => current.snoozePullRequestsAwaitingReview), + Effect.orElseSucceed(() => false), + ); + const fingerprints = yield* Effect.forEach(due, fingerprintOf, { concurrency: "unbounded", }); yield* Effect.forEach( - groups, + due, (group, index) => { const fingerprint = fingerprints[index]!; return fingerprint === "paused" ? Effect.void - : readGroup(group, fingerprint).pipe( + : readGroup(group, fingerprint, snoozeAwaitingReview).pipe( Effect.catchCause( logFailure("pull request watch check failed", { pullRequest: group.key }), ), @@ -641,12 +788,73 @@ export const make = Effect.gen(function* () { Effect.withSpan("PullRequestWatchReactor.sweep"), ); + const snoozeUntilAttention: PullRequestWatchReactor["Service"]["snoozeUntilAttention"] = ( + command, + ) => + Effect.gen(function* () { + const thread = yield* engine.getThreadShell(command.threadId); + const startedAt = DateTime.formatIso(yield* DateTime.now); + // A merged pull request never reopens. + const open = + thread === null + ? [] + : visibleThreadPullRequests(thread.pullRequests ?? []).flatMap((link) => + link.snapshot?.state === "merged" + ? [] + : [{ link, reference: { projectId: thread.projectId, ...identityOf(link) } }], + ); + const read = yield* Effect.forEach( + open, + ({ link, reference }) => + Effect.gen(function* () { + // The same fresh read a pass makes, not a cached answer from before the snooze. + yield* pullRequests.invalidate({ reference }); + const detail = yield* pullRequests.detail({ ...reference, allowStale: false }).pipe( + Effect.mapError( + (cause) => + new PullRequestAttentionReadError({ + threadId: command.threadId, + number: link.number, + cause, + }), + ), + ); + return detail.state === "open" ? [{ link, reference, detail }] : []; + }), + { concurrency: 4 }, + ).pipe(Effect.map((reads) => reads.flat())); + const keys = read.map(({ link, reference }) => groupKeyOf(reference.projectId, link)); + const prime = () => { + primings += 1; + for (const key of keys) primedAt.set(key, primings); + }; + // A pass that began before this read may have read the pull request before it did, so once + // this lands, nothing that pass saw may be reported against the watches it primes. + prime(); + // The snooze's read succeeded, so like a pass's it starts the failure count over. + for (const key of keys) readFailures.delete(key); + const result = yield* engine.dispatch({ + ...command, + links: visibleThreadPullRequests(thread?.pullRequests ?? []).map(identityOf), + watches: read.map(({ link, detail }) => ({ + ...identityOf(link), + previous: link.watch ?? null, + watch: primedPullRequestWatch(link.watch ?? newPullRequestWatch(startedAt), detail), + })), + }); + // The next pass reads these in full, against the watches the snooze primed, so one that + // began while it dispatched must not leave a read it would trust. + prime(); + for (const key of keys) lastReads.delete(key); + return result; + }).pipe(Effect.withSpan("PullRequestWatchReactor.snoozeUntilAttention")); + const start: PullRequestWatchReactor["Service"]["start"] = () => forkParked( sweep.pipe(Effect.repeat(Schedule.spaced(`${SWEEP_MINUTES} minutes`)), Effect.asVoid), ); - return { start, sweep } satisfies PullRequestWatchReactor["Service"]; + return { start, sweep, snoozeUntilAttention } satisfies PullRequestWatchReactor["Service"]; }); export const layer = Layer.effect(PullRequestWatchReactor, make); diff --git a/apps/server/src/orchestration-v2/SubagentProjection.test.ts b/apps/server/src/orchestration-v2/SubagentProjection.test.ts index 23d180102cd3..018840759c20 100644 --- a/apps/server/src/orchestration-v2/SubagentProjection.test.ts +++ b/apps/server/src/orchestration-v2/SubagentProjection.test.ts @@ -114,6 +114,24 @@ it("keeps a subagent child awake when its parent thread is snoozed", () => { }); }); +it("keeps a subagent child awake when its parent waits on a pull request", () => { + const childThread = makeSubagentChildThread({ + parentThread: { ...makeParentThread(), snoozedUntil: null, snoozedUntilNeedsAttention: true }, + childThreadId, + parentNodeId: NodeId.make("node:subagent-parent"), + activeProviderThreadId: null, + providerInstanceId: childProviderInstanceId, + modelSelection: childModelSelection, + title: "Awake child", + now: childCreatedAt, + createdBy: "agent", + creationSource: "provider", + }); + + assert.notEqual(childThread.snoozedUntilNeedsAttention, true); + assert.isNull(childThread.snoozedAt); +}); + it("attributes native subagent prompts to their parent thread", () => { for (const role of ["user", "assistant"] as const) { const artifacts = makeSubagentConversationArtifacts({ diff --git a/apps/server/src/orchestration-v2/SubagentProjection.ts b/apps/server/src/orchestration-v2/SubagentProjection.ts index f28dc1aa4be5..003e2034f155 100644 --- a/apps/server/src/orchestration-v2/SubagentProjection.ts +++ b/apps/server/src/orchestration-v2/SubagentProjection.ts @@ -51,8 +51,9 @@ export function makeSubagentChildThread(input: { readonly createdBy: OrchestrationV2Actor; readonly creationSource: OrchestrationV2CreationSource; }): OrchestrationV2AppThread { + const { snoozedUntilNeedsAttention: _held, ...parentThread } = input.parentThread; return { - ...input.parentThread, + ...parentThread, createdBy: input.createdBy, creationSource: input.creationSource, id: input.childThreadId, diff --git a/apps/server/src/orchestration-v2/ThreadForkService.ts b/apps/server/src/orchestration-v2/ThreadForkService.ts index 56c7aa81693d..21490a789539 100644 --- a/apps/server/src/orchestration-v2/ThreadForkService.ts +++ b/apps/server/src/orchestration-v2/ThreadForkService.ts @@ -85,8 +85,10 @@ export const layer: Layer.Layer = Layer.succeed( cause: forkableSourceRunStatusError(input.sourceRun), }); } + const { snoozedUntilNeedsAttention: _held, ...sourceThread } = + input.sourceProjection.thread; const targetThread: OrchestrationV2AppThread = { - ...input.sourceProjection.thread, + ...sourceThread, createdBy: input.createdBy, creationSource: input.creationSource, id: input.targetThreadId, diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts index 6bc54e94e738..ff1ec6caebe2 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.test.ts @@ -208,6 +208,17 @@ describe("isAutoSettlementCandidate", () => { ), ).toBe(true); }); + + it("leaves threads snoozed until their pull request needs attention to the watch", () => { + const snoozed = shell({ snoozedUntilNeedsAttention: true, snoozedAt: at(-60 * 60 * 1_000) }); + expect(ThreadSettlementService.isAutoSettlementCandidate(snoozed, NOW_MS)).toBe(false); + expect( + ThreadSettlementService.isAutoSettlementCandidate( + shell({ ...snoozed, status: "failed", latestRunCompletedAt: at(-30 * 60 * 1_000) }), + NOW_MS, + ), + ).toBe(false); + }); }); describe("threadHasQueuedTurnStart", () => { diff --git a/apps/server/src/orchestration-v2/ThreadSettlementService.ts b/apps/server/src/orchestration-v2/ThreadSettlementService.ts index 620e1e498d7b..df6d1e73004c 100644 --- a/apps/server/src/orchestration-v2/ThreadSettlementService.ts +++ b/apps/server/src/orchestration-v2/ThreadSettlementService.ts @@ -149,6 +149,9 @@ export function isAutoSettlementCandidate( if (thread.activityRunStatus != null) return false; if (backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? [])) return false; if (threadHasQueuedTurnStart(thread, nowMs)) return false; + // The pull request watch reactor settles a thread snoozed until its pull + // request needs attention, from a fresh read of the host. + if (thread.snoozedUntilNeedsAttention === true) return false; const snoozedUntilMs = toMillis(thread.snoozedUntil); if (snoozedUntilMs === null || snoozedUntilMs <= nowMs) return true; // A snoozed thread that woke early (error or completed work) can settle; diff --git a/apps/server/src/orchestration-v2/ThreadStop.test.ts b/apps/server/src/orchestration-v2/ThreadStop.test.ts index 60a251d019fa..17f01b96e787 100644 --- a/apps/server/src/orchestration-v2/ThreadStop.test.ts +++ b/apps/server/src/orchestration-v2/ThreadStop.test.ts @@ -308,7 +308,27 @@ it.effect("a run Stop reached cannot delegate or start a watch, even after it en watched: [4], }); - // The user can still stop and restart a watch by hand. + // Nor can a slow snooze_until_pull_request_needs_attention call snooze the thread. + const snooze = (createdBy: "agent" | "user") => + Effect.gen(function* () { + const { thread } = yield* orchestrator.getThreadProjection(threadId); + const watch = thread.pullRequests?.find((link) => link.number === 4)?.watch; + return yield* orchestrator.dispatch({ + type: "thread.snooze-until-attention", + commandId: CommandId.make(`snooze:${createdBy}`), + threadId, + createdBy, + links: [pullRequest(4)], + watches: [{ ...pullRequest(4), previous: watch!, watch: watch! }], + }); + }); + assert.include(String((yield* Effect.flip(snooze("agent"))).cause), "was stopped"); + assert.notEqual( + (yield* orchestrator.getThreadProjection(threadId)).thread.snoozedUntilNeedsAttention, + true, + ); + + // The user can still stop and restart a watch, and snooze, by hand. for (const watching of [false, true]) { yield* orchestrator.dispatch({ type: "thread.pull-request.watch", @@ -319,6 +339,10 @@ it.effect("a run Stop reached cannot delegate or start a watch, even after it en }); } assert.deepEqual((yield* threadState(threadId)).watched, [4]); + yield* snooze("user"); + assert.isTrue( + (yield* orchestrator.getThreadProjection(threadId)).thread.snoozedUntilNeedsAttention, + ); }).pipe(Effect.provide(layerTest)), ); diff --git a/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts b/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts index a7fe7c2902ec..067b078f188e 100644 --- a/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts +++ b/apps/server/src/orchestration-v2/UsageLimitRecoveryWorker.ts @@ -47,9 +47,11 @@ export function limitRecoveryCommand( }, }; } + // A snooze holds the continuation until the thread wakes. if ( !recovery.autoResume || resetMs > nowMs || + thread.snoozedUntilNeedsAttention === true || (thread.snoozedUntil != null && DateTime.toEpochMillis(thread.snoozedUntil) > nowMs) ) return null; diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts index ddd329d41173..94e8a1094563 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts @@ -8,6 +8,7 @@ import { assert, describe, it } from "@effect/vitest"; import { PULL_REQUEST_WATCH_WAKE_LIMIT, evaluatePullRequestWatch, + primedPullRequestWatch, pullRequestWatchMessage, } from "./pullRequestWatch.ts"; @@ -265,7 +266,67 @@ describe("evaluatePullRequestWatch", () => { }); }); +describe("primedPullRequestWatch", () => { + it("holds the pull request as it is, and reports what changes after", () => { + // Lint already fails and the branch conflicts: that is where the snooze found it. + const failing = detail({ + checks: [check("lint", "failure"), check("test", "pending")], + mergeability: "conflicting", + }); + const told = watch({ remarksThrough: "2026-10-02T12:05:00.000Z", remarkIds: ["r"], wakes: 3 }); + const primed = primedPullRequestWatch(told, failing); + assert.deepEqual(primed, { + ...told, + headSha: "aaaaaaaaaa", + failedChecks: ["lint"], + conflicting: true, + }); + assert.deepEqual(evaluatePullRequestWatch(primed, failing, noRemarks).changes, []); + + // Another check failing is news, and so is a comment after the snooze. + const comment = remark("reviewer", "2026-10-02T12:10:00.000Z"); + const worse = detail({ + checks: [check("lint", "failure"), check("test", "failure")], + mergeability: "conflicting", + }); + assert.deepEqual(evaluatePullRequestWatch(primed, worse, [comment]).changes, [ + { kind: "checks-failed", failed: [check("test", "failure")] }, + { kind: "remarks", remarks: [comment] }, + ]); + }); + + it("counts required checks that already passed as told, whatever advisory checks do", () => { + const green = detail({ + checks: [{ ...check("test", "success"), required: true }, check("bot", "pending")], + }); + const primed = primedPullRequestWatch(watch(), green); + assert.isTrue(primed.passed); + assert.deepEqual(evaluatePullRequestWatch(primed, green, noRemarks).changes, []); + }); +}); + describe("pullRequestWatchMessage", () => { + it("suggests snoozing a pull request that waits on review only when the setting is on", () => { + const report = evaluatePullRequestWatch( + watch(), + detail({ checks: [check("lint", "success")] }), + noRemarks, + ); + const message = (snoozeAwaitingReview: boolean) => + pullRequestWatchMessage({ + number: 12, + url: "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/o/r/pull/12", + baseBranch: "main", + headSha: report.next.headSha, + report, + snoozeAwaitingReview, + }).text; + assert.include(message(true), "unwatch_pull_request"); + assert.include(message(true), "call snooze_until_pull_request_needs_attention instead"); + assert.include(message(false), "unwatch_pull_request"); + assert.notInclude(message(false), "snooze"); + }); + it("tells the agent what changed and marks failures for the timeline", () => { const report = evaluatePullRequestWatch( watch(), diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.ts b/apps/server/src/orchestration-v2/pullRequestWatch.ts index 2d0fd15a9889..6923e59f018e 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.ts @@ -3,8 +3,14 @@ import type { PullRequestCheck, PullRequestComment, PullRequestDetail, + ThreadPullRequestKey, + ThreadPullRequestLink, ThreadPullRequestWatch, } from "@t3tools/contracts"; +import { + threadPullRequestKeysEqual, + visibleThreadPullRequests, +} from "@t3tools/shared/threadPullRequests"; /** * Wakes in a row that bring only comments. Check, conflict, or push news resets the count, so @@ -125,6 +131,66 @@ export function evaluatePullRequestWatch( }; } +/** A watch that starts now: only what happens from here on wakes the agent. */ +export function newPullRequestWatch(startedAt: string): ThreadPullRequestWatch { + return { + startedAt, + headSha: null, + failedChecks: [], + passed: false, + passedChecks: [], + remarksThrough: startedAt, + remarkIds: [], + conflicting: false, + wakes: 0, + }; +} + +/** Whether two watches record the same progress. */ +export function pullRequestWatchesEqual( + left: ThreadPullRequestWatch, + right: ThreadPullRequestWatch, +): boolean { + return ( + left.startedAt === right.startedAt && + left.headSha === right.headSha && + left.failedChecks.join("\n") === right.failedChecks.join("\n") && + left.passed === right.passed && + left.passedChecks.join("\n") === right.passedChecks.join("\n") && + left.remarksThrough === right.remarksThrough && + left.remarkIds.join("\n") === right.remarkIds.join("\n") && + left.conflicting === right.conflicting && + left.wakes === right.wakes + ); +} + +/** + * Whether a linked pull request other than `key` was open when it last synced. A thread snoozed + * until its pull requests need attention settles only when none is, since one that reopened after + * its watch ended is not watched again. + */ +export function otherPullRequestOpen( + pullRequests: ReadonlyArray, + key: ThreadPullRequestKey, +): boolean { + return visibleThreadPullRequests(pullRequests).some( + (link) => link.snapshot?.state === "open" && !threadPullRequestKeysEqual(link, key), + ); +} + +/** + * The watch a snooze until the pull request needs attention waits with: `watch` brought up to the + * head, checks, and conflict `detail` shows, as if the agent had been told, so only what changes + * after the snooze wakes it. Remarks are left to the watch's reads, so its remark cursor and + * comment-only wake count stay. + */ +export function primedPullRequestWatch( + watch: ThreadPullRequestWatch, + detail: Parameters[1], +): ThreadPullRequestWatch { + return { ...evaluatePullRequestWatch(watch, detail, null).next, wakes: watch.wakes }; +} + function snippet(body: string): string { const text = body .replaceAll(//g, " ") @@ -179,13 +245,18 @@ const SUMMARY: Record = { conflicting: "merge conflict", }; -/** The wake the agent reads and the timeline notification the user sees. */ +/** + * The wake the agent reads and the timeline notification the user sees. With + * `snoozeAwaitingReview` (the `snoozePullRequestsAwaitingReview` setting), a pull request that only + * waits on someone else is snoozed rather than handed back. + */ export function pullRequestWatchMessage(input: { readonly number: number; readonly url: string; readonly baseBranch: string; readonly headSha: string | null; readonly report: PullRequestWatchReport; + readonly snoozeAwaitingReview?: boolean; }): { readonly text: string; readonly notification: OrchestrationV2Notification } { const { changes, exhausted } = input.report; const context = { @@ -198,7 +269,14 @@ export function pullRequestWatchMessage(input: { "", exhausted ? `T3 Code stopped watching after ${PULL_REQUEST_WATCH_WAKE_LIMIT} comment-only updates in a row. Call watch_pull_request to watch it again.` - : "Look into each item and act on it as your task requires. T3 Code keeps watching and wakes you on the next change, so end your turn when you are done. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.", + : [ + "Look into each item and act on it as your task requires. T3 Code keeps watching and wakes you on the next change, so end your turn when you are done. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.", + ...(input.snoozeAwaitingReview + ? [ + "If the pull request is ready and only waits on someone else to review or merge it, call snooze_until_pull_request_needs_attention instead of unwatch_pull_request, then end your turn: T3 Code keeps watching and snoozes the thread until the pull request needs attention.", + ] + : []), + ].join(" "), ].join("\n"); const failed = changes.some( (change) => change.kind === "checks-failed" || change.kind === "conflicting", diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 131f05034051..c57d532fcc81 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -19,6 +19,7 @@ import { ProjectId, type PullRequestDetail, type PullRequestComment, + type ThreadPullRequestSnapshot, PullRequestOperationError, ProviderDriverKind, ProviderInstanceId, @@ -64,6 +65,7 @@ import * as ProviderRuntimeRecoveryService from "./ProviderRuntimeRecoveryServic import * as ProjectionMaintenance from "./ProjectionMaintenance.ts"; import * as ProjectionStore from "./ProjectionStore.ts"; import * as PullRequestWatchReactor from "./PullRequestWatchReactor.ts"; +import { newPullRequestWatch } from "./pullRequestWatch.ts"; import { PullRequestProviderError } from "../pullRequest/PullRequestProvider.ts"; import * as PullRequestService from "../pullRequest/PullRequestService.ts"; import * as ProjectStore from "./ProjectStore.ts"; @@ -73,6 +75,7 @@ import * as RuntimeLayer from "./runtimeLayer.ts"; import { shellStreamItemFromThreadShell } from "./ShellStream.ts"; import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as ThreadManagementService from "./ThreadManagementService.ts"; +import * as ThreadSettlementService from "./ThreadSettlementService.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; const layerPlatformTest = Layer.merge( @@ -2262,7 +2265,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { commandId: CommandId.make("pr-watch-record"), threadId, ...key, - startedAt: started.startedAt, + previous: started, watch: recorded, }); assert.deepEqual(yield* watchOf, recorded); @@ -2283,7 +2286,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { commandId: CommandId.make("pr-watch-late-record"), threadId, ...key, - startedAt: started.startedAt, + previous: recorded, watch: { ...recorded, wakes: 2 }, wake: { messageId: MessageId.make("pr-watch-late-wake"), @@ -2365,7 +2368,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { threadId, ...key, number: 7, - startedAt: started.startedAt, + previous: started, watch: { ...started, headSha: "late", wakes: 1 }, ...(wake === undefined ? {} : { wake }), }) @@ -2498,6 +2501,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { Effect.provide( Layer.mergeAll( NodeServices.layer, + ServerSettings.layerTest(), Layer.mock(PullRequestService.PullRequestService)({ detail: () => Effect.die("host unreachable"), activity: () => Effect.die("host unreachable"), @@ -2577,6 +2581,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { Effect.provide( Layer.mergeAll( NodeServices.layer, + ServerSettings.layerTest(), Layer.mock(PullRequestService.PullRequestService)({ detail: () => Effect.suspend(() => { @@ -2713,6 +2718,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { Effect.provide( Layer.mergeAll( NodeServices.layer, + ServerSettings.layerTest(), Layer.mock(PullRequestService.PullRequestService)({ detail: () => Effect.sync(() => ({ @@ -2815,6 +2821,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { Effect.provide( Layer.mergeAll( NodeServices.layer, + ServerSettings.layerTest(), Layer.mock(PullRequestService.PullRequestService)({ watchFingerprint: () => Effect.suspend(() => @@ -3024,6 +3031,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { Effect.provide( Layer.mergeAll( NodeServices.layer, + ServerSettings.layerTest(), Layer.mock(PullRequestService.PullRequestService)({ detail: () => Effect.succeed(detail), activity: () => @@ -3127,6 +3135,1214 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + /** + * One project whose host serves pull requests #7 and #8 (open, mergeable, lint failing), with a + * fingerprint that moves on every change and one reactor over it. `thread` makes a thread that + * links the given pull requests, synced as the host serves them; `sync` changes what sync saw. + * A pull request in `host.unreadable` fails to read. + */ + const attentionHost = (name: string) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projectId = ProjectId.make(`pr-attention-${name}-project`); + yield* seedProject({ + projectId, + title: "Attention", + workspaceRoot: "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/workspace/watch", + defaultModelSelection: null, + createdAt: "2026-10-01T00:00:00.000Z", + }); + const host = { + details: {} as Record>, + comments: {} as Record>, + version: 0, + stateFingerprints: false, + // Reads of this project's pull requests; watches left by other tests are read too. + reads: {} as Record, + unreadable: new Set(), + readGate: null as null | { + entered: Deferred.Deferred; + release: Deferred.Deferred; + }, + }; + const change = ( + number: number, + patch: { + readonly detail?: Partial; + readonly comments?: ReadonlyArray; + }, + ) => + Effect.sync(() => { + if (patch.detail) host.details[number] = { ...host.details[number], ...patch.detail }; + if (patch.comments) host.comments[number] = patch.comments; + host.version += 1; + }); + const reactor = yield* PullRequestWatchReactor.make.pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + ServerSettings.layerTest({ sidebarAutoSettleOnMerge: false }), + Layer.mock(PullRequestService.PullRequestService)({ + watchFingerprint: (input) => + Effect.sync(() => + host.stateFingerprints + ? { + status: host.details[input.number]?.mergeability ?? "mergeable", + remarks: "no-remarks", + } + : { status: `${host.version}`, remarks: `${host.version}` }, + ), + invalidate: () => Effect.void, + detail: (input) => + Effect.suspend(() => { + if (input.projectId === projectId) { + host.reads[input.number] = (host.reads[input.number] ?? 0) + 1; + if (host.unreadable.has(input.number)) { + const failed = Effect.fail( + new PullRequestOperationError({ + operation: "detail", + detail: "Host unavailable", + }), + ); + const gate = host.readGate; + if (gate === null) return failed; + host.readGate = null; + return Deferred.succeed(gate.entered, undefined).pipe( + Effect.andThen(Deferred.await(gate.release)), + Effect.andThen(failed), + ); + } + } + const detail = { + ...watchedPullRequestDetail({ + projectId, + number: input.number, + at: "2026-10-02T12:00:00.000Z", + }), + ...host.details[input.number], + }; + const gate = input.projectId === projectId ? host.readGate : null; + if (gate === null) return Effect.succeed(detail); + host.readGate = null; + return Deferred.succeed(gate.entered, undefined).pipe( + Effect.andThen(Deferred.await(gate.release)), + Effect.as(detail), + ); + }), + activity: (input) => + Effect.sync(() => { + const comments = host.comments[input.number] ?? []; + return { + comments, + commentCount: comments.length, + commentsTruncated: false, + reviewThreads: [], + commits: [], + }; + }), + }), + ), + ), + ); + let commands = 0; + const commandId = (label: string) => + CommandId.make(`pr-attention-${name}-${label}-${(commands += 1)}`); + const thread = (id: string, numbers: ReadonlyArray) => + Effect.gen(function* () { + const threadId = ThreadId.make(`runtime-pr-attention-${name}-${id}`); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: commandId("create"), + threadId, + projectId, + title: "Attention", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + for (const number of numbers) { + yield* orchestrator.dispatch({ + type: "thread.pull-request.link", + commandId: commandId("link"), + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number, + url: `https://github.com/pingdotgg/t3code/pull/${number}`, + source: "agent", + }); + } + const sync = (number: number, snapshot: Partial = {}) => + orchestrator.dispatch({ + type: "thread.pull-request-link.sync", + commandId: commandId("sync"), + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number, + snapshot: { + state: "open", + title: "Watched pull request", + headBranch: "feature", + baseBranch: "main", + isDraft: false, + updatedAt: null, + syncedAt: "2026-10-02T12:00:00.000Z", + checksState: "failing", + mergeability: "mergeable", + ...snapshot, + }, + stack: null, + }); + for (const number of numbers) yield* sync(number); + const shell = orchestrator + .getThreadShell(threadId) + .pipe(Effect.map((current) => current!)); + return { + threadId, + shell, + sync, + watchOf: (number: number) => + Effect.map( + shell, + (current) => current.pullRequests?.find((link) => link.number === number)?.watch, + ), + summaries: Effect.map( + orchestrator.getThreadRecords(threadId, ["messages"]), + ({ messages }) => messages.flatMap((message) => message.notification?.summary ?? []), + ), + snooze: Effect.suspend(() => + reactor.snoozeUntilAttention({ + type: "thread.snooze-until-attention", + commandId: commandId("snooze"), + threadId, + }), + ), + }; + }); + return { host, change, reactor, thread, commandId }; + }); + + it.effect("snoozes a thread whose pull request was linked by a legacy single-link event", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const { thread, commandId } = yield* attentionHost("legacy-single-link"); + const subject = yield* thread("subject", []); + const projection = yield* orchestrator.getThreadProjection(subject.threadId); + const { pullRequests: _links, ...legacy } = projection.thread; + const now = yield* DateTime.now; + yield* eventSink.write({ + commandId: commandId("legacy-event"), + events: [ + { + id: EventId.make("legacy-single-link"), + type: "thread.metadata-updated", + threadId: subject.threadId, + occurredAt: now, + payload: { + ...legacy, + linkedPullRequest: { + projectId: legacy.projectId, + repository: "pingdotgg/t3code", + number: 7, + url: "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/pingdotgg/t3code/pull/7", + }, + }, + }, + ], + }); + const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; + assert.isTrue((yield* maintenance.rebuild).valid); + assert.isUndefined( + (yield* orchestrator.getThreadProjection(subject.threadId)).thread.pullRequests, + ); + const before = yield* subject.shell; + assert.equal(before.pullRequests?.[0]?.number, 7); + yield* subject.snooze; + assert.isTrue((yield* subject.shell).snoozedUntilNeedsAttention); + assert.isDefined(yield* subject.watchOf(7)); + const recorded = (yield* orchestrator.getThreadProjection(subject.threadId)).thread; + assert.isDefined(recorded.pullRequests?.find((link) => link.number === 7)?.watch); + }), + ); + + it.effect("snoozes a thread until its pull requests need attention", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const { thread, reactor, commandId } = yield* attentionHost("snooze"); + const subject = yield* thread("subject", [7, 8]); + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: commandId("watch"), + threadId: subject.threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 8, + watching: true, + }); + const watching = yield* subject.watchOf(8); + yield* TestClock.adjust("1 minute"); + + yield* subject.snooze; + const snoozed = yield* subject.shell; + assert.isTrue(snoozed.snoozedUntilNeedsAttention); + assert.isNull(snoozed.snoozedUntil ?? null); + assert.isNotNull(snoozed.snoozedAt ?? null); + // Each watch waits from what the host showed at the snooze; one already on keeps its start. + const held = { headSha: "abc1234def", failedChecks: ["lint"] }; + assert.deepInclude(yield* subject.watchOf(7), { ...held, wakes: 0 }); + assert.deepEqual(yield* subject.watchOf(8), { ...watching!, ...held }); + + // The pull requests already fail lint; that is where they stood, so it wakes nobody. + yield* reactor.sweep; + assert.deepEqual(yield* subject.summaries, []); + assert.isTrue((yield* subject.shell).snoozedUntilNeedsAttention); + assert.deepEqual((yield* subject.watchOf(7))?.failedChecks, ["lint"]); + + // Snoozing follows the snooze rules, and needs a pull request to wait on. + const unlinked = yield* thread("unlinked", []); + assert.equal((yield* unlinked.snooze.pipe(Effect.flip))._tag, "OrchestratorDispatchError"); + const queued = yield* thread("queued", [7]); + for (const mode of ["start_immediately", "queue_after_active"] as const) { + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: commandId("message"), + threadId: queued.threadId, + messageId: MessageId.make(`pr-attention-snooze-queued-${mode}`), + text: "Work.", + attachments: [], + modelSelection, + dispatchMode: { type: mode }, + }); + } + const refused = yield* queued.snooze.pipe(Effect.flip); + assert.instanceOf(refused, Orchestrator.OrchestratorDispatchError); + assert.include(String(refused.cause), "queued run"); + assert.isUndefined(yield* queued.watchOf(7)); + }), + ); + + it.effect("an agent snoozes its running thread, which stays snoozed once its turn ends", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const { thread, commandId } = yield* attentionHost("running"); + const subject = yield* thread("subject", [7]); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: commandId("message"), + threadId: subject.threadId, + messageId: MessageId.make("pr-attention-running-message"), + text: "Open the pull request and wait for review.", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + }); + const { runs } = yield* orchestrator.getThreadRecords(subject.threadId, ["runs"]); + assert.equal(runs[0]?.status, "starting"); + + yield* subject.snooze; + const snoozedAt = (yield* subject.shell).snoozedAt; + assert.isTrue((yield* subject.shell).snoozedUntilNeedsAttention); + + yield* TestClock.adjust("1 minute"); + const now = yield* DateTime.now; + yield* eventSink.write({ + commandId: commandId("completed"), + events: [ + { + id: EventId.make("pr-attention-running-completed"), + type: "run.updated", + threadId: subject.threadId, + runId: runs[0]!.id, + occurredAt: now, + payload: { ...runs[0]!, status: "completed", startedAt: now, completedAt: now }, + }, + ], + }); + const after = yield* subject.shell; + assert.isTrue(after.snoozedUntilNeedsAttention); + assert.deepEqual(after.snoozedAt, snoozedAt); + assert.isDefined(yield* subject.watchOf(7)); + }), + ); + + it.effect.each([ + ["a failed check", "#7: checks failed"], + ["a comment", "#7: new comments"], + ["a conflict", "#7: merge conflict"], + ] as const)("%s wakes the agent and brings the thread back, still watching", ([news, summary]) => + Effect.gen(function* () { + const { thread, reactor, change } = yield* attentionHost(`wake-${news.replaceAll(" ", "-")}`); + const subject = yield* thread("subject", [7]); + yield* subject.snooze; + yield* reactor.sweep; + assert.deepEqual(yield* subject.summaries, []); + + yield* change( + 7, + news === "a failed check" + ? { + detail: { + checks: [ + { name: "lint", status: "failure", description: null, url: null }, + { name: "test", status: "failure", description: null, url: null }, + ], + }, + } + : news === "a comment" + ? { + comments: [ + { + id: "review-1", + kind: "review-comment", + author: { login: "maintainer", name: null, avatarUrl: null }, + body: "One more thing.", + createdAt: "2999-01-01T00:00:00.000Z", + url: null, + path: "src/index.ts", + reviewState: null, + }, + ], + } + : { detail: { mergeability: "conflicting" } }, + ); + yield* TestClock.adjust("10 minutes"); + yield* reactor.sweep; + assert.deepEqual(yield* subject.summaries, [summary]); + const woken = yield* subject.shell; + assert.notEqual(woken.snoozedUntilNeedsAttention, true); + assert.isNull(woken.snoozedAt ?? null); + assert.isDefined(yield* subject.watchOf(7)); + }), + ); + + it.effect.each([ + [ + "another check fails", + { + detail: { + checks: [ + { name: "lint", status: "failure", description: null, url: null }, + { name: "test", status: "failure", description: null, url: null }, + ], + }, + }, + "#7: checks failed", + ], + [ + "the branch starts to conflict", + { detail: { mergeability: "conflicting" } }, + "#7: merge conflict", + ], + ] as const)( + "when %s between the snooze and the watch's first read, the agent wakes", + ([news, hostChange, summary]) => + Effect.gen(function* () { + const { thread, reactor, change } = yield* attentionHost( + `early-${news.replaceAll(" ", "-")}`, + ); + const subject = yield* thread("subject", [7]); + yield* subject.snooze; + yield* change(7, hostChange); + yield* reactor.sweep; + assert.deepEqual(yield* subject.summaries, [summary]); + assert.notEqual((yield* subject.shell).snoozedUntilNeedsAttention, true); + }), + ); + + it.effect.each([ + ["sync last saw checks running", { checksState: "pending" }], + ["sync never saw it", null], + ] as const)("a pull request snoozed as the host shows it stays quiet when %s", ([name, synced]) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const { thread, reactor, change, commandId } = yield* attentionHost( + `as-is-${name.replaceAll(" ", "-")}`, + ); + const subject = yield* thread("subject", synced === null ? [] : [7]); + if (synced === null) { + yield* orchestrator.dispatch({ + type: "thread.pull-request.link", + commandId: commandId("link"), + threadId: subject.threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 7, + url: "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/pingdotgg/t3code/pull/7", + source: "agent", + }); + } else { + yield* subject.sync(7, synced); + } + // The required check passed; an advisory bot still runs, and lint already failed. + yield* change(7, { + detail: { + checks: [ + { name: "test", status: "success", required: true, description: null, url: null }, + { name: "bot", status: "pending", description: null, url: null }, + { name: "lint", status: "failure", description: null, url: null }, + ], + }, + }); + yield* subject.snooze; + yield* reactor.sweep; + yield* TestClock.adjust("10 minutes"); + yield* reactor.sweep; + assert.deepEqual(yield* subject.summaries, []); + assert.isTrue((yield* subject.shell).snoozedUntilNeedsAttention); + }), + ); + + it.effect("a watch already on waits from what the host shows at the snooze", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const { thread, reactor, change, commandId } = yield* attentionHost("existing"); + const subject = yield* thread("subject", [7]); + const pending = [{ name: "test", status: "pending", description: null, url: null }] as const; + yield* change(7, { detail: { checks: pending } }); + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: commandId("watch"), + threadId: subject.threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 7, + watching: true, + }); + yield* reactor.sweep; + const watching = yield* subject.watchOf(7); + + // The checks pass before the snooze, and the watch has not seen it. + yield* change(7, { + detail: { checks: [{ name: "test", status: "success", description: null, url: null }] }, + }); + yield* subject.snooze; + const primed = yield* subject.watchOf(7); + assert.equal(primed?.startedAt, watching?.startedAt); + assert.isTrue(primed?.passed); + yield* TestClock.adjust("10 minutes"); + yield* reactor.sweep; + assert.deepEqual(yield* subject.summaries, []); + assert.isTrue((yield* subject.shell).snoozedUntilNeedsAttention); + + // A comment after the snooze still wakes it. + yield* change(7, { + comments: [ + { + id: "review-1", + kind: "review-comment", + author: { login: "maintainer", name: null, avatarUrl: null }, + body: "One more thing.", + createdAt: "2999-01-01T00:00:00.000Z", + url: null, + path: "src/index.ts", + reviewState: null, + }, + ], + }); + yield* TestClock.adjust("10 minutes"); + yield* reactor.sweep; + assert.deepEqual(yield* subject.summaries, ["#7: new comments"]); + }), + ); + + it.effect("needs an open pull request, and a pull request it can read", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const { thread, change, host, commandId } = yield* attentionHost("refused"); + // The only watched pull request closed, whatever sync last saw. + const closed = yield* thread("closed", [7]); + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: commandId("watch"), + threadId: closed.threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 7, + watching: true, + }); + yield* change(7, { detail: { state: "closed" } }); + const refused = yield* closed.snooze.pipe(Effect.flip); + assert.include(String(refused.cause), "no open linked pull request"); + assert.notEqual((yield* closed.shell).snoozedUntilNeedsAttention, true); + + host.unreadable.add(8); + const unread = yield* thread("unread", [8]); + const failed = yield* unread.snooze.pipe(Effect.flip); + assert.equal(failed._tag, "PullRequestAttentionReadError"); + assert.notEqual((yield* unread.shell).snoozedUntilNeedsAttention, true); + assert.isUndefined(yield* unread.watchOf(8)); + }), + ); + + it.effect("refuses a snooze whose watch changed while the host was read", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const { thread, commandId } = yield* attentionHost("raced"); + const subject = yield* thread("subject", [7]); + const key = { host: "github.com", repository: "pingdotgg/t3code", number: 7 }; + const watch = { + startedAt: "2026-10-02T12:00:00.000Z", + headSha: "abc1234def", + failedChecks: ["lint"], + passed: false, + passedChecks: [], + remarksThrough: "2026-10-02T12:00:00.000Z", + remarkIds: [], + conflicting: false, + wakes: 0, + }; + // Read with no watch on, then a watch started before the snooze landed. + yield* orchestrator.dispatch({ + ...key, + type: "thread.pull-request.watch", + commandId: commandId("watch"), + threadId: subject.threadId, + watching: true, + }); + const refused = yield* orchestrator + .dispatch({ + type: "thread.snooze-until-attention", + commandId: commandId("snooze"), + threadId: subject.threadId, + links: [key], + watches: [{ ...key, previous: null, watch }], + }) + .pipe(Effect.flip); + assert.include(String(refused.cause), "changed while they were read"); + assert.notEqual((yield* subject.shell).snoozedUntilNeedsAttention, true); + }), + ); + + it.effect.each(["merged", "closed"] as const)( + "a pull request %s while the thread is at work settles it once the turn ends", + (state) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const { thread, reactor, change, host, commandId } = yield* attentionHost(`busy-${state}`); + const subject = yield* thread("subject", [7]); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: commandId("message"), + threadId: subject.threadId, + messageId: MessageId.make(`pr-attention-busy-${state}-message`), + text: "Open the pull request and wait for review.", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + }); + const { runs } = yield* orchestrator.getThreadRecords(subject.threadId, ["runs"]); + yield* subject.snooze; + yield* reactor.sweep; + + // The agent's turn is still going, so the snooze holds and keeps watching. + yield* change(7, { detail: { state } }); + yield* TestClock.adjust("10 minutes"); + yield* reactor.sweep; + const waiting = yield* subject.shell; + assert.isNull(waiting.settledOverride); + assert.isTrue(waiting.snoozedUntilNeedsAttention); + assert.isDefined(yield* subject.watchOf(7)); + // A merge is final, so it waits on what it read; a closed pull request can reopen, so each + // pass reads it again. + const reads = host.reads[7] ?? 0; + yield* TestClock.adjust("2 minutes"); + yield* reactor.sweep; + assert.equal(host.reads[7], state === "merged" ? reads : reads + 1); + + const now = yield* DateTime.now; + yield* eventSink.write({ + commandId: commandId("completed"), + events: [ + { + id: EventId.make(`pr-attention-busy-${state}-completed`), + type: "run.updated", + threadId: subject.threadId, + runId: runs[0]!.id, + occurredAt: now, + payload: { ...runs[0]!, status: "completed", startedAt: now, completedAt: now }, + }, + ], + }); + yield* TestClock.adjust("2 minutes"); + yield* reactor.sweep; + const settled = yield* subject.shell; + assert.equal(settled.settledOverride, "settled"); + assert.notEqual(settled.snoozedUntilNeedsAttention, true); + assert.isUndefined(yield* subject.watchOf(7)); + assert.deepEqual(yield* subject.summaries, []); + }), + ); + + it.effect.each(["idle", "busy"] as const)( + "a linked pull request that reopened keeps the thread from settling when it is %s", + (activity) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const { thread, reactor, change, commandId } = yield* attentionHost(`reopened-${activity}`); + const subject = yield* thread("subject", [7, 8]); + if (activity === "busy") { + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: commandId("message"), + threadId: subject.threadId, + messageId: MessageId.make(`pr-attention-reopened-${activity}-message`), + text: "Wait for both reviews.", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + }); + } + const { runs } = yield* orchestrator.getThreadRecords(subject.threadId, ["runs"]); + yield* subject.snooze; + yield* reactor.sweep; + + // #7 closing ends its watch at once, since #8 is still watched. + yield* change(7, { detail: { state: "closed" } }); + yield* subject.sync(7, { state: "closed" }); + yield* TestClock.adjust("10 minutes"); + yield* reactor.sweep; + assert.isUndefined(yield* subject.watchOf(7)); + assert.isTrue((yield* subject.shell).snoozedUntilNeedsAttention); + + // #7 reopens without a watch, then #8 closes. + yield* change(7, { detail: { state: "open" } }); + yield* subject.sync(7, { state: "open" }); + yield* change(8, { detail: { state: "closed" } }); + yield* subject.sync(8, { state: "closed" }); + yield* TestClock.adjust("10 minutes"); + yield* reactor.sweep; + if (activity === "busy") { + const now = yield* DateTime.now; + yield* eventSink.write({ + commandId: commandId("completed"), + events: [ + { + id: EventId.make(`pr-attention-reopened-${activity}-completed`), + type: "run.updated", + threadId: subject.threadId, + runId: runs[0]!.id, + occurredAt: now, + payload: { ...runs[0]!, status: "completed", startedAt: now, completedAt: now }, + }, + ], + }); + yield* TestClock.adjust("2 minutes"); + yield* reactor.sweep; + } + const after = yield* subject.shell; + assert.isNull(after.settledOverride); + assert.notEqual(after.snoozedUntilNeedsAttention, true); + assert.isUndefined(yield* subject.watchOf(8)); + assert.deepEqual(yield* subject.summaries, ["#8: closed, stopped watching"]); + }), + ); + + it.effect("automatic settlement leaves an attention snooze to its watch after a rollback", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const eventSink = yield* EventSink.EventSinkV2; + const { thread, reactor, change, commandId } = yield* attentionHost("auto-reopened"); + const subject = yield* thread("subject", [7]); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: commandId("message"), + threadId: subject.threadId, + messageId: MessageId.make("r7-auto-reopened-message"), + text: "Wait for review.", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + }); + const { runs } = yield* orchestrator.getThreadRecords(subject.threadId, ["runs"]); + yield* subject.snooze; + yield* reactor.sweep; + yield* TestClock.adjust("10 minutes"); + const closedAt = DateTime.formatIso(yield* DateTime.now); + yield* change(7, { detail: { state: "closed" } }); + yield* subject.sync(7, { state: "closed", closedAt }); + yield* reactor.sweep; + assert.isTrue((yield* subject.shell).snoozedUntilNeedsAttention); + assert.isDefined(yield* subject.watchOf(7)); + yield* change(7, { detail: { state: "open" } }); + yield* TestClock.adjust("1 millis"); + const now = yield* DateTime.now; + yield* eventSink.write({ + commandId: commandId("completed"), + events: [ + { + id: EventId.make("r7-auto-reopened-completed"), + type: "run.updated", + threadId: subject.threadId, + runId: runs[0]!.id, + occurredAt: now, + payload: { ...runs[0]!, status: "completed", startedAt: now, completedAt: now }, + }, + { + id: EventId.make("r7-auto-reopened-rolled-back"), + type: "run.updated", + threadId: subject.threadId, + runId: runs[0]!.id, + occurredAt: now, + payload: { ...runs[0]!, status: "rolled_back", startedAt: now, completedAt: now }, + }, + ], + }); + yield* subject.snooze; + assert.isTrue((yield* subject.shell).snoozedUntilNeedsAttention); + const candidate = (yield* projections.getSettlementCandidates(subject.threadId))[0]!; + const settledAt = ThreadSettlementService.resolveAutoSettlementAt({ + thread: candidate, + pullRequest: null, + nowMs: DateTime.toEpochMillis(now), + autoSettleAfterDays: null, + autoSettleOnMerge: true, + }); + assert.isNull(settledAt); + yield* reactor.sweep; + const after = yield* subject.shell; + assert.deepEqual( + { + settled: after.settledOverride, + snoozed: after.snoozedUntilNeedsAttention === true, + watching: (yield* subject.watchOf(7)) !== undefined, + }, + { settled: null, snoozed: true, watching: true }, + ); + }), + ); + + it.effect("waking the thread yourself ends the snooze and keeps the watch", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const { thread, reactor, change, host, commandId } = yield* attentionHost("user-wake"); + const subject = yield* thread("subject", [7]); + yield* subject.snooze; + yield* reactor.sweep; + yield* orchestrator.dispatch({ + type: "thread.unsnooze", + commandId: commandId("unsnooze"), + threadId: subject.threadId, + reason: "user", + }); + const awake = yield* subject.shell; + assert.notEqual(awake.snoozedUntilNeedsAttention, true); + assert.isNull(awake.snoozedAt ?? null); + assert.isDefined(yield* subject.watchOf(7)); + + // It is an ordinary watch again, checked at the normal pace. + const reads = host.reads[7]!; + yield* change(7, { detail: { mergeability: "conflicting" } }); + yield* reactor.sweep; + assert.equal(host.reads[7], reads + 1); + assert.deepEqual(yield* subject.summaries, ["#7: merge conflict"]); + }), + ); + + it.effect.each(["merged", "closed"] as const)( + "settles when its pull request is %s, without a wake, even with auto-settle on merge off", + (state) => + Effect.gen(function* () { + const { thread, reactor, change } = yield* attentionHost(`ended-${state}`); + const subject = yield* thread("subject", [7]); + yield* subject.snooze; + yield* reactor.sweep; + + yield* change(7, { detail: { state } }); + yield* TestClock.adjust("10 minutes"); + yield* reactor.sweep; + const ended = yield* subject.shell; + assert.equal(ended.settledOverride, "settled"); + assert.notEqual(ended.snoozedUntilNeedsAttention, true); + assert.isNull(ended.snoozedAt ?? null); + assert.isUndefined(yield* subject.watchOf(7)); + assert.deepEqual(yield* subject.summaries, []); + }), + ); + + it.effect("waits on every watched pull request until none is left open", () => + Effect.gen(function* () { + const { thread, reactor, change } = yield* attentionHost("several"); + const subject = yield* thread("subject", [7, 8]); + yield* subject.snooze; + yield* reactor.sweep; + + // One merging leaves the thread waiting on the other. + yield* change(7, { detail: { state: "merged" } }); + yield* subject.sync(7, { state: "merged" }); + yield* TestClock.adjust("10 minutes"); + yield* reactor.sweep; + const waiting = yield* subject.shell; + assert.isTrue(waiting.snoozedUntilNeedsAttention); + assert.isNull(waiting.settledOverride); + assert.isUndefined(yield* subject.watchOf(7)); + assert.isDefined(yield* subject.watchOf(8)); + + yield* change(8, { detail: { state: "closed" } }); + yield* TestClock.adjust("10 minutes"); + yield* reactor.sweep; + assert.equal((yield* subject.shell).settledOverride, "settled"); + assert.deepEqual(yield* subject.summaries, []); + }), + ); + + it.effect.each(["unwatch", "stop", "unlink", "settle", "archive"] as const)( + "ending the watch with %s ends the snooze", + (ending) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const { thread, commandId } = yield* attentionHost(`end-${ending}`); + const subject = yield* thread("subject", [7]); + yield* subject.snooze; + const key = { host: "github.com", repository: "pingdotgg/t3code", number: 7 }; + const common = { commandId: commandId(ending), threadId: subject.threadId }; + yield* orchestrator.dispatch( + ending === "unwatch" + ? { ...common, ...key, type: "thread.pull-request.watch", watching: false } + : ending === "stop" + ? { ...common, type: "thread.stop" } + : ending === "unlink" + ? { ...common, ...key, type: "thread.pull-request.unlink" } + : { ...common, type: `thread.${ending}` }, + ); + const ended = yield* orchestrator.getThreadProjection(subject.threadId); + assert.notEqual(ended.thread.snoozedUntilNeedsAttention, true); + assert.isNull(ended.thread.snoozedAt ?? null); + assert.isFalse( + ended.thread.pullRequests?.some((link) => link.watch !== undefined) ?? false, + ); + }), + ); + + it.effect("checks a pull request only snoozed threads watch every ten minutes", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const { thread, reactor, change, host, commandId } = yield* attentionHost("cadence"); + const alone = yield* thread("alone", [7]); + yield* alone.snooze; + const shared = yield* thread("shared", [8]); + yield* shared.snooze; + const awake = yield* thread("awake", [8]); + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: commandId("watch"), + threadId: awake.threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 8, + watching: true, + }); + + // Each snooze read its pull request, and every watch takes its first look at once. + assert.deepEqual(host.reads, { 7: 1, 8: 1 }); + yield* reactor.sweep; + assert.deepEqual(host.reads, { 7: 2, 8: 2 }); + + // The host moved: the pull request a normal watch shares is read on the next pass. + yield* change(7, { detail: { title: "Renamed" } }); + yield* TestClock.adjust("2 minutes"); + yield* reactor.sweep; + assert.deepEqual(host.reads, { 7: 2, 8: 3 }); + + yield* TestClock.adjust("8 minutes"); + yield* reactor.sweep; + assert.deepEqual(host.reads, { 7: 3, 8: 3 }); + }), + ); + + it.effect("a watch pass read before a snooze cannot replace the watch the snooze read", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const { thread, reactor, change, host, commandId } = yield* attentionHost("stale-pass"); + const subject = yield* thread("subject", [7]); + yield* change(7, { + detail: { checks: [{ name: "lint", status: "pending", description: null, url: null }] }, + }); + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: commandId("watch"), + threadId: subject.threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 7, + watching: true, + }); + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + host.readGate = { entered, release }; + const sweep = yield* reactor.sweep.pipe(Effect.forkChild); + yield* Deferred.await(entered); + yield* change(7, { + detail: { checks: [{ name: "lint", status: "failure", description: null, url: null }] }, + }); + yield* subject.snooze; + assert.deepEqual((yield* subject.watchOf(7))?.failedChecks, ["lint"]); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(sweep); + yield* TestClock.adjust("10 minutes"); + yield* reactor.sweep; + assert.deepEqual(yield* subject.summaries, []); + assert.isTrue((yield* subject.shell).snoozedUntilNeedsAttention); + }), + ); + + it.effect.each(["before", "after"] as const)( + "a pass that read before a snooze cannot hide a later change, finishing %s it", + (finishes) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const { thread, reactor, change, host, commandId } = yield* attentionHost( + `stale-pass-${finishes}`, + ); + host.stateFingerprints = true; + const subject = yield* thread("subject", [7]); + yield* change(7, { detail: { mergeability: "conflicting" } }); + yield* subject.snooze; + yield* reactor.sweep; + yield* orchestrator.dispatch({ + type: "thread.unsnooze", + commandId: commandId("unsnooze"), + threadId: subject.threadId, + reason: "user", + }); + yield* TestClock.adjust("30 minutes"); + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + host.readGate = { entered, release }; + const stale = yield* reactor.sweep.pipe(Effect.forkChild); + yield* Deferred.await(entered); + if (finishes === "before") { + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(stale); + } + yield* change(7, { detail: { mergeability: "mergeable" } }); + yield* subject.snooze; + assert.isFalse((yield* subject.watchOf(7))!.conflicting); + yield* change(7, { detail: { mergeability: "conflicting" } }); + if (finishes === "after") { + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(stale); + } + yield* TestClock.adjust("10 minutes"); + yield* reactor.sweep; + assert.deepEqual(yield* subject.summaries, ["#7: merge conflict"]); + assert.notEqual((yield* subject.shell).snoozedUntilNeedsAttention, true); + }), + ); + + it.effect( + "a pass that read a pull request closed cannot settle a snooze made after it reopened", + () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const { thread, reactor, change, host, commandId } = yield* attentionHost("stale-closed"); + const subject = yield* thread("subject", [7]); + yield* subject.snooze; + yield* reactor.sweep; + yield* orchestrator.dispatch({ + type: "thread.unsnooze", + commandId: commandId("unsnooze"), + threadId: subject.threadId, + reason: "user", + }); + const previous = yield* subject.watchOf(7); + yield* change(7, { detail: { state: "closed" } }); + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + host.readGate = { entered, release }; + const stale = yield* reactor.sweep.pipe(Effect.forkChild); + yield* Deferred.await(entered); + yield* change(7, { detail: { state: "open" } }); + yield* subject.snooze; + assert.deepEqual(yield* subject.watchOf(7), previous); + assert.isTrue((yield* subject.shell).snoozedUntilNeedsAttention); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(stale); + assert.notEqual((yield* subject.shell).settledOverride, "settled"); + assert.isTrue((yield* subject.shell).snoozedUntilNeedsAttention); + assert.isDefined(yield* subject.watchOf(7)); + }), + ); + + it.effect( + "a snooze starts the read failures over, and a pass that failed before it does not count", + () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const { thread, reactor, host, commandId } = yield* attentionHost("stale-failure"); + const subject = yield* thread("subject", [7]); + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: commandId("watch"), + threadId: subject.threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 7, + watching: true, + }); + host.unreadable.add(7); + for (let attempt = 0; attempt < 6; attempt += 1) yield* reactor.sweep; + assert.isDefined(yield* subject.watchOf(7)); + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + host.readGate = { entered, release }; + const stale = yield* reactor.sweep.pipe(Effect.forkChild); + yield* Deferred.await(entered); + host.unreadable.delete(7); + yield* subject.snooze; + assert.isTrue((yield* subject.shell).snoozedUntilNeedsAttention); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(stale); + assert.isDefined(yield* subject.watchOf(7)); + host.unreadable.add(7); + for (let attempt = 0; attempt < 7; attempt += 1) yield* reactor.sweep; + assert.isDefined(yield* subject.watchOf(7)); + assert.isTrue((yield* subject.shell).snoozedUntilNeedsAttention); + }), + ); + + it.effect("refuses a snooze when a watch pass records while the host is read", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const { thread, reactor, change, host, commandId } = yield* attentionHost("recorded-pass"); + const subject = yield* thread("subject", [7]); + yield* change(7, { + detail: { checks: [{ name: "lint", status: "pending", description: null, url: null }] }, + }); + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: commandId("watch"), + threadId: subject.threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 7, + watching: true, + }); + yield* reactor.sweep; + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + host.readGate = { entered, release }; + const snooze = yield* subject.snooze.pipe(Effect.forkChild); + yield* Deferred.await(entered); + // The pass sees lint fail and wakes the agent while the snooze still holds the pending read. + yield* change(7, { + detail: { checks: [{ name: "lint", status: "failure", description: null, url: null }] }, + }); + yield* TestClock.adjust("2 minutes"); + yield* reactor.sweep; + assert.deepEqual(yield* subject.summaries, ["#7: checks failed"]); + yield* Deferred.succeed(release, undefined); + const refused = yield* Fiber.join(snooze).pipe(Effect.flip); + assert.include(String(refused.cause), "changed while they were read"); + assert.notEqual((yield* subject.shell).snoozedUntilNeedsAttention, true); + // The agent was told about the failure once. + yield* TestClock.adjust("2 minutes"); + yield* reactor.sweep; + assert.equal((yield* subject.summaries).length, 1); + }), + ); + + it.effect( + "a pull request that closed and reopened during the turn does not settle the thread", + () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const eventSink = yield* EventSink.EventSinkV2; + const { thread, reactor, change, commandId } = yield* attentionHost("reopened"); + const subject = yield* thread("subject", [7]); + yield* orchestrator.dispatch({ + type: "message.dispatch", + createdBy: "user", + creationSource: "web", + commandId: commandId("message"), + threadId: subject.threadId, + messageId: MessageId.make("pr-attention-reopened-message"), + text: "Wait for review.", + attachments: [], + modelSelection, + dispatchMode: { type: "start_immediately" }, + }); + const { runs } = yield* orchestrator.getThreadRecords(subject.threadId, ["runs"]); + yield* subject.snooze; + yield* reactor.sweep; + yield* change(7, { detail: { state: "closed" } }); + yield* TestClock.adjust("10 minutes"); + yield* reactor.sweep; + assert.isTrue((yield* subject.shell).snoozedUntilNeedsAttention); + yield* change(7, { detail: { state: "open" } }); + yield* subject.sync(7, { state: "open" }); + const now = yield* DateTime.now; + yield* eventSink.write({ + commandId: commandId("completed"), + events: [ + { + id: EventId.make("pr-attention-reopened-completed"), + type: "run.updated", + threadId: subject.threadId, + runId: runs[0]!.id, + occurredAt: now, + payload: { ...runs[0]!, status: "completed", startedAt: now, completedAt: now }, + }, + ], + }); + yield* reactor.sweep; + assert.notEqual((yield* subject.shell).settledOverride, "settled"); + assert.isDefined(yield* subject.watchOf(7)); + }), + ); + + it.effect("refuses a snooze when a pull request is linked while the host is read", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const { thread, host, commandId } = yield* attentionHost("link-race"); + const subject = yield* thread("subject", [7]); + const entered = yield* Deferred.make(); + const release = yield* Deferred.make(); + host.readGate = { entered, release }; + const snooze = yield* subject.snooze.pipe(Effect.forkChild); + yield* Deferred.await(entered); + yield* orchestrator.dispatch({ + type: "thread.pull-request.link", + commandId: commandId("link8"), + threadId: subject.threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 8, + url: "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/pingdotgg/t3code/pull/8", + source: "agent", + }); + yield* Deferred.succeed(release, undefined); + // A snooze that never read #8 cannot wait on it, so it is refused and nothing changes. + const refused = yield* Fiber.join(snooze).pipe(Effect.flip); + assert.include(String(refused.cause), "changed while they were read"); + assert.notEqual((yield* subject.shell).snoozedUntilNeedsAttention, true); + assert.isUndefined(yield* subject.watchOf(7)); + yield* subject.snooze; + assert.isTrue((yield* subject.shell).snoozedUntilNeedsAttention); + assert.isDefined(yield* subject.watchOf(7)); + assert.isDefined(yield* subject.watchOf(8)); + }), + ); + it.effect("persists rejected command receipts across retries", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; @@ -4843,6 +6059,8 @@ it.layer(layerTest)("usage-limit recovery", (it) => { "cancel", "rearm", "snooze-race", + "attention-snooze-race", + "attention-snooze-armed", "new-message", "archive", "settle", @@ -4984,6 +6202,62 @@ it.layer(layerTest)("usage-limit recovery", (it) => { assert.equal(resumeHeldQueue._tag, "Failure"); assert.isTrue((yield* orchestrator.getThreadProjection(threadId)).runs[1]?.queueHeld); } + // Snoozed until pull request #1 needs attention, as the watch reactor primes it. + const snoozeUntilAttention = (label: string) => + Effect.gen(function* () { + const pullRequest = { host: "github.com", repository: "pingdotgg/t3code", number: 1 }; + yield* orchestrator.dispatch({ + ...pullRequest, + type: "thread.pull-request.link", + commandId: CommandId.make(`${label}:link`), + threadId, + url: "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/pingdotgg/t3code/pull/1", + source: "agent", + }); + yield* orchestrator.dispatch({ + type: "thread.snooze-until-attention", + commandId: CommandId.make(label), + threadId, + links: [pullRequest], + watches: [ + { + ...pullRequest, + previous: null, + watch: newPullRequestWatch(DateTime.formatIso(yield* DateTime.now)), + }, + ], + }); + }); + if (scenario === "attention-snooze-armed") { + // Arming with the snooze setting on, as the worker does, keeps the attention snooze. + yield* snoozeUntilAttention("recovery:armed-snooze"); + const held = (yield* orchestrator.getShellSnapshot()).threads.find( + (thread) => thread.id === threadId, + )!; + const arm = limitRecoveryCommand(held, true, DateTime.toEpochMillis(now), true); + assert.isNotNull(arm); + yield* orchestrator.dispatch(arm!); + const armed = (yield* orchestrator.getShellSnapshot()).threads.find( + (thread) => thread.id === threadId, + )!; + assert.isTrue(armed.snoozedUntilNeedsAttention); + assert.isNull(armed.snoozedUntil ?? null); + assert.isFalse(armed.limitRecovery?.snooze); + assert.isTrue(armed.limitRecovery?.autoResume); + + // The limit resets, and the continuation still waits for the pull request. + yield* TestClock.adjust("1 minute"); + const later = DateTime.toEpochMillis(yield* DateTime.now); + assert.isNull(limitRecoveryCommand(armed, true, later, true)); + const raced = limitRecoveryCommand( + { ...armed, snoozedUntilNeedsAttention: false }, + true, + later, + ); + yield* orchestrator.dispatch(raced!); + assert.lengthOf((yield* orchestrator.getThreadProjection(threadId)).runs, 1); + return; + } const shell = (yield* orchestrator.getShellSnapshot()).threads.find( (thread) => thread.id === threadId, )!; @@ -5183,6 +6457,43 @@ it.layer(layerTest)("usage-limit recovery", (it) => { yield* orchestrator.dispatch(freshResume!); assert.lengthOf((yield* orchestrator.getThreadProjection(threadId)).runs, 2); } + if (scenario === "attention-snooze-race") { + // Snoozed until a pull request needs attention, the continuation waits for the wake. + yield* snoozeUntilAttention("recovery:attention-snooze"); + const nowMs = DateTime.toEpochMillis(yield* DateTime.now); + const candidates = () => + Effect.flatMap(ProjectionStore.ProjectionStoreV2, (projections) => + Effect.flatMap(DateTime.now, (now) => + projections.getLimitRecoveryCandidates({ now, autoResume: true, snooze: false }), + ), + ).pipe(Effect.map((threads) => threads.map((thread) => thread.id))); + const held = (yield* orchestrator.getShellSnapshot()).threads.find( + (thread) => thread.id === threadId, + )!; + assert.isNull(limitRecoveryCommand(held, true, nowMs)); + assert.notInclude(yield* candidates(), threadId); + yield* orchestrator.dispatch(resume!); + assert.lengthOf((yield* orchestrator.getThreadProjection(threadId)).runs, 1); + assert.isTrue( + (yield* orchestrator.getThreadProjection(threadId)).thread.snoozedUntilNeedsAttention, + ); + + yield* orchestrator.dispatch({ + type: "thread.unsnooze", + commandId: CommandId.make("recovery:attention-wake"), + threadId, + reason: "user", + }); + assert.include(yield* candidates(), threadId); + const current = (yield* orchestrator.getShellSnapshot()).threads.find( + (thread) => thread.id === threadId, + )!; + const freshResume = limitRecoveryCommand(current, true, nowMs); + assert.isNotNull(freshResume); + assert.notEqual(freshResume!.commandId, resume!.commandId); + yield* orchestrator.dispatch(freshResume!); + assert.lengthOf((yield* orchestrator.getThreadProjection(threadId)).runs, 2); + } if (scenario === "expired-snooze") { const staleSnooze = yield* orchestrator .dispatch({ diff --git a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts index 0bb2d5053b4b..1f90a6e17c70 100644 --- a/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts +++ b/apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts @@ -142,6 +142,7 @@ function commandThreadIds(command: OrchestrationV2Command): ReadonlyArray { expect(instructions).toContain("call list_thread_pull_requests and link any PR"); }); + it("suggests snoozing a pull request that waits on review only when the user turned it on", () => { + const off = buildRuntimeInstructions({ harness: "Codex" }); + expect(off).toContain("call unwatch_pull_request first"); + expect(off).not.toContain("snooze_until_pull_request_needs_attention"); + const on = buildRuntimeInstructions({ + harness: "Codex", + snoozePullRequestsAwaitingReview: true, + }); + expect(on).toContain( + "call snooze_until_pull_request_needs_attention instead of unwatch_pull_request", + ); + expect(on).toMatch(/snooze_until_pull_request_needs_attention[^]*<\/pull_request_linking>$/); + }); + it("keeps known model and effort metadata on one line", () => { expect( buildRuntimeInstructions({ diff --git a/apps/server/src/provider/RuntimeInstructions.ts b/apps/server/src/provider/RuntimeInstructions.ts index a7b1c5e4c2e0..63c51a28aa80 100644 --- a/apps/server/src/provider/RuntimeInstructions.ts +++ b/apps/server/src/provider/RuntimeInstructions.ts @@ -1,6 +1,8 @@ -const PULL_REQUEST_LINKING_INSTRUCTIONS = ` -When the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox. -`; +const PULL_REQUEST_LINKING_INSTRUCTIONS = `When the t3-code MCP server exposes link_pull_request, you must use it to register every pull request you create or work on for this thread. Call link_pull_request with the full PR URL immediately after creating a PR or starting work on an existing PR. For a stack, call it for every layer, not just the current branch or the top PR. This applies when creating or updating PRs through gh, gh stack, another CLI, or the host API: those operations do not register the PRs with this thread. Linking an already-linked PR is safe. Before finishing PR work, call list_thread_pull_requests and link any PR from your work that is missing. Do not link unrelated PRs mentioned only as background. If a linking call fails, report that failure instead of claiming the PR is linked. When asked to monitor, watch, or babysit a PR and watch_pull_request is available, call it and end your turn: T3 Code wakes you when checks finish, someone else comments, or the branch conflicts, so do not poll or run your own watcher. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.`; + +/** For users who turned on snoozePullRequestsAwaitingReview. */ +const PULL_REQUEST_SNOOZE_INSTRUCTIONS = + "When a pull request is ready and only waits on someone else to review or merge it, call snooze_until_pull_request_needs_attention instead of unwatch_pull_request, then end your turn: T3 Code keeps watching and brings the thread back when the pull request needs attention."; /** * Shared runtime context; omit model and effort when the harness manages them dynamically. @@ -11,6 +13,8 @@ export function buildRuntimeInstructions(runtime: { readonly model?: string | undefined; readonly modelName?: string | undefined; readonly reasoningEffort?: string | undefined; + /** The user's snoozePullRequestsAwaitingReview setting. */ + readonly snoozePullRequestsAwaitingReview?: boolean | undefined; }): string { const harness = toSingleLine(runtime.harness); const model = toSingleLine(runtime.model ?? ""); @@ -20,7 +24,10 @@ export function buildRuntimeInstructions(runtime: { modelName && modelName !== model ? `${modelName} (model slug: ${model})` : model; const modelInfo = model && model !== "auto" && model !== "default" ? `, as ${modelLabel}` : ""; const effortInfo = effort ? ` with ${effort} reasoning effort` : ""; - return `In case you're asked: you are running in T3 Code through the ${harness} harness${modelInfo}${effortInfo}. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n${PULL_REQUEST_LINKING_INSTRUCTIONS}`; + const pullRequests = runtime.snoozePullRequestsAwaitingReview + ? `${PULL_REQUEST_LINKING_INSTRUCTIONS} ${PULL_REQUEST_SNOOZE_INSTRUCTIONS}` + : PULL_REQUEST_LINKING_INSTRUCTIONS; + return `In case you're asked: you are running in T3 Code through the ${harness} harness${modelInfo}${effortInfo}. No need to mention this otherwise. You can embed images and videos in your response using Markdown with absolute file paths.\n\n\n${pullRequests}\n`; } function toSingleLine(value: string): string { diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index ebbc73758a43..d2eb0cdcd78d 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -548,7 +548,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( yield* service.start(); }), ).pipe( - Layer.provide(PullRequestWatchReactor.layer), + Layer.provideMerge(PullRequestWatchReactor.layer), Layer.provide(layerPullRequestService), Layer.provide(ProjectionStoreV2.layer), ), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index fab4edb44406..ecc974e80ea7 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -224,6 +224,7 @@ import { listLinkedPullRequestThreads } from "./pullRequest/linkedThreads.ts"; import { pullRequestSyncKey } from "./pullRequest/pullRequestSyncKey.ts"; import * as SqlClient from "effect/sql/SqlClient"; import * as PullRequestSyncReactor from "./orchestration-v2/PullRequestSyncReactor.ts"; +import * as PullRequestWatchReactor from "./orchestration-v2/PullRequestWatchReactor.ts"; import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts"; @@ -1222,6 +1223,7 @@ const layerWsRpc = ( const secretRequests = yield* SecretRequests.SecretRequests; const pullRequests = yield* PullRequestService.PullRequestService; const pullRequestSync = yield* PullRequestSyncReactor.PullRequestSyncReactor; + const pullRequestWatch = yield* PullRequestWatchReactor.PullRequestWatchReactor; const deviceService = yield* DeviceService.DeviceService; const deviceHostContext = yield* Effect.context>>(); @@ -1827,17 +1829,26 @@ const layerWsRpc = ( Effect.andThen( startup .enqueueCommand( - // A retry also restarts the preparation work the launch owns. - (command.type === "prepared-run.retry" - ? threadLaunch.retryPreparation(command) - : ThreadMessageIntake.dispatchCommand( - ThreadManagementService.withCreationProvenance(command, { - createdBy: "user", - creationSource: - "creationSource" in command ? command.creationSource : "web", - }), - ) - ).pipe(Effect.provide(intakeContext)), + Effect.gen(function* () { + // A retry also restarts the preparation work the launch owns. + if (command.type === "prepared-run.retry") { + return yield* threadLaunch.retryPreparation(command); + } + // A snooze until a pull request needs attention reads its pull requests first. + if (command.type === "thread.snooze-until-attention") { + return yield* pullRequestWatch.snoozeUntilAttention({ + ...command, + createdBy: "user", + }); + } + return yield* ThreadMessageIntake.dispatchCommand( + ThreadManagementService.withCreationProvenance(command, { + createdBy: "user", + creationSource: + "creationSource" in command ? command.creationSource : "web", + }), + ); + }).pipe(Effect.provide(intakeContext)), ) .pipe( Effect.tap(() => recordClientCommandAnalytics(command)), diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 344f4c97917d..142613f6d6e1 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -215,6 +215,23 @@ A watched thread counts as working between wakes, so it stays in the **Working** not auto-settle. Agents stop watching when they hand the work back to you, and the thread then returns to your inbox. +To keep a pull request that waits on someone else out of sight, choose **Snooze until it needs +attention** from the thread's snooze menu or the command palette on web and desktop, or from the +snooze menu on mobile. The thread needs an open linked pull request. The server reads every open +linked pull request, watches them, and snoozes the thread with no wake time; if a pull request +cannot be read, the snooze fails and you can try again. What is already true when you snooze, such +as a failing check, does not wake it. A newly failed check, the required checks passing, a +comment or review from someone else, or a new conflict wakes the agent and brings the thread back +to **Working**, and watching continues. While snoozed, its pull requests are checked every 10 +minutes. Once none of them is left open, because they merged or closed, the thread settles, after +the agent's current turn if one is running. Waking the thread yourself keeps the watch. Stopping the +thread or ending its last watch ends the snooze. + +Agents can snooze their own thread the same way. Turn on **Snooze pull requests awaiting review** in +**Settings → General** on web and desktop, or **Settings → Thread behavior** on mobile, to have +agents do this instead of returning the thread to your inbox when a pull request is ready and only +waits on someone else to review or merge it. + Cross-repository links use a project on the same host. Azure DevOps reviews require a project checked out from the matching organization and repository. diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index 84f4a2ad3cff..e4229ac61ae3 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -114,6 +114,8 @@ export interface UnsnoozeThreadInput extends ThreadCommandInput { readonly reason: "user"; } +export type SnoozeThreadUntilAttentionInput = ThreadCommandInput; + export interface VisitThreadInput extends ThreadCommandInput { /** Watermark of the thread state the viewer has seen (ISO timestamp). */ readonly visitedAt: string; @@ -416,7 +418,8 @@ function simpleThreadCommand( | "thread.unarchive" | "thread.settle" | "thread.pin" - | "thread.unpin", + | "thread.unpin" + | "thread.snooze-until-attention", input: ThreadCommandInput, ) { return allocateCommandId(input).pipe( @@ -525,6 +528,13 @@ export const snoozeThread = Effect.fn("EnvironmentCommands.snoozeThread")(functi }); }); +/** Watches the thread's open linked pull requests and snoozes it until one needs attention. */ +export const snoozeThreadUntilAttention = Effect.fn( + "EnvironmentCommands.snoozeThreadUntilAttention", +)(function* (input: SnoozeThreadUntilAttentionInput) { + return yield* simpleThreadCommand("thread.snooze-until-attention", input); +}); + export const unsnoozeThread = Effect.fn("EnvironmentCommands.unsnoozeThread")(function* ( input: UnsnoozeThreadInput, ) { diff --git a/packages/client-runtime/src/state/models.ts b/packages/client-runtime/src/state/models.ts index e9049239e7e7..814823bb1aa3 100644 --- a/packages/client-runtime/src/state/models.ts +++ b/packages/client-runtime/src/state/models.ts @@ -125,6 +125,8 @@ export interface EnvironmentThreadShell { readonly unsettledAt: string | null; readonly snoozedUntil: string | null; readonly snoozedAt: string | null; + /** Snoozed with no wake time until a watched pull request needs attention. */ + readonly snoozedUntilNeedsAttention?: boolean; readonly limitRecovery?: import("@t3tools/contracts").OrchestrationV2LimitRecovery | null; readonly pinnedAt: string | null; readonly autoSettleDisabledAt?: string | null; @@ -271,6 +273,7 @@ export function presentThreadShell( unsettledAt: nullableIso(thread.unsettledAt ?? null), snoozedUntil: nullableIso(thread.snoozedUntil ?? null), snoozedAt: nullableIso(thread.snoozedAt ?? null), + snoozedUntilNeedsAttention: thread.snoozedUntilNeedsAttention === true, limitRecovery: thread.limitRecovery ?? null, pinnedAt: nullableIso(thread.pinnedAt ?? null), autoSettleDisabledAt: nullableIso(thread.autoSettleDisabledAt ?? null), diff --git a/packages/client-runtime/src/state/sharedSettings.test.ts b/packages/client-runtime/src/state/sharedSettings.test.ts index 6f94fe0f9037..c395776b3eb3 100644 --- a/packages/client-runtime/src/state/sharedSettings.test.ts +++ b/packages/client-runtime/src/state/sharedSettings.test.ts @@ -129,6 +129,7 @@ describe("pickSharedServerSettings", () => { "sidebarAutoSettleAfterDays", "sidebarAutoSettleOnMerge", "snoozeLimitedThreads", + "snoozePullRequestsAwaitingReview", "sourceControlWritingStyle", "textGenerationModelSelection", ]); diff --git a/packages/client-runtime/src/state/sharedSettings.ts b/packages/client-runtime/src/state/sharedSettings.ts index 1d6fb5e14ca7..5b0fe5c33cd9 100644 --- a/packages/client-runtime/src/state/sharedSettings.ts +++ b/packages/client-runtime/src/state/sharedSettings.ts @@ -27,6 +27,7 @@ const SHARED_SERVER_SETTING_KEYS = [ "sidebarAutoSettleOnMerge", "autoResumeLimitedThreads", "snoozeLimitedThreads", + "snoozePullRequestsAwaitingReview", "newWorktreesStartFromOrigin", "sourceControlWritingStyle", "textGenerationModelSelection", diff --git a/packages/client-runtime/src/state/threadCommands.test.ts b/packages/client-runtime/src/state/threadCommands.test.ts index cf36760baecb..ec78ef086c94 100644 --- a/packages/client-runtime/src/state/threadCommands.test.ts +++ b/packages/client-runtime/src/state/threadCommands.test.ts @@ -130,6 +130,11 @@ describe("remote thread lifecycle commands", () => { ["unsettle", { reason: "user" }, { settledOverride: "active", settledAt: null }], ["snooze", { snoozedUntil: "2099-01-01T00:00:00.000Z" }, { snoozedUntil: FUTURE }], ["unsnooze", { reason: "user" }, { snoozedUntil: null, snoozedAt: null }], + [ + "snoozeUntilAttention", + {}, + { snoozedUntil: null, snoozedAt: expect.any(Object), snoozedUntilNeedsAttention: true }, + ], ["pin", { orderKey: "a" }, { pinnedAt: expect.any(Object), pinOrderKey: "a" }], ["unpin", {}, { pinnedAt: null, pinOrderKey: null }], ["setAutoSettle", { enabled: false }, { autoSettleDisabledAt: expect.any(Object) }], diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index f237f1ce943d..e4a473f7585d 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -44,6 +44,7 @@ import { type SetThreadAutoSettleInput, type SettleThreadInput, type SnoozeThreadInput, + type SnoozeThreadUntilAttentionInput, type StartThreadTurnInput, type StopThreadSessionInput, type UnarchiveThreadInput, @@ -80,6 +81,7 @@ import { setThreadAutoSettle, settleThread, snoozeThread, + snoozeThreadUntilAttention, startThreadTurn, stopThreadSession, unarchiveThread, @@ -124,6 +126,7 @@ export type { SetThreadAutoSettleInput, SettleThreadInput, SnoozeThreadInput, + SnoozeThreadUntilAttentionInput, StartThreadTurnInput, StopThreadSessionInput, ThreadCommandInput, @@ -196,6 +199,12 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + snoozeUntilAttention: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:snooze-until-attention", + execute: (input: SnoozeThreadUntilAttentionInput) => snoozeThreadUntilAttention(input), + scheduler, + concurrency, + }), pin: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:pin", execute: (input: PinThreadInput) => pinThread(input), @@ -422,6 +431,7 @@ export function createThreadEnvironmentAtoms( pinOrderKey: null, snoozedAt: null, snoozedUntil: null, + snoozedUntilNeedsAttention: false, }, ), unsettle: optimistic.wrap(commands.unsettle, (thread, input, now) => ({ @@ -439,6 +449,7 @@ export function createThreadEnvironmentAtoms( : { ...thread, pendingRuntimeRequest: null, + snoozedUntilNeedsAttention: false, snoozedUntil: DateTime.makeUnsafe(input.snoozedUntil), snoozedAt: thread.snoozedUntil != null && @@ -451,7 +462,23 @@ export function createThreadEnvironmentAtoms( ...thread, snoozedUntil: null, snoozedAt: null, + snoozedUntilNeedsAttention: false, })), + snoozeUntilAttention: optimistic.wrap( + commands.snoozeUntilAttention, + (thread, _input, now, accepted) => + !accepted && + (thread.pendingRuntimeRequest !== null || + ["preparing", "queued", "starting"].includes(thread.status)) + ? thread + : { + ...thread, + pendingRuntimeRequest: null, + snoozedUntil: null, + snoozedAt: thread.snoozedUntilNeedsAttention === true ? thread.snoozedAt : now, + snoozedUntilNeedsAttention: true, + }, + ), setAutoSettle: optimistic.wrap(commands.setAutoSettle, (thread, input, now) => ({ ...thread, autoSettleDisabledAt: input.enabled ? null : (thread.autoSettleDisabledAt ?? now), @@ -469,6 +496,7 @@ export function createThreadEnvironmentAtoms( : {}), snoozedUntil: null, snoozedAt: null, + snoozedUntilNeedsAttention: false, })), unpin: optimistic.wrap(commands.unpin, (thread) => ({ ...thread, diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index 104414a9201d..f08bfcef572a 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -88,6 +88,8 @@ export function hasQueuedTurnStart( export interface ThreadSnoozeShell extends QueuedThreadShell { readonly snoozedUntil?: string | null; readonly snoozedAt?: string | null; + /** Snoozed with no wake time until a watched pull request needs attention. */ + readonly snoozedUntilNeedsAttention?: boolean; readonly hasPendingApprovals: boolean; readonly hasPendingUserInput: boolean; } @@ -98,7 +100,9 @@ export interface ThreadSnoozeShell extends QueuedThreadShell { * the session failed, or a run completed after the snooze was set — the * v1 taste of event-based snooze ("something happened" wakes early). * Raising a hand never clears the server-side snooze fields; it only stops - * the thread from classifying as snoozed. + * the thread from classifying as snoozed. A snooze until a pull request needs + * attention is often set by the agent mid-turn, so that turn completing is not + * news; the server ends that snooze itself when anything starts a new turn. */ export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean { if (shell.hasPendingApprovals || shell.hasPendingUserInput) return true; @@ -116,6 +120,7 @@ export function threadRaisedHandWhileSnoozed(shell: ThreadSnoozeShell): boolean return true; } if ( + shell.snoozedUntilNeedsAttention !== true && shell.snoozedAt != null && (latestRun?.state === "completed" || latestRun?.status === "completed") && latestRun.completedAt != null && @@ -154,15 +159,16 @@ export function canSnooze( /** * Snoozed resolution: hidden from the inbox while the wake time is in the - * future and the thread has not raised its hand. Timer wakes are derived — - * no server event fires when snoozedUntil passes; the stale fields simply - * stop classifying as snoozed (and feed the woke indicator until the user - * visits or re-engages). + * future, or until a pull request needs attention, and the thread has not + * raised its hand. Timer wakes are derived — no server event fires when + * snoozedUntil passes; the stale fields simply stop classifying as snoozed + * (and feed the woke indicator until the user visits or re-engages). */ export function effectiveSnoozed( shell: ThreadSnoozeShell, options: { readonly now: string }, ): boolean { + if (shell.snoozedUntilNeedsAttention === true) return !threadRaisedHandWhileSnoozed(shell); if (shell.snoozedUntil == null) return false; const wakeAtMs = Date.parse(shell.snoozedUntil); // Malformed data never hides a thread. @@ -186,6 +192,11 @@ export function threadWokeAt( shell: ThreadSnoozeShell, options: { readonly now: string }, ): string | null { + if (shell.snoozedUntilNeedsAttention === true) { + return threadRaisedHandWhileSnoozed(shell) + ? ((shell.runtime ?? shell.session)?.updatedAt ?? shell.snoozedAt ?? null) + : null; + } if (shell.snoozedUntil == null) return null; const wakeAtMs = Date.parse(shell.snoozedUntil); if (Number.isNaN(wakeAtMs)) return null; diff --git a/packages/client-runtime/src/state/threadSnoozed.test.ts b/packages/client-runtime/src/state/threadSnoozed.test.ts index 793d5a4a3a95..d45b4f94edad 100644 --- a/packages/client-runtime/src/state/threadSnoozed.test.ts +++ b/packages/client-runtime/src/state/threadSnoozed.test.ts @@ -26,6 +26,7 @@ function localDate(year: number, month: number, day: number, hour: number, minut function makeShell(input: { readonly snoozedUntil?: string | null; readonly snoozedAt?: string | null; + readonly snoozedUntilNeedsAttention?: boolean; readonly sessionStatus?: "starting" | "running" | "ready" | "error"; readonly pending?: "approval" | "user-input"; readonly turnCompletedAt?: string | null; @@ -33,7 +34,12 @@ function makeShell(input: { const threadId = ThreadId.make("thread-1"); return { snoozedUntil: input.snoozedUntil ?? null, - snoozedAt: input.snoozedAt ?? (input.snoozedUntil != null ? SNOOZED_AT : null), + snoozedAt: + input.snoozedAt ?? + (input.snoozedUntil != null || input.snoozedUntilNeedsAttention ? SNOOZED_AT : null), + ...(input.snoozedUntilNeedsAttention === undefined + ? {} + : { snoozedUntilNeedsAttention: input.snoozedUntilNeedsAttention }), hasPendingApprovals: input.pending === "approval", hasPendingUserInput: input.pending === "user-input", session: @@ -148,6 +154,34 @@ describe("effectiveSnoozed", () => { }); }); +describe("snoozed until a pull request needs attention", () => { + const attention = { snoozedUntilNeedsAttention: true } as const; + + it("stays snoozed with no wake time", () => { + expect(effectiveSnoozed(makeShell(attention), { now: NOW })).toBe(true); + expect(effectiveSnoozed(makeShell(attention), { now: "2999-01-01T00:00:00.000Z" })).toBe(true); + expect(threadWokeAt(makeShell(attention), { now: NOW })).toBe(null); + }); + + it("stays snoozed when the turn that snoozed it completes", () => { + const completed = makeShell({ ...attention, turnCompletedAt: "2026-04-10T10:30:00.000Z" }); + expect(threadRaisedHandWhileSnoozed(completed)).toBe(false); + expect(effectiveSnoozed(completed, { now: NOW })).toBe(true); + }); + + it("raises its hand when the agent needs the user or fails", () => { + for (const shell of [ + makeShell({ ...attention, pending: "approval" }), + makeShell({ ...attention, sessionStatus: "error" }), + ]) { + expect(effectiveSnoozed(shell, { now: NOW })).toBe(false); + } + expect(threadWokeAt(makeShell({ ...attention, sessionStatus: "error" }), { now: NOW })).toBe( + "2026-04-10T11:00:00.000Z", + ); + }); +}); + describe("threadRaisedHandWhileSnoozed", () => { it("is false for a quiet snoozed thread", () => { expect(threadRaisedHandWhileSnoozed(makeShell({ snoozedUntil: FUTURE_WAKE }))).toBe(false); diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index c9ac7482d4cc..f8f776a2f858 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -135,6 +135,10 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server understands thread.snooze / thread.unsnooze commands. Same version-skew contract as threadSettlement. */ threadSnooze: Schema.optionalKey(Schema.Boolean), + /** Server understands thread.snooze-until-attention and the + snoozePullRequestsAwaitingReview setting. Servers from before it still + advertise threadSnooze, so clients gate the attention snooze on this. */ + threadSnoozeUntilAttention: Schema.optionalKey(Schema.Boolean), /** Server streams themes an environment publishes. Absent on servers from before environment themes shipped, which never emit the events -- so a client reconnecting to one must drop published themes rather than keep diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index c26d5a9327e4..d92f17ad2ed3 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -404,6 +404,12 @@ export const OrchestrationV2AppThread = Schema.Struct({ unsettledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), snoozedUntil: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), snoozedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), + /** + * Snoozed with no wake time until a watched pull request needs attention. `snoozedUntil` is + * null and `snoozedAt` is set. Ends when a watch wakes the agent, the user wakes the thread, or + * the last watch ends; a merged or closed pull request settles the thread instead. + */ + snoozedUntilNeedsAttention: Schema.optional(Schema.Boolean), limitRecovery: Schema.optional(Schema.NullOr(OrchestrationV2LimitRecovery)), pinnedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), autoSettleDisabledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), @@ -1899,6 +1905,8 @@ export const OrchestrationV2ThreadShell = Schema.Struct({ unsettledAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), snoozedUntil: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), snoozedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), + /** Snoozed until a watched pull request needs attention; omitted by older servers. */ + snoozedUntilNeedsAttention: Schema.optional(Schema.Boolean), limitRecovery: Schema.optional(Schema.NullOr(OrchestrationV2LimitRecovery)), /** Omitted by servers that predate thread pinning. */ pinnedAt: Schema.optional(Schema.NullOr(Schema.DateTimeUtc)), @@ -2681,6 +2689,18 @@ export const OrchestrationV2Command = Schema.Union([ threadId: ThreadId, reason: Schema.Literal("user"), }), + /** + * Watches every open linked pull request and snoozes the thread with no wake time until one + * needs attention. Follows the snooze rules, and needs an open linked pull request. An agent's + * snooze is refused once Stop reached the thread's latest run. The server reads each pull + * request before it dispatches the internal form below, and refuses the snooze when it cannot. + */ + Schema.Struct({ + type: Schema.Literal("thread.snooze-until-attention"), + commandId: CommandId, + threadId: ThreadId, + createdBy: Schema.optional(OrchestrationV2Actor), + }), Schema.Struct({ type: Schema.Literal("thread.auto-settle.set"), commandId: CommandId, @@ -3057,19 +3077,43 @@ export type OrchestrationV2Command = typeof OrchestrationV2Command.Type; * send them. */ const OrchestrationV2InternalCommand = Schema.Union([ + /** + * `thread.snooze-until-attention` once the server read each open linked pull request. `watches` + * are the watches the thread waits with, holding what the host showed then, so only what + * changes after the snooze wakes it. Each one replaces `previous`, the watch it read with, or + * starts one when that is null. The snooze is rejected when `links`, every pull request the + * thread linked, or one of those watches changed while the host was read. + */ + Schema.Struct({ + type: Schema.Literal("thread.snooze-until-attention"), + commandId: CommandId, + threadId: ThreadId, + createdBy: Schema.optional(OrchestrationV2Actor), + links: Schema.Array(ThreadPullRequestKey), + watches: Schema.Array( + Schema.Struct({ + ...ThreadPullRequestKey.fields, + previous: Schema.NullOr(ThreadPullRequestWatch), + watch: ThreadPullRequestWatch, + }), + ), + }), /** * Records what a pull request watch saw, and wakes the agent in the same transaction when - * `wake` is set. Rejected once the watch started at `startedAt` has ended, and a wake is - * rejected on a settled or archived thread, so a read that raced either changes nothing. + * `wake` is set. Rejected unless the recorded watch is still `previous`, the one the pass + * evaluated, and a wake is rejected on a settled or archived thread, so a read that raced a + * stop, a restart, a snooze's newer read, or a settle changes nothing. `ended` says the watch + * ends because its pull request merged or closed. */ Schema.Struct({ type: Schema.Literal("thread.pull-request-watch.sync"), commandId: CommandId, threadId: ThreadId, ...ThreadPullRequestKey.fields, - startedAt: IsoDateTime, + previous: ThreadPullRequestWatch, /** The watch to record, or null to end it. */ watch: Schema.NullOr(ThreadPullRequestWatch), + ended: Schema.optional(Schema.Literals(["merged", "closed"])), wake: Schema.optional( Schema.Struct({ messageId: MessageId, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 942fbb915ffe..f7ed92aa1d9a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1340,6 +1340,13 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), ), snoozeLimitedThreads: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + /** + * Agents are told to snooze a thread until its pull request needs attention, instead of + * handing it back, when the pull request is ready and only waits on someone else. + */ + snoozePullRequestsAwaitingReview: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + ), autoResumeLimitedThreads: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), sidebarAutoSettleOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), backgroundActivity: BackgroundActivitySettings, @@ -1706,6 +1713,7 @@ export const ServerSettingsPatch = Schema.Struct({ sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), autoResumeLimitedThreads: Schema.optionalKey(Schema.Boolean), snoozeLimitedThreads: Schema.optionalKey(Schema.Boolean), + snoozePullRequestsAwaitingReview: Schema.optionalKey(Schema.Boolean), backgroundActivity: Schema.optionalKey( Schema.Struct({ schemaVersion: Schema.optionalKey(Schema.Literal(1)), diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index 11abf77f704b..bfe88123bd6f 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -108,6 +108,11 @@ const T3_MCP_TOOLS: Readonly> = { "unwatch-pr", "pull-request", ), + snooze_until_pull_request_needs_attention: tool( + ["Snooze", "Snoozing", "Snoozed", "until a pull request needs attention"], + "thread-organize", + "pull-request", + ), orchestrator_capabilities: tool( ["Get", "Getting", "Got", "orchestration capabilities"], "capabilities",