Skip to content

cmux-next: screenshot and video tours for UI pull requests - #16427

Merged
teamleaderleo merged 22 commits into
feat-cmux-nextfrom
feat-cmux-next-ci-media
Oct 10, 2026
Merged

teamleaderleo merged 22 commits into
feat-cmux-nextfrom
feat-cmux-next-ci-media

Conversation

@teamleaderleo

@teamleaderleo teamleaderleo commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

Part of the cmux-next parity work listed in #16174 (comment).

Problem

UI pull requests into feat-cmux-next have no visual evidence from CI. The old app's media pipeline (pr-media.yml, test-e2e.yml, DogfoodScenarioUITests) depends on pieces this branch deleted, and cmux-next CI only runs swift test.

What this adds

cmux-next-media.yml runs on pull requests into feat-cmux-next that touch Packages/macOS/CmuxNext/Sources, webviews/src (agent pane) or the tour files:

  1. route picks an owned Mac's gui runner (scripts/ci/cmux_next_media_runner.py, built on pr_runner_pool). It never falls back to Blacksmith, whose macOS sessions sit at a locked screen and cannot capture. Fork heads and an empty gui pool skip the tour, and the comment says why.

  2. tour builds the head with scripts/reload.sh --tag ci-media. The fixed tag keeps the mini's DerivedData warm. The job then launches the app on a private socket (the smoke-signed-app-cli.sh knobs) and runs scripts/cmux-next/media/tour.py. The tour drives the app only through the bundled cmux CLI from cmux-next: the Rust cmux CLI (cmux-tui) with acpmux and cmux acp; delete the Swift CLI #16174 and captures:

    • one screenshot per step, covering the window's screen rect so browser child windows are included;
    • a frame about every 0.5 s for the video.

    Capture runs in the console user's session, as main's E2E recorder does.

  3. publish makes an mp4 and a GIF from the frames. It uploads them and the screenshots to pr-media/<pr>/<sha8>/next-<tour>/ (the layout prune_pr_media.py already bounds) through scripts/pr-media.py's writer, and keeps one sticky comment with:

    • the GIF;
    • a step table;
    • failed steps' screenshots inline;
    • the other screenshots folded.

Tours are data: scripts/cmux-next/media/tours/*.json. A step is a title plus a cmux argv (with daemon: true for daemon verbs), wait, shot, and optional: "<why>" for features not ported yet. Other lanes add steps or a tour file. tour.py --check validates them, and the tour job runs that check too.

The starter tour covers:

  • a new workspace;
  • typing in the terminal;
  • split right and split down;
  • a new terminal tab;
  • a browser tab;
  • hiding and showing the sidebar;
  • a notification;
  • the notifications panel and agent chat. These two are marked optional because they are bindUnavailable today; they show as "unavailable" until cmux-next: agent pane hosts the React acpmux pane (New Agent Chat) #16231 and the sidebar/notifications lane land.

cmux-next has no XCUITest target, so the tour is the UI check: a failed step fails the tour job and its screenshot shows inline in the comment.

Choices to review

  • Base feat-cmux-next-acpmux, stacked. The tour drives cmux-next: the Rust cmux CLI (cmux-tui) with acpmux and cmux acp; delete the Swift CLI #16174's CLI (--app-socket, action run); on feat-cmux-next the app still bundles the Swift CLI. This stays open until cmux-next: the Rust cmux CLI (cmux-tui) with acpmux and cmux acp; delete the Swift CLI #16174 merges and GitHub retargets it, then gets feat-cmux-next merged in and re-checked. It carries 822db55 (the Swift 6.2 ResourceControl build fix already on feat-cmux-next) so the base compiles for the tour; that commit drops out of the diff after the retarget. Red checks inherited from the base (cmux-next swift test, the 239-commit gap; the fork-routing guard on cmux-next.yml:60) are not from this diff.
  • Write token: approved by Leo. The publish job holds contents: write and pull-requests: write, on same-repository heads only. It never checks out or runs the pull request's code: publish.py and pr-media.py come from the base SHA, and the tour artifact is read as data (tour folders must be slugs, shot paths must be shots/NN-slug.png regular files inside the tour folder, PNG by content, and manifest text is HTML-escaped). The route and tour jobs stay contents: read. Until the base has publish.py (this PR's own runs), the publish job notes that and leaves the media in the run artifact.

What the CI runs found

  • The gui runner works for build and launch: reload.sh --tag ci-media builds on the mini, the app answers on its private socket, and workspace new, terminal typing, tab new-browser, the sidebar toggle and notify all pass.
  • action run splitRight / splitDown / newSurface fail on this base with terminal_id must be a 32-character lowercase UUIDv4 hex value: an app-side bug the tour caught (reported on cmux-next: the Rust cmux CLI (cmux-tui) with acpmux and cmux acp; delete the Swift CLI #16174). They stay required steps, so the tour job is red until it is fixed.
  • Screen capture: the gui runner's job process has no Screen Recording permission and no passwordless sudo, so screencapture and the TCC grant main's E2E lane uses do not work there. The tour now also tries an approved CuaSshScreenCapture.app (main's release-media helper), and the comment says which route failed when none works.

Testing

  • tests/test_cmux_next_media.py (wired into the preflight guard group) covers:
    • the runner pick: a gui label for a same-repo head; nothing for a fork, owned pools off, no gui slots, another Xcode pin, or malformed slots;
    • tour validation;
    • the comment rendering.
  • scripts/ci/guards-local.sh --all passes except for failures that already exist on feat-cmux-next-acpmux (fork routing in cmux-next.yml:60, the determinism gate, the iOS conventions, the bash shell checks). None of them touch these files.
  • Live CI runs on this PR follow; I will post the first tour comment's state here.

Example output

From CI run 4 on be0c62d, published by hand with publish.py while the base has no publisher yet. Split and new tab fail on an app bug in the acpmux base (reported on #16174).

Core UI: 7 ok, 3 failed, 2 unavailable

tour

Video (mp4)

# Step Result Command
1 Launch ok
2 New workspace ok cmux workspace new --name Tour
3 Type in the terminal ok cmux terminal current write --text "echo hello from the cmux-next tour\r"
4 Split right failed: exited 1 cmux action run splitRight
5 Split down failed: exited 1 cmux action run splitDown
6 New terminal tab failed: exited 1 cmux action run newSurface
7 Browser tab ok cmux tab new-browser --url https://example.com
8 Hide the sidebar ok cmux action run toggleSidebar
9 Show the sidebar ok cmux action run toggleSidebar
10 Notification ok cmux notify --title Tour --body "Hello from CI"
11 Notifications panel unavailable: exited 1 (the notifications panel is not ported yet) cmux action run showNotifications
12 Agent chat unavailable: exited 1 (agent chat views are not ported yet) cmux agent new-chat

Failed steps:

4. Split right
Split right

5. Split down
Split down

6. New terminal tab
New terminal tab

Screenshots (8)

1. Launch
Launch

2. New workspace
New workspace

3. Type in the terminal
Type in the terminal

7. Browser tab
Browser tab

8. Hide the sidebar
Hide the sidebar

10. Notification
Notification

11. Notifications panel
Notifications panel

12. Agent chat
Agent chat

🤖 Generated with Claude Code

— Zoetrope g1 🗝️ · run run_cc_next_ci_media_20261001_e712de82


Note

Medium Risk
Touches cmux-next routing, cmux-tui tree pinning, and a publish job with write tokens; behavior is guarded but mis-routing could skip or run wrong Mac tiers.

Overview
Adds cmux-next PR media CI: a new workflow builds the head app on an owned Mac GUI runner, runs JSON-defined CLI tours (tour.py), and posts GIF/video/screenshots via publish.py from the base revision (same-repo only; forks skip with an explanation).

cmux-next CI gains a Swift canary job for small package-only CmuxNext edits (debug swift build on changed production targets); Release compile stays advisory on that PR class. cmux-tui same-tree probing can fall back to the newest published tree (or skip tree jobs) when a PR does not touch cmux-tui/ghostty-next, with CMUX_TUI_TREE_KEY wired into daemon/scheme jobs. Mac job placement now keeps minis through bot attempt 3 and sends attempt 4 to Blacksmith after rescue re-runs.

cmux-tui-spec runs scoped cargo fmt --check via cmux-tui-fmt-changed.py (plus unit tests). Gallery PR renders narrow and normal widths, shards evidence across four parallel jobs, and merges results before compare. Pre-commit formats staged webviews (vp fmt) and cmux-tui Rust (rustfmt), skips merges, and documents the behavior in CONTRIBUTING.

Other fixes: manifest publish verification retries remote binary digest mismatches with fresh URLs; nightly release-notes step gets its own timeout; gh-merge-green requires the canary check; new UI reference sources plan doc and tier doc updates.

Reviewed by Cursor Bugbot for commit 9eab0ac. Bugbot is set up for automated code reviews on this repo. Configure here.

A new cmux-next-media workflow builds the head as a tagged dev app on an
owned Mac's gui runner, launches it on a private control socket, drives a
tour through the bundled cmux CLI and captures a screenshot per step plus
frames for a video. The publish job uploads them to pr-media/<pr>/<sha8>/
and keeps one sticky comment on the pull request.

Tours are JSON in scripts/cmux-next/media/tours so other lanes can add
steps. The runner is picked by scripts/ci/cmux_next_media_runner.py and
never falls back to Blacksmith, whose sessions cannot capture.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Repository: manaflow-ai/cmux/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: f2bcbea3-2734-4ca5-9583-2a08f1699155

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

CI fast guards passes on 9eab0ace79 (https://github.com/manaflow-ai/cmux/actions/runs/38017503270). This covers only the fast guards, not CI: CI's result is the ci-status check, and the CI failure attribution comment names any failing test.

@blacksmith-sh

This comment has been minimized.

@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Passes: CI passes on 9eab0ace79.

CI passes on 9eab0ace79 (run 38017503270 attempt 1).

Written by scripts/ci/classify_failures.py (ci-failure-attribution.yml); signatures are its SIGNATURES table. A machine verdict is the runner's fault, not this PR's; yours means the failing file is one this PR changes, also red on main that main's latest full suite fails the same way, seen on other PRs that it failed on another pull request's run lately.

@github-actions

github-actions Bot commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

cmux-next tour of 6c8c3c857f5db08d5ad4ec37ddb5629b88167219 (run)

The tour job failed; failed steps are below, and the run log has the build output.

Core UI: 7 ok, 3 failed, 2 unavailable

# Step Result Command
1 Launch ok
2 New workspace ok cmux workspace new --name Tour
3 Type in the terminal ok cmux terminal current write --text "echo hello from the cmux-next tour\r"
4 Split right failed: exited 1 cmux pane split-right
5 Split down failed: exited 1 cmux pane split-down
6 New terminal tab failed: exited 1 cmux tab new-terminal
7 Browser tab ok cmux tab new-browser --url https://example.com
8 Hide the sidebar ok cmux action run toggleSidebar
9 Show the sidebar ok cmux action run toggleSidebar
10 Notification ok cmux notify --title Tour --body "Hello from CI"
11 Notifications panel unavailable: exited 1 (the notifications panel is not ported yet) cmux action run showNotifications
12 Agent chat unavailable: exited 1 (agent chat views are not ported yet) cmux agent new-chat

Tours are data in scripts/cmux-next/media/tours: add a step or a tour file to show your feature.

…rget function)

ResourceControl.run declares `let target = try await MainActor.run { try
target(...) }`. Swift 6.3 (Xcode 27) resolves the call to the static
function; Swift 6.2 (Xcode 26.3 and 26.6, which the fleet workers use)
resolves it to the local being declared and fails with "generic parameter
'T' could not be inferred", so every fleet build of feat-cmux-next since
3d9c39f failed (fleet job 20ae3ebca821c9f69f0b857f). Call Self.target.
Reproduced and checked with a minimal file under Xcode 26.3 swiftc.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 822db55)

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

8 issues found and verified against the latest diff

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="scripts/cmux-next/media/publish.py">

<violation number="1" location="scripts/cmux-next/media/publish.py:187">
P2: The generated MP4 bypasses `pr-media.py`'s 25 MB file limit. Check the video with `pr_media.check_size(local, "video")` before uploading, otherwise larger tours leave partial media and no comment.</violation>
</file>

<file name="tests/test_cmux_next_media.py">

<violation number="1" location="tests/test_cmux_next_media.py:60">
P3: `TourFormat.write()` and `Comment.test_dry_run_renders_from_tour_output()` create temp directories with `tempfile.mkdtemp()` and never remove them, leaking a directory in `/tmp` for every test run (9 in the `test_bad_steps` subTests alone). Track them and clean up in `tearDown`, or use `tempfile.TemporaryDirectory()` as a context manager in each test.</violation>
</file>

<file name="scripts/cmux-next/media/tours/core.json">

<violation number="1" location="scripts/cmux-next/media/tours/core.json:13">
P3: The tour screenshots the sidebar only in its hidden state: "Hide the sidebar" captures a shot, while "Show the sidebar" sets `shot: false`. For UI visual evidence of the sidebar feature this skips the one state a reviewer wants to see. Capture the visible state instead (e.g., drop `shot: false` here, or put it on the Hide step if the hidden-state shot is redundant).</violation>
</file>

<file name="scripts/cmux-next/media/tour.py">

<violation number="1" location="scripts/cmux-next/media/tour.py:174">
P2: The screenshot rect comes from `NSScreen.frame` (`Screen.__init__`/`follow`), which is in points, while `screencapture -R` interprets its rectangle in the display's backing pixel space. On a Retina (2x) runner the point-based rect covers only the top-left quarter of the actual window pixels, cropping every step screenshot and every video frame. Verify the owned runner's display scale, and multiply by `backingScaleFactor` (or capture by CGWindow ID) if it is 2x.</violation>

<violation number="2" location="scripts/cmux-next/media/tour.py:201">
P3: A failed frame capture can leave an empty `frame-NNNNN.jpg` behind: `capture()` treats `st_size == 0` as an error but never deletes the file, and the recorder only skips appending the frame name. When the failing capture is the last one, ffmpeg's `frame-%05d.jpg` pattern reads the empty jpg and `make_video` drops the entire mp4/GIF for the tour. Unlink the file when a capture fails (also for step screenshots, so a failed screenshot does not leave a 0-byte png that confuses uploads).</violation>

<violation number="3" location="scripts/cmux-next/media/tour.py:272">
P1: Run daemon-scoped CLI calls as the console user, or pass the daemon’s explicit socket path. With a different runner and console UID, `--session cmux-app[-tag]` resolves under the runner’s runtime directory and cannot reach the daemon started by the app, causing required terminal and notification tour steps to fail on the supported cross-user setup.</violation>

<violation number="4" location="scripts/cmux-next/media/tour.py:411">
P2: Treat `shot_error` as a tour failure. The current status check returns success when `screencapture` fails for an otherwise successful required step, so CI can publish a green tour without the promised screenshot.</violation>
</file>

<file name=".github/workflows/cmux-next-media.yml">

<violation number="1" location=".github/workflows/cmux-next-media.yml:35">
P3: Fork PRs never receive the explanatory comment this workflow promises. The `if` on the `route` job skips the entire job for fork heads, so `needs.route.outputs.reason` is empty, and `publish`'s `if: ${{ !cancelled() && needs.route.result == 'success' }}` evaluates false (route is `skipped`) — publish is skipped too, so no comment is posted at all. That also makes `cmux_next_media_runner.py`'s `if not same_repo` branch unreachable. The header comment and PR description state the comment explains why the tour did not run; for forks it never does. If the skip is intentional (fork-controlled `publish.py` must not run with the pr-media write token), update the header/description to state forks are excluded without a comment, and drop the dead fork branch or route only forks into a reason-less path.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

leading = [word for word in args[:1] if word in ("--json", "--jsonl", "--quiet")]
argv = [str(self.cli_path), *leading, *scope, *args[len(leading):]]
try:
return subprocess.run(argv, capture_output=True, text=True, timeout=timeout, env=self.environment())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Run daemon-scoped CLI calls as the console user, or pass the daemon’s explicit socket path. With a different runner and console UID, --session cmux-app[-tag] resolves under the runner’s runtime directory and cannot reach the daemon started by the app, causing required terminal and notification tour steps to fail on the supported cross-user setup.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At scripts/cmux-next/media/tour.py, line 272:

<comment>Run daemon-scoped CLI calls as the console user, or pass the daemon’s explicit socket path. With a different runner and console UID, `--session cmux-app[-tag]` resolves under the runner’s runtime directory and cannot reach the daemon started by the app, causing required terminal and notification tour steps to fail on the supported cross-user setup.</comment>

<file context>
@@ -0,0 +1,447 @@
+        leading = [word for word in args[:1] if word in ("--json", "--jsonl", "--quiet")]
+        argv = [str(self.cli_path), *leading, *scope, *args[len(leading):]]
+        try:
+            return subprocess.run(argv, capture_output=True, text=True, timeout=timeout, env=self.environment())
+        except subprocess.TimeoutExpired as expired:
+            return subprocess.CompletedProcess(argv, 124, expired.stdout or "", f"timed out after {timeout:.0f}s")
</file context>

Comment thread scripts/cmux-next/media/publish.py Outdated
Comment thread scripts/cmux-next/media/publish.py
Comment thread scripts/cmux-next/media/publish.py Outdated
Comment on lines +187 to +189
for local in (mp4, gif):
if local:
files[local.name] = local

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The generated MP4 bypasses pr-media.py's 25 MB file limit. Check the video with pr_media.check_size(local, "video") before uploading, otherwise larger tours leave partial media and no comment.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At scripts/cmux-next/media/publish.py, line 187:

<comment>The generated MP4 bypasses `pr-media.py`'s 25 MB file limit. Check the video with `pr_media.check_size(local, "video")` before uploading, otherwise larger tours leave partial media and no comment.</comment>

<file context>
@@ -0,0 +1,209 @@
+            into = Path(scratch) / name
+            into.mkdir()
+            mp4, gif = make_video(directory, manifest, into, pr_media)
+            for local in (mp4, gif):
+                if local:
+                    files[local.name] = local
</file context>
Suggested change
for local in (mp4, gif):
if local:
files[local.name] = local
for local in (mp4, gif):
if local:
pr_media.check_size(local, "video" if local.suffix == ".mp4" else "gif")
files[local.name] = local



def passed(manifest: dict[str, Any]) -> bool:
return "error" not in manifest and all(step["status"] != "failed" for step in manifest["steps"])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Treat shot_error as a tour failure. The current status check returns success when screencapture fails for an otherwise successful required step, so CI can publish a green tour without the promised screenshot.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At scripts/cmux-next/media/tour.py, line 411:

<comment>Treat `shot_error` as a tour failure. The current status check returns success when `screencapture` fails for an otherwise successful required step, so CI can publish a green tour without the promised screenshot.</comment>

<file context>
@@ -0,0 +1,447 @@
+
+
+def passed(manifest: dict[str, Any]) -> bool:
+    return "error" not in manifest and all(step["status"] != "failed" for step in manifest["steps"])
+
+
</file context>
Suggested change
return "error" not in manifest and all(step["status"] != "failed" for step in manifest["steps"])
return "error" not in manifest and all(step["status"] != "failed" and "shot_error" not in step for step in manifest["steps"])

{"title": "New terminal tab", "cmux": ["tab", "new-terminal"]},
{"title": "Browser tab", "cmux": ["tab", "new-browser", "--url", "https://example.com"], "wait": 5},
{"title": "Hide the sidebar", "cmux": ["action", "run", "toggleSidebar"]},
{"title": "Show the sidebar", "cmux": ["action", "run", "toggleSidebar"], "shot": false},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The tour screenshots the sidebar only in its hidden state: "Hide the sidebar" captures a shot, while "Show the sidebar" sets shot: false. For UI visual evidence of the sidebar feature this skips the one state a reviewer wants to see. Capture the visible state instead (e.g., drop shot: false here, or put it on the Hide step if the hidden-state shot is redundant).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At scripts/cmux-next/media/tours/core.json, line 13:

<comment>The tour screenshots the sidebar only in its hidden state: "Hide the sidebar" captures a shot, while "Show the sidebar" sets `shot: false`. For UI visual evidence of the sidebar feature this skips the one state a reviewer wants to see. Capture the visible state instead (e.g., drop `shot: false` here, or put it on the Hide step if the hidden-state shot is redundant).</comment>

<file context>
@@ -0,0 +1,21 @@
+    {"title": "New terminal tab", "cmux": ["tab", "new-terminal"]},
+    {"title": "Browser tab", "cmux": ["tab", "new-browser", "--url", "https://example.com"], "wait": 5},
+    {"title": "Hide the sidebar", "cmux": ["action", "run", "toggleSidebar"]},
+    {"title": "Show the sidebar", "cmux": ["action", "run", "toggleSidebar"], "shot": false},
+    {"title": "Notification", "daemon": true,
+     "cmux": ["notify", "--title", "Tour", "--body", "Hello from CI"], "wait": 2},
</file context>
Suggested change
{"title": "Show the sidebar", "cmux": ["action", "run", "toggleSidebar"], "shot": false},
{"title": "Show the sidebar", "cmux": ["action", "run", "toggleSidebar"]},

Comment thread .github/workflows/cmux-next-media.yml Outdated
jobs:
route:
name: Pick the tour runner
if: ${{ github.repository_owner == 'manaflow-ai' && github.event.pull_request.head.repo.full_name == github.repository }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Fork PRs never receive the explanatory comment this workflow promises. The if on the route job skips the entire job for fork heads, so needs.route.outputs.reason is empty, and publish's if: ${{ !cancelled() && needs.route.result == 'success' }} evaluates false (route is skipped) — publish is skipped too, so no comment is posted at all. That also makes cmux_next_media_runner.py's if not same_repo branch unreachable. The header comment and PR description state the comment explains why the tour did not run; for forks it never does. If the skip is intentional (fork-controlled publish.py must not run with the pr-media write token), update the header/description to state forks are excluded without a comment, and drop the dead fork branch or route only forks into a reason-less path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At .github/workflows/cmux-next-media.yml, line 35:

<comment>Fork PRs never receive the explanatory comment this workflow promises. The `if` on the `route` job skips the entire job for fork heads, so `needs.route.outputs.reason` is empty, and `publish`'s `if: ${{ !cancelled() && needs.route.result == 'success' }}` evaluates false (route is `skipped`) — publish is skipped too, so no comment is posted at all. That also makes `cmux_next_media_runner.py`'s `if not same_repo` branch unreachable. The header comment and PR description state the comment explains why the tour did not run; for forks it never does. If the skip is intentional (fork-controlled `publish.py` must not run with the pr-media write token), update the header/description to state forks are excluded without a comment, and drop the dead fork branch or route only forks into a reason-less path.</comment>

<file context>
@@ -0,0 +1,181 @@
+jobs:
+  route:
+    name: Pick the tour runner
+    if: ${{ github.repository_owner == 'manaflow-ai' && github.event.pull_request.head.repo.full_name == github.repository }}
+    runs-on: ${{ github.repository_owner != 'manaflow-ai' && 'ubuntu-24.04' || github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'blacksmith-4vcpu-ubuntu-2404' || vars.LINUX_RUNNER || 'blacksmith-4vcpu-ubuntu-2404' }}
+    timeout-minutes: 5
</file context>

Comment thread scripts/cmux-next/media/tour.py
Comment thread scripts/cmux-next/media/tour.py Outdated
started = time.monotonic()
path = self.directory / f"frame-{len(self.frames):05d}.jpg"
problem = self.screen.capture(path, "jpg")
if problem:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: A failed frame capture can leave an empty frame-NNNNN.jpg behind: capture() treats st_size == 0 as an error but never deletes the file, and the recorder only skips appending the frame name. When the failing capture is the last one, ffmpeg's frame-%05d.jpg pattern reads the empty jpg and make_video drops the entire mp4/GIF for the tour. Unlink the file when a capture fails (also for step screenshots, so a failed screenshot does not leave a 0-byte png that confuses uploads).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At scripts/cmux-next/media/tour.py, line 201:

<comment>A failed frame capture can leave an empty `frame-NNNNN.jpg` behind: `capture()` treats `st_size == 0` as an error but never deletes the file, and the recorder only skips appending the frame name. When the failing capture is the last one, ffmpeg's `frame-%05d.jpg` pattern reads the empty jpg and `make_video` drops the entire mp4/GIF for the tour. Unlink the file when a capture fails (also for step screenshots, so a failed screenshot does not leave a 0-byte png that confuses uploads).</comment>

<file context>
@@ -0,0 +1,447 @@
+            started = time.monotonic()
+            path = self.directory / f"frame-{len(self.frames):05d}.jpg"
+            problem = self.screen.capture(path, "jpg")
+            if problem:
+                self.error = problem
+            else:
</file context>

…ne and tab steps by action id

Run 36903298606: every capture failed with "could not create image from
rect", and pane split-right / tab new-terminal were taken by the daemon
grammar as terminal ids, so those steps run their app actions by id.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 issues found and verified against the latest diff

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="tests/test_cmux_next_media.py">

<violation number="1" location="tests/test_cmux_next_media.py:139">
P3: `test_dry_run_renders_from_tour_output` asserts only that `publish.main` exits 0, but the dry-run path returns 0 even when `manifests()` finds nothing (it would print "The tour produced no media."). A regression that broke media discovery or rendering would still make this test pass. Capture the printed body and assert it contains the tour content, e.g. `### Core UI`.</violation>
</file>

<file name="scripts/ci/cmux_next_media_runner.py">

<violation number="1" location="scripts/ci/cmux_next_media_runner.py:47">
P1: This skips every tour when the valid pool configuration omits an explicit GUI slot count, even if an owned GUI runner is online. Use live GUI-runner discovery for this route, or pass a GUI-capacity source alongside the pool slots before returning no runner.</violation>
</file>

<file name="scripts/cmux-next/media/publish.py">

<violation number="1" location="scripts/cmux-next/media/publish.py:151">
P3: `upsert_comment` patches only `existing[0]`, the first marker comment listed, and never reconciles the rest. If a PR ever carries two marker comments (e.g. one posted by a run whose successor/ancestor both finished, or a duplicate left by a manual edit), only the oldest is updated and the second goes stale forever, breaking the one-sticky-comment invariant. Update the newest marker comment and delete the others, or have each run remove every marker comment it does not own.</violation>

<violation number="2" location="scripts/cmux-next/media/publish.py:176">
P2: A missing or empty artifact directory becomes a successful `The tour produced no media.` comment, masking artifact-download failures after a successful tour. Treat absent manifests as an error unless an explicit `--note` explains the skipped or failed tour.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

slots = pr_runner_pool.slots(owned_slots, pr_xcode_app)
for pool in pools:
gui = pr_runner_pool.gui_label(pool)
if gui and slots.get(gui, 0) > 0:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: This skips every tour when the valid pool configuration omits an explicit GUI slot count, even if an owned GUI runner is online. Use live GUI-runner discovery for this route, or pass a GUI-capacity source alongside the pool slots before returning no runner.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At scripts/ci/cmux_next_media_runner.py, line 47:

<comment>This skips every tour when the valid pool configuration omits an explicit GUI slot count, even if an owned GUI runner is online. Use live GUI-runner discovery for this route, or pass a GUI-capacity source alongside the pool slots before returning no runner.</comment>

<file context>
@@ -0,0 +1,72 @@
+    slots = pr_runner_pool.slots(owned_slots, pr_xcode_app)
+    for pool in pools:
+        gui = pr_runner_pool.gui_label(pool)
+        if gui and slots.get(gui, 0) > 0:
+            return gui, f"{slots[gui]} gui runner(s) on {pool}"
+    return "", "no owned pool has gui runners in CI_OWNED_POOL_SLOTS"
</file context>

pr_media = load_pr_media()
sections = []
with tempfile.TemporaryDirectory() as scratch:
for directory, manifest in manifests(args.media) if args.media.is_dir() else []:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A missing or empty artifact directory becomes a successful The tour produced no media. comment, masking artifact-download failures after a successful tour. Treat absent manifests as an error unless an explicit --note explains the skipped or failed tour.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At scripts/cmux-next/media/publish.py, line 176:

<comment>A missing or empty artifact directory becomes a successful `The tour produced no media.` comment, masking artifact-download failures after a successful tour. Treat absent manifests as an error unless an explicit `--note` explains the skipped or failed tour.</comment>

<file context>
@@ -0,0 +1,209 @@
+    pr_media = load_pr_media()
+    sections = []
+    with tempfile.TemporaryDirectory() as scratch:
+        for directory, manifest in manifests(args.media) if args.media.is_dir() else []:
+            name = manifest.get("name") or directory.name
+            folder = f"{args.pr}/{args.sha[:8]}/next-{name}"
</file context>

Comment thread tests/test_cmux_next_media.py Outdated
(media / "core" / "shots").mkdir(parents=True)
(media / "core" / "shots" / "01-launch.png").write_bytes(b"\x89PNG\r\n\x1a\n")
(media / "core" / "manifest.json").write_text(json.dumps(self.MANIFEST), encoding="utf-8")
self.assertEqual(publish.main(["--media", str(media), "--pr", "1", "--sha", "b" * 40, "--dry-run"]), 0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: test_dry_run_renders_from_tour_output asserts only that publish.main exits 0, but the dry-run path returns 0 even when manifests() finds nothing (it would print "The tour produced no media."). A regression that broke media discovery or rendering would still make this test pass. Capture the printed body and assert it contains the tour content, e.g. ### Core UI.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At tests/test_cmux_next_media.py, line 139:

<comment>`test_dry_run_renders_from_tour_output` asserts only that `publish.main` exits 0, but the dry-run path returns 0 even when `manifests()` finds nothing (it would print "The tour produced no media."). A regression that broke media discovery or rendering would still make this test pass. Capture the printed body and assert it contains the tour content, e.g. `### Core UI`.</comment>

<file context>
@@ -0,0 +1,143 @@
+        (media / "core" / "shots").mkdir(parents=True)
+        (media / "core" / "shots" / "01-launch.png").write_bytes(b"\x89PNG\r\n\x1a\n")
+        (media / "core" / "manifest.json").write_text(json.dumps(self.MANIFEST), encoding="utf-8")
+        self.assertEqual(publish.main(["--media", str(media), "--pr", "1", "--sha", "b" * 40, "--dry-run"]), 0)
+
+
</file context>

existing = ids.split()
payload = json.dumps({"body": body})
if existing:
gh(["api", "-X", "PATCH", f"repos/{repo}/issues/comments/{existing[0]}", "--input", "-"], payload)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: upsert_comment patches only existing[0], the first marker comment listed, and never reconciles the rest. If a PR ever carries two marker comments (e.g. one posted by a run whose successor/ancestor both finished, or a duplicate left by a manual edit), only the oldest is updated and the second goes stale forever, breaking the one-sticky-comment invariant. Update the newest marker comment and delete the others, or have each run remove every marker comment it does not own.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At scripts/cmux-next/media/publish.py, line 151:

<comment>`upsert_comment` patches only `existing[0]`, the first marker comment listed, and never reconciles the rest. If a PR ever carries two marker comments (e.g. one posted by a run whose successor/ancestor both finished, or a duplicate left by a manual edit), only the oldest is updated and the second goes stale forever, breaking the one-sticky-comment invariant. Update the newest marker comment and delete the others, or have each run remove every marker comment it does not own.</comment>

<file context>
@@ -0,0 +1,209 @@
+    existing = ids.split()
+    payload = json.dumps({"body": body})
+    if existing:
+        gh(["api", "-X", "PATCH", f"repos/{repo}/issues/comments/{existing[0]}", "--input", "-"], payload)
+    else:
+        gh(["api", "-X", "POST", f"repos/{repo}/issues/{pr}/comments", "--input", "-"], payload)
</file context>

@blacksmith-sh

This comment has been minimized.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 4 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="scripts/cmux-next/media/tour.py">

<violation number="1" location="scripts/cmux-next/media/tour.py:164">
P2: These checks treat a nonempty file as success even when `screencapture` exits nonzero. Require `returncode == 0` in the probe and both capture checks, otherwise failed or partial images can be published as valid evidence.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

said = (done.stderr or done.stdout).strip() or f"exit {done.returncode}"
except (OSError, subprocess.SubprocessError) as error:
said = str(error)
if image.is_file() and image.stat().st_size > 0:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: These checks treat a nonempty file as success even when screencapture exits nonzero. Require returncode == 0 in the probe and both capture checks, otherwise failed or partial images can be published as valid evidence.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At scripts/cmux-next/media/tour.py, line 164:

<comment>These checks treat a nonempty file as success even when `screencapture` exits nonzero. Require `returncode == 0` in the probe and both capture checks, otherwise failed or partial images can be published as valid evidence.</comment>

<file context>
@@ -119,25 +119,58 @@ def command_line(argv: list[str]) -> str:
+                said = (done.stderr or done.stdout).strip() or f"exit {done.returncode}"
+            except (OSError, subprocess.SubprocessError) as error:
+                said = str(error)
+            if image.is_file() and image.stat().st_size > 0:
+                self.capture_prefix, self.capture_mode = prefix, mode
+                return
</file context>

Comment thread scripts/cmux-next/media/tour.py Outdated
Comment thread scripts/cmux-next/media/tour.py Outdated
teamleaderleo and others added 2 commits October 1, 2026 11:20
… data, capture through the cua-ssh helper

The publish job holds the pr-media write token, so it checks out its
scripts from the base SHA and validates tour names and shot paths taken
from the artifact. Run 36904395488 showed the gui runner has no Screen
Recording and no passwordless sudo, so the tour also tries an approved
CuaSshScreenCapture.app (main's release-media helper backend), and the
comment says why when nothing could be captured.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…re, inert comment text)

- tour.py: SIGTERM unwinds through cleanup; the app stops before the
  recorder; a leftover app or daemon from a cancelled run is stopped
  before launch; recording starts once the window is on screen; shots
  never fall back to the whole shared display; the probe image is
  deleted; the helper runs in the console session; the crop rect is
  clamped to the display; the recorder's frame list is locked.
- publish.py: manifest steps are normalized to plain strings and ints,
  HTML, Markdown and @mentions are escaped, a bad tour is reported
  instead of failing the job, the video has a fixed size, and the head
  is checked again right before the comment is written.
- The typing step runs before the new workspace so it lands on screen.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 4 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="scripts/cmux-next/media/tour.py">

<violation number="1" location="scripts/cmux-next/media/tour.py:60">
P1: This resolves the helper from the runner's home, but `Session` explicitly supports a different console user. Resolve the helper under the console user and launch it through that user's session, otherwise the fallback is skipped and the tour produces no captures.</violation>
</file>

<file name=".github/workflows/cmux-next-media.yml">

<violation number="1" location=".github/workflows/cmux-next-media.yml:164">
P2: This checkout pins the publisher scripts to `base.sha`, but the media artifact they consume is produced by the head's `tour.py`, and the artifact format is an unversioned, moving contract. Within this PR alone the format already changed between commits (tour folder naming, `capture_mode` in manifest.json). On a future PR that changes `tour.py`'s output — shot naming, manifest fields, or tour folder layout — the base `publish.py` will silently drop the media: `shot_file()` returns None for anything not matching `SHOT_PATH` without a warning, and non-dict steps are filtered quietly, so the posted comment loses screenshots/video while the job stays green. That PR's author also cannot demonstrate working media for their own PR because the publisher is the last merged base revision. Version the artifact (e.g., a `format_version` field in manifest.json) and have `publish.py` fail fast with a clear message when it sees a newer version than it supports, or at least emit `::warning::` for each skipped step/shot so skew is visible in the run.</violation>
</file>

<file name="scripts/cmux-next/media/publish.py">

<violation number="1" location="scripts/cmux-next/media/publish.py:81">
P1: `shot_file` checks only the leaf, so a symlinked tour directory or `shots/` can make the publisher upload a file outside the artifact. Reject every path component (including the tour directory in `manifests`) before reading or uploading it.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

SOCKET_TIMEOUT = 60.0
FRAME_INTERVAL = 0.5
# scripts/release-media/host_agent.py on main looks in the same places.
HELPER_CANDIDATES = (Path.home() / "Applications/CuaSshScreenCapture.app",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: This resolves the helper from the runner's home, but Session explicitly supports a different console user. Resolve the helper under the console user and launch it through that user's session, otherwise the fallback is skipped and the tour produces no captures.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At scripts/cmux-next/media/tour.py, line 60:

<comment>This resolves the helper from the runner's home, but `Session` explicitly supports a different console user. Resolve the helper under the console user and launch it through that user's session, otherwise the fallback is skipped and the tour produces no captures.</comment>

<file context>
@@ -56,6 +56,9 @@
 SOCKET_TIMEOUT = 60.0
 FRAME_INTERVAL = 0.5
+# scripts/release-media/host_agent.py on main looks in the same places.
+HELPER_CANDIDATES = (Path.home() / "Applications/CuaSshScreenCapture.app",
+                     Path("/Applications/CuaSshScreenCapture.app"))
 # A long tour would make a GIF past the inline limit; split it instead.
</file context>

if not isinstance(shot, str) or not SHOT_PATH.fullmatch(shot):
return None
path = directory / shot
if path.is_symlink() or not path.is_file():

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: shot_file checks only the leaf, so a symlinked tour directory or shots/ can make the publisher upload a file outside the artifact. Reject every path component (including the tour directory in manifests) before reading or uploading it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At scripts/cmux-next/media/publish.py, line 81:

<comment>`shot_file` checks only the leaf, so a symlinked tour directory or `shots/` can make the publisher upload a file outside the artifact. Reject every path component (including the tour directory in `manifests`) before reading or uploading it.</comment>

<file context>
@@ -50,15 +53,36 @@ def load_pr_media() -> Any:
+    if not isinstance(shot, str) or not SHOT_PATH.fullmatch(shot):
+        return None
+    path = directory / shot
+    if path.is_symlink() or not path.is_file():
+        return None
+    return path
</file context>

- name: Checkout the publisher from the base revision
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.base.sha }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This checkout pins the publisher scripts to base.sha, but the media artifact they consume is produced by the head's tour.py, and the artifact format is an unversioned, moving contract. Within this PR alone the format already changed between commits (tour folder naming, capture_mode in manifest.json). On a future PR that changes tour.py's output — shot naming, manifest fields, or tour folder layout — the base publish.py will silently drop the media: shot_file() returns None for anything not matching SHOT_PATH without a warning, and non-dict steps are filtered quietly, so the posted comment loses screenshots/video while the job stays green. That PR's author also cannot demonstrate working media for their own PR because the publisher is the last merged base revision. Version the artifact (e.g., a format_version field in manifest.json) and have publish.py fail fast with a clear message when it sees a newer version than it supports, or at least emit ::warning:: for each skipped step/shot so skew is visible in the run.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At .github/workflows/cmux-next-media.yml, line 164:

<comment>This checkout pins the publisher scripts to `base.sha`, but the media artifact they consume is produced by the head's `tour.py`, and the artifact format is an unversioned, moving contract. Within this PR alone the format already changed between commits (tour folder naming, `capture_mode` in manifest.json). On a future PR that changes `tour.py`'s output — shot naming, manifest fields, or tour folder layout — the base `publish.py` will silently drop the media: `shot_file()` returns None for anything not matching `SHOT_PATH` without a warning, and non-dict steps are filtered quietly, so the posted comment loses screenshots/video while the job stays green. That PR's author also cannot demonstrate working media for their own PR because the publisher is the last merged base revision. Version the artifact (e.g., a `format_version` field in manifest.json) and have `publish.py` fail fast with a clear message when it sees a newer version than it supports, or at least emit `::warning::` for each skipped step/shot so skew is visible in the run.</comment>

<file context>
@@ -154,8 +156,12 @@ jobs:
+      - name: Checkout the publisher from the base revision
+        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
         with:
+          ref: ${{ github.event.pull_request.base.sha }}
           persist-credentials: false
           sparse-checkout: |
</file context>

teamleaderleo and others added 3 commits October 1, 2026 11:27
…failing the publish

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e app stops, signal-safe cleanup)

- A newline or CR in manifest text or a command can no longer end the
  code span or the table row.
- The recorder stops before the app is killed, so no frame shows what
  was behind the window; a window off the display clears the rect.
- Cleanup ignores further signals, and the step execs python so the
  runner's cancel signal reaches it.
- The helper's raw desktop image is written outside shots/ and frames/.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e window rect

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Review: third subagent pass over 92303ae (tour.py, publish.py, workflow).

Fixed in 034e69a:

  • The raw desktop capture had moved to the runner's private temp directory, which the capture helper cannot write when it runs as the console user. It now lands in the tour's shared scratch directory, still outside shots/ and frames/.
  • A window that vanished or shrank kept its old capture rect, so a "-failed" shot after an app crash would show whatever sat behind the window. Any unusable frame now clears the rect and nothing is captured. Test added.

Left: none. Signals, the recorder stop lock, exec, the shot path regex and comment escaping were confirmed fine.

Head: 034e69a

— Zoetrope g1 🗝️

teamleaderleo added a commit that referenced this pull request Oct 1, 2026
teamleaderleo added a commit that referenced this pull request Oct 1, 2026
teamleaderleo added a commit that referenced this pull request Oct 1, 2026
teamleaderleo added a commit that referenced this pull request Oct 1, 2026
teamleaderleo added a commit that referenced this pull request Oct 1, 2026
teamleaderleo added a commit that referenced this pull request Oct 1, 2026
teamleaderleo added a commit that referenced this pull request Oct 1, 2026
teamleaderleo added a commit that referenced this pull request Oct 1, 2026
teamleaderleo added a commit that referenced this pull request Oct 1, 2026
…und ffmpeg

Run 6 still launched into two earlier Tour workspaces: the daemon
outlives the app and keeps its workspaces, so deleting the state
directory under it changed nothing. The tour now ends the tagged
daemon through its socket (server stop --end-terminals) before the
pkill fallback and the state removal. An untagged build's daemon is
the user's and is never stopped.

Run 6's publish job spent its 15 minutes in apt installing ffmpeg.
The install is now capped at 4 minutes and may fail (the comment then
has screenshots and no video), and nothing installs when the base
revision has no publisher.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 3 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name=".github/workflows/cmux-next-media.yml">

<violation number="1" location=".github/workflows/cmux-next-media.yml:199">
P2: A step that exceeds `timeout-minutes` cancels the entire job: GitHub's runner treats the timed-out step as cancelled, and `continue-on-error: true` only applies to steps that fail, not to job cancellation. So a slow or stuck `apt-get` here cancels the publish job outright and the sticky comment is never posted — the opposite of the intent stated in the comment above the step. Wrap the apt command with coreutils `timeout` (e.g. `timeout 240`) and keep `continue-on-error: true` for a genuinely failed install; that fails only the step, and publish.py still posts the screenshots-only comment.</violation>
</file>

<file name="scripts/cmux-next/media/tour.py">

<violation number="1" location="scripts/cmux-next/media/tour.py:381">
P2: This stops all terminals in a tagged daemon even for the default local mode without `--fresh-state`, including terminals the developer already owns. Gate the stop on tour ownership rather than tag presence; at minimum, honor `fresh_state` here.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

# video, so a slow or stuck apt never holds the comment back.
- name: Install ffmpeg
if: ${{ steps.publisher.outputs.present == 'true' }}
timeout-minutes: 4

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A step that exceeds timeout-minutes cancels the entire job: GitHub's runner treats the timed-out step as cancelled, and continue-on-error: true only applies to steps that fail, not to job cancellation. So a slow or stuck apt-get here cancels the publish job outright and the sticky comment is never posted — the opposite of the intent stated in the comment above the step. Wrap the apt command with coreutils timeout (e.g. timeout 240) and keep continue-on-error: true for a genuinely failed install; that fails only the step, and publish.py still posts the screenshots-only comment.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At .github/workflows/cmux-next-media.yml, line 199:

<comment>A step that exceeds `timeout-minutes` cancels the entire job: GitHub's runner treats the timed-out step as cancelled, and `continue-on-error: true` only applies to steps that fail, not to job cancellation. So a slow or stuck `apt-get` here cancels the publish job outright and the sticky comment is never posted — the opposite of the intent stated in the comment above the step. Wrap the apt command with coreutils `timeout` (e.g. `timeout 240`) and keep `continue-on-error: true` for a genuinely failed install; that fails only the step, and publish.py still posts the screenshots-only comment.</comment>

<file context>
@@ -183,10 +183,27 @@ jobs:
       - name: Install ffmpeg
-        run: command -v ffmpeg >/dev/null || (sudo apt-get update -qq && sudo apt-get install -y -qq ffmpeg >/dev/null)
+        if: ${{ steps.publisher.outputs.present == 'true' }}
+        timeout-minutes: 4
+        continue-on-error: true
+        env:
</file context>

directory, so deleting that directory under a live daemon changes nothing.
An untagged build's session is the user's own, so it is never stopped.
"""
if self.bundle_tag:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This stops all terminals in a tagged daemon even for the default local mode without --fresh-state, including terminals the developer already owns. Gate the stop on tour ownership rather than tag presence; at minimum, honor fresh_state here.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At scripts/cmux-next/media/tour.py, line 381:

<comment>This stops all terminals in a tagged daemon even for the default local mode without `--fresh-state`, including terminals the developer already owns. Gate the stop on tour ownership rather than tag presence; at minimum, honor `fresh_state` here.</comment>

<file context>
@@ -371,13 +371,25 @@ def state_directory(self) -> Path | None:
+        directory, so deleting that directory under a live daemon changes nothing.
+        An untagged build's session is the user's own, so it is never stopped.
+        """
+        if self.bundle_tag:
+            self.cli(["server", "stop", "--end-terminals"], daemon=True, timeout=20)
+
</file context>

@teamleaderleo

Copy link
Copy Markdown
Collaborator Author

Tour state fixed at d82961b (run).

  • Runs 5 and 6 launched into earlier tours' workspaces: the tagged daemon outlives the app and keeps them. The tour now stops the tagged daemon through its socket (server stop --end-terminals), then clears the tag's state directory, with --fresh-state passed only by CI. Run 7 starts from one empty workspace, and the typed echo shows in the terminal.
  • The publish job no longer sits in apt: the ffmpeg install is capped at 4 minutes and skipped while the base has no publisher.

The tour job stays red on split right, split down and new terminal tab (the terminal_id bug in the base, reported on #16174). This PR waits for #16174 to merge and retarget it.

Head: d82961b

— Zoetrope g1 🗝️

A stuck tour recorded a frame every half second for as long as it ran.
The recorder now stops at five minutes of frames and the publisher's
ffmpeg reads no more than that, so neither the runner nor the publish
job grows with a slow tour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 3 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="scripts/cmux-next/media/publish.py">

<violation number="1" location="scripts/cmux-next/media/publish.py:41">
P3: MAX_FRAMES is duplicated in tour.py and this constant, and only a comment keeps them in sync. If tour.py's cap changes, the comment drifts and the mp4 cap silently diverges from the recorder cap. tests/test_cmux_next_media.py already imports both modules, so assert equality there to keep the coupling enforced.</violation>
</file>

<file name="scripts/cmux-next/media/tour.py">

<violation number="1" location="scripts/cmux-next/media/tour.py:313">
P3: The truncation message set on this branch is never surfaced: run_tour's finally block only writes recorder.error into the manifest when the frames list is empty (tour.py:579-580), but this branch fires only once 600 frames exist, so the guard is always false and the "video stops at 600 frames" notice never reaches the manifest (publish.py ignores frames_error, so nothing else can report it). A video cut short by the cap therefore looks finished with no indication it stopped at five minutes. Record the truncation with a dedicated flag (for example self.truncated = True here) and, in the finally block, write frames_error when that flag is set, instead of assigning an error this consumer cannot report.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

GIF_FPS = 4
VIDEO_SIZE = (1280, 800)
# tour.py's MAX_FRAMES: a larger artifact does not make a longer video.
MAX_FRAMES = 600

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: MAX_FRAMES is duplicated in tour.py and this constant, and only a comment keeps them in sync. If tour.py's cap changes, the comment drifts and the mp4 cap silently diverges from the recorder cap. tests/test_cmux_next_media.py already imports both modules, so assert equality there to keep the coupling enforced.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At scripts/cmux-next/media/publish.py, line 41:

<comment>MAX_FRAMES is duplicated in tour.py and this constant, and only a comment keeps them in sync. If tour.py's cap changes, the comment drifts and the mp4 cap silently diverges from the recorder cap. tests/test_cmux_next_media.py already imports both modules, so assert equality there to keep the coupling enforced.</comment>

<file context>
@@ -37,6 +37,8 @@
 GIF_FPS = 4
 VIDEO_SIZE = (1280, 800)
+# tour.py's MAX_FRAMES: a larger artifact does not make a longer video.
+MAX_FRAMES = 600
 GIF_WIDTHS = (720, 560, 440)
 TOUR_NAME = re.compile(r"[a-z0-9][a-z0-9-]{0,39}")
</file context>

if self.stopping.is_set():
break
if len(self.frames) >= MAX_FRAMES:
self.error = f"the video stops at {MAX_FRAMES} frames"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The truncation message set on this branch is never surfaced: run_tour's finally block only writes recorder.error into the manifest when the frames list is empty (tour.py:579-580), but this branch fires only once 600 frames exist, so the guard is always false and the "video stops at 600 frames" notice never reaches the manifest (publish.py ignores frames_error, so nothing else can report it). A video cut short by the cap therefore looks finished with no indication it stopped at five minutes. Record the truncation with a dedicated flag (for example self.truncated = True here) and, in the finally block, write frames_error when that flag is set, instead of assigning an error this consumer cannot report.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At scripts/cmux-next/media/tour.py, line 313:

<comment>The truncation message set on this branch is never surfaced: run_tour's finally block only writes recorder.error into the manifest when the frames list is empty (tour.py:579-580), but this branch fires only once 600 frames exist, so the guard is always false and the "video stops at 600 frames" notice never reaches the manifest (publish.py ignores frames_error, so nothing else can report it). A video cut short by the cap therefore looks finished with no indication it stopped at five minutes. Record the truncation with a dedicated flag (for example self.truncated = True here) and, in the finally block, write frames_error when that flag is set, instead of assigning an error this consumer cannot report.</comment>

<file context>
@@ -306,6 +309,9 @@ def run(self) -> None:
                 if self.stopping.is_set():
                     break
+                if len(self.frames) >= MAX_FRAMES:
+                    self.error = f"the video stops at {MAX_FRAMES} frames"
+                    break
                 path = self.directory / f"frame-{len(self.frames):05d}.jpg"
</file context>

@cursor

cursor Bot commented Oct 6, 2026

Copy link
Copy Markdown

Bugbot is paused — on-demand spend limit reached

Bugbot uses usage-based billing for this team and has hit its on-demand spend limit.

A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue.

teamleaderleo and others added 11 commits October 9, 2026 19:28
* ci: format staged files in the trusted pre-commit hook

* ci: guard formatter scope for staged files

* ci: scope Rust formatter guard to its package

* ci: use direct rustfmt and skip merge formatting

* ci: pass the cmux-tui Rust edition to rustfmt
* test(ci): red test, PRs check rustfmt on the cmux-tui packages they touch

PR CI never runs cmux-tui.yml (dispatch only), so #18706 landed
unformatted Rust and broke base cargo fmt --check. These tests pin a
helper that runs cargo fmt --check once per touched package, checks
every package when a rustfmt config changes or there is no base, and
names each unformatted package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci: check rustfmt on the cmux-tui packages a PR touches

PR CI never runs cmux-tui.yml (dispatch only), so #18706 landed
unformatted Rust and broke base cargo fmt --check. cmux-tui-spec.yml
already runs on every PR and push touching cmux-tui/** on hosted Linux;
its new rustfmt job runs cmux-tui-fmt-changed.py, which maps each changed
.rs file or Cargo.toml to its nearest tracked package and runs cargo fmt
--check on it with the pinned toolchain. No build and no dependency
download. A rustfmt config or toolchain change, or no base, checks every
package; vendored crates and deleted packages are skipped. A PR compares
with its merge commit's first parent, a push with the commit before it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* style(cmux-tui): rustfmt acpmux's draft tests and the relay circuit

The new rustfmt job's full run found two base packages that fail
cargo fmt --check: acpmux, from #18706's draft tests and their module
order, and the Cloudflare relay's circuit.rs. Formatting only, exactly
the hosted rustfmt diff.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* style(cmux-tui): leave the relay circuit to its own repair

Touching the Cloudflare relay runs its lane, whose npm audit is red on
base (sharp and undici advisories through wrangler, fixed only by a
wrangler bump outside the stated range). That is the relay's own repair,
so this PR keeps acpmux's formatting only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* ci: add mini-first Swift canary for cmux-next

* ci: remove unit tests from Swift canary change
…e cmux-tui (#18698)

* test(cmux-next): red: a PR that changes no cmux-tui fails same-tree on an unpublished merge tree

#16614, #17551 and #18605 change no cmux-tui or ghostty-next path, yet
same-tree cmux-tui failed them because the base tip's tree was never published
(its artifacts run was cancelled). Such a PR should reuse the newest published
tree in its history (tree_fetch_key, fetched through CMUX_TUI_TREE_KEY), or skip
the tree jobs when none is published, never fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci(cmux-next): same-tree cmux-tui is a hard check only for PRs that change cmux-tui

Path routing records whether the pull request's own diff touches cmux-tui/ or
ghostty-next. For one that does not, a merge tree that is not ready no longer
fails: the probe (CMUX_TUI_TREE_BASE_FALLBACK=1) resolves the newest published
tree in the merge's history and reports ready with tree_fetch_key, which the
daemon tests and the Debug scheme compile fetch through CMUX_TUI_TREE_KEY; with
none published it reports skipped. Pushes, dispatches and PRs that change
cmux-tui keep the hard check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cmux-next): the newest-published search deepens only this commit's history

A bare `git fetch --deepen=50 origin` deepens every branch of origin: on this
PR's first run it took path routing past its 5-minute limit (job 113609407061)
before the probe's base fallback, which then resolved in under a second.
Deepening only HEAD takes about 30 s on a depth-2 clone of feat-cmux-next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* docs(cmux-next): map UI parity reference sources

* docs(cmux-next): add media preview reference source

* docs(cmux-next): define reference freshness checks
* test(nightly): the notes step cannot spend the publish budget before the upload (red)

Run 37611677848 spent 29 of publish-nightly's 30 minutes on release notes and
was cancelled mid-upload, leaving partial release assets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(nightly): bound the publish notes step at 10 minutes

The notes take seconds since 0115b26. A regression now fails the step
before any asset upload, instead of spending publish-nightly's 30 minutes and
being cancelled mid-upload with partial assets left on the release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…self (#17622)

* test(cmux-next): the cmux-tui gate pins and dispatches an unpublished tree itself

Publisher run 37505519359 failed transiently and its retry was replaced by a
newer feat-cmux-next push, so the tree was never published and lanes pushed
cmux-tui-pin-* branches by hand to unblock their gates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(cmux-next): the tree gate watches the base and may pin the tree

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci(cmux-next): the cmux-tui gate pins and dispatches an unpublished tree

When two run checks find no artifacts run that will publish the tree, the
gate creates cmux-tui-pin-<key12> at the publisher commit and dispatches the
artifacts workflow there, once, then keeps waiting. A pull request now
watches its base commit (the merge's first parent) instead of no publisher,
so it neither waits 45 minutes for a tree nobody builds nor needs a lane to
push a pin branch by hand. A superseded push still skips; forks keep a
read-only token and no autopin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(cmux-tui): a transient wrong binary read must not fail publication

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cmux-tui): read a mismatched published binary again before failing

R2 objects are write-once, so a digest mismatch on a binary the run just
uploaded is a bad read. Fetch it again from a cache-busting URL up to
three times, and name the response headers when it still differs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(cmux-next): a pull request gate pins a tree its base never builds

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci(cmux-next): a pull request gate pins a tree its base never builds

A pull request that changes cmux-tui has a merge tree no push builds,
and the artifacts workflow's pull_request_target trigger has never
started a run, so its gate waited 45 minutes with the run check off.
With CMUX_TUI_TREE_AUTOPIN the gate now pins its own checkout at once
and watches that run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ci(cmux-next): retain base tree dispatch tests

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* ci(gallery): capture narrow responsive states

* test(gallery): cover workflow matrix changes

* fix(gallery): dedupe equal responsive widths

* ci: shard gallery matrix renders

* ci: retrigger sharded gallery validation
tests/test-execution.toml: took base. Dropped the PR's unit test
tests/test_cmux_next_media.py and its ci-guards step (no unit tests in
cmux-next).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@blacksmith-sh

blacksmith-sh Bot commented Oct 10, 2026

Copy link
Copy Markdown

Found 1 test failure on Blacksmith runners:

Failure

Test View Logs
test_every_reused_workspace_checkout_retries_once_from_a_fresh_repository (main.Cmu
xNextCheckoutRetryTests.test_every_reused_workspace_checkout_retries_once_from_a_fresh_
repository) (job='swift-canary')/
test_every_reused_workspace_checkout_retries_once_from_a_fresh_repository (main.Cmu
xNextCheckoutRetryTests.test_every_reused_workspace_checkout_retries_once_from_a_fresh_
repository) (job='swift-canary')
View Logs

Fix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 9eab0ac. Configure here.

contents: read
timeout-minutes: 20
env:
CMUX_NEXT_RUNNER: ${{ needs.macos-placement.outputs.runner || github.repository_owner != 'manaflow-ai' && 'macos-26' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && 'blacksmith-6vcpu-macos-26' || vars.CI_PR_POOL_OWNED == '1' && contains(fromJSON('["pull_request","push","workflow_dispatch"]'), github.event_name) && (github.run_attempt <= 2 || github.triggering_actor != 'github-actions[bot]') && vars.CI_SIDE_LANE_RUNNER || vars.MACOS_RUNNER_26 || 'blacksmith-6vcpu-macos-26') }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Canary ignores attempt-four overflow

Medium Severity

The new swift-canary job always prefers macos-placement's runner label. Sibling Mac jobs now drop that label on bot attempt 4 so a refused mini overflows to Blacksmith. A canary refusal stays on the same owned label, so the merge gate for package-only Swift PRs can queue indefinitely.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9eab0ac. Configure here.

for stage in base head repeat; do
mkdir -p "$evidence/runs/$stage"
jq -s 'add' "$evidence"/runs/$stage/shard-*/results.json > "$evidence/runs/$stage/results.json"
done

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sharded screenshots miss compare paths

High Severity

Each render shard writes screenshots and results.json under runs/<stage>/shard-N/, but the compare job only concatenates those JSON files into runs/<stage>/results.json. Image paths stay one directory deeper than compare expects, so before/after evidence can look broken or missing.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9eab0ac. Configure here.

@teamleaderleo
teamleaderleo merged commit 14a49ef into feat-cmux-next Oct 10, 2026
55 of 59 checks passed
@teamleaderleo
teamleaderleo deleted the feat-cmux-next-ci-media branch October 10, 2026 02:48
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.

3 participants