Repository navigation
Start a Cloud agent only once its project copy lands - #94
Open
jparker2006 wants to merge 4 commits into
Open
jparker2006 wants to merge 4 commits into
jparker2006 wants to merge 4 commits into
Conversation
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.
Problem. A child started with
room-cli thread spawnin its own sandbox (the default) does not get the project's skills or instructions. A Claude Code child gets none of the repo's.claude/skills(real folders or symlinks) and no CLAUDE.md. A Codex child gets only~/.codex/skills,~/.codex/skills/.systemand~/.agents/skills, never the repo's.agents/skills, and no AGENTS.md. A new Cloud thread whose project is cloned or uploaded has the same race. A normal session started in that folder gets all of them.Repro. The fixture repo has
.claude/skills/probe-claude-real/(a real folder),.claude/skills/probe-agents-linked -> ../../.agents/skills/probe-agents-real(a symlink),.agents/skills/probe-agents-real/, a CLAUDE.md with markerCLAUDE_MD_MARKER_7731, and an AGENTS.md with markerAGENTS_MD_MARKER_4412. From a Cloud thread in that folder, four children got the same probe prompt (2026-10-31, core 0.1.87, upstream bd894ee):probe-agents-realmissing)--shared-sandbox--shared-sandboxprobe-agents-realps lstartputs the claude process at 00:38:39. The birth times of/code,/code/.claude/skillsand/code/CLAUDE.mdare 00:38:41.34 to .37, and.gitfinished at 00:38:41.6. The project landed about 2 s after the harness had started./code/CLAUDE.mdwas created at 00:43:31.8. Its session never got the CLAUDE.md marker or the probe skills.Ruled out.
claude.rscommand(), which has no--setting-sources, so the CLI default (user, project and local) applies. The shared-sandbox child loaded everything with that argv..agents/skillsfrom the same/code.Cause.
Manager::launchrunsensure_directoryand starts the harness right away. Claude Code and Codex read skills and CLAUDE.md or AGENTS.md as they start, so they start in an empty folder and never pick the files up. Core already knew about this race for skills a user selects in a message:claude.rssend()callsfiles::settle. The launch itself had no wait.A wait in Core that only reads the status file would not have fixed it. The app marks
<folder>.cloudroom-filesascopyingafter Core has accepted the start, indeliverPending:child_request→ownSandboxChild→deliverPending): the mark is written onceclient.startreturns, and for a Personal project only after a furthersessionWorkspacecall.The app cannot write the mark any earlier.
writeFilesStatefinds the folder throughGET /v1/workspaces/{id}, and that returns 404 until Core's start registers the workspace. A child in a Personal project only learns its folder from the session it has just started. So the start request itself has to tell Core that a copy is coming.Change.
Core:
POST /v1/sessionstakesproject_copy: true(new capabilityproject_copy), meaning the app is about to copy the project, or a child's code, into this session's folder. Side chats and shared-sandbox children refuse it.launchfirst waits infiles::landeduntil the status file saysreadyorfailed: …. It reusescheckandstatus_file, polling every 250 ms.project_copy, it also waits while the file doesn't exist yet.copying, for example when a start lands in a folder another copy is still filling. It never waits for a missing file, so a blank thread does not pay the 10 s grace thatroom-cli files waitgives. It also never waits for a mark whose mtime is older than the 5-minute limit. The app writescopyingonce, as a copy starts (or restarts after an app restart, or on "Try again"), and never refreshes it, so a mark that old most likely outlived the app that ran the copy (87392f5).files waitandsettleare unchanged.startupspermit is taken, so one slow copy holds up only its own session.pendingthe whole time. Prompts queue,stopcompletes and pauses the queue (as it already did before a harness existed), andclosecloses it.waiting_start.relaunchnever waits, so resume, wake and restart of a session that already started are unchanged.PROJECT_COPY_WAIT).project-copy.tscomment).room-cli files waitallows (10 minutes). If a copy is truly stuck (say the Mac app quit mid-copy), the agent starts as it does today. Its first message, the[Cloud child]hint or the "Cloudroom is copying its files" context, tells it to runroom-cli files wait, which then reports the failure or waits for the rest.project_copy, which is ignored likeplan_only, so a retry that plans its copy differently does not get a 409.agent_startstill measures only harness startup: its timer starts after the wait.docs/api.mddocuments the field.App:
deliverPendingsendsproject_copy: trueonly when Core supports it and a status file will follow (commands.ts). That means always for a child, since its parent's code always comes, and for a new sandbox thread whose plan is a clone or a full upload.lson the VM, and a.envscan on the Mac) would slow down every later start.room-cli files waitnow just returns at once.Tests.
[]instead of['.agents', '.claude', 'AGENTS.md', 'CLAUDE.md']intest_harness_starts_once_a_running_project_copy_landsandtest_restart_during_the_wait_and_resume_after_the_copy. Theproject_copytests fail with 422unknown field project_copy.test_stop_and_close_during_the_waitgets 409session is still starting, because the harness is already starting.core/tests/workspaces.py:test_harness_starts_once_a_running_project_copy_lands: with a folder markedcopyingbefore the start, the harness is not running after 1 s (pending, no native ID). Once the files land and the status isready, it starts and sees them.test_a_mark_left_by_a_dead_copy_holds_no_start(87392f5): a start without the flag, next to acopyingmark whose mtime is 6 minutes old, runs its first turn in under 4 s. It timed out on f9bbe1a.test_a_promised_copy_holds_the_harness_until_its_status_file_says_it_landed: the app's real order, for a child in a Personal project whose status file sits next to the sandbox root, as in the bug report. The start saysproject_copy, the status file is still missing, then it sayscopying, the files land, thenready. The harness sees the files, and a retried start without the field returns the same session.test_no_copy_or_a_failed_one_starts_the_harness_at_once: a blank Personal thread, a blank project thread, and a promised copy thatfailedeach run their first turn in under 4 s, far below the 10 s grace.test_a_waiting_start_holds_up_no_other_start:cpu_countstarts wait on copies, at least as many as there are startup permits, and a blank start next to them still runs its first turn in under 4 s.test_stop_and_close_during_the_wait: stop completes during the wait, and the agent starts in the copied folder with its queue paused. Close closes the session, and its harness never starts.test_restart_during_the_wait_and_resume_after_the_copy: core restarts mid-wait, and the restored start waits again and then sees the files. A session that has already started resumes at once with the same native ID, even if its folder is markedcopyingagain.a_launch_waits_for_a_copy_only_while_it_runs_and_never_past_its_limit: no wait for no copy, a stuck or unmarked promised copy waits exactly until the limit, cancelling ends the wait, andreadyorfailedreturns at once. A mark older than the limit holds a promised start but not an unpromised one.cloudroom-sandboxes.test.ts: in a sandbox thread, the start carriesproject_copy: truefor a copyable project, only after the copy was planned. It carriesproject_copy: truefor a child of a Cloud thread even when its project has nothing to copy. It is absent for a project with nothing to copy, and absent whenever Core lacks the capability. This test fails without the app commit.cargo fmt --checkandcargo clippy --locked --all-targets -- -D warningspass.cargo test --locked --all-targets --no-fail-fast(rerun on 87392f5) passes exceptharness_commits_drop_ai_co_authors_and_keep_human_ones, which fails the same way on clean upstream/main in this sandbox (git 2.39.5).core_fixture.py(32),pi_http.py(15),workspaces.py(13 on 87392f5),preview_client.py,install.py, andintegrations/bb/client.test.ts.sync_e2e.py: one run failedtest_authentication_device_and_path_boundariesin tearDown ("core left owned processes running"). That test then passed 8 of 8 times on both this branch and clean upstream, and the whole file passed twice on each.turbo run build typecheckfor app, server and desktop passes, and all 12test/services/cloudroom*files (73 tests) pass. The full server suite isn't part of app CI. Here it fails in 14 files outside cloudroom (plugins, registry skills, threads, the origin guard). Rerunning those 14 alone gives the same 19 failing tests with and without this PR's app changes. The other 3 files pass alone.storage_e2e.py(needs sudo, useradd and Docker;a_full_disk_stops_the_writing_tool_command_not_the_harnesslives there),previews_ssh.py, andcore_e2e.py --foundation/--fixture(PostgreSQL).For review. Choices worth challenging:
parent_thread(it covers children only).copying. This helps a shared-sandbox child that starts while its parent's copy is still running. Since 87392f5, a mark older than 5 minutes holds none of them, so a copy whose app died leaves at most 5 minutes after it began in which later starts wait, and a later start never waits past the copy's own limit. A legitimate copy that runs longer than 5 minutes, or one paused for that long (a sleeping sandbox resumes without re-marking), no longer holds unpromised starts; they start as they did before this PR.looks_emptynow treats a folder that doesn't exist yet as empty, because the wait runs beforeensure_directory.settleandfiles waitalways pass an existing folder, so nothing changes for them.