From 58833d19ef6170804630d364f6f03528443b1411 Mon Sep 17 00:00:00 2001 From: gurnro <122301785+gurnro@users.noreply.github.com> Date: Sun, 8 Nov 2026 01:32:22 +0700 Subject: [PATCH 1/4] fix(server): wake the watching thread when its pull request merges (pingdotgg/t3code#25160) A merged pull request ended its watch with no message, so the agent that opened it never learned it had landed. A closed pull request already wakes the thread. Do the same for a merge, both when the host read reports the merge and when the stored snapshot already says merged. A settled thread is still ended quietly, because the orchestrator refuses to wake it. Co-Authored-By: Claude Sonnet 5.5 --- .../PullRequestWatchReactor.ts | 27 +++++- .../src/orchestration-v2/runtimeLayer.test.ts | 84 +++++++++++++++++++ 2 files changed, 108 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index 369bead0ec42..58f4393dd158 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -410,6 +410,23 @@ export const make = Effect.gen(function* () { Effect.tap(() => ended(target, "closed")), ); + const merged = (target: WatchTarget) => + record(target, null, { + text: `Pull request #${target.link.number} (${target.link.url}) was merged, so T3 Code stopped watching it. Finish any cleanup for it now.`, + notification: { + source: { kind: "monitor" }, + outcome: "completed", + summary: `#${target.link.number}: merged, stopped watching`, + }, + }).pipe( + Effect.tap(() => woke(target)), + Effect.tap(() => ended(target, "merged")), + ); + + // The orchestrator refuses to wake a settled thread, so only an unsettled one is told. + const canHearMerge = ({ thread }: WatchTarget) => + thread.settledOverride !== "settled" && thread.settledAt === null; + const endsWithoutRead = ({ thread, link }: WatchTarget): WatchEndReason | undefined => link.snapshot?.state === "merged" ? "merged" @@ -529,7 +546,9 @@ export const make = Effect.gen(function* () { return yield* eachTarget(group, (target) => detail.state === "closed" ? closed(target) - : record(target, null).pipe(Effect.tap(() => ended(target, "merged"))), + : canHearMerge(target) + ? merged(target) + : record(target, null).pipe(Effect.tap(() => ended(target, "merged"))), ); } @@ -618,8 +637,10 @@ export const make = Effect.gen(function* () { yield* Effect.forEach( ending, ([target, reason]) => - record(target, null).pipe( - Effect.tap(() => ended(target, reason)), + (reason === "merged" && canHearMerge(target) + ? merged(target) + : record(target, null).pipe(Effect.tap(() => ended(target, reason))) + ).pipe( Effect.catchCause( logFailure("pull request watch stop failed", { threadId: target.thread.id, diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 170ceee73a33..4ccbb353b8de 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -4335,6 +4335,90 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect("wakes the watching thread once when its pull request merges", () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("runtime-pull-request-watch-merge"); + const projectId = ProjectId.make("pr-watch-merge-project"); + yield* seedProject({ + projectId, + title: "Watch merge", + workspaceRoot: "/workspace/watch", + defaultModelSelection: null, + createdAt: "2026-10-01T00:00:00.000Z", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("pr-watch-merge-create"), + threadId, + projectId, + title: "Watch merge", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + const key = { host: "github.com", repository: "pingdotgg/t3code", number: 8 }; + yield* orchestrator.dispatch({ + type: "thread.pull-request.link", + commandId: CommandId.make("pr-watch-merge-link"), + threadId, + ...key, + url: "https://github.com/pingdotgg/t3code/pull/8", + source: "agent", + }); + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make("pr-watch-merge-start"), + threadId, + ...key, + watching: true, + }); + + const at = "2026-10-02T12:00:00.000Z"; + const detail = { + ...watchedPullRequestDetail({ projectId, number: key.number, at }), + state: "merged" as const, + mergedAt: at, + }; + const reactor = yield* PullRequestWatchReactor.make.pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + NodeCrypto.layer, + Layer.mock(PullRequestService.PullRequestService)({ + detail: () => Effect.succeed(detail), + activity: () => + Effect.succeed({ + comments: [], + commentCount: 0, + commentsTruncated: false, + reviewThreadsTruncated: false, + reviewThreads: [], + commits: [], + }), + }), + ), + ), + ); + yield* reactor.sweep; + yield* reactor.sweep; + + const { messages } = yield* orchestrator.getThreadRecords(threadId, ["messages"]); + assert.deepEqual( + messages.flatMap((message) => + message.notification === undefined ? [] : [message.notification.summary], + ), + ["#8: merged, stopped watching"], + ); + assert.include(messages.at(-1)?.text ?? "", "was merged"); + assert.isUndefined((yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.watch); + }), + ); + it.effect("reads each watched PR as soon as its own fingerprint lands", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; From de09d64581e66b985b2aac64d861f9e37156aa90 Mon Sep 17 00:00:00 2001 From: gurnro <122301785+gurnro@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:55:06 +0700 Subject: [PATCH 2/4] fix(server): handle a merged PR detail when the activity read fails (pingdotgg/t3code#32582) Co-Authored-By: Claude Sonnet 5.5 --- .../PullRequestWatchReactor.ts | 5 +- .../src/orchestration-v2/runtimeLayer.test.ts | 92 +++++++++++++++++++ 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index 58f4393dd158..93610c450ea8 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -504,10 +504,13 @@ export const make = Effect.gen(function* () { { concurrency: 2 }, ); // Activity failures must not cancel or hide a permanent detail failure. + // A merged or closed detail is terminal, so a failed activity read cannot hide it. const read = Exit.isFailure(detailRead) ? Exit.failCause(detailRead.cause) : Exit.isFailure(activityRead) - ? Exit.failCause(activityRead.cause) + ? detailRead.value.state !== "open" && !Cause.hasInterruptsOnly(activityRead.cause) + ? Exit.succeed([detailRead.value, Option.none()] as const) + : Exit.failCause(activityRead.cause) : Exit.succeed([detailRead.value, activityRead.value] as const); if (Exit.isFailure(read)) { if (Cause.hasInterruptsOnly(read.cause)) return yield* Effect.failCause(read.cause); diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 4ccbb353b8de..09763f52fd41 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -4419,6 +4419,98 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect( + "wakes the watching thread when its pull request merges even if the activity read fails", + () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("runtime-pull-request-watch-merge-activity-fails"); + const projectId = ProjectId.make("pr-watch-merge-activity-fails-project"); + yield* seedProject({ + projectId, + title: "Watch merge activity fails", + workspaceRoot: "/workspace/watch", + defaultModelSelection: null, + createdAt: "2026-10-01T00:00:00.000Z", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("pr-watch-merge-activity-fails-create"), + threadId, + projectId, + title: "Watch merge activity fails", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + const key = { host: "github.com", repository: "pingdotgg/t3code", number: 18 }; + yield* orchestrator.dispatch({ + type: "thread.pull-request.link", + commandId: CommandId.make("pr-watch-merge-activity-fails-link"), + threadId, + ...key, + url: "https://github.com/pingdotgg/t3code/pull/18", + source: "agent", + }); + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make("pr-watch-merge-activity-fails-start"), + threadId, + ...key, + watching: true, + }); + + const at = "2026-10-02T12:00:00.000Z"; + const detail = { + ...watchedPullRequestDetail({ projectId, number: key.number, at }), + state: "merged" as const, + mergedAt: at, + }; + const reactor = yield* PullRequestWatchReactor.make.pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + NodeCrypto.layer, + Layer.mock(PullRequestService.PullRequestService)({ + detail: () => Effect.succeed(detail), + activity: () => + Effect.fail( + new PullRequestOperationError({ + operation: "activity", + detail: "host read failed", + cause: new PullRequestProviderError({ + provider: SourceControlProviderKind.make("github"), + operation: "activity", + reason: "failed", + detail: "host read failed", + }), + }), + ), + }), + ), + ), + ); + yield* reactor.sweep; + yield* reactor.sweep; + + const { messages } = yield* orchestrator.getThreadRecords(threadId, ["messages"]); + assert.deepEqual( + messages.flatMap((message) => + message.notification === undefined ? [] : [message.notification.summary], + ), + ["#18: merged, stopped watching"], + ); + assert.include(messages.at(-1)?.text ?? "", "was merged"); + assert.isUndefined( + (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.watch, + ); + }), + ); + it.effect("reads each watched PR as soon as its own fingerprint lands", () => Effect.gen(function* () { const orchestrator = yield* Orchestrator.OrchestratorV2; From 6624df9b51849d7c270c42d813ef7865156dbfe7 Mon Sep 17 00:00:00 2001 From: gurnro <122301785+gurnro@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:58:23 +0700 Subject: [PATCH 3/4] test(server): cover the stored merged snapshot path of the PR watch (pingdotgg/t3code#32582) Co-Authored-By: Claude Sonnet 5.5 --- .../src/orchestration-v2/runtimeLayer.test.ts | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 09763f52fd41..6f4345fd2596 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -4419,6 +4419,104 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }), ); + it.effect( + "wakes the watching thread once from a stored merged snapshot without reading the host", + () => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const threadId = ThreadId.make("runtime-pull-request-watch-merged-snapshot"); + const projectId = ProjectId.make("pr-watch-merged-snapshot-project"); + yield* seedProject({ + projectId, + title: "Watch merged snapshot", + workspaceRoot: "/workspace/watch", + defaultModelSelection: null, + createdAt: "2026-10-01T00:00:00.000Z", + }); + yield* orchestrator.dispatch({ + type: "thread.create", + createdBy: "user", + creationSource: "web", + commandId: CommandId.make("pr-watch-merged-snapshot-create"), + threadId, + projectId, + title: "Watch merged snapshot", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + }); + const key = { host: "github.com", repository: "pingdotgg/t3code", number: 28 }; + yield* orchestrator.dispatch({ + type: "thread.pull-request.link", + commandId: CommandId.make("pr-watch-merged-snapshot-link"), + threadId, + ...key, + url: "https://github.com/pingdotgg/t3code/pull/28", + source: "agent", + }); + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make("pr-watch-merged-snapshot-start"), + threadId, + ...key, + watching: true, + }); + + yield* orchestrator.dispatch({ + type: "thread.pull-request-link.sync", + commandId: CommandId.make("pr-watch-merged-snapshot-sync"), + threadId, + ...key, + snapshot: { + state: "merged", + title: "Merged PR", + headBranch: "feature/pr", + baseBranch: "main", + isDraft: false, + updatedAt: null, + syncedAt: "2026-10-02T12:00:00.000Z", + }, + stack: null, + }); + let hostReads = 0; + const reactor = yield* PullRequestWatchReactor.make.pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + NodeCrypto.layer, + Layer.mock(PullRequestService.PullRequestService)({ + detail: () => + Effect.sync(() => hostReads++).pipe( + Effect.andThen(Effect.die("host detail read")), + ), + activity: () => + Effect.sync(() => hostReads++).pipe( + Effect.andThen(Effect.die("host activity read")), + ), + }), + ), + ), + ); + yield* reactor.sweep; + yield* reactor.sweep; + + const { messages } = yield* orchestrator.getThreadRecords(threadId, ["messages"]); + assert.deepEqual( + messages.flatMap((message) => + message.notification === undefined ? [] : [message.notification.summary], + ), + ["#28: merged, stopped watching"], + ); + assert.equal(hostReads, 0); + assert.include(messages.at(-1)?.text ?? "", "was merged"); + assert.isUndefined( + (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.watch, + ); + }), + ); + it.effect( "wakes the watching thread when its pull request merges even if the activity read fails", () => From d6c24da0ed10f7c854b2cea89bb2720182a7e46c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 11 Nov 2026 14:55:04 +0000 Subject: [PATCH 4/4] fix(server): end a merged watch even when its wake is refused --- apps/server/src/orchestration-v2/PullRequestWatchReactor.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index cc4fd8077ec5..77b1ce648252 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -434,6 +434,7 @@ export const make = Effect.gen(function* () { }, }).pipe( Effect.tap(() => woke(target)), + Effect.catch(() => record(target, null)), Effect.tap(() => ended(target, "merged")), );