feat(agents): native agents reachable from the planner and eve agents

Merged
CH
5d ago
+17137-55

P11b of the rollout in "Bringing Codex or Claude Code Max plans into Luke". P11 (#4313) routed every coder-mount eve composition through NativeAgentSessions, but only the web functions provide that layer, because only apps/web ships the runner bundle. So from an eve agent's tools (apps/dev), a native agent got NATIVE_AGENT_NOT_HERE, and a Start from there always chose eve. (The planner, apps/luke, was deleted on main by #4335 while this PR was open, so only the eve agent's side remains here.)

What changed

The eve apps now delegate to the web function instead of duplicating the harness:

  • POST /api/agents/native-sessions (native-relay-app.ts, route file apps/web/server/routes/agents/native-sessions.ts, in the default function next to nativeAgents). It runs open, send, cancel and reset on that function's own harnesses.
    • Admits only a bearer equal to EVE_DEPLOYMENT_SECRET, compared in constant time. That's the same secret the web already presents at eve's door, never the cron's. If the deployment has no secret, the route answers 503 and runs nothing.
    • Acts for the account named in the body, as eve's deploymentActor does. The harness then reaches only what that account reaches.
    • A message (open/send) must be for the conversation's own agent, which the account must reach, on the named runtime, and the account must pass admitsPlanWriter (PLAN_AGENT_WRITER_POLICY). A cancel or reset must name a session minted by the named harness.
    • Answers with the harness's outcome only, so no token crosses.
    • Runtime-agnostic: the accepted runtimes come from NATIVE_SESSION_PREFIX, so codex is relayed as soon as P24 routes it.
  • nativeRelayLayer (native-relay.ts) is provided in apps/dev/runtime.ts. sessionsRouter, agentRuntimeChoice, and every caller above them (message_agent, stop_agent, start_agent, chat notices) now see a harness there, so a Start decides the runtime as the panel's does. The P11 TODOs are removed.
  • The relay also carries retune (main's change that hands a native runner a new model or effort, runnerChoiceApplied), held to the same checks as a message.
  • NATIVE_AGENT_NOT_HERE stays for a function with no path at all: a web function without the harness, or an eve app whose deployment names no secret or origin.

The lock a message holds, and what a relayed launch writes

A message is handed over inside the conversation's write transaction (writeAwaitingLine), which holds the conversation row FOR UPDATE. When the words mention an MCP server, dispatchMentioningServers also locks and writes the agent row before the hand-over. In-process, the harness's writes run on that same connection, so neither lock mattered. Through the relay, the harness runs on the web function's own connection, so any row the message's transaction holds is one a launch waits on until the 30-second dispatch deadline. The message is then recorded as failed with no line, though the runner launches afterwards. Devin caught the conversation-row half of this; I found the agent-row half. Two changes fix it:

  • The runner bearer digest moves to the agent's row. It was conversations.runner_token_hash, the row the message holds. It is now coding_agent.runner_token_hash: an additive column with a partial unique index, plus an idempotent UPDATE in the migration that backfills a digest a runner already holds. The cohort is empty, so none should exist; the backfill is there so the migration stays correct anyway. readRunnerTokenConversation (behind admitRunnerBearer, used by the MCP server and the events route) and withNativeProjection's lock now join through the agent. The lock takes both rows, so a retire still serializes against a batch. conversations.runner_token_hash and its index are left in place, unread and unwritten, for a later cleanup to drop.
  • A native agent's mentioned servers are written after the hand-over, still in the same transaction, so the message holds no lock on the agent row while the harness writes it. That's safe because the runner's first events take those servers under the conversation's lock (withNativeProjection), which waits for this commit, so the turn still finds them. eve agents keep today's order exactly, since eve's turn start takes the servers on its own.

Tests

  • native-relay.test.ts runs both production ends, joined by a fake network (testing/native-relay-fake.ts). It covers:
    • every router call reaching the harness, acting for the starter (open/send) or the caller (cancel/reset), while an eve agent's calls reach eve and post nothing;
    • a message, a Stop, and a Start (which decides claude_code) through the relay;
    • the route refusing no bearer, the cron's secret, a wrong secret, a stranger account, a runtime mismatch, an eve session, and a deployment with no secret;
    • NATIVE_AGENT_NOT_HERE where neither path exists;
    • the deadlock case: a mention plus a relaunch (send) and a launch (open) through the relay. At each launch, the test records the bearer as a launch does and checks that the row it landed on was not written or locked by the message's transaction (its xmin/xmax against the transaction's own id). That's the row a launch on its own connection would wait on. It also checks the servers are written afterwards, that eve still sees them written beforehand, and that each bearer is found again by its digest. The test fails with the bearer on the conversation's row, and fails without the steer.ts reorder.
    • coding-agent-runner-token-hash.test.ts: the migration over a database from before it moves a held digest to the agent, leaves an agent with none at none, and changes nothing when run twice.
  • coder-agent-tools.test.ts: an eve agent's message_agent and stop_agent do the same.
  • All existing eve-agent tests are unchanged.

Bundles

An esbuild metafile check shows:

  • apps/dev bundles no runner-bundle.ts or native-agent-sessions.ts.
  • The new route reaches eve only through agent-runtime/sandbox-image.ts, which the default function already bundles through orpc-route.ts and runner-events, so no web function gains an eve import.

pnpm check:affected ran out of memory in this sandbox while running its parallel typechecks (exit 137). Typecheck passes for @luke/service, @luke/coder, @luke/luke and @luke/web run one at a time, lint and discarded-effect pass, and CI runs the full set.

Preview runs were waived by the developer for this rollout.

Made with Luke


Devin Review