372 lines
22 KiB
Markdown
372 lines
22 KiB
Markdown
# Orchestration: sub agents on the multiplexer screen
|
|
|
|
Status: implemented in taicpp, not in the Python app. Code: `src/mplex/mplex_vpane.*`, `src/orch/`,
|
|
`src/agent/registry.*`, `src/agent/agent.*`, `src/cap/cap_mention.*`. Tests: `tests/orch_test.cpp`
|
|
(`build/orch_test`, step `orch.unit`), `tests/orch_mplex_e2e.py` with `tests/orch_live.cpp`
|
|
(`build/orch_live`, step `orch.mplex_e2e`). Research: `research/background-work-limits/` (agent teams,
|
|
Temporal timeouts, claim check, Celery limits, tmux layouts, terminal streaming) and
|
|
`research/cap-protocol/`. Related: [cap-agents.md](cap-agents.md) (what `@agent` runs),
|
|
[mplex.md](mplex.md) (the multiplexer), [fuzzy.md](fuzzy.md).
|
|
|
|
## 1. The rule
|
|
|
|
The main application orchestrates. Every sub agent it runs, whatever kind (an internal `fork` worker, a CAP
|
|
session over any binding, an `@agent` mention), gets a panel on the main multiplexer session screen while it
|
|
works, fed live, and is supervised by one clock whether it is shown or not.
|
|
|
|
```
|
|
one sub agent two sub agents three
|
|
+-------+-------+ +-------+-------+ +-------+-------+
|
|
| | agent | | | agent1| | | agent1|
|
|
| main | 1 | | main +-------+ | main +-------+
|
|
| | | | | agent2| | | agent2|
|
|
| | | | | | | +-------+
|
|
+-------+-------+ +-------+-------+ | | agent3|
|
|
+-------+-------+
|
|
```
|
|
|
|
The main pane stays on the left at full height, `MPLEX_VPANE_MAIN_PERCENT` (55) percent of the window wide.
|
|
Sub agents are stacked equally on the right (tmux `main-vertical`). Panels appear when an agent starts and
|
|
leave `ORCH_LINGER` seconds after it finishes; the column then rebalances. Outside a multiplexer (plain
|
|
terminal, tmux) nothing is drawn and everything else works unchanged.
|
|
|
|
How the application gets into the multiplexer: the installed shell hook runs `tai --plexer-wrap` for interactive
|
|
shells. A bare `tai` run is not wrapped automatically (changing how the program starts was not asked).
|
|
Inside the multiplexer every pane has `TAI_PLEXER_ACTIVE=1`, `TAI_PLEXER_SOCKET` and `TAI_PLEXER_PANE`, and
|
|
that is how the orchestrator finds its screen.
|
|
|
|
## 2. Architecture
|
|
|
|
| Layer | Class | File | Job |
|
|
|---|---|---|---|
|
|
| server | `MplexVpanes` | `src/mplex/mplex_vpane.*` | feed panes: open, write, close, list; layout; linger; orphan lease |
|
|
| server | `MplexPane::virtual_pane` | `src/mplex/mplex_pane.*` | a pane without a process; row 0 is a header |
|
|
| server | `MplexRenderer::frame` | `src/mplex/mplex_renderer.cpp` | draws the header row and the screen below it |
|
|
| transport | `OrchTransport`, `OrchMplexTransport`, `OrchNullTransport` | `src/orch/orch_transport.*` | control-socket client, one round trip per write |
|
|
| core | `OrchBoard` | `src/orch/orch_board.*` | opens panels, coalesced flusher thread, watchdog clock |
|
|
| core | `OrchPanel` | `src/orch/orch_panel.*` | one agent's buffer, state, counters, cancel hook |
|
|
| view | `OrchFormatter` | `src/orch/orch_formatter.*` | events to sanitized, word-wrapped ANSI text |
|
|
| events | `OrchEvent` | `src/orch/orch_event.h` | what an agent tells its panel |
|
|
| adapter | `OrchCapAdapter` | `src/orch/orch_cap_adapter.*` | `cap.*` events to `OrchEvent` |
|
|
| adapter | `Agent::observer` | `src/agent/agent.*` | tool calls, results and streamed text of an internal worker |
|
|
| owner | `spawn_agent` / `AgentRecord` | `src/agent/registry.*` | opens a panel per `fork` worker, wires cancel, finishes it |
|
|
| owner | `CapMention` | `src/cap/cap_mention.*` | opens a panel per CAP turn, supervises, escalates cancel |
|
|
| policy | `orch_policy.h` | `src/orch/orch_policy.h` | every cap and floor (section 9) |
|
|
|
|
Data flow for one streamed token:
|
|
|
|
```
|
|
agent thread --emit(OrchEvent)--> OrchPanel (format, append to buffer, mutex)
|
|
OrchBoard thread, every 25 ms: panel.collect(op) for every panel
|
|
--> one "vpane_write" with all ops --> multiplexer server --> pane.feed() --> screen
|
|
server render loop, at most every 12 ms: diff by row --> attached terminal
|
|
```
|
|
|
|
Producers never wait for I/O. If the screen cannot keep up the buffer is bounded (section 4).
|
|
|
|
## 3. The multiplexer side: feed panes
|
|
|
|
A feed pane is an `MplexPane` with `virtual_pane = true`: no pty, no process, `master_fd` is -1, so the
|
|
server loop never polls it and no reaper touches it. Its `h - 1` rows are a normal `MplexVtScreen` (scrollback,
|
|
copy mode, `capture-pane`, `read` all work); row 0 of its rectangle is a header line drawn by
|
|
`MplexRenderer::frame` with the pane's `header_style`. Keys typed into it are dropped (`MplexPane::write` returns
|
|
early for a feed pane).
|
|
|
|
### 3.1 Control protocol (JSON lines on the multiplexer socket)
|
|
|
|
All requests are one line, one reply line, one connection per request, like the existing `send` and `read`
|
|
commands. Replies are `{"ok": true, "data": ...}` or `{"ok": false, "error": "..."}`.
|
|
|
|
**`vpane_open`**
|
|
|
|
```json
|
|
{"cmd":"vpane_open","pane":"0","label":"agent #1","header":" ● agent #1 · running · 0s","state":"running","main_percent":55}
|
|
{"ok":true,"data":{"id":3,"cols":53,"rows":38}}
|
|
```
|
|
|
|
`pane` is the origin pane (default: the active pane of the latest session). `label` becomes the pane title,
|
|
`header` the first line, `state` selects the header color. Errors: `too many feed panes (limit 8)`,
|
|
`too many panes (limit 256)`, `no space for another feed pane` (the window is too short for another pane of
|
|
at least `MPLEX_VPANE_MIN_ROWS` rows), `window too narrow for a feed pane` (under 41 columns).
|
|
|
|
**`vpane_write`**
|
|
|
|
```json
|
|
{"cmd":"vpane_write","ops":[
|
|
{"pane":3,"data":"\u001b[1mhello\u001b[0m\r\n","header":" ● agent #1 · running · 4s","state":"running"},
|
|
{"pane":4,"data":"bye\r\n","state":"done","done":true,"linger":20}]}
|
|
{"ok":true,"data":{"closed":[],"cols":{"3":53,"4":53}}}
|
|
```
|
|
|
|
Per op: `pane` (required), `data` (text for the screen, at most `MPLEX_VPANE_WRITE_MAX` = 65536 bytes), `header`,
|
|
`state` (`running`, `idle`, `done`, `failed`, `timeout`, `cancelled`), `done` (start the linger countdown) with
|
|
`linger` seconds (clamped to `MPLEX_VPANE_LINGER_MAX` = 300). At most `MPLEX_VPANE_OPS_MAX` = 16 ops per request.
|
|
Every op also refreshes the pane's lease. The reply lists in `closed` the pane ids that no longer exist (the
|
|
user closed them or they timed out) and in `cols` the current width of every pane written to, which the client
|
|
uses for word wrap. Errors: `ops must be a list`, `too many ops in one write`, `op data too large`.
|
|
|
|
**`vpane_close`** closes one pane now. **`vpane_list`** returns
|
|
`[{id, state, header, cols, rows, closing}]`.
|
|
|
|
### 3.2 Layout policy (`MplexVpanes::arrange`)
|
|
|
|
After every open and every close the window is rebuilt with `MplexLayout::preset("main-vertical", panes, ...)`:
|
|
|
|
- the first element is the main pane (the first real, non-feed pane of the window);
|
|
- then any other real panes the user made;
|
|
- then the feed panes in creation order.
|
|
|
|
The main width is `window width x percent / 100`, clamped to [1, width - 2]; when the right column would be
|
|
narrower than `MPLEX_VPANE_MIN_COLS` (20) the main pane gives up width to keep it. Heights are shared equally
|
|
by the existing `shares` rule (the first pane gets the remainder), for example `[13, 13, 12]` for three agents
|
|
in a 40-row window (the heights plus two border rows). The layout is re-applied on every open and close, so a layout the user
|
|
set by hand does not survive a panel change.
|
|
|
|
### 3.3 Lifecycle on the server
|
|
|
|
| Event | Behavior |
|
|
|---|---|
|
|
| `done` op | `close_at = now + linger`; the pane closes on the next tick after that and the column rebalances |
|
|
| user kills a feed pane (`prefix x`) | the pane is gone; the next write reports its id in `closed`; the orchestrator cancels the job |
|
|
| owner stops writing for `MPLEX_VPANE_LEASE` (30 s) | the header becomes `owner gone: ...`, state `failed`, closes after `MPLEX_VPANE_ORPHAN_LINGER` (10 s) |
|
|
| the main pane exits | `MplexServer::kill_pane` closes the whole window when only feed panes remain |
|
|
| window zoomed | opening a panel un-zooms it |
|
|
|
|
The server tick runs once a second, so linger and lease have one-second granularity.
|
|
|
|
## 4. The orchestrator side
|
|
|
|
### 4.1 `OrchBoard`
|
|
|
|
One per process (`OrchBoard::instance()`, created on first use over `OrchMplexTransport::from_env()`, flushed
|
|
at exit with a 2 s limit, never destroyed so detached worker threads cannot outlive it). `open(label, task)`
|
|
never returns null: when there is no multiplexer (`TAI_PLEXER_ACTIVE` unset), `TAI_ORCH_PANELS=0`, no space,
|
|
or `ORCH_PANELS_MAX` panels are already shown, it returns a panel with no pane behind it and records the reason
|
|
(`last_error()`). Such a panel behaves identically except nothing is drawn.
|
|
|
|
The board thread runs every `ORCH_FLUSH_INTERVAL` (25 ms):
|
|
|
|
1. once per `ORCH_MONITOR_INTERVAL` (1 s) call `watchdog` on every panel;
|
|
2. call `collect` on every panel; combine the non-empty results into one `vpane_write`;
|
|
3. apply the reply: widths to the panels, `closed` ids to `closed_by_user()`;
|
|
4. drop panels that are drained.
|
|
|
|
Three consecutive failed writes mark the transport broken (`transport_ok() == false`, reason in
|
|
`last_error()`): drawing stops, supervision continues, new panels are hidden panels.
|
|
|
|
### 4.2 `OrchPanel`
|
|
|
|
Thread-safe. State: `Queued`, `Running`, `Idle`, `Done`, `Failed`, `Timeout`, `Cancelled`.
|
|
|
|
```
|
|
emit() finish(state)
|
|
Running ------> Running Running/Idle ----------> Done | Failed | Timeout | Cancelled
|
|
| no event 60 s ^ emit() while Idle returns to Running
|
|
v
|
|
Idle --- no event 300 s ---> cancel hook fires once
|
|
any --- pane closed by user ---> Cancelled, cancel hook fires
|
|
```
|
|
|
|
- `emit(event)` formats the event, appends to the buffer, counts tools and tokens, and is a heartbeat. After
|
|
`finish` events are ignored.
|
|
- `finish(state, summary)` flushes the held word, appends the footer, and arms the `done` op, which is sent only
|
|
after the buffer is drained.
|
|
- `collect(op)` takes up to `ORCH_WRITE_MAX` (32768) bytes without splitting a UTF-8 character, adds a changed
|
|
header and state, and returns false when there is nothing to send. A word held back by the formatter is
|
|
released when the text stream has paused for 0.1 s.
|
|
- The buffer is capped at `ORCH_PENDING_MAX` (262144). Over it, the oldest bytes are dropped at a line end and a
|
|
visible line `output skipped: the screen fell behind` is inserted. Dropping is counted (`dropped_bytes()`).
|
|
- `watchdog(now)` is the idle logic above; `set_cancel` registers the owner's cancel function, called at most once.
|
|
|
|
Header text: ` <icon> <label> · <state> · <elapsed> · <n> tools · <tokens> tok`, with `●` running, `◌` idle,
|
|
`✓` done, `✗` failed, `⏱` timeout, `⊘` cancelled; elapsed is `Ns` or `MmSSs`; tokens abbreviated (`1.5k`).
|
|
Header colors (server side, by state): running blue, idle and timeout orange, done green, failed red, cancelled grey.
|
|
|
|
### 4.3 `OrchFormatter`
|
|
|
|
Turns events into bytes for a feed pane (CRLF line ends, because a feed pane has no tty to translate them).
|
|
|
|
| Event | Rendering |
|
|
|---|---|
|
|
| (opening) | dim `▸ <task>` on one line, at most `ORCH_TASK_SHOW_MAX` (300) characters, then a blank line |
|
|
| Text | word-wrapped to the pane width |
|
|
| Thought | the same, dim italic |
|
|
| ToolStart | on a new line: cyan `⏺ name`, then dim `(arguments)` on one line, clipped to the pane width and `ORCH_TOOL_ARGS_MAX` (120) |
|
|
| ToolEnd | dim, red when failed: ` ⎿` the first `ORCH_TOOL_RESULT_LINES` (3) non-empty lines, each clipped to the width and `ORCH_TOOL_RESULT_COLS` (160), then `· N chars` |
|
|
| Plan | magenta `▤` and the plan lines |
|
|
| Note | yellow `· text`, at most 400 characters |
|
|
| Error | red `✗ text`, at most 400 characters |
|
|
| Usage | nothing in the stream; shown in the header |
|
|
| (footer) | blank line, colored `✓ done` / `✗ failed` / `◌ timeout` / `◌ cancelled`, seconds, ` · summary` (300) |
|
|
|
|
Sanitizing: agent text may contain only SGR color sequences, newline and tab. Every other escape sequence
|
|
(cursor movement, OSC titles, ...) and every other control byte is removed, so an agent cannot move the
|
|
cursor, retitle the terminal or write outside its pane.
|
|
|
|
Word wrap: the width comes from the server (`cols - 1`, so a character in the last column cannot wrap by
|
|
itself). A word that does not fit the rest of the line moves to the next line; a word longer than a line is
|
|
broken; multi-byte characters count as one column; SGR sequences count as zero. The last, possibly incomplete word of a chunk is held until
|
|
a space or newline arrives, because tokens often split a word.
|
|
|
|
### 4.4 Event sources
|
|
|
|
**Internal worker (`fork`)**: `Agent::observer` is called from `Agent::show_call` (tool start, with secrets
|
|
masked: `store_secret` arguments become `[hidden]`, everything else goes through the vault redaction), from
|
|
`Agent::show_result` (tool end, failure detected from the result text the way the main display does), from the
|
|
stream sink of `chat.complete` (text pieces as they arrive), and after each reply (the whole text when the
|
|
backend did not stream; reasoning when there is no text and no tool call).
|
|
|
|
**CAP session**: `CapSession::addTap` receives every event of the session; `OrchCapAdapter::map` turns them:
|
|
|
|
| CAP event | OrchEvent |
|
|
|---|---|
|
|
| `cap.text_chunk` (channel `thought`) | Thought |
|
|
| `cap.text_chunk`, `cap.thought` | Text / Thought, newline appended when missing |
|
|
| `cap.tool_call.start` | ToolStart (name, input as JSON) |
|
|
| `cap.tool_call.end` | ToolEnd; the tool name comes from the remembered call id (at most 256 remembered) |
|
|
| `cap.plan` | Plan: `[status] content` per entry |
|
|
| `cap.usage` | Usage: input plus output tokens |
|
|
| `cap.error` | Error: `code: message` |
|
|
| `cap.ask_user`, `cap.permission.request`, `cap.artifact` | Note |
|
|
| anything else | ignored |
|
|
|
|
## 5. Owners
|
|
|
|
### 5.1 `fork` workers (`spawn_agent`)
|
|
|
|
1. refuse loudly past `AGENTS_MAX_PARALLEL` (8) live workers (`AgentsFull`, existing behavior);
|
|
2. open a panel `agent #<id>` with the task; register a cancel function that raises `AgentRecord::cancel`;
|
|
3. in the worker thread `default_runner` sets `worker.observer` (events to the panel) and `worker.cancel_flag`;
|
|
4. the agent loop checks the flag before every step: a raised flag ends the turn with `[cancelled]`;
|
|
5. on completion the record gets its status and the panel is finished: `done` to Done, the text
|
|
`[time limit reached]` to Timeout, a raised cancel flag to Cancelled (record status `error`, result
|
|
`worker cancelled (<reason>); partial result: ...`), any exception to Failed.
|
|
|
|
`poll` and the lanes display are unchanged. `poll` collects the result and spills a large one (section 7).
|
|
|
|
### 5.2 CAP turns (`CapMention::ask`)
|
|
|
|
1. open a panel `cap <agent>[:<model>]` with the prompt;
|
|
2. reuse or create the session for (agent, model, directory); a tap routes its events to the current panel;
|
|
3. register a cancel function: `cap.cancel` for the current turn at once, and after `ORCH_CANCEL_GRACE` (5 s)
|
|
`close()` on the session if the turn is still running;
|
|
4. `session.ask(prompt, timeout)`; a `CapTimeoutError` cancels and drops the session;
|
|
5. the caller (`Agent::run_agent`) collects the answer (section 7) and finishes the panel: Done, Timeout,
|
|
Cancelled or Failed with a one-line summary.
|
|
|
|
## 6. Supervision
|
|
|
|
| Concern | Rule | Constant |
|
|
|---|---|---|
|
|
| Parallelism | at most 8 live workers, refused loudly; at most 8 panels per process; the server allows 8 feed panes | `ORCH_PANELS_MAX`, `MPLEX_VPANE_MAX` |
|
|
| Space | a pane needs `MPLEX_VPANE_MIN_ROWS` (6, header plus five) and the window 41 columns; otherwise the agent runs without a panel and `last_error` says why | `MPLEX_VPANE_MIN_ROWS`, `MPLEX_VPANE_MIN_COLS` |
|
|
| Wall clock | `fork`: 30..3600 s, default 600, the worker ends with `[time limit reached]`; CAP turn: 600 s | existing |
|
|
| Heartbeat | every event is one; 60 s of silence shows `idle` | `ORCH_IDLE_WARN` |
|
|
| Stall | 300 s of silence cancels the job | `ORCH_IDLE_KILL` |
|
|
| User | closing the pane cancels the job | none |
|
|
| Cancel | soft first (flag read between steps, `cap.cancel`); a CAP session that ignores it is closed after the grace | `ORCH_CANCEL_GRACE` |
|
|
| Display failure | three failed writes stop drawing; supervision continues | board |
|
|
|
|
These follow the research: a limit on every job and a heartbeat to find a stalled worker earlier
|
|
(Temporal), soft stop before hard stop (Celery, Kubernetes), a cap on useful team size (agent teams advice: 3 to
|
|
5, token cost scales with the number of agents). The idle limits are static judgments, not measurements.
|
|
|
|
Supervision does not depend on a panel being shown: the watchdog runs on every panel the board tracks.
|
|
|
|
## 7. Collecting results
|
|
|
|
Unchanged, shared and deliberately not the panel's job: the panel is a view with a scrollback cap, not a store.
|
|
|
|
- A result longer than `SPILL_LIMIT` (6000 characters) is stored as a record (`mem:` id, chunked and indexed)
|
|
and the conversation receives the head, the tail and the record id (`spilled_view`); `record_read` pages the rest.
|
|
This is the claim-check pattern (`research/background-work-limits/azure-claim-check-pattern.md`).
|
|
- `poll` does this for `fork` workers (once; the record id is cached on the agent record).
|
|
- `Agent::run_agent` does it for CAP answers with `spill_or_truncate`, tag `agent-output`.
|
|
- The panel footer shows the size and, for CAP answers, whether it was stored.
|
|
|
|
## 8. Switches and environment
|
|
|
|
| Name | Effect |
|
|
|---|---|
|
|
| `TAI_PLEXER_ACTIVE`, `TAI_PLEXER_SOCKET`, `TAI_PLEXER_PANE` | set by the multiplexer in every pane; the orchestrator's way to find its screen |
|
|
| `TAI_ORCH_PANELS=0` | draw nothing (supervision stays on) |
|
|
|
|
There is no configuration file for the caps; they are constants (section 9).
|
|
|
|
## 9. Caps and floors (all static)
|
|
|
|
| Constant | Value |
|
|
|---|---|
|
|
| `ORCH_PANELS_MAX` | 8 |
|
|
| `ORCH_FLUSH_INTERVAL` | 0.025 s |
|
|
| `ORCH_MONITOR_INTERVAL` | 1.0 s |
|
|
| `ORCH_PENDING_MAX` | 262144 bytes |
|
|
| `ORCH_WRITE_MAX` | 32768 bytes |
|
|
| `ORCH_LINGER` | 20 s |
|
|
| `ORCH_IDLE_WARN` | 60 s |
|
|
| `ORCH_IDLE_KILL` | 300 s |
|
|
| `ORCH_CANCEL_GRACE` | 5 s |
|
|
| `ORCH_TASK_SHOW_MAX` | 300 characters |
|
|
| `ORCH_TOOL_ARGS_MAX` | 120 characters |
|
|
| `ORCH_TOOL_RESULT_LINES` | 3 |
|
|
| `ORCH_TOOL_RESULT_COLS` | 160 |
|
|
| `ORCH_LABEL_MAX` | 60 |
|
|
| `ORCH_CALL_TIMEOUT_MS` | 2000 |
|
|
| `MPLEX_VPANE_MAX` | 8 |
|
|
| `MPLEX_VPANE_MIN_ROWS` | 6 |
|
|
| `MPLEX_VPANE_MIN_COLS` | 20 |
|
|
| `MPLEX_VPANE_MAIN_PERCENT` | 55 |
|
|
| `MPLEX_VPANE_OPS_MAX` | 16 |
|
|
| `MPLEX_VPANE_WRITE_MAX` | 65536 bytes |
|
|
| `MPLEX_VPANE_HEADER_MAX` | 200 characters |
|
|
| `MPLEX_VPANE_LEASE` | 30 s |
|
|
| `MPLEX_VPANE_ORPHAN_LINGER` | 10 s |
|
|
| `MPLEX_VPANE_LINGER_MAX` | 300 s |
|
|
|
|
The feed panes and the `vpane_*` commands are outside the tables that `mplex_spec_test` compares with the
|
|
Python `mplex.md`, because the Python app has none of them.
|
|
|
|
## 10. Measured behavior
|
|
|
|
Measured with the real binaries (an attached client on a pty, three agents each streaming 500 tokens/s for
|
|
about 1.3 s):
|
|
|
|
| Measure | Value |
|
|
|---|---|
|
|
| frames received by the client | 85 in 1.33 s (about 64 per second) |
|
|
| interval between frames | median 12.9 ms, p90 25.3 ms (the single 295 ms gap is the scripted pause at the end of the stream) |
|
|
| bytes to the terminal | 234 KB (row-diffed frames, three panes changing) |
|
|
| writes to the multiplexer | at most 40 per second however many agents stream (unit test: 200 events in far fewer than 120 writes) |
|
|
|
|
Layout measured on a 120 x 40 window: one agent, main pane 66 columns and 40 rows, agent pane 53 x 40; three
|
|
agents, right column heights `[13, 13, 12]`; six agents fit; a seventh gets no panel.
|
|
|
|
Live check: the real REPL as the main pane (`detz --yolo`), a real model asked to fork two workers: two panels
|
|
appeared, streamed the tool calls (`⏺ shell(...)`, `⎿ exit 0`, output), changed to `✓ ... done`, showed the
|
|
footer, and left after the linger. A real `@tai:zen` CAP turn did the same (header `cap tai:zen-free`, the
|
|
panel filled when the turn ended, section 11).
|
|
|
|
## 11. Verification
|
|
|
|
| Test | Covers |
|
|
|---|---|
|
|
| `tests/orch_test.cpp` | sanitizing, clipping, CRLF, word wrap (inside a word never, across chunks rejoined, long words broken, multi-byte kept), tool and error rendering, the CAP adapter, panel buffering and the byte ceiling, never splitting a character, idle warn and idle cancel (once), cancel on user close, a hidden panel supervised like a shown one, the board over a fake transport (ordering, coalescing, done op, closed pane to cancel), no space, repeated write failure, the panel ceiling, and that this page lists every constant and command |
|
|
| `tests/orch_mplex_e2e.py` | a real multiplexer server and the real board: geometry for 1, 3 and 6 panels, content and footers, the space limit, linger and rebalance, a killed pane reported as closed, a failed footer, the main pane exiting |
|
|
| `tests/run_all.py` | steps `orch.unit`, `orch.mplex_e2e`, plus all `mplex.*` steps (the multiplexer itself is unchanged for existing behavior) |
|
|
|
|
Not covered by an automated test: the orphan lease (30 s plus 10 s) and the live REPL runs above.
|
|
|
|
## 12. Gaps
|
|
|
|
- tmux and bare terminals get no panels (a tail-file transport would close this).
|
|
- Keys typed into a feed pane are ignored; steering an agent from its pane is not built.
|
|
- The idle watchdog needs events; a CAP agent that streams nothing for 300 s is cancelled even if it works.
|
|
- A hung model request inside an internal worker is bounded only by the worker's own wall clock; the cancel
|
|
flag is read between steps.
|
|
- tai as a CAP agent (`--cap-speak`) sends its transcript after the turn, with generic tool names, so a tai
|
|
sub agent over CAP fills its panel at the end of the turn; agents that stream (stream-json, ACP) fill it
|
|
live. An internal `fork` worker is live.
|
|
- Panels are not restored after the orchestrator restarts; agent output records have no retention policy yet.
|
|
- A bare `tai` is not wrapped in the multiplexer automatically.
|
|
- Cancel escalation closes a CAP session from a second thread; a race inside the driver on close is possible.
|