fix: dogfood-blocker bundle — workdir precedence, never-sleep-mid-run, truthful vision timeout#163
Merged
Conversation
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
# Conflicts: # RUN.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
The three product bugs from the first founder dogfooding session (2026-07-05), each TDD:
RORO_WORKDIRprecedence 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_CWDunchanged; one[config]log names which source won (test-pinned). Corruptconfig.jsonno longer bricks startup (previously an unhandled rejection with no window when env was unset) — quarantined toconfig.json.corrupt, first-run picker recovers.setBusywired 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.OllamaTimeoutErrorcarrying 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