From 96e5f2f3a69f545e5eebb3b9feae39b06bbaaf27 Mon Sep 17 00:00:00 2001 From: TylerJNewman Date: Sat, 7 Nov 2026 21:51:08 +0000 Subject: [PATCH 1/3] fix(server): report PR access blockers once until recovery --- .../src/orchestration-v2/Orchestrator.ts | 3 + .../PullRequestWatchReactor.ts | 41 ++++++- .../src/orchestration-v2/runtimeLayer.test.ts | 113 ++++++++++++++---- packages/contracts/src/threadPullRequest.ts | 1 + 4 files changed, 131 insertions(+), 27 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 5d3c4391b1fe8..287fb352e052b 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -4060,6 +4060,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(existing.endedWatch?.checksAwaitedSince !== undefined && { checksAwaitedSince: existing.endedWatch.checksAwaitedSince, }), + ...(existing.endedWatch?.readBlocker !== undefined && { + readBlocker: existing.endedWatch.readBlocker, + }), remarksThrough: startedAt, remarkIds: [], wakes: 0, diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index 369bead0ec428..d103965848a75 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -182,6 +182,7 @@ function watchesEqual(left: ThreadPullRequestWatch, right: ThreadPullRequestWatc left.remarkIds.join("\n") === right.remarkIds.join("\n") && left.conflicting === right.conflicting && left.wakes === right.wakes && + left.readBlocker === right.readBlocker && left.checksAwaitedSince === right.checksAwaitedSince ); } @@ -487,11 +488,19 @@ export const make = Effect.gen(function* () { { concurrency: 2 }, ); // Activity failures must not cancel or hide a permanent detail failure. - const read = Exit.isFailure(detailRead) - ? Exit.failCause(detailRead.cause) - : Exit.isFailure(activityRead) + const activityAccessFailure = + Exit.isFailure(activityRead) && + ["forbidden", "unauthenticated"].includes(providerFailureReason(activityRead.cause) ?? "") && + Exit.isFailure(detailRead) && + providerFailureReason(detailRead.cause) === "failed"; + const read = + activityAccessFailure && Exit.isFailure(activityRead) ? Exit.failCause(activityRead.cause) - : Exit.succeed([detailRead.value, activityRead.value] as const); + : Exit.isFailure(detailRead) + ? Exit.failCause(detailRead.cause) + : Exit.isFailure(activityRead) + ? 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); lastReads.delete(group.key); @@ -504,6 +513,30 @@ export const make = Effect.gen(function* () { } const failures = (readFailures.get(group.key) ?? 0) + 1; readFailures.set(group.key, failures); + if (reason === "forbidden" || reason === "unauthenticated") { + if (failures >= READ_FAILURE_LIMIT) { + const operation = + !activityAccessFailure && Exit.isFailure(detailRead) ? "detail" : "activity"; + const readBlocker = `${operation}:${reason}`; + yield* eachTarget(group, (target) => + target.watch.readBlocker !== undefined + ? Effect.void + : record( + target, + { ...target.watch, readBlocker }, + { + text: `T3 Code cannot read pull request #${target.link.number} (${target.link.url}): host ${operation} access is ${reason}. The watch remains active and will resume after access recovers. Report this blocker once; do not restart the watch or merge without check and review evidence.`, + notification: { + source: { kind: "monitor" }, + outcome: "failed", + summary: `#${target.link.number}: host access blocked, watching for recovery`, + }, + }, + ).pipe(Effect.tap(() => woke(target))), + ); + } + return yield* Effect.failCause(read.cause); + } if (failures >= READ_FAILURE_LIMIT && (yield* eachTarget(group, giveUp))) { readFailures.delete(group.key); } diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 170ceee73a331..28a3678b60705 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -3255,20 +3255,23 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { ); it.effect.each([ - { reason: "failed", activityFails: false }, - { reason: "not-found", activityFails: false }, - { reason: "failed", activityFails: true }, - { reason: "not-found", activityFails: true }, - { reason: "forbidden", activityFails: true }, + { reason: "failed", activityFails: false, activityFailure: "failed" }, + { reason: "not-found", activityFails: false, activityFailure: "failed" }, + { reason: "failed", activityFails: true, activityFailure: "failed" }, + { reason: "not-found", activityFails: true, activityFailure: "failed" }, + { reason: "forbidden", activityFails: false, activityFailure: "failed" }, + { reason: "forbidden", activityFails: true, activityFailure: "failed" }, + { reason: "unauthenticated", activityFails: false, activityFailure: "failed" }, + { reason: "failed", activityFails: true, activityFailure: "forbidden" }, + { reason: "failed", activityFails: true, activityFailure: "unauthenticated" }, ] as const)( - "handles repeated host read failures: $reason (activity fails: $activityFails)", - ({ reason, activityFails }) => + "handles repeated host read failures: $reason (activity fails: $activityFails, $activityFailure)", + ({ reason, activityFails, activityFailure }) => Effect.gen(function* () { + const scenario = `${reason}-${activityFails}-${activityFailure}`; const orchestrator = yield* Orchestrator.OrchestratorV2; - const threadId = ThreadId.make( - `runtime-pull-request-watch-unreadable-${reason}-${activityFails}`, - ); - const projectId = ProjectId.make(`pr-watch-unreadable-project-${reason}-${activityFails}`); + const threadId = ThreadId.make(`runtime-pull-request-watch-unreadable-${scenario}`); + const projectId = ProjectId.make(`pr-watch-unreadable-project-${scenario}`); yield* seedProject({ projectId, title: "Watch unreadable", @@ -3280,7 +3283,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { type: "thread.create", createdBy: "user", creationSource: "web", - commandId: CommandId.make(`pr-watch-unreadable-create-${reason}-${activityFails}`), + commandId: CommandId.make(`pr-watch-unreadable-create-${scenario}`), threadId, projectId, title: "Watch unreadable", @@ -3292,7 +3295,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }); yield* orchestrator.dispatch({ type: "thread.pull-request.watch", - commandId: CommandId.make(`pr-watch-unreadable-start-${reason}-${activityFails}`), + commandId: CommandId.make(`pr-watch-unreadable-start-${scenario}`), threadId, host: "github.com", repository: "pingdotgg/t3code", @@ -3303,19 +3306,29 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { let disconnected = false; let passed = false; let genericFailure = false; + let accessChanged = false; const activityFailures = yield* Queue.unbounded(); - const readFailure = (operation: string, failure: "failed" | "not-found" | "forbidden") => + const readFailure = ( + operation: string, + failure: "failed" | "not-found" | "forbidden" | "unauthenticated", + ) => new PullRequestOperationError({ operation, detail: "host read failed", cause: new PullRequestProviderError({ provider: SourceControlProviderKind.make("github"), operation, - reason: failure, + reason: accessChanged + ? failure === "forbidden" + ? "unauthenticated" + : failure === "unauthenticated" + ? "forbidden" + : failure + : failure, detail: "host read failed", }), }); - const reactor = yield* PullRequestWatchReactor.make.pipe( + const makeReactor = PullRequestWatchReactor.make.pipe( Effect.provide( Layer.mergeAll( NodeServices.layer, @@ -3349,7 +3362,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { activity: () => disconnected && activityFails ? Queue.offer(activityFailures, undefined).pipe( - Effect.andThen(Effect.fail(readFailure("activity", "failed"))), + Effect.andThen(Effect.fail(readFailure("activity", activityFailure))), ) : Effect.succeed({ comments: [], @@ -3362,6 +3375,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { ), ), ); + const reactor = yield* makeReactor; const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; const sweep = reactor.sweep.pipe(Effect.andThen(worker.drain())); const summaries = Effect.map( @@ -3372,30 +3386,83 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { assert.deepEqual(yield* summaries, []); disconnected = true; for (let pass = 0; pass < 15; pass += 1) { - genericFailure = reason === "failed" && (pass === 0 || (pass >= 2 && pass <= 8)); + genericFailure = + reason === "failed" && + activityFailure === "failed" && + (pass === 0 || (pass >= 2 && pass <= 8)); yield* TestClock.adjust("2 minutes"); yield* sweep; } const thread = yield* orchestrator.getThreadShell(threadId); - if (reason !== "failed") { + if (reason === "not-found") { assert.isUndefined(thread?.pullRequests?.[0]?.watch); assert.deepEqual(yield* summaries, ["#8: stopped watching, could not read it"]); return; } assert.isDefined(thread?.pullRequests?.[0]?.watch); - assert.deepEqual(yield* summaries, []); + const blocked = [reason, activityFailure].some( + (failure) => failure === "forbidden" || failure === "unauthenticated", + ); + const expected = blocked ? ["#8: host access blocked, watching for recovery"] : []; + assert.deepEqual(yield* summaries, expected); + if (blocked) { + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make(`pr-watch-blocked-stop-${scenario}`), + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 8, + watching: false, + }); + yield* orchestrator.dispatch({ + type: "thread.pull-request.watch", + commandId: CommandId.make(`pr-watch-blocked-resume-${scenario}`), + threadId, + host: "github.com", + repository: "pingdotgg/t3code", + number: 8, + watching: true, + }); + const restarted = yield* makeReactor; + for (let pass = 0; pass < 10; pass += 1) { + yield* TestClock.adjust("2 minutes"); + yield* restarted.sweep; + yield* worker.drain(); + } + assert.deepEqual(yield* summaries, expected); + accessChanged = true; + for (let pass = 0; pass < 10; pass += 1) { + yield* TestClock.adjust("2 minutes"); + yield* restarted.sweep; + yield* worker.drain(); + } + assert.deepEqual(yield* summaries, expected); + } disconnected = false; genericFailure = false; passed = true; yield* sweep; - assert.deepEqual(yield* summaries, ["#8: checks passed"]); + assert.deepEqual(yield* summaries, [...expected, "#8: checks passed"]); + assert.isUndefined( + (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.watch?.readBlocker, + ); yield* TestClock.adjust("10 minutes"); yield* sweep; - assert.deepEqual(yield* summaries, ["#8: checks passed"]); + assert.deepEqual(yield* summaries, [...expected, "#8: checks passed"]); + if (blocked) { + disconnected = true; + accessChanged = false; + for (let pass = 0; pass < 15; pass += 1) { + yield* TestClock.adjust("2 minutes"); + yield* sweep; + } + assert.deepEqual(yield* summaries, [...expected, "#8: checks passed", ...expected]); + } yield* orchestrator.dispatch({ type: "thread.pull-request.watch", - commandId: CommandId.make(`pr-watch-unreadable-stop-${reason}-${activityFails}`), + commandId: CommandId.make(`pr-watch-unreadable-stop-${scenario}`), threadId, host: "github.com", repository: "pingdotgg/t3code", diff --git a/packages/contracts/src/threadPullRequest.ts b/packages/contracts/src/threadPullRequest.ts index 3daee9ce197e2..efac1ffc077ef 100644 --- a/packages/contracts/src/threadPullRequest.ts +++ b/packages/contracts/src/threadPullRequest.ts @@ -100,6 +100,7 @@ export const ThreadPullRequestWatch = Schema.Struct({ * none did. Null once either happened; absent on watches saved before it existed. */ checksAwaitedSince: Schema.optional(Schema.NullOr(IsoDateTime)), + readBlocker: Schema.optional(TrimmedNonEmptyString), }); export type ThreadPullRequestWatch = typeof ThreadPullRequestWatch.Type; From 3eff1b3200404f737fd6f2d58f3354ccccd03b89 Mon Sep 17 00:00:00 2001 From: TylerJNewman Date: Sat, 7 Nov 2026 22:47:14 +0000 Subject: [PATCH 2/3] fix(server): retain access blocker during incomplete remarks reads --- .../PullRequestWatchReactor.ts | 6 ++++- .../src/orchestration-v2/runtimeLayer.test.ts | 23 ++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index d103965848a75..b87cdaaaf5281 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -591,7 +591,11 @@ export const make = Effect.gen(function* () { remarks: fingerprint?.remarks ?? null, }); yield* eachTarget(group, (target) => { - const report = evaluatePullRequestWatch(target.watch, detail, remarks, now); + const evaluated = evaluatePullRequestWatch(target.watch, detail, remarks, now); + const report = + remarks === null && target.watch.readBlocker !== undefined + ? { ...evaluated, next: { ...evaluated.next, readBlocker: target.watch.readBlocker } } + : evaluated; if (report.changes.length > 0) { const wake = pullRequestWatchMessage({ number: target.link.number, diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 28a3678b60705..47c5cbfea633c 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -3307,6 +3307,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { let passed = false; let genericFailure = false; let accessChanged = false; + let remarksIncomplete = false; const activityFailures = yield* Queue.unbounded(); const readFailure = ( operation: string, @@ -3367,7 +3368,8 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { : Effect.succeed({ comments: [], commentCount: 0, - commentsTruncated: false, + commentsTruncated: remarksIncomplete, + reviewThreadsTruncated: remarksIncomplete, reviewThreads: [], commits: [], }), @@ -3439,10 +3441,29 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { yield* worker.drain(); } assert.deepEqual(yield* summaries, expected); + const blocker = (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.watch + ?.readBlocker; + disconnected = false; + passed = true; + remarksIncomplete = true; + yield* TestClock.adjust("2 minutes"); + yield* sweep; + assert.equal( + (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.watch?.readBlocker, + blocker, + ); + assert.deepEqual(yield* summaries, [...expected, "#8: checks passed"]); + disconnected = true; + for (let pass = 0; pass < 10; pass += 1) { + yield* TestClock.adjust("2 minutes"); + yield* sweep; + } + assert.deepEqual(yield* summaries, [...expected, "#8: checks passed"]); } disconnected = false; genericFailure = false; passed = true; + remarksIncomplete = false; yield* sweep; assert.deepEqual(yield* summaries, [...expected, "#8: checks passed"]); assert.isUndefined( From 50850f31faff0e4420b629685e4cd2ee888ad792 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Thu, 12 Nov 2026 00:48:01 +0000 Subject: [PATCH 3/3] fix(server): keep queued access notices tied to their outage --- .../src/orchestration-v2/Orchestrator.ts | 11 ++++ .../PullRequestWatchReactor.ts | 2 +- .../src/orchestration-v2/runtimeLayer.test.ts | 56 ++++++++++++++++--- packages/contracts/src/orchestrationV2.ts | 1 + 4 files changed, 62 insertions(+), 8 deletions(-) diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index ea2496122c5d4..90c7cbbd259ff 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -970,6 +970,13 @@ function isStalePullRequestWatchWake( candidate.source !== "stack-dismissed" && threadPullRequestKeysEqual(candidate, stamp), ); if (link === undefined) return true; + if (stamp.readBlocker !== undefined) { + return ( + link.watch === undefined || + link.watch.readBlocker !== stamp.readBlocker || + pullRequestSnapshotEndsWatch(link.snapshot, link.watch.startedAt) + ); + } if (stamp.watchEnded === true) { // The final notice outlives its watch, but a replacement watch supersedes it. const latestWatch = link.watch ?? link.endedWatch; @@ -3590,6 +3597,10 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ? null : (command.watch?.headSha ?? null), ...(command.watch === null ? { watchEnded: true } : {}), + ...(command.watch?.readBlocker !== undefined && + link.watch.readBlocker === undefined && { + readBlocker: command.watch.readBlocker, + }), }, } : command.wake.notification.source, diff --git a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts index 51d84f421cf83..c683af7b53ead 100644 --- a/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts +++ b/apps/server/src/orchestration-v2/PullRequestWatchReactor.ts @@ -534,7 +534,7 @@ export const make = Effect.gen(function* () { if (failures >= READ_FAILURE_LIMIT) { const operation = !activityAccessFailure && Exit.isFailure(detailRead) ? "detail" : "activity"; - const readBlocker = `${operation}:${reason}`; + const readBlocker = `${operation}:${reason}:${yield* random.uuidV4()}`; yield* eachTarget(group, (target) => target.watch.readBlocker !== undefined ? Effect.void diff --git a/apps/server/src/orchestration-v2/runtimeLayer.test.ts b/apps/server/src/orchestration-v2/runtimeLayer.test.ts index 9adc6166b6dd0..d18bf5c51591d 100644 --- a/apps/server/src/orchestration-v2/runtimeLayer.test.ts +++ b/apps/server/src/orchestration-v2/runtimeLayer.test.ts @@ -5047,6 +5047,12 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { }); it.effect.each([ + "blocker still watching", + "blocker new watch", + "blocker recovered", + "blocker new outage", + "blocker unwatch", + "blocker newer head", "unwatch", "unlink", "new watch", @@ -5067,9 +5073,18 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { `queued-${mode}`, mode === "reopened", ); + const accessNotice = mode.startsWith("blocker "); const finalNotice = mode === "closed notice" || mode === "closed notice unlinked and relinked"; + const wakeSummary = finalNotice + ? "#7: closed, stopped watching" + : accessNotice + ? "#7: host access blocked, watching for recovery" + : "#7: checks failed"; const deliverable = + mode === "blocker still watching" || + mode === "blocker new watch" || + mode === "blocker newer head" || mode === "still watching" || mode === "closed notice" || mode === "reopened" || @@ -5088,7 +5103,12 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { dispatchMode: { type: "start_immediately" }, }); const wakeMessageId = MessageId.make(`${threadId}-wake`); - const recordedWatch = { ...watch, headSha: "abc123", wakes: 1 }; + const recordedWatch = { + ...watch, + headSha: "abc123", + wakes: 1, + ...(accessNotice ? { readBlocker: "detail:forbidden" } : {}), + }; yield* orchestrator.dispatch({ type: "thread.pull-request-watch.sync", commandId: CommandId.make(`${threadId}-wake`), @@ -5098,11 +5118,11 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { watch: finalNotice ? null : recordedWatch, wake: { messageId: wakeMessageId, - text: finalNotice ? "Closed, stopped watching" : "Checks failed", + text: wakeSummary, notification: { source: { kind: "monitor" }, outcome: "updated", - summary: finalNotice ? "#7: closed, stopped watching" : "#7: checks failed", + summary: wakeSummary, }, ...(mode === "comments then newer head" || mode === "comment limit" ? { includesRemarks: true } @@ -5132,7 +5152,12 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { assert.equal(wakeRun.status, "queued"); assert.equal(userRun.status, "queued"); - if (mode === "unwatch" || mode === "new watch") { + if ( + mode === "unwatch" || + mode === "new watch" || + mode === "blocker unwatch" || + mode === "blocker new watch" + ) { yield* orchestrator.dispatch({ type: "thread.pull-request.watch", commandId: CommandId.make(`${threadId}-unwatch`), @@ -5140,7 +5165,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { ...key, watching: false, }); - if (mode === "new watch") { + if (mode === "new watch" || mode === "blocker new watch") { yield* TestClock.adjust("1 millis"); yield* orchestrator.dispatch({ type: "thread.pull-request.watch", @@ -5222,7 +5247,24 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { includesRemarks: true, }, }); - } else if (mode === "newer head" || mode === "comments then newer head") { + } else if (mode === "blocker recovered" || mode === "blocker new outage") { + const { readBlocker: _blocker, ...recovered } = recordedWatch; + yield* orchestrator.dispatch({ + type: "thread.pull-request-watch.sync", + commandId: CommandId.make(`${threadId}-recovered`), + threadId, + ...key, + startedAt: watch.startedAt, + watch: + mode === "blocker new outage" + ? { ...recovered, readBlocker: "detail:forbidden:next" } + : recovered, + }); + } else if ( + mode === "newer head" || + mode === "comments then newer head" || + mode === "blocker newer head" + ) { yield* orchestrator.dispatch({ type: "thread.pull-request-watch.sync", commandId: CommandId.make(`${threadId}-advance`), @@ -5281,7 +5323,7 @@ it.layer(layerTest)("OrchestrationV2LayerLive lifecycle", (it) => { after.messages .filter((message) => message.id === wakeMessageId) .map((message) => message.notification?.summary), - [finalNotice ? "#7: closed, stopped watching" : "#7: checks failed"], + [wakeSummary], ); }), ); diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 4cb4984b74baa..8cbb1670ba5e4 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1304,6 +1304,7 @@ export const OrchestrationV2NotificationSource = kindUnionWithFallback( /** Null when the wake is not about one head, so a newer push does not outdate it. */ headSha: Schema.NullOr(TrimmedNonEmptyString), watchEnded: Schema.optional(Schema.Boolean), + readBlocker: Schema.optional(TrimmedNonEmptyString), }), ), }),