Skip to content
Open
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
76 changes: 76 additions & 0 deletions docs/adr/0005-codex-invocation-scoped-app-server.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# ADR 0005: Codex invocation-scoped app-server lifecycle

**Status:** proposed
**Date:** 2026-08-21
**Deciders:** @fujibee

## Context

The Codex monitor currently reuses one app-server per project, launches its
bridge dispatcher in the background, and then replaces the monitor with the
Codex TUI via `exec`. Remote tool processes inherit the TUI thread working
directory from that shared app-server, so closing one TUI does not provide an
ownership boundary for its workers. The orphan behavior is tracked in
[#149](https://github.com/fujibee/agmsg/issues/149).

Some callers launch Codex in a disposable Git worktree and need a bounded
lifecycle boundary before deciding whether the worktree is ready for removal.
A project hash, process name, shared parent PID, or logical Codex thread ID
cannot prove OS-process ownership when several sessions share one app-server.

## Decision

Add an opt-in `codex-monitor.sh --invocation-scope <token>` mode. The token is
validated, combined with the canonical project path, and hashed before it is
used as the app-server record key. A scoped launch never reuses an existing
app-server: the monitor acquires an exact scope lease, starts a fresh app-server
as its child, runs the Codex TUI as a supervised foreground child, and on TUI
exit stops and waits for its captured app-server, bridge-launcher, and TUI
processes before returning the TUI status. A direct `TERM` takes the same path
and returns status 143. A live duplicate scope fails closed. Scope-less
launches retain the existing project-shared app-server and `exec` behavior.

Only app-server lifetime is invocation-scoped. agmsg role seating, the bridge
request, and dispatcher ownership remain project-scoped so concurrent sessions
cannot create duplicate inbox consumers. The scoped server key is inherited
internally as `AGMSG_CODEX_APP_SERVER_KEY`, allowing hook-side code running in
the app-server context to resolve the correct port without exposing the raw
token or falling back to another invocation's project server.

## Alternatives considered

- **Classify every shared app-server child as owned.** Rejected because a shared
server can have same-cwd workers from another Codex session.
- **Bind cleanup only to Codex thread IDs.** Rejected because thread APIs do not
enumerate every OS worker or provide a complete PID ownership tree.
- **Disable agmsg monitor for disposable worktrees.** Safe, but removes real-time
delivery from the exact sessions that use multi-agent work most heavily.
- **Make every bridge and role record invocation-scoped.** Rejected because it
would create competing consumers for one project role and is unnecessary for
process ownership.
- **Change all monitor launches to scoped lifetime.** Rejected for compatibility;
current users may rely on project-wide server reuse.

## Consequences

- Positive: a scoped monitor captures exact app-server, bridge-launcher, and
TUI processes that it can stop and wait for without inspecting or signalling
foreign project sessions.
- Positive: fresh and resume launches use the same lifecycle contract, preserve
the Codex exit status, and leave the existing no-scope behavior unchanged.
- Positive: no daemon, external dependency, or second cleanup implementation is
introduced.
- Negative: scoped launches pay app-server startup cost on every invocation.
- Negative: the monitor does not prove that any downstream remote or role
descendant has independently exited. They naturally bind to the scoped
app-server lifetime, while the final caller still decides readiness.
- Negative: `SIGKILL` can leave a stale lease; the next same-scope launch fails
closed unless it can prove the recorded owner is dead before reclaiming it.
- Neutral: project role seating remains single-owner/latest-seat behavior.

## References

- [Issue #149](https://github.com/fujibee/agmsg/issues/149)
- `scripts/drivers/types/codex/codex-monitor.sh`
- `scripts/drivers/types/codex/codex-bridge-launcher.sh`
- `docs/codex-monitor-beta.md`
33 changes: 30 additions & 3 deletions docs/codex-monitor-beta.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ approximates the same experience by launching Codex through an app-server bridge
> enabling monitor takes effect only after you **restart Codex and send your
> first message** — the SessionStart hook fires on the first turn, not the
> moment Codex opens, so the bridge is absent until you interact once; an
> already-running session stays unmonitored until you restart it (#151); the
> bridge is not torn down when you close the TUI (orphans linger until reboot
> or `mode off`/manual kill, see #149).
> already-running session stays unmonitored until you restart it (#151). A
> scope-less launch retains the shared app-server lifetime, so use the opt-in
> invocation scope below when a disposable worktree needs a bounded lifecycle
> (#149).

## Quick Start

Expand Down Expand Up @@ -110,6 +111,32 @@ codex logout
The shim also passes through when the current project is not in Codex monitor
mode.

## Invocation-scoped lifetime (opt-in)

The normal, scope-less monitor keeps its existing behavior: it reuses a live
app-server for the project and `exec`s the Codex TUI. Use an invocation scope
only when a caller needs a disposable-worktree lifecycle boundary:

```bash
codex-monitor.sh --project "$PWD" --invocation-scope "$opaque_scope" --codex-command codex -- -C "$target"
```

`$opaque_scope` is a unique, non-secret token for this invocation. The monitor
combines it with the canonical project path and records only the resulting key.
Scoped launches always start a fresh app-server, so they pay startup cost and
do not reuse the project server.

The scoped monitor captures its Codex TUI, app-server, and bridge launcher.
When the TUI exits, it stops and waits for those captured processes before
returning the TUI status. A direct `TERM` follows the same cleanup path and
returns status `143`.

This does not prove that any downstream remote or role descendants have
independently exited. They naturally bind to the scoped app-server lifetime;
the final caller still decides whether its own readiness condition is met.
`SIGKILL` cannot run cleanup and can leave a stale lease. The next launch with
that same scope fails closed unless it can prove the recorded owner is dead.

## Bridge Mechanics

`codex-monitor.sh` starts (or reuses) an agmsg-managed Codex app-server socket
Expand Down
23 changes: 17 additions & 6 deletions scripts/drivers/types/codex/_app-server.sh
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,29 @@

# Echo the app-server URL for <project>, or nothing.
#
# The environment variable wins when present: it is the value monitor exported
# for this very process, and preferring it keeps every context that already
# worked on exactly the path it used before.
# With no scoped key, the environment variable still wins: it is the value a
# legacy monitor exported for this process. A scoped key identifies the exact
# server instead, so its port record must win over any inherited generic URL.
_agmsg_codex_app_server_record_key() {
local project="$1"
if [ -n "${AGMSG_CODEX_APP_SERVER_KEY:-}" ]; then
printf '%s' "$AGMSG_CODEX_APP_SERVER_KEY"
else
printf '%s' "$project" | agmsg_sha1
fi
}

_agmsg_codex_app_server_url() {
local project="$1" port_file port
local project="$1" record_key port_file port
[ -n "$project" ] || return 0
if [ -n "${AGMSG_CODEX_BRIDGE_APP_SERVER:-}" ]; then
if [ -z "${AGMSG_CODEX_APP_SERVER_KEY:-}" ] \
&& [ -n "${AGMSG_CODEX_BRIDGE_APP_SERVER:-}" ]; then
printf '%s' "$AGMSG_CODEX_BRIDGE_APP_SERVER"
return 0
fi
command -v agmsg_sha1 >/dev/null 2>&1 || return 0
port_file="$SKILL_DIR/run/codex-app-server.$(printf '%s' "$project" | agmsg_sha1 2>/dev/null).port"
record_key="$(_agmsg_codex_app_server_record_key "$project")"
port_file="$SKILL_DIR/run/codex-app-server.$record_key.port"
port="$(cat "$port_file" 2>/dev/null || true)"
# Digits, and a port a TCP stack could have handed out. Digits alone are not
# enough on their own — a prefix of a real port (5 of 52962) is all digits and
Expand Down
38 changes: 24 additions & 14 deletions scripts/drivers/types/codex/_session-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
# launcher start the bridge — a hook-launched bridge cannot connect to the unix
# socket from inside the Codex sandbox (#41).

# shellcheck source=_app-server.sh
source "$SKILL_DIR/scripts/drivers/types/codex/_app-server.sh"

# Newest-N rollout files under $sessions_dir, sorted by mtime descending.
# `ls -t "$dir"/*/*/*/rollout-*.jsonl` is unreliable on Windows/Git Bash --
# reported to intermittently return an empty/truncated list with no
Expand Down Expand Up @@ -120,21 +123,28 @@ agmsg_session_start() {
done <<< "$PAIRS"
PAIRS="$safe_pairs"
[ -n "$PAIRS" ] || exit 0
app_server="${AGMSG_CODEX_BRIDGE_APP_SERVER:-}"
if [ -z "$app_server" ]; then
agent_pid=$(agmsg_agent_pid "$TYPE" 2>/dev/null || true)
if [ -n "$agent_pid" ]; then
agent_cmd=$(compat_get_cmdline "$agent_pid" 2>/dev/null || true)
app_server=$(printf '%s\n' "$agent_cmd" \
| sed -n 's/.*\(unix:\/\/[^[:space:]]*\).*/\1/p' \
| head -1)
if [ -n "${AGMSG_CODEX_APP_SERVER_KEY:-}" ]; then
app_server="$(_agmsg_codex_app_server_url "$PROJECT")"
else
app_server="${AGMSG_CODEX_BRIDGE_APP_SERVER:-}"
if [ -z "$app_server" ]; then
agent_pid=$(agmsg_agent_pid "$TYPE" 2>/dev/null || true)
if [ -n "$agent_pid" ]; then
agent_cmd=$(compat_get_cmdline "$agent_pid" 2>/dev/null || true)
app_server=$(printf '%s\n' "$agent_cmd" \
| sed -n 's/.*\(unix:\/\/[^[:space:]]*\).*/\1/p' \
| head -1)
fi
fi
fi
if [ -z "$app_server" ]; then
project_hash=$(printf '%s' "$PROJECT" | agmsg_sha1)
socket_path="$RUN_DIR/codex-app-server.$project_hash.sock"
if [ -S "$socket_path" ] || [ "${AGMSG_TEST_ASSUME_CODEX_SOCKET:-}" = "$socket_path" ]; then
app_server="unix://$socket_path"
if [ -z "$app_server" ]; then
app_server="$(_agmsg_codex_app_server_url "$PROJECT")"
fi
if [ -z "$app_server" ]; then
project_hash=$(printf '%s' "$PROJECT" | agmsg_sha1)
socket_path="$RUN_DIR/codex-app-server.$project_hash.sock"
if [ -S "$socket_path" ] || [ "${AGMSG_TEST_ASSUME_CODEX_SOCKET:-}" = "$socket_path" ]; then
app_server="unix://$socket_path"
fi
fi
fi
[ -n "$app_server" ] || exit 0
Expand Down
58 changes: 50 additions & 8 deletions scripts/drivers/types/codex/codex-bridge-launcher.sh
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ source "$SCRIPT_DIR/../../../lib/close-fds.sh"
agmsg_close_inherited_fds
# shellcheck source=../../../lib/hash.sh
source "$SCRIPT_DIR/../../../lib/hash.sh"
# shellcheck source=_app-server.sh
source "$SCRIPT_DIR/_app-server.sh"
# The liveness helpers. Every lifetime and lock-owner check below goes through
# one of them, chosen by where the pid was minted: _agmsg_pid_alive_local for
# the ones this shell or codex-monitor.sh produced, _agmsg_pid_alive for the
Expand All @@ -49,7 +51,8 @@ source "$SCRIPT_DIR/../../../lib/instance-id.sh"
PROJECT_HASH="$(printf '%s' "$PROJECT" | agmsg_sha1)"
REQUEST_FILE="$RUN_DIR/codex-bridge-request.$PROJECT_HASH"
DISPATCHER_LOCK_RESOURCE="codex-dispatcher:$PROJECT_HASH"
SERVER_PID_FILE="$RUN_DIR/codex-app-server.$PROJECT_HASH.pid"
SERVER_RECORD_KEY="$(_agmsg_codex_app_server_record_key "$PROJECT")"
SERVER_PID_FILE="$RUN_DIR/codex-app-server.$SERVER_RECORD_KEY.pid"

# shellcheck source=../../../lib/node.sh
source "$SCRIPT_DIR/../../../lib/node.sh"
Expand All @@ -71,12 +74,13 @@ PROJECT_PHYS="$(agmsg_canonical_path "$PROJECT" 2>/dev/null || printf '%s' "$PRO

mkdir -p "$RUN_DIR"

# The app-server is shared by every Codex TUI in a project. Bind dispatcher and
# role-child lifetime to that shared process rather than whichever TUI happened
# to start first. Tests/older launchers without the sidecar retain parent-PID
# fallback behavior.
# Scoped launchers must bind to their exact server record; a missing/dead record
# is the end of that scope, never permission to follow a project peer. Legacy
# launchers retain the parent-PID fallback used before scoped servers existed.
LIFETIME_PID="$(cat "$SERVER_PID_FILE" 2>/dev/null || true)"
if [ -z "$LIFETIME_PID" ] || ! _agmsg_pid_alive_local "$LIFETIME_PID"; then
if [ -n "${AGMSG_CODEX_APP_SERVER_KEY:-}" ]; then
[ -n "$LIFETIME_PID" ] && _agmsg_pid_alive_local "$LIFETIME_PID" || exit 0
elif [ -z "$LIFETIME_PID" ] || ! _agmsg_pid_alive_local "$LIFETIME_PID"; then
LIFETIME_PID="$PARENT_PID"
fi

Expand Down Expand Up @@ -224,6 +228,32 @@ poll_sleep() {
return 0
}

mark_runtime_lock_standby() {
local resource="$1"
[ -n "${AGMSG_TEST_LOCK_STANDBY_BARRIER:-}" ] || return 0
printf '%s' "$resource" > "$AGMSG_TEST_LOCK_STANDBY_BARRIER.$$"
}

acquire_runtime_lock_while_alive() {
local resource="$1" lifetime_pid="$2"
while _agmsg_pid_alive_local "$lifetime_pid"; do
if acquire_runtime_lock "$resource"; then
if _agmsg_pid_alive_local "$lifetime_pid"; then
poll_reset
return 0
fi
# The scope died inside acquisition. Drop only our CAS row; never signal
# the previous owner or any process from another scope.
agmsg_runtime_lock_release "$resource" "$$" || true
HELD_LOCK_RESOURCE=""
return 1
fi
mark_runtime_lock_standby "$resource"
poll_sleep
done
return 1
}

# Any change here can change the safe subscription set. Include the request
# thread plus each role's recorded session/project, not merely registrations:
# actas/resume rewrites a role record without changing identities.sh output.
Expand Down Expand Up @@ -254,7 +284,15 @@ build_safety_state() {
# The parent only dispatches. Every role receives an independent child launcher
# and therefore an independent bridge bound to its own recorded thread.
if [ -z "$ROLE_PAIR" ]; then
acquire_runtime_lock "$DISPATCHER_LOCK_RESOURCE" || exit 0
# A second scoped server shares this project dispatcher, but it must not
# disappear just because the first scope currently owns the CAS row. Retry
# only while this launcher's exact app-server lifetime is alive; that lifetime
# is the bound, so a dead scope neither polls forever nor signals its peer.
if [ -n "${AGMSG_CODEX_APP_SERVER_KEY:-}" ]; then
acquire_runtime_lock_while_alive "$DISPATCHER_LOCK_RESOURCE" "$LIFETIME_PID" || exit 0
else
acquire_runtime_lock "$DISPATCHER_LOCK_RESOURCE" || exit 0
fi
known_pairs=""
while agmsg_runtime_lock_verify "$DISPATCHER_LOCK_RESOURCE" "$$" \
&& _agmsg_pid_alive_local "$LIFETIME_PID"; do
Expand Down Expand Up @@ -293,7 +331,11 @@ fi
# this lock every dispatcher generation left another full set of children behind.
# The lock makes those re-spawns exit on arrival instead of accumulating.
CHILD_LOCK_RESOURCE="codex-child:$PROJECT_HASH:$(printf '%s' "$ROLE_PAIR" | agmsg_sha1)"
acquire_runtime_lock "$CHILD_LOCK_RESOURCE" || exit 0
if [ -n "${AGMSG_CODEX_APP_SERVER_KEY:-}" ]; then
acquire_runtime_lock_while_alive "$CHILD_LOCK_RESOURCE" "$PARENT_PID" || exit 0
else
acquire_runtime_lock "$CHILD_LOCK_RESOURCE" || exit 0
fi

# Bounded, not open-ended. The dispatcher only spawns a child for a pair it has
# already seen registered, so an empty list here is either the brief actas write
Expand Down
Loading
Loading