From f8fe6ed9ab85efce2019ec0541119812ce1b3c55 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Wed, 28 Oct 2026 23:42:52 -0700 Subject: [PATCH 1/5] feat(server): support opt-in PR watch quiet deadlines --- .../src/environment/ServerEnvironment.ts | 1 + .../toolkits/pullRequests/handlers.test.ts | 2 + .../src/mcp/toolkits/pullRequests/handlers.ts | 6 +- .../src/mcp/toolkits/pullRequests/tools.ts | 13 +- .../src/orchestration-v2/Orchestrator.ts | 76 ++++++--- .../PullRequestWatchReactor.ts | 11 ++ .../orchestration-v2/pullRequestWatch.test.ts | 41 +++++ .../src/orchestration-v2/pullRequestWatch.ts | 19 +++ .../src/orchestration-v2/runtimeLayer.test.ts | 150 ++++++++++++++++++ packages/contracts/src/environment.ts | 1 + packages/contracts/src/orchestrationV2.ts | 3 + packages/contracts/src/threadPullRequest.ts | 4 + 12 files changed, 299 insertions(+), 28 deletions(-) diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index cef18fbe325c3..a088221d5540a 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -255,6 +255,7 @@ export const make = Effect.gen(function* () { threadVisitedTracking: true, threadPullRequests: true, threadPullRequestWatch: true, + pullRequestWatchQuietDeadline: true, pullRequestStackActions: true, threadPullRequestLinking: true, serverResolvedCommandContext: true, diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts index 88691423c0efc..8fde7c8a52dfd 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts @@ -271,6 +271,7 @@ describe("pull request toolkit handlers", () => { const harness = yield* makeHarness(); const result = yield* harness.call("watch_pull_request", { url: "https://github.com/t3tools/t3code/pull/9", + quietTimeoutMinutes: 45, }); expect(result).toMatchObject({ number: 9, watching: false, wasWatching: false }); expect(yield* Ref.get(harness.commands)).toMatchObject([ @@ -278,6 +279,7 @@ describe("pull request toolkit handlers", () => { type: "thread.pull-request.watch", number: 9, watching: true, + quietTimeoutMinutes: 45, link: { url: "https://github.com/t3tools/t3code/pull/9", source: "agent" }, }, ]); diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts index a4cd07b89fb4f..8241f672ad1be 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts @@ -38,6 +38,7 @@ import { PullRequestListFailedError, PullRequestNotOpenError, type PullRequestTargetInput, + type WatchPullRequestInput, PullRequestWatchFailedError, PullRequestWatchFromSubagentError, PullRequestThreadNotFoundError, @@ -261,7 +262,7 @@ const make = Effect.gen(function* () { : Effect.fail(new Failure({ cause })); const setWatching = Effect.fn("PullRequestsToolkit.setWatching")(function* ( - input: PullRequestTargetInput, + input: WatchPullRequestInput, watching: boolean, ) { const thread = yield* requireThread(PullRequestWatchFailedError, input.threadId); @@ -287,6 +288,9 @@ const make = Effect.gen(function* () { repository: target.repository, number: target.number, watching, + ...(watching && input.quietTimeoutMinutes !== undefined + ? { quietTimeoutMinutes: input.quietTimeoutMinutes } + : {}), ...(watching ? { link: { url: target.url, source: "agent" as const } } : {}), }) .pipe(Effect.catchCause(dispatchFailure(PullRequestWatchFailedError))); diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts index 866580fc5e029..99d587a3e0a48 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -59,6 +59,17 @@ export const PullRequestTargetInput = Schema.Struct({ }); export type PullRequestTargetInput = typeof PullRequestTargetInput.Type; +export const WatchPullRequestInput = Schema.Struct({ + ...PullRequestTargetInput.fields, + quietTimeoutMinutes: Schema.optional( + Schema.NullOr(PositiveInt).annotate({ + description: + "Quiet-wait deadline in minutes; null disables it. Omit to keep the current policy; new watches have no deadline.", + }), + ), +}); +export type WatchPullRequestInput = typeof WatchPullRequestInput.Type; + export class PullRequestUrlInvalidError extends Schema.TaggedError()( "PullRequestUrlInvalidError", {}, @@ -317,7 +328,7 @@ const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", { const WatchPullRequestTool = Tool.make("watch_pull_request", { description: "Watch an open PR for this thread, linking it first if needed. T3 wakes you on failed checks, passed required checks, new comments or reviews, or conflicts. Handle old comments first. Only later ones wake you. After this call, end your turn. The watch stays on after each wake, so do not call this again on a wake. A wake is not a merge decision. Check readiness yourself.", - parameters: PullRequestTargetInput, + parameters: WatchPullRequestInput, success: WatchPullRequestResult, failure: PullRequestToolError, dependencies, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 76bcc00bbcb4e..76aac95d9066d 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -2688,7 +2688,12 @@ 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?.startedAt !== command.startedAt || + (command.expectedQuietDeadline !== undefined && + command.expectedQuietDeadline !== (link.watch.quietDeadline?.dueAt ?? null)) || + (command.wake && inactive) + ) { return yield* new OrchestratorDispatchError({ commandId: command.commandId, commandType: command.type, @@ -3583,6 +3588,19 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio : linked; const existing = links.find(visible); if (existing === undefined) return thread; + const quietTimeoutMinutes = + command.type === "thread.pull-request.watch" + ? command.quietTimeoutMinutes === undefined + ? (existing.watch?.quietDeadline?.timeoutMinutes ?? null) + : command.quietTimeoutMinutes + : null; + const quietDeadline = + quietTimeoutMinutes === null + ? undefined + : { + timeoutMinutes: quietTimeoutMinutes, + dueAt: DateTime.formatIso(DateTime.add(now, { minutes: quietTimeoutMinutes })), + }; const watch = command.type === "thread.pull-request-watch.sync" ? existing.watch?.startedAt === command.startedAt @@ -3590,31 +3608,37 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio : existing.watch : !command.watching ? undefined - : (existing.watch ?? { - startedAt, - headSha: null, - failedChecks: [], - passed: false, - passedChecks: [], - conflicting: false, - // Watching again must not repeat check or conflict news the last watch told. - ...(existing.endedWatch && { - headSha: existing.endedWatch.headSha, - failedChecks: existing.endedWatch.failedChecks, - passed: existing.endedWatch.passed, - passedChecks: existing.endedWatch.passedChecks, - conflicting: existing.endedWatch.conflicting, - }), - ...(existing.endedWatch?.owedChecks !== undefined && { - owedChecks: existing.endedWatch.owedChecks, - }), - ...(existing.endedWatch?.checksAwaitedSince !== undefined && { - checksAwaitedSince: existing.endedWatch.checksAwaitedSince, - }), - remarksThrough: startedAt, - remarkIds: [], - wakes: 0, - }); + : existing.watch !== undefined + ? command.quietTimeoutMinutes === undefined && + existing.watch.quietDeadline?.dueAt !== null + ? existing.watch + : { ...existing.watch, quietDeadline } + : { + startedAt, + ...(quietDeadline === undefined ? {} : { quietDeadline }), + headSha: null, + failedChecks: [], + passed: false, + passedChecks: [], + conflicting: false, + // Watching again must not repeat check or conflict news the last watch told. + ...(existing.endedWatch && { + headSha: existing.endedWatch.headSha, + failedChecks: existing.endedWatch.failedChecks, + passed: existing.endedWatch.passed, + passedChecks: existing.endedWatch.passedChecks, + conflicting: existing.endedWatch.conflicting, + }), + ...(existing.endedWatch?.owedChecks !== undefined && { + owedChecks: existing.endedWatch.owedChecks, + }), + ...(existing.endedWatch?.checksAwaitedSince !== undefined && { + checksAwaitedSince: existing.endedWatch.checksAwaitedSince, + }), + remarksThrough: startedAt, + remarkIds: [], + wakes: 0, + }; if (watch === existing.watch && links === linked) return thread; const final = command.type === "thread.pull-request-watch.sync" && watch === undefined diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index 03a16e770efac..3a63595d7224d 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -136,6 +136,14 @@ function planRead( fingerprint: ProviderChangeRequestWatchFingerprint | null, now: number, ): ReadPlan { + if ( + group.targets.some( + ({ watch }) => + watch.quietDeadline?.dueAt != null && now >= Date.parse(watch.quietDeadline.dueAt), + ) + ) { + return { detail: true, activity: true }; + } if (last === undefined || group.targets.some((target) => !last.watches.has(watchKey(target)))) { return { detail: true, activity: true }; } @@ -157,6 +165,8 @@ function planRead( function watchesEqual(left: ThreadPullRequestWatch, right: ThreadPullRequestWatch): boolean { return ( left.startedAt === right.startedAt && + left.quietDeadline?.timeoutMinutes === right.quietDeadline?.timeoutMinutes && + left.quietDeadline?.dueAt === right.quietDeadline?.dueAt && left.headSha === right.headSha && left.failedChecks.join("\n") === right.failedChecks.join("\n") && left.passed === right.passed && @@ -270,6 +280,7 @@ export const make = Effect.gen(function* () { threadId: target.thread.id, ...identityOf(target.link), startedAt: target.watch.startedAt, + expectedQuietDeadline: target.watch.quietDeadline?.dueAt ?? null, watch: next, ...(endedWatch === undefined ? {} : { endedWatch }), ...(wake === undefined diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts index 0b6070f364662..6d05f4c8dd619 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts @@ -78,6 +78,47 @@ const remark = ( const noRemarks: ReadonlyArray = []; describe("evaluatePullRequestWatch", () => { + it("reports an unchanged wait once, then rearms only on actionable news or a push", () => { + const start = Date.parse(STARTED); + const dueAt = "2026-10-02T12:45:00.000Z"; + const armed = { + ...watch({ headSha: "aaaaaaaaaa", wakes: 3 }), + quietDeadline: { timeoutMinutes: 45, dueAt }, + }; + const before = evaluate(armed, detail(), noRemarks, start + 44 * 60_000); + assert.deepEqual(before.changes, []); + assert.deepEqual(before.next.quietDeadline, armed.quietDeadline); + + const quiet = evaluate(before.next, detail(), noRemarks, start + 45 * 60_000); + assert.deepEqual(quiet.changes, [{ kind: "quiet", minutes: 45 }]); + assert.deepEqual(quiet.next.quietDeadline, { timeoutMinutes: 45, dueAt: null }); + assert.equal(quiet.next.wakes, 3); + assert.isFalse(quiet.exhausted); + assert.deepEqual(evaluate(quiet.next, detail(), noRemarks, start + 90 * 60_000).changes, []); + + const pushed = evaluate( + quiet.next, + detail({ headSha: "bbbbbbbbbb" }), + noRemarks, + start + 91 * 60_000, + ); + assert.deepEqual(pushed.next.quietDeadline, { + timeoutMinutes: 45, + dueAt: "2026-10-02T14:16:00.000Z", + }); + const done = evaluate( + armed, + detail({ checks: [check("lint", "success")] }), + noRemarks, + start + 45 * 60_000, + ); + assert.deepEqual( + done.changes.map((change) => change.kind), + ["checks-passed"], + ); + assert.equal(done.next.quietDeadline?.dueAt, "2026-10-02T13:30:00.000Z"); + }); + it("reports each failure at once, even while another check never finishes", () => { const bot = check("CodeRabbit", "pending"); const first = detail({ checks: [check("lint", "failure"), check("test", "pending"), bot] }); diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.ts b/apps/server/src/orchestration-v2/pullRequestWatch.ts index 1213948cc4d6c..fb5f97fd98f9d 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.ts @@ -19,6 +19,7 @@ export type PullRequestWatchChange = | { readonly kind: "checks-passed"; readonly count: number; readonly required: boolean } | { readonly kind: "remarks"; readonly remarks: ReadonlyArray } | { readonly kind: "no-checks"; readonly minutes: number } + | { readonly kind: "quiet"; readonly minutes: number } | { readonly kind: "conflicting" }; export interface PullRequestWatchReport { @@ -136,10 +137,23 @@ export function evaluatePullRequestWatch( const commentsOnly = changes.length > 0 && changes.every((change) => change.kind === "remarks"); const progress = headMoved || (changes.length > 0 && !commentsOnly); const wakes = (progress ? 0 : watch.wakes) + (commentsOnly ? 1 : 0); + let quietDeadline = watch.quietDeadline; + if (quietDeadline !== undefined) { + if ((headMoved && watch.headSha !== null) || changes.length > 0) { + quietDeadline = { + ...quietDeadline, + dueAt: DateTime.formatIso(DateTime.makeUnsafe(now + quietDeadline.timeoutMinutes * 60_000)), + }; + } else if (quietDeadline.dueAt !== null && now >= Date.parse(quietDeadline.dueAt)) { + changes.push({ kind: "quiet", minutes: quietDeadline.timeoutMinutes }); + quietDeadline = { ...quietDeadline, dueAt: null }; + } + } return { changes, next: { startedAt: watch.startedAt, + ...(quietDeadline === undefined ? {} : { quietDeadline }), headSha, failedChecks, passed, @@ -201,6 +215,10 @@ function changeLines( return [ `- No checks have reported${context.commit} after ${change.minutes} minutes. None may be configured to run on this change.`, ]; + case "quiet": + return [ + `- No actionable update has arrived for ${change.minutes} minutes. Check whether CI or review is still progressing; this notice does not satisfy any review or merge requirement.`, + ]; case "conflicting": return [`- The branch now conflicts with ${context.baseBranch}.`]; } @@ -211,6 +229,7 @@ const SUMMARY: Record = { "checks-passed": "checks passed", remarks: "new comments", "no-checks": "no checks reported", + quiet: "still waiting", conflicting: "merge conflict", }; diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 428202d17b651..2d267acda45d5 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -2623,6 +2623,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { const started = yield* watchOf; assert.isDefined(started); if (started === undefined) return; + assert.isUndefined(started.quietDeadline); yield* orchestrator.dispatch({ type: "thread.metadata.update", @@ -3340,6 +3341,155 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect("persists one quiet wake across restarts and unchanged host fingerprints", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; + const threadId = ThreadId.make("pr-watch-quiet"); + const projectId = ProjectId.make("pr-watch-quiet-project"); + yield* seedProject({ + projectId, + title: "Quiet watch", + workspaceRoot: "/workspace/quiet-watch", + defaultModelSelection: null, + createdAt: "2026-10-01T00:00:00.000Z", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("quiet-create"), + createdBy: "user", + creationSource: "web", + threadId, + projectId, + title: "Quiet watch", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + const key = { host: "github.com", repository: "pingdotgg/t3code", number: 15 }; + const watching = (id: string, quietTimeoutMinutes?: number | null) => + orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make(`quiet-watch-${id}`), + threadId, + ...key, + watching: true, + ...(quietTimeoutMinutes === undefined ? {} : { quietTimeoutMinutes }), + link: { url: "https://github.com/pingdotgg/t3code/pull/15", source: "agent" }, + }); + const watchOf = Effect.map( + orchestrator.getThreadShell(threadId), + (thread) => thread?.pullRequests?.[0]?.watch, + ); + const wakes = Effect.map( + orchestrator.getThreadRecords(threadId, ["messages"]), + ({ messages }) => messages.filter((message) => message.notification != null), + ); + let state: PullRequestDetail["state"] = "open"; + let reads = 0; + const host = Layer.mergeAll( + NodeServices.layer, + Layer.mock(PullRequestService.PullRequestService)({ + watchFingerprint: () => Effect.succeed({ status: "running", remarks: "unchanged" }), + invalidate: () => Effect.void, + detail: () => + Effect.sync(() => { + reads++; + return { + ...watchedPullRequestDetail({ + projectId, + number: key.number, + at: "2026-10-01T00:00:00.000Z", + }), + state, + capabilities: { + ...watchedPullRequestDetail({ + projectId, + number: key.number, + at: "2026-10-01T00:00:00.000Z", + }).capabilities, + checksAlwaysReport: false, + }, + checks: [], + }; + }), + activity: () => + Effect.succeed({ + comments: [], + commentCount: 0, + commentsTruncated: false, + reviewThreads: [], + commits: [], + }), + }), + ); + yield* watching("start", 5); + const initial = yield* watchOf; + assert.equal(initial?.quietDeadline?.timeoutMinutes, 5); + const reactor = yield* PullRequestWatchReactor.make.pipe(Effect.provide(host)); + yield* reactor.sweep; + yield* TestClock.adjust("4 minutes"); + yield* watching("idempotent"); + assert.deepEqual((yield* watchOf)?.quietDeadline, initial?.quietDeadline); + const restarted = yield* PullRequestWatchReactor.make.pipe(Effect.provide(host)); + yield* restarted.sweep; + assert.lengthOf(yield* wakes, 0); + assert.equal(reads, 2); + yield* TestClock.adjust("1 minute"); + yield* restarted.sweep; + assert.equal(reads, 3, "the deadline bypasses the unchanged fingerprint cache"); + const delivered = yield* watchOf; + assert.deepEqual(delivered?.quietDeadline, { timeoutMinutes: 5, dueAt: null }); + assert.lengthOf(yield* wakes, 1); + assert.include((yield* wakes)[0]!.text, "No actionable update"); + assert.isTrue((yield* maintenance.rebuild).valid); + assert.deepEqual((yield* watchOf)?.quietDeadline, delivered?.quietDeadline); + + const stale = yield* orchestrator + .dispatch({ + type: "thread.pull-request-watch.sync", + commandId: CommandId.make("quiet-stale"), + threadId, + ...key, + startedAt: initial!.startedAt, + expectedQuietDeadline: initial!.quietDeadline!.dueAt, + watch: delivered!, + wake: { + messageId: MessageId.make("quiet-duplicate"), + text: "Duplicate", + notification: { source: { kind: "monitor" }, outcome: "updated", summary: "Duplicate" }, + }, + }) + .pipe(Effect.flip); + assert.equal(stale._tag, "OrchestratorDispatchError"); + yield* TestClock.adjust("1 hour"); + const again = yield* PullRequestWatchReactor.make.pipe(Effect.provide(host)); + yield* again.sweep; + assert.lengthOf(yield* wakes, 1); + + yield* watching("rearm"); + assert.isString((yield* watchOf)?.quietDeadline?.dueAt); + state = "closed"; + yield* TestClock.adjust("5 minutes"); + yield* again.sweep; + assert.isUndefined(yield* watchOf); + assert.lengthOf(yield* wakes, 2); + assert.include((yield* wakes)[1]!.text, "was closed"); + + yield* watching("disabled", null); + assert.isUndefined((yield* watchOf)?.quietDeadline); + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make("quiet-end"), + threadId, + ...key, + watching: false, + }); + }), + ); + it.effect("wakes for checks that complete after the no-checks notice on the same head", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 75a546bed875f..e6dc2395f5086 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -120,6 +120,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ localServerStop: Schema.optionalKey(Schema.Boolean), threadPullRequests: Schema.optionalKey(Schema.Boolean), threadPullRequestWatch: Schema.optionalKey(Schema.Boolean), + pullRequestWatchQuietDeadline: Schema.optionalKey(Schema.Boolean), pullRequestStackActions: Schema.optionalKey(Schema.Boolean), serverSelfUpdate: Schema.optionalKey(ServerSelfUpdateCapability), /** Manual commands must update this install, not the host's default global prefix. */ diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 747d05e7ce4eb..c53ae40871513 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -3024,6 +3024,8 @@ export const OrchestrationV2Command = Schema.Union([ threadId: ThreadId, ...ThreadPullRequestKey.fields, watching: Schema.Boolean, + /** Quiet-wait limit for this watch; null disables it. The owning server supplies the default. */ + quietTimeoutMinutes: Schema.optional(Schema.NullOr(PositiveInt)), link: Schema.optional( Schema.Struct({ url: TrimmedNonEmptyString, @@ -3348,6 +3350,7 @@ const OrchestrationV2InternalCommand = Schema.Union([ threadId: ThreadId, ...ThreadPullRequestKey.fields, startedAt: IsoDateTime, + expectedQuietDeadline: Schema.optional(Schema.NullOr(IsoDateTime)), watch: Schema.NullOr(ThreadPullRequestWatch), /** With `watch: null`, the state the ended watch keeps instead of its last saved one. */ endedWatch: Schema.optional(ThreadPullRequestWatch), diff --git a/packages/contracts/src/threadPullRequest.ts b/packages/contracts/src/threadPullRequest.ts index 3daee9ce197e2..496a55783662b 100644 --- a/packages/contracts/src/threadPullRequest.ts +++ b/packages/contracts/src/threadPullRequest.ts @@ -81,6 +81,10 @@ export type ThreadPullRequestKey = typeof ThreadPullRequestKey.Type; export const ThreadPullRequestWatch = Schema.Struct({ startedAt: IsoDateTime, + /** Null dueAt records a delivered quiet wake until progress or an explicit rearm. */ + quietDeadline: Schema.optional( + Schema.Struct({ timeoutMinutes: PositiveInt, dueAt: Schema.NullOr(IsoDateTime) }), + ), headSha: Schema.NullOr(TrimmedNonEmptyString), failedChecks: Schema.Array(TrimmedNonEmptyString), passed: Schema.Boolean, From 71953c16c76cf5e155f0243f8cc15f607fd6da19 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Wed, 28 Oct 2026 23:49:56 -0700 Subject: [PATCH 2/5] fix(server): preserve explicit quiet-watch policy changes --- .../toolkits/pullRequests/handlers.test.ts | 17 ++++++- .../src/mcp/toolkits/pullRequests/tools.ts | 7 +-- .../src/orchestration-v2/Orchestrator.ts | 9 ++-- .../PullRequestWatchReactor.ts | 2 +- .../src/orchestration-v2/pullRequestWatch.ts | 4 +- .../src/orchestration-v2/runtimeLayer.test.ts | 46 ++++++++++++++++--- packages/contracts/src/orchestrationV2.ts | 8 ++-- packages/contracts/src/threadPullRequest.ts | 13 ++++-- 8 files changed, 84 insertions(+), 22 deletions(-) diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts index 8fde7c8a52dfd..8eaef94dd5ec0 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 Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import type { Tool } from "effect/ai"; @@ -32,7 +33,11 @@ import * as McpToolAccess from "../../McpToolAccess.ts"; import * as McpToolAccessTestkit from "../../McpToolAccess.testkit.ts"; import { listThreadPullRequests } from "./handlers.ts"; import * as PullRequestsHandlers from "./handlers.ts"; -import { CHILD_PULL_REQUEST_REPORT_NEXT, PullRequestsToolkit } from "./tools.ts"; +import { + CHILD_PULL_REQUEST_REPORT_NEXT, + PullRequestsToolkit, + WatchPullRequestInput, +} from "./tools.ts"; const PROJECT_ID = ProjectId.make("project-1"); const THREAD_ID = ThreadId.make("thread-1"); @@ -225,6 +230,16 @@ const makeHarness = Effect.fn("makePullRequestsToolkitHarness")(function* ( }); describe("pull request toolkit handlers", () => { + it.each([ + [0, false], + [1, true], + [10080, true], + [10081, false], + [2e11, false], + ])("bounds quiet wait duration %s", (quietTimeoutMinutes, accepted) => { + expect(Schema.is(WatchPullRequestInput)({ quietTimeoutMinutes })).toBe(accepted); + }); + it.effect("refuses a credential without the pull-requests capability", () => Effect.gen(function* () { const harness = yield* makeHarness(); diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts index 99d587a3e0a48..4aa4847927ab5 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -5,6 +5,7 @@ import { PositiveInt, PullRequestReviewGuide, PullRequestReviewGuideRun, + PullRequestWatchQuietTimeoutMinutes, PullRequestState, ThreadPullRequestLinkSource, TrimmedNonEmptyString, @@ -62,9 +63,9 @@ export type PullRequestTargetInput = typeof PullRequestTargetInput.Type; export const WatchPullRequestInput = Schema.Struct({ ...PullRequestTargetInput.fields, quietTimeoutMinutes: Schema.optional( - Schema.NullOr(PositiveInt).annotate({ + Schema.NullOr(PullRequestWatchQuietTimeoutMinutes).annotate({ description: - "Quiet-wait deadline in minutes; null disables it. Omit to keep the current policy; new watches have no deadline.", + "Wake you once if the PR has no new actionable check results, comments, reviews, conflicts, or pushes for this many minutes. New watch updates or a push restarts the timer. A number starts the timer now; null turns it off. Omit to keep this watch's setting (new watches have none); after the quiet wake, a call without it restarts the timer.", }), ), }); @@ -327,7 +328,7 @@ const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", { const WatchPullRequestTool = Tool.make("watch_pull_request", { description: - "Watch an open PR for this thread, linking it first if needed. T3 wakes you on failed checks, passed required checks, new comments or reviews, or conflicts. Handle old comments first. Only later ones wake you. After this call, end your turn. The watch stays on after each wake, so do not call this again on a wake. A wake is not a merge decision. Check readiness yourself.", + "Watch an open PR for this thread, linking it first if needed. T3 wakes you on failed checks, passed required checks, new comments or reviews, or conflicts. Handle old comments first. Only later ones wake you. After this call, end your turn. The watch stays on after each wake, so do not call this again on a wake, except to restart a quiet timer after its notice. A wake is not a merge decision. Check readiness yourself.", parameters: WatchPullRequestInput, success: WatchPullRequestResult, failure: PullRequestToolError, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 76aac95d9066d..9b35647f741ae 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -2691,7 +2691,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if ( link?.watch?.startedAt !== command.startedAt || (command.expectedQuietDeadline !== undefined && - command.expectedQuietDeadline !== (link.watch.quietDeadline?.dueAt ?? null)) || + (command.expectedQuietDeadline?.dueAt !== link.watch.quietDeadline?.dueAt || + command.expectedQuietDeadline?.timeoutMinutes !== + link.watch.quietDeadline?.timeoutMinutes)) || (command.wake && inactive) ) { return yield* new OrchestratorDispatchError({ @@ -3609,8 +3611,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio : !command.watching ? undefined : existing.watch !== undefined - ? command.quietTimeoutMinutes === undefined && - existing.watch.quietDeadline?.dueAt !== null + ? (command.quietTimeoutMinutes === undefined && + existing.watch.quietDeadline?.dueAt !== null) || + (existing.watch.quietDeadline === undefined && quietDeadline === undefined) ? existing.watch : { ...existing.watch, quietDeadline } : { diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index 3a63595d7224d..51b98e367db2e 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -280,7 +280,7 @@ export const make = Effect.gen(function* () { threadId: target.thread.id, ...identityOf(target.link), startedAt: target.watch.startedAt, - expectedQuietDeadline: target.watch.quietDeadline?.dueAt ?? null, + expectedQuietDeadline: target.watch.quietDeadline ?? null, watch: next, ...(endedWatch === undefined ? {} : { endedWatch }), ...(wake === undefined diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.ts b/apps/server/src/orchestration-v2/pullRequestWatch.ts index fb5f97fd98f9d..cbff34f8f2cf2 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.ts @@ -217,7 +217,7 @@ function changeLines( ]; case "quiet": return [ - `- No actionable update has arrived for ${change.minutes} minutes. Check whether CI or review is still progressing; this notice does not satisfy any review or merge requirement.`, + `- No new actionable check results, comments, reviews, conflicts, or pushes for ${change.minutes} minutes. Check whether CI or review is stuck; this notice is not a review or merge signal. The watch stays on and sends this notice once; new watch updates, a push, or calling watch_pull_request again restarts the timer.`, ]; case "conflicting": return [`- The branch now conflicts with ${context.baseBranch}.`]; @@ -251,7 +251,7 @@ 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. This PR stays linked and watched, so do not link, watch, or list it again. End your turn when you are done, and T3 Code wakes you on the next change. 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. This PR stays linked and watched, so do not link, watch, or list it again${changes.some((change) => change.kind === "quiet") ? ", except to deliberately start another bounded quiet wait" : ""}. End your turn when you are done, and T3 Code wakes you on the next change. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.`, ].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 2d267acda45d5..2d9f73b249278 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -3389,14 +3389,22 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { ); let state: PullRequestDetail["state"] = "open"; let reads = 0; + let headSha = "abc1234def"; + let holdRead = false; + const readStarted = yield* Deferred.make(); + const finishRead = yield* Deferred.make(); const host = Layer.mergeAll( NodeServices.layer, Layer.mock(PullRequestService.PullRequestService)({ watchFingerprint: () => Effect.succeed({ status: "running", remarks: "unchanged" }), invalidate: () => Effect.void, detail: () => - Effect.sync(() => { + Effect.gen(function* () { reads++; + if (holdRead) { + yield* Deferred.succeed(readStarted, undefined); + yield* Deferred.await(finishRead); + } return { ...watchedPullRequestDetail({ projectId, @@ -3404,6 +3412,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { at: "2026-10-01T00:00:00.000Z", }), state, + headSha, capabilities: { ...watchedPullRequestDetail({ projectId, @@ -3443,7 +3452,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { const delivered = yield* watchOf; assert.deepEqual(delivered?.quietDeadline, { timeoutMinutes: 5, dueAt: null }); assert.lengthOf(yield* wakes, 1); - assert.include((yield* wakes)[0]!.text, "No actionable update"); + assert.equal((yield* wakes)[0]!.notification?.summary, "#15: still waiting"); assert.isTrue((yield* maintenance.rebuild).valid); assert.deepEqual((yield* watchOf)?.quietDeadline, delivered?.quietDeadline); @@ -3454,7 +3463,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { threadId, ...key, startedAt: initial!.startedAt, - expectedQuietDeadline: initial!.quietDeadline!.dueAt, + expectedQuietDeadline: initial!.quietDeadline!, watch: delivered!, wake: { messageId: MessageId.make("quiet-duplicate"), @@ -3471,15 +3480,40 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { yield* watching("rearm"); assert.isString((yield* watchOf)?.quietDeadline?.dueAt); - state = "closed"; yield* TestClock.adjust("5 minutes"); yield* again.sweep; - assert.isUndefined(yield* watchOf); assert.lengthOf(yield* wakes, 2); - assert.include((yield* wakes)[1]!.text, "was closed"); + headSha = "new-head"; + holdRead = true; + const racing = yield* PullRequestWatchReactor.make.pipe(Effect.provide(host)); + const inFlight = yield* Effect.forkChild(racing.sweep); + yield* Deferred.await(readStarted); + yield* watching("disable-during-read", null); + yield* Deferred.succeed(finishRead, undefined); + yield* Fiber.join(inFlight); + assert.isUndefined((yield* watchOf)?.quietDeadline, "a stale read must not undo disable"); + yield* watching("keep-disabled"); + assert.isUndefined((yield* watchOf)?.quietDeadline); + + yield* watching("enable", 5); + const enabled = yield* watchOf; + yield* TestClock.adjust("1 minute"); + yield* watching("change-duration", 7); + assert.equal((yield* watchOf)?.quietDeadline?.timeoutMinutes, 7); + assert.notEqual((yield* watchOf)?.quietDeadline?.dueAt, enabled?.quietDeadline?.dueAt); + state = "closed"; + yield* TestClock.adjust("7 minutes"); + yield* again.sweep; + assert.isUndefined(yield* watchOf); + assert.lengthOf(yield* wakes, 3); + assert.include((yield* wakes)[2]!.text, "was closed"); yield* watching("disabled", null); assert.isUndefined((yield* watchOf)?.quietDeadline); + const disabledUpdatedAt = (yield* orchestrator.getThreadShell(threadId))?.updatedAt; + yield* TestClock.adjust("1 minute"); + yield* watching("disable-again", null); + assert.equal((yield* orchestrator.getThreadShell(threadId))?.updatedAt, disabledUpdatedAt); yield* orchestrator.dispatch({ type: "thread.pull-request.watch", commandId: CommandId.make("quiet-end"), diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index c53ae40871513..76b4ad10aa7be 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -63,6 +63,8 @@ import { ThreadPullRequestSnapshot, ThreadPullRequestStack, ThreadPullRequestWatch, + ThreadPullRequestWatchQuietDeadline, + PullRequestWatchQuietTimeoutMinutes, } from "./threadPullRequest.ts"; import { ProviderApprovalDecision, @@ -3024,8 +3026,8 @@ export const OrchestrationV2Command = Schema.Union([ threadId: ThreadId, ...ThreadPullRequestKey.fields, watching: Schema.Boolean, - /** Quiet-wait limit for this watch; null disables it. The owning server supplies the default. */ - quietTimeoutMinutes: Schema.optional(Schema.NullOr(PositiveInt)), + /** Quiet-wait limit; null disables it. Omitted keeps the existing watch's setting; a new watch gets none. */ + quietTimeoutMinutes: Schema.optional(Schema.NullOr(PullRequestWatchQuietTimeoutMinutes)), link: Schema.optional( Schema.Struct({ url: TrimmedNonEmptyString, @@ -3350,7 +3352,7 @@ const OrchestrationV2InternalCommand = Schema.Union([ threadId: ThreadId, ...ThreadPullRequestKey.fields, startedAt: IsoDateTime, - expectedQuietDeadline: Schema.optional(Schema.NullOr(IsoDateTime)), + expectedQuietDeadline: Schema.optional(Schema.NullOr(ThreadPullRequestWatchQuietDeadline)), watch: Schema.NullOr(ThreadPullRequestWatch), /** With `watch: null`, the state the ended watch keeps instead of its last saved one. */ endedWatch: Schema.optional(ThreadPullRequestWatch), diff --git a/packages/contracts/src/threadPullRequest.ts b/packages/contracts/src/threadPullRequest.ts index 496a55783662b..e9c6607e56a4f 100644 --- a/packages/contracts/src/threadPullRequest.ts +++ b/packages/contracts/src/threadPullRequest.ts @@ -79,12 +79,19 @@ export const ThreadPullRequestKey = Schema.Struct({ }); export type ThreadPullRequestKey = typeof ThreadPullRequestKey.Type; +export const PullRequestWatchQuietTimeoutMinutes = PositiveInt.check( + Schema.isLessThanOrEqualTo(10080), +); + +export const ThreadPullRequestWatchQuietDeadline = Schema.Struct({ + timeoutMinutes: PullRequestWatchQuietTimeoutMinutes, + dueAt: Schema.NullOr(IsoDateTime), +}); + export const ThreadPullRequestWatch = Schema.Struct({ startedAt: IsoDateTime, /** Null dueAt records a delivered quiet wake until progress or an explicit rearm. */ - quietDeadline: Schema.optional( - Schema.Struct({ timeoutMinutes: PositiveInt, dueAt: Schema.NullOr(IsoDateTime) }), - ), + quietDeadline: Schema.optional(ThreadPullRequestWatchQuietDeadline), headSha: Schema.NullOr(TrimmedNonEmptyString), failedChecks: Schema.Array(TrimmedNonEmptyString), passed: Schema.Boolean, From 42376b1eb10df12cf92ec81b2ff0e442c4b41fcb Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Wed, 28 Oct 2026 23:50:43 -0700 Subject: [PATCH 3/5] test(server): compile quiet-wait validator once --- apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts index 8eaef94dd5ec0..452acba584ea2 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts @@ -41,6 +41,7 @@ import { const PROJECT_ID = ProjectId.make("project-1"); const THREAD_ID = ThreadId.make("thread-1"); +const isWatchPullRequestInput = Schema.is(WatchPullRequestInput); const testCrypto = Crypto.make({ randomBytes: (size) => new Uint8Array(size).fill(7), @@ -237,7 +238,7 @@ describe("pull request toolkit handlers", () => { [10081, false], [2e11, false], ])("bounds quiet wait duration %s", (quietTimeoutMinutes, accepted) => { - expect(Schema.is(WatchPullRequestInput)({ quietTimeoutMinutes })).toBe(accepted); + expect(isWatchPullRequestInput({ quietTimeoutMinutes })).toBe(accepted); }); it.effect("refuses a credential without the pull-requests capability", () => From bc74741a18cfb553ff1f1ec0f78ac2a11e94e0b4 Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Wed, 28 Oct 2026 23:51:30 -0700 Subject: [PATCH 4/5] test(server): compare quiet-watch timestamps by value --- apps/server/src/orchestration-v2/runtimeLayer.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 2d9f73b249278..1e906f87b7853 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -3510,10 +3510,13 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { yield* watching("disabled", null); assert.isUndefined((yield* watchOf)?.quietDeadline); - const disabledUpdatedAt = (yield* orchestrator.getThreadShell(threadId))?.updatedAt; + const disabledUpdatedAt = (yield* orchestrator.getThreadShell(threadId))!.updatedAt; yield* TestClock.adjust("1 minute"); yield* watching("disable-again", null); - assert.equal((yield* orchestrator.getThreadShell(threadId))?.updatedAt, disabledUpdatedAt); + assert.equal( + DateTime.toEpochMillis((yield* orchestrator.getThreadShell(threadId))!.updatedAt), + DateTime.toEpochMillis(disabledUpdatedAt), + ); yield* orchestrator.dispatch({ type: "thread.pull-request.watch", commandId: CommandId.make("quiet-end"), From e9ca0c79def775685211a87f80808bd5deb5770e Mon Sep 17 00:00:00 2001 From: Michael Johnston Date: Thu, 29 Oct 2026 00:00:15 -0700 Subject: [PATCH 5/5] test(server): record quiet-watch tool context budget --- apps/server/src/provider/agentContextBudget.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 1711e148d4ca4..2e4950d83e63f 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5135, "codexAdditionalContextBytes": 6865, - "mcpToolListJsonBytes": 46007 + "mcpToolListJsonBytes": 46511 }