Skip to content

run_agent — the FLT-2 uniform agent contract

One call shape for "have an agent CLI do a coding task in a repo," identical across codex, claude, gemini (agy), and grok. Consumed by the FLT-1 LangGraph supervisor and by humans via the CLI verb. This schema is frozen — additive changes only. Breaking changes need a new kind (run_agent_v2), never a mutation of this one.

Entry points

  1. Queue/HTTP job kind run_agent — payload rides the existing signed dispatch queue (--lane is required by the queue, as for every kind):
fleet-dispatch enqueue --targets GAMINGPC --kind run_agent --lane MULTI-MODEL --payload-file job.json
fleet-dispatch send --target GAMINGPC --kind run_agent --lane MULTI-MODEL --payload-file job.json
  1. Local CLI verb — same host code, no queue round-trip:
fleet-dispatch run-agent --agent codex --task "..." --repo C:\src\myrepo --ticket FLT-x
    [--branch master] [--mode build] [--timeout 1200] [--validate-cmd "npm test"] [--task-file path]

Prints the result JSON (indented) to stdout. Exit code 0 only when status == completed.

Request (queue payload / CLI flags)

{
  "agent": "codex | claude | gemini | grok",
  "task": "full task brief (prompt) for the agent",
  "repo": "C:\\absolute\\path\\to\\git\\repo on the EXECUTING machine",
  "ticket": "FLT-2",
  "branch": "master",
  "mode": "build",
  "timeoutSec": 1200,
  "validateCommand": "optional shell command run in the worktree to validate"
}
field required notes
agent yes adapter key; unknown values are rejected at enqueue
task yes staged to a temp file, piped to the CLI (argv limits, stdin-hang class)
repo yes must exist on the listener that executes the job
ticket yes [A-Za-z0-9._-] only; becomes part of the branch name
branch no base branch / start point (default master); must be a safe git ref — [A-Za-z0-9._-/], no leading -, no ..
mode no free-form routing hint recorded in the result (default build)
timeoutSec no agent-process timeout, clamped 60–14400 (default 1200 = 20 min)
validateCommand no overrides validation autodetect (see Validation). Runs as a shell command on the listener — RCE-equivalent, same privilege as the existing pwsh kind. Only trusted queue signers should use it.

Payloads are validated at enqueue time (HarnessRegistry.ValidateEnqueue) so malformed jobs die on the sender.

Prompt size: every adapter except codex passes the prompt as a native -p argument, so it is bounded by the Windows 32767-char command-line limit. Those adapters cap task at 28 000 bytes and refuse anything larger outright — explicit refusal, never silent truncation. codex is the agent for oversized briefs: it takes the prompt on stdin and is uncapped.

Result

Returned by the CLI verb on stdout, and — for queue jobs — serialized verbatim into DispatchResult.StdoutTail (with Ok = status == "completed").

{
  "status": "completed | failed | timed_out | rate_limited | approval_required",
  "summary": "best human-readable account of what the agent did",
  "changed_files": ["src/a.cs", "docs/x.md"],
  "tests_attempted": ["dotnet build => exit 0", "dotnet test => exit 0"],
  "known_risks": ["first attempt exited nonzero; result reflects the retry in the same worktree"],
  "needs_human_review": true,
  "agent": "codex",
  "durationSeconds": 312.4,
  "worktreePath": "C:\\src\\myrepo-agents\\a1b2c3d4"
}

Status semantics

status meaning
completed agent exited 0 and host validation did not fail
failed agent failed twice, validation failed, or the request was invalid
timed_out agent exceeded timeoutSec and was killed (process tree) — no retry
rate_limited agent exited nonzero and its output matched usage-limit/429/quota patterns; not a failure — reroute or wait
approval_required agent exited nonzero and stalled on an approval it cannot grant itself

rate_limited and approval_required are only ever assigned on a nonzero exit. The patterns match agent-controlled text, so classifying a successful run would let any agent that merely prints 429 (a task about rate limiting, say) spoof its own status and skip validation. Exit 0 always proceeds to validation.

needs_human_review is false only when status is completed, validation actually ran and passed, the diff is non-empty, and no risks were recorded. Everything else forces true.

What the host owns (FLT-3 policy, codified)

The agent's own claims about its success carry zero weight. The host:

  • creates a per-job git worktree add at {repo}-agents/{jobId}, branch wt/{jobId}/{ticket}, cut from branch — the agent never touches the main checkout. The start point is ref-validated and passed after a -- terminator, so it can never be read as a git option;
  • enforces the process timeout and kills the whole process tree on expiry. Output drains share that deadline (a backgrounded grandchild holding the pipes cannot outlive it) and retain a bounded 1 MB tail per stream, so a runaway log cannot OOM the listener;
  • retries once on a nonzero exit (same worktree; the validator judges the end state), then reports failed. Timeouts, rate limits, and approval stalls are not retried;
  • collects changed_files itself via git status --porcelain -z in the worktree (tracked edits + untracked files, rename-aware; -z so paths with spaces are not C-escaped), after validation runs — a formatter or codegen step can move the tree, and the result describes its final state. If git status itself fails, that is a recorded risk, never a silent "no changes";
  • re-runs validation itself and records the ACTUAL exit codes in tests_attempted:
  • validateCommand set → run it in the worktree;
  • else exactly one *.sln (preferred) or *.csproj at the worktree root → dotnet build <target> then dotnet test <target>, naming the target explicitly;
  • else (no target, or an ambiguous root with several) validation is skipped and known_risks says so — the run may still be completed but needs_human_review stays true;
  • sets TOKENROI_CONTEXT=fleet-dispatch in the agent's environment (token-roi attribution);
  • leaves the worktree in place — it is the review surface; worktreePath points at it. Cleanup is the consumer's job after review/landing (git worktree remove). These worktrees are not registered in the WorktreeOwnership registry that backs the worktree verbs; wiring the two together is open work.

Known gaps

  • Abandoned {repo}-agents/* worktrees are never garbage-collected — a long-lived listener accumulates them until someone prunes.
  • mode is recorded but does not change adapter behavior; FLT-1 uses it for routing only.
  • The "agent exited 0 but produced no file changes" risk can be masked by the validator's own build output. changed_files is re-collected after validation, so dotnet build's bin/ and obj/ make the list non-empty in a repo that does not ignore them — and a non-empty list clears both that risk and needs_human_review. An agent that did nothing then reads as a clean pass. Found while fixing FLT-5, where exactly this masked a gemini run that edited the wrong repo. The agent-edit check probably belongs before validation (the host already collects the diff there) or should ignore build output; both collections have uses and the fix is a contract decision, so it is raised here rather than smuggled into FLT-5.
  • ~~The gemini adapter calls agy directly, so run_agent's Gemini spend is unmetered.~~ Closed by FLT-50: the gemini adapter now routes through AgyInvocation, which books the spend atomically via UsageGovernor agy-admit before agy runs. A pool-gate denial fails the run with the deny phrase and classifies as RateLimited; a missing governor exe warns rather than bricking the lane.
  • --add-dir pins agy's workspace; it is not an OS write sandbox. Two council seats raised this independently. Nothing stops an agent emitting an absolute path or ../ and writing outside its worktree, and because CollectChangedFilesAsync only reads git status inside the worktree, such a write is invisible to the host — it cannot trip the "no file changes" risk or needs_human_review. So the contract's promise is confinement by cooperation, not enforcement. Real enforcement means an OS boundary (low-privilege account, AppContainer, Windows Sandbox) and is a host policy change, deliberately not smuggled into FLT-5's staging-barrier diff.
  • The live agy confinement proof is opt-in, so default CI never exercises it. Gemini_run_lands_its_edit_inside_the_result_worktree is gated behind FLEET_DISPATCH_E2E_AGY=1 because it spends real Gemini quota, which means the bug FLT-5 exists to prevent has no standing regression test — the unit tests assert only that --add-dir appears in a string, never that agy honors it. A fake-agy harness that records argv and its write location would close this without spending quota.

Adapter invocations

Every adapter stages the prompt to a temp file and runs through the shell: the shell resolves both real exes and npm .ps1 shims via PATH, and the leading pipe closes stdin (agy and codex hang forever on an open stdin). Only codex feeds the prompt itself down that pipe; the others read the file into $p and pass it as an argument, which is what bounds them to 28 000 bytes.

Every adapter's command also opens with $ErrorActionPreference = 'Stop'; and reads the prompt into $p before naming the CLI. Both halves are load-bearing. Get-Content on a missing or unreadable staged prompt is a non-terminating error: without the guard the script carries on and launches the agent with an empty brief, which answers "how can I help?" and exits 0 — a run that did nothing, reported as success. Measured in pwsh 7.6.3: unguarded, a missing staged file still reaches the agent call and the shell exits 0; guarded, it exits 1.

The guard alone fixes the status, not the launch. PowerShell starts both ends of a pipeline concurrently, so Get-Content ... | cli still starts the CLI, which reads EOF as an empty prompt — measured: shell exit 1, downstream process ran anyway, and the host's single retry ran it twice. codex had that shape (it is the adapter that feeds the prompt on stdin) and now assigns first, so the read is a barrier: no adapter may pipe the staging read straight into its CLI.

This is a host-wide invariant, not an AgentRunners one. HarnessRegistry.BuildShellCommand is a second lane into the same CLIs, and it carried the same bug in both flavors until the FLT-5 council caught it: codex/ollama piped the read straight in with no guard at all, while agy/grok assigned to $p but only reached Stop after the read (agy's arrived inside AgyInvocation.BuildCommand). Both lanes now share one shape. Measured against the real built HarnessRegistry, pwsh 7.6.3, with fake CLIs on PATH that mark the disk when launched:

shape shell exit CLI launched?
pre-fix Get-Content ... \| codex 1 yes — the run "failed" and burned quota anyway
post-fix, missing prompt (codex/grok/ollama) 1 no
post-fix, readable prompt (control) 0 yes

The control row is the point: "nothing launched" is only evidence if the same command does launch on a good prompt. A future adapter or lane belongs in this table before it ships. This does not hide agent exit codes — $PSNativeCommandUseErrorActionPreference defaults to False, so a nonzero native exit still returns normally and stays classifiable (rate limit / approval / failure). If a future shell flips that default, this is the thing to re-measure.

agent invocation core prompt path notes
codex codex exec -C <worktree> --json --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check - stdin, uncapped sandbox helper missing from Windows CLI bin; --json gives a parseable final agent message
claude claude -p $p --permission-mode acceptEdits (cwd = worktree) argv, 28 KB edits allowed, riskier actions still gated
gemini agy --model 'Gemini 3.1 Pro (High)' --add-dir <worktree> -p $p (stdin closed) argv, 28 KB never -m; npm gemini CLI is dead; --add-dir is mandatory — agy ignores cwd (FLT-5)
grok grok -p $p --disable-web-search argv, 28 KB CLI dies past ~32 KB of argv

Confinement is per-adapter, and cwd is not enough for all of them. The host sets ProcessSpec.WorkingDirectory to the worktree for every agent, but only claude and grok actually respect it. codex needs -C, and agy needs --add-dir: agy resolves files against its own persisted "active workspace", so with cwd alone it edits whatever repo it last had open. Measured 2026-07-14 (FLT-5): a gemini run reported creating its file in an unrelated real checkout, and with no active workspace agy dumps to ~/.gemini/antigravity-cli/scratch — cwd got nothing either way. --add-dir also beats a previously-active workspace, so --project/--new-project are not needed.

Be precise about what that buys: --add-dir pins agy's default workspace resolution; it is not an OS-level write sandbox. It fixes the measured bug (relative work landing in the wrong repo) and nothing more — it does not stop an agent from writing to an absolute path outside the worktree, and a future agy could treat the flag as additive multi-root. The same caveat applies to codex -C and to plain cwd for claude/grok: confinement here is cooperative, and the host's real guarantees are the ones it owns — the worktree, the diff it collects, and the validation it re-runs itself. Any new adapter must be placed in one of these two camps deliberately; Adapters_that_ignore_cwd_name_the_worktree_explicitly guards the explicit camp, and AgentRunLiveAgyTests proves the agy case end-to-end against the real CLI (opt-in: FLEET_DISPATCH_E2E_AGY=1, spends Gemini quota).

PowerShell passes $p to a native command as a single argument even when it contains spaces and newlines (no bash-style word splitting), so the prompt arrives intact — the cap is about the OS command-line length limit, not quoting.

Trust model

This runs on a trusted home fleet behind an HMAC-signed queue. Anyone who can enqueue a job can already use kind pwsh (arbitrary remote shell), so run_agent grants no new privilege — but note that it is not a sandbox either: validateCommand is shell, and codex runs with its sandbox bypassed. Treat "a run_agent job completed" as "a coding agent had listener-user privileges for 20 minutes", not as "something safe happened in a box". Validation is an honesty mechanism (it discards the agent's self-report), not a containment one.

Code map

  • src/FleetDispatch.Core/AgentRun/ — contracts, adapters, host, validator, worktree manager, process seam (IProcessRunner; only RealProcessRunner spawns real processes), job-object process reaping (WindowsProcessJob)
  • HarnessRegistry.ParseRunAgentPayload — enqueue-time validation
  • RealJobExecutor.RunAgentContractAsync — queue/HTTP wiring
  • tests/FleetDispatch.Tests/AgentRunTests.cs — mocked-process coverage; tests never invoke real CLIs