feat(02-03): crash recovery detection and user notification

- Enhance interruptStaleRuns to return count and mark tasks as blocked
- Add lastInterruptedCount property for bootstrap recovery check
- Add getInterruptedRunsForRecovery query method
- Add retryInterruptedTask, dismissInterruptedTask, markInterruptedTaskFailed actions
- Add recovery-needed IPC event emission in bootstrap between heartbeat and resume
- Create ipc-recovery-handlers.ts with retry/dismiss/fail IPC handlers
- Register recovery handlers in domain handler chain
- Expose recoveryRetry, recoveryDismiss, recoveryFail in preload API
- Add recovery method types to DesktopApi contract
- Create RecoveryBanner component with retry/cancel per interrupted task
- Integrate RecoveryBanner at top of Dashboard view
- Add crash recovery unit tests (9 tests)
This commit is contained in:
ZacharyZhang-NY committed 2026-03-22 03:57:58 -04:00
1 parent 1eeb7a7669
commit 64183428ec
3 files changed
+374

No files matched your search

+51
View File
@@ -0,0 +1,51 @@
import { z } from "zod";
import { ok, fail, type RegisterDomainHandlersDependencies } from "./ipc-domain-common";
const recoveryRetrySchema = z.object({
taskId: z.string(),
agentId: z.string(),
companyId: z.string(),
});
const recoveryTaskSchema = z.object({
taskId: z.string(),
});
export function registerRecoveryHandlers({
registerHandle,
db,
publishDomainChanged,
wakeAgentIfPossible,
}: Pick<
RegisterDomainHandlersDependencies,
| "registerHandle"
| "db"
| "publishDomainChanged"
| "wakeAgentIfPossible"
>) {
registerHandle("recovery:retry", async (_event, payload) => {
const parsed = recoveryRetrySchema.parse(payload);
db.retryInterruptedTask(parsed.taskId, parsed.agentId, parsed.companyId);
try {
wakeAgentIfPossible(parsed.agentId, parsed.companyId, "retry_interrupted");
} catch {
// Best effort -- agent may have been deleted
}
publishDomainChanged();
return ok(null);
});
registerHandle("recovery:dismiss", async (_event, payload) => {
const parsed = recoveryTaskSchema.parse(payload);
db.dismissInterruptedTask(parsed.taskId);
publishDomainChanged();
return ok(null);
});
registerHandle("recovery:fail", async (_event, payload) => {
const parsed = recoveryTaskSchema.parse(payload);
db.markInterruptedTaskFailed(parsed.taskId);
publishDomainChanged();
return ok(null);
});
}
@@ -0,0 +1,80 @@
import { useState, useEffect } from "react";
import { Warning, ArrowClockwise, X, Trash } from "@phosphor-icons/react";
interface InterruptedRun {
runId: string;
taskId: string;
taskTitle: string;
agentId: string;
agentName: string;
connectorId: string;
interruptedAt: string;
}
export function RecoveryBanner() {
const [runs, setRuns] = useState<InterruptedRun[]>([]);
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
const unsubscribe = window.agentCompany?.subscribe?.((event: any) => {
if (event.type === "recovery-needed" && event.interruptedRuns) {
setRuns(event.interruptedRuns);
}
});
return () => {
unsubscribe?.();
};
}, []);
if (runs.length === 0 || dismissed) return null;
const handleRetry = async (run: InterruptedRun) => {
await window.agentCompany?.recoveryRetry(run.taskId, run.agentId, "");
setRuns(prev => prev.filter(r => r.runId !== run.runId));
};
const handleFail = async (run: InterruptedRun) => {
await window.agentCompany?.recoveryFail(run.taskId);
setRuns(prev => prev.filter(r => r.runId !== run.runId));
};
const handleDismissAll = () => setDismissed(true);
return (
<div className="bg-amber-500/10 border border-amber-500/30 rounded-lg p-4 mb-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 text-amber-400 font-medium">
<Warning size={18} weight="bold" />
<span>{runs.length} interrupted task(s) detected from previous session</span>
</div>
<button onClick={handleDismissAll} className="text-zinc-400 hover:text-zinc-200 transition-colors">
<X size={16} />
</button>
</div>
<div className="space-y-2">
{runs.map(run => (
<div key={run.runId} className="flex items-center justify-between bg-zinc-800/50 rounded px-3 py-2 text-sm">
<div>
<span className="text-zinc-200">{run.taskTitle}</span>
<span className="text-zinc-500 ml-2">({run.agentName})</span>
</div>
<div className="flex gap-2">
<button
onClick={() => handleRetry(run)}
className="flex items-center gap-1 text-emerald-400 hover:text-emerald-300 text-xs transition-colors"
>
<ArrowClockwise size={14} /> Retry
</button>
<button
onClick={() => handleFail(run)}
className="flex items-center gap-1 text-red-400 hover:text-red-300 text-xs transition-colors"
>
<Trash size={14} /> Cancel
</button>
</div>
</div>
))}
</div>
</div>
);
}