Skip to content

feat: enforce provenance-gated role placement#329

Merged
EtanHey merged 1 commit into
mainfrom
feat/provenance-gated-role-placement
Jul 15, 2026
Merged

feat: enforce provenance-gated role placement#329
EtanHey merged 1 commit into
mainfrom
feat/provenance-gated-role-placement

Conversation

@EtanHey

@EtanHey EtanHey commented Jul 15, 2026

Copy link
Copy Markdown
Owner

Summary

  • enforce deterministic role columns: orchestrators in column 0 and workers in column 1, always docking at the current top of the role column
  • persist cmuxlayer-spawn provenance and gate every sweep/reflow mutation on provable provenance plus idle/terminal state
  • ingest and re-register existing topology before the boot sweep and before placement becomes live
  • serialize delivery and placement mutations by stable surface UUID, with just-in-time authority rechecks
  • keep unknown/operator surfaces untouched and fail closed when boot topology cannot be ingested

Contract

Boot order is: reconstitute registry -> ingest/re-register existing panes -> sweep provable idle leftovers -> placement live.

Reconciliation runs at spawn and idle only. It never yanks a working pane. Unknown provenance is operator-owned and is never moved or closed.

TDD evidence

Counterfactual RED coverage includes:

  • immediate post-boot spawn docks against ingested current topology
  • unknown provenance is never moved or closed
  • working panes are not reconciled
  • idle-to-working races abort before mutation
  • stale refs and recycled UUIDs cannot authorize placement
  • zero-area phantom panes do not redefine role columns
  • failed boot ingestion keeps the daemon gate closed
  • repeated role spawns remain at the current top of the canonical role column

Verification

  • bun run test: 104 files, 2186 tests passed
  • bun run typecheck
  • bun run build
  • hermetic pre-PR harness: 61 tests passed
  • git diff --check
  • manual red-team, blue-team, and code-review follow-ups: no findings

CodeRabbit rerun was unavailable due the free OSS rate limit; the required manual red/blue fallback completed cleanly.


Note

High Risk
Large changes to cmux topology mutations, boot gating, and automated surface moves; mistakes could relocate operator panes or block daemon clients until boot succeeds.

Overview
Introduces a two-column role contract (orchestrators column 0, workers column 1) backed by surface_provenance: only cmuxlayer_spawn surfaces are auto-moved; discovered/operator panes stay unknown and untouched.

reconcileRolePlacements runs at spawn, when agents become idle, on boot, and each sweep. It uses stable UUID topology, observer epochs, and beforeMutation checks so working agents, recycled refs, and idle→working races never get yanked. Worker column seeding can split beside the lead pane and then close the seed by UUID.

Spawn placement is simplified to dock at the top of the canonical role column (topPaneInRoleColumn / deriveRoleColumnIndex), ignoring zero-area phantom panes instead of fill-based heuristics.

Boot is stricter: registry reconstitute → forced discovery merge → boot reconcile → sidebar; discovery failure rejects init. The daemon holds new sockets until lifecycle startup succeeds and records lifecycleStartError when it does not.

Surface I/O locks and mutations key off stable UUIDs; moveSurface is wired through server, app-server runtime, and reflow. markAgentWorking runs only after successful Return on submit paths, not on staging-only sends.

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

Note

Enforce provenance-gated role placement for spawned agent terminals

  • Introduces surface_provenance on AgentRecord ('cmuxlayer_spawn' | 'unknown') so the engine can distinguish programmatically created terminals from auto-discovered ones.
  • Adds AgentEngine.reconcileRolePlacements which enforces a two-column layout: orchestrators dock into canonical column 0, workers into column 1, using stable UUID-based write locks and pre-mutation guards to avoid stale-ref races.
  • Reconciliation runs at spawn, on idle transition, on each sweep, and at boot (before first sidebar sync); auto-discovered (unknown-provenance) agents are never moved.
  • Rewrites chooseAgentSpawnPlacement in layout-policy.ts to use topPaneInRoleColumn and canonical column indices, removing majority/heuristic predicates; zero-area phantom panes are filtered from column derivation.
  • Incoming daemon connections are now gated on lifecycleStartPromise; failures are recorded in lifecycleStartError and surfaced to callers rather than silently proceeding.
  • markAgentWorking transitions an idle agent to working immediately when a command is dispatched (Enter key sent), reserving its placement slot.
  • Risk: initialize now rejects on first-connect discovery failure instead of degrading to an 'unknown' placement state, which is a behavioral change for callers that previously handled the degraded state.
📊 Macroscope summarized 8e80644. 8 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

Summary by CodeRabbit

  • New Features

    • Added deterministic two-column role placement for orchestrators and workers.
    • Added automatic reconciliation to restore misplaced agent surfaces.
    • Added provenance tracking for agent surfaces.
    • Agents now transition to “working” after successful Return-key delivery.
  • Bug Fixes

    • Prevented commands from routing to surfaces that change during delivery.
    • Improved connection startup and shutdown handling.
    • Unknown or unverified surfaces are no longer moved automatically.
    • Zero-area phantom panes are ignored during placement decisions.

@cursor

cursor Bot commented Jul 15, 2026

Copy link
Copy Markdown

Bugbot couldn't run - usage limit reached

Bugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit.

A user or team admin can review and increase usage limits in the Cursor dashboard.

(requestId: serverGenReqId_295cdbd8-1a99-428d-ad56-7dd6f7d53d0a)

@coderabbitai

coderabbitai Bot commented Jul 15, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Agent surface placement now uses canonical role columns and provenance-gated reconciliation. Surface mutations and input delivery use stable identity locks and route validation. Lifecycle boot failures gate connections and clean up pending sockets, while Return delivery explicitly transitions idle agents to working.

Changes

Role placement and reconciliation

Layer / File(s) Summary
Canonical role-column placement
src/agent-types.ts, src/layout-policy.ts, src/surface-topology.ts, tests/layout-policy.test.ts
Role columns ignore zero-area panes, select canonical top panes, and place orchestrators and workers into deterministic columns.
Provenance-gated placement reconciliation
src/agent-engine.ts, src/state-manager.ts, tests/agent-engine.test.ts, tests/resync-tool.test.ts
Spawned records receive provenance, eligible surfaces are reconciled by stable UUID, and reconciliation is invoked during boot, sweeps, idle transitions, and spawning.
Stable surface mutation and input routing
src/app-server-runtime.ts, src/server.ts, tests/app-server-runtime.test.ts, tests/server-agent-tools.test.ts
Surface mutations use stable identity locks; command and interrupt routes are revalidated, and successful Return delivery marks agents working.
Lifecycle boot and connection gating
src/daemon.ts, src/server.ts, tests/daemon.test.ts, tests/sidebar-sync.test.ts
Lifecycle failures prevent relay publication and connection activation; pending boot sockets are cleaned up on failure and shutdown.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant CmuxLayerDaemon
  participant AgentEngine
  participant CmuxSurface
  Client->>CmuxLayerDaemon: connect
  CmuxLayerDaemon->>AgentEngine: await lifecycle start and boot ingestion
  AgentEngine->>CmuxSurface: reconcile role placements
  CmuxSurface-->>AgentEngine: topology and mutation results
  AgentEngine-->>CmuxLayerDaemon: lifecycle ready
  CmuxLayerDaemon-->>Client: activate connection
Loading

Possibly related PRs

Poem

A rabbit sees columns, tidy and bright,
Workers hop rightward into place just right.
UUID locks guard every door,
Idle paws work when Returns soar.
Boot gates close when errors call—
Safe little surfaces, one and all.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main change: provenance-gated role placement enforcement.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/provenance-gated-role-placement

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.

Comment thread src/agent-engine.ts
if (!leadPane) {
throw new Error("column 0 anchor is unavailable");
}
const createdSeed = await this.client.newSplit("right", {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium src/agent-engine.ts:3785

In reconcileRolePlacements, when this.client.newSplit throws at line 3794 or the returned identity trips the collision check at line 3798–3805, the newly created seed surface/pane is left open with no cleanup. The seed variable is only assigned at line 3806 after those checks pass, so when an exception is thrown the finally block at line 3838 never sees a seed to clean up and the orphaned pane persists as an unmanaged terminal. Consider capturing the newSplit result into seed immediately and performing the collision/epoch validation inside the try/finally guarded cleanup, or using a temporary variable that is cleaned up on any throw.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/agent-engine.ts around line 3785:

In `reconcileRolePlacements`, when `this.client.newSplit` throws at line 3794 or the returned identity trips the collision check at line 3798–3805, the newly created seed surface/pane is left open with no cleanup. The `seed` variable is only assigned at line 3806 *after* those checks pass, so when an exception is thrown the `finally` block at line 3838 never sees a `seed` to clean up and the orphaned pane persists as an unmanaged terminal. Consider capturing the `newSplit` result into `seed` immediately and performing the collision/epoch validation inside the `try`/`finally` guarded cleanup, or using a temporary variable that is cleaned up on any throw.

Comment thread src/daemon.ts
}

const transport = new SocketJsonRpcTransport(socket);
const mcpServer = createServer({

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 High src/daemon.ts:560

When multiple sockets connect during boot, each connection independently calls createServer and assigns a new lifecycle engine to context.lifecycleSweepEngine before the shared lifecycleStartPromise resolves. The second connection overwrites the first engine, so the first engine's completion callback skips startSweep, while the second engine was never initialized. Boot can finish with lifecycle reconciliation and placement inactive. Serialize the lifecycle-start gate before constructing additional servers so all connections reuse the same initializing engine.

Also found in 1 other location(s)

src/agent-engine.ts:5565

markAgentWorking is called only after the awaited sendKey operation has returned and released its stable-surface write lock. For an idle agent, a concurrent idle reconciliation can acquire that lock in the gap, pass its idle-state beforeMutation check, and move the pane even though Return has already submitted work. Mark the agent working while still inside the serialized mutation (for example in the Return operation's callback) so placement cannot yank a newly working pane.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/daemon.ts around line 560:

When multiple sockets connect during boot, each connection independently calls `createServer` and assigns a new lifecycle engine to `context.lifecycleSweepEngine` before the shared `lifecycleStartPromise` resolves. The second connection overwrites the first engine, so the first engine's completion callback skips `startSweep`, while the second engine was never initialized. Boot can finish with lifecycle reconciliation and placement inactive. Serialize the lifecycle-start gate before constructing additional servers so all connections reuse the same initializing engine.

Also found in 1 other location(s):
- src/agent-engine.ts:5565 -- `markAgentWorking` is called only after the awaited `sendKey` operation has returned and released its stable-surface write lock. For an idle agent, a concurrent idle reconciliation can acquire that lock in the gap, pass its idle-state `beforeMutation` check, and move the pane even though Return has already submitted work. Mark the agent working while still inside the serialized mutation (for example in the Return operation's callback) so placement cannot yank a newly working pane.

Comment thread src/app-server-runtime.ts
: lockedRoute.surface_id;
await this.withSurfaceWrite(lockKey, async () => {
const route = await this.resolveFreshMutationRoute(threadId, "send_key");
if (

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium src/app-server-runtime.ts:523

interruptTurn rejects the interrupt whenever surface_id changes between the two route resolutions, even when surface_uuid is unchanged. Surface refs are mutable, so a legitimate ref refresh for the same surface causes the interrupt to fail instead of sending Ctrl-C. The binding-changed check should compare normalized surface_uuid first and only fall back to comparing surface_id when no UUID exists, matching the logic in sendAgentCommand.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/app-server-runtime.ts around line 523:

`interruptTurn` rejects the interrupt whenever `surface_id` changes between the two route resolutions, even when `surface_uuid` is unchanged. Surface refs are mutable, so a legitimate ref refresh for the same surface causes the interrupt to fail instead of sending Ctrl-C. The binding-changed check should compare normalized `surface_uuid` first and only fall back to comparing `surface_id` when no UUID exists, matching the logic in `sendAgentCommand`.

Comment thread src/agent-engine.ts
});
}
}
} catch (error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium src/agent-engine.ts:3896

reconcileRolePlacements swallows every placement failure into the skipped summary array instead of throwing. The spawn caller awaits this method but ignores the returned summary, so an incomplete topology, a failed moveSurface, or an unavailable target column all leave the agent in the wrong column while the launch proceeds normally. Because idle reconciliation deliberately skips non-idle agents, the spawned agent can spend its entire working phase docked in the wrong role column, violating the deterministic two-column contract. Consider rethrowing placement errors (or surfacing them to the caller) when trigger === "spawn" so a failed docking attempt fails the spawn instead of being silently absorbed into summary.skipped.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/agent-engine.ts around line 3896:

`reconcileRolePlacements` swallows every placement failure into the `skipped` summary array instead of throwing. The spawn caller awaits this method but ignores the returned summary, so an incomplete topology, a failed `moveSurface`, or an unavailable target column all leave the agent in the wrong column while the launch proceeds normally. Because idle reconciliation deliberately skips non-idle agents, the spawned agent can spend its entire `working` phase docked in the wrong role column, violating the deterministic two-column contract. Consider rethrowing placement errors (or surfacing them to the caller) when `trigger === "spawn"` so a failed docking attempt fails the spawn instead of being silently absorbed into `summary.skipped`.

@coderabbitai coderabbitai 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.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/app-server-runtime.ts (1)

513-535: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Align interruptTurn with the UUID-preferred binding check

interruptTurn currently rejects any surface_id rotation even when surface_uuid is unchanged, while resolveFreshMutationRoute and sendAgentCommand allow that case. This can spuriously block a valid Ctrl-C on a UUID-bound surface; compare UUID first and only fall back to surface_id when no UUID is present.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app-server-runtime.ts` around lines 513 - 535, Update the binding
validation in interruptTurn to prefer surface_uuid: when both routes have a
UUID, accept matching UUIDs regardless of surface_id changes; only compare
surface_id when no UUID is present. Keep the existing stale-binding error and
sendKey flow unchanged.
src/server.ts (1)

2973-3021: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Unify withSurfaceWrite lock keys across writers
withSurfaceWrite locks by uuid:<stableSurfaceIdentity> when present, but send_input/send_command/send_key and background delivery still acquire the raw surface ref. That leaves the same live PTY in two lock namespaces, so a direct surface write can race with an agent relay on the same surface. Thread the stable UUID through every writer or normalize the lock key in withSurfaceWrite.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server.ts` around lines 2973 - 3021, The withSurfaceWrite lock key is
inconsistent with direct and background writers, allowing concurrent writes to
the same PTY. Update all send_input, send_command, send_key, and
background-delivery call sites to pass the same stableSurfaceIdentity, or
otherwise normalize their keys through withSurfaceWrite so every writer uses one
lock namespace while preserving existing ownership and release behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/agent-engine.ts`:
- Around line 3778-3808: Reorder the worker seed handling in the role-placement
flow: keep the collision validation for createdSeed before assignment, then
assign createdSeed to seed and targetPane, and only afterward call
assertSurfaceObserverEpochCurrent. Preserve the existing collision safeguards so
cleanup cannot close the agent’s own surface, while ensuring any epoch failure
after creation is covered by the finally cleanup.
- Around line 3838-3895: Update the seed cleanup in the moveSurface operation’s
finally block so validation, topology lookup, and closeSurface failures are
caught and logged as best-effort cleanup errors, following the existing
cleanupUnboundCreatedSurface pattern. Remove the throws from finally, preserve
observer-epoch and UUID-binding checks, and ensure cleanup failures cannot enter
the outer catch or add a duplicate summary.skipped entry after summary.moved has
already been updated.

In `@src/server.ts`:
- Around line 7402-7424: Pass splitOpts?.workspace as the workspace argument in
the withSurfaceWrite call inside newSplit, and apply the same change to the
corresponding moveSurface path. Preserve the existing lockKey, toolName, and
mutation behavior while ensuring both calls forward their available workspace
instead of relying on surface identification.

In `@tests/agent-engine.test.ts`:
- Around line 5599-5780: Add a regression test alongside the existing
reconcileRolePlacements test that makes seed closeSurface reject after
moveSurface succeeds. Assert the returned summary keeps the agent in
summary.moved and does not add a duplicate entry to summary.skipped, covering
the cleanup failure path without changing the successful move behavior.

---

Outside diff comments:
In `@src/app-server-runtime.ts`:
- Around line 513-535: Update the binding validation in interruptTurn to prefer
surface_uuid: when both routes have a UUID, accept matching UUIDs regardless of
surface_id changes; only compare surface_id when no UUID is present. Keep the
existing stale-binding error and sendKey flow unchanged.

In `@src/server.ts`:
- Around line 2973-3021: The withSurfaceWrite lock key is inconsistent with
direct and background writers, allowing concurrent writes to the same PTY.
Update all send_input, send_command, send_key, and background-delivery call
sites to pass the same stableSurfaceIdentity, or otherwise normalize their keys
through withSurfaceWrite so every writer uses one lock namespace while
preserving existing ownership and release behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0c9dacfe-b736-4bd8-a3c3-e585a60f0045

📥 Commits

Reviewing files that changed from the base of the PR and between 90c98c6 and 8e80644.

📒 Files selected for processing (16)
  • src/agent-engine.ts
  • src/agent-types.ts
  • src/app-server-runtime.ts
  • src/daemon.ts
  • src/layout-policy.ts
  • src/server.ts
  • src/state-manager.ts
  • src/surface-topology.ts
  • tests/agent-engine.test.ts
  • tests/app-server-runtime.test.ts
  • tests/daemon.test.ts
  • tests/layout-policy.test.ts
  • tests/resync-tool.test.ts
  • tests/server-agent-tools.test.ts
  • tests/sidebar-sync.test.ts
  • tests/state-manager.test.ts
📜 Review details
⏰ Context from checks skipped due to timeout. (1)
  • GitHub Check: Macroscope - Correctness Check
🧰 Additional context used
📓 Path-based instructions (6)
src/**/*.ts

📄 CodeRabbit inference engine (CLAUDE.md)

src/**/*.ts: Build the project with TypeScript (tsc) and keep source code compatible with Node 20+ and Zod-based typing.
Use the ok(data) and err(error) helpers for consistent MCP tool responses.
All MCP tool handlers must return { content: TextContent[], structuredContent?, isError? }.

Files:

  • src/surface-topology.ts
  • src/agent-types.ts
  • src/state-manager.ts
  • src/daemon.ts
  • src/agent-engine.ts
  • src/layout-policy.ts
  • src/app-server-runtime.ts
  • src/server.ts
src/state-manager.ts

📄 CodeRabbit inference engine (CLAUDE.md)

Keep sidebar state synchronization logic in state-manager.ts.

Files:

  • src/state-manager.ts
tests/**/*.test.ts

📄 CodeRabbit inference engine (CLAUDE.md)

tests/**/*.test.ts: Mirror source layout in tests (src/foo.ts -> tests/foo.test.ts).
Do not add integration tests that require a running cmux instance; tests should be fully mocked.

Files:

  • tests/state-manager.test.ts
  • tests/sidebar-sync.test.ts
  • tests/app-server-runtime.test.ts
  • tests/daemon.test.ts
  • tests/server-agent-tools.test.ts
  • tests/agent-engine.test.ts
  • tests/layout-policy.test.ts
  • tests/resync-tool.test.ts
src/agent-engine.ts

📄 CodeRabbit inference engine (CLAUDE.md)

Implement agent lifecycle behavior in agent-engine.ts, including spawning, monitoring, and quality tracking.

Files:

  • src/agent-engine.ts
tests/**/*agent-engine*.test.ts

📄 CodeRabbit inference engine (CLAUDE.md)

Agent engine tests should use 1-second timeouts for state-change detection.

Files:

  • tests/agent-engine.test.ts
src/server.ts

📄 CodeRabbit inference engine (CLAUDE.md)

Register all MCP tools in server.ts, including the 33 tool handlers, and conditionally skip agent-lifecycle tools when skipAgentLifecycle: true.

Files:

  • src/server.ts
🧠 Learnings (2)
📚 Learning: 2026-03-15T10:42:35.917Z
Learnt from: EtanHey
Repo: EtanHey/cmuxlayer PR: 1
File: tests/quality-tracking.test.ts:171-200
Timestamp: 2026-03-15T10:42:35.917Z
Learning: In tests/quality-tracking.test.ts for the cmuxlayer project, ensure that at or above 80% context quality degradation, behavior depends on depth: depth-0 agents receive a /compact command; depth > 0 agents are killed and logged (kill + log). Respawn of non-root agents is out of scope for v1. Treat the design doc quality tracking section as the authoritative source for this behavior, and align test expectations accordingly.

Applied to files:

  • tests/state-manager.test.ts
  • tests/sidebar-sync.test.ts
  • tests/app-server-runtime.test.ts
  • tests/daemon.test.ts
  • tests/server-agent-tools.test.ts
  • tests/agent-engine.test.ts
  • tests/layout-policy.test.ts
  • tests/resync-tool.test.ts
📚 Learning: 2026-03-15T10:42:36.027Z
Learnt from: EtanHey
Repo: EtanHey/cmuxlayer PR: 1
File: tests/sidebar-sync.test.ts:79-279
Timestamp: 2026-03-15T10:42:36.027Z
Learning: In the cmuxlayer project, tests/sidebar-sync.test.ts should cover only the implemented channels: set-status, set-progress, and log. The rename-workspace and report_meta_block channels are intentionally deferred (per phase5-v2-cmux-sidebar-research.md) and must not be considered as missing test coverage. Do not flag or require tests for these two channels in this file.

Applied to files:

  • tests/sidebar-sync.test.ts
🪛 Biome (2.5.3)
src/agent-engine.ts

[error] 3841-3843: Unsafe usage of 'throw'.

(lint/correctness/noUnsafeFinally)


[error] 3856-3858: Unsafe usage of 'throw'.

(lint/correctness/noUnsafeFinally)

🔇 Additional comments (23)
src/agent-types.ts (1)

19-19: LGTM!

Also applies to: 33-34

src/layout-policy.ts (1)

151-197: LGTM!

Also applies to: 376-421, 443-443

tests/layout-policy.test.ts (1)

204-243: LGTM!

Also applies to: 529-595, 664-752, 1218-1372, 1491-1596, 1632-1779

tests/resync-tool.test.ts (2)

77-99: LGTM!

Also applies to: 943-1139, 1538-1559, 1661-2211


1784-1844: LGTM!

Also applies to: 2212-2245

tests/state-manager.test.ts (1)

320-320: LGTM!

src/surface-topology.ts (1)

4-4: LGTM!

Also applies to: 313-313

src/agent-engine.ts (2)

25-25: LGTM!

Also applies to: 55-65, 341-360, 600-601, 632-645, 1562-1566, 1765-1766, 2718-2718, 2963-2972, 3937-3952, 4046-4046, 4490-4492, 4760-4771, 5564-5566


3636-3654: 🩺 Stability & Availability

Role inference here is already guarded inferRecordRole only raises AgentRoleInferenceError, and inferRecordRoleOrNull converts that to null, so this sweep won't abort or skip sidebar/outbox work because of malformed role data.

			> Likely an incorrect or invalid review comment.
src/state-manager.ts (1)

551-551: LGTM!

tests/agent-engine.test.ts (1)

736-752: LGTM!

Also applies to: 1119-1125, 1260-1263, 1408-1408, 1460-1466, 2170-2213, 2596-2651, 3744-3744, 5285-5598, 5782-5855, 9947-10021

src/app-server-runtime.ts (2)

285-366: LGTM!


469-511: LGTM!

src/server.ts (5)

132-132: LGTM!

Also applies to: 2063-2105, 2146-2254


7930-7966: LGTM!


7955-7959: 🎯 Functional Correctness

Verify markAgentWorking guards its own state transition for allow_busy interjections.

This unconditionally calls engine.markAgentWorking(args.agent_id) whenever press_enter is true, including allow_busy: true nudges (e.g. dispatch_to_agent's stale-monitor nudge) that can legitimately target agents in error/done states. If markAgentWorking doesn't itself gate on the current state (e.g. only transition from idle/ready), this could resurrect a terminal agent's registry state to "working" just because a best-effort keystroke nudge landed on its dead pane.


7308-7315: 🩺 Stability & Availability

awaitLifecycleStart() gating is applied inconsistently across mutation tools.

list_agents, broadcast, resync_agents, and my_agents explicitly call awaitLifecycleStart() and fail closed when context.lifecycleStartError is set, but spawn_agent, new_worktree_split, spawn_in_workspace, send_to, send_to_agent, and interact do not. In the daemon (src/daemon.ts) this gap is covered because connections are gated before createServer()'s tools become reachable, but any direct/non-daemon embedding of createServer() (tests, or src/app-server-runtime.ts-style hosts) could still invoke placement-mutating tools like spawn_agent after a failed boot-topology ingestion, contradicting the stated goal that "placement stays disabled if boot topology ingestion fails."

Also applies to: 9110-9183, 9249-9397, 9398-9950, 10667-10807, 8012-8617


9479-9518: LGTM!

Also applies to: 9533-9551, 9586-9592, 9654-9658, 9674-9674, 9692-9695, 9724-9728, 9744-9744, 9772-9774, 9882-9882

tests/app-server-runtime.test.ts (1)

592-617: LGTM!

Also applies to: 678-728

tests/server-agent-tools.test.ts (1)

5309-5345: LGTM!

Also applies to: 5916-5960

src/daemon.ts (1)

390-390: LGTM!

Also applies to: 539-559, 568-586, 641-644

tests/daemon.test.ts (1)

21-21: LGTM!

Also applies to: 1717-1843

tests/sidebar-sync.test.ts (1)

751-767: LGTM!

Comment thread src/agent-engine.ts
Comment on lines +3778 to +3808
let targetPane = topPaneInRoleColumn(panes.panes, role)?.ref ?? null;
let seed: CmuxNewSplitResult | null = null;
if (!targetPane && role === "worker") {
const leadPane = topPaneInRoleColumn(panes.panes, "orchestrator");
if (!leadPane) {
throw new Error("column 0 anchor is unavailable");
}
const createdSeed = await this.client.newSplit("right", {
pane: leadPane.ref,
surface: sourceRef,
workspace: agent.workspace_id,
type: "terminal",
stableSurfaceIdentity: agent.surface_uuid,
beforeMutation: () =>
assertFreshAgentBinding(sourceRef, "worker-column seed"),
});
this.assertSurfaceObserverEpochCurrent(
observerEpoch,
"role placement",
);
if (
createdSeed.surface === sourceRef ||
(createdSeed.surface_id ?? null) === agent.surface_uuid
) {
throw new Error(
"worker-column seed collided with the spawned surface binding",
);
}
seed = createdSeed;
targetPane = seed.pane;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Worker-column seed can leak if the epoch check fires between creation and tracking.

seed = createdSeed (3806) only happens after assertSurfaceObserverEpochCurrent (3794) and the collision check (3798-3805) both succeed. If the epoch assertion throws right after newSplit actually creates the seed pane/surface in cmux, the local seed variable is never set, so the finally cleanup (if (seed) { ... }, 3839) never attempts to close it — the seed surface is permanently orphaned in the workspace.

The collision check must stay before assignment (assigning seed earlier would let finally accidentally closeSurface the agent's own real surface on a collision), but the epoch check can safely move after the collision check + assignment so any later failure still triggers cleanup of a surface already confirmed distinct from sourceRef.

🐛 Proposed fix: track the seed before any check that can still throw
           const createdSeed = await this.client.newSplit("right", {
             pane: leadPane.ref,
             surface: sourceRef,
             workspace: agent.workspace_id,
             type: "terminal",
             stableSurfaceIdentity: agent.surface_uuid,
             beforeMutation: () =>
               assertFreshAgentBinding(sourceRef, "worker-column seed"),
           });
-          this.assertSurfaceObserverEpochCurrent(
-            observerEpoch,
-            "role placement",
-          );
           if (
             createdSeed.surface === sourceRef ||
             (createdSeed.surface_id ?? null) === agent.surface_uuid
           ) {
             throw new Error(
               "worker-column seed collided with the spawned surface binding",
             );
           }
+          // Track the seed for cleanup before any further check that could
+          // still throw, so a later epoch failure can't leak this pane.
           seed = createdSeed;
+          this.assertSurfaceObserverEpochCurrent(
+            observerEpoch,
+            "role placement",
+          );
           targetPane = seed.pane;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let targetPane = topPaneInRoleColumn(panes.panes, role)?.ref ?? null;
let seed: CmuxNewSplitResult | null = null;
if (!targetPane && role === "worker") {
const leadPane = topPaneInRoleColumn(panes.panes, "orchestrator");
if (!leadPane) {
throw new Error("column 0 anchor is unavailable");
}
const createdSeed = await this.client.newSplit("right", {
pane: leadPane.ref,
surface: sourceRef,
workspace: agent.workspace_id,
type: "terminal",
stableSurfaceIdentity: agent.surface_uuid,
beforeMutation: () =>
assertFreshAgentBinding(sourceRef, "worker-column seed"),
});
this.assertSurfaceObserverEpochCurrent(
observerEpoch,
"role placement",
);
if (
createdSeed.surface === sourceRef ||
(createdSeed.surface_id ?? null) === agent.surface_uuid
) {
throw new Error(
"worker-column seed collided with the spawned surface binding",
);
}
seed = createdSeed;
targetPane = seed.pane;
}
let targetPane = topPaneInRoleColumn(panes.panes, role)?.ref ?? null;
let seed: CmuxNewSplitResult | null = null;
if (!targetPane && role === "worker") {
const leadPane = topPaneInRoleColumn(panes.panes, "orchestrator");
if (!leadPane) {
throw new Error("column 0 anchor is unavailable");
}
const createdSeed = await this.client.newSplit("right", {
pane: leadPane.ref,
surface: sourceRef,
workspace: agent.workspace_id,
type: "terminal",
stableSurfaceIdentity: agent.surface_uuid,
beforeMutation: () =>
assertFreshAgentBinding(sourceRef, "worker-column seed"),
});
if (
createdSeed.surface === sourceRef ||
(createdSeed.surface_id ?? null) === agent.surface_uuid
) {
throw new Error(
"worker-column seed collided with the spawned surface binding",
);
}
// Track the seed for cleanup before any further check that could
// still throw, so a later epoch failure can't leak this pane.
seed = createdSeed;
this.assertSurfaceObserverEpochCurrent(
observerEpoch,
"role placement",
);
targetPane = seed.pane;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/agent-engine.ts` around lines 3778 - 3808, Reorder the worker seed
handling in the role-placement flow: keep the collision validation for
createdSeed before assignment, then assign createdSeed to seed and targetPane,
and only afterward call assertSurfaceObserverEpochCurrent. Preserve the existing
collision safeguards so cleanup cannot close the agent’s own surface, while
ensuring any epoch failure after creation is covered by the finally cleanup.

Comment thread src/agent-engine.ts
Comment on lines +3838 to +3895
} finally {
if (seed) {
if (!seed.surface_id) {
throw new Error(
"worker-column seed has no stable UUID; refusing cleanup by mutable ref",
);
}
const seedTopology = await this.collectObservedSurfaceTopology();
const seedBinding = seedTopology?.complete
? resolveAgentSurfaceBinding(
{
surface_id: seed.surface,
surface_uuid: seed.surface_id,
},
seedTopology,
)
: null;
if (!seedBinding || seedBinding.provenance !== "uuid") {
throw new Error(
`worker-column seed UUID ${seed.surface_id} is no longer uniquely bound; refusing cleanup`,
);
}
await this.client.closeSurface(seedBinding.surfaceRef, {
workspace: seedBinding.workspaceId ?? seed.workspace,
stableSurfaceIdentity: seed.surface_id,
beforeMutation: async () => {
this.assertSurfaceObserverEpochCurrent(
observerEpoch,
"role placement seed cleanup",
);
const freshSeedTopology =
await this.collectObservedSurfaceTopology();
this.assertSurfaceObserverEpochCurrent(
observerEpoch,
"role placement seed cleanup",
);
const freshSeedBinding = freshSeedTopology?.complete
? resolveAgentSurfaceBinding(
{
surface_id: seed.surface,
surface_uuid: seed.surface_id,
},
freshSeedTopology,
)
: null;
if (
!freshSeedBinding ||
freshSeedBinding.provenance !== "uuid" ||
freshSeedBinding.surfaceRef !== seedBinding.surfaceRef
) {
throw new Error(
"worker-column seed binding changed before cleanup",
);
}
},
});
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Unsafe throw in finally can mask a successful move and double-report the same agent.

Biome flags exactly this pattern (noUnsafeFinally) at 3841-3843 and 3856-3858. Per ESLint's rule rationale, when return, throw, break, or continue is used in finally, control flow statements inside try and catch are overwritten, which is considered as unexpected behavior.

Concretely: if moveSurface succeeds and summary.moved.push(...) (3831) already ran, and the seed-cleanup logic inside this finally then throws (e.g. the seed's binding raced away before cleanup), that throw propagates to the outer catch (3896) which pushes a summary.skipped entry for the same agent_id. The candidate now appears in both summary.moved and summary.skipped, and the real cleanup failure reason is swallowed/replaced. This exact scenario isn't exercised by the new "serializes worker-column seed cleanup" test (its mocked closeSurface always succeeds).

Restructure so seed-cleanup failures are caught and logged as best-effort (matching cleanupUnboundCreatedSurface's pattern elsewhere in this file) instead of throwing out of the finally.

🐛 Proposed fix
         } finally {
           if (seed) {
-            if (!seed.surface_id) {
-              throw new Error(
-                "worker-column seed has no stable UUID; refusing cleanup by mutable ref",
-              );
-            }
-            const seedTopology = await this.collectObservedSurfaceTopology();
-            const seedBinding = seedTopology?.complete
-              ? resolveAgentSurfaceBinding(
-                  { surface_id: seed.surface, surface_uuid: seed.surface_id },
-                  seedTopology,
-                )
-              : null;
-            if (!seedBinding || seedBinding.provenance !== "uuid") {
-              throw new Error(
-                `worker-column seed UUID ${seed.surface_id} is no longer uniquely bound; refusing cleanup`,
-              );
-            }
-            await this.client.closeSurface(seedBinding.surfaceRef, {
-              workspace: seedBinding.workspaceId ?? seed.workspace,
-              stableSurfaceIdentity: seed.surface_id,
-              beforeMutation: async () => { /* ...fresh checks... */ },
-            });
+            try {
+              if (!seed.surface_id) {
+                throw new Error(
+                  "worker-column seed has no stable UUID; refusing cleanup by mutable ref",
+                );
+              }
+              const seedTopology = await this.collectObservedSurfaceTopology();
+              const seedBinding = seedTopology?.complete
+                ? resolveAgentSurfaceBinding(
+                    { surface_id: seed.surface, surface_uuid: seed.surface_id },
+                    seedTopology,
+                  )
+                : null;
+              if (!seedBinding || seedBinding.provenance !== "uuid") {
+                throw new Error(
+                  `worker-column seed UUID ${seed.surface_id} is no longer uniquely bound; refusing cleanup`,
+                );
+              }
+              await this.client.closeSurface(seedBinding.surfaceRef, {
+                workspace: seedBinding.workspaceId ?? seed.workspace,
+                stableSurfaceIdentity: seed.surface_id,
+                beforeMutation: async () => { /* ...fresh checks... */ },
+              });
+            } catch (cleanupError) {
+              await this.logUnboundSurfaceCleanupWarning(
+                `role placement: failed to clean worker-column seed ${seed.surface} ` +
+                  `(${seed.surface_id ?? "UUID unknown"}): ` +
+                  `${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`,
+              );
+            }
           }
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
} finally {
if (seed) {
if (!seed.surface_id) {
throw new Error(
"worker-column seed has no stable UUID; refusing cleanup by mutable ref",
);
}
const seedTopology = await this.collectObservedSurfaceTopology();
const seedBinding = seedTopology?.complete
? resolveAgentSurfaceBinding(
{
surface_id: seed.surface,
surface_uuid: seed.surface_id,
},
seedTopology,
)
: null;
if (!seedBinding || seedBinding.provenance !== "uuid") {
throw new Error(
`worker-column seed UUID ${seed.surface_id} is no longer uniquely bound; refusing cleanup`,
);
}
await this.client.closeSurface(seedBinding.surfaceRef, {
workspace: seedBinding.workspaceId ?? seed.workspace,
stableSurfaceIdentity: seed.surface_id,
beforeMutation: async () => {
this.assertSurfaceObserverEpochCurrent(
observerEpoch,
"role placement seed cleanup",
);
const freshSeedTopology =
await this.collectObservedSurfaceTopology();
this.assertSurfaceObserverEpochCurrent(
observerEpoch,
"role placement seed cleanup",
);
const freshSeedBinding = freshSeedTopology?.complete
? resolveAgentSurfaceBinding(
{
surface_id: seed.surface,
surface_uuid: seed.surface_id,
},
freshSeedTopology,
)
: null;
if (
!freshSeedBinding ||
freshSeedBinding.provenance !== "uuid" ||
freshSeedBinding.surfaceRef !== seedBinding.surfaceRef
) {
throw new Error(
"worker-column seed binding changed before cleanup",
);
}
},
});
}
}
} finally {
if (seed) {
try {
if (!seed.surface_id) {
throw new Error(
"worker-column seed has no stable UUID; refusing cleanup by mutable ref",
);
}
const seedTopology = await this.collectObservedSurfaceTopology();
const seedBinding = seedTopology?.complete
? resolveAgentSurfaceBinding(
{
surface_id: seed.surface,
surface_uuid: seed.surface_id,
},
seedTopology,
)
: null;
if (!seedBinding || seedBinding.provenance !== "uuid") {
throw new Error(
`worker-column seed UUID ${seed.surface_id} is no longer uniquely bound; refusing cleanup`,
);
}
await this.client.closeSurface(seedBinding.surfaceRef, {
workspace: seedBinding.workspaceId ?? seed.workspace,
stableSurfaceIdentity: seed.surface_id,
beforeMutation: async () => {
this.assertSurfaceObserverEpochCurrent(
observerEpoch,
"role placement seed cleanup",
);
const freshSeedTopology =
await this.collectObservedSurfaceTopology();
this.assertSurfaceObserverEpochCurrent(
observerEpoch,
"role placement seed cleanup",
);
const freshSeedBinding = freshSeedTopology?.complete
? resolveAgentSurfaceBinding(
{
surface_id: seed.surface,
surface_uuid: seed.surface_id,
},
freshSeedTopology,
)
: null;
if (
!freshSeedBinding ||
freshSeedBinding.provenance !== "uuid" ||
freshSeedBinding.surfaceRef !== seedBinding.surfaceRef
) {
throw new Error(
"worker-column seed binding changed before cleanup",
);
}
},
});
} catch (cleanupError) {
await this.logUnboundSurfaceCleanupWarning(
`role placement: failed to clean worker-column seed ${seed.surface} ` +
`(${seed.surface_id ?? "UUID unknown"}): ` +
`${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`,
);
}
}
}
🧰 Tools
🪛 Biome (2.5.3)

[error] 3841-3843: Unsafe usage of 'throw'.

(lint/correctness/noUnsafeFinally)


[error] 3856-3858: Unsafe usage of 'throw'.

(lint/correctness/noUnsafeFinally)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/agent-engine.ts` around lines 3838 - 3895, Update the seed cleanup in the
moveSurface operation’s finally block so validation, topology lookup, and
closeSurface failures are caught and logged as best-effort cleanup errors,
following the existing cleanupUnboundCreatedSurface pattern. Remove the throws
from finally, preserve observer-epoch and UUID-binding checks, and ensure
cleanup failures cannot enter the outer catch or add a duplicate summary.skipped
entry after summary.moved has already been updated.

Source: Linters/SAST tools

Comment thread src/server.ts
Comment on lines 7402 to 7424
newSplit: async (direction, splitOpts) => {
const {
beforeMutation,
stableSurfaceIdentity,
...clientOpts
} = splitOpts ?? {};
await assertWorkspaceMutationAllowed(
"agent_engine",
splitOpts?.workspace,
);
return client.newSplit(direction, splitOpts);
const mutate = async () => {
await beforeMutation?.();
return client.newSplit(direction, clientOpts);
};
return splitOpts?.surface
? withSurfaceWrite(splitOpts.surface, mutate, {
toolName: "new_split",
lockKey: stableSurfaceIdentity
? `uuid:${stableSurfaceIdentity.toLowerCase()}`
: splitOpts.surface,
})
: mutate();
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map candidate symbols and read relevant slices without dumping huge files.
rg -n "withSurfaceWrite|assertSurfaceMutationAllowed|newSplit: async|moveSurface: async|closeSurface" src/server.ts

# Show the surrounding lines for the implementations and call sites.
sed -n '7300,7535p' src/server.ts

Repository: EtanHey/cmuxlayer

Length of output: 10475


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read definitions if they live elsewhere in the repo.
rg -n "function withSurfaceWrite|const withSurfaceWrite|assertSurfaceMutationAllowed" src

Repository: EtanHey/cmuxlayer

Length of output: 1180


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the exact implementations around the helper and the mutation wiring.
sed -n '1,220p' src/server.ts | cat -n | sed -n '1,220p'

Repository: EtanHey/cmuxlayer

Length of output: 7561


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the helper implementations that drive the policy check.
sed -n '2440,3015p' src/server.ts | cat -n | sed -n '1,620p'

Repository: EtanHey/cmuxlayer

Length of output: 22038


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the two call sites with full surrounding context.
sed -n '7390,7510p' src/server.ts | cat -n | sed -n '1,180p'

Repository: EtanHey/cmuxlayer

Length of output: 5349


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Show the policy helper and withSurfaceWrite implementation.
sed -n '2462,3005p' src/server.ts

Repository: EtanHey/cmuxlayer

Length of output: 17043


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the full surface control-mode resolution path.
sed -n '2400,2475p' src/server.ts
rg -n "identify\\(|readSurfaceControlMode\\(" src/server.ts

Repository: EtanHey/cmuxlayer

Length of output: 2827


Pass workspace into withSurfaceWrite for newSplit and moveSurface

These paths already have the workspace, but dropping it here makes assertSurfaceMutationAllowed(...) fall back to client.identify(surface). If that lookup fails, the check returns autonomous and the manual-mode gate is skipped. closeSurface already forwards workspace; do the same here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server.ts` around lines 7402 - 7424, Pass splitOpts?.workspace as the
workspace argument in the withSurfaceWrite call inside newSplit, and apply the
same change to the corresponding moveSurface path. Preserve the existing
lockKey, toolName, and mutation behavior while ensuring both calls forward their
available workspace instead of relying on surface identification.

Comment on lines +5599 to +5780
it("serializes worker-column seed cleanup by the seed's stable UUID", async () => {
const record = makeRecord({
agent_id: "single-column-worker",
surface_id: "surface:single-column-worker",
surface_uuid: "11111111-2222-4333-8444-555555555555",
workspace_id: "ws:placement",
state: "idle",
role: "worker",
surface_provenance: "cmuxlayer_spawn",
});
const leadRef = "surface:lead";
const leadUuid = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee";
const seedRef = "surface:worker-column-seed";
const seedUuid = "33333333-4444-4555-8666-777777777777";
let workerPane: "pane:left" | "pane:right" = "pane:left";
let seedOpen = false;
const refreshLiveSurfaces = () => {
liveSurfaces = [
{
...makeSurface(leadRef),
id: leadUuid,
workspace_ref: "ws:placement",
},
{
...makeSurface(record.surface_id),
id: record.surface_uuid ?? undefined,
workspace_ref: "ws:placement",
},
...(seedOpen
? [
{
...makeSurface(seedRef),
id: seedUuid,
workspace_ref: "ws:placement",
},
]
: []),
];
};
refreshLiveSurfaces();
stateMgr.writeState(record);
engine.getRegistry().set(record.agent_id, record);
(mockClient.listPanes as ReturnType<typeof vi.fn>).mockImplementation(
async () => {
const leftRefs = [
leadRef,
...(workerPane === "pane:left" ? [record.surface_id] : []),
];
const leftIds = [
leadUuid,
...(workerPane === "pane:left" ? [record.surface_uuid] : []),
];
const rightRefs = [
...(seedOpen ? [seedRef] : []),
...(workerPane === "pane:right" ? [record.surface_id] : []),
];
const rightIds = [
...(seedOpen ? [seedUuid] : []),
...(workerPane === "pane:right" ? [record.surface_uuid] : []),
];
return {
workspace_ref: "ws:placement",
window_ref: "window:placement",
panes: [
{
ref: "pane:left",
index: 0,
focused: true,
surface_count: leftRefs.length,
surface_refs: leftRefs,
surface_ids: leftIds,
pixel_frame: leftFrame,
},
...(rightRefs.length > 0
? [
{
ref: "pane:right",
index: 1,
focused: false,
surface_count: rightRefs.length,
surface_refs: rightRefs,
surface_ids: rightIds,
pixel_frame: rightFrame,
},
]
: []),
],
};
},
);
(
mockClient.listPaneSurfaces as ReturnType<typeof vi.fn>
).mockImplementation(async ({ pane }: { pane?: string }) => ({
workspace_ref: "ws:placement",
window_ref: "window:placement",
pane_ref: pane,
surfaces:
pane === "pane:right"
? [
...(seedOpen
? [
{
...makeSurface(seedRef),
id: seedUuid,
workspace_ref: "ws:placement",
},
]
: []),
...(workerPane === "pane:right"
? [
{
...makeSurface(record.surface_id),
id: record.surface_uuid ?? undefined,
workspace_ref: "ws:placement",
},
]
: []),
]
: [
{
...makeSurface(leadRef),
id: leadUuid,
workspace_ref: "ws:placement",
},
...(workerPane === "pane:left"
? [
{
...makeSurface(record.surface_id),
id: record.surface_uuid ?? undefined,
workspace_ref: "ws:placement",
},
]
: []),
],
}));
(mockClient.newSplit as ReturnType<typeof vi.fn>).mockImplementationOnce(
async (_direction, opts) => {
await opts.beforeMutation?.();
seedOpen = true;
refreshLiveSurfaces();
return {
workspace: "ws:placement",
surface: seedRef,
surface_id: seedUuid,
pane: "pane:right",
title: "",
type: "terminal",
};
},
);
(mockClient.moveSurface as ReturnType<typeof vi.fn>).mockImplementationOnce(
async (opts) => {
await opts.beforeMutation?.();
workerPane = "pane:right";
return {
ok: true,
workspace: "ws:placement",
surface: opts.surface,
pane: "pane:right",
};
},
);
(mockClient.closeSurface as ReturnType<typeof vi.fn>).mockImplementationOnce(
async (_surface, opts) => {
await opts.beforeMutation?.();
seedOpen = false;
refreshLiveSurfaces();
},
);

const summary = await engine.reconcileRolePlacements("idle");

expect(summary.moved).toHaveLength(1);
expect(mockClient.closeSurface).toHaveBeenCalledWith(
seedRef,
expect.objectContaining({
workspace: "ws:placement",
stableSurfaceIdentity: seedUuid,
beforeMutation: expect.any(Function),
}),
);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider adding a regression test for seed-cleanup failure after a successful move.

This test only exercises the happy path (seed create → move → seed close all succeed). It would be worth adding a case where closeSurface for the seed rejects after moveSurface already succeeded, asserting summary.moved still contains the agent and summary.skipped does not gain a duplicate entry for it — this is exactly the scenario exposed by the unsafe-finally issue flagged in src/agent-engine.ts (3838-3895).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/agent-engine.test.ts` around lines 5599 - 5780, Add a regression test
alongside the existing reconcileRolePlacements test that makes seed closeSurface
reject after moveSurface succeeds. Assert the returned summary keeps the agent
in summary.moved and does not add a duplicate entry to summary.skipped, covering
the cleanup failure path without changing the successful move behavior.

@EtanHey
EtanHey merged commit 21c8509 into main Jul 15, 2026
5 checks passed
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