| author | |
| committer | |
| log | 18b21325c3ab1ff4f0ee7b017d9c4eefc2b2d307 |
| tree | 9c61cc2abdf156c805caa71d78e2bc167e1235b0 |
| parent | e8545b293b79cdd73d30ef921db149ef1b5abee0 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>31 files changed, 1531 insertions(+), 39 deletions(-)
apps/mobile/src/features/threads/git/GitOverviewSheet.tsx+1-1| ... | @@ -339,7 +339,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { | ... | @@ -339,7 +339,7 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) { |
| 339 | <SheetListRow | 339 | <SheetListRow |
| 340 | icon="arrow.triangle.pull" | 340 | icon="arrow.triangle.pull" |
| 341 | title={`#${link.number} ${link.snapshot?.title ?? "Pull request"}`} | 341 | title={`#${link.number} ${link.snapshot?.title ?? "Pull request"}`} |
| 342 | subtitle={`${link.repository} · ${link.snapshot === null ? "Status pending" : link.snapshot.isDraft && link.snapshot.state === "open" ? "Draft" : link.snapshot.state}`} | 342 | subtitle={`${link.repository} · ${link.snapshot === null ? "Status pending" : link.snapshot.isDraft && link.snapshot.state === "open" ? "Draft" : link.snapshot.state}${link.watch === undefined ? "" : " · Watching"}`} |
| 343 | onPress={() => { | 343 | onPress={() => { |
| 344 | void tryOpenExternalUrl(link.url, "pull-request").then((opened) => { | 344 | void tryOpenExternalUrl(link.url, "pull-request").then((opened) => { |
| 345 | if (!opened) | 345 | if (!opened) |
apps/mobile/src/features/threads/thread-work-log.tsx+2| ... | @@ -1368,6 +1368,8 @@ function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName { | ... | @@ -1368,6 +1368,8 @@ function toolGroupSummarySymbolName(kind: ToolGroupSummaryKind): AppSymbolName { |
| 1368 | case "link-pr": | 1368 | case "link-pr": |
| 1369 | case "unlink-pr": | 1369 | case "unlink-pr": |
| 1370 | case "list-prs": | 1370 | case "list-prs": |
| 1371 | case "watch-pr": | ||
| 1372 | case "unwatch-pr": | ||
| 1371 | return "arrow.triangle.pull"; | 1373 | return "arrow.triangle.pull"; |
| 1372 | case "read": | 1374 | case "read": |
| 1373 | return { ios: "eye", android: "visibility" }; | 1375 | return { ios: "eye", android: "visibility" }; |
apps/server/src/environment/ServerEnvironment.ts+1| ... | @@ -240,6 +240,7 @@ export const make = Effect.gen(function* () { | ... | @@ -240,6 +240,7 @@ export const make = Effect.gen(function* () { |
| 240 | threadTitleRegeneration: true, | 240 | threadTitleRegeneration: true, |
| 241 | threadVisitedTracking: true, | 241 | threadVisitedTracking: true, |
| 242 | threadPullRequests: true, | 242 | threadPullRequests: true, |
| 243 | threadPullRequestWatch: true, | ||
| 243 | pullRequestStackActions: true, | 244 | pullRequestStackActions: true, |
| 244 | threadPullRequestLinking: true, | 245 | threadPullRequestLinking: true, |
| 245 | serverResolvedCommandContext: true, | 246 | serverResolvedCommandContext: true, |
apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts+53| ... | @@ -222,6 +222,58 @@ describe("pull request toolkit handlers", () => { | ... | @@ -222,6 +222,58 @@ describe("pull request toolkit handlers", () => { |
| 222 | }), | 222 | }), |
| 223 | ); | 223 | ); |
| 224 | 224 | ||
| 225 | it.effect("watching an unlinked pull request links it first", () => | ||
| 226 | Effect.gen(function* () { | ||
| 227 | const harness = yield* makeHarness(); | ||
| 228 | const result = yield* harness.call("watch_pull_request", { | ||
| 229 | url: "https://github.com/t3tools/t3code/pull/9", | ||
| 230 | }); | ||
| 231 | // The harness thread never changes, so the result reports what it still holds. | ||
| 232 | expect(result).toMatchObject({ number: 9, watching: false, wasWatching: false }); | ||
| 233 | expect(yield* Ref.get(harness.commands)).toMatchObject([ | ||
| 234 | { | ||
| 235 | type: "thread.pull-request.watch", | ||
| 236 | number: 9, | ||
| 237 | watching: true, | ||
| 238 | link: { url: "https://github.com/t3tools/t3code/pull/9", source: "agent" }, | ||
| 239 | }, | ||
| 240 | ]); | ||
| 241 | }), | ||
| 242 | ); | ||
| 243 | |||
| 244 | it.effect("refuses to watch a merged pull request and stops an existing watch", () => | ||
| 245 | Effect.gen(function* () { | ||
| 246 | const watch = { | ||
| 247 | startedAt: "2026-08-20T00:00:00.000Z", | ||
| 248 | headSha: null, | ||
| 249 | failedChecks: [], | ||
| 250 | passed: false, | ||
| 251 | remarksThrough: "2026-08-20T00:00:00.000Z", | ||
| 252 | remarkIds: [], | ||
| 253 | conflicting: false, | ||
| 254 | wakes: 0, | ||
| 255 | }; | ||
| 256 | const merged = makeLink(1, { headBranch: "done" }); | ||
| 257 | const harness = yield* makeHarness({ | ||
| 258 | thread: makeThread([ | ||
| 259 | { ...merged, snapshot: merged.snapshot && { ...merged.snapshot, state: "merged" } }, | ||
| 260 | makeLink(2, { headBranch: "idle" }), | ||
| 261 | makeLink(3, { headBranch: "watched", watch }), | ||
| 262 | ]), | ||
| 263 | }); | ||
| 264 | const error = yield* harness | ||
| 265 | .call("watch_pull_request", { repository: "t3tools/t3code", number: 1 }) | ||
| 266 | .pipe(Effect.flip); | ||
| 267 | expect(error).toMatchObject({ _tag: "PullRequestNotOpenError", state: "merged" }); | ||
| 268 | expect( | ||
| 269 | yield* harness.call("unwatch_pull_request", { repository: "t3tools/t3code", number: 3 }), | ||
| 270 | ).toMatchObject({ wasWatching: true }); | ||
| 271 | expect(yield* Ref.get(harness.commands)).toMatchObject([ | ||
| 272 | { type: "thread.pull-request.watch", number: 3, watching: false }, | ||
| 273 | ]); | ||
| 274 | }), | ||
| 275 | ); | ||
| 276 | |||
| 225 | it.effect("links by repository and number, defaulting the host to the project's", () => | 277 | it.effect("links by repository and number, defaulting the host to the project's", () => |
| 226 | Effect.gen(function* () { | 278 | Effect.gen(function* () { |
| 227 | const harness = yield* makeHarness(); | 279 | const harness = yield* makeHarness(); |
| ... | @@ -396,6 +448,7 @@ describe("pull request toolkit handlers", () => { | ... | @@ -396,6 +448,7 @@ describe("pull request toolkit handlers", () => { |
| 396 | number: 3, | 448 | number: 3, |
| 397 | url: "https://github.com/t3tools/t3code/pull/3", | 449 | url: "https://github.com/t3tools/t3code/pull/3", |
| 398 | source: "agent", | 450 | source: "agent", |
| 451 | watching: false, | ||
| 399 | state: "open", | 452 | state: "open", |
| 400 | title: "PR 3", | 453 | title: "PR 3", |
| 401 | headBranch: "feat-c", | 454 | headBranch: "feat-c", |
apps/server/src/mcp/toolkits/pullRequests/handlers.ts+64-4| ... | @@ -34,7 +34,9 @@ import { | ... | @@ -34,7 +34,9 @@ import { |
| 34 | PullRequestHostRequiredError, | 34 | PullRequestHostRequiredError, |
| 35 | PullRequestUnlinkFailedError, | 35 | PullRequestUnlinkFailedError, |
| 36 | PullRequestListFailedError, | 36 | PullRequestListFailedError, |
| 37 | PullRequestNotOpenError, | ||
| 37 | type PullRequestTargetInput, | 38 | type PullRequestTargetInput, |
| 39 | PullRequestWatchFailedError, | ||
| 38 | PullRequestThreadNotFoundError, | 40 | PullRequestThreadNotFoundError, |
| 39 | PullRequestsToolkit, | 41 | PullRequestsToolkit, |
| 40 | type ThreadPullRequestEntry, | 42 | type ThreadPullRequestEntry, |
| ... | @@ -122,6 +124,7 @@ function entryOf( | ... | @@ -122,6 +124,7 @@ function entryOf( |
| 122 | number: link.number, | 124 | number: link.number, |
| 123 | url: link.url, | 125 | url: link.url, |
| 124 | source: link.source, | 126 | source: link.source, |
| 127 | watching: link.watch !== undefined, | ||
| 125 | state: link.snapshot?.state ?? null, | 128 | state: link.snapshot?.state ?? null, |
| 126 | title: link.snapshot?.title ?? null, | 129 | title: link.snapshot?.title ?? null, |
| 127 | headBranch: link.snapshot?.headBranch ?? null, | 130 | headBranch: link.snapshot?.headBranch ?? null, |
| ... | @@ -163,7 +166,8 @@ const make = Effect.gen(function* () { | ... | @@ -163,7 +166,8 @@ const make = Effect.gen(function* () { |
| 163 | Failure: | 166 | Failure: |
| 164 | | typeof PullRequestLinkFailedError | 167 | | typeof PullRequestLinkFailedError |
| 165 | | typeof PullRequestUnlinkFailedError | 168 | | typeof PullRequestUnlinkFailedError |
| 166 | | typeof PullRequestListFailedError, | 169 | | typeof PullRequestListFailedError |
| 170 | | typeof PullRequestWatchFailedError, | ||
| 167 | ) { | 171 | ) { |
| 168 | const scope = yield* McpInvocationContext.requireMcpCapability("pull-requests"); | 172 | const scope = yield* McpInvocationContext.requireMcpCapability("pull-requests"); |
| 169 | const thread = yield* engine | 173 | const thread = yield* engine |
| ... | @@ -178,7 +182,10 @@ const make = Effect.gen(function* () { | ... | @@ -178,7 +182,10 @@ const make = Effect.gen(function* () { |
| 178 | 182 | ||
| 179 | const projectOf = ( | 183 | const projectOf = ( |
| 180 | thread: OrchestrationV2ThreadShell, | 184 | thread: OrchestrationV2ThreadShell, |
| 181 | Failure: typeof PullRequestLinkFailedError | typeof PullRequestUnlinkFailedError, | 185 | Failure: |
| 186 | | typeof PullRequestLinkFailedError | ||
| 187 | | typeof PullRequestUnlinkFailedError | ||
| 188 | | typeof PullRequestWatchFailedError, | ||
| 182 | ) => | 189 | ) => |
| 183 | projects.getShell(thread.projectId).pipe( | 190 | projects.getShell(thread.projectId).pipe( |
| 184 | Effect.map(Option.getOrUndefined), | 191 | Effect.map(Option.getOrUndefined), |
| ... | @@ -186,14 +193,65 @@ const make = Effect.gen(function* () { | ... | @@ -186,14 +193,65 @@ const make = Effect.gen(function* () { |
| 186 | ); | 193 | ); |
| 187 | 194 | ||
| 188 | const dispatchFailure = | 195 | const dispatchFailure = |
| 189 | (Failure: typeof PullRequestLinkFailedError | typeof PullRequestUnlinkFailedError) => | 196 | ( |
| 197 | Failure: | ||
| 198 | | typeof PullRequestLinkFailedError | ||
| 199 | | typeof PullRequestUnlinkFailedError | ||
| 200 | | typeof PullRequestWatchFailedError, | ||
| 201 | ) => | ||
| 190 | <E>( | 202 | <E>( |
| 191 | cause: Cause.Cause<E>, | 203 | cause: Cause.Cause<E>, |
| 192 | ): Effect.Effect<never, PullRequestLinkFailedError | PullRequestUnlinkFailedError> => | 204 | ): Effect.Effect< |
| 205 | never, | ||
| 206 | PullRequestLinkFailedError | PullRequestUnlinkFailedError | PullRequestWatchFailedError | ||
| 207 | > => | ||
| 193 | Cause.hasInterruptsOnly(cause) | 208 | Cause.hasInterruptsOnly(cause) |
| 194 | ? Effect.failCause(cause as Cause.Cause<never>) | 209 | ? Effect.failCause(cause as Cause.Cause<never>) |
| 195 | : Effect.fail(new Failure({ cause })); | 210 | : Effect.fail(new Failure({ cause })); |
| 196 | 211 | ||
| 212 | /** | ||
| 213 | * Starts or stops a watch. One command links an unlinked pull request and watches it, and the | ||
| 214 | * result reports the state the thread holds afterwards. | ||
| 215 | */ | ||
| 216 | const setWatching = Effect.fn("PullRequestsToolkit.setWatching")(function* ( | ||
| 217 | input: PullRequestTargetInput, | ||
| 218 | watching: boolean, | ||
| 219 | ) { | ||
| 220 | const thread = yield* requireThread(PullRequestWatchFailedError); | ||
| 221 | const project = yield* projectOf(thread, PullRequestWatchFailedError); | ||
| 222 | const target = yield* resolveTarget(input, project); | ||
| 223 | const watchedLink = (shell: OrchestrationV2ThreadShell) => | ||
| 224 | threadPullRequestsOf(shell).find( | ||
| 225 | (link) => link.source !== "stack-dismissed" && threadPullRequestKeysEqual(link, target), | ||
| 226 | ); | ||
| 227 | const before = watchedLink(thread); | ||
| 228 | const state = before?.snapshot?.state; | ||
| 229 | if (watching && state !== undefined && state !== "open") { | ||
| 230 | return yield* new PullRequestNotOpenError({ state }); | ||
| 231 | } | ||
| 232 | yield* engine | ||
| 233 | .dispatch({ | ||
| 234 | type: "thread.pull-request.watch", | ||
| 235 | commandId: yield* commandId("mcp-pr-watch", thread.id), | ||
| 236 | threadId: thread.id, | ||
| 237 | host: target.host, | ||
| 238 | repository: target.repository, | ||
| 239 | number: target.number, | ||
| 240 | watching, | ||
| 241 | ...(watching ? { link: { url: target.url, source: "agent" as const } } : {}), | ||
| 242 | }) | ||
| 243 | .pipe(Effect.catchCause(dispatchFailure(PullRequestWatchFailedError))); | ||
| 244 | const after = yield* requireThread(PullRequestWatchFailedError); | ||
| 245 | return { | ||
| 246 | host: target.host, | ||
| 247 | repository: target.repository, | ||
| 248 | number: target.number, | ||
| 249 | url: target.url, | ||
| 250 | watching: watchedLink(after)?.watch !== undefined, | ||
| 251 | wasWatching: before?.watch !== undefined, | ||
| 252 | }; | ||
| 253 | }); | ||
| 254 | |||
| 197 | return PullRequestsToolkit.of({ | 255 | return PullRequestsToolkit.of({ |
| 198 | link_pull_request: (input) => | 256 | link_pull_request: (input) => |
| 199 | Effect.gen(function* () { | 257 | Effect.gen(function* () { |
| ... | @@ -260,6 +318,8 @@ const make = Effect.gen(function* () { | ... | @@ -260,6 +318,8 @@ const make = Effect.gen(function* () { |
| 260 | }), | 318 | }), |
| 261 | list_thread_pull_requests: () => | 319 | list_thread_pull_requests: () => |
| 262 | requireThread(PullRequestListFailedError).pipe(Effect.map(listThreadPullRequests)), | 320 | requireThread(PullRequestListFailedError).pipe(Effect.map(listThreadPullRequests)), |
| 321 | watch_pull_request: (input) => setWatching(input, true), | ||
| 322 | unwatch_pull_request: (input) => setWatching(input, false), | ||
| 263 | }); | 323 | }); |
| 264 | }); | 324 | }); |
| 265 | 325 |
apps/server/src/mcp/toolkits/pullRequests/tools.ts+62| ... | @@ -108,6 +108,24 @@ export class PullRequestUnlinkFailedError extends Schema.TaggedError<PullRequest | ... | @@ -108,6 +108,24 @@ export class PullRequestUnlinkFailedError extends Schema.TaggedError<PullRequest |
| 108 | } | 108 | } |
| 109 | } | 109 | } |
| 110 | 110 | ||
| 111 | export class PullRequestWatchFailedError extends Schema.TaggedError<PullRequestWatchFailedError>()( | ||
| 112 | "PullRequestWatchFailedError", | ||
| 113 | { cause: Schema.Defect() }, | ||
| 114 | ) { | ||
| 115 | override get message(): string { | ||
| 116 | return "Could not change whether the pull request is watched."; | ||
| 117 | } | ||
| 118 | } | ||
| 119 | |||
| 120 | export class PullRequestNotOpenError extends Schema.TaggedError<PullRequestNotOpenError>()( | ||
| 121 | "PullRequestNotOpenError", | ||
| 122 | { state: Schema.String }, | ||
| 123 | ) { | ||
| 124 | override get message(): string { | ||
| 125 | return `The pull request is ${this.state}, so there is nothing to watch.`; | ||
| 126 | } | ||
| 127 | } | ||
| 128 | |||
| 111 | export class PullRequestListFailedError extends Schema.TaggedError<PullRequestListFailedError>()( | 129 | export class PullRequestListFailedError extends Schema.TaggedError<PullRequestListFailedError>()( |
| 112 | "PullRequestListFailedError", | 130 | "PullRequestListFailedError", |
| 113 | { cause: Schema.Defect() }, | 131 | { cause: Schema.Defect() }, |
| ... | @@ -126,6 +144,8 @@ export const PullRequestToolError = Schema.Union([ | ... | @@ -126,6 +144,8 @@ export const PullRequestToolError = Schema.Union([ |
| 126 | PullRequestLinkFailedError, | 144 | PullRequestLinkFailedError, |
| 127 | PullRequestUnlinkFailedError, | 145 | PullRequestUnlinkFailedError, |
| 128 | PullRequestListFailedError, | 146 | PullRequestListFailedError, |
| 147 | PullRequestWatchFailedError, | ||
| 148 | PullRequestNotOpenError, | ||
| 129 | ]); | 149 | ]); |
| 130 | export type PullRequestToolError = typeof PullRequestToolError.Type; | 150 | export type PullRequestToolError = typeof PullRequestToolError.Type; |
| 131 | 151 | ||
| ... | @@ -154,9 +174,21 @@ export const UnlinkPullRequestResult = Schema.Struct({ | ... | @@ -154,9 +174,21 @@ export const UnlinkPullRequestResult = Schema.Struct({ |
| 154 | }); | 174 | }); |
| 155 | export type UnlinkPullRequestResult = typeof UnlinkPullRequestResult.Type; | 175 | export type UnlinkPullRequestResult = typeof UnlinkPullRequestResult.Type; |
| 156 | 176 | ||
| 177 | export const WatchPullRequestResult = Schema.Struct({ | ||
| 178 | ...PullRequestIdentity, | ||
| 179 | watching: Schema.Boolean.annotate({ | ||
| 180 | description: "Whether T3 Code now watches the pull request for this thread.", | ||
| 181 | }), | ||
| 182 | wasWatching: Schema.Boolean.annotate({ | ||
| 183 | description: "Whether it was already watched before the call.", | ||
| 184 | }), | ||
| 185 | }); | ||
| 186 | export type WatchPullRequestResult = typeof WatchPullRequestResult.Type; | ||
| 187 | |||
| 157 | export const ThreadPullRequestEntry = Schema.Struct({ | 188 | export const ThreadPullRequestEntry = Schema.Struct({ |
| 158 | ...PullRequestIdentity, | 189 | ...PullRequestIdentity, |
| 159 | source: ThreadPullRequestLinkSource, | 190 | source: ThreadPullRequestLinkSource, |
| 191 | watching: Schema.Boolean, | ||
| 160 | state: Schema.NullOr(PullRequestState), | 192 | state: Schema.NullOr(PullRequestState), |
| 161 | title: Schema.NullOr(Schema.String), | 193 | title: Schema.NullOr(Schema.String), |
| 162 | headBranch: Schema.NullOr(Schema.String), | 194 | headBranch: Schema.NullOr(Schema.String), |
| ... | @@ -224,8 +256,38 @@ const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", { | ... | @@ -224,8 +256,38 @@ const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", { |
| 224 | .annotate(Tool.Idempotent, true) | 256 | .annotate(Tool.Idempotent, true) |
| 225 | .annotate(Tool.OpenWorld, false); | 257 | .annotate(Tool.OpenWorld, false); |
| 226 | 258 | ||
| 259 | const WatchPullRequestTool = Tool.make("watch_pull_request", { | ||
| 260 | description: | ||
| 261 | "Have T3 Code watch an open pull request for this thread, linking it first if needed. T3 Code checks it every minute and wakes you with a message when a check fails, the required checks pass, someone else comments or reviews, or the branch starts to conflict with its base. Use this to monitor or babysit a pull request instead of polling, sleeping, or running a watcher. Only comments posted after this call wake you, so handle the existing ones first, then end your turn. A wake is news, not a merge decision: check readiness yourself before merging. Watching ends when the pull request merges or closes, when T3 Code cannot read it for 15 minutes, or when you call unwatch_pull_request.", | ||
| 262 | parameters: PullRequestTargetInput, | ||
| 263 | success: WatchPullRequestResult, | ||
| 264 | failure: PullRequestToolError, | ||
| 265 | dependencies, | ||
| 266 | }) | ||
| 267 | .annotate(Tool.Title, "Watch pull request") | ||
| 268 | .annotate(Tool.Readonly, false) | ||
| 269 | .annotate(Tool.Destructive, false) | ||
| 270 | .annotate(Tool.Idempotent, true) | ||
| 271 | .annotate(Tool.OpenWorld, false); | ||
| 272 | |||
| 273 | const UnwatchPullRequestTool = Tool.make("unwatch_pull_request", { | ||
| 274 | description: | ||
| 275 | "Stop T3 Code from watching a pull request for this thread. The pull request stays linked. Pass the URL, or repository plus number.", | ||
| 276 | parameters: PullRequestTargetInput, | ||
| 277 | success: WatchPullRequestResult, | ||
| 278 | failure: PullRequestToolError, | ||
| 279 | dependencies, | ||
| 280 | }) | ||
| 281 | .annotate(Tool.Title, "Stop watching pull request") | ||
| 282 | .annotate(Tool.Readonly, false) | ||
| 283 | .annotate(Tool.Destructive, false) | ||
| 284 | .annotate(Tool.Idempotent, true) | ||
| 285 | .annotate(Tool.OpenWorld, false); | ||
| 286 | |||
| 227 | export const PullRequestsToolkit = Toolkit.make( | 287 | export const PullRequestsToolkit = Toolkit.make( |
| 228 | LinkPullRequestTool, | 288 | LinkPullRequestTool, |
| 229 | UnlinkPullRequestTool, | 289 | UnlinkPullRequestTool, |
| 230 | ListThreadPullRequestsTool, | 290 | ListThreadPullRequestsTool, |
| 291 | WatchPullRequestTool, | ||
| 292 | UnwatchPullRequestTool, | ||
| 231 | ); | 293 | ); |
apps/server/src/orchestration-v2/Orchestrator.ts+172-22| ... | @@ -19,6 +19,8 @@ import { | ... | @@ -19,6 +19,8 @@ import { |
| 19 | OrchestrationV2Command, | 19 | OrchestrationV2Command, |
| 20 | type OrchestrationV2InternalCommand, | 20 | type OrchestrationV2InternalCommand, |
| 21 | type OrchestrationV2ServerCommand, | 21 | type OrchestrationV2ServerCommand, |
| 22 | type ThreadPullRequestLink, | ||
| 23 | type ThreadPullRequestWatch, | ||
| 22 | type OrchestrationV2AppThread, | 24 | type OrchestrationV2AppThread, |
| 23 | type OrchestrationV2ContextHandoff, | 25 | type OrchestrationV2ContextHandoff, |
| 24 | type OrchestrationV2ContextSourcePoint, | 26 | type OrchestrationV2ContextSourcePoint, |
| ... | @@ -371,6 +373,8 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { | ... | @@ -371,6 +373,8 @@ function commandThreadId(command: OrchestrationV2ServerCommand): ThreadId { |
| 371 | case "thread.pull-request.link": | 373 | case "thread.pull-request.link": |
| 372 | case "thread.pull-request.unlink": | 374 | case "thread.pull-request.unlink": |
| 373 | case "thread.pull-request-link.sync": | 375 | case "thread.pull-request-link.sync": |
| 376 | case "thread.pull-request.watch": | ||
| 377 | case "thread.pull-request-watch.sync": | ||
| 374 | case "thread.pull-request.sync": | 378 | case "thread.pull-request.sync": |
| 375 | case "thread.title.regeneration.complete": | 379 | case "thread.title.regeneration.complete": |
| 376 | case "thread.runtime-mode.set": | 380 | case "thread.runtime-mode.set": |
| ... | @@ -444,6 +448,35 @@ function hasLiveRun(projection: Pick<OrchestrationV2ThreadProjection, "runs">): | ... | @@ -444,6 +448,35 @@ function hasLiveRun(projection: Pick<OrchestrationV2ThreadProjection, "runs">): |
| 444 | ); | 448 | ); |
| 445 | } | 449 | } |
| 446 | 450 | ||
| 451 | /** The link with its watch replaced, or removed when `watch` is undefined. */ | ||
| 452 | function withPullRequestWatch( | ||
| 453 | link: ThreadPullRequestLink, | ||
| 454 | watch: ThreadPullRequestWatch | undefined, | ||
| 455 | ): ThreadPullRequestLink { | ||
| 456 | const { watch: _previous, ...rest } = link; | ||
| 457 | return watch === undefined ? rest : { ...rest, watch }; | ||
| 458 | } | ||
| 459 | |||
| 460 | /** A legacy single-PR link as a link entry. Re-linking a pull request keeps its watch. */ | ||
| 461 | function legacyPullRequestLink( | ||
| 462 | thread: OrchestrationV2AppThread, | ||
| 463 | linked: ThreadLinkedPullRequest, | ||
| 464 | now: DateTime.Utc, | ||
| 465 | ): ThreadPullRequestLink { | ||
| 466 | const key = legacyThreadPullRequestKey(linked); | ||
| 467 | return withPullRequestWatch( | ||
| 468 | { | ||
| 469 | ...key, | ||
| 470 | url: linked.url, | ||
| 471 | source: "manual", | ||
| 472 | linkedAt: DateTime.formatIso(now), | ||
| 473 | snapshot: null, | ||
| 474 | stack: null, | ||
| 475 | }, | ||
| 476 | threadPullRequestsOf(thread).find((link) => threadPullRequestKeysEqual(link, key))?.watch, | ||
| 477 | ); | ||
| 478 | } | ||
| 479 | |||
| 447 | function delegatedCompletionWakeDetail(taskIds: ReadonlyArray<string>): string { | 480 | function delegatedCompletionWakeDetail(taskIds: ReadonlyArray<string>): string { |
| 448 | const taskList = taskIds.join(", "); | 481 | const taskList = taskIds.join(", "); |
| 449 | return taskIds.length === 1 | 482 | return taskIds.length === 1 |
| ... | @@ -2188,9 +2221,66 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | ... | @@ -2188,9 +2221,66 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 2188 | }); | 2221 | }); |
| 2189 | }); | 2222 | }); |
| 2190 | 2223 | ||
| 2224 | // Checked under the thread lock: the watch or the thread can change while the host is read. | ||
| 2225 | // The watch is recorded first so the wake's own thread events carry it. | ||
| 2226 | const dispatchPullRequestWatchSync = Effect.fn("orchestrationV2.dispatch.pullRequestWatchSync")( | ||
| 2227 | function* ( | ||
| 2228 | command: Extract< | ||
| 2229 | OrchestrationV2ServerCommand, | ||
| 2230 | { readonly type: "thread.pull-request-watch.sync" } | ||
| 2231 | >, | ||
| 2232 | events: Ref.Ref<Array<OrchestrationV2DomainEvent>>, | ||
| 2233 | effects: Ref.Ref<Array<PendingOrchestrationEffectV2>>, | ||
| 2234 | ) { | ||
| 2235 | const thread = yield* projectionStore | ||
| 2236 | .getThread(command.threadId) | ||
| 2237 | .pipe( | ||
| 2238 | Effect.mapError( | ||
| 2239 | (cause) => new OrchestratorProjectionError({ threadId: command.threadId, cause }), | ||
| 2240 | ), | ||
| 2241 | ); | ||
| 2242 | const key = normalizeThreadPullRequestKey(command); | ||
| 2243 | const link = threadPullRequestsOf(thread).find( | ||
| 2244 | (candidate) => | ||
| 2245 | candidate.source !== "stack-dismissed" && threadPullRequestKeysEqual(candidate, key), | ||
| 2246 | ); | ||
| 2247 | // Same rule as a direct message.dispatch: a provider-native subagent takes no messages. | ||
| 2248 | const inactive = | ||
| 2249 | thread.archivedAt !== null || | ||
| 2250 | thread.settledOverride === "settled" || | ||
| 2251 | thread.settledAt !== null || | ||
| 2252 | isProviderNativeSubagentThread(thread); | ||
| 2253 | if (link?.watch?.startedAt !== command.startedAt || (command.wake && inactive)) { | ||
| 2254 | return yield* new OrchestratorDispatchError({ | ||
| 2255 | commandId: command.commandId, | ||
| 2256 | commandType: command.type, | ||
| 2257 | cause: "The pull request watch ended or its thread settled while it was read.", | ||
| 2258 | }); | ||
| 2259 | } | ||
| 2260 | yield* dispatchThreadMutation(command, events, effects); | ||
| 2261 | if (command.wake === undefined) return; | ||
| 2262 | yield* dispatchMessage( | ||
| 2263 | { | ||
| 2264 | type: "message.dispatch", | ||
| 2265 | commandId: command.commandId, | ||
| 2266 | threadId: command.threadId, | ||
| 2267 | messageId: command.wake.messageId, | ||
| 2268 | text: command.wake.text, | ||
| 2269 | notification: command.wake.notification, | ||
| 2270 | attachments: [], | ||
| 2271 | dispatchMode: { type: "queue_after_active" }, | ||
| 2272 | createdBy: "agent", | ||
| 2273 | creationSource: "server", | ||
| 2274 | }, | ||
| 2275 | events, | ||
| 2276 | effects, | ||
| 2277 | ); | ||
| 2278 | }, | ||
| 2279 | ); | ||
| 2280 | |||
| 2191 | const dispatchThreadMutation = Effect.fn("orchestrationV2.dispatch.threadMutation")(function* ( | 2281 | const dispatchThreadMutation = Effect.fn("orchestrationV2.dispatch.threadMutation")(function* ( |
| 2192 | command: Extract< | 2282 | command: Extract< |
| 2193 | OrchestrationV2Command, | 2283 | OrchestrationV2ServerCommand, |
| 2194 | { | 2284 | { |
| 2195 | readonly type: | 2285 | readonly type: |
| 2196 | | "thread.archive" | 2286 | | "thread.archive" |
| ... | @@ -2209,6 +2299,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | ... | @@ -2209,6 +2299,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 2209 | | "thread.pull-request.link" | 2299 | | "thread.pull-request.link" |
| 2210 | | "thread.pull-request.unlink" | 2300 | | "thread.pull-request.unlink" |
| 2211 | | "thread.pull-request-link.sync" | 2301 | | "thread.pull-request-link.sync" |
| 2302 | | "thread.pull-request.watch" | ||
| 2303 | | "thread.pull-request-watch.sync" | ||
| 2212 | | "thread.pull-request.sync" | 2304 | | "thread.pull-request.sync" |
| 2213 | | "thread.title.regeneration.complete" | 2305 | | "thread.title.regeneration.complete" |
| 2214 | | "thread.runtime-mode.set" | 2306 | | "thread.runtime-mode.set" |
| ... | @@ -2236,6 +2328,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | ... | @@ -2236,6 +2328,16 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 2236 | cause: `Thread ${command.threadId} is deleted.`, | 2328 | cause: `Thread ${command.threadId} is deleted.`, |
| 2237 | }); | 2329 | }); |
| 2238 | } | 2330 | } |
| 2331 | if ( | ||
| 2332 | command.type === "thread.pull-request.watch" && | ||
| 2333 | command.watching && | ||
| 2334 | isProviderNativeSubagentThread(thread) | ||
| 2335 | ) { | ||
| 2336 | return yield* new OrchestratorSubagentThreadReadOnlyError({ | ||
| 2337 | commandId: command.commandId, | ||
| 2338 | threadId: command.threadId, | ||
| 2339 | }); | ||
| 2340 | } | ||
| 2239 | if ( | 2341 | if ( |
| 2240 | command.type === "thread.metadata.update" && | 2342 | command.type === "thread.metadata.update" && |
| 2241 | command.expectedWorktreePath !== undefined && | 2343 | command.expectedWorktreePath !== undefined && |
| ... | @@ -2719,16 +2821,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | ... | @@ -2719,16 +2821,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 2719 | ), | 2821 | ), |
| 2720 | ), | 2822 | ), |
| 2721 | ...(command.linkedPullRequest | 2823 | ...(command.linkedPullRequest |
| 2722 | ? [ | 2824 | ? [legacyPullRequestLink(thread, command.linkedPullRequest, now)] |
| 2723 | { | ||
| 2724 | ...legacyThreadPullRequestKey(command.linkedPullRequest), | ||
| 2725 | url: command.linkedPullRequest.url, | ||
| 2726 | source: "manual" as const, | ||
| 2727 | linkedAt: DateTime.formatIso(now), | ||
| 2728 | snapshot: null, | ||
| 2729 | stack: null, | ||
| 2730 | }, | ||
| 2731 | ] | ||
| 2732 | : []), | 2825 | : []), |
| 2733 | ], | 2826 | ], |
| 2734 | }), | 2827 | }), |
| ... | @@ -2801,7 +2894,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | ... | @@ -2801,7 +2894,12 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 2801 | ); | 2894 | ); |
| 2802 | pullRequests = belongsToStack | 2895 | pullRequests = belongsToStack |
| 2803 | ? links.map((link) => | 2896 | ? links.map((link) => |
| 2804 | link === existing ? { ...link, source: "stack-dismissed" as const } : link, | 2897 | link === existing |
| 2898 | ? { | ||
| 2899 | ...withPullRequestWatch(link, undefined), | ||
| 2900 | source: "stack-dismissed" as const, | ||
| 2901 | } | ||
| 2902 | : link, | ||
| 2805 | ) | 2903 | ) |
| 2806 | : links.filter((link) => link !== existing); | 2904 | : links.filter((link) => link !== existing); |
| 2807 | } else { | 2905 | } else { |
| ... | @@ -2830,6 +2928,61 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | ... | @@ -2830,6 +2928,61 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 2830 | updatedAt: command.type === "thread.pull-request-link.sync" ? thread.updatedAt : now, | 2928 | updatedAt: command.type === "thread.pull-request-link.sync" ? thread.updatedAt : now, |
| 2831 | }; | 2929 | }; |
| 2832 | } | 2930 | } |
| 2931 | case "thread.pull-request.watch": | ||
| 2932 | case "thread.pull-request-watch.sync": { | ||
| 2933 | const key = normalizeThreadPullRequestKey(command); | ||
| 2934 | const startedAt = DateTime.formatIso(now); | ||
| 2935 | const linked = threadPullRequestsOf(thread); | ||
| 2936 | const visible = (link: ThreadPullRequestLink) => | ||
| 2937 | link.source !== "stack-dismissed" && threadPullRequestKeysEqual(link, key); | ||
| 2938 | // A watch started on an unlinked (or dismissed) pull request links it in the same step. | ||
| 2939 | const links = | ||
| 2940 | command.type === "thread.pull-request.watch" && | ||
| 2941 | command.watching && | ||
| 2942 | command.link !== undefined && | ||
| 2943 | !linked.some(visible) | ||
| 2944 | ? [ | ||
| 2945 | ...linked.filter((link) => !threadPullRequestKeysEqual(link, key)), | ||
| 2946 | { | ||
| 2947 | ...key, | ||
| 2948 | url: command.link.url, | ||
| 2949 | source: command.link.source, | ||
| 2950 | linkedAt: startedAt, | ||
| 2951 | snapshot: null, | ||
| 2952 | stack: null, | ||
| 2953 | }, | ||
| 2954 | ] | ||
| 2955 | : linked; | ||
| 2956 | const existing = links.find(visible); | ||
| 2957 | if (existing === undefined) return thread; | ||
| 2958 | const watch = | ||
| 2959 | command.type === "thread.pull-request-watch.sync" | ||
| 2960 | ? // Progress read before a stop or restart must not bring the old watch back. | ||
| 2961 | existing.watch?.startedAt === command.startedAt | ||
| 2962 | ? (command.watch ?? undefined) | ||
| 2963 | : existing.watch | ||
| 2964 | : !command.watching | ||
| 2965 | ? undefined | ||
| 2966 | : (existing.watch ?? { | ||
| 2967 | startedAt, | ||
| 2968 | headSha: null, | ||
| 2969 | failedChecks: [], | ||
| 2970 | passed: false, | ||
| 2971 | remarksThrough: startedAt, | ||
| 2972 | remarkIds: [], | ||
| 2973 | conflicting: false, | ||
| 2974 | wakes: 0, | ||
| 2975 | }); | ||
| 2976 | if (watch === existing.watch && links === linked) return thread; | ||
| 2977 | return { | ||
| 2978 | ...thread, | ||
| 2979 | pullRequests: links.map((link) => | ||
| 2980 | link === existing ? withPullRequestWatch(link, watch) : link, | ||
| 2981 | ), | ||
| 2982 | // A user or agent starting or stopping a watch is activity; recorded progress is not. | ||
| 2983 | updatedAt: command.type === "thread.pull-request.watch" ? now : thread.updatedAt, | ||
| 2984 | }; | ||
| 2985 | } | ||
| 2833 | case "thread.pull-request.sync": | 2986 | case "thread.pull-request.sync": |
| 2834 | return { | 2987 | return { |
| 2835 | ...thread, | 2988 | ...thread, |
| ... | @@ -2857,16 +3010,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | ... | @@ -2857,16 +3010,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 2857 | ), | 3010 | ), |
| 2858 | ), | 3011 | ), |
| 2859 | ...(command.linkedPullRequest | 3012 | ...(command.linkedPullRequest |
| 2860 | ? [ | 3013 | ? [legacyPullRequestLink(thread, command.linkedPullRequest, now)] |
| 2861 | { | ||
| 2862 | ...legacyThreadPullRequestKey(command.linkedPullRequest), | ||
| 2863 | url: command.linkedPullRequest.url, | ||
| 2864 | source: "manual" as const, | ||
| 2865 | linkedAt: DateTime.formatIso(now), | ||
| 2866 | snapshot: null, | ||
| 2867 | stack: null, | ||
| 2868 | }, | ||
| 2869 | ] | ||
| 2870 | : []), | 3014 | : []), |
| 2871 | ], | 3015 | ], |
| 2872 | }), | 3016 | }), |
| ... | @@ -2927,6 +3071,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | ... | @@ -2927,6 +3071,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 2927 | case "thread.pull-request.link": | 3071 | case "thread.pull-request.link": |
| 2928 | case "thread.pull-request.unlink": | 3072 | case "thread.pull-request.unlink": |
| 2929 | case "thread.pull-request-link.sync": | 3073 | case "thread.pull-request-link.sync": |
| 3074 | case "thread.pull-request.watch": | ||
| 3075 | case "thread.pull-request-watch.sync": | ||
| 2930 | case "thread.pull-request.sync": | 3076 | case "thread.pull-request.sync": |
| 2931 | return "thread.pull-request-synced" as const; | 3077 | return "thread.pull-request-synced" as const; |
| 2932 | case "thread.runtime-mode.set": | 3078 | case "thread.runtime-mode.set": |
| ... | @@ -9130,6 +9276,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | ... | @@ -9130,6 +9276,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 9130 | case "thread.pull-request.link": | 9276 | case "thread.pull-request.link": |
| 9131 | case "thread.pull-request.unlink": | 9277 | case "thread.pull-request.unlink": |
| 9132 | case "thread.pull-request-link.sync": | 9278 | case "thread.pull-request-link.sync": |
| 9279 | case "thread.pull-request.watch": | ||
| 9133 | case "thread.pull-request.sync": | 9280 | case "thread.pull-request.sync": |
| 9134 | case "thread.title.regeneration.complete": | 9281 | case "thread.title.regeneration.complete": |
| 9135 | case "thread.runtime-mode.set": | 9282 | case "thread.runtime-mode.set": |
| ... | @@ -9138,6 +9285,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio | ... | @@ -9138,6 +9285,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 9138 | case "provider.switch": | 9285 | case "provider.switch": |
| 9139 | yield* dispatchThreadMutation(command, events, effects); | 9286 | yield* dispatchThreadMutation(command, events, effects); |
| 9140 | break; | 9287 | break; |
| 9288 | case "thread.pull-request-watch.sync": | ||
| 9289 | yield* dispatchPullRequestWatchSync(command, events, effects); | ||
| 9290 | break; | ||
| 9141 | case "provider-session.detach": | 9291 | case "provider-session.detach": |
| 9142 | yield* dispatchProviderSessionDetach(command, events, effects); | 9292 | yield* dispatchProviderSessionDetach(command, events, effects); |
| 9143 | break; | 9293 | break; |
apps/server/src/orchestration-v2/PullRequestWatchReactor.ts created+219| ... | @@ -0,0 +1,219 @@ | ||
| 1 | import { | ||
| 2 | CommandId, | ||
| 3 | MessageId, | ||
| 4 | type OrchestrationV2Notification, | ||
| 5 | type ThreadPullRequestLink, | ||
| 6 | type ThreadPullRequestWatch, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | import { | ||
| 9 | normalizeThreadPullRequestKey, | ||
| 10 | threadPullRequestKeyOf, | ||
| 11 | visibleThreadPullRequests, | ||
| 12 | } from "@t3tools/shared/threadPullRequests"; | ||
| 13 | import * as Cause from "effect/Cause"; | ||
| 14 | import * as Context from "effect/Context"; | ||
| 15 | import * as Crypto from "effect/Crypto"; | ||
| 16 | import * as Effect from "effect/Effect"; | ||
| 17 | import * as Exit from "effect/Exit"; | ||
| 18 | import * as Layer from "effect/Layer"; | ||
| 19 | import * as Schedule from "effect/Schedule"; | ||
| 20 | import type * as Scope from "effect/Scope"; | ||
| 21 | |||
| 22 | import * as PullRequestService from "../pullRequest/PullRequestService.ts"; | ||
| 23 | import { forkParked } from "../serverActivation.ts"; | ||
| 24 | import * as Orchestrator from "./Orchestrator.ts"; | ||
| 25 | import * as ProjectionStore from "./ProjectionStore.ts"; | ||
| 26 | import { evaluatePullRequestWatch, pullRequestWatchMessage } from "./pullRequestWatch.ts"; | ||
| 27 | |||
| 28 | /** Passes in a row that could not read a pull request before its watch ends (one a minute). */ | ||
| 29 | const READ_FAILURE_LIMIT = 15; | ||
| 30 | |||
| 31 | const logFailure = | ||
| 32 | (message: string, fields: Record<string, unknown>) => | ||
| 33 | <E>(cause: Cause.Cause<E>): Effect.Effect<void> => | ||
| 34 | Cause.hasInterruptsOnly(cause) | ||
| 35 | ? Effect.interrupt | ||
| 36 | : Effect.logWarning(message, { ...fields, cause }); | ||
| 37 | |||
| 38 | interface WatchTarget { | ||
| 39 | readonly thread: ProjectionStore.ProjectionThreadPullRequests; | ||
| 40 | readonly link: ThreadPullRequestLink; | ||
| 41 | readonly watch: ThreadPullRequestWatch; | ||
| 42 | } | ||
| 43 | |||
| 44 | const failureKey = ({ thread, link, watch }: WatchTarget) => | ||
| 45 | `${thread.id} ${threadPullRequestKeyOf(link)} ${watch.startedAt}`; | ||
| 46 | |||
| 47 | function watchesEqual(left: ThreadPullRequestWatch, right: ThreadPullRequestWatch): boolean { | ||
| 48 | return ( | ||
| 49 | left.startedAt === right.startedAt && | ||
| 50 | left.headSha === right.headSha && | ||
| 51 | left.failedChecks.join("\n") === right.failedChecks.join("\n") && | ||
| 52 | left.passed === right.passed && | ||
| 53 | left.remarksThrough === right.remarksThrough && | ||
| 54 | left.remarkIds.join("\n") === right.remarkIds.join("\n") && | ||
| 55 | left.conflicting === right.conflicting && | ||
| 56 | left.wakes === right.wakes | ||
| 57 | ); | ||
| 58 | } | ||
| 59 | |||
| 60 | /** | ||
| 61 | * Wakes a thread's agent when a pull request it watches (`watch_pull_request`) needs a look: | ||
| 62 | * checks finished on the head commit, someone else commented, or the branch started to | ||
| 63 | * conflict. One pass a minute reads each watched pull request; settled threads wait until | ||
| 64 | * they are active again, and a merged or closed pull request ends its watch. | ||
| 65 | */ | ||
| 66 | export class PullRequestWatchReactor extends Context.Service< | ||
| 67 | PullRequestWatchReactor, | ||
| 68 | { | ||
| 69 | readonly start: () => Effect.Effect<void, never, Scope.Scope>; | ||
| 70 | /** One pass over every watched pull request. */ | ||
| 71 | readonly sweep: Effect.Effect<void>; | ||
| 72 | } | ||
| 73 | >()("t3/orchestration-v2/PullRequestWatchReactor") {} | ||
| 74 | |||
| 75 | /** @public Service construction is part of the canonical Effect module API. */ | ||
| 76 | export const make = Effect.gen(function* () { | ||
| 77 | const engine = yield* Orchestrator.OrchestratorV2; | ||
| 78 | const projections = yield* ProjectionStore.ProjectionStoreV2; | ||
| 79 | const pullRequests = yield* PullRequestService.PullRequestService; | ||
| 80 | const crypto = yield* Crypto.Crypto; | ||
| 81 | |||
| 82 | // Passes in a row that failed, per watch. Kept in memory: a restart only delays the stop. | ||
| 83 | const readFailures = new Map<string, number>(); | ||
| 84 | |||
| 85 | // Host-level identity, with the repository as linked, the way pull request sync reads it. | ||
| 86 | const identityOf = (link: ThreadPullRequestLink) => ({ | ||
| 87 | host: normalizeThreadPullRequestKey(link).host, | ||
| 88 | repository: link.repository, | ||
| 89 | number: link.number, | ||
| 90 | }); | ||
| 91 | |||
| 92 | /** | ||
| 93 | * Records what a pass saw, and wakes the agent with it. The orchestrator applies this only | ||
| 94 | * while the same watch is on, so a stop or restart that lands during the host read wins. | ||
| 95 | */ | ||
| 96 | const record = ( | ||
| 97 | target: WatchTarget, | ||
| 98 | next: ThreadPullRequestWatch | null, | ||
| 99 | wake?: { readonly text: string; readonly notification: OrchestrationV2Notification }, | ||
| 100 | ) => | ||
| 101 | Effect.gen(function* () { | ||
| 102 | const uuid = yield* crypto.randomUUIDv4; | ||
| 103 | yield* engine.dispatch({ | ||
| 104 | type: "thread.pull-request-watch.sync", | ||
| 105 | commandId: CommandId.make(`server:pr-watch:${target.thread.id}:${uuid}`), | ||
| 106 | threadId: target.thread.id, | ||
| 107 | ...identityOf(target.link), | ||
| 108 | startedAt: target.watch.startedAt, | ||
| 109 | watch: next, | ||
| 110 | ...(wake === undefined | ||
| 111 | ? {} | ||
| 112 | : { wake: { ...wake, messageId: MessageId.make(`message:pr-watch:${uuid}`) } }), | ||
| 113 | }); | ||
| 114 | }); | ||
| 115 | |||
| 116 | // A watch that cannot read its pull request ends with a wake saying so, rather than showing | ||
| 117 | // "Watching" while it learns nothing. | ||
| 118 | const giveUp = (target: WatchTarget) => | ||
| 119 | record(target, null, { | ||
| 120 | text: `T3 Code stopped watching pull request #${target.link.number} (${target.link.url}) because it could not read it from the host for ${READ_FAILURE_LIMIT} minutes. Check it yourself, and call watch_pull_request to watch it again.`, | ||
| 121 | notification: { | ||
| 122 | source: { kind: "monitor" }, | ||
| 123 | outcome: "failed", | ||
| 124 | summary: `#${target.link.number}: stopped watching, could not read it`, | ||
| 125 | }, | ||
| 126 | }).pipe(Effect.catch(() => record(target, null))); | ||
| 127 | |||
| 128 | const check = Effect.fn("PullRequestWatchReactor.check")(function* (target: WatchTarget) { | ||
| 129 | const { thread, link, watch } = target; | ||
| 130 | const pullRequest = identityOf(link); | ||
| 131 | // A merged pull request cannot reopen, so its watch ends without a host read, even on a | ||
| 132 | // settled thread. A closed one can, so the host decides below. | ||
| 133 | if (link.snapshot?.state === "merged") return yield* record(target, null); | ||
| 134 | if (thread.settledOverride === "settled" || thread.settledAt !== null) return; | ||
| 135 | |||
| 136 | const reference = { projectId: thread.projectId, ...pullRequest }; | ||
| 137 | const read = yield* Effect.exit( | ||
| 138 | Effect.all( | ||
| 139 | [ | ||
| 140 | pullRequests.detail({ ...reference, allowStale: false }), | ||
| 141 | pullRequests.activity(reference), | ||
| 142 | ], | ||
| 143 | { concurrency: 2 }, | ||
| 144 | ), | ||
| 145 | ); | ||
| 146 | // Only host reads count towards giving up; a refused wake is not the host's fault. | ||
| 147 | const key = failureKey(target); | ||
| 148 | if (Exit.isFailure(read)) { | ||
| 149 | if (Cause.hasInterruptsOnly(read.cause)) return yield* Effect.failCause(read.cause); | ||
| 150 | const failures = (readFailures.get(key) ?? 0) + 1; | ||
| 151 | readFailures.set(key, failures); | ||
| 152 | // The count stays until the stop lands, so a failed stop is tried again next pass. | ||
| 153 | if (failures >= READ_FAILURE_LIMIT) { | ||
| 154 | yield* giveUp(target); | ||
| 155 | readFailures.delete(key); | ||
| 156 | } | ||
| 157 | return yield* Effect.failCause(read.cause); | ||
| 158 | } | ||
| 159 | readFailures.delete(key); | ||
| 160 | const [detail, activity] = read.value; | ||
| 161 | if (detail.state !== "open") return yield* record(target, null); | ||
| 162 | |||
| 163 | // A degraded read (GitHub's review thread query failed) is truncated with no long thread to | ||
| 164 | // explain it, and would skip review comments, so remarks wait for a later pass. Replies past | ||
| 165 | // the first ten of a long review thread are not read. | ||
| 166 | const degraded = | ||
| 167 | activity.commentsTruncated && | ||
| 168 | !activity.reviewThreads.some((reviewThread) => reviewThread.nextCommentsCursor !== undefined); | ||
| 169 | const report = evaluatePullRequestWatch(watch, detail, degraded ? null : activity.comments); | ||
| 170 | if (report.changes.length > 0) { | ||
| 171 | return yield* record( | ||
| 172 | target, | ||
| 173 | report.exhausted ? null : report.next, | ||
| 174 | pullRequestWatchMessage({ | ||
| 175 | number: link.number, | ||
| 176 | url: link.url, | ||
| 177 | baseBranch: detail.baseBranch, | ||
| 178 | headSha: report.next.headSha, | ||
| 179 | report, | ||
| 180 | }), | ||
| 181 | ); | ||
| 182 | } | ||
| 183 | if (!watchesEqual(report.next, watch)) yield* record(target, report.next); | ||
| 184 | }); | ||
| 185 | |||
| 186 | const sweep = Effect.gen(function* () { | ||
| 187 | const threads = yield* projections.getThreadsWithPullRequests(); | ||
| 188 | const targets = threads.flatMap((thread) => | ||
| 189 | visibleThreadPullRequests(thread.pullRequests ?? []).flatMap((link) => | ||
| 190 | link.watch === undefined ? [] : [{ thread, link, watch: link.watch }], | ||
| 191 | ), | ||
| 192 | ); | ||
| 193 | const keys = new Set(targets.map(failureKey)); | ||
| 194 | for (const key of readFailures.keys()) if (!keys.has(key)) readFailures.delete(key); | ||
| 195 | yield* Effect.forEach( | ||
| 196 | targets, | ||
| 197 | (target) => | ||
| 198 | check(target).pipe( | ||
| 199 | Effect.catchCause( | ||
| 200 | logFailure("pull request watch check failed", { | ||
| 201 | threadId: target.thread.id, | ||
| 202 | pullRequest: threadPullRequestKeyOf(target.link), | ||
| 203 | }), | ||
| 204 | ), | ||
| 205 | ), | ||
| 206 | { concurrency: 4, discard: true }, | ||
| 207 | ); | ||
| 208 | }).pipe( | ||
| 209 | Effect.catchCause(logFailure("pull request watch sweep failed", {})), | ||
| 210 | Effect.withSpan("PullRequestWatchReactor.sweep"), | ||
| 211 | ); | ||
| 212 | |||
| 213 | const start: PullRequestWatchReactor["Service"]["start"] = () => | ||
| 214 | forkParked(sweep.pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.asVoid)); | ||
| 215 | |||
| 216 | return { start, sweep } satisfies PullRequestWatchReactor["Service"]; | ||
| 217 | }); | ||
| 218 | |||
| 219 | export const layer = Layer.effect(PullRequestWatchReactor, make); | ||
apps/server/src/orchestration-v2/pullRequestWatch.test.ts created+217| ... | @@ -0,0 +1,217 @@ | ||
| 1 | import type { | ||
| 2 | PullRequestCheck, | ||
| 3 | PullRequestComment, | ||
| 4 | PullRequestDetail, | ||
| 5 | ThreadPullRequestWatch, | ||
| 6 | } from "@t3tools/contracts"; | ||
| 7 | import { assert, describe, it } from "@effect/vitest"; | ||
| 8 | |||
| 9 | import { | ||
| 10 | PULL_REQUEST_WATCH_WAKE_LIMIT, | ||
| 11 | evaluatePullRequestWatch, | ||
| 12 | pullRequestWatchMessage, | ||
| 13 | } from "./pullRequestWatch.ts"; | ||
| 14 | |||
| 15 | const STARTED = "2026-10-02T12:00:00.000Z"; | ||
| 16 | |||
| 17 | const watch = (overrides: Partial<ThreadPullRequestWatch> = {}): ThreadPullRequestWatch => ({ | ||
| 18 | startedAt: STARTED, | ||
| 19 | headSha: null, | ||
| 20 | failedChecks: [], | ||
| 21 | passed: false, | ||
| 22 | remarksThrough: STARTED, | ||
| 23 | remarkIds: [], | ||
| 24 | conflicting: false, | ||
| 25 | wakes: 0, | ||
| 26 | ...overrides, | ||
| 27 | }); | ||
| 28 | |||
| 29 | const check = (name: string, status: PullRequestCheck["status"]): PullRequestCheck => ({ | ||
| 30 | name, | ||
| 31 | status, | ||
| 32 | description: null, | ||
| 33 | url: `https://ci.example/${name}`, | ||
| 34 | }); | ||
| 35 | |||
| 36 | type Detail = Parameters<typeof evaluatePullRequestWatch>[1]; | ||
| 37 | |||
| 38 | const detail = (overrides: Partial<Detail> = {}): Detail => ({ | ||
| 39 | headSha: "aaaaaaaaaa", | ||
| 40 | checks: [check("lint", "success"), check("test", "pending")], | ||
| 41 | mergeability: "mergeable", | ||
| 42 | viewer: "agent-user", | ||
| 43 | author: { login: "agent-user", name: null, avatarUrl: null }, | ||
| 44 | ...overrides, | ||
| 45 | }); | ||
| 46 | |||
| 47 | const remark = ( | ||
| 48 | login: string, | ||
| 49 | createdAt: string, | ||
| 50 | body = "Please rename this.", | ||
| 51 | ): PullRequestComment => ({ | ||
| 52 | id: `${login}-${createdAt}`, | ||
| 53 | kind: "review-comment", | ||
| 54 | author: { login, name: null, avatarUrl: null }, | ||
| 55 | body, | ||
| 56 | createdAt, | ||
| 57 | url: `https://github.com/o/r/pull/1#${login}`, | ||
| 58 | path: "src/index.ts", | ||
| 59 | reviewState: null, | ||
| 60 | }); | ||
| 61 | |||
| 62 | const noRemarks: ReadonlyArray<PullRequestComment> = []; | ||
| 63 | |||
| 64 | describe("evaluatePullRequestWatch", () => { | ||
| 65 | it("reports each failure at once, even while another check never finishes", () => { | ||
| 66 | const bot = check("CodeRabbit", "pending"); | ||
| 67 | const first = detail({ checks: [check("lint", "failure"), check("test", "pending"), bot] }); | ||
| 68 | const lint = evaluatePullRequestWatch(watch(), first, noRemarks); | ||
| 69 | assert.deepEqual(lint.changes, [{ kind: "checks-failed", failed: [check("lint", "failure")] }]); | ||
| 70 | assert.deepEqual(evaluatePullRequestWatch(lint.next, first, noRemarks).changes, []); | ||
| 71 | |||
| 72 | // A different job failing later is news of its own. | ||
| 73 | const second = detail({ checks: [check("lint", "failure"), check("test", "failure"), bot] }); | ||
| 74 | const test = evaluatePullRequestWatch(lint.next, second, noRemarks); | ||
| 75 | assert.deepEqual(test.changes, [{ kind: "checks-failed", failed: [check("test", "failure")] }]); | ||
| 76 | |||
| 77 | // A rerun leaves the list while it runs, so failing again is reported again. | ||
| 78 | const rerun = evaluatePullRequestWatch(test.next, first, noRemarks); | ||
| 79 | assert.equal(evaluatePullRequestWatch(rerun.next, second, noRemarks).changes.length, 1); | ||
| 80 | |||
| 81 | // A push reports its failures, even ones that failed between two passes. | ||
| 82 | const pushed = detail({ ...second, headSha: "bbbbbbbbbb" }); | ||
| 83 | assert.equal(evaluatePullRequestWatch(test.next, pushed, noRemarks).changes.length, 1); | ||
| 84 | }); | ||
| 85 | |||
| 86 | it("reports passed once the required checks pass, whatever the others do", () => { | ||
| 87 | const required = (name: string, status: PullRequestCheck["status"]) => ({ | ||
| 88 | ...check(name, status), | ||
| 89 | required: true, | ||
| 90 | }); | ||
| 91 | const green = detail({ | ||
| 92 | checks: [required("test", "success"), required("lint", "success"), check("bot", "pending")], | ||
| 93 | }); | ||
| 94 | const passed = evaluatePullRequestWatch(watch(), green, noRemarks); | ||
| 95 | assert.deepEqual(passed.changes, [{ kind: "checks-passed", count: 2, required: true }]); | ||
| 96 | assert.deepEqual(evaluatePullRequestWatch(passed.next, green, noRemarks).changes, []); | ||
| 97 | |||
| 98 | // Where nothing is marked required, every check has to pass. | ||
| 99 | const plain = detail({ checks: [check("test", "success"), check("bot", "pending")] }); | ||
| 100 | assert.deepEqual(evaluatePullRequestWatch(watch(), plain, noRemarks).changes, []); | ||
| 101 | }); | ||
| 102 | |||
| 103 | it("keeps remarks for a later pass when the conversation was not read whole", () => { | ||
| 104 | const comments = [remark("reviewer", "2026-10-02T12:06:00Z")]; | ||
| 105 | const partial = evaluatePullRequestWatch(watch(), detail(), null); | ||
| 106 | assert.deepEqual(partial.changes, []); | ||
| 107 | assert.equal( | ||
| 108 | evaluatePullRequestWatch(partial.next, detail(), comments).changes[0]?.kind, | ||
| 109 | "remarks", | ||
| 110 | ); | ||
| 111 | }); | ||
| 112 | |||
| 113 | it("reports a remark that shows up late with the same time as a reported one", () => { | ||
| 114 | const first = remark("reviewer", "2026-10-02T12:06:00Z"); | ||
| 115 | const late = { ...remark("bot", "2026-10-02T12:06:00Z"), id: "late" }; | ||
| 116 | const reported = evaluatePullRequestWatch(watch(), detail(), [first]); | ||
| 117 | const again = evaluatePullRequestWatch(reported.next, detail(), [first, late]); | ||
| 118 | assert.deepEqual(again.changes, [{ kind: "remarks", remarks: [late] }]); | ||
| 119 | assert.deepEqual(again.next.remarkIds, [first.id, "late"]); | ||
| 120 | }); | ||
| 121 | |||
| 122 | it("does not treat a failed check read as a rerun", () => { | ||
| 123 | const failed = detail({ checks: [check("lint", "failure")] }); | ||
| 124 | const reported = evaluatePullRequestWatch(watch(), failed, noRemarks); | ||
| 125 | assert.equal(reported.changes.length, 1); | ||
| 126 | const unreadable = evaluatePullRequestWatch(reported.next, detail({ checks: [] }), noRemarks); | ||
| 127 | assert.deepEqual(evaluatePullRequestWatch(unreadable.next, failed, noRemarks).changes, []); | ||
| 128 | }); | ||
| 129 | |||
| 130 | it("wakes for the pull request's author when the agent is someone else", () => { | ||
| 131 | const contributor = detail({ author: { login: "contributor", name: null, avatarUrl: null } }); | ||
| 132 | const reply = remark("contributor", "2026-10-02T12:06:00Z"); | ||
| 133 | assert.deepEqual(evaluatePullRequestWatch(watch(), contributor, [reply]).changes, [ | ||
| 134 | { kind: "remarks", remarks: [reply] }, | ||
| 135 | ]); | ||
| 136 | // Without a viewer, the author is taken to be the agent. | ||
| 137 | const noViewer = detail({ viewer: undefined, author: contributor.author }); | ||
| 138 | assert.deepEqual(evaluatePullRequestWatch(watch(), noViewer, [reply]).changes, []); | ||
| 139 | }); | ||
| 140 | |||
| 141 | it("reports remarks from others once and never the agent's own", () => { | ||
| 142 | const comments = [ | ||
| 143 | remark("agent-user", "2026-10-02T12:05:00Z", "Fixed in the latest push."), | ||
| 144 | remark("macroscope-app[bot]", "2026-10-02T12:06:00Z"), | ||
| 145 | remark("reviewer", "2026-10-02T11:00:00Z", "Older than the watch."), | ||
| 146 | ]; | ||
| 147 | const report = evaluatePullRequestWatch(watch(), detail(), comments); | ||
| 148 | assert.deepEqual(report.changes, [{ kind: "remarks", remarks: [comments[1]!] }]); | ||
| 149 | assert.equal(report.next.remarksThrough, "2026-10-02T12:06:00Z"); | ||
| 150 | assert.deepEqual(evaluatePullRequestWatch(report.next, detail(), comments).changes, []); | ||
| 151 | }); | ||
| 152 | |||
| 153 | it("reports a conflict once, until the branch is clean again", () => { | ||
| 154 | const conflicting = detail({ mergeability: "conflicting" }); | ||
| 155 | const first = evaluatePullRequestWatch(watch(), conflicting, noRemarks); | ||
| 156 | assert.deepEqual(first.changes, [{ kind: "conflicting" }]); | ||
| 157 | // GitHub answers "unknown" while it recomputes after a push; that is not a resolution. | ||
| 158 | const recomputing = evaluatePullRequestWatch( | ||
| 159 | first.next, | ||
| 160 | detail({ mergeability: "unknown" }), | ||
| 161 | noRemarks, | ||
| 162 | ); | ||
| 163 | assert.deepEqual( | ||
| 164 | evaluatePullRequestWatch(recomputing.next, conflicting, noRemarks).changes, | ||
| 165 | [], | ||
| 166 | ); | ||
| 167 | const clean = evaluatePullRequestWatch(first.next, detail(), noRemarks); | ||
| 168 | assert.deepEqual(evaluatePullRequestWatch(clean.next, conflicting, noRemarks).changes, [ | ||
| 169 | { kind: "conflicting" }, | ||
| 170 | ]); | ||
| 171 | }); | ||
| 172 | |||
| 173 | it("does not spend the comment wake limit on check results", () => { | ||
| 174 | const tired = watch({ headSha: "aaaaaaaaaa", wakes: PULL_REQUEST_WATCH_WAKE_LIMIT - 1 }); | ||
| 175 | const result = evaluatePullRequestWatch( | ||
| 176 | tired, | ||
| 177 | detail({ checks: [check("lint", "failure")] }), | ||
| 178 | noRemarks, | ||
| 179 | ); | ||
| 180 | assert.isFalse(result.exhausted); | ||
| 181 | assert.equal(result.next.wakes, 0); | ||
| 182 | }); | ||
| 183 | |||
| 184 | it("stops after the wake limit unless the head moves", () => { | ||
| 185 | const comments = [remark("reviewer", "2026-10-02T12:10:00Z")]; | ||
| 186 | const tired = watch({ headSha: "aaaaaaaaaa", wakes: PULL_REQUEST_WATCH_WAKE_LIMIT - 1 }); | ||
| 187 | assert.isTrue(evaluatePullRequestWatch(tired, detail(), comments).exhausted); | ||
| 188 | const pushed = evaluatePullRequestWatch(tired, detail({ headSha: "cccccccccc" }), comments); | ||
| 189 | assert.isFalse(pushed.exhausted); | ||
| 190 | assert.equal(pushed.next.wakes, 1); | ||
| 191 | }); | ||
| 192 | }); | ||
| 193 | |||
| 194 | describe("pullRequestWatchMessage", () => { | ||
| 195 | it("tells the agent what changed and marks failures for the timeline", () => { | ||
| 196 | const report = evaluatePullRequestWatch( | ||
| 197 | watch(), | ||
| 198 | detail({ checks: [check("lint", "failure")] }), | ||
| 199 | [remark("reviewer", "2026-10-02T12:10:00Z", "<!-- bot -->Needs a test.")], | ||
| 200 | ); | ||
| 201 | const message = pullRequestWatchMessage({ | ||
| 202 | number: 12, | ||
| 203 | url: "https://github.com/o/r/pull/12", | ||
| 204 | baseBranch: "main", | ||
| 205 | headSha: report.next.headSha, | ||
| 206 | report, | ||
| 207 | }); | ||
| 208 | assert.include(message.text, "- Checks failed on aaaaaaa:\n - lint https://ci.example/lint"); | ||
| 209 | assert.include(message.text, ' - reviewer on src/index.ts: "Needs a test."'); | ||
| 210 | assert.include(message.text, "unwatch_pull_request"); | ||
| 211 | assert.deepEqual(message.notification, { | ||
| 212 | source: { kind: "monitor" }, | ||
| 213 | outcome: "failed", | ||
| 214 | summary: "#12: checks failed, new comments", | ||
| 215 | }); | ||
| 216 | }); | ||
| 217 | }); | ||
apps/server/src/orchestration-v2/pullRequestWatch.ts created+209| ... | @@ -0,0 +1,209 @@ | ||
| 1 | import type { | ||
| 2 | OrchestrationV2Notification, | ||
| 3 | PullRequestCheck, | ||
| 4 | PullRequestComment, | ||
| 5 | PullRequestDetail, | ||
| 6 | ThreadPullRequestWatch, | ||
| 7 | } from "@t3tools/contracts"; | ||
| 8 | |||
| 9 | /** | ||
| 10 | * Wakes in a row that bring only comments. Check, conflict, or push news resets the count, so | ||
| 11 | * this only stops a chatty bot looping an agent that is replying to it. | ||
| 12 | */ | ||
| 13 | export const PULL_REQUEST_WATCH_WAKE_LIMIT = 10; | ||
| 14 | const LISTED_ITEMS = 10; | ||
| 15 | const SNIPPET_LENGTH = 200; | ||
| 16 | |||
| 17 | export type PullRequestWatchChange = | ||
| 18 | | { readonly kind: "checks-failed"; readonly failed: ReadonlyArray<PullRequestCheck> } | ||
| 19 | | { readonly kind: "checks-passed"; readonly count: number; readonly required: boolean } | ||
| 20 | | { readonly kind: "remarks"; readonly remarks: ReadonlyArray<PullRequestComment> } | ||
| 21 | | { readonly kind: "conflicting" }; | ||
| 22 | |||
| 23 | export interface PullRequestWatchReport { | ||
| 24 | /** What the agent has not been told yet. Empty means no wake. */ | ||
| 25 | readonly changes: ReadonlyArray<PullRequestWatchChange>; | ||
| 26 | /** The watch to record, whether or not anything is reported. */ | ||
| 27 | readonly next: ThreadPullRequestWatch; | ||
| 28 | /** This report spends the last wake before the limit, so watching stops after it. */ | ||
| 29 | readonly exhausted: boolean; | ||
| 30 | } | ||
| 31 | |||
| 32 | // "action-required" is a finished check that needs someone, so the agent hears about it. | ||
| 33 | const isFailedCheck = (check: PullRequestCheck) => | ||
| 34 | check.status === "failure" || check.status === "cancelled" || check.status === "action-required"; | ||
| 35 | |||
| 36 | /** | ||
| 37 | * Compares a watched pull request with what its agent was last told. Each check is reported as | ||
| 38 | * soon as it fails, so a check that never finishes (an advisory review bot) cannot hold the | ||
| 39 | * news back. "Passed" is reported once the checks the base branch requires all passed, or all | ||
| 40 | * checks where the host marks none required. Remarks count when someone other than the agent's | ||
| 41 | * own account wrote them, so its own replies never wake it. `remarks` is null when the | ||
| 42 | * conversation could not be read; remarks then wait for a later pass. | ||
| 43 | */ | ||
| 44 | export function evaluatePullRequestWatch( | ||
| 45 | watch: ThreadPullRequestWatch, | ||
| 46 | detail: Pick<PullRequestDetail, "headSha" | "checks" | "mergeability" | "viewer" | "author">, | ||
| 47 | remarks: ReadonlyArray<PullRequestComment> | null, | ||
| 48 | ): PullRequestWatchReport { | ||
| 49 | const changes: Array<PullRequestWatchChange> = []; | ||
| 50 | const headSha = detail.headSha ?? null; | ||
| 51 | const headMoved = headSha !== watch.headSha; | ||
| 52 | |||
| 53 | // An empty list keeps the last state: a host can answer with one when its check read fails. | ||
| 54 | let failedChecks = headMoved ? [] : watch.failedChecks; | ||
| 55 | let passed = headMoved ? false : watch.passed; | ||
| 56 | if (detail.checks.length > 0) { | ||
| 57 | const failed = detail.checks.filter(isFailedCheck); | ||
| 58 | const newlyFailed = failed.filter((check) => !failedChecks.includes(check.name)); | ||
| 59 | if (newlyFailed.length > 0) changes.push({ kind: "checks-failed", failed: newlyFailed }); | ||
| 60 | // A check that runs again leaves the list, so a rerun that fails again is reported. | ||
| 61 | failedChecks = failed.map((check) => check.name); | ||
| 62 | |||
| 63 | const required = detail.checks.filter((check) => check.required === true); | ||
| 64 | const gate = required.length > 0 ? required : detail.checks; | ||
| 65 | const passedNow = gate.every((check) => check.status !== "pending" && !isFailedCheck(check)); | ||
| 66 | if (passedNow && !passed) { | ||
| 67 | changes.push({ kind: "checks-passed", count: gate.length, required: required.length > 0 }); | ||
| 68 | } | ||
| 69 | passed = passedNow; | ||
| 70 | } | ||
| 71 | |||
| 72 | const own = (detail.viewer ?? detail.author?.login)?.toLowerCase(); | ||
| 73 | const through = Date.parse(watch.remarksThrough); | ||
| 74 | // GitHub times are per second, so remarks at the boundary time are told apart by ID. | ||
| 75 | const fresh = (remarks ?? []).filter((remark) => { | ||
| 76 | const at = Date.parse(remark.createdAt); | ||
| 77 | return ( | ||
| 78 | (at > through || (at === through && !watch.remarkIds.includes(remark.id))) && | ||
| 79 | remark.author?.login.toLowerCase() !== own | ||
| 80 | ); | ||
| 81 | }); | ||
| 82 | if (fresh.length > 0) changes.push({ kind: "remarks", remarks: fresh }); | ||
| 83 | const latest = Math.max(through, ...fresh.map((remark) => Date.parse(remark.createdAt))); | ||
| 84 | const atLatest = fresh.filter((remark) => Date.parse(remark.createdAt) === latest); | ||
| 85 | const remarksThrough = latest === through ? watch.remarksThrough : atLatest[0]!.createdAt; | ||
| 86 | const remarkIds = [ | ||
| 87 | ...(latest === through ? watch.remarkIds : []), | ||
| 88 | ...atLatest.map((remark) => remark.id), | ||
| 89 | ]; | ||
| 90 | |||
| 91 | if (detail.mergeability === "conflicting" && !watch.conflicting) { | ||
| 92 | changes.push({ kind: "conflicting" }); | ||
| 93 | } | ||
| 94 | // "unknown" is GitHub still computing after a push; only a clean answer clears a conflict. | ||
| 95 | const conflicting = | ||
| 96 | detail.mergeability === "unknown" ? watch.conflicting : detail.mergeability === "conflicting"; | ||
| 97 | |||
| 98 | const commentsOnly = changes.length > 0 && changes.every((change) => change.kind === "remarks"); | ||
| 99 | const progress = headMoved || (changes.length > 0 && !commentsOnly); | ||
| 100 | const wakes = (progress ? 0 : watch.wakes) + (commentsOnly ? 1 : 0); | ||
| 101 | return { | ||
| 102 | changes, | ||
| 103 | next: { | ||
| 104 | startedAt: watch.startedAt, | ||
| 105 | headSha, | ||
| 106 | failedChecks, | ||
| 107 | passed, | ||
| 108 | remarksThrough, | ||
| 109 | remarkIds, | ||
| 110 | conflicting, | ||
| 111 | wakes, | ||
| 112 | }, | ||
| 113 | exhausted: commentsOnly && wakes >= PULL_REQUEST_WATCH_WAKE_LIMIT, | ||
| 114 | }; | ||
| 115 | } | ||
| 116 | |||
| 117 | function snippet(body: string): string { | ||
| 118 | const text = body | ||
| 119 | .replaceAll(/<!--[\s\S]*?-->/g, " ") | ||
| 120 | .replaceAll(/\s+/g, " ") | ||
| 121 | .trim(); | ||
| 122 | return text.length <= SNIPPET_LENGTH ? text : `${text.slice(0, SNIPPET_LENGTH - 3)}...`; | ||
| 123 | } | ||
| 124 | |||
| 125 | function listed<T>(items: ReadonlyArray<T>, line: (item: T) => string): Array<string> { | ||
| 126 | const lines = items.slice(0, LISTED_ITEMS).map(line); | ||
| 127 | if (items.length > LISTED_ITEMS) lines.push(` - and ${items.length - LISTED_ITEMS} more`); | ||
| 128 | return lines; | ||
| 129 | } | ||
| 130 | |||
| 131 | function changeLines( | ||
| 132 | change: PullRequestWatchChange, | ||
| 133 | context: { readonly baseBranch: string; readonly commit: string }, | ||
| 134 | ): Array<string> { | ||
| 135 | switch (change.kind) { | ||
| 136 | case "checks-failed": | ||
| 137 | return [ | ||
| 138 | `- Checks failed${context.commit}:`, | ||
| 139 | ...listed( | ||
| 140 | change.failed, | ||
| 141 | (check) => | ||
| 142 | ` - ${check.name}${check.status === "failure" ? "" : ` (${check.status})`}${check.url ? ` ${check.url}` : ""}`, | ||
| 143 | ), | ||
| 144 | ]; | ||
| 145 | case "checks-passed": | ||
| 146 | return [ | ||
| 147 | `- All ${change.count} ${change.required ? "required " : ""}${change.count === 1 ? "check" : "checks"} passed${context.commit}.`, | ||
| 148 | ]; | ||
| 149 | case "remarks": | ||
| 150 | return [ | ||
| 151 | `- ${change.remarks.length} new ${change.remarks.length === 1 ? "comment" : "comments"}:`, | ||
| 152 | ...listed(change.remarks, (remark) => { | ||
| 153 | const where = remark.path === null ? "" : ` on ${remark.path}`; | ||
| 154 | const body = snippet(remark.body); | ||
| 155 | const said = body.length === 0 ? (remark.reviewState ?? "reviewed") : `"${body}"`; | ||
| 156 | return ` - ${remark.author?.login ?? "someone"}${where}: ${said}${remark.url ? ` ${remark.url}` : ""}`; | ||
| 157 | }), | ||
| 158 | ]; | ||
| 159 | case "conflicting": | ||
| 160 | return [`- The branch now conflicts with ${context.baseBranch}.`]; | ||
| 161 | } | ||
| 162 | } | ||
| 163 | |||
| 164 | const SUMMARY: Record<PullRequestWatchChange["kind"], string> = { | ||
| 165 | "checks-failed": "checks failed", | ||
| 166 | "checks-passed": "checks passed", | ||
| 167 | remarks: "new comments", | ||
| 168 | conflicting: "merge conflict", | ||
| 169 | }; | ||
| 170 | |||
| 171 | /** The wake the agent reads and the timeline notification the user sees. */ | ||
| 172 | export function pullRequestWatchMessage(input: { | ||
| 173 | readonly number: number; | ||
| 174 | readonly url: string; | ||
| 175 | readonly baseBranch: string; | ||
| 176 | readonly headSha: string | null; | ||
| 177 | readonly report: PullRequestWatchReport; | ||
| 178 | }): { readonly text: string; readonly notification: OrchestrationV2Notification } { | ||
| 179 | const { changes, exhausted } = input.report; | ||
| 180 | const context = { | ||
| 181 | baseBranch: input.baseBranch, | ||
| 182 | commit: input.headSha === null ? "" : ` on ${input.headSha.slice(0, 7)}`, | ||
| 183 | }; | ||
| 184 | const text = [ | ||
| 185 | `Update on pull request #${input.number} (${input.url}), which T3 Code is watching for you:`, | ||
| 186 | ...changes.flatMap((change) => changeLines(change, context)), | ||
| 187 | "", | ||
| 188 | exhausted | ||
| 189 | ? `T3 Code stopped watching after ${PULL_REQUEST_WATCH_WAKE_LIMIT} comment-only updates in a row. Call watch_pull_request to watch it again.` | ||
| 190 | : "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. Call unwatch_pull_request when you no longer need updates.", | ||
| 191 | ].join("\n"); | ||
| 192 | const failed = changes.some( | ||
| 193 | (change) => change.kind === "checks-failed" || change.kind === "conflicting", | ||
| 194 | ); | ||
| 195 | const summary = changes.map((change) => SUMMARY[change.kind]); | ||
| 196 | if (exhausted) summary.push("stopped watching"); | ||
| 197 | return { | ||
| 198 | text, | ||
| 199 | notification: { | ||
| 200 | source: { kind: "monitor" }, | ||
| 201 | outcome: failed | ||
| 202 | ? "failed" | ||
| 203 | : changes.every((change) => change.kind === "checks-passed") | ||
| 204 | ? "completed" | ||
| 205 | : "updated", | ||
| 206 | summary: `#${input.number}: ${summary.join(", ")}`, | ||
| 207 | }, | ||
| 208 | }; | ||
| 209 | } | ||
apps/server/src/orchestration-v2/runtimeLayer.test.ts+297| ... | @@ -17,6 +17,7 @@ import { | ... | @@ -17,6 +17,7 @@ import { |
| 17 | type ModelSelection, | 17 | type ModelSelection, |
| 18 | type OrchestrationV2Run, | 18 | type OrchestrationV2Run, |
| 19 | ProjectId, | 19 | ProjectId, |
| 20 | type PullRequestDetail, | ||
| 20 | ProviderDriverKind, | 21 | ProviderDriverKind, |
| 21 | ProviderInstanceId, | 22 | ProviderInstanceId, |
| 22 | ProviderThreadId, | 23 | ProviderThreadId, |
| ... | @@ -58,6 +59,9 @@ import * as EffectOutbox from "./EffectOutbox.ts"; | ... | @@ -58,6 +59,9 @@ import * as EffectOutbox from "./EffectOutbox.ts"; |
| 58 | import * as EventSink from "./EventSink.ts"; | 59 | import * as EventSink from "./EventSink.ts"; |
| 59 | import * as ProviderRuntimeRecoveryService from "./ProviderRuntimeRecoveryService.ts"; | 60 | import * as ProviderRuntimeRecoveryService from "./ProviderRuntimeRecoveryService.ts"; |
| 60 | import * as ProjectionMaintenance from "./ProjectionMaintenance.ts"; | 61 | import * as ProjectionMaintenance from "./ProjectionMaintenance.ts"; |
| 62 | import * as ProjectionStore from "./ProjectionStore.ts"; | ||
| 63 | import * as PullRequestWatchReactor from "./PullRequestWatchReactor.ts"; | ||
| 64 | import * as PullRequestService from "../pullRequest/PullRequestService.ts"; | ||
| 61 | import * as ProjectStore from "./ProjectStore.ts"; | 65 | import * as ProjectStore from "./ProjectStore.ts"; |
| 62 | import type { ProviderAdapterV2SessionRuntime, ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; | 66 | import type { ProviderAdapterV2SessionRuntime, ProviderAdapterV2Shape } from "./ProviderAdapter.ts"; |
| 63 | import * as ProviderSessionManager from "./ProviderSessionManager.ts"; | 67 | import * as ProviderSessionManager from "./ProviderSessionManager.ts"; |
| ... | @@ -212,6 +216,7 @@ const TestLayer = Layer.mergeAll( | ... | @@ -212,6 +216,7 @@ const TestLayer = Layer.mergeAll( |
| 212 | OrchestrationV2LayerLive, | 216 | OrchestrationV2LayerLive, |
| 213 | OrchestrationV2EventSinkLayerLive, | 217 | OrchestrationV2EventSinkLayerLive, |
| 214 | ProjectStore.layer, | 218 | ProjectStore.layer, |
| 219 | ProjectionStore.layer, | ||
| 215 | EffectOutbox.layer, | 220 | EffectOutbox.layer, |
| 216 | ThreadCommandExecutor.layer, | 221 | ThreadCommandExecutor.layer, |
| 217 | ).pipe( | 222 | ).pipe( |
| ... | @@ -2148,6 +2153,298 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { | ... | @@ -2148,6 +2153,298 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { |
| 2148 | }), | 2153 | }), |
| 2149 | ); | 2154 | ); |
| 2150 | 2155 | ||
| 2156 | it.effect("starts, records, and stops a pull request watch", () => | ||
| 2157 | Effect.gen(function* () { | ||
| 2158 | const orchestrator = yield* Orchestrator.OrchestratorV2; | ||
| 2159 | const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; | ||
| 2160 | const threadId = ThreadId.make("runtime-pull-request-watch"); | ||
| 2161 | yield* orchestrator.dispatch({ | ||
| 2162 | type: "thread.create", | ||
| 2163 | createdBy: "user", | ||
| 2164 | creationSource: "web", | ||
| 2165 | commandId: CommandId.make("pr-watch-create"), | ||
| 2166 | threadId, | ||
| 2167 | projectId: ProjectId.make("pr-watch-project"), | ||
| 2168 | title: "Watch", | ||
| 2169 | modelSelection, | ||
| 2170 | runtimeMode: "full-access", | ||
| 2171 | interactionMode: "default", | ||
| 2172 | branch: null, | ||
| 2173 | worktreePath: null, | ||
| 2174 | }); | ||
| 2175 | const key = { host: "github.com", repository: "pingdotgg/t3code", number: 7 }; | ||
| 2176 | const url = "https://github.com/pingdotgg/t3code/pull/7"; | ||
| 2177 | const watchOf = Effect.map( | ||
| 2178 | orchestrator.getThreadShell(threadId), | ||
| 2179 | (thread) => thread?.pullRequests?.[0]?.watch, | ||
| 2180 | ); | ||
| 2181 | |||
| 2182 | // Watching an unlinked pull request links it in the same command. | ||
| 2183 | yield* orchestrator.dispatch({ | ||
| 2184 | type: "thread.pull-request.watch", | ||
| 2185 | commandId: CommandId.make("pr-watch-start"), | ||
| 2186 | threadId, | ||
| 2187 | ...key, | ||
| 2188 | watching: true, | ||
| 2189 | link: { url, source: "agent" }, | ||
| 2190 | }); | ||
| 2191 | assert.equal( | ||
| 2192 | (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.source, | ||
| 2193 | "agent", | ||
| 2194 | ); | ||
| 2195 | const started = yield* watchOf; | ||
| 2196 | assert.isDefined(started); | ||
| 2197 | if (started === undefined) return; | ||
| 2198 | |||
| 2199 | // A legacy client re-linking the same pull request keeps its watch. | ||
| 2200 | yield* orchestrator.dispatch({ | ||
| 2201 | type: "thread.metadata.update", | ||
| 2202 | commandId: CommandId.make("pr-watch-legacy-relink"), | ||
| 2203 | threadId, | ||
| 2204 | linkedPullRequest: { projectId: ProjectId.make("pr-watch-project"), ...key, url }, | ||
| 2205 | }); | ||
| 2206 | assert.deepEqual(yield* watchOf, started); | ||
| 2207 | |||
| 2208 | const recorded = { ...started, headSha: "abc123", failedChecks: ["lint"], wakes: 1 }; | ||
| 2209 | yield* orchestrator.dispatch({ | ||
| 2210 | type: "thread.pull-request-watch.sync", | ||
| 2211 | commandId: CommandId.make("pr-watch-record"), | ||
| 2212 | threadId, | ||
| 2213 | ...key, | ||
| 2214 | startedAt: started.startedAt, | ||
| 2215 | watch: recorded, | ||
| 2216 | }); | ||
| 2217 | assert.deepEqual(yield* watchOf, recorded); | ||
| 2218 | assert.isTrue((yield* maintenance.rebuild).valid); | ||
| 2219 | assert.deepEqual(yield* watchOf, recorded); | ||
| 2220 | |||
| 2221 | yield* orchestrator.dispatch({ | ||
| 2222 | type: "thread.pull-request.watch", | ||
| 2223 | commandId: CommandId.make("pr-watch-stop"), | ||
| 2224 | threadId, | ||
| 2225 | ...key, | ||
| 2226 | watching: false, | ||
| 2227 | }); | ||
| 2228 | // A wake read before the stop must neither wake the agent nor bring the watch back. | ||
| 2229 | const late = yield* orchestrator | ||
| 2230 | .dispatch({ | ||
| 2231 | type: "thread.pull-request-watch.sync", | ||
| 2232 | commandId: CommandId.make("pr-watch-late-record"), | ||
| 2233 | threadId, | ||
| 2234 | ...key, | ||
| 2235 | startedAt: started.startedAt, | ||
| 2236 | watch: { ...recorded, wakes: 2 }, | ||
| 2237 | wake: { | ||
| 2238 | messageId: MessageId.make("pr-watch-late-wake"), | ||
| 2239 | text: "Update", | ||
| 2240 | notification: { source: { kind: "monitor" }, outcome: "updated", summary: "#7" }, | ||
| 2241 | }, | ||
| 2242 | }) | ||
| 2243 | .pipe(Effect.flip); | ||
| 2244 | assert.equal(late._tag, "OrchestratorDispatchError"); | ||
| 2245 | assert.isUndefined(yield* watchOf); | ||
| 2246 | const { messages } = yield* orchestrator.getThreadRecords(threadId, ["messages"]); | ||
| 2247 | assert.deepEqual(messages, []); | ||
| 2248 | }), | ||
| 2249 | ); | ||
| 2250 | |||
| 2251 | it.effect("ends a watch it cannot read, and tells the agent", () => | ||
| 2252 | Effect.gen(function* () { | ||
| 2253 | const orchestrator = yield* Orchestrator.OrchestratorV2; | ||
| 2254 | const threadId = ThreadId.make("runtime-pull-request-watch-unreadable"); | ||
| 2255 | const projectId = ProjectId.make("pr-watch-unreadable-project"); | ||
| 2256 | yield* seedProject({ | ||
| 2257 | projectId, | ||
| 2258 | title: "Watch unreadable", | ||
| 2259 | workspaceRoot: "/workspace/watch-unreadable", | ||
| 2260 | defaultModelSelection: null, | ||
| 2261 | createdAt: "2026-10-01T00:00:00.000Z", | ||
| 2262 | }); | ||
| 2263 | yield* orchestrator.dispatch({ | ||
| 2264 | type: "thread.create", | ||
| 2265 | createdBy: "user", | ||
| 2266 | creationSource: "web", | ||
| 2267 | commandId: CommandId.make("pr-watch-unreadable-create"), | ||
| 2268 | threadId, | ||
| 2269 | projectId, | ||
| 2270 | title: "Watch unreadable", | ||
| 2271 | modelSelection, | ||
| 2272 | runtimeMode: "full-access", | ||
| 2273 | interactionMode: "default", | ||
| 2274 | branch: null, | ||
| 2275 | worktreePath: null, | ||
| 2276 | }); | ||
| 2277 | yield* orchestrator.dispatch({ | ||
| 2278 | type: "thread.pull-request.watch", | ||
| 2279 | commandId: CommandId.make("pr-watch-unreadable-start"), | ||
| 2280 | threadId, | ||
| 2281 | host: "github.com", | ||
| 2282 | repository: "pingdotgg/t3code", | ||
| 2283 | number: 8, | ||
| 2284 | watching: true, | ||
| 2285 | link: { url: "https://github.com/pingdotgg/t3code/pull/8", source: "agent" }, | ||
| 2286 | }); | ||
| 2287 | const reactor = yield* PullRequestWatchReactor.make.pipe( | ||
| 2288 | Effect.provide( | ||
| 2289 | Layer.mergeAll( | ||
| 2290 | NodeServices.layer, | ||
| 2291 | Layer.mock(PullRequestService.PullRequestService)({ | ||
| 2292 | detail: () => Effect.die("host unreachable"), | ||
| 2293 | activity: () => Effect.die("host unreachable"), | ||
| 2294 | }), | ||
| 2295 | ), | ||
| 2296 | ), | ||
| 2297 | ); | ||
| 2298 | for (let pass = 0; pass < 15; pass += 1) yield* reactor.sweep; | ||
| 2299 | |||
| 2300 | const thread = yield* orchestrator.getThreadShell(threadId); | ||
| 2301 | assert.isUndefined(thread?.pullRequests?.[0]?.watch); | ||
| 2302 | const { messages } = yield* orchestrator.getThreadRecords(threadId, ["messages"]); | ||
| 2303 | assert.deepEqual( | ||
| 2304 | messages.flatMap((message) => message.notification?.summary ?? []), | ||
| 2305 | ["#8: stopped watching, could not read it"], | ||
| 2306 | ); | ||
| 2307 | }), | ||
| 2308 | ); | ||
| 2309 | |||
| 2310 | it.effect("wakes a watched thread once for failed checks and a review comment", () => | ||
| 2311 | Effect.gen(function* () { | ||
| 2312 | const orchestrator = yield* Orchestrator.OrchestratorV2; | ||
| 2313 | const threadId = ThreadId.make("runtime-pull-request-watch-wake"); | ||
| 2314 | const projectId = ProjectId.make("pr-watch-wake-project"); | ||
| 2315 | yield* seedProject({ | ||
| 2316 | projectId, | ||
| 2317 | title: "Watch wake", | ||
| 2318 | workspaceRoot: "/workspace/watch", | ||
| 2319 | defaultModelSelection: null, | ||
| 2320 | createdAt: "2026-10-01T00:00:00.000Z", | ||
| 2321 | }); | ||
| 2322 | yield* orchestrator.dispatch({ | ||
| 2323 | type: "thread.create", | ||
| 2324 | createdBy: "user", | ||
| 2325 | creationSource: "web", | ||
| 2326 | commandId: CommandId.make("pr-watch-wake-create"), | ||
| 2327 | threadId, | ||
| 2328 | projectId, | ||
| 2329 | title: "Watch wake", | ||
| 2330 | modelSelection, | ||
| 2331 | runtimeMode: "full-access", | ||
| 2332 | interactionMode: "default", | ||
| 2333 | branch: null, | ||
| 2334 | worktreePath: null, | ||
| 2335 | }); | ||
| 2336 | const key = { host: "github.com", repository: "pingdotgg/t3code", number: 7 }; | ||
| 2337 | const url = "https://github.com/pingdotgg/t3code/pull/7"; | ||
| 2338 | yield* orchestrator.dispatch({ | ||
| 2339 | type: "thread.pull-request.link", | ||
| 2340 | commandId: CommandId.make("pr-watch-wake-link"), | ||
| 2341 | threadId, | ||
| 2342 | ...key, | ||
| 2343 | url, | ||
| 2344 | source: "agent", | ||
| 2345 | }); | ||
| 2346 | yield* orchestrator.dispatch({ | ||
| 2347 | type: "thread.pull-request.watch", | ||
| 2348 | commandId: CommandId.make("pr-watch-wake-start"), | ||
| 2349 | threadId, | ||
| 2350 | ...key, | ||
| 2351 | watching: true, | ||
| 2352 | }); | ||
| 2353 | |||
| 2354 | const at = "2026-10-02T12:00:00.000Z"; | ||
| 2355 | const detail: PullRequestDetail = { | ||
| 2356 | provider: "github", | ||
| 2357 | capabilities: { | ||
| 2358 | diff: true, | ||
| 2359 | comment: true, | ||
| 2360 | actions: [], | ||
| 2361 | mergeMethods: [], | ||
| 2362 | search: false, | ||
| 2363 | review: { inlineComment: false, reply: false, resolve: false, verdicts: [] }, | ||
| 2364 | reviewers: { request: false, listCandidates: false }, | ||
| 2365 | }, | ||
| 2366 | viewerPermissions: { | ||
| 2367 | actions: [], | ||
| 2368 | comment: true, | ||
| 2369 | resolve: true, | ||
| 2370 | verdicts: [], | ||
| 2371 | requestReviewers: false, | ||
| 2372 | }, | ||
| 2373 | projectId, | ||
| 2374 | projectTitle: "Watch wake", | ||
| 2375 | workspaceRoot: "/workspace/watch", | ||
| 2376 | repository: key.repository, | ||
| 2377 | number: key.number, | ||
| 2378 | title: "Watched pull request", | ||
| 2379 | body: "", | ||
| 2380 | url, | ||
| 2381 | author: { login: "agent-user", name: null, avatarUrl: null }, | ||
| 2382 | state: "open", | ||
| 2383 | isDraft: false, | ||
| 2384 | mergeability: "mergeable", | ||
| 2385 | additions: 1, | ||
| 2386 | deletions: 0, | ||
| 2387 | changedFiles: 1, | ||
| 2388 | headBranch: "feature", | ||
| 2389 | headSha: "abc1234def", | ||
| 2390 | baseBranch: "main", | ||
| 2391 | createdAt: at, | ||
| 2392 | updatedAt: at, | ||
| 2393 | mergedAt: null, | ||
| 2394 | closedAt: null, | ||
| 2395 | reviewers: [], | ||
| 2396 | labels: [], | ||
| 2397 | checks: [{ name: "lint", status: "failure", description: null, url: null }], | ||
| 2398 | mergeCapabilities: { merge: true, squash: true, rebase: true }, | ||
| 2399 | viewer: "agent-user", | ||
| 2400 | }; | ||
| 2401 | const reactor = yield* PullRequestWatchReactor.make.pipe( | ||
| 2402 | Effect.provide( | ||
| 2403 | Layer.mergeAll( | ||
| 2404 | NodeServices.layer, | ||
| 2405 | Layer.mock(PullRequestService.PullRequestService)({ | ||
| 2406 | detail: () => Effect.succeed(detail), | ||
| 2407 | activity: () => | ||
| 2408 | Effect.succeed({ | ||
| 2409 | comments: [ | ||
| 2410 | { | ||
| 2411 | id: "review-1", | ||
| 2412 | kind: "review-comment", | ||
| 2413 | author: { login: "reviewer", name: null, avatarUrl: null }, | ||
| 2414 | body: "One more thing.", | ||
| 2415 | createdAt: "2999-01-01T00:00:00.000Z", | ||
| 2416 | url: null, | ||
| 2417 | path: "src/index.ts", | ||
| 2418 | reviewState: null, | ||
| 2419 | }, | ||
| 2420 | ], | ||
| 2421 | commentCount: 1, | ||
| 2422 | commentsTruncated: false, | ||
| 2423 | reviewThreads: [], | ||
| 2424 | commits: [], | ||
| 2425 | }), | ||
| 2426 | }), | ||
| 2427 | ), | ||
| 2428 | ), | ||
| 2429 | ); | ||
| 2430 | yield* reactor.sweep; | ||
| 2431 | yield* reactor.sweep; | ||
| 2432 | |||
| 2433 | const { messages } = yield* orchestrator.getThreadRecords(threadId, ["messages"]); | ||
| 2434 | assert.deepEqual( | ||
| 2435 | messages.flatMap((message) => | ||
| 2436 | message.notification === undefined ? [] : [message.notification.summary], | ||
| 2437 | ), | ||
| 2438 | ["#7: checks failed, new comments"], | ||
| 2439 | ); | ||
| 2440 | const watch = (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.watch; | ||
| 2441 | assert.deepEqual( | ||
| 2442 | { headSha: watch?.headSha, failedChecks: watch?.failedChecks, wakes: watch?.wakes }, | ||
| 2443 | { headSha: "abc1234def", failedChecks: ["lint"], wakes: 0 }, | ||
| 2444 | ); | ||
| 2445 | }), | ||
| 2446 | ); | ||
| 2447 | |||
| 2151 | it.effect("persists rejected command receipts across retries", () => | 2448 | it.effect("persists rejected command receipts across retries", () => |
| 2152 | Effect.gen(function* () { | 2449 | Effect.gen(function* () { |
| 2153 | const orchestrator = yield* Orchestrator.OrchestratorV2; | 2450 | const orchestrator = yield* Orchestrator.OrchestratorV2; |
apps/server/src/orchestration-v2/testkit/OrchestratorScenario.ts+1| ... | @@ -153,6 +153,7 @@ function commandThreadIds(command: OrchestrationV2Command): ReadonlyArray<Thread | ... | @@ -153,6 +153,7 @@ function commandThreadIds(command: OrchestrationV2Command): ReadonlyArray<Thread |
| 153 | case "thread.pull-request.link": | 153 | case "thread.pull-request.link": |
| 154 | case "thread.pull-request.unlink": | 154 | case "thread.pull-request.unlink": |
| 155 | case "thread.pull-request-link.sync": | 155 | case "thread.pull-request-link.sync": |
| 156 | case "thread.pull-request.watch": | ||
| 156 | case "thread.pull-request.sync": | 157 | case "thread.pull-request.sync": |
| 157 | case "thread.title.regeneration.complete": | 158 | case "thread.title.regeneration.complete": |
| 158 | case "thread.runtime-mode.set": | 159 | case "thread.runtime-mode.set": |
apps/server/src/provider/RuntimeInstructions.ts+1-1| ... | @@ -1,5 +1,5 @@ | ... | @@ -1,5 +1,5 @@ |
| 1 | const PULL_REQUEST_LINKING_INSTRUCTIONS = `<pull_request_linking> | 1 | const PULL_REQUEST_LINKING_INSTRUCTIONS = `<pull_request_linking> |
| 2 | 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. | 2 | 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. |
| 3 | </pull_request_linking>`; | 3 | </pull_request_linking>`; |
| 4 | 4 | ||
| 5 | /** | 5 | /** |
apps/server/src/pullRequest/GitHubPullRequestCli.ts+2-2| ... | @@ -52,7 +52,7 @@ import { | ... | @@ -52,7 +52,7 @@ import { |
| 52 | decodePullRequestActivityJson, | 52 | decodePullRequestActivityJson, |
| 53 | decodePullRequestDetailJson, | 53 | decodePullRequestDetailJson, |
| 54 | decodePullRequestCoreJson, | 54 | decodePullRequestCoreJson, |
| 55 | PULL_REQUEST_CORE_GRAPHQL_QUERY, | 55 | pullRequestCoreGraphQlQuery, |
| 56 | type GitHubPullRequestCore, | 56 | type GitHubPullRequestCore, |
| 57 | type GitHubPullRequestSummary, | 57 | type GitHubPullRequestSummary, |
| 58 | decodePullRequestPreviewJson, | 58 | decodePullRequestPreviewJson, |
| ... | @@ -1595,7 +1595,7 @@ export const make = Effect.gen(function* () { | ... | @@ -1595,7 +1595,7 @@ export const make = Effect.gen(function* () { |
| 1595 | ["-F", `number=${input.number}`], | 1595 | ["-F", `number=${input.number}`], |
| 1596 | ["-f", `headRef=refs/pull/${input.number}/head`], | 1596 | ["-f", `headRef=refs/pull/${input.number}/head`], |
| 1597 | ], | 1597 | ], |
| 1598 | query: PULL_REQUEST_CORE_GRAPHQL_QUERY, | 1598 | query: pullRequestCoreGraphQlQuery(input.host), |
| 1599 | decode: decodePullRequestCoreJson, | 1599 | decode: decodePullRequestCoreJson, |
| 1600 | }), | 1600 | }), |
| 1601 | ), | 1601 | ), |
apps/server/src/pullRequest/PullRequestProvider.ts+2| ... | @@ -213,6 +213,8 @@ export interface ProviderChangeRequestStat { | ... | @@ -213,6 +213,8 @@ export interface ProviderChangeRequestStat { |
| 213 | } | 213 | } |
| 214 | 214 | ||
| 215 | export interface ProviderChangeRequestDetail extends ProviderChangeRequest { | 215 | export interface ProviderChangeRequestDetail extends ProviderChangeRequest { |
| 216 | /** The head commit, where the host's detail read reports it. */ | ||
| 217 | readonly headSha?: string | null; | ||
| 216 | readonly body: string; | 218 | readonly body: string; |
| 217 | readonly changedFiles: number; | 219 | readonly changedFiles: number; |
| 218 | readonly mergedAt: string | null; | 220 | readonly mergedAt: string | null; |
apps/server/src/pullRequest/PullRequestService.ts+1| ... | @@ -1697,6 +1697,7 @@ export const make = Effect.gen(function* () { | ... | @@ -1697,6 +1697,7 @@ export const make = Effect.gen(function* () { |
| 1697 | ...(changeRequest.headRepositoryNameWithOwner === undefined | 1697 | ...(changeRequest.headRepositoryNameWithOwner === undefined |
| 1698 | ? {} | 1698 | ? {} |
| 1699 | : { headRepositoryNameWithOwner: changeRequest.headRepositoryNameWithOwner }), | 1699 | : { headRepositoryNameWithOwner: changeRequest.headRepositoryNameWithOwner }), |
| 1700 | ...(changeRequest.headSha ? { headSha: changeRequest.headSha } : {}), | ||
| 1700 | baseBranch: changeRequest.baseBranch, | 1701 | baseBranch: changeRequest.baseBranch, |
| 1701 | createdAt: changeRequest.createdAt, | 1702 | createdAt: changeRequest.createdAt, |
| 1702 | updatedAt: changeRequest.updatedAt, | 1703 | updatedAt: changeRequest.updatedAt, |
apps/server/src/pullRequest/gitHubPullRequestJson.test.ts+20| ... | @@ -26,6 +26,7 @@ import { | ... | @@ -26,6 +26,7 @@ import { |
| 26 | decodeWorkflowRunApprovalsJson, | 26 | decodeWorkflowRunApprovalsJson, |
| 27 | reviewThreadConversation, | 27 | reviewThreadConversation, |
| 28 | REVIEW_THREADS_GRAPHQL_QUERY, | 28 | REVIEW_THREADS_GRAPHQL_QUERY, |
| 29 | pullRequestCoreGraphQlQuery, | ||
| 29 | pullRequestSearchGraphQlQuery, | 30 | pullRequestSearchGraphQlQuery, |
| 30 | } from "./gitHubPullRequestJson.ts"; | 31 | } from "./gitHubPullRequestJson.ts"; |
| 31 | 32 | ||
| ... | @@ -285,6 +286,25 @@ describe("pull request detail decoding", () => { | ... | @@ -285,6 +286,25 @@ describe("pull request detail decoding", () => { |
| 285 | ]); | 286 | ]); |
| 286 | }); | 287 | }); |
| 287 | 288 | ||
| 289 | it("keeps what branch protection requires, and asks for it on github.com only", () => { | ||
| 290 | const raw = JSON.parse(detailJson) as Record<string, unknown>; | ||
| 291 | const detail = expectSuccess( | ||
| 292 | decodePullRequestDetailJson( | ||
| 293 | JSON.stringify({ | ||
| 294 | ...raw, | ||
| 295 | statusCheckRollup: [ | ||
| 296 | { __typename: "CheckRun", name: "test", status: "IN_PROGRESS", isRequired: true }, | ||
| 297 | { __typename: "StatusContext", context: "bot", state: "PENDING", isRequired: false }, | ||
| 298 | { __typename: "StatusContext", context: "legacy", state: "SUCCESS" }, | ||
| 299 | ], | ||
| 300 | }), | ||
| 301 | ), | ||
| 302 | ); | ||
| 303 | expect(detail.checks.map((check) => check.required)).toEqual([true, false, undefined]); | ||
| 304 | expect(pullRequestCoreGraphQlQuery("github.com")).toContain("isRequired"); | ||
| 305 | expect(pullRequestCoreGraphQlQuery("github.example.com")).not.toContain("isRequired"); | ||
| 306 | }); | ||
| 307 | |||
| 288 | it("keeps a workflow waiting for approval out of the passing state", () => { | 308 | it("keeps a workflow waiting for approval out of the passing state", () => { |
| 289 | const raw = JSON.parse(detailJson) as Record<string, unknown>; | 309 | const raw = JSON.parse(detailJson) as Record<string, unknown>; |
| 290 | const detail = expectSuccess( | 310 | const detail = expectSuccess( |
apps/server/src/pullRequest/gitHubPullRequestJson.ts+15-4| ... | @@ -93,6 +93,8 @@ const RawCheckSchema = Schema.Struct({ | ... | @@ -93,6 +93,8 @@ const RawCheckSchema = Schema.Struct({ |
| 93 | workflowName: Schema.optional(Schema.NullOr(Schema.String)), | 93 | workflowName: Schema.optional(Schema.NullOr(Schema.String)), |
| 94 | startedAt: Schema.optional(Schema.NullOr(Schema.String)), | 94 | startedAt: Schema.optional(Schema.NullOr(Schema.String)), |
| 95 | completedAt: Schema.optional(Schema.NullOr(Schema.String)), | 95 | completedAt: Schema.optional(Schema.NullOr(Schema.String)), |
| 96 | /** Branch protection requires this check; read by the detail query on github.com only. */ | ||
| 97 | isRequired: Schema.optional(Schema.NullOr(Schema.Boolean)), | ||
| 96 | }); | 98 | }); |
| 97 | 99 | ||
| 98 | const RawListItemSchema = Schema.Struct({ | 100 | const RawListItemSchema = Schema.Struct({ |
| ... | @@ -706,8 +708,15 @@ export const PULL_REQUEST_LIST_JSON_FIELDS = | ... | @@ -706,8 +708,15 @@ export const PULL_REQUEST_LIST_JSON_FIELDS = |
| 706 | 708 | ||
| 707 | export const PULL_REQUEST_DETAIL_JSON_FIELDS = `${PULL_REQUEST_LIST_JSON_FIELDS},body,changedFiles,closedAt,isCrossRepository,headRepositoryOwner,headRefOid,autoMergeRequest`; | 709 | export const PULL_REQUEST_DETAIL_JSON_FIELDS = `${PULL_REQUEST_LIST_JSON_FIELDS},body,changedFiles,closedAt,isCrossRepository,headRepositoryOwner,headRefOid,autoMergeRequest`; |
| 708 | 710 | ||
| 709 | /** Pull refs let the comparison share the detail read without first resolving a fork branch. */ | 711 | /** |
| 710 | export const PULL_REQUEST_CORE_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!, $headRef: String!) { | 712 | * Pull refs let the comparison share the detail read without first resolving a fork branch. |
| 713 | * `isRequired` is asked for on github.com only: an older Enterprise server may not know it, and | ||
| 714 | * an unknown field fails the whole read. | ||
| 715 | */ | ||
| 716 | export const pullRequestCoreGraphQlQuery = (host: string) => { | ||
| 717 | const required = | ||
| 718 | host.toLowerCase() === "github.com" ? " isRequired(pullRequestNumber: $number)" : ""; | ||
| 719 | return `query($owner: String!, $name: String!, $number: Int!, $headRef: String!) { | ||
| 711 | repository(owner: $owner, name: $name) { | 720 | repository(owner: $owner, name: $name) { |
| 712 | mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed viewerPermission | 721 | mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed viewerPermission |
| 713 | pullRequest(number: $number) { | 722 | pullRequest(number: $number) { |
| ... | @@ -727,9 +736,9 @@ export const PULL_REQUEST_CORE_GRAPHQL_QUERY = `query($owner: String!, $name: St | ... | @@ -727,9 +736,9 @@ export const PULL_REQUEST_CORE_GRAPHQL_QUERY = `query($owner: String!, $name: St |
| 727 | nodes { commit { statusCheckRollup { contexts(first: 100) { | 736 | nodes { commit { statusCheckRollup { contexts(first: 100) { |
| 728 | nodes { | 737 | nodes { |
| 729 | __typename | 738 | __typename |
| 730 | ... on StatusContext { context state targetUrl createdAt description } | 739 | ... on StatusContext { context state targetUrl createdAt description${required} } |
| 731 | ... on CheckRun { | 740 | ... on CheckRun { |
| 732 | name status conclusion startedAt completedAt detailsUrl | 741 | name status conclusion startedAt completedAt detailsUrl${required} |
| 733 | checkSuite { workflowRun { workflow { name } } } | 742 | checkSuite { workflowRun { workflow { name } } } |
| 734 | } | 743 | } |
| 735 | } | 744 | } |
| ... | @@ -739,6 +748,7 @@ export const PULL_REQUEST_CORE_GRAPHQL_QUERY = `query($owner: String!, $name: St | ... | @@ -739,6 +748,7 @@ export const PULL_REQUEST_CORE_GRAPHQL_QUERY = `query($owner: String!, $name: St |
| 739 | } | 748 | } |
| 740 | } | 749 | } |
| 741 | }`; | 750 | }`; |
| 751 | }; | ||
| 742 | 752 | ||
| 743 | export const PULL_REQUEST_PREVIEW_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { | 753 | export const PULL_REQUEST_PREVIEW_GRAPHQL_QUERY = `query($owner: String!, $name: String!, $number: Int!) { |
| 744 | repository(owner: $owner, name: $name) { | 754 | repository(owner: $owner, name: $name) { |
| ... | @@ -1468,6 +1478,7 @@ function toCheckEntries( | ... | @@ -1468,6 +1478,7 @@ function toCheckEntries( |
| 1468 | status: toCheckStatus(check), | 1478 | status: toCheckStatus(check), |
| 1469 | description: trimmed(check.description), | 1479 | description: trimmed(check.description), |
| 1470 | url: trimmed(check.detailsUrl) ?? trimmed(check.targetUrl), | 1480 | url: trimmed(check.detailsUrl) ?? trimmed(check.targetUrl), |
| 1481 | ...(typeof check.isRequired === "boolean" ? { required: check.isRequired } : {}), | ||
| 1471 | }, | 1482 | }, |
| 1472 | workflowName: trimmed(check.workflowName), | 1483 | workflowName: trimmed(check.workflowName), |
| 1473 | at: realTimestamp(check.completedAt) ?? realTimestamp(check.startedAt), | 1484 | at: realTimestamp(check.completedAt) ?? realTimestamp(check.startedAt), |
apps/server/src/server.ts+11| ... | @@ -4,6 +4,7 @@ import * as Random from "effect/Random"; | ... | @@ -4,6 +4,7 @@ import * as Random from "effect/Random"; |
| 4 | import * as Semaphore from "effect/Semaphore"; | 4 | import * as Semaphore from "effect/Semaphore"; |
| 5 | import * as StorageCleanup from "./storageCleanup.ts"; | 5 | import * as StorageCleanup from "./storageCleanup.ts"; |
| 6 | import * as PullRequestSyncReactor from "./orchestration-v2/PullRequestSyncReactor.ts"; | 6 | import * as PullRequestSyncReactor from "./orchestration-v2/PullRequestSyncReactor.ts"; |
| 7 | import * as PullRequestWatchReactor from "./orchestration-v2/PullRequestWatchReactor.ts"; | ||
| 7 | // @effect-diagnostics nodeBuiltinImport:off | 8 | // @effect-diagnostics nodeBuiltinImport:off |
| 8 | import * as NodeHttp from "node:http"; | 9 | import * as NodeHttp from "node:http"; |
| 9 | 10 | ||
| ... | @@ -521,6 +522,16 @@ const RuntimeCoreDependenciesBaseLive = Layer.mergeAll( | ... | @@ -521,6 +522,16 @@ const RuntimeCoreDependenciesBaseLive = Layer.mergeAll( |
| 521 | Layer.provide(PullRequestServiceLive), | 522 | Layer.provide(PullRequestServiceLive), |
| 522 | Layer.provide(ProjectionStoreV2.layer), | 523 | Layer.provide(ProjectionStoreV2.layer), |
| 523 | ), | 524 | ), |
| 525 | Layer.effectDiscard( | ||
| 526 | Effect.gen(function* () { | ||
| 527 | const service = yield* PullRequestWatchReactor.PullRequestWatchReactor; | ||
| 528 | yield* service.start(); | ||
| 529 | }), | ||
| 530 | ).pipe( | ||
| 531 | Layer.provide(PullRequestWatchReactor.layer), | ||
| 532 | Layer.provide(PullRequestServiceLive), | ||
| 533 | Layer.provide(ProjectionStoreV2.layer), | ||
| 534 | ), | ||
| 524 | // Subscribes to `account.rate-limits.updated` so usage bars track live | 535 | // Subscribes to `account.rate-limits.updated` so usage bars track live |
| 525 | // telemetry instead of waiting for the next status probe. | 536 | // telemetry instead of waiting for the next status probe. |
| 526 | ProviderUsageLimitsIngestionLive, | 537 | ProviderUsageLimitsIngestionLive, |
apps/web/src/components/chat/MessagesTimeline.tsx+2| ... | @@ -3630,6 +3630,8 @@ function toolGroupSummaryIconName( | ... | @@ -3630,6 +3630,8 @@ function toolGroupSummaryIconName( |
| 3630 | case "link-pr": | 3630 | case "link-pr": |
| 3631 | case "unlink-pr": | 3631 | case "unlink-pr": |
| 3632 | case "list-prs": | 3632 | case "list-prs": |
| 3633 | case "watch-pr": | ||
| 3634 | case "unwatch-pr": | ||
| 3633 | return "pull-request"; | 3635 | return "pull-request"; |
| 3634 | case "read": | 3636 | case "read": |
| 3635 | return "eye"; | 3637 | return "eye"; |
apps/web/src/components/pullRequest/ThreadPullRequestsPanel.tsx+53-4| ... | @@ -3,7 +3,14 @@ import { | ... | @@ -3,7 +3,14 @@ import { |
| 3 | resolveThreadPullRequestChains, | 3 | resolveThreadPullRequestChains, |
| 4 | visibleThreadPullRequests, | 4 | visibleThreadPullRequests, |
| 5 | } from "@t3tools/shared/threadPullRequests"; | 5 | } from "@t3tools/shared/threadPullRequests"; |
| 6 | import { ArrowUpRightIcon, LinkIcon, MoreHorizontalIcon, PlusIcon } from "lucide-react"; | 6 | import { |
| 7 | ArrowUpRightIcon, | ||
| 8 | EyeIcon, | ||
| 9 | EyeOffIcon, | ||
| 10 | LinkIcon, | ||
| 11 | MoreHorizontalIcon, | ||
| 12 | PlusIcon, | ||
| 13 | } from "lucide-react"; | ||
| 7 | import { useCallback, useMemo } from "react"; | 14 | import { useCallback, useMemo } from "react"; |
| 8 | 15 | ||
| 9 | import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; | 16 | import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; |
| ... | @@ -68,14 +75,19 @@ function LinkRow({ | ... | @@ -68,14 +75,19 @@ function LinkRow({ |
| 68 | line, | 75 | line, |
| 69 | threadRef, | 76 | threadRef, |
| 70 | onUnlink, | 77 | onUnlink, |
| 78 | onSetWatching, | ||
| 71 | }: { | 79 | }: { |
| 72 | line: PullRequestListLine; | 80 | line: PullRequestListLine; |
| 73 | threadRef: ScopedThreadRef; | 81 | threadRef: ScopedThreadRef; |
| 74 | onUnlink: (link: ThreadPullRequestLink) => void; | 82 | onUnlink: (link: ThreadPullRequestLink) => void; |
| 83 | /** Null when the environment cannot watch pull requests. */ | ||
| 84 | onSetWatching: ((link: ThreadPullRequestLink, watching: boolean) => void) | null; | ||
| 75 | }) { | 85 | }) { |
| 76 | const openPrLink = useOpenPrLink(threadRef); | 86 | const openPrLink = useOpenPrLink(threadRef); |
| 77 | const { link, depth, stack } = line; | 87 | const { link, depth, stack } = line; |
| 78 | const snapshot = link.snapshot; | 88 | const snapshot = link.snapshot; |
| 89 | const open = snapshot === null || snapshot.state === "open"; | ||
| 90 | const watching = link.watch !== undefined; | ||
| 79 | return ( | 91 | return ( |
| 80 | <div | 92 | <div |
| 81 | className={cn(PULL_REQUEST_ROW_CLASS, "relative hover:bg-accent/60")} | 93 | className={cn(PULL_REQUEST_ROW_CLASS, "relative hover:bg-accent/60")} |
| ... | @@ -116,10 +128,21 @@ function LinkRow({ | ... | @@ -116,10 +128,21 @@ function LinkRow({ |
| 116 | } | 128 | } |
| 117 | title={snapshot?.title ?? link.repository} | 129 | title={snapshot?.title ?? link.repository} |
| 118 | signals={ | 130 | signals={ |
| 119 | snapshot?.state === "open" ? ( | 131 | open ? ( |
| 120 | <> | 132 | <> |
| 121 | {snapshot.checksState ? <ChecksGlyph state={snapshot.checksState} /> : null} | 133 | {watching ? ( |
| 122 | {snapshot.reviewDecision ? ( | 134 | <Tooltip> |
| 135 | <TooltipTrigger render={<span className="inline-flex shrink-0" />}> | ||
| 136 | <EyeIcon role="img" aria-label="Watching" className="size-3.5" /> | ||
| 137 | </TooltipTrigger> | ||
| 138 | <TooltipPopup> | ||
| 139 | Watching: the agent wakes when checks finish, someone comments, or the branch | ||
| 140 | conflicts | ||
| 141 | </TooltipPopup> | ||
| 142 | </Tooltip> | ||
| 143 | ) : null} | ||
| 144 | {snapshot?.checksState ? <ChecksGlyph state={snapshot.checksState} /> : null} | ||
| 145 | {snapshot?.reviewDecision ? ( | ||
| 123 | <PullRequestReviewDecisionGlyph decision={snapshot.reviewDecision} /> | 146 | <PullRequestReviewDecisionGlyph decision={snapshot.reviewDecision} /> |
| 124 | ) : null} | 147 | ) : null} |
| 125 | </> | 148 | </> |
| ... | @@ -220,6 +243,12 @@ function LinkRow({ | ... | @@ -220,6 +243,12 @@ function LinkRow({ |
| 220 | <ArrowUpRightIcon className="size-3.5" /> | 243 | <ArrowUpRightIcon className="size-3.5" /> |
| 221 | Open | 244 | Open |
| 222 | </MenuItem> | 245 | </MenuItem> |
| 246 | {onSetWatching !== null && open ? ( | ||
| 247 | <MenuItem onClick={() => onSetWatching(link, !watching)}> | ||
| 248 | {watching ? <EyeOffIcon className="size-3.5" /> : <EyeIcon className="size-3.5" />} | ||
| 249 | {watching ? "Stop watching" : "Watch for changes"} | ||
| 250 | </MenuItem> | ||
| 251 | ) : null} | ||
| 223 | <MenuItem onClick={() => onUnlink(link)}> | 252 | <MenuItem onClick={() => onUnlink(link)}> |
| 224 | <PullRequestGlyph.unlink className="size-3.5" /> | 253 | <PullRequestGlyph.unlink className="size-3.5" /> |
| 225 | {link.source === "stack" ? "Dismiss from thread" : "Unlink from thread"} | 254 | {link.source === "stack" ? "Dismiss from thread" : "Unlink from thread"} |
| ... | @@ -248,6 +277,10 @@ function EnabledThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThread | ... | @@ -248,6 +277,10 @@ function EnabledThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThread |
| 248 | const thread = useThreadShell(threadRef); | 277 | const thread = useThreadShell(threadRef); |
| 249 | const openLinkDialog = useCallback(() => openLinkPullRequestDialog(threadRef), [threadRef]); | 278 | const openLinkDialog = useCallback(() => openLinkPullRequestDialog(threadRef), [threadRef]); |
| 250 | const unlink = useAtomCommand(threadEnvironment.unlinkPullRequest, { reportFailure: true }); | 279 | const unlink = useAtomCommand(threadEnvironment.unlinkPullRequest, { reportFailure: true }); |
| 280 | const watch = useAtomCommand(threadEnvironment.watchPullRequest, { reportFailure: true }); | ||
| 281 | const supportsWatch = | ||
| 282 | useServerConfigs().get(threadRef.environmentId)?.environment.capabilities | ||
| 283 | .threadPullRequestWatch === true; | ||
| 251 | const links = useMemo(() => visibleThreadPullRequests(thread?.pullRequests ?? []), [thread]); | 284 | const links = useMemo(() => visibleThreadPullRequests(thread?.pullRequests ?? []), [thread]); |
| 252 | const lines = useMemo(() => pullRequestListLines(resolveThreadPullRequestChains(links)), [links]); | 285 | const lines = useMemo(() => pullRequestListLines(resolveThreadPullRequestChains(links)), [links]); |
| 253 | const handleUnlink = useCallback( | 286 | const handleUnlink = useCallback( |
| ... | @@ -264,6 +297,21 @@ function EnabledThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThread | ... | @@ -264,6 +297,21 @@ function EnabledThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThread |
| 264 | }, | 297 | }, |
| 265 | [threadRef, unlink], | 298 | [threadRef, unlink], |
| 266 | ); | 299 | ); |
| 300 | const handleSetWatching = useCallback( | ||
| 301 | (link: ThreadPullRequestLink, watching: boolean) => { | ||
| 302 | void watch({ | ||
| 303 | environmentId: threadRef.environmentId, | ||
| 304 | input: { | ||
| 305 | threadId: threadRef.threadId, | ||
| 306 | host: link.host, | ||
| 307 | repository: link.repository, | ||
| 308 | number: link.number, | ||
| 309 | watching, | ||
| 310 | }, | ||
| 311 | }); | ||
| 312 | }, | ||
| 313 | [threadRef, watch], | ||
| 314 | ); | ||
| 267 | const openCount = useMemo( | 315 | const openCount = useMemo( |
| 268 | () => links.filter((link) => link.snapshot === null || link.snapshot.state === "open").length, | 316 | () => links.filter((link) => link.snapshot === null || link.snapshot.state === "open").length, |
| 269 | [links], | 317 | [links], |
| ... | @@ -304,6 +352,7 @@ function EnabledThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThread | ... | @@ -304,6 +352,7 @@ function EnabledThreadPullRequestsPanel({ threadRef }: { threadRef: ScopedThread |
| 304 | line={line} | 352 | line={line} |
| 305 | threadRef={threadRef} | 353 | threadRef={threadRef} |
| 306 | onUnlink={handleUnlink} | 354 | onUnlink={handleUnlink} |
| 355 | onSetWatching={supportsWatch ? handleSetWatching : null} | ||
| 307 | /> | 356 | /> |
| 308 | ))} | 357 | ))} |
| 309 | </div> | 358 | </div> |
docs/user/source-control.md+7| ... | @@ -181,6 +181,13 @@ closed reviews refresh periodically so reopening one on the host is detected. Me | ... | @@ -181,6 +181,13 @@ closed reviews refresh periodically so reopening one on the host is detected. Me |
| 181 | when requested. With **Auto-settle merged threads** enabled, a thread can settle after every linked | 181 | when requested. With **Auto-settle merged threads** enabled, a thread can settle after every linked |
| 182 | review is terminal. An open or unsynced link keeps it active. | 182 | review is terminal. An open or unsynced link keeps it active. |
| 183 | 183 | ||
| 184 | Ask the agent to watch, monitor, or babysit a pull request and it calls `watch_pull_request`. While | ||
| 185 | the thread is active, the server checks the pull request every minute and wakes the agent when a check | ||
| 186 | fails, the required checks pass, someone else comments or reviews, or the branch starts to conflict. | ||
| 187 | Comments from your own account do not wake it. Watching ends when the pull request merges or closes, | ||
| 188 | after 10 wakes in a row that bring only comments, or when the server cannot read the pull request for | ||
| 189 | 15 minutes. To start or stop it yourself, use the row menu in the **Linked pull requests** panel. | ||
| 190 | |||
| 184 | Cross-repository links use a project on the same host. Azure DevOps reviews require a project checked | 191 | Cross-repository links use a project on the same host. Azure DevOps reviews require a project checked |
| 185 | out from the matching organization and repository. | 192 | out from the matching organization and repository. |
| 186 | 193 |
packages/client-runtime/src/operations/commands.ts+14| ... | @@ -1027,6 +1027,20 @@ export const linkThreadPullRequest = Effect.fn("EnvironmentCommands.linkThreadPu | ... | @@ -1027,6 +1027,20 @@ export const linkThreadPullRequest = Effect.fn("EnvironmentCommands.linkThreadPu |
| 1027 | }); | 1027 | }); |
| 1028 | }, | 1028 | }, |
| 1029 | ); | 1029 | ); |
| 1030 | export type WatchThreadPullRequestInput = Omit< | ||
| 1031 | Extract<OrchestrationV2Command, { type: "thread.pull-request.watch" }>, | ||
| 1032 | "type" | "commandId" | ||
| 1033 | > & | ||
| 1034 | CommandMetadata; | ||
| 1035 | export const watchThreadPullRequest = Effect.fn("EnvironmentCommands.watchThreadPullRequest")( | ||
| 1036 | function* (input: WatchThreadPullRequestInput) { | ||
| 1037 | return yield* dispatch({ | ||
| 1038 | ...input, | ||
| 1039 | type: "thread.pull-request.watch", | ||
| 1040 | commandId: yield* allocateCommandId(input), | ||
| 1041 | }); | ||
| 1042 | }, | ||
| 1043 | ); | ||
| 1030 | export const unlinkThreadPullRequest = Effect.fn("EnvironmentCommands.unlinkThreadPullRequest")( | 1044 | export const unlinkThreadPullRequest = Effect.fn("EnvironmentCommands.unlinkThreadPullRequest")( |
| 1031 | function* (input: UnlinkThreadPullRequestInput) { | 1045 | function* (input: UnlinkThreadPullRequestInput) { |
| 1032 | return yield* dispatch({ | 1046 | return yield* dispatch({ |
packages/client-runtime/src/state/threadCommands.ts+9| ... | @@ -48,6 +48,7 @@ import { | ... | @@ -48,6 +48,7 @@ import { |
| 48 | type UnarchiveThreadInput, | 48 | type UnarchiveThreadInput, |
| 49 | type UnlinkThreadPullRequestInput, | 49 | type UnlinkThreadPullRequestInput, |
| 50 | type UnpinThreadInput, | 50 | type UnpinThreadInput, |
| 51 | type WatchThreadPullRequestInput, | ||
| 51 | type UnsettleThreadInput, | 52 | type UnsettleThreadInput, |
| 52 | type UnsnoozeThreadInput, | 53 | type UnsnoozeThreadInput, |
| 53 | type UpdateThreadMetadataInput, | 54 | type UpdateThreadMetadataInput, |
| ... | @@ -86,6 +87,7 @@ import { | ... | @@ -86,6 +87,7 @@ import { |
| 86 | unsnoozeThread, | 87 | unsnoozeThread, |
| 87 | updateThreadMetadata, | 88 | updateThreadMetadata, |
| 88 | visitThread, | 89 | visitThread, |
| 90 | watchThreadPullRequest, | ||
| 89 | } from "../operations/commands.ts"; | 91 | } from "../operations/commands.ts"; |
| 90 | import type { EnvironmentRegistry } from "../connection/registry.ts"; | 92 | import type { EnvironmentRegistry } from "../connection/registry.ts"; |
| 91 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; | 93 | import * as EnvironmentSupervisor from "../connection/supervisor.ts"; |
| ... | @@ -130,6 +132,7 @@ export type { | ... | @@ -130,6 +132,7 @@ export type { |
| 130 | UnsnoozeThreadInput, | 132 | UnsnoozeThreadInput, |
| 131 | UpdateThreadMetadataInput, | 133 | UpdateThreadMetadataInput, |
| 132 | VisitThreadInput, | 134 | VisitThreadInput, |
| 135 | WatchThreadPullRequestInput, | ||
| 133 | } from "../operations/commands.ts"; | 136 | } from "../operations/commands.ts"; |
| 134 | 137 | ||
| 135 | export function createThreadEnvironmentAtoms<R, E>( | 138 | export function createThreadEnvironmentAtoms<R, E>( |
| ... | @@ -251,6 +254,12 @@ export function createThreadEnvironmentAtoms<R, E>( | ... | @@ -251,6 +254,12 @@ export function createThreadEnvironmentAtoms<R, E>( |
| 251 | scheduler, | 254 | scheduler, |
| 252 | concurrency, | 255 | concurrency, |
| 253 | }), | 256 | }), |
| 257 | watchPullRequest: createEnvironmentCommand(runtime, { | ||
| 258 | label: "environment-data:commands:thread:watch-pull-request", | ||
| 259 | execute: (input: WatchThreadPullRequestInput) => watchThreadPullRequest(input), | ||
| 260 | scheduler, | ||
| 261 | concurrency, | ||
| 262 | }), | ||
| 254 | setRuntimeMode: createEnvironmentCommand(runtime, { | 263 | setRuntimeMode: createEnvironmentCommand(runtime, { |
| 255 | label: "environment-data:commands:thread:set-runtime-mode", | 264 | label: "environment-data:commands:thread:set-runtime-mode", |
| 256 | execute: (input: SetThreadRuntimeModeInput) => setThreadRuntimeMode(input), | 265 | execute: (input: SetThreadRuntimeModeInput) => setThreadRuntimeMode(input), |
packages/client-runtime/src/t3ToolSummary.ts+10| ... | @@ -372,6 +372,16 @@ export function summarizeT3ToolCalls( | ... | @@ -372,6 +372,16 @@ export function summarizeT3ToolCalls( |
| 372 | case "unlink-pr": | 372 | case "unlink-pr": |
| 373 | label = phrase("Unlinked", "unlink", quantity(selected.length, "pull request")); | 373 | label = phrase("Unlinked", "unlink", quantity(selected.length, "pull request")); |
| 374 | break; | 374 | break; |
| 375 | case "watch-pr": | ||
| 376 | label = phrase("Watching", "watch", quantity(selected.length, "pull request")); | ||
| 377 | break; | ||
| 378 | case "unwatch-pr": | ||
| 379 | label = phrase( | ||
| 380 | "Stopped watching", | ||
| 381 | "stop watching", | ||
| 382 | quantity(selected.length, "pull request"), | ||
| 383 | ); | ||
| 384 | break; | ||
| 375 | case "list-prs": | 385 | case "list-prs": |
| 376 | label = phrase( | 386 | label = phrase( |
| 377 | "Checked", | 387 | "Checked", |
packages/client-runtime/src/work-log/presentation.ts+9-1| ... | @@ -85,6 +85,8 @@ export type ToolGroupAction = | ... | @@ -85,6 +85,8 @@ export type ToolGroupAction = |
| 85 | | "link-pr" | 85 | | "link-pr" |
| 86 | | "unlink-pr" | 86 | | "unlink-pr" |
| 87 | | "list-prs" | 87 | | "list-prs" |
| 88 | | "watch-pr" | ||
| 89 | | "unwatch-pr" | ||
| 88 | | "read" | 90 | | "read" |
| 89 | | "edit" | 91 | | "edit" |
| 90 | | "command" | 92 | | "command" |
| ... | @@ -156,7 +158,9 @@ function resolveT3McpToolPresentation( | ... | @@ -156,7 +158,9 @@ function resolveT3McpToolPresentation( |
| 156 | const actionKind = | 158 | const actionKind = |
| 157 | definition.summaryAction === "link-pr" || | 159 | definition.summaryAction === "link-pr" || |
| 158 | definition.summaryAction === "unlink-pr" || | 160 | definition.summaryAction === "unlink-pr" || |
| 159 | definition.summaryAction === "list-prs" | 161 | definition.summaryAction === "list-prs" || |
| 162 | definition.summaryAction === "watch-pr" || | ||
| 163 | definition.summaryAction === "unwatch-pr" | ||
| 160 | ? definition.summaryAction | 164 | ? definition.summaryAction |
| 161 | : undefined; | 165 | : undefined; |
| 162 | const payload = asRecord(data); | 166 | const payload = asRecord(data); |
| ... | @@ -539,6 +543,10 @@ function toolGroupActionLabel(action: ToolGroupAction, count: number): string { | ... | @@ -539,6 +543,10 @@ function toolGroupActionLabel(action: ToolGroupAction, count: number): string { |
| 539 | return `Linked ${count} ${count === 1 ? "pull request" : "pull requests"}`; | 543 | return `Linked ${count} ${count === 1 ? "pull request" : "pull requests"}`; |
| 540 | case "unlink-pr": | 544 | case "unlink-pr": |
| 541 | return `Unlinked ${count} ${count === 1 ? "pull request" : "pull requests"}`; | 545 | return `Unlinked ${count} ${count === 1 ? "pull request" : "pull requests"}`; |
| 546 | case "watch-pr": | ||
| 547 | return `Watching ${count} ${count === 1 ? "pull request" : "pull requests"}`; | ||
| 548 | case "unwatch-pr": | ||
| 549 | return `Stopped watching ${count} ${count === 1 ? "pull request" : "pull requests"}`; | ||
| 542 | case "list-prs": | 550 | case "list-prs": |
| 543 | return count === 1 | 551 | return count === 1 |
| 544 | ? "Checked linked pull requests" | 552 | ? "Checked linked pull requests" |
packages/contracts/src/environment.ts+2| ... | @@ -162,6 +162,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ | ... | @@ -162,6 +162,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ |
| 162 | shaping and validation when this is absent. */ | 162 | shaping and validation when this is absent. */ |
| 163 | serverResolvedCommandContext: Schema.optionalKey(Schema.Boolean), | 163 | serverResolvedCommandContext: Schema.optionalKey(Schema.Boolean), |
| 164 | threadPullRequests: Schema.optionalKey(Schema.Boolean), | 164 | threadPullRequests: Schema.optionalKey(Schema.Boolean), |
| 165 | /** Server understands thread.pull-request.watch and wakes agents on pull request changes. */ | ||
| 166 | threadPullRequestWatch: Schema.optionalKey(Schema.Boolean), | ||
| 165 | pullRequestStackActions: Schema.optionalKey(Schema.Boolean), | 167 | pullRequestStackActions: Schema.optionalKey(Schema.Boolean), |
| 166 | /** The update path clients should offer for this server. Absent on | 168 | /** The update path clients should offer for this server. Absent on |
| 167 | servers that must be relaunched manually (dev checkouts, Windows | 169 | servers that must be relaunched manually (dev checkouts, Windows |
packages/contracts/src/orchestrationV2.ts+34| ... | @@ -46,6 +46,7 @@ import { | ... | @@ -46,6 +46,7 @@ import { |
| 46 | ThreadPullRequestLinkSource, | 46 | ThreadPullRequestLinkSource, |
| 47 | ThreadPullRequestSnapshot, | 47 | ThreadPullRequestSnapshot, |
| 48 | ThreadPullRequestStack, | 48 | ThreadPullRequestStack, |
| 49 | ThreadPullRequestWatch, | ||
| 49 | } from "./threadPullRequest.ts"; | 50 | } from "./threadPullRequest.ts"; |
| 50 | import { | 51 | import { |
| 51 | ProviderApprovalDecision, | 52 | ProviderApprovalDecision, |
| ... | @@ -2600,6 +2601,18 @@ export const OrchestrationV2Command = Schema.Union([ | ... | @@ -2600,6 +2601,18 @@ export const OrchestrationV2Command = Schema.Union([ |
| 2600 | snapshot: ThreadPullRequestSnapshot, | 2601 | snapshot: ThreadPullRequestSnapshot, |
| 2601 | stack: Schema.NullOr(ThreadPullRequestStack), | 2602 | stack: Schema.NullOr(ThreadPullRequestStack), |
| 2602 | }), | 2603 | }), |
| 2604 | /** Start or stop the server watching a linked pull request for this thread's agent. */ | ||
| 2605 | Schema.Struct({ | ||
| 2606 | type: Schema.Literal("thread.pull-request.watch"), | ||
| 2607 | commandId: CommandId, | ||
| 2608 | threadId: ThreadId, | ||
| 2609 | ...ThreadPullRequestKey.fields, | ||
| 2610 | watching: Schema.Boolean, | ||
| 2611 | /** Links the pull request first when starting a watch on one the thread has not linked. */ | ||
| 2612 | link: Schema.optional( | ||
| 2613 | Schema.Struct({ url: TrimmedNonEmptyString, source: ThreadPullRequestLinkSource }), | ||
| 2614 | ), | ||
| 2615 | }), | ||
| 2603 | Schema.Struct({ | 2616 | Schema.Struct({ |
| 2604 | type: Schema.Literal("thread.pull-request.sync"), | 2617 | type: Schema.Literal("thread.pull-request.sync"), |
| 2605 | commandId: CommandId, | 2618 | commandId: CommandId, |
| ... | @@ -2859,6 +2872,27 @@ export type OrchestrationV2Command = typeof OrchestrationV2Command.Type; | ... | @@ -2859,6 +2872,27 @@ export type OrchestrationV2Command = typeof OrchestrationV2Command.Type; |
| 2859 | * send them. | 2872 | * send them. |
| 2860 | */ | 2873 | */ |
| 2861 | const OrchestrationV2InternalCommand = Schema.Union([ | 2874 | const OrchestrationV2InternalCommand = Schema.Union([ |
| 2875 | /** | ||
| 2876 | * Records what a pull request watch saw, and wakes the agent in the same transaction when | ||
| 2877 | * `wake` is set. Rejected once the watch started at `startedAt` has ended, and a wake is | ||
| 2878 | * rejected on a settled or archived thread, so a read that raced either changes nothing. | ||
| 2879 | */ | ||
| 2880 | Schema.Struct({ | ||
| 2881 | type: Schema.Literal("thread.pull-request-watch.sync"), | ||
| 2882 | commandId: CommandId, | ||
| 2883 | threadId: ThreadId, | ||
| 2884 | ...ThreadPullRequestKey.fields, | ||
| 2885 | startedAt: IsoDateTime, | ||
| 2886 | /** The watch to record, or null to end it. */ | ||
| 2887 | watch: Schema.NullOr(ThreadPullRequestWatch), | ||
| 2888 | wake: Schema.optional( | ||
| 2889 | Schema.Struct({ | ||
| 2890 | messageId: MessageId, | ||
| 2891 | text: Schema.String, | ||
| 2892 | notification: OrchestrationV2Notification, | ||
| 2893 | }), | ||
| 2894 | ), | ||
| 2895 | }), | ||
| 2862 | /** Records that the provider rollback `requestId` failed for good. */ | 2896 | /** Records that the provider rollback `requestId` failed for good. */ |
| 2863 | Schema.Struct({ | 2897 | Schema.Struct({ |
| 2864 | type: Schema.Literal("checkpoint.rollback.fail"), | 2898 | type: Schema.Literal("checkpoint.rollback.fail"), |
packages/contracts/src/pullRequest.ts+4| ... | @@ -152,6 +152,8 @@ export const PullRequestCheck = Schema.Struct({ | ... | @@ -152,6 +152,8 @@ export const PullRequestCheck = Schema.Struct({ |
| 152 | status: PullRequestCheckStatus, | 152 | status: PullRequestCheckStatus, |
| 153 | description: Schema.NullOr(Schema.String), | 153 | description: Schema.NullOr(Schema.String), |
| 154 | url: Schema.NullOr(Schema.String), | 154 | url: Schema.NullOr(Schema.String), |
| 155 | /** The base branch requires this check to merge. Absent where the host does not say. */ | ||
| 156 | required: Schema.optional(Schema.Boolean), | ||
| 155 | }); | 157 | }); |
| 156 | export type PullRequestCheck = typeof PullRequestCheck.Type; | 158 | export type PullRequestCheck = typeof PullRequestCheck.Type; |
| 157 | 159 | ||
| ... | @@ -844,6 +846,8 @@ export const PullRequestDetail = Schema.Struct({ | ... | @@ -844,6 +846,8 @@ export const PullRequestDetail = Schema.Struct({ |
| 844 | changedFiles: NonNegativeInt, | 846 | changedFiles: NonNegativeInt, |
| 845 | headBranch: TrimmedNonEmptyString, | 847 | headBranch: TrimmedNonEmptyString, |
| 846 | headRepositoryNameWithOwner: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), | 848 | headRepositoryNameWithOwner: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), |
| 849 | /** The head commit, where the host reports it with the detail. */ | ||
| 850 | headSha: Schema.optional(TrimmedNonEmptyString), | ||
| 847 | baseBranch: TrimmedNonEmptyString, | 851 | baseBranch: TrimmedNonEmptyString, |
| 848 | createdAt: IsoDateTime, | 852 | createdAt: IsoDateTime, |
| 849 | updatedAt: IsoDateTime, | 853 | updatedAt: IsoDateTime, |
packages/contracts/src/threadPullRequest.ts+25| ... | @@ -92,6 +92,30 @@ export const ThreadPullRequestKey = Schema.Struct({ | ... | @@ -92,6 +92,30 @@ export const ThreadPullRequestKey = Schema.Struct({ |
| 92 | }); | 92 | }); |
| 93 | export type ThreadPullRequestKey = typeof ThreadPullRequestKey.Type; | 93 | export type ThreadPullRequestKey = typeof ThreadPullRequestKey.Type; |
| 94 | 94 | ||
| 95 | /** | ||
| 96 | * Present while the server watches the pull request for its thread. The server wakes the | ||
| 97 | * thread's agent when checks finish on the head commit, someone else comments, or the branch | ||
| 98 | * starts to conflict. The other fields record what the agent was last told, so each change is | ||
| 99 | * reported once. | ||
| 100 | */ | ||
| 101 | export const ThreadPullRequestWatch = Schema.Struct({ | ||
| 102 | startedAt: IsoDateTime, | ||
| 103 | /** Head commit at the last pass; null where the host does not report one. */ | ||
| 104 | headSha: Schema.NullOr(TrimmedNonEmptyString), | ||
| 105 | /** Failed checks on that commit the agent was told about; a rerun that fails again is news. */ | ||
| 106 | failedChecks: Schema.Array(TrimmedNonEmptyString), | ||
| 107 | /** The agent was told the required checks on that commit passed. */ | ||
| 108 | passed: Schema.Boolean, | ||
| 109 | /** Remarks from others created up to this host time were reported. */ | ||
| 110 | remarksThrough: IsoDateTime, | ||
| 111 | /** Remarks created exactly at `remarksThrough` that were reported, so a late one still counts. */ | ||
| 112 | remarkIds: Schema.Array(TrimmedNonEmptyString), | ||
| 113 | conflicting: Schema.Boolean, | ||
| 114 | /** Comment-only wakes in a row. Watching stops at a limit, so bots cannot loop it. */ | ||
| 115 | wakes: NonNegativeInt, | ||
| 116 | }); | ||
| 117 | export type ThreadPullRequestWatch = typeof ThreadPullRequestWatch.Type; | ||
| 118 | |||
| 95 | export const ThreadPullRequestLink = Schema.Struct({ | 119 | export const ThreadPullRequestLink = Schema.Struct({ |
| 96 | ...ThreadPullRequestKey.fields, | 120 | ...ThreadPullRequestKey.fields, |
| 97 | url: TrimmedNonEmptyString, | 121 | url: TrimmedNonEmptyString, |
| ... | @@ -99,5 +123,6 @@ export const ThreadPullRequestLink = Schema.Struct({ | ... | @@ -99,5 +123,6 @@ export const ThreadPullRequestLink = Schema.Struct({ |
| 99 | linkedAt: IsoDateTime, | 123 | linkedAt: IsoDateTime, |
| 100 | snapshot: Schema.NullOr(ThreadPullRequestSnapshot), | 124 | snapshot: Schema.NullOr(ThreadPullRequestSnapshot), |
| 101 | stack: Schema.NullOr(ThreadPullRequestStack), | 125 | stack: Schema.NullOr(ThreadPullRequestStack), |
| 126 | watch: Schema.optional(ThreadPullRequestWatch), | ||
| 102 | }); | 127 | }); |
| 103 | export type ThreadPullRequestLink = typeof ThreadPullRequestLink.Type; | 128 | export type ThreadPullRequestLink = typeof ThreadPullRequestLink.Type; |
packages/shared/src/t3McpToolPresentation.ts+12| ... | @@ -55,6 +55,8 @@ export type T3McpToolSummaryAction = | ... | @@ -55,6 +55,8 @@ export type T3McpToolSummaryAction = |
| 55 | | "link-pr" | 55 | | "link-pr" |
| 56 | | "unlink-pr" | 56 | | "unlink-pr" |
| 57 | | "list-prs" | 57 | | "list-prs" |
| 58 | | "watch-pr" | ||
| 59 | | "unwatch-pr" | ||
| 58 | | "browser" | 60 | | "browser" |
| 59 | | "device"; | 61 | | "device"; |
| 60 | 62 | ||
| ... | @@ -93,6 +95,16 @@ const T3_MCP_TOOLS: Readonly<Record<string, T3McpToolDefinition>> = { | ... | @@ -93,6 +95,16 @@ const T3_MCP_TOOLS: Readonly<Record<string, T3McpToolDefinition>> = { |
| 93 | "list-prs", | 95 | "list-prs", |
| 94 | "pull-request", | 96 | "pull-request", |
| 95 | ), | 97 | ), |
| 98 | watch_pull_request: tool( | ||
| 99 | ["Watch", "Watching", "Watching", "a pull request"], | ||
| 100 | "watch-pr", | ||
| 101 | "pull-request", | ||
| 102 | ), | ||
| 103 | unwatch_pull_request: tool( | ||
| 104 | ["Stop watching", "Stopping watching", "Stopped watching", "a pull request"], | ||
| 105 | "unwatch-pr", | ||
| 106 | "pull-request", | ||
| 107 | ), | ||
| 96 | orchestrator_capabilities: tool( | 108 | orchestrator_capabilities: tool( |
| 97 | ["Get", "Getting", "Got", "orchestration capabilities"], | 109 | ["Get", "Getting", "Got", "orchestration capabilities"], |
| 98 | "capabilities", | 110 | "capabilities", |