Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 180 additions & 0 deletions docs/plans/2026-09-27-worktree-timeout-consumers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
# A timed-out worktree list is never read as "no family" (#1430)

Size: short plan. It is a follow-up of #1429, which is merged; each
consumer's fix is small and bounded.

## Outcome

When `git worktree list` times out, none of the four remaining consumers
treats the empty answer as "this checkout has no siblings". Each either
says it or stays unknown, and none caches or records the wrong family.

## Evidence (verified 2026-09-27 on origin/main after #1429, do not re-derive)

- `src/main/ipc/git.ts`: `listWorktreesForCwd` returns `[]` on a timeout.
`listWorktreesForCwdDetailed` returns `{ worktrees, timedOut }` but is not
exported. Timed-out results are never cached (#1429).
- **Conversations:** `family.ts` `resolveFamily` falls back to the cwd
alone when the list is empty, so from a linked checkout the main
checkout's conversations drop out. `service.ts` `discover` caches that
family's discovery for `DISCOVERY_FRESH_MS` (3 s). The response
(`ConversationListResponse.family`) says nothing.
- **Worktree activity:** `ipc/worktreeActivity.ts` throws "not a git
worktree" on `[]` and answers `{ ok: false }`, which `loadWorktreeDump`
shows as "Agent activity: unavailable", the same as a non-repository.
- **Agent activity repo root:** `index.ts` `resolveRepoRoot` is
`listWorktreesForCwd(cwd).then(w => w[0]?.path ?? cwd)`. On a timeout it
records the cwd as the repo root in `AgentActivityRecorder` context, so
a worktree's activity is filed under the worktree, not its repository.
- **Renderer history:** `initialHistory.ts:301-303` and `history.ts:111-112`
map any non-ok `gitWorktrees` answer (including `timedOut`) to `[]`, and
feed it into `ingestWorktreeRawEvent`, which attributes against an empty
family.

## Change

- `git.ts`: export `listWorktreesForCwdDetailed`.
- **Conversations:**
- the `ListWorktrees` dependency returns `{ worktrees, timedOut }`;
- `RepositoryFamily.gitTimedOut: boolean`;
- a discovery whose family timed out is returned but NOT kept as
`this.discovery`, so the next request asks git again;
- `ConversationListResponse.family.gitTimedOut?: true`, and the picker
shows a muted line: "Git didn't answer in time. Conversations from this
repository's other worktrees may be missing."
- **Worktree activity:** `{ ok: false, timedOut: true }` on a timeout, and
the preload type matches. `loadWorktreeDump` gets `activityTimedOut`, and
the dump line reads "unavailable (Git timed out)".
- **Repo root:** `resolveRepoRootAfterGit(listDetailed, cwd)` lives in
`agentActivity/`.
- It retries once when the list timed out: the git queue is the usual
cause, and it drains.
- If it times out again it throws `RepoRootUnknown`. The recorder then
records the interval's repository as UNKNOWN (`''`, the store's
existing "no repository" value, which the summary labels Unknown) and
warns. The worktree row keeps its `cwd`, and the next interval asks git
again.
- Steering q126: the first version fell back to the cwd. The store
persisted it, and summarize grouped by it, so a worktree folder became
a repository of its own that no later interval could fold back. It
never healed.
- Ruling: one retry, never a loop. The recorder awaits this on every
interval open.
- **Renderer history:** a `gitWorktrees` answer with `timedOut` skips
worktree attribution for that chunk, and `workActivity`/`workContext`
stay as they were. "Unknown" stays unknown; the live reconciler fills it
in later. A non-repository (`ok: false` without `timedOut`) keeps
today's `[]`.

## Tests (fail-first, with #1429's execFile timeout fake where main reads git)

- `family`/`service`: a timed-out list gives `gitTimedOut` on the family
and response, and a second request re-asks git (it isn't cached).
Before: a cwd-only family, cached.
- `worktreeActivity` IPC: timeout → `{ ok: false, timedOut: true }`.
Before: `{ ok: false }`.
- `resolveRepoRootAfterGit`:
- timeout then success → the main checkout;
- two timeouts → throws;
- a success first → no retry.
- `AgentActivityRecorder` (steering q126), the real interval path and a real
store: two timeouts, then success. The first interval is under Unknown,
the second under the repository, and there is never a bucket keyed by the
worktree folder. Before: a `/dev/agent-code/.worktrees/fix` repository.
- `loadWorktreeDump` / `formatWorktreeDump`: the activity line says the
git timeout.
- `initialHistory`: a `timedOut` worktrees answer leaves `workActivity`
untouched. Before: it was ingested against `[]`.

## Verification

`npx tsc -b` and the scoped vitest runs. The app is not launched.

## Out of scope

`listWorktreesForCwd`'s other callers (MCP read paths) already go through
#1429's surfaces.

## Review round 1 (a, b: FIX-BEFORE-MERGE), each fix fail-first

- **a (major): pages from two families.** Page 1 was built while git timed
out (the cwd alone), and page 2 after git recovered (the whole
repository). Appending lost the rows the recovered order puts before the
cursor, and page 2's family cleared the warning.
- Fix: `useConversationList` compares the page's family (root, roots,
`gitTimedOut`) with what it appends to. On a change it discards the page
and reloads page 1.
- Pinned: a picker test drives ArrowDown paging across recovery.
- **a + b (major): skipped history lost its worktree evidence.** Skipping
attribution on a timeout meant the reconciler, which replays only what it
observed, never saw the chunk. A quiet session stayed on the launch folder
after git recovered.
- Fix: `WorkspaceRefs.worktreeReconcilerRef` publishes the live
reconciler. Both history loaders hand a timed-out chunk to it
(`handHistoryToReconciler`: `observe` plus `refresh`), and its bounded
window replays the chunk when a later refresh gets the catalog. Failed
probes are not cached, so the next refresh retries.
- Pinned: the real reconciler with the recorded `codex-0151` window
reaches `.../worktree-2` after git recovers, and a loader-level test
shows the timed-out chunk is handed over. Removing that call turns it
red.
- **b (minor): the note showed in Everywhere,** where the family removes no
rows. It now shows only in Repository scope. Pinned.
- **a (surviving mutation): the repository-unknown warning.** It is now
asserted.
- **c (MERGE-READY), minors:**
- The older-history loader's hand-off and its null guard are pinned by a
`loadOlderHistory` test with git timing out. Both of c's mutations are
now red.
- The dead `listWorktreesForCwd` export is removed.
- The body's counts are corrected.
- Residual, accepted: the production publish of `worktreeReconcilerRef`
in `useIpcSubscriptions` is unasserted, because mounting that hook is
heavy. Every loader and reconciler test injects the ref.

## Verification pass (a, b: FIX-BEFORE-MERGE), each fix fail-first

Both findings are gaps in the round-1 hand-off.

- **a (major): a fresh cached catalog never repainted.** A live event had
already cached the catalog, and only the history's own `gitWorktrees`
call timed out. `refresh()` answered `cached` and never called
`onCatalogReady`, so the handed-over chunk sat in the window.
- Fix: `LiveWorktreeReconciler.replayCachedCatalog(cwd)` replays the
retained evidence against a real cached catalog. It does nothing for
the empty placeholder an in-flight probe writes.
`handHistoryToReconciler` calls it when `refresh` answers `cached`.
- Pinned: the recorded `codex-0151` window, with the catalog loaded
first, reaches `worktree-2`. Before the fix it stayed on the main
checkout.
- **b (major): an older page could replace a newer context.** The
reconciler appends what it observes as the newest evidence.
- Fix: the older-history loader hands a page over only while
`workContext` is unknown. This is the same recency rule as its
answered-git backfill.
- Pinned: a pane with a known context hands nothing over and keeps it.
- Ruling: initial history needs no such guard. It is the transcript's
tail, the newest evidence there is.

## Manager verification (B6 at f5fc3585: FIX), fixed fail-first

- **A scroll-up during a git timeout outranked the newest chunk.** The
sequence: the initial history (worktree-1) is handed over during a
timeout, then the user scrolls up while git still times out, and the
older page (worktree-2) is handed over too. It was appended as the
newest evidence, and once git recovered the pane landed on worktree-2.
- Fix: `observe(..., position)`. An older page enters at the OLD end of
the window (`retainOlder`), under the same 2 × limit bound.
- Overflow is the oldest evidence there is, so it is dropped rather than
folded when a catalog is cached. The folded baseline holds newer
records, and folding on top of them would make the page newest again.
- `handHistoryToReconciler(..., 'older')` is used by the older-history
loader.
- Pinned: the recorded `codex-0151` window as the older page, with the
same records moved to worktree-1 and 1 h later as the newest chunk,
and the recorded three-worktree catalog. It now lands on worktree-1.
- Mutations: appending instead of prepending, and the loader passing
newest, are each 1 red.
- The `resolveRepoRoot.ts` header now says Unknown, not the cwd.
- Filed separately by B6, out of scope here: a git failure that is not a
timeout is still read as "not a repository".
57 changes: 57 additions & 0 deletions src/main/agentActivity/AgentActivityRecorder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,3 +474,60 @@ describe('AgentActivityRecorder', () => {
expect(summary.totals.agentMs).toBe(10 * MINUTE)
})
})

// #1430 / steering q126: git timing out twice made resolveRepoRoot throw, the
// recorder's catch answered the CWD, and the store persisted the worktree folder
// as the interval's repository. summarize groups by that stored key, so the
// worktree became a repository of its own and a later successful interval could
// never fold the earlier one back. An unresolved repository is recorded as
// UNKNOWN ('' — the store's existing "no repository" value, labelled Unknown),
// never as a false one; the cwd still names the worktree row.
describe('AgentActivityRecorder when git times out resolving the repository', () => {
it('files the interval under Unknown, never under the worktree folder, and later intervals under the repository', async () => {
const { resolveRepoRootAfterGit } = await import('@main/agentActivity/resolveRepoRoot.js')
let gitTimingOut = true
const manager = new EventEmitter()
const recorder = new AgentActivityRecorder({
manager: manager as unknown as Pick<SessionManager, 'on'>,
store: new AgentActivityStore(dir),
// The REAL retry-once policy over a git lister that times out, then answers.
resolveRepoRoot: cwd => resolveRepoRootAfterGit(async () => gitTimingOut
? { worktrees: [], timedOut: true }
: { worktrees: [{ path: '/dev/agent-code' }, { path: '/dev/agent-code/.worktrees/fix' }], timedOut: false }, cwd),
identityOf: () => undefined,
})
recorders.push(recorder)
await recorder.start()
recorder.updateWorkspace(windows(), { 'name-1': 'Ada' })
manager.emit('started', { sessionId: 'child', kind: 'codex' })
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
manager.emit('semantic-event', { sessionId: 'child', event: { type: 'stream_phase', phase: 'responding' } })
vi.setSystemTime(T0 + HOUR)
manager.emit('semantic-event', { sessionId: 'child', event: { type: 'stream_phase', phase: 'idle' } })
await vi.waitFor(async () => expect((await recorder.summary('24h')).totals.agentMs).toBe(HOUR))

gitTimingOut = false
vi.setSystemTime(T0 + 2 * HOUR)
manager.emit('semantic-event', { sessionId: 'child', event: { type: 'stream_phase', phase: 'responding' } })
vi.setSystemTime(T0 + 3 * HOUR)
manager.emit('semantic-event', { sessionId: 'child', event: { type: 'stream_phase', phase: 'idle' } })
vi.setSystemTime(T0 + 4 * HOUR)
await vi.waitFor(async () => expect((await recorder.summary('24h')).totals.agentMs).toBe(2 * HOUR))

// The unknown interval is said once in main's log, not silently filed
// (review a: the warning was not asserted).
expect(warn).toHaveBeenCalledWith(expect.stringContaining('repository unknown for this interval'), expect.anything())
const [project] = (await recorder.summary('24h')).projects
const repositories = project!.repositories.map(r => [r.repoRoot, r.label, r.agentMs, r.worktrees.map(w => w.cwd)])
expect(repositories).toEqual(expect.arrayContaining([
['', 'Unknown', HOUR, ['/dev/agent-code/.worktrees/fix']],
['/dev/agent-code', 'agent-code', HOUR, ['/dev/agent-code/.worktrees/fix']],
]))
// The false repository — the worktree folder as its own repository — never exists.
expect(project!.repositories.some(r => r.repoRoot === '/dev/agent-code/.worktrees/fix')).toBe(false)
} finally {
warn.mockRestore()
}
})
})
13 changes: 12 additions & 1 deletion src/main/agentActivity/AgentActivityRecorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,18 @@ export class AgentActivityRecorder {
provider: placement?.kind ?? entry.kind ?? 'unknown',
tabId: placement?.tabId ?? null,
tabTitle: placement?.tabTitle ?? null,
repoRoot: cwd ? await this.deps.resolveRepoRoot(cwd).catch(() => cwd) : '',
// #1430 / steering q126: a rejection means the repository is UNKNOWN (git
// timed out twice). It is recorded as '' — the store's existing "no
// repository" value, which the summary labels Unknown — and NEVER as the
// cwd. The cwd used to be persisted as this interval's repository: the
// store keeps it, summarize groups by it, so a worktree folder became a
// repository of its own that no later, correctly resolved interval could
// fold back. `cwd` below still names the worktree row, and the next
// interval asks git again.
repoRoot: cwd ? await this.deps.resolveRepoRoot(cwd).catch((error: unknown) => {
console.warn('[agent-activity] repository unknown for this interval (recorded as Unknown):', error instanceof Error ? error.message : error)
return ''
}) : '',
cwd,
}
}
Expand Down
35 changes: 35 additions & 0 deletions src/main/agentActivity/resolveRepoRoot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, it, vi } from 'vitest'

import { RepoRootUnknown, resolveRepoRootAfterGit } from './resolveRepoRoot'

// #1430: a timed-out `git worktree list` used to file an agent's activity under
// its worktree folder instead of its repository (resolveRepoRoot read `[]` as
// "not a repository" and answered the cwd).
const MAIN = '/repo'
const WORKTREE = '/repo/.worktrees/feature'
const answered = { worktrees: [{ path: MAIN }, { path: WORKTREE }], timedOut: false }
const timeout = { worktrees: [], timedOut: true }

describe('resolveRepoRootAfterGit', () => {
it('answers the main checkout when git answers', async () => {
const list = vi.fn(async () => answered)
expect(await resolveRepoRootAfterGit(list, WORKTREE)).toBe(MAIN)
expect(list).toHaveBeenCalledTimes(1)
})

it('retries a timeout once, and files under the repository when the retry answers', async () => {
const list = vi.fn().mockResolvedValueOnce(timeout).mockResolvedValueOnce(answered)
expect(await resolveRepoRootAfterGit(list, WORKTREE)).toBe(MAIN)
expect(list).toHaveBeenCalledTimes(2)
})

it('throws after two timeouts instead of answering the worktree folder', async () => {
const list = vi.fn(async () => timeout)
await expect(resolveRepoRootAfterGit(list, WORKTREE)).rejects.toBeInstanceOf(RepoRootUnknown)
expect(list).toHaveBeenCalledTimes(2)
})

it('keeps the folder for a real non-repository (git answered, no worktrees)', async () => {
expect(await resolveRepoRootAfterGit(async () => ({ worktrees: [], timedOut: false }), '/tmp/plain')).toBe('/tmp/plain')
})
})
36 changes: 36 additions & 0 deletions src/main/agentActivity/resolveRepoRoot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* The repository an agent's activity is filed under: the main checkout, i.e.
* the first entry of `git worktree list` (#1430).
*
* WHY a timeout is retried once and then THROWN rather than answered with the
* cwd: the recorder files each interval under the root this returns, so a
* timed-out (empty) list used to file a worktree's activity under the worktree
* itself — a silent, persisted mis-grouping. A timeout is almost always the
* git queue being busy (#1429's shared queue of five), which drains, so one
* retry usually answers. Two timeouts are "unknown", and unknown is thrown:
* the recorder records that one interval's repository as UNKNOWN (`''`, the
* store's "no repository" value) and warns, and the next interval asks again.
* Not the cwd (steering q126): the store persisted it, and a worktree folder
* became a repository of its own that no later interval could fold back.
* Never a loop — the recorder awaits this on every interval open.
*
* A non-repository (git answered, no worktrees) is not a timeout: the cwd is
* then the honest root, exactly as before.
*/
export class RepoRootUnknown extends Error {
constructor(cwd: string) {
super(`git worktree list timed out twice for ${cwd}`)
this.name = 'RepoRootUnknown'
}
}

export async function resolveRepoRootAfterGit(
listDetailed: (cwd: string) => Promise<{ worktrees: ReadonlyArray<{ path: string }>; timedOut: boolean }>,
cwd: string,
): Promise<string> {
for (let attempt = 0; attempt < 2; attempt += 1) {
const { worktrees, timedOut } = await listDetailed(cwd)
if (!timedOut) return worktrees[0]?.path ?? cwd
}
throw new RepoRootUnknown(cwd)
}
2 changes: 1 addition & 1 deletion src/main/conversations/catalog/listing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export function buildListing(input: BuildListingInput): ConversationListResponse
total,
hiddenChildren,
nextCursor: start + limit < visible.length && last ? encodeCursor(last) : null,
family: { repoRoot: family.root, roots: family.roots },
family: { repoRoot: family.root, roots: family.roots, ...(family.gitTimedOut ? { gitTimedOut: true as const } : {}) },
timing: { ms: Math.max(0, Date.now() - input.startedAt) },
}
}
22 changes: 20 additions & 2 deletions src/main/conversations/family.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,24 @@ export type RepositoryFamily = {
* project directory name from the literal cwd, so a lowercased root
* would name a directory that does not exist. */
rawRoots: string[]
/** `git worktree list` timed out (#1430), so the siblings are UNKNOWN, not
* absent: `roots` fell back to the cwd alone and may be missing the main
* checkout and other worktrees. The service does not cache such a family,
* and the picker says so. False when git answered (or is simply not a
* repository here). */
gitTimedOut: boolean
matches(candidate: string | null | undefined): boolean
}

/** A plain list means git answered. The detailed form (main's
* listWorktreesForCwdDetailed) can also say the list timed out (#1430);
* both are accepted so fixtures that hand in a known list stay as they are. */
export type ListedWorktrees =
| ReadonlyArray<{ path: string }>
| { worktrees: ReadonlyArray<{ path: string }>; timedOut: boolean }

export type FamilyDeps = {
listWorktrees(cwd: string): Promise<ReadonlyArray<{ path: string }>>
listWorktrees(cwd: string): Promise<ListedWorktrees>
}

/** `path.resolve` collapses `..` and trailing slashes; darwin and win32 file
Expand Down Expand Up @@ -76,8 +89,12 @@ export async function resolveFamily(
const cwdForms = forms(cwd)
const cwdRoots = cwdForms.map(normalizeCwd)
let worktreeForms: string[][] = []
let gitTimedOut = false
try {
worktreeForms = (await deps.listWorktrees(cwd)).map(w => forms(w.path))
const listed = await deps.listWorktrees(cwd)
const worktrees = 'timedOut' in listed ? listed.worktrees : listed
gitTimedOut = 'timedOut' in listed && listed.timedOut === true
worktreeForms = worktrees.map(w => forms(w.path))
} catch {
worktreeForms = []
}
Expand Down Expand Up @@ -122,6 +139,7 @@ export async function resolveFamily(
root,
roots,
rawRoots,
gitTimedOut,
matches(candidate) {
if (scope === 'everywhere') return true
if (!candidate) return false
Expand Down
Loading
Loading