Skip to content

Commit 585f610

Browse files
committed
fix(ui,tauri): prevent navigation from killing in-progress tasks, add deep scan coordination
The root bug: useStartupRecovery used useRef(false) as a run-once guard, but navigating to /settings unmounts AppShell. Navigating back remounts a fresh instance, resetting the ref — so recoverStaleTasks() ran again and reset the legitimately in-progress task to "failed". Combined with a missing "failed" case in TaskRow's icon switch, both the spinner and state icon disappeared completely. Navigation fix: - Replace useRef guards in useStartupRecovery and useStartupScan with module-level flags that survive unmount/remount cycles - Add "failed" state to TaskRow (red AlertCircle icon + [Failed] prefix) Deep scan / concurrency fixes: - Add deep_scanning_repos set to EngineState for scan-task coordination - engine_start_task waits for active deep scan before starting work, preventing concurrent Claude CLI instances in the same repo - engine_scan_now marks/unmarks repos in deep_scanning_repos - Add busy_timeout(10s) to Rust SQLite connections to avoid SQLITE_BUSY - Add dbUpdateTaskWithRetry helper for resilient DB writes - TaskStatusBanner shows amber "Waiting for scan" state - useStartTask awaits DB write before long-running invoke - useDeepScanListener re-invalidates active task query after list refetch
1 parent 5d8345a commit 585f610

8 files changed

Lines changed: 149 additions & 18 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ If you'd prefer to build locally:
5858
**Prerequisites:** Node.js >= 22, Rust (stable), pnpm
5959

6060
```bash
61-
git clone https://github.com/user/sustn.git
61+
git clone https://github.com/ghvstcode/sustn.git
6262
cd sustn
6363
pnpm install
6464
pnpm tauri:dev

src-tauri/src/engine/db.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,9 @@ pub fn save_scanned_tasks(
136136
let conn = Connection::open(&db_path).map_err(|e| {
137137
format!("Failed to open database at {}: {e}", db_path.display())
138138
})?;
139+
// Set busy timeout so concurrent writes (from Tauri SQL plugin) don't
140+
// immediately fail with SQLITE_BUSY — wait up to 10s for the lock.
141+
let _ = conn.busy_timeout(std::time::Duration::from_secs(10));
139142

140143
// Load existing task titles for dedup (case-insensitive)
141144
let mut existing_titles: std::collections::HashSet<String> = std::collections::HashSet::new();

src-tauri/src/engine/mod.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ pub mod scheduler;
77
pub mod worker;
88

99
use serde::{Deserialize, Serialize};
10+
use std::collections::HashSet;
1011
use std::sync::Arc;
1112
use tokio::sync::{Mutex, RwLock};
1213

@@ -18,6 +19,10 @@ pub struct EngineState {
1819
pub current_task: Mutex<Option<CurrentTask>>,
1920
/// Handle to cancel the scheduler loop.
2021
pub cancel_token: Mutex<Option<tokio::sync::watch::Sender<bool>>>,
22+
/// Repository IDs that currently have a deep scan in progress.
23+
/// Task execution waits for the scan to finish before starting,
24+
/// preventing concurrent Claude CLI instances in the same repo.
25+
pub deep_scanning_repos: Mutex<HashSet<String>>,
2126
}
2227

2328
impl EngineState {
@@ -26,6 +31,7 @@ impl EngineState {
2631
running: RwLock::new(false),
2732
current_task: Mutex::new(None),
2833
cancel_token: Mutex::new(None),
34+
deep_scanning_repos: Mutex::new(HashSet::new()),
2935
})
3036
}
3137
}

src-tauri/src/engine_commands.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,16 @@ pub async fn engine_scan_now(
7070
let repo_id_clone = repository_id.clone();
7171
let scan_prefs_clone = scan_prefs.clone();
7272

73+
// Access shared engine state to coordinate with task execution
74+
let engine_state: tauri::State<'_, Arc<EngineState>> = app.state();
75+
let engine_state_clone = Arc::clone(&engine_state);
76+
7377
tokio::spawn(async move {
7478
println!("[engine] deep scan starting for repository {repo_id_clone}");
7579

80+
// Mark this repo as deep-scanning so engine_start_task can wait
81+
engine_state_clone.deep_scanning_repos.lock().await.insert(repo_id_clone.clone());
82+
7683
let _ = app_clone.emit("agent:scan-deep-started", serde_json::json!({
7784
"repositoryId": repo_id_clone,
7885
}));
@@ -94,6 +101,7 @@ pub async fn engine_scan_now(
94101
"repositoryId": repo_id_clone,
95102
"error": format!("Failed to get app data dir: {e}"),
96103
}));
104+
engine_state_clone.deep_scanning_repos.lock().await.remove(&repo_id_clone);
97105
return;
98106
}
99107
};
@@ -133,6 +141,10 @@ pub async fn engine_scan_now(
133141
"taskIds": serde_json::Value::Array(vec![]),
134142
}));
135143
}
144+
145+
// Clear deep-scanning flag so waiting tasks can proceed
146+
engine_state_clone.deep_scanning_repos.lock().await.remove(&repo_id_clone);
147+
println!("[engine] deep scan flag cleared for repository {repo_id_clone}");
136148
});
137149
} else {
138150
println!("[engine] deep scan skipped — budget exhausted");
@@ -160,6 +172,31 @@ pub async fn engine_start_task(
160172
) -> Result<worker::WorkResult, String> {
161173
println!("[engine_start_task] invoked — task_id={task_id}, repo_path={repo_path}, title={task_title}, base_branch={base_branch}, branch_name={branch_name}, has_user_messages={}, resume_session={:?}", user_messages.is_some(), resume_session_id);
162174

175+
// Wait for any active deep scan on this repo to finish.
176+
// Running two Claude CLI instances in the same repo concurrently causes
177+
// git conflicts and unpredictable behavior.
178+
{
179+
let mut waited = false;
180+
loop {
181+
let is_scanning = state.deep_scanning_repos.lock().await.contains(&repository_id);
182+
if !is_scanning {
183+
break;
184+
}
185+
if !waited {
186+
println!("[engine_start_task] waiting for deep scan to finish on {repository_id}...");
187+
let _ = app.emit("agent:task-waiting-for-scan", serde_json::json!({
188+
"taskId": task_id,
189+
"repositoryId": repository_id,
190+
}));
191+
waited = true;
192+
}
193+
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
194+
}
195+
if waited {
196+
println!("[engine_start_task] deep scan finished, proceeding with task {task_id}");
197+
}
198+
}
199+
163200
// Check budget before starting work (respects per-project ceiling override)
164201
{
165202
let app_data_dir = app

src/core/api/useEngine.ts

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,32 @@ function invalidateTaskQueries(
362362
});
363363
}
364364

365+
/** Retry a DB write with a short delay. Prevents tasks getting permanently
366+
* stuck as in_progress when a transient SQLITE_BUSY error occurs. */
367+
async function dbUpdateTaskWithRetry(
368+
taskId: string,
369+
fields: Parameters<typeof dbUpdateTask>[1],
370+
retries = 2,
371+
): Promise<void> {
372+
for (let attempt = 0; attempt <= retries; attempt++) {
373+
try {
374+
await dbUpdateTask(taskId, fields);
375+
return;
376+
} catch (e) {
377+
console.error(
378+
`[dbUpdateTaskWithRetry] attempt ${attempt + 1}/${retries + 1} failed:`,
379+
e,
380+
);
381+
if (attempt < retries) {
382+
await new Promise((r) => setTimeout(r, 500 * (attempt + 1)));
383+
}
384+
}
385+
}
386+
console.error(
387+
`[dbUpdateTaskWithRetry] all ${retries + 1} attempts failed for task ${taskId}`,
388+
);
389+
}
390+
365391
async function handleTaskResult(
366392
result: WorkResult,
367393
variables: TaskStartParams,
@@ -421,7 +447,7 @@ async function handleTaskResult(
421447
}
422448
}
423449

424-
await dbUpdateTask(variables.taskId, {
450+
await dbUpdateTaskWithRetry(variables.taskId, {
425451
state: prUrl ? ("done" as const) : ("review" as const),
426452
baseBranch: variables.baseBranch,
427453
branchName: result.branchName,
@@ -448,7 +474,7 @@ async function handleTaskResult(
448474
"[handleTaskResult] persisting failure — error:",
449475
result.error,
450476
);
451-
await dbUpdateTask(variables.taskId, {
477+
await dbUpdateTaskWithRetry(variables.taskId, {
452478
state: "failed" as const,
453479
lastError: result.error ?? "Unknown error",
454480
branchName: result.branchName,
@@ -495,14 +521,10 @@ async function handleTaskError(
495521
success: false,
496522
});
497523

498-
try {
499-
await dbUpdateTask(variables.taskId, {
500-
state: "failed" as const,
501-
lastError: error instanceof Error ? error.message : String(error),
502-
});
503-
} catch (e) {
504-
console.error("[handleTaskError] failed to persist error state:", e);
505-
}
524+
await dbUpdateTaskWithRetry(variables.taskId, {
525+
state: "failed" as const,
526+
lastError: error instanceof Error ? error.message : String(error),
527+
});
506528

507529
if (notify) {
508530
try {
@@ -898,17 +920,20 @@ export function useDiff(
898920

899921
// ── Startup Recovery ────────────────────────────────────────
900922

923+
// Module-level flag so it survives AppShell unmount/remount cycles
924+
// (e.g. navigating to /settings and back). Only resets on true app restart.
925+
let startupRecovered = false;
926+
901927
/**
902928
* Recovers stale in_progress tasks on app startup.
903929
* Call once from the top-level app shell.
904930
*/
905931
export function useStartupRecovery() {
906932
const queryClient = useQueryClient();
907-
const recovered = useRef(false);
908933

909934
useEffect(() => {
910-
if (recovered.current) return;
911-
recovered.current = true;
935+
if (startupRecovered) return;
936+
startupRecovered = true;
912937

913938
void recoverStaleTasks().then((count) => {
914939
if (count > 0) {
@@ -926,18 +951,20 @@ export function useStartupRecovery() {
926951

927952
// ── Startup Scan ────────────────────────────────────────────
928953

954+
// Module-level flag — same rationale as startupRecovered above.
955+
let startupScanStarted = false;
956+
929957
/**
930958
* Scans repositories that have never been scanned on app startup.
931959
* Runs once, staggering scans to avoid overloading the system.
932960
* Call once from the top-level app shell.
933961
*/
934962
export function useStartupScan() {
935963
const queryClient = useQueryClient();
936-
const started = useRef(false);
937964

938965
useEffect(() => {
939-
if (started.current) return;
940-
started.current = true;
966+
if (startupScanStarted) return;
967+
startupScanStarted = true;
941968

942969
void runStartupScans(queryClient);
943970
}, [queryClient]);

src/ui/components/tasks/TaskListView.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -372,7 +372,7 @@ export function TaskListView({ repositoryId }: TaskListViewProps) {
372372
className="flex-1 overflow-y-auto"
373373
onMouseLeave={() => setHoveredTaskId(null)}
374374
>
375-
<div className="mx-auto w-full max-w-2xl px-6 pt-[28vh] pb-8">
375+
<div className="mx-auto w-full max-w-2xl px-6 pt-[5vh] pb-8">
376376
<DndContext
377377
sensors={sensors}
378378
collisionDetection={closestCenter}

src/ui/components/tasks/TaskRow.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
Loader2,
99
GitPullRequest,
1010
XCircle,
11+
AlertCircle,
1112
Pencil,
1213
MessageSquare,
1314
Clock,
@@ -48,6 +49,7 @@ const categoryLabels: Record<string, string> = {
4849
const statePrefix: Record<string, string | undefined> = {
4950
pending: undefined,
5051
in_progress: "In-Progress",
52+
failed: "Failed",
5153
review: "Awaiting Review",
5254
done: "Done",
5355
dismissed: "Dismissed",
@@ -156,6 +158,16 @@ export function TaskRow({
156158
<Loader2 className="h-4 w-4 text-blue-500 animate-spin transition-colors group-hover/circle:text-green-500 group-hover/circle:animate-none" />
157159
</button>
158160
);
161+
case "failed":
162+
return (
163+
<button
164+
type="button"
165+
onClick={handleToggleDone}
166+
className="group/circle shrink-0"
167+
>
168+
<AlertCircle className="h-4 w-4 text-red-500 transition-colors group-hover/circle:text-green-500" />
169+
</button>
170+
);
159171
case "review":
160172
return (
161173
<button

src/ui/components/tasks/TaskStatusBanner.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import { useState, useEffect } from "react";
12
import { Loader2 } from "lucide-react";
3+
import { listen } from "@tauri-apps/api/event";
24
import { useEngineStatus } from "@core/api/useEngine";
35

46
interface TaskStatusBannerProps {
@@ -13,10 +15,54 @@ const phaseLabels: Record<string, string> = {
1315

1416
export function TaskStatusBanner({ taskId }: TaskStatusBannerProps) {
1517
const { data: status } = useEngineStatus();
18+
const [waitingForScan, setWaitingForScan] = useState(false);
19+
20+
// Listen for the waiting-for-scan event from the Rust backend.
21+
// This fires when engine_start_task is blocked waiting for a deep scan
22+
// to finish before it can start working on the task.
23+
useEffect(() => {
24+
const unlisten = listen<{ taskId: string; repositoryId: string }>(
25+
"agent:task-waiting-for-scan",
26+
(event) => {
27+
if (event.payload.taskId === taskId) {
28+
setWaitingForScan(true);
29+
}
30+
},
31+
);
32+
33+
return () => {
34+
void unlisten.then((fn) => fn());
35+
};
36+
}, [taskId]);
1637

1738
const isWorking = status?.currentTask?.taskId === taskId;
1839
const phase = status?.currentTask?.phase;
1940

41+
// Clear scan-waiting state once the engine picks up the task
42+
useEffect(() => {
43+
if (isWorking && waitingForScan) {
44+
setWaitingForScan(false);
45+
}
46+
}, [isWorking, waitingForScan]);
47+
48+
if (waitingForScan && !isWorking) {
49+
return (
50+
<div className="flex items-center gap-2.5 rounded-lg border border-amber-500/20 bg-amber-500/5 px-4 py-3">
51+
<Loader2 className="h-4 w-4 animate-spin text-amber-500" />
52+
<div className="flex-1 min-w-0">
53+
<p className="text-sm font-medium text-foreground">
54+
Waiting for scan to finish
55+
</p>
56+
<p className="text-xs text-muted-foreground mt-0.5">
57+
A deep scan is still running on this repository. Work
58+
will start automatically once it completes.
59+
</p>
60+
</div>
61+
<div className="h-2 w-2 rounded-full bg-amber-500 animate-pulse" />
62+
</div>
63+
);
64+
}
65+
2066
if (!isWorking) return null;
2167

2268
return (

0 commit comments

Comments
 (0)