Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions backend/internal/adapters/agent/codex/codex.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ func (p *Plugin) EmitsSubmitActivity() bool { return true }
// ports.ActivitySignaler.
func (p *Plugin) EmitsBlockedActivity() bool { return false }

// ExitDetectionMode opts Codex into AO's process supervisor. Codex hooks
// expose turn boundaries but no reliable session-end event.
func (p *Plugin) ExitDetectionMode() ports.AgentExitDetectionMode {
return ports.AgentExitDetectionSupervisor
}

// SteersActiveTurn is true: submitting input to the codex TUI mid-turn steers
// the running turn rather than being swallowed or queued, so AO may write an
// unsolicited coordination message into an active codex session. See
Expand Down
7 changes: 7 additions & 0 deletions backend/internal/adapters/agent/codex/codex_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ func sessionHookFlags() []string {
}
}

func TestExitDetectionUsesAOProcessSupervisor(t *testing.T) {
plugin := &Plugin{}
if got := plugin.ExitDetectionMode(); got != ports.AgentExitDetectionSupervisor {
t.Fatalf("exit detection mode = %q, want %q", got, ports.AgentExitDetectionSupervisor)
}
}

func TestGetLaunchCommandBuildsCrossPlatformArgv(t *testing.T) {
plugin := &Plugin{resolvedBinary: "codex"}
workspace := canonicalTempDir(t)
Expand Down
42 changes: 30 additions & 12 deletions backend/internal/adapters/runtime/conpty/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,18 +155,26 @@ func clientGetOutput(addr string, lines int) (string, error) {
// death. Mirrors ptyHostIsAlive from pty-client.ts on the alive path: host
// reachable == alive, regardless of the inner agent's alive field.
func clientIsAlive(addr string) (alive bool, transientErr error) {
_, hostAlive, err := clientStatus(addr)
return hostAlive, err
}

// clientStatus returns both pty-host reachability and the state of the child
// process managed by that host. A reachable host remains the runtime after its
// child exits.
func clientStatus(addr string) (status StatusPayload, hostAlive bool, transientErr error) {
conn, err := dialHost(addr, isAliveTimeout)
if err != nil {
// A dial timeout is transient (the loopback hiccupped). A refused
// connection means nothing is listening -> definitively gone. Any
// other dial failure is treated as transient ("when unsure, retry").
if isTimeout(err) {
return false, err
return StatusPayload{}, false, err
}
if isConnRefused(err) {
return false, nil
return StatusPayload{}, false, nil
}
return false, err
return StatusPayload{}, false, err
}
defer func() { _ = conn.Close() }()

Expand All @@ -176,16 +184,20 @@ func clientIsAlive(addr string) (alive bool, transientErr error) {
if _, err := conn.Write(statusReqFrame); err != nil {
// We connected, then the write failed: connected-then-failed I/O is
// transient (the host may still be up; the conn was disrupted).
return false, err
return StatusPayload{}, false, err
}

aliveC := make(chan bool, 1)
type statusResult struct {
payload StatusPayload
err error
}
statusC := make(chan statusResult, 1)
parser := NewMessageParser(func(msgType byte, payload []byte) {
if msgType == MsgStatusRes {
var sp StatusPayload
ok := json.Unmarshal(payload, &sp) == nil
decodeErr := json.Unmarshal(payload, &sp)
select {
case aliveC <- ok:
case statusC <- statusResult{payload: sp, err: decodeErr}:
default:
}
}
Expand All @@ -199,8 +211,11 @@ func clientIsAlive(addr string) (alive bool, transientErr error) {
parser.Feed(buf[:n])
}
select {
case result := <-aliveC:
return result, nil
case result := <-statusC:
if result.err != nil {
return StatusPayload{}, false, result.err
}
return result.payload, true, nil
default:
}
if err != nil {
Expand All @@ -209,12 +224,15 @@ func clientIsAlive(addr string) (alive bool, transientErr error) {
}
}
select {
case result := <-aliveC:
return result, nil
case result := <-statusC:
if result.err != nil {
return StatusPayload{}, false, result.err
}
return result.payload, true, nil
default:
// Connected but never got a STATUS_RES: read timeout or mid-read EOF.
// lastErr is the error that broke the read loop (always non-nil here).
return false, lastErr
return StatusPayload{}, false, lastErr
}
}

Expand Down
18 changes: 18 additions & 0 deletions backend/internal/adapters/runtime/conpty/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,24 @@ func (r *Runtime) IsAlive(ctx context.Context, handle ports.RuntimeHandle) (bool
return clientIsAlive(sess.addr)
}

// IsSupervisedProcessAlive uses the pty-host's child status. For a supervised
// launch that child is the AO supervisor, whose lifetime matches the managed
// agent process.
func (r *Runtime) IsSupervisedProcessAlive(ctx context.Context, handle ports.RuntimeHandle, _ ports.SupervisedProcessRef) (bool, error) {
sess := r.resolve(handle.ID)
if sess == nil {
return false, nil
}
status, hostAlive, err := clientStatus(sess.addr)
if err != nil {
return false, err
}
if !hostAlive {
return false, nil
}
return status.Alive, nil
}

// SendMessage chunks message and writes it to the pty-host followed by Enter.
func (r *Runtime) SendMessage(ctx context.Context, handle ports.RuntimeHandle, message string) error {
sess := r.resolve(handle.ID)
Expand Down
41 changes: 41 additions & 0 deletions backend/internal/adapters/runtime/conpty/runtime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,47 @@ func TestIsAlive_TrueWhileServing_FalseAfterClose(t *testing.T) {
}
}

func TestSupervisedProcessExitKeepsHostAlive(t *testing.T) {
isolateRegistry(t)
hosts := map[string]*inProcHost{}
rt := New(Options{Spawner: fakeSpawnerFor(t, hosts, livePID())})
ctx := context.Background()

handle, err := rt.Create(ctx, ports.RuntimeConfig{
SessionID: "sess-supervised",
WorkspacePath: "/tmp/w",
Argv: []string{"ao", "agent-process", "supervise"},
})
if err != nil {
t.Fatal(err)
}
h := hosts["sess-supervised"]
t.Cleanup(func() { h.cleanup(t) })

if alive, err := rt.IsSupervisedProcessAlive(ctx, handle, ports.SupervisedProcessRef{}); err != nil || !alive {
t.Fatalf("supervised process before exit = (%v, %v), want (true, nil)", alive, err)
}
h.pty.signalExit(42)

deadline := time.Now().Add(time.Second)
for {
alive, probeErr := rt.IsSupervisedProcessAlive(ctx, handle, ports.SupervisedProcessRef{})
if probeErr != nil {
t.Fatal(probeErr)
}
if !alive {
break
}
if time.Now().After(deadline) {
t.Fatal("supervised process remained alive after PTY exit")
}
time.Sleep(10 * time.Millisecond)
}
if alive, probeErr := rt.IsAlive(ctx, handle); probeErr != nil || !alive {
t.Fatalf("runtime host after child exit = (%v, %v), want (true, nil)", alive, probeErr)
}
}

// TestIsAlive_FalseForUnknownSession verifies IsAlive returns (false, nil) for
// a session not in the map or registry.
func TestIsAlive_FalseForUnknownSession(t *testing.T) {
Expand Down
17 changes: 17 additions & 0 deletions backend/internal/adapters/runtime/tmux/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@ func newSessionArgs(id, cwd, shellPath, launchCmd string) []string {
}
}

// respawnPaneArgs replaces the process in the session's only pane while keeping
// the tmux session and terminal handle intact.
func respawnPaneArgs(id, cwd, shellPath, launchCmd string) []string {
return []string{
"respawn-pane", "-k",
"-t", id + ":0.0",
"-c", cwd,
shellPath, "-c", launchCmd,
}
}

// setStatusOffArgs hides the tmux status bar for the given session.
// set-option uses pane-targeting syntax which does not accept the `=` prefix,
// so we pass the session name directly.
Expand Down Expand Up @@ -44,6 +55,12 @@ func setWindowSizeLargestArgs(id string) []string {
return []string{"set-option", "-t", id, "window-size", "largest"}
}

// panePIDArgs returns the pid of tmux's direct pane process. AO walks its
// descendants to find the exact supervisor for the current launch.
func panePIDArgs(id string) []string {
return []string{"display-message", "-p", "-t", id + ":0.0", "#{pane_pid}"}
}

// killSessionArgs builds args for `tmux kill-session -t =<id>`. The `=` prefix
// requests exact-name matching so a session "foo" does not accidentally match
// "foobar" (tmux otherwise does unique-prefix matching).
Expand Down
Loading