diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts index ceaef41d459f..7cbbd27c70cc 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts @@ -283,6 +283,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", + events: ["review", "checks_failed"], }); expect(result).toMatchObject({ number: 9, watching: false, wasWatching: false }); expect(yield* Ref.get(harness.commands)).toMatchObject([ @@ -291,6 +292,7 @@ describe("pull request toolkit handlers", () => { number: 9, watching: true, link: { url: "https://github.com/t3tools/t3code/pull/9", source: "agent" }, + events: ["review", "checks_failed"], }, ]); }), diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts index a703a8f5f39f..e6a6f759a45e 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts @@ -47,6 +47,7 @@ import { PullRequestsToolkit, type ThreadPullRequestEntry, CHILD_PULL_REQUEST_REPORT_NEXT, + type WatchPullRequestInput, } from "./tools.ts"; interface ResolvedTarget { @@ -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); @@ -288,6 +289,7 @@ const make = Effect.gen(function* () { number: target.number, watching, ...(watching ? { link: { url: target.url, source: "agent" as const } } : {}), + ...(input.events === undefined ? {} : { events: input.events }), }) .pipe(Effect.catchCause(dispatchFailure(PullRequestWatchFailedError))); const after = yield* requireThread(PullRequestWatchFailedError, thread.id); diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts index e86bf3013bc9..b782147125e8 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -7,6 +7,7 @@ import { PullRequestReviewGuideRun, PullRequestState, ThreadPullRequestLinkSource, + ThreadPullRequestWatchEvents, TrimmedNonEmptyString, ThreadId, } from "@t3tools/contracts"; @@ -59,6 +60,12 @@ export const PullRequestTargetInput = Schema.Struct({ }); export type PullRequestTargetInput = typeof PullRequestTargetInput.Type; +export const WatchPullRequestInput = Schema.Struct({ + ...PullRequestTargetInput.fields, + events: Schema.optional(ThreadPullRequestWatchEvents), +}); +export type WatchPullRequestInput = typeof WatchPullRequestInput.Type; + export class PullRequestUrlInvalidError extends Schema.TaggedError()( "PullRequestUrlInvalidError", {}, @@ -374,8 +381,8 @@ 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, + "Watch open PR; link if needed. Wake on `events` (default all; checks_passed=required), unmerged close/unreadable. Old comments first; only later wake. End turn. Stays on; don't re-call. Wake isn't approval; check readiness.", + 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 e95fb6ec4865..88a799746eb9 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -36,6 +36,7 @@ import { type OrchestrationV2ServerCommand, type ThreadPullRequestLink, type ThreadPullRequestWatch, + type ThreadPullRequestWatchEvents, type OrchestrationV2AppThread, type OrchestrationV2ContextHandoff, type OrchestrationV2ContextSourcePoint, @@ -723,6 +724,18 @@ function hasLiveRun(projection: Pick): return projection.runs.some(isLiveRun); } +/** Sorted and deduplicated, so the same filter in another order is the same watch. */ +const sortedWatchEvents = (events: ThreadPullRequestWatchEvents | undefined) => + events && [...new Set(events)].toSorted(); + +function withWatchEvents( + watch: ThreadPullRequestWatch, + events: ThreadPullRequestWatchEvents | undefined, +): ThreadPullRequestWatch { + const { events: _events, ...rest } = watch; + return events === undefined ? rest : { ...rest, events }; +} + function withPullRequestWatch( link: ThreadPullRequestLink, watch: ThreadPullRequestWatch | undefined, @@ -3329,6 +3342,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio cause: "The pull request watch ended or its thread settled while it was read.", }); } + if ( + sortedWatchEvents(link.watch.events)?.join() !== + sortedWatchEvents(command.expectedEvents)?.join() + ) { + return yield* new OrchestratorDispatchError({ + commandId: command.commandId, + commandType: command.type, + cause: "The pull request watch events changed while it was read.", + }); + } yield* dispatchThreadMutation(command, events, effects); if (command.wake === undefined) return; yield* dispatchMessage( @@ -4251,7 +4274,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio : linked; const existing = links.find(visible); if (existing === undefined) return thread; - const watch = + const observed = command.type === "thread.pull-request-watch.sync" ? existing.watch?.startedAt === command.startedAt ? (command.watch ?? undefined) @@ -4289,6 +4312,14 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio remarkIds: [], wakes: 0, }); + // The filter is the watcher's choice, not read state, so a sync never changes it. + const events = sortedWatchEvents( + command.type === "thread.pull-request.watch" ? command.events : existing.watch?.events, + ); + const watch = + observed === undefined || observed.events?.join() === events?.join() + ? observed + : withWatchEvents(observed, events); 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 f37a69c08ee0..56a6137d47c7 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -96,7 +96,8 @@ interface ReadPlan { readonly activity: boolean; } -const watchKey = ({ thread, watch }: WatchTarget) => `${thread.id} ${watch.startedAt}`; +const watchKey = ({ thread, watch }: WatchTarget) => + `${thread.id} ${watch.startedAt} ${watch.events?.join() ?? "all"}`; type WatchEndReason = | "merged" @@ -288,6 +289,7 @@ export const make = Effect.gen(function* () { threadId: target.thread.id, ...identityOf(target.link), startedAt: target.watch.startedAt, + ...(target.watch.events === undefined ? {} : { expectedEvents: target.watch.events }), 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 dece3e344983..3e4c68daf2ad 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts @@ -81,6 +81,41 @@ const remark = ( const noRemarks: ReadonlyArray = []; describe("evaluatePullRequestWatch", () => { + it("wakes only for the events a watch lists, and still records the rest", () => { + const green = detail({ checks: [check("lint", "success")], mergeability: "conflicting" }); + const comment = { + ...remark("reviewer", "2026-10-02T12:01:00.000Z"), + kind: "issue-comment" as const, + }; + const review = { ...remark("reviewer", "2026-10-02T12:02:00.000Z"), kind: "review" as const }; + const remarks: ReadonlyArray = [comment, review]; + assert.deepEqual( + evaluatePullRequestWatch(watch(), green, noRemarks).changes.map((change) => change.kind), + ["checks-passed", "conflicting"], + ); + + const quiet = evaluatePullRequestWatch(watch({ events: ["comment"] }), green, noRemarks); + assert.deepEqual(quiet.changes, []); + assert.include(quiet.next, { passed: true, conflicting: true, wakes: 0 }); + + const reviews = evaluatePullRequestWatch(watch({ events: ["review"] }), green, remarks); + assert.deepEqual(reviews.changes, [{ kind: "remarks", remarks: [review] }]); + assert.equal(reviews.next.remarksThrough, review.createdAt); + + const conversation = evaluatePullRequestWatch(watch({ events: ["comment"] }), green, remarks); + assert.deepEqual(conversation.changes, [{ kind: "remarks", remarks: [comment] }]); + + const approval = check("CI", "action-required"); + const gated = detail({ checks: [approval] }); + assert.deepEqual( + evaluatePullRequestWatch(watch({ events: ["checks_failed"] }), gated, noRemarks).changes, + [{ kind: "checks-awaiting-approval", checks: [approval] }], + ); + const unasked = evaluatePullRequestWatch(watch({ events: ["comment"] }), gated, noRemarks); + assert.deepEqual(unasked.changes, []); + assert.isTrue(unasked.next.approvalAnnounced); + }); + it("waits for workflow approval once, through reads that miss it, until the run finishes", () => { const bot = check("bot", "success"); const approval = { ...check("CI", "action-required"), url: run(1) }; diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.ts b/apps/server/src/orchestration-v2/pullRequestWatch.ts index 046c48e1858b..108536b4f5ad 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.ts @@ -4,6 +4,8 @@ import type { PullRequestComment, PullRequestDetail, ThreadPullRequestWatch, + ThreadPullRequestWatchEvent, + ThreadPullRequestWatchEvents, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; @@ -35,6 +37,29 @@ const isFailedCheck = (check: PullRequestCheck) => const workflowRunOf = (url: string | null) => url?.match(/^.*\/actions\/runs\/\d+/)?.[0] ?? null; const approvalKey = (check: PullRequestCheck) => workflowRunOf(check.url) ?? check.name; +const CHANGE_EVENT: Record< + Exclude, + ThreadPullRequestWatchEvent +> = { + "checks-failed": "checks_failed", + "checks-passed": "checks_passed", + "checks-awaiting-approval": "checks_failed", + "no-checks": "no_checks", + conflicting: "conflict", +}; + +const remarkEvent = (remark: PullRequestComment): ThreadPullRequestWatchEvent => + remark.kind === "issue-comment" ? "comment" : "review"; + +function wanted( + change: PullRequestWatchChange, + events: ThreadPullRequestWatchEvents, +): Array { + if (change.kind !== "remarks") return events.includes(CHANGE_EVENT[change.kind]) ? [change] : []; + const remarks = change.remarks.filter((remark) => events.includes(remarkEvent(remark))); + return remarks.length === 0 ? [] : [{ kind: "remarks", remarks }]; +} + export function evaluatePullRequestWatch( watch: ThreadPullRequestWatch, detail: Pick< @@ -186,11 +211,15 @@ export function evaluatePullRequestWatch( const conflicting = detail.mergeability === "unknown" ? toldConflict : detail.mergeability === "conflicting"; - const commentsOnly = changes.length > 0 && changes.every((change) => change.kind === "remarks"); - const progress = headMoved || (changes.length > 0 && !commentsOnly); + // Filtered changes still advance the state above, so widening the filter never replays them. + const events = watch.events; + const waking = + events === undefined ? changes : changes.flatMap((change) => wanted(change, events)); + const commentsOnly = waking.length > 0 && waking.every((change) => change.kind === "remarks"); + const progress = headMoved || (waking.length > 0 && !commentsOnly); const wakes = (progress ? 0 : watch.wakes) + (commentsOnly ? 1 : 0); return { - changes, + changes: waking, next: { startedAt: watch.startedAt, headSha, diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index a49e323770cc..5d83a7f2bbcd 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -35,6 +35,7 @@ import { RunId, ScheduledTaskId, ThreadId, + type ThreadPullRequestWatch, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as DateTime from "effect/DateTime"; @@ -3784,6 +3785,185 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect.each([ + { name: "widening", before: ["comment"], after: undefined, news: "failure" }, + { name: "replacement", before: ["comment"], after: ["review"], news: "review" }, + { name: "narrowing", before: undefined, after: ["comment"], news: "passed-conflict" }, + { name: "quiet replacement", before: ["comment"], after: ["review"], news: "quiet" }, + ] satisfies ReadonlyArray<{ + name: string; + before: ThreadPullRequestWatch["events"]; + after: ThreadPullRequestWatch["events"]; + news: string; + }>)("uses the current events selection after an in-flight host read: $name", (scenario) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const projectId = ProjectId.make(`pr-watch-events-race-project-${scenario.name}`); + const threadId = ThreadId.make(`pr-watch-events-race-thread-${scenario.name}`); + yield* seedProject({ + projectId, + title: "Watch events race", + workspaceRoot: "/workspace/watch-events-race", + defaultModelSelection: null, + createdAt: "2026-10-01T00:00:00.000Z", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make(`pr-watch-events-race-create-${scenario.name}`), + threadId, + projectId, + title: "Watch events race", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + const key = { host: "github.com", repository: "pingdotgg/t3code", number: 15 }; + const watch = (id: string, events: ThreadPullRequestWatch["events"]) => + orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make(`pr-watch-events-race-${scenario.name}-${id}`), + threadId, + ...key, + watching: true, + link: { url: "https://github.com/pingdotgg/t3code/pull/15", source: "agent" }, + ...(events === undefined ? {} : { events }), + }); + const watchOf = Effect.map( + orchestrator.getThreadShell(threadId), + (thread) => thread?.pullRequests?.[0]?.watch, + ); + const summaries = Effect.map( + orchestrator.getThreadRecords(threadId, ["messages"]), + ({ messages }) => messages.flatMap((message) => message.notification?.summary ?? []), + ); + yield* watch("start", scenario.before); + const started = yield* watchOf; + assert.isDefined(started); + if (started === undefined) return; + const reviewAt = DateTime.formatIso( + DateTime.add(DateTime.makeUnsafe(started.startedAt), { seconds: 1 }), + ); + let detail: PullRequestDetail = { + ...watchedPullRequestDetail({ projectId, number: 15, at: reviewAt }), + checks: [{ name: "old", status: "failure" as const, description: null, url: null }], + }; + const comments: Array = []; + const readReady = yield* Deferred.make(); + const releaseRead = yield* Deferred.make(); + let blocked = false; + let fingerprint = "baseline"; + let reads = 0; + const host = Layer.mergeAll( + NodeServices.layer, + NodeCrypto.layer, + Layer.mock(PullRequestService.PullRequestService)({ + watchFingerprint: () => Effect.succeed({ status: fingerprint, remarks: fingerprint }), + invalidate: () => Effect.void, + detail: (reference) => + Effect.gen(function* () { + if (reference.projectId === projectId) reads += 1; + if (blocked && reference.projectId === projectId) { + yield* Deferred.succeed(readReady, undefined); + yield* Deferred.await(releaseRead); + } + return detail; + }), + activity: (reference) => + Effect.gen(function* () { + if (blocked && reference.projectId === projectId) yield* Deferred.await(releaseRead); + return { + comments: [...comments], + commentCount: comments.length, + commentsTruncated: false, + reviewThreads: [], + commits: [], + }; + }), + }), + ); + const reactor = yield* PullRequestWatchReactor.make.pipe(Effect.provide(host)); + const sweep = reactor.sweep.pipe(Effect.andThen(worker.drain())); + yield* sweep; + const observed = yield* watchOf; + assert.deepEqual(observed?.failedChecks, ["old"]); + const baselineSummaries = yield* summaries; + fingerprint = "race"; + blocked = true; + const pending = yield* sweep.pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(readReady); + yield* watch("change", scenario.after); + if (scenario.news === "failure") { + detail = { ...detail, checks: [...detail.checks, { ...detail.checks[0]!, name: "new" }] }; + } else if (scenario.news === "passed-conflict") { + detail = { + ...detail, + mergeability: "conflicting", + checks: [{ ...detail.checks[0]!, status: "success" }], + }; + } else if (scenario.news === "review") { + comments.push({ + id: "new-review", + kind: "review", + author: { login: "reviewer", name: null, avatarUrl: null }, + body: "Needs a test.", + createdAt: reviewAt, + url: "https://github.com/pingdotgg/t3code/pull/15#review", + path: null, + reviewState: "CHANGES_REQUESTED", + }); + } + yield* Deferred.succeed(releaseRead, undefined); + yield* Fiber.join(pending); + assert.deepEqual( + { ...(yield* watchOf), events: undefined }, + { ...observed, events: undefined }, + ); + assert.deepEqual((yield* watchOf)?.events, scenario.after); + assert.deepEqual(yield* summaries, baselineSummaries); + blocked = false; + if (scenario.news === "quiet") { + comments.push({ + id: "late-review", + kind: "review-comment", + author: { login: "reviewer", name: null, avatarUrl: null }, + body: "Needs a test here.", + createdAt: reviewAt, + url: "https://github.com/pingdotgg/t3code/pull/15#discussion", + path: "test.ts", + reviewState: null, + }); + } + yield* sweep; + assert.equal(reads, 3); + assert.equal((yield* watchOf)?.startedAt, started.startedAt); + assert.deepEqual(yield* summaries, [ + ...baselineSummaries, + ...(scenario.news === "passed-conflict" + ? [] + : [scenario.news === "failure" ? "#15: checks failed" : "#15: new comments"]), + ]); + if (scenario.news === "failure") { + const { messages } = yield* orchestrator.getThreadRecords(threadId, ["messages"]); + const text = messages.at(-1)?.text ?? ""; + assert.include(text, " - new"); + assert.notInclude(text, " - old"); + } + const recorded = yield* watchOf; + yield* sweep; + assert.equal(reads, 3); + assert.deepEqual(yield* watchOf, recorded); + assert.equal( + (yield* summaries).length, + baselineSummaries.length + (scenario.news === "passed-conflict" ? 0 : 1), + ); + }), + ); + // regression: #23701 a head no check runs on never woke the watch it.effect("wakes once when no checks report on the head, without saying they passed", () => Effect.gen(function* () { diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 39731cb3d35b..a34da9dd4899 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -320,11 +320,16 @@ review is terminal. An open or unsynced link keeps it active. Ask the agent to watch, monitor, or babysit a pull request and it calls `watch_pull_request`. While the thread is active, the server checks the pull request every two minutes and wakes the agent when a check fails or waits for a maintainer's approval, the required checks pass, someone else comments or reviews, or the branch starts to -conflict. Threads in a project that watch the same pull request share one check. On GitHub, a check +conflict. To cut wakes on a busy pull request, ask the agent to watch only some of these, such as +comments, reviews and failed checks. The agent still wakes when the pull request closes or when +failed reads end the watch, and only the comments and reviews a watch includes count toward the +comment-only wakes that end watching. +Threads in a project that watch the same pull request share one check. On GitHub, a check first asks whether anything changed and reads the pull request only when it did, which keeps watching inside GitHub's rate limit. Comments from your own account do not wake it. Watching ends when the pull request merges or closes, after 10 wakes in a row that bring only comments, after 8 -failed reads in a row, or when you press Stop on the thread. A rate limit only pauses watching. +reads in a row fail because the pull request is missing or T3 Code can no longer access it, or when +you press Stop on the thread. A rate limit, connection failure or host outage only pauses watching. Settling or archiving a thread also ends all its watches. Unsettle the thread before starting a new watch. Watching a pull request again does not repeat check or conflict news its last watch already reported; unlinking the pull request clears that. Subagents cannot watch pull requests; the thread that delegated to them does. To start or stop diff --git a/evals/mcp-tool-use/cases/pull-requests.ndjson b/evals/mcp-tool-use/cases/pull-requests.ndjson index 2e14e45f7f25..86e0b3450ca9 100644 --- a/evals/mcp-tool-use/cases/pull-requests.ndjson +++ b/evals/mcp-tool-use/cases/pull-requests.ndjson @@ -37,3 +37,4 @@ {"id": "link-one-pr", "area": "pull-requests", "privacy": "commit-safe", "kind": "control", "pattern": "single link stays single", "experience": "Linking one named PR is ordinary work.", "setup": {"role": "top", "context": "No pull requests are linked to this thread.", "world": {"repo": "acme/app", "branch": "main"}}, "prompt": "link https://github.com/acme/app/pull/130 here", "expect": {"calls": [{"tool": "link_pull_request", "args": {"url": "~/pull/130"}}], "notCalls": ["link_pull_requests"]}, "maxSteps": 6, "source": {"thread": "Synthetic control for batch link"}} {"id": "schedule-one-time-date", "area": "pull-requests", "kind": "bad", "pattern": "one-off date scheduled as a daily task", "experience": "Theo asked whether a scheduled task can run at a specific date and time. schedule_task only had daily times, intervals, and webhooks, so a one-off request became a daily task.", "source": {"report": "maintainer request, 2026-10-31: scheduled tasks could not run at one date and time"}, "rule": "schedule: {type:'once',runAt} for one date and time", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"schedule_task": {"scheduledTaskId": "scheduled-task-4", "title": "Cut 2.0 release branch", "schedule": {"type": "once", "runAt": "2026-11-03T09:00:00.000Z"}, "enabled": true, "nextRunAt": "2026-11-03T09:00:00.000Z"}}}, "prompt": "On November 3rd at 9am UTC, start a new thread that cuts the 2.0 release branch from main in acme/app. Only that one time.", "expect": {"calls": [{"tool": "schedule_task", "args": {"schedule.type": "once", "schedule.runAt": "~11-03T09:00"}}], "notCalls": ["delegate_task", "run_scheduled_task_now"]}, "notes": "Mock result shape is an approximation."} {"id": "schedule-fresh-session-per-run", "area": "pull-requests", "kind": "guard", "pattern": "recurring task that should not accumulate context", "experience": "A recurring triage task kept one thread so the user could read its history, but each run carried every earlier run's context into the next.", "rule": "schedule_task `freshSession: true` keeps runs in one thread while each run starts a fresh session.", "privacy": "commit-safe", "setup": {"role": "top", "context": "Today is Monday 2026-11-02, 10:00 local time.", "mocks": {"schedule_task": {"scheduledTaskId": "scheduled-task-7", "title": "Triage new issues", "enabled": true, "projectId": "project-1", "boundThreadId": null, "freshSession": true, "schedule": {"type": "fixed_time", "timeOfDay": "09:00"}, "nextRunAt": "2026-11-03T09:00:00-08:00", "lastRunStatus": "never"}}}, "prompt": "Every morning at 9, triage new issues in acme/app. Keep all the runs in one thread so I can scroll back, but each run should start clean without remembering the previous days.", "expect": {"calls": [{"tool": "schedule_task", "args": {"schedule.type": "fixed_time", "freshSession": true}}], "notCalls": ["delegate_task", "run_scheduled_task_now"]}, "source": {"request": "juliusmarminge (maintainer) request, 2026-11-03: automations in one thread with separate provider sessions"}, "notes": "schedule-one-off-tomorrow is the control: an ordinary schedule must not set freshSession."} +{"id": "filtered-watch-merge-no-wake", "area": "pull-requests", "kind": "bad", "pattern": "relies on a wake that a filtered watch never sends", "experience": "The watch_pull_request description said a filtered watch also wakes when the T3 watch ends. A merge ends the watch without a wake, so an agent can promise follow-up work after a merge that never wakes it.", "rule": "Wake on `events` (default all; checks_passed=required), unmerged close/unreadable", "privacy": "commit-safe", "source": {"pr": "https://github.com/pingdotgg/t3code/pull/24981", "finding": "the tool text named every watch end as a wake; only a close without merging or a watch that can no longer read the PR wakes"}, "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Open the PR and have T3 watch it for review comments only. I'll keep an eye on CI myself."}, {"role": "assistant", "text": "Opened [#124](https://github.com/acme/app/pull/124). T3 Code is watching it for comments and reviews.", "toolCalls": [{"tool": "Bash", "input": {"command": "git push -u origin fix/socket-retry"}, "result": "To github.com:acme/app.git\n * [new branch] fix/socket-retry -> fix/socket-retry\nbranch 'fix/socket-retry' set up to track 'origin/fix/socket-retry'."}, {"tool": "Bash", "input": {"command": "gh pr create --base main --title \"fix(web): retry the socket once before showing an error\" --fill"}, "result": "https://github.com/acme/app/pull/124"}, {"tool": "link_pull_request", "input": {"url": "https://github.com/acme/app/pull/124"}, "result": {"host": "github.com", "repository": "acme/app", "number": 124, "url": "https://github.com/acme/app/pull/124", "alreadyLinked": false}}, {"tool": "watch_pull_request", "input": {"url": "https://github.com/acme/app/pull/124", "events": ["comment", "review"]}, "result": {"host": "github.com", "repository": "acme/app", "number": 124, "url": "https://github.com/acme/app/pull/124", "watching": true, "wasWatching": false}}]}], "mocks": {"watch_pull_request": "default", "list_thread_pull_requests": "default", "link_pull_request": "default", "unwatch_pull_request": "default"}, "world": {"branch": "fix/socket-retry", "commits": [{"sha": "abc1234", "subject": "fix(web): retry the socket once before showing an error"}], "prs": [{"number": 124, "title": "fix(web): retry the socket once before showing an error", "headRefName": "fix/socket-retry", "state": "OPEN", "checks": [{"name": "Lint", "state": "pass"}, {"name": "Typecheck", "state": "pending"}, {"name": "Test", "state": "pending"}], "mergeStateStatus": "BLOCKED"}]}}, "prompt": "I'll merge #124 myself later today. Once it's merged, delete fix/socket-retry locally and on origin. No scheduled task or polling for this. I'm heading out, so end your turn. End your reply with one line: `Cleanup trigger: `.", "expect": {"notCalls": ["schedule_task", "Monitor", "ScheduleWakeup"], "shellMustNot": ["git push [^\\n]*--delete", "git push [^\\n]*origin :", "git branch -[dD]\\b", "\\bsleep \\d", "gh pr (view|checks)[^\\n]*--watch", "\\buntil\\b[^\\n]*\\bgh (pr|run|api)\\b", "\\bwhile\\b[^\\n]*\\bgh (pr|run|api)\\b"], "finalTextMust": ["(?:^|\\n)\\W*Cleanup trigger:(?![^\\n]*(?i:wake|woke|notif|alert|watch|T3|automatic|event|clos|report|\\bno\\b|\\bnot\\b|n['’]t\\b|\\bnever\\b|\\bwithout\\b|\\bnone\\b|\\bnothing\\b|needn))\\s*(?i:(?:a|an|the|when|once|after|if|new|next|another|fresh)\\s+){0,3}(?i:you\\s+(?:[\\w’']+\\s+){0,2}?(?:message|ping|tell|ask|send|reply|return|come back|check in|resume|prompt|write|let me know)|your\\s+(?:(?:next|new|later|own|follow-up)\\s+)?(?:message|reply|ping|prompt|go-ahead|note|cleanup request)\\b|(?:message|reply|ping|prompt|note|go-ahead|confirmation)\\s+from\\s+you\\b)(?:(?!(?i:\\b(?:or|and|either|otherwise|else|plus|alternatively)\\b))[^;,.\\n]|\\.(?=\\S)){0,100}\\.?[ \\t]*(?:\\n|$)"]}, "notes": "The watch filters to comments and reviews. A merge ends it without a wake (PullRequestWatchReactor records it and ends the watch silently), so the cleanup cannot run unattended, and the prompt rules out scheduling and polling. The answer ends with a `Cleanup trigger:` line so the grader reads one short field instead of free text: it passes only when that line opens with an action by the user (\"your message\", \"a message from you\", \"when you tell me\") followed only by a short qualifier with no comma, semicolon or conjunction, so the line names exactly one trigger, and has no negation and no mention of a wake, notification, report, watch, close, T3, event or anything automatic. On the old text every sample named the watch's close notification. Deleting the branch now or waiting in the shell fails."} diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 856cf89bafcb..be3bdb3e03fb 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -64,6 +64,7 @@ import { ThreadPullRequestSnapshot, ThreadPullRequestStack, ThreadPullRequestWatch, + ThreadPullRequestWatchEvents, } from "./threadPullRequest.ts"; import { ProviderApprovalDecision, @@ -3123,6 +3124,8 @@ export const OrchestrationV2Command = Schema.Union([ threadId: ThreadId, ...ThreadPullRequestKey.fields, watching: Schema.Boolean, + /** Replaces the watch's filter; absent means all events. */ + events: Schema.optional(ThreadPullRequestWatchEvents), link: Schema.optional( Schema.Struct({ url: TrimmedNonEmptyString, @@ -3450,6 +3453,8 @@ const OrchestrationV2InternalCommand = Schema.Union([ threadId: ThreadId, ...ThreadPullRequestKey.fields, startedAt: IsoDateTime, + /** The selection used to evaluate this read; omitted means all events. */ + expectedEvents: Schema.optional(ThreadPullRequestWatchEvents), 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 bd0a65a3c0bc..94e589fa065d 100644 --- a/packages/contracts/src/threadPullRequest.ts +++ b/packages/contracts/src/threadPullRequest.ts @@ -79,8 +79,25 @@ export const ThreadPullRequestKey = Schema.Struct({ }); export type ThreadPullRequestKey = typeof ThreadPullRequestKey.Type; +export const ThreadPullRequestWatchEvent = Schema.Literals([ + "comment", + "review", + "checks_failed", + "checks_passed", + "no_checks", + "conflict", +]); +export type ThreadPullRequestWatchEvent = typeof ThreadPullRequestWatchEvent.Type; + +export const ThreadPullRequestWatchEvents = Schema.Array(ThreadPullRequestWatchEvent).check( + Schema.isMinLength(1), +); +export type ThreadPullRequestWatchEvents = typeof ThreadPullRequestWatchEvents.Type; + export const ThreadPullRequestWatch = Schema.Struct({ startedAt: IsoDateTime, + /** The only changes that wake the thread; absent means all. Terminal handling is unchanged. */ + events: Schema.optional(ThreadPullRequestWatchEvents), headSha: Schema.NullOr(TrimmedNonEmptyString), failedChecks: Schema.Array(TrimmedNonEmptyString), passed: Schema.Boolean,