Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
8d0fa43
fix: unify session state derivation and presentation
whoisasx Jul 22, 2026
09db362
fix: keep merged session cards on standard surface
whoisasx Jul 22, 2026
dddfcf4
fix: remove merged card border accent
whoisasx Jul 22, 2026
b3d1a3d
fix(board): prioritize idle sessions in work lane
whoisasx Jul 22, 2026
6c8c778
feat(board): pair merged sessions with ready lane
whoisasx Jul 22, 2026
8cee460
feat(board): improve merged states and session archive
whoisasx Jul 22, 2026
4789a04
feat(board): add archive layout switcher
whoisasx Jul 22, 2026
475d3ff
fix(board): default archive to aligned grid
whoisasx Jul 22, 2026
97f0918
fix(board): tighten archive spacing
whoisasx Jul 22, 2026
bceef13
feat(sessions): refine workspace UI and PR activity
whoisasx Jul 22, 2026
1d50de8
feat(sessions): complete lifecycle and merge handling
whoisasx Jul 22, 2026
846fd96
chore: remove redundant session state paths
whoisasx Jul 22, 2026
0faf3a2
Merge remote-tracking branch 'origin/main' into codex/issue-2950-sess…
whoisasx Jul 22, 2026
7a99399
feat(sessions): finalize state and recovery flow
whoisasx Jul 23, 2026
86107d8
Merge remote-tracking branch 'origin/main' into codex/issue-2950-sess…
whoisasx Jul 23, 2026
159e6db
fix: extend missing activity grace period
whoisasx Jul 23, 2026
cc2f227
fix: restore board smoke test contract
whoisasx Jul 23, 2026
e47e15f
fix: preserve early activity on agent resume
whoisasx Jul 23, 2026
b979a05
fix: address session state review feedback
whoisasx Jul 23, 2026
c0303d7
Merge remote-tracking branch 'origin/main' into codex/issue-2950-sess…
whoisasx Jul 23, 2026
3deb3ea
style: simplify exited activity guard
whoisasx Jul 23, 2026
aa1932c
fix: preserve merge-triggered session termination
whoisasx Jul 23, 2026
90c0488
fix: align session activity presentation
whoisasx Jul 23, 2026
a484bb4
Merge remote-tracking branch 'origin/main' into codex/issue-2950-sess…
whoisasx Jul 23, 2026
fa1ca99
style: format session state changes
whoisasx Jul 23, 2026
77c64fc
fix: make board termination action self-contained
whoisasx Jul 23, 2026
93f501b
Merge remote-tracking branch 'origin/main' into codex/issue-2950-sess…
whoisasx Jul 23, 2026
97c812b
fix: treat merged terminated sessions as archived
whoisasx Jul 24, 2026
2aca521
fix: persist workspace repository metadata
whoisasx Jul 24, 2026
4fdde29
Merge remote-tracking branch 'origin/main' into codex/issue-2950-sess…
whoisasx Jul 24, 2026
5d69fb7
Merge remote-tracking branch 'origin/main' into codex/issue-2950-sess…
whoisasx Jul 24, 2026
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
14 changes: 7 additions & 7 deletions backend/internal/adapters/agent/fake/fake.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@
// reaches the daemon and its activity reports land against the spawning session.
//
// The terminal state is deterministic: the run ends on a `session-end` event
// that derives ActivityExited (a durable, is_terminated fact), NOT an incidental
// idle left behind by a trailing `stop`. A turn-boundary event (session-end)
// also clears the sticky `blocked` state that precedes it, so the lifecycle
// reaches its terminal cleanly without needing the pre/post-tool-use trio.
// that derives ActivityExited, NOT an incidental idle left behind by a trailing
// `stop`. Exited records agent-process death while the runtime remains available;
// it does not set is_terminated. A turn-boundary event (session-end) also clears
// the sticky `blocked` state that precedes it.
//
// Timing is controlled by AO_FAKE_SPEEDUP (a float, default 1): every phase
// sleeps for a base duration divided by the speedup, so tests can compress the
Expand Down Expand Up @@ -216,9 +216,9 @@ func phaseSleep() float64 {
// pr-push event fires during the active work phase — the canned "pushed PR"
// marker plus a `pr-push` hook (which derives active, so it refreshes rather
// than changes state). The run ENDS on session-end (derives exited), a
// turn-boundary event that both clears the sticky `blocked` and terminates the
// session — a deterministic terminal, not an ageable idle. No trailing sleep
// follows the terminal event, so a high AO_FAKE_SPEEDUP collapses the whole run.
// turn-boundary event that clears the sticky `blocked` without terminating the
// runtime. No trailing sleep follows the terminal event, so a high
// AO_FAKE_SPEEDUP collapses the whole run.
func timelineScript(sleepSeconds float64) string {
d := formatSeconds(sleepSeconds)
var b strings.Builder
Expand Down
16 changes: 8 additions & 8 deletions backend/internal/adapters/agent/fake/fake_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,11 +260,11 @@ func (s *lifecycleStore) MarkWorkerIdleEventDelivered(_ context.Context, _ strin
// -> hooks fire (the script actually runs, calling a stub `ao` on PATH that
// records each event) -> activity derivation (DeriveActivityState) -> reduction
// (lifecycle.Manager.ApplyActivitySignal, including the sticky-state precedence
// rules) -> termination — and asserts the derived activity states in order.
// rules) -> agent exit — and asserts the derived activity states in order.
//
// It also asserts the whole sped-up run compresses to well under a second, and
// that the terminal state is a deterministic ActivityExited (is_terminated),
// not an incidental idle.
// that the terminal state is a deterministic ActivityExited without terminating
// the still-available runtime, not an incidental idle.
func TestFullLifecycleSpawnToTermination(t *testing.T) {
if _, err := exec.LookPath("sh"); err != nil {
t.Skip("no POSIX shell on PATH")
Expand Down Expand Up @@ -324,7 +324,7 @@ func TestFullLifecycleSpawnToTermination(t *testing.T) {
// fired event through the real deriver and lifecycle reducer, capturing the
// persisted state after each. This exercises sticky-state precedence:
// waiting_input and blocked must be cleared by the following turn-boundary
// events, and session-end must terminate.
// events, and session-end must mark the agent exited.
store := &lifecycleStore{sessions: map[domain.SessionID]domain.SessionRecord{
"fake-1": {
ID: "fake-1",
Expand Down Expand Up @@ -358,7 +358,7 @@ func TestFullLifecycleSpawnToTermination(t *testing.T) {
domain.ActivityActive, // user-prompt-submit (clears waiting_input)
domain.ActivityActive, // pr-push (already active)
domain.ActivityBlocked, // permission-request
domain.ActivityExited, // session-end (clears blocked + terminates)
domain.ActivityExited, // session-end (clears blocked)
}
if len(got) != len(want) {
t.Fatalf("derived %d states %v, want %d %v", len(got), got, len(want), want)
Expand All @@ -369,10 +369,10 @@ func TestFullLifecycleSpawnToTermination(t *testing.T) {
}
}

// Termination must be a durable, deterministic exit.
// Agent exit must be durable and distinct from runtime termination.
final := store.sessions["fake-1"]
if !final.IsTerminated {
t.Fatalf("session not terminated after session-end; state=%q", final.Activity.State)
if final.IsTerminated {
t.Fatalf("session terminated after agent-only session-end; state=%q", final.Activity.State)
}
if final.Activity.State != domain.ActivityExited {
t.Fatalf("terminal state = %q, want %q (not an incidental idle)", final.Activity.State, domain.ActivityExited)
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}"}
}

// paneCurrentPathArgs prints tmux's cwd for the session's active pane. Create
// uses this after new-session so a poisoned tmux server that ignores -c fails
// loudly instead of silently starting the agent in the wrong directory.
Expand Down
Loading
Loading