Skip to content

Commit d9bfb15

Browse files
authored
Merge pull request #53 from kurtstohrer/fix/embedded-agent-reliability
fix(embedded-agent): honor provider pick, survive reload, persist agent pins (v0.4.1)
2 parents 53128d9 + 66bca85 commit d9bfb15

16 files changed

Lines changed: 571 additions & 116 deletions

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@
22

33
All notable changes to this project are documented here. Versions follow [Semantic Versioning](https://semver.org/). Dates are ISO 8601.
44

5+
## [0.4.1] - 2026-06-23
6+
7+
### Fixed
8+
- **The selected provider now actually applies the task.** Built-in agent personas hard-coded `claude-local`, so every apply/seed run spawned `claude` regardless of the provider you picked in Settings → Providers — surfacing as `spawn claude ENOENT` when claude wasn't installed (e.g. you'd selected Copilot). Built-in personas now inherit the global active provider; an explicit per-persona pin in `.annotask/agents.json` still wins. Verified live across all four CLIs (claude/codex/opencode/copilot).
9+
- **Per-agent provider/model pins persist.** Setting a persona's model in Settings → Agents was silently wiped whenever the provider's live model catalog couldn't be enumerated (Copilot's interactive-only picker is the canonical case) — and a saved value displayed as empty. The model field now keeps and shows the saved value; a stale id from a *different* provider is cleared on provider change instead, never on catalog-fetch failure.
10+
- **Reloading the page no longer destroys an in-flight apply.** An applying CLI was bound to the browser tab: a reload tore down the SSE, killed the child mid-edit, and reverted the task to `pending`. The spawn server now keeps a task-bearing run alive across a client disconnect (detach grace) and finalizes it on the child's own exit — a clean exit lands the task in `review`, an interrupted/failed run reverts to `pending`. The client no longer reverts the task on `pagehide` and warns before an accidental reload. A stale orphan-finalize can no longer clobber a newer run for the same task.
11+
- **Auto-run reliability.** The headless auto-run driver logs when its single-run guard blocks a drain (previously silent), and bounds each run so a hung provider can't pin the queue and stall every later task.
12+
- **Conversation rendering.** Agent messages now style the full Markdown surface (lists, headings, blockquotes, links, tables) instead of only paragraphs/code; wide tool output and long tokens no longer scroll the whole panel (`min-width: 0` + code wrapping); the "agent paused for input" banner label meets contrast; and rendered links open in a new tab with `rel="noopener"`.
13+
14+
### Changed
15+
- **Seed prompt carries the task inline.** Apply runs now embed a compact `Task grounding` block (file/line/component/route + per-type context, via the shared task-summary) in the seed prompt, so the agent applies directly instead of reflexively calling `annotask_get_task`. Heavy fields (screenshot, rendered HTML, interaction history) stay behind their MCP tools.
16+
517
## [0.4.0] - 2026-06-22
618

719
### Fixed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "annotask",
3-
"version": "0.4.0",
3+
"version": "0.4.1",
44
"description": "Visual UI design tool for web apps. Make changes in the browser and generate structured reports that AI agents can apply to source code. Works with Vue, React, Svelte, SolidJS, and plain HTML via Vite or Webpack.",
55
"license": "MIT",
66
"type": "module",

src/server/__tests__/agent-spawn-http.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,39 @@ describe('agent-spawn over HTTP (real middleware + real spawn handler)', () => {
300300
child.finish(143) // simulate the SIGTERM landing so the run drains
301301
})
302302

303+
it('keeps the child alive on client disconnect when the run carries a taskId (reload survival)', async () => {
304+
const before = spawned.length
305+
const clientReq = http.request({
306+
method: 'POST',
307+
hostname: 'localhost',
308+
port: serverPort(),
309+
path: SPAWN_PATH,
310+
headers: { 'Content-Type': 'application/json' },
311+
})
312+
clientReq.on('error', () => { /* expected after destroy */ })
313+
clientReq.on('response', (res) => {
314+
res.on('data', () => { /* keep the stream flowing */ })
315+
res.on('error', () => { /* expected after destroy */ })
316+
})
317+
clientReq.end(JSON.stringify({ cli: 'claude', args: [], taskId: 'task-reload' }))
318+
319+
await waitFor(() => spawned.length > before, 'child spawn')
320+
const child = spawned[spawned.length - 1]
321+
expect(child.killed).toBe(false)
322+
323+
clientReq.destroy()
324+
// Apply runs get a detach grace so a page reload doesn't destroy in-flight
325+
// work — the child must still be alive shortly after the disconnect (the
326+
// no-taskId case above is killed immediately).
327+
await new Promise((r) => setTimeout(r, 80))
328+
expect(child.killed).toBe(false)
329+
expect(agentSpawn.registry.taskRunning('task-reload')).toBe(true)
330+
331+
// The child exiting on its own drains the run (afterEach also guards this).
332+
child.finish(0)
333+
await waitFor(() => agentSpawn.registry.size() === 0, 'run drains after child exit')
334+
})
335+
303336
describe('ANNOTASK_MAX_PERMISSION cap at the route level', () => {
304337
it('403s a bypass spawn under cap=default — canonical flag AND synonym spellings', async () => {
305338
process.env.ANNOTASK_MAX_PERMISSION = 'default'

src/server/__tests__/agent-spawn.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,42 @@ describe('run registry — per-task dedup + orphan hook', () => {
278278
await p
279279
expect(ended).toEqual([]) // no taskId → no orphan reconciliation
280280
})
281+
282+
it('reports a clean self-exit as { killed:false, exitCode:0 } so the finalizer can land review', async () => {
283+
const ends: Array<{ killed: boolean; exitCode: number | null }> = []
284+
const child = new FakeChild()
285+
const handler = createAgentSpawnHandler({ spawnImpl: fakeSpawn(child), onRunEnd: (_id, info) => ends.push(info) })
286+
const p = handler.handleSpawn(fakeReq(), fakeRes(), { cli: 'claude', args: [], taskId: 'task-clean' }, '/tmp')
287+
child.finish(0)
288+
await p
289+
expect(ends).toEqual([{ killed: false, exitCode: 0 }])
290+
})
291+
292+
it('does NOT kill an apply run (taskId) on client disconnect — keeps it alive across a reload', async () => {
293+
const child = new FakeChild()
294+
const handler = createAgentSpawnHandler({ spawnImpl: fakeSpawn(child) })
295+
const req = fakeReq()
296+
const p = handler.handleSpawn(req, fakeRes(), { cli: 'claude', args: [], taskId: 'task-detach' }, '/tmp')
297+
;(req as unknown as EventEmitter).emit('close')
298+
// The detach grace keeps the child alive (a chat run would be killed — next test).
299+
expect(child.killed).toBe(false)
300+
expect(handler.registry.taskRunning('task-detach')).toBe(true)
301+
// Its own clean exit drains the run and clears the grace timer.
302+
child.finish(0)
303+
await p
304+
expect(handler.registry.taskRunning('task-detach')).toBe(false)
305+
})
306+
307+
it('kills a chat run (no taskId) immediately on client disconnect', async () => {
308+
const child = new FakeChild()
309+
const handler = createAgentSpawnHandler({ spawnImpl: fakeSpawn(child) })
310+
const req = fakeReq()
311+
const p = handler.handleSpawn(req, fakeRes(), { cli: 'claude', args: [] }, '/tmp')
312+
;(req as unknown as EventEmitter).emit('close')
313+
expect(child.killed).toBe(true)
314+
child.finish(143)
315+
await p
316+
})
281317
})
282318

283319
describe('origin policy', () => {

src/server/agent-spawn.ts

Lines changed: 65 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,18 @@ const MAX_SPAWN_DURATION_MS = 15 * 60_000
6363
* at once. Excess spawns are refused with a `too_many_agents` error.
6464
*/
6565
const MAX_CONCURRENT_SPAWNS = 4
66+
/**
67+
* Grace window to keep a CLI child alive after its client SSE disconnects,
68+
* WHEN the run is applying a task (carries a taskId). A page reload tears down
69+
* the SSE; without this the child is SIGTERM'd mid-edit and the in-flight work
70+
* is lost. With it, the child keeps running and the server finalizes the task
71+
* on the child's own exit (see `onRunEnd` → index.ts: clean exit → review,
72+
* otherwise → pending). Chat runs (no taskId) are bound to their tab and are
73+
* still killed the instant the client disconnects. Kept beneath
74+
* MAX_SPAWN_DURATION_MS and below the boot-reclaim age so a client that never
75+
* returns is still cleaned up promptly.
76+
*/
77+
const DETACH_GRACE_MS = 90_000
6678

6779
/**
6880
* PATH passed to spawned CLIs. Appends `ANNOTASK_HOST_PATH` (if set) so
@@ -143,12 +155,16 @@ export interface AgentSpawnOptions {
143155
* the orchestrating tab closed mid-run) — it grace-checks "still in_progress?"
144156
* so a normal completion (client about to set `review`) is a no-op.
145157
*
146-
* `info.killed` is true when WE terminated the child (client disconnect,
147-
* explicit abort, or the duration backstop) and false when it exited on its
148-
* own — lets the finalizer word its reason honestly instead of always
149-
* blaming a closed tab.
158+
* `info.killed` is true when WE terminated the child (explicit abort, the
159+
* detach-grace expiry, or the duration backstop) and false when it exited on
160+
* its own — lets the finalizer word its reason honestly instead of always
161+
* blaming a closed tab. `info.exitCode` is the child's exit status (null when
162+
* killed/signalled): a clean `0` from a child that exited on its own means
163+
* the apply finished even though the client never finalized it (e.g. the tab
164+
* reloaded mid-run), so the finalizer can land the task in `review` instead
165+
* of wrongly reverting completed work to `pending`.
150166
*/
151-
onRunEnd?: (taskId: string, info: { killed: boolean }) => void
167+
onRunEnd?: (taskId: string, info: { killed: boolean; exitCode: number | null }) => void
152168
/** Test seam: inject a child-process factory (defaults to node:child_process spawn). */
153169
spawnImpl?: typeof spawn
154170
}
@@ -423,6 +439,13 @@ export function createAgentSpawnHandler(opts: AgentSpawnOptions = {}): AgentSpaw
423439
}
424440

425441
let killed = false
442+
// Set once the run has fully ended (child closed + cleanup running). Guards
443+
// onClientGone so the `res.end()` in cleanup — which itself fires res
444+
// 'close' — can't re-enter and arm a pointless detach timer on a dead run.
445+
let runEnded = false
446+
// Detach-grace timer: armed when an apply run's client disconnects, kills
447+
// the child if no one returns within DETACH_GRACE_MS.
448+
let detachTimer: ReturnType<typeof setTimeout> | null = null
426449
// Signal the whole process group on POSIX (the child was spawned detached,
427450
// i.e. as a group leader) so grandchildren the agent forked die with it.
428451
// Falls back to the direct child.kill when there's no pid (spawn-time
@@ -436,9 +459,28 @@ export function createAgentSpawnHandler(opts: AgentSpawnOptions = {}): AgentSpaw
436459
function killChild() {
437460
if (killed) return
438461
killed = true
462+
if (detachTimer) { clearTimeout(detachTimer); detachTimer = null }
439463
killGroup('SIGTERM')
440464
setTimeout(() => { killGroup('SIGKILL') }, KILL_GRACE_MS).unref()
441465
}
466+
// Client SSE went away (tab closed, navigated, or RELOADED). For a chat run
467+
// the child is bound to its tab — kill it. For an APPLY run (has a taskId)
468+
// a reload would otherwise destroy in-flight edits, so keep the child alive
469+
// for a grace window: it finishes and the server finalizes the task on its
470+
// exit. The child keeps writing to a dead socket during the grace, which is
471+
// harmless (write() swallows EPIPE). Kill on grace expiry.
472+
// Capture the (narrowed) taskId in a local — TS doesn't carry the
473+
// `typeof parsed === 'string'` narrowing into the nested closure below.
474+
const runTaskId = parsed.taskId
475+
let clientGone = false
476+
function onClientGone() {
477+
if (clientGone || runEnded) return
478+
clientGone = true
479+
if (!runTaskId) { killChild(); return }
480+
if (detachTimer || killed) return
481+
detachTimer = setTimeout(() => { killChild() }, DETACH_GRACE_MS)
482+
detachTimer.unref?.()
483+
}
442484
active.set(runId, { child, kill: killChild, taskId: parsed.taskId })
443485

444486
// Pipe stdin if provided. Attach the async error listener BEFORE writing:
@@ -463,10 +505,11 @@ export function createAgentSpawnHandler(opts: AgentSpawnOptions = {}): AgentSpaw
463505
}, KEEPALIVE_INTERVAL_MS)
464506
keepalive.unref()
465507

466-
// Abort on client disconnect. Belt-and-suspenders: some proxies close
467-
// the response socket rather than the request, so we listen on both.
468-
req.on('close', () => { killChild() })
469-
res.on('close', () => { killChild() })
508+
// Client disconnect (incl. page reload). Belt-and-suspenders: some proxies
509+
// close the response socket rather than the request, so we listen on both.
510+
// For apply runs this starts the detach grace rather than an instant kill.
511+
req.on('close', () => { onClientGone() })
512+
res.on('close', () => { onClientGone() })
470513

471514
// Absolute duration backstop — kill a child that outlives the ceiling
472515
// (e.g. its client died without closing the socket). Cleared on close.
@@ -523,6 +566,7 @@ export function createAgentSpawnHandler(opts: AgentSpawnOptions = {}): AgentSpaw
523566
})
524567
})
525568

569+
let exitCode: number | null = null
526570
await new Promise<void>((resolve) => {
527571
let resolved = false
528572
const finish = () => { if (!resolved) { resolved = true; resolve() } }
@@ -532,6 +576,7 @@ export function createAgentSpawnHandler(opts: AgentSpawnOptions = {}): AgentSpaw
532576
finish()
533577
})
534578
child.on('close', (code, signal) => {
579+
exitCode = typeof code === 'number' ? code : null
535580
// Flush trailing stdout/stderr (no terminating newline).
536581
if (stdoutBuf.length > 0) write(res, 'stdout', stdoutBuf)
537582
if (stderrBuf.length > 0) write(res, 'stderr', stderrBuf)
@@ -540,16 +585,23 @@ export function createAgentSpawnHandler(opts: AgentSpawnOptions = {}): AgentSpaw
540585
})
541586
})
542587

