feat(orchestrator): add brain-orchestrator crate + brain-ide scaffold#1
feat(orchestrator): add brain-orchestrator crate + brain-ide scaffold#1codejunkie99 wants to merge 6 commits into
Conversation
brain-orchestrator is a new pure-logic crate that turns free-form specs
into a directed acyclic graph of feature cards, resolves dependencies,
and schedules work onto agent slots. Card lifecycle is Pending -> Ready
-> Running -> {Succeeded|Failed|Cancelled|Blocked}, with descendant
status propagated on every transition. Cycles are refused at edge-add
time; per-agent concurrency limits keep subprocess runners serialized
by default.
The spec parser ships in two flavors: a heuristic numbered-list splitter
for offline mode, and an `LlmEnvelope` JSON contract the IDE feeds to
Claude/Codex via the standard prompt template. The crate exposes only
the schema + prompt; the API client lives downstream.
apps/brain-ide/src-tauri is added to the workspace with a minimal Tauri 2
entry point so subsequent commits can wire backend modules incrementally
without breaking the workspace. 11 orchestrator tests pass.
Full Tauri 2 backend for the Brain IDE. Modules: - memory: thin wrapper around brain-store with note/recent/search - agents: ChatAgent + CardAgent traits, registry, transcript; Claude (Anthropic Messages SSE), Codex (OpenAI Responses SSE), Claude/Codex CLI runners, shell and human assignees - orchestrator: bridges brain-orchestrator scheduler events to Tauri events, dispatches assigned cards to their agents, marks cards Running/Succeeded/Failed as outcomes resolve - pty: portable-pty session manager, base64 byte streaming over pty://<id>/data, exit emitted on pty://<id>/exit - projects/settings/auth: persisted stores; auth lives in the macOS Keychain via the keyring crate (frontend never sees secrets) - commands: 30 tauri commands covering chat, graph CRUD with drag-drop assignment, spec parsing (heuristic + LLM via the registered agent), PTY lifecycle, file tree + read/write, memory operations, project/settings/credential CRUD Tauri config includes a tightened CSP that whitelists api.anthropic.com and api.openai.com, macOS entitlements for network + sandboxed file pickers, and capability set for the plugins (shell, fs, dialog, store, os, process, clipboard). `cargo check -p brain-ide` compiles clean on Linux with GTK deps installed; on macOS no extra deps are required.
Tauri 2 frontend driving the brain-ide backend. Boots into a 3-column layout: persistent chat with model picker, the feature graph with drag-to-agent assignment, and a bottom terminal pane with one tab per PTY session (cards open their own tabs on click). Core surfaces: - Chat: streamed via chat://<thread>/chunk events. Model picker flips agent + model without dropping the thread; the next turn goes to the new model with full prior context. Cmd+Enter sends. - Plan button: feeds the composer text to the active chat agent, parses the strict JSON envelope into a card breakdown, and grafts it into the graph in one click. - Graph: React Flow + dagre auto-layout, color-coded by card kind with status pills. Cards are HTML5-draggable onto the right-side agent lane to assign. Connect handles wire dependencies (refused if they would create a cycle). - Card inspector: live tail of agent log output, manual run/done/ cancel buttons, agent + model dropdowns, "open terminal" that spawns a PTY tagged with the card id. - Terminals: xterm.js with the brand theme, base64 byte streaming to/from portable-pty, TIOCSWINSZ on resize, exit status banner. - File tree: ignore-aware (gitignore + global) project browser with a read-only viewer. - Memory: live brain event feed with substring search and a "quick note" composer that commits straight into the brain repo. - Settings: 4 tabs (general / projects / agents / API keys). API keys are write-only from the frontend — backend stores them in the macOS Keychain. - Onboarding: 3-step first-run flow (project pick, optional keys, done). State: zustand stores per surface (chat, graph, terminals, projects, settings, agents). IPC bridge is a single typed module so backend renames produce one compile error to fix, not 40. `tsc --noEmit` clean; `vite build` produces a 263kB gzipped bundle.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f7a07a2bbe
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| *inner.in_flight.entry(agent.clone()).or_default() += 1; | ||
| // The IDE's runner will call mark_running -> mark_*; we | ||
| // pre-incremented above so we *decrement* here before | ||
| // mark_running re-increments. | ||
| *inner.in_flight.entry(agent).or_default() -= 1; |
There was a problem hiding this comment.
Reserve in-flight capacity during dispatch
The dispatch loop increments in_flight and then immediately decrements it before emitting DispatchCard, so the per-agent counter never actually reserves capacity. When multiple Ready cards share an agent, a single try_dispatch() call can emit multiple dispatches even with a concurrency limit of 1, and repeated calls can over-schedule work before mark_running is observed.
Useful? React with 👍 / 👎.
| if current >= limit { | ||
| continue; |
There was a problem hiding this comment.
Handle zero limit as unlimited instead of blocking all
With the current check, any agent configured with a limit of 0 will never dispatch because current >= limit is always true at 0. This contradicts the config contract that 0 means unlimited concurrency and effectively deadlocks that agent’s queue when such a config is used.
Useful? React with 👍 / 👎.
| let _ = state | ||
| .transcripts | ||
| .append(input.thread_id, assistant.clone()); | ||
|
|
There was a problem hiding this comment.
Avoid persisting empty assistant message on stream failure
The command appends an empty assistant message to the transcript before attempting agent.stream(req). If stream creation fails (e.g., missing API key or transport error), the function returns an error but leaves that placeholder message in history, creating stale blank assistant turns that accumulate over time.
Useful? React with 👍 / 👎.
| }, | ||
| ); | ||
| } | ||
| None => break, |
There was a problem hiding this comment.
🟡 Medium commands/chat.rs:155
When the stream ends normally (stream.next() returns None at line 155), the code breaks without emitting a ChatChunk::Done event to the frontend. This leaves the UI in a "loading" state when the agent stream finishes without emitting its own completion signal, because no Done chunk is sent to signal normal termination (unlike cancellation, which explicitly emits one).
- None => break,
+ None => {
+ let _ = app_for_pump.emit(
+ &format!("chat://{thread_id}/chunk"),
+ ChatStreamEvent {
+ thread_id,
+ assistant_message_id: assistant_id,
+ chunk: ChatChunk::Done {
+ input_tokens: None,
+ output_tokens: None,
+ cached_tokens: None,
+ stop_reason: None,
+ },
+ },
+ );
+ break;
+ }🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/brain-ide/src-tauri/src/commands/chat.rs around line 155:
When the stream ends normally (`stream.next()` returns `None` at line 155), the code breaks without emitting a `ChatChunk::Done` event to the frontend. This leaves the UI in a "loading" state when the agent stream finishes without emitting its own completion signal, because no `Done` chunk is sent to signal normal termination (unlike cancellation, which explicitly emits one).
Evidence trail:
apps/brain-ide/src-tauri/src/commands/chat.rs lines 117-159 (REVIEWED_COMMIT): the select loop showing cancellation emits Done (lines 119-133) but None branch (line 155) just breaks. apps/brain-ide/src-tauri/src/agents/claude.rs lines 105-108: filter_map with ev.ok()? silently drops errors. apps/brain-ide/src-tauri/src/agents/codex.rs lines 139-144, 164-165: filter_map drops errors and [DONE] sentinel. apps/brain-ide/src-tauri/src/agents/types.rs lines 72-92: ChatChunk enum and ChatStream type. apps/brain-ide/src-tauri/src/commands/spec.rs line 62: frontend expects ChatChunk::Done to break out of loading.
| fn api_key(&self) -> Option<String> { | ||
| AuthStore::get(CredentialKind::Anthropic).ok().flatten() | ||
| } |
There was a problem hiding this comment.
🟢 Low agents/claude.rs:40
api_key() converts all AuthStore::get() errors to None via .ok().flatten(), so credential access failures (e.g., keyring permission denied, corrupted store) are reported as "not configured" rather than the actual error. This causes the descriptor() readiness message to misleadingly tell users to set their API key when the real issue is a system or permission problem.
- fn api_key(&self) -> Option<String> {
- AuthStore::get(CredentialKind::Anthropic).ok().flatten()
- }
+ fn api_key(&self) -> Result<Option<String>, AgentError> {
+ AuthStore::get(CredentialKind::Anthropic)
+ .map_err(|e| AgentError::Other(format!("credential store error: {}", e)))
+ }Also found in 1 other location(s)
apps/brain-ide/src-tauri/src/memory/mod.rs:127
Error masking when
BrainRepo::openfails for reasons other than 'not found': Ifpath.exists()is true butBrainRepo::open(path)fails withDetachedHead,SchemaMismatch, orNotABrain, the code falls through toBrainRepo::init(path)which then fails withAlreadyExists. The user sees a confusing 'already exists' error instead of the actual problem (detached HEAD, schema mismatch, etc.). The fallback toinitshould only happen when the path doesn't exist or isn't a git repository at all, not for all open failures.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/brain-ide/src-tauri/src/agents/claude.rs around lines 40-42:
`api_key()` converts all `AuthStore::get()` errors to `None` via `.ok().flatten()`, so credential access failures (e.g., keyring permission denied, corrupted store) are reported as "not configured" rather than the actual error. This causes the `descriptor()` readiness message to misleadingly tell users to set their API key when the real issue is a system or permission problem.
Evidence trail:
apps/brain-ide/src-tauri/src/agents/claude.rs lines 40-42: `api_key()` uses `.ok().flatten()` discarding errors. apps/brain-ide/src-tauri/src/auth/mod.rs lines 59-66: `AuthStore::get()` returns `anyhow::Result<Option<String>>`, distinguishing `NoEntry` (→ `Ok(None)`) from other errors (→ `Err(e)`). apps/brain-ide/src-tauri/src/agents/claude.rs lines 47-63: `descriptor()` maps `api_key() == None` to readiness_message "Set ANTHROPIC_API_KEY in Settings → API keys".
Also found in 1 other location(s):
- apps/brain-ide/src-tauri/src/memory/mod.rs:127 -- Error masking when `BrainRepo::open` fails for reasons other than 'not found': If `path.exists()` is true but `BrainRepo::open(path)` fails with `DetachedHead`, `SchemaMismatch`, or `NotABrain`, the code falls through to `BrainRepo::init(path)` which then fails with `AlreadyExists`. The user sees a confusing 'already exists' error instead of the actual problem (detached HEAD, schema mismatch, etc.). The fallback to `init` should only happen when the path doesn't exist or isn't a git repository at all, not for all open failures.
| master: pair.master, | ||
| writer, | ||
| }; | ||
| self.inner.lock().insert(id, session); |
There was a problem hiding this comment.
🟢 Low pty/mod.rs:156
When spawning the reader thread fails at line 181, the .ok() call silently drops the error and the session remains in inner with no thread reading PTY output. Once the PTY's internal buffer fills, the child process blocks and hangs indefinitely. Consider propagating the spawn error and removing the session from the map so the caller knows the session is unusable.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/brain-ide/src-tauri/src/pty/mod.rs around line 156:
When spawning the reader thread fails at line 181, the `.ok()` call silently drops the error and the session remains in `inner` with no thread reading PTY output. Once the PTY's internal buffer fills, the child process blocks and hangs indefinitely. Consider propagating the spawn error and removing the session from the map so the caller knows the session is unusable.
Evidence trail:
apps/brain-ide/src-tauri/src/pty/mod.rs lines 156 (session inserted into inner), 161-181 (thread spawn with .ok() silently dropping errors), 198 (Ok(info) returned regardless of spawn failure) at REVIEWED_COMMIT.
| {activeSession ? ( | ||
| <XTerm | ||
| session={activeSession} | ||
| onExit={() => removeLocal(activeSession.id)} |
There was a problem hiding this comment.
🟠 High Terminal/TerminalTabs.tsx:112
The onExit callback passed to XTerm is an inline arrow function () => removeLocal(activeSession.id), so it gets a new reference on every TerminalTabs render. Since XTerm's useEffect includes onExit in its dependency array [session.id, onExit], the effect re-runs on every parent re-render, disposing and recreating the terminal instance. This causes visible terminal flash/reset whenever zustand store state changes (adding sessions, selecting cards, etc.). Consider wrapping removeLocal in useCallback with stable dependencies and passing it directly, or memoizing the callback with useMemo/useCallback in TerminalTabs.
- onExit={() => removeLocal(activeSession.id)}Also found in 1 other location(s)
apps/brain-ide/src/components/Graph/GraphView.tsx:64
The
animatedproperty inrfEdgesreads card status viauseGraphStore.getState().cards[e.from]?.status, but theuseMemodependency array only contains[edges]. When a card's status changes to/from"running", thecardsstate updates butedgesdoes not, so the memo won't recalculate. This causes edge animations to remain stale—edges won't start animating when a card begins running and won't stop when it finishes.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/brain-ide/src/components/Terminal/TerminalTabs.tsx around line 112:
The `onExit` callback passed to `XTerm` is an inline arrow function `() => removeLocal(activeSession.id)`, so it gets a new reference on every `TerminalTabs` render. Since `XTerm`'s `useEffect` includes `onExit` in its dependency array `[session.id, onExit]`, the effect re-runs on every parent re-render, disposing and recreating the terminal instance. This causes visible terminal flash/reset whenever zustand store state changes (adding sessions, selecting cards, etc.). Consider wrapping `removeLocal` in `useCallback` with stable dependencies and passing it directly, or memoizing the callback with `useMemo`/`useCallback` in `TerminalTabs`.
Evidence trail:
apps/brain-ide/src/components/Terminal/TerminalTabs.tsx line 112 (inline arrow `() => removeLocal(activeSession.id)` passed as `onExit`), apps/brain-ide/src/components/Terminal/XTerm.tsx lines 40-105 (useEffect with dependency array `[session.id, onExit]` at line 105, cleanup calls `term.dispose()` at line 102)
Also found in 1 other location(s):
- apps/brain-ide/src/components/Graph/GraphView.tsx:64 -- The `animated` property in `rfEdges` reads card status via `useGraphStore.getState().cards[e.from]?.status`, but the `useMemo` dependency array only contains `[edges]`. When a card's status changes to/from `"running"`, the `cards` state updates but `edges` does not, so the memo won't recalculate. This causes edge animations to remain stale—edges won't start animating when a card begins running and won't stop when it finishes.
|
|
||
| function AgentDropZone({ slug, label, model, models, ready, readinessMessage }: ZoneProps) { | ||
| const [hover, setHover] = useState(false); | ||
| const [chosenModel, setChosenModel] = useState(model); |
There was a problem hiding this comment.
🟢 Low Graph/AgentLane.tsx:66
When model is null but models is non-empty, the dropdown displays the first option as selected (browser default), yet chosenModel state stays null. If the user drops a card without interacting with the select, bridge.assignCard receives model: undefined instead of the visually-selected model, silently ignoring the user's apparent choice.
| const [chosenModel, setChosenModel] = useState(model); | |
| const [chosenModel, setChosenModel] = useState(model ?? models[0] ?? null); |
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/brain-ide/src/components/Graph/AgentLane.tsx around line 66:
When `model` is `null` but `models` is non-empty, the dropdown displays the first option as selected (browser default), yet `chosenModel` state stays `null`. If the user drops a card without interacting with the select, `bridge.assignCard` receives `model: undefined` instead of the visually-selected model, silently ignoring the user's apparent choice.
Evidence trail:
apps/brain-ide/src/components/Graph/AgentLane.tsx line 49 (model comes from d.default_model), line 58 (typed string | null), line 66 (useState(model) initializes to null), line 135 (value={chosenModel ?? ""} resolves to empty string), lines 148-152 (no option has value=""), lines 78-82 (model: chosenModel ?? undefined sends undefined on drop). Reviewed at commit REVIEWED_COMMIT.
| pub fn update(&self, mutate: impl FnOnce(&mut Settings)) -> anyhow::Result<Settings> { | ||
| let updated = { | ||
| let mut guard = self.inner.write(); | ||
| mutate(&mut guard); | ||
| guard.clone() | ||
| }; | ||
| self.persist(&updated)?; | ||
| Ok(updated) | ||
| } |
There was a problem hiding this comment.
🟢 Low settings/mod.rs:169
update() drops the write lock before calling persist(), so concurrent updates race: Thread A releases the lock, Thread B acquires it and persists, then Thread A's slower persist() overwrites with stale data — Thread B's change is lost. Consider persisting while holding the lock, or using a file lock to serialize writes.
- pub fn update(&self, mutate: impl FnOnce(&mut Settings)) -> anyhow::Result<Settings> {
- let updated = {
- let mut guard = self.inner.write();
- mutate(&mut guard);
- guard.clone()
- };
- self.persist(&updated)?;
- Ok(updated)
- }🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/brain-ide/src-tauri/src/settings/mod.rs around lines 169-177:
`update()` drops the write lock before calling `persist()`, so concurrent updates race: Thread A releases the lock, Thread B acquires it and persists, then Thread A's slower `persist()` overwrites with stale data — Thread B's change is lost. Consider persisting while holding the lock, or using a file lock to serialize writes.
Evidence trail:
apps/brain-ide/src-tauri/src/settings/mod.rs lines 169-177 (REVIEWED_COMMIT): The write lock `guard` is scoped to the inner block (lines 170-174), released before `persist()` is called at line 175. `persist()` (lines 179-187) writes to a tmp file and renames, but without any lock held. `open_or_default()` (lines 154-162) reads from the same file on startup, so stale persisted data means lost settings.
| CardOutcome::Cancelled => { | ||
| // Leave the card in whatever state | ||
| // the user puts it in (shell/human | ||
| // cards expect manual transition). | ||
| } |
There was a problem hiding this comment.
🟠 High orchestrator/mod.rs:149
When CardOutcome::Cancelled is received, the code skips calling any scheduler transition method. Since mark_running was already called at line 98 (which increments in_flight), the counter is never decremented for cancelled cards. Over time, repeated cancellations exhaust the agent's slot capacity and block new dispatches even though no work is in flight. Consider calling scheduler.cancel(id) to properly decrement the counter.
CardOutcome::Cancelled => {
- // Leave the card in whatever state
- // the user puts it in (shell/human
- // cards expect manual transition).
+ let _ = scheduler.cancel(card.id);
}Also found in 1 other location(s)
apps/brain-ide/src-tauri/src/commands/graph.rs:142
reset_graphremoves running cards without first cancelling them, which leaksin_flightslots. When a running card's background task completes, it callsmark_succeededormark_failed, which internally callstransition()→inner.graph.get(id)?. Since the card was removed, this returnsGraphError::UnknownCardanddec_in_flightis never called. The agent'sin_flightcounter remains elevated, potentially blocking future card dispatches to that agent. The function should callscheduler.cancel(id)for cards withCardStatus::Runningbefore removing them.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/brain-ide/src-tauri/src/orchestrator/mod.rs around lines 149-153:
When `CardOutcome::Cancelled` is received, the code skips calling any scheduler transition method. Since `mark_running` was already called at line 98 (which increments `in_flight`), the counter is never decremented for cancelled cards. Over time, repeated cancellations exhaust the agent's slot capacity and block new dispatches even though no work is in flight. Consider calling `scheduler.cancel(id)` to properly decrement the counter.
Evidence trail:
apps/brain-ide/src-tauri/src/orchestrator/mod.rs lines 98 (mark_running call) and 149-153 (empty Cancelled arm) at REVIEWED_COMMIT; crates/brain-orchestrator/src/scheduler.rs lines 216-221 (mark_running increments in_flight), 224-233 (mark_succeeded/mark_failed call dec_in_flight), 236-247 (cancel method exists and calls dec_in_flight), 249-253 (dec_in_flight definition), 296-299 (in_flight gates dispatch); apps/brain-ide/src-tauri/src/commands/graph.rs line 124 (UI cancel path).
Also found in 1 other location(s):
- apps/brain-ide/src-tauri/src/commands/graph.rs:142 -- `reset_graph` removes running cards without first cancelling them, which leaks `in_flight` slots. When a running card's background task completes, it calls `mark_succeeded` or `mark_failed`, which internally calls `transition()` → `inner.graph.get(id)?`. Since the card was removed, this returns `GraphError::UnknownCard` and `dec_in_flight` is never called. The agent's `in_flight` counter remains elevated, potentially blocking future card dispatches to that agent. The function should call `scheduler.cancel(id)` for cards with `CardStatus::Running` before removing them.
| fn try_dispatch(&self) { | ||
| let to_dispatch: Vec<Card> = { | ||
| let mut inner = self.inner.lock(); | ||
| let ready: Vec<CardId> = inner.graph.ready_cards(); | ||
| let mut out = Vec::new(); | ||
| for id in ready { | ||
| let card = match inner.graph.get(id) { | ||
| Ok(c) => c.clone(), | ||
| Err(_) => continue, | ||
| }; | ||
| let Some(agent) = card.assigned_agent.clone() else { | ||
| // Unassigned Ready cards just sit in the graph | ||
| // until the user drags them onto an agent. | ||
| continue; | ||
| }; | ||
| let limit = inner.config.limit_for(&agent); | ||
| let current = *inner.in_flight.get(&agent).unwrap_or(&0); | ||
| if current >= limit { | ||
| continue; | ||
| } | ||
| // Reserve the slot before dispatching so two near- | ||
| // simultaneous calls to try_dispatch don't both pick the | ||
| // same card. | ||
| *inner.in_flight.entry(agent.clone()).or_default() += 1; | ||
| // The IDE's runner will call mark_running -> mark_*; we | ||
| // pre-incremented above so we *decrement* here before | ||
| // mark_running re-increments. | ||
| *inner.in_flight.entry(agent).or_default() -= 1; | ||
| out.push(card); |
There was a problem hiding this comment.
🟠 High src/scheduler.rs:281
In try_dispatch, the in_flight counter for an agent is incremented at line 304 and immediately decremented at line 308 while the lock is still held, leaving the counter unchanged. This means the slot reservation has no effect: if try_dispatch is called again before mark_running increments the counter, the same Ready cards will be re-dispatched because ready_cards() still returns them and in_flight shows available capacity.
- // Reserve the slot before dispatching so two near-
- // simultaneous calls to try_dispatch don't both pick the
- // same card.
- *inner.in_flight.entry(agent.clone()).or_default() += 1;
- // The IDE's runner will call mark_running -> mark_*; we
- // pre-incremented above so we *decrement* here before
- // mark_running re-increments.
- *inner.in_flight.entry(agent).or_default() -= 1;
- out.push(card);🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file crates/brain-orchestrator/src/scheduler.rs around lines 281-309:
In `try_dispatch`, the `in_flight` counter for an agent is incremented at line 304 and immediately decremented at line 308 while the lock is still held, leaving the counter unchanged. This means the slot reservation has no effect: if `try_dispatch` is called again before `mark_running` increments the counter, the same Ready cards will be re-dispatched because `ready_cards()` still returns them and `in_flight` shows available capacity.
Evidence trail:
crates/brain-orchestrator/src/scheduler.rs lines 281-316 at REVIEWED_COMMIT: `try_dispatch` method. Line 283: lock acquired. Line 304: `in_flight` incremented. Line 308: `in_flight` decremented. Both within the same lock scope, net effect is zero. Comments at lines 301-303 state intent to reserve slot to prevent double-dispatch, but the immediate decrement at line 308 nullifies the reservation.
| #[derive(Debug, Deserialize)] | ||
| pub struct ListTreeInput { | ||
| pub root: Option<String>, | ||
| /// Max depth to recurse. 0 means just the root. |
There was a problem hiding this comment.
🟢 Low commands/fs.rs:29
The comment claims depth=0 means "just the root", but max_depth(Some(depth + 1)) passes Some(1) to the walker, which includes the root and its immediate children. This creates an off-by-one mismatch between the documented API contract and actual behavior.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/brain-ide/src-tauri/src/commands/fs.rs around line 29:
The comment claims `depth=0` means "just the root", but `max_depth(Some(depth + 1))` passes `Some(1)` to the walker, which includes the root and its immediate children. This creates an off-by-one mismatch between the documented API contract and actual behavior.
Evidence trail:
apps/brain-ide/src-tauri/src/commands/fs.rs lines 29, 45, 48-49 at REVIEWED_COMMIT. Doc comment on line 29 says '0 means just the root'. Code on line 49: `.max_depth(Some(depth + 1))` with depth=0 yields `max_depth(Some(1))`. walkdir/ignore crate documentation confirms max_depth(0) = root only, max_depth(1) = root + immediate children: https://docs.rs/walkdir/latest/walkdir/struct.WalkDir.html, https://docs.rs/ignore/latest/ignore/struct.WalkBuilder.html
| #[tauri::command] | ||
| pub fn read_file(input: ReadFileInput) -> Result<ReadFileOutput, String> { | ||
| let max = input.max_bytes.unwrap_or(2 * 1024 * 1024); | ||
| let raw = std::fs::read(&input.path).map_err(|e| e.to_string())?; | ||
| let truncated = raw.len() > max; | ||
| let slice = if truncated { &raw[..max] } else { &raw }; | ||
| let content = String::from_utf8_lossy(slice).to_string(); | ||
| Ok(ReadFileOutput { content, truncated }) | ||
| } |
There was a problem hiding this comment.
🟡 Medium commands/fs.rs:140
std::fs::read(&input.path) loads the entire file into memory before truncation is applied, so a path to a multi-gigabyte file allocates that much memory regardless of max_bytes. Use std::io::Read::take() to limit bytes read at the source.
pub fn read_file(input: ReadFileInput) -> Result<ReadFileOutput, String> {
- let max = input.max_bytes.unwrap_or(2 * 1024 * 1024);
- let raw = std::fs::read(&input.path).map_err(|e| e.to_string())?;
- let truncated = raw.len() > max;
- let slice = if truncated { &raw[..max] } else { &raw };
- let content = String::from_utf8_lossy(slice).to_string();
+ let max = input.max_bytes.unwrap_or(2 * 1024 * 1024) as u64;
+ let file = std::fs::File::open(&input.path).map_err(|e| e.to_string())?;
+ let mut limited = file.take(max);
+ let mut buf = Vec::new();
+ let truncated = limited.read_to_end(&mut buf).map_err(|e| e.to_string())? == (max as usize);
+ let content = String::from_utf8_lossy(&buf).to_string();
Ok(ReadFileOutput { content, truncated })🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/brain-ide/src-tauri/src/commands/fs.rs around lines 140-148:
`std::fs::read(&input.path)` loads the entire file into memory before truncation is applied, so a path to a multi-gigabyte file allocates that much memory regardless of `max_bytes`. Use `std::io::Read::take()` to limit bytes read at the source.
Evidence trail:
apps/brain-ide/src-tauri/src/commands/fs.rs lines 128-148 at REVIEWED_COMMIT: `ReadFileInput` struct defines `max_bytes: Option<usize>` (line 131). Line 143: `std::fs::read(&input.path)` reads the entire file into memory. Lines 144-145: truncation is applied after full read. Rust std::fs::read documentation confirms it reads the entire file: https://doc.rust-lang.org/std/fs/fn.read.html
Replaces the read-only <pre> file viewer with a real editor. Pieces: - editor store: open files with per-file baseline/content/language; tabs render dirty markers when content != baseline; Cmd/Ctrl+S saves the active file through the existing write_file command. - MonacoPane: thin wrapper around @monaco-editor/react with the brain-dark theme, font/tab settings sourced from the settings store, and word-wrap / format-on-save honored. - monacoLoader: registers `monaco-editor` against the Monaco React loader and points its 5 web workers (editor/json/css/html/ts) at Vite-bundled `?worker` chunks, so the IDE never reaches for a CDN at runtime. - EditorTabs: tab strip with close button + dirty dot. - FileTree: clicking a file calls editor.openFile() instead of inlining the content; the right pane mounts the tab strip + Monaco pane. Shiki: - CodeBlock: a Markdown `code` renderer that wraps inline `code` in styled chips and fenced blocks in Shiki HTML, with a copy button and a language label header. - Uses shiki/core + per-language fine-grained imports so the chat panel only pulls grammars on demand. ~20 common languages (typescript/tsx/rust/python/bash/yaml/diff/...) are registered; unknown languages fall through to plain text. - MessageList wires the renderer into ReactMarkdown. Vite manual chunks split monaco / shiki / reactflow / xterm into their own files, so the main bundle is ~64 kB gzipped and heavy panels load on demand. MonacoPane is `React.lazy` so the editor chunk is paid for only when the user opens a file.
Two new surfaces stitched into the IDE: git (libgit2-backed, no shell-out): - src-tauri/src/git: status (with ahead/behind, conflicts, renames), unified-diff per file (staged vs. unstaged), stage/unstage by path, commit with the user's git signature, log, checkout (auto-creates a local tracking branch when checking out a remote-only ref). - 7 tauri commands wired through with the project root passed explicitly so the backend never guesses which repo. - GitView: 3-pane layout — branch dropdown + ahead/behind pills in the header, staged/changes groups with per-file ± buttons, commit composer with disabled state until something's staged and there's a message. Selecting a file streams its diff into a syntax-aware unified diff viewer (additions/deletions/context color-coded with line numbers on both sides). - New "Git" entry in the activity bar; sits between Files and Memory. mcp: - src-tauri/src/mcp: spawns `brain serve --mcp --brain-dir <dir>` as a child process so external agents (Claude Code, Cursor, the Codex CLI) can read and write the same memory the IDE chat does, with serialized writes through brain-store's append lock. - McpHost manages start/stop/status and emits the exact JSON / TOML snippets each agent's config needs. - 4 tauri commands + a Settings → MCP host panel with a one-click toggle, live status pill (running/stopped + pid + brain dir), and copy-to-clipboard buttons for the Claude Code, Cursor, and Codex config snippets. `cargo check --workspace` clean. `vite build` ships a 64kB gzipped main bundle with the heavy editor / highlighter / flow chunks split out behind dynamic imports.
| })); | ||
| }, | ||
|
|
||
| saveAll: async () => { |
There was a problem hiding this comment.
🟡 Medium state/editor.ts:111
In saveAll, the dirty snapshot captures file contents before the async loop, but the final set reads f.content from current state s.open. If a user edits during save, disk contains old content while baseline is set to new content, making the file appear saved when it isn't—causing silent data loss. Consider using the captured dirty contents to update baseline instead of re-reading from state.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/brain-ide/src/state/editor.ts around line 111:
In `saveAll`, the `dirty` snapshot captures file contents before the async loop, but the final `set` reads `f.content` from current state `s.open`. If a user edits during save, disk contains old content while `baseline` is set to new content, making the file appear saved when it isn't—causing silent data loss. Consider using the captured `dirty` contents to update `baseline` instead of re-reading from state.
Evidence trail:
apps/brain-ide/src/state/editor.ts lines 99-119 at REVIEWED_COMMIT. The `save` function (lines 99-108) captures `file` at line 100 and uses `file.content` (the captured value) in the `set` callback at line 106. The `saveAll` function (lines 111-119) captures `dirty` at line 112, writes from the snapshot at line 114, but the `set` callback at line 117 uses `f.content` from `s.open` (current state), not from the captured `dirty` array. This mismatch causes the race condition described.
| useEffect(() => { | ||
| if (!selectedPath) { | ||
| setDiff(null); | ||
| return; | ||
| } |
There was a problem hiding this comment.
🟢 Low Git/GitView.tsx:50
When a diff fetch fails and the user clicks Retry, the diff panel displays stale data from a previously selected file. The diff state is not cleared when selectedPath changes, so if the new fetch fails, the old diff object (with a different file's path) remains in state. After Retry clears the error, the component renders <DiffView diff={diff} /> with the stale data since the effect dependencies haven't changed, causing the diff to show content for file A while file B is visually selected.
useEffect(() => {
if (!selectedPath) {
setDiff(null);
return;
}
+ setDiff(null);
let cancelled = false;🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/brain-ide/src/components/Git/GitView.tsx around lines 50-54:
When a diff fetch fails and the user clicks Retry, the diff panel displays stale data from a previously selected file. The `diff` state is not cleared when `selectedPath` changes, so if the new fetch fails, the old `diff` object (with a different file's `path`) remains in state. After Retry clears the error, the component renders `<DiffView diff={diff} />` with the stale data since the effect dependencies haven't changed, causing the diff to show content for file A while file B is visually selected.
Evidence trail:
apps/brain-ide/src/components/Git/GitView.tsx lines 50-71 (diff effect does not clear `diff` at start for non-null selectedPath, and `.catch` block at line 62-63 sets error but never clears diff), lines 92-103 (error view with Retry button calling `refresh()`), lines 26-41 (`refresh()` clears error on line 27 but does not clear diff; preserves selectedPath if file still in list on lines 33-36), lines 353-355 (renders `<DiffView diff={diff} />` when both selectedPath and diff are truthy).
| onClick={async () => { | ||
| setBusy(true); | ||
| try { | ||
| await bridge.mcpStop(); | ||
| await refresh(); | ||
| } finally { | ||
| setBusy(false); | ||
| } | ||
| }} |
There was a problem hiding this comment.
🟢 Low Settings/McpPanel.tsx:91
When bridge.mcpStop() throws, the error becomes an unhandled promise rejection because the Stop button's handler lacks a catch block. The error is never displayed via setError, even though that state exists to surface such failures to the user. Consider adding a catch block that calls setError(String(e)) to match the Start button handler.
- onClick={async () => {
- setBusy(true);
- try {
- await bridge.mcpStop();
- await refresh();
- } finally {
- setBusy(false);
- }
- }}
+ onClick={async () => {
+ setBusy(true);
+ try {
+ await bridge.mcpStop();
+ await refresh();
+ } catch (e) {
+ setError(String(e));
+ } finally {
+ setBusy(false);
+ }
+ }}🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/brain-ide/src/components/Settings/McpPanel.tsx around lines 91-99:
When `bridge.mcpStop()` throws, the error becomes an unhandled promise rejection because the Stop button's handler lacks a `catch` block. The error is never displayed via `setError`, even though that state exists to surface such failures to the user. Consider adding a `catch` block that calls `setError(String(e))` to match the Start button handler.
Evidence trail:
apps/brain-ide/src/components/Settings/McpPanel.tsx lines 91-99 (Stop handler: try/finally, no catch), lines 108-117 (Start handler: try/catch/finally with setError), line 13 (setError state declaration)
| // Try local branch first, then resolve a remote one by name. | ||
| let refname = if repo.find_branch(branch, git2::BranchType::Local).is_ok() { | ||
| format!("refs/heads/{branch}") | ||
| } else if let Ok(remote_branch) = repo.find_branch(branch, git2::BranchType::Remote) { |
There was a problem hiding this comment.
🟡 Medium git/mod.rs:435
When checking out a branch that exists only as a remote branch, repo.find_branch(branch, BranchType::Remote) on line 435 searches for a remote branch literally named "main" rather than "origin/main", so the lookup always fails even when origin/main exists. This causes checkout(root, "main") to incorrectly return "branch 'main' not found" when no local main exists but origin/main is available.
- } else if let Ok(remote_branch) = repo.find_branch(branch, git2::BranchType::Remote) {
+ } else if let Ok(remote_branch) = repo.find_branch(&format!("origin/{branch}"), git2::BranchType::Remote) {🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/brain-ide/src-tauri/src/git/mod.rs around line 435:
When checking out a branch that exists only as a remote branch, `repo.find_branch(branch, BranchType::Remote)` on line 435 searches for a remote branch literally named `"main"` rather than `"origin/main"`, so the lookup always fails even when `origin/main` exists. This causes `checkout(root, "main")` to incorrectly return `"branch 'main' not found"` when no local `main` exists but `origin/main` is available.
Evidence trail:
1. apps/brain-ide/src-tauri/src/git/mod.rs lines 430-448 (REVIEWED_COMMIT): `checkout` function passes bare `branch` name to `repo.find_branch(branch, git2::BranchType::Remote)` on line 435.
2. git2 crate docs (https://docs.rs/git2/latest/git2/struct.Repository.html#method.find_branch): `find_branch` with `BranchType::Remote` expects full remote-qualified name like `"origin/main"`, not just `"main"`.
| pub async fn start(&self) -> Result<McpStatus, McpError> { | ||
| // Resolve everything without holding the lock across .await. | ||
| let (binary, brain_dir) = { | ||
| let inner = self.inner.lock(); | ||
| if inner.child.is_some() { | ||
| return Ok(self.status()); | ||
| } | ||
| let dir = inner.brain_dir.clone().ok_or(McpError::NoBrain)?; | ||
| (inner.binary.clone(), dir) | ||
| }; |
There was a problem hiding this comment.
🟠 High mcp/mod.rs:97
When a child is already running, start() calls self.status() at line 102 while still holding the lock acquired at line 100. The status() method at line 86 then attempts to acquire the same lock, causing a deadlock since the mutex is not reentrant. The inner guard is not dropped before self.status() evaluates.
- // Resolve everything without holding the lock across .await.
- let (binary, brain_dir) = {
- let inner = self.inner.lock();
- if inner.child.is_some() {
- return Ok(self.status());
- }
- let dir = inner.brain_dir.clone().ok_or(McpError::NoBrain)?;
- (inner.binary.clone(), dir)
- };🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/brain-ide/src-tauri/src/mcp/mod.rs around lines 97-106:
When a child is already running, `start()` calls `self.status()` at line 102 while still holding the lock acquired at line 100. The `status()` method at line 86 then attempts to acquire the same lock, causing a deadlock since the mutex is not reentrant. The `inner` guard is not dropped before `self.status()` evaluates.
Evidence trail:
apps/brain-ide/src-tauri/src/mcp/mod.rs lines 18 (parking_lot::Mutex import), 55-56 (inner: Arc<Mutex<Inner>>), 85-95 (status() acquires self.inner.lock()), 97-106 (start() acquires lock at line 100, calls self.status() at line 102 while guard is still alive). parking_lot::Mutex is non-reentrant.
| let codex_toml = format!( | ||
| "[mcp_servers.brain]\ncommand = \"{binary}\"\nargs = [\"serve\", \"--mcp\", \"--brain-dir\", \"{brain_dir_str}\"]\n" | ||
| ); |
There was a problem hiding this comment.
🟢 Low mcp/mod.rs:166
The codex_toml string uses format! with raw, unescaped binary and brain_dir_str values. On Windows, brain_dir_str contains backslashes that are interpreted as TOML escape sequences (e.g., \U in C:\Users), causing invalid TOML that fails to parse. Paths or binary names containing double quotes also break the format. The JSON outputs correctly use serde_json::json! which escapes values, but the TOML does not.
- let codex_toml = format!(
- "[mcp_servers.brain]\ncommand = \"{binary}\"\nargs = [\"serve\", \"--mcp\", \"--brain-dir\", \"{brain_dir_str}\"]\n"
- );🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/brain-ide/src-tauri/src/mcp/mod.rs around lines 166-168:
The `codex_toml` string uses `format!` with raw, unescaped `binary` and `brain_dir_str` values. On Windows, `brain_dir_str` contains backslashes that are interpreted as TOML escape sequences (e.g., `\U` in `C:\Users`), causing invalid TOML that fails to parse. Paths or binary names containing double quotes also break the format. The JSON outputs correctly use `serde_json::json!` which escapes values, but the TOML does not.
Evidence trail:
apps/brain-ide/src-tauri/src/mcp/mod.rs lines 142, 150-168 at REVIEWED_COMMIT. Line 142: `brain_dir_str = brain_dir.display().to_string()` (unescaped path). Lines 150-165: JSON uses `serde_json::json!` (properly escaped). Lines 166-168: TOML uses `format!` with raw interpolation into double-quoted TOML basic strings — no backslash or quote escaping. TOML spec: in basic strings, backslash is an escape character (https://toml.io/en/v1.0.0#string).
brain-orchestrator is a new pure-logic crate that turns free-form specs
into a directed acyclic graph of feature cards, resolves dependencies,
and schedules work onto agent slots. Card lifecycle is Pending -> Ready
-> Running -> {Succeeded|Failed|Cancelled|Blocked}, with descendant
status propagated on every transition. Cycles are refused at edge-add
time; per-agent concurrency limits keep subprocess runners serialized
by default.
The spec parser ships in two flavors: a heuristic numbered-list splitter
for offline mode, and an
LlmEnvelopeJSON contract the IDE feeds toClaude/Codex via the standard prompt template. The crate exposes only
the schema + prompt; the API client lives downstream.
apps/brain-ide/src-tauri is added to the workspace with a minimal Tauri 2
entry point so subsequent commits can wire backend modules incrementally
without breaking the workspace. 11 orchestrator tests pass.
Note
Add
brain-orchestratorcrate andbrain-ideTauri desktop application scaffoldcrates/brain-orchestratorcrate with aFeatureGraph(petgraph-backed, cycle-safe), aSchedulerwith broadcast events and per-agent concurrency, and aSpecParsertrait with a heuristic implementation.apps/brain-ideTauri v2 desktop app with a React/TypeScript frontend: Monaco editor, React Flow graph canvas, xterm.js terminal tabs, chat panel with streaming SSE from Claude and OpenAI, Git view, file tree, memory log, and a tabbed settings UI.bridge.ts.OrchestratorBridgesubscribes to scheduler events and dispatches cards to registered agents (ClaudeChat,CodexChat,ShellAgent,HumanAssignee), streaming output chunks and outcomes to the frontend via Tauri channels.keyring), and PTY sessions are all persisted or managed with thread-safe stores and atomic-rename JSON writes.Macroscope summarized 089fd18.