mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 08:04:05 +00:00
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:
+32
-2
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
+51
-1
@@ -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 } : {}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user