Skip to content

feat: add durable delivery receipts and inbox cursors - #395

Merged
EtanHey merged 3 commits into
mainfrom
fix/stability-p1-delivery
Aug 12, 2026
Merged

feat: add durable delivery receipts and inbox cursors#395
EtanHey merged 3 commits into
mainfrom
fix/stability-p1-delivery

Conversation

@EtanHey

@EtanHey EtanHey commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Summary

  • return keyed submitted, queued, or failed delivery receipts from send_to
  • durably persist accepted queued payloads, drain them when the target is interactive, and refuse uncertain replay after a timeout or restart
  • add an agent-owned inbox.cursor with strict-after replay, cross-process locking, atomic advancement, and a shell-safe compiled CLI helper
  • correlate receipt transitions with existing delivery telemetry without duplicating established events

Verification

  • npm run typecheck
  • npm run build
  • npm test — 107 files passed; 2530 passed, 1 skipped
  • push hook run_tests.sh — exit 0
  • compiled cursor CLI probe — advanced the real inbox cursor to probe-msg
  • forced in-process SDK probe — 42 tools, send_to present, queued contract advertised
  • CodeRabbit review iterated through all actionable findings; the final additional pass was rate-limited after the prior findings were covered by focused regression tests

— cmuxlayerCodex (worker) · codex/gpt-5.6-sol


Note

High Risk
Changes core agent input delivery and spawn-boot behavior for every role, including durable queue semantics and no-replay rules after restart/timeout. Mis-handling here can drop, duplicate, or mis-order agent keystrokes.

Overview
send_to now returns durable keyed receipts (delivery_id, delivery_state, terminal) instead of rejecting busy agents. Without allow_busy, a working agent gets a nonterminal queued receipt; the lifecycle sweep drains it when the target is interactive.

AgentEngine persists a delivery queue in delivery-receipts.json with atomic writes, submission timeouts, and exponential backoff (capped at 30s) for RetryableDeliveryError. Receipts go terminal only when the target is gone or the outcome is uncertain—including after restart when submission_started_at was set, so in-flight input is never silently replayed.

Every spawned role now gets inbox bootstrap plus an injected mailbox boot contract (monitor command + inbox-cursor helper). Caller task text stays the task summary; monitor_boot is returned for workers too, not just orchestrators.

Agent-owned inbox.cursor advances via locked atomic rename; replayUndelivered prefers strict-after-cursor replay when present. New CLI: CMUX_INBOX_MSG_ID=<id> cmuxlayer inbox-cursor <agent-id>.

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

Note

Add durable delivery receipts and inbox cursor watermarks to agent message delivery

  • Introduces AgentDeliveryReceipt in src/agent-engine.ts with atomic persistence to delivery-receipts.json, exponential backoff (capped at 30s) for retryable failures, and a bounded per-submission timeout to prevent hung drains.
  • send_to now returns structured receipts with delivery_id, delivery_state (queued/submitted/failed), and terminal flag; busy agents get a non-terminal queued receipt instead of an error.
  • Adds readInboxCursor/writeInboxCursor in src/inbox.ts for atomic, cross-process-safe watermark advancement; replayUndelivered now uses the cursor to suppress already-handled messages on restart.
  • All spawned agents receive an injected mailbox boot contract prompt describing the cursor update command and env var (CMUX_INBOX_MSG_ID); monitor_boot now always includes cursor_path, cursor_update_command, and cursor_update_env.
  • Adds an inbox-cursor CLI subcommand in src/index.ts for agents to advance their own cursor watermark.
  • Risk: queued receipts with submission_started_at set at startup are marked failed (no replay) to prevent double-delivery after process restart.

Macroscope summarized 9d2c7d7.

Summary by CodeRabbit

  • New Features

    • Added reliable delivery tracking with durable receipts, retries, timeouts, and terminal failure states.
    • Busy agent deliveries are queued and automatically retried.
    • Added inbox cursor tracking for precise message replay.
    • Added the inbox-cursor command for updating an agent’s inbox position.
    • Spawned agents now receive mailbox and cursor instructions with verified boot-prompt delivery.
  • Bug Fixes

    • Prevented uncertain submissions from being replayed after restart.
    • Improved delivery reporting when telemetry or post-delivery verification fails.

Co-Authored-By: cmuxlayerCodex running gpt-5.6-sol <noreply@anthropic.com>
@cursor

cursor Bot commented Aug 11, 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_9d00e107-a1c3-4e8f-9eb9-bd4f243e48c0)

@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

Mailbox and delivery lifecycle

Layer / File(s) Summary
Inbox cursor persistence
src/inbox.ts, src/index.ts, tests/inbox.test.ts, docs/plans/...
Adds atomic per-agent cursors, cursor-based replay, and the inbox-cursor CLI command.
Durable delivery receipts and queue draining
src/agent-engine.ts, src/agent-types.ts, tests/agent-engine.test.ts
Persists receipts, retries retryable failures, marks uncertain submissions terminally, and drains queued deliveries after sweeps.
Guarded agent-input delivery
src/server.ts, tests/server-agent-tools.test.ts
Routes delivery through the guarded submitter, queues busy-agent input, and exposes receipt state in send_to results and errors.
Mailbox contracts and spawn verification
src/server.ts, tests/server.test.ts, tests/spawn-monitor-boot.test.ts, tests/spawn-workspace.test.ts, tests/pointer-discipline.test.ts, tests/inbox-nudge.test.ts
Initializes mailbox metadata for spawned agents, injects cursor-update instructions, supports buffered prompt delivery, and verifies boot submission before readiness.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant spawn_agent
  participant AgentMonitor
  participant AgentEngine
  participant SpawnedAgent
  spawn_agent->>AgentMonitor: initialize mailbox and cursor metadata
  AgentMonitor->>SpawnedAgent: inject mailbox contract
  spawn_agent->>AgentEngine: submit boot prompt
  AgentEngine->>SpawnedAgent: guarded delivery
  SpawnedAgent-->>AgentEngine: verified submission
  AgentEngine-->>spawn_agent: delivery and readiness result
Loading

Possibly related PRs

Poem

A rabbit queued a message one day,
With receipts tucked safely away.
The inbox cursor hopped,
While retries were stopped,
And verified prompts led the way.

🚥 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 clearly summarizes the primary changes: durable delivery receipts and inbox cursors.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/stability-p1-delivery

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.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e9b1a0c345

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/agent-engine.ts
Comment on lines +4640 to +4642
const agent = this.getAgentState(receipt.agent_id);
if (!agent || (agent.state !== "ready" && agent.state !== "idle")) {
continue;

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 Badge Drain queued sends before marking agents done

When a working agent completes its current task, runSweepOnce() first transitions it to done in maybeMarkTaskDone(), and only afterward calls this drain, which accepts only ready or idle. Normal sweeps do not transition a working agent to idle, so a follow-up queued while it is busy commonly remains queued forever precisely when the TUI becomes available; eligibility should use fresh interactive-screen evidence or drain before the terminal transition.

AGENTS.md reference: AGENTS.md:L8-L16

Useful? React with 👍 / 👎.

Comment thread src/server.ts
Comment on lines +12214 to +12219
const data = {
accepted: true,
agent_id: agentId,
delivery_id: receipt.delivery_id,
delivery_state: receipt.delivery_state,
terminal: receipt.terminal,

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 Badge Expose terminal state for queued delivery receipts

For every send to a working agent this returns a nonterminal receipt, but repo-wide the only receipt lookup is the internal AgentEngine.getDeliveryReceipt() method, and no registered tool, resource, or notification exposes either that receipt or the delivery event log. An MCP caller therefore cannot learn whether its accepted delivery was later submitted or failed, so it cannot safely retry or depend on the message; expose a receipt-status/wait path through the tool API.

AGENTS.md reference: AGENTS.md:L11-L16

Useful? React with 👍 / 👎.

Comment thread src/inbox.ts
Comment on lines +405 to +408
const current = readInboxCursor(agentId, opts);
if (current) {
const currentIndex = messages.findIndex((message) => message.id === current);
if (currentIndex >= 0 && nextIndex < currentIndex) {

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 Badge Require contiguous inbox cursor advancement

The helper accepts any later message ID, including when no cursor exists, rather than requiring that every preceding message has completed. If overlapping resumed processes handle messages out of order—or an agent simply finishes m3 before m2—writing m3 makes replayUndelivered() slice past m2, permanently suppressing an unhandled dispatch; preserve per-message acknowledgements or only advance through a contiguous handled prefix.

AGENTS.md reference: AGENTS.md:L27-L34

Useful? React with 👍 / 👎.

Comment thread src/inbox.ts
Comment on lines +383 to +386
try {
try {
mkdirSync(lockPath);
lockAcquired = true;

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 Badge Recover cursor locks left by terminated processes

If the CLI process is killed or crashes after this mkdirSync() and before the finally, the lock directory remains indefinitely. Every resumed process then receives the same EEXIST retry error, so the cursor can never advance and already handled messages replay forever; store lock ownership and reclaim dead/stale locks, or use a locking primitive released by process exit.

AGENTS.md reference: AGENTS.md:L27-L34

Useful? React with 👍 / 👎.

Comment thread src/agent-engine.ts
Comment on lines +4569 to +4572
writeFileSync(
tempPath,
`${JSON.stringify([...this.deliveryReceipts.values()], null, 2)}\n`,
"utf8",

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 Badge Prune terminal receipts before rewriting the receipt store

Every immediate or queued delivery remains in deliveryReceipts forever, including its full text, and each state change synchronously serializes and rewrites the entire map. On a long-running daemon this makes disk usage unbounded and turns ordinary sends into progressively larger O(total historical payload) writes; apply retention or compact terminal receipts before persisting.

Useful? React with 👍 / 👎.

Comment thread src/agent-engine.ts
@@ -4463,6 +4512,207 @@ export class AgentEngine {

async runSweep(): Promise<void> {

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/agent-engine.ts:4513

If runSweepOnce() (or the lifecycle-mutation wrapper) rejects — e.g. due to a setStatus/setStatuses failure inside syncSidebardrainDeliveryQueue() is never reached. Durable queued deliveries that were already accepted and persisted will remain stuck for as long as the sweep keeps failing, even when the target agent is ready/idle. Wrap the sweep in try/finally so the drain always runs.

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

If `runSweepOnce()` (or the lifecycle-mutation wrapper) rejects — e.g. due to a `setStatus`/`setStatuses` failure inside `syncSidebar` — `drainDeliveryQueue()` is never reached. Durable queued deliveries that were already accepted and persisted will remain stuck for as long as the sweep keeps failing, even when the target agent is `ready`/`idle`. Wrap the sweep in `try/finally` so the drain always runs.

Comment thread src/inbox.ts
let tempPath: string | null = null;
try {
try {
mkdirSync(lockPath);

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/inbox.ts:385

writeInboxCursor permanently deadlocks if the process is killed between mkdirSync(lockPath) on line 385 and the finally cleanup on line 421. The orphaned lock directory survives the crash, and every subsequent call throws "Inbox cursor is locked for ${agentId}; retry" unconditionally — there is no stale-lock detection. This means handled messages can never be watermarked again, causing unbounded replay on every restart until an operator manually removes the .lock directory.

The EEXIST handler assumes the lock is always held by a live process, but a crash (or kill -9) invalidates that assumption. Consider writing the owning PID into a file inside the lock directory after acquisition, and on EEXIST, reading that PID and checking whether the process is still alive (e.g. process.kill(pid, 0)). If the owner is dead, remove the stale lock and re-acquire. Alternatively, fall back to the lock directory's mtime (via statSync) and break it after a reasonable timeout.

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

`writeInboxCursor` permanently deadlocks if the process is killed between `mkdirSync(lockPath)` on line 385 and the `finally` cleanup on line 421. The orphaned lock directory survives the crash, and every subsequent call throws `"Inbox cursor is locked for ${agentId}; retry"` unconditionally — there is no stale-lock detection. This means handled messages can never be watermarked again, causing unbounded replay on every restart until an operator manually removes the `.lock` directory.

The `EEXIST` handler assumes the lock is always held by a live process, but a crash (or `kill -9`) invalidates that assumption. Consider writing the owning PID into a file inside the lock directory after acquisition, and on `EEXIST`, reading that PID and checking whether the process is still alive (e.g. `process.kill(pid, 0)`). If the owner is dead, remove the stale lock and re-acquire. Alternatively, fall back to the lock directory's `mtime` (via `statSync`) and break it after a reasonable timeout.

Comment thread src/agent-engine.ts
}
}

private persistDeliveryReceipts(): void {

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/agent-engine.ts:4565

Terminal delivery receipts (both resolved and drained) are never removed from deliveryReceipts. Every call to resolveDelivery, queueDelivery, or the drain loop appends to the map and then persistDeliveryReceipts rewrites the entire collection — including the full text payload of every historical receipt — as a single synchronous JSON file. On a long-running server this causes unbounded memory and disk growth, and each write gets progressively slower.

Consider pruning terminal receipts (e.g. receipts where terminal === true and resolved_at is older than a retention window) before persisting, or cap the number of retained receipts.

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

Terminal delivery receipts (both resolved and drained) are never removed from `deliveryReceipts`. Every call to `resolveDelivery`, `queueDelivery`, or the drain loop appends to the map and then `persistDeliveryReceipts` rewrites the entire collection — including the full `text` payload of every historical receipt — as a single synchronous JSON file. On a long-running server this causes unbounded memory and disk growth, and each write gets progressively slower.

Consider pruning terminal receipts (e.g. receipts where `terminal === true` and `resolved_at` is older than a retention window) before persisting, or cap the number of retained receipts.

Comment thread src/agent-engine.ts
this.deliveryReceipts.set(receipt.delivery_id, receipt);
try {
// Acceptance is not returned until the full replay payload is durable.
this.persistDeliveryReceipts();

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/agent-engine.ts:4601

persistDeliveryReceipts() serializes the entire in-memory deliveryReceipts map via atomic rename without first reading and merging the current file contents. When two AgentEngine instances share the same deliveryReceiptsPath (same state directory), the second writer's rename silently overwrites the first writer's receipt. The first caller already received an accepted receipt (the method returned successfully with delivery_state: "queued"), but its payload is permanently lost on disk and will never be drained after a restart.

The root cause is that persistDeliveryReceipts is a blind full-file replace with no read-merge step and no inter-process lock. Consider either (a) reloading the file and merging unknown entries before writing, ideally under an advisory file lock, or (b) using an append-only format (one JSON line per receipt) so concurrent writers cannot destroy each other's data.

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

`persistDeliveryReceipts()` serializes the entire in-memory `deliveryReceipts` map via atomic rename without first reading and merging the current file contents. When two `AgentEngine` instances share the same `deliveryReceiptsPath` (same state directory), the second writer's rename silently overwrites the first writer's receipt. The first caller already received an accepted receipt (the method returned successfully with `delivery_state: "queued"`), but its payload is permanently lost on disk and will never be drained after a restart.

The root cause is that `persistDeliveryReceipts` is a blind full-file replace with no read-merge step and no inter-process lock. Consider either (a) reloading the file and merging unknown entries before writing, ideally under an advisory file lock, or (b) using an append-only format (one JSON line per receipt) so concurrent writers cannot destroy each other's data.

Comment thread src/inbox.ts
Comment on lines +362 to +363
const cursor = readFileSync(inboxCursorPath(agentId, opts), "utf8").trim();
return cursor.length > 0 ? cursor : null;

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/inbox.ts:362

readInboxCursor returns a .trim()-ed cursor value, but writeInboxCursor writes the raw messageId (plus a trailing newline). If a message ID contains leading or trailing whitespace (allowed by DispatchInput.id), readInboxCursor returns a different string than what was written. replayUndelivered then fails to find the trimmed cursor in the inbox and replays the entire message list, duplicating already-handled work. Strip only the trailing newline that writeInboxCursor appends instead of calling .trim().

Suggested change
const cursor = readFileSync(inboxCursorPath(agentId, opts), "utf8").trim();
return cursor.length > 0 ? cursor : null;
const raw = readFileSync(inboxCursorPath(agentId, opts), "utf8");
const cursor = raw.endsWith("\n") ? raw.slice(0, -1) : raw;
return cursor.length > 0 ? cursor : null;
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/inbox.ts around lines 362-363:

`readInboxCursor` returns a `.trim()`-ed cursor value, but `writeInboxCursor` writes the raw `messageId` (plus a trailing newline). If a message ID contains leading or trailing whitespace (allowed by `DispatchInput.id`), `readInboxCursor` returns a different string than what was written. `replayUndelivered` then fails to find the trimmed cursor in the inbox and replays the entire message list, duplicating already-handled work. Strip only the trailing newline that `writeInboxCursor` appends instead of calling `.trim()`.

Comment thread src/agent-engine.ts
Comment on lines +4649 to +4652
let timeout: ReturnType<typeof setTimeout> | null = null;
const result = await Promise.race([
this.deliverySubmitter(receipt),
new Promise<never>((_resolve, reject) => {

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/agent-engine.ts:4649

drainDeliveryQueue checks this.deliverySubmitter only once at entry, then awaits multiple submissions in a loop using the mutable field directly. If setDeliverySubmitter(null) is called while a submission is in flight, the next loop iteration calls null(receipt), throws a TypeError, and permanently records that never-attempted delivery as failed. Capture the submitter reference before entering the loop, or re-check it before each invocation.

           let timeout: ReturnType<typeof setTimeout> | null = null;
+          const submitter = this.deliverySubmitter;
+          if (!submitter) break;
           const result = await Promise.race([
-            this.deliverySubmitter(receipt),
+            submitter(receipt),
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/agent-engine.ts around lines 4649-4652:

`drainDeliveryQueue` checks `this.deliverySubmitter` only once at entry, then awaits multiple submissions in a loop using the mutable field directly. If `setDeliverySubmitter(null)` is called while a submission is in flight, the next loop iteration calls `null(receipt)`, throws a `TypeError`, and permanently records that never-attempted delivery as `failed`. Capture the submitter reference before entering the loop, or re-check it before each invocation.

Comment thread src/agent-engine.ts
try {
for (const receipt of this.deliveryReceipts.values()) {
if (receipt.delivery_state !== "queued") continue;
const agent = this.getAgentState(receipt.agent_id);

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/agent-engine.ts:4640

drainDeliveryQueue looks up the agent via this.getAgentState(receipt.agent_id), which returns null after a restart if the agent was renamed (e.g. pending → final ID during session capture). In-memory registry aliases resolve the old ID within the same process, but reconstitute clears aliases on restart, so durable queued receipts referencing the old agent_id are silently skipped forever.

transferAgentRenameMemory rekeys every other in-memory map (readyPatternMatches, cliExitShellMatches, fleetScreenProgress, etc.) but does not update deliveryReceipts. Add rekeying of delivery receipts (updating each receipt's agent_id field) in transferAgentRenameMemory and call persistDeliveryReceipts() so the renamed ID survives a restart.

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

`drainDeliveryQueue` looks up the agent via `this.getAgentState(receipt.agent_id)`, which returns `null` after a restart if the agent was renamed (e.g. pending → final ID during session capture). In-memory registry aliases resolve the old ID within the same process, but `reconstitute` clears aliases on restart, so durable queued receipts referencing the old `agent_id` are silently skipped forever.

`transferAgentRenameMemory` rekeys every other in-memory map (`readyPatternMatches`, `cliExitShellMatches`, `fleetScreenProgress`, etc.) but does not update `deliveryReceipts`. Add rekeying of delivery receipts (updating each receipt's `agent_id` field) in `transferAgentRenameMemory` and call `persistDeliveryReceipts()` so the renamed ID survives a restart.

Comment thread src/server.ts
};
throw error;
}
const receipt = engine.resolveDelivery({

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/server.ts:12273

If engine.resolveDelivery(...) throws after deliverAgentInput has already succeeded (e.g. disk-full during persistDeliveryReceipts()), the exception propagates to the outer catch, which returns an error response with an empty failedReceiptPayload. The caller sees ok: false with no delivery receipt, even though the text was already submitted to the terminal. A caller that retries the apparently-failed send will deliver the input a second time.

The failedReceiptPayload assignment on line 12287 happens after resolveDelivery, so a persistence failure means the success-path receipt identity is never recorded. Move the failedReceiptPayload assignment to before the resolveDelivery call and wrap resolveDelivery in a try-catch that continues on the success path, so a post-delivery persistence failure cannot mask a delivered terminal mutation as a retryable error.

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

If `engine.resolveDelivery(...)` throws after `deliverAgentInput` has already succeeded (e.g. disk-full during `persistDeliveryReceipts()`), the exception propagates to the outer `catch`, which returns an error response with an empty `failedReceiptPayload`. The caller sees `ok: false` with no delivery receipt, even though the text was already submitted to the terminal. A caller that retries the apparently-failed send will deliver the input a second time.

The `failedReceiptPayload` assignment on line 12287 happens *after* `resolveDelivery`, so a persistence failure means the success-path receipt identity is never recorded. Move the `failedReceiptPayload` assignment to *before* the `resolveDelivery` call and wrap `resolveDelivery` in a try-catch that continues on the success path, so a post-delivery persistence failure cannot mask a delivered terminal mutation as a retryable error.

Comment thread src/agent-engine.ts Outdated
for (const receipt of this.deliveryReceipts.values()) {
if (receipt.delivery_state !== "queued") continue;
const agent = this.getAgentState(receipt.agent_id);
if (!agent || (agent.state !== "ready" && agent.state !== "idle")) {

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/agent-engine.ts:4641

Queued delivery receipts for agents that are missing or in a terminal state (done/error) are silently skipped on every drain cycle but never resolved. If the target agent transitions from working directly to done/error (or is removed) without ever re-entering ready/idle, the receipt stays durably queued and nonterminal forever — callers waiting on resolution never receive either a success or failure.

The continue at line 4642 treats a missing/terminal agent the same as a transiently non-interactive one. The fix is to fail the receipt terminally when the agent is gone or terminal, and only skip when the agent is in a transient non-interactive state.

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

Queued delivery receipts for agents that are missing or in a terminal state (`done`/`error`) are silently skipped on every drain cycle but never resolved. If the target agent transitions from `working` directly to `done`/`error` (or is removed) without ever re-entering `ready`/`idle`, the receipt stays durably `queued` and nonterminal forever — callers waiting on resolution never receive either a success or failure.

The `continue` at line 4642 treats a missing/terminal agent the same as a transiently non-interactive one. The fix is to fail the receipt terminally when the agent is gone or terminal, and only skip when the agent is in a transient non-interactive state.

Inject the mailbox cursor contract into every agent boot and retry known pre-mutation delivery rejections with capped backoff.

Co-Authored-By: cmuxlayerCodex running gpt-5.6-sol <noreply@anthropic.com>
@cursor

cursor Bot commented Aug 11, 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_bd9f97c4-2f32-4e0f-ad07-456c54324c1e)

@EtanHey

EtanHey commented Aug 11, 2026

Copy link
Copy Markdown
Owner Author

Addressed both HIGH findings in 937c827.

  • HIGH-1: every spawn_agent and spawn_in_workspace boot now receives its concrete mailbox monitor + cursor-update contract, including workers; the orchestrator-only role gates are removed.
  • HIGH-2: queued receipts now retry known pre-mutation posture rejections with persisted exponential backoff (250ms, capped at 30s). Missing targets terminal-fail without submission; unknown post-boundary failures remain terminal/uncertain to preserve no-replay safety. The stale engine gate is removed and the route submitter is the single interactive authority.
  • Verification: build passed; 107/107 test files passed (2533 passed, 1 skipped); compiled probes confirmed universal contract emission and retry scheduling. Push hook independently reran the full green gate.
  • Local CodeRabbit review: fixed its valid test-fixture ordering note; skipped its src/inbox.ts stale-lock suggestion because that file is unchanged by this review fix and the finding is outside HIGH-1/HIGH-2 scope.

Ready for re-review.

— cmuxlayerCodex (gpt-5.6-sol)

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 937c827797

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/server.ts
Comment on lines +2938 to +2939
`cmuxlayer mailbox contract for ${agentId}: monitor with ${monitorBoot.monitor_command}; ` +
`after each handled message run CMUX_INBOX_MSG_ID=<handled-message-id> ${monitorBoot.cursor_update_command}`;

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 Badge Make cursor consumers publish an agent heartbeat

A freshly spawned recipient that follows this injected contract never proves its monitor is armed: ensureMonitorBoot() writes only a server_boot heartbeat, recommendedMonitorCommand() merely tails the file, and inbox-cursor does not call writeHeartbeat(). Since inboxMonitorState() deliberately considers only agent-authored heartbeats, every subsequent dispatch reports inbox_monitor_never_armed and falls back to a raw nudge even while the monitor is running; the arm/cursor path must publish and refresh an agent heartbeat.

AGENTS.md reference: AGENTS.md:L11-L16

Useful? React with 👍 / 👎.

Comment thread src/server.ts
: ""
}cmuxlayer inbox-cursor ${shellQuote(agentId)}`;
try {
monitorCommand = recommendedMonitorCommand(agentId, inboxOpts);

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 Badge Generate a poll contract for Codex and Cursor

For Codex or Cursor agents, this unconditionally supplies recommendedMonitorCommand() (tail -n0 -F) even though the repository's harness policy states those CLIs have no native asynchronous Monitor and must call replayUndelivered() on each turn. The injected prompt contains no replay/poll instruction, so a looping Codex/Cursor agent following it will not consume queued inbox messages unless the raw-terminal nudge happens to succeed; select the bootstrap contract using the agent's CLI and include the poll-on-turn path for non-Monitor harnesses.

AGENTS.md reference: AGENTS.md:L44-L47

Useful? React with 👍 / 👎.

Comment thread src/server.ts
};
}
};
const mailboxBootContract = (

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 Badge Bootstrap mailboxes in every spawn tool

The new bootstrap helper is only invoked by spawn_agent and spawn_in_workspace; the repo-wide new_worktree_split path at src/server.ts:10230-10343 still creates a formal worker without calling ensureMonitorBoot() or injecting the cursor contract. Agents created through that public spawn tool therefore remain permanently unarmed for the new inbox protocol, so this initialization should be centralized or added to that path as well.

AGENTS.md reference: AGENTS.md:L8-L9

Useful? React with 👍 / 👎.

Comment thread src/server.ts
inboxBaseDir
? `CMUXLAYER_INBOX_BASE_DIR=${shellQuote(inboxBaseDir)} `
: ""
}cmuxlayer inbox-cursor ${shellQuote(agentId)}`;

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 Badge Emit an invocable cursor command for local installations

When cmuxlayer is run from a source checkout via the documented npm run dev or npm start, or launched through a package runner rather than installed globally, the spawned agent's workspace need not have a cmuxlayer executable on PATH. In those installations the exact command injected here fails after every handled message, so the durable cursor never advances; construct the command from the currently running executable/entrypoint or expose an invocation that does not assume a global launcher.

AGENTS.md reference: AGENTS.md:L49-L49

Useful? React with 👍 / 👎.

Comment thread src/agent-engine.ts
return receipt ? { ...receipt } : null;
}

async drainDeliveryQueue(): Promise<void> {

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/agent-engine.ts:4646

drainDeliveryQueue processes queued receipts sequentially with a per-item timeout, so a drain with N hung submissions blocks runSweep for N * deliverySubmitTimeoutMs — e.g. 100 stuck receipts stall the lifecycle sweep for ~50 minutes at the default 30s timeout. The deliverySubmitTimeoutMs bound only limits each item, not the whole drain, so an unbounded queue of hung submissions can halt all periodic reconciliation and lifecycle work. Consider bounding the total drain time or processing receipts concurrently so one slow queue cannot block the sweep indefinitely.

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

`drainDeliveryQueue` processes queued receipts sequentially with a per-item timeout, so a drain with N hung submissions blocks `runSweep` for `N * deliverySubmitTimeoutMs` — e.g. 100 stuck receipts stall the lifecycle sweep for ~50 minutes at the default 30s timeout. The `deliverySubmitTimeoutMs` bound only limits each item, not the whole drain, so an unbounded queue of hung submissions can halt all periodic reconciliation and lifecycle work. Consider bounding the total drain time or processing receipts concurrently so one slow queue cannot block the sweep indefinitely.

@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: 16

🤖 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 4576-4589: Update delivery-receipt lifecycle handling around
persistDeliveryReceipts and drainDeliveryQueue: retain only bounded-age or
bounded-count receipts, remove terminal receipts when they exceed retention, and
clear or omit AgentDeliveryReceipt.text when a receipt becomes terminal. Batch
queue mutations so drainDeliveryQueue invokes persistence once per drain rather
than once per receipt, while preserving atomic file replacement.
- Around line 4523-4526: Update the stop cleanup path used by runSweep and
stopAgent so the route close operation receives stableSurfaceIdentity set to
route.surface_uuid, matching deliverAgentInput’s uuid-based lock key. Keep
drainDeliveryQueue() outside runLifecycleMutation(); do not change the delivery
queue ordering or lifecycle lock behavior.

In `@src/inbox.ts`:
- Around line 439-447: The unresolvable persisted cursor must be handled
consistently across replay and cursor advancement. In src/inbox.ts lines
439-447, update replayUndelivered to use ack-based filtering when cursorIndex is
-1 rather than returning all messages; in src/inbox.ts lines 398-418, update
writeInboxCursor to reject advances when currentIndex is -1 instead of bypassing
the backwards-move check.
- Around line 398-418: Update the cursor validation around readInboxCursor so an
existing current cursor whose ID is absent from messages is rejected or
preserved instead of allowing advancement. Keep the existing backwards-movement
check for located cursors, while ensuring unknown persisted cursors cannot move
to a new message.
- Around line 384-396: Update writeInboxCursor’s lock acquisition and cleanup to
write owner metadata, detect stale locks using a bounded age policy, and remove
a lock only when ownership can be established for the current writer. Preserve
the existing retry error for active locks, ensure stale locks can be recovered
safely, and add a regression test covering recovery from a stale lock.

In `@src/index.ts`:
- Around line 95-102: Update main’s inbox-cursor command flow around
writeInboxCursor to catch its failures instead of allowing the top-level handler
to print a stack; write exactly one line to stderr for each failure and exit
with the distinct retry status only when the error is specifically the
cursor-lock error, using its error code/type or exact lock-error check rather
than matching “locked” in the message.

In `@src/server.ts`:
- Around line 10709-10710: Update the ready-transition condition in the handler
around engine.getAgentState so it requires the recorded
bootPromptDelivery.submit_verified value to be true in addition to hasPrompt,
matching the verification gate used by spawn_agent. Keep the agent booting when
verification is null or false.
- Around line 5101-5105: Normalize prompt_text in the SubmitVerificationError
recovery return path to use the same hasInlinePrompt(rawPrompt) ? rawPrompt :
null logic as the success return path. Update the fallback return site near the
existing delivery object while preserving the current prompt_warning and other
delivery fields.
- Around line 12266-12296: Update the catch block around deliverAgentInput so
RetryableDeliveryError resolves the receipt as a queued, non-terminal delivery
while preserving its retry metadata and replay eligibility. Keep the existing
terminal failed-resolution behavior for non-retryable errors and preserve
SubmitVerificationError event handling.
- Around line 12245-12253: Update the queued send_to receipt response to use
okFormatted(...) instead of the hand-built content and structuredContent object.
Preserve the delivery receipt data while returning the standard ok and
retry_count fields, and remove the accepted-specific schema field.

In `@tests/agent-engine.test.ts`:
- Line 11167: Replace the direct private-field assignment in the test with a
dedicated AgentEngine constructed using the public deliverySubmitTimeoutMs
option. Ensure the test exercises AgentEngineOptions through normal construction
and remove the `(engine as any).deliverySubmitTimeoutMs` mutation.

In `@tests/inbox.test.ts`:
- Around line 225-235: Expand the writeInboxCursor tests around the existing
cursor-lock case to cover rejection of an unknown message id, rejection of a
backwards cursor move, and replay when the cursor id is absent from the inbox
file. Use the existing inbox setup and helpers, assert the documented errors for
the two rejection paths, and verify the absent-id replay returns the full
retained history.

In `@tests/pointer-discipline.test.ts`:
- Around line 120-123: Stop resetting submissionObservationPending in the
list-workspaces handling path, since topology collection may invoke it before
submission verification completes. Clear the flag only through an explicit test
signal or after a bounded read-screen count, while preserving the caller-screen
transition until verification has finished.

In `@tests/server-agent-tools.test.ts`:
- Around line 8234-8236: Update the “nothing was sent” assertion around mockExec
calls to also reject buffer-based delivery, checking for both set-buffer and
paste-buffer commands in addition to send. Preserve the existing zero-call
expectation for all pane-delivery paths.
- Around line 7057-7066: Extend the test around the existing failed delivery
assertions to verify the duplicate-event guard: using failed.delivery_id,
inspect the event log and assert exactly one matching terminal failure event
exists after the submission-verification failure. Keep the existing delivery
receipt and failure-state assertions unchanged.

In `@tests/spawn-workspace.test.ts`:
- Around line 146-152: Update the sendKey mock in the spawn workspace test to
mark a surface submitted only when a Return is delivered after the
mailbox-contract text has been delivered, rather than when the Return count
reaches two. Reuse the existing contract-delivery tracking pattern from
spawn-monitor-boot.test.ts and preserve the current returnCount tracking only
where needed for setup or assertions.
🪄 Autofix

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 Plus

Run ID: 6bca9ab3-80f6-4f48-aa82-b276a50d2b6d

📥 Commits

Reviewing files that changed from the base of the PR and between f32d004 and 937c827.

📒 Files selected for processing (14)
  • docs/plans/2026-08-11-pr395-review-fixes.md
  • src/agent-engine.ts
  • src/agent-types.ts
  • src/inbox.ts
  • src/index.ts
  • src/server.ts
  • tests/agent-engine.test.ts
  • tests/inbox-nudge.test.ts
  • tests/inbox.test.ts
  • tests/pointer-discipline.test.ts
  • tests/server-agent-tools.test.ts
  • tests/server.test.ts
  • tests/spawn-monitor-boot.test.ts
  • tests/spawn-workspace.test.ts
📜 Review details
⏰ Context from checks skipped due to timeout. (1)
  • GitHub Check: Macroscope - Correctness Check
🧰 Additional context used
🧠 Learnings (1)
📚 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/pointer-discipline.test.ts
  • tests/inbox-nudge.test.ts
  • tests/inbox.test.ts
  • tests/spawn-workspace.test.ts
  • tests/spawn-monitor-boot.test.ts
  • tests/agent-engine.test.ts
  • tests/server.test.ts
  • tests/server-agent-tools.test.ts
🔇 Additional comments (34)
docs/plans/2026-08-11-pr395-review-fixes.md (1)

1-13: LGTM!

src/inbox.ts (1)

23-33: LGTM!

Also applies to: 119-121, 356-367

src/index.ts (1)

22-22: LGTM!

Also applies to: 41-43

tests/inbox.test.ts (1)

5-5: LGTM!

Also applies to: 19-19, 30-32, 210-223

src/agent-engine.ts (3)

6-15: LGTM!

Also applies to: 58-58, 165-197, 427-428, 952-972, 4622-4644, 4729-4754


4591-4620: LGTM!


4646-4667: 🩺 Stability & Availability

Confirm the delivery retry scheduler.

drainDeliveryQueue() runs only from runSweep(). A retryable failure sets next_attempt_at, but no timer targets that deadline. With default 5-second active and 15-second idle sweeps, retry delays are rounded up to the next sweep. Schedule a timer if retries must follow the backoff deadline; otherwise document that sweep cadence controls retries.

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

21-21: LGTM!

Also applies to: 11133-11155, 11187-11264, 11266-11307


11316-11319: 🎯 Functional Correctness

No change needed. TEST_DIR is reset in beforeEach, and this test queues one delivery before reading the receipt. persisted[0] is the receipt under test.

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

191-195: LGTM!

src/server.ts (8)

32-32: LGTM!

Also applies to: 49-49, 115-115, 2237-2241, 2523-2523


4207-4207: LGTM!

Also applies to: 4335-4343


9333-9333: LGTM!

Also applies to: 9457-9461, 9508-9508


9540-9549: LGTM!


9844-9844: LGTM!

Also applies to: 9910-9915, 9932-9947, 9982-9982, 10075-10075


10634-10634: LGTM!

Also applies to: 10656-10680, 10749-10760, 10769-10769


12115-12115: LGTM!

Also applies to: 12125-12133, 12297-12342


2902-2907: 🗄️ Data Integrity & Integration

The environment variable name is correct. inbox-cursor reads CMUXLAYER_INBOX_BASE_DIR, so the configured directory is preserved.

			> Likely an incorrect or invalid review comment.
tests/server-agent-tools.test.ts (5)

103-135: LGTM!

Also applies to: 148-148, 164-166, 235-235, 251-251


648-649: LGTM!

Also applies to: 674-693


2639-2645: LGTM!

Also applies to: 2740-2750, 2829-2840, 2861-2873, 2948-2949, 2961-2962, 3904-3910, 4015-4017, 4033-4033, 4095-4101, 4163-4165, 4181-4181


33-33: LGTM!

Also applies to: 8198-8233, 8237-8271


1945-1945: LGTM!

Also applies to: 2053-2053, 3694-3694, 4986-4986, 5802-5802, 8420-8465

tests/inbox-nudge.test.ts (1)

64-64: LGTM!

Also applies to: 161-179

tests/pointer-discipline.test.ts (1)

53-53: LGTM!

Also applies to: 74-92, 104-112

tests/server.test.ts (3)

1150-1152: LGTM!

Also applies to: 1188-1218, 1242-1242, 1260-1283, 1291-1292


8110-8128: LGTM!

Also applies to: 8197-8203, 8264-8269, 8414-8415, 8431-8432, 8470-8471, 8585-8586, 8597-8599, 8659-8664, 8771-8786, 8867-8867, 8960-8975, 9044-9049


9131-9131: LGTM!

Also applies to: 9456-9456, 9686-9686

tests/spawn-monitor-boot.test.ts (3)

19-32: LGTM!

Also applies to: 91-97


127-135: LGTM!


184-186: LGTM!

Also applies to: 197-197, 207-223, 266-268

tests/spawn-workspace.test.ts (3)

79-81: LGTM!

Also applies to: 94-123


138-145: LGTM!

Also applies to: 153-166


242-258: LGTM!

Also applies to: 275-281

Comment thread src/agent-engine.ts
Comment on lines 4523 to +4526
async runSweep(): Promise<void> {
await this.runLifecycleMutation(() => this.runSweepOnce());
await this.drainDeliveryQueue();
}

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Find the delivery submitter registration and its mutation path.
rg -n -C15 'setDeliverySubmitter|deliverAgentInput' src --glob '*.ts'

Repository: EtanHey/cmuxlayer

Length of output: 21881


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- lifecycle lock and delivery methods ---'
rg -n -C35 'runLifecycleMutation|drainDeliveryQueue|deliverySubmitter|deliveryDrainInFlight' src/agent-engine.ts
printf '%s\n' '--- server delivery implementation ---'
sed -n '9327,9549p' src/server.ts
printf '%s\n' '--- lifecycle mutation call sites ---'
rg -n -C8 'runLifecycleMutation\(' src --glob '*.ts'

Repository: EtanHey/cmuxlayer

Length of output: 32543


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- delivery metadata refresh path ---'
rg -n -C20 'refreshManagedMetadataBestEffort|lifecycleRefreshManagedMetadata' src/server.ts src/agent-engine.ts
printf '%s\n' '--- lifecycle mutation implementations and terminal operations ---'
rg -n -C12 'stopAgent|recoverCrashedAgents|reconcileRolePlacements|closeSurface|bind.*Surface|withSurfaceWrite' src/agent-engine.ts src/server.ts
printf '%s\n' '--- write serialization implementation ---'
rg -n -C25 'function withSurfaceWrite|const withSurfaceWrite|withSurfaceWrite =' src --glob '*.ts'

Repository: EtanHey/cmuxlayer

Length of output: 50374


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- refresh lock and delivery write lock ---'
sed -n '2938,2964p' src/server.ts
sed -n '9137,9155p' src/server.ts
rg -n -C30 'withSurfaceWrite' src/server.ts src/agent-engine.ts
printf '%s\n' '--- direct lifecycle mutation wrappers near relevant tools ---'
rg -n -C10 'runLifecycleMutation' src/server.ts
printf '%s\n' '--- sweep startup and scheduling ---'
rg -n -C20 'startSweep|runSweep\(' src/agent-engine.ts src/server.ts

Repository: EtanHey/cmuxlayer

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- surface-write lock implementation ---'
sed -n '3310,3575p' src/server.ts
printf '%s\n' '--- lifecycle surface mutation calls ---'
rg -n -C14 'this\.client\.(closeSurface|moveSurface|newSplit)|reconcileRolePlacements\(' src/agent-engine.ts
printf '%s\n' '--- stable identity propagation in recovery/reflow ---'
rg -n -C10 'stableSurfaceIdentity|surface_uuid' src/agent-engine.ts | head -240

Repository: EtanHey/cmuxlayer

Length of output: 31403


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- stopAgent implementation and callers ---'
sed -n '6200,6355p' src/agent-engine.ts
rg -n -C20 '"stop_agent"|stopAgent\(' src/server.ts src/agent-engine.ts
printf '%s\n' '--- crash recovery close/write options ---'
sed -n '3144,3330p' src/agent-engine.ts
printf '%s\n' '--- lifecycle scheduler and direct sweep callers ---'
rg -n -C18 'startSweep|runSweep\(' src/agent-engine.ts src/server.ts

Repository: EtanHey/cmuxlayer

Length of output: 32145


🏁 Script executed:

#!/bin/bash
set -eu
node - <<'JS'
"use strict";

// Model the lock semantics shown by withSurfaceWrite and runLifecycleMutation.
// acquireSurfaceWrite rejects only an identical key; different keys do not
// serialize. The lifecycle tail is a non-reentrant FIFO lock.

const active = new Map();
function acquire(key, owner) {
  if (active.has(key)) throw new Error(`conflict:${key}`);
  active.set(key, owner);
}
function release(key, owner) {
  if (active.get(key) === owner) active.delete(key);
}

acquire("uuid:agent-1", "delivery");
let overlap = false;
try {
  acquire("surface-ref-1", "stop-close");
  overlap = true;
} finally {
  release("surface-ref-1", "stop-close");
  release("uuid:agent-1", "delivery");
}

let released = false;
let nestedCompleted = false;
const outer = Promise.resolve().then(async () => {
  const nestedWait = new Promise((resolve) => {
    if (released) resolve();
    else setTimeout(() => resolve(), 0);
  });
  await Promise.race([
    nestedWait.then(() => { nestedCompleted = true; }),
    new Promise((resolve) => setTimeout(resolve, 10)),
  ]);
  released = true;
});
outer.then(() => {});
setTimeout(() => {
  if (!overlap) throw new Error("expected distinct surface keys to overlap");
  if (nestedCompleted) throw new Error("nested lock unexpectedly completed");
  console.log(JSON.stringify({
    distinct_surface_keys_overlap: overlap,
    nested_lifecycle_lock_waits: !nestedCompleted,
  }));
}, 20);
JS

Repository: EtanHey/cmuxlayer

Length of output: 481


🏁 Script executed:

#!/bin/bash
set -eu
node - <<'JS'
"use strict";

const active = new Map();
function acquire(key, owner) {
  if (active.has(key)) throw new Error(`conflict:${key}`);
  active.set(key, owner);
}
function release(key, owner) {
  if (active.get(key) === owner) active.delete(key);
}

acquire("uuid:agent-1", "delivery");
let distinctKeysOverlap = false;
try {
  acquire("surface-ref-1", "stop-close");
  distinctKeysOverlap = true;
} finally {
  release("surface-ref-1", "stop-close");
  release("uuid:agent-1", "delivery");
}

let tail = Promise.resolve();
function runLifecycleMutation(operation) {
  const previous = tail;
  let release;
  tail = new Promise((resolve) => { release = resolve; });
  return previous.then(async () => {
    try {
      return await operation();
    } finally {
      release();
    }
  });
}

let nestedCompleted = false;
const outer = runLifecycleMutation(async () => {
  await Promise.race([
    runLifecycleMutation(async () => {
      nestedCompleted = true;
    }),
    new Promise((resolve) => setTimeout(resolve, 20)),
  ]);
});
await outer;

console.log(JSON.stringify({
  distinct_surface_keys_overlap: distinctKeysOverlap,
  nested_lifecycle_lock_pending_during_outer_operation: !nestedCompleted,
}));
JS

Repository: EtanHey/cmuxlayer

Length of output: 255


🏁 Script executed:

#!/bin/bash
set -eu
node - <<'JS'
"use strict";

let tail = Promise.resolve();
function runLifecycleMutation(operation) {
  const previous = tail;
  let release;
  tail = new Promise((resolve) => { release = resolve; });
  return previous.then(async () => {
    try {
      return await operation();
    } finally {
      release();
    }
  });
}

let nestedCompleted = false;
let nestedCompletedWhileOuterHeld = false;
let outerReleased = false;
let observedWhileOuterHeld = false;

const outer = runLifecycleMutation(async () => {
  void runLifecycleMutation(async () => {
    nestedCompleted = true;
    if (!outerReleased) nestedCompletedWhileOuterHeld = true;
  });
  await new Promise((resolve) => setTimeout(resolve, 10));
  observedWhileOuterHeld = !nestedCompleted;
});
await outer;
outerReleased = true;
await new Promise((resolve) => setTimeout(resolve, 0));

console.log(JSON.stringify({
  nested_lock_pending_while_outer_held: observedWhileOuterHeld,
  nested_completed_while_outer_held: nestedCompletedWhileOuterHeld,
  nested_completed_after_outer_release: nestedCompleted,
}));
JS

Repository: EtanHey/cmuxlayer

Length of output: 286


🏁 Script executed:

#!/bin/bash
set -eu
node - <<'JS'
"use strict";

let tail = Promise.resolve();
let outerReleased = false;

function runLifecycleMutation(id, operation) {
  const previous = tail;
  let release;
  tail = new Promise((resolve) => { release = resolve; });
  return previous.then(async () => {
    try {
      return await operation();
    } finally {
      if (id === "outer") outerReleased = true;
      release();
    }
  });
}

let nestedCompleted = false;
let nestedCompletedWhileOuterHeld = false;
let observedWhileOuterHeld = false;

const outer = runLifecycleMutation("outer", async () => {
  void runLifecycleMutation("nested", async () => {
    nestedCompleted = true;
    if (!outerReleased) nestedCompletedWhileOuterHeld = true;
  });
  await new Promise((resolve) => setTimeout(resolve, 10));
  observedWhileOuterHeld = !nestedCompleted;
});
await outer;
await new Promise((resolve) => setTimeout(resolve, 0));

console.log(JSON.stringify({
  nested_lock_pending_while_outer_held: observedWhileOuterHeld,
  nested_completed_while_outer_held: nestedCompletedWhileOuterHeld,
  nested_completed_after_outer_release: nestedCompleted && outerReleased,
}));
JS

Repository: EtanHey/cmuxlayer

Length of output: 287


Use the same surface-write lock key for delivery and stop cleanup.

deliverAgentInput() locks uuid:${surface_uuid}. stopAgent() closes the route without stableSurfaceIdentity, so it locks the mutable surface reference instead. These keys can overlap on the same surface.

Do not move drainDeliveryQueue() inside runLifecycleMutation(). Its metadata refresh uses the non-reentrant lifecycle lock and can block until the delivery timeout. Pass stableSurfaceIdentity: route.surface_uuid to the stop close path.

🤖 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 4523 - 4526, Update the stop cleanup path
used by runSweep and stopAgent so the route close operation receives
stableSurfaceIdentity set to route.surface_uuid, matching deliverAgentInput’s
uuid-based lock key. Keep drainDeliveryQueue() outside runLifecycleMutation();
do not change the delivery queue ordering or lifecycle lock behavior.

Comment thread src/agent-engine.ts
Comment on lines +4576 to +4589
private persistDeliveryReceipts(): void {
mkdirSync(dirname(this.deliveryReceiptsPath), { recursive: true });
const tempPath = `${this.deliveryReceiptsPath}.${process.pid}.${randomUUID()}.tmp`;
try {
writeFileSync(
tempPath,
`${JSON.stringify([...this.deliveryReceipts.values()], null, 2)}\n`,
"utf8",
);
renameSync(tempPath, this.deliveryReceiptsPath);
} finally {
if (existsSync(tempPath)) unlinkSync(tempPath);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

Delivery receipts are never pruned, and the whole file is rewritten on every mutation.

Three consequences follow from the same root cause:

  1. this.deliveryReceipts only ever grows. No code path deletes a terminal receipt. For a long-lived daemon, delivery-receipts.json grows without bound.
  2. persistDeliveryReceipts serializes the entire map with 2-space indentation on every change. drainDeliveryQueue calls it at least twice per queued receipt. Cost per drain is O(total receipts × queued receipts), and every sweep re-scans all historical receipts at Line 4650.
  3. AgentDeliveryReceipt.text stores the full delivery payload. Agent-to-agent message bodies are retained on disk in plaintext for the lifetime of the state directory.

Add retention: drop terminal receipts after a bounded age or count, and truncate or omit text once a receipt becomes terminal. Batch the persist calls in drainDeliveryQueue so one drain writes once.

🤖 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 4576 - 4589, Update delivery-receipt
lifecycle handling around persistDeliveryReceipts and drainDeliveryQueue: retain
only bounded-age or bounded-count receipts, remove terminal receipts when they
exceed retention, and clear or omit AgentDeliveryReceipt.text when a receipt
becomes terminal. Batch queue mutations so drainDeliveryQueue invokes
persistence once per drain rather than once per receipt, while preserving atomic
file replacement.

Comment thread src/inbox.ts
Comment on lines +384 to +396
try {
mkdirSync(lockPath);
lockAcquired = true;
} catch (error) {
if (
error instanceof Error &&
"code" in error &&
error.code === "EEXIST"
) {
throw new Error(`Inbox cursor is locked for ${agentId}; retry`);
}
throw 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.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Look for any cleanup of inbox cursor lock directories.
rg -n -C4 'inbox\.cursor|cursor\.lock|\.lock' --glob '*.ts' -g '!**/node_modules/**'

Repository: EtanHey/cmuxlayer

Length of output: 155


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- inbox.ts structure ---'
ast-grep outline src/inbox.ts
printf '%s\n' '--- lock-related code ---'
rg -n -C8 'lockPath|mkdirSync|writeInboxCursor|replayUndelivered|rmSync' src/inbox.ts
printf '%s\n' '--- relevant file references ---'
rg -n -C3 'writeInboxCursor|replayUndelivered|cursor.*lock|lockPath|Inbox cursor is locked' --glob '*.ts' --glob '!**/node_modules/**'

Repository: EtanHey/cmuxlayer

Length of output: 9763


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- writeInboxCursor implementation ---'
sed -n '348,428p' src/inbox.ts
printf '%s\n' '--- all lock and cursor references ---'
rg -n -C5 'inboxCursorPath|writeInboxCursor|readInboxCursor|\.lock|Inbox cursor is locked' --glob '!**/node_modules/**' .
printf '%s\n' '--- tests and package scripts ---'
git ls-files | rg '(^|/)(test|tests|__tests__|spec)(/|\\.)|package.json$'
if [ -f package.json ]; then sed -n '1,180p' package.json; fi

Repository: EtanHey/cmuxlayer

Length of output: 26002


🏁 Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- inbox cursor tests ---'
sed -n '190,250p' tests/inbox.test.ts
printf '%s\n' '--- source-only lock lifecycle verifier ---'
python3 - <<'PY'
from pathlib import Path
import re

source = Path("src/inbox.ts").read_text()
fn = source[source.index("export function writeInboxCursor"):source.index("/** Set of message ids", source.index("export function writeInboxCursor"))]

checks = {
    "lock path is derived from cursor path": 'const lockPath = `${path}.lock`;' in fn,
    "lock acquisition uses mkdirSync": "mkdirSync(lockPath);" in fn,
    "existing lock always throws": 'error.code === "EEXIST"' in fn and
        'throw new Error(`Inbox cursor is locked for ${agentId}; retry`);' in fn,
    "cleanup is conditional on current process acquisition": 
        'if (lockAcquired) rmSync(lockPath, { recursive: true, force: true });' in fn,
    "no owner or stale-lock metadata": not any(
        token in fn for token in ("process.pid", "Date.now()", "statSync", "mtime", "owner")
    ),
}
for name, result in checks.items():
    print(f"{name}: {result}")
raise SystemExit(0 if all(checks.values()) else 1)
PY
printf '%s\n' '--- inbox-specific cleanup references ---'
rg -n -C3 'inboxCursorPath|inbox\.cursor\.lock|Inbox cursor is locked|writeInboxCursor' src tests --glob '*.ts'

Repository: EtanHey/cmuxlayer

Length of output: 2894


Add recovery for stale inbox cursor locks

writeInboxCursor removes ${path}.lock only when the owning process reaches finally. A terminated process can leave the directory behind and make all later writes fail with Inbox cursor is locked. Add owner metadata and a bounded stale-lock policy. Ensure a writer can remove only its own lock, and add a regression test for a stale lock.

🤖 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/inbox.ts` around lines 384 - 396, Update writeInboxCursor’s lock
acquisition and cleanup to write owner metadata, detect stale locks using a
bounded age policy, and remove a lock only when ownership can be established for
the current writer. Preserve the existing retry error for active locks, ensure
stale locks can be recovered safely, and add a regression test covering recovery
from a stale lock.

Comment thread src/inbox.ts
Comment on lines +398 to +418
// Read both the inbox and watermark only after acquiring the lock. This is
// the compare-and-set boundary across independently resumed agent processes.
const messages = readInbox(agentId, opts);
const nextIndex = messages.findIndex((message) => message.id === messageId);
if (nextIndex < 0) {
throw new Error(`Cannot advance inbox cursor to unknown message ${messageId}`);
}
const current = readInboxCursor(agentId, opts);
if (current) {
const currentIndex = messages.findIndex((message) => message.id === current);
if (currentIndex >= 0 && nextIndex < currentIndex) {
throw new Error(
`Cannot move inbox cursor backwards from ${current} to ${messageId}`,
);
}
}
tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
writeFileSync(tempPath, `${messageId}\n`, "utf8");
renameSync(tempPath, path);
tempPath = null;
return messageId;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

The regression guard fails open for an out-of-range current cursor.

currentIndex is -1 when the persisted cursor id is absent from the inbox file. The condition currentIndex >= 0 && nextIndex < currentIndex then skips the check, and the cursor moves to any known message. This allows a silent backwards move after inbox truncation.

Reject the advance, or preserve the current cursor, when the current cursor id cannot be located.

🤖 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/inbox.ts` around lines 398 - 418, Update the cursor validation around
readInboxCursor so an existing current cursor whose ID is absent from messages
is rejected or preserved instead of allowing advancement. Keep the existing
backwards-movement check for located cursors, while ensuring unknown persisted
cursors cannot move to a new message.

Comment thread src/inbox.ts
Comment on lines +439 to +447
const messages = readInbox(agentId, opts);
const cursor = readInboxCursor(agentId, opts);
if (cursor) {
const cursorIndex = messages.findIndex((message) => message.id === cursor);
// An unknown/corrupt cursor cannot safely suppress anything: replay all.
return cursorIndex >= 0 ? messages.slice(cursorIndex + 1) : messages;
}
const acked = ackedIds(agentId, opts);
return readInbox(agentId, opts).filter((m) => !acked.has(m.id));
return messages.filter((m) => !acked.has(m.id));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Inconsistent handling of a cursor id that is absent from the inbox file. Both sites read the persisted cursor and locate it with findIndex. Neither site defines a coherent behavior for -1. replayUndelivered treats the unresolvable cursor as "suppress nothing" and returns the entire inbox. writeInboxCursor treats it as "no constraint" and permits any advance. One decision about the unresolvable-cursor case resolves both.

  • src/inbox.ts#L439-L447: fall back to ack-based filtering when cursorIndex is -1, instead of returning every message.
  • src/inbox.ts#L398-L418: reject the advance when currentIndex is -1, instead of skipping the backwards-move check.
📍 Affects 1 file
  • src/inbox.ts#L439-L447 (this comment)
  • src/inbox.ts#L398-L418
🤖 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/inbox.ts` around lines 439 - 447, The unresolvable persisted cursor must
be handled consistently across replay and cursor advancement. In src/inbox.ts
lines 439-447, update replayUndelivered to use ack-based filtering when
cursorIndex is -1 rather than returning all messages; in src/inbox.ts lines
398-418, update writeInboxCursor to reject advances when currentIndex is -1
instead of bypassing the backwards-move check.

Comment thread tests/inbox.test.ts
Comment on lines +225 to +235
it("serializes cursor advancement with a per-agent cross-process lock", () => {
dispatch("cursor-lock", { from: "orc", task: "t1", id: "m1" }, opts);
mkdirSync(`${inboxCursorPath("cursor-lock", opts)}.lock`, {
recursive: true,
});

expect(() => writeInboxCursor("cursor-lock", "m1", opts)).toThrow(
/cursor.*locked/i,
);
expect(readInboxCursor("cursor-lock", opts)).toBeNull();
});

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

Add coverage for the remaining writeInboxCursor rejection paths.

The suite pins the lock path only. Three other documented behaviors have no test: rejection of an unknown message id, rejection of a backwards move, and replay behavior for a cursor id that is absent from the inbox file. The third case controls whether an agent receives its entire retained history, so it deserves an explicit assertion.

🤖 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/inbox.test.ts` around lines 225 - 235, Expand the writeInboxCursor
tests around the existing cursor-lock case to cover rejection of an unknown
message id, rejection of a backwards cursor move, and replay when the cursor id
is absent from the inbox file. Use the existing inbox setup and helpers, assert
the documented errors for the two rejection paths, and verify the absent-id
replay returns the full retained history.

Comment on lines 120 to +123
if (args.includes("list-workspaces")) {
// Spawn response shaping enumerates topology after boot submission has
// been verified. Return subsequent reads to the caller-controlled screen.
submissionObservationPending = 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.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Reset the observation flag on an explicit signal, not on list-workspaces.

The mock clears submissionObservationPending when the server enumerates workspaces. That couples the fixture to an assumption that no workspace enumeration happens between the Return keypress and submission verification. Topology collection also calls list-workspaces, and the server does collect topology mid-delivery whenever the spawned surface exposes a stable UUID. This fixture avoids that only because list-pane-surfaces omits id.

If a future change adds a surface UUID to this fixture, the flag clears early, the caller screen returns before verification finishes, and the test flakes. Prefer clearing the flag after a bounded number of read-screen calls, or expose an explicit helper the test calls when it wants the caller-controlled screen back.

🤖 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/pointer-discipline.test.ts` around lines 120 - 123, Stop resetting
submissionObservationPending in the list-workspaces handling path, since
topology collection may invoke it before submission verification completes.
Clear the flag only through an explicit test signal or after a bounded
read-screen count, while preserving the caller-screen transition until
verification has finished.

Comment on lines +7057 to +7066
expect(result.isError).toBe(true);
expect(failed).toMatchObject({
delivery_id: expect.any(String),
delivery_state: "failed",
terminal: true,
});
expect(engine.getDeliveryReceipt(failed.delivery_id)).toMatchObject({
delivery_state: "failed",
terminal: true,
});

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

Add an assertion for the duplicate-event guard.

send_to passes appendFailureEvent: !(error instanceof SubmitVerificationError) so a submission-verification failure does not append a second terminal event on top of the one deliverInputChunks already wrote. No test covers that guard. A regression there would double-count failures in delivery telemetry and stay green.

This test already holds the delivery id, so counting matching event-log entries is a small addition.

♻️ Proposed assertion
     expect(engine.getDeliveryReceipt(failed.delivery_id)).toMatchObject({
       delivery_state: "failed",
       terminal: true,
     });
+    expect(
+      engine.stateMgr
+        .getEventLog()
+        .readEntries()
+        .filter(
+          (entry: any) =>
+            entry.delivery_id === failed.delivery_id &&
+            entry.delivery_state === "failed",
+        ),
+    ).toHaveLength(1);
   });
📝 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
expect(result.isError).toBe(true);
expect(failed).toMatchObject({
delivery_id: expect.any(String),
delivery_state: "failed",
terminal: true,
});
expect(engine.getDeliveryReceipt(failed.delivery_id)).toMatchObject({
delivery_state: "failed",
terminal: true,
});
expect(result.isError).toBe(true);
expect(failed).toMatchObject({
delivery_id: expect.any(String),
delivery_state: "failed",
terminal: true,
});
expect(engine.getDeliveryReceipt(failed.delivery_id)).toMatchObject({
delivery_state: "failed",
terminal: true,
});
expect(
engine.stateMgr
.getEventLog()
.readEntries()
.filter(
(entry: any) =>
entry.delivery_id === failed.delivery_id &&
entry.delivery_state === "failed",
),
).toHaveLength(1);
🤖 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/server-agent-tools.test.ts` around lines 7057 - 7066, Extend the test
around the existing failed delivery assertions to verify the duplicate-event
guard: using failed.delivery_id, inspect the event log and assert exactly one
matching terminal failure event exists after the submission-verification
failure. Keep the existing delivery receipt and failure-state assertions
unchanged.

Comment on lines +8234 to +8236
expect(
mockExec.mock.calls.filter(([, args]) => args.includes("send")),
).toHaveLength(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.

🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Include buffer-based delivery in the "nothing was sent" assertion.

This asserts only that no send command ran. Boot and relay text now reaches panes through set-buffer plus paste-buffer, as the other assertions in this file were updated to reflect. A queued delivery that incorrectly wrote to the pane through the buffer path would keep this assertion green.

♻️ Proposed assertion
     expect(
-      mockExec.mock.calls.filter(([, args]) => args.includes("send")),
+      mockExec.mock.calls.filter(
+        ([, args]) =>
+          args.includes("send") ||
+          args.includes("set-buffer") ||
+          args.includes("paste-buffer"),
+      ),
     ).toHaveLength(0);
📝 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
expect(
mockExec.mock.calls.filter(([, args]) => args.includes("send")),
).toHaveLength(0);
expect(
mockExec.mock.calls.filter(
([, args]) =>
args.includes("send") ||
args.includes("set-buffer") ||
args.includes("paste-buffer"),
),
).toHaveLength(0);
🤖 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/server-agent-tools.test.ts` around lines 8234 - 8236, Update the
“nothing was sent” assertion around mockExec calls to also reject buffer-based
delivery, checking for both set-buffer and paste-buffer commands in addition to
send. Preserve the existing zero-call expectation for all pane-delivery paths.

Comment on lines +146 to +152
sendKey: vi.fn().mockImplementation(async (surface: string, key: string) => {
if (key === "return") {
const count = (returnCount.get(surface) ?? 0) + 1;
returnCount.set(surface, count);
if (count >= 2) submitted.add(surface);
}
}),

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

Key submission on the delivered contract, not on a Return count.

count >= 2 encodes the assumption that exactly one Return precedes the boot prompt and the second Return submits it. The delivery path can press additional Returns: verifySubmitAfterEnter performs a recovery-enter retry when input still looks pending. An extra Return would mark the surface submitted early and let a genuine verification failure pass.

tests/spawn-monitor-boot.test.ts already models this by tracking whether the mailbox-contract text was delivered and only then treating a Return as a submission. Use the same rule here so the fixture states its intent and does not depend on a keypress count.

♻️ Proposed fix
-  const returnCount = new Map<string, number>();
+  const bootTextSent = new Set<string>();
     send: vi.fn().mockImplementation(async (surface: string, text: string) => {
       if (/Codex/.test(text)) activeCli.set(surface, "codex");
       if (/Claude/.test(text)) activeCli.set(surface, "claude");
+      if (text.includes("cmuxlayer mailbox contract")) bootTextSent.add(surface);
     }),
     pasteText: vi.fn().mockImplementation(async (surface: string, text: string) => {
       if (/Codex/.test(text)) activeCli.set(surface, "codex");
       if (/Claude/.test(text)) activeCli.set(surface, "claude");
+      if (text.includes("cmuxlayer mailbox contract")) bootTextSent.add(surface);
     }),
     sendKey: vi.fn().mockImplementation(async (surface: string, key: string) => {
-      if (key === "return") {
-        const count = (returnCount.get(surface) ?? 0) + 1;
-        returnCount.set(surface, count);
-        if (count >= 2) submitted.add(surface);
-      }
+      if (key === "return" && bootTextSent.has(surface)) {
+        submitted.add(surface);
+      }
     }),
🤖 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/spawn-workspace.test.ts` around lines 146 - 152, Update the sendKey
mock in the spawn workspace test to mark a surface submitted only when a Return
is delivered after the mailbox-contract text has been delivered, rather than
when the Return count reaches two. Reuse the existing contract-delivery tracking
pattern from spawn-monitor-boot.test.ts and preserve the current returnCount
tracking only where needed for setup or assertions.

Co-Authored-By: cmuxlayerCodex running gpt-5.6-sol <noreply@anthropic.com>
@cursor

cursor Bot commented Aug 12, 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_4f1c8afd-a45e-4e2c-9a17-abd2e4747911)

@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: 1

🤖 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 `@tests/server-agent-tools.test.ts`:
- Line 8260: Replace the fixed 275 ms sleep in the retry test with bounded
polling that reads the persisted next_attempt_at and proceeds once it is due, or
advance controlled timers using the same clock as AgentEngine. Keep the wait
bounded and preserve the test’s existing retry assertions without coupling them
to the current backoff duration.
🪄 Autofix

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 Plus

Run ID: 5fa3bc6d-6648-449b-a54c-4588e303e4c5

📥 Commits

Reviewing files that changed from the base of the PR and between 937c827 and 9d2c7d7.

📒 Files selected for processing (1)
  • tests/server-agent-tools.test.ts
📜 Review details
🧰 Additional context used
🧠 Learnings (14)
📓 Common learnings
Learnt from: EtanHey
Repo: EtanHey/cmuxlayer PR: 0
File: :0-0
Timestamp: 2026-08-02T16:01:45.986Z
Learning: PR `#345` preserves created resource identities in `src/server.ts` spawn-related tool failure responses. The follow-up structural prevention work is tracked in GitHub issue `#348`.
📚 Learning: 2026-03-15T10:46:40.958Z
Learnt from: EtanHey
Repo: EtanHey/cmuxlayer PR: 1
File: tests/sidebar-sync.test.ts:18-77
Timestamp: 2026-03-15T10:46:40.958Z
Learning: In the cmuxlayer project, each test file (e.g., tests/sidebar-sync.test.ts, tests/quality-tracking.test.ts, tests/agent-hierarchy.test.ts) is intentionally self-contained. All mock setup helpers (makeMockClient, makeSurface, makeRecord) are defined locally within each test file rather than in shared fixtures. This is a deliberate design choice so that when a test fails, all context is in one file. Shared fixtures are avoided to prevent coupling between test suites. Minor drift in mock fields across files (e.g., listStatus present in one file but not another) is acceptable — it only matters when a test explicitly calls that method. Do not flag duplicated test helpers or suggest extracting them into shared fixture modules.

Applied to files:

  • tests/server-agent-tools.test.ts
📚 Learning: 2026-07-18T01:46:21.272Z
Learnt from: EtanHey
Repo: EtanHey/cmuxlayer PR: 338
File: tests/self-registration.test.ts:775-935
Timestamp: 2026-07-18T01:46:21.272Z
Learning: In the self-registration feature, `tests/self-registration.test.ts` intentionally contains fully mocked `AgentEngine` composition/boot-capture cases. These feature-level acceptance tests cover the contract between `src/self-registration.ts` and `src/agent-engine.ts`; do not require moving them to `tests/agent-engine.test.ts` solely to mirror source layout.

Applied to files:

  • tests/server-agent-tools.test.ts
📚 Learning: 2026-07-14T17:32:22.637Z
Learnt from: EtanHey
Repo: EtanHey/cmuxlayer PR: 0
File: :0-0
Timestamp: 2026-07-14T17:32:22.637Z
Learning: In cmuxlayer's `src/agent-engine.ts`, `runCloseForensicsBestEffort` treats both `tab_close` and `workspace_teardown` close-forensics event origins as terminal operator intent for a matching managed surface, persisting that intent before absence reconciliation can treat it as a recoverable crash (as of commit cd1ac43). Previously only `tab_close` was treated this way. Genuine PTY-death recovery (respawn with attempt limits) is a separate code path and remains unaffected by this origin allowlist.

Applied to files:

  • tests/server-agent-tools.test.ts
📚 Learning: 2026-07-04T23:37:37.595Z
Learnt from: EtanHey
Repo: EtanHey/cmuxlayer PR: 220
File: tests/agent-engine.test.ts:2977-2977
Timestamp: 2026-07-04T23:37:37.595Z
Learning: In tests/agent-engine.test.ts for the cmuxlayer project, the general guideline of using 1-second timeouts for `waitFor` in agent-engine tests does not apply to positive/ready-resolution test cases that depend on consecutive-match accumulation across multiple poll/sweep ticks (e.g., "codex-pending-ready", "gemini-identity-screen-ready"). In `AgentEngine.waitFor`, the elapsed time is checked against timeoutMs before the next evidence poll, so a 1s budget can cause a false timeout for these multi-poll cases. These specific tests intentionally use longer timeouts (e.g., 1500ms/2500ms) and this is verified/expected behavior, not a violation to flag.

Applied to files:

  • tests/server-agent-tools.test.ts
📚 Learning: 2026-07-14T17:22:29.038Z
Learnt from: EtanHey
Repo: EtanHey/cmuxlayer PR: 0
File: :0-0
Timestamp: 2026-07-14T17:22:29.038Z
Learning: In cmuxlayer's `src/server.ts` `close_surface` tool handler, the UUID-less legacy-record fallback match (`record.surface_id === args.surface` for records without `surface_uuid`) must only be applied when `observedSurfaceUuid === undefined` (i.e., the live topology genuinely cannot resolve a UUID for the closed ref). This mirrors the invariant in `resolveAgentIoRoute` (`src/agent-engine.ts`), which only permits UUID-less ref-based terminal I/O when a complete fresh topology proves zero UUID coverage. Without this guard, a recycled mutable ref could be mistakenly attributed to a stale legacy record even when a live UUID is observed for that ref (belonging to a different, current owner).

Applied to files:

  • tests/server-agent-tools.test.ts
📚 Learning: 2026-04-01T20:31:10.910Z
Learnt from: CR
Repo: EtanHey/cmuxlayer PR: 0
File: site/CLAUDE.md:0-0
Timestamp: 2026-04-01T20:31:10.910Z
Learning: Applies to site/**/*agent*.test.{ts,tsx} : Agents must have comprehensive unit tests covering success and failure paths

Applied to files:

  • tests/server-agent-tools.test.ts
📚 Learning: 2026-06-05T17:19:12.114Z
Learnt from: EtanHey
Repo: EtanHey/cmuxlayer PR: 0
File: :0-0
Timestamp: 2026-06-05T17:19:12.114Z
Learning: In the cmuxlayer project (src/server.ts / spawn lifecycle), readiness timeouts during agent launch are non-terminal for lifecycle state. A `BootPromptTimeoutError` should NOT transition the agent to `error` — the agent stays in `booting` with no `error` set. A timeout can mean the CLI chrome changed or the PTY is still healthy but not yet matched; transitioning to error ("poisoning the registry") would block `send_to_agent` and inbox wake. Only actual boot-prompt delivery failures (non-timeout) are terminal, because partial delivery can leave the receiver in an unreliable state.

Applied to files:

  • tests/server-agent-tools.test.ts
📚 Learning: 2026-03-16T22:37:27.455Z
Learnt from: EtanHey
Repo: EtanHey/cmuxlayer PR: 0
File: :0-0
Timestamp: 2026-03-16T22:37:27.455Z
Learning: In the cmuxlayer project (src/agent-engine.ts / src/agent-types.ts), the inconsistency between `buildLaunchCommand` (throws on `/` in repo names for shell arg safety) and `generateAgentId` (sanitizes `/` to `-` for key safety) is intentional and tracked for follow-up. Do not flag this mismatch as a bug. Both approaches are valid for their respective contexts.

Applied to files:

  • tests/server-agent-tools.test.ts
📚 Learning: 2026-07-13T18:37:02.633Z
Learnt from: EtanHey
Repo: EtanHey/cmuxlayer PR: 311
File: src/server.ts:93-93
Timestamp: 2026-07-13T18:37:02.633Z
Learning: In `src/server.ts`, `assertDeliveryTargetIsSafe` deliberately calls `isPickerOrMenuScreen(snapshot.text, cli)` without threading a resolved `cli` hint through the shared `deliverInputChunks` delivery-safety boundary. This is intentional: the delivery safety gate must fail closed across every recognized picker/menu shape regardless of CLI, because registry/launcher CLI metadata can be absent or stale (especially on recycled surfaces), and scoping the gate by CLI could let a live menu from a different CLI slip through and consume text. The optional `cli` parameter remains useful for targeted parser callers that need CLI-scoped detection (e.g., Codex-specific update-menu detection), but the delivery-safety path itself stays CLI-agnostic by design.

Applied to files:

  • tests/server-agent-tools.test.ts
📚 Learning: 2026-03-16T22:37:27.796Z
Learnt from: EtanHey
Repo: EtanHey/cmuxlayer PR: 0
File: :0-0
Timestamp: 2026-03-16T22:37:27.796Z
Learning: In the cmuxlayer project (src/agent-registry.ts), orphan reparenting is NOT part of V1. When a parent agent crashes, children intentionally keep their parent_agent_id pointing to the dead parent (orphan survival). Reparenting children to root (setting parent_agent_id to null) is a V2 design feature that will be introduced in a dedicated future PR with its own tests. Do not flag missing reparenting logic in agent-registry.ts until the V2 reparenting PR lands.

Applied to files:

  • tests/server-agent-tools.test.ts
📚 Learning: 2026-07-11T13:51:37.614Z
Learnt from: EtanHey
Repo: EtanHey/cmuxlayer PR: 285
File: src/control-health.ts:183-208
Timestamp: 2026-07-11T13:51:37.614Z
Learning: In the `cmuxlayer` repository, `src/monitor-registry.ts` internals (e.g., `readMonitorRegistry`, `queryMonitorRegistryForGates`) are considered out of scope for modification in PR `#285`; its public API only accepts registry file paths, not pre-parsed snapshots. A true single-snapshot read-validate-count fix for `collectSelfHealHealth` in `src/control-health.ts` (avoiding a TOCTOU risk from reopening the file twice) requires adding a new public snapshot parser/query API to `monitor-registry.ts`. This is tracked in issue `#286` (EtanHey/cmuxlayer) with concrete acceptance criteria; until then, the mitigation is size-bounded reads and fail-safe (unavailable) handling of malformed/invalid registries, relying on the registry writer's atomic-rename contract.

Applied to files:

  • tests/server-agent-tools.test.ts
📚 Learning: 2026-03-15T10:42:08.557Z
Learnt from: EtanHey
Repo: EtanHey/cmuxlayer PR: 1
File: src/agent-engine.ts:174-178
Timestamp: 2026-03-15T10:42:08.557Z
Learning: In the cmuxlayer project (`src/agent-engine.ts`), the `CmuxClient` methods `send()` and `sendKey()` are backed by a cmux socket that processes commands in order. Awaiting them sequentially guarantees the prior command is fully delivered before the next is sent — no additional delay or confirmation is needed between consecutive `send()`/`sendKey()` calls.

Applied to files:

  • tests/server-agent-tools.test.ts
📚 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/server-agent-tools.test.ts
🔇 Additional comments (2)
tests/server-agent-tools.test.ts (2)

7057-7066: Verify the exact-one failure-event assertion.

The previous review requested an event-log assertion for failed.delivery_id. If Lines 7057-7066 still check only the terminal receipt, the duplicate-event guard remains untested. Assert that exactly one matching terminal failure event exists.

#!/usr/bin/env bash
set -euo pipefail
sed -n '7050,7070p' tests/server-agent-tools.test.ts
rg -n -C 5 'readEntries|delivery_id|delivery_state' tests/server-agent-tools.test.ts

33-33: LGTM!

Also applies to: 103-135, 148-168, 235-251, 648-649, 674-694, 1945-1945, 2053-2053, 2639-2645, 2740-2752, 2829-2840, 2861-2873, 2948-2949, 2961-2962, 3694-3694, 3904-3910, 4015-4017, 4033-4033, 4095-4101, 4163-4165, 4181-4181, 4986-4986, 5802-5802, 7027-7056, 7067-7068, 8198-8259, 8261-8278, 8427-8473


const ready = engine.stateMgr.updateRecord(agentId, { state: "idle" });
registry.set(agentId, ready);
await new Promise((resolve) => setTimeout(resolve, 275));

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 | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- target test context ---'
sed -n '8200,8295p' tests/server-agent-tools.test.ts

printf '%s\n' '--- retry/backoff and receipt references ---'
rg -n -C 4 'next_attempt_at|backoff|retry|delivery_id|getDeliveryReceipt|setTimeout' tests/server-agent-tools.test.ts src server packages 2>/dev/null | head -n 500

printf '%s\n' '--- candidate implementation files ---'
rg -l 'next_attempt_at|getDeliveryReceipt|first retry|retry.*250|250.*retry' . --glob '!node_modules' --glob '!dist' --glob '!build' | head -n 100

Repository: EtanHey/cmuxlayer

Length of output: 31095


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- AgentEngine structure ---'
ast-grep outline src/agent-engine.ts | head -n 250

printf '%s\n' '--- queue implementation references ---'
rg -n -C 8 'drainDeliveryQueue|next_attempt_at|delivery_state|retry_count|delivery receipt|backoff' src/agent-engine.ts src/agent-types.ts tests/server-agent-tools.test.ts

printf '%s\n' '--- test setup and timer support ---'
sed -n '1,180p' tests/server-agent-tools.test.ts
rg -n -C 5 'useFakeTimers|advanceTimers|waitFor|poll|sleep|drainDeliveryQueue' tests/server-agent-tools.test.ts src/agent-engine.ts

Repository: EtanHey/cmuxlayer

Length of output: 50373


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- exact queue implementation ---'
sed -n '4530,4730p' src/agent-engine.ts

printf '%s\n' '--- delivery submitter wiring ---'
rg -n -C 12 'setDeliverySubmitter|RetryableDeliveryError|sendToAgent|send_to_agent|send_to' src/server.ts src/*.ts tests/server-agent-tools.test.ts | head -n 600

printf '%s\n' '--- queue test declaration and setup ---'
sed -n '8120,8279p' tests/server-agent-tools.test.ts

printf '%s\n' '--- deterministic source verifier ---'
python3 - <<'PY'
from pathlib import Path
p = Path("src/agent-engine.ts").read_text()
required = [
    'Date.parse(receipt.next_attempt_at) > Date.now()',
    'const backoffMs = Math.min(',
    '250 * 2 ** Math.min(receipt.retry_count - 1, 16)',
    'receipt.next_attempt_at = new Date(',
]
for needle in required:
    print(f"{needle!r}: {needle in p}")
start = p.index("if (error instanceof RetryableDeliveryError)")
end = p.index("this.persistDeliveryReceipts();", start)
print("\n--- retry scheduling statements ---")
print(p[start:end])
PY

Repository: EtanHey/cmuxlayer

Length of output: 47768


Replace the hard-coded retry delay.

Line 8260 waits 275 ms to exceed the current 250 ms backoff. This couples the test to the retry policy and adds fixed latency. Wait until the persisted next_attempt_at is due with a bounded polling helper, or advance controlled timers using the same clock as AgentEngine.

🤖 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/server-agent-tools.test.ts` at line 8260, Replace the fixed 275 ms
sleep in the retry test with bounded polling that reads the persisted
next_attempt_at and proceeds once it is due, or advance controlled timers using
the same clock as AgentEngine. Keep the wait bounded and preserve the test’s
existing retry assertions without coupling them to the current backoff duration.

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