588+
// Mark ended BEFORE res.end() (which fires res 'close') so the disconnect
589+
// handler can't arm a detach timer on this already-finished run.
590+
runEnded = true
543591
clearInterval(keepalive)
544592
clearTimeout(maxDuration)
593+
if (detachTimer) { clearTimeout(detachTimer); detachTimer = null }
545594
active.delete(runId)
546595
if (parsed.taskId && byTask.get(parsed.taskId) === runId) byTask.delete(parsed.taskId)
547596
try { res.end() } catch { /* already ended */ }
548-
// Report the run end so a task the client never finalized (orphaned by a
549-
// closed tab) can be reconciled server-side. Fired for normal completions
550-
// too — the handler grace-checks status, so those no-op.
597+
// Report the run end so a task the client never finalized can be reconciled
598+
// server-side. `killed` distinguishes a terminated child from one that
599+
// exited on its own; `exitCode` lets the finalizer land a detached-but-
600+
// completed apply (clean exit) in `review` instead of reverting it to
601+
// `pending`. Fired for normal completions too — the handler grace-checks
602+
// status, so a client that already finalized makes this a no-op.
551603
if (parsed.taskId) {
552-
try { opts.onRunEnd?.(parsed.taskId, { killed }) } catch { /* never let a sink break cleanup */ }
604+
try { opts.onRunEnd?.(parsed.taskId, { killed, exitCode }) } catch { /* never let a sink break cleanup */ }
553605
}
554606
}
555607

src/server/index.ts

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ export function createAnnotaskServer(options: AnnotaskServerOptions): AnnotaskSe
122122
if (res && typeof res === 'object' && 'error' in res) return
123123
// eslint-disable-next-line no-console
124124
console.warn(`[annotask] reconciled orphaned task ${taskId} → pending (${why})`)
125+
void finalizeFrozenPartial(taskId)
125126
if (before?.type === 'wireframe_apply') {
126127
try { await releaseApplyTask(applyOptions, taskId) }
127128
catch (err) {
@@ -134,18 +135,76 @@ export function createAnnotaskServer(options: AnnotaskServerOptions): AnnotaskSe
134135
console.warn(`[annotask] orphan reconcile failed for task ${taskId}:`, err)
135136
})
136137
}
138+
/**
139+
* A detached apply run exited cleanly (code 0) but its client never flipped
140+
* the status — the orchestrating tab reloaded/closed mid-run while the CLI
141+
* (kept alive by the spawn detach-grace) finished and wrote the files. Land
142+
* the task in `review` (the state the client would have set) so completed
143+
* work isn't reverted to `pending` and re-applied. Guarded to in_progress so
144+
* a returning client that finalizes first still wins.
145+
*/
146+
function finalizeDetachedReview(taskId: string): void {
147+
void state.updateTask(
148+
taskId,
149+
{ status: 'review', resolution: 'Applied by the embedded agent; finalized server-side after the tab was closed or reloaded mid-run. Review the changes.' },
150+
{ guard: (t) => (t.status === 'in_progress' ? null : 'task already finalized') },
151+
).then((res) => {
152+
if (res && typeof res === 'object' && 'error' in res) return
153+
// eslint-disable-next-line no-console
154+
console.warn(`[annotask] finalized detached run ${taskId} → review (clean exit)`)
155+
void finalizeFrozenPartial(taskId)
156+
}).catch((err) => {
157+
// eslint-disable-next-line no-console
158+
console.warn(`[annotask] detached review finalize failed for task ${taskId}:`, err)
159+
})
160+
}
161+
/**
162+
* Clear a stuck `isPartial:true` assistant turn left behind when the
163+
* streaming client vanished mid-run (only the client run loop ever clears it,
164+
* so without this the Conversation tab shows a turn frozen mid-stream
165+
* forever). Best-effort.
166+
*/
167+
async function finalizeFrozenPartial(taskId: string): Promise<void> {
168+
try {
169+
const msgs = await taskThread.read(taskId)
170+
for (let i = msgs.length - 1; i >= 0; i--) {
171+
if (msgs[i].isPartial) {
172+
await taskThread.update(taskId, msgs[i].id, { isPartial: false })
173+
break
174+
}
175+
}
176+
} catch (err) {
177+
// eslint-disable-next-line no-console
178+
console.warn(`[annotask] clearing frozen partial failed for task ${taskId}:`, err)
179+
}
180+
}
137181
// The registry reports every run end; we grace-check a moment later (a normal
138182
// completion's client review/pending PATCH lands well within this window, so
139-
// it no-ops) and, if the task is still `in_progress`, reconcile it. `killed`
140-
// distinguishes an interrupted run (we terminated the child) from a child
141-
// that exited on its own while the client failed to finalize — for honest
142-
// logging; both reconcile to `pending`.
183+
// it no-ops) and, if the task is still `in_progress`, finalize it. A child
184+
// that exited on its own with code 0 finished the apply (the client just
185+
// never flipped status — e.g. its tab reloaded) → land in `review`. Anything
186+
// else — a non-zero exit, or a child WE killed (detach-grace expiry, abort,
187+
// duration backstop) — reverts to `pending` so it stays retryable.
188+
// wireframe_apply always takes the pending path: its apply-batch lifecycle is
189+
// reconciled there and a server-side review flip would bypass it.
143190
const ORPHAN_FINALIZE_GRACE_MS = 12_000
144-
function scheduleOrphanFinalize(taskId: string, info: { killed: boolean }): void {
191+
function scheduleOrphanFinalize(taskId: string, info: { killed: boolean; exitCode: number | null }): void {
145192
setTimeout(() => {
146-
const task = state.getTasks().tasks.find((t: { id?: string; status?: string }) => t?.id === taskId)
193+
const task = state.getTasks().tasks.find((t: { id?: string; status?: string; type?: string }) => t?.id === taskId)
147194
if (!task || task.status !== 'in_progress') return
148-
reconcileOrphanedTask(taskId, info.killed ? 'run interrupted' : 'client never finalized')
195+
// A NEWER run already owns this task (a re-spawn landed in the window
196+
// between this run's child-exit — which cleared the byTask reservation —
197+
// and this delayed callback). Our finalize is stale: flipping the task
198+
// now would clobber the live run mid-edit. Bail and let that run finalize
199+
// itself. The finalizer is otherwise run-identity-unaware (it keys only
200+
// on taskId + status), so this guard is what prevents the clobber.
201+
if (agentSpawn.registry.taskRunning(taskId)) return
202+
const cleanCompletion = !info.killed && info.exitCode === 0 && task.type !== 'wireframe_apply'
203+
if (cleanCompletion) {
204+
finalizeDetachedReview(taskId)
205+
} else {
206+
reconcileOrphanedTask(taskId, info.killed ? 'run interrupted' : 'client never finalized')
207+
}
149208
}, ORPHAN_FINALIZE_GRACE_MS).unref()
150209
}
151210
const agentSpawn = createAgentSpawnHandler({ onRunEnd: scheduleOrphanFinalize })

0 commit comments

Comments
 (0)