Skip to content

exec: v1.4 notify, revised: callbacks never open a session, live reconcile, job ledger, sweep - #395

Merged
jaredLunde merged 2 commits into
mainfrom
jared/exec-notify-spec-rev
Oct 18, 2026
Merged

jaredLunde merged 2 commits into
mainfrom
jared/exec-notify-spec-rev

Conversation

@jaredLunde

@jaredLunde jaredLunde commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Revises the exec v1.4 "Sandbox job notify" spec (crates/agent/src/exec_endpoint.rs module doc), FLEET.md "Job-end callbacks", the tests/fixtures/exec/v1.4-*.json vectors and tests/exec_notify_vectors.rs together. This is spec only: nothing implements v1.4 yet. Requirements are renumbered N1–N64 and E1–E6, and every one names a check in v1.4-checks.json, with the task that writes it.

The two rules everything else follows

v1.4 changes how a replica learns of a job's end, and nothing else. A notified end is handed to the session exactly as a streamed one (N42): the same notice, through the same path, wake turns included, whether or not a client is attached, in every mode. The old rule against starting a turn with no client attached is removed from the spec, the fixtures and the checks. The invariant is now I2: a job end learned by notify behaves exactly as the same end learned by the held stream.

A callback never opens a session. Today, a session that is closed, reaped or moved learns of its jobs' ends only when it next opens and reads its sandbox's jobs back. So a callback only matters to the replica that has the session open right now.

  • Edge: routes the callback to the session's hashed replica, unchanged. It attaches, mints and verifies nothing (E2, E3).
  • Replica: verifies the token itself (N30) before it looks for the session.
    • If the session is open in this process, the replica runs the live reconcile.
    • If not, it answers 200 with {"listener": false} and does nothing: no session is opened and nothing is written to storage (N31). This answer is terminal for the attempt.
  • A reaped or closed session behaves exactly as today (N44). Test: job_notify::a_reaped_session_is_as_today.

What changed, problem by problem

An unauthenticated request could reach full session access.

  • Fixed: callbacks carry no grant at all. The replica handles a request that matches the callback header rule only on the callback path, ignoring any grant it carries, so the request can do nothing but job_ended (N28–N30).
  • A forged or garbled token is refused before the session is looked up. So header-parsing differences between the edge and the replica can only produce refusals, never access.
  • The edge and the replica classify with the same two rules: exactly one Authorization header, a case-insensitive scheme, one or more spaces. A request that only looks like a callback is answered 400 (N28, E1).
  • Fixtures cover a lower-case scheme, a double space, surrounding spaces, a tab, no space, and duplicate headers. There are also 13 edge-classification cases. The test recomputes all of them with a reference classifier.
  • No requirement relies on a session id being secret. Fork ids are predictable, but a forged callback learns nothing, not even whether the session exists.
  • Removed: the callback-only grant claim. With no grant, there is nothing for it to restrict.

The reconcile was destructive in a live session. bash_sandbox::read_back kills unkept jobs and replaces table entries.

  • Fixed: the open reconcile (N36) is today's read-back. It runs only at a session's first use of its sandbox, never because of a callback, and must spare jobs the session already tracks.
  • The live reconcile (N37) only runs list and read. It acts only on released jobs, and on a proving job whose callback carries its current generation. It never kills, adopts, replaces, re-follows or re-keeps.
  • Re-keeping happens only when the open reconcile adopts a job (N57). That ends the keep → proof → reconcile → keep loop.

False leftovers notices.

  • Fixed: exited is observable only once a live group member has been found after the shell exited. That is the same condition under which a stream sends shell_exit (N13).
  • The baseline writes exit only in that case. For jobs started by an older supervisor, list checks that the group is still alive (N14).

Exactly-once reporting.

  • Fixed: a job ledger (N45–N47). It is per session id, sealed, written only by the store's atomic epoch-fenced commits, and not part of the transcript, so compaction can't drop it.
  • When a job's end is reported, the ledger marks it in the same commit as the message that carries it. The sandbox record is removed only after that commit, and an open reconcile reports nothing for a job the ledger holds.
  • This closes the crash window between persisting an end and removing its sandbox record, in which today's code reports the end twice. A crash-point table explains why each crash is safe.
  • What the ledger no longer does, and why:
    • Writing a record before keep: that only existed to report lost on callback-opened or crashed sessions. Today's reopen path (open reconcile plus reopen note) already handles those, and a reaped session must behave as today.
    • Reporting lost for jobs missing from the listing: a session that isn't open must behave as today, and today's reopen note already reports such a job as gone.
    • Covering compaction of pending notices: a notice now always goes straight to a turn or a run, so nothing waits in the transcript.

job_ended was answered "busy" during a run.

  • Fixed: it is served mid-run and only ever answered with this section's statuses (N33).
  • Arrival inside the proof window is latched at the request head. A retry after a 503 counts only if it too arrives in the window (N48).

Callback replies were matched by job id.

  • Fixed: each callback is answered through a reply path that belongs to that request alone, never through the session's fanout (N34).
  • The body carries no correlation id (N18). The edge cancels the upstream request at its 25 s budget (E4).

Fleet faults stopped the sandbox for good.

  • Fixed: these are answered 503, never a stopping status:
    • a missing notify key (N29);
    • an unknown kid (N63).
  • Clock skew and grant-key rollouts no longer apply, because callbacks carry no grant.
  • Notify key sets now hold the current key plus upcoming and retired keys. A replica refuses to start if two keys share a kid (N61). Rotation follows a fixed order, and retired keys are kept 7 days (N64).
  • 407, 408 and 425 are retried.
  • A pre-v1.4 replica answers a callback 401, as it answers any request without a grant. That loses nothing: such a replica holds its own streams, and a v1.4 owner's sweep covers a callback that went astray. So no rollout order is needed (FLEET.md).

Callback-opened sessions and the reaper.

  • Mostly moot now: callbacks never open sessions.
  • What remains (N35): a callback never counts as attached and never extends the idle clock, and a session is not reaped while a callback's reconcile runs in it.

The proof might not take the path the end takes.

  • Fixed: one sender for the proof and the end (N8). For the baseline, that sender is the job's setsid supervisor, signalled by keep.
  • keep refuses notify for a job whose supervisor carries no notifier (N9).

keep could wait on its own callback.

  • Fixed: keep never waits for an attempt (N10). Attempts are made outside the process group that v1.1 kills.

An end could be lost while a client watched.

  • Fixed: a 5-minute sweep while a session is open and holds a released job (N54).
  • A slow tail of retries from the sandbox was rejected. It would keep an abandoned sandbox sending forever, and it can't fix a path that stays broken. One list every 5 minutes is sixty times less traffic than a 5 s heartbeat ("What release costs").
  • The sweep also covers a callback that reached a replica which had stopped being the session's hash target.

A replacing keep left old notifications running.

  • Fixed: each keep carries a fresh generation in the URL (&keep=<16 hex>), and only a callback carrying the latest generation counts as the proof (N52).
  • An accepted keep ends earlier notifications and abandons any attempt still in flight (N6, N22). Tokens stay deterministic (N56).

Takeover (stated explicitly in "Crash, takeover and rotation"):

  • A session that moves while open re-keeps on its new owner at that owner's open reconcile.
  • A callback that lands on the old owner gets "no listener".
  • A proof carrying the old generation counts for nothing on the new owner.
  • A session that the edge walked to a substitute never receives its proof, so it stays held, as today. The edge never walks the ring for a callback (E3).

Recreated sessions and new_session.

  • Removed: the scope-owner wipe. Callbacks never open or adopt anything, and the live reconcile never adopts, so v1.4 adds no way for a dead conversation's job to reach a new one. The reopen case is today's, unchanged.

Smaller fixes:

  • The edge no longer mints grants, so forged callbacks cost it nothing. The 404/401 existence oracle is gone too: a forged token is refused before the session is looked up (E3 removed).
  • The edge no longer pre-checks the token's grammar: it verifies nothing.
  • Fixed refusal bodies (N32).
  • I5 is restated to match what N53 allows. Idle connections to the endpoint close after 30 s, with no TCP keepalives (N55).
  • The spec now says the baseline cannot hide the token from sandbox code, and what that code can gain.
  • 410 means the open session neither tracks the job nor has recorded its end.
  • The proof latch is defined (N48).

Verified

  • cargo test -p beyond-ai-agent --test exec_notify_vectors: 10/10 pass. The new test fails against the old fixtures.
  • A reference receiver, written from N30's steps and N31/N38/N40, recomputes every callback case: status, data.listener, and whether the sandbox is contacted. No case can open a session. A reference edge classifier checks the edge cases.
  • Token vectors gain an upcoming-key case, and all keys are re-derived.
  • cargo fmt --check, clippy (--all-targets --features code-mode -D warnings), dprint check and check-module-tables are clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JimHGjsfk2Ktm5GxyZJKKk

…r, sweep

v1.4 now changes how a replica learns of a job's end and nothing else: a
notified end is handed to the session exactly as a streamed one, wake turns
included, in every mode. The rule that kept a session from starting a turn
with no client attached is gone.

- The edge attaches a callback grant carrying "purpose": "job_ended". A
  replica refuses anything but a job_ended POST under it, decided from the
  verified grant, and a replica that predates the claim fails it closed. One
  header grammar, shared by edge and replica; look-alikes are refused.
- Two reconciles: the open reconcile (today's read-back) and a live
  reconcile that only lists and reads, acts only on released jobs and a
  current-generation proof, and never kills, replaces, adopts or re-keeps.
  Re-keeping happens only on adoption at open, so no keep/proof loop.
- `exited` is observable only after a live group member was found past the
  shell's exit; the baseline writes `exit` only then.
- A durable per-session job ledger, committed atomically with the transcript
  and outside it: records precede keep, ends are marked reported in the
  commit that carries them, reopen reports missing jobs lost, and a scope
  owner record wipes a dead conversation's jobs before a new one uses it.
- job_ended is served mid-run, answered from its own reply path, never
  counts as a client, and never extends the idle clock.
- Fleet faults (no key, failing callback grant, header disagreement,
  unknown kid) answer 503, never a stopping status. Upcoming and retired
  notify keys, kid collisions refused, a rotation order.
- The proof is sent by the job's supervisor, the same sender as the end;
  keep never waits for it; a keep's generation rides in the url so a stale
  proof counts for nothing, and a new keep ends older notifications.
- A 5-minute sweep while live bounds a lost end; idle connections close.

Fixtures and the coverage test follow: a reference receiver and edge
classifier recompute every callback case from the spec's own steps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JimHGjsfk2Ktm5GxyZJKKk
@jaredLunde
jaredLunde enabled auto-merge October 18, 2026 21:11
@jaredLunde
jaredLunde added this pull request to the merge queue Oct 18, 2026
@jaredLunde
jaredLunde removed this pull request from the merge queue due to a manual request Oct 18, 2026
A session that is closed, reaped or moved learns of its jobs' ends when it
next opens, as today, so a callback matters only to the replica holding the
session open now. The edge routes a callback to the hashed replica and
attaches, mints and verifies nothing; the url names the tenant, and the
replica verifies the token itself before it looks for the session. A replica
holding the session runs the live reconcile; any other answers 200 with
{"listener": false} and does nothing: no open, no storage write.

Gone with it: the callback grant and its purpose claim, the edge's tenant
lookup and grammar pre-check, its rollout gate, callback-opened sessions, the
ledger's records-before-keep, lost reports and scope owner. The ledger keeps
only what stops a crash reporting an end twice. The edge never walks the
ring for a callback. Takeover is stated: the new owner re-keeps at its open
reconcile, a callback at the old owner gets no listener, and the sweep
covers a session that stops being its hash target.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JimHGjsfk2Ktm5GxyZJKKk
@jaredLunde jaredLunde changed the title exec: v1.4 notify, revised: callback grant, live reconcile, job ledger, sweep exec: v1.4 notify, revised: callbacks never open a session, live reconcile, job ledger, sweep Oct 18, 2026
@jaredLunde
jaredLunde added this pull request to the merge queue Oct 18, 2026
Merged via the queue into main with commit 5273d95 Oct 18, 2026
12 checks passed
@jaredLunde
jaredLunde deleted the jared/exec-notify-spec-rev branch October 18, 2026 21:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant