Skip to content

refactor(orchestration): resolve every caller to one orchestration actor - #22555

Open
brennanb2025 wants to merge 11 commits into
brennanb2025/d1-actor-columnsfrom
brennanb2025/d2-caller-resolver
Open

brennanb2025 wants to merge 11 commits into
brennanb2025/d1-actor-columnsfrom
brennanb2025/d2-caller-resolver

Conversation

@brennanb2025

@brennanb2025 brennanb2025 commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor
Files Added Deleted Net
Test 9 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1408 $\color{#cf222e}{\Huge{\mathbf{−}}}$​6 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1402
Prod 42 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​1136 $\color{#cf222e}{\Huge{\mathbf{−}}}$​189 $\color{#1a7f37}{\Huge{\mathbf{+}}}$​947

ELI5

Orchestration only knows how to recognize a terminal. A native chat has no terminal, so it cannot create or use a Run. This PR teaches the Orca host to recognize a chat by the session id Orca gave it. The host checks that id once, where every request comes in, turns it into the chat's orchestration identity (session:<id>), and every orchestration command then works with that identity. Nothing sends the id yet: #22568, the next PR in this stack, makes the CLI send it. Terminal agents behave exactly as before.

Merge order

Second PR of the structured-chat orchestration stack: #22522 (actor columns) → this PR → #22568 (the CLI sends the id) → result delivery to a session coordinator (not open yet) → telling agents their own address (not open yet). #22522 must merge before this PR.

What Changed

Second PR of the structured-chat orchestration stack, stacked on #22522. Host side only: no CLI or UI change.

Wire contract (what #22568 sends)

  • The session id rides the existing orchestration envelope evidence as orchestrationCompatibilityEvidence.agentSessionId: string, the Orca-minted id from the caller's injected environment. Every orchestration call already carries this envelope on both transports.
  • It is an optional field, so it is safe with mixed versions. An older host ignores it.
  • The caller's params do not need to name the caller. On the 15 methods that consult caller identity (listed below), a from, terminal or callerTerminalHandle that is present must name this same session: session:<id>, the bare <id>, or a structured worker's own structworker_ handle. Any other value is refused.
  • On the 6 methods where one of those fields names a target rather than the caller (inbox --terminal, workerTerminalUserInput, federationAttachStart, runShow, dispatchShow, the retired run), the field is not fenced. A coordinator must be able to reach its worker's terminal.
  • New error codes, in src/shared/orchestration-session-caller-codes.ts, passed through the RPC error map:
Code When Message (abridged)
session_caller_host_boundary the request came from a paired client (the WebSocket route), carries an SSH or WSL host stamp, or the session's record says it runs on another host "…an agent session id identifies a caller only on the host that runs that session. Run the command on that host. No effects were applied."
session_caller_unknown no Orca session with that id on this host, or the value is not an Orca session id (a term_ handle included) "No Orca agent session <id> exists on this host."
session_caller_provider_id the id is a provider's own session id, which changes on /clear "<id> is the provider's own session id… This session's Orca id is <orca id>; use that instead." data.orcaSessionId carries the Orca id.
session_caller_not_live the lease is released, mid-handoff, unreconciled, or cannot be verified; or the session is a structured worker whose worker identity this host no longer has "Agent session <id> is not running right now… / is switching between chat and terminal view… / has no live owner… / is a structured worker whose worker identity this host no longer has…"
consumer_fenced (existing) a declared caller or check --terminal-pane-key names someone else "This caller is agent session <id> and cannot act as <declared>."

All of them carry { effectsApplied: false }.

session_caller_not_live is one code for ended, mid-handoff, ownerless and lost-worker-identity; the message says which, and whether to retry. No client branches on the distinction today; if one ever must, add a data field, not a new code.

One resolver, at the entry of both dispatchers (src/main/runtime/rpc/orchestration-session-caller.ts)

  • RpcDispatcher.dispatch (the Unix socket and Electron IPC) and RpcStreamingDispatcher.dispatch (the paired WebSocket, and IPC subscriptions) both call it:
    • after the method lookup and the migration fence;
    • before params parse, the unary/streaming split, legacy compatibility, and receipt lookup/join/replay.
  • It runs only when the request claims a session. claimsOrchestrationSession is a synchronous check, so terminal callers reach their method exactly as before, with no extra async hop.
  • In order, it:
    1. refuses the paired route and SSH/WSL-stamped requests;
    2. validates the id;
    3. reads the session record, bringing up the agent-session host if needed;
    4. names the Orca id when given a provider id;
    5. requires a live lease (agentSessionLeaseAdmitsWriter) under either owner, native or tui, on this host outside WSL;
    6. maps a structured worker's session to the handle and pane it was minted.
  • The result is ctx.orchestrationCaller, an OrchestrationSessionCaller = { actor: 'session:<id>', sessionId, address, terminalHandle, paneKey, workspaceId }.
  • The request is then normalized:
    • its declared caller param becomes the session's address;
    • its evidence is reduced to { agentSessionId }, so terminal evidence a terminal view inherits never attests anyone.

The caller-param map is the verb contract. ORCHESTRATION_CALLER_PARAM lists the 15 RPC methods that consult caller identity, and the param each uses:

  • from: runCreate, runUse, runCurrent, send, reply, ask, dispatch, gateCreate, gateResolve, gateList, workerStart
  • terminal: check
  • callerTerminalHandle: taskCreate, taskList, taskUpdate

Population, pinned by a test:

  • 41 registered orchestration methods; 21 carry a party-naming field.
  • The other 6 name a party but never the caller: run (retired), runShow, dispatchShow, inbox, federationAttachStart, workerTerminalUserInput.
  • The remaining 20 carry no caller identity for any actor, including reset, workerRelease, workerRetain, workerRead, workerShow, workerList, workerStop and workerAbandon.
  • A session claim on any of them is still validated at the entry, then the method runs exactly as it does for a terminal. Nothing is gated by the actor's kind.

Methods never inspect the actor's kind. They pass the caller identity (OrchestrationCallerIdentity: address, terminal handle, pane key, actor) to the lookups. A plain terminal caller's identity has no actor, so every lookup takes the same pane path, and the same statement, as before. A structured worker calling by its structworker_ handle without a session claim now carries its actor too. Its lookups run the combined pane-or-actor statement, which returns the same rows, and it keeps its Run if its pane lookup fails.

  • resolveOrchestrationCaller and resolveRunScope return and use the identity. They take the resolved session from the context.
  • Every place a method found the caller's Run through its pane now asks getCurrentRunForCoordinator(identity): run-create/use/current, check, send, group send, and worker-start.
  • worker-start with --worktree current places the worker in the session record's workspace.

Binding a Run by actor (db/runs/)

  • runsBoundToCoordinator / getCurrentRunForCoordinator / unbindOtherRunsForCoordinator generalize the pane-keyed lookups. A caller without an actor runs the original runsBoundToPane query unchanged.
  • Stale actors (refactor(orchestration): give structured sessions an orchestration actor column #22522's obligation 4): a Run's actor counts only at the consumer_generation it was written at (currentRunCoordinatorActor, from refactor(orchestration): give structured sessions an orchestration actor column #22522). createRun, bindRun and the same-coordinator correction write coordinator_actor_generation in the statement that writes the actor.
    • Every binary, including one without the actor column, bumps consumer_generation when it rebinds or unbinds a Run. So an actor such a write leaves behind stops counting, beside another handle or beside none, with nothing needing to clear it.
    • The resolver still refuses a worker session whose handle and pane it can no longer map, instead of binding it like a chat. That is a different fact: it stops this binary from writing a second identity for one worker. The refusal is reachable when the durable custody row is missing or fails rehydration; it ends with the session or an orchestration reset.
  • Binding as session X unbinds only X's other Runs, and bumps consumer_generation as the pane path does. Session X's run-create never unbinds session Y's Run.
  • A different actor using a bound Run follows the same rule a terminal does:
    • the generation bump and delivery fence make the previous coordinator's next check --run and any waiting check fail as consumer_fenced;
    • its pending coordinator mail is rerouted into the Run mailbox.
  • A same-coordinator rebind whose row lacks, or carries a wrong, actor corrects it without a new consumer generation.

#22522's four obligations

  1. Actors on every bind, create and assign, structured workers included.

    • createRun and bindRun write coordinator_actor.
    • Every dispatch-row writer records assignee_actor derived from a structured:<id> process incarnation: the claim insert, worker-start authority attach, and the failed-start identity copy. Anything else gets NULL, so a reassignment never leaves the previous actor. This subsumes refactor(orchestration): give structured sessions an orchestration actor column #22522's defensive clear.
    • The claim insert and the starting-row insert record creator_actor. A handle-less session creates as a new DispatchCreator kind actor; a structured worker creates as a terminal with its actor.
    • isSelfCreatedDispatch compares handle and pane only, by design: an assignee actor is always recorded beside a worker handle, and an actor-kind creator has neither, so actors alone can never make creator == assignee. The delivery PR (not open yet) extends it before any dispatch is assigned to a handle-less session.
  2. Actor-aware mail matchers. All three readers that match "this address is an active assignee in this Run" now also match assignee_actor, through one SQL fragment activeDispatchOwnsAddressSql:

    • the routing trigger's self-dispatch exclusion;
    • routeAllUnreadDirectMessagesToRunMailbox and the paged direct-mail router;
    • routeForeignDirectMessagesToOwnedMailboxes, which gains an actor branch, with its owner lookup.

    For a terminal handle the SQL is unchanged. The trigger is rebuilt on every open, so no migration is needed. Unbind and rebind reroute unread mail for both the handle and the actor.

  3. A structured worker that coordinates is reachable at its session: address too. createRun and bindRun remember both its handle and its actor in the coordinator-address cache. addressSpellingsOf is the one owner of a party's addresses; createRun, bindRun, the coordinator unbind and the declared-caller check all take it. Everyone is told session:<id> is their public address, so mail sent there must reach the Run.

  4. Stale actors from an older binary: the generation rule above.

Receipts. replayStableCallerParams folds the resolved actor into the payload hash, so a retry from the same session replays and the same request id from another session is request_mismatch. This holds even for methods that carry no caller param. Terminal callers' hashes are unchanged.

A structured worker carries both a handle and (after #22568) a session id. The session id wins for every verb, and the host maps it to the worker's minted handle and pane. The worker's address, dispatch mailbox and Run bindings are therefore exactly what they are today through the handle.

Why

This supersedes #19949, which is closed in its favor, and absorbs the purpose of #16859 (coordinator takeover).

Ported from #19949:

  • one resolved caller object carried whole, whose kind methods never inspect;
  • a lease-currency check (now the shared agentSessionLeaseAdmitsWriter: live, reconciled, no handoff in progress, a proven owner process);
  • the Run-lookup generalization and its session-caller tests as templates.

Dropped from #19949:

  • the rule that a declared session id is not a credential (the program decided no credential);
  • the runtime fence travelling with the id: fence 0 means no owner has ever existed, and the session id, not the fence, is the lineage;
  • the pane arm of the principal and pane-keyed principal matching;
  • per-method agentSessionId/runtimeFence params;
  • the ownership gate only structured callers faced;
  • the native-only lease check (structuredWorkerRecordIsCurrent), which would have refused a chat in terminal view.

#16859's purpose: main already solved coordinator takeover with caller-evidence attestation, and its authority columns never landed. This PR makes that mechanism hold for session actors:

  • actor-keyed unbind;
  • a takeover that fences and reroutes like the pane path;
  • a session wins over inherited terminal evidence.

Design decisions a reviewer should check:

  • Resolution runs before params parse, not after. A session caller need not name itself, and runCreate/runUse/runCurrent/workerStart require from in their schemas. Binding the declared caller before the schema runs keeps every schema unchanged.
  • Lease rule: live under either owner. A handoff in progress refuses as retryable instead of letting a half-switched owner act. Deadline expiry is not checked separately: the renewer's claimStatus is the verdict.
  • The paired route is refused for every orchestration method, not only the 15 identity methods. A session claim from another host is never meaningful.
  • Caller identity is resolved once at the entry, not per operation as a per-request sender id would be. The placement requirement is to resolve before legacy compatibility and receipts on both routes. Checking a live lease rather than mere existence is stricter because Orca has durable per-address mailboxes and native/terminal handoff.

Architecture review

An architecture review asked for three changes. #22522 also changed, and this PR now builds on that change. All four are at head 6746bb52a4, rebased onto #22522's b183a5c294.

One mechanism for stale actors. This PR used to guard stale actors three ways: the handle-wins rule when reading, an open-time repair for a structured worker's unbind leftover, and the resolver refusal. #22522 now records the generation each coordinator actor was written at, and an actor counts only at that generation. This PR reads the actor only through that rule, and both the handle-wins rule and the repair are deleted. The generation also covers the one case neither old rule caught: an older binary rebinds a chat's Run to a terminal and then unbinds it, which leaves a row that looks exactly like a live chat binding (tested). The resolver refusal stays, because it covers something else: it stops this binary from giving one worker a second identity. Its check moved from the backfill module to structured-worker-authority.ts, next to the worker identity lookup.

One owner for a worker's addresses. A structured worker is reachable at two addresses: its structworker_ handle and session:<id>. Each place that needed both used to list them by hand. addressSpellingsOf is now the only owner, and runCoordinatorKey gives a Run's current coordinator, leaving out an actor that no longer counts. createRun, bindRun, the coordinator unbind and the declared-caller check use them. Each of those has a test at both of a worker's addresses, and each test turns red when that one use goes back to handles only. Rule for the rest of the stack: any new code that compares an agent address needs a test at both of a worker's addresses. Difference from the usual pattern, in Orca terms: one agent still has two stored addresses rather than one. That is kept on purpose so PTY workers keep working; the shared owner is what keeps it safe.

Released is not ended. A released lease is refused with "is not running right now. A new message or user turn revives it; retry then." (it used to say "has ended"). The text matches #22631, which uses the same words. The "new message" wake-up arrives with #22631; in this PR alone, a user turn resumes the session.

The pane-key credential comment. mintStructuredWorkerPaneKey's comment said the random pane key is what stops anyone who learns a session id from acting as the worker. That is still true for a request that names no session: a PTY agent's, or any request from a paired client, which refuses session ids. On the same-host socket route, the session id already names the worker with no token, and that is intended. The comment now says so and adds that the pane key must not be made into a credential there.

Ablations at this head, each red: reading the raw actor (4 tests, all older-binary cases); no generation from createRun (14); none from bindRun (2); none from the same-coordinator correction (1); handles only in bindRun (2), the unbind (2), createRun (2) and the declared-caller check (1); the old "has ended" text (1); the resolver refusal removed (1). The tests: 81 in the three files above, and 2,234 across the orchestration, RPC, SSH, CLI and cross-version suites (259 files; 10 skipped). tc:node, tc:cli, tc:web, the full oxlint, the changed-code gate and audit:anti-slop all pass. The ablation and suite tables above are from the earlier head.

Linked Issue

None — part of the structured chat status/orchestration program. Supersedes #19949; absorbs #16859's purpose. Stacked on #22522.

Visual Proof

N/A — host-side orchestration only. Nothing sends a session id until #22568, and no UI changed.

Testing

  • I manually tested these changes locally (no client sends the new field yet)
  • Automated tests added/updated

New tests (3 test files and a shared fixture, 75 tests, plus 2 in the actor-repair test):

  • orchestration-session-caller.test.ts (47 tests) covers:

    • The population. The pinned 15/6/20 classification of all 41 methods.
    • Both routes, for each of the 15 identity methods. On the paired route the session claim is refused with session_caller_host_boundary and no Run is written. On the local route it resolves as the session: no session refusal, and inherited terminal evidence never reaches attestation.
    • The real transport entry points. OrcaRuntimeRpcServer.handleWebSocketMessage with a paired device refuses the claim; handleMessage over the Unix-socket path admits it.
    • Every refusal, before any effect. Unknown id, a term_ handle, provider id (the message names the Orca id), released, mid-handoff, unreconciled, a record on another host, SSH and WSL stamps, an unverifiable host, and a structured worker whose identity is gone (no Run written). Each is asserted through a mailbox-consuming check and a destructive reset, with the pending mail still unread afterwards.
    • Declared callers. A declared --from naming someone else and a check --terminal-pane-key are refused. session:<id> and the bare id are accepted.
    • Terminal callers. A terminal caller makes no claim and keeps a NULL actor.
  • orchestration-session-coordinator.test.ts (15 tests) covers:

    • the whole loop as a chat: run-create/current, task create/list/update, dispatch (creator actor recorded), worker mail to session:<id> landing in the Run mailbox, a consuming check, send, reply, gates, ask;
    • worker-start placing in the session's workspace;
    • plain and group sends filed under the session's Run;
    • two chats never unbinding each other;
    • takeover fencing the previous coordinator's check --run and its in-flight waiting check;
    • native → terminal view → native keeping the Run;
    • an older binary's rebind leaving the chat's actor not counting;
    • a structured worker declared as its handle, its session: address or its bare id, all binding by its handle;
    • replay by the same session and request_mismatch from another, including reset, which names no caller;
    • a Run-less session reading its direct mailbox;
    • a structured worker whose session id maps to its handle's Dispatch mailbox and whose Runs are reachable at both addresses.
  • run-coordinator-actor-binding.test.ts (13 tests) covers the DB layer:

    • binding and unbinding by actor, with mail rerouting;
    • older-binary rebind, rebind-then-unbind and worker unbind leaving an actor that no longer counts;
    • both of each worker's addresses rerouted and remembered on a takeover between two workers, and rerouted when a worker's next Run unbinds the last;
    • both addresses remembered for a coordinating worker;
    • takeover between two sessions;
    • a same-coordinator rebind that fills the actor without a new generation;
    • the three actor-aware mail matchers;
    • actor writes by the claim, the starting row and worker authority.
  • The existing tests were updated for decided changes:

    • refactor(orchestration): give structured sessions an orchestration actor column #22522's v41 fixture now also restores main's handle-only routing trigger (a column drop fails while the new trigger references it);
    • its "rows without actors are filled" tests now simulate the rolled-back writer explicitly, since current writers record actors;
    • its "never reachable at the session address" line becomes the obligation-3 decision;
    • the SSH shim's pane assertion moves to the identity lookup;
    • two fakes gain the new lookup.
  • The two open-time repair tests were removed with the repair (Architecture review).

Ablations: 37 mechanisms, each red. Each deletes or neutralizes one mechanism in source that is identical to the final head (only tests were added after the runs). Each runs the three new files, 71–74 tests, then restores from HEAD.

Ablated Red tests
Unary dispatcher resolves no session 27 (every local-route resolution and refusal)
Streaming dispatcher resolves no session 16 (all paired-route refusals)
No paired-client refusal 16
No SSH/WSL stamp refusal 1
No lease/host check on the record 4 (released, handoff, unreconciled, other host)
No provider-id lookup 1
Declared caller not checked 1
check pane key not checked 1
Declared caller not replaced by the address 12
Terminal evidence kept beside the session 8
Session id not mapped to its structured worker 2
No actor in receipt identity 1 (reset joined across sessions)
Actor never matches a Run 10
No stale-handle clause 3 — mechanism removed at 6746bb52a4
createRun / bindRun record no actor 10 / 2
Unbind reroutes no actor mail 1
bindRun remembers/reroutes no actor address 1
createRun remembers no actor beside a handle 2
Mail ownership exclusion ignores actors 3
Foreign sweep has no actor branch / owner lookup 1 / 1
Claim insert / worker authority record no assignee actor 5 / 1
Actor creator records no actor 3
A handle-less session creates as the system 1
check keeps the pane-only guard 1
send / group send find the sender's Run by pane only 1 / 1
worker-start ignores the session's workspace 1
taskCreate records the address as a terminal handle 1
Run scope / run-create-use-current ignore the session 4 / 13
An extra async hop before the claim check 3 existing timing tests (terminal subscribe, session-tabs inventory)
No unbind-residue repair 1 — mechanism removed at 6746bb52a4
Repair clears every handle-less actor (chat included) 1 (the chat binding is lost) — mechanism removed at 6746bb52a4
Resolver admits a worker whose identity is gone 1

The first two ablations of the foreign sweep and of send's sender Run were green. Tests isolating each were added, and both then went red.

Suites at this head (env -u ORCA_STRUCTURED_SESSION npx vitest run --config config/vitest.config.ts …):

  • Covered: src/main/runtime/orchestration, src/main/runtime/rpc, the 66 other test files that import the orchestration DB or the dispatcher, the CLI runtime and orchestration identity tests, and orca-runtime.test.ts.
  • 481 files / 5,554 tests passed, 12 skipped.
  • 1 failure: tests/e2e/cross-version-wire/orchestration-delivery-downgrade.unit.test.ts times out materializing a release checkout. It fails identically at refactor(orchestration): give structured sessions an orchestration actor column #22522's head in this environment.
  • pnpm tc:node, tc:cli and tc:web are clean.
  • oxlint and oxfmt --check on the 51 changed files are clean.
  • check-changed-code-quality: 0 findings. The new DB row reads carry the SAFETY rationale the other row casts in db/ use.
  • audit:anti-slop is clean.
  • pnpm-lock.yaml is absent from the range; no documentation files are added.

AI Disclosure

Review

  • The generation rule assumes nothing bumps consumer_generation on a Run except a rebind or unbind (true of every writer today). If something else did, a valid actor would stop counting; a stale one would never count.
  • Resolving before params parse.
  • Refusing mid-handoff instead of admitting either owner.

Agent skill upstream boundary

  • Not applicable, or this change follows docs/reference/agent-skill-sharing-upstream-boundary.md and copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.

Notes

  • SSH: refused.
    • A request carrying an SSH host stamp is refused.
    • So is a session record whose execution host is not local. Structured sessions only run locally, outside WSL.
    • The SSH shim forwards no session id today (REMOTE_CONTEXT_ENV_VARS).
    • The host-CLI passthrough spreads the host's own environment. feat(orchestration): let a structured chat run orchestration as itself #22568 adds the id to the environment, and must also drop it from that path.
  • Paired clients (the WebSocket route): every orchestration session claim is refused with a message naming the host boundary. A mixed-version paired host that predates this ignores the field, so an identity method there fails its schema ("Missing coordinator terminal") instead of acting.
  • WSL: refused by the host stamp and by the record's location.
  • Folder workspaces: the session's workspace comes from its record, whatever its kind; worker-start placement uses it.
  • Mobile: no mobile code changed. Mobile never sends a session claim, and the mobile allowlist already gates methods. Mobile tests were not run.
  • Wire: agentSessionId is a new optional envelope field. The four new codes reach only a caller that sends a claim. Dispatch rows now carry non-NULL actor values; refactor(orchestration): give structured sessions an orchestration actor column #22522 already published the keys. Run receipts still strip coordinator_actor, so readers must take it from the DB.
  • Performance:

Out of scope, for the later PRs in this stack:

  • The CLI does not send the id yet. Injection, the CLI precedence rule, and both client-side refusal sites belong to feat(orchestration): let a structured chat run orchestration as itself #22568.
  • Addressing a non-coordinating session. Mail to session:<id> reaches only a session that coordinates a Run; a send to any other session address is refused at recipient routing.
  • Push delivery to a handle-less coordinator (reading coordinator_actor in the pointer lane) belongs to the delivery PR (not open yet). Until then a chat coordinator polls with check.
  • Preamble wording. The worker preamble still says "Your coordinator's terminal handle is: session:<id>". The address works; the wording is for the PR that tells agents their own address (not open yet).

Not verified:

  • No Electron or UI run. Nothing sends the field yet, and no UI changed.
  • No end-to-end run from a real structured chat.
  • The cross-version harness suites could not materialize release checkouts here: tar/git extraction timeouts, which also fail at refactor(orchestration): give structured sessions an orchestration actor column #22522's head. The terminal-journey stub was therefore not exercised locally. The resolver calls no runtime member unless a request claims a session, and those journeys never do. CI will run them.
  • Windows, Linux and mobile tests were not run.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No critical issues — minor suggestions inline.

Reviewed changes

The host-side half of the structured-chat orchestration stack (stacked on #22522), reviewed at bbf398d6 against 9d8e5734. Nothing sends the new field yet.

  • One resolver at both dispatch entries. orchestration-session-caller.ts resolves orchestrationCompatibilityEvidence.agentSessionId once, after the migration fence and before params parse / the unary-streaming split, in RpcDispatcher.dispatch and RpcStreamingDispatcher.dispatch. It refuses the paired route and SSH/WSL stamps, validates the Orca id (naming it when handed a provider id), requires a live lease, maps a structured worker's session to its minted handle and pane, then rebinds the declared caller and reduces the evidence to { agentSessionId }.
  • Every caller becomes one OrchestrationCallerIdentity (address/terminalHandle/paneKey/actor) rather than a bare pane key. Run lookup moves from getCurrentRunForPane to getCurrentRunForCoordinator in run-create/use/current, check, send, group send, worker-start and run-scope.
  • Run binding and mail routing become actor-aware. coordinator_actor/assignee_actor/creator_actor are written on bind/create/assign/claim/authority attach; the stale-actor "handle wins" rule governs matching; activeDispatchOwnsAddressSql widens the three ownership matchers.
  • Receipts fold the resolved actor into the payload hash, leaving terminal hashes byte-identical.
  • Tests: three new suites (74 tests), a shared fixture, targeted updates to the #22522 suites, and 34 documented ablations.

I traced the actor binding, the stale-actor rule, the two new mail-routing branches and the receipt hash myself, and confirmed the two partial indexes behind INDEXED BY idx_dispatch_assignee_actor / idx_messages_undelivered_direct_run are eligible and hit (reproduced against SQLite). I could not construct a sequence that misroutes mail or takes over a Run without a fence.

ℹ️ The session id is a broader credential than the handle and pane key it replaces

The resolver's only checks on agentSessionId are format, local non-WSL host, a live lease, and existence in the local store — no token, signature, fence or per-call nonce. For a structured worker it then injects that worker's random handle and paneKey. Those two were made random on purpose because the session id is not secret, so resolving the id into them is strictly more permissive than the terminal path this PR preserves. This looks like the intended same-user threat model, but it is worth an explicit confirmation before the next PR makes the field reachable.

Technical details
# Session id as a bearer credential

## Where the id is accepted
- `src/main/runtime/rpc/orchestration-session-caller.ts:82-119` — format (`sessionOrchestrationActor`), local host, live lease (`agentSessionLeaseAdmitsWriter`), store hit. For a structured worker the resolved caller then carries the worker's random `handle`/`paneKey` (`:110-118`).

## Where a same-host non-owner can read the id
- `orchestration.dispatchShow` returns the raw dispatch row (`src/main/runtime/rpc/methods/orchestration/runs/dispatch-methods.ts:195,218` — `db.getDispatchContext`, a `SELECT *`), now including `assignee_actor = session:<id>`; `orchestration.workerShow` / `worker-list` publish `processIncarnation = structured:<id>` (`worker-observation.ts:196`, `worker-list-projection.ts:39`), and `sessionIdFromStructuredWorkerIncarnation` strips the prefix. Neither handler consults caller identity.
- Tab ids embed the id verbatim (`structuredAgentSessionTabId`, `src/shared/structured-agent-session-projection.ts:220`); the tree already notes this exposure (`src/main/runtime/structured-worker-identity.ts:13,61-69`).
- `<userData>/orchestration.db` and `agent-sessions.json` are `0600`, which does not separate same-UID processes.

## Required outcome
- A conscious answer, not a code change: either "any same-host process may act as any live session" (a design-doc note suffices), or the resolver should carry the same per-call attestation terminal callers still obtain.

## Open questions for the human
- Is the same-user, session-id-as-identity model the intended end state, or an artifact of porting from #19949 with the credential rule dropped?

ℹ️ Nitpicks

  • bindDeclaredCaller builds names including caller.terminalHandle, which is null for a handle-less chat, so { from: null } passes the fence and is then overwritten by the address (orchestration-session-caller.ts:216-224). Harmless, but filtering nulls would make the fence literally mean "the declared caller must name this session".

Pullfrog  | Fix all ➔ | Fix 👍s ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

export function claimsOrchestrationSession(request: RpcRequest): boolean {
return (
request.method.startsWith('orchestration.') &&
request.orchestrationCompatibilityEvidence?.agentSessionId !== undefined

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agentSessionId is client-controlled wire data and is not schema-validated (only params go through parseRpcRequestParams), so !== undefined makes a null/''/0/false field count as a session claim: every orchestration.* method is then refused with session_caller_unknown before the handler runs. A client that serializes an unset optional as null (env.ORCA_AGENT_SESSION_ID ?? null) would break all orchestration verbs once the next PR wires injection. Gate on a non-empty string, matching the resolver's own typeof claimed === 'string' check.

Suggested change
request.orchestrationCompatibilityEvidence?.agentSessionId !== undefined
typeof request.orchestrationCompatibilityEvidence?.agentSessionId === 'string' &&
request.orchestrationCompatibilityEvidence.agentSessionId.length > 0

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

Re-reviewed the delta since the prior pullfrog review at bbf398d6 — one commit, 22f54f28, on the second PR of the structured-chat orchestration stack.

  • Every-open repair of the unbind residue. backfillStructuredWorkerActors now clears coordinator_actor on handle-less, pane-less Runs whose actor this host recorded as a structured worker, leaving a chat coordinator's binding intact.
  • Refuse a worker session whose identity is gone. The resolver throws session_caller_not_live when a session has a recorded worker Dispatch but no resolvable worker identity, so it can never bind handle-less and split one worker into two identities.
  • New coverage. The residue is shown matching as the worker session's binding before repair and cleared after (never the chat's); a runCreate for the abandoned worker session is refused with no Run written.

Both changes are scoped to the only current-binary shape that produces this residue (unbindOtherRunsForCoordinator clears all three columns; adoptLegacyRunIfNeeded clears handle/pane without an actor), and the new tests fail without their fixes. structured-worker-actor-backfill.test.ts passes 5/5 locally.

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found. All substantive feedback from the prior reviews is addressed; the one open inline suggestion is unchanged and left for the author.

Reviewed changes

Re-reviewed the delta since the prior pullfrog review at 22f54f28 — the three architecture-review commits on the second PR of the structured-chat orchestration stack (f771efd6..6746bb52a4).

  • A Run's coordinator actor now counts only at its write generation. currentRunCoordinatorActor / currentRunCoordinatorActorSql gate the actor on coordinator_actor_generation === consumer_generation; createRun, bindRun (rebind and same-coordinator correction) and unbindOtherRunsForCoordinator write the matching generation, and runBoundToCoordinator matches through it. D1's "actor beside its own handle" rule, the open-time unbind-repair and their tests are deleted in favour of the one rule; the resolver's worker-identity refusal moves next to the worker lookup.
  • One owner for a party's addresses. addressSpellingsOf and runCoordinatorKey replace the hand-listed handle+actor pairs in createRun, bindRun, unbindOtherRunsForCoordinator and the declared-caller fence; isRecordedStructuredWorkerSession replaces the backfill-local worker probe.
  • Wording and comment fixes. A released lease now reads "is not running right now" (wakeable) instead of "has ended"; the pane-key comment scopes its credential claim to requests that name no session.
  • Tests. New generation/stale-actor cases in run-coordinator-actor-binding.test.ts, a same-coordinator correction case, the both-addresses reroute cases, and the declared-self cases in orchestration-session-caller.test.ts; the migration fixture restores main's handle-only routing trigger.

I traced the generation rule end to end: every writer on runs.consumer_generation either sets coordinator_actor_generation in the same statement or leaves the actor counting only when the generations agree; the one raw-actor read (RUNS_BOUND_TO_COORDINATOR_SQL) is a candidate prefilter behind the generation-aware JS predicate; no reader re-persists a stale actor; and a same-session rebind is not wrongly fenced or over-bumped. run-coordinator-actor-binding, run-coordinator-actor-address, structured-worker-actor-backfill and orchestration-actor-column-migration pass 32/32 locally.

ℹ️ The historical mailbox-owner cache is generation-blind, so a multi-Run handle-less coordinator can be unaddressable

run_coordinator_handles records every address a party ever coordinated under, and nothing prunes it. The generation rule stops a stale actor from matching as the coordinator, but the mailbox-owner path (getRunMailboxOwnerIdsForHandle, the routing trigger, and recipient-routing.ts) never consults consumer_generation. Actors newly populate that cache via createRun/bindRun, so a live handle-less coordinator with more than one past Run now resolves to multiple owners; selectHistoricalRun returns undefined and a sender with no Run of its own gets recipient_ambiguous at that coordinator's session:<id> address. The PR documents the "former coordinator's address stays remembered" half as intended, and this mirrors existing terminal-handle semantics, but the multi-owner side effect is untested.

Technical details
# Generation-blind historical address cache

## Affected sites
- `src/main/runtime/orchestration/db/runs/run-lookup.ts:55-79` — `getRunMailboxOwnerIdsForHandle` joins `run_coordinator_handles -> runs` with `runs.legacy = 0`, no generation predicate.
- `src/main/runtime/orchestration/db/runs/run-coordinator-mail-routing.ts:41-67` — the INSERT trigger matches the cached address by string equality only.
- `src/main/runtime/rpc/methods/orchestration/messaging/recipient-routing.ts:79-86` and `selectHistoricalRun:128-139` — multiple owners with no explicit/sender Run becomes `recipient_ambiguous`.
- `src/main/runtime/orchestration/db/runs/run-create.ts:35-37` and `run-binding.ts:124-131` — the delta is what puts actor addresses into the cache.
- `src/main/runtime/orchestration/db/runs/run-coordinator-actor-address.test.ts:155-157` — pins the intended "stays remembered" half.

## Required outcome
- A conscious call, not necessarily a code change: either accept the ambiguity (a design note plus a test pinning the multi-Run outcome), or prune/replace the prior owner row for a party when a new Run supersedes it.

## Open questions for the human
- Should `getRunMailboxOwnerIdsForHandle` return only the Run a party currently coordinates once it has one, so a live coordinator is not ambiguous at its own address?

Pullfrog  | Fix it ➔ | View workflow run | Using DeepSeek Flash (free via Pullfrog for OSS) | 𝕏

…ry and bind it by actor

WIP: entry resolver on both dispatchers, caller identity through run scope,
actor-keyed Run binding and unbind, actor writes on bind/create/assign, and
actor-aware mail ownership exclusions.
…nce and actor mail ownership

Keeps the non-session dispatch path synchronous so terminal and session-tab
streams reach their handler without an extra async hop.
…pin every verb on both routes

A session caller need not name itself in a param that requires a caller: the
entry binds the declared caller to the session before the schema runs. Session
refusal codes pass through the RPC error map, and DB row reads added here carry
their SAFETY rationale.
…t binding without a caller param

Drops the actor clause from self-dispatch detection: a creator and assignee can
only share an actor when they already share a handle or pane.
…ves, and refuse a worker without its identity

An older binary unbinds a structured worker's Run by clearing handle and pane, which
leaves the actor looking like a handle-less chat binding. The every-open repair
clears that shape for actors recorded as structured workers only, and the resolver
refuses a worker session whose worker identity is gone, so this binary never
writes the shape itself.
…eneration, and give a party's addresses one owner

The coordinator actor now counts only at the consumer generation it was written at, so a
Run binding matches a session by that rule alone. It replaces two mechanisms for the same
fact: the rule that an actor beside a handle it did not bind with never matches, and the
open-time repair that cleared a structured worker's actor an older binary's unbind left.
Every write of an older binary that rebinds or unbinds bumps the generation, so both
shapes stop counting by themselves, including a chat's actor after a rebind then an
unbind, which neither old mechanism caught. createRun and the same-coordinator actor
correction write the generation in the statement that writes the actor. The resolver
still refuses a structured worker whose worker identity is gone; its predicate moves
next to the worker identity lookup.

addressSpellingsOf is the one owner of the addresses a party is reachable at (a
structured worker's handle and session actor). createRun, bindRun, the coordinator
unbind and the declared-caller check use it instead of hand-built sets, and each has a
test at both of a worker's addresses.
…the pane-key credential claim to requests without a session

A released lease is evicted, not ended: a user turn resumes the session, so the refusal
now says it is not running right now instead of that it has ended.

The worker pane-key comment claimed the random leaf is what stops anyone who learns a
session id from acting as the worker. On the same-host socket route the session id now
names the worker with no token by design; the pane key still matters where a request
names no session (a PTY agent's, or the paired-client route, which refuses session ids).
@brennanb2025
brennanb2025 force-pushed the brennanb2025/d1-actor-columns branch from b183a5c to 19218ab Compare September 24, 2026 22:10
@brennanb2025
brennanb2025 force-pushed the brennanb2025/d2-caller-resolver branch from 6746bb5 to 8dac986 Compare September 24, 2026 22:10

This branch has not been deployed

No deployments
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