fix(apps): stop task.yaml rewrite races + blank-load watchdog + loop-lag monitor

Investigating 'apps blank while a bg task runs': measured serving latency
through a real scheduled bg-task run — 1-3ms on both loopbacks throughout,
so the server is not the bottleneck. Found and fixed the likely renderer-era
culprit plus made the failure observable:

- agent sync (apps:list, polled every 4s) rewrote each bundled agent's
  task.yaml unconditionally — racing the bg runner's own patches mid-run and
  spamming watcher events; now writes only when the definition changed
- AppFrame load watchdog: if the iframe hasn't loaded in 6s, show a visible
  'taking too long' state with Retry instead of a silent blank pane
- main event-loop lag monitor: stalls >300ms are logged so future blank
  reports can be tied to the offending work
This commit is contained in:
Gagan 2026-07-05 00:45:30 +05:30
parent 9173097715
commit 7c9fe7214d
3 changed files with 65 additions and 2 deletions

View file

@ -11,12 +11,29 @@ import { AppDetail } from '@/components/apps/app-detail'
export function AppFrame({ app, onBack }: { app: rowboatApp.AppSummary; onBack: () => void }) {
const [reloadNonce, setReloadNonce] = useState(0)
const [showDetail, setShowDetail] = useState(false)
// Load watchdog: if the iframe hasn't fired `load` within the deadline,
// surface a visible retry state instead of a silent blank pane.
const [loadState, setLoadState] = useState<'loading' | 'ok' | 'stuck'>('loading')
const title = app.manifest?.name ?? app.folder
useEffect(() => {
appOpened(app.folder)
}, [app.folder])
// Reset the watchdog when the target changes (adjust-during-render pattern).
const [watchKey, setWatchKey] = useState(`${app.folder}:${reloadNonce}`)
if (watchKey !== `${app.folder}:${reloadNonce}`) {
setWatchKey(`${app.folder}:${reloadNonce}`)
setLoadState('loading')
}
useEffect(() => {
const timer = window.setTimeout(() => {
setLoadState((s) => (s === 'loading' ? 'stuck' : s))
}, 6000)
return () => window.clearTimeout(timer)
}, [watchKey])
return (
<div className="flex h-full flex-col">
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
@ -55,13 +72,27 @@ export function AppFrame({ app, onBack }: { app: rowboatApp.AppSummary; onBack:
</button>
</div>
<div className="flex min-h-0 flex-1">
<div className="min-w-0 flex-1">
<div className="relative min-w-0 flex-1">
<iframe
key={reloadNonce}
title={title}
src={`${app.origin}/`}
onLoad={() => setLoadState('ok')}
className="h-full w-full border-0 bg-background"
/>
{loadState === 'stuck' && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-background/95 text-sm">
<div className="text-muted-foreground">This app is taking too long to load.</div>
<button
type="button"
onClick={() => setReloadNonce((n) => n + 1)}
className="rounded-md border border-border px-3 py-1.5 font-medium hover:bg-accent"
>
Retry
</button>
<div className="font-mono text-xs text-muted-foreground">{app.origin}</div>
</div>
)}
</div>
{showDetail && (
<div className="w-80 shrink-0 border-l border-border">

View file

@ -59,6 +59,9 @@ async function materializeAgent(folder: string, agentFile: string): Promise<stri
if (await pathExists(taskYaml)) {
// Update path (§8.4): overwrite definition fields, preserve the rest.
// Write ONLY when the definition actually changed — this sync runs on
// every apps:list poll, and an unconditional rewrite races the bg
// runner's own task.yaml patches mid-run (and spams watcher events).
try {
const current = parseYaml(await fs.readFile(taskYaml, 'utf-8')) as BackgroundTask;
const next: BackgroundTask = {
@ -69,7 +72,14 @@ async function materializeAgent(folder: string, agentFile: string): Promise<stri
sourceApp: folder,
};
if (!def.triggers) delete next.triggers;
await fs.writeFile(taskYaml, stringifyYaml(next), 'utf-8');
const unchanged =
current.name === next.name &&
current.instructions === next.instructions &&
current.sourceApp === next.sourceApp &&
JSON.stringify(current.triggers ?? null) === JSON.stringify(next.triggers ?? null);
if (!unchanged) {
await fs.writeFile(taskYaml, stringifyYaml(next), 'utf-8');
}
} catch (e) {
console.warn(`[Apps] failed to update agent task ${slug}:`, e);
}

View file

@ -710,6 +710,27 @@ export function getServerStatus(): { running: boolean; error?: string } {
return server ? { running: true } : { running: false, ...(serverError ? { error: serverError } : {}) };
}
let lagMonitor: NodeJS.Timeout | null = null;
/**
* Event-loop lag monitor: the apps server shares the main process with agent
* runs, sync pipelines, etc. If the loop stalls, every open app hangs with it
* log stalls >300ms so "app went blank" reports can be tied to a culprit.
*/
function startLagMonitor(): void {
if (lagMonitor) return;
let last = Date.now();
lagMonitor = setInterval(() => {
const now = Date.now();
const lag = now - last - 500;
if (lag > 300) {
console.warn(`[Apps] main event-loop stalled ~${lag}ms (apps server shares this loop — open apps hang during stalls)`);
}
last = now;
}, 500);
lagMonitor.unref?.();
}
export async function init(): Promise<void> {
if (server) return;
if (startPromise) return startPromise;
@ -717,6 +738,7 @@ export async function init(): Promise<void> {
startPromise = (async () => {
try {
await fsp.mkdir(APPS_DIR, { recursive: true });
startLagMonitor();
await startWatcher();
const expressApp = createApp();
await new Promise<void>((resolve, reject) => {