Skip to content

Start a Cloud agent only once its project copy lands - #94

Open
jparker2006 wants to merge 4 commits into
davidondrej:mainfrom
jparker2006:core-child-project-skills
Open

jparker2006 wants to merge 4 commits into
davidondrej:mainfrom
jparker2006:core-child-project-skills

Conversation

@jparker2006

@jparker2006 jparker2006 commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

Problem. A child started with room-cli thread spawn in 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/.system and ~/.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 marker CLAUDE_MD_MARKER_7731, and an AGENTS.md with marker AGENTS_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):

Child Sandbox cwd / git root Project skills loaded Project instructions
claude-code (haiku) own /code, /code none of 3 none
codex own /code, /code none (probe-agents-real missing) none
claude-code (haiku) --shared-sandbox /code, /code all 3 CLAUDE_MD_MARKER_7731
codex --shared-sandbox /code, /code probe-agents-real AGENTS_MD_MARKER_4412
  • The own-sandbox Claude child still had neither the skills nor the marker on a second turn about 50 s later, though the files were on disk by then.
  • In that child, ps lstart puts the claude process at 00:38:39. The birth times of /code, /code/.claude/skills and /code/CLAUDE.md are 00:38:41.34 to .37, and .git finished at 00:38:41.6. The project landed about 2 s after the harness had started.
  • This PR was written by another own-sandbox Claude child of the same fixture, and it hit the same bug. Claude started at 00:43:30 and /code/CLAUDE.md was created at 00:43:31.8. Its session never got the CLAUDE.md marker or the probe skills.

Ruled out.

  • A missing "project" setting source. Both Claude children ran with the same argv from claude.rs command(), which has no --setting-sources, so the CLI default (user, project and local) applies. The shared-sandbox child loaded everything with that argv.
  • Codex trust. The shared Codex child loaded .agents/skills from the same /code.

Cause. Manager::launch runs ensure_directory and 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.rs send() calls files::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-files as copying after Core has accepted the start, in deliverPending:

  • Own-sandbox child (child_request → ownSandboxChild → deliverPending): the mark is written once client.start returns, and for a Personal project only after a further sessionWorkspace call.
  • New thread: the mark is written only after the first message has been sent (ADR 0160).

The app cannot write the mark any earlier. writeFilesState finds the folder through GET /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/sessions takes project_copy: true (new capability project_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.
  • launch first waits in files::landed until the status file says ready or failed: …. It reuses check and status_file, polling every 250 ms.
    • With project_copy, it also waits while the file doesn't exist yet.
    • Without it, it waits only while the file says 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 that room-cli files wait gives. It also never waits for a mark whose mtime is older than the 5-minute limit. The app writes copying once, 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 wait and settle are unchanged.
    • A failed copy starts the agent at once.
  • The wait happens before the startups permit is taken, so one slow copy holds up only its own session.
    • It ends early on service stop or on close.
    • The session stays pending the whole time. Prompts queue, stop completes and pauses the queue (as it already did before a harness existed), and close closes it.
    • A core restart during the wait starts the wait again, through the existing waiting_start.
  • relaunch never waits, so resume, wake and restart of a session that already started are unchanged.
  • The wait is capped at 5 minutes (PROJECT_COPY_WAIT).
    • Most copies take seconds: about 2 s for the child's code above, and 10 to 20 s for a small upload from a Mac on a home connection (from the project-copy.ts comment).
    • An upload piece only counts as stalled after 2 minutes and is then sent again, so 5 minutes covers one stall plus the resend.
    • That is still half of what room-cli files wait allows (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 run room-cli files wait, which then reports the failure or waits for the rest.
  • A retry of the same start may change project_copy, which is ignored like plan_only, so a retry that plans its copy differently does not get a 409.
  • agent_start still measures only harness startup: its timer starts after the wait.
  • docs/api.md documents the field.

App:

  • deliverPending sends project_copy: true only 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.
    • In sandbox threads the copy is now planned before the start. On a GitHub project that costs one workspace lookup, and the copy that follows takes far longer anyway.
    • On a VM the start does not wait for planning. Only a project's first thread there copies anything, and the plan (a workspace lookup, ls on the VM, and a .env scan on the Mac) would slow down every later start.
  • The hint texts are unchanged. They are still right for older cores, and room-cli files wait now just returns at once.

Tests.

  • Failing tests first. Commit c0005de only adds tests and a fake-harness change: the fixture Codex now records the folder it sees as it starts. On upstream/main the harness sees [] instead of ['.agents', '.claude', 'AGENTS.md', 'CLAUDE.md'] in test_harness_starts_once_a_running_project_copy_lands and test_restart_during_the_wait_and_resume_after_the_copy. The project_copy tests fail with 422 unknown field project_copy. test_stop_and_close_during_the_wait gets 409 session is still starting, because the harness is already starting.
  • New tests in core/tests/workspaces.py:
    • test_harness_starts_once_a_running_project_copy_lands: with a folder marked copying before the start, the harness is not running after 1 s (pending, no native ID). Once the files land and the status is ready, it starts and sees them.
    • test_a_mark_left_by_a_dead_copy_holds_no_start (87392f5): a start without the flag, next to a copying mark 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 says project_copy, the status file is still missing, then it says copying, the files land, then ready. 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 that failed each run their first turn in under 4 s, far below the 10 s grace.
    • test_a_waiting_start_holds_up_no_other_start: cpu_count starts 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 marked copying again.
  • Rust unit test 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, and ready or failed returns at once. A mark older than the limit holds a promised start but not an unpromised one.
  • App vitest in cloudroom-sandboxes.test.ts: in a sandbox thread, the start carries project_copy: true for a copyable project, only after the copy was planned. It carries project_copy: true for 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.
  • Results:
    • Core: cargo fmt --check and cargo clippy --locked --all-targets -- -D warnings pass.
    • cargo test --locked --all-targets --no-fail-fast (rerun on 87392f5) passes except harness_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).
    • These pass: core_fixture.py (32), pi_http.py (15), workspaces.py (13 on 87392f5), preview_client.py, install.py, and integrations/bb/client.test.ts.
    • sync_e2e.py: one run failed test_authentication_device_and_path_boundaries in 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.
    • App: turbo run build typecheck for app, server and desktop passes, and all 12 test/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.
    • Not run: storage_e2e.py (needs sudo, useradd and Docker; a_full_disk_stops_the_writing_tool_command_not_the_harness lives there), previews_ssh.py, and core_e2e.py --foundation/--fixture (PostgreSQL).
  • No live end-to-end check. Real claude and codex CLIs can't be run from a shell here, and checking against the real harnesses needs a core release. The repro table above is the "before" evidence.

For review. Choices worth challenging:

  1. A start option rather than reordering the app. The app can't mark a folder that Core hasn't registered yet (see Cause). The other options were a grace period for every empty folder (10 s added to blank threads) or guessing from parent_thread (it covers children only).
  2. The 5-minute limit (reasons above). Too short brings the bug back silently for slow copies; too long holds a dead copy's agent for longer.
  3. Starts without the flag also wait while the file says 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.
  4. On a VM the copy is not planned before the start, so a project's first VM thread keeps today's behavior. In a sandbox, a non-GitHub project that is too large to copy now pays its local scan before the start.
  5. Forks are not covered. They start through Teleport activation, and their code is marked as copying only after that, so the same race remains. That fits a follow-up with the same field on the Teleport manifest.
  6. looks_empty now treats a folder that doesn't exist yet as empty, because the wait runs before ensure_directory. settle and files wait always pass an existing folder, so nothing changes for them.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant