fix(ui,process): dashboard agent lifecycle feedback + Windows daemon stop - #449
fix(ui,process): dashboard agent lifecycle feedback + Windows daemon stop#449lucifer78907 wants to merge 5 commits into
Conversation
|
@initializ-mk please review these bug fixes |
initializ-mk
left a comment
There was a problem hiding this comment.
Thanks for a genuinely excellent contribution — every one of these fixes traces a real bug to its actual root cause, and the commit messages are some of the clearest I have read. The Windows handle-openability-≠-liveness diagnosis in particular is a great catch. I verified all of it against your branch; the core is correct. Requesting changes on scope + two small hardening items below, none of which are about the correctness of the fixes themselves.
Verified correct
process.go—broadcastStatussnapshot fixes the queued-event aliasing (the rewritten field isStatus, a scalar, so the shallow copy fully decouples thestopping→stoppedrewrite);stoppingannounced before the blockingcmd.Run(); rollback-on-error prevents a card wedged onstopping.process_windows.go—OpenProcess(QUERY_LIMITED_INFO|SYNCHRONIZE)+WaitForSingleObject(h, 0)returningevent == waitTimeoutfor alive is the right liveness test; handle closed; the new test fails against the old implementation.serve.go—SilenceUsage, and treating a kill that fails because the target is already gone as success correctly closes the liveness/kill race (right on UnixESRCHtoo).app.js— keyingChatPageis the correct fix for the staleuseStateacross an agent switch; unmount abort,agent_createdlistener + refetch, and optimistic stop + rollback are all sound. The newuseSSE((type, data) => …)signature has exactly one caller, so nothing else breaks.
Changes requested
- Split the code-agent prompt change into its own PR (see inline on
runner.go). ThecodeAgentDirective+SKILL.mdchange is an LLM-behavior change unrelated to the dashboard / Windows-stop theme and is not in the PR summary. It is a good fix on its own merits, but bundling a system-prompt behavioral change with UI/process bugfixes makes both harder to review and to revert independently, and there is no eval pinning the new greeting→text behavior. Please extract it into a separate PR (or, if it must stay, describe it in the PR body and add a note on how the conversation carve-out was verified). - Document the shallow-copy assumption in
broadcastStatus(see inline onprocess.go). - Client stop-rollback hardcodes
running. InhandleStop's catch you setstatus: 'running', but the true prior state could differ (e.g. the agent was alreadyerrored). The server's authoritativeagent_statusSSE event reconciles it so it self-corrects, but the card can briefly show the wrong state. Prefer mirroring the server's rollback-to-previous, or lean on the SSE event and drop the optimistic rollback. Low severity.
Note
Appreciated the transparency on the unchecked boxes (gofmt/CRLF, golangci-lint, Windows-only testing) — CI confirms Lint + gofmt are clean and all 10 checks pass, so those are resolved.
Really nice work — once the prompt change is split out and the two small items are addressed, this is good to go.
| const codeAgentDirective = `## Code Agent — MANDATORY RULES | ||
|
|
||
| You are a coding agent. Every response MUST include tool calls. NEVER respond with only text. | ||
| You are a coding agent. When the user asks you to build, fix, or change code, ACT with tools in the same response instead of describing what you would do. |
There was a problem hiding this comment.
Change requested (scope): this codeAgentDirective rework — and the paired SKILL.md edit — is an LLM-behavior change, unrelated to the dashboard-feedback / Windows-daemon-stop bugs this PR is about, and it is not mentioned in the PR summary. The fix itself is sound and well-reasoned (the old unconditional "every response MUST include tool calls" left a plain "Hi" no legal move, so the model saved its greeting to a file — scoping the mandate to coding work and carving out conversation is the right call). But please lift it into its own PR: a system-prompt behavioral change deserves independent review and a clean revert path, and ideally a small eval demonstrating greeting→text vs the old greeting→file-write. If it truly must ride along, at minimum call it out in the description.
| // channel, so sharing the pointer would let a later mutation rewrite an | ||
| // already-queued event — a "stopping" event would serialize as "stopped". | ||
| func (pm *ProcessManager) broadcastStatus(info *AgentInfo) { | ||
| snapshot := *info |
There was a problem hiding this comment.
Change requested (document the assumption): snapshot := *info is a shallow copy. It correctly fixes the bug because the fields mutated between broadcasts (Status/Port/Error) are scalars, so the copy fully decouples them — that is exactly right. The subtlety is that AgentInfo's slice fields (Tools/Channels/Skills) still share the caller's backing arrays, so this only stays correct as long as those slices are reassigned at load and never mutated in place after a broadcast. Please add a one-line comment here recording that assumption, so a future in-place slice mutation doesn't silently reintroduce the aliasing for those fields.
Clicking Stop on the dashboard left the card reading running with a live
Stop button until the stop finished, then repainted it wrong — metadata
tags blanked, a stale port still showing — so only a manual refresh gave a
correct card back. Three defects stacked:
- StateStopping was never assigned anywhere. The card already renders it
(status dot, spinner, disabled buttons via isBusy), so the UI was waiting
on a signal the backend never sent.
- The only broadcast happened AFTER the blocking `forge serve stop`, and
handleStopAgent is synchronous, so nothing reached the browser until the
child exited. Announce before cmd.Run() instead: handleSSE runs on its own
goroutine and delivers while Stop is still blocked.
- Stop broadcast a stub &AgentInfo{ID, Directory, Status}. A partial struct
is not a partial JSON object — version/framework/model/tools/channels/
skills have no omitempty and marshal as zero values, which the dashboard's
object-spread merge wrote over the real values, while `port,omitempty` was
omitted and left the stale running port in place. Stop now takes the full
*AgentInfo so every event carries a complete record, and clears Port.
Also route all six broadcasts through broadcastStatus, which sends a
snapshot rather than the pointer: SSEBroker queues events and handleSSE
marshals them on drain, so sharing the live pointer let a later mutation
rewrite an already-queued event — the stopping event would serialize as
stopped. Start had the same latent aliasing.
A failed stop now rolls status/port back and attaches stderr, so the card
can't wedge on stopping with both buttons disabled. handleStop also flips
optimistically on click, since Broadcast drops events on a full buffer and
the click shouldn't depend on the stream.
Tests: new TestProcessManagerStopBroadcastsStoppingFirst asserts the first
event is stopping, carries full metadata, and is not the caller's pointer;
TestProcessManagerStopExecError gains rollback assertions.
`forge serve stop` reported failure on Windows even though the daemon was already dead, dumping `sending SIGKILL: TerminateProcess: Access is denied` plus the whole cobra help text into the dashboard. IsAlive used OpenProcess success as the liveness test, but Windows keeps a terminated process's kernel object alive as long as any handle to it stays open (so callers can still read its exit code), and OpenProcess succeeds against that object. serveStopRun holds exactly such a handle — from os.FindProcess, needed to do the killing — for the whole function, so the stop path pinned its own dead child and then asked whether it was running: 1. sendTermSignal -> proc.Kill(), daemon actually dies 2. IsAlive keeps reporting alive: the held handle keeps the object resolvable 3. full 10s poll loop burns 4. sendKillSignal on the dead process -> ERROR_ACCESS_DENIED 5. non-zero exit; the stop succeeded but reported failure Open the handle with SYNCHRONIZE as well and wait on it with a zero timeout instead. A process handle becomes signaled exactly when the process exits, which is unambiguous: WAIT_TIMEOUT means still running, signaled means gone. GetExitCodeProcess was the other option but its STILL_ACTIVE sentinel (259) collides with a genuine exit code of 259. Also in forge-cli: - serve stop/status/logs were missing SilenceUsage, so any error dumped flag help after it (serve and start already had it). - A kill that fails because the target is already gone is now a successful stop, closing the race between the liveness check and the kill. Correct on Unix too, where killing a dead pid returns ESRCH. TestIsAlive_ChildAfterExit could not catch this: cmd.Wait() closes Go's handle, releasing the object before the check runs. The new TestIsAlive_FalseWhileHandleHeld holds a handle across the kill, and fails against the old implementation. Windows-only (//go:build windows); process_unix.go is untouched and Unix keeps its Signal(0) probe.
…atus Addresses review feedback: - broadcastStatus: spell out that *info is a SHALLOW copy. It decouples the scalar fields Start/Stop mutate (Status, Port, Error), but Tools, Channels and DeniedChannels still alias the caller's backing arrays and StartedAt stays a shared pointer. That is only safe because those are populated once by Scanner.scanDir and read-only afterwards; mutating a slice element in place would reintroduce the aliasing bug, so deep-copy if that changes. - handleStop rolled back to a hardcoded 'running', which mislabels an agent that was in some other state (e.g. already errored) for the window before the authoritative agent_status event lands. Capture the real prior status and restore that, mirroring ProcessManager.Stop's rollback-to-previous. Read from the rendered `agents` rather than inside the setAgents updater: the updater is not guaranteed to have run by the time the request settles, and assigning to a closure variable from it would be an impure reducer. handleStop therefore takes `agents` as a dependency, matching handleStart. Kept the optimistic rollback rather than leaning solely on SSE, since SSEBroker.Broadcast drops events when a subscriber's buffer is full — the case the optimistic update exists to cover. The code-agent prompt change (runner.go + code-agent/SKILL.md) has been split out of this branch onto fix/code-agent-prompt, as requested.
1994728 to
78c9faa
Compare
Type of Change
Summary
stoppingstate — the card fully renders that state(dot, spinner, disabled buttons) but nothing ever assigned it, and the
one broadcast fired after the blocking stop rather than before.
Go's zero values (
"",0,null) overwrote the real model/tools/channels tags, while
port,omitemptywas omitted and left a staleport. Only a refresh gave a correct card.
that
handleSSEmarshals on drain, so a later mutation meant thestoppingevent would serialize asstopped.forge serve stopfailed on Windows even when it succeeded —IsAlivetreated "can I open a handle?" as liveness, but the stop path holds a
handle itself, which is exactly what keeps a dead process resolvable. It
killed the daemon, then spent 10s convinced it was alive, then failed
trying to kill it twice. Previously invisible: the UI discarded the error.
useStatesurvives a propchange and
ChatPagewasn't keyed, so the transcript,sessionId(wouldpost the wrong session) and
streaming(left the new input dead) allstayed behind.
EventSourcedispatches byevent name and only
agent_statushad a listener, soagent_createdwassilently discarded.
Description
Four dashboard bugs, all variations on "the UI never learns what happened".
The last one turned out to be a Windows process bug that the first fix
merely made visible.
1. Stop showed no
stoppingstate, and repainted the card wrong(
forge-ui/process.go,handlers.go)StateStoppingwas defined and fully rendered by the card (status dot,spinner,
isBusybutton disabling) but never assigned anywhere. Theonly broadcast also happened after the blocking
forge serve stop, andhandleStopAgentis synchronous, so nothing reached the browser until thechild exited.
That broadcast then sent a stub
&AgentInfo{ID, Directory, Status}. Apartial struct is not a partial JSON object —
version/framework/model/tools/channels/skillshave noomitemptyand marshal as zerovalues, which the dashboard's object-spread merge wrote over the real
values, while
port,omitemptywas omitted and left the stale runningport in place. Hence "only a refresh gives a correct card".
Stopnow takes the full*AgentInfo, announcesstoppingbeforecmd.Run()(SSE is served on its own goroutine, so it delivers while Stopis still blocked), clears
Port, and rolls status/port back with thestderr message on failure so the card can't wedge on
stoppingwith bothbuttons disabled.
All six broadcasts now go through
broadcastStatus, which sends asnapshot rather than the pointer:
SSEBrokerqueues events andhandleSSEmarshals on drain, so sharing the live pointer let a latermutation rewrite an already-queued event — the
stoppingevent wouldserialize as
stopped.Starthad the same latent aliasing.2.
forge serve stopfailed on Windows even when it succeeded(
forge-core/util/process/process_windows.go,forge-cli/cmd/serve.go)Surfaced by fix 1 — previously the UI caught this and discarded it, so it
had been failing silently. It reported
sending SIGKILL: TerminateProcess: Access is deniedplus the whole cobrahelp text.
IsAliveusedOpenProcesssuccess as the liveness test, but Windowskeeps a terminated process's kernel object alive while any handle to it
stays open (so callers can read its exit code), and
OpenProcesssucceedsagainst that object.
serveStopRunholds exactly such a handle — fromos.FindProcess, needed to do the killing — for the whole function, so thestop path pinned its own dead child and then asked whether it was running:
sendTermSignal→proc.Kill(), daemon actually diesIsAlivekeeps reporting alive: the held handle keeps the object resolvablesendKillSignalon the dead process →ERROR_ACCESS_DENIEDNow opens with
SYNCHRONIZEand waits on the handle with a zero timeout. Aprocess handle becomes signaled exactly when the process exits, which is
unambiguous. (
GetExitCodeProcesswas the alternative, but itsSTILL_ACTIVEsentinel is 259 and collides with a genuine exit code of259.)
Also:
serve stop/status/logswere missingSilenceUsage(serveandstartalready had it), and a kill that fails because the target isalready gone now counts as a successful stop — correct on Unix too, where
killing a dead pid returns
ESRCH.Windows-only (
//go:build windows).process_unix.gois untouched andUnix keeps its
Signal(0)probe. Theserve.gochanges reach allplatforms but are either cosmetic (
SilenceUsage) or inside the errorbranch only, so the normal Unix path is unchanged.
3. Switching agents kept the previous agent's chat (
static/app.js)useChatStreamholdsmessages/sessionId/streaminginuseState.Preact reuses the mounted
ChatPageon anagentIdchange, anduseStatesurvives a prop change — so the header (prop) and session list
(
agentId-keyed effect) re-rendered correctly while the transcript didnot.
Worse than cosmetic:
sessionIdwas stale too, so the first message to thenew agent would post the previous agent's session id; and stale
streamingleft the new agent's input dead while the old agent'ssetMessagescalls appended into it.Fixed by keying
ChatPageon the agent id, which resets all of it at once,plus an unmount abort so the previous stream doesn't keep running into a
discarded component.
4. A newly created agent needed a refresh to appear (
static/app.js)EventSourcedispatches strictly byevent:name anduseSSEregisteredonly
agent_status, so theagent_createdeventhandleCreateAgentbroadcasts reached the browser and was silently discarded. The status
merge also deliberately ignores unknown ids, so a creation could never be
handled there either. Only the 60s poll or a manual refresh surfaced it.
Now registers an
agent_createdlistener and refetches on it — the eventcarries only
{id, directory}, not the record a card renders from.handleCreateAgentbroadcasts afterCreateFunchas written to disk, sothe refetch is guaranteed to see the new
forge.yaml.General Checklist
go test ./...)gofmt -w)golangci-lint run)go vetreports no issuesNotes on the unchecked boxes:
gofmt -wdeliberately not run. The working copy is CRLF, so itwould rewrite all 35 files in the module and bury the change. Verified
instead that the diff contains no line-ending churn and that
gofmtreports nothing beyond line endings on the touched files. Please confirm
in CI.
golangci-lintnot installed locally, so it hasn't been run.environmental (Unix permission bits, CRLF fixtures) and were confirmed to
fail on a clean tree with the changes stashed:
forge-uiTestForgeSkillBuilderMD_MirrorsPrompt,uiconfigTestSetEnvFileValue_*/TestLoad_UserFallbackWhenNoWorkspace,forge-cli/cmdTestAuthMintToken_StoresWithCorrectPermissions/TestDeriveEgressDomains/TestMCPLogout_DeletesTokens.platform isolation was verified by build tags and diff review.
Tests added
TestProcessManagerStopBroadcastsStoppingFirst— asserts the first eventis
stopping, carries full metadata, and is not the caller's pointerTestProcessManagerStopExecError— gains rollback assertionsTestIsAlive_FalseWhileHandleHeld— holds a handle across the kill andfails against the old implementation (verified both ways). The
existing
TestIsAlive_ChildAfterExitcould not catch this becausecmd.Wait()closes Go's handle, releasing the object before the checkruns.
Frontend changes are manually verified in the dashboard; there is no JS
test harness in the repo.
Related Issues