refactor(server): settled state is now server-authored, ending client drift#5462

closed1
main
t3code/server-side-settled-logic
openedAug 6, 2026byTtheo
View on GitHubTwo-way sync enabledPR last imported 10d ago

"Settled" was split across the stack: the server stored a user override while every client re-derived the actual classification from an inactivity window, per-row PR state, and clock heuristics. The copies had drifted — mobile hardcoded the 3-day window web made configurable, sorted the settled shelf by a different key, and inverted the capability-gate default — so the same thread could be settled on one device and active on another.

Now the server is the single author of settled state and clients just read settledOverride:

  • A new ThreadAutoSettleReactor sweeps once a minute and dispatches the existing thread.settle command for threads that qualify: quiet past the inactivity window, or on a merged/closed PR. All existing decider invariants and the activity-driven auto-unsettle apply unchanged, so a raced sweep can never hide live work.
  • The auto-settle window moved from per-device client settings (localStorage) to ServerSettings.threadAutoSettleAfterDays — one value per environment, same shelf on every device.
  • An open PR still blocks inactivity settling. The sweep reads cached VCS status, and verifies cold checkouts with a cooldown-limited, background-policy-gated live lookup so it never stampedes the forge.
  • Auto-settles backdate settledAt to the thread's last activity, and both platforms now sort the settled shelf by settledAt — fixing the ordering drift.
  • Snooze wakes count as activity, so a woken thread gets a fresh window instead of settling the moment it wakes.
  • The settle-on-merge toggle (#5880) moved server-side with the rest of the policy: ServerSettings.threadAutoSettleOnMerge, applied by the sweep. Clients keep a small changeRequestAutoSettles helper for display only (Woke-pill suppression). Mobile's device-local copy of the toggle is removed — settle policy has no per-device knobs.
  • Deleted from clients: the whole effectiveSettled derivation (window/PR/clock inputs, the "serverAdjudicated" clock-skew hack), the per-row PR-state lift-up machinery on web and mobile, web's useNowMinute hook, and mobile's hardcoded window. effectiveSettled is now a plain override read with a blocked-work guard.

Old servers never emit the override, so their threads simply stay active — same graceful degradation as before, minus a capability check per row.

Built by Claude Fable 5 via Claude Code.


Note

High Risk Changes core thread-list behavior and settlement timing across server and all clients; incorrect sweep or settledAt derivation could hide active work or settle threads users still care about.

Overview Thread settlement is now server-authored so web, mobile, and desktop no longer disagree on whether a thread is settled.

A new ThreadAutoSettleReactor runs periodic sweeps using pure policy in autoSettle.ts: inactivity (threadAutoSettleAfterDays), merged/closed PR rules (threadAutoSettleOnMerge), and cooldown-limited VCS/PR verification via VcsStatusBroadcaster.peekStatus / refreshStatus. Qualifying threads get thread.settle dispatched server-side; the decider derives settledAt from last activity (including latestUserMessageAt on the read model) instead of settle time.

Clients stop re-deriving settled state: effectiveSettled is a settledOverride read plus local guards (live session, pending input, user message newer than settledAt). Removed are client-side inactivity/PR/clock partitioning, per-row PR state lift-up on lists, useNowMinute for settle, and mobile/desktop sidebarAutoSettle* / autoSettleOnMerge preferences. Auto-settle knobs move to server settings (threadAutoSettleAfterDays, threadAutoSettleOnMerge), gated by a threadAutoSettle capability; settled shelf ordering aligns on settledAt across platforms.

Reviewed by Cursor Bugbot for commit 8b8bec71c5446d3cd404f1a32bf0bef1d89cd895. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Move thread settled state from client-side heuristics to server-authored settlement

  • Introduces ThreadAutoSettleReactor on the server that periodically sweeps threads and dispatches settle commands based on inactivity windows and PR merge state read from ServerSettings.
  • effectiveSettled in threadSettled.ts is rewritten to only classify a thread as settled when the server has set settledOverride = 'settled' and no newer user message exists; all client-side inactivity/PR-state paths are removed.
  • Auto-settle settings (threadAutoSettleAfterDays, threadAutoSettleOnMerge) move from ClientSettings to ServerSettings; the web settings panel now reads/writes server-scoped fields and only renders when the server advertises the threadAutoSettle capability.
  • thread.settled events now stamp settledAt with the thread's last recorded activity time rather than the command dispatch time, affecting shelf ordering.
  • Mobile and web clients stop tracking per-row PR change request state and no longer pass changeRequestStateByKey, autoSettleOnMerge, or wall-clock now into thread list partitioning.
  • Risk: effectiveSettled no longer accepts any options object; all callers must be updated, and threads will not appear settled until the server reactor marks them.

Macroscope summarized 8b8bec7.

Summary by CodeRabbit

  • New Features

    • Added server-wide automatic thread settlement for inactive threads.
    • Added settings to enable, disable, and configure automatic settlement from 1–90 days.
    • Added support for environments to indicate automatic settlement availability.
  • Improvements

    • Thread settlement status is now consistently determined by server state across web and mobile.
    • Settled threads are sorted using their settlement time, with last update time as a fallback.
    • Snooze behavior and settlement safeguards remain supported.
MmacroscopeappBotreviewed · 2 inline threadsAug 6, 2026
Synced from GitHub

Two Effect service convention violations in the new ThreadAutoSettleReactor service. The rest of apps/server/src already follows the canonical single-module make + layer shape (e.g. vcs/VcsStatusBroadcaster.ts, background/BackgroundPolicy.ts), so the new service is the outlier here. Everything else in the diff (namespace subpath imports, dependency acquisition via yield* Foo, pure-config options, test-only Layer.succeed/Layer.mock seams, VcsStatusBroadcaster.peekStatus addition, contracts/settings moves) looks consistent with the conventions.

Posted via Macroscope — Effect Service Conventions

apps/server/src/orchestration/Layers/ThreadAutoSettleReactor.tslines 214–217Outdated · bfa8139Open thread
……@@ -0,0 +1,217 @@
214+export const makeThreadAutoSettleReactorLive = (options?: ThreadAutoSettleReactorLiveOptions) =>
215+ Layer.effect(ThreadAutoSettleReactor, makeThreadAutoSettleReactor(options));
216+
217+export const ThreadAutoSettleReactorLive = makeThreadAutoSettleReactorLive();
MmacroscopeappBoton lines 214–217Aug 6, 2026

Layers/ThreadAutoSettleReactor.ts:217

This module owns construction of the only implementation, so the canonical export names are make and layer rather than makeThreadAutoSettleReactorLive / ThreadAutoSettleReactorLive (the *Live suffix is reserved for abstract-port modules with several implementations). Suggest exporting the existing constructor as make and adding export const layer = Layer.effect(ThreadAutoSettleReactor, make()), then updating server.ts and the test import.

diff
1-const makeThreadAutoSettleReactor = (options?: ThreadAutoSettleReactorLiveOptions) =>
2+export const make = (options?: ThreadAutoSettleReactorOptions) =>
3...
4-export const makeThreadAutoSettleReactorLive = (options?: ThreadAutoSettleReactorLiveOptions) =>
5- Layer.effect(ThreadAutoSettleReactor, makeThreadAutoSettleReactor(options));
6-
7-export const ThreadAutoSettleReactorLive = makeThreadAutoSettleReactorLive();
8+export const layer = Layer.effect(ThreadAutoSettleReactor, make());

No longer relevant as of f5ee144326035566857dea093aa1a5c6cd267497

Fixed in c514c22cc alongside the module consolidation: exports are now make (options-taking) and layer, with server.ts importing the namespace and the test building Layer.effect(ThreadAutoSettleReactor, make()).

……@@ -0,0 +1,35 @@
32+export class ThreadAutoSettleReactor extends Context.Service<
33+ ThreadAutoSettleReactor,
34+ ThreadAutoSettleReactorShape
35+>()("t3/orchestration/Services/ThreadAutoSettleReactor") {}
MmacroscopeappBoton lines 19–35Aug 6, 2026

The new service is split across Services/ThreadAutoSettleReactor.ts (tag + standalone ThreadAutoSettleReactorShape) and Layers/ThreadAutoSettleReactor.ts (construction + layer). Consider a single canonical module apps/server/src/orchestration/ThreadAutoSettleReactor.ts with the interface declared inline in the Context.Service call, and reference the inferred interface as ThreadAutoSettleReactor["Service"] (the ThreadAutoSettleReactorShape["sweepOnce"] / ["start"] / satisfies annotations in the layer file) instead of keeping a FooShape type.

Posted via Macroscope — Effect Service Conventions

Keeping the Services/Layers split: it matches every other reactor in this package (CheckpointReactor, ThreadDeletionReactor, ProviderCommandReactor all have a Services/ tag+Shape module and a Layers/ implementation). A one-off consolidation here would make this the odd one out.

Reversed my earlier dismissal — the check clarified the convention distinguishes single-implementation modules from abstract ports (the sibling reactors' Services/Layers split predates it). Fixed in c514c22cc: one canonical module at orchestration/ThreadAutoSettleReactor.ts, interface inline in Context.Service, no standalone Shape type, references via ThreadAutoSettleReactor["Service"].

packages/client-runtime/src/state/threadSettled.tsline 204Outdated · bfa8139Open thread
……@@ -216,68 +192,24 @@ export function threadWokeAt(
201+ * covering the beat between a request landing and the server's auto-unsettle
202+ * event arriving.
229203 */
230204export function effectiveSettled(
MmacroscopeappBoton line 204Aug 6, 2026

🟡 Medium state/threadSettled.ts:204

effectiveSettled now returns true for a thread that has settledOverride === "settled" but also a queued turn start — a user message no session has adopted yet. In the window between a new user message landing and the server's auto-unsettle projection arriving, the shell still carries the stale "settled" override, no running session, and no pending approval/input, so all three guards pass and the just-reactivated thread classifies as settled. The previous implementation detected this race via hasQueuedTurnStart and a settledAt-versus-latestUserMessageAt comparison to keep the thread visible until the server cleared the override. Consider restoring the queued-turn guard (with the settledAt adjudication) so a freshly sent message isn't hidden before auto-unsettle lands.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @packages/client-runtime/src/state/threadSettled.ts around line 204:
2
3`effectiveSettled` now returns `true` for a thread that has `settledOverride === "settled"` but also a queued turn start — a user message no session has adopted yet. In the window between a new user message landing and the server's auto-unsettle projection arriving, the shell still carries the stale `"settled"` override, no running session, and no pending approval/input, so all three guards pass and the just-reactivated thread classifies as settled. The previous implementation detected this race via `hasQueuedTurnStart` and a `settledAt`-versus-`latestUserMessageAt` comparison to keep the thread visible until the server cleared the override. Consider restoring the queued-turn guard (with the `settledAt` adjudication) so a freshly sent message isn't hidden before auto-unsettle lands.
apps/server/src/orchestration/ThreadAutoSettleReactor.tsline 174Outdated · bfa8139Open thread
……@@ -0,0 +1,217 @@
171+ autoSettleAfterDays: afterDays,
172+ changeRequestState: yield* peekChangeRequestState(target),
173+ });
174+ if (verdict.kind === "skip") continue;
MmacroscopeappBoton line 174Aug 6, 2026

🟡 Medium Layers/ThreadAutoSettleReactor.ts:174

A thread whose cached PR state is "open" is never live-verified, so it stays active indefinitely even after the PR is actually merged or closed. peekChangeRequestState returns "open" from a stale cache, resolveAutoSettleVerdict returns skip for any "open" state, and the sweep immediately continues — only "unknown" states ever reach refreshStatus. A cold checkout whose cache still says "open" will never be re-checked, so a merged/closed PR never triggers the settle that "merged"/"closed" would. Consider live-verifying stale "open" states (or treating them like "unknown" after a cooldown) so a merged PR unblocks the settle.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/server/src/orchestration/Layers/ThreadAutoSettleReactor.ts around line 174:
2
3A thread whose cached PR state is `"open"` is never live-verified, so it stays active indefinitely even after the PR is actually merged or closed. `peekChangeRequestState` returns `"open"` from a stale cache, `resolveAutoSettleVerdict` returns `skip` for any `"open"` state, and the sweep immediately `continue`s — only `"unknown"` states ever reach `refreshStatus`. A cold checkout whose cache still says `"open"` will never be re-checked, so a merged/closed PR never triggers the settle that `"merged"`/`"closed"` would. Consider live-verifying stale `"open"` states (or treating them like `"unknown"` after a cooldown) so a merged PR unblocks the settle.
apps/web/src/components/settings/BetaSettingsPanel.tsxline 114Outdated · bfa8139Open thread
……@@ -87,27 +91,27 @@ export function BetaSettingsPanel() {
109- value={sidebarAutoSettleAfterDays}
110- onCommit={(days) => updateSettings({ sidebarAutoSettleAfterDays: days })}
113+ value={threadAutoSettleAfterDays}
114+ onCommit={(days) => updateServerSettings({ threadAutoSettleAfterDays: days })}
CcursorBoton line 114Aug 6, 2026

Auto-settle settings ignore capability

Medium Severity

The new threadAutoSettle capability is documented as the gate for the auto-settle settings UI, but BetaSettingsPanel shows and writes threadAutoSettleAfterDays whenever sidebar v2 is on. On servers without the sweep, the control still appears and persists a no-op setting.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit bfa8139a0d1dfd610a008172e5388685432d9029. Configure here.

Fixed in bad152b9a: the settings rows now render only when the primary server reports capabilities.threadAutoSettle === true.

apps/server/src/orchestration/autoSettle.tsline 157Outdated · bfa8139Open thread
……@@ -0,0 +1,159 @@
154+ }
155+ // Quiet past the window, but PR state undetermined: verify before hiding —
156+ // the branch may have an open PR nobody has looked at from this server yet.
157+ if (changeRequestState === "unknown") return { kind: "verify-pr" };
CcursorBoton line 157Aug 6, 2026

Stale open PR blocks settle forever

High Severity

Cached open PR state returns skip, so the sweep never enters the verify-pr path. After the first live lookup caches open, later merges on unwatched checkouts never re-verify despite the reactor’s cooldown comments promising settle within that window. Quiet branch threads can stay active indefinitely after merge.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit bfa8139a0d1dfd610a008172e5388685432d9029. Configure here.

CcoderabbitaiBotreviewed · 5 inline threadsAug 6, 2026
Synced from GitHub

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
1Verify each finding against current code. Fix only still-valid issues, skip the
2rest with a brief reason, keep changes minimal, and validate.
3
4Inline comments:
5In `@apps/server/src/orchestration/Layers/ThreadAutoSettleReactor.ts`:
6- Around line 169-185: The inactivity-candidate flow around
7resolveAutoSettleVerdict must treat a cached "open" result from
8peekChangeRequestState as "unknown" so it reaches the existing verification
9path. Preserve the cooldown, background-policy gate, and verifyBudget checks,
10then use verifyChangeRequestState to refresh and settle when the PR is merged or
11closed. Add a focused test covering peekStatus returning open and refreshStatus
12returning merged or closed.
13
14In `@apps/server/src/orchestration/Services/ThreadAutoSettleReactor.ts`:
15- Around line 19-35: Move ThreadAutoSettleReactor and its layer implementation
16into the canonical orchestration/ThreadAutoSettleReactor module, inline
17ThreadAutoSettleReactorShape in Context.Service, and export the service type,
18make, and layer members there. Update all consumers to import
19ThreadAutoSettleReactor from the canonical module instead of the Services/ or
20Layers/ modules, removing the obsolete split definitions.
21
22In `@apps/web/src/components/settings/BetaSettingsPanel.tsx`:
23- Around line 112-114: Update the AutoSettleDaysInput usage in BetaSettingsPanel
24so updateServerSettings is not called for every valid keystroke; commit the
25fully validated draft threshold only on blur or Enter, preserving the existing
26threadAutoSettleAfterDays setting update once editing completes.
27
28In `@packages/contracts/src/orchestration.ts`:
29- Around line 596-600: Keep auto-settle backdating server-only: in
30packages/contracts/src/orchestration.ts:596-600, remove settledAt from the
31client-callable thread.settle contract or provide a separate server-only
32auto-settle command; in apps/server/src/orchestration/decider.ts:500-502, derive
33the timestamp exclusively from trusted server projection data; in
34apps/server/src/orchestration/decider.settled.test.ts:111-130, update coverage
35to exercise the trusted server path without accepting a caller-provided
36timestamp.
37
38In `@packages/contracts/src/settings.ts`:
39- Around line 536-541: The default for threadAutoSettleAfterDays must preserve
40clients that previously persisted sidebarAutoSettleAfterDays: null instead of
41enabling auto-settlement with 3 days. Add a one-time migration that carries the
42explicit null forward, or change the server default to a disabled-safe value,
43and add coverage verifying the persisted null case remains disabled after
44decoding.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 732a9a94-b034-4f54-a494-b6616cb226ed

📥 Commits

Reviewing files that changed from the base of the PR and between 7251f1a1f14afdf626e7c7f8664da423594732a3 and bfa8139a0d1dfd610a008172e5388685432d9029.

📒 Files selected for processing (33)
  • apps/desktop/src/settings/DesktopClientSettings.test.ts
  • apps/mobile/src/features/home/HomeScreen.tsx
  • apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx
  • apps/mobile/src/features/threads/thread-list-v2-items.tsx
  • apps/mobile/src/features/threads/threadListV2.test.ts
  • apps/mobile/src/features/threads/threadListV2.ts
  • apps/server/integration/OrchestrationEngineHarness.integration.ts
  • apps/server/src/environment/ServerEnvironment.ts
  • apps/server/src/orchestration/Layers/CheckpointReactor.test.ts
  • apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts
  • apps/server/src/orchestration/Layers/OrchestrationReactor.ts
  • apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts
  • apps/server/src/orchestration/Layers/ThreadAutoSettleReactor.test.ts
  • apps/server/src/orchestration/Layers/ThreadAutoSettleReactor.ts
  • apps/server/src/orchestration/Services/ThreadAutoSettleReactor.ts
  • apps/server/src/orchestration/autoSettle.test.ts
  • apps/server/src/orchestration/autoSettle.ts
  • apps/server/src/orchestration/decider.settled.test.ts
  • apps/server/src/orchestration/decider.ts
  • apps/server/src/server.ts
  • apps/server/src/vcs/VcsStatusBroadcaster.ts
  • apps/web/src/components/ChatView.tsx
  • apps/web/src/components/Sidebar.logic.test.ts
  • apps/web/src/components/Sidebar.logic.ts
  • apps/web/src/components/SidebarV2.tsx
  • apps/web/src/components/settings/BetaSettingsPanel.tsx
  • apps/web/src/hooks/useNowMinute.ts
  • packages/client-runtime/src/state/threadSettled.test.ts
  • packages/client-runtime/src/state/threadSettled.ts
  • packages/contracts/src/environment.ts
  • packages/contracts/src/orchestration.ts
  • packages/contracts/src/settings.test.ts
  • packages/contracts/src/settings.ts
💤 Files with no reviewable changes (3)
  • apps/desktop/src/settings/DesktopClientSettings.test.ts
  • apps/mobile/src/features/threads/thread-list-v2-items.tsx
  • apps/web/src/hooks/useNowMinute.ts
……@@ -0,0 +1,35 @@
32+export class ThreadAutoSettleReactor extends Context.Service<
33+ ThreadAutoSettleReactor,
34+ ThreadAutoSettleReactorShape
35+>()("t3/orchestration/Services/ThreadAutoSettleReactor") {}
CcoderabbitaiBoton lines 19–35Aug 6, 2026

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

shell
1#!/bin/bash
2set -euo pipefail
3
4echo "== files =="
5git ls-files | rg '(^|/)ThreadAutoSettleReactor\.ts$|Layers/|Services/|effect-smol/LLMS\.md$' | sed -n '1,200p'
6
7echo "== target file outline =="
8ast-grep outline apps/server/src/orchestration/Services/ThreadAutoSettleReactor.ts --view expanded || true
9
10echo "== target file content =="
11cat -n apps/server/src/orchestration/Services/ThreadAutoSettleReactor.ts
12
13echo "== related layer files =="
14for f in $(git ls-files 'apps/server/src/**/*' | rg 'ThreadAutoSettle|AutoSettle|Reactor|Layers/.*Reactor|Services/.*Reactor'); do
15 echo "--- $f"
16 sed -n '1,220p' "$f"
17done
18
19echo "== imports/usages =="
20rg -n "ThreadAutoSettleReactor|ThreadAutoSettleReactorShape|autoSettle|AutoSettleSettle|sweepOnce" apps/server/src || true
21
22echo "== effect docs if present =="
23if [ -f .repos/effect-smol/LLMS.md ]; then sed -n '1,260p' .repos/effect-smol/LLMS.md; fi

Repository: pingdotgg/t3code

Length of output: 50372


🏁 Script executed:

shell
1#!/bin/bash
2set -euo pipefail
3
4echo "== orchestration Layer ThreadAutoSettleReactor outline/content =="
5ast-grep outline apps/server/src/orchestration/Layers/ThreadAutoSettleReactor.ts --view expanded || true
6sed -n '1,260p' apps/server/src/orchestration/Layers/ThreadAutoSettleReactor.ts
7
8echo "== service peer files =="
9for f in apps/server/src/orchestration/Services/CheckpointReactor.ts \
10 apps/server/src/orchestration/Services/OrchestrationReactor.ts \
11 apps/server/src/orchestration/Services/ThreadDeletionReactor.ts; do
12 echo "--- $f"
13 sed -n '1,140p' "$f"
14done
15
16echo "== imports of ThreadAutoSettleReactor =="
17rg -n "ThreadAutoSettleReactor|ThreadAutoSettleReactorShape|makeThreadAutoSettleReactorLive|threadAutoSettleReactor|AutoSettle" apps/server/src -g '*.ts' || true
18
19echo "== effect guidance relevant lines =="
20if [ -f .repos/effect-smol/LLMS.md ]; then
21 rg -n "Service|layer|Context.Service|canonical|reactor|module" .repos/effect-smol/LLMS.md || true
22 sed -n '1,220p' .repos/effect-smol/LLMS.md
23fi

Repository: pingdotgg/t3code

Length of output: 34812


Use the canonical Effect service module for ThreadAutoSettleReactor.

The service exports ThreadAutoSettleReactorShape from Services/ while its layer implementation lives in Layers/; move both into apps/server/src/orchestration/ThreadAutoSettleReactor.ts, inline the shape in Context.Service, expose ThreadAutoSettleReactor["Service"], make, and layer; and update dependent imports from Services/ and Layers/ to the canonical module.

🤖 Prompt for AI Agents
1Verify each finding against current code. Fix only still-valid issues, skip the
2rest with a brief reason, keep changes minimal, and validate.
3
4In `@apps/server/src/orchestration/Services/ThreadAutoSettleReactor.ts` around
5lines 19 - 35, Move ThreadAutoSettleReactor and its layer implementation into
6the canonical orchestration/ThreadAutoSettleReactor module, inline
7ThreadAutoSettleReactorShape in Context.Service, and export the service type,
8make, and layer members there. Update all consumers to import
9ThreadAutoSettleReactor from the canonical module instead of the Services/ or
10Layers/ modules, removing the obsolete split definitions.

Source: Pipeline failures

@t3dotgg, agreed. The Services/ and Layers/ split is consistent with the sibling reactors. This PR should not introduce a one-off module convention change. I am withdrawing this finding.


✏️ Learnings added
1Learnt from: t3dotgg
2Repo: pingdotgg/t3code PR: 5462
3File: apps/server/src/orchestration/Services/ThreadAutoSettleReactor.ts:19-35
4Timestamp: 2026-08-06T22:17:52.062Z
5Learning: In `apps/server/src/orchestration`, reactor implementations follow a split module structure: the service contract is in `Services/`, and the live implementation layer is in `Layers/`. `CheckpointReactor`, `ThreadDeletionReactor`, and `ProviderCommandReactor` establish this convention. Do not request a canonical combined module for one reactor unless the repository adopts that convention broadly.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

apps/server/src/orchestration/Layers/ThreadAutoSettleReactor.tslines 169–185Outdated · bfa8139Open thread
……@@ -0,0 +1,217 @@
182+ const verified = resolveAutoSettleVerdict(thread, {
183+ now,
184+ autoSettleAfterDays: afterDays,
185+ changeRequestState: yield* verifyChangeRequestState(target),
CcoderabbitaiBoton lines 169–185Aug 6, 2026

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Refresh cached open PR state for inactive threads.

peekChangeRequestState returns "open" from cache. resolveAutoSettleVerdict then returns skip, so this code never calls refreshStatus for that thread.

If VCS polling has stopped and the PR later merges or closes, the thread remains active indefinitely. Treat cached "open" as unknown for an inactivity candidate, then use the existing cooldown, background-policy gate, and verification budget. Add a focused test where peekStatus returns open and refreshStatus returns merged or closed.

🤖 Prompt for AI Agents
1Verify each finding against current code. Fix only still-valid issues, skip the
2rest with a brief reason, keep changes minimal, and validate.
3
4In `@apps/server/src/orchestration/Layers/ThreadAutoSettleReactor.ts` around lines
5169 - 185, The inactivity-candidate flow around resolveAutoSettleVerdict must
6treat a cached "open" result from peekChangeRequestState as "unknown" so it
7reaches the existing verification path. Preserve the cooldown, background-policy
8gate, and verifyBudget checks, then use verifyChangeRequestState to refresh and
9settle when the PR is merged or closed. Add a focused test covering peekStatus
10returning open and refreshStatus returning merged or closed.

✅ Confirmed as addressed by @t3dotgg

Fixed in bad152b9a as suggested: cached "open" is treated as needing verification (a distinct open-cached state routed through the existing cooldown + background-policy gate + budget), with the focused test where peekStatus returns open and refreshStatus returns merged, plus the inverse where it stays open.

🧩 Analysis chain

🏁 Script executed:

shell
1#!/bin/bash
2set -euo pipefail
3
4git show --stat --oneline --no-renames bad152b9a
5git show --format= --no-ext-diff --unified=80 bad152b9a -- \
6 apps/server/src/orchestration/Layers/ThreadAutoSettleReactor.ts \
7 apps/server/src/orchestration/Layers/ThreadAutoSettleReactor.test.ts

Length of output: 22983


@t3dotgg, verified in bad152b9a.

"open-cached" reaches the existing verification path. The background-policy gate and verification budget remain in place. The focused tests cover both refreshed "merged" settlement and refreshed "open" retention.


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

You are interacting with an AI system.

packages/contracts/src/settings.tslines 536–541Outdated · bfa8139Open thread
……@@ -536,6 +533,12 @@ export type BackgroundActivitySettings = typeof BackgroundActivitySettings.Type;
538+ // same settled shelf — the server derives settled state, clients only read it.
539+ threadAutoSettleAfterDays: Schema.NullOr(ThreadAutoSettleAfterDays).pipe(
540+ Schema.withDecodingDefault(Effect.succeed(DEFAULT_THREAD_AUTO_SETTLE_AFTER_DAYS)),
541+ ),
CcoderabbitaiBoton lines 536–541Aug 6, 2026

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Preserve an existing disabled auto-settle preference during migration.

A client with persisted sidebarAutoSettleAfterDays: null loses that opt-out when this field is removed. The new server setting then decodes to 3, which enables auto-settlement after upgrade.

Add a one-time migration or define a safe server default that does not convert an explicit disable into enabled behavior. Add coverage for the persisted null case.

🤖 Prompt for AI Agents
1Verify each finding against current code. Fix only still-valid issues, skip the
2rest with a brief reason, keep changes minimal, and validate.
3
4In `@packages/contracts/src/settings.ts` around lines 536 - 541, The default for
5threadAutoSettleAfterDays must preserve clients that previously persisted
6sidebarAutoSettleAfterDays: null instead of enabling auto-settlement with 3
7days. Add a one-time migration that carries the explicit null forward, or change
8the server default to a disabled-safe value, and add coverage verifying the
9persisted null case remains disabled after decoding.

Dismissing as intended behavior: there is no canonical per-device value to migrate — the old setting lived independently in each browser's localStorage, the desktop app's file, and mobile hardcoded 3 regardless. A user could simultaneously have null on desktop and 3 in a browser against the same server, and mobile always auto-settled at 3 days. That ambiguity is what this PR removes. The server default of 3 matches what the majority of surfaces already did; users who had disabled it flip one switch in Settings → Beta (and un-settling any thread is one click). A migration would have to pick one arbitrary device's value to win, which is worse than a clean default.

@t3dotgg, agreed. The old value was per-device and could conflict across clients. It cannot provide a valid migration source for the new server-authoritative setting.

Using the three-day default is intentional and matches the existing behavior on most surfaces. I am withdrawing this finding.


✏️ Learnings added
1Learnt from: t3dotgg
2Repo: pingdotgg/t3code PR: 5462
3File: packages/contracts/src/settings.ts:536-541
4Timestamp: 2026-08-06T22:17:44.440Z
5Learning: For the server-authoritative thread auto-settlement migration, the former `sidebarAutoSettleAfterDays` client setting must not be migrated into `ServerSettings.threadAutoSettleAfterDays`. The former value was independently persisted per browser and desktop client, while mobile used a fixed three-day behavior. These values can conflict for the same server, so `threadAutoSettleAfterDays` intentionally defaults to three days. Users who previously disabled auto-settlement can disable the new server setting in Settings → Beta.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

apps/web/src/components/settings/BetaSettingsPanel.tsxlines 112–114Outdated · bfa8139Open thread
……@@ -87,27 +91,27 @@ export function BetaSettingsPanel() {
109- value={sidebarAutoSettleAfterDays}
110- onCommit={(days) => updateSettings({ sidebarAutoSettleAfterDays: days })}
113+ value={threadAutoSettleAfterDays}
114+ onCommit={(days) => updateServerSettings({ threadAutoSettleAfterDays: days })}
CcoderabbitaiBoton lines 112–114Aug 6, 2026

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Commit the threshold only after editing completes.

AutoSettleDaysInput calls onCommit for every valid keystroke. Line 114 persists each intermediate value to the server.

If a user changes 3 to 14, a pause after entering 1 can set the policy to one day. The reactor can settle eligible threads before the user enters the final value. Restoring 14 does not restore those threads.

Commit on blur or Enter after validating the full draft value.

🤖 Prompt for AI Agents
1Verify each finding against current code. Fix only still-valid issues, skip the
2rest with a brief reason, keep changes minimal, and validate.
3
4In `@apps/web/src/components/settings/BetaSettingsPanel.tsx` around lines 112 -
5114, Update the AutoSettleDaysInput usage in BetaSettingsPanel so
6updateServerSettings is not called for every valid keystroke; commit the fully
7validated draft threshold only on blur or Enter, preserving the existing
8threadAutoSettleAfterDays setting update once editing completes.

✅ Confirmed as addressed by @t3dotgg

Fixed in bad152b9a: the input commits on blur/Enter only, with full-draft validation. Good catch — with the sweep being server-real, a transient "1" while typing "14" would have settled day-old threads irreversibly.

@t3dotgg, confirmed. AutoSettleDaysInput now validates the complete draft and updates threadAutoSettleAfterDays only on blur or Enter. This prevents transient values from reaching the server sweep.


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

You are interacting with an AI system.

packages/contracts/src/orchestration.tslines 596–600Outdated · bfa8139Open thread
……@@ -600,6 +593,11 @@ const ThreadSettleCommand = Schema.Struct({
597+ // settled shelf orders by when work actually ended, not by when the sweep
598+ // happened to run. Absent (user-initiated settles) the event stamps the
599+ // command time.
600+ settledAt: Schema.optionalKey(IsoDateTime),
CcoderabbitaiBoton lines 596–600Aug 6, 2026

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Keep auto-settle backdating server-only.

settledAt is now accepted from the public thread.settle command and stored unchanged. Any command caller can select a past, future, or malformed string because IsoDateTime is currently Schema.String. This can corrupt settled-shelf ordering.

  • packages/contracts/src/orchestration.ts#L596-L600: remove settledAt from the client-callable thread.settle contract, or introduce a server-only auto-settle command.
  • apps/server/src/orchestration/decider.ts#L500-L502: derive the backdated timestamp only from trusted server projection data.
  • apps/server/src/orchestration/decider.settled.test.ts#L111-L130: test the trusted server path instead of accepting a caller-provided timestamp.
📍 Affects 3 files
  • packages/contracts/src/orchestration.ts#L596-L600 (this comment)
  • apps/server/src/orchestration/decider.ts#L500-L502
  • apps/server/src/orchestration/decider.settled.test.ts#L111-L130
🤖 Prompt for AI Agents
1Verify each finding against current code. Fix only still-valid issues, skip the
2rest with a brief reason, keep changes minimal, and validate.
3
4In `@packages/contracts/src/orchestration.ts` around lines 596 - 600, Keep
5auto-settle backdating server-only: in
6packages/contracts/src/orchestration.ts:596-600, remove settledAt from the
7client-callable thread.settle contract or provide a separate server-only
8auto-settle command; in apps/server/src/orchestration/decider.ts:500-502, derive
9the timestamp exclusively from trusted server projection data; in
10apps/server/src/orchestration/decider.settled.test.ts:111-130, update coverage
11to exercise the trusted server path without accepting a caller-provided
12timestamp.

✅ Confirmed as addressed by @t3dotgg

Fixed in bad152b9a: the command no longer carries settledAt at all. The decider derives the stamp from trusted read-model data (last user message / turn timestamps), and the decider test now covers that derivation instead of a caller-provided timestamp.

@t3dotgg, confirmed. ThreadSettleCommand no longer accepts settledAt. The decider now owns timestamp derivation from trusted read-model activity. This resolves the client-controlled shelf-ordering risk.


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

You are interacting with an AI system.

apps/server/src/orchestration/decider.tsfileOutdated · bad152bOpen thread

🟡 Medium

https://github.com/pingdotgg/t3code/blob/bad152b9a6363edcebff43d97ee71fbe25a6b6c5/apps/server/src/orchestration/decider.ts#L160

thread.settle persists a future settledAt when the newest user message carries a client-supplied timestamp ahead of the server time. The threadHasQueuedTurnStart guard only blocks settle inside a ±2-minute grace window, so a message timestamp more than two minutes in the future makes that guard pass; the thread is then settled with threadLastActivityAt(thread, occurredAt), which returns the unclamped client timestamp, and that future value controls shelf ordering — placing the settled thread ahead of genuine recent work. Clamp candidate activity timestamps in threadLastActivityAt to occurredAt (or ignore future candidates) so a skewed or malicious client cannot forge shelf position.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/server/src/orchestration/decider.ts around line 160:
2
3`thread.settle` persists a future `settledAt` when the newest user message carries a client-supplied timestamp ahead of the server time. The `threadHasQueuedTurnStart` guard only blocks settle inside a ±2-minute grace window, so a message timestamp more than two minutes in the future makes that guard pass; the thread is then settled with `threadLastActivityAt(thread, occurredAt)`, which returns the unclamped client timestamp, and that future value controls shelf ordering — placing the settled thread ahead of genuine recent work. Clamp candidate activity timestamps in `threadLastActivityAt` to `occurredAt` (or ignore future candidates) so a skewed or malicious client cannot forge shelf position.

Fixed in f5ee14432: settledAt candidates are clamped to the command time (parsed <= occurredAtMs), so a future-stamped client message can no longer forge shelf position — it falls through to the next-newest real candidate or the command time.

apps/server/src/orchestration/decider.tslines 163–168Outdated · bad152bOpen thread
……@@ -142,6 +142,41 @@ function threadHasQueuedTurnStart(
165+ thread.latestTurn?.requestedAt,
166+ thread.latestTurn?.startedAt,
167+ thread.latestTurn?.completedAt,
168+ ];
MmacroscopeappBoton lines 163–168Aug 6, 2026

🟡 Medium orchestration/decider.ts:163

threadLastActivityAt omits thread.snoozedUntil from its candidates. When a snoozed thread's wake time is the most recent activity, thread.settle stamps settledAt with an older message or turn timestamp instead, placing the thread at the wrong position in the settled shelf ordering. Add thread.snoozedUntil to the candidates array so the wake time is considered.

suggestion
1 ...thread.messages.filter((message) => message.role === "user").map((m) => m.createdAt),
2 thread.latestTurn?.requestedAt,
3 thread.latestTurn?.startedAt,
4 thread.latestTurn?.completedAt,
5 thread.snoozedUntil,
6 ];
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/server/src/orchestration/decider.ts around lines 163-168:
2
3`threadLastActivityAt` omits `thread.snoozedUntil` from its candidates. When a snoozed thread's wake time is the most recent activity, `thread.settle` stamps `settledAt` with an older message or turn timestamp instead, placing the thread at the wrong position in the settled shelf ordering. Add `thread.snoozedUntil` to the candidates array so the wake time is considered.

Fixed in f5ee14432: an ELAPSED snooze wake now counts as a settledAt candidate (the same clamp keeps a still-pending wake out — a future wake is not activity). Matches the sweep's inactivity accounting on the reactor side.

apps/server/src/orchestration/decider.tsline 178Outdated · bad152bOpen thread
……@@ -142,6 +142,41 @@ function threadHasQueuedTurnStart(
175+ }
176+ }
177+ return latest ?? fallback;
178+}
CcursorBoton line 178Aug 6, 2026

SettledAt misses unhydrated messages

High Severity

thread.settle now derives settledAt from thread.messages and latestTurn, but the engine’s command read model bootstraps every thread with messages: [] and never reloads historical messages. Quiet threads after restart therefore lose user-message activity when stamping. An understated settledAt trips the new client guard (latestUserMessageAt newer than settledAt), so server-settled threads can stay in the active list.

Additional Locations (2)

Reviewed by Cursor Bugbot for commit bad152b9a6363edcebff43d97ee71fbe25a6b6c5. Configure here.

Fixed in f5ee14432 — this was the sharpest catch of the round. The command read model indeed boots threads with messages: [] and never rehydrates, so after a restart the decider lost all user-message activity: the queued-turn invariant went blind and settledAt understated, which would trip the client's reactivation guard and strand server-settled threads in the active list. OrchestrationThread now carries the projected latestUserMessageAt stamp (maintained by the projector on message-sent, hydrated by both getSnapshot and getCommandReadModel), and the decider reads it alongside in-memory messages for both the invariant and the stamp.

apps/server/src/orchestration/projector.tsline 535Outdated · f5ee144Open thread
……@@ -516,10 +517,22 @@ export function projectEvent(
520532 ...nextBase,
521533 threads: updateThread(nextBase.threads, payload.threadId, {
522534 messages: cappedMessages,
535+ latestUserMessageAt,
CcursorBoton line 535Aug 7, 2026

Revert leaves stale activity stamp

Medium Severity

thread.reverted prunes in-memory messages and resets latestTurn, but never recomputes latestUserMessageAt. The engine decides against that in-memory model, while SQLite is refreshed correctly, so a same-session settle after revert can still see the pre-revert stamp. That can falsely trip the queued-turn settle guard or backdate settledAt to removed work.

Additional Locations (2)

Reviewed by Cursor Bugbot for commit f5ee144326035566857dea093aa1a5c6cd267497. Configure here.

Fixed in 12b1dc681: the in-memory revert projection recomputes latestUserMessageAt from retained messages (the SQL side already did), so a same-session settle after revert no longer sees the pre-revert stamp — neither falsely tripping the queued-turn guard nor backdating settledAt to removed work. Covered by an assertion in the existing revert projector test.

apps/server/src/orchestration/ThreadAutoSettleReactor.tsline 110Outdated · 671e346Open thread
……@@ -0,0 +1,245 @@
107+ const prState = status.pr?.state ?? null;
108+ if (prState === null) return "none";
109+ return prState === "open" ? "open-cached" : prState;
110+ },
CcursorBoton line 110Aug 7, 2026

Stale empty PR cache settles

Medium Severity

peekChangeRequestState treats a cached status with no PR as authoritative none, and the inactivity path settles without live verification. Only unknown and cached open re-verify. A warm cache that predates opening a PR can therefore inactivity-settle a thread that still has an open PR, which breaks the stated open-PR block.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 671e3465b231298b025183a65e60b8f97edc666b. Configure here.

Fixed in 407c63ea3 (was 0c62906a3 pre-rebase): a cached status with no PR now maps to "unknown" instead of "none" — the cache may predate the PR being opened, so only a live lookup (or a branch mismatch, which a lookup cannot change) may conclude there is no gating PR. Added a test where peekStatus reports no PR but refreshStatus finds an open one: the thread stays active.

……@@ -0,0 +1,245 @@
86+ const autoSettleAfterDays = serverSettings.getSettings.pipe(
87+ Effect.map((settings) => settings.threadAutoSettleAfterDays),
88+ Effect.orElseSucceed(() => DEFAULT_THREAD_AUTO_SETTLE_AFTER_DAYS),
89+ );
CcursorBoton line 89Aug 7, 2026

Settings failure enables settle

Low Severity

When getSettings fails, autoSettleAfterDays falls back to DEFAULT_THREAD_AUTO_SETTLE_AFTER_DAYS (3) instead of disabling the sweep. A user who set threadAutoSettleAfterDays to null can still get inactivity settles during a settings read failure.

Reviewed by Cursor Bugbot for commit 671e3465b231298b025183a65e60b8f97edc666b. Configure here.

Fixed in 407c63ea3: a failed settings read now resolves autoSettleAfterDays to null (sweep disabled) instead of the 3-day default — hiding threads on a read error is not undone by the next successful sweep, so the failure mode must be inert. Covered by a test with a failing ServerSettingsService mock.

apps/server/src/orchestration/ThreadAutoSettleReactor.tsline 112Outdated · 407c63eOpen thread
……@@ -0,0 +1,248 @@
109+ if (status.refName !== thread.branch) return "none";
110+ const prState = status.pr?.state ?? null;
111+ if (prState === null) return "unknown";
112+ return prState === "open" ? "open-cached" : prState;
MmacroscopeappBoton line 112Aug 7, 2026

🟠 High orchestration/ThreadAutoSettleReactor.ts:112

peekChangeRequestState treats a cached closed PR state as final and returns "closed", which resolveAutoSettleVerdict immediately settles on. A closed PR can be reopened, so a stale cached closed causes the sweep to settle a thread whose PR is actually open again — hiding work behind a reopened PR without any live verification. Only merged is truly terminal; a cached closed should map to "unknown" (or "open-cached") so the verdict requests a live lookup before settling, the same way cached "open" is handled.

✅ Resolved in 72f1b2fab90273c1dc9de4e2b497d5637228774f

Fixed in 72f1b2fab: cached "closed" now maps to closed-cached, which the verdict routes through the same cooldown-limited live verification as cached "open" — a confirmed close still settles regardless of recency, but a reopened PR is caught before the thread is hidden. Only "merged" remains trusted from cache.

Confirmed—closed-cached correctly routes through live verification before settling, preventing stale cached close states from hiding threads whose PRs have been reopened.

apps/server/src/orchestration/autoSettle.tsline 96Outdated · 407c63eOpen thread
……@@ -0,0 +1,174 @@
93+ latestTimestamp = timestamp;
94+ }
95+ }
96+ return latest;
CcursorBoton line 96Aug 7, 2026

Future timestamps block inactivity settle

Medium Severity

threadLastActivityAt treats client-supplied message/turn timestamps as activity even when they are ahead of server now. The inactivity check then always sees the thread as fresh, so a skewed createdAt can prevent server auto-settle indefinitely. Snooze wakes are already clamped with nowMs, and the decider clamps settledAt candidates the same way, but this path does not.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 407c63ea327370d8ecde6c40b9abb9b9437b167d. Configure here.

Fixed in 72f1b2fab: threadLastActivityAt clamps candidates to the sweep's clock (Math.min with nowMs), and the window comparison applies the same clamp — a future-stamped createdAt counts as activity NOW, so the skew can no longer extend the freshness window indefinitely. Mirrors the decider's settledAt clamp. Test covers both sides: still active within a window of the sweep clock, settles once the sweep clock alone moves a full window past.

apps/server/src/vcs/VcsStatusBroadcaster.tsline 346Outdated · 5199acaOpen thread
……@@ -339,6 +343,17 @@ export const make = Effect.gen(function* () {
339343 return yield* updateCachedStatus(cwd, local, remote);
340344 });
341345
346+ const peekStatus: VcsStatusBroadcaster["Service"]["peekStatus"] = Effect.fn(
MmacroscopeappBoton line 346Aug 7, 2026

🟠 High vcs/VcsStatusBroadcaster.ts:346

peekStatus merges independently cached local and remote halves without verifying they describe the same checkout. When refreshLocalStatusCore updates only cached.local after a branch switch, the stale cached.remote from the previous branch remains in the cache. mergeGitStatusParts pairs the new branch name from local with the old branch's PR data from remote, so the auto-settle reactor can see a new branch alongside a merged/closed PR from the prior branch and incorrectly settle an active thread. Consider invalidating the remote half when the local checkout identity changes, or adding a coherence check before merging in peekStatus.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/server/src/vcs/VcsStatusBroadcaster.ts around line 346:
2
3`peekStatus` merges independently cached local and remote halves without verifying they describe the same checkout. When `refreshLocalStatusCore` updates only `cached.local` after a branch switch, the stale `cached.remote` from the previous branch remains in the cache. `mergeGitStatusParts` pairs the new branch name from local with the old branch's PR data from remote, so the auto-settle reactor can see a new branch alongside a merged/closed PR from the prior branch and incorrectly settle an active thread. Consider invalidating the remote half when the local checkout identity changes, or adding a coherence check before merging in `peekStatus`.

Fixed in 72f1b2fab at the consumer: peekChangeRequestState now requires the cached PR's headRef to equal the thread's branch, so a stale local/remote pairing after a branch switch (new refName + previous branch's PR) maps to "unknown" and live-verifies instead of settling. I kept the fix in the sweep rather than changing peekStatus/cache invalidation because the streaming path already tolerates the transient mismatch (rows re-render when the remote half refreshes) and the sweep is the only consumer that acts irreversibly on the merged view.

MmacroscopeappBotreviewed · 1 inline threadAug 8, 2026
Synced from GitHub

One convention finding in the new server-side auto-settle code: the test harness references the service interface through Parameters<typeof Tag["of"]>[0] instead of the canonical Tag["Service"]. The reactor module itself now follows the canonical layout (single orchestration/ThreadAutoSettleReactor.ts, inline interface in Context.Service, real make, layer, all dependencies acquired via yield*), so the earlier layout/naming findings are resolved.

Posted via Macroscope — Effect Service Conventions

apps/server/src/orchestration/ThreadAutoSettleReactor.test.tslines 77–79Outdated · ffb4e20Open thread
……@@ -0,0 +1,321 @@
76+ /** Cached VCS status handed to peekStatus; null = nothing cached. */
77+ readonly peekStatus?: Parameters<(typeof VcsStatusBroadcaster)["of"]>[0]["peekStatus"];
78+ /** Live refreshStatus result; unset = the sweep must not go live. */
79+ readonly refreshStatus?: Parameters<(typeof VcsStatusBroadcaster)["of"]>[0]["refreshStatus"];
MmacroscopeappBoton lines 77–79Aug 8, 2026

orchestration/ThreadAutoSettleReactor.test.ts:79

Reference the inferred service interface as VcsStatusBroadcaster["Service"][...] rather than Parameters<(typeof VcsStatusBroadcaster)["of"]>[0][...] — that is the shape used everywhere else in the server tests and it reads directly.

suggestion
1 readonly peekStatus?: VcsStatusBroadcaster["Service"]["peekStatus"];
2 /** Live refreshStatus result; unset = the sweep must not go live. */
3 readonly refreshStatus?: VcsStatusBroadcaster["Service"]["refreshStatus"];

No longer relevant as of 2693edadb16e01d1b96d3ef838f08e6fe773fc8d

apps/server/src/orchestration/projector.tsline 769Outdated · ffb4e20Open thread
……@@ -748,6 +761,18 @@ export function projectEvent(
766+ // seeing it as still queued (blocking settle/snooze) or backdate
767+ // settledAt to reverted-away work.
768+ let latestUserMessageAt: string | null = null;
769+ for (const message of messages) {
MmacroscopeappBoton line 769Aug 8, 2026

🟡 Medium orchestration/projector.ts:769

thread.reverted recomputes latestUserMessageAt from the capped messages array, so a revert that retains an older checkpoint but whose matching user message was already evicted (by the MAX_THREAD_MESSAGES cap) resets latestUserMessageAt to null. This drops the timestamp even though the reverted history still contains user activity, causing the decider's settle/auto-settle logic to compute timing from incomplete history until rehydration. Consider recomputing from the full pre-cap message set, or preserving the existing thread.latestUserMessageAt when no retained user message is found.

No longer relevant as of 5d03b32c8d36fda48bf5e2178aa0bf5fa0a05bab

Dismissing with reasoning — the null is harmless in exactly the eviction case described, and the suggested preservation would reintroduce a worse bug. When a retained turn's user message has been evicted by the message cap, that message is necessarily OLDER than its own turn's completion, and the revert keeps that turn's checkpoint — so latestTurn.completedAt ≥ the evicted message time. Every consumer of the stamp (the decider's settledAt derivation and last-activity computation, and the sweep's inactivity window) takes the max over the stamp AND the latestTurn timestamps, so a null stamp with an older true value never changes any outcome. The queued-turn guard is also unaffected: an evicted message is far outside its 2-minute grace window. Preserving the pre-revert stamp instead would resurrect the stale-stamp bug fixed earlier in this PR — a reverted-away NEWER message blocking settle/snooze on work that no longer exists.

apps/server/src/orchestration/ThreadAutoSettleReactor.tsline 160Outdated · 9763b02Open thread
……@@ -0,0 +1,257 @@
157+ );
158+ if (status === null) return { state: "unknown", verified: true };
159+ if (status.refName !== thread.branch) return { state: "none", verified: true };
160+ return { state: status.pr?.state ?? "none", verified: true };
CcursorBoton line 160Aug 8, 2026

Live PR verify skips headRef guard

Medium Severity

peekChangeRequestState refuses to act on a cached PR whose headRef does not match the thread branch (stale local/remote pairing after a branch switch), but verifyChangeRequestState returns status.pr?.state after only checking refName. refreshStatus loads local and remote halves concurrently, so a mid-refresh checkout change can still pair a new refName with the previous branch’s PR. That path can auto-settle a thread from another branch’s merge or close.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 9763b020cba93dc83ffa893278a41f70317b21c6. Configure here.

Fixed in 5d03b32c8: the live path now applies the same headRef coherence guard as the cached path — a PR whose headRef is not the thread's branch reports "unknown" instead of settling (or blocking) on another branch's PR, and a later sweep retries.

Added 1 commit

  • refactor(orchestration): derive thread settled state on the server8b8bec7
CcursorBotreviewed · 1 inline threadAug 15, 2026
Synced from GitHub

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 8b8bec71c5446d3cd404f1a32bf0bef1d89cd895. Configure here.

……@@ -397,6 +397,11 @@ export const OrchestrationThread = Schema.Struct({
401+ // array: the engine's command read model boots threads with no message
402+ // bodies, and the decider needs this stamp for settle invariants and the
403+ // settledAt derivation. Optional for pre-existing payloads.
404+ latestUserMessageAt: Schema.optional(Schema.NullOr(IsoDateTime)),
CcursorBoton line 404Aug 15, 2026

Shell stamp breaks old servers

High Severity

latestUserMessageAt is required on OrchestrationThreadShell, unlike neighboring lifecycle fields that use Schema.optional for old-server and cached-snapshot interop. The full OrchestrationThread marks the same field optional. New clients decoding shells from older servers or pre-upgrade persisted snapshots missing the key can fail the shell stream or cache hydrate, which breaks the thread list despite the PR’s graceful-degradation goal for older servers.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 8b8bec71c5446d3cd404f1a32bf0bef1d89cd895. Configure here.

False positive — the required latestUserMessageAt on OrchestrationThreadShell (L464) is pre-existing and byte-identical to main (main L459); this PR does not touch it, so old-server interop is unchanged. The field this PR ADDS is on the full OrchestrationThread detail model (L404), and that one is Schema.optional for exactly the interop reason you describe.