mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 00:02:48 +00:00
Reuse creationKey across move retries when schedule changes
When retrying a failed automation move, dtstart is minted fresh each attempt, changing the payload. Previously, operationKey included the full payload, so retries would mint new creationKeys and risk duplicate automations on the destination if the initial create failed in transport. Now key only by the move (source + destination) to ensure stable creationKey across retries. Also fix workspace auto-selection to use authority-scoped worktrees instead of the merged cache, preventing unwanted restoration of source-host workspaces after switching authorities.
This commit is contained in:
@@ -528,6 +528,70 @@ describe('AutomationsPage edit destination', () => {
|
||||
expect(api.automations.delete).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reuses the move creation key on retry even when the schedule changed', async () => {
|
||||
const automation = makeAutomation({ id: 'a-1' })
|
||||
api.automations.list.mockResolvedValue([])
|
||||
scopedList([automation])
|
||||
runtimeHost([], [])
|
||||
addRuntimeProject()
|
||||
// The first create dies in transport after the server may have committed;
|
||||
// only an identical creationKey on the retry keeps that copy singular.
|
||||
let failCreate = true
|
||||
const previous = mocks.callRuntimeRpc.getMockImplementation()
|
||||
mocks.callRuntimeRpc.mockImplementation(
|
||||
async (target: unknown, method: string, params: unknown, options: unknown) => {
|
||||
if (method === 'automation.create') {
|
||||
if (failCreate) {
|
||||
failCreate = false
|
||||
throw new Error('socket hang up')
|
||||
}
|
||||
return { automation: makeAutomation({ id: 'a-moved', projectId: RUNTIME_REPO_ID }) }
|
||||
}
|
||||
return await previous?.(target, method, params, options)
|
||||
}
|
||||
)
|
||||
|
||||
await renderPage()
|
||||
await settleHostQueries()
|
||||
await act(async () => {
|
||||
void mocks.listPanel?.openEditDialog(listedRow(automation.id))
|
||||
})
|
||||
await act(async () => {
|
||||
mocks.editorDialog?.editDestination?.onSelect(RUNTIME_SELF_KEY)
|
||||
})
|
||||
await act(async () => {
|
||||
mocks.editorDialog?.onDraftChange((current) => ({
|
||||
...(current as Record<string, unknown>),
|
||||
projectId: RUNTIME_REPO_ID,
|
||||
workspaceMode: 'existing',
|
||||
workspaceId: RUNTIME_WORKSPACE_ID,
|
||||
// A changed schedule mints dtstart per attempt — the volatile input that
|
||||
// must not re-key the move.
|
||||
preset: 'custom',
|
||||
customSchedule: 'FREQ=DAILY;BYHOUR=10;BYMINUTE=0'
|
||||
}))
|
||||
})
|
||||
|
||||
const realNow = Date.now.bind(Date)
|
||||
let clockSkew = 0
|
||||
const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => realNow() + clockSkew)
|
||||
try {
|
||||
await save()
|
||||
clockSkew = 5000
|
||||
await save()
|
||||
} finally {
|
||||
nowSpy.mockRestore()
|
||||
}
|
||||
|
||||
const creationKeys = runtimeCreateCalls().map(
|
||||
(call) => (call[2] as { creationKey?: string }).creationKey
|
||||
)
|
||||
expect(creationKeys).toHaveLength(2)
|
||||
expect(creationKeys[0]).toBeTruthy()
|
||||
expect(creationKeys[1]).toBe(creationKeys[0])
|
||||
expect(api.automations.delete).toHaveBeenCalledWith(expect.objectContaining({ id: 'a-1' }))
|
||||
})
|
||||
|
||||
it('reports a kept original as two live copies, never as a completed move', async () => {
|
||||
const automation = makeAutomation({ id: 'a-1' })
|
||||
api.automations.list.mockResolvedValue([])
|
||||
|
||||
@@ -1345,12 +1345,13 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
if (!draft.projectId) {
|
||||
return
|
||||
}
|
||||
const available = worktreesByRepo[draft.projectId] ?? []
|
||||
const defaultWorktree = getDefaultWorktree(available)
|
||||
// Authority-scoped: the merged cache would restore a source-host workspace
|
||||
// after a host change clears it, which save validation then rejects.
|
||||
const defaultWorktree = getDefaultWorktree(dialogWorktrees)
|
||||
if (!draft.workspaceId && defaultWorktree) {
|
||||
setDraft((current) => ({ ...current, workspaceId: defaultWorktree.id }))
|
||||
}
|
||||
}, [draft.projectId, draft.workspaceId, worktreesByRepo])
|
||||
}, [dialogWorktrees, draft.projectId, draft.workspaceId])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -2026,7 +2027,8 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
})
|
||||
setDraft((current) => ({ ...current, name: '', prompt: '' }))
|
||||
await refresh()
|
||||
// A move creates a new row on another authority; select it by its new id.
|
||||
// A move retires the edited row key; the else branch selects the new copy
|
||||
// by id alone and lets the refreshed list resolve its row.
|
||||
if (editingAutomationId && editingRowKey && !moveTarget) {
|
||||
setSelectedAutomationRunPageId(null)
|
||||
setSelectedRowKey(editingRowKey)
|
||||
@@ -2094,7 +2096,10 @@ export default function AutomationsPage(): React.JSX.Element {
|
||||
originalRemoved: false
|
||||
}
|
||||
}
|
||||
const operationKey = `${source.id}:${target.entry.stableKey}:${JSON.stringify(input)}`
|
||||
// Keyed by the move, not its payload: dtstart is minted per attempt, so a
|
||||
// payload-keyed retry would mint a fresh creationKey and let an ambiguous
|
||||
// create failure schedule a second copy on the destination.
|
||||
const operationKey = `${source.id}:${target.entry.stableKey}`
|
||||
const creationKey = moveCreationKeysRef.current.get(operationKey) ?? crypto.randomUUID()
|
||||
moveCreationKeysRef.current.set(operationKey, creationKey)
|
||||
const created = toDispatchResult(
|
||||
|
||||
Reference in New Issue
Block a user