mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 00:03:09 +00:00
Scope task retry state by source context
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -228,3 +228,4 @@ Done means: reviewers and future agents can understand the vision, implementatio
|
||||
- [x] 2026-06-12: Re-verified project-first drag/reorder behavior across workspace manual order, drag units, workspace-board pointer drops, host-section order/rows, repo reorder host splitting, and project-header drag helpers; 9 files / 62 tests passed plus targeted oxlint.
|
||||
- [x] 2026-06-12: Routed Linear and Jira Tasks reads, create flows, list/detail mutations, comments, metadata, and New Workspace handoff through explicit account-backed `TaskSourceContext` while preserving legacy focused-runtime cache keys for old callers; verified with Linear/Jira source-routing regressions, focused Linear/Jira slice tests (2 files / 48 tests), targeted oxlint, `git diff --check`, and full `pnpm run typecheck`.
|
||||
- [x] 2026-06-12: Preserved Linear task detail source context in `openTaskPage` data, back/forward history entries, history de-dupe keys, and Linear warm prefetch so direct detail opens replay the same account/host source instead of recomputing from focused settings; verified with UI/history tests (2 files / 131 tests) and full `pnpm run typecheck`.
|
||||
- [x] 2026-06-12: Scoped GitHub Tasks partial-failure retry ownership to the selected source cache scope instead of `repoPath`, so same-project/same-path multi-host failures cannot make the wrong source banner look in-flight; verified with `task-page-cache-selectors.test.ts`, targeted oxlint, and full `pnpm run typecheck`.
|
||||
|
||||
@@ -3256,33 +3256,33 @@ export default function TaskPage(): React.JSX.Element {
|
||||
// Why: on a partial-failure retry the cache still holds successful-side
|
||||
// data, so `tasksLoading` (which is gated on `anyUncached`) never flips
|
||||
// true and the Retry button would otherwise give no feedback. Track
|
||||
// retry-in-flight per repo (keyed by `repoPath`) so that clicking Retry
|
||||
// on one banner only flips that banner's button into its "Retrying…"
|
||||
// retry-in-flight per selected source so that clicking Retry
|
||||
// on one banner only flips that source's button into its "Retrying…"
|
||||
// state — other still-failing banners stay in their "Retry" state rather
|
||||
// than misleadingly flipping in lockstep. The fetch effect clears the set
|
||||
// when the nonce-driven refresh settles.
|
||||
const [retryingRepoPaths, setRetryingRepoPaths] = useState<ReadonlySet<string>>(() => new Set())
|
||||
const [retryingSourceKeys, setRetryingSourceKeys] = useState<ReadonlySet<string>>(() => new Set())
|
||||
|
||||
const handleRetryIssuesFetch = useCallback(
|
||||
(repoPath: string) => {
|
||||
const repo = selectedRepos.find((r) => r.path === repoPath)
|
||||
if (!repo) {
|
||||
(sourceKey: string) => {
|
||||
const source = perRepoSourceState.find((s) => s.sourceKey === sourceKey)
|
||||
if (!source) {
|
||||
return
|
||||
}
|
||||
// Why: bumping the shared refresh nonce reuses the Tasks list's
|
||||
// single fetch path — nonce changes are treated as force=true so
|
||||
// retry doesn't silently dedupe onto a still-failing in-flight request.
|
||||
// The nonce bump refreshes ALL selected repos, but the Retrying…
|
||||
// state is scoped to the clicked repo so other banners stay in their
|
||||
// state is scoped to the clicked source so other banners stay in their
|
||||
// "Retry" state rather than misleadingly flipping to "Retrying…".
|
||||
setRetryingRepoPaths((prev) => {
|
||||
setRetryingSourceKeys((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.add(repoPath)
|
||||
next.add(source.sourceKey)
|
||||
return next
|
||||
})
|
||||
setTaskRefreshNonce((n) => n + 1)
|
||||
},
|
||||
[selectedRepos]
|
||||
[perRepoSourceState]
|
||||
)
|
||||
const handleRefreshGithubTasks = useCallback((): void => {
|
||||
setTasksRefreshing(true)
|
||||
@@ -5267,19 +5267,19 @@ export default function TaskPage(): React.JSX.Element {
|
||||
if (!taskResumeApplied) {
|
||||
return
|
||||
}
|
||||
// Why: both early-return branches must clear `retryingRepoPaths` — if the
|
||||
// Why: both early-return branches must clear `retryingSourceKeys` — if the
|
||||
// user clicks Retry and then switches `taskSource` away from 'github' (or
|
||||
// somehow ends up with zero repos selected) before the fetch dispatches,
|
||||
// neither the `.then` nor the `.catch` below will fire, and the Retry
|
||||
// button would stay stuck in its disabled/Retrying state indefinitely.
|
||||
if (taskSource !== 'github' || githubMode !== 'items') {
|
||||
setRetryingRepoPaths(new Set())
|
||||
setRetryingSourceKeys(new Set())
|
||||
setTasksRefreshing(false)
|
||||
setTasksFiltering(false)
|
||||
return
|
||||
}
|
||||
if (selectedRepos.length === 0) {
|
||||
setRetryingRepoPaths(new Set())
|
||||
setRetryingSourceKeys(new Set())
|
||||
setTasksRefreshing(false)
|
||||
setTasksFiltering(false)
|
||||
return
|
||||
@@ -5360,29 +5360,29 @@ export default function TaskPage(): React.JSX.Element {
|
||||
// so the toolbar still shows a refresh-in-progress affordance.
|
||||
setTasksRefreshing(forcedFetch)
|
||||
|
||||
// Why: snapshot the retrying paths at effect-dispatch so overlapping
|
||||
// Why: snapshot the retrying source keys at effect-dispatch so overlapping
|
||||
// retries don't clear each other's pending state. An earlier cancelled
|
||||
// effect settling after a newer retry starts would otherwise wipe the
|
||||
// newer retry's repo from the set. Clearing only the paths captured
|
||||
// newer retry's source from the set. Clearing only the keys captured
|
||||
// when this effect dispatched preserves later additions.
|
||||
const dispatchedRetryPaths = retryingRepoPaths
|
||||
const dispatchedRetrySourceKeys = retryingSourceKeys
|
||||
void fetchWorkItemsAcrossRepos(repoArgs, PER_REPO_FETCH_LIMIT, CROSS_REPO_DISPLAY_LIMIT, q, {
|
||||
...deriveTaskPageGitHubWorkItemsFetchOptions(forcedFetch, shouldProbeOnLanding)
|
||||
})
|
||||
.then(({ items, failedCount: failed }) => {
|
||||
// Why: clear only the repos this effect was responsible for
|
||||
// Why: clear only the sources this effect was responsible for
|
||||
// retrying (the snapshot captured at dispatch time). Overlapping
|
||||
// retries — a second click while a prior fetch is still in flight
|
||||
// — must not clear the newer repo from the set, so we can't just
|
||||
// — must not clear the newer source from the set, so we can't just
|
||||
// reset the whole set here. The early-return branches above reset
|
||||
// the whole set because those branches won't dispatch a fetch.
|
||||
setRetryingRepoPaths((prev) => {
|
||||
if (dispatchedRetryPaths.size === 0) {
|
||||
setRetryingSourceKeys((prev) => {
|
||||
if (dispatchedRetrySourceKeys.size === 0) {
|
||||
return prev
|
||||
}
|
||||
const next = new Set(prev)
|
||||
for (const p of dispatchedRetryPaths) {
|
||||
next.delete(p)
|
||||
for (const key of dispatchedRetrySourceKeys) {
|
||||
next.delete(key)
|
||||
}
|
||||
return next
|
||||
})
|
||||
@@ -5408,19 +5408,19 @@ export default function TaskPage(): React.JSX.Element {
|
||||
.catch((err) => {
|
||||
// Why: fetchWorkItemsAcrossRepos swallows per-repo failures, so a
|
||||
// reject here means an IPC-level or programmer error — surface it.
|
||||
// Clear only the repos this effect was responsible for retrying
|
||||
// Clear only the sources this effect was responsible for retrying
|
||||
// (the snapshot captured at dispatch time). Overlapping retries —
|
||||
// a second click while a prior fetch is still in flight — must
|
||||
// not clear the newer repo from the set, so we can't just reset
|
||||
// not clear the newer source from the set, so we can't just reset
|
||||
// the whole set here. The early-return branches above reset the
|
||||
// whole set because those branches won't dispatch a fetch.
|
||||
setRetryingRepoPaths((prev) => {
|
||||
if (dispatchedRetryPaths.size === 0) {
|
||||
setRetryingSourceKeys((prev) => {
|
||||
if (dispatchedRetrySourceKeys.size === 0) {
|
||||
return prev
|
||||
}
|
||||
const next = new Set(prev)
|
||||
for (const p of dispatchedRetryPaths) {
|
||||
next.delete(p)
|
||||
for (const key of dispatchedRetrySourceKeys) {
|
||||
next.delete(key)
|
||||
}
|
||||
return next
|
||||
})
|
||||
@@ -8139,10 +8139,10 @@ export default function TaskPage(): React.JSX.Element {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRetryIssuesFetch(s.repoPath)}
|
||||
disabled={tasksLoading || retryingRepoPaths.has(s.repoPath)}
|
||||
onClick={() => handleRetryIssuesFetch(s.sourceKey)}
|
||||
disabled={tasksLoading || retryingSourceKeys.has(s.sourceKey)}
|
||||
>
|
||||
{retryingRepoPaths.has(s.repoPath) ? (
|
||||
{retryingSourceKeys.has(s.sourceKey) ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<LoaderCircle className="h-3 w-3 animate-spin" />
|
||||
{translate('auto.components.TaskPage.5b6b2af943', 'Retrying…')}
|
||||
|
||||
@@ -66,12 +66,35 @@ describe('task page cache selectors', () => {
|
||||
{
|
||||
repoId: 'repo-1',
|
||||
repoPath: '/repo/one',
|
||||
sourceKey: 'repo-1::local',
|
||||
sources: null,
|
||||
error: null
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('scopes repo source rows by source cache scope for retry ownership', () => {
|
||||
const localRepo = {
|
||||
id: 'repo-1',
|
||||
path: '/same/path',
|
||||
sourceCacheScope: 'source:local:github:stablyai/orca'
|
||||
}
|
||||
const sshRepo = {
|
||||
id: 'repo-1',
|
||||
path: '/same/path',
|
||||
sourceCacheScope: 'source:ssh:devbox:github:stablyai/orca'
|
||||
}
|
||||
|
||||
expect(buildTaskPageRepoSourceState([localRepo, sshRepo], [])).toMatchObject([
|
||||
{
|
||||
sourceKey: 'repo-1::source:local:github:stablyai/orca'
|
||||
},
|
||||
{
|
||||
sourceKey: 'repo-1::source:ssh:devbox:github:stablyai/orca'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('selects work-item cache entries by repo id, not legacy path keys', () => {
|
||||
const repo = { id: 'repo-1', path: '/same/path' }
|
||||
const repoEntry = entry<GitHubWorkItem[]>([workItem('issue-1', 'repo-1')])
|
||||
|
||||
@@ -23,6 +23,7 @@ export type TaskPageDialogWorkItemKey = {
|
||||
export type TaskPageRepoSourceState = {
|
||||
repoId: string
|
||||
repoPath: string
|
||||
sourceKey: string
|
||||
sources: WorkItemsCacheSources | null
|
||||
error: WorkItemsCacheError | null
|
||||
}
|
||||
@@ -70,6 +71,7 @@ export function buildTaskPageRepoSourceState(
|
||||
return {
|
||||
repoId: repo.id,
|
||||
repoPath: repo.path,
|
||||
sourceKey: `${repo.id}::${repo.sourceCacheScope ?? repo.executionHostId ?? 'local'}`,
|
||||
sources: entry?.sources ?? null,
|
||||
error: entry?.error ?? null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user