feat: add agy backend, RunFirstTurn, ContentBlock/BlockSender, engine fixes#53
Conversation
… fixes New Antigravity CLI (agy) backend - engine/cli/agy: Spawner + Parser + Resumer (spawn-per-turn). Wraps agy in an injection-safe `sh -c '"$0" "$@" ...'` shell that emits a JSON result sentinel on clean exit; captures the conversation ID from agy's --log-file and resumes via --conversation. Maps Model/AddDirs/HITL/sandbox/skip-permissions options. - cmd/agentrun-mcp: register agy in newCLIBackend, cliBackendNames, validBackends. RunFirstTurn helper (fixes spawn-per-turn first-turn double-prompt) - Root RunFirstTurn drains the Start-initiated turn for SequentialSender (spawn-per-turn) backends and sends for streaming/ACP backends, so callers no longer branch on backend type. Previously Start(prompt)+RunTurn(prompt) ran a redundant turn-2 resume before the session ID was captured, breaking codex, opencode, and agy under run_turn / session_start / probe_backend. - Use it in cmd/agentrun-mcp doRunTurn/doSessionStart/doProbe and examples/interactive. CLI engine turn-boundary race fix - Close output/done channels under p.mu (finishLocked/finalizeTurn) and have replaceSubprocess re-check done under the same lock, falling back to fresh channels (spawnCleanResume) — and returning ErrTerminated rather than silently resuming a turn that finalized with an error. Fixes a Send-right-after-result race that dropped the resumed turn. ContentBlock / BlockSender (multi-modal input) - Root BlockSender/ContentBlock + RunTurnBlocks; cli.BlockFormatter with Claude implementation; ACP SendBlocks; text-degradation fallback. Tests: deterministic regression tests for the race fix and RunFirstTurn (engine/cli/race_test.go, firstturn_test.go, runfirstturn_test.go). make qa and make mcp-qa green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces support for structured content blocks (such as text and base64 images) via the BlockSender and BlockFormatter interfaces, allowing multi-modal inputs with graceful degradation to plain text for unsupported backends. It also adds a new Antigravity CLI backend (agy) and introduces RunFirstTurn to unify the first-turn execution logic across different backend models. The review feedback highlights two resource leaks where temporary log files created by the agy backend are not cleaned up during single-turn executions or test runs, suggesting the use of runtime finalizers and t.Cleanup to resolve them.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| if f, err := os.CreateTemp("", "agy-*.log"); err == nil { | ||
| f.Close() | ||
| b.logFile = f.Name() | ||
| } |
There was a problem hiding this comment.
The temporary log file created here is only deleted if ResumeArgs is eventually called. However, for single-turn executions (such as the run_turn tool or sessions that are stopped before any resume), ResumeArgs is never invoked, leading to a persistent temporary file leak in the OS temp directory.
To prevent this resource leak, you can register a runtime finalizer on the Backend struct to automatically clean up the temporary file when the backend is garbage collected. Note that you will also need to import the "runtime" package.
if f, err := os.CreateTemp("", "agy-*.log"); err == nil {
f.Close()
logFile := f.Name()
b.logFile = logFile
runtime.SetFinalizer(b, func(_ *Backend) {
_ = os.Remove(logFile)
})
}There was a problem hiding this comment.
Good catch — confirmed. Fixed in #54: SpawnArgs is now a pure, I/O-free builder. Rather than a finalizer, the shell wrapper allocates its own log via mktemp and rms it on exit, and the conversation ID is captured from a stdout sentinel (codex/opencode pattern) instead of a persistent temp file — so nothing leaks on Validate, single-turn runs, or otherwise.
| if b.logFile == "" { | ||
| t.Error("Backend.logFile not set by SpawnArgs") | ||
| } |
There was a problem hiding this comment.
The temporary log file created during SpawnArgs is not cleaned up by this test, leading to leaked files in the system temporary directory on every test run.
Using t.Cleanup to delete the file ensures the system remains clean after the test completes.
if b.logFile == "" {
t.Error("Backend.logFile not set by SpawnArgs")
} else {
t.Cleanup(func() {
_ = os.Remove(b.logFile)
})
}There was a problem hiding this comment.
Addressed in #54 — the redesign removes the Go-side temp file entirely (the wrapper manages its own via mktemp), so there is no b.logFile for the test to leak. Added a purity test asserting SpawnArgs creates no temp files.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e5e3801d5f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| args = append(args, "-c", shellWrapper, b.binary) | ||
| args = append(args, agyArgs...) | ||
| args = append(args, "--print", prompt) | ||
| return "sh", args |
There was a problem hiding this comment.
Avoid wrapping agy in a shell that leaks the child
When an agy turn is cancelled or stopped before normal completion, the existing CLI lifecycle code signals only cmd.Process during Stop/replacement; with this wrapper that process is sh, not agy. A non-interactive sh -c 'agy ...' does not reliably forward SIGTERM to its foreground child, so timeouts, session_stop, or interrupted replacement can leave the agy agent running orphaned after agentrun reports it stopped, potentially continuing tool work or consuming resources.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed real (the engine signals cmd.Process = sh, no process group). Fixed in #54: the wrapper now runs agy in the background and traps SIGTERM/SIGINT, forwarding to the agy child. Verified live — SIGTERM to the wrapper terminates agy with no orphan.
| if f, err := os.CreateTemp("", "agy-*.log"); err == nil { | ||
| f.Close() | ||
| b.logFile = f.Name() |
There was a problem hiding this comment.
Keep SpawnArgs side-effect free
Engine.Validate calls backend.SpawnArgs(agentrun.Session{}) before each MCP run/session/probe to discover the binary, so creating the log file here leaks a temp agy-*.log on every validation call: Start calls SpawnArgs again, overwrites b.logFile, and only the second path can later be removed by ResumeArgs. This also violates the Spawner contract that SpawnArgs is a pure argument builder; allocate the capture file as part of actual start state instead of validation-time argument construction.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Agreed — SpawnArgs must be side-effect free since Engine.Validate calls it with a zero Session. Fixed in #54: log-file allocation moved out of SpawnArgs into the run-time wrapper (mktemp); SpawnArgs now only builds args.
) Addresses post-merge review feedback on #53 from Gemini Code Assist and Codex. ## Issues fixed | # | Reviewer | Issue | |---|---|---| | Temp-file leak | Gemini (medium), Codex P2 | `SpawnArgs` created a temp log file → leaked on every `Engine.Validate()` (zero session), leaked again because `Start` re-calls `SpawnArgs` and overwrites the path, leaked on single-turn runs (no `ResumeArgs`), and violated the "`SpawnArgs` is a pure builder" contract. | | Test temp-file leak | Gemini (medium) | The test left `agy-*.log` files in the temp dir. | | Orphaned agy on Stop | Codex **P1** | The `sh -c` wrapper means `cmd.Process` is `sh`, not `agy`. The engine signals `cmd.Process` on Stop/replacement, and a plain `sh -c` doesn't forward signals to its child → agy left running orphaned. | ## Approach Rather than papering over with a finalizer, the backend now matches the codex/opencode capture pattern and hardens the wrapper: - **`SpawnArgs` is pure** — no file I/O. The shell wrapper allocates its own temp log via `mktemp` and `rm`s it on exit, so nothing leaks (Validate, single-turn, or test). - **Conversation ID via stdout** — the wrapper extracts `Created conversation <id>` from the log and emits a `{"type":"agy_session","id":"..."}` sentinel; `ParseLine` captures it write-once into an atomic pointer. `ResumeArgs` no longer reads/removes a file side channel. - **Signal forwarding** — the wrapper runs agy in the background and `trap`s `SIGTERM`/`SIGINT`, forwarding to the agy child so Stop/replacement actually terminates agy. ## Verification - `make qa` + `make mcp-qa` green. - Live (real agy): turn-1 ID capture + turn-2 resume passed 2/2; SIGTERM to the wrapper killed agy with **no orphan**; no leftover temp files. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
Bundles four related pieces of work (all green under
make qa+make mcp-qa):1. New Antigravity CLI (
agy) backend —engine/cli/agySpawner + Parser + Resumer (spawn-per-turn).
agyis plain-text, spawn-per-turn, with no JSON/streaming mode and the conversation ID only in its log file, so the backend:sh -c '"$0" "$@"; ...'shell that emits a{"type":"result","stop_reason":"end_turn"}sentinel on clean exit (parsed intoMessageResult),agy --log-file <tmp>and resumes via--conversation <id>,Model→--model,OptionAddDirs→--add-dir, HITL/agy.dangerously_skip_permissions→--dangerously-skip-permissions,agy.sandbox→--sandbox.agentrun-mcpdiagnostic server (list_backends,parse_line,run_turn,probe_backend, sessions).2.
RunFirstTurnhelper — fixes the spawn-per-turn first-turn double-promptSpawn-per-turn backends (
SequentialSender) run turn 1 atStart(prompt baked into the spawn); streaming/ACP backends start turn 1 on the firstSend. Callers that didStart(prompt)+RunTurn(prompt)triggered a redundant turn-2 resume before the session ID was captured, which brokerun_turn/session_start/probe_backendfor codex, opencode, and agy (cli: resume args: ... no ID available).agentrun.RunFirstTurndrains forSequentialSenderand sends otherwise, so callers don't branch on backend type. Applied in the MCP harness (doRunTurn,doSessionStart,doProbe) andexamples/interactive.3. CLI engine turn-boundary race fix —
engine/cli/process.goA
Sendarriving right afterMessageResult(before the subprocessreadLoopclosed the channels) reused anoutputchannel thatfinish()was concurrently closing → the resumed turn was silently dropped. Fix: close channels underp.mu(finishLocked/finalizeTurn);replaceSubprocessre-checksdoneunder the same lock and falls back to fresh channels (spawnCleanResume), returningErrTerminatedinstead of silently resuming a turn that finalized with an error.4. ContentBlock / BlockSender (multi-modal input)
Root
BlockSender/ContentBlock+RunTurnBlocks;cli.BlockFormatter(Claude impl); ACPSendBlocks; text-degradation fallback.Tests
Deterministic regression tests:
engine/cli/race_test.go(turn-boundary race + crashed-turn-not-resumed),engine/cli/firstturn_test.go,runfirstturn_test.go. The race test fails reliably without the fix and passes count=30 under-race.Known pre-existing limitation (out of scope, documented)
An adversarial review surfaced a separate pre-existing concurrency issue:
Stop()called concurrently with a resumeSendcan deadlock or bypass the grace period, becausecmdDoneis a single buffered channel thatStop,replaceSubprocess, and the resume drain compete for. A surgical patch proved fragile and was reverted; a proper fix needs per-generation exit-signal channels. Normal sequential usage (drain → Send; Stop after/between turns) is unaffected.🤖 Generated with Claude Code