Skip to content

fix: dogfood-blocker bundle — workdir precedence, never-sleep-mid-run, truthful vision timeout#163

Merged
gorajing merged 14 commits into
mainfrom
fix/dogfood-blockers
Jul 6, 2026
Merged

fix: dogfood-blocker bundle — workdir precedence, never-sleep-mid-run, truthful vision timeout#163
gorajing merged 14 commits into
mainfrom
fix/dogfood-blockers

Conversation

@gorajing

@gorajing gorajing commented Jul 6, 2026

Copy link
Copy Markdown
Owner

What & why

The three product bugs from the first founder dogfooding session (2026-07-05), each TDD:

  1. RORO_WORKDIR precedence reversed — the persisted in-app project choice now wins over the env var (a stale shell var silently retargeted the executor). Env still applies when no choice exists; RORO_ALLOW_CWD unchanged; one [config] log names which source won (test-pinned). Corrupt config.json no longer bricks startup (previously an unhandled rejection with no window when env was unset) — quarantined to config.json.corrupt, first-run picker recovers.
  2. The cat never sleeps mid-runsetBusy wired into the floating run lifecycle (run.started → true; completed/failed/universal runEnd → false; the centralized collapse turn-exit also clears, so the turnRun IPC-reject path can't leak busy). Sleep gate extracted to a pure pinned helper (isSleepingPose: asleep+busy is never sleeping). Dev typedPrompt mirrors the clear.
  3. Truthful vision timeout — the 300s abort existed (feat: paw-on-the-pixel — roro points a paw at what you ask about on screen #135) but surfaced as an untyped error that always claimed the 120s default + wrong remedy. Now a typed OllamaTimeoutError carrying the bound that ACTUALLY fired, covering post-header body stalls too; non-timeout errors pass through unmasked.

Plus the settings panel's async races (found in review): a canceled picker is never a "project change" (incl. env-launch + click-before-config races), and config applies are epoch-sequenced so a stale refresh can't repaint over a pick or a fail-loud error.

Review

5-hat adversarial panel (all confirmed findings fixed) + codex max-effort 5 rounds — round 1 the cancel-misclassification, rounds 2–4 successively deeper async races (each RED-first), round 5 residue = two doc sentences (fixed).

Gate

tsc clean · 1128 passed / 12 skipped (+16 tests over baseline) · eslint 0 errors.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Vsq543S6voPTUsGsEqZvnD

gorajing and others added 14 commits July 6, 2026 00:29
First dogfooding session: the founder chose ~/Code/workforroro in-app, but a
stale shell RORO_WORKDIR silently retargeted the executor to another repo.
Reverse the precedence deliberately — persisted user choice > explicit env >
RORO_ALLOW_CWD=1 cwd opt-in — and make any present-but-ignored (or applied)
env override VISIBLE with one clear main-process [config] log line naming
which source won and why.

- workdir.ts / configStore.ts: the reversal + the visibility logging; a
  corrupt config.json is NOT a persisted choice, so the env recovery path
  there is kept (now with a loud warn).
- persistWorkdirChoice: choosing a project in-app now takes effect
  immediately even on an env-sourced launch (previously env kept winning).
- projectSettings: drop the "This launch uses RORO_WORKDIR. Unset it..."
  block — Change Project is meaningful on every launch now.
- Pinned tests flipped deliberately (workdir/configStore/ipc.config/
  projectSettings), each with a comment citing the dogfooding incident;
  added env-only and choose-under-env coverage.
- smokes: smoke-memory-panel-rendered + smoke-floating-live-turn set
  RORO_WORKDIR against the shared dev userData — give them a disposable
  HOME so a persisted config.json on the dev machine can't shadow them.
  (Packaged smokes already delete env + write config.json — unaffected.)
- docs: sync the RORO_WORKDIR one-liners (RUN.md, PUBLIC.md, .env.example).

Claude-Session: https://claude.ai/code/session_01Vsq543S6voPTUsGsEqZvnD
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d-task

CharacterDriver.setBusy existed but only the dev typedPrompt path called it;
in FLOATING mode nothing set busy, so during a long codex run the 120s
presence timer put the cat to SLEEP and framePolicy throttled to 6fps — the
pet looked dead exactly while working (first dogfooding session).

Wire it in the floating run-lifecycle seam that already observes the push
stream (floatingAsk's handleActionEvent/handleRunEnd — the same events
runLifecycle reduces; no new event channel): setBusy(true) on run.started,
setBusy(false) on run.completed/run.failed (mirroring typedPrompt) AND on
the universal runEnd, so busy can never outlive the turn (e.g. a
watchdog-ended run whose terminal event missed the turn filter). The avatar
side already honors busy — actionForTick skips 'sleeping' and framePolicy
runs full-rate while busy — only the wiring was missing.

jsdom tests (failing before the fix): floating run.started -> setBusy(true);
runEnd / run.completed / run.failed -> setBusy(false); submit alone does not
touch busy.

Claude-Session: https://claude.ai/code/session_01Vsq543S6voPTUsGsEqZvnD
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… silent hang

Dogfooding blocker: a hung vision daemon call must fail the capture_screen
turn loudly, never hang it. Ground truth on main: the aborting per-call bound
already landed with the paw-on-the-pixel merge (#135) — visionTimeoutMs() =
OLLAMA_VISION_TIMEOUT_MS, generous 300s default (vision is SLOW: 40-150s per
call is normal), threaded into AbortSignal.timeout for describeScreen AND
groundTarget, and the decision router already turns vision errors into
run.failed. What was still broken against the intended spec (HANDOFF's
vision-timeout follow-up note): the surfaced error was an UNTYPED generic
Error whose message always claimed the 120s OLLAMA_TIMEOUT_MS default and its
remedy — actively misdiagnosing a 300s vision abort.

- ollama.ts: new typed OllamaTimeoutError carrying the bound that ACTUALLY
  fired (per-call override or default); fetchOllama/ollamaTags throw it on a
  TimeoutError; message names both env remedies (OLLAMA_TIMEOUT_MS; vision:
  OLLAMA_VISION_TIMEOUT_MS). Unreachable-daemon copy unchanged.
- brain/index.ts: visionTimeoutMs exported so the generous 300s default and
  the env override stay pinned by test.
- Failing-first tests (aborted-fetch stub honoring AbortSignal): typed error
  + effective-bound reporting at the ollamaChat level; describeScreen and
  groundTarget abort at the VISION bound (not the chat default), typed; the
  300s default + garbage-override fallback. Router -> run.failed was already
  pinned (orchestrator.captureScreen/locate tests).
- .env.example: document OLLAMA_VISION_TIMEOUT_MS.

Kept the existing OLLAMA_VISION_TIMEOUT_MS name (the OLLAMA_TIMEOUT_MS
convention, already documented) rather than introducing RORO_VISION_TIMEOUT_MS.

Claude-Session: https://claude.ai/code/session_01Vsq543S6voPTUsGsEqZvnD
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…llback logs

Panel findings 1, 5, 6 (dogfood-blockers review):

- [should-fix] ConfigCorruptError is now treated as no-persisted-choice
  UNCONDITIONALLY: main.ts awaits hydrateWorkdirConfig before createWindow
  with no catch, so the old no-env rethrow left a packaged user with a
  truncated config.json NO window and no recovery. We warn loudly,
  quarantine the evidence as config.json.corrupt (best-effort rename,
  never throws), and fall through to env/'unset' so the first-run picker
  recovers. Every other error class still propagates.
- [nit] The env-fallback log now distinguishes a persisted choice whose
  directory is GONE (names the stale path) from never-chose-a-project.
- [nit] The 'persisted user choice wins; ignoring RORO_WORKDIR' log line
  is now pinned by a console.log spy — it IS the never-silently-retarget
  guarantee, and deleting it previously passed every test.

TDD: corrupt+no-env → {source:'unset'} + quarantine, and the stale-path
log test were RED before the fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… + typed release

Panel findings 2, 4 (dogfood-blockers review):

- [should-fix] floatingAsk: run.started → turnRun IPC-reject left busy
  true forever (the ask left 'tasked', so eventBelongsToFloatingTurn
  filtered every later terminal event). The centralized 'collapse'
  turn-exit effect now clears busy, covering the IPC-reject AND the
  bridge-missing recovery; handleRunEnd's clear becomes
  redundant-but-harmless.
- [nit] typedPrompt (dev): releaseTypedTurn now mirrors the same
  universal clear — runEnd (and the turnRun-reject release) can arrive
  without a terminal run event passing the turn filter, the exact
  watchdog gap floatingAsk guards.

TDD: both tests (rejected turnRun after run.started → busy false; typed
runEnd → busy false) were RED before the fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… TimeoutError

Panel finding 3 (dogfood-blockers review, nit): the OllamaTimeoutError
translation only wrapped fetch(); a daemon that sends headers then
wedges surfaced the abort from res.json() (the stream:false path both
vision calls use) or reader.read() as an untyped 'TimeoutError' with no
bound and no remedy. fetchOllama now threads the effective timeout out
alongside the Response, and both body-consumption sites translate a
timeout abort (TimeoutError, or AbortError with a timeout cause) into
OllamaTimeoutError carrying the bound that actually fired. Any other
body failure propagates untouched.

Tests: post-header stall on res.json and on reader.read → typed error
with the per-call bound (RED before); non-timeout body failure stays
unmasked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…helper

Panel finding 7 (dogfood-blockers review, nit): 'busy cat never sleeps'
was pinned at the callers + framePolicy, but the actual pose gate
(actionForTick's first branch) was untested. Pure extraction, zero
behavior change: isSleepingPose(energy, busy, inCall) lives next to
framePolicy and is pinned — asleep-energy + busy (or in-call) is NOT
'sleeping'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…env → cwd)

Panel finding 8 (dogfood-blockers review, should-fix): HANDOFF.md's
state-map line and README.md still described the pre-reversal order
(explicit RORO_WORKDIR first). Both now read: persisted in-app choice →
RORO_WORKDIR env → RORO_ALLOW_CWD=1 cwd (dev). Also reworded README's
.env sample comment ('Optional dev override') to the fallback framing
RUN.md already uses. Swept every other RORO_WORKDIR mention in *.md —
RUN.md/PUBLIC.md already state the saved-project-wins order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ound 1)

config:chooseWorkdir returns the CURRENT effective config on cancel — on an env-sourced launch
that is {workdir, source:'env'}, and with a saved project it is the same config back — but the
handler treated any truthy workdir as success, broadcasting a fake WORKDIR_CONFIGURED_EVENT and
claiming 'Project changed'. A real change now requires a persisted choice (source==='config')
for a DIFFERENT workdir than the panel knew before the picker opened. TDD: two RED tests
(cancel-on-env, cancel-with-saved-project) now green; the real-change test unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vsq543S6voPTUsGsEqZvnD
…he click-before-config race (codex S-B round 2)

If Change was clicked before the initial async getConfig resolved, knownWorkdir was still
undefined and a cancel returning the existing saved config read as a real change. The handler
now establishes 'before' from getConfig when unknown (best-effort; failure falls through with
undefined). RED race test (deferred getConfig + immediate click + cancel) now green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vsq543S6voPTUsGsEqZvnD
…ver overwrite a pick (codex S-B round 3)

refresh() applied any outstanding getConfig() result unsequenced: a slow initial refresh
resolving after a successful pick repainted the OLD project over the new one (and corrupted
knownWorkdir for the next cancel check). Authoritative applies (the picker result, the
WORKDIR_CONFIGURED broadcast) now bump an epoch; in-flight refreshes only paint if still
current. RED race test (stale initial refresh resolving after a pick) now green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vsq543S6voPTUsGsEqZvnD
…-B round 4 — cap)

The error path's showError didn't bump the epoch, so a late in-flight refresh could repaint
stale config over the fail-loud 'Project change failed' state. One-line epoch bump + a RED
race test. Completes the sequencing model: every authoritative paint (pick, broadcast,
failure) invalidates in-flight refreshes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vsq543S6voPTUsGsEqZvnD
…(codex S-B round 5 residue)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vsq543S6voPTUsGsEqZvnD
@gorajing
gorajing merged commit 3360f1b into main Jul 6, 2026
3 checks passed
@gorajing
gorajing deleted the fix/dogfood-blockers branch July 6, 2026 10:23
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