Skip to content

Linked projects intermittently split out of their repo group while a server's repository identity is unresolved or expired #32347

Closed

Description

@hunterbohm

What happened

I have four linked environments (a MacBook, a Mac mini, two Linux boxes), each running its own T3 server with the same projects: same git remotes, different home paths. I expect each project to appear once in the sidebar, grouped by repository. Instead some projects intermittently show twice: the grouped entry plus a lone copy from one machine.

On Nov 6, 2026, one Linux box restarted at 10:50 PM Central. For several minutes its project list, read over the WebSocket API, had repositoryIdentity: null for all 32 projects, then 1 or 2 nulls on back-to-back reads. Earlier the same day another Linux box returned null for almost all projects with no restart. The git remotes never changed.

Diagnosis

The null is a not-yet-resolved identity, not a failed git lookup.

Server. Shell reads (enrichProjectShells in ws.ts -> getAvailable -> peek) use Cache.getSuccess(repositoryIdentityCache, root) (apps/server/src/project/ProjectEnrichmentService.ts). getSuccess (effect 4.1.2) returns None for missing, expired or pending entries, and the success TTL is DEFAULT_SUCCESS_TTL = 1 minute, so:

  • on a fresh server every project is repositoryIdentity: null until its lane resolves it;
  • each entry then expires every minute, and a read before the re-request finishes gets null again (the 1-2 nulls per read).

The server marks these repositoryIdentityResolved: false, so it knows null means "unknown", not "no repo".

Client. buildProjectGroups -> deriveLogicalProjectKey (packages/client-runtime/src/state/projectGrouping.ts) treats a null identity like a folder with no repo: deriveRepositoryScopedKey returns null and the key falls back to derivePhysicalProjectKey (environmentId:path). Nothing in grouping reads the resolved flag, so an unresolved copy gets its own group. retainRepositoryIdentity (shellReducer.ts) keeps an identity the client already had, but a copy with none on record yet splits.

Minor. resolveRepositoryIdentityFromCacheKey (RepositoryIdentityResolver.ts) checks code !== 0 but not timedOut on git remote -v (the rev-parse calls do). A slow call becomes a cached null (1-minute negative TTL) reported as resolved-with-no-repo.

Possible fixes: serve the last good identity while a refresh runs; don't split unresolved copies in grouping; treat timedOut as a failure.

Steps to reproduce

  1. Link two or more environments with a project for the same git remote at different paths; confirm they group as one sidebar entry.
  2. Restart one environment's server.
  3. Within the first minute, its project shells (WebSocket API) have repositoryIdentity: null, and the sidebar shows a lone copy from that environment beside the group.
  4. Keep reading every few seconds: single projects go null again as their 1-minute entries expire.

Version

0.1.8-nightly.20261106.4288 on all four environments: the desktop app and local server on the MacBook, and the servers on the Mac mini and both Linux boxes. Code checked at tag v0.1.8-nightly.20261106.4288. main is 17 commits ahead and none touch ProjectEnrichmentService.ts, RepositoryIdentityResolver.ts, projectGrouping.ts or shellReducer.ts. Triage CLI: 0.1.6.

Environment

Client: T3 Code desktop app, macOS 26 (Darwin 25.6.0) arm64. Linked environments: this MacBook, a Mac mini, two Linux servers, each running its own T3 server with the same 32 projects.

Evidence

  • Linux box restarted 10:50 PM Central, Nov 6: null for 32/32 projects for several minutes, then 1-2 nulls per back-to-back read.
  • Another Linux box (no restart), earlier Nov 6: null for almost all projects.
  • MacBook server traces (~1.5 h): all 102 resolveFromCacheKey spans succeeded in at most 13 ms, so these nulls are not git failures.

Related issues

Fix applied or workaround

None applied. The duplicates go away on their own once identities re-resolve.

Filed by

Claude Code (Claude Opus 5.5, claude-opus-5-5), via t3 triage.

Activity

  1. PookMook commented on Nov 7, 2026

    @PookMook

    This comment was generated by AI (Claude Code, Claude Opus 5.5), driven by a human (Arthur), and should be reviewed for accuracy.

    Another symptom of the same cause, on a single environment with no linked machines, through the HTTP shell endpoint.

    What we see

    We run a small CLI wrapper next to T3 Code (0.1.6, Linux). Every 5 minutes it reads GET /api/orchestration/shell and treats each project with a non-null repositoryIdentity as a repository it can work in. To that wrapper, projects keep vanishing and coming back even though nothing about them changes:

    07:41:31 now advertising resident-experience
    07:46:31 now advertising resident-experience, monorepo, nerdsniped
    08:06:31 now advertising resident-experience
    08:11:31 now advertising resident-experience, monorepo, nerdsniped
    08:51:31 now advertising resident-experience, nerdsniped
    

    It's the same getAvailable → peek → Cache.getSuccess path described above, this time from enrichProjectShells in orchestration-v2/http.ts. The server traces back it up: ProjectEnrichmentService.resolveRepositoryIdentity spans start in the same second as the wrapper's reads (for example 13:06:31 and 13:11:31 UTC). So the wrapper's own read found the entry expired, got null, and set off the refresh. Which projects drop depends on whether anything else read them in the previous minute.

    What this adds to the issue

    • This isn't only about linked environments. Anything that polls the shell less often than once a minute, through the API, sees repositoryIdentity: null for repositories whose remotes haven't changed.
    • HTTP clients can't tell "unknown" from "no repository". The HTTP shell response carries only repositoryIdentity, not repositoryIdentityResolved, so a client reading it has no way to tell the two apart.
    • fix(server): linked projects keep their repository identity while it refreshes #32359 fixes the once-a-minute case, but not the first minute after a restart. Every project is still null until its first lookup finishes, so a client that connects right after the server starts would still see none of them as repositories. Exposing repositoryIdentityResolved (or leaving repositoryIdentity out until it's resolved) on the HTTP shell would let clients wait instead of guessing.

    Versions checked

    The code involved (ProjectEnrichmentService.ts, its 1-minute DEFAULT_SUCCESS_TTL, and the HTTP enrichProjectShells) behaves the same in v0.1.6, v0.1.7 and 0.1.8-nightly.20261107.4314. The only change in that range is to tracing (#29640).

    Workaround on our side

    We are changing the wrapper to stop reading repositoryIdentity at all and instead decide a project is a repository by checking for <workspaceRoot>/.git itself.

  2. matx-art commented on Nov 9, 2026

    @matx-art

    Another symptom of the same cause: the PR review guide refuses to start and shows the wrong error.

    PullRequestReviewGuideService.generate reads the project through ProjectService.getShell, so it gets the peeked identity. requireOwnRepository treats a null identity the same as a pull request from another repository. If nothing read the project in the previous minute, the first "Write review guide" fails with:

    Guides are written from this project's checkout, and this pull request is in another repository. Write it from a project that is a checkout of that repository.

    A retry a few seconds later works, because the failed call queued the refresh. Server traces on 0.1.7 (Linux, a GitHub pull request in the project's own repository):

    22:43:10.466  ws.rpc.pullRequests.generateReviewGuide           Failure in requireOwnRepository
    22:43:10.467  ProjectEnrichmentService.requestLane              enqueued, cache had no entry
    22:43:10.469  ProjectEnrichmentService.resolveRepositoryIdentity  Success, 0.17 ms
    22:43:21      ws.rpc.pullRequests.generateReviewGuide           Success, run started
    

    The MCP write_pull_request_guides handler (apps/server/src/mcp/toolkits/pullRequests/handlers.ts) does the same check on project.repositoryIdentity, so agents can get the same refusal.

    #32359 also fixes the once-a-minute case on these paths. It does not fix the first lookup after a server restart: the identity is still null, and the guide refuses with the "another repository" message. Two changes would close that gap:

    1. Where the identity decides an outcome, wait for it with ProjectEnrichmentService.readRepositoryIdentity instead of reading the peeked shell value.
    2. Give a null identity its own error, for example "Could not determine this project's repository. Try again."
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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