Skip to content

[orchestrator-v2] fix(orchestrator): Harden Grok v2 settlement, background task lifecycle, steer visibility, and images#3578

Open
mwolson wants to merge 13 commits into
pingdotgg:t3code/codex-turn-mappingfrom
mwolson:fix/grok-v2
Open

[orchestrator-v2] fix(orchestrator): Harden Grok v2 settlement, background task lifecycle, steer visibility, and images#3578
mwolson wants to merge 13 commits into
pingdotgg:t3code/codex-turn-mappingfrom
mwolson:fix/grok-v2

Conversation

@mwolson

@mwolson mwolson commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Fixes #3580

Summary

Harden Grok orchestration v2 so root turns complete correctly, steered user
messages stay visible, Stop then continue recovers from a poisoned ACP child,
async spawn_subagent / monitor project real lifecycle (not start ACKs), late
post-settle Grok traffic can land as a continuation run instead of being
dropped, screenshot / image attachments actually reach Grok instead of failing
turn start, and interrupt cleanup actually contains and reaps native Grok work
(cgroup kill / owned process ledger) instead of leaving zombie shells.

Follow-up commits soften non-Stop interrupts: steer / restart_active now cancels
and re-prompts the same live session instead of killing the CLI, a steer on a
settled held turn preserves the runtime (running subagents survive), and user
Stop keeps the unchanged hard kill + respawn.

Primary commits on fix/grok-v2 above t3code/codex-turn-mapping (the
feature story reviewers should start with):

  1. Grok specialty settlement / tools / post-settle continuation / images
  2. Interrupt process containment and cleanup hardening

Follow-up commits may land on top of those two. Treat them as reviewable
delta fixes, not a rewrite of the primary story. Integration snapshots should
still merge the whole branch, not tip-only cherry-pick.

The shared provider-continuation plumbing (C0) already landed in the base branch
alongside the Claude and Codex consumers.

What you'll notice

Steered messages stay visible

When you steer an in-flight Grok run, your message no longer flashes away
between server events. The timeline keeps the optimistic user row until the
committed user_message turn item lands.

Steering also works while a run is held open for background work: pre-fix,
steering a settled turn that deferred finalize was holding (a subagent still
running) froze the thread at Working, the steer text never reached Grok, and
later messages queued forever.

Grok multi-tool turns finish with tools and final text

Grok often emits a short assistant preamble, then tools, then more text.
Speculative idle settlement used to complete the turn after a short quiet and
call session/cancel, which froze T3 projection while the CLI kept working.
This PR disables Grok idle settle and terminalizes from real provider signals.

Stop then continue recovers instead of task_already_running

After you Stop a Grok run mid-turn, the next message restarts the Grok ACP child
before the recovery prompt, clearing zombie shell state.

Interrupt cleanup contains native work

Stop is not only an ACP cancel. On Linux with cgroup v2, delegated Grok launches
join a unique child cgroup and interrupt uses cgroup.kill as the authoritative
cleanup. When cgroup containment is unavailable, the owned-process ledger is the
fallback (Darwin process groups / Windows taskkill retained). ACP cancel is
generation-aware; unexpected transport death, double-fork descendants, and
teardown races are covered by focused tests.

Steers no longer kill the Grok process

The interrupt matrix after the follow-up commits:

Interrupt Behavior
User Stop Unchanged: hard cgroup kill + respawn, session/load on the next turn
Mid-prompt steer / restart_active Soft: session/cancel + same-session re-prompt; native context survives without a session/load replay
Settled steer (held turn) Preserve: no cancel at all; the runtime and its running subagents survive

Grok's session/cancel is detach-and-continue: the CLI reaps the foreground
shell but re-runs the interrupted command as a background task, and only the
model can kill it (via kill_command_or_subagent). Product call: the steered
model, not the client, decides whether that detached work dies, matching the
CLI's own behavior. The adapter tracks _x.ai/task_backgrounded /
task_completed into background-task mutations and tombstones model-killed
tasks so wake machinery stays truthful, and a direct Stop quarantines any late
lifecycle noise from the stopped run.

spawn_subagent and monitor project real work

Async spawn_subagent no longer completes on the background ACK alone. T3 binds
the child id, keeps the subagent running, shows durable subagent.result when
available, and hydrates from TaskOutput / get_command_or_subagent_output when
needed. monitor stays running through the start ACK, streams as command
execution, and completes from Bash exit codes or monitor-end text.

Late “will report when finished” can wake again

After the root turn settles (including when a subagent card already has a
result), Grok may still stream a late root follow-up (tool fetch + confirmation)
without a new session/prompt from T3. That traffic is buffered and attached to
a provider continuation run so the UI can go idle, then show Working again
with the follow-up, instead of dropping frames after finalize.

Long-running monitors initially made this path spam: Grok narrates every
monitor event (~3s apart, wider than the ~2s finalize quiet window), and each
burst opened its own continuation run with its own synthetic "Background task
completed." user row (4+ observed live). Continuation offers are now fully
suppressed while a tracked background task is still running: per-event agent
commentary AND tool re-reports buffer (Grok re-reports a running monitor as
Bash frames that already carry exit_code: 0 mid-stream, so a terminal-looking
tool status is not evidence the task ended), and exactly one continuation run
opens once the monitor actually ends, draining the buffered commentary.

In-turn monitors do not open a redundant wake

When Grok waits on a monitor and reports the listing in the same root turn
(no post-settle wait), late CLI re-reports and residual agent chatter must not
open synthetic "Background task completed." continuation runs. The follow-up
commit marks those tools handled mid-turn and suppresses residual agent-message
offers once handled work is done and nothing is still running.

Settled turns still get the injected monitor report

When Grok settles first ("I'll report when the monitor ends") and the CLI later
injects a monitor-event turn, the report could vanish: injected turns emit no
turn_completed, and the deferred-finalize debounce could fire while Grok was
still generating the report, dropping the chunk into an already finalized turn
(thread a8e8b0a9 run 5). The run now holds until the report streams (or a 25s
safety elapses), so the listing lands as a normal assistant message.

Screenshots and image attachments work on Grok

Attaching a screenshot to a Grok turn no longer fails immediately with a generic
provider turn start error. Grok’s ACP initialize still reports
promptCapabilities.image: false, but the agent accepts and vision-processes
image content blocks. The Grok flavor now opts in via supportsImagePrompts so
attachments are sent as ACP image blocks instead of being rejected before
session/prompt.

Problem and Fix

Problem and Why it Happened Fix
Steered user text vanished briefly on the v2 timeline (optimistic row keyed off messages that reordered against turn items) Keep the optimistic user row until the committed user_message turn item lands
Multi-tool Grok turns completed after the preamble; tools and final text never showed while the CLI kept working Disable speculative idle settle / silent session/cancel for Grok; terminalize only on real provider signals
Root turns hung Working when session/prompt stayed open without a reliable complete path Race the prompt RPC against root-matched turn_completed (and legacy prompt-complete) for the same prompt id
Stop mid-turn, then send again, wedged on Working / task_already_running Restart the Grok ACP child on interrupt recovery before the next prompt
Stop left native shells and descendants alive (ACP cancel alone did not contain the process tree) Linux cgroup kill when available; owned-process ledger fallback elsewhere
spawn_subagent showed completed in ~1ms on the background ACK; child work never bound Keep async spawn running, bind the child, project result / hydrate when available
monitor stayed spinning forever or never streamed output Keep start ACKs running; stream events into the tool; complete from exit codes / end notices
After “I'll report when finished,” late root follow-up never appeared (or every monitor burst opened its own synthetic “Background task completed.” run) Buffer post-settle wake traffic; suppress offers while background work still runs; open one continuation when the work actually ends and drain the buffer
When Grok fetched monitor output itself (long TaskOutput), the only end signal never cleared “still running,” so no continuation ever opened Treat TaskOutput completion as a real end signal post-settle and open the single continuation
Settled-then-injected monitor reports vanished (report streamed into an already-finalized turn) Hold finalize until the injected report streams (or a short safety timeout)
Steering a run held open for background work froze Working; the steer never reached Grok Finalize a prompt-settled held turn without waiting for a cancel ack, then restart into the steer
After a steer restart, still-running subagents spun forever (lineage maps were empty on the fresh turn) Carry live subagent lineages across the same-session restart so terminal signals still land
Subagent stayed running forever when the agent answered with text only and never called get_command Terminalize from the root “Background subagent … completed/failed” reminder
Persistent monitors kept the whole root run Working indefinitely Exclude persistent: true monitors from deferred-finalize holds; they continue post-settle
Screenshots / image attachments failed Grok turn start instantly while Codex accepted the same image Opt Grok into image prompt blocks even when ACP advertises image: false
In-turn monitor already reported, then late CLI re-reports opened many synthetic "Background task completed." runs Mark mid-turn terminal background tools as handled; suppress residual agent-message offers when handled work is idle
Every steer hard-killed the Grok CLI (restartRuntimeOnEveryInterrupt), discarding native session context and any running subagents even when the user was only redirecting Non-Stop interrupts go soft: session/cancel + same-session re-prompt; a steer on a settled held turn skips the cancel entirely and preserves the runtime
Grok session/cancel detaches and re-runs the interrupted command as a background task; nothing tracked that work, so wake machinery could misread pending state Track _x.ai/task_backgrounded / task_completed into background-task mutations; tombstone tasks the model kills via kill_command_or_subagent (no task_completed is emitted for kills)
Late background task lifecycle after a direct Stop could re-pin wake machinery (activeSessionId still points at the stopped session until the next turn respawns) Drop background task mutations while stoppedRunQuarantine is set, and ignore child-session mutations that must not gate root wake state

Defensive Fixes

Problem and Why it Happened Fix
Hard Stop raced transport death: interrupt effects retried “turn is not active” until the outbox exhausted Idempotent interrupt when already cleared; concurrent hard teardown awaits the barrier; finalize the turn before process-group kill
Restart control effects hung polling for terminalization when the live session was already gone Treat missing session as already stopped; skip the interrupt wait and continue detach/start
Non-retryable “not active” matching on provider-turn.restart could swallow a failed replacement start Limit non-retry completion to pure interrupt races; restart stays retryable through start
Sticky continuationRequested after a failed or skipped dispatch blocked further offers and pinned idle Clear the flag when this generation finishes a dispatch attempt (success or failure); invalidate on every startTurn
Thought-only post-settle chunks opened synthetic continuation runs Offer only on assistant text or terminal tool status; thoughts may still buffer for attach
Post-settle agent text was dropped when activeTurn was null and in-turn-handled task ids remained Only suppress while a finalized context is still held; null activeTurn goes to the wake buffer
Coalesced <monitor-event> chunks dropped later progress / end notices Apply every monitor-event, batched <monitor> block, and end notice in the chunk
Multi-task_ids TaskOutput hydrated only the first id Prefer the result/header task id; otherwise complete each requested id
Text envelopes with Exit Code: -1 read as success Match optional signed exit codes as failed
Idle pin re-arm could stamp pin state onto a replacement session or advanced generation Stamp only when generation and runtime still match; re-arm in-place without self-interrupting the idle fiber
Session load idle detection refreshed on unrelated sessions (multi-session setups) Load gate is scoped to the session being loaded
Byte-array tool outputs lost leading/trailing whitespace that string outputs kept Decode byte text without trimming
TaskOutput observed as still running cleared the hydration hold early Only non-running completions clear the hydration hold
Late re-report of an already in-turn-handled TaskOutput pinned idle via a wake buffer that never drained Drop already-handled frames from the wake buffer unless a continuation is already outstanding

Commits

Commit Role
fix(orchestrator): Grok v2 settlement, spawn_subagent, monitor, and images C1: Grok/ACP settlement + tools + steer-during-hold interrupt handling + post-settle buffer/attach + image attachment override
fix(orchestrator): Harden Grok interrupt cleanup C1b: cgroup/owned-process interrupt containment, generation-aware ACP cancel/teardown, process-tree tests
fix(orchestrator): Preserve Grok runtime on settled steering interrupt C2: settled steer preserves the live process and its running subagents (no cancel, no respawn)
fix(orchestrator): Soften non-Stop Grok interrupts and track backgrounded tasks C3: drop restartRuntimeOnEveryInterrupt; mid-prompt steer / restart_active becomes soft cancel + same-session re-prompt; _x.ai background-task lifecycle tracking + kill tombstones
fix(orchestrator): Quarantine post-Stop background task mutations C4: post-Stop lifecycle quarantine + child-session gate; adds the direct-Stop quarantine and production-flavor Stop hard-kill tests

The shared C0 plumbing this builds on (feat(orchestrator): Add shared provider continuation and background item plumbing) is already in
t3code/codex-turn-mapping, so this PR no longer carries it and integration
stacks pick up C0 exactly once from the base.

Root-matched terminal signal (live evidence)

Current Grok CLI emits completion as:

{
  "method": "_x.ai/session/update",
  "params": {
    "sessionId": "<root session>",
    "update": {
      "sessionUpdate": "turn_completed",
      "prompt_id": "t3-xai-prompt-N",
      "stop_reason": "end_turn"
    }
  }
}

Matching rules:

  • Root sessionId equals the active ACP session
  • prompt_id equals the pending T3-injected id (t3-xai-prompt-N)
  • Ignore task-completed-* prompt ids (background tools)
  • Keep legacy top-level _x.ai/session/prompt_complete for older builds

Do not treat idle silence or subagent completions as root terminalization.

Server mechanics

Area Behavior
Idle settle Off for Grok (settleRootTurnWhenIdle: false); speculative idle complete stays disabled
Prompt race session/prompt RPC result or root-matched turn_completed / legacy prompt_complete
Deferred finalize Holds the root turn while monitors hydrate or subagents are still running; short quiet after last rearm (~2s) so a slightly late first post-hydration chunk is not dropped; a monitor end notice on a settled turn additionally holds finalize until the injected report streams (25s safety), and hydration holds carry a 60s safety
Interrupt restart User Stop requests runtime restart; next startTurn spawns a fresh Grok process. Non-Stop interrupts no longer restart the runtime (restartRuntimeOnEveryInterrupt dropped from the Grok flavor)
Interrupt cleanup Linux cgroup kill when available; otherwise owned PID ledger; Darwin process groups; Windows taskkill
Steer restart Does not force full process respawn; a mid-turn steer cancels + re-prompts, while a prompt-settled held turn finalizes directly (there is no cancel ack to wait for) and live subagent lineages carry into the restarted turn
spawn_subagent ACK stays running + binds child; TaskOutput / get_command hydrates; durable subagent.result when available
monitor Start ACK stays running; Bash exit_code or monitor-end text completes; projected as command execution
Post-settle continuation Buffer meaningful root updates when activeTurn is null; offer only on completion-like evidence (terminal tool status after normalization, or agent text with no tracked background task still running); monitor-ended reminders and TaskOutput completion frames are the genuine end signals that clear tracked task ids (ended ids are tombstoned against straggler re-adds); attach mode skips session/prompt and drains the buffer
Image attachments Grok flavor supportsImagePrompts: true overrides ACP promptCapabilities.image:false so screenshots are sent as image content blocks
Background task tracking _x.ai/task_backgrounded / task_completed become background-task mutations; kill_command_or_subagent tombstones the killed task; mutations are dropped while the Stop quarantine is active or when they belong to a child session

Design decisions (interrupt complexity; safe to simplify later)

Two choices below are deliberate and reviewable. If maintainers want less
surface area, each has a known downgrade path and a concrete fidelity trade-off.

1. Linux cgroup join wrapper (production spawn path)

Current landing: before exec of the real Grok/ACP binary on Linux, spawn
is rewritten through process.execPath + a small inline -e script
(wrapCommandForLinuxCgroup). The shim writes its PID into the leased
cgroup.procs, verifies /proc/self/cgroup, strips wrapper env, then
process.execves into the real command so membership sticks to the same PID.
This is only used when cgroup v2 containment is available; otherwise spawn is
unwrapped and interrupt falls back to the owned-process ledger (reduced
guarantee). Mac/Windows never use this path (process group / taskkill).

Why not PATH node: the wrapper uses the running Electron/Node binary
(process.execPath + ELECTRON_RUN_AS_NODE under desktop), so packaged
AppImages do not require a separate system Node install.

Preferred cleanup if inline -e is too opaque: replace the string with a
small checked-in acp-cgroup-enter.mjs next to the server (same execPath
invocation, no temp files, no C toolchain for production). Same semantics;
easier to review and diff.

Heavier alternative: checked-in C helper binary shipped in the desktop
artifact. Clearer argv contract and no execve dependency on the Electron
Node build, but adds a real packaging/build matrix. Not chosen for this PR.

Complexity you can drop: if cgroup pre-exec join is too much, ship only the
ledger / process-group fallback on Linux too. Trade-off: double-fork /
reparented descendants can escape kill (the portable ledger comment already
calls this out). Headless interrupt packs would get flakier on adversarial
shell shapes.

2. Pthread spawn test helper (.c, test-only)

Current landing: keep apps/server/scripts/acp-thread-spawn-helper.c. One
Linux live test compiles it with cc -pthread to fork a child from a
non-leader pthread, then asserts childrenOf(parent) still finds that
child. That exercises the production scan of every
/proc/<pid>/task/<tid>/children entry (main-thread-only scans miss this).

Local: if cc is missing, the test soft-skips (suite stays green).

CI: Blacksmith Ubuntu runners mirror GitHub’s Ubuntu image and often
already have gcc, but that is not a repo contract. The Test job therefore
installs build-essential so cc is present and the fixture runs instead
of silently no-oping:

# .github/workflows/ci.yml (Test job)
sudo apt-get install -y --no-install-recommends build-essential

Simpler alternative (drop complexity): replace the helper with a plain
Node/.mjs spawn("sleep", …) and rename the test to “finds a spawned child.”
No compiler, always runs. Fidelity loss: a regression that only walks the
main task’s children file (not every thread) can still pass a normal spawn
fixture and only fail the pthread case. Other double-fork / cgroup-kill tests
remain; only this multi-thread discovery edge case goes soft.

Not a good substitute without rethinking the assertion: pure JS cannot
faithfully create “non-leader pthread then fork” without native code.

Validation

  • vp check: format clean on changed files; monorepo lint warnings only (known
    Vite+ stdout panic can flake the full wrapper after lint)
  • vp run typecheck: clean
  • vp test apps/web/src/components/ChatView.logic.test.ts: pass
  • vp test apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts:
    pass (includes image capability override)
  • vp test apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts: pass
    (wake-evidence filters, continuation offer gating, the monitor start ACK
    normalization case, steer-during-hold interrupt finalize, subagent lineage
    carry-over across a steering restart, injected-report hold, interrupt
    cleanup fixtures, soft mid-prompt cancel + same-session re-prompt, direct
    Stop dropping late background task mutations, and the production Grok flag
    set still hard-killing and respawning on user Stop)
  • vp test apps/server/src/provider/acp/AcpSessionRuntime.processTree.test.ts:
    pass (cgroup / process-tree interrupt evidence; pthread fixture needs cc,
    provided in CI via build-essential on the Test job; soft-skips locally if
    cc is absent)
  • vp test apps/server/src/orchestration-v2/ProviderSessionManager.test.ts:
    pass (idle pin with C0)
  • vp test apps/server/src/provider/acp/XAiAcpExtension.test.ts: pass (async
    spawn, TaskOutput, Monitor variant cases)
  • Headless live suites (parent live-scenarios, private serve against this
    branch / restacked AppImage):
    • grok-interrupt-fast: direct Stop + restart_active (the restart_active
      pack rewritten for soft semantics: process retained, one initialize, no
      session/load, cancel + re-prompt, codeword carry-forward proving
      same-session context; detach handling recorded as evidence, with a
      branch-conditional -detach variant)
    • grok-interrupt-soak: held subagent, mid-prompt steer during a subagent
      hold (new pack sampling the cancel-kills-subagent edge), duplicate Stop,
      leader exit, queued follow-up (scheduler gate); re-run green on the C4 tip
  • Desktop AppImage + mobile from-the-top interrupt pass (alairo project, one
    Grok thread titled from scenario 1 "Live-test direct Stop…", 2026-07-17 on
    orchestrator-v2 AppImage @ current snapshot; projection checked in userdata
    DB for thread 6c9994b7-bb04-4fb7-a9a8-e95688a6b7d7):
    • Scenario 1 direct Stop + INTERRUPTED_OK: pass
    • Scenario 2 steer / restart_active + RESTART_ACTIVE_OK: pass
    • Scenario 3 queue contrast (Ctrl/⌘+Enter only) + QUEUE_CONTRAST_OK: pass
      (one late-queue practice attempt finished the sleep without a queue; retry
      with queue while Working passed)
    • Scenario 4 rapid duplicate Stop + DUPLICATE_STOP_OK: pass
    • Scenario 5 queue then Stop: pass on mobile (desktop still hides Stop
      while queued; headless grok-interrupt-queued-followup remains the
      automated contract)
    • Scenario 6 Stop on plan/question card: pass on mobile (desktop still
      hides Stop while the card is open)
    • Scenario 7 Stop while subagent holds root + HOLD_FOLLOW_OK: pass (subagent
      interrupted, no HOLD_SUB_DONE / forbidden shell marker)
  • Earlier desktop-only pass (2026-07-16) covered 1–3 and 7 under the previous
    scenario numbering; 4–5 were blocked on desktop Stop UI as above.
  • Desktop AppImage smoke after Pass A rebuild (alairo, new thread
    16092557-…, 2026-07-17, scenarios 1 and 7 only):
    • Scenario 1 direct Stop + INTERRUPTED_OK: pass (sleep interrupted; no
      SHOULD_NOT_FINISH_CMD output)
    • Scenario 7 subagent hold Stop + HOLD_FOLLOW_OK: pass (subagent
      interrupted; no HOLD_SUB_DONE / forbidden shell marker)
  • Live private serve and desktop AppImage restack (trees/orchestrator-v2):
    • Multi-tool settlement, multiturn, monitors, subagents, post-settle packs,
      steer-during-hold, screenshot attach as previously reported on this PR

Out of Scope

Client presentation and desktop chrome that are not part of this Grok
settlement / interrupt PR. Called out so maintainers can track separate UI work
if desired; projection and run status still settle correctly.

Topic Notes
Desktop: no Stop while a message is queued After Ctrl/⌘+Enter queues a follow-up, red Stop does not appear; Queue panel only offers reorder / Steer (promote-to-steer). Queue-then-Stop is headless + mobile until desktop chrome is fixed.
Desktop: no Stop while an approval/question card is open Plan-mode cards hide Stop. Mid-card interrupt is mobile-testable and headless-adjacent; not desktop-manual.
Mobile: trailing work-log tool rows after a mobile Stop After a Stop initiated on the phone, iOS often shows a collapsed work log with +N previous tool calls (interrupted tools, Ask, etc.). Mobile keeps neutral tool rows in the work group; desktop filters neutrals out. Presentation difference only.
Mobile: interrupt lifecycle chrome missing for desktop-initiated Stops Server projection still writes run_interrupt_request / run_interrupt_result for Stops from desktop (verified in userdata). On mobile, those Interrupted rows are not shown for desktop-initiated Stops—not merely collapsed under +N previous tool calls (expanding the fold still does not surface them the way a live mobile Stop does). Live mobile Stops can show an X Interrupted work-log row and a You stopped after … fold. Incomplete multi-client / mobile lifecycle parity; not a missing server interrupt.
Mobile: no desktop-style --- Run interrupted --- divider Desktop renders run_interrupt_result as a full-width system divider and leaves interrupted turns unfolded. Mobile never uses that divider chrome.
workEntryPreview prefers detail over file_change filenames UI polish; expand path still lists files.
Subagent status badge styling via lifecycle early return Lifecycle row already shows a badge; extra badge path is unreachable.
Env-locking user-only count keyed off visible turn items Steer visibility intentionally uses visible user turn items; brief projection gap only.
Prompt-complete dedup without promptId / low-risk Grok session edge cases Serialized root prompts limit blast radius; not expanded in this PR.
Empty successful Bash forced inProgress until native output Intentional for provisional ACKs and post-settle monitor re-reports; narrower gate is future work.

Known limitations

  • After a real terminal signal, the hung session/prompt fiber may still log
    Interrupt when cancelled locally; runs complete with full content (log
    noise).

  • Builds that emit neither prompt RPC completion nor turn_completed /
    prompt_complete can still under-settle (no silent idle cancel fallback).

  • After Stop, a tool in the old shell may finish briefly before process kill
    (interruptPromptOnCancel: false); recovery still succeeds on the fresh
    child.

  • Subagent child-session tool calls (tools inside the child) are not fully
    projected as nested tool items; assistant text + final hydration / result card
    are covered.

  • Subagent wall time is provider-variable. A trivial “sleep ~5s then print a
    token” general-purpose spawn is often ~10–20s end-to-end, but the child agent
    can occasionally take much longer (minute-scale outliers observed). A long
    Working state usually reflects child runtime, not the ~2s deferred-finalize
    quiet window.

  • The root model may poll get_command_or_subagent_output with long
    timeouts, so the root run’s Working state can cover the entire child wait even
    when the subagent card is the durable success signal.

  • Non-persistent background work still holds Working. Deferred finalize
    keeps the root run open while a spawned subagent or a non-persistent monitor
    tool row is still running. Persistent monitors (persistent: true) no
    longer hold that gate; they settle the root and continue via post-settle
    continuation. Claude-style “go idle then reopen when background finishes” for
    non-persistent work is still future work. Content that arrives during a hold
    (CLI-injected monitor report) is delivered by the injected-report hold, so
    this is about run status staying Working, not about lost output.

  • Continuation dispatch is by threadId (the request also carries driver
    and providerThreadId detail for future scoping).

  • Buffered per-event commentary drains into the continuation run as several
    small assistant messages (one per burst). That is faithful to what the
    provider emitted; coalescing the display would be UI-side follow-up work.

  • Soft-interrupt detach tracking is best-effort. Grok's session/cancel
    detaches and re-runs the interrupted foreground command to full duration as
    a background task; no client-side ACP method kills it (only the model can,
    via kill_command_or_subagent). _x.ai/task_backgrounded can arrive late
    (~16s observed) or not at all (the model's poll-and-report shape emits
    none), so lifecycle tracking begins only when a notification arrives.
    Residual frames in that gap are accepted best-effort noise per the product
    call above; live packs record detach handling as evidence rather than
    pinning it.

  • Fast steers can trigger a model-side no-op flood. Steering within a
    couple of seconds of a reply while background work is still held (send a
    short message right after the settle reply with a subagent still running)
    can push Grok into emitting no-op run_terminal_command (true) calls
    about once per second until interrupted; the same loop shows up without
    steering after monitor-event injections (154 no-ops observed in one live
    thread, 207 in another). The steer text and background completions still
    land once the loop breaks or the run is stopped. This is xAI model
    behavior, not orchestrator state; recovery is Stop.

  • Desktop UI: no Stop while a message is queued or an approval/question card
    is open.
    See Out of Scope (mobile can still run those paths; headless
    covers queue-then-Stop).

  • Mobile vs desktop interrupt presentation. Trailing work-log rows after a
    mobile Stop, missing --- Run interrupted --- dividers, and no Interrupted
    lifecycle chrome on mobile for desktop-initiated Stops
    (not just collapsed)
    are client parity gaps only; server still projects interrupt items. See
    Out of Scope.

  • Grok exact-reply EOS token leakage (model, not T3). On short
    “reply with exactly TOKEN, no tools” recovery prompts, Grok sometimes
    returns TOKEN plus a literal end-of-sequence marker. Observed 2026-07-16
    on desktop AppImage after subagent-hold Stop:

    1. New Grok thread, model grok-4.5.
    2. Prompt: spawn one general-purpose subagent that runs
      sleep 30 && echo HOLD_SHOULD_NOT_MATTER and should return
      HOLD_SUB_DONE; root waits for the subagent.
    3. While root + subagent are still Working, press Stop.
    4. Send: Reply with exactly HOLD_FOLLOW_OK. No tools.
    5. Assistant final text was HOLD_FOLLOW_OK<|eos|> (ASCII <|eos|>
      appended). Projection stored that string verbatim; subagent was
      correctly interrupted with no forbidden shell marker.

    Other exact-reply markers in the same session (INTERRUPTED_OK,
    RESTART_ACTIVE_OK, DUPLICATE_STOP_OK) were clean. Treat as xAI/Grok
    model leakage suitable for upstream Grok team follow-up if desired; not a
    T3 interrupt/projection bug.

  • Stuck Working after hard interrupt on a poisoned session is reduced, not
    fully gone.
    This PR hardens the common Stop races (idempotent interrupt,
    finalize-before-kill, missing-session restart, sticky continuation flag,
    process-group reap). Residual risk remains if a held turn keeps a
    background/lineage entry that can no longer receive a terminal signal after
    restart (no overall force-finalize cap on that path). Fresh threads and the
    headless steer pack are the reliable recovery; candidate follow-ups are a
    bounded hold cap and reconciling carried subagent / background-tool state on
    interrupt.

Note

Harden Grok v2 prompt settlement, background task lifecycle, process containment, and steer visibility

  • Prompt settlement: makeXAiPromptCompletionRuntime now settles from both _x.ai/session/update and _x.ai/session/prompt_complete notifications; prompt fibers are raced against deferred completion and return cancelled on interrupt-only causes.
  • Grok adapter flavor: makeGrokAcpAdapterFlavor introduces soft/hard interrupt strategies (interruptPromptOnCancel=false, restartRuntimeAfterInterrupt=true, terminateRuntimeProcessGroupOnInterrupt=true), deferred finalize for background work, post-settle continuation, and supportsImagePrompts=true.
  • Process containment: AcpSessionRuntime can now own detached process groups and, on Linux, contain ACP descendants in a cgroup v2 lease with grace-period kill; grokAcpRuntimeProcessOwnership selects the appropriate strategy per host platform.
  • Background task hydration: extractXAiAcpSubagentUpdate and applyBackgroundTaskMutation track running/completed/failed background tasks and suppress duplicate tool projections for get_command_or_subagent_output calls.
  • Steer visibility fix: deriveCommittedServerUserMessageIds now derives committed user message IDs from visible turn items instead of projection messages, preventing steer rows from being evicted when message updates arrive one event before matching turn-item updates.
  • Non-retryable interrupt races: isNonRetryableProviderTurnControlFailure marks terminal provider-turn.interrupt failures as succeeded in the outbox, stopping retry loops when the session is already gone.
  • Idle pin safety: ProviderSessionManagerV2 idle-release logic now checks runtime identity before continuing a pin, preventing stale pins from applying to replacement sessions.
  • ACP protocol acknowledgement: outgoing Exit responses now block until written to stdout; writer failures fail all pending acknowledgements and trigger onTermination; intentional serverProtocol.end does not signal a transport failure.

Macroscope summarized 6add8c0.


Note

Low Risk
Changes are confined to CI and test/mock ACP tooling; production orchestrator paths are not modified in this diff.

Overview
CI now installs build-essential on the Test job so ACP process-tree live tests can compile the pthread fixture with cc instead of soft-skipping when no compiler is present.

A new acp-thread-spawn-helper.c forks a long-running child from a non-leader pthread and writes thread/child PIDs to a path, so AcpSessionRuntime.processTree tests can assert discovery across /proc/.../task/*/children, not only the main thread.

acp-mock-agent.ts gains many T3_ACP_* flags to simulate Grok-style behavior for integration tests: long-running terminal tools (with optional detached sessions and PID files), cancel → task_backgrounded / task_completed, residual session updates and late permission/elicitation callbacks, form/URL elicitation, empty successful Bash (including duplicate updates), post-settle monitor + TaskOutput hydration (with optional injected-report trigger), in-turn monitor output followed by a late duplicate TaskOutput, and optional exit on cancel or after launching a running command.

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

@coderabbitai

coderabbitai Bot commented Jun 27, 2026

Copy link
Copy Markdown

Important

Review skipped

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

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 46ceebcc-94e0-4922-9997-ce7cc7dd5dc7

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

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

❤️ Share

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

@github-actions github-actions Bot added size:L 100-499 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list. labels Jun 27, 2026
@mwolson
mwolson marked this pull request as ready for review June 27, 2026 00:43
Comment thread apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts
Comment thread apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts Outdated
Comment thread apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts
Comment thread apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts
Comment thread apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts Outdated
() => new Set(serverProjection?.messages.map((message) => message.id) ?? []),
[serverProjection],
() => deriveCommittedServerUserMessageIds(serverVisibleTurnItems),
[serverVisibleTurnItems],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

User-only count breaks env locking

Medium Severity

committedServerMessageIds now holds only visible user_message turn item ids, but activeMessageCount still uses that set’s size for envLocked and canOverrideServerThreadEnvMode. During the projection-vs-turn-item gap (and vs all projection.messages), the thread can look message-empty and allow env overrides that used to be blocked.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 61f4adb. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Out of scope — steer fix intentionally keys off visible user turn items. Brief projection gap only. Out of scope in PR description.

Comment thread apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts Outdated
@macroscopeapp

macroscopeapp Bot commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

7 blocking correctness issues found. Diff is too large for automated approval analysis. A human reviewer should evaluate this PR.

You can customize Macroscope's approvability policy. Learn more.

Comment thread apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts
Comment thread apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts
Comment thread apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts
* `_x.ai/session/prompt_complete` notifications (sessionId-matched). Subagent
* completions on foreign session ids are ignored by the pending-entry gate.
*/
export const makeXAiPromptCompletionRuntime = Effect.fn("makeXAiPromptCompletionRuntime")(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Medium acp/XAiAcpExtension.ts:455

The dedup guard in resolveXAiPromptCompletionFallback only checks completedPromptIdsRef when notification.promptId is defined. When a _x.ai/session/prompt_complete notification arrives without a promptId, the guard is skipped and the code resolves the first pending entry matching by sessionId alone. Because makeXAiPromptCompletionRuntime registers a pending entry for every new prompt before the RPC completes, a late duplicate notification from a previous turn (without promptId) will match the new prompt's pending entry and resolve it with the stale notification's stopReason and agentResult instead of waiting for the new prompt's own response. Consider tracking completed sessions alongside completed prompt IDs so that promptId-less notifications can be deduplicated per session.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/acp/XAiAcpExtension.ts around line 455:

The dedup guard in `resolveXAiPromptCompletionFallback` only checks `completedPromptIdsRef` when `notification.promptId` is defined. When a `_x.ai/session/prompt_complete` notification arrives without a `promptId`, the guard is skipped and the code resolves the first pending entry matching by `sessionId` alone. Because `makeXAiPromptCompletionRuntime` registers a pending entry for every new prompt before the RPC completes, a late duplicate notification from a previous turn (without `promptId`) will match the new prompt's pending entry and resolve it with the stale notification's `stopReason` and `agentResult` instead of waiting for the new prompt's own response. Consider tracking completed sessions alongside completed prompt IDs so that `promptId`-less notifications can be deduplicated per session.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Out of scope — serialized root prompts limit blast radius today. Out of scope in PR description.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sorry, I'm unable to act on this request because you do not have permissions within this repository.

Comment thread apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts
Comment thread apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts
Comment thread apps/server/src/provider/acp/XAiAcpExtension.ts
@mwolson
mwolson force-pushed the fix/grok-v2 branch 3 times, most recently from 4c4d0dd to 8f6ec37 Compare June 27, 2026 01:18
Comment thread apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts
Comment thread apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts Outdated
Comment thread apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts
Comment thread apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts
Comment thread apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts
Comment thread apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts
Comment thread apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts
Comment thread apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts Outdated
Comment thread apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts
@github-actions github-actions Bot added size:XL 500-999 changed lines (additions + deletions). and removed size:L 100-499 changed lines (additions + deletions). labels Jun 27, 2026
Comment thread apps/server/src/provider/acp/XAiAcpExtension.ts
@mwolson
mwolson force-pushed the fix/grok-v2 branch 2 times, most recently from ddfedee to e84f666 Compare July 17, 2026 00:35
Comment thread apps/server/scripts/acp-mock-agent.ts
@mwolson
mwolson force-pushed the fix/grok-v2 branch 2 times, most recently from ba852af to 0583e10 Compare July 17, 2026 00:58
Comment thread apps/server/src/provider/acp/AcpSessionRuntime.ts Outdated
@mwolson
mwolson marked this pull request as draft July 17, 2026 01:31
Comment thread apps/server/src/orchestration-v2/EffectWorker.ts
Comment thread apps/server/src/provider/acp/AcpSessionRuntime.ts Outdated
mwolson added 2 commits July 17, 2026 20:22
Mark background tools terminalized mid-turn as handled so late CLI
re-reports and residual agent chatter do not open synthetic
"Background task completed." continuation runs after the root already
reported the monitor result in-turn.
Comment thread apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts
Comment thread apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts
mwolson added 2 commits July 17, 2026 23:04
Only mark completed (post-normalize) background tools as in-turn handled,
not interrupted or failed. When residual agent chatter follows handled work,
skip synthetic continuation offers without clearing the wake buffer so other
tasks can still drain.
Completing a monitor after STARTED under deferred finalize must not mark
the task handled, or post-settle TaskOutput cannot open a continuation
(grok-post-settle-continuation-poll). Keep no-wake marking when the
tool completes before the root settles.
Comment thread apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts Outdated
Comment thread apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts Outdated
mwolson added 4 commits July 17, 2026 23:26
Before prompt settle, treat failed and interrupted background tools like
completed for handled-set purposes so residual CLI chatter does not open
synthetic post-settle wakes. Still skip marking after STARTED so deferred
finalize TaskOutput continuations keep working.
A steering interrupt on a turn whose native prompt already settled used to
hard-kill the Grok process group, destroying still-running fire-and-forget
subagents. Live ACP experiments showed the Grok CLI accepts a concurrent
session/prompt in that state and that session/cancel kills background
subagents, so the settled steer now skips the process-group kill, the ACP
cancel, and the runtime respawn behind a new preserveRuntimeOnSettledInterrupt
flavor flag (enabled only for Grok). User Stop and mid-prompt interrupts keep
the hard teardown path.
…nded tasks

Grok session/cancel is detach-and-continue: the CLI re-runs cancelled
foreground work as a background task no client RPC can kill (E3 harness,
2026-07-18). User Stop therefore keeps the hard process-group kill and
respawn, but mid-prompt steering and restart_active now stay soft: drop
restartRuntimeOnEveryInterrupt so the session survives in the same
process, and let the model itself decide the fate of backgrounded work
via its native kill_command_or_subagent tool.

Track that backgrounded work as first-class background tasks: subscribe
to _x.ai/task_backgrounded and _x.ai/task_completed through a new
applyBackgroundTaskMutation extension-context hook (gated to the root
session), and tombstone tasks the model kills, since Grok emits no
task_completed for killed tasks.
Pre-push review fixes for the soft non-Stop interrupt change:

- Gate applyBackgroundTaskMutation on stoppedRunQuarantine so residual
  task lifecycle from a stopped run cannot mutate wake machinery, and on
  the active session id so a cancelled subagent's re-run in its child
  session cannot gate root wake state. Post-Stop the activeSessionId
  still points at the stopped session until the next turn respawns, so
  the quarantine is the only guard in that window.
- Fix the stale preserveRuntimeOnSettledInterrupt contract comment to
  describe the current interrupt matrix.
- Add tests: direct Stop drops late background task mutations from the
  stopped run, and the production Grok flag set (no
  restartRuntimeOnEveryInterrupt) still hard-kills and respawns on user
  Stop, proven by session/load and runtime ordinal 2 on the next turn.
// untouched for the replacement turn.
if (!settledSoftInterrupt) {
yield* runtime.cancel;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Settled-steer cancel race

Medium Severity

settledSoftInterrupt snapshots promptSettled once, but cancel is gated on that stale flag while finalize later reads live context.promptSettled. Soft interrupts do not hold runtimeCallbackPermit, so the prompt-completion fiber can mark the turn settled (or return while interrupted blocks the defer path) before cancel runs. session/cancel then still fires and can kill the background subagents settled-steer is meant to preserve.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit cc13ec5. Configure here.

return undefined;
}
}
const context = interruptContext;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Stop no-ops after soft steer

High Severity

After a soft mid-prompt steer clears activeTurn without killing the process, a queued user Stop for the same turn hits the already-interrupted early return and skips hard teardown. runtimeRestartRequired and stoppedRunQuarantine never arm, so Stop fails to contain cancel-detached Grok work.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit cc13ec5. Configure here.

mwolson added 3 commits July 19, 2026 09:13
Runtime teardown paths that poll real kernel state (cgroup emptiness,
process-tree reaping, TERM grace) paced their retries with Effect.sleep on
the ambient Clock. Under @effect/vitest it.effect the TestClock never
advances, so a scope close that raced a still-populated cgroup slept
forever, hanging every GrokTextGeneration test for its full 120s timeout
and pushing the serial server suite past the 10-minute CI job budget.

Provide the default live Clock to terminateLinuxCgroupLease,
terminatePosixOwnedProcessTree, observePosixOwnershipLedgerContinuously,
and terminateOwnedProcessGroupImpl. Virtual time is never correct for
these sleeps: they pace observations of external OS state.
A soft steering interrupt can clear the active turn while intentionally
leaving the runtime process alive. A user Stop arriving afterward hit the
cleared-turn early return and no-oped, leaving the orphan process running.
Stop now quarantines the transport, tears down the process group, and
forces a runtime respawn on the next turn.

Also read prompt settlement under the runtime callback permit after
draining admitted native responses, so a prompt that returned on the wire
but whose completion callback has not yet run still classifies as settled
and skips session/cancel.
lease.remove() failures during teardown are swallowed best-effort, so
empty t3-acp-<pid>-<uuid> directories can accumulate under the parent
cgroup across app restarts. Before creating a new lease, sweep sibling
lease directories whose embedded creator pid is dead and whose
cgroup.events reports populated 0. All sweep errors are ignored and never
delay or fail lease creation.
Comment thread apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts Outdated
Comment thread apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts
Comment thread apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts
…op teardown

Three follow-ups on the orphan-containment and settled-steer code:

- Add a wire-settlement signal completed the moment session/prompt
  resolves, before the completion callback contends for the callback
  permit. Settled-soft classification ORs it with promptSettled, so an
  interrupt that wins the permit ahead of the completion fiber no longer
  sends a redundant session/cancel that would kill background subagents.
- Cancel pending approval/elicitation requests in the orphan Stop path,
  matching the main hard-restart path, so a request raised by the
  orphaned agent cannot leave the composer approval-locked forever.
- Terminalize carried-over subagents in the orphan Stop path with
  interrupted node/subagent/turn_item events, so soft-steered subagent
  cards do not stay running in the transcript after the process is
  killed.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

There are 6 total unresolved issues (including 4 from previous reviews).

Fix All in Cursor

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

Reviewed by Cursor Bugbot for commit 6add8c0. Configure here.

childThreadId: subagent.childThreadId,
prompt: subagent.task.prompt,
result,
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Carryover Stop drops subagent text

Medium Severity

When terminalizeCarryoverSubagents processes a subagent stop after a soft steer, it only uses subagent.task.result for the final output. This omits streamed partial results, causing the parent subagent card to lose previously projected output.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6add8c0. Configure here.

const providerTurn = turns.get(String(subagent.providerTurnId));
const parentProviderThreadId =
providerTurn?.providerThreadId ?? subagent.task.providerThreadId;
if (parentProviderThreadId === null) continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Null parent thread skips interrupt

Medium Severity

When resolving the parent providerThreadId, terminalizeCarryoverSubagents falls back to subagent.task.providerThreadId and continues if that value is null. Unbound child sessions keep task.providerThreadId null, and unlike emitSubagent there is no stored parent thread id on the carryover record. Any missed providerTurns lookup therefore skips emission and leaves the subagent running after Stop—the failure mode this change is trying to close.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6add8c0. Configure here.

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

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant