Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
62ec00a
Write down who owns the relay's state and its daemon
thisisjun786 Sep 16, 2026
1e64a12
Let every participant prove which store it is actually using
thisisjun786 Sep 16, 2026
035834c
Mark the state-selection and store-identity contract as shipped
thisisjun786 Sep 16, 2026
98cc235
Give the relay an owner, and make it refuse to guess who that is
thisisjun786 Sep 16, 2026
59197a8
Carry an assignment past the lifetime of the process serving it
thisisjun786 Sep 16, 2026
d48c8e4
Treat two names for one socket as one operating scope
thisisjun786 Sep 16, 2026
1610302
Answer the ownership findings review raised on this branch
thisisjun786 Sep 16, 2026
7527384
Answer the second review round on ownership and identity
thisisjun786 Sep 16, 2026
5d0924f
Require a live pid before start calls a launch successful
thisisjun786 Sep 16, 2026
37118cd
Answer the third review round on ownership, side effects and readiness
thisisjun786 Sep 16, 2026
ca4f3f6
Answer the fourth review round on abandoned children and unreadable a…
thisisjun786 Sep 16, 2026
f522993
Answer the fifth review round on shared intent, stale worker reads an…
thisisjun786 Sep 16, 2026
c6fc0d1
Answer the sixth review round on the record the worker must not write
thisisjun786 Sep 16, 2026
0752a69
Answer the seventh review round on shared intent and an absent boot
thisisjun786 Sep 16, 2026
e389908
Answer the eighth review round on enable, a missing boot and a blocke…
thisisjun786 Sep 16, 2026
f155c88
Answer the ninth review round on an upgrade that would have lost a store
thisisjun786 Sep 16, 2026
c138b80
Answer the tenth review round on a directory that is not a store
thisisjun786 Sep 16, 2026
639bde8
Merge remote-tracking branch 'origin/dev' into codex/jun-103-shared-r…
thisisjun786 Sep 16, 2026
a3f3dc0
Close three ownership races the lock could already have settled
thisisjun786 Sep 16, 2026
9462291
Four more places where a second launch was not accounted for
thisisjun786 Sep 16, 2026
903a2ce
Two launches with no launch id are still two launches
thisisjun786 Sep 16, 2026
d9d688c
Order launch identity by stability, so a normal exit is not a replace…
thisisjun786 Sep 16, 2026
471e50e
Two refinements to identity and discovery, both from review
thisisjun786 Sep 16, 2026
5ee8b6d
Adopting neither store was not the visible failure I argued it was
thisisjun786 Sep 16, 2026
f416667
Four more ways a lifecycle command acted on something it had not iden…
thisisjun786 Sep 16, 2026
2f36f02
Three more from round six, two of them about who was holding what
thisisjun786 Sep 16, 2026
b64ceff
Round seven: a stop that could erase a live launch, and two smaller t…
thisisjun786 Sep 16, 2026
2c516bc
Two windows my last round left open
thisisjun786 Sep 16, 2026
d243e63
One rule for the stop record, after three narrower ones each left a w…
thisisjun786 Sep 16, 2026
5681e26
open() reports EACCES too, and that is not contention
thisisjun786 Sep 16, 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
10 changes: 9 additions & 1 deletion packages/codex-session-relay/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,13 +56,19 @@ activate a service on another host or establish that an earlier test process is

Runtime state lives outside any repository, in
`$XDG_STATE_HOME/codex-session-relay/<endpoint-hash>/` (mode 0700), holding `relay.sqlite3`,
`daemon.lock`, `daemon.pid` and `daemon.log`. `CODEX_SESSION_RELAY_STATE` overrides the directory.
`daemon.lock`, `daemon.pid` and `daemon.log`. Precedence, highest first: `--state`,
`CODEX_SESSION_RELAY_STATE`, `XDG_STATE_HOME`, then `~/.local/state`.

**Every process must point at the same state directory.** The child emitting, the parent
acknowledging and the daemon delivering share one store; a mismatched `--state` means they simply do
not see each other. The endpoint hash is derived from the socket path, so passing the same
`--socket` is enough.

`doctor` reports which rule won, the database it resolved to and the access this process really
has. To prove two participants share one store rather than two copies of one, write a nonce with
`store-challenge --write` and check it from the other side with `doctor --expect-nonce`; an
identifier alone is copied along with the file. See [docs/operations.md](docs/operations.md).

## Authorized execution settings

A send carries the settings the recipient task was actually created with. This is not defensive
Expand Down Expand Up @@ -336,3 +342,5 @@ These are recorded because behaviour depends on them.

- `docs/protocol-v1.md` — the wire and record protocol, derived from the frozen contract.
- `docs/invariants.md` — every invariant and the code that enforces it.
- `docs/operations.md` — where the state lives, who owns the daemon, and how to read a
stuck delivery. Each section says whether the behaviour is implemented or planned.
155 changes: 155 additions & 0 deletions packages/codex-session-relay/docs/operations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# Operating the relay

Who owns the state, who owns the daemon, and what an operator can ask it. The delivery
contract itself is in [protocol-v1.md](protocol-v1.md) and the refusals that enforce it are
in [invariants.md](invariants.md).

Each section carries a status, because this document is written alongside the work that
makes it true: **implemented** means the behaviour exists and is tested in this package,
**planned** means the contract is agreed and the code lands in a named pull request.
Nothing here describes an installed runtime, a registered service or a live App Server;
source in this package changes none of those.

## Where the state lives

Every participant of one assignment must read and write the same SQLite store. The
directory is chosen by the first rule that applies:

| Precedence | Source | Notes |
|---|---|---|
| 1 | `--state <dir>` | explicit, wins over everything |
| 2 | `CODEX_SESSION_RELAY_STATE` | also read independently by the bridge adapter for its transport ledger |
| 3 | `XDG_STATE_HOME/codex-session-relay/<scope>` | the scope is a hash of the App Server socket path |
| 4 | `~/.local/state/codex-session-relay/<scope>` | the default |

The store is `<dir>/relay.sqlite3`. `codex-session-relay doctor` reports which rule won, the
value that won, the resolved database path and the measured read/write access, so a
participant never has to infer its own configuration.

Setting `--state` alone is not enough for an isolated run. The bridge adapter resolves its
transport ledger from `CODEX_SESSION_RELAY_STATE` independently, so a run that overrides only
the flag splits the relay store from the ledger that carries send idempotency. Set both.

Status: implemented. `resolve_state_dir` returns the winning rule and `doctor` reports it with
the resolved database and the measured access.

## Proving two participants share one store

A path string is not proof: symlinks, bind mounts and per-sandbox mounts all make equal
paths unequal and unequal paths equal. A stored identifier alone is not proof either,
because copying the database copies the identifier.

| Evidence | Verdict |
|---|---|
| a nonce written by one participant is readable by the other | proven |
| equal store id and equal device/inode | proven |
| equal store id, different inode, no nonce | unproven - a copy is possible |
| different store id | mismatch |

`doctor --expect-store <id>` exits non-zero on a mismatch. An unproven result is never
reported as healthy.

Status: implemented. Unproven also exits non-zero, because a caller that asked whether this is
the same store must not read exit 0 as yes. Each participant runs the check in its own sandbox;
one invocation cannot establish another participant's access.

## The service

Two process roles. A **worker** is the existing bounded daemon: it runs a set number of ticks
or until a deadline and then exits, and cannot be constructed unbounded (I-64). A
**supervisor** owns the locks and launches successive workers, which is what carries an
assignment past any single process bound.

| Command | Effect |
|---|---|
| `service status` | intent, liveness, ownership, store, conflicts, projects, observation health |
| `service enable` / `disable` | records the owner's intent; disable also stops a running service |
| `service start` | refuses if already running, if another store owns the operating scope, or if intent is disabled |
| `service stop` | refuses unless this installation owns the process; confirms both supervisor and worker exited |
| `service restart` | stop then start, preserving the recorded intent |
| `service run` | the foreground supervisor; what `start` launches |

An update tool should read `service status`, act on `stop` or `restart`, and rely on one
guarantee: **a service that was disabled is never enabled as a side effect**. Intent lives in
`service.json` and absence means never configured, which is not enabled.

Status: implemented. `service run` is the supervisor: it holds the locks once and replaces
bounded workers, so an assignment continues on the same store and generation past any single
process lifetime. A clean segment waits the restart interval; repeated failure backs off
exponentially to a cap and is reported as `degraded` rather than retried silently.

### Ownership

Single ownership is enforced at two levels, because either alone has a hole.

- `<state>/daemon.lock` prevents two daemons per state directory.
- A per-user scope registry, keyed by the App Server socket and located independently of the
state directory, prevents two daemons **with different state directories** from serving the
same App Server. A file lock in one state directory cannot see a rival that chose another.

Both locks are held by the supervisor and inherited by its worker, so ownership persists
until every process of the service has exited. A dead supervisor with a live worker does not
release the scope.

Termination is bound to a process handle rather than a pid, and identity includes the boot
id, the installation and the store, so a reused pid is never signalled by mistake. Where a
stable handle is unavailable, ownership reports `unverifiable` and stop refuses.

Status: implemented. The supervisor acquires both locks, marks them inheritable and passes
them to each worker, which adopts them rather than taking a second lock. A worker is
authenticated by a token recorded in `daemon.json` plus a device/inode check on each
descriptor, arms `PR_SET_PDEATHSIG` in its own bootstrap and immediately re-checks its
parent, and refuses to serve if the supervisor has already gone.

## One service, several projects

A single supervisor and a single store serve every assignment on that host, user and App
Server, across projects and repositories. Each delivery is checked against its own
assignment before any transport call: the recipient must be that assignment's own parent for
a completion, or its own child for a revision. Membership in the authorized recipient list
alone is not sufficient, because two assignments may legitimately list the same recipient.
`service status` groups by project so one service carrying several projects is visible.

Cancelling, pausing or archiving one assignment removes it from the loop. It does not stop
the shared service: only the supervisor's own bound, an explicit stop, or disabled intent
does that.

Status: implemented. The direction check runs at both `enqueue` and `attempt`, before any
transport call.

## Reading a stuck delivery

`status` distinguishes the stages that all previously read as one withheld state:

| Phase | Meaning |
|---|---|
| `awaiting_receipt` | the child has not produced a completion receipt yet |
| `parent_busy` | the parent is mid-turn; it is never interrupted |
| `settings_rejected` | the host would not confirm the authorized execution settings |
| `turn_accepted` | the transport started a turn |
| `awaiting_ack` | delivered, acknowledgement outstanding |
| `channel_closed` | the push channel itself is unavailable; stored, not woken |
| `superseded` | a newer generation or revision replaced this one |

Each carries the most recent failed operation, its concrete error, the exact settings
difference where there is one, and the next retry time.

Health is separate from liveness. A running process with a growing observation backlog is
reported as stalled: staged event age, when each current anchor was last successfully
polled, and the backlog per assignment are all exposed, and a live pid is never counted as
working.

Status: planned in PR-B.

## What a restart preserves

Assignments, generations and anchors, queued and deferred deliveries, attempt history and
frozen message bytes, acknowledgements, verdicts and the Linear outbox all live in the store
and survive any process boundary. On start the relay reconciles unresolved attempts before
doing anything else, and reconciliation establishes what happened without sending: an
attempt whose response was lost stays uncertain until evidence resolves it, and is never
resent on the strength of elapsed time.

Status: implemented. The supervisor runs recovery before its first worker, and an expired
lease returns its delivery to `held_uncertain` for the reconciler to judge, never to the
send queue, because a queued row would be eligible to send again on no evidence.
Loading
Loading