| author | |
| committer | |
| log | 51f7f77f87318a5329d3bab15d513fda5654200b |
| tree | 81497483a7275ff9611ea8e2365264bd4e1136a5 |
| parent | 68802a48f9a8f7f7db5ca4d605bdc818be62a804 |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 119 insertions(+), 4 deletions(-)
apps/server/src/mcp/toolkits/pullRequests/tools.ts+1-1| ... | ... | @@ -289,7 +289,7 @@ const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", { |
| 289 | 289 | |
| 290 | 290 | const WatchPullRequestTool = Tool.make("watch_pull_request", { |
| 291 | 291 | description: |
| 292 | "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.", | |
| 292 | "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 its thread settles, when T3 Code cannot read it for 15 minutes, or when you call unwatch_pull_request. Unsettle the thread before starting a new watch.", | |
| 293 | 293 | parameters: PullRequestTargetInput, |
| 294 | 294 | success: WatchPullRequestResult, |
| 295 | 295 | failure: PullRequestToolError, |
apps/server/src/orchestration-v2/Orchestrator.ts+12| ... | ... | @@ -2368,6 +2368,17 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 2368 | 2368 | threadId: command.threadId, |
| 2369 | 2369 | }); |
| 2370 | 2370 | } |
| 2371 | if ( | |
| 2372 | command.type === "thread.pull-request.watch" && | |
| 2373 | command.watching && | |
| 2374 | (thread.settledOverride === "settled" || thread.settledAt !== null) | |
| 2375 | ) { | |
| 2376 | return yield* new OrchestratorDispatchError({ | |
| 2377 | commandId: command.commandId, | |
| 2378 | commandType: command.type, | |
| 2379 | cause: `Thread ${command.threadId} is settled and cannot watch pull requests.`, | |
| 2380 | }); | |
| 2381 | } | |
| 2371 | 2382 | if ( |
| 2372 | 2383 | command.type === "thread.metadata.update" && |
| 2373 | 2384 | command.expectedWorktreePath !== undefined && |
| ... | ... | @@ -2685,6 +2696,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio |
| 2685 | 2696 | ...thread, |
| 2686 | 2697 | settledOverride: "settled", |
| 2687 | 2698 | settledAt: alreadySettled ? thread.settledAt : (command.settledAt ?? now), |
| 2699 | pullRequests: thread.pullRequests?.map((link) => withPullRequestWatch(link, undefined)), | |
| 2688 | 2700 | unsettledAt: null, |
| 2689 | 2701 | pinnedAt: null, |
| 2690 | 2702 | pinOrderKey: null, |
apps/server/src/orchestration-v2/PullRequestWatchReactor.ts+2-2| ... | ... | @@ -64,8 +64,8 @@ function watchesEqual(left: ThreadPullRequestWatch, right: ThreadPullRequestWatc |
| 64 | 64 | /** |
| 65 | 65 | * Wakes a thread's agent when a pull request it watches (`watch_pull_request`) needs a look: |
| 66 | 66 | * checks finished on the head commit, someone else commented, or the branch started to |
| 67 | * conflict. One pass a minute reads each watched pull request; settled threads wait until | |
| 68 | * they are active again, and a merged or closed pull request ends its watch. | |
| 67 | * conflict. One pass a minute reads each watched pull request; settling a thread ends its | |
| 68 | * watches, and a merged or closed pull request ends its watch. | |
| 69 | 69 | */ |
| 70 | 70 | export class PullRequestWatchReactor extends Context.Service< |
| 71 | 71 | PullRequestWatchReactor, |
apps/server/src/orchestration-v2/runtimeLayer.test.ts+102| ... | ... | @@ -2250,6 +2250,108 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { |
| 2250 | 2250 | }), |
| 2251 | 2251 | ); |
| 2252 | 2252 | |
| 2253 | it.effect.each(["manual", "automatic"])("settling ends every pull request watch: %s", (mode) => | |
| 2254 | Effect.gen(function* () { | |
| 2255 | const orchestrator = yield* Orchestrator.OrchestratorV2; | |
| 2256 | const maintenance = yield* ProjectionMaintenance.ProjectionMaintenanceV2; | |
| 2257 | const threadId = ThreadId.make(`pr-watch-settle-${mode}`); | |
| 2258 | yield* orchestrator.dispatch({ | |
| 2259 | type: "thread.create", | |
| 2260 | createdBy: "user", | |
| 2261 | creationSource: "web", | |
| 2262 | commandId: CommandId.make(`pr-watch-settle-create-${mode}`), | |
| 2263 | threadId, | |
| 2264 | projectId: ProjectId.make("pr-watch-settle-project"), | |
| 2265 | title: "Settle watched PRs", | |
| 2266 | modelSelection, | |
| 2267 | runtimeMode: "full-access", | |
| 2268 | interactionMode: "default", | |
| 2269 | branch: null, | |
| 2270 | worktreePath: null, | |
| 2271 | }); | |
| 2272 | const key = { host: "github.com", repository: "pingdotgg/t3code" }; | |
| 2273 | for (const number of [7, 8]) { | |
| 2274 | yield* orchestrator.dispatch({ | |
| 2275 | type: "thread.pull-request.watch", | |
| 2276 | commandId: CommandId.make(`pr-watch-settle-start-${mode}-${number}`), | |
| 2277 | threadId, | |
| 2278 | ...key, | |
| 2279 | number, | |
| 2280 | watching: true, | |
| 2281 | link: { url: `https://github.com/pingdotgg/t3code/pull/${number}`, source: "agent" }, | |
| 2282 | }); | |
| 2283 | } | |
| 2284 | const before = yield* orchestrator.getThreadShell(threadId); | |
| 2285 | const started = before?.pullRequests?.[0]?.watch; | |
| 2286 | assert.isDefined(started); | |
| 2287 | if (started === undefined || before === null) return; | |
| 2288 | yield* orchestrator.dispatch({ | |
| 2289 | ...(mode === "manual" | |
| 2290 | ? { type: "thread.settle" as const } | |
| 2291 | : { type: "thread.auto-settle" as const, snapshotAt: before.updatedAt }), | |
| 2292 | commandId: CommandId.make(`pr-watch-settle-${mode}`), | |
| 2293 | threadId, | |
| 2294 | }); | |
| 2295 | const expected = before.pullRequests?.map(({ watch: _watch, ...link }) => link); | |
| 2296 | assert.deepEqual((yield* orchestrator.getThreadShell(threadId))?.pullRequests, expected); | |
| 2297 | assert.isTrue((yield* maintenance.rebuild).valid); | |
| 2298 | assert.deepEqual((yield* orchestrator.getThreadShell(threadId))?.pullRequests, expected); | |
| 2299 | ||
| 2300 | for (const wake of [ | |
| 2301 | undefined, | |
| 2302 | { | |
| 2303 | messageId: MessageId.make(`pr-watch-settle-late-wake-${mode}`), | |
| 2304 | text: "Update", | |
| 2305 | notification: { | |
| 2306 | source: { kind: "monitor" as const }, | |
| 2307 | outcome: "updated" as const, | |
| 2308 | summary: "#7", | |
| 2309 | }, | |
| 2310 | }, | |
| 2311 | ]) { | |
| 2312 | const late = yield* orchestrator | |
| 2313 | .dispatch({ | |
| 2314 | type: "thread.pull-request-watch.sync", | |
| 2315 | commandId: CommandId.make(`pr-watch-settle-late-${mode}-${wake !== undefined}`), | |
| 2316 | threadId, | |
| 2317 | ...key, | |
| 2318 | number: 7, | |
| 2319 | startedAt: started.startedAt, | |
| 2320 | watch: { ...started, headSha: "late", wakes: 1 }, | |
| 2321 | ...(wake === undefined ? {} : { wake }), | |
| 2322 | }) | |
| 2323 | .pipe(Effect.flip); | |
| 2324 | assert.equal(late._tag, "OrchestratorDispatchError"); | |
| 2325 | } | |
| 2326 | const restart = { | |
| 2327 | type: "thread.pull-request.watch" as const, | |
| 2328 | threadId, | |
| 2329 | ...key, | |
| 2330 | number: 7, | |
| 2331 | watching: true, | |
| 2332 | }; | |
| 2333 | const blocked = yield* orchestrator | |
| 2334 | .dispatch({ | |
| 2335 | ...restart, | |
| 2336 | commandId: CommandId.make(`pr-watch-settle-blocked-${mode}`), | |
| 2337 | }) | |
| 2338 | .pipe(Effect.flip); | |
| 2339 | assert.equal(blocked._tag, "OrchestratorDispatchError"); | |
| 2340 | assert.deepEqual((yield* orchestrator.getThreadRecords(threadId, ["messages"])).messages, []); | |
| 2341 | yield* orchestrator.dispatch({ | |
| 2342 | type: "thread.unsettle", | |
| 2343 | commandId: CommandId.make(`pr-watch-settle-unsettle-${mode}`), | |
| 2344 | threadId, | |
| 2345 | reason: "user", | |
| 2346 | }); | |
| 2347 | yield* orchestrator.dispatch({ | |
| 2348 | ...restart, | |
| 2349 | commandId: CommandId.make(`pr-watch-settle-restart-${mode}`), | |
| 2350 | }); | |
| 2351 | assert.isDefined((yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.watch); | |
| 2352 | }), | |
| 2353 | ); | |
| 2354 | ||
| 2253 | 2355 | it.effect("ends a watch it cannot read, and tells the agent", () => |
| 2254 | 2356 | Effect.gen(function* () { |
| 2255 | 2357 | const orchestrator = yield* Orchestrator.OrchestratorV2; |
docs/user/source-control.md+2-1| ... | ... | @@ -190,7 +190,8 @@ the thread is active, the server checks the pull request every minute and wakes |
| 190 | 190 | fails, the required checks pass, someone else comments or reviews, or the branch starts to conflict. |
| 191 | 191 | Comments from your own account do not wake it. Watching ends when the pull request merges or closes, |
| 192 | 192 | after 10 wakes in a row that bring only comments, or when the server cannot read the pull request for |
| 193 | 15 minutes. To start or stop it yourself, use the row menu in the **Linked pull requests** panel. | |
| 193 | 15 minutes. Settling a thread also ends all its watches. Unsettle the thread before starting a new | |
| 194 | watch. To start or stop it yourself, use the row menu in the **Linked pull requests** panel. | |
| 194 | 195 | |
| 195 | 196 | Cross-repository links use a project on the same host. Azure DevOps reviews require a project checked |
| 196 | 197 | out from the matching organization and repository. |