CodeDocs Vault

Orchestration & Session Lifecycle

Omnigent's bet is unusual: instead of one agent that spawns clones of itself, it lets different vendors' coding agents — Claude Code, Codex, Cursor, Pi, Goose, and your own — collaborate inside one shared session. An orchestrator agent plans and delegates; sub-agents do the coding; one agent reviews another's work; and humans can join the same session live from any device. This doc traces how that works end to end.

The recurring shape is worth naming up front: everything is a child session. A delegation, a code review, even a web_fetch, is the parent posting a message to a child conversation and waiting on an inbox. There is no separate "task" abstraction — sub-agents are conversations, which is what makes them shareable, resumable, and forkable like any other.


1. The Orchestrator / Sub-agent Model

An Omnigent agent is a short YAML file: a prompt, some tools, and optionally a set of sub-agents the agent can delegate to (docs/AGENT_YAML_SPEC.md:261-293). A sub-agent is just another tool of type: agent with its own executor.harness and model — so an orchestrator can mix vendors by role (a cursor coder, a claude-sdk reviewer), and a sub-agent can pass_history: true to inherit the parent's transcript (docs/AGENT_YAML_SPEC.md:264-277).

The reference orchestrator is Polly (examples/polly/config.yaml). Polly runs on the claude-sdk harness (:27-32) and has one hard rule baked into its prompt: it writes no code itself — all coding work is delegated (:33-42). It declares exactly three sub-agents (:271-278):

Sub-agent Harness Role
claude_code claude-native primary implementer (multi-file / refactor)
codex codex-native primary implementer (narrow, scoped)
pi pi headless review / explore specialist

How a delegation actually fires

Delegation is a single tool call: sys_session_send (examples/polly/config.yaml:89-108). The runner classifies it as a sub-agent tool (omnigent/runner/tool_dispatch.py:200-203) and dispatches it through _execute_subagent_tool (omnigent/runner/tool_dispatch.py:985-1011). That function:

  1. Creates or reuses a child session on the server (POST /v1/sessions), with the parent forced as parent_conversation_id (omnigent/runner/tool_dispatch.py:205-209, 1965-1967).
  2. Registers a runner-local launch entry and posts the child's first message.
  3. Returns a launching handle immediately — the parent does not block.
  4. On child completion, runner turn-end bookkeeping pushes a completion payload into the parent's sys_read_inbox queue (omnigent/runner/tool_dispatch.py:991-994; delivery in omnigent/runner/app.py:6250-6316) and POSTs inbox_awaken to wake the parent if its turn was sleeping (_schedule_subagent_wake, app.py:11321, 11406).

So the loop is async by construction. Polly dispatches a fan of workers, ends its turn, and is automatically woken when any sub-agent finishes — the prompt is emphatic that busy-polling the inbox is a bug (examples/polly/config.yaml:88-94, 210-220). Waiting on sub-agents is the framework's job.

Every sys_session_send must carry a human-readable title (the task label shown in the UI's Subagents panel) and an args.purpose — one of implement / review / explore / search (examples/polly/config.yaml:95-108). The title is also load-bearing mechanically: the server enforces a partial unique index on (parent_conversation_id, title), so reusing a title continues the same child conversation, while a fresh title spawns a new worker with no memory (omnigent/runner/tool_dispatch.py:1993-1997; cross-review relies on this at examples/polly/skills/cross-review/SKILL.md:38-41).

There are two spawn paths. Declared sub-agents go through sys_session_send. With spawn: true (examples/polly/config.yaml:11-16) the orchestrator also gets sys_session_create (omnigent/runner/tool_dispatch.py:205-209) — it can launch an existing agent by id, or author a custom agent YAML and launch it via config_path. Both count against the per-turn fan-out cap (see §6).

Everything is a child session web_fetch is implemented by translating its query/url into a sys_session_send against a built-in __web_researcher sub-agent (omnigent/runner/tool_dispatch.py:1955-2004). The same primitive — post to a child conversation, await the inbox — backs delegation, review, and web research. One mechanism, many features.

For your project — Swisscheese: This is the meta-harness shape Swisscheese needs: a planner brain that never touches code, a typed purpose on every dispatch, and an async inbox so the orchestrator scales a fan of parallel workers without turn-by-turn babysitting. The (parent, title) uniqueness index is a clean way to make "continue this worker" vs. "spawn a new one" an explicit caller decision.


2. Cross-agent Review — the Writer → Reviewer Pattern

This is the Swisscheese pattern, and Omnigent wires it as a first-class workflow. The rule, from Polly's prompt: review is ALWAYS done by a different vendor than the implementer (examples/polly/config.yaml:141-147). Claude Code's PR is reviewed by Codex or Pi; Codex's by Claude Code or Pi.

The procedure lives in a skill, cross-review (examples/polly/skills/cross-review/SKILL.md):

  1. Get the diff — gh pr diff <pr> or git -C .worktrees/<id> diff (:13-14).
  2. Run deterministic gates first — tests / lint / typecheck. If red, re-dispatch the implementer to go green; don't involve the reviewer yet (:15-17).
  3. Dispatch a different-vendor sub-agent with purpose: "review", passing only the diff + the acceptance contract as text — never a pointer to the implementer's worktree or transcript (:18-33, 55-56).
  4. The reviewer surfaces issues; it does not fix them (:34). Only the implementer ever opens a PR, so a stray reviewer edit can never reach the deliverable (:57-60).
  5. Each blocking issue becomes a fix-task sent back to the same implementer conversation (reuse its title/session_id so it keeps its worktree/branch context), then loop (:35-41).
  6. When gates are green and zero blocking issues remain, the PR is ready — the human merges; Polly never does (:42-44).
flowchart LR
    O[Orchestrator<br/>polly · claude-sdk] -->|sys_session_send<br/>purpose=implement| I[Implementer<br/>claude_code]
    I -->|opens PR + worktree| PR[(diff + contract)]
    O -->|sys_session_send<br/>purpose=review<br/>DIFFERENT vendor| R[Reviewer<br/>codex / pi]
    PR -->|diff text only| R
    R -->|blocking issues<br/>via inbox| O
    O -->|fix-task to SAME<br/>implementer convo| I
    R -.->|clean| H[Human merges]

Three design choices make the independence real:

The same writer→critic shape appears in Debby, where every question fans to a Claude head and a GPT head and /debate has them critique each other for a few rounds (README.md:223-227), and in Scribe, which routes a draft through an independent reviewer to fact-check claims before shipping (README.md:229-234). Scribe's reviewer is deliberately on the Codex harness while Scribe's brain is claude-sdk, "so it catches claims the author's own model would miss," and it is "only ever dispatched with purpose: review and reports rather than edits" (examples/scribe/agents/reviewer/config.yaml:8-9, 16, 20, 55). The agent reads files to verify claims against the real code but never writes them — the same diff-in, report-out contract as Polly's code reviewer.

For your project — Swisscheese: Omnigent has independently arrived at Swisscheese's core thesis — reviewing AI-generated code is the hard problem, and the reviewer must be a different model than the writer. The concrete tactics to borrow: pass the reviewer only the diff + contract (not the writer's reasoning, so it can't anchor on it), make reviewers structurally incapable of editing, and treat single-vendor availability as a refusal condition rather than silently letting the writer grade itself.

For your project — EU AI Act compliance platform: The diff-only, reviewer-can't-edit handoff is an audit-traceable construction: the review artifact is a structured report keyed to a specific diff + contract, separate from the implementer's transcript. That separation is exactly what an Article 12 logging story wants — a reviewer's verdict that can't be confused with the author's work.


3. Worktree Isolation & Fan-out

Parallel-safe subtasks run through the fanout skill (examples/polly/skills/fanout/SKILL.md). Each task gets its own git worktree and branch (git worktree add .worktrees/<id> -b polly/<id>, :11-15), one implementation sub-agent scoped to that worktree (:16-29), and opens its own PR. The orchestrator records each handle's conversation_id in a .polly/registry.json task list (:11-15; config.yaml:223-225), dispatches the whole parallel-safe set, then ends its turn — no polling (:16-29). Each finished PR then flows through cross-review (:35).

Because Polly never merges, cross-PR conflicts surface at human merge time, not during the run — which is why keeping each task's file scope disjoint matters (examples/polly/skills/fanout/SKILL.md:55-57).

Investigation is delegated the same way: the investigate skill answers read-only questions by dispatching explore/search sub-agents and synthesizing only from their structured reports (config.yaml:110-117, 193-201) — the orchestrator is forbidden from sprawling reads of the codebase itself.


4. The Shared Session: Sharing, Co-driving, Forking

A session is a server-hosted conversation. The split that makes collaboration work: the runner owns the agent subprocesses and is pinned to the conversation; clients (CLI REPL or web UI) only post turns and read the event stream.

Server ↔ runner: the WS tunnel

Each conversation is bound to exactly one runner via conversations.runner_id (omnigent/runner/routing.py:88-115). The server routes a turn to that runner over a WebSocket tunnel (routing.py:242-260, WSTunnelTransport). Binding a runner is owner-only, and dispatch never picks or persists a runner itself (routing.py:88-105). Capability is checked at routing time — a runner must advertise the harness in its hello frame or the dispatch fails with RUNNER_CAPABILITY_MISMATCH (routing.py:235-239, 263-274). This is why a Claude-Code session can't be resumed onto a host that only has Codex.

Real-time sync

Clients receive live updates over a per-session Server-Sent Events stream: GET /v1/sessions/{id}/stream (omnigent/runner/app.py:207, 8576). Under it sits a small in-process pub-sub broadcaster (omnigent/runtime/session_stream.py): subscribe() hands each connected client its own ephemeral queue (omnigent/runtime/session_stream.py:41-48), and publish() fans one event to every active subscriber of a conversation, thread-safely via call_soon_threadsafe from the sync workflow thread (omnigent/runtime/session_stream.py:52-59). The runner emits through a single _publish_event chokepoint threaded through every surface — new terminals, sub-agent discovery, harness output (app.py:925, 1123, 1370, 1476, 6991). So "messages, sub-agents, terminals, and files stay in sync" across laptop + phone + a teammate's browser (README.md:29, 288) is literally one SSE feed fanned to every viewer. Note the wire carries no sequence numbers and events emitted with no subscriber connected are dropped — reconnecting clients re-read a snapshot rather than replaying (omnigent/runtime/session_stream.py:52-59).

Child sub-agent status and preview deltas are also mirrored onto the parent's stream (app.py:7011), so the orchestrator — and anyone watching it — sees live progress on every worker.

Presence (who's watching)

Multi-user sessions get Google-Docs-style presence circles (omnigent/server/presence.py). Presence is scoped to the session tree's root conversation, not the individual conversation (omnigent/server/presence.py:4-10): two users — one looking at the orchestrator, one looking at a sub-agent of the same session — still appear in each other's viewer list. A leave-grace window of 15s absorbs proxy reconnects so an avatar doesn't flicker on a transient drop (omnigent/server/presence.py:22, 43-45).

Co-drive (omnigent attach)

omnigent attach <session_id> joins a live conversation as a pure co-drive client (omnigent/chat.py:513-532). The contract is precise:

attach is a pure co-drive client: it never launches OR binds a runner. Turns post to the runner the host already bound (POST /v1/sessions/{id}/events) … post-only is what makes cross-user co-drive work. (omnigent/chat.py:525-532)

A teammate's messages execute on your machine (the host runner), exactly like the web UI's co-drive (README.md:340-346). A read-only preflight confirms the host runner is online — attach can't start one, and fails loud if the host is offline (omnigent/chat.py:530-532, 548-558). Who may co-drive vs. only watch is a permission level: grants are (user_id, conversation_id, level) triples with 1=read, 2=edit, 3=manage (omnigent/stores/permission_store/__init__.py:4, 30-47), and the web UI gates sending behind permission_level >= 2 (ap-web/src/shell/Sidebar.tsx:898). A __public__ sentinel grants anonymous read-only access (omnigent/stores/permission_store/__init__.py:44).

Turn ordering is the real co-drive primitive. With multiple humans and the agent itself all posting to one conversation, who runs next? Inbound messages take a monotonic arrival sequence and then wait at a FIFO gate until every earlier message has made its turn-vs-buffer decision (omnigent/runner/app.py:6919-6928, _ingest_now_serving / _ingest_cond). The invariant is one active turn per session: while a turn runs, later messages are buffered, not dropped and not allowed to jump the gate. A slow-to-resolve message can't be overtaken by a fast one that arrived later — ordering follows arrival, not content-resolution latency. That single serialization point is what lets several drivers share one message log without a locking protocol.

Share

sys_session_share (omnigent/runner/tool_dispatch.py:217-225) mutates session permissions via PUT /v1/sessions/{id}/permissions. An agent can only grant a share if its spec's agent_session_sharing policy allows it (non-public or public, omnigent/runner/tool_dispatch.py:234-239); a public share uses an anonymous read-only sentinel (_PUBLIC_USER_SENTINEL, omnigent/runner/tool_dispatch.py:227-232). Crucially, the server can't see the agent's sharing policy, so the runner is the gate — the share tool is only exposed when the spec opts in.

Fork

omnigent run --fork <id> clones a conversation and continues independently from the fork point (README.md:348-353). The CLI calls the fork endpoint before entering the REPL so the user lands in the fork (omnigent/chat.py:3944-3957); the transport exposes fork(session_id, at_message_id=...) returning a new session id (omnigent/native_server_transport.py:158-160). Server-side, fork_conversation deep-copies the source conversation and its items into a fresh top-level conversation atomically in one transaction (omnigent/stores/conversation_store/__init__.py:1048); the caller needs only read access on the source, and the owner gets full permission on the fork (omnigent/server/routes/sessions.py:14408, 14535). A fork can switch agents — even to a different vendor — and optionally truncate history at a chosen response id (omnigent/server/routes/sessions.py:14437-14451, 14387).

Fork directives ride as labels rather than columns: omnigent.fork.source_id marks the unbound clone, omnigent.fork.source_external_session_id carries the source's native session uuid for transcript cloning, and omnigent.fork.carry_history tells native targets to rebuild the transcript (omnigent/stores/conversation_store/__init__.py:23, 37, 54). Native replay only works for harnesses that support it (claude/codex carry history; cursor/pi rebuild from Omnigent items), and a cross-vendor fork skips the source native session entirely because the transcript format wouldn't match (omnigent/server/routes/sessions.py:14481-14492). The native wrappers have matching plumbing — Claude's rotates the bridge session and reseeds the forked transcript (omnigent/claude_native_forwarder.py:1859, 1967, 2021).

flowchart TB
    subgraph clients[Clients - any device]
        L[Laptop REPL<br/>owner · binds runner]
        P[Phone web UI]
        T[Teammate browser<br/>co-drive · post-only]
    end
    SRV[(Omnigent server<br/>conversation store<br/>SSE: /sessions/id/stream)]
    subgraph host[Host machine]
        RUN[Runner<br/>pinned: conversations.runner_id]
        ORCH[Orchestrator brain]
        SA1[claude_code worktree A]
        SA2[codex worktree B]
        REV[pi reviewer]
    end
    L -->|owner binds + posts| SRV
    P -->|posts turns| SRV
    T -->|POST /events only| SRV
    SRV <-->|WS tunnel| RUN
    SRV -.->|SSE fan-out| L
    SRV -.->|SSE fan-out| P
    SRV -.->|SSE fan-out| T
    RUN --> ORCH
    ORCH -->|sys_session_send| SA1
    ORCH -->|sys_session_send| SA2
    ORCH -->|review · diff only| REV
    SA1 -.->|inbox completion| ORCH
    SA2 -.->|inbox completion| ORCH
    REV -.->|inbox report| ORCH

5. Resume & Continuation Across Devices and Restarts

Resume is dispatch-by-runtime. omnigent resume <conv_id> (omnigent/resume_dispatch.py:39-87) fetches the conversation, reads its omnigent.wrapper label, and routes to the matching native wrapper (_dispatch_wrapper, omnigent/resume_dispatch.py:201-309) — Claude, Codex, Pi, Cursor, Kiro, Goose, Antigravity, Qwen, Kimi, Hermes each have a run_*_native(server, session_id, args) entry point. With no id, the cross-agent picker lists prior conversations across all agents (omnigent/resume_dispatch.py:69-145).

The wrapper label is read from a local SQLite store (SqlAlchemyConversationStore, chat.db, omnigent/resume_dispatch.py:312-336) or, with --server, via GET /v1/sessions/{id} (omnigent/resume_dispatch.py:339-394). This is the same label-as-metadata scheme that drives the whole lifecycle: native sessions carry omnigent.wrapper and a UI-mode label so they render terminal-first (omnigent/native_coding_agents.py:30-44).

Cold resume vs. reattach

Two outcomes when resuming a terminal-native session (omnigent/_native_resume_hint.py:60-95):

Session close

Closing a session marks it closed to new user input via an omnigent.closed=true label (omnigent/session_lifecycle.py:7-9, 70-86). Legacy rows encoded this as a title suffix (":closed:conv_..."); the API synthesizes the label from the suffix for old rows so clients and write-guards see a uniform signal (omnigent/session_lifecycle.py:46-67).

Delivery safety (the cross-device duplicate problem)

A subtle correctness pitfall worth noting: native forwarders mirror transcript items into the server as external_conversation_item POSTs, which persist with a random primary key and are not deduped server-side (omnigent/_native_post_delivery.py:1-18). So a blind retry after an ambiguous transport failure (request sent, response lost) would append a duplicate bubble in the web UI. post_may_have_been_delivered (omnigent/_native_post_delivery.py:38-65) classifies failures: HTTP-status and connect-establishment errors are safe to retry; any "sent but no response" error is ambiguous and not retried for conversation items (omnigent/_native_post_delivery.py:108-126). The native tmux pane is unaffected, which is why the duplicate is web-only.

For your project — Swisscheese: The non-idempotent-event problem bites any multi-client harness that mirrors a local terminal into a shared web view. Omnigent's answer — classify the failure, only retry the provably-undelivered ones — is the right default for an event log that can't dedupe. Worth copying verbatim if Swisscheese mirrors agent output to a dashboard.


6. Guardrails on Orchestration

Polly's fan-out is bounded by runner-side policies (config.yaml:280-311):

Guardrail Mechanism Where
Per-turn fan-out cap (5 dispatches) spawn_bounds policy, counts sys_session_send and sys_session_create config.yaml:295-304
Headless sub-agents must declare a valid purpose headless_subagent_purpose_guard (implement/review/explore/search) config.yaml:305-311
Day-long approval window ask_timeout: 86400 (the runner previously hard-coded 120s) config.yaml:281-284
Don't gate pushes, still DENY catastrophic ops blast_radius with gate_pushes: false config.yaml:285-294
Cancel a runaway worker sys_cancel_task with the worker's conversation_id config.yaml:226-232, omnigent/runner/tool_dispatch.py:264-269

Cancellation honesty is encoded too: claude_code workers are hard-stopped (and wake the parent with a cancelled inbox item), pi honors the interrupt, but codex cancellation is "best-effort" — so the prompt tells the orchestrator not to assume the worker is gone until the inbox confirms it (config.yaml:226-232, examples/polly/skills/fanout/SKILL.md:48-51).

The orchestrator's read-only observability surface (peek / list / close / get-info / share) all route through the server's REST endpoints because the runner has no in-process conversation store (omnigent/runner/tool_dispatch.py:211-225). sys_session_list maps to GET .../child_sessions (omnigent/runner/tool_dispatch.py:2591, 3289-3321).


7. Things to Learn From / Pitfalls

Good ideas:

Pitfalls / risks: