Skip to content

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

Open
t3dotgg wants to merge 1 commit into
mainfrom
t3code/server-side-settled-logic
Open

refactor(server): settled state is now server-authored, ending client drift#5462
t3dotgg wants to merge 1 commit into
mainfrom
t3code/server-side-settled-logic

Conversation

@t3dotgg

@t3dotgg t3dotgg commented Aug 6, 2026

Copy link
Copy Markdown
Member

"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 (feat: allow disabling auto-settle on merge #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 8b8bec7. 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.

@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 46.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The description clearly explains the server-authored settled state refactor, its motivation, implementation, risks, and UI impact.
Title check ✅ Passed The title clearly and concisely summarizes the primary change: settled state is now authored by the server.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XL 500-999 changed lines (additions + deletions). labels Aug 6, 2026

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread apps/server/src/orchestration/Services/ThreadAutoSettleReactor.ts Outdated
Comment thread apps/server/src/orchestration/Layers/ThreadAutoSettleReactor.ts Outdated
Comment thread apps/server/src/orchestration/ThreadAutoSettleReactor.ts
Comment thread apps/server/src/orchestration/decider.ts Outdated
Comment thread apps/server/src/orchestration/ThreadAutoSettleReactor.ts
Comment thread apps/web/src/components/settings/BetaSettingsPanel.tsx Outdated
Comment thread packages/client-runtime/src/state/threadSettled.ts
Comment thread apps/server/src/orchestration/autoSettle.ts Outdated
Comment thread apps/web/src/components/settings/BetaSettingsPanel.tsx Outdated
@macroscopeapp

macroscopeapp Bot commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

1 blocking correctness issue found. This PR fundamentally changes settled-state handling from client-derived to server-authored, introducing a new periodic reactor and schema changes. The architectural scope—new server infrastructure, capability flags, and settings migration—plus an unresolved High severity finding about shell schema compatibility warrant human review.

You can customize Macroscope's approvability policy. Learn more.

@t3dotgg
t3dotgg force-pushed the t3code/server-side-settled-logic branch from bfa8139 to c2b77f2 Compare August 6, 2026 22:01

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/server/src/orchestration/Layers/ThreadAutoSettleReactor.ts`:
- Around line 169-185: The inactivity-candidate flow around
resolveAutoSettleVerdict must treat a cached "open" result from
peekChangeRequestState as "unknown" so it reaches the existing verification
path. Preserve the cooldown, background-policy gate, and verifyBudget checks,
then use verifyChangeRequestState to refresh and settle when the PR is merged or
closed. Add a focused test covering peekStatus returning open and refreshStatus
returning merged or closed.

In `@apps/server/src/orchestration/Services/ThreadAutoSettleReactor.ts`:
- Around line 19-35: Move ThreadAutoSettleReactor and its layer implementation
into the canonical orchestration/ThreadAutoSettleReactor module, inline
ThreadAutoSettleReactorShape in Context.Service, and export the service type,
make, and layer members there. Update all consumers to import
ThreadAutoSettleReactor from the canonical module instead of the Services/ or
Layers/ modules, removing the obsolete split definitions.

In `@apps/web/src/components/settings/BetaSettingsPanel.tsx`:
- Around line 112-114: Update the AutoSettleDaysInput usage in BetaSettingsPanel
so updateServerSettings is not called for every valid keystroke; commit the
fully validated draft threshold only on blur or Enter, preserving the existing
threadAutoSettleAfterDays setting update once editing completes.

In `@packages/contracts/src/orchestration.ts`:
- Around line 596-600: Keep auto-settle backdating server-only: in
packages/contracts/src/orchestration.ts:596-600, remove settledAt from the
client-callable thread.settle contract or provide a separate server-only
auto-settle command; in apps/server/src/orchestration/decider.ts:500-502, derive
the timestamp exclusively from trusted server projection data; in
apps/server/src/orchestration/decider.settled.test.ts:111-130, update coverage
to exercise the trusted server path without accepting a caller-provided
timestamp.

In `@packages/contracts/src/settings.ts`:
- Around line 536-541: The default for threadAutoSettleAfterDays must preserve
clients that previously persisted sidebarAutoSettleAfterDays: null instead of
enabling auto-settlement with 3 days. Add a one-time migration that carries the
explicit null forward, or change the server default to a disabled-safe value,
and add coverage verifying the persisted null case remains disabled after
decoding.
🪄 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 7251f1a and bfa8139.

📒 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

Comment thread apps/server/src/orchestration/Layers/ThreadAutoSettleReactor.ts Outdated
Comment thread apps/server/src/orchestration/Services/ThreadAutoSettleReactor.ts Outdated
Comment thread apps/web/src/components/settings/BetaSettingsPanel.tsx Outdated
Comment thread packages/contracts/src/orchestration.ts Outdated
Comment thread packages/contracts/src/settings.ts
Comment thread packages/contracts/src/settings.ts
Comment thread apps/mobile/src/features/home/HomeScreen.tsx
Comment thread apps/server/src/orchestration/decider.ts
Comment thread apps/server/src/orchestration/decider.ts
Comment thread apps/server/src/orchestration/decider.ts
@github-actions github-actions Bot added size:XXL 1,000+ changed lines (additions + deletions). and removed size:XL 500-999 changed lines (additions + deletions). labels Aug 6, 2026
Comment thread apps/server/src/orchestration/decider.ts
Comment thread apps/server/src/orchestration/projector.ts
Comment thread apps/server/src/orchestration/projector.ts
@t3dotgg
t3dotgg force-pushed the t3code/server-side-settled-logic branch from 12b1dc6 to 671e346 Compare August 7, 2026 06:20
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Thread transfer impact

✅ Thread transfer remains within every enforced ceiling.

Provider Metric Main baseline This PR Impact PR ceiling
Codex Total thread wire 11.3 KiB 11.4 KiB +17 B (+0.1%) 15.1 KiB
Codex Thread snapshot wire 5.5 KiB 5.5 KiB +11 B (+0.2%) 7.3 KiB
Codex Live turn WebSocket wire 5.9 KiB 5.9 KiB +6 B (+0.1%) 7.8 KiB
Codex Live turn WebSocket decoded 49.7 KiB 49.7 KiB 0 B (0.0%) 66.4 KiB
Codex Live turn messages 16 16 0 (0.0%) 21
Claude Total thread wire 11.3 KiB 11.3 KiB +5 B (+0.0%) 15.1 KiB
Claude Thread snapshot wire 5.5 KiB 5.5 KiB +15 B (+0.3%) 7.3 KiB
Claude Live turn WebSocket wire 5.9 KiB 5.9 KiB −10 B (−0.2%) 7.8 KiB
Claude Live turn WebSocket decoded 50.6 KiB 50.6 KiB 0 B (0.0%) 66.4 KiB
Claude Live turn messages 16 16 0 (0.0%) 21

Baseline: 57a299a · PR result: 8b8bec7 · Source CI: success

Scenario and decoded snapshot size

10 historical turns, 5 command tools per turn, 878.9 KiB retained MCP result per historical turn, and a 1.05 MiB retained result in the measured turn.

  • Codex decoded thread snapshot: 94.7 KiB
  • Claude decoded thread snapshot: 95.4 KiB

Updated in place by a trusted workflow. PR artifacts are strictly validated and never executed.

Comment thread apps/server/src/orchestration/ThreadAutoSettleReactor.ts
Comment thread apps/server/src/orchestration/ThreadAutoSettleReactor.ts
@t3dotgg
t3dotgg force-pushed the t3code/server-side-settled-logic branch from 671e346 to 407c63e Compare August 7, 2026 08:40
Comment thread apps/server/src/orchestration/ThreadAutoSettleReactor.ts Outdated
Comment thread apps/server/src/orchestration/autoSettle.ts
@t3dotgg
t3dotgg force-pushed the t3code/server-side-settled-logic branch 2 times, most recently from 09f046c to 5199aca Compare August 7, 2026 10:16
return yield* updateCachedStatus(cwd, local, remote);
});

const peekStatus: VcsStatusBroadcaster["Service"]["peekStatus"] = Effect.fn(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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:
In file @apps/server/src/vcs/VcsStatusBroadcaster.ts around line 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`.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 72f1b2f 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.

@t3dotgg
t3dotgg force-pushed the t3code/server-side-settled-logic branch 3 times, most recently from 2693eda to ffb4e20 Compare August 8, 2026 11:17

@macroscopeapp macroscopeapp Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread apps/server/src/orchestration/ThreadAutoSettleReactor.test.ts Outdated
Comment thread apps/server/src/orchestration/projector.ts
Comment thread packages/contracts/src/settings.ts
@t3dotgg
t3dotgg force-pushed the t3code/server-side-settled-logic branch from ffb4e20 to 9763b02 Compare August 8, 2026 11:27
Comment thread apps/server/src/orchestration/ThreadAutoSettleReactor.ts Outdated
Settled classification used to be re-derived per client (inactivity window,
PR state, clock hacks), with real drift between web and mobile. The server
is now the single author of settled state: a ThreadAutoSettleReactor sweep
dispatches thread.settle for quiet threads and merged/closed PRs, the
auto-settle window moved to ServerSettings, and clients just read
settledOverride.

Includes the review-hardening rounds: settledAt derived in the decider from
read-model activity (unforgeable, restart-safe via a projected
latestUserMessageAt stamp, revert-consistent, clock-skew clamped), only
live-confirmed PR states act as settle authority (cached open/closed/no-PR
re-verify with cooldown), fail-safe settings reads, and capability-gated
settings UI with blur-committed input.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@t3dotgg
t3dotgg force-pushed the t3code/server-side-settled-logic branch from 5d03b32 to 8b8bec7 Compare August 15, 2026 01:06

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 8b8bec7. Configure here.

// array: the engine's command read model boots threads with no message
// bodies, and the decider needs this stamp for settle invariants and the
// settledAt derivation. Optional for pre-existing payloads.
latestUserMessageAt: Schema.optional(Schema.NullOr(IsoDateTime)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant