| author | |
| committer | |
| log | 207f6a10b05cbdaee6fc650e915ba09dbe39d428 |
| tree | 4bf21c7d45af9db02702c21af03df1eec8c2fc91 |
| parent | f5eb25071c042c4a729ddae9fd07bd81804907da |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>15 files changed, 357 insertions(+), 10 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 its thread settles, when T3 Code cannot read it for 15 minutes, when the user stops this thread, or when you call unwatch_pull_request. Unsettle the thread before starting a new watch.", | |
| 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. While T3 Code watches, the thread stays in the user's Working list, not their inbox. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox. Watching ends when the pull request merges or closes, when its thread settles, when T3 Code cannot read it for 15 minutes, when the user stops this thread, 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/ProjectionStore.test.ts+123| ... | ... | @@ -21,6 +21,7 @@ import { |
| 21 | 21 | RunId, |
| 22 | 22 | ThreadId, |
| 23 | 23 | TurnItemId, |
| 24 | type ThreadPullRequestLink, | |
| 24 | 25 | } from "@t3tools/contracts"; |
| 25 | 26 | import * as DateTime from "effect/DateTime"; |
| 26 | 27 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -4401,6 +4402,128 @@ it.layer(TestLayer)("ProjectionStoreV2", (it) => { |
| 4401 | 4402 | }), |
| 4402 | 4403 | ); |
| 4403 | 4404 | |
| 4405 | it.effect("a pull request watch keeps a finished thread working until it ends", () => | |
| 4406 | Effect.gen(function* () { | |
| 4407 | const store = yield* ProjectionStore.ProjectionStoreV2; | |
| 4408 | const threadId = ThreadId.make("thread:watched-pull-request"); | |
| 4409 | const runId = RunId.make("run:watched-pull-request"); | |
| 4410 | const at = DateTime.makeUnsafe("2026-10-05T12:00:00.000Z"); | |
| 4411 | const thread = { | |
| 4412 | createdBy: "user" as const, | |
| 4413 | creationSource: "web" as const, | |
| 4414 | id: threadId, | |
| 4415 | projectId: ProjectId.make("project:watched-pull-request"), | |
| 4416 | title: "Babysit the PR", | |
| 4417 | providerInstanceId, | |
| 4418 | modelSelection, | |
| 4419 | runtimeMode: "full-access" as const, | |
| 4420 | interactionMode: "default" as const, | |
| 4421 | branch: null, | |
| 4422 | worktreePath: null, | |
| 4423 | activeProviderThreadId: null, | |
| 4424 | lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, | |
| 4425 | forkedFrom: null, | |
| 4426 | createdAt: at, | |
| 4427 | updatedAt: at, | |
| 4428 | archivedAt: null, | |
| 4429 | settledOverride: null, | |
| 4430 | settledAt: null, | |
| 4431 | lastVisitedAt: null, | |
| 4432 | deletedAt: null, | |
| 4433 | }; | |
| 4434 | yield* store.apply({ | |
| 4435 | id: EventId.make("event:watched-pull-request:thread"), | |
| 4436 | type: "thread.created", | |
| 4437 | threadId, | |
| 4438 | occurredAt: at, | |
| 4439 | payload: thread, | |
| 4440 | }); | |
| 4441 | yield* store.apply({ | |
| 4442 | id: EventId.make("event:watched-pull-request:run"), | |
| 4443 | type: "run.created", | |
| 4444 | threadId, | |
| 4445 | runId, | |
| 4446 | driver, | |
| 4447 | providerInstanceId, | |
| 4448 | occurredAt: at, | |
| 4449 | payload: { | |
| 4450 | id: runId, | |
| 4451 | threadId, | |
| 4452 | ordinal: 1, | |
| 4453 | providerInstanceId, | |
| 4454 | modelSelection, | |
| 4455 | providerThreadId: null, | |
| 4456 | userMessageId: MessageId.make("message:watched-pull-request"), | |
| 4457 | rootNodeId: null, | |
| 4458 | activeAttemptId: null, | |
| 4459 | status: "completed", | |
| 4460 | requestedAt: at, | |
| 4461 | startedAt: at, | |
| 4462 | completedAt: at, | |
| 4463 | checkpointId: null, | |
| 4464 | contextHandoffId: null, | |
| 4465 | }, | |
| 4466 | }); | |
| 4467 | const link = { | |
| 4468 | host: "github.com", | |
| 4469 | repository: "pingdotgg/t3code", | |
| 4470 | number: 7, | |
| 4471 | url: "https://github.com/pingdotgg/t3code/pull/7", | |
| 4472 | source: "agent" as const, | |
| 4473 | linkedAt: DateTime.formatIso(at), | |
| 4474 | snapshot: null, | |
| 4475 | stack: null, | |
| 4476 | }; | |
| 4477 | const syncPullRequests = (id: string, pullRequests: ReadonlyArray<ThreadPullRequestLink>) => | |
| 4478 | store.apply({ | |
| 4479 | id: EventId.make(`event:watched-pull-request:${id}`), | |
| 4480 | type: "thread.pull-request-synced", | |
| 4481 | threadId, | |
| 4482 | occurredAt: at, | |
| 4483 | payload: { ...thread, pullRequests }, | |
| 4484 | }); | |
| 4485 | const project = { title: "Project" }; | |
| 4486 | const environmentId = EnvironmentId.make("environment:watched-pull-request"); | |
| 4487 | const phase = Effect.gen(function* () { | |
| 4488 | const shell = yield* store.getThreadShell(threadId); | |
| 4489 | const listed = (yield* store.getShellSnapshot()).threads.find( | |
| 4490 | (candidate) => candidate.id === threadId, | |
| 4491 | ); | |
| 4492 | assert.deepEqual(listed?.pendingBackgroundTasks, shell?.pendingBackgroundTasks); | |
| 4493 | return shell && projectThreadAwarenessV2({ environmentId, project, thread: shell })?.phase; | |
| 4494 | }); | |
| 4495 | ||
| 4496 | yield* syncPullRequests("watched", [ | |
| 4497 | { | |
| 4498 | ...link, | |
| 4499 | watch: { | |
| 4500 | startedAt: DateTime.formatIso(at), | |
| 4501 | headSha: null, | |
| 4502 | failedChecks: [], | |
| 4503 | passed: false, | |
| 4504 | passedChecks: [], | |
| 4505 | remarksThrough: DateTime.formatIso(at), | |
| 4506 | remarkIds: [], | |
| 4507 | conflicting: false, | |
| 4508 | wakes: 0, | |
| 4509 | }, | |
| 4510 | }, | |
| 4511 | ]); | |
| 4512 | assert.deepEqual((yield* store.getThreadShell(threadId))?.pendingBackgroundTasks, [ | |
| 4513 | { | |
| 4514 | taskId: "pull-request-watch:github.com/pingdotgg/t3code#7", | |
| 4515 | description: "Watching pull request #7", | |
| 4516 | kind: "monitor", | |
| 4517 | }, | |
| 4518 | ]); | |
| 4519 | assert.equal(yield* phase, "running"); | |
| 4520 | ||
| 4521 | yield* syncPullRequests("unwatched", [link]); | |
| 4522 | assert.deepEqual((yield* store.getThreadShell(threadId))?.pendingBackgroundTasks, []); | |
| 4523 | assert.equal(yield* phase, "completed"); | |
| 4524 | }), | |
| 4525 | ); | |
| 4526 | ||
| 4404 | 4527 | it.effect("dates a completion released by held background work at the release", () => |
| 4405 | 4528 | Effect.gen(function* () { |
| 4406 | 4529 | const store = yield* ProjectionStore.ProjectionStoreV2; |
apps/server/src/orchestration-v2/ProjectionStore.ts+3| ... | ... | @@ -1346,6 +1346,7 @@ export function threadShellFromProjection( |
| 1346 | 1346 | turnItems: projection.turnItems, |
| 1347 | 1347 | activeProviderThreadId: projection.thread.activeProviderThreadId, |
| 1348 | 1348 | runs: projection.runs, |
| 1349 | pullRequests: projection.thread.pullRequests, | |
| 1349 | 1350 | }); |
| 1350 | 1351 | return { |
| 1351 | 1352 | createdBy: projection.thread.createdBy, |
| ... | ... | @@ -5220,6 +5221,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = |
| 5220 | 5221 | turnItems: pendingTurnItemsByThreadId.get(thread.id) ?? [], |
| 5221 | 5222 | activeProviderThreadId: thread.activeProviderThreadId, |
| 5222 | 5223 | hasActiveRun: false, |
| 5224 | pullRequests: thread.pullRequests, | |
| 5223 | 5225 | }), |
| 5224 | 5226 | } satisfies ProjectionSettlementCandidate; |
| 5225 | 5227 | }), |
| ... | ... | @@ -5338,6 +5340,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = |
| 5338 | 5340 | turnItems: pendingTurnItemsByThreadId.get(thread.id) ?? [], |
| 5339 | 5341 | activeProviderThreadId: thread.activeProviderThreadId, |
| 5340 | 5342 | hasActiveRun: row.active_run_id !== null, |
| 5343 | pullRequests: thread.pullRequests, | |
| 5341 | 5344 | }), |
| 5342 | 5345 | ]; |
| 5343 | 5346 | return { |
apps/server/src/orchestration-v2/PullRequestWatchReactor.ts+6-3| ... | ... | @@ -193,10 +193,13 @@ export const make = Effect.gen(function* () { |
| 193 | 193 | const check = Effect.fn("PullRequestWatchReactor.check")(function* (target: WatchTarget) { |
| 194 | 194 | const { thread, link, watch } = target; |
| 195 | 195 | const pullRequest = identityOf(link); |
| 196 | // A merged pull request cannot reopen, so its watch ends without a host read, even on a | |
| 197 | // settled thread. A closed one can, so the host decides below. | |
| 196 | // A merged pull request cannot reopen, so its watch ends without a host read. A closed one | |
| 197 | // can, so the host decides below. Settling ends watches; one left from before that rule | |
| 198 | // ends here, so the thread does not show as working while settled. | |
| 198 | 199 | if (link.snapshot?.state === "merged") return yield* record(target, null); |
| 199 | if (thread.settledOverride === "settled" || thread.settledAt !== null) return; | |
| 200 | if (thread.settledOverride === "settled" || thread.settledAt !== null) { | |
| 201 | return yield* record(target, null); | |
| 202 | } | |
| 200 | 203 | |
| 201 | 204 | const reference = { projectId: thread.projectId, ...pullRequest }; |
| 202 | 205 | const read = yield* Effect.exit( |
apps/server/src/orchestration-v2/pullRequestWatch.ts+1-1| ... | ... | @@ -197,7 +197,7 @@ export function pullRequestWatchMessage(input: { |
| 197 | 197 | "", |
| 198 | 198 | exhausted |
| 199 | 199 | ? `T3 Code stopped watching after ${PULL_REQUEST_WATCH_WAKE_LIMIT} comment-only updates in a row. Call watch_pull_request to watch it again.` |
| 200 | : "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.", | |
| 200 | : "Look into each item and act on it as your task requires. T3 Code keeps watching and wakes you on the next change, so end your turn when you are done. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.", | |
| 201 | 201 | ].join("\n"); |
| 202 | 202 | const failed = changes.some( |
| 203 | 203 | (change) => change.kind === "checks-failed" || change.kind === "conflicting", |
apps/server/src/provider/RuntimeInstructions.ts+1-1| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | 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. 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. | |
| 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. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox. | |
| 3 | 3 | </pull_request_linking>`; |
| 4 | 4 | |
| 5 | 5 | /** |
apps/web/src/components/ChatView.tsx+1| ... | ... | @@ -3561,6 +3561,7 @@ export default function ChatView(props: ChatViewProps) { |
| 3561 | 3561 | turnItems: serverProjection.turnItems, |
| 3562 | 3562 | activeProviderThreadId: serverProjection.thread.activeProviderThreadId, |
| 3563 | 3563 | runs: serverProjection.runs, |
| 3564 | pullRequests: serverProjection.thread.pullRequests, | |
| 3564 | 3565 | }), |
| 3565 | 3566 | ]; |
| 3566 | 3567 | }, [serverProjection]); |
docs/user/source-control.md+4| ... | ... | @@ -200,6 +200,10 @@ after 10 wakes in a row that bring only comments, when the server cannot read th |
| 200 | 200 | Unsettle the thread before starting a new watch. To start or stop it yourself, use the row menu in |
| 201 | 201 | the **Linked pull requests** panel. |
| 202 | 202 | |
| 203 | A watched thread counts as working between wakes, so it stays in the **Working** section and does | |
| 204 | not auto-settle. Agents stop watching when they hand the work back to you, and the thread then | |
| 205 | returns to your inbox. | |
| 206 | ||
| 203 | 207 | Cross-repository links use a project on the same host. Azure DevOps reviews require a project checked |
| 204 | 208 | out from the matching organization and repository. |
| 205 | 209 |
packages/client-runtime/src/operations/commands.test.ts+54| ... | ... | @@ -502,6 +502,60 @@ describe("V2 environment commands", () => { |
| 502 | 502 | }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), |
| 503 | 503 | ); |
| 504 | 504 | |
| 505 | it.effect("Stop with no run left ends the thread's pull request watches", () => | |
| 506 | Effect.gen(function* () { | |
| 507 | const link = { | |
| 508 | host: "github.com", | |
| 509 | repository: "pingdotgg/t3code", | |
| 510 | url: "https://github.com/pingdotgg/t3code/pull/7", | |
| 511 | source: "agent" as const, | |
| 512 | linkedAt: "2026-10-05T00:00:00.000Z", | |
| 513 | snapshot: null, | |
| 514 | stack: null, | |
| 515 | }; | |
| 516 | const watch = { | |
| 517 | startedAt: "2026-10-05T00:00:00.000Z", | |
| 518 | headSha: null, | |
| 519 | failedChecks: [], | |
| 520 | passed: false, | |
| 521 | passedChecks: [], | |
| 522 | remarksThrough: "2026-10-05T00:00:00.000Z", | |
| 523 | remarkIds: [], | |
| 524 | conflicting: false, | |
| 525 | wakes: 0, | |
| 526 | }; | |
| 527 | const projection: OrchestrationV2ThreadProjection = { | |
| 528 | ...v2Projection, | |
| 529 | thread: { | |
| 530 | ...v2Projection.thread, | |
| 531 | pullRequests: [ | |
| 532 | { ...link, number: 7, watch }, | |
| 533 | { ...link, number: 8 }, | |
| 534 | { ...link, number: 9, source: "stack-dismissed", watch }, | |
| 535 | ], | |
| 536 | }, | |
| 537 | }; | |
| 538 | const commands: OrchestrationV2Command[] = []; | |
| 539 | const supervisor = yield* makeSupervisor({ commands, projects: [], projection }); | |
| 540 | ||
| 541 | yield* interruptThreadTurn({ threadId: v2ThreadId }).pipe( | |
| 542 | Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), | |
| 543 | ); | |
| 544 | ||
| 545 | expect(commands).toEqual([ | |
| 546 | { | |
| 547 | type: "thread.pull-request.watch", | |
| 548 | commandId: expect.any(String), | |
| 549 | threadId: v2ThreadId, | |
| 550 | host: "github.com", | |
| 551 | repository: "pingdotgg/t3code", | |
| 552 | number: 7, | |
| 553 | watching: false, | |
| 554 | }, | |
| 555 | ]); | |
| 556 | }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), | |
| 557 | ); | |
| 558 | ||
| 505 | 559 | it.effect.each([ |
| 506 | 560 | "waiting", |
| 507 | 561 | "completed", |
packages/client-runtime/src/operations/commands.ts+22| ... | ... | @@ -28,6 +28,7 @@ import { |
| 28 | 28 | } from "@t3tools/contracts"; |
| 29 | 29 | import { modelSelectionCommandType } from "@t3tools/shared/model"; |
| 30 | 30 | import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; |
| 31 | import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; | |
| 31 | 32 | import * as Crypto from "effect/Crypto"; |
| 32 | 33 | import * as Effect from "effect/Effect"; |
| 33 | 34 | |
| ... | ... | @@ -771,6 +772,11 @@ export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")( |
| 771 | 772 | }); |
| 772 | 773 | }); |
| 773 | 774 | |
| 775 | /** | |
| 776 | * Stop for the thread's latest work: interrupts the active run, or the settled run whose | |
| 777 | * background work still runs. With no run to stop, it ends the thread's pull request | |
| 778 | * watches, the only background work that has no run. | |
| 779 | */ | |
| 774 | 780 | export const interruptThreadTurn = Effect.fn("EnvironmentCommands.interruptThreadTurn")(function* ( |
| 775 | 781 | input: InterruptThreadTurnInput, |
| 776 | 782 | ) { |
| ... | ... | @@ -798,6 +804,22 @@ export const interruptThreadTurn = Effect.fn("EnvironmentCommands.interruptThrea |
| 798 | 804 | runId = latestRun?.id; |
| 799 | 805 | } |
| 800 | 806 | } |
| 807 | if (runId === undefined) { | |
| 808 | let result = { sequence: 0 }; | |
| 809 | for (const link of visibleThreadPullRequests(projection.thread.pullRequests ?? [])) { | |
| 810 | if (link.watch === undefined) continue; | |
| 811 | result = yield* dispatch({ | |
| 812 | type: "thread.pull-request.watch", | |
| 813 | commandId: yield* allocateCommandId(result.sequence === 0 ? input : {}), | |
| 814 | threadId: input.threadId, | |
| 815 | host: link.host, | |
| 816 | repository: link.repository, | |
| 817 | number: link.number, | |
| 818 | watching: false, | |
| 819 | }); | |
| 820 | } | |
| 821 | return result; | |
| 822 | } | |
| 801 | 823 | } |
| 802 | 824 | if (runId === undefined) return { sequence: 0 }; |
| 803 | 825 | return yield* dispatch({ |
packages/client-runtime/src/state/entities.test.ts+22| ... | ... | @@ -155,6 +155,28 @@ describe("V2 client presentation", () => { |
| 155 | 155 | ]); |
| 156 | 156 | }); |
| 157 | 157 | |
| 158 | it("parks a thread that never ran at idle while a pull request watch holds it", () => { | |
| 159 | const watch = { taskId: "pull-request-watch:github.com/acme/app#1", kind: "monitor" as const }; | |
| 160 | const watched = presentThreadShell(environmentId, { | |
| 161 | ...v2ThreadShell, | |
| 162 | latestRunId: null, | |
| 163 | activeProviderThreadId: null, | |
| 164 | activeRunId: null, | |
| 165 | status: "idle", | |
| 166 | pendingBackgroundTasks: [watch], | |
| 167 | }); | |
| 168 | expect(watched.runtime).toMatchObject({ status: "idle", activeRunId: null }); | |
| 169 | expect( | |
| 170 | presentThreadShell(environmentId, { | |
| 171 | ...v2ThreadShell, | |
| 172 | latestRunId: null, | |
| 173 | activeProviderThreadId: null, | |
| 174 | status: "idle", | |
| 175 | pendingBackgroundTasks: [], | |
| 176 | }).runtime, | |
| 177 | ).toBeNull(); | |
| 178 | }); | |
| 179 | ||
| 158 | 180 | it.each([ |
| 159 | 181 | { kinds: ["command"], expected: "completed" }, |
| 160 | 182 | { kinds: ["command", "subagent"], expected: "idle" }, |
packages/client-runtime/src/state/models.ts+4-1| ... | ... | @@ -174,10 +174,13 @@ function terminalRunStatus(status: OrchestrationV2RunStatus): boolean { |
| 174 | 174 | // latestRun keeps the latest run's status for history presentation. |
| 175 | 175 | // A failed latest run outranks the roster, so the failure stays visible. |
| 176 | 176 | function shellRuntime(thread: OrchestrationV2ThreadShell): ThreadRuntimeSummary | null { |
| 177 | if (thread.latestRunId === null && thread.activeProviderThreadId === null) return null; | |
| 178 | 177 | const parkAtIdle = |
| 179 | 178 | backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? []) && |
| 180 | 179 | thread.status !== "failed"; |
| 180 | // A pull request watch can hold a thread that never ran. | |
| 181 | if (thread.latestRunId === null && thread.activeProviderThreadId === null && !parkAtIdle) { | |
| 182 | return null; | |
| 183 | } | |
| 181 | 184 | const status = parkAtIdle ? "idle" : (thread.activityRunStatus ?? thread.status); |
| 182 | 185 | return { |
| 183 | 186 | status, |
packages/client-runtime/src/state/threadExecution.ts+11-3| ... | ... | @@ -234,9 +234,6 @@ export function deriveThreadRuntime( |
| 234 | 234 | const liveActivityRun = latestMatchingRun(projection, (run) => |
| 235 | 235 | ACTIVITY_RUN_STATUSES.has(run.status), |
| 236 | 236 | ); |
| 237 | if (latestRun === null && projection.thread.activeProviderThreadId === null) return null; | |
| 238 | const activeRunId = | |
| 239 | latestMatchingRun(projection, (run) => INTERRUPTIBLE_RUN_STATUSES.has(run.status))?.id ?? null; | |
| 240 | 237 | // Same rule as the shell runtime: only background work that holds the |
| 241 | 238 | // completion parks the thread at idle; a dev server left running does not. |
| 242 | 239 | const backgroundWorkHoldsRun = backgroundWorkHoldsCompletion( |
| ... | ... | @@ -246,8 +243,19 @@ export function deriveThreadRuntime( |
| 246 | 243 | turnItems: projection.turnItems, |
| 247 | 244 | activeProviderThreadId: projection.thread.activeProviderThreadId, |
| 248 | 245 | runs: projection.runs, |
| 246 | pullRequests: projection.thread.pullRequests, | |
| 249 | 247 | }), |
| 250 | 248 | ); |
| 249 | // A pull request watch can hold a thread that never ran. | |
| 250 | if ( | |
| 251 | latestRun === null && | |
| 252 | projection.thread.activeProviderThreadId === null && | |
| 253 | !backgroundWorkHoldsRun | |
| 254 | ) { | |
| 255 | return null; | |
| 256 | } | |
| 257 | const activeRunId = | |
| 258 | latestMatchingRun(projection, (run) => INTERRUPTIBLE_RUN_STATUSES.has(run.status))?.id ?? null; | |
| 251 | 259 | return { |
| 252 | 260 | status: usageLimitedRun |
| 253 | 261 | ? "failed" |
packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts+69| ... | ... | @@ -517,4 +517,73 @@ describe("derivePendingBackgroundWork kinds", () => { |
| 517 | 517 | { taskId: "cmd", description: "npm test", kind: "command" }, |
| 518 | 518 | ]); |
| 519 | 519 | }); |
| 520 | ||
| 521 | describe("pull request watches", () => { | |
| 522 | const watch = { | |
| 523 | startedAt: "2026-10-05T00:00:00.000Z", | |
| 524 | headSha: null, | |
| 525 | failedChecks: [], | |
| 526 | passed: false, | |
| 527 | passedChecks: [], | |
| 528 | remarksThrough: "2026-10-05T00:00:00.000Z", | |
| 529 | remarkIds: [], | |
| 530 | conflicting: false, | |
| 531 | wakes: 0, | |
| 532 | }; | |
| 533 | const link = ( | |
| 534 | number: number, | |
| 535 | extra: { source?: "agent" | "stack-dismissed"; watched?: boolean }, | |
| 536 | ) => ({ | |
| 537 | host: "github.com", | |
| 538 | repository: "acme/app", | |
| 539 | number, | |
| 540 | url: `https://github.com/acme/app/pull/${number}`, | |
| 541 | source: extra.source ?? "agent", | |
| 542 | ...(extra.watched === false ? {} : { watch }), | |
| 543 | }); | |
| 544 | const pullRequests = [ | |
| 545 | link(1, {}), | |
| 546 | link(2, { watched: false }), | |
| 547 | link(3, { source: "stack-dismissed" }), | |
| 548 | ]; | |
| 549 | ||
| 550 | it("keeps a settled run waiting on each visible watch, as a monitor", () => { | |
| 551 | const tasks = derivePendingBackgroundWork({ | |
| 552 | latestRun: { id: "run-1" as never, ordinal: 1, status: "completed" }, | |
| 553 | providerThreads: [], | |
| 554 | turnItems: [], | |
| 555 | pullRequests, | |
| 556 | }); | |
| 557 | expect(tasks).toEqual([ | |
| 558 | { | |
| 559 | taskId: "pull-request-watch:github.com/acme/app#1", | |
| 560 | description: "Watching pull request #1", | |
| 561 | kind: "monitor", | |
| 562 | }, | |
| 563 | ]); | |
| 564 | expect(backgroundWorkHoldsCompletion(tasks)).toBe(true); | |
| 565 | }); | |
| 566 | ||
| 567 | it("keeps a thread that never ran waiting on its watch", () => { | |
| 568 | expect( | |
| 569 | derivePendingBackgroundWork({ | |
| 570 | latestRun: null, | |
| 571 | providerThreads: [], | |
| 572 | turnItems: [], | |
| 573 | pullRequests, | |
| 574 | }).map((task) => task.taskId), | |
| 575 | ).toEqual(["pull-request-watch:github.com/acme/app#1"]); | |
| 576 | }); | |
| 577 | ||
| 578 | it("lists no watch while a run is active", () => { | |
| 579 | expect( | |
| 580 | derivePendingBackgroundWork({ | |
| 581 | latestRun: { id: "run-1" as never, ordinal: 1, status: "running" }, | |
| 582 | providerThreads: [], | |
| 583 | turnItems: [], | |
| 584 | pullRequests, | |
| 585 | }), | |
| 586 | ).toEqual([]); | |
| 587 | }); | |
| 588 | }); | |
| 520 | 589 | }); |
packages/shared/src/orchestrationV2PendingBackgroundWork.ts+35| ... | ... | @@ -4,9 +4,12 @@ import type { |
| 4 | 4 | OrchestrationV2Run, |
| 5 | 5 | OrchestrationV2TurnItem, |
| 6 | 6 | ThreadId, |
| 7 | ThreadPullRequestLink, | |
| 7 | 8 | } from "@t3tools/contracts"; |
| 8 | 9 | import { isOrchestrationV2WorkActive } from "@t3tools/contracts"; |
| 9 | 10 | |
| 11 | import { threadPullRequestKeyOf } from "./threadPullRequests.ts"; | |
| 12 | ||
| 10 | 13 | const BACKGROUND_TURN_ITEM_TYPES = new Set<OrchestrationV2TurnItem["type"]>([ |
| 11 | 14 | "command_execution", |
| 12 | 15 | "dynamic_tool", |
| ... | ... | @@ -86,6 +89,11 @@ function backgroundWorkKindHoldsCompletion(kind: PendingBackgroundWorkTask["kind |
| 86 | 89 | |
| 87 | 90 | type PendingBackgroundWorkRun = Pick<OrchestrationV2Run, "id" | "ordinal" | "status">; |
| 88 | 91 | |
| 92 | type PendingBackgroundWorkPullRequest = Pick< | |
| 93 | ThreadPullRequestLink, | |
| 94 | "host" | "repository" | "number" | "url" | "source" | "watch" | |
| 95 | >; | |
| 96 | ||
| 89 | 97 | type PendingBackgroundWorkProviderThread = Pick< |
| 90 | 98 | OrchestrationV2ProviderThread, |
| 91 | 99 | "id" | "pendingBackgroundTasks" |
| ... | ... | @@ -200,6 +208,10 @@ export function pendingBackgroundTurnItems<Item extends PendingBackgroundWorkTur |
| 200 | 208 | * Sources: |
| 201 | 209 | * - Provider-thread roster (Claude SDK background tasks) |
| 202 | 210 | * - Active command_execution / dynamic_tool / subagent turn items |
| 211 | * - Pull request watches, as monitors: a watch wakes the agent, so the thread | |
| 212 | * stays working between wakes instead of returning to the inbox. Callers | |
| 213 | * that pick a run to interrupt leave `pullRequests` out; Stop ends watches | |
| 214 | * on its own. | |
| 203 | 215 | * |
| 204 | 216 | * Gated on latest root run settlement. Dedupes by native task ID. Excludes |
| 205 | 217 | * the roster while any interruptible foreground run remains active. Excludes |
| ... | ... | @@ -220,6 +232,7 @@ export function derivePendingBackgroundWork(input: { |
| 220 | 232 | * pass projection runs so policy cannot drift. |
| 221 | 233 | */ |
| 222 | 234 | readonly runs?: ReadonlyArray<PendingBackgroundWorkRun>; |
| 235 | readonly pullRequests?: ReadonlyArray<PendingBackgroundWorkPullRequest> | undefined; | |
| 223 | 236 | }): ReadonlyArray<PendingBackgroundWorkTask> { |
| 224 | 237 | const hasActiveRun = |
| 225 | 238 | input.hasActiveRun ?? |
| ... | ... | @@ -230,6 +243,10 @@ export function derivePendingBackgroundWork(input: { |
| 230 | 243 | if (hasActiveRun) { |
| 231 | 244 | return []; |
| 232 | 245 | } |
| 246 | // A thread that never ran waits on nothing else, but a watch started on it still wakes it. | |
| 247 | if (input.latestRun == null) { | |
| 248 | return pullRequestWatchTasks(input.pullRequests); | |
| 249 | } | |
| 233 | 250 | if (!isLatestRunSettledForBackgroundWait(input.latestRun)) { |
| 234 | 251 | return []; |
| 235 | 252 | } |
| ... | ... | @@ -264,5 +281,23 @@ export function derivePendingBackgroundWork(input: { |
| 264 | 281 | byTaskId.set(taskId, pendingTaskFromTurnItem(taskId, item)); |
| 265 | 282 | } |
| 266 | 283 | |
| 284 | for (const task of pullRequestWatchTasks(input.pullRequests)) byTaskId.set(task.taskId, task); | |
| 285 | ||
| 267 | 286 | return Array.from(byTaskId.values()); |
| 268 | 287 | } |
| 288 | ||
| 289 | function pullRequestWatchTasks( | |
| 290 | pullRequests: ReadonlyArray<PendingBackgroundWorkPullRequest> | undefined, | |
| 291 | ): Array<PendingBackgroundWorkTask> { | |
| 292 | return (pullRequests ?? []).flatMap((link) => | |
| 293 | link.watch === undefined || link.source === "stack-dismissed" | |
| 294 | ? [] | |
| 295 | : [ | |
| 296 | { | |
| 297 | taskId: `pull-request-watch:${threadPullRequestKeyOf(link)}`, | |
| 298 | description: `Watching pull request #${link.number}`, | |
| 299 | kind: "monitor" as const, | |
| 300 | }, | |
| 301 | ], | |
| 302 | ); | |
| 303 | } |