From 2e706be2ee8263e733b3da6922ac7220d010635f Mon Sep 17 00:00:00 2001 From: Nathan Shan Date: Sun, 25 Oct 2026 12:02:14 +0800 Subject: [PATCH 1/7] =?UTF-8?q?fix(server):=20=E5=85=81=E8=AE=B8=E7=9B=91?= =?UTF-8?q?=E5=90=AC=E5=85=B1=E4=BA=AB=E8=B4=A6=E6=88=B7=E5=8F=91=E5=B8=83?= =?UTF-8?q?=E7=9A=84=E8=AF=84=E5=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增显式 includeOwnRemarks 选项,默认仍过滤自身评论并保留唤醒上限。 持久化监听选项,切换策略时开启新评论窗口并拒绝旧请求覆盖。 通过 154 项相关测试及受影响包类型检查,覆盖投影重建、去重和同一时刻切换策略。 --- .../toolkits/pullRequests/handlers.test.ts | 13 +++++++ .../src/mcp/toolkits/pullRequests/handlers.ts | 6 ++- .../src/mcp/toolkits/pullRequests/tools.ts | 14 ++++++- .../src/orchestration-v2/Orchestrator.ts | 26 ++++++++++++- .../orchestration-v2/pullRequestWatch.test.ts | 16 ++++++++ .../src/orchestration-v2/pullRequestWatch.ts | 5 ++- .../src/orchestration-v2/runtimeLayer.test.ts | 38 +++++++++++++++++++ docs/user/source-control.md | 5 ++- packages/contracts/src/orchestrationV2.ts | 1 + packages/contracts/src/threadPullRequest.ts | 1 + 10 files changed, 120 insertions(+), 5 deletions(-) diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts index 507381531ac49..6601e922bd97f 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts @@ -284,6 +284,19 @@ describe("pull request toolkit handlers", () => { }), ); + it.effect("passes the same-account review opt-in to the durable watch command", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + yield* harness.call("watch_pull_request", { + url: "https://github.com/t3tools/t3code/pull/9", + includeOwnRemarks: true, + }); + expect(yield* Ref.get(harness.commands)).toMatchObject([ + { type: "thread.pull-request.watch", number: 9, includeOwnRemarks: true }, + ]); + }), + ); + it.effect("refuses to watch a merged pull request and stops an existing watch", () => Effect.gen(function* () { const watch = { diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts index fcd9f8bfef5cd..3d08b7166b1b4 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.ts @@ -260,7 +260,7 @@ const make = Effect.gen(function* () { : Effect.fail(new Failure({ cause })); const setWatching = Effect.fn("PullRequestsToolkit.setWatching")(function* ( - input: PullRequestTargetInput, + input: PullRequestTargetInput & { readonly includeOwnRemarks?: boolean | undefined }, watching: boolean, ) { const thread = yield* requireThread(PullRequestWatchFailedError, input.threadId); @@ -286,6 +286,9 @@ const make = Effect.gen(function* () { repository: target.repository, number: target.number, watching, + ...(watching && input.includeOwnRemarks !== undefined + ? { includeOwnRemarks: input.includeOwnRemarks } + : {}), ...(watching ? { link: { url: target.url, source: "agent" as const } } : {}), }) .pipe(Effect.catchCause(dispatchFailure(PullRequestWatchFailedError))); @@ -296,6 +299,7 @@ const make = Effect.gen(function* () { number: target.number, url: target.url, watching: watchedLink(after)?.watch !== undefined, + includeOwnRemarks: watchedLink(after)?.watch?.includeOwnRemarks === true, wasWatching: before?.watch !== undefined, }; }); diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts index 63c0efa1684a4..633fe07d6f3e2 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -239,6 +239,10 @@ export type UnlinkPullRequestResult = typeof UnlinkPullRequestResult.Type; export const WatchPullRequestResult = Schema.Struct({ ...PullRequestIdentity, + includeOwnRemarks: Schema.Boolean.annotate({ + description: + "Whether the active watch includes comments and reviews from the authenticated account. False by default.", + }), watching: Schema.Boolean.annotate({ description: "Whether T3 Code now watches the pull request for this thread.", }), @@ -327,7 +331,15 @@ const ListThreadPullRequestsTool = Tool.make("list_thread_pull_requests", { const WatchPullRequestTool = Tool.make("watch_pull_request", { description: "Have T3 Code watch an open pull request for this thread, linking it first if needed. T3 Code checks it every two minutes 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 or is archived, when T3 Code fails to read it 8 times in a row (a host rate limit only delays it), when the user stops this thread, or when you call unwatch_pull_request. Unsettle the thread before starting a new watch. A subagent cannot watch: its parent thread owns the pull request.", - parameters: PullRequestTargetInput, + parameters: Schema.Struct({ + ...PullRequestTargetInput.fields, + includeOwnRemarks: Schema.optional( + Schema.Boolean.annotate({ + description: + "Include comments and reviews from the authenticated account when several people or agents share it. Defaults to false for a new watch; omit to keep an existing watch's setting. Changing this setting starts a new comment window now. The comment-only wake limit still applies.", + }), + ), + }), success: WatchPullRequestResult, failure: PullRequestToolError, dependencies, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 13ae0a48fa132..dcf6e7a515a4a 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -3513,7 +3513,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio : linked; const existing = links.find(visible); if (existing === undefined) return thread; - const watch = + let watch = command.type === "thread.pull-request-watch.sync" ? existing.watch?.startedAt === command.startedAt ? (command.watch ?? undefined) @@ -3545,6 +3545,30 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio remarkIds: [], wakes: 0, }); + if ( + command.type === "thread.pull-request.watch" && + watch !== undefined && + command.includeOwnRemarks !== undefined && + command.includeOwnRemarks !== (watch.includeOwnRemarks ?? false) + ) { + // A changed policy must reject in-flight syncs even within the same clock tick. + const policyStartedAt = DateTime.formatIso( + DateTime.makeUnsafe( + Math.max( + DateTime.toEpochMillis(now), + DateTime.toEpochMillis(DateTime.makeUnsafe(watch.startedAt)) + 1, + ), + ), + ); + watch = { + ...watch, + includeOwnRemarks: command.includeOwnRemarks, + startedAt: policyStartedAt, + remarksThrough: policyStartedAt, + remarkIds: [], + wakes: 0, + }; + } 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/pullRequestWatch.test.ts b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts index 0b6070f364662..b1cec928e43ba 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.test.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.test.ts @@ -305,6 +305,22 @@ describe("evaluatePullRequestWatch", () => { assert.deepEqual(evaluatePullRequestWatch(watching, detail(), [own]).changes, []); }); + it("can watch same-account reviews once while retaining the wake limit", () => { + const review = remark("AGENT-USER", "2026-10-02T12:06:00Z", "Please fix the race."); + const shared = { ...watch({ headSha: detail().headSha ?? null }), includeOwnRemarks: true }; + const first = evaluatePullRequestWatch(shared, detail(), [review]); + assert.deepEqual(first.changes, [{ kind: "remarks", remarks: [review] }]); + assert.isTrue(first.next.includeOwnRemarks); + assert.deepEqual(evaluatePullRequestWatch(first.next, detail(), [review]).changes, []); + const last = evaluatePullRequestWatch( + { ...shared, wakes: PULL_REQUEST_WATCH_WAKE_LIMIT - 1 }, + detail(), + [review], + ); + assert.isTrue(last.exhausted); + assert.deepEqual(evaluatePullRequestWatch(watch(), detail(), [review]).changes, []); + }); + it("does not treat a failed check read as a rerun", () => { const failed = detail({ checks: [check("lint", "failure")] }); const reported = evaluatePullRequestWatch(watch(), failed, noRemarks); diff --git a/apps/server/src/orchestration-v2/pullRequestWatch.ts b/apps/server/src/orchestration-v2/pullRequestWatch.ts index 810a899e17786..d6bb461d493c8 100644 --- a/apps/server/src/orchestration-v2/pullRequestWatch.ts +++ b/apps/server/src/orchestration-v2/pullRequestWatch.ts @@ -114,7 +114,7 @@ export function evaluatePullRequestWatch( const at = Date.parse(activeAt(remark)); return ( (at > through || (at === through && !watch.remarkIds.includes(remark.id))) && - remark.author?.login.toLowerCase() !== own + (watch.includeOwnRemarks === true || remark.author?.login.toLowerCase() !== own) ); }); if (fresh.length > 0) changes.push({ kind: "remarks", remarks: fresh }); @@ -140,6 +140,9 @@ export function evaluatePullRequestWatch( changes, next: { startedAt: watch.startedAt, + ...(watch.includeOwnRemarks === undefined + ? {} + : { includeOwnRemarks: watch.includeOwnRemarks }), headSha, failedChecks, passed, diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 31e8c04f30f5a..5c3438d6a43ea 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -2392,6 +2392,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { threadId, ...key, watching: true, + includeOwnRemarks: true, link: { url, source: "agent" }, }); assert.equal( @@ -2401,6 +2402,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { const started = yield* watchOf; assert.isDefined(started); if (started === undefined) return; + assert.isTrue(started.includeOwnRemarks); yield* orchestrator.dispatch({ type: "thread.metadata.update", @@ -2423,6 +2425,42 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { assert.isTrue((yield* maintenance.rebuild).valid); assert.deepEqual(yield* watchOf, recorded); + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make("pr-watch-exclude-own"), + threadId, + ...key, + watching: true, + includeOwnRemarks: false, + }); + const changed = yield* watchOf; + assert.isFalse(changed?.includeOwnRemarks); + assert.notEqual(changed?.startedAt, started.startedAt); + assert.equal(changed?.remarksThrough, changed?.startedAt); + assert.deepEqual(changed?.failedChecks, recorded.failedChecks); + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make("pr-watch-preserve-option"), + threadId, + ...key, + watching: true, + }); + assert.deepEqual(yield* watchOf, changed); + const staleOption = yield* orchestrator + .dispatch({ + type: "thread.pull-request-watch.sync", + commandId: CommandId.make("pr-watch-old-option-sync"), + threadId, + ...key, + startedAt: recorded.startedAt, + watch: recorded, + }) + .pipe(Effect.flip); + assert.equal(staleOption._tag, "OrchestratorDispatchError"); + assert.deepEqual(yield* watchOf, changed); + assert.isTrue((yield* maintenance.rebuild).valid); + assert.deepEqual(yield* watchOf, changed); + yield* orchestrator.dispatch({ type: "thread.pull-request.watch", commandId: CommandId.make("pr-watch-stop"), diff --git a/docs/user/source-control.md b/docs/user/source-control.md index de63202c51ebd..5478be336b100 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -288,7 +288,10 @@ the thread is active, the server checks the pull request every two minutes and w check fails, the required checks pass, someone else comments or reviews, or the branch starts to conflict. Threads in a project that watch the same pull request share one check. On GitHub, a check first asks whether anything changed and reads the pull request only when it did, which keeps -watching inside GitHub's rate limit. Comments from your own account do not wake it. Watching ends +watching inside GitHub's rate limit. Comments from your own account do not wake it by default. +If multiple people or agents share that account, ask the agent to watch with `includeOwnRemarks: true` +to include their comments and reviews. Changing this option starts a new comment window; earlier +comments are not replayed. The same comment-only wake limit applies. Watching ends when the pull request merges or closes, after 10 wakes in a row that bring only comments, after 8 failed reads in a row, or when you press Stop on the thread. A rate limit only pauses watching. Settling or archiving a thread also ends all its watches. Unsettle the thread before starting a new diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 6aa4ea7bbe66a..6fd92a05c9994 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -2856,6 +2856,7 @@ export const OrchestrationV2Command = Schema.Union([ threadId: ThreadId, ...ThreadPullRequestKey.fields, watching: Schema.Boolean, + includeOwnRemarks: 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 3daee9ce197e2..2262450cc4c4c 100644 --- a/packages/contracts/src/threadPullRequest.ts +++ b/packages/contracts/src/threadPullRequest.ts @@ -81,6 +81,7 @@ export type ThreadPullRequestKey = typeof ThreadPullRequestKey.Type; export const ThreadPullRequestWatch = Schema.Struct({ startedAt: IsoDateTime, + includeOwnRemarks: Schema.optional(Schema.Boolean), headSha: Schema.NullOr(TrimmedNonEmptyString), failedChecks: Schema.Array(TrimmedNonEmptyString), passed: Schema.Boolean, From e915f6180f148d035d784c4d9f676afb0df85d0f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 22:01:17 +0000 Subject: [PATCH 2/7] fix(server): start new watches with the own-remarks option set New watches take includeOwnRemarks directly instead of going through the policy-change path, which shifted their start time. Drop a handler test that only mirrored the dispatched command. AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- .../src/mcp/toolkits/pullRequests/handlers.test.ts | 13 ------------- apps/server/src/orchestration-v2/Orchestrator.ts | 2 ++ 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts index 41e8ac58532a1..ceaef41d459f3 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts @@ -296,19 +296,6 @@ describe("pull request toolkit handlers", () => { }), ); - it.effect("passes the same-account review opt-in to the durable watch command", () => - Effect.gen(function* () { - const harness = yield* makeHarness(); - yield* harness.call("watch_pull_request", { - url: "https://github.com/t3tools/t3code/pull/9", - includeOwnRemarks: true, - }); - expect(yield* Ref.get(harness.commands)).toMatchObject([ - { type: "thread.pull-request.watch", number: 9, includeOwnRemarks: true }, - ]); - }), - ); - it.effect("refuses to watch a merged pull request and stops an existing watch", () => Effect.gen(function* () { const watch = { diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index f73fac96a9884..b0b86120372f2 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -4696,6 +4696,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? undefined : (existing.watch ?? { startedAt, + ...(command.includeOwnRemarks && { includeOwnRemarks: true }), headSha: null, failedChecks: [], passed: false, @@ -4728,6 +4729,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio if ( command.type === "thread.pull-request.watch" && watch !== undefined && + watch === existing.watch && command.includeOwnRemarks !== undefined && command.includeOwnRemarks !== (watch.includeOwnRemarks ?? false) ) { From 5fc824d1720542e7af4909390f1dbce00fa03faf Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 22:04:38 +0000 Subject: [PATCH 3/7] fix(server): keep the own-remarks watch option text short Record the larger MCP tool list in the agent context budget. AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/toolkits/pullRequests/tools.ts | 7 ++----- apps/server/src/provider/agentContextBudget.json | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts index 4f0ee3057a748..ed157c59fe52a 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -254,10 +254,7 @@ export type UnlinkPullRequestsResult = typeof UnlinkPullRequestsResult.Type; export const WatchPullRequestResult = Schema.Struct({ ...PullRequestIdentity, - includeOwnRemarks: Schema.Boolean.annotate({ - description: - "Whether the active watch includes comments and reviews from the authenticated account. False by default.", - }), + includeOwnRemarks: Schema.Boolean, watching: Schema.Boolean, wasWatching: Schema.Boolean, }); @@ -384,7 +381,7 @@ const WatchPullRequestTool = Tool.make("watch_pull_request", { includeOwnRemarks: Schema.optional( Schema.Boolean.annotate({ description: - "Include comments and reviews from the authenticated account when several people or agents share it. Defaults to false for a new watch; omit to keep an existing watch's setting. Changing this setting starts a new comment window now. The comment-only wake limit still applies.", + "Also wake on comments and reviews from this account, for accounts several people or agents share. Omit to keep the current setting. A change starts a new comment window.", }), ), }), diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index e166af3f78340..400c90fcfd1cc 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5597, "codexAdditionalContextBytes": 7327, - "mcpToolListJsonBytes": 48126 + "mcpToolListJsonBytes": 48351 } From caa1a54eafaf1db96a7d118bc43e7a38c5206c3d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 22:16:18 +0000 Subject: [PATCH 4/7] fix(server): drop the own-remarks parameter description Cut agent context growth from +225 to +39 bytes, the bare schema of the new parameter. The user guide explains the option. AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/toolkits/pullRequests/tools.ts | 7 +------ apps/server/src/provider/agentContextBudget.json | 2 +- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/apps/server/src/mcp/toolkits/pullRequests/tools.ts b/apps/server/src/mcp/toolkits/pullRequests/tools.ts index ed157c59fe52a..003af02251695 100644 --- a/apps/server/src/mcp/toolkits/pullRequests/tools.ts +++ b/apps/server/src/mcp/toolkits/pullRequests/tools.ts @@ -378,12 +378,7 @@ const WatchPullRequestTool = Tool.make("watch_pull_request", { "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: Schema.Struct({ ...PullRequestTargetInput.fields, - includeOwnRemarks: Schema.optional( - Schema.Boolean.annotate({ - description: - "Also wake on comments and reviews from this account, for accounts several people or agents share. Omit to keep the current setting. A change starts a new comment window.", - }), - ), + includeOwnRemarks: Schema.optional(Schema.Boolean), }), success: WatchPullRequestResult, failure: PullRequestToolError, diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 400c90fcfd1cc..8698f1b375580 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5597, "codexAdditionalContextBytes": 7327, - "mcpToolListJsonBytes": 48351 + "mcpToolListJsonBytes": 48165 } From fb1f8903431b109f01515d1e8f6fa52e81c3ec1d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 22:51:05 +0000 Subject: [PATCH 5/7] fix(server): keep queued watch wakes when the own-remarks option changes Changing includeOwnRemarks moved the watch's startedAt, which made check and conflict wakes already queued for that watch stale while their news stayed recorded as told. Keep startedAt and reject a sync read under the old option instead. AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- .../src/orchestration-v2/Orchestrator.ts | 18 +++++++----------- .../src/orchestration-v2/runtimeLayer.test.ts | 2 +- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index b0b86120372f2..3a695680f570a 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -3683,7 +3683,13 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio pullRequestSnapshotEndsWatch(link?.snapshot ?? null, entry.startedAt), ); if ( - entries.some(({ entry, link }) => link?.watch?.startedAt !== entry.startedAt) || + entries.some( + ({ entry, link }) => + link?.watch?.startedAt !== entry.startedAt || + // Keeping startedAt on a policy change keeps queued wakes; a read under the old policy is stale. + (entry.watch !== null && + (entry.watch.includeOwnRemarks ?? false) !== (link.watch.includeOwnRemarks ?? false)), + ) || (wake && inactive) || // One wake cannot drop the news of just some of its pull requests; the sender splits it. (entries.length > 1 && outdated.some(Boolean)) @@ -4733,19 +4739,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio command.includeOwnRemarks !== undefined && command.includeOwnRemarks !== (watch.includeOwnRemarks ?? false) ) { - // A changed policy must reject in-flight syncs even within the same clock tick. - const policyStartedAt = DateTime.formatIso( - DateTime.makeUnsafe( - Math.max( - DateTime.toEpochMillis(now), - DateTime.toEpochMillis(DateTime.makeUnsafe(watch.startedAt)) + 1, - ), - ), - ); watch = { ...watch, includeOwnRemarks: command.includeOwnRemarks, - startedAt: policyStartedAt, remarksThrough: DateTime.formatIso(now), remarkIds: [], wakes: 0, diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 76390f28979a9..1cd98d4d63835 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -3620,7 +3620,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }); const changed = yield* watchOf; assert.isFalse(changed?.includeOwnRemarks); - assert.notEqual(changed?.startedAt, started.startedAt); + assert.equal(changed?.startedAt, started.startedAt); assert.equal(changed?.remarksThrough, DateTime.formatIso(yield* DateTime.now)); assert.deepEqual(changed?.failedChecks, recorded.failedChecks); yield* orchestrator.dispatch({ From f88939baef5b4e6e399eb258df1e98bf61a8cd12 Mon Sep 17 00:00:00 2001 From: Nathan Shan Date: Sat, 14 Nov 2026 15:10:13 +0800 Subject: [PATCH 6/7] =?UTF-8?q?fix(server):=20=E8=AF=84=E8=AE=BA=E8=BF=87?= =?UTF-8?q?=E6=BB=A4=E5=88=87=E6=8D=A2=E5=90=8E=E9=87=8D=E6=96=B0=E8=AF=BB?= =?UTF-8?q?=E5=8F=96=E7=9B=91=E6=8E=A7=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将评论过滤版本纳入读取缓存标识,以便下一轮读取生效。 - 验证有无主机指纹两种情况下的重新读取。 --- .../src/orchestration-v2/PullRequestWatchReactor.ts | 3 ++- apps/server/src/orchestration-v2/runtimeLayer.test.ts | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index 5fefaac5506ba..c9610b58d9d1e 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -115,7 +115,8 @@ interface HeldWake { readonly reason?: WatchEndReason; } -const watchKey = ({ thread, watch }: WatchTarget) => `${thread.id} ${watch.startedAt}`; +const watchKey = ({ thread, watch }: WatchTarget) => + `${thread.id} ${watch.startedAt} ${watch.remarksPolicyRevision ?? 0}`; type WatchEndReason = | "merged" diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index bcfead7e47520..33ad262c58ed9 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -5182,6 +5182,17 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { assert.equal(reads, readsAfterNotice); assert.lengthOf(yield* wakes, 1); + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make(`pr-watch-due-change-policy-${fingerprinted}`), + threadId, + ...key, + watching: true, + includeOwnRemarks: true, + }); + yield* sweep; + assert.equal(reads, readsAfterNotice + 1); + yield* orchestrator.dispatch({ type: "thread.pull-request.watch", commandId: CommandId.make(`pr-watch-due-end-${fingerprinted}`), From e7fad42a96303ccf23979b7fa5ab065e6ca027bf Mon Sep 17 00:00:00 2001 From: Nathan Shan Date: Sat, 14 Nov 2026 15:24:30 +0800 Subject: [PATCH 7/7] =?UTF-8?q?fix(server):=20=E7=AD=96=E7=95=A5=E5=88=87?= =?UTF-8?q?=E6=8D=A2=E6=97=B6=E5=88=B7=E6=96=B0=E8=BF=9C=E7=AB=AF=E5=AE=A1?= =?UTF-8?q?=E6=9F=A5=E6=B4=BB=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 主机无法提供指纹时,评论过滤切换也会清除活动缓存。 - 保留首读和普通重试行为,282 个相关测试通过。 --- .../src/orchestration-v2/PullRequestWatchReactor.ts | 12 ++++++++++-- .../server/src/orchestration-v2/runtimeLayer.test.ts | 10 ++++++++-- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index c9610b58d9d1e..4d41c0157c92f 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -605,8 +605,16 @@ export const make = Effect.gen(function* () { threadTails.delete(group.key); } const reference = referenceOf(group); - // Invalidate cached reads before using the newer fingerprint; clients receive no notification. - if (fingerprint !== null) yield* pullRequests.invalidate({ reference }); + const policyChanged = + last !== undefined && + group.targets.some( + (target) => + (target.watch.remarksPolicyRevision ?? 0) > 0 && !last.watches.has(watchKey(target)), + ); + // Refresh host reads for a changed policy or a fingerprint-driven read. + if (policyChanged || fingerprint !== null) { + yield* pullRequests.invalidate({ reference }); + } const [detailRead, activityRead] = yield* Effect.all( [ Effect.exit(pullRequests.detail({ ...reference, allowStale: false })), diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 33ad262c58ed9..a3362759319c0 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -5124,14 +5124,18 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }); let reads = 0; + let invalidations = 0; const host = Layer.mergeAll( NodeServices.layer, NodeCrypto.layer, Layer.mock(PullRequestService.PullRequestService)({ - // The host reports the same fingerprint throughout, so only the deadline can prompt a read. + // The host reports the same fingerprint throughout. watchFingerprint: () => Effect.succeed(fingerprinted ? { status: "quiet", remarks: "none" } : null), - invalidate: () => Effect.void, + invalidate: () => + Effect.sync(() => { + invalidations++; + }), detail: () => Effect.sync(() => { reads++; @@ -5177,6 +5181,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { // The notice clears the deadline, so the watch returns to its quiet cadence. const readsAfterNotice = reads; + const invalidationsAfterNotice = invalidations; yield* TestClock.adjust("2 minutes"); yield* sweep; assert.equal(reads, readsAfterNotice); @@ -5192,6 +5197,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }); yield* sweep; assert.equal(reads, readsAfterNotice + 1); + assert.equal(invalidations, invalidationsAfterNotice + 1); yield* orchestrator.dispatch({ type: "thread.pull-request.watch",