Route cancellations to starting runtime and prune stale worktree records

Ensure commit message and pull request generation cancellation requests
are always routed to the runtime environment that was selected when
the generation started, even if active settings change before
cancellation.

Additionally, prune commit message and pull request generation records
when worktrees are removed to prevent stale records from lingering in
the store.
This commit is contained in:
Jinjing
2026-06-11 23:22:12 -07:00
parent 793b356c11
commit ad741f3f0b
5 changed files with 168 additions and 12 deletions
@@ -116,13 +116,43 @@ describe('SourceControl commit message generation records', () => {
worktreeId: 'wt-a',
worktreePath: '/repo/a',
connectionId: 'conn-a',
requestId
requestId,
runtimeTargetSettings: { activeRuntimeEnvironmentId: 'runtime-a' }
})
)
expect(store.getState().commitMessageGenerationRecords[key]).toMatchObject({
context: { requestId, worktreeId: 'wt-a', worktreePath: '/repo/a' },
context: {
requestId,
worktreeId: 'wt-a',
worktreePath: '/repo/a',
runtimeTargetSettings: { activeRuntimeEnvironmentId: 'runtime-a' }
},
status: 'running'
})
})
it('prunes commit generation records for removed worktrees', () => {
const store = createCommitMessageGenerationTestStore()
store.getState().setCommitMessageGenerationRecord(
'wt-a',
createRunningCommitMessageGenerationRecord({
worktreeId: 'wt-a',
worktreePath: '/repo/a',
requestId: 1
})
)
store.getState().setCommitMessageGenerationRecord(
'wt-b',
createRunningCommitMessageGenerationRecord({
worktreeId: 'wt-b',
worktreePath: '/repo/b',
requestId: 2
})
)
store.getState().pruneCommitMessageGenerationRecords(new Set(['wt-a']))
expect(Object.keys(store.getState().commitMessageGenerationRecords)).toEqual(['wt-a'])
})
})
@@ -186,7 +186,8 @@ describe('SourceControl pull request generation records', () => {
connectionId: 'conn-a',
requestId: 1,
repoId: 'repo-1',
branch: 'feature-a'
branch: 'feature-a',
runtimeTargetSettings: { activeRuntimeEnvironmentId: 'runtime-a' }
},
seed,
fieldRevisions
@@ -208,12 +209,61 @@ describe('SourceControl pull request generation records', () => {
)
expect(store.getState().pullRequestGenerationRecords[key!]).toMatchObject({
context: { runtimeTargetSettings: { activeRuntimeEnvironmentId: 'runtime-a' } },
status: 'succeeded',
result: generated,
hydrated: false
})
})
it('prunes PR generation records for removed worktrees', () => {
const store = createPullRequestGenerationTestStore()
const keyA = getPullRequestGenerationRecordKey({
worktreeId: 'wt-a',
worktreePath: '/repo/a',
repoId: 'repo-1',
branch: 'feature-a'
})!
const keyB = getPullRequestGenerationRecordKey({
worktreeId: 'wt-b',
worktreePath: '/repo/b',
repoId: 'repo-1',
branch: 'feature-b'
})!
store.getState().setPullRequestGenerationRecord(
keyA,
createRunningPullRequestGenerationRecord(
{
worktreeId: 'wt-a',
worktreePath: '/repo/a',
requestId: 1,
repoId: 'repo-1',
branch: 'feature-a'
},
seed,
fieldRevisions
)
)
store.getState().setPullRequestGenerationRecord(
keyB,
createRunningPullRequestGenerationRecord(
{
worktreeId: 'wt-b',
worktreePath: '/repo/b',
requestId: 2,
repoId: 'repo-1',
branch: 'feature-b'
},
seed,
fieldRevisions
)
)
store.getState().prunePullRequestGenerationRecords(new Set(['wt-a']))
expect(Object.keys(store.getState().pullRequestGenerationRecords)).toEqual([keyA])
})
it('does not reuse PR generation request ids across composer remounts', () => {
const store = createPullRequestGenerationTestStore()
const key = getPullRequestGenerationRecordKey({
@@ -895,12 +895,16 @@ function SourceControlInner(): React.JSX.Element {
const updateCommitMessageGenerationRecord = useAppStore(
(s) => s.updateCommitMessageGenerationRecord
)
const pruneCommitMessageGenerationRecords = useAppStore(
(s) => s.pruneCommitMessageGenerationRecords
)
const prGenerationRecords = useAppStore((s) => s.pullRequestGenerationRecords)
const allocatePullRequestGenerationRequestId = useAppStore(
(s) => s.allocatePullRequestGenerationRequestId
)
const setPullRequestGenerationRecord = useAppStore((s) => s.setPullRequestGenerationRecord)
const updatePullRequestGenerationRecord = useAppStore((s) => s.updatePullRequestGenerationRecord)
const prunePullRequestGenerationRecords = useAppStore((s) => s.prunePullRequestGenerationRecords)
const filterInputRef = useRef<HTMLInputElement>(null)
const commitMessage = readCommitDraftForWorktree(commitDrafts, activeWorktreeId)
const commitError = commitErrors[activeWorktreeId ?? ''] ?? null
@@ -1382,11 +1386,18 @@ function SourceControlInner(): React.JSX.Element {
// state — especially commitInFlightRef, which would permanently disable
// Commit for that ID if left stuck at `true`.
useEffect(() => {
const liveWorktreeKeys = new Set<string>()
for (const worktree of worktreeMap.values()) {
liveWorktreeKeys.add(worktree.id)
if (worktree.path.trim()) {
liveWorktreeKeys.add(worktree.path)
}
}
const pruneRecord = <T,>(prev: Record<string, T>): Record<string, T> => {
let changed = false
const next: Record<string, T> = {}
for (const key of Object.keys(prev)) {
if (worktreeMap.has(key)) {
if (liveWorktreeKeys.has(key)) {
next[key] = prev[key]
} else {
changed = true
@@ -1400,18 +1411,20 @@ function SourceControlInner(): React.JSX.Element {
setCommitInFlightByWorktree((prev) => pruneRecord(prev))
setAbortOperationInFlightByWorktree((prev) => pruneRecord(prev))
setGitHistoryByWorktree((prev) => pruneRecord(prev))
pruneCommitMessageGenerationRecords(liveWorktreeKeys)
prunePullRequestGenerationRecords(liveWorktreeKeys)
// Refs don't need setState — mutate in place to drop stale keys.
for (const key of Object.keys(commitInFlightRef.current)) {
if (!worktreeMap.has(key)) {
if (!liveWorktreeKeys.has(key)) {
delete commitInFlightRef.current[key]
}
}
for (const key of Object.keys(gitHistoryRequestByWorktreeRef.current)) {
if (!worktreeMap.has(key)) {
if (!liveWorktreeKeys.has(key)) {
delete gitHistoryRequestByWorktreeRef.current[key]
}
}
}, [worktreeMap])
}, [pruneCommitMessageGenerationRecords, prunePullRequestGenerationRecords, worktreeMap])
useEffect(() => {
// Why: users often finish merge/rebase conflicts in a terminal. Once git
@@ -1595,7 +1608,10 @@ function SourceControlInner(): React.JSX.Element {
worktreeId,
worktreePath: generationWorktreePath,
connectionId,
requestId
requestId,
runtimeTargetSettings: {
activeRuntimeEnvironmentId: target.settings?.activeRuntimeEnvironmentId ?? null
}
}),
requestId,
error: 'Custom command is empty. Add one in Settings -> Git -> Source Control AI.'
@@ -1608,13 +1624,18 @@ function SourceControlInner(): React.JSX.Element {
}
const requestId = allocateCommitMessageGenerationRequestId()
// Why: Stop must route to the runtime selected at generation start, even
// if the user changes settings before cancellation.
setCommitMessageGenerationRecord(
generationKey,
createRunningCommitMessageGenerationRecord({
worktreeId,
worktreePath: generationWorktreePath,
connectionId,
requestId
requestId,
runtimeTargetSettings: {
activeRuntimeEnvironmentId: target.settings?.activeRuntimeEnvironmentId ?? null
}
})
)
try {
@@ -1743,7 +1764,7 @@ function SourceControlInner(): React.JSX.Element {
// resolves with `{canceled: true}` once the kill propagates, while the
// durable record lets the visible spinner clear immediately.
void cancelRuntimeGenerateCommitMessage({
settings: useAppStore.getState().settings,
settings: context.runtimeTargetSettings,
worktreeId: context.worktreeId,
worktreePath: context.worktreePath,
connectionId: context.connectionId
@@ -2147,13 +2168,18 @@ function SourceControlInner(): React.JSX.Element {
return
}
const requestId = allocatePullRequestGenerationRequestId()
// Why: Stop must route to the runtime selected at generation start, even
// if the user changes settings before cancellation.
const context: PullRequestGenerationContext = {
worktreeId: target.worktreeId,
worktreePath: target.worktreePath,
connectionId: target.connectionId,
requestId,
repoId: target.repoId,
branch: target.branch
branch: target.branch,
runtimeTargetSettings: {
activeRuntimeEnvironmentId: target.settings?.activeRuntimeEnvironmentId ?? null
}
}
const seed = { ...target.fields }
// Why: SourceControl can unmount on tab/worktree switches; the record is
@@ -2245,7 +2271,7 @@ function SourceControlInner(): React.JSX.Element {
return resolvePullRequestGenerationCancel(current)
})
void cancelRuntimeGeneratePullRequestFields({
settings: useAppStore.getState().settings,
settings: record.context.runtimeTargetSettings,
worktreeId: record.context.worktreeId,
worktreePath: record.context.worktreePath,
connectionId: record.context.connectionId
@@ -1,11 +1,18 @@
import type { StateCreator } from 'zustand'
import type { GlobalSettings } from '../../../../shared/types'
import type { AppState } from '../types'
export type CommitMessageGenerationRuntimeTargetSettings = Pick<
GlobalSettings,
'activeRuntimeEnvironmentId'
>
export type CommitMessageGenerationContext = {
worktreeId: string
worktreePath: string
connectionId?: string
requestId: number
runtimeTargetSettings?: CommitMessageGenerationRuntimeTargetSettings | null
}
export type CommitMessageGenerationStatus = 'idle' | 'running' | 'canceled' | 'failed' | 'succeeded'
@@ -29,6 +36,7 @@ export type CommitMessageGenerationSlice = {
key: string,
updater: (record: CommitMessageGenerationRecord | null) => CommitMessageGenerationRecord | null
) => void
pruneCommitMessageGenerationRecords: (liveWorktreeKeys: ReadonlySet<string>) => void
}
export function getCommitMessageGenerationRecordKey(
@@ -160,5 +168,22 @@ export const createCommitMessageGenerationSlice: StateCreator<
[key]: nextRecord
}
}
}),
pruneCommitMessageGenerationRecords: (liveWorktreeKeys) =>
set((state) => {
let changed = false
const nextRecords: CommitMessageGenerationRecords = {}
for (const [key, record] of Object.entries(state.commitMessageGenerationRecords)) {
const worktreeKey = getCommitMessageGenerationRecordKey(
record.context.worktreeId,
record.context.worktreePath
)
if (worktreeKey && liveWorktreeKeys.has(worktreeKey)) {
nextRecords[key] = record
} else {
changed = true
}
}
return changed ? { commitMessageGenerationRecords: nextRecords } : {}
})
})
@@ -1,4 +1,5 @@
import type { StateCreator } from 'zustand'
import type { GlobalSettings } from '../../../../shared/types'
import type { AppState } from '../types'
export type PullRequestFieldName = 'base' | 'title' | 'body' | 'draft'
@@ -11,6 +12,11 @@ export type PullRequestGenerationFields = {
draft: boolean
}
export type PullRequestGenerationRuntimeTargetSettings = Pick<
GlobalSettings,
'activeRuntimeEnvironmentId'
>
export type PullRequestGenerationContext = {
worktreeId: string | null
worktreePath: string
@@ -18,6 +24,7 @@ export type PullRequestGenerationContext = {
requestId: number
repoId: string
branch: string
runtimeTargetSettings?: PullRequestGenerationRuntimeTargetSettings | null
}
export type PullRequestGenerationStatus = 'idle' | 'running' | 'canceled' | 'failed' | 'succeeded'
@@ -43,6 +50,7 @@ export type PullRequestGenerationSlice = {
key: string,
updater: (record: PullRequestGenerationRecord | null) => PullRequestGenerationRecord | null
) => void
prunePullRequestGenerationRecords: (liveWorktreeKeys: ReadonlySet<string>) => void
}
export function getPullRequestGenerationWorktreeKey(
@@ -214,5 +222,22 @@ export const createPullRequestGenerationSlice: StateCreator<
[key]: nextRecord
}
}
}),
prunePullRequestGenerationRecords: (liveWorktreeKeys) =>
set((state) => {
let changed = false
const nextRecords: PullRequestGenerationRecords = {}
for (const [key, record] of Object.entries(state.pullRequestGenerationRecords)) {
const worktreeKey = getPullRequestGenerationWorktreeKey(
record.context.worktreeId,
record.context.worktreePath
)
if (worktreeKey && liveWorktreeKeys.has(worktreeKey)) {
nextRecords[key] = record
} else {
changed = true
}
}
return changed ? { pullRequestGenerationRecords: nextRecords } : {}
})
})