Skip to content

Add a workspace git runtime state manager for stable hydrated workspace summaries #212

@boudra

Description

@boudra

Problem

Workspace hydration currently returns baseline descriptors while live git-derived fields are computed later and emitted opportunistically. That creates UI instability:

  • fetch_workspaces can return diffStat: null even after the daemon has already computed a real git summary before.
  • reconnect / app resume / sidebar refresh can overwrite enriched workspace state with baseline values.
  • volatile git/runtime state is scattered across Session methods instead of having a single daemon-owned home.

We just landed a narrow client-side fix to stop diffStat flashing. This issue tracks the real server-side reshape.

Goal

Split workspace data into:

  1. Stable workspace/project identity data
    • persisted registry now, SQLite-friendly later
  2. Volatile git runtime state
    • in-memory, daemon-owned, continuously refreshed, joined into workspace descriptors on fetch and update

Non-Goals

  • Do not move workspace/project identity into an in-memory snapshot manager.
  • Do not change websocket schemas in a breaking way.
  • Do not fold file-level checkout diff subscriptions into the workspace summary cache.

Stable Data That Should Stay In Registry / Reconciliation

These fields affect identity, grouping, or archival and should remain part of stable workspace/project storage:

  • workspaceId
  • projectId
  • cwd
  • kind
  • archivedAt
  • projectDisplayName
  • projectRootPath
  • projectKind
  • remote-derived project regrouping (remoteUrl -> projectKey)

Relevant code today:

  • packages/server/src/server/session.ts
  • packages/server/src/server/workspace-registry.ts
  • packages/server/src/server/workspace-registry-model.ts

Volatile Data That Should Move Into A Workspace Git State Manager

This is the live per-workspace git/runtime summary state that changes frequently and should be cached separately from stable workspace identity:

export type WorkspaceGitRuntimeState = {
  workspaceId: string;
  status: "unknown" | "loading" | "ready" | "error" | "not_git";
  fetchedAt?: string;
  error?: string | null;

  currentBranch: string | null;
  displayNameOverride: string | null;
  diffStat: { additions: number; deletions: number } | null;

  isDirty: boolean | null;
  baseRef: string | null;
  aheadBehind: { ahead: number; behind: number } | null;
  aheadOfOrigin: number | null;
  behindOfOrigin: number | null;
  hasRemote: boolean;
  remoteUrl: string | null;

  repoRoot: string | null;
  mainRepoRoot: string | null;
  isPaseoOwnedWorktree: boolean;

  pr: {
    status: {
      url: string;
      title: string;
      state: string;
      baseRefName: string;
      headRefName: string;
      isMerged: boolean;
    } | null;
    githubFeaturesEnabled: boolean;
    fetchedAt?: string;
    error?: string | null;
  } | null;
};

Notes:

  • diffStat: null means unknown / unavailable.
  • { additions: 0, deletions: 0 } means known clean state.
  • PR state remains summary-only here. Full PR actions stay elsewhere.

Proposed Manager Boundary

Add a daemon-owned WorkspaceGitStateManager under packages/server/src/server/.

Responsibilities

  • own the in-memory cache of WorkspaceGitRuntimeState keyed by workspaceId
  • warm a missing entry on first read
  • refresh entries in the background
  • keep last-known-good values while refresh is in flight
  • expose change events for subscribers
  • own git-watch debounce / fingerprint dedupe / background fetch hooks
  • provide joined runtime state to workspace descriptor shaping

Explicit Non-Responsibilities

  • do not own persisted workspace/project identity
  • do not archive or regroup workspaces/projects
  • do not own file-level checkout diff subscriptions
  • do not own git write actions (commit / merge / push / create PR)

Proposed Interface

type WorkspaceGitStateChangeListener = (
  workspaceId: string,
  state: WorkspaceGitRuntimeState,
) => void;

export class WorkspaceGitStateManager {
  getState(workspaceId: string): WorkspaceGitRuntimeState | null;
  getStates(workspaceIds: Iterable<string>): Map<string, WorkspaceGitRuntimeState>;

  ensureWorkspace(input: {
    workspaceId: string;
    cwd: string;
    isGit: boolean;
  }): WorkspaceGitRuntimeState | null;

  refreshWorkspace(input: {
    workspaceId: string;
    cwd: string;
    reason:
      | "bootstrap"
      | "watch"
      | "background_fetch"
      | "explicit_refresh"
      | "reconcile";
  }): Promise<void>;

  removeWorkspace(workspaceId: string): void;

  on(event: "change", listener: WorkspaceGitStateChangeListener): this;
  off(event: "change", listener: WorkspaceGitStateChangeListener): this;
  dispose(): void;
}

Optional follow-up if useful:

getOrCreateLoadingState(input: { workspaceId: string; isGit: boolean }): WorkspaceGitRuntimeState

Descriptor Join Seam

Workspace descriptors should become a pure join of:

  • stable workspace record
  • stable project record
  • agent-derived status/activity
  • git runtime state (if present)

Pseudo-shape:

function buildWorkspaceDescriptor(input: {
  workspace: PersistedWorkspaceRecord;
  project: PersistedProjectRecord | null;
  activity: WorkspaceActivitySummary;
  git: WorkspaceGitRuntimeState | null;
}): WorkspaceDescriptorPayload

Rules:

  • baseline workspace/project fields always come from stable data
  • name uses git.displayNameOverride ?? workspace.displayName
  • diffStat uses git.diffStat
  • later, if we add more workspace-level git summary UI, it should come from git

Event / Refresh Sources That Should Feed The Manager

1. First fetch / hydration

fetch_workspaces should:

  • read stable workspace/project records
  • ask manager for cached git state for those workspaces
  • return joined descriptors immediately
  • call ensureWorkspace(...) / schedule background refresh for git workspaces missing runtime state

Result: second client hydration sees the cached git state instead of baseline nulls.

2. Workspace reconciliation

When reconcileWorkspaceRecord(...) discovers or updates a workspace:

  • keep stable identity logic where it is
  • call ensureWorkspace(...) or removeWorkspace(...) on the manager as needed
  • if the workspace is git, schedule runtime refresh

3. Git watch events

Move or delegate the current workspace git watch machinery into the manager:

  • watch target lifecycle
  • debounce
  • fingerprint dedupe on branch + diff summary
  • refresh scheduling

4. Background git fetch

The current BackgroundGitFetchManager can stay separate, but its subscriptions should be owned by the git state manager rather than Session.

Flow:

  • manager subscribes a git workspace/repo root
  • fetch completes
  • manager refreshes affected workspace git runtime state
  • manager emits change

5. PR status polling / refresh

Workspace-level PR summary should also be cached in the manager instead of fetched ad hoc only from the client query path.

Potential first step:

  • keep current RPC for checkout PR status
  • let manager optionally refresh/store PR summary on a slower cadence
  • join cached PR summary later when we have a workspace-level consumer that needs it

What Should Stay Separate

Checkout diff manager

Keep file-level diff subscriptions separate:

  • keyed by (cwd, compare)
  • heavier payloads
  • different lifecycle from summary git state

Git write operations

Keep these outside the manager:

  • commit
  • merge
  • merge-from-base
  • push
  • create PR
  • branch validation / branch suggestions

Those operations can notify / invalidate the manager after completion.

Session Changes

Session should get simpler.

Session should keep

  • request orchestration
  • subscription state for clients
  • reconciliation orchestration
  • emitting websocket messages

Session should stop owning directly

  • git watch targets
  • git fetch subscriptions
  • descriptor fingerprint dedupe for git summary changes
  • ad hoc git-enriched descriptor computation during emission

Suggested Implementation Steps

  1. Introduce WorkspaceGitRuntimeState type and WorkspaceGitStateManager.
  2. Move workspace git watch target management out of Session into the manager.
  3. Move background fetch subscription ownership into the manager.
  4. Add a joined descriptor builder that accepts stable + runtime state.
  5. Change fetch_workspaces to read manager state instead of computing git summary inline or throwing it away.
  6. Change workspace update emission to use the joined descriptor builder.
  7. Invalidate / refresh manager state on workspace reconcile, open-project, worktree create/archive, and relevant git write operations.
  8. Add PR summary caching only if we want workspace-level consumers now; otherwise keep it as a second phase.

Verification Plan

Server tests

  • first fetch_workspaces returns baseline values when runtime state is missing
  • later manager refresh emits workspace_update with enriched git summary
  • second fetch_workspaces returns cached enriched summary, not baseline nulls
  • git watch refresh updates branch / diffStat without duplicate emissions
  • background fetch invalidates and refreshes runtime state
  • reconcile/archive/remove cleans up runtime state and watchers

App verification

  • workspace list does not flash diff stats on reconnect / resume / refresh
  • second client hydration receives enriched values immediately once manager is warm

Safety checks

  • no websocket schema breakage
  • typecheck passes
  • targeted workspace + git-watch tests pass

Notes

This should be built as a reshape, not as more special-case logic in Session.

The bug-fix PR/hotfix should stay narrow. This issue tracks the follow-up architecture work.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions