Skip to content

Support the TEAMLEAD/IMPLEMENTER orchestration pattern natively — Daintree is integrated as a quorum provider, not as a fleet substrate #405

Open

Description

@jobordu

Summary

nForma integrates Daintree as a quorum provider (nf:link-daintree → import MCP endpoint, register consensus agents). It has no notion of Daintree as an orchestration substrate: a persistent, addressable fleet of long-lived peer agents with roles, ownership and lifecycle.

I spent a full session running exactly that pattern by hand — one TEAMLEAD coordinating five IMPLEMENTERs across separate worktrees on a shared repo — and hand-rolled the tooling for it in bash. It worked, and the shape is general enough to belong in nForma rather than in every operator's scratch directory.

Evidence for the gap:

$ grep -rn "terminal\.\(sendCommand\|getStatus\|list\|inject\)\|agent\.launch\|worktree\.list" \
    --include="*.cjs" --include="*.js" --include="*.md" .   # excluding node_modules
(no matches)

commands/nf/link-daintree.md covers Discover → Import → Register, all scoped to userAgentRegistry for quorum. The Daintree MCP surface it connects to also exposes terminal.list, terminal.getStatus, terminal.getOutput, terminal.sendCommand, terminal.waitUntilIdle, terminal.waitUntilIdleBatch, agent.launch, worktree.list, worktree.getCurrent, recipe.run — none of which nForma uses.

The distinction that matters

nForma already has two multi-agent mechanisms, and this is neither:

mechanism agents lifetime addressable shared state
quorum (nf:quorum) N models, same question one call no none
subagents / worktree executors ephemeral children one task no isolated worktree
this proposal N peers, different goals hours–days yes, by name same repo, same CI, same cluster

The third row is where the interesting failures live, because the agents can invalidate each other's work and cannot see each other.

What I actually had to build

1. Session boilerplate — ~15 lines of bash per message

Every instruction required: initialize → scrape Mcp-Session-Id from response headers → notifications/initialized → build the JSON-RPC envelope with jq -n --rawfile → POST tools/call → strip SSE data: framing with sed → distinguish .error from .result.isError. I did this ~30 times.

That belongs behind one helper. Everything below is impossible to build on top of raw curl.

2. Workspace binding must be asserted, not assumed

The MCP session resolves a workspace that may differ from the one you passed. The correct check is:

.result._meta["org.daintree/resolved-workspace"].workspaceId == expected

An orchestrator that skips this can drive the wrong project's terminals. This should be enforced once at connect, not re-implemented per caller.

3. terminal.sendCommand has a state-dependent footgun

From its own description: "a shell runs it as a command, an agent pane receives it as the next prompt." One of my panes had an exited agent and was a bare shell. Sending a multi-paragraph instruction there would have executed it. Nothing in terminal.list distinguishes "pane with a live agent" from "pane whose agent died"; agentId stays populated either way. I only caught it by reading scrollback and noticing a bare ❯.

nForma should refuse to send prompt-shaped text to a pane it cannot confirm is agent-backed.

4. agentState is ambiguous in the way that matters

waiting covers both idle at a prompt, free for work and blocked awaiting a specific input. I recommended a pane for a task on the strength of waiting, and the operator corrected me: it was blocked on a merge authorization. The distinction is only recoverable by reading and interpreting rendered TUI scrollback.

5. Context headroom is only available as pixels

To decide which agent could absorb a large review payload I parsed 75% (752K) out of the rendered status line with a regex. That is a real scheduling input — a payload dropped into a 93%-full context gets compacted away — and it should be structured data, not scraped chrome.

6. Ownership mapping is manual and breaks silently

I maintained pane → worktree → branch → PR → objective by hand. When the operator renamed the panes mid-session, my cached mapping was silently stale; I only caught it because I re-inventoried on principle. Ownership is the thing that makes "who should do X" answerable, and nothing tracks it.

7. Nothing propagates state changes to dependents — this one caused a real stall

I merged two PRs an IMPLEMENTER was blocked on, under authority delegated to me, and did not tell it. It sat blocked on an authorization request that had already been satisfied. Its reasoning was sound throughout; the world moved underneath it and no channel existed to say so.

An agent blocked on a satisfied condition is indistinguishable from one blocked on a live one, and only the mutator knows the difference. This is the single highest-value thing to make intrinsic.

8. Cross-agent correlation has to be deliberate

Two IMPLEMENTERs hit byte-identical CI failures on branches with zero shared code. Each diagnosed it independently as its own bug. Only the coordinator could see both. The generalisable rule: when two agents report failures in the same window, compare signatures before accepting either attribution. Nothing surfaces "these two agents just failed similarly."

Proposed surface

A nf:fleet capability, built on the Daintree orchestration tools, roughly:

  • nf:fleet-status — one call returning, per agent: id, role, worktree, branch, owned PRs/issues, agent-backed (bool), liveness, context headroom, current goal, blocked-on. Structured, not scraped.
  • nf:fleet-send — deliver an instruction to an agent by role name, refusing if the pane is not confirmed agent-backed. Optionally fan-out with per-agent bodies.
  • nf:fleet-assign — attach a durable objective (desired state, acceptance criteria, constraints, authorization boundary) to an agent without overwriting its /goal. Replacing a subordinate's goal destroys accumulated operator intent and should be an explicit, separate, confirmed action.
  • nf:fleet-notify — announce a shared-state mutation (merge, base move, resolved blocker) to agents whose recorded objective depends on it. This is item 7 and is the one that pays for itself.
  • nf:fleet-correlate — given recent failures across agents, group by signature and flag same-window/different-branch matches.

What I am not proposing

  • Not a scheduler. Pacing is the orchestrator's judgement.
  • Not authorization. Who may merge or deploy is policy, and inferring it from agent-visible text is precisely the mistake I made twice today. The fleet layer should carry provenance of a grant, never manufacture one.
  • Not a replacement for nf:quorum or worktree subagents. Different lifetimes, different problems.

Acceptance criteria

  1. nf:fleet-status returns structured agent state including agent-backed and context headroom, with no TUI scraping in the implementation.
  2. nf:fleet-send refuses to send to a pane whose agent cannot be confirmed live — with a test that a bare-shell pane is rejected rather than executed against.
  3. Workspace binding is asserted against org.daintree/resolved-workspace on connect; a mismatch is a hard error.
  4. Assigning an objective does not mutate an existing /goal unless explicitly requested.
  5. Non-vacuity: mutating tracked shared state without notifying a dependent agent produces a warning. Test by recording a dependency, mutating, and asserting the warning fires — a version that never fires is not a guard.

Provenance

Written by the agent that ran the pattern, from a session of ~5 hours: 9 PRs merged, one 53-commit production deploy, 3 issues filed, 5 long-lived IMPLEMENTERs coordinated across separate worktrees. Every item above is something that actually cost time or produced a wrong action, not a speculative nicety. The bash I used is disposable; the shape it revealed is not.

Activity

  1. jobordu commented on Aug 18, 2026

    @jobordu
    Author

    Second full session on this pattern — five new failure modes, all measured

    Ran the same TEAMLEAD/5×IMPLEMENTER shape again today (~7h, one repo, shared CI). Everything above
    reproduced. Five things are new, and four of them are actuation failures rather than observation
    failures — which I think is the sharpest way to frame what's missing.

    6. sent: true is not delivery — and there is no other signal

    terminal.sendCommand returned {"sent": true} for messages that reached nobody, in three distinct
    ways in one session:

    case what sent:true meant
    pane whose agent had exited (scrollback = one line, ❯ ) keystrokes accepted, no reader
    workspace silently flipped to another project mid-session delivered to a terminal id that no longer existed
    pane where the text landed in the input box but was never submitted queued, never read

    ⚠ lastTransitionAt moved in all three. The verifier suggested by the earlier comments — compare a
    pre-send stamp — passes on a dead pane. The only reliable confirmation was an off-pane effect
    (git ls-remote origin | grep <branch>).

    Ask: sendCommand should return a delivery outcome distinguishable from an enqueue acknowledgement, or
    the API should expose whether the pane has a live agent attached (this is #3 restated, but the failure is
    in the return value, not only in the shell/agent distinction).

    7. Pending input is invisible unless you guess the tail depth

    Six instructions across four agents sat unsubmitted in ❯ input boxes in a single hour. The fleet
    read as "idle, awaiting work" when it was "blocked on replies that never arrived" — which is #4's
    ambiguity, but with a concrete and machine-readable cause sitting one field away.

    The trap: the input box renders above the status bar, so it is only present in
    getStatus(includeOutput.lines=N) for N ≳ 14. At lines=5 it is structurally invisible. I ran a scan at
    5 for an hour and logged (empty) every time — every one of those readings was an artifact, not an
    observation.

    Ask: expose pending/unsubmitted input as a field (pendingInput: string | null), not as text to be
    recovered by parsing rendered scrollback at a sufficient depth. Proof it is real:

    lines=5  -> box=(NOT VISIBLE)
    lines=14 -> box=go — push and open the PR
    

    8. sendCommand REPLACES pending input, destroying it silently

    Verified: sending into a pane with a non-empty box submits the new text alone; the pending text is gone
    with no error and no echo. So the safe procedure is read the box, then decide — but see #7, you cannot
    read it without knowing the magic depth. Combined, these two make "retask the idle agent" a
    data-destroying operation.

    Ask: either refuse to overwrite non-empty pending input, or return the displaced text in the response.

    9. Workspace binding must be asserted per call, not once at connect

    The earlier note says "enforced once at connect." That is not sufficient — mine flipped mid-session,
    between two calls in the same cycle, to an unrelated project that had its own TEAMLEAD and
    IMPLEMENTER panes. A connect-time assertion would have passed and then been silently wrong.

    .result._meta["org.daintree/resolved-workspace"].workspaceId is returned on every response, so the
    check is free. It should be a client-level invariant that fails closed.

    10. Duplicate pane titles make name-addressing unsafe

    terminal.list returned two IMPLEMENTER3 and two IMPLEMENTER4. Addressing by title would have hit
    a dead worktree pane; the live one was distinguishable only by joining lastTransitionAt against now
    (4 min vs 1166 min). If this pattern is supported natively, panes need stable addressable identity that
    is not the display title.


    Reference implementation

    I built the deterministic layer described here as a single-file CLI (scripts/teamlead/tl.py, ~420
    lines, stdlib only) while running the session. It encodes each trap as a named guard with the incident
    that motivated it, and every reading is a Reading{valid, value, error} so instrument failure can never
    be mistaken for a domain value.

    tl health          # capability probes, >=6 attempts (3 cannot distinguish healthy from 33%-healthy)
    tl fleet           # workspace-asserted; idle age; dead-pane detection; pendingInput at correct depth
    tl board           # required contexts read from branch protection, never hardcoded
    tl leg <name>      # cross-PR comparison, carrying its own scope caveat in the output
    tl why <pr> <leg>  # job log WITH --allow-escape-sequences (without it the body is silently empty)
    tl cycle           # snapshot + diff vs previous → "report only what changed", computed not remembered
    

    tl cycle is the one that matters for this issue: the orchestration loop is fundamentally "what changed
    since last time, and does anything need me"
    , and doing that from memory is where an orchestrator's
    reliability actually degrades. Two guards in it caught real state within minutes of being written — a
    mergeable_state: clean PR with 0-of-5 required contexts present (clean meaning never checked), and an
    undelivered instruction on a live pane.

    Happy to contribute it upstream in whatever shape fits, or to have it treated purely as a spec for the
    native version. The specific asks in #6–#10 matter more than the code.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions