Skip to content

fix: an open dashboard tab must stay a watcher, or a headless run hangs - #46

Merged
linhdmn merged 1 commit into
mainfrom
fix/watcher-ttl-keepalive
Oct 7, 2026
Merged

linhdmn merged 1 commit into
mainfrom
fix/watcher-ttl-keepalive

Conversation

@linhdmn

@linhdmn linhdmn commented Oct 7, 2026

Copy link
Copy Markdown
Member

Summary

Fixes the bug that hung the first headless run of this loop: an open dashboard tab was a watcher only 60% of the time, so an ask raised in the gap was delegated to the composer panel — which does not exist on a headless run, so the turn hung until the answer timeout.

The measurement

A real headless run on 2026-10-07 (harness 0.2.0-rc.2, fl-live profile, plugin v0.4.3) reached step 1, called bash, and stopped:

  • the session log had approval/asked for that bash call,
  • lsof showed the SSE connection to :8121 ESTABLISHED,
  • and GET /api/state reported pending: [] for 10+ minutes, with every settle POST answering 409 no such pending approval.

That is the claim never being made. Reading the code: openSse calls noteWatcher() once on connect, then setInterval(… , KEEPALIVE_MS) writes : ping every 25s — without calling noteWatcher again — against WATCHER_TTL_MS = 15_000. So the tab was a watcher for 15s out of every 25s, and createApprovalRegistry's if (!hasWatcher()) return next() handed the ask to the composer.

The fix

  • The ping is now the heartbeat it was always meant to be: the keep-alive handler calls noteWatcher().
  • SSE_KEEPALIVE_MS moves out of src/dashboard.ts into src/watcher-ttl.ts, beside the TTL it must stay under, so the interval cannot drift outside the window it refreshes.
  • WATCHER_TTL_MS 15s → 60s, which clears the 25s ping with room for a slow remote read.

Verification

  • The new test fails on the old code — revert only the noteWatcher() call in the keep-alive handler and test/dashboard.test.ts fails on not ok 41; set the TTL to 25s and it fails on the arithmetic (the SSE ping every 25000ms cannot keep a 25000ms watcher TTL alive). Both confirmed, then restored.

  • Full suite: 820 pass / 0 fail / 1 skipped (821 tests); tsc --noEmit clean; bash .githooks/pre-commit --all → security scan ok (all).

  • End-to-end on a real headless run against this build: the same profile, plugin installed from this worktree, headless Chrome, dashboard as the approval channel. The first ask appeared in pending and settled 200 within ~5s of being raised — 19 claims, zero 409s — and the run walked the whole spine:

    Phase Outcome Steps Cost
    research passed 15 $0.0478
    prd passed 1 $0.0203
    implement passed 3 $0.0309
    test passed 3 $0.0138
    ship passed 1 $0.0091

    outcome: goal-met, 24 steps, $0.1264. The test phase passed after two test → implement bounces, and ship produced a local commit (af0c144, no remote configured — the workspace is a scratch repo on purpose).

Not claimed

The run's own verify.sh gate passes (OK: landing page present, uses assistant-ui), but next build fails on the artifact it produced: app/layout.tsx imports ./globals.css and no CSS file was ever written. That is a model defect the gate did not catch, not a plugin bug — the gate asserts the page exists and uses assistant-ui, which it does. Widening the gate is a separate change and is not in this PR.

The standalone server registered a watcher when an SSE client connected and
then pinged every 25s, but the ping never refreshed the flag — against a 15s
TTL that left an open tab a watcher 60% of the time. An ask raised in the 10s
gap was delegated to the composer panel, and with no UI tab attached there is
no panel: the turn hung until the answer timeout and the run never left
`research`.

Measured on a real headless run (2026-10-07) against a live plugin: the ask
`approval/asked` was in the session log and the SSE connection was
ESTABLISHED, while `GET /api/state` reported `pending: []` for 10+ minutes
and every settle POST answered 409.

The ping is now the heartbeat it was always meant to be — it calls
`noteWatcher()` — and `SSE_KEEPALIVE_MS` moves into `src/watcher-ttl.ts`
beside the TTL it must stay under, so the interval cannot drift outside the
window it refreshes. TTL 15s -> 60s, which clears the 25s ping with room for a
slow read.

Verified: the new test fails on the old code (no `noteWatcher` in the handler)
and on a 25s TTL (the arithmetic), and passes on the fix; full suite 820 pass /
0 fail; `tsc --noEmit` clean.
@linhdmn
linhdmn merged commit b4c3f44 into main Oct 7, 2026
3 checks passed
@linhdmn
linhdmn deleted the fix/watcher-ttl-keepalive branch October 7, 2026 09:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant