Files
craft-anything/docs/pitfalls.md

44 KiB
Raw Permalink Blame History

Pitfalls

Mistakes made while building this project, what they cost, and the rule that prevents each one. Read this before your first change; add to it when you make a new mistake worth not repeating. Keep entries short: what happened, then the rule.

Git and pull requests

A commit landed on main without a PR. git switch main && git pull was run with uncommitted changes: the switch succeeded, the pull refused, and the failure was read as "still on my branch". The next commit and git push --force-with-lease went to main.

  • Check git branch --show-current before every commit and push.
  • Push by name (git push origin <branch>), never a bare git push after switching around.
  • Commit or stash before switching branches; read every line of a failed compound command.
  • Maintainers: protect main (PRs required, squash only) so this can't happen.

A PR merged with only its claim. One shell command did commit, push, gh pr create and gh pr merge; the pre-commit hook failed (it ran cargo xtask secrets on an older default Rust), the && chain stopped, but the next lines still ran, so #138 merged the claim commit under the work's title. Run commit, push, create and merge as separate steps and read each result; run the hook with the project's toolchain (RUSTUP_TOOLCHAIN=1.99.0 git commit …).

A merge of commits no check ran on. A script waited for one CI run of a PR, then merged whatever the PR's head was by then; its author had pushed two more commits meanwhile, so #141 reached main with code neither CI nor review had seen. Merge with --match-head-commit set to the commit the green run tested (gh run view <id> --json headSha), never to a head read later.

A squash commit names the wrong PR number. The subject was written as "(#12)" before the PR existed; it became #11. Take the number from gh pr create's output (${URL##*/}) and use it.

Two PRs pass alone and break main together. One tightened cargo xtask layers, the other added an app the new rule rejected; both were green on their own branch.

  • Before merging a PR that was rebased before another merge, build and test the merged result: git merge-tree --write-tree origin/main origin/<branch> for conflicts, then a temporary branch with the merge, cargo test --workspace, clippy and cargo xtask layers.
  • After merging several PRs in a row, run the full checks on main once more.
  • It happened again with translations: #117 added UI strings and #115, merged right after it, added a check that every string has a Chinese translation, so cargo xtask ci failed on main. A PR that adds a check, or adds what a check covers, is tested on top of the other. And again with a new catalog: #555 added "Pausing…" to every catalog there was, and #486 (Russian) merged after it without it. A new language and a new UI string merged together need the string in the new catalog too.

A dependency change merged without its lockfile. Changes were staged with git add -A crates apps docs, which leaves out the root Cargo.lock, so a new dependency reached main without its lock entry. Stage by git add -A (then review git status), or list Cargo.lock whenever a Cargo.toml changed.

A superseded PR on someone else's branch. Rebasing it means force-pushing their branch. Replay it on a new branch, open a new PR that says it supersedes the old one, and comment on the old one. Don't close or rewrite other people's work without asking.

Tests

A test started a real download on a contributor's machine. A test called engines_install for Unity; a later change made that download the vendor installer and open it on Windows. It also created folders in the real config directory (Session::default() uses it).

  • Tests never touch the network, never start installers or GUI programs, and never write outside a temp folder. Use ids that are refused before any I/O, or local stand-in servers.
  • When you widen what a tool does (a new download, a launch), grep the tests that call it.
  • Tests build their session with craft_anything_engine::testing::session() (config, plugin caches, engine store and projects in a temp folder); cargo xtask layers rejects Session::default(). CLI tests set CRAFT_ANYTHING_CONFIG_DIR to a temp folder.

A demo app wired to the person's settings. The headless app the UI tests use built its AI hub with AiHub::new, which saves to the real config.toml and the system keychain; no test had added or removed an AI yet, so nothing noticed. Everything a test app can save goes to a temp folder (AiHub::with_stores), not only the engine's session.

A test that passes only where a program is installed. A tool's Requires::Engine is checked before it runs, so a test of its input checks passed on a machine with ilspycmd and failed on one without. Point the test session's engines.tools path at a stand-in file when the tool refuses before running anything, and run new tests on a machine without the program. It happened again when Clean room began refusing tools before their program is looked for: a "missing adb" test returned early on a machine with adb and failed in CI without it. A test of what happens without a program builds its session in the mode the tool runs in. It happened a third time with Ghidra: the test session's engine store was empty, but the lookup still searched PATH, ~/.local/bin and /opt/ghidra, so two Deep-mode tests got Ghidra's answer on a machine that had it. testing::session() now sets engines.only_configured, so it finds only the engines its settings or store name.

A failing crate hid the next crate's tests. cargo test -p a -p b stopped after a failure in a, so b's tests never ran; the rerun was filtered to the failing test, and a broken engine id reached main. Run with --no-fail-fast, and after a fix rerun the whole crates, not a filter.

A test that changes the whole process. A UI test raised the process-wide stuck-lock banner; in CI the banner moved the layout under another test running beside it, and its click missed. It passed 25 runs on a Mac. Anything a test sets for the whole process goes in a test program of its own.

Fixtures that leak into each other. A "nothing here" path sat beside a fake game folder in the same temp directory, and game detection walked up and found the game. Give each case its own tempfile::tempdir().

Proving a refactor changed nothing. Unit tests that check substrings can miss changed prompts. For a refactor that must keep output identical, dump the outputs for every combination before and after and compare them byte for byte (done for every brief across 162 combinations in R1.1).

Building and running

A model catalogue queried in the wrong project. OpenCode V2's raw api get /api/model does not reliably select the invoking CLI's directory. Use the model.list operation with an explicit location[directory] parameter, and test that the returned Location matches it. Over SSH, expand the remote shell's working directory, never send a local project path.

Rebuilding a binary that is running fails on Windows ("Access is denied"), and the next command silently used the old binary. Long-running binaries (live rebuilds, servers) get their own CARGO_TARGET_DIR.

A program started from a screen outlived the tests. The Ghidra memory setting first asked PowerShell for the computer's memory on a background thread while the screen drew; in UI tests the test process ended first, the orphaned PowerShell kept the test runner's output pipe open, and cargo test | grep never returned. Screens never start programs, not even "in the background"; read a setting or a value a worker already has.

Asking a process to stop isn't stopping it. RunningProcess::kill() only raises a flag for the supervisor thread; the window-capture tool returned and the CLI exited before the thread acted, so the program it opened stayed open (and held the shell's pipe, so the command never returned). When the caller may exit next, use kill_and_wait, and check after a live run that nothing was left running.

The newest program isn't the app. Play started the newest executable in target/; a workspace that builds an app and its CLI started the CLI, which printed help and quit with no console, so the button did nothing, three times in a row. Pick what to start from what the project declares (Cargo manifests: default-run, the package using a window library), never from file dates, and make a start that exits at once say so.

A launcher's exit read as the program's. On some Windows machines chrome.exe hands the browser to a second process and exits; the supervisor took that exit as the run ending and stopped the whole group, so websites never opened ("closed after 0.3 s"). Where it didn't hand off, the browser outlived the CLI (the job isn't killed when closed) and held the shell's output pipe, so the command hung and old browsers piled up. Start programs that hand off with .hands_off(), and after a live run check that the command returned and nothing it started is still running.

A separate config folder hides the agent's own settings. To keep a live run from touching the real config.toml, XDG_CONFIG_HOME was pointed at a scratch folder; the agent CLI we start reads the same variable, lost its own provider settings, and failed with "unsupported provider". Back up config.toml and restore it after the run instead, and leave the agent's environment as it is.

A renamed CLI can't start the builder's tools. To keep a live run's binary from being locked, it was copied as cli-r25.exe; the CLI finds itself by name (craft-anything-cli) to start the builder's MCP server, so Claude Code saw no tools and stopped. Copy it under its own name into a folder of its own. It happened again in a release: the macOS app bundle had only craft-anything, with the CLI in a separate zip, so every Claude Code build from the 0.2.0 DMG failed the same way (#91). Every package that ships the app ships the CLI beside its executable, and the packaging script runs the CLI from the finished package before it's published. Its symptom misled people too: Claude Code's MCP log says Executable not found in $PATH: "stdio" for any command that won't start, so #64 went looking for a bad config. Reproduce with the real program before believing what a log's wording suggests.

A Windows path with forward slashes read as nothing. rtl_433 decoded a recording in the shell but found no messages, and exited 0, when the test passed C:/…/x.cu8; it only reads Windows paths with backslashes. Hand external programs native paths (\ on Windows), and give each external tool a live test that checks it really produced output, not just that it exited 0. It happened again with Explorer: project folders are kept as D:/Projects/…\name, and "Open folder" opened Documents. Paths for the system's programs go through platform::native_path.

A message on the command line. Claude Code was handed the whole task as -p <message>; a 34,641-character brief passed Windows' 32,767-character command-line limit, so the build never started ("The filename or extension is too long", os error 206, #402). Give a program anything that can grow (messages, briefs, lists of files) on stdin or in a file, never in argv.

Console windows popping up over the app. Only the detached launcher hid the console; the supervised runner didn't, so Ghidra's .bat and PowerShell each opened a console window during a real Deep-mode run (the CLI never shows it: it already has a console). Every child started from the windowed app gets CREATE_NO_WINDOW; check the desktop app, not only the CLI, after adding a program.

A graphics backend that crashed before it was chosen. We ranked GL last when picking an adapter, but eframe starts every backend first, and starting GL loaded an old Intel OpenGL driver that crashed the app before any window or error box (#196). Ranking can't protect you from a backend that's started at all: start only the backends the app may use. It happened again with Vulkan on Windows (#394): starting Vulkan loaded an Intel Vulkan driver (igvk64.dll) that crashed with 0xc0000005 on startup. Windows now starts DX12 only by default (which includes WARP); WGPU_BACKEND still allows choosing other backends.

A shader compiler picked by a default that looks around the computer. wgpu's DX12 Auto compiler takes a dxcompiler.dll it finds on the PATH; one without dxil.dll makes unsigned shaders, and on an RTX 5060 Ti the device was lost as the window opened ("Parent device is lost", #522). Where a library chooses a program or DLL by searching, name the one that ships with the system (FXC), and keep the environment variable (WGPU_DX12_COMPILER) as the way to another. A window that can't start now says how to draw without the graphics card (CRAFT_ANYTHING_RENDERER=software). Not run on the reporter's machine or on Windows here.

"Not installed" for a program that was there. The system answers "not found" both for a missing program and for a file whose interpreter is missing (an npm script with no node), and we turned every one into "isn't installed or isn't on your PATH": #95, #130 and #152 reinstalled what they had. When a spawn says not found, look the program up yourself; if it's there, name the file and what it needs (spawn_error_for). Not run on Windows here.

An answer with two parts, read as one. An approval card has "Allow once" and "Always for this project": the answer carries allow and always. The consent card our MCP server shows for a tool that runs code read only allow, so "Always" was dropped and the same tool (a scripted web session) was asked about every time (#563). When a card offers a choice, whoever reads the answer handles every field of it, and a test answers it each way and asks again.

A loop that only ends at its step cap. A builder on an API backend may take 400 steps in one step of the run, and each step sends the whole conversation again: one stage spent 9.4M tokens on code that never compiled (#399). A failed build command is an Ok tool result (succeeded: false), so counting tool errors misses it. Cap a loop on what it is stuck on (build commands failing in a row, read from the typed succeeded field), not only on its total steps.

A router that picks the provider for you. OpenRouter sends each request to one of several upstream providers, and a provider without tool calling answered 400 "Provider returned error" (#369), which said nothing the person could act on. For a service that routes, say in the request what the provider must support (provider.require_parameters for tools), and show the provider's own reason when it still fails (#542).

A crash that no code can report. Three Windows tickets (#33, #104, #74) said the app "does nothing": a graphics driver crashed natively as the window opened, so no dialog, panic hook or log line ever ran, and each fix only covered the driver already seen. When a failure can't be caught, leave a mark before the risky step and clear it after (start_guard.rs); a mark found at the next start is the report, and that start takes the safer path (WARP). Not run on Windows here.

A spinner that kept the window drawing at full speed. egui's Spinner asks to be redrawn at once, every frame, while it's on screen. A build or a page capture can run for an hour, so the window was redrawn at the display's rate the whole time (a core busy, or every core when drawing in software), and a person reported "my CPU is fully used" (#579). Animations ask for their next frame at their own pace (widgets::animate, widgets::Spinner); check a library widget for request_repaint before using it for something long. Not measured on Windows here.

Every tool offered to a model that can only use a few. The builder on an API backend was sent all 33-41 Core and Build tools, about 23,000-30,000 characters of descriptions. A local 14B model (#417) called the study tools (core_session_show, core_journal_read) 35 times and never build_file_write, and Ollama's default context is smaller than the list. Offer a model the tools the job needs (LOCAL_BUILDER_CORE), measure the list's size with the real registry, and run a small local model before saying it works. Not run live here.

A debugger failure reported as an observation. apple_calls_observe built a Python callback with \n between statements in a Rust string, so lldb rejected it and exited 1 before launching anything; the tool ignored the exit status and returned "Observed 0 call(s)" with confidence observed (#251). A run that never started the thing it observes is a failure, not a count of zero: check the exit status before building evidence, and compile generated code (here, with Python) before trusting it. lldb itself was not run here.

A Mac app that couldn't find Claude Code, and offered a GitHub key instead. An app opened from the Dock gets the system's short PATH, not the shell's, so claude in ~/.local/bin wasn't found; "Built by" then fell back to "Claude Code on github.com-work", an ~/.ssh/config alias for a second GitHub key, read as a computer to build on. Look programs up and start them with which::search_path (Process and Launch do), and leave git servers out of SSH hosts.

Found on Windows, but "isn't on your PATH" when run. npm installs Gemini CLI, Codex and often Claude Code as gemini.cmd-style shims; which finds them through PATHEXT, but Rust starts only .exe files by bare name, so every build failed (#18). Through cmd.exe a prompt with line breaks or over 8191 characters wouldn't arrive anyway. Process runs an npm shim's script with Node directly (which::launch_target); test a new program on Windows with a multi-line argument. npm also writes an extensionless POSIX script beside each shim, and which returned that first, so OpenCode was offered and then every run said it wasn't installed (#41): look up the kinds Windows can start (PATHEXT) before the bare name.

A first analysis that waited for a lock it could never take. Ghidra's analysis lock is created with create_new in ghidra-cache/, which only existed on machines that had used it before; on a fresh store the create failed and the loop read that as "someone else holds it" until the deadline. Create a lock's folder first, and treat any error but "already exists" as a failure.

A bridge that didn't compile. A helper in the Ghidra bridge was named like a public GhidraScript method, so Ghidra refused the script and the run only said the bridge stopped. Compile the .java against Ghidra's jars (javac -proc:none -cp <Ghidra/**/lib/*.jar>) before a live run. Ghidra also rejects project paths with a folder starting with a dot, as tempfile::tempdir()'s do: give live-test temp folders a plain prefix.

A check that read another worktree. A PR was tested in a worktree that shared the main target/; xtask finds the repository through CARGO_MANIFEST_DIR, fixed when it's built, so afterwards cargo xtask in the main tree reused that binary and read the worktree's files. Once the worktree was removed it said "can't read docs/ROADMAP.md"; while it existed, every check that reads files itself (pending, secrets, layers, locks, tools --check…) would have looked at the wrong tree (the cargo commands ci runs use the folder they're started in). Give every worktree its own CARGO_TARGET_DIR, and rebuild xtask (or delete it from target/) after sharing one.

Stopping a long run leaves children behind unless every program goes through craft_anything_platform::process. After stopping one, check that its child processes are gone.

Separate target folders fill the disk. Agents given their own CARGO_TARGET_DIR under target/ (to avoid locking a running exe) left them behind; one reached 79 GB and the disk filled mid-build ("not enough space", os error 112). Delete an agent's target folder when its work is merged, and check du -sh target/* when a build fails to write. It happened again from a commit: the pre-commit hook builds cargo xtask secrets, and in a new worktree with no CARGO_TARGET_DIR set that is a whole fresh build in its target/, which filled the disk under a person's running build. Commit with the same CARGO_TARGET_DIR you built with, and check free space (df -h) before a build next to someone else's work. And again from the main target/: its incremental cache alone had reached 46 GB (81 GB in target/debug) and the drive hit 0 bytes mid-test. Delete target/debug/incremental when it grows, and build checks with CARGO_INCREMENTAL=0.

A green PR that ran nothing. gh pr view said CLEAN/MERGEABLE, but while the repository is private every CI job is skipped, so nothing had been checked. Read the checks' conclusions, not the merge state. Without CI, run its steps locally with --locked; a Mac can compile for the other systems with cargo check --target, with zig as the C compiler for ring (a wrapper that drops the --target= flag cc adds), but that only proves it compiles.

Unformatted code reached main. A test was edited by script after cargo fmt --all had run, so #53 merged with a long line rustfmt rewraps. Run cargo fmt --all --check last, after every edit, right before committing.

Design

An early error without a terminal event. Model validation returned after reporting an error, but headless chat waits for TurnDone, not just an idle worker. Preflight failures and cancellation must finish the event sequence too; regression tests check both the error/status and the terminal event. Worker-thread creation failures need the same terminal event even though no worker ever started.

A conditional call erased the skipped path's value. The ARM decoder discarded conditions on calls, and value flow replaced the earlier register definitions as if every call executed. Keep the call's condition and merge its possible writes with the skipped path in both block transfer and edge construction. A known non-returning target stops only an unconditional call; after a conditional one, only the skipped path contributes values. Test both kinds within one block and across blocks, with unconditional calls as controls.

A candidate cap discarded a real dependency. The esbuild module reader kept the first 4096 resolved zero-argument calls, then filtered for known wrappers; ordinary local calls could fill the buffer before a real dependency appeared. Discover wrapper bindings first and count only recognised dependencies against their limit. Keep the buffer bounded, and test unrelated calls, forward declarations and more actual dependencies than the limit permits.

An occurrence label was also a binding name. The second obj.x window and the first obj["x[2]"] window both appeared as obj.x[2]. Reserve literal labels and use a separate disambiguator for collisions; never change the occurrence index to make a label unique. Include unchanged windows when reserving labels so a different set of changes cannot rename a window.

Reading literals and skipping the rest. The Electron window reader skipped a spread inside webPreferences, so a window whose preferences came partly from another object was reported as "contextIsolation is not set; the default applies", which nobody had checked until a live run on an installed app. When a reader takes literal values, anything that may add or replace them (spreads, computed keys, later writes) makes the result unknown, never the default; and check new readers on real, minified inputs, not only on the shapes the tests were written for.

A naming scheme that clients can't accept. Dotted tool ids (web.page.capture) were decided before checking that MCP clients and model APIs allow only [A-Za-z0-9_-] in tool names. Check external constraints (protocols, APIs, file systems) before fixing a public contract.

A tool planned in a layer that can't reach what it needs. Rebuild control as engine tools (L4) can't call RebuildRun (L5). Before planning a tool, check which crate owns what it calls; if a higher layer owns it, it needs a way for that layer to register tools (R1.7).

An event queue drained in the middle of a run. A scripted browser session's goto step cleared the CDP event queue to "start fresh", so every network request before and during the navigation vanished from the run's network capture. Only the code that owns a run's results drains its event queue; steps leave events where they are, and the run keeps them all, tagged with the step they arrived in.

Two writers to one file. The app and the rebuild run both wrote feed.json. Give each persisted file one owner; when another writer is unavoidable (an answer given from the CLI while a run is going), merge what's on disk before saving instead of overwriting it. The same happened in craft.json: a run saved from the copy it started with and undid a budget the person raised meanwhile (and any "always allow" answer). Fields the person owns change only through Workspace::set_person; save keeps them as they are on disk. And again inside one process: the engine had its own write_atomic whose temp file was named by process only, so two threads saving craft.json wrote the same temp file and one rename found it gone (#273). There is one atomic writer, craft_anything_platform::fs::write_atomic; don't write another. The UI settings worker also released the session guard after cloning settings, letting that stale snapshot overwrite a later model-selection save. Keep the guard through the worker's disk write so session-based writers cannot overtake each other between reading and saving. The exit-time save must merge the session's latest AI table before writing the app's pending settings; the UI's AI snapshot may predate a selection already committed by the hub. But waiting for that guard can hold shutdown behind a tool for hours. Try it once; if busy, durably defer only non-AI settings and recover them at startup against the latest committed AI table, rather than racing a live writer. After a successful full exit save, discard an older deferred snapshot so startup cannot replay it over the newer app settings.

Preflight checked a different transport. An explicit OpenCode selection bypassed ACP, but builder preflight still checked ACP's helper and the native adapter never supplied the MCP server. Share transport selection between preflight and execution; reject unsupported native options before running a process rather than starting an agent without the tools its task requires.

Keywords that only some code paths honour. "…in Rust" picked Rust for apps but not for games, so a headless game rebuild stopped at once for want of Unity. It was found only by a live run. Run the real flow end to end after changing how requests are read, and add the case as a test.

A default given as an order. "A notes app in Electron" was built in Rust: the brief quoted the request, then said "Use Rust with eframe/egui… create a cargo project" with nothing saying which wins, and the builder followed the later, more specific line; the app, the build check and Play followed the stack picked at the start too. Say that the person's words come first and phrase a default as one ("unless their request says otherwise"); let what acts on the result (checks, Play, the recorded stack) look at what was built, not at what was planned.

More than one way to build the engine. The CLI's commands listing built its own engine from default settings, so it missed the plugins (and the person's settings) every other command had. Build the engine through one function per app (make_engine in the CLI) and add new layers' tools and plugins there.

A best-effort step that hides its errors. Capturing a site's extra pages used .is_ok(), so a missing parent folder made every page silently drop out; only a live run showed "Photographing more pages" followed by nothing. When a step may fail without stopping the run, still say why (the feed, a note, a log line), and test the success path end to end, not just the picking. The fix then made the check create checks/round-N/routes/ first, which made round-N exist, and web_site_capture (which refuses an existing folder) stopped the run. Create a tool's output parent right before that tool runs, never a folder that another tool must create itself.

A size measured after drawing, used a frame late. The prompt box's height was measured each frame and used to size the feed on the next, so every time it grew or shrank one frame showed it 100 px too low, and typing looked like flicker. When a layout uses a size from the last frame, call ctx.request_discard when it changed, so egui lays the frame out again before showing it. The same layout then came out 17 points taller than the space it was given: it left out the item spacing after the feed, and ui.horizontal centred the 85-point box 12 points down in its row. The agent console's panel grows to fit its content, so it grew 17 points every frame until its maximum, and then the top of its tabs was cut off. When a part is sized to fill what's left, count the item spacing and use horizontal_top for tall rows, and test that the panel's size holds still over several frames.

A library default that blinked over a text field. egui's scroll areas fade their edge when there's more below; the prompt box's one measured a few points of overflow on the frame after each keystroke, so a dark gradient blinked over the line being typed. Unit tests look at text, not at what's painted over it. When something flickers, render the frames (Harness::render at a fractional scale) and diff them, then list the shapes painted over the spot.

A tool that locked the session it was called with. rebuild_question_ask read the settings with hub.engine.session(), but a tool runs while its call holds the session, so the CLI waited forever before starting anything (found by the first live run, not by tests, which stopped earlier with no AI set up). Inside a tool, read what you need from ctx.session and hand it on; test a tool through Engine::call with a time limit on a thread.

A forked session's cost is the session's. Claude Code's total_cost_usd for a --fork-session run is the whole session's running total ($22.67 for a question that cost cents). Don't show a cost you can't attribute to the thing it's shown beside.

An option that offered nothing new. Asked for "a shallow live copy", the pin dialog got a Live choice that copied nothing and opened the project's app, which is exactly Play, under a name that suggested something was kept. It was merged, tested and installed before anyone asked what it was for. Before adding a choice, write one sentence on what it gives that the existing path doesn't; if there's no such sentence, ask what was meant. Its replacement nearly repeated it: hard-linking dependencies saved almost nothing on real projects, because 85% of a Rust pin was a .pdb nothing needs. Measure on real data before designing around where you assume the size is.

Long content above a fixed prompt box. Answers to questions were cards between the feed and the prompt box, sized to their text; the feed got what was left, so a long answer squeezed it and pushed the box off the window. Content of any length belongs inside the scroll area. And an X on something that can't be brought back is a delete: offer folding instead, and keep the data.

A library default that hid the scroll bars. egui's thin scroll bars paint the thumb in the button fill and the track in the field colour; our theme made those the same, and the thin preset narrows the bar to 2 px at rest, so the thumb showed only on hover. When a theme sets a library's colours, check every widget that borrows them, in both themes, and test contrast with numbers (3:1 for parts you interact with), not by eye.

Files read while drawing. The Project screen parsed package.json 2–4 times a frame, listed the reference pictures and checked screenshot files on every repaint, and read the side panel's files on every frame a build event arrived; on a slow or network drive the window would freeze. Drawing reads only memory: anything from disk is read on a worker when something changes, and the screen shows the last result.

A non-blocking read that falls back to defaults. The app read the person's settings with try_session().unwrap_or_default() at startup, so a busy engine meant Clean room and no AI, and the next settings change saved those defaults over theirs. Seen once in a live run. A fallback must never stand in for data that gets saved back: wait (it's startup), or keep what's on disk. The model picker later captured a busy session as "no project", queried in the wrong directory, and rejected the save as a settings change. Capture settings and project under one guard; report contention, preserve the draft and matching background results, and check again when available.

A save that changed memory first. Adding an AI in the app put it in the session's settings, then the file write failed (on Windows, likely a virus scan holding config.toml), so the app showed the AI and its role while nothing was saved, and the error sat in the message bar behind the Settings window. Save to disk first and change memory only after it worked; replace files with a short retry on Windows (write_atomic); show errors where the person is looking.

Showing settings shows their secrets. settings_show returned the whole settings, plugins' env values included, which can hold API keys; a tool's output reaches the journal and MCP clients. The journal only redacts by field name. When a setting can hold a secret, hide its value in every tool that prints settings, and test that it doesn't appear.

Secret files kept out only by one person's gitignore. .env files and .claude/settings.local.json were ignored by a contributor's global gitignore, not the repo's, so anyone else could have committed them. Ignore secret and local files in the repo's .gitignore, and run cargo xtask secrets before every commit (the .githooks/pre-commit hook does it).

An empty float sum is -0.0. iter().map(..).sum::<f64>() over nothing gives -0.0, which {:.2} shows as "$-0.00". Sum money with fold(0.0, ..).

Docs that drift. STATUS.md described a branch 25 commits old and the tool count was stale. Update STATUS, the roadmap and generated docs (cargo xtask tools, cargo xtask schemas) in the same PR as the change.

A second lock in one statement. CliSpec { deep: !engine.session().clean_room(), logs: engine.session().config_dir(), … } looked like two quick reads, but an unbound guard lives to the end of the statement, so the second waited on the first forever. Every build froze after studying its target while the card calmly said "This is a big one", and no test ran that line. Read the session once; cargo xtask locks and platform::sync now catch this. A wait message must not reassure about what it can't see: say how long it's been since anything happened. Find a hang with sample <pid>: it names the lock.

An error that points at nothing. "Check the output below; run Claude Code by hand" came with no output below (the event carried only message and remediation) and no command, and the program that failed was really ssh, talking to a git alias from ~/.ssh/config that had been offered as a computer. Say what ran and what it printed, write the rest to a log the person can open (a failed agent run does: crate::failure_log), and never name a next step you don't spell out. Exit 255 from ssh is ssh's own failure: don't blame the remote program. Test a message by causing the failure.

A retry that took "no message" for "brand new". A run with no message and no continuing flag was read as a first build, and Try again starts exactly that run. A browser that died during the final check failed a finished build; Try again then studied the original again and gave the builder the first brief. A run decides what's left from what the project records, not from which button started it, and a check that couldn't look says nothing about the build.

A stopped tool call broke the next request. Retaining a stopped assistant reply kept its tool calls without matching results. The next model request then carried unpaired calls. Every retained call needs a matching result, including an explicit non-execution result. Test the next serialized request as well as whether a tool ran.

A deferred request reset lost on failure. Moving consumed-note bookkeeping out of the live builder loop saved the reset only after a successful turn. A failed or cancelled turn had already removed the note, so reopening kept the old unchanged-turn count. Persist deferred request state on terminal errors too, outside provider retries; keep the original outcome if that save also fails.

A filter written for one kind of text, run on another. The stand-in's secret scan was written for API answers, then also ran on recorded scripts: this.token=t became [dropped] and the scripts broke, and "Basic Membership" lost a word. It also tested whole tokens, so a secret inside a URL or after ref= passed. Test a scrubber on what must survive as well as what must go, and on each kind of content it can receive.

A strict tool that sent an AI off to write its own. game_pak_extract refused an output whose folder didn't exist and took one file per call; the extractor model hit that twice, then wrote its own pak reader in Python for a large archive. Tools an AI drives make what they can (folders), take whole sets in one call, and every brief says to fix the call rather than replace the tool. Read an AI run's transcript for tools it gave up on.

Two engines, two address spaces. Ghidra loads a position-independent ELF (lowest segment at 0) at 0x100000, while the built-in engine reads it where its headers say. Handing Ghidra our address found nothing there, and Deep mode quietly fell back to the built-in decompiler for every PIE program and shared library. Whenever an address crosses between engines, move it by their image bases (ghidra::Rebase, from the base the bridge reports) and say in the evidence which space each address is in.

An id that only means something on one computer, kept in a folder that travels. The project saved Claude Code's session id, but the session itself lives in ~/.claude/projects/ on the computer (and under the folder) that started it; a project synced to another computer stopped at "No conversation found" (#452), and the retry for a cut-off turn asked for the same missing session three more times. Anything a project keeps must still work where the folder goes: keep what the id points to as well (agent-session.jsonl), tell "not here" apart from a failure by a typed signal (Conversation::session_lost), and never retry what can't succeed.

Agent tooling

Shell quoting breaks generated edits. Heredocs carrying Python with quotes, ''' or \n were mangled by the shell several times. Write multi-line edit scripts to a file and run the file.

A Python edit that turned every line into CRLF. Path.write_text on Windows writes CRLF line endings, so a scripted size change rewrote 19 LF files with CRLF (git only warned). Write with write_bytes(s.encode()) and check git ls-files --eol -m after scripted edits.

A process search that found itself. Looking for a helper by a word in its command line, with that word in the search's own command, listed the search: "a helper is left behind" after every run, with a new pid each time. The new pid was the clue. Build the pattern from parts ('Set' + 'Thread'), and don't stop processes by pattern on a machine where real builds may run.

A command that waits for a terminal. git stash show -p opened a pager and an empty python - <<'EOF' heredoc opened the interactive interpreter; both sat waiting for input until the tool timed out, with work left in a stash. Never run anything that can page or prompt: pass --no-pager (or GIT_PAGER=cat), never feed an interpreter an empty script, and check git stash list and git status after a timeout before carrying on. It happened again from a leftover first line (python -I - <<'X' with nothing before X) in a command that then wrote and ran a script: 10 minutes lost. Write the script with the editor tool and run only python -I <file>. And twice more in one night, each time a python -I - <<'X' with an empty body typed as a throwaway step in a longer command. There is no safe use of python - here: never type it.

A search that ran no search. timeout 40 find … 2>/dev/null on a Mac: there's no timeout there, the error went to /dev/null, and the empty result read as "no such files on this computer". Don't hide a command's errors when an empty result means something; set the time limit on the tool call.

A file list in a variable that zsh didn't split. F=$(grep -l …); sed -i '' … $F handed sed all the names as one path; it failed, the tests that followed passed on the old code, and it read as done. Pipe lists into xargs, and check the edit landed (git status) before reading the test result.

A scripted edit that stops half-way. One script deleted a file, then failed an assertion on a later file, leaving the tree half-edited. Make edit scripts check every anchor before writing anything, and run git status after any failure before trying again.

A scripted edit that deletes more than it meant to. A roadmap update found the end of an item by searching for the next - [ line; the item was the last in its section, so the search ran into the next section and deleted its heading. Read git diff of every scripted edit before committing, and bound searches to the section (stop at the next heading).

A time limit that also covers the build. A live browser test was run under timeout; the first build of the test binary used most of the time, the limit killed the test mid-run and its private browser was left running. Build first (cargo test --no-run), then run the test under the limit, and don't kill test processes by hand to clean up after it.

A UI tree that showed a password. The Windows UI tree skipped values of password fields, but .NET's UI Automation client hadn't loaded its Win32 proxies, so every WinForms control was a plain pane, nothing was flagged as a password, and a password box's text came back as its title. Register the client-side providers, never read a password field's title or value, treat an unrecognised edit box as one, and test against a real window with a secret in it.

A list of tool names that drifted. The MCP server marked results as untrusted by a list of tool names; two of its names no longer existed, so nothing failed and the gap went unseen. Put such facts on the tool itself (Effects::CARRIES_TARGET_CONTENT), compare Registry::canonical ids (which cover old ids and mcp__…__ names) instead of raw names, and keep tool ids that other code depends on in one constant next to the tool.

A new tool named the old way. Two new tools got ids outside the scheme although new tools use <area>_<object>_<action> from the start, and had to be renamed. Read docs/tool-ids.md before naming a tool.

A stash that left a new file behind. Work in progress was stashed to fix something on another branch; git stash keeps only tracked files, so a new file stayed in the tree and git add -A put it into the other PR. Use git stash -u (or commit the work in progress on its own branch), and read git status before git add -A.

Fence checks that disagreed about tool calls. Suffix checks accepted malformed closing text, while requiring exactly three closing backticks rejected valid longer fences. Share one boundary parser between JSON recovery and ReAct: count the opener, require a standalone closing line at least that long, and reject an earlier matching close followed by more text. Shorter backtick lines inside a longer fence are content. Test both paths and the next HTTP request, including malformed suffixes and non-call JSON examples whose strings contain protocol markers.