| 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,7 +289,7 @@ const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", { |
| 289 | 289 | ||
| 290 | const WatchPullRequestTool = Tool.make("watch_pull_request", { | 290 | const WatchPullRequestTool = Tool.make("watch_pull_request", { |
| 291 | description: | 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 | parameters: PullRequestTargetInput, | 293 | parameters: PullRequestTargetInput, |
| 294 | success: WatchPullRequestResult, | 294 | success: WatchPullRequestResult, |
| 295 | failure: PullRequestToolError, | 295 | failure: PullRequestToolError, |
apps/server/src/orchestration-v2/ProjectionStore.test.ts+123| ... | @@ -21,6 +21,7 @@ import { | ... | @@ -21,6 +21,7 @@ import { |
| 21 | RunId, | 21 | RunId, |
| 22 | ThreadId, | 22 | ThreadId, |
| 23 | TurnItemId, | 23 | TurnItemId, |
| 24 | type ThreadPullRequestLink, | ||
| 24 | } from "@t3tools/contracts"; | 25 | } from "@t3tools/contracts"; |
| 25 | import * as DateTime from "effect/DateTime"; | 26 | import * as DateTime from "effect/DateTime"; |
| 26 | import * as Effect from "effect/Effect"; | 27 | import * as Effect from "effect/Effect"; |
| ... | @@ -4401,6 +4402,128 @@ it.layer(TestLayer)("ProjectionStoreV2", (it) => { | ... | @@ -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 | it.effect("dates a completion released by held background work at the release", () => | 4527 | it.effect("dates a completion released by held background work at the release", () => |
| 4405 | Effect.gen(function* () { | 4528 | Effect.gen(function* () { |
| 4406 | const store = yield* ProjectionStore.ProjectionStoreV2; | 4529 | const store = yield* ProjectionStore.ProjectionStoreV2; |
apps/server/src/orchestration-v2/ProjectionStore.ts+3| ... | @@ -1346,6 +1346,7 @@ export function threadShellFromProjection( | ... | @@ -1346,6 +1346,7 @@ export function threadShellFromProjection( |
| 1346 | turnItems: projection.turnItems, | 1346 | turnItems: projection.turnItems, |
| 1347 | activeProviderThreadId: projection.thread.activeProviderThreadId, | 1347 | activeProviderThreadId: projection.thread.activeProviderThreadId, |
| 1348 | runs: projection.runs, | 1348 | runs: projection.runs, |
| 1349 | pullRequests: projection.thread.pullRequests, | ||
| 1349 | }); | 1350 | }); |
| 1350 | return { | 1351 | return { |
| 1351 | createdBy: projection.thread.createdBy, | 1352 | createdBy: projection.thread.createdBy, |
| ... | @@ -5220,6 +5221,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = | ... | @@ -5220,6 +5221,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = |
| 5220 | turnItems: pendingTurnItemsByThreadId.get(thread.id) ?? [], | 5221 | turnItems: pendingTurnItemsByThreadId.get(thread.id) ?? [], |
| 5221 | activeProviderThreadId: thread.activeProviderThreadId, | 5222 | activeProviderThreadId: thread.activeProviderThreadId, |
| 5222 | hasActiveRun: false, | 5223 | hasActiveRun: false, |
| 5224 | pullRequests: thread.pullRequests, | ||
| 5223 | }), | 5225 | }), |
| 5224 | } satisfies ProjectionSettlementCandidate; | 5226 | } satisfies ProjectionSettlementCandidate; |
| 5225 | }), | 5227 | }), |
| ... | @@ -5338,6 +5340,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = | ... | @@ -5338,6 +5340,7 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> = |
| 5338 | turnItems: pendingTurnItemsByThreadId.get(thread.id) ?? [], | 5340 | turnItems: pendingTurnItemsByThreadId.get(thread.id) ?? [], |
| 5339 | activeProviderThreadId: thread.activeProviderThreadId, | 5341 | activeProviderThreadId: thread.activeProviderThreadId, |
| 5340 | hasActiveRun: row.active_run_id !== null, | 5342 | hasActiveRun: row.active_run_id !== null, |
| 5343 | pullRequests: thread.pullRequests, | ||
| 5341 | }), | 5344 | }), |
| 5342 | ]; | 5345 | ]; |
| 5343 | return { | 5346 | return { |
apps/server/src/orchestration-v2/PullRequestWatchReactor.ts+6-3| ... | @@ -193,10 +193,13 @@ export const make = Effect.gen(function* () { | ... | @@ -193,10 +193,13 @@ export const make = Effect.gen(function* () { |
| 193 | const check = Effect.fn("PullRequestWatchReactor.check")(function* (target: WatchTarget) { | 193 | const check = Effect.fn("PullRequestWatchReactor.check")(function* (target: WatchTarget) { |
| 194 | const { thread, link, watch } = target; | 194 | const { thread, link, watch } = target; |
| 195 | const pullRequest = identityOf(link); | 195 | const pullRequest = identityOf(link); |
| 196 | // A merged pull request cannot reopen, so its watch ends without a host read, even on a | 196 | // A merged pull request cannot reopen, so its watch ends without a host read. A closed one |
| 197 | // settled thread. A closed one can, so the host decides below. | 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 | if (link.snapshot?.state === "merged") return yield* record(target, null); | 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 | const reference = { projectId: thread.projectId, ...pullRequest }; | 204 | const reference = { projectId: thread.projectId, ...pullRequest }; |
| 202 | const read = yield* Effect.exit( | 205 | const read = yield* Effect.exit( |
apps/server/src/orchestration-v2/pullRequestWatch.ts+1-1| ... | @@ -197,7 +197,7 @@ export function pullRequestWatchMessage(input: { | ... | @@ -197,7 +197,7 @@ export function pullRequestWatchMessage(input: { |
| 197 | "", | 197 | "", |
| 198 | exhausted | 198 | exhausted |
| 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.` | 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 | ].join("\n"); | 201 | ].join("\n"); |
| 202 | const failed = changes.some( | 202 | const failed = changes.some( |
| 203 | (change) => change.kind === "checks-failed" || change.kind === "conflicting", | 203 | (change) => change.kind === "checks-failed" || change.kind === "conflicting", |
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. 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 | </pull_request_linking>`; | 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,6 +3561,7 @@ export default function ChatView(props: ChatViewProps) { |
| 3561 | turnItems: serverProjection.turnItems, | 3561 | turnItems: serverProjection.turnItems, |
| 3562 | activeProviderThreadId: serverProjection.thread.activeProviderThreadId, | 3562 | activeProviderThreadId: serverProjection.thread.activeProviderThreadId, |
| 3563 | runs: serverProjection.runs, | 3563 | runs: serverProjection.runs, |
| 3564 | pullRequests: serverProjection.thread.pullRequests, | ||
| 3564 | }), | 3565 | }), |
| 3565 | ]; | 3566 | ]; |
| 3566 | }, [serverProjection]); | 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,6 +200,10 @@ after 10 wakes in a row that bring only comments, when the server cannot read th |
| 200 | Unsettle the thread before starting a new watch. To start or stop it yourself, use the row menu in | 200 | Unsettle the thread before starting a new watch. To start or stop it yourself, use the row menu in |
| 201 | the **Linked pull requests** panel. | 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 | Cross-repository links use a project on the same host. Azure DevOps reviews require a project checked | 207 | Cross-repository links use a project on the same host. Azure DevOps reviews require a project checked |
| 204 | out from the matching organization and repository. | 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,6 +502,60 @@ describe("V2 environment commands", () => { |
| 502 | }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), | 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 | it.effect.each([ | 559 | it.effect.each([ |
| 506 | "waiting", | 560 | "waiting", |
| 507 | "completed", | 561 | "completed", |
packages/client-runtime/src/operations/commands.ts+22| ... | @@ -28,6 +28,7 @@ import { | ... | @@ -28,6 +28,7 @@ import { |
| 28 | } from "@t3tools/contracts"; | 28 | } from "@t3tools/contracts"; |
| 29 | import { modelSelectionCommandType } from "@t3tools/shared/model"; | 29 | import { modelSelectionCommandType } from "@t3tools/shared/model"; |
| 30 | import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; | 30 | import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork"; |
| 31 | import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; | ||
| 31 | import * as Crypto from "effect/Crypto"; | 32 | import * as Crypto from "effect/Crypto"; |
| 32 | import * as Effect from "effect/Effect"; | 33 | import * as Effect from "effect/Effect"; |
| 33 | 34 | ||
| ... | @@ -771,6 +772,11 @@ export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")( | ... | @@ -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 | export const interruptThreadTurn = Effect.fn("EnvironmentCommands.interruptThreadTurn")(function* ( | 780 | export const interruptThreadTurn = Effect.fn("EnvironmentCommands.interruptThreadTurn")(function* ( |
| 775 | input: InterruptThreadTurnInput, | 781 | input: InterruptThreadTurnInput, |
| 776 | ) { | 782 | ) { |
| ... | @@ -798,6 +804,22 @@ export const interruptThreadTurn = Effect.fn("EnvironmentCommands.interruptThrea | ... | @@ -798,6 +804,22 @@ export const interruptThreadTurn = Effect.fn("EnvironmentCommands.interruptThrea |
| 798 | runId = latestRun?.id; | 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 | if (runId === undefined) return { sequence: 0 }; | 824 | if (runId === undefined) return { sequence: 0 }; |
| 803 | return yield* dispatch({ | 825 | return yield* dispatch({ |
packages/client-runtime/src/state/entities.test.ts+22| ... | @@ -155,6 +155,28 @@ describe("V2 client presentation", () => { | ... | @@ -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 | it.each([ | 180 | it.each([ |
| 159 | { kinds: ["command"], expected: "completed" }, | 181 | { kinds: ["command"], expected: "completed" }, |
| 160 | { kinds: ["command", "subagent"], expected: "idle" }, | 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,10 +174,13 @@ function terminalRunStatus(status: OrchestrationV2RunStatus): boolean { |
| 174 | // latestRun keeps the latest run's status for history presentation. | 174 | // latestRun keeps the latest run's status for history presentation. |
| 175 | // A failed latest run outranks the roster, so the failure stays visible. | 175 | // A failed latest run outranks the roster, so the failure stays visible. |
| 176 | function shellRuntime(thread: OrchestrationV2ThreadShell): ThreadRuntimeSummary | null { | 176 | function shellRuntime(thread: OrchestrationV2ThreadShell): ThreadRuntimeSummary | null { |
| 177 | if (thread.latestRunId === null && thread.activeProviderThreadId === null) return null; | ||
| 178 | const parkAtIdle = | 177 | const parkAtIdle = |
| 179 | backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? []) && | 178 | backgroundWorkHoldsCompletion(thread.pendingBackgroundTasks ?? []) && |
| 180 | thread.status !== "failed"; | 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 | const status = parkAtIdle ? "idle" : (thread.activityRunStatus ?? thread.status); | 184 | const status = parkAtIdle ? "idle" : (thread.activityRunStatus ?? thread.status); |
| 182 | return { | 185 | return { |
| 183 | status, | 186 | status, |
packages/client-runtime/src/state/threadExecution.ts+11-3| ... | @@ -234,9 +234,6 @@ export function deriveThreadRuntime( | ... | @@ -234,9 +234,6 @@ export function deriveThreadRuntime( |
| 234 | const liveActivityRun = latestMatchingRun(projection, (run) => | 234 | const liveActivityRun = latestMatchingRun(projection, (run) => |
| 235 | ACTIVITY_RUN_STATUSES.has(run.status), | 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 | // Same rule as the shell runtime: only background work that holds the | 237 | // Same rule as the shell runtime: only background work that holds the |
| 241 | // completion parks the thread at idle; a dev server left running does not. | 238 | // completion parks the thread at idle; a dev server left running does not. |
| 242 | const backgroundWorkHoldsRun = backgroundWorkHoldsCompletion( | 239 | const backgroundWorkHoldsRun = backgroundWorkHoldsCompletion( |
| ... | @@ -246,8 +243,19 @@ export function deriveThreadRuntime( | ... | @@ -246,8 +243,19 @@ export function deriveThreadRuntime( |
| 246 | turnItems: projection.turnItems, | 243 | turnItems: projection.turnItems, |
| 247 | activeProviderThreadId: projection.thread.activeProviderThreadId, | 244 | activeProviderThreadId: projection.thread.activeProviderThreadId, |
| 248 | runs: projection.runs, | 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 | return { | 259 | return { |
| 252 | status: usageLimitedRun | 260 | status: usageLimitedRun |
| 253 | ? "failed" | 261 | ? "failed" |
packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts+69| ... | @@ -517,4 +517,73 @@ describe("derivePendingBackgroundWork kinds", () => { | ... | @@ -517,4 +517,73 @@ describe("derivePendingBackgroundWork kinds", () => { |
| 517 | { taskId: "cmd", description: "npm test", kind: "command" }, | 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,9 +4,12 @@ import type { |
| 4 | OrchestrationV2Run, | 4 | OrchestrationV2Run, |
| 5 | OrchestrationV2TurnItem, | 5 | OrchestrationV2TurnItem, |
| 6 | ThreadId, | 6 | ThreadId, |
| 7 | ThreadPullRequestLink, | ||
| 7 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 8 | import { isOrchestrationV2WorkActive } from "@t3tools/contracts"; | 9 | import { isOrchestrationV2WorkActive } from "@t3tools/contracts"; |
| 9 | 10 | ||
| 11 | import { threadPullRequestKeyOf } from "./threadPullRequests.ts"; | ||
| 12 | |||
| 10 | const BACKGROUND_TURN_ITEM_TYPES = new Set<OrchestrationV2TurnItem["type"]>([ | 13 | const BACKGROUND_TURN_ITEM_TYPES = new Set<OrchestrationV2TurnItem["type"]>([ |
| 11 | "command_execution", | 14 | "command_execution", |
| 12 | "dynamic_tool", | 15 | "dynamic_tool", |
| ... | @@ -86,6 +89,11 @@ function backgroundWorkKindHoldsCompletion(kind: PendingBackgroundWorkTask["kind | ... | @@ -86,6 +89,11 @@ function backgroundWorkKindHoldsCompletion(kind: PendingBackgroundWorkTask["kind |
| 86 | 89 | ||
| 87 | type PendingBackgroundWorkRun = Pick<OrchestrationV2Run, "id" | "ordinal" | "status">; | 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 | type PendingBackgroundWorkProviderThread = Pick< | 97 | type PendingBackgroundWorkProviderThread = Pick< |
| 90 | OrchestrationV2ProviderThread, | 98 | OrchestrationV2ProviderThread, |
| 91 | "id" | "pendingBackgroundTasks" | 99 | "id" | "pendingBackgroundTasks" |
| ... | @@ -200,6 +208,10 @@ export function pendingBackgroundTurnItems<Item extends PendingBackgroundWorkTur | ... | @@ -200,6 +208,10 @@ export function pendingBackgroundTurnItems<Item extends PendingBackgroundWorkTur |
| 200 | * Sources: | 208 | * Sources: |
| 201 | * - Provider-thread roster (Claude SDK background tasks) | 209 | * - Provider-thread roster (Claude SDK background tasks) |
| 202 | * - Active command_execution / dynamic_tool / subagent turn items | 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 | * Gated on latest root run settlement. Dedupes by native task ID. Excludes | 216 | * Gated on latest root run settlement. Dedupes by native task ID. Excludes |
| 205 | * the roster while any interruptible foreground run remains active. Excludes | 217 | * the roster while any interruptible foreground run remains active. Excludes |
| ... | @@ -220,6 +232,7 @@ export function derivePendingBackgroundWork(input: { | ... | @@ -220,6 +232,7 @@ export function derivePendingBackgroundWork(input: { |
| 220 | * pass projection runs so policy cannot drift. | 232 | * pass projection runs so policy cannot drift. |
| 221 | */ | 233 | */ |
| 222 | readonly runs?: ReadonlyArray<PendingBackgroundWorkRun>; | 234 | readonly runs?: ReadonlyArray<PendingBackgroundWorkRun>; |
| 235 | readonly pullRequests?: ReadonlyArray<PendingBackgroundWorkPullRequest> | undefined; | ||
| 223 | }): ReadonlyArray<PendingBackgroundWorkTask> { | 236 | }): ReadonlyArray<PendingBackgroundWorkTask> { |
| 224 | const hasActiveRun = | 237 | const hasActiveRun = |
| 225 | input.hasActiveRun ?? | 238 | input.hasActiveRun ?? |
| ... | @@ -230,6 +243,10 @@ export function derivePendingBackgroundWork(input: { | ... | @@ -230,6 +243,10 @@ export function derivePendingBackgroundWork(input: { |
| 230 | if (hasActiveRun) { | 243 | if (hasActiveRun) { |
| 231 | return []; | 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 | if (!isLatestRunSettledForBackgroundWait(input.latestRun)) { | 250 | if (!isLatestRunSettledForBackgroundWait(input.latestRun)) { |
| 234 | return []; | 251 | return []; |
| 235 | } | 252 | } |
| ... | @@ -264,5 +281,23 @@ export function derivePendingBackgroundWork(input: { | ... | @@ -264,5 +281,23 @@ export function derivePendingBackgroundWork(input: { |
| 264 | byTaskId.set(taskId, pendingTaskFromTurnItem(taskId, item)); | 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 | return Array.from(byTaskId.values()); | 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 | } |