Skip to content

fix(orchestration): reliable subagent launches, prompts, waits, answers and exits - #147

Open
BIackFIame wants to merge 9 commits into
howdeploy:mainfrom
BIackFIame:pr/orchestration-reliability
Open

BIackFIame wants to merge 9 commits into
howdeploy:mainfrom
BIackFIame:pr/orchestration-reliability

Conversation

@BIackFIame

Copy link
Copy Markdown
Contributor

Branch pr/orchestration-reliability (head e88f2a3e, 9 commits), based on main ba427487. No UI.

Why

In the review of the closed #132/#134 you asked for narrow core fixes, each with a test and no UI, for bugs in the existing orchestration that showed up in live runs. Those were: a subagent silently leaving its orchestrator's account (OpenCode then falls back to hosted OpenCode Zen, a privacy issue), SIGKILL reported as "done, exit 0", a double --model crash, OpenCode losing its first prompt before its TUI is ready, and wait_for_agent outliving the MCP client's tool timeout. This PR has those fixes plus two more found the same way: launch-file cleanup order and plain-text subagent output. One commit per fix.

What changes for users

  • Account and model: a subagent runs on its orchestrator's model and account (the plugin launch options it inherits), not on the CLI's default. A choice that would give it a second model is refused with a reason. spawn_agent reports servedBy. Launches the person starts are unchanged.
  • Signals: an agent killed by a signal shows as failed with exit 128+n (SIGKILL → 137), as a shell reports it.
  • First prompt: OpenCode gets its first prompt (and follow-ups) only once its input field is up, so the prompt is no longer lost. Every send has a 45 s limit with a clear error. Windows keeps the current behaviour.
  • Waiting: wait_for_agent returns within 50 s, under the ~60 s at which MCP clients end a call. On a timeout it returns progress and asks to call again.
  • Answers: a subagent's answer belongs to the turn that answered the latest prompt, never to an earlier one. With status hooks off, the tools say answerUnavailable: status_hooks_off. Every write of person or agent input goes through one method, writeInput, which keeps this bookkeeping, so a later feature that holds input and replays it (such as macOS: editing keys for Claude Code cards (newline, select-all, copy/cut/paste) and UTF-8 locale #145's draft keys) counts a replayed Enter as a prompt.
  • Hooks: a turn whose start hook was lost still ends, and a failed approved tool no longer leaves the card at "needs approval".
  • Plain-text output: wait_for_agent, get_agent_result, observe_agent and exitLines return the subagent's screen as plain text, not raw TUI escape sequences.
  • Cleanup and quit: closing a card removes its run folder only after its launch files are gone. Quitting runs every shutdown step even when one fails, so PTYs are always hung up and sessions saved.

Commits

  1. 4bba2cf1 fix(terminal): remove a closed card's run folder only after its launch files
  2. b286d26d fix(main): run every shutdown step even when one fails
  3. 9572d228 fix: report an agent killed by a signal as failed with exit 128+n
  4. dfb9a2a9 fix(orchestration): keep the orchestrator's model choice for subagents
  5. 8a59c822 fix(agents): type OpenCode prompts only once its input field is up
  6. 1cb4ba99 fix(orchestration): return wait_for_agent before the MCP client gives up
  7. 93b53479 fix(agents): tie subagent answers to their turn and say when hooks are off
  8. cac98b6d fix(runtime): keep a turn whose start hook was lost, and map PostToolUseFailure
  9. e88f2a3e fix(orchestration): return a subagent's screen as plain text

Each commit builds and type-checks on its own, so you can drop any one of them. Commits 5–9 touch the same files, so dropping one of those needs a small rebase.

Verification

  • macOS: this exact tree, with a fake HOME, XDG, CODEX_HOME, GROK_HOME and CLAUDE_CONFIG_DIR. Results: build:helpers --host, --catalog (no diff), typecheck, full suite 2233 tests / 2229 pass / 0 fail / 4 skipped, build and audit:secrets all pass; startup bundle equal to main.
  • Live (macOS, hidden app, OpenCode + GLM-flash): one orchestrator and two subagents, on an integration that contains this tree. All three were served only by the orchestrator's GLM account. Each subagent got one prompt. The 50 s wait returned, answers were captured, and the output was plain text. A SIGKILLed subagent showed failed / 137, and the app quit cleanly.
  • Linux (Ubuntu 24.04 VM, normal user, xvfb): the full suite on the same integration fails only the three tests that also fail on main on that VM (native-helper-mcp-parity, native-helper-runtime-parity, permission-gate-fail-closed).
  • Screenshots: none needed (no UI).
  • Not verified: Windows.

Risks

  • Same-CLI subagents now inherit all of the orchestrator's plugin launch options, not only the account. An orchestrator can still name a plugin to choose differently.
  • Any process killed by a signal, a person's shell included, now shows as failed instead of done.
  • OpenCode's readiness signal is the terminal cursor sequence. A future OpenCode that never shows it would fail its first prompt with a reason after 45 s instead of losing it silently.
  • Orchestrators make more wait_for_agent calls (at most 50 s each, instead of up to 600 s).
  • Rendering the output costs one headless xterm parse of up to 64 KiB per call (milliseconds).

Depends on

Nothing. It applies to main as is.

…h files

Closing a card started the run cleanup and the removal of runs/<id> at the same time, and a launch still being prepared (plugin prepare, environment wrap) could write run files after the folder was gone. A throw from the environment wrap also skipped the planned and contributed cleanup.

Contributed launches are now tracked. Closing waits for pending launches, then the serialized exit/restart cleanups, then removes the run folders each pipeline created for this card object, and the card folder only when it is empty, so a card brought back under the same id keeps its new run. An unadopted launch releases its planned spawn and contribution in a finally block, and a launch whose card was closed or restarted in the same tick never asks its plugins. Run folders are removed with retries for files Windows still holds open.
If closing the Even G2 companion threw, quitting skipped terminalManager.shutdown(), so PTYs were not hung up and sessions were not saved. Shutdown now runs its steps in order through runShutdownSteps, which logs a failed step and continues.
…e off

A Codex or OpenCode subagent's final answer could belong to an earlier
turn: OpenCode reused its session id as the turn id and reported idle
before its asynchronous answer read finished, and recordAnswer accepted
any answer. OpenCode now names each turn, reports no idle before the
answer is read and drops a read a newer turn superseded; retry counts as
a turn start. A submitted input (not an approval answer, not an empty
Enter in an idle card) starts a new task, and only an answer of the turn
that answers it is kept. A signal of that turn which predates the input
still moves the status, so wait_for_agent ends instead of hanging, but
brings no answer. An answer reported as the agent exits still counts.
main's signal glue is now applyRuntimeSignal, shared with the tests.

With CanvasTTY status hooks off a launch gets no hooks, so no answer can
be captured. Whether a launch has them is decided when it is planned
(deferred plugin launches included). wait_for_agent and get_agent_result
then return answerUnavailable { reason: "status_hooks_off", message } so
the orchestrator reads the output instead.

Every write of person or agent input now goes through one method,
writeInput, which does this bookkeeping; input() and deliverInput call it,
and a later writer that holds input and replays it must call it too, so a
replayed Enter counts as a submitted prompt. It writes nothing into a
process the card no longer runs.
…UseFailure

Once a turn id was active, the gateway dropped every non-start event with
a new turn id. Each hook runs on its own connection and can time out, so
when a turn's start hook was lost the whole turn, including its Stop, was
ignored and the card kept the previous turn's status. The gateway now
tracks whether a turn is open. After an idle, an unseen turn id opens the
next turn; during an open turn, or for an id already seen, the event is
still dropped as out of order.

When a tool the person approved fails, the agent CLI runs
PostToolUseFailure instead of PostToolUse. CanvasTTY did not register it,
so the card stayed needs_approval until the next hook although the turn
went on. It now maps to working.
node-pty reports a process killed by a signal as exitCode 0 plus the
signal. The signal was dropped, so an agent killed with SIGKILL (OOM,
kill -9) was recorded as "done, exit 0": wait_for_agent returned done,
get_agent_result said done, and the saved session restored as exited.

Record such an exit the way a shell does, as 128+signal (SIGKILL -> 137),
mark the session failed, and put the signal name first in the failure
details. Every reader of the exit (wait_for_agent, get_agent_result, the
session store, the HOME failure summary) already derives its state from
the exit code, so they now agree. Exits CanvasTTY causes itself (closing a
card, quitting) are unaffected: those sessions are removed before
the kill, and the exit handler ignores them.
A subagent spawned without launch options ran on its CLI's own default
model and sign-in (for OpenCode the hosted OpenCode Zen model), even when
its orchestrator ran on a model the person picked through a plugin's
launch options. A plugin the spawn does not name now keeps the
orchestrator's own values for a subagent of the same CLI; repeating the
person's choice needs no delegable declaration, and then the normalized
values must equal the orchestrator's exactly (missing keys come from the
orchestrator, never from the plugin's defaults). A subagent of another
CLI must name a plugin whose contribution set the orchestrator's model,
and spawn_agent reports servedBy (CLI, requested model, inherited or
explicit options).

- Recognise every form a CLI takes for its model: --model and --model=,
  -m and -mX for OpenCode, Codex, Qwen, Kimi and Grok, and for Codex also
  --profile/-p and -c/--config setting model, model_provider or profile.
- Refuse a second model (an explicit model next to a plugin that sets it,
  or two plugins that both set it; OpenCode crashes at start on two
  --model values) only for a subagent's launch. A person's own launches
  and restores start as before.
- Inherit an empty choice (a plugin without fields) as chosen. Leave out
  an inherited plugin that was turned off since, unless it set the model
  (then refuse); refuse with a clear reason when its fields changed.
- Reset modelSetBy at the start of each launch.
- Describe inheritance, servedBy and the refusals in the orchestrator
  skill and docs.

The core stays plugin-agnostic: it reads the plugin from the launch
options and the contribution, never from a fixed plugin id.
OpenCode drops every key that arrives while its full-screen view starts
(a few seconds). CanvasTTY typed spawn_agent's first prompt, send_to_agent
and plugin sessions.send as soon as the PTY existed, so an OpenCode
subagent sat at an empty prompt until the orchestrator gave up.

deliverInput now waits, within the same bounded wait it already had for
the launch, until OpenCode can take input: the terminal control sequence
that shows its text cursor on the alternate screen once its prompt has
focus, or a lifecycle hook from its plugin, counted only 2 s after the
launch's first one (the plugin can report before the prompt takes keys).
Only DEC private mode sequences are read; human-readable screen text is
never parsed and no status changes. Other CLIs keep getting input when
the PTY exists.

- The wait is used on macOS and Linux. Windows' pseudo console may not
  pass those sequences, so there input is typed when the PTY exists, as
  before.
- spawn_agent (checks, launch and readiness together) and send_to_agent
  wait at most 45 s, so the orchestrator gets the reason and the session
  id, not an MCP client timeout. Nothing is typed then.
- A handoff waits up to 15 s for the CLI to take input instead of
  failing at once; a caller that does not wait is told the state.
- Sends to one card are delivered in the order they were made, a queued
  send never reaches a later launch, and a send cancelled while it waits
  behind another settles at once and types nothing.
wait_for_agent accepted up to 600 seconds, but the MCP clients of several
supported CLIs (OpenCode among them) end a tool call after about 60
seconds, so an orchestrator that asked for a long wait got a failed call
instead of a result.

A call now asks for at most 100 seconds (default 50). Unless the
orchestrator's CLI is known to let a tool call run longer, a call returns
after at most 50 seconds. A timeout carries the status and the masked
output as progress plus a message to call again, and the tool
description in both catalogs, the orchestrator skill and the docs say
that a timeout means the subagent is still running.
wait_for_agent, get_agent_result and observe_agent returned the raw PTY
tail, which for a full-screen TUI such as OpenCode is cursor moves,
colours and spinner redraws, often starting mid-sequence. They now render
the masked scrollback window (the last 65,536 characters) through the
existing headless xterm at the session's size and return the visible
text, masked again and capped as before (observe_agent within its
maxChars). A cut window starts at its first escape or line break, so the
rest of a cut sequence ("18;9H") is never printed as text.

exitLines were the regex-stripped last 16 KiB, so a TUI that redraws only
the cells that changed read as its redraws joined together, and a line
drawn early and never redrawn (the model's status line) was lost. They
are now rendered by the same headless terminal from the same window,
masked before and after, and the three tools return the same exitLines.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-10-14T08:04:54.378555Z e88f2a3 PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

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