Skip to content

[Bug]: Since #33214, a watched PR waiting on a teammate's review leaves Working, though the next move isn't the user's #36230

Open

Description

@havoclad

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/server

Steps to reproduce

  1. In a Claude thread, have the agent open a PR in a repo where a teammate (not the T3 user) must approve before merging.
  2. Request review from the teammate. Wait for CI to go green.
  3. Tell the agent "babysit the PR, land it when approved". It calls watch_pull_request (watching: true) and ends its turn.
  4. Look at the sidebar.

Expected behavior

The thread stays in Working until the approval (or a comment, failure or conflict) wakes the agent, and then the agent merges. That's how it worked between #16204 and #33214. The user has handed the whole job to the agent and isn't needed again unless something goes wrong.

Actual behavior

The thread goes straight to the inbox, as if it needed the user, while it waits for someone else's review. The PR watch is still active (pendingBackgroundTasks lists pull-request-watch:…, and the composer shows "Watching pull request #N"), so the agent will still act on the approval. Only the sidebar placement is wrong.

#33214 lists this as a known gap ("A merge waiting on someone's approval also sits in the inbox until that approval arrives"). On a team with required reviews, that gap applies to nearly every PR. #33214 assumes that once checks finish, "a person moves the PR next", and that the person is the T3 user. That holds when you review your own agent's PRs. It doesn't hold when a teammate approves and the agent merges. Then the inbox fills with threads that need nothing from the user, and the inbox stops meaning "needs me".

Two ways to keep #33214's fix (no threads stuck forever after "ready for your review") and still cover this:

Impact

Major degradation or frequent failure

Version or commit

0.1.10-nightly.20261113.4630

Environment

macOS desktop app, Claude provider (Opus 5.5), GitHub

Workaround

None found. The thread stays in the inbox until a review wakes the agent.

Activity

  1. jawr commented on Nov 14, 2026

    @jawr

    Same here on 0.1.10-nightly.20261113.4656 (desktop, Claude provider, thread on a linked Linux environment). Every PR in our repo needs a teammate's approval, so after #33214 nearly every watched thread drops into the inbox the moment checks go green, while the agent is still waiting for that approval and will merge once it arrives.

    Reproduced from one thread's history:

    • watch_pull_request returned watching: true; pendingBackgroundTasks still lists pull-request-watch:github.com/<repo>#<n> (kind monitor).
    • The "checks passed" wake ran, the agent confirmed "waiting on approval, no open threads, auto-merge on" and ended its turn. The thread then left Working.
    • Nothing about the PR needed me. The only pending item was a review request for someone else.

    Suggested approach: the explicit option, off by default. watch_pull_request takes an optional untilApproved. Without it, nothing changes from #33214. With it, once checks finish the watch keeps holding the thread while the PR is open, not a draft, and its reviewDecision is review-required. It lets go when the PR is approved, gets changes requested, becomes a draft, or merges.

    Making it opt-in keeps #34770 fixed: an agent handing a PR back for the user's own review still sends the thread to the inbox. Only the agent knows whether the next step is a teammate's, and our monitor skill would pass untilApproved: true exactly when the PR is only waiting on others.

    How it's wired:

    • Storage: ThreadPullRequestWatch gains untilApproved?: true.
    • Setting it: thread.pull-request.watch takes untilApproved?: boolean, and the MCP tool passes it through PullRequestWatchService. Watching again without the parameter keeps the current value.
    • Across reads: the read loop's next and an in-flight pull-request-watch.sync keep the flag, so a read that started before the agent set it doesn't drop it.
    • The hold: decided in the shared pullRequestWatchTasks, from the flag plus the snapshot's existing isDraft and reviewDecision. No new host reads.
    • Tool list: 120 bytes larger; agentContextBudget.json is updated.

    Tests:

    • orchestrationV2PendingBackgroundWork.test.ts: holds with the flag while review-required; releases without it, and when approved, changes-requested, null or draft.
    • runtimeLayer.test.ts: the flag is set by the command, survives an in-flight read, a re-watch without the parameter and a projection rebuild, and clears with false. This test fails without the Orchestrator.ts change.
    • vp run check:changed passes.
    • Not done yet: an mcp-tool-use eval case for the new parameter, and a dev-server run against a real PR.

    Since this adds a tool option, I'd rather ask before opening a PR: would you take it? If so I'll add the eval case and open it against main.

    Diff (13 files, +218 −53, mostly reindentation in Orchestrator.ts)
    diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts
    index 84e65ce877..03553850a6 100644
    --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts
    +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts
    @@ -199,18 +199,25 @@ const make = Effect.gen(function* () {
     
       // The domain service owns the registration span.
       const setWatching = Effect.fnUntraced(function* (
    -    input: PullRequestTargetInput,
    +    input: PullRequestTargetInput & { readonly untilApproved?: boolean | undefined },
         watching: boolean,
       ) {
         const scope = yield* McpInvocationContext.requireMcpCapability("pull-requests");
         const threadId = input.threadId ?? scope.thread?.threadId;
         if (threadId === undefined) return yield* new PullRequestThreadRequiredError();
    -    return yield* watches.setWatching({ threadId, target: input, watching }).pipe(
    -      Effect.catchTags({
    -        PullRequestWatchThreadNotFoundError: (error) =>
    -          threadNotFound(engine, error.threadId, error.message),
    -      }),
    -    );
    +    return yield* watches
    +      .setWatching({
    +        threadId,
    +        target: input,
    +        watching,
    +        ...(input.untilApproved !== undefined && { untilApproved: input.untilApproved }),
    +      })
    +      .pipe(
    +        Effect.catchTags({
    +          PullRequestWatchThreadNotFoundError: (error) =>
    +            threadNotFound(engine, error.threadId, error.message),
    +        }),
    +      );
       });
     
       const locateAll = Effect.fn("PullRequestsToolkit.locateAll")(function* (
    diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts
    index 376817db10..b139e9b23e 100644
    --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts
    +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts
    @@ -324,10 +324,20 @@ const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", {
       .annotate(Tool.Idempotent, true)
       .annotate(Tool.OpenWorld, false);
     
    +export const WatchPullRequestInput = Schema.Struct({
    +  ...PullRequestTargetInput.fields,
    +  untilApproved: Schema.optional(
    +    Schema.Boolean.annotate({
    +      description: "Someone else approves: stay working until approved, not just checks.",
    +    }),
    +  ),
    +});
    +export type WatchPullRequestInput = typeof WatchPullRequestInput.Type;
    +
     const WatchPullRequestTool = Tool.make("watch_pull_request", {
       description:
         "Watch an open PR for this thread, linking it first if needed. T3 wakes you on failed checks, passed required checks, new comments or reviews, or conflicts. Handle old comments first. Only later ones wake you. After this call, end your turn. The watch stays on after each wake, so do not call this again on a wake. A wake is not a merge decision. Check readiness yourself.",
    -  parameters: PullRequestTargetInput,
    +  parameters: WatchPullRequestInput,
       success: WatchPullRequestResult,
       failure: PullRequestToolError,
       dependencies: watchDependencies,
    diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts
    index c732e0ca67..e916a6822e 100644
    --- a/apps/server/src/orchestration-v2/Orchestrator.ts
    +++ b/apps/server/src/orchestration-v2/Orchestrator.ts
    @@ -828,11 +828,23 @@ function syncedPullRequestWatch(
       },
     ): ThreadPullRequestWatch | undefined {
       if (command.watch === null) return undefined;
    +  // Only the agent sets untilApproved, so a read in flight keeps what the watch has now.
    +  const watch = withUntilApproved(command.watch, existing?.untilApproved === true);
       // A push cleared the answer while this read was in flight, so its answer is the old head's.
       if (command.checksPendingBefore === undefined || existing?.checksPending !== undefined) {
    -    return command.watch;
    +    return watch;
       }
    -  return withoutChecksAnswer(command.watch);
    +  return withoutChecksAnswer(watch);
    +}
    +
    +function withUntilApproved(
    +  watch: ThreadPullRequestWatch,
    +  untilApproved: boolean | undefined,
    +): ThreadPullRequestWatch {
    +  if (untilApproved === undefined || untilApproved === (watch.untilApproved === true)) return watch;
    +  if (untilApproved) return { ...watch, untilApproved: true };
    +  const { untilApproved: _off, ...rest } = watch;
    +  return rest;
     }
     
     const withoutChecksAnswer = ({
    @@ -4909,42 +4921,45 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
                     : existing.watch
                   : !command.watching
                     ? undefined
    -                : (existing.watch ?? {
    -                    startedAt,
    -                    headSha: null,
    -                    failedChecks: [],
    -                    passed: false,
    -                    passedChecks: [],
    -                    conflicting: false,
    -                    // Watching again must not repeat check or conflict news the last watch told.
    -                    ...(existing.endedWatch && {
    -                      headSha: existing.endedWatch.headSha,
    -                      failedChecks: existing.endedWatch.failedChecks,
    -                      passed: existing.endedWatch.passed,
    -                      passedChecks: existing.endedWatch.passedChecks,
    -                      conflicting: existing.endedWatch.conflicting,
    -                    }),
    -                    awaitingBaseline: existing.endedWatch
    -                      ? existing.endedWatch.awaitingBaseline
    -                      : "all",
    -                    ...(existing.endedWatch?.owedChecks !== undefined && {
    -                      owedChecks: existing.endedWatch.owedChecks,
    -                    }),
    -                    ...(existing.endedWatch?.awaitingApproval !== undefined && {
    -                      awaitingApproval: existing.endedWatch.awaitingApproval,
    -                    }),
    -                    ...(existing.endedWatch?.approvalAnnounced !== undefined && {
    -                      approvalAnnounced: existing.endedWatch.approvalAnnounced,
    -                    }),
    -                    // A finished-checks answer may be an older head's; a fresh read decides.
    -                    ...(existing.endedWatch?.checksPending === true && { checksPending: true }),
    -                    ...(existing.endedWatch?.checksAwaitedSince !== undefined && {
    -                      checksAwaitedSince: existing.endedWatch.checksAwaitedSince,
    -                    }),
    -                    remarksThrough: startedAt,
    -                    remarkIds: [],
    -                    wakes: 0,
    -                  });
    +                : withUntilApproved(
    +                    existing.watch ?? {
    +                      startedAt,
    +                      headSha: null,
    +                      failedChecks: [],
    +                      passed: false,
    +                      passedChecks: [],
    +                      conflicting: false,
    +                      // Watching again must not repeat check or conflict news the last watch told.
    +                      ...(existing.endedWatch && {
    +                        headSha: existing.endedWatch.headSha,
    +                        failedChecks: existing.endedWatch.failedChecks,
    +                        passed: existing.endedWatch.passed,
    +                        passedChecks: existing.endedWatch.passedChecks,
    +                        conflicting: existing.endedWatch.conflicting,
    +                      }),
    +                      awaitingBaseline: existing.endedWatch
    +                        ? existing.endedWatch.awaitingBaseline
    +                        : "all",
    +                      ...(existing.endedWatch?.owedChecks !== undefined && {
    +                        owedChecks: existing.endedWatch.owedChecks,
    +                      }),
    +                      ...(existing.endedWatch?.awaitingApproval !== undefined && {
    +                        awaitingApproval: existing.endedWatch.awaitingApproval,
    +                      }),
    +                      ...(existing.endedWatch?.approvalAnnounced !== undefined && {
    +                        approvalAnnounced: existing.endedWatch.approvalAnnounced,
    +                      }),
    +                      // A finished-checks answer may be an older head's; a fresh read decides.
    +                      ...(existing.endedWatch?.checksPending === true && { checksPending: true }),
    +                      ...(existing.endedWatch?.checksAwaitedSince !== undefined && {
    +                        checksAwaitedSince: existing.endedWatch.checksAwaitedSince,
    +                      }),
    +                      remarksThrough: startedAt,
    +                      remarkIds: [],
    +                      wakes: 0,
    +                    },
    +                    command.untilApproved,
    +                  );
               if (watch === existing.watch && links === linked) return thread;
               const final =
                 command.type === "thread.pull-request-watch.sync" && watch === undefined
    diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts
    index ddfe741604..3c93a94727 100644
    --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts
    +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts
    @@ -212,7 +212,8 @@ function watchesEqual(left: ThreadPullRequestWatch, right: ThreadPullRequestWatc
         left.wakes === right.wakes &&
         left.awaitingBaseline === right.awaitingBaseline &&
         left.checksAwaitedSince === right.checksAwaitedSince &&
    -    left.checksPending === right.checksPending
    +    left.checksPending === right.checksPending &&
    +    left.untilApproved === right.untilApproved
       );
     }
     
    diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.ts b/apps/server/src/orchestration-v2/pullRequestWatch.ts
    index e12d9a124d..3b97bca45f 100644
    --- a/apps/server/src/orchestration-v2/pullRequestWatch.ts
    +++ b/apps/server/src/orchestration-v2/pullRequestWatch.ts
    @@ -248,6 +248,7 @@ export function evaluatePullRequestWatch(
             }),
           checksAwaitedSince,
           checksPending,
    +      ...(watch.untilApproved === true && { untilApproved: true as const }),
         },
         exhausted: commentsOnly && wakes >= PULL_REQUEST_WATCH_WAKE_LIMIT,
       };
    diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts
    index c7cc43ee5d..aea8562535 100644
    --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts
    +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts
    @@ -3638,6 +3638,74 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => {
         }),
       );
     
    +  it.effect("keeps a watch's untilApproved across reads until the agent changes it", () =>
    +    Effect.gen(function* () {
    +      const orchestrator = yield* Orchestrator.OrchestratorV2;
    +      const maintenance = yield* ProjectionReplay.ProjectionReplay;
    +      const threadId = ThreadId.make("runtime-pull-request-watch-until-approved");
    +      yield* orchestrator.dispatch({
    +        type: "thread.create",
    +        createdBy: "user",
    +        creationSource: "web",
    +        commandId: CommandId.make("pr-watch-approved-create"),
    +        threadId,
    +        projectId: ProjectId.make("pr-watch-project"),
    +        title: "Watch until approved",
    +        modelSelection,
    +        runtimeMode: "full-access",
    +        interactionMode: "default",
    +        branch: null,
    +        worktreePath: null,
    +      });
    +      const key = { host: "github.com", repository: "pingdotgg/t3code", number: 7 };
    +      const watchOf = Effect.map(
    +        orchestrator.getThreadShell(threadId),
    +        (thread) => thread?.pullRequests?.[0]?.watch,
    +      );
    +      const watch = (id: string, untilApproved?: boolean) =>
    +        orchestrator.dispatch({
    +          type: "thread.pull-request.watch",
    +          commandId: CommandId.make(id),
    +          threadId,
    +          ...key,
    +          watching: true,
    +          link: { url: "https://github.com/pingdotgg/t3code/pull/7", source: "agent" },
    +          ...(untilApproved !== undefined && { untilApproved }),
    +        });
    +
    +      yield* watch("pr-watch-approved-start");
    +      assert.isUndefined((yield* watchOf)?.untilApproved);
    +
    +      yield* watch("pr-watch-approved-on", true);
    +      const started = yield* watchOf;
    +      assert.equal(started?.untilApproved, true);
    +      if (started === undefined) return;
    +
    +      // A read that started before the agent asked must not drop it.
    +      const { untilApproved: _read, ...readBefore } = started;
    +      yield* orchestrator.dispatch({
    +        type: "thread.pull-request-watch.sync",
    +        commandId: CommandId.make("pr-watch-approved-record"),
    +        threadId,
    +        ...key,
    +        startedAt: started.startedAt,
    +        watch: { ...readBefore, headSha: "abc123", checksPending: false },
    +      });
    +      assert.equal((yield* watchOf)?.untilApproved, true);
    +      assert.equal((yield* watchOf)?.headSha, "abc123");
    +
    +      // Watching again without the option leaves it as it was.
    +      yield* watch("pr-watch-approved-again");
    +      assert.equal((yield* watchOf)?.untilApproved, true);
    +      assert.isTrue((yield* maintenance.rebuild).valid);
    +      assert.equal((yield* watchOf)?.untilApproved, true);
    +
    +      yield* watch("pr-watch-approved-off", false);
    +      assert.isUndefined((yield* watchOf)?.untilApproved);
    +      assert.equal((yield* watchOf)?.headSha, "abc123");
    +    }),
    +  );
    +
       it.effect.each(["manual", "automatic"])("settling ends every pull request watch: %s", (mode) =>
         Effect.gen(function* () {
           const orchestrator = yield* Orchestrator.OrchestratorV2;
    diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json
    index 0a354c1e7d..e2241c2abd 100644
    --- a/apps/server/src/provider/agentContextBudget.json
    +++ b/apps/server/src/provider/agentContextBudget.json
    @@ -1,5 +1,5 @@
     {
       "claudeSystemAppendBytes": 5930,
       "codexAdditionalContextBytes": 7324,
    -  "mcpToolListJsonBytes": 49806
    +  "mcpToolListJsonBytes": 49926
     }
    diff --git a/apps/server/src/pullRequest/PullRequestWatchService.ts b/apps/server/src/pullRequest/PullRequestWatchService.ts
    index 6ed736075c..51ec5bac47 100644
    --- a/apps/server/src/pullRequest/PullRequestWatchService.ts
    +++ b/apps/server/src/pullRequest/PullRequestWatchService.ts
    @@ -69,6 +69,7 @@ export class PullRequestWatchService extends Context.Service<
           readonly threadId: ThreadId;
           readonly target: PullRequestTarget;
           readonly watching: boolean;
    +      readonly untilApproved?: boolean | undefined;
         }) => Effect.Effect<
           ResolvedTarget & { readonly watching: boolean; readonly wasWatching: boolean },
           | PullRequestWatchFailedError
    @@ -104,6 +105,7 @@ const make = Effect.gen(function* () {
         readonly threadId: ThreadId;
         readonly target: PullRequestTarget;
         readonly watching: boolean;
    +    readonly untilApproved?: boolean | undefined;
       }) {
         const { watching } = input;
         const thread = yield* requireThread(input.threadId);
    @@ -158,6 +160,8 @@ const make = Effect.gen(function* () {
             number: target.number,
             watching,
             ...(watching ? { link: { url: target.url, source: "agent" as const } } : {}),
    +        ...(watching &&
    +          input.untilApproved !== undefined && { untilApproved: input.untilApproved }),
           })
           .pipe(
             Effect.catchCause((cause) =>
    diff --git a/docs/user/source-control.md b/docs/user/source-control.md
    index 2ebd53ced8..6b9d930dd2 100644
    --- a/docs/user/source-control.md
    +++ b/docs/user/source-control.md
    @@ -344,8 +344,10 @@ A watched thread counts as working while the pull request still has checks runni
     the **Working** section, including after the agent pushes a fix. Once every check has finished,
     including review bots that report as checks, the thread returns to your inbox: what happens next is
     up to a person. That includes a workflow waiting for a maintainer to approve it, which runs only
    -once someone does. The watch stays on and still wakes the agent on new comments, failed checks or
    -conflicts, and a watched thread never auto-settles.
    +once someone does. If you tell the agent that someone else approves, the thread stays in **Working**
    +while the pull request still needs that approval, and returns to your inbox once it is approved, or
    +when changes are requested or it becomes a draft. The watch stays on and still wakes the agent on new comments, failed checks or conflicts, and a
    +watched thread never auto-settles.
     
     Cross-repository links use a project on the same host. Azure DevOps reviews require a project checked
     out from the matching organization and repository.
    diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts
    index 69338865e5..76d38ae67a 100644
    --- a/packages/contracts/src/orchestrationV2.ts
    +++ b/packages/contracts/src/orchestrationV2.ts
    @@ -3247,6 +3247,8 @@ export const OrchestrationV2Command = Schema.Union([
         threadId: ThreadId,
         ...ThreadPullRequestKey.fields,
         watching: Schema.Boolean,
    +    /** With `watching`, sets whether the watch holds its thread until the PR is approved. */
    +    untilApproved: Schema.optional(Schema.Boolean),
         link: Schema.optional(
           Schema.Struct({
             url: TrimmedNonEmptyString,
    diff --git a/packages/contracts/src/threadPullRequest.ts b/packages/contracts/src/threadPullRequest.ts
    index 7775f0755f..5ce08f9a4c 100644
    --- a/packages/contracts/src/threadPullRequest.ts
    +++ b/packages/contracts/src/threadPullRequest.ts
    @@ -116,6 +116,11 @@ export const ThreadPullRequestWatch = Schema.Struct({
        * watch keep its thread waiting; absent on watches not read since it existed.
        */
       checksPending: Schema.optional(Schema.Boolean),
    +  /**
    +   * The agent waits for someone else to approve, so the watch keeps its thread waiting after checks
    +   * finish while the pull request still needs an approval. Absent unless the agent asked.
    +   */
    +  untilApproved: Schema.optional(Schema.Literal(true)),
     });
     export type ThreadPullRequestWatch = typeof ThreadPullRequestWatch.Type;
     
    diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts
    index a35767d4f2..0b8e40d511 100644
    --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts
    +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts
    @@ -473,6 +473,43 @@ describe("derivePendingBackgroundWork kinds", () => {
           expect(backgroundWorkHoldsCompletion(running)).toBe(true);
         });
     
    +    it("keeps holding a watch until approval while someone else's approval is required", () => {
    +      const holds = (
    +        untilApproved: boolean,
    +        snapshot: {
    +          reviewDecision: "review-required" | "approved" | "changes-requested" | null;
    +          isDraft: boolean;
    +        },
    +      ) =>
    +        backgroundWorkHoldsCompletion(
    +          derivePendingBackgroundWork({
    +            latestRun: { id: "run-1" as never, ordinal: 1, status: "completed" },
    +            providerThreads: [],
    +            turnItems: [],
    +            pullRequests: [
    +              {
    +                ...link(1, {}),
    +                snapshot: { state: "open", ...snapshot },
    +                watch: {
    +                  ...watch,
    +                  passed: true,
    +                  checksPending: false,
    +                  ...(untilApproved && { untilApproved: true as const }),
    +                },
    +              },
    +            ],
    +          }),
    +        );
    +      expect(holds(true, { reviewDecision: "review-required", isDraft: false })).toBe(true);
    +      // Off by default: once checks finish, the pull request is the user's again.
    +      expect(holds(false, { reviewDecision: "review-required", isDraft: false })).toBe(false);
    +      // Approved, changes requested, no approval required, or a draft: the user moves next.
    +      expect(holds(true, { reviewDecision: "approved", isDraft: false })).toBe(false);
    +      expect(holds(true, { reviewDecision: "changes-requested", isDraft: false })).toBe(false);
    +      expect(holds(true, { reviewDecision: null, isDraft: false })).toBe(false);
    +      expect(holds(true, { reviewDecision: "review-required", isDraft: true })).toBe(false);
    +    });
    +
         it("lists no watch while a run is active", () => {
           expect(
             derivePendingBackgroundWork({
    diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts
    index 3b25db88ac..8178258a63 100644
    --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts
    +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts
    @@ -134,9 +134,19 @@ type PendingBackgroundWorkPullRequest = Pick<
       ThreadPullRequestLink,
       "host" | "repository" | "number" | "url" | "source" | "watch"
     > & {
    -  readonly snapshot: Pick<NonNullable<ThreadPullRequestLink["snapshot"]>, "state"> | null;
    +  readonly snapshot:
    +    | (Pick<NonNullable<ThreadPullRequestLink["snapshot"]>, "state"> &
    +        Partial<Pick<NonNullable<ThreadPullRequestLink["snapshot"]>, "isDraft" | "reviewDecision">>)
    +    | null;
     };
     
    +// The agent said someone else approves, so a required approval is not the user's move. Changes
    +// requested, a draft, or an approval hand the pull request back.
    +const awaitsOthersApproval = (link: PendingBackgroundWorkPullRequest) =>
    +  link.watch?.untilApproved === true &&
    +  link.snapshot?.isDraft === false &&
    +  link.snapshot.reviewDecision === "review-required";
    +
     type PendingBackgroundWorkProviderThread = Pick<
       OrchestrationV2ProviderThread,
       "id" | "pendingBackgroundTasks"
    @@ -326,8 +336,11 @@ function pullRequestWatchTasks(
                 taskId: `${PULL_REQUEST_WATCH_TASK_ID}${link.host.toLowerCase()}/${link.repository.toLowerCase()}#${link.number}`,
                 description: `Watching pull request #${link.number}`,
                 kind: "monitor" as const,
    -            // Once every check finished, a person moves the PR next: the watch stays on but holds nothing.
    -            ...(link.watch.checksPending === false ? {} : { awaited: true as const }),
    +            // Once every check finished, a person moves the PR next: the watch stays on but holds
    +            // nothing, unless that person is someone else.
    +            ...(link.watch.checksPending === false && !awaitsOthersApproval(link)
    +              ? {}
    +              : { awaited: true as const }),
               },
             ],
       );
  2. bartlomiejbloniarz commented on Nov 14, 2026

    @bartlomiejbloniarz
    Contributor

    Same here on 0.1.10-nightly.20261114.4695 (macOS desktop, Claude provider).

    I hand most PRs to agents to babysit. Since #33214, each of those threads drops into the inbox as soon as CI goes green, while the agent is still watching and the next move belongs to the reviewer. The composer still shows "Watching pull request #N" and pendingBackgroundTasks still lists the pull-request-watch: task. With several of these at once, the inbox fills with threads that don't need me.

    I'd prefer the automatic option: keep holding the thread while the PR has review requests pending for someone other than me. It needs nothing from the agent, and it keeps #33214's fix for "ready for your review" hand-offs, where the reviewer is me.

    A separate section for these threads would also help, like the Watching PRs section in #27341 or the broader Waiting section suggested there. Threads waiting on a reviewer or CI wouldn't sit in Working next to running agents, and wouldn't fill the inbox either.

  3. jawr commented on Nov 15, 2026

    @jawr

    Having a dedicated section to split it out of working would be even better!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions