From 47db7473ffc52e5778f87c50660e159d02894f1b Mon Sep 17 00:00:00 2001 From: Matt Galligan Date: Sat, 12 Sep 2026 10:28:40 -0400 Subject: [PATCH 1/3] feat(registry): scope native identity by provider binding --- .agents/plans/stations-providers/GOAL.md | 6 +- .agents/plans/stations-providers/PLAN.md | 16 +- .agents/plans/stations-providers/RETRO.md | 15 + ...provider-bindings-and-durable-execution.md | 4 +- docs/usage/README.md | 37 +- .../dispatch/contracts/legacy_baseline.py | 45 +- src/outfitter/dispatch/core/backfill.py | 16 +- src/outfitter/dispatch/core/codex_items.py | 4 + src/outfitter/dispatch/core/delivery.py | 25 +- .../dispatch/core/delivery_reconciliation.py | 26 +- src/outfitter/dispatch/core/event_index.py | 35 +- src/outfitter/dispatch/core/handlers.py | 165 +++-- src/outfitter/dispatch/core/history.py | 8 +- src/outfitter/dispatch/core/history_index.py | 20 +- src/outfitter/dispatch/core/models.py | 12 +- src/outfitter/dispatch/core/ops.py | 9 + src/outfitter/dispatch/core/queue.py | 17 +- src/outfitter/dispatch/core/reactor.py | 5 +- .../dispatch/core/server_requests.py | 22 +- src/outfitter/dispatch/core/subscriptions.py | 9 +- src/outfitter/dispatch/core/topology.py | 45 +- src/outfitter/dispatch/daemon/supervisor.py | 20 +- .../dispatch/registry/ingest_harness.py | 6 + src/outfitter/dispatch/registry/models.py | 16 +- src/outfitter/dispatch/registry/refs.py | 8 + src/outfitter/dispatch/registry/sql_compat.py | 49 +- src/outfitter/dispatch/registry/store.py | 617 ++++++++++++++---- tests/contracts/test_contracts.py | 3 +- tests/core/test_provider_binding_identity.py | 143 ++++ tests/core/test_server_requests.py | 40 ++ tests/daemon/test_supervisor.py | 20 + .../codex_turn_lifecycle.jsonl | 4 +- tests/fixtures/registry/builders.py | 16 + tests/fixtures/registry/v23_binding_scope.sql | 184 ++++++ tests/fixtures/test_corpus.py | 1 + tests/registry/test_binding_identity.py | 577 ++++++++++++++++ tests/registry/test_deliveries.py | 2 +- tests/registry/test_sql_compat.py | 13 +- tests/registry/test_store.py | 16 +- tests/surfaces/test_cli_daemon_skew.py | 4 +- .../test_control_execution_binding.py | 24 +- tests/surfaces/test_mcp_routing.py | 16 +- 42 files changed, 2028 insertions(+), 292 deletions(-) create mode 100644 tests/core/test_provider_binding_identity.py create mode 100644 tests/fixtures/registry/v23_binding_scope.sql create mode 100644 tests/registry/test_binding_identity.py diff --git a/.agents/plans/stations-providers/GOAL.md b/.agents/plans/stations-providers/GOAL.md index 9aad6cc..8d8953f 100644 --- a/.agents/plans/stations-providers/GOAL.md +++ b/.agents/plans/stations-providers/GOAL.md @@ -1,7 +1,7 @@ # Current execution objective -Make the shared Dispatch foundation reviewable and advance the first bounded code slice ahead of Hermes: complete DIS-78's project, native issue dependencies, portable Linear handoff and repository contract; then implement and verify DIS-70's operation-bound schema check in a separate draft PR. +Continue from the reviewed DIS-78/DIS-70 stack through the shared provider foundation, evidence-backed Hermes research and the smallest complete native Hermes integration. Implement the remaining shared identity, routing, reservation and observation contracts; prove two coherent Hermes turns and advance recovery, Desktop continuity, independent startup and operator documentation as far as the supported runtime permits. -The broader project is tracked by the [plan](PLAN.md). This current wave does not complete DIS-79–83, enable Hermes execution, build network infrastructure, merge, release or replace installed runtimes. +The [plan](PLAN.md) records the dependency graph. Matt authorized direct Hermes interaction on September 12 using the local `default` profile. Use dedicated synthetic conversations for live proof and isolated automated/fault tests. #Dispatch owns shared source and Git coordination; Hermes is a research collaborator. Network infrastructure, merges, releases, public deployments and installed-runtime replacement retain their separate authority boundaries. -Completion evidence: read-back-verified Linear resources, reviewed documentation, meaningful compatibility tests, full repository checks, scoped draft PRs and an updated handoff that accurately names remaining prerequisites and the next base to consume. Do not mark the Hermes integration gate ready until its separate dependencies are fulfilled. +Completion evidence includes verified migration and Codex compatibility, meaningful provider-routing and receipt tests, native Hermes session/run/history proof, full repository checks, independent review, scoped draft PRs, and read-back-verified Linear status and portable handoff. Do not claim support, durability or Desktop coexistence without the corresponding evidence. Record any concrete upstream limit and the exact remaining work. diff --git a/.agents/plans/stations-providers/PLAN.md b/.agents/plans/stations-providers/PLAN.md index 585e37e..9b877ff 100644 --- a/.agents/plans/stations-providers/PLAN.md +++ b/.agents/plans/stations-providers/PLAN.md @@ -4,7 +4,7 @@ The [Linear project](https://linear.app/outfitter/project/dispatch-stations-and- ## Current assignment -#Dispatch coordinates the shared foundation ahead of the Hermes agent. The current bounded wave completes the project/issue/document setup, repository architecture reconciliation in DIS-78, and the operation-bound compatibility fix in DIS-70. It does not claim to finish every prerequisite for Hermes integration. The portable handoff must continue to show the remaining blockers until their implementation evidence exists. +#Dispatch coordinates implementation through the shared foundation and the Hermes integration. On September 12, Matt expanded the assignment beyond DIS-78/DIS-70 to proceed as far as the evidence supports, including direct Hermes interaction on the local `default` profile. Use dedicated synthetic conversations for live proof; automated tests and destructive fault simulations remain isolated. The portable handoff must show remaining blockers until their implementation evidence exists. Use the dedicated foundation worktree and preserve other worktree owners. One branch owns each shared registry/admission change. Existing Claude and provider-history projects keep their provider-specific scope. DIS-50 consumes the shared foundation rather than implementing another migration. DIS-24 retains its existing parent and anchors network design. @@ -19,7 +19,7 @@ Use the dedicated foundation worktree and preserve other worktree owners. One br | [DIS-81](https://linear.app/outfitter/issue/DIS-81) | Minimum immutable prepared-request/reservation seam on Codex | DIS-80 | | [DIS-82](https://linear.app/outfitter/issue/DIS-82) | Minimum shared observation/receipt seam on Codex | DIS-80 | | [DIS-83](https://linear.app/outfitter/issue/DIS-83) | Independent provider startup/recovery | DIS-80, DIS-82 | -| [DIS-84](https://linear.app/outfitter/issue/DIS-84) | Isolated native Hermes API contract proof | Can start independently; findings inform the shared design | +| [DIS-84](https://linear.app/outfitter/issue/DIS-84) | Native Hermes API proof on the authorized default profile | Can start independently; findings inform the shared design | | [DIS-85](https://linear.app/outfitter/issue/DIS-85) | Hermes adapter and two coherent local turns | DIS-70, DIS-79, DIS-80, DIS-81, DIS-82, DIS-84 | | [DIS-86](https://linear.app/outfitter/issue/DIS-86) | Hermes ambiguity/replay/attention recovery | DIS-85 | | [DIS-87](https://linear.app/outfitter/issue/DIS-87) | Desktop A → Dispatch B → Desktop C coexistence | DIS-86 | @@ -35,7 +35,7 @@ This is a dependency graph, not a requirement to work through every row serially DIS-81/82 extract the minimum request/evidence contract with actual Codex callers and preserve current guarantees. Hermes native creation, replay windows, crash recovery and attention are exercised in DIS-85/86; Claude-specific hook/generation/resolution proof remains in DIS-50/51/52. The first adapter must not wait for a generic recovery framework or a full network read model. -Matt is the native Linear assignee for the active foundation and initial Hermes issues. #Dispatch is the execution owner for DIS-70/78–83; the Hermes Desktop agent is the intended execution owner for DIS-84 onward. No Hermes app user is available in Linear, so this plan and the handoff carry that worker distinction without triggering a different agent integration. +Matt is the native Linear assignee for the active foundation and initial Hermes issues. #Dispatch owns execution and source-control coordination through DIS-88, with bounded research, implementation and review workers. A dedicated Hermes Desktop conversation on the local default profile provides independent native-runtime research. Keep one source owner for each shared migration and one live writer per synthetic Hermes session. ## Current wave gates @@ -55,6 +55,14 @@ Keep the change within the control protocol and CLI/MCP transport projection. De Verify matching/mismatching/malformed hashes, unknown checked method, changed daemon between preflight and execution, zero handler calls on rejection, CLI/MCP error parity and existing compatibility behavior. Run the smallest relevant suites, then `just check`; request local review after green checks. Keep the PR draft and record hosted CI for its exact head. +### 3. DIS-79–88: complete the foundation and native Hermes path + +Implement and review the binding migration before enabling provider routing. Extract the minimum prepared-request and observation seams with real Codex callers, then consume the verified Hermes contract in a small native API adapter. Keep independent startup as a focused slice and prove it before claiming Hermes-only availability. + +In parallel with shared code, inspect the installed Hermes contract and use the authorized local default profile for synthetic live checks. Record version, profile, session/run IDs, replay scope and canonical history evidence. Alternate Desktop and API writers only after the preceding turn is demonstrably idle. An acceptance response or a closed event stream alone never proves execution. + +After two coherent Dispatch turns, exercise replay/conflict, lost acknowledgment, unavailable runtime and attention boundaries, using isolated services for destructive fault simulations. Verify Desktop continuity and operator diagnostics, then run a fresh full-stack review. Continue through correctable findings; stop only at a concrete capability or authority boundary and preserve a precise pickup record. + ## Hermes pickup contract The [Linear handoff](https://linear.app/outfitter/document/hermes-agent-handoff-prerequisites-workflow-and-pickup-gates-5dd9c614285a) is the portable entry point. It must work without another agent's gitignored files. @@ -71,7 +79,7 @@ A completed document, a passing transport probe or a green unrelated revision do ## Verification and authority -Use repository tasks and `uv`; new behavior follows TDD. Full gates include lint, format, strict types, unit/examples tests and package contents. Live integration/scenarios are separate opt-in isolated proofs with temporary Dispatch/provider homes and synthetic state. Documentation-only changes do not need a new live provider turn. +Use repository tasks and `uv`; new behavior follows TDD. Full gates include lint, format, strict types, unit/examples tests and package contents. Automated integration/scenarios use temporary Dispatch/provider homes and synthetic state. Matt separately authorized live interaction on Hermes's local default profile for this task; use dedicated synthetic sessions and retain their evidence. Documentation-only changes do not need a new live provider turn. Each phase is a Graphite branch. A local reviewer must score at least 4/5 with no open P0/P1/P2 before the next phase. Record commands, revision, review and unresolved limits in [RETRO.md](RETRO.md). Keep source-control mutations with the coordinator. PRs remain draft until the applicable current-head checks and readiness authorization are satisfied. Merge, publication, installed-runtime changes and public deployment are separate actions. diff --git a/.agents/plans/stations-providers/RETRO.md b/.agents/plans/stations-providers/RETRO.md index a52b4fa..fdbdce1 100644 --- a/.agents/plans/stations-providers/RETRO.md +++ b/.agents/plans/stations-providers/RETRO.md @@ -32,3 +32,18 @@ DIS-70 adds protocol version 2's reserved checked-execution envelope and receivi - Fresh targeted implementation review passed at 5/5 with no findings. The reviewer independently reran the 158-test daemon/surface/contract suite, a 49-test focused suite, scoped Ruff, strict mypy on changed source files and diff checks. No provider model call, registry migration, installed-runtime replacement, merge or release was performed. DIS-79–82 remain unimplemented. DIS-84's isolated native Hermes API investigation may start; DIS-85 adapter integration remains blocked on real foundation and API proof. The Linear handoff must retain that distinction when PR evidence is added. + +## September 12, 2026 — provider and binding identity + +Matt expanded the assignment through the shared foundation and native Hermes implementation, including direct default-profile research. DIS-79 extends the reviewed `904386c` stack with schema v24. It scopes native sessions, history, topology, events, normalized receipts, runtime state and server requests by provider and binding, while preserving Dispatch lane keys and refs. Default Codex keeps `provider_session_id == id`; other bindings remain non-executable in this migration slice. + +### Verification and review + +- Added collision and no-fallback regressions, an exact populated v23 schema fixture, deterministic failed-migration rollback, and transaction recovery tests. +- Migration preserves child foreign keys, durable local row IDs and SQLite sequence high-water marks, including previously deleted rows. An independent probe with the prior sequence at 42 confirmed the next ID is 43 and `PRAGMA foreign_key_check` is clean. +- Review found and resolved provider leaks in read/recovery paths, native server-request lookup collisions, transaction cleanup on identity conflict, default-Codex identity reassignment, sequence reuse, and stale public identity documentation. The full client routing extraction remains DIS-80. +- Final implementer and independent reviewer `just check` runs passed Ruff lint/format, strict mypy, 1,115 tests / 17 live tests deselected, wheel/sdist build and package-content validation. `git diff --check` passed. +- Fresh targeted review passed at 5/5 with zero open P0/P1/P2. The reviewed source/doc diff fingerprint was `2dea8b8df01191053a93212f6c512f1faaba3ee0b2aa4293c68aecef56ca8861`; this ledger entry adds the resulting evidence. +- The migration was tested only on isolated state. It was not run on the installed Dispatch registry. No installed runtime replacement, merge, release or deployment was performed. + +DIS-80 may begin from this reviewed slice. The separately researched Hermes transport remains subject to DIS-81/82 reservation/observation and adapter integration gates; its successful native probes do not bypass them. diff --git a/docs/adrs/0028-stations-own-provider-bindings-and-durable-execution.md b/docs/adrs/0028-stations-own-provider-bindings-and-durable-execution.md index c15e5e6..41267e4 100644 --- a/docs/adrs/0028-stations-own-provider-bindings-and-durable-execution.md +++ b/docs/adrs/0028-stations-own-provider-bindings-and-durable-execution.md @@ -53,7 +53,7 @@ Observed-only sessions require stable binding-scoped observation identity withou This proposal extends [ADR-0019](0019-dispatch-local-refs-and-flat-thread-cli.md) to additional providers while preserving its full Codex ID escape hatch: - Existing and newly created threads in the default Codex binding retain their full native Codex ID as `lanes.id`. Existing refs and child foreign keys remain unchanged. Other providers allocate opaque Dispatch keys in a namespace disjoint from Codex IDs; they never derive refs with Codex-specific hashing. -- In local managed-thread outputs, `id` remains the stable Dispatch key and the existing `lane` compatibility field, wherever present, remains its alias. `ref` and `handle` retain their current meanings. Add `provider`, `binding_id`, and `provider_thread_id` as local identity metadata; do not introduce another `lane_key` output alias or rename existing fields. Native thread continuation may change `provider_thread_id` with positive evidence while `id`, `lane`, and `ref` stay stable. +- In local managed-thread outputs, `id` remains the stable Dispatch key and the existing `lane` compatibility field, wherever present, remains its alias. `ref` and `handle` retain their current meanings. Add `provider`, `binding_id`, and `provider_thread_id` as local identity metadata; do not introduce another `lane_key` output alias or rename existing fields. For other bindings with opaque Dispatch keys, native thread continuation may change `provider_thread_id` with positive evidence while `id`, `lane`, and `ref` stay stable. The default Codex binding preserves `provider_thread_id == id`; reassignment is unsupported. - Existing managed ref, exact Dispatch key, handle and title resolution keeps its precedence. The full native Codex ID remains accepted, including unmanaged read paths, against the designated default Codex binding. Enabling Hermes does not redirect that fallback or make it ambiguous. Unsupported operations still fail at the authority/capability boundary. - The first additional-provider slice selects managed threads by existing refs, Dispatch keys or labels. It does not accept a bare Hermes/Claude native ID or invent colon-qualified selector syntax. Native lookup within another binding requires a future explicit binding-scoped authored contract. Additional Codex execution bindings remain disabled until that contract is defined; storage collision tests cover them without implying public execution support. - A network target's `thread_id` is the managed Dispatch key. Binding IDs and native IDs remain local metadata by default and are not remote authority tokens. Endpoint addresses, credentials and filesystem paths are never encoded into these identifiers. @@ -110,7 +110,7 @@ Keep raw history, provider events, absolute paths, credentials and full tool out ### Independent progress and compatibility -The Hermes native API probe can proceed in an isolated profile while the shared contract is reviewed. Its findings constrain supported adapter semantics. Local Hermes integration depends on the complete identity, routing, reservation, evidence and compatibility gates, not on building a cloud gateway or finishing Claude's UI transport. +The Hermes native API probe can proceed while the shared contract is reviewed. Its findings constrain supported adapter semantics. For this implementation Matt authorized dedicated synthetic conversations on the local default profile; automated tests and destructive fault simulations remain isolated. Local Hermes integration depends on the complete identity, routing, reservation, evidence and compatibility gates, not on building a cloud gateway or finishing Claude's UI transport. [DIS-70](https://linear.app/outfitter/issue/DIS-70) is the first bounded code slice: validate the expected op schema at the receiving execution boundary. A separate preflight connection cannot protect against daemon replacement. An old receiver must reject a checked request it does not understand instead of silently ignoring a new field. Intentional legacy read compatibility needs separate proof; provider-bearing operations never fall back to unchecked execution. diff --git a/docs/usage/README.md b/docs/usage/README.md index 561b0cb..7268a85 100644 --- a/docs/usage/README.md +++ b/docs/usage/README.md @@ -228,6 +228,29 @@ Common recovery paths: operations. In shared mode, verify the configured Unix socket exists and its daemon is ready; Dispatch will not replace it with a private server. +### Provider identity migration (schema 24) + +Schema 24 adds a runtime binding namespace to managed threads and normalized +provider history, topology, receipts, runtime state and server requests. Existing +Codex thread IDs, refs and dependent records remain unchanged. Managed-thread +output adds `provider`, `binding_id` and `provider_session_id`; `id` and the +existing `lane` alias still identify the same stable Dispatch thread. Native +session IDs are interpreted inside their binding, so identical IDs in different +profiles cannot share evidence. + +Use the backed-up `dispatch registry migrate` workflow above before starting the +new binary against an existing registry. The migration rebuilds affected tables +transactionally and verifies foreign keys. Binaries supporting only schema 23 +refuse to open the upgraded database. Rolling back the executable therefore also +requires restoring the pre-migration backup with the daemon stopped; it does not +preserve work recorded after that snapshot. Keep the upgraded database for +recovery rather than replacing it without a copy. + +This migration provides storage identity. It does not itself enable another +execution provider or grant additional write authority. Existing full Codex IDs +remain valid selectors; another provider's bare native session ID is not a new +public selector syntax. + ## Release Publishing `project.version` in `pyproject.toml` is the release trigger. Maintainers bump @@ -316,9 +339,11 @@ uv run dispatch send "Review the README for missing usage steps." ``` Every managed thread gets a dispatch-local `ref`, for example `0k7M4a`. Use refs -for day-to-day commands. The full Codex thread id is still the canonical global -identity and is accepted everywhere. Titles and `@handles` are mutable labels; -they are convenient, but not stable identity. +for day-to-day commands. Its `id` is the stable Dispatch identity. For the +default Codex binding, that remains the full native Codex thread ID; existing +full-ID selectors retain their behavior. Other bindings keep native identity +in `provider_session_id`. Titles and `@handles` are mutable labels; they are +convenient, but not stable identity. Example `.dispatch/config.toml`: @@ -1340,8 +1365,10 @@ to fork history through that completed turn, inclusive. The thread-read tool's `roster`, `discover`, and `show` ops expose the same parent/ancestor/root filters and bounded topology fields as the CLI. Reading or discovering topology does not create a lane or grant write authority. -Structured MCP outputs that identify a managed thread include the dispatch `ref`, full -Codex id, title/handle, managed/source/status, and cwd when available. +Structured MCP outputs that identify a managed thread include its Dispatch `ref` +and stable `id`, provider/binding/native-session metadata, title/handle, +managed/source/status, and cwd when available. In the default Codex binding, +the stable `id` remains the full native Codex thread ID. The workspace Codex plugin at [`plugins/dispatch/`](../../plugins/dispatch/) exposes that MCP server through [`plugins/dispatch/.mcp.json`](../../plugins/dispatch/.mcp.json). The diff --git a/src/outfitter/dispatch/contracts/legacy_baseline.py b/src/outfitter/dispatch/contracts/legacy_baseline.py index 3ed660d..9681d2a 100644 --- a/src/outfitter/dispatch/contracts/legacy_baseline.py +++ b/src/outfitter/dispatch/contracts/legacy_baseline.py @@ -12,12 +12,9 @@ and ``prehandshake_op_allowed``); older releases' schemas may differ from the baseline (e.g. v0.8.2's ``send`` had no ``content`` field). -Baseline for parent v0.11.0 (main): computed from the current tree rather than -a parent checkout. That is sound because this branch's only schema change -relative to main is the ``provider`` field added to ``NewInput`` (DIS-49), -which is shared by exactly ``new`` and ``new-plan`` — both excluded via -CHANGED_SINCE_PARENT. No other input/output model changed, so every remaining -op's current hash equals the parent's. +Baseline for parent v0.11.0 (main): computed before the current schema changes. +Every op whose current input or output differs is excluded via +``CHANGED_SINCE_PARENT``; retained hashes remain the genuine parent hashes. """ PARENT_VERSION = "0.11.0" @@ -52,7 +49,28 @@ ``PARENT_VERSION``, so a read-op schema change must also bump this floor to the first release carrying the new schema.""" -CHANGED_SINCE_PARENT: frozenset[str] = frozenset({"new", "new-plan", "send", "roster", "show"}) +CHANGED_SINCE_PARENT: frozenset[str] = frozenset( + { + "attach", + "compact", + "discover", + "fork", + "goal-clear", + "goal-get", + "goal-set", + "new", + "new-plan", + "open", + "rollback", + "roster", + "send", + "show", + "stop", + "sync", + "transcript", + "watch", + } +) """Ops whose input/output schema drifted since ``PARENT_VERSION``. Never forwarded to a pre-handshake daemon: its Pydantic models (default @@ -63,15 +81,8 @@ """Ops absent from ``PARENT_VERSION`` and unsafe for pre-handshake forwarding.""" PARENT_OP_SCHEMA_HASHES: dict[str, str] = { - "open": "2e663feeb4533c6838b00a4ff352597abaa3b728b166b3aa2a6ee4f69bcc41d8", - "attach": "b6897800a0405339e910cf98de8277bac6ad6f1b6b90bd58345182d5c9d21bc9", - "stop": "16ccc26967a0c253f1cff65f4b8a455f7db38226773d0b0c1cb5f6e2b86e392c", "lane-rename": "c90f27886bd7bb6a8468f7bc7336716d60525b2361b4e9507d6e9d8b9273cf46", - "transcript": "d1d2acca3ed46ce7451801aff4c588038e8cd13eea83b5b8e6c8e2a87671b86a", "history": "43f8d8689b4171d655a8b14895cbb1af6ffe007aaa93ec14d6be76c19d2e1243", - "watch": "9febdc5f24bac264900435da630a5c4adb3b63e8da934d58cef5b3750f498fa5", - "sync": "306c6ddcf8abf3fca5f0509ca85499064b67e8b98eb1db68f9942a1e9b768845", - "discover": "96a284f08bdb3b9c4f15c6a70f5abb0ecf3b5022d7b05ac443a2c8d5377adeb1", "search": "6383eb95772eae4ad689ad90c123244f36434f5b884cbe43cfab15075a891c0e", "query": "37544d538a1ee55818edcda6b9615db719d35fd8dd4274fc4075fccf5e6b79ba", "models": "246d581df042fe34b36c404ab9d75bdce1e678aee1c01662a16b93bb6f997035", @@ -87,12 +98,6 @@ "unsubscribe": "0d2203429b98ed3614707287c4f424f58813c02664aad35743cbb7bbe82eb6da", "archive": "164ef5d176c8c1c90a95627bc6695d6f7632b0adf68919d8cccf63599b03a475", "restore": "164ef5d176c8c1c90a95627bc6695d6f7632b0adf68919d8cccf63599b03a475", - "goal-get": "0ff6b7f7467ce48c6afbf39fa7900be67776664783c0c5a9a0bd90e8df06c699", - "goal-set": "2fcb09ccd7fb2825ad2794d036efe7958f869812df66cc3631613b8bf6a3100f", - "goal-clear": "2dfb19dbfdc2ad9e10583a7df2cbb01f7988922748caa1d921b6306930afffc5", - "fork": "3db182e6fed46adc4c4a6c246ba69b39aa86378276c58f1b2cb2f671a7686db6", - "rollback": "02199e08bfd8f932addf73cf3a328f2d72e40c05fbdcd6f8de95da8ce5af0426", - "compact": "5aec00f1dd3427fb7368223277dd7310d254b3a7a4252cde4ab5451bf759dca9", "status": "4c9f6d7a774c4b4d06d0da9743eb55113c13cc323e6a47f6bbeb3fa94145bc48", "log": "fa720f3eb8c816d2045d996ac49b1a51217872215d660566b40e19a6ed1a8d66", "trigger-add": "ceef3ef04f635e1bc3af4b9b019e6c7a253d26ef5221fcbacbaa60f1a5950d0f", diff --git a/src/outfitter/dispatch/core/backfill.py b/src/outfitter/dispatch/core/backfill.py index c02010f..b78f5c3 100644 --- a/src/outfitter/dispatch/core/backfill.py +++ b/src/outfitter/dispatch/core/backfill.py @@ -19,8 +19,9 @@ ) from outfitter.dispatch.config import CapturePolicy from outfitter.dispatch.contracts.context import LaneClient +from outfitter.dispatch.contracts.errors import CapabilityUnavailableError from outfitter.dispatch.registry.models import Lane -from outfitter.dispatch.registry.store import Registry +from outfitter.dispatch.registry.store import DEFAULT_CODEX_BINDING_ID, Registry from .history_index import index_codex_items_page, index_codex_turns_page @@ -83,6 +84,15 @@ async def backfill_codex_history( ) -> HistoryBackfillResult: """Reconcile recent turns first, then resume bounded older history.""" + if ( + lane.provider != "codex" + or lane.binding_id != DEFAULT_CODEX_BINDING_ID + or lane.provider_session_id != lane.id + ): + raise CapabilityUnavailableError( + f"history sync is unavailable for provider binding {lane.provider}:{lane.binding_id}" + ) + native_id = lane.provider_session_id started = monotonic() deadline = started + max_seconds initial_request = ThreadResumeInitialTurnsPageParams( @@ -93,7 +103,7 @@ async def backfill_codex_history( try: resumed = await _within_deadline( client.thread_resume_full( - lane.id, + native_id, exclude_turns=True, initial_turns_page=initial_request, ), @@ -105,7 +115,7 @@ async def backfill_codex_history( raise try: await _within_deadline( - client.thread_resume(lane.id, exclude_turns=True), + client.thread_resume(native_id, exclude_turns=True), deadline=deadline, monotonic=monotonic, ) diff --git a/src/outfitter/dispatch/core/codex_items.py b/src/outfitter/dispatch/core/codex_items.py index 805579e..f0af513 100644 --- a/src/outfitter/dispatch/core/codex_items.py +++ b/src/outfitter/dispatch/core/codex_items.py @@ -12,6 +12,7 @@ bound_redacted_text, ) from outfitter.dispatch.registry.models import ThreadItem, ThreadItemRef +from outfitter.dispatch.registry.store import DEFAULT_CODEX_BINDING_ID CODEX_ITEM_TYPES = frozenset( { @@ -41,6 +42,7 @@ def normalize_codex_item( raw_item: dict[str, object], *, provider_thread_id: str, + binding_id: str = DEFAULT_CODEX_BINDING_ID, lane: str, turn_id: str | None, inserted_at: str, @@ -62,6 +64,7 @@ def normalize_codex_item( arguments = str(arguments) item = ThreadItem( provider="codex", + binding_id=binding_id, provider_thread_id=provider_thread_id, item_id=item_id, lane=lane, @@ -202,6 +205,7 @@ def _item_refs( return [ ThreadItemRef( provider=item.provider, + binding_id=item.binding_id, provider_thread_id=item.provider_thread_id, item_id=item.item_id, ref_type=ref_type, diff --git a/src/outfitter/dispatch/core/delivery.py b/src/outfitter/dispatch/core/delivery.py index 3dbd970..fc0e6b5 100644 --- a/src/outfitter/dispatch/core/delivery.py +++ b/src/outfitter/dispatch/core/delivery.py @@ -17,6 +17,7 @@ ValidationError, ) from outfitter.dispatch.registry.models import Lane +from outfitter.dispatch.registry.store import DEFAULT_CODEX_BINDING_ID from .models import DeliveryLookupInput, DeliveryView, SendInput from .turn_settings import TurnStartSettings, load_turn_start_settings @@ -24,6 +25,16 @@ _SETTINGS = TypeAdapter(TurnStartSettings) +def _native_id(lane: Lane) -> str | None: + if ( + lane.provider != "codex" + or lane.binding_id != DEFAULT_CODEX_BINDING_ID + or lane.provider_session_id != lane.id + ): + return None + return lane.provider_session_id + + async def get_receipt(inp: DeliveryLookupInput, ctx: Ctx) -> DeliveryView: receipt = await ctx.registry.get_delivery(inp.receipt_id) return DeliveryView.model_validate(receipt.model_dump(mode="json")) @@ -37,6 +48,10 @@ async def reconcile_receipt_request(inp: DeliveryLookupInput, ctx: Ctx) -> Deliv async def send_reserved(inp: SendInput, lane: Lane, text: str, ctx: Ctx) -> DeliveryView: + if _native_id(lane) is None: + raise CapabilityUnavailableError( + f"provider binding {lane.provider}:{lane.binding_id} execution is not supported" + ) native = lane.source == "attached" and inp.mode == "queue" if lane.source != "own" and not native: raise AuthorityError("idempotent delivery currently requires a Dispatch-owned thread") @@ -85,6 +100,12 @@ async def send_reserved(inp: SendInput, lane: Lane, text: str, ctx: Ctx) -> Deli async def submit_reserved(delivery_id: str, ctx: Ctx) -> bool: receipt = await ctx.registry.get_delivery(delivery_id) + lane = await ctx.registry.find_lane(receipt.lane) + if lane is None: + raise NotFoundError(f"no managed thread {receipt.lane!r}") + native_id = _native_id(lane) + if native_id is None: + return False payload = json.loads(receipt.payload) if not await ctx.registry.claim_delivery(delivery_id): return False @@ -99,7 +120,7 @@ async def submit_reserved(delivery_id: str, ctx: Ctx) -> bool: async with asyncio.timeout(15): provider_call_entered = True submission = await ctx.client.thread_queue_add( - receipt.lane, payload["text"], client_user_message_id=receipt.id + native_id, payload["text"], client_user_message_id=receipt.id ) if ( submission.client_user_message_id != receipt.id @@ -117,7 +138,7 @@ async def submit_reserved(delivery_id: str, ctx: Ctx) -> bool: async with asyncio.timeout(15): provider_call_entered = True result = await ctx.client.turn_start( - receipt.lane, + native_id, payload["text"], cwd=payload["cwd"], client_user_message_id=receipt.id, diff --git a/src/outfitter/dispatch/core/delivery_reconciliation.py b/src/outfitter/dispatch/core/delivery_reconciliation.py index 7bdbf31..b29bdd3 100644 --- a/src/outfitter/dispatch/core/delivery_reconciliation.py +++ b/src/outfitter/dispatch/core/delivery_reconciliation.py @@ -8,6 +8,8 @@ from outfitter.dispatch.client.errors import ClientError from outfitter.dispatch.client.models import ThreadTurn from outfitter.dispatch.contracts.context import Ctx +from outfitter.dispatch.registry.models import Lane +from outfitter.dispatch.registry.store import DEFAULT_CODEX_BINDING_ID MAX_CHECKS = 3 MAX_PAGES = 4 @@ -15,6 +17,17 @@ MAX_READINESS_CHECKS = 3 +def _native_id(lane: Lane | None) -> str | None: + if ( + lane is None + or lane.provider != "codex" + or lane.binding_id != DEFAULT_CODEX_BINDING_ID + or lane.provider_session_id != lane.id + ): + return None + return lane.provider_session_id + + async def reconcile_pending(ctx: Ctx) -> None: """Check independent threads concurrently; each receipt has a finite budget.""" unresolved = await ctx.registry.list_unresolved_deliveries() @@ -80,6 +93,10 @@ async def reconcile_accepted_after_reconnect(ctx: Ctx) -> None: async def reconcile_receipt(delivery_id: str, ctx: Ctx, *, automatic: bool = True) -> None: receipt = await ctx.registry.get_delivery(delivery_id) + lane = await ctx.registry.find_lane(receipt.lane) + native_id = _native_id(lane) + if native_id is None: + return if receipt.status == "completed" and receipt.execution_status == "completed": if ( not automatic @@ -131,7 +148,7 @@ async def reconcile_receipt(delivery_id: str, ctx: Ctx, *, automatic: bool = Tru receipt.id, status="accepted", submission_id=submission.id ) return - turn, reason = await _find_arrival(ctx, receipt.lane, receipt.id, expected) + turn, reason = await _find_arrival(ctx, native_id, receipt.id, expected) except (ClientError, TimeoutError) as exc: turn, reason = None, f"provider history unavailable: {exc}" if turn is None: @@ -188,12 +205,15 @@ async def _refresh_idle_readiness(lane_id: str, ctx: Ctx) -> bool: lane = await ctx.registry.find_lane(lane_id) if lane is None or lane.status in ("archived", "error"): return False + native_id = _native_id(lane) + if native_id is None: + return False try: - result = await ctx.client.thread_read(lane_id, include_turns=False) + result = await ctx.client.thread_read(native_id, include_turns=False) except (ClientError, TimeoutError): continue thread = result.get("thread") - if not isinstance(thread, dict) or thread.get("id") != lane_id: + if not isinstance(thread, dict) or thread.get("id") != native_id: continue status = thread.get("status") if not isinstance(status, dict): diff --git a/src/outfitter/dispatch/core/event_index.py b/src/outfitter/dispatch/core/event_index.py index c34d943..6ca1e2a 100644 --- a/src/outfitter/dispatch/core/event_index.py +++ b/src/outfitter/dispatch/core/event_index.py @@ -33,8 +33,6 @@ ) from outfitter.dispatch.registry.store import Registry -_CODEX_PROVIDER = "codex" - async def index_codex_lane_event( registry: Registry, @@ -48,8 +46,9 @@ async def index_codex_lane_event( retained_payload = _retained_payload(event, policy) received_at = registry.now_iso() provider_event = ProviderEvent( - provider=_CODEX_PROVIDER, - provider_thread_id=lane.id, + provider=lane.provider, + binding_id=lane.binding_id, + provider_thread_id=lane.provider_session_id or lane.id, lane=lane.id, event_type=_event_type(event), provider_event_id=_provider_event_id(event), @@ -66,7 +65,8 @@ async def index_codex_lane_event( if isinstance(event, ItemStarted | ItemCompleted) and event.item is not None: item, refs = normalize_codex_item( event.item, - provider_thread_id=lane.id, + provider_thread_id=lane.provider_session_id or lane.id, + binding_id=lane.binding_id, lane=lane.id, turn_id=event.turn_id, inserted_at=received_at, @@ -74,7 +74,10 @@ async def index_codex_lane_event( capture=policy, ) existing = await registry.find_thread_item( - item.provider, item.provider_thread_id, item.item_id + item.provider, + item.provider_thread_id, + item.item_id, + binding_id=item.binding_id, ) stale_start = ( isinstance(event, ItemStarted) @@ -256,8 +259,9 @@ def _thread_turn( ) -> ThreadTurn | None: if isinstance(event, TurnStarted) and event.turn_id is not None: return ThreadTurn( - provider=_CODEX_PROVIDER, - provider_thread_id=lane.id, + provider=lane.provider, + binding_id=lane.binding_id, + provider_thread_id=lane.provider_session_id or lane.id, lane=lane.id, turn_id=event.turn_id, status="started", @@ -266,8 +270,9 @@ def _thread_turn( ) if isinstance(event, TurnCompleted) and event.turn_id is not None: return ThreadTurn( - provider=_CODEX_PROVIDER, - provider_thread_id=lane.id, + provider=lane.provider, + binding_id=lane.binding_id, + provider_thread_id=lane.provider_session_id or lane.id, lane=lane.id, turn_id=event.turn_id, status="completed", @@ -278,8 +283,9 @@ def _thread_turn( if isinstance(event, TurnFailed) and event.turn_id is not None: message = bound_text(event.message, capture) return ThreadTurn( - provider=_CODEX_PROVIDER, - provider_thread_id=lane.id, + provider=lane.provider, + binding_id=lane.binding_id, + provider_thread_id=lane.provider_session_id or lane.id, lane=lane.id, turn_id=event.turn_id, status=event.execution_status, @@ -348,8 +354,9 @@ def _state( ) -> LaneRuntimeState: return LaneRuntimeState( lane=lane.id, - provider=_CODEX_PROVIDER, - provider_thread_id=lane.id, + provider=lane.provider, + binding_id=lane.binding_id, + provider_thread_id=lane.provider_session_id or lane.id, status=status, # type: ignore[arg-type] active_turn_id=active_turn_id, latest_turn_id=latest_turn_id, diff --git a/src/outfitter/dispatch/core/handlers.py b/src/outfitter/dispatch/core/handlers.py index 13bea74..7c190b5 100644 --- a/src/outfitter/dispatch/core/handlers.py +++ b/src/outfitter/dispatch/core/handlers.py @@ -63,6 +63,7 @@ ThreadItem, ThreadItemRef, ) +from outfitter.dispatch.registry.store import DEFAULT_CODEX_BINDING_ID from . import queue from .backfill import backfill_codex_history @@ -192,7 +193,7 @@ from .server_requests import respond_to_server_request from .staging import StageContent, stage_session from .sync import SourceIdentity, SyncLimits, scan_codex_jsonl -from .topology import observe_thread, observe_threads, topology_views +from .topology import lane_topology_views, observe_thread, observe_threads, topology_views from .turn_settings import ( load_turn_start_settings, runtime_settings_for_lane, @@ -211,6 +212,9 @@ class _ManagedIdentityPayload(TypedDict): lane: str ref: str id: str + provider: str + binding_id: str + provider_session_id: str | None title: str | None handle: str | None managed: bool @@ -236,7 +240,7 @@ class _SubscriptionSettings(TypedDict): @dataclass(frozen=True) class _IndexedHistory: indexed: list[ThreadItem] - refs: dict[tuple[str, str, str], list[ThreadItemRef]] + refs: dict[tuple[str, str, str, str], list[ThreadItemRef]] _ATTACHED_WRITE_LOCK_REASON = ( @@ -245,13 +249,36 @@ class _IndexedHistory: ) +def _has_default_codex_binding(lane: Lane) -> bool: + return ( + lane.provider == "codex" + and lane.binding_id == DEFAULT_CODEX_BINDING_ID + and lane.provider_session_id == lane.id + ) + + +def _require_default_codex_binding(lane: Lane, operation: str) -> str: + if not _has_default_codex_binding(lane): + raise CapabilityUnavailableError( + f"{operation} is unavailable for provider binding {lane.provider}:{lane.binding_id}" + ) + assert lane.provider_session_id is not None + return lane.provider_session_id + + def _can_write(lane: Lane, ctx: Ctx) -> bool: + if not _has_default_codex_binding(lane): + return False return lane.source == "own" or ctx.policy.allow_attached_writes def _capabilities(lane: Lane, ctx: Ctx) -> LaneCapabilities: + readable = _has_default_codex_binding(lane) writable = _can_write(lane, ctx) return LaneCapabilities( + read=readable, + sync=readable, + tail=readable, send=writable, context=writable, steer=writable, @@ -269,6 +296,8 @@ def _capabilities(lane: Lane, ctx: Ctx) -> LaneCapabilities: def _write_locked_reason(lane: Lane, ctx: Ctx) -> str | None: if _can_write(lane, ctx): return None + if not _has_default_codex_binding(lane): + return f"provider binding {lane.provider}:{lane.binding_id} execution is not supported" if lane.source == "attached": return _ATTACHED_WRITE_LOCK_REASON return "thread is not writable" @@ -278,6 +307,9 @@ def _ref(lane: Lane, ctx: Ctx) -> LaneRef: return LaneRef( ref=lane.ref, id=lane.id, + provider=lane.provider, + binding_id=lane.binding_id, + provider_session_id=lane.provider_session_id, handle=lane.handle, source=lane.source, status=lane.status, @@ -312,6 +344,9 @@ def _managed_identity(lane: Lane, ctx: Ctx) -> _ManagedIdentityPayload: "lane": lane.id, "ref": lane.ref, "id": lane.id, + "provider": lane.provider, + "binding_id": lane.binding_id, + "provider_session_id": lane.provider_session_id, "title": lane.handle.removeprefix("@"), "handle": lane.handle, "managed": True, @@ -502,7 +537,9 @@ async def _resolve_self(ctx: Ctx, caller_thread_id: str | None) -> Lane: thread_id = caller_thread_id or os.environ.get("CODEX_THREAD_ID") if not thread_id: raise ValidationError("self requires CODEX_THREAD_ID from the current Codex thread") - lane = await ctx.registry.find_lane(thread_id) + lane = await ctx.registry.find_lane_by_provider_session( + "codex", DEFAULT_CODEX_BINDING_ID, thread_id + ) if lane is None: raise ValidationError("self requires the current Codex thread to be managed by dispatch") return lane @@ -516,6 +553,10 @@ async def _resolve_thread_target(ctx: Ctx, ref: str) -> tuple[str, Lane | None]: def _require_writable(lane: Lane, ctx: Ctx) -> None: if _can_write(lane, ctx): return + if not _has_default_codex_binding(lane): + raise CapabilityUnavailableError( + f"provider binding {lane.provider}:{lane.binding_id} execution is not supported" + ) if lane.source == "attached": raise AuthorityError( f"lane {lane.handle} ({lane.ref}) has source=attached and is read-only by " @@ -530,8 +571,9 @@ def _require_writable(lane: Lane, ctx: Ctx) -> None: async def _prepare_attached_write(lane: Lane, ctx: Ctx) -> None: if lane.source == "attached": + native_id = _require_default_codex_binding(lane, "attached write") try: - await ctx.client.thread_resume(lane.id, exclude_turns=True) + await ctx.client.thread_resume(native_id, exclude_turns=True) except ClientAppServerError as exc: if exc.code == -32600 and "already has an active writer" in exc.message: raise CapabilityUnavailableError( @@ -1327,8 +1369,9 @@ async def _record_direct_send_receipt( await ctx.registry.upsert_message_receipt( MessageReceipt( lane=lane.id, - provider="codex", - provider_thread_id=lane.id, + provider=lane.provider, + binding_id=lane.binding_id, + provider_thread_id=lane.provider_session_id or lane.id, status=status, # type: ignore[arg-type] error=error, created_at=now, @@ -1352,8 +1395,9 @@ async def _record_queue_receipt( MessageReceipt( lane=lane.id, queued_message_id=queued_message_id, - provider="codex", - provider_thread_id=lane.id, + provider=lane.provider, + binding_id=lane.binding_id, + provider_thread_id=lane.provider_session_id or lane.id, dispatch_message_id=f"queue:{queued_message_id}", status=status, # type: ignore[arg-type] error=error, @@ -1450,7 +1494,8 @@ async def _send_message(inp: SendInput, ctx: Ctx) -> ActionAck: case "steer": turn_id = _require_active_turn(lane, "steer") await _prepare_attached_write(lane, ctx) - await ctx.client.turn_steer(lane.id, turn_id, wire.text, input_items=wire.input_items) + native_id = _require_default_codex_binding(lane, "steer") + await ctx.client.turn_steer(native_id, turn_id, wire.text, input_items=wire.input_items) await ctx.registry.log_action( "steer", lane=lane.id, detail=message_audit_detail(wire, ctx.capture) ) @@ -1465,8 +1510,9 @@ async def _send_message(inp: SendInput, ctx: Ctx) -> ActionAck: for item in rich.input_items if item.type == "text" ) + native_id = _require_default_codex_binding(lane, "context") await ctx.client.inject_items( - lane.id, + native_id, [{"type": "message", "role": "user", "content": parts}], ) await ctx.registry.log_action( @@ -1476,7 +1522,8 @@ async def _send_message(inp: SendInput, ctx: Ctx) -> ActionAck: case "interject": turn_id = _require_active_turn(lane, "interject") await _prepare_attached_write(lane, ctx) - await ctx.client.turn_interrupt(lane.id, turn_id) + native_id = _require_default_codex_binding(lane, "interject") + await ctx.client.turn_interrupt(native_id, turn_id) await ctx.registry.log_action("interrupt", lane=lane.id, detail="interject") return await _send_rich(lane, rich, wire, ctx, op="interject", prepare=False) case "queue": @@ -1517,8 +1564,9 @@ async def _send_rich( await _prepare_attached_write(lane, ctx) turn_settings = await load_turn_start_settings(ctx.registry, lane.id) await ctx.registry.update_lane_status(lane.id, "busy") + native_id = _require_default_codex_binding(lane, op) await ctx.client.turn_start( - lane.id, + native_id, wire.text, cwd=lane.cwd or ".", input_items=wire.input_items, @@ -1556,7 +1604,8 @@ async def steer(inp: LaneTextInput, ctx: Ctx) -> ActionAck: _require_writable(lane, ctx) turn_id = _require_active_turn(lane, "steer") await _prepare_attached_write(lane, ctx) - await ctx.client.turn_steer(lane.id, turn_id, inp.text) + native_id = _require_default_codex_binding(lane, "steer") + await ctx.client.turn_steer(native_id, turn_id, inp.text) await ctx.registry.log_action("steer", lane=lane.id, detail=inp.text[:120]) return ActionAck(**_managed_identity(lane, ctx), op="steer") @@ -1570,7 +1619,8 @@ async def brief(inp: LaneTextInput, ctx: Ctx) -> ActionAck: "content": [{"type": "input_text", "text": inp.text}], } await _prepare_attached_write(lane, ctx) - await ctx.client.inject_items(lane.id, [item]) + native_id = _require_default_codex_binding(lane, "context") + await ctx.client.inject_items(native_id, [item]) await ctx.registry.log_action("brief", lane=lane.id, detail=inp.text[:120]) return ActionAck(**_managed_identity(lane, ctx), op="brief") @@ -1580,7 +1630,8 @@ async def interrupt(inp: LaneInput, ctx: Ctx) -> ActionAck: _require_writable(lane, ctx) turn_id = _require_active_turn(lane, "interrupt") await _prepare_attached_write(lane, ctx) - await ctx.client.turn_interrupt(lane.id, turn_id) + native_id = _require_default_codex_binding(lane, "interrupt") + await ctx.client.turn_interrupt(native_id, turn_id) await ctx.registry.log_action("interrupt", lane=lane.id) return ActionAck(**_managed_identity(lane, ctx), op="interrupt") @@ -1590,7 +1641,8 @@ async def stop(inp: LaneInput, ctx: Ctx) -> ActionAck: _require_writable(lane, ctx) turn_id = _require_active_turn(lane, "stop") await _prepare_attached_write(lane, ctx) - await ctx.client.turn_interrupt(lane.id, turn_id) + native_id = _require_default_codex_binding(lane, "stop") + await ctx.client.turn_interrupt(native_id, turn_id) await ctx.registry.log_action("stop", lane=lane.id) return ActionAck(**_managed_identity(lane, ctx), op="stop") @@ -1904,11 +1956,12 @@ async def show(inp: ShowInput, ctx: Ctx) -> LaneDetail: model_settings = await ctx.registry.get_lane_model_settings(lane.id) transcript: list[TranscriptItem] = [] if inp.topology: - thread = await _read_thread_metadata(ctx, lane.id) + native_id = _require_default_codex_binding(lane, "topology") + thread = await _read_thread_metadata(ctx, native_id) await observe_thread(ctx.registry, thread, relationship_source="thread/read") descendants = await ctx.client.thread_list( limit=inp.topology_limit, - ancestor_thread_id=lane.id, + ancestor_thread_id=native_id, archived=False, sort_direction="desc", sort_key="updated_at", @@ -1921,10 +1974,11 @@ async def show(inp: ShowInput, ctx: Ctx) -> LaneDetail: relationship_source="thread/list:ancestor", ) if inp.include_transcript: - result = await ctx.client.thread_read(lane.id, include_turns=True) + native_id = _require_default_codex_binding(lane, "transcript") + result = await ctx.client.thread_read(native_id, include_turns=True) await index_codex_thread_read(ctx.registry, lane, result, ctx.capture) transcript = _transcript_from_thread(result, limit=inp.max_items) - topology = await topology_views(ctx.registry, [lane.id], max_nodes=inp.topology_limit) + topology = await lane_topology_views(ctx.registry, [lane], max_nodes=inp.topology_limit) return LaneDetail( **_ref(lane, ctx).model_dump(), active_turn_id=lane.active_turn_id, @@ -1943,6 +1997,7 @@ async def sync_lane(inp: LaneSyncInput, ctx: Ctx) -> LaneSyncResult: lane = await _register_attached_thread(thread, ctx, sync=False, audit_op="attach") else: lane = resolved.lane + _require_default_codex_binding(lane, "sync") jsonl_budget = max(1, inp.max_bytes // 2) try: async with asyncio.timeout(inp.max_seconds): @@ -2009,11 +2064,13 @@ async def rename_lane(inp: LaneRenameInput, ctx: Ctx) -> ThreadActionRef: await ctx.registry.log_action("lane-rename", lane=thread_id, detail=inp.new) return _action_ref(thread_id=thread_id) + thread_id = _require_default_codex_binding(lane, "rename") + handle = _handle(inp.new) existing = await ctx.registry.find_lane_by_handle(handle) if existing is not None and existing.id != lane.id: raise ValidationError(f"lane handle {handle!r} is already registered") - await ctx.client.thread_set_name(lane.id, handle.removeprefix("@")) + await ctx.client.thread_set_name(thread_id, handle.removeprefix("@")) await ctx.registry.update_lane_handle(lane.id, handle) await ctx.registry.log_action("lane-rename", lane=lane.id, detail=handle) return _action_ref(thread_id=lane.id, lane=await ctx.registry.get_lane(lane.id)) @@ -2024,9 +2081,10 @@ async def watch(inp: WatchInput, ctx: Ctx) -> WatchOutput: if resolved.lane is None: raise NotFoundError(f"no managed thread {inp.lane!r}") lane = resolved.lane + native_id = _require_default_codex_binding(lane, "tail") if inp.timeout == 0: return WatchOutput(**_managed_identity(lane, ctx), events=[], timed_out=True) - stream = ctx.client.raw_events(lane.id) + stream = ctx.client.raw_events(native_id) events: list[WatchEvent] = [] timed_out = False loop = asyncio.get_running_loop() @@ -2059,7 +2117,8 @@ async def transcript(inp: TranscriptInput, ctx: Ctx) -> TranscriptOutput: if resolved.lane is None: raise NotFoundError(f"no managed thread {inp.lane!r}") lane = resolved.lane - result = await ctx.client.thread_read(lane.id, include_turns=True) + native_id = _require_default_codex_binding(lane, "transcript") + result = await ctx.client.thread_read(native_id, include_turns=True) await index_codex_thread_read(ctx.registry, lane, result, ctx.capture) return TranscriptOutput( **_managed_identity(lane, ctx), @@ -2084,7 +2143,8 @@ async def history(inp: HistoryInput, ctx: Ctx) -> HistoryOutput: if inp.lane is None: raise ValidationError("history view requires a thread selector") lane = await _resolve(ctx, inp.lane) - result = await ctx.client.thread_read(lane.id, include_turns=True) + native_id = _require_default_codex_binding(lane, "history") + result = await ctx.client.thread_read(native_id, include_turns=True) summary, items, tools, files = await _history_details(lane, result, ctx) if mode == "summary": return HistoryOutput(mode="summary", thread=summary, tools=tools, files=files) @@ -2312,7 +2372,9 @@ async def query(inp: QueryInput, ctx: Ctx) -> QueryOutput: lane = lane_map.get(item.lane) if lane is None: continue - refs = refs_by_item.get((item.provider, item.provider_thread_id, item.item_id), []) + refs = refs_by_item.get( + (item.provider, item.binding_id, item.provider_thread_id, item.item_id), [] + ) matches.append(_query_match(lane, item, refs, query=inp.query)) return QueryOutput(query=inp.query, matches=matches, scanned=scanned) @@ -2490,7 +2552,11 @@ async def _search_one_thread( resolved = await resolve_thread_selector( ctx, inp.lane, allow_unmanaged_raw=True, allow_fuzzy=True ) - thread_id = resolved.thread_id + thread_id = ( + _require_default_codex_binding(resolved.lane, "search") + if resolved.lane is not None + else resolved.thread_id + ) result = await ctx.client.thread_read(thread_id, include_turns=True) try: thread = ThreadResult.model_validate(result).thread @@ -2721,7 +2787,8 @@ async def goal_get(inp: GoalGetInput, ctx: Ctx) -> GoalView: if resolved.lane is None: raise NotFoundError(f"no managed thread {inp.lane!r}") lane = resolved.lane - goal = await ctx.client.thread_goal_get(lane.id) + native_id = _require_default_codex_binding(lane, "goal read") + goal = await ctx.client.thread_goal_get(native_id) return GoalView(**_managed_identity(lane, ctx), goal=_goal(goal) if goal is not None else None) @@ -2729,15 +2796,16 @@ async def goal_set(inp: GoalSetInput, ctx: Ctx) -> GoalView: lane = await _resolve(ctx, inp.lane) _require_writable(lane, ctx) await _prepare_attached_write(lane, ctx) + native_id = _require_default_codex_binding(lane, "goal write") if inp.objective is None and inp.status is None and inp.token_budget is None: raise ValidationError("goal-set requires objective, status, or token_budget") - if inp.objective is None and await ctx.client.thread_goal_get(lane.id) is None: + if inp.objective is None and await ctx.client.thread_goal_get(native_id) is None: raise ValidationError( "goal-set requires objective when creating a goal; status and token_budget " "only update an existing goal" ) goal = await ctx.client.thread_goal_set( - lane.id, + native_id, objective=inp.objective, status=inp.status, token_budget=inp.token_budget, @@ -2750,7 +2818,8 @@ async def goal_clear(inp: GoalClearInput, ctx: Ctx) -> GoalView: lane = await _resolve(ctx, inp.lane) _require_writable(lane, ctx) await _prepare_attached_write(lane, ctx) - await ctx.client.thread_goal_clear(lane.id) + native_id = _require_default_codex_binding(lane, "goal write") + await ctx.client.thread_goal_clear(native_id) await ctx.registry.log_action("goal-clear", lane=lane.id) return GoalView(**_managed_identity(lane, ctx), goal=None) @@ -2759,6 +2828,7 @@ async def fork(inp: ForkInput, ctx: Ctx) -> LaneRef: source = await _resolve(ctx, inp.lane) _require_writable(source, ctx) await _prepare_attached_write(source, ctx) + source_native_id = _require_default_codex_binding(source, "fork") resolved_model = await resolve_model_settings( ctx, model=inp.model, @@ -2772,7 +2842,7 @@ async def fork(inp: ForkInput, ctx: Ctx) -> LaneRef: ctx, inp.permission_profile, cwd=str(Path(fork_cwd).expanduser().resolve()) ) thread = await ctx.client.thread_fork( - source.id, + source_native_id, cwd=inp.cwd or source.cwd, permission_profile=permission_profile, sandbox=inp.sandbox, @@ -2824,7 +2894,8 @@ async def rollback(inp: RollbackInput, ctx: Ctx) -> LaneRef: lane = await _resolve(ctx, inp.lane) _require_writable(lane, ctx) await _prepare_attached_write(lane, ctx) - await ctx.client.thread_rollback(lane.id, inp.turns) + native_id = _require_default_codex_binding(lane, "rollback") + await ctx.client.thread_rollback(native_id, inp.turns) await ctx.registry.set_active_turn(lane.id, None) await ctx.registry.update_lane_status(lane.id, "idle") await ctx.registry.log_action("rollback", lane=lane.id, detail=f"{inp.turns} turn(s)") @@ -2835,7 +2906,8 @@ async def compact(inp: CompactInput, ctx: Ctx) -> ActionAck: lane = await _resolve(ctx, inp.lane) _require_writable(lane, ctx) await _prepare_attached_write(lane, ctx) - await ctx.client.thread_compact_start(lane.id) + native_id = _require_default_codex_binding(lane, "compact") + await ctx.client.thread_compact_start(native_id) await ctx.registry.log_action("compact", lane=lane.id) return ActionAck(**_managed_identity(lane, ctx), op="compact") @@ -2848,8 +2920,12 @@ async def roster(inp: RosterInput, ctx: Ctx) -> Roster: resolved = await resolve_thread_selector( ctx, selector, allow_unmanaged_raw=True, allow_fuzzy=False ) - parent_thread_id = resolved.thread_id if inp.parent is not None else None - ancestor_thread_id = resolved.thread_id if inp.parent is None else None + if resolved.lane is not None: + native_id = _require_default_codex_binding(resolved.lane, "topology") + else: + native_id = resolved.thread_id + parent_thread_id = native_id if inp.parent is not None else None + ancestor_thread_id = native_id if inp.parent is None else None active = await ctx.client.thread_list( limit=inp.topology_limit, archived=False, @@ -2885,13 +2961,11 @@ async def roster(inp: RosterInput, ctx: Ctx) -> Roster: related = [*active, *archived] selected_ids = {thread.id for thread in related} if inp.root is not None: - selected_ids.add(resolved.thread_id) + selected_ids.add(native_id) lanes = [lane for lane in lanes if lane.id in selected_ids] syncs = await ctx.registry.get_lane_sync_many([lane.id for lane in lanes]) models = await ctx.registry.get_lane_model_settings_many([lane.id for lane in lanes]) - topology = await topology_views( - ctx.registry, [lane.id for lane in lanes], max_nodes=inp.topology_limit - ) + topology = await lane_topology_views(ctx.registry, lanes, max_nodes=inp.topology_limit) return Roster( lanes=[ _list_item( @@ -2946,10 +3020,15 @@ async def discover(inp: DiscoverInput, ctx: Ctx) -> Discovery: resolved = await resolve_thread_selector( ctx, selector, allow_unmanaged_raw=True, allow_fuzzy=False ) + native_id = ( + _require_default_codex_binding(resolved.lane, "discovery") + if resolved.lane is not None + else resolved.thread_id + ) if inp.parent is not None: - parent_thread_id = resolved.thread_id + parent_thread_id = native_id else: - ancestor_thread_id = resolved.thread_id + ancestor_thread_id = native_id threads = await ctx.client.thread_list( limit=inp.limit, archived=inp.archived, @@ -3227,6 +3306,8 @@ def _model_catalog_item(entry: ModelCatalogEntry) -> ModelCatalogItem: async def archive(inp: ThreadTargetInput, ctx: Ctx) -> ThreadActionRef: thread_id, lane = await _resolve_thread_target(ctx, inp.target) + if lane is not None: + thread_id = _require_default_codex_binding(lane, "archive") try: await ctx.client.thread_archive(thread_id) except ClientAppServerError as exc: @@ -3247,6 +3328,8 @@ def _is_no_rollout_archive_error(exc: ClientAppServerError) -> bool: async def restore(inp: ThreadTargetInput, ctx: Ctx) -> ThreadActionRef: thread_id, lane = await _resolve_thread_target(ctx, inp.target) + if lane is not None: + thread_id = _require_default_codex_binding(lane, "restore") thread = await ctx.client.thread_unarchive(thread_id) await observe_thread( ctx.registry, thread, lifecycle_state="active", relationship_source="thread/unarchive" diff --git a/src/outfitter/dispatch/core/history.py b/src/outfitter/dispatch/core/history.py index 15e0b76..12edde9 100644 --- a/src/outfitter/dispatch/core/history.py +++ b/src/outfitter/dispatch/core/history.py @@ -61,7 +61,7 @@ def history_items_from_thread( def history_items_from_indexed( items: list[ThreadItem], - refs_by_item: dict[tuple[str, str, str], list[ThreadItemRef]], + refs_by_item: dict[tuple[str, str, str, str], list[ThreadItemRef]], *, item_type: str | None = None, role: str | None = None, @@ -104,7 +104,7 @@ def history_items_from_indexed( def history_rollups_from_indexed( items: list[ThreadItem], - refs_by_item: dict[tuple[str, str, str], list[ThreadItemRef]], + refs_by_item: dict[tuple[str, str, str, str], list[ThreadItemRef]], ) -> tuple[list[HistoryToolStat], list[HistoryFileStat]]: projected = [ _indexed_history_item(item, refs_by_item.get(_item_identity(item), []), include_raw=False) @@ -315,8 +315,8 @@ def _indexed_history_item( ) -def _item_identity(item: ThreadItem) -> tuple[str, str, str]: - return (item.provider, item.provider_thread_id, item.item_id) +def _item_identity(item: ThreadItem) -> tuple[str, str, str, str]: + return (item.provider, item.binding_id, item.provider_thread_id, item.item_id) def _turns(thread: dict[str, object]) -> list[dict[str, object]]: diff --git a/src/outfitter/dispatch/core/history_index.py b/src/outfitter/dispatch/core/history_index.py index cca0a05..763965b 100644 --- a/src/outfitter/dispatch/core/history_index.py +++ b/src/outfitter/dispatch/core/history_index.py @@ -13,7 +13,6 @@ from outfitter.dispatch.registry.models import Lane, ThreadItem, ThreadItemRef, ThreadTurn from outfitter.dispatch.registry.store import Registry -_CODEX_PROVIDER = "codex" _TURN_STATUSES: set[str] = {"started", "completed", "failed", "interrupted", "unknown"} @@ -41,7 +40,7 @@ async def index_codex_thread_read( registry, lane, [turn for turn in turns if isinstance(turn, dict)], - provider_thread_id=_string(thread.get("id")) or lane.id, + provider_thread_id=_string(thread.get("id")) or lane.provider_session_id or lane.id, capture=capture, completion_source="thread-read", ) @@ -59,7 +58,7 @@ async def index_codex_turns_page( registry, lane, [turn.model_dump(by_alias=True, exclude_none=True) for turn in turns], - provider_thread_id=lane.id, + provider_thread_id=lane.provider_session_id or lane.id, capture=capture, completion_source="thread-turns-list", ) @@ -87,7 +86,8 @@ async def index_codex_items_page( item_ids.add(item_id) item, refs = normalize_codex_item( raw_item, - provider_thread_id=lane.id, + provider_thread_id=lane.provider_session_id or lane.id, + binding_id=lane.binding_id, lane=lane.id, turn_id=turn_id, inserted_at=now, @@ -98,8 +98,9 @@ async def index_codex_items_page( await registry.upsert_thread_history_snapshot( turns=[], items=indexed_items, - provider=_CODEX_PROVIDER, - provider_thread_id=lane.id, + provider=lane.provider, + binding_id=lane.binding_id, + provider_thread_id=lane.provider_session_id or lane.id, turn_ids=set(), item_ids=item_ids, prune_missing=False, @@ -131,7 +132,8 @@ async def _index_codex_turns( turn_error = bound_text(_turn_error(raw_turn.get("error")), policy) indexed_turns.append( ThreadTurn( - provider=_CODEX_PROVIDER, + provider=lane.provider, + binding_id=lane.binding_id, provider_thread_id=provider_thread_id, lane=lane.id, turn_id=turn_id, @@ -157,6 +159,7 @@ async def _index_codex_turns( item, refs = normalize_codex_item( raw_item, provider_thread_id=provider_thread_id, + binding_id=lane.binding_id, lane=lane.id, turn_id=turn_id, inserted_at=now, @@ -173,7 +176,8 @@ async def _index_codex_turns( await registry.upsert_thread_history_snapshot( turns=indexed_turns, items=indexed_items, - provider=_CODEX_PROVIDER, + provider=lane.provider, + binding_id=lane.binding_id, provider_thread_id=provider_thread_id, turn_ids=seen_turn_ids, item_ids=seen_item_ids, diff --git a/src/outfitter/dispatch/core/models.py b/src/outfitter/dispatch/core/models.py index 5c9ebaa..e5e7e66 100644 --- a/src/outfitter/dispatch/core/models.py +++ b/src/outfitter/dispatch/core/models.py @@ -720,6 +720,9 @@ class LaneCapabilities(BaseModel): class LaneRef(BaseModel): ref: str id: str + provider: str + binding_id: str + provider_session_id: str | None = None handle: str source: LaneSource status: LaneStatus @@ -744,13 +747,16 @@ class ThreadActionRef(BaseModel): class ManagedThreadIdentity(BaseModel): """Stable identity fields for outputs that refer to one managed thread. - ``lane`` stays as the compatibility field for the full Codex thread id. - ``id`` names the same full id explicitly for thread-oriented consumers. + ``lane`` stays as the compatibility alias for the stable Dispatch ``id``. + Default-Codex rows retain their native thread id as that stable key. """ lane: str ref: str id: str + provider: str + binding_id: str + provider_session_id: str | None = None title: str | None = None handle: str | None = None managed: bool = True @@ -790,6 +796,8 @@ class LaneSyncView(BaseModel): class ThreadTopologyNode(BaseModel): id: str + provider: str = "codex" + binding_id: str = "codex-default" managed: bool = False ref: str | None = None handle: str | None = None diff --git a/src/outfitter/dispatch/core/ops.py b/src/outfitter/dispatch/core/ops.py index aa3171f..409fa17 100644 --- a/src/outfitter/dispatch/core/ops.py +++ b/src/outfitter/dispatch/core/ops.py @@ -100,6 +100,9 @@ output={ "ref": "0BGeK1", "id": "lane-1", + "provider": "codex", + "binding_id": "codex-default", + "provider_session_id": "lane-1", "handle": "@alpha", "source": "own", "status": "idle", @@ -148,6 +151,9 @@ output={ "ref": "0BGeK1", "id": "lane-1", + "provider": "codex", + "binding_id": "codex-default", + "provider_session_id": "lane-1", "handle": "@[dispatch] alpha", "source": "own", "status": "idle", @@ -325,6 +331,9 @@ output={ "ref": "0ABFs1", "id": "T1", + "provider": "codex", + "binding_id": "codex-default", + "provider_session_id": "T1", "handle": "@T1", "source": "attached", "status": "idle", diff --git a/src/outfitter/dispatch/core/queue.py b/src/outfitter/dispatch/core/queue.py index 624c247..bc50fa9 100644 --- a/src/outfitter/dispatch/core/queue.py +++ b/src/outfitter/dispatch/core/queue.py @@ -11,6 +11,7 @@ from outfitter.dispatch.contracts.context import Ctx from outfitter.dispatch.contracts.errors import DispatchError, project_error from outfitter.dispatch.registry.models import Lane, MessageReceipt, QueuedMessage +from outfitter.dispatch.registry.store import DEFAULT_CODEX_BINDING_ID from .capture import bound_text from .model_registry import validate_lane_input_modalities @@ -32,6 +33,13 @@ async def drain_next_queued_message(ctx: Ctx, lane_id: str) -> bool: lane = await ctx.registry.find_lane(lane_id) if lane is None or lane.status != "idle": return False + if ( + lane.provider != "codex" + or lane.binding_id != DEFAULT_CODEX_BINDING_ID + or lane.provider_session_id != lane.id + ): + return False + native_id = lane.provider_session_id if await ctx.registry.lane_delivery_held(lane.id): return False message = await ctx.registry.next_pending_message(lane.id) @@ -63,11 +71,11 @@ async def drain_next_queued_message(ctx: Ctx, lane_id: str) -> bool: await validate_lane_input_modalities(ctx, lane.id, frozenset({"image"})) wire = await materialize_remote_images(rich) if lane.source == "attached" and ctx.policy.allow_attached_writes: - await ctx.client.thread_resume(lane.id, exclude_turns=True) + await ctx.client.thread_resume(native_id, exclude_turns=True) turn_settings = await load_turn_start_settings(ctx.registry, lane.id) await ctx.registry.update_lane_status(lane.id, "busy") await ctx.client.turn_start( - lane.id, + native_id, wire.text, cwd=lane.cwd or ".", input_items=wire.input_items, @@ -127,8 +135,9 @@ async def _record_queue_receipt( MessageReceipt( lane=lane.id, queued_message_id=message.id, - provider="codex", - provider_thread_id=lane.id, + provider=lane.provider, + binding_id=lane.binding_id, + provider_thread_id=lane.provider_session_id or lane.id, dispatch_message_id=f"queue:{message.id}", status=status, # type: ignore[arg-type] error=error, diff --git a/src/outfitter/dispatch/core/reactor.py b/src/outfitter/dispatch/core/reactor.py index a21da1a..19872bf 100644 --- a/src/outfitter/dispatch/core/reactor.py +++ b/src/outfitter/dispatch/core/reactor.py @@ -25,6 +25,7 @@ ) from outfitter.dispatch.contracts.context import Ctx from outfitter.dispatch.registry.models import EventWhen +from outfitter.dispatch.registry.store import DEFAULT_CODEX_BINDING_ID from .capacity import observe_codex_rate_limits from .capture import bound_text @@ -94,7 +95,9 @@ async def handle(self, event: LaneEvent) -> None: await registry.mark_provider_thread_state("codex", event.lane_id, "active") elif isinstance(event, ThreadDeleted): await registry.mark_provider_thread_state("codex", event.lane_id, "deleted") - lane = await registry.find_lane(event.lane_id) + lane = await registry.find_lane_by_provider_session( + "codex", DEFAULT_CODEX_BINDING_ID, event.lane_id + ) if lane is None: return # an event for a thread dispatch does not track await index_codex_lane_event(registry, lane, event, self._ctx.capture) diff --git a/src/outfitter/dispatch/core/server_requests.py b/src/outfitter/dispatch/core/server_requests.py index 755e4ee..93e250b 100644 --- a/src/outfitter/dispatch/core/server_requests.py +++ b/src/outfitter/dispatch/core/server_requests.py @@ -18,6 +18,7 @@ ProviderEvent, ServerRequest, ) +from outfitter.dispatch.registry.store import DEFAULT_CODEX_BINDING_ID from .server_request_policy import ( PlannedResponse, @@ -86,7 +87,13 @@ async def close(self) -> None: self._timeouts.clear() async def handle(self, request: ServerRequestReceived) -> ServerRequest: - lane = await self._ctx.registry.find_lane(request.lane_id) if request.lane_id else None + lane = ( + await self._ctx.registry.find_lane_by_provider_session( + "codex", DEFAULT_CODEX_BINDING_ID, request.lane_id + ) + if request.lane_id + else None + ) now = datetime.now(UTC) received_at = now.isoformat() timeout = self._ctx.policy.interactive_request_timeout_seconds @@ -240,8 +247,9 @@ async def _surface_attention( await ctx.registry.upsert_lane_runtime_state( LaneRuntimeState( lane=lane.id, - provider="codex", - provider_thread_id=lane.id, + provider=lane.provider, + binding_id=lane.binding_id, + provider_thread_id=lane.provider_session_id or lane.id, status=status, active_turn_id=(current.active_turn_id if current else lane.active_turn_id), latest_turn_id=(current.latest_turn_id if current else lane.latest_turn_id), @@ -304,8 +312,9 @@ async def _clear_attention_if_resolved(ctx: Ctx, lane_id: str) -> None: await ctx.registry.upsert_lane_runtime_state( LaneRuntimeState( lane=lane_id, - provider="codex", - provider_thread_id=lane_id, + provider=lane.provider, + binding_id=lane.binding_id, + provider_thread_id=lane.provider_session_id or lane.id, status=status, active_turn_id=active_turn_id, latest_turn_id=(current.latest_turn_id if current else lane.latest_turn_id), @@ -372,7 +381,8 @@ async def _record_request_event(ctx: Ctx, request: ServerRequest, state: str) -> now = datetime.now(UTC).isoformat() await ctx.registry.record_provider_event( ProviderEvent( - provider="codex", + provider=request.provider, + binding_id=request.binding_id, provider_thread_id=request.provider_thread_id, lane=request.lane, event_type=f"server_request.{state}", diff --git a/src/outfitter/dispatch/core/subscriptions.py b/src/outfitter/dispatch/core/subscriptions.py index 2e2cfef..ccc70b1 100644 --- a/src/outfitter/dispatch/core/subscriptions.py +++ b/src/outfitter/dispatch/core/subscriptions.py @@ -17,6 +17,7 @@ ) from outfitter.dispatch.contracts.context import Ctx from outfitter.dispatch.registry.models import Lane, Subscription +from outfitter.dispatch.registry.store import DEFAULT_CODEX_BINDING_ID from . import queue from .message_attribution import codex_thread_link, render_dispatch_message @@ -155,8 +156,14 @@ async def _deliver_subscription( async def _tail_text(ctx: Ctx, lane: Lane, tail: int) -> str | None: if tail <= 0: return None + if ( + lane.provider != "codex" + or lane.binding_id != DEFAULT_CODEX_BINDING_ID + or lane.provider_session_id != lane.id + ): + return None try: - result = await ctx.client.thread_read(lane.id, include_turns=True) + result = await ctx.client.thread_read(lane.provider_session_id, include_turns=True) except Exception as exc: ctx.log.warning("subscription.tail_read_failed", lane=lane.id, error=str(exc)) return None diff --git a/src/outfitter/dispatch/core/topology.py b/src/outfitter/dispatch/core/topology.py index 7de5499..f29d89c 100644 --- a/src/outfitter/dispatch/core/topology.py +++ b/src/outfitter/dispatch/core/topology.py @@ -5,11 +5,12 @@ from outfitter.dispatch.client.models import ThreadInfo from outfitter.dispatch.core.models import ThreadTopologyNode, ThreadTopologyView from outfitter.dispatch.registry.models import ( + Lane, ProviderThreadLifecycleState, ProviderThreadNode, ProviderThreadObservation, ) -from outfitter.dispatch.registry.store import Registry +from outfitter.dispatch.registry.store import DEFAULT_CODEX_BINDING_ID, Registry def observation_from_thread( @@ -25,6 +26,7 @@ def observation_from_thread( spawned_nickname = spawned.get("agent_nickname") spawned_role = spawned.get("agent_role") return ProviderThreadObservation( + binding_id=DEFAULT_CODEX_BINDING_ID, provider_thread_id=thread.id, session_id=thread.session_id, parent_thread_id=thread.parent_thread_id @@ -89,6 +91,8 @@ def _node(node: ProviderThreadNode | None, relation: str) -> ThreadTopologyNode thread = node.thread return ThreadTopologyNode( id=thread.provider_thread_id, + provider=thread.provider, + binding_id=thread.binding_id, managed=node.managed, ref=node.ref, handle=node.handle, @@ -107,12 +111,21 @@ def _nodes(nodes: list[ProviderThreadNode], relation: str) -> list[ThreadTopolog async def topology_views( - registry: Registry, thread_ids: list[str], *, max_nodes: int + registry: Registry, + thread_ids: list[str], + *, + max_nodes: int, + provider: str = "codex", + binding_id: str = DEFAULT_CODEX_BINDING_ID, ) -> dict[str, ThreadTopologyView]: if not thread_ids: return {} topology = await registry.get_provider_thread_topology( - "codex", thread_ids, max_nodes=max_nodes, max_depth=16 + provider, + thread_ids, + binding_id=binding_id, + max_nodes=max_nodes, + max_depth=16, ) indexed = {node.thread.provider_thread_id: node for node in topology.nodes} views: dict[str, ThreadTopologyView] = {} @@ -133,3 +146,29 @@ async def topology_views( observed_at=current.thread.last_seen_at if current is not None else None, ) return views + + +async def lane_topology_views( + registry: Registry, lanes: list[Lane], *, max_nodes: int +) -> dict[str, ThreadTopologyView]: + """Project topology by stable lane id without confusing it with native identity.""" + + views: dict[str, ThreadTopologyView] = {} + groups: dict[tuple[str, str], list[Lane]] = {} + for lane in lanes: + if lane.provider_session_id is None: + views[lane.id] = ThreadTopologyView() + continue + groups.setdefault((lane.provider, lane.binding_id), []).append(lane) + for (provider, binding_id), group in groups.items(): + native_views = await topology_views( + registry, + [lane.provider_session_id for lane in group if lane.provider_session_id is not None], + max_nodes=max_nodes, + provider=provider, + binding_id=binding_id, + ) + for lane in group: + assert lane.provider_session_id is not None + views[lane.id] = native_views[lane.provider_session_id] + return views diff --git a/src/outfitter/dispatch/daemon/supervisor.py b/src/outfitter/dispatch/daemon/supervisor.py index d4e63c4..c033333 100644 --- a/src/outfitter/dispatch/daemon/supervisor.py +++ b/src/outfitter/dispatch/daemon/supervisor.py @@ -19,6 +19,7 @@ from outfitter.dispatch.contracts.errors import DispatchError from outfitter.dispatch.core.permission_profiles import resolve_permission_profile from outfitter.dispatch.core.queue import drain_idle_queues +from outfitter.dispatch.registry.store import DEFAULT_CODEX_BINDING_ID class SupervisedClient(LaneClient, Protocol): @@ -92,6 +93,19 @@ async def _restore_lanes(self, client: SupervisedClient) -> None: """ validated_profiles: dict[tuple[str, str], str] = {} for lane in await self._ctx.registry.list_lanes(): + if ( + lane.provider != "codex" + or lane.binding_id != DEFAULT_CODEX_BINDING_ID + or lane.provider_session_id != lane.id + ): + self._ctx.log.info( + "lane.restore_unsupported_binding", + lane=lane.id, + provider=lane.provider, + binding_id=lane.binding_id, + ) + continue + native_id = lane.provider_session_id try: sync = await self._ctx.registry.get_lane_sync(lane.id) observed = sync is not None and sync.observation_enabled @@ -110,17 +124,17 @@ async def _restore_lanes(self, client: SupervisedClient) -> None: permission_profile = validated_profiles[key] try: await client.thread_resume( - lane.id, + native_id, permission_profile=permission_profile, exclude_turns=True, ) except AppServerError as exc: if exc.code != -32602: raise - await client.thread_resume(lane.id, permission_profile=permission_profile) + await client.thread_resume(native_id, permission_profile=permission_profile) self._ctx.log.info("lane.resumed", lane=lane.id, source=lane.source) else: - await client.thread_read(lane.id, include_turns=False) + await client.thread_read(native_id, include_turns=False) self._ctx.log.info("lane.metadata_read", lane=lane.id, source=lane.source) except (ClientError, DispatchError) as exc: await self._ctx.registry.update_lane_status(lane.id, "error") diff --git a/src/outfitter/dispatch/registry/ingest_harness.py b/src/outfitter/dispatch/registry/ingest_harness.py index b6babb1..52b6d8c 100644 --- a/src/outfitter/dispatch/registry/ingest_harness.py +++ b/src/outfitter/dispatch/registry/ingest_harness.py @@ -164,6 +164,7 @@ async def _write_synthetic_event( await store.record_provider_event( ProviderEvent( provider="codex", + binding_id="codex-default", provider_thread_id=thread_id, lane=lane, event_type="turn/completed", @@ -180,6 +181,7 @@ async def _write_synthetic_event( await store.upsert_thread_turn( ThreadTurn( provider="codex", + binding_id="codex-default", provider_thread_id=thread_id, turn_id=turn_id, lane=lane, @@ -193,6 +195,7 @@ async def _write_synthetic_event( await store.upsert_thread_item( ThreadItem( provider="codex", + binding_id="codex-default", provider_thread_id=thread_id, item_id=item_id, lane=lane, @@ -210,6 +213,7 @@ async def _write_synthetic_event( refs=[ ThreadItemRef( provider="codex", + binding_id="codex-default", provider_thread_id=thread_id, item_id=item_id, ref_type="file", @@ -221,6 +225,7 @@ async def _write_synthetic_event( MessageReceipt( lane=lane, provider="codex", + binding_id="codex-default", provider_thread_id=thread_id, dispatch_message_id=f"dispatch-message-{index:06d}", status="completed", @@ -236,6 +241,7 @@ async def _write_synthetic_event( LaneRuntimeState( lane=lane, provider="codex", + binding_id="codex-default", provider_thread_id=thread_id, status="idle", latest_turn_id=turn_id, diff --git a/src/outfitter/dispatch/registry/models.py b/src/outfitter/dispatch/registry/models.py index 33f75b3..f64c504 100644 --- a/src/outfitter/dispatch/registry/models.py +++ b/src/outfitter/dispatch/registry/models.py @@ -142,6 +142,7 @@ class ProviderEvent(BaseModel): id: int | None = None provider: str + binding_id: str = "codex-default" provider_thread_id: str lane: str | None = None event_type: str @@ -164,6 +165,7 @@ class ProviderThreadObservation(BaseModel): """ provider: str = "codex" + binding_id: str = "codex-default" provider_thread_id: str session_id: str | None = None parent_thread_id: str | None = None @@ -183,6 +185,7 @@ class ProviderThread(BaseModel): """A provider-owned thread identity retained independently of lanes.""" provider: str + binding_id: str = "codex-default" provider_thread_id: str session_id: str | None = None parent_thread_id: str | None = None @@ -327,6 +330,7 @@ class ServerRequest(BaseModel): id: int | None = None # local operator selector, assigned by the registry provider: Literal["codex"] = "codex" + binding_id: str = "codex-default" provider_session_id: str # unique for one App Server connection lifetime provider_thread_id: str | None = None lane: str | None = None @@ -345,6 +349,7 @@ class ThreadTurn(BaseModel): """Normalized turn lifecycle facts derived from provider events/history.""" provider: str + binding_id: str = "codex-default" provider_thread_id: str turn_id: str lane: str | None = None @@ -361,6 +366,7 @@ class ThreadItem(BaseModel): """Normalized history item indexed from provider transcript/history data.""" provider: str + binding_id: str = "codex-default" provider_thread_id: str item_id: str lane: str | None = None @@ -391,6 +397,7 @@ class ThreadItemRef(BaseModel): """Queryable reference extracted from a normalized history item.""" provider: str + binding_id: str = "codex-default" provider_thread_id: str item_id: str ref_type: str @@ -404,6 +411,7 @@ class MessageReceipt(BaseModel): lane: str | None = None queued_message_id: int | None = None provider: str + binding_id: str = "codex-default" provider_thread_id: str dispatch_message_id: str | None = None status: MessageReceiptStatus = "created" @@ -422,6 +430,7 @@ class LaneRuntimeState(BaseModel): lane: str provider: str + binding_id: str = "codex-default" provider_thread_id: str status: LaneStatus = "unknown" active_turn_id: str | None = None @@ -435,9 +444,12 @@ class LaneRuntimeState(BaseModel): class Lane(BaseModel): - """A managed Codex thread — one row of the ``lanes`` table.""" + """A managed provider thread — one row of the ``lanes`` table.""" - id: str # the App Server threadId + id: str # stable Dispatch key (native Codex id for the default binding) + provider: str = "codex" + binding_id: str = "codex-default" + provider_session_id: str | None = None ref: str # dispatch-local stable short ref ref_source: str ref_payload: str diff --git a/src/outfitter/dispatch/registry/refs.py b/src/outfitter/dispatch/registry/refs.py index 0663882..860f92a 100644 --- a/src/outfitter/dispatch/registry/refs.py +++ b/src/outfitter/dispatch/registry/refs.py @@ -6,6 +6,7 @@ BASE58BTC_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" CODEX_REF_SOURCE = "0" +GENERIC_REF_SOURCE = "1" def codex_ref_payload(thread_id: str) -> str: @@ -15,6 +16,13 @@ def codex_ref_payload(thread_id: str) -> str: return _base58btc(digest)[:4] +def generic_ref_payload(lane_id: str) -> str: + """Return a short payload derived from an opaque Dispatch lane key.""" + + digest = sha256(f"dispatch:{lane_id}".encode()).digest() + return _base58btc(digest)[:4] + + def make_ref(*, source: str, payload: str, mixer: str) -> str: return f"{source}{payload}{mixer}" diff --git a/src/outfitter/dispatch/registry/sql_compat.py b/src/outfitter/dispatch/registry/sql_compat.py index 576d1dc..7b27bea 100644 --- a/src/outfitter/dispatch/registry/sql_compat.py +++ b/src/outfitter/dispatch/registry/sql_compat.py @@ -71,11 +71,14 @@ def exercise_registry_sql(conn: Any) -> None: conn, """ INSERT INTO lanes ( - id, ref, ref_source, ref_payload, ref_mixer, handle, source, status, - pinned, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + id, provider, binding_id, provider_session_id, ref, ref_source, ref_payload, + ref_mixer, handle, source, status, pinned, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( + "L1", + "codex", + "codex-default", "L1", "0abc1234", "0", @@ -93,13 +96,14 @@ def exercise_registry_sql(conn: Any) -> None: conn, """ INSERT INTO provider_events ( - provider, provider_thread_id, lane, event_type, provider_event_id, + provider, binding_id, provider_thread_id, lane, event_type, provider_event_id, provider_turn_id, received_at, summary, payload, raw_retained - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING """, ( "codex", + "codex-default", "thread-1", "L1", "turn/started", @@ -115,15 +119,16 @@ def exercise_registry_sql(conn: Any) -> None: conn, """ INSERT INTO thread_turns ( - provider, provider_thread_id, turn_id, lane, status, started_at, + provider, binding_id, provider_thread_id, turn_id, lane, status, started_at, completion_source, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(provider, provider_thread_id, turn_id) DO UPDATE SET + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(provider, binding_id, provider_thread_id, turn_id) DO UPDATE SET status = excluded.status, updated_at = excluded.updated_at """, ( "codex", + "codex-default", "thread-1", "turn-1", "L1", @@ -137,16 +142,17 @@ def exercise_registry_sql(conn: Any) -> None: conn, """ INSERT INTO thread_items ( - provider, provider_thread_id, item_id, lane, turn_id, item_type, + provider, binding_id, provider_thread_id, item_id, lane, turn_id, item_type, role, text, tool, created_at, position, inserted_at, payload, raw_retained - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(provider, provider_thread_id, item_id) DO UPDATE SET + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(provider, binding_id, provider_thread_id, item_id) DO UPDATE SET text = excluded.text, position = excluded.position, payload = excluded.payload """, ( "codex", + "codex-default", "thread-1", "item-1", "L1", @@ -166,10 +172,10 @@ def exercise_registry_sql(conn: Any) -> None: conn, """ INSERT OR IGNORE INTO thread_item_refs ( - provider, provider_thread_id, item_id, ref_type, ref_value - ) VALUES (?, ?, ?, ?, ?) + provider, binding_id, provider_thread_id, item_id, ref_type, ref_value + ) VALUES (?, ?, ?, ?, ?, ?) """, - ("codex", "thread-1", "item-1", "tool", "bash"), + ("codex", "codex-default", "thread-1", "item-1", "tool", "bash"), ) commit(conn) @@ -203,17 +209,22 @@ def partial_conflict_target_supported(connect: Connect) -> bool: try: conn = connect(":memory:") try: - execute(conn, "CREATE TABLE events(provider TEXT NOT NULL, event_id TEXT)") execute( conn, - "CREATE UNIQUE INDEX idx_events ON events(provider, event_id) " + "CREATE TABLE events(provider TEXT NOT NULL, binding_id TEXT NOT NULL, " + "event_id TEXT)", + ) + execute( + conn, + "CREATE UNIQUE INDEX idx_events ON events(provider, binding_id, event_id) " "WHERE event_id IS NOT NULL", ) execute( conn, - "INSERT INTO events(provider, event_id) VALUES (?, ?) " - "ON CONFLICT(provider, event_id) WHERE event_id IS NOT NULL DO NOTHING", - ("codex", "event-1"), + "INSERT INTO events(provider, binding_id, event_id) VALUES (?, ?, ?) " + "ON CONFLICT(provider, binding_id, event_id) " + "WHERE event_id IS NOT NULL DO NOTHING", + ("codex", "codex-default", "event-1"), ) commit(conn) return True diff --git a/src/outfitter/dispatch/registry/store.py b/src/outfitter/dispatch/registry/store.py index 7a1d927..5f717b9 100644 --- a/src/outfitter/dispatch/registry/store.py +++ b/src/outfitter/dispatch/registry/store.py @@ -9,6 +9,7 @@ import asyncio import json +import re from collections.abc import AsyncIterator, Callable, Coroutine from contextlib import asynccontextmanager from dataclasses import dataclass @@ -20,7 +21,11 @@ import aiosqlite -from outfitter.dispatch.contracts.errors import DeliveryConflictError, NotFoundError +from outfitter.dispatch.contracts.errors import ( + DeliveryConflictError, + NotFoundError, + ValidationError, +) from .delivery import ( DeliveryExecutionStatus, @@ -63,10 +68,18 @@ Trigger, WhenAdapter, ) -from .refs import BASE58BTC_ALPHABET, CODEX_REF_SOURCE, codex_ref_payload, make_ref +from .refs import ( + BASE58BTC_ALPHABET, + CODEX_REF_SOURCE, + GENERIC_REF_SOURCE, + codex_ref_payload, + generic_ref_payload, + make_ref, +) Clock = Callable[[], datetime] -SCHEMA_VERSION = 23 +SCHEMA_VERSION = 24 +DEFAULT_CODEX_BINDING_ID = "codex-default" class _ReentrantAsyncLock: @@ -164,6 +177,7 @@ class ProviderThreadTopology: """Bounded parent/fork topology around one or more provider threads.""" provider: str + binding_id: str requested_thread_ids: list[str] nodes: list[ProviderThreadNode] roots: dict[str, ProviderThreadNode | None] @@ -178,11 +192,11 @@ class ProviderThreadTopology: truncated: bool -ThreadItemIdentity = tuple[str, str, str] +ThreadItemIdentity = tuple[str, str, str, str] def _thread_item_identity(item: ThreadItem | ThreadItemRef) -> ThreadItemIdentity: - return (item.provider, item.provider_thread_id, item.item_id) + return (item.provider, item.binding_id, item.provider_thread_id, item.item_id) def _ref_exists_sql(ref_type: str, *, operator: str = "instr", exact: bool = False) -> str: @@ -198,6 +212,7 @@ def _ref_exists_sql(ref_type: str, *, operator: str = "instr", exact: bool = Fal return ( "EXISTS (SELECT 1 FROM thread_item_refs refs " "WHERE refs.provider = items.provider " + "AND refs.binding_id = items.binding_id " "AND refs.provider_thread_id = items.provider_thread_id " "AND refs.item_id = items.item_id " "AND refs.ref_type = " @@ -215,6 +230,7 @@ def _ref_path_under_sql() -> str: return ( "EXISTS (SELECT 1 FROM thread_item_refs refs " "WHERE refs.provider = items.provider " + "AND refs.binding_id = items.binding_id " "AND refs.provider_thread_id = items.provider_thread_id " "AND refs.item_id = items.item_id " "AND refs.ref_type = 'file' " @@ -226,6 +242,18 @@ def _extension_suffix(ext: str) -> str: return ext if ext.startswith(".") else f".{ext}" +def _initial_provider_session_id( + lane_id: str, provider: str, binding_id: str, provider_session_id: str | None +) -> str | None: + if provider == "codex" and binding_id == DEFAULT_CODEX_BINDING_ID: + if provider_session_id not in (None, lane_id): + raise ValueError("a new default-Codex lane must retain its native id as the lane id") + return lane_id + if not lane_id.startswith("dsp_"): + raise ValueError("non-default provider lanes require an opaque dsp_ Dispatch id") + return provider_session_id + + _QUEUED_MESSAGES_SCHEMA = """ CREATE TABLE IF NOT EXISTS queued_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -273,6 +301,7 @@ def _extension_suffix(ext: str) -> str: _PROVIDER_THREADS_SCHEMA = """ CREATE TABLE IF NOT EXISTS provider_threads ( provider TEXT NOT NULL, + binding_id TEXT NOT NULL, provider_thread_id TEXT NOT NULL, session_id TEXT, parent_thread_id TEXT, @@ -290,12 +319,8 @@ def _extension_suffix(ext: str) -> str: last_seen_at TEXT NOT NULL, archived_at TEXT, deleted_at TEXT, - PRIMARY KEY(provider, provider_thread_id) + PRIMARY KEY(provider, binding_id, provider_thread_id) ); -CREATE INDEX IF NOT EXISTS idx_provider_threads_parent -ON provider_threads(provider, parent_thread_id); -CREATE INDEX IF NOT EXISTS idx_provider_threads_fork -ON provider_threads(provider, forked_from_id); """ _PROVIDER_CAPACITY_SCHEMA = """ @@ -325,6 +350,7 @@ def _extension_suffix(ext: str) -> str: CREATE TABLE IF NOT EXISTS provider_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, provider TEXT NOT NULL, + binding_id TEXT NOT NULL, provider_thread_id TEXT NOT NULL, lane TEXT, event_type TEXT NOT NULL, @@ -339,16 +365,12 @@ def _extension_suffix(ext: str) -> str: raw_retained INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(lane) REFERENCES lanes(id) ON DELETE SET NULL ); -CREATE UNIQUE INDEX IF NOT EXISTS idx_provider_events_provider_event_id -ON provider_events(provider, provider_event_id) -WHERE provider_event_id IS NOT NULL; -CREATE INDEX IF NOT EXISTS idx_provider_events_thread_received -ON provider_events(provider, provider_thread_id, received_at); CREATE INDEX IF NOT EXISTS idx_provider_events_lane_received ON provider_events(lane, received_at); CREATE TABLE IF NOT EXISTS thread_turns ( provider TEXT NOT NULL, + binding_id TEXT NOT NULL, provider_thread_id TEXT NOT NULL, turn_id TEXT NOT NULL, lane TEXT, @@ -359,7 +381,7 @@ def _extension_suffix(ext: str) -> str: error TEXT, completion_source TEXT, updated_at TEXT NOT NULL, - PRIMARY KEY(provider, provider_thread_id, turn_id), + PRIMARY KEY(provider, binding_id, provider_thread_id, turn_id), FOREIGN KEY(lane) REFERENCES lanes(id) ON DELETE SET NULL ); CREATE INDEX IF NOT EXISTS idx_thread_turns_lane_updated @@ -367,6 +389,7 @@ def _extension_suffix(ext: str) -> str: CREATE TABLE IF NOT EXISTS thread_items ( provider TEXT NOT NULL, + binding_id TEXT NOT NULL, provider_thread_id TEXT NOT NULL, item_id TEXT NOT NULL, lane TEXT, @@ -391,23 +414,22 @@ def _extension_suffix(ext: str) -> str: inserted_at TEXT NOT NULL, payload TEXT, raw_retained INTEGER NOT NULL DEFAULT 0, - PRIMARY KEY(provider, provider_thread_id, item_id), + PRIMARY KEY(provider, binding_id, provider_thread_id, item_id), FOREIGN KEY(lane) REFERENCES lanes(id) ON DELETE SET NULL ); CREATE INDEX IF NOT EXISTS idx_thread_items_lane_inserted ON thread_items(lane, position, inserted_at); -CREATE INDEX IF NOT EXISTS idx_thread_items_turn -ON thread_items(provider, provider_thread_id, turn_id); CREATE TABLE IF NOT EXISTS thread_item_refs ( provider TEXT NOT NULL, + binding_id TEXT NOT NULL, provider_thread_id TEXT NOT NULL, item_id TEXT NOT NULL, ref_type TEXT NOT NULL, ref_value TEXT NOT NULL, - PRIMARY KEY(provider, provider_thread_id, item_id, ref_type, ref_value), - FOREIGN KEY(provider, provider_thread_id, item_id) - REFERENCES thread_items(provider, provider_thread_id, item_id) + PRIMARY KEY(provider, binding_id, provider_thread_id, item_id, ref_type, ref_value), + FOREIGN KEY(provider, binding_id, provider_thread_id, item_id) + REFERENCES thread_items(provider, binding_id, provider_thread_id, item_id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_thread_item_refs_lookup @@ -418,6 +440,7 @@ def _extension_suffix(ext: str) -> str: lane TEXT, queued_message_id INTEGER, provider TEXT NOT NULL, + binding_id TEXT NOT NULL, provider_thread_id TEXT NOT NULL, dispatch_message_id TEXT, status TEXT NOT NULL, @@ -441,6 +464,7 @@ def _extension_suffix(ext: str) -> str: CREATE TABLE IF NOT EXISTS lane_runtime_state ( lane TEXT PRIMARY KEY, provider TEXT NOT NULL, + binding_id TEXT NOT NULL, provider_thread_id TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'unknown', active_turn_id TEXT, @@ -459,6 +483,7 @@ def _extension_suffix(ext: str) -> str: CREATE TABLE IF NOT EXISTS server_requests ( id INTEGER PRIMARY KEY AUTOINCREMENT, provider TEXT NOT NULL CHECK (provider = 'codex'), + binding_id TEXT NOT NULL, provider_session_id TEXT NOT NULL, provider_thread_id TEXT, provider_thread_key TEXT NOT NULL, @@ -473,13 +498,9 @@ def _extension_suffix(ext: str) -> str: resolved_at TEXT, response_summary TEXT, error TEXT, - UNIQUE(provider, provider_session_id, provider_thread_key, request_id_json), + UNIQUE(provider, binding_id, provider_session_id, provider_thread_key, request_id_json), FOREIGN KEY(lane) REFERENCES lanes(id) ON DELETE SET NULL ); -CREATE INDEX IF NOT EXISTS idx_server_requests_pending -ON server_requests(provider, provider_session_id, state, deadline_at, received_at); -CREATE INDEX IF NOT EXISTS idx_server_requests_lane_pending -ON server_requests(lane, provider_session_id, state, received_at); """ @@ -490,6 +511,9 @@ def _utcnow() -> datetime: _SCHEMA = f""" CREATE TABLE IF NOT EXISTS lanes ( id TEXT PRIMARY KEY, + provider TEXT NOT NULL, + binding_id TEXT NOT NULL, + provider_session_id TEXT, ref TEXT NOT NULL UNIQUE, ref_source TEXT NOT NULL, ref_payload TEXT NOT NULL, @@ -685,7 +709,41 @@ def _utcnow() -> datetime: {_DELIVERIES_SCHEMA} """ -REGISTRY_SCHEMA_SQL = _SCHEMA +_BINDING_SCOPE_INDEX_STATEMENTS = ( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_lanes_provider_session " + "ON lanes(provider, binding_id, provider_session_id) " + "WHERE provider_session_id IS NOT NULL", + "CREATE INDEX IF NOT EXISTS idx_provider_threads_parent " + "ON provider_threads(provider, binding_id, parent_thread_id)", + "CREATE INDEX IF NOT EXISTS idx_provider_threads_fork " + "ON provider_threads(provider, binding_id, forked_from_id)", + "CREATE UNIQUE INDEX IF NOT EXISTS idx_provider_events_provider_event_id " + "ON provider_events(provider, binding_id, provider_event_id) " + "WHERE provider_event_id IS NOT NULL", + "CREATE INDEX IF NOT EXISTS idx_provider_events_thread_received " + "ON provider_events(provider, binding_id, provider_thread_id, received_at)", + "CREATE INDEX IF NOT EXISTS idx_provider_events_lane_received " + "ON provider_events(lane, received_at)", + "CREATE INDEX IF NOT EXISTS idx_thread_turns_lane_updated ON thread_turns(lane, updated_at)", + "CREATE INDEX IF NOT EXISTS idx_thread_items_lane_inserted " + "ON thread_items(lane, position, inserted_at)", + "CREATE INDEX IF NOT EXISTS idx_thread_items_turn " + "ON thread_items(provider, binding_id, provider_thread_id, turn_id)", + "CREATE INDEX IF NOT EXISTS idx_thread_item_refs_lookup " + "ON thread_item_refs(ref_type, ref_value)", + "CREATE UNIQUE INDEX IF NOT EXISTS idx_message_receipts_dispatch_message_id " + "ON message_receipts(dispatch_message_id) " + "WHERE dispatch_message_id IS NOT NULL", + "CREATE INDEX IF NOT EXISTS idx_message_receipts_lane_updated " + "ON message_receipts(lane, updated_at)", + "CREATE INDEX IF NOT EXISTS idx_server_requests_pending " + "ON server_requests(provider, binding_id, provider_session_id, state, " + "deadline_at, received_at)", + "CREATE INDEX IF NOT EXISTS idx_server_requests_lane_pending " + "ON server_requests(lane, binding_id, provider_session_id, state, received_at)", +) + +REGISTRY_SCHEMA_SQL = _SCHEMA + ";\n".join(_BINDING_SCOPE_INDEX_STATEMENTS) + ";\n" class Registry: @@ -715,11 +773,15 @@ async def open(cls, path: str | Path = ":memory:", now: Clock = _utcnow) -> Regi f"registry schema version {user_version} is newer than supported " f"version {SCHEMA_VERSION}" ) - await store._conn.executescript(_SCHEMA) - if user_version < SCHEMA_VERSION: - await store._migrate(user_version) - await store._conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") - await store._conn.commit() + try: + await store._conn.executescript(_SCHEMA) + if user_version < SCHEMA_VERSION: + await store._migrate(user_version) + await store._conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") + await store._conn.commit() + except BaseException: + await store._conn.close() + raise return store @_serialized_access @@ -806,6 +868,127 @@ async def _migrate(self, user_version: int) -> None: await self._ensure_queued_message_content_column() if user_version < 22: await self._ensure_deliveries_table() + if user_version < 24: + await self._ensure_binding_scope_v24() + + async def _ensure_binding_scope_v24(self) -> None: + """Add binding identity without changing stable lane keys or local row ids.""" + + await self._conn.commit() + await self._conn.execute("PRAGMA foreign_keys = OFF") + try: + await self._conn.execute("BEGIN IMMEDIATE") + sequence_high_water: dict[str, int] = {} + async with self._conn.execute( + "SELECT name, seq FROM sqlite_sequence " + "WHERE name IN ('provider_events', 'message_receipts', 'server_requests')" + ) as cur: + sequence_high_water = { + str(row["name"]): int(row["seq"]) for row in await cur.fetchall() + } + async with self._conn.execute("PRAGMA table_info(lanes)") as cur: + lane_columns = {str(row["name"]) for row in await cur.fetchall()} + if "provider" not in lane_columns: + await self._conn.execute( + "ALTER TABLE lanes ADD COLUMN provider TEXT NOT NULL DEFAULT 'codex'" + ) + if "binding_id" not in lane_columns: + await self._conn.execute( + "ALTER TABLE lanes ADD COLUMN binding_id TEXT NOT NULL " + f"DEFAULT '{DEFAULT_CODEX_BINDING_ID}'" + ) + added_provider_session_id = "provider_session_id" not in lane_columns + if added_provider_session_id: + await self._conn.execute("ALTER TABLE lanes ADD COLUMN provider_session_id TEXT") + await self._conn.execute( + "UPDATE lanes SET provider = 'codex', binding_id = ?, provider_session_id = id", + (DEFAULT_CODEX_BINDING_ID,), + ) + + schemas = { + "provider_threads": _PROVIDER_THREADS_SCHEMA, + "provider_events": _PROVIDER_HISTORY_SCHEMA, + "thread_turns": _PROVIDER_HISTORY_SCHEMA, + "thread_items": _PROVIDER_HISTORY_SCHEMA, + "thread_item_refs": _PROVIDER_HISTORY_SCHEMA, + "message_receipts": _PROVIDER_HISTORY_SCHEMA, + "lane_runtime_state": _PROVIDER_HISTORY_SCHEMA, + "server_requests": _SERVER_REQUESTS_SCHEMA, + } + rebuilt: list[str] = [] + for table, schema in schemas.items(): + async with self._conn.execute(f"PRAGMA table_info({table})") as cur: + old_columns = [str(row["name"]) for row in await cur.fetchall()] + if not old_columns or "binding_id" in old_columns: + continue + match = re.search( + rf"CREATE TABLE IF NOT EXISTS {table} \((.*?)\n\);", + schema, + re.DOTALL, + ) + if match is None: + raise RuntimeError(f"missing v24 schema for {table}") + create_sql = f"CREATE TABLE {table}_v24 ({match.group(1)}\n)" + if table == "thread_item_refs": + create_sql = create_sql.replace( + "REFERENCES thread_items(", "REFERENCES thread_items_v24(" + ) + await self._conn.execute(create_sql) + new_columns = [ + str(row["name"]) + for row in await ( + await self._conn.execute(f"PRAGMA table_info({table}_v24)") + ).fetchall() + ] + select_parts = ["?" if column == "binding_id" else column for column in new_columns] + await self._conn.execute( + f"INSERT INTO {table}_v24 ({', '.join(new_columns)}) " + f"SELECT {', '.join(select_parts)} FROM {table}", + (DEFAULT_CODEX_BINDING_ID,), + ) + rebuilt.append(table) + + if "thread_item_refs" in rebuilt: + await self._conn.execute("DROP TABLE thread_item_refs") + for table in rebuilt: + if table != "thread_item_refs": + await self._conn.execute(f"DROP TABLE {table}") + for table in rebuilt: + if table != "thread_item_refs": + await self._conn.execute(f"ALTER TABLE {table}_v24 RENAME TO {table}") + if "thread_item_refs" in rebuilt: + await self._conn.execute( + "ALTER TABLE thread_item_refs_v24 RENAME TO thread_item_refs" + ) + + for table, sequence in sequence_high_water.items(): + cur = await self._conn.execute( + "UPDATE sqlite_sequence SET seq = CASE WHEN seq < ? THEN ? ELSE seq END " + "WHERE name = ?", + (sequence, sequence, table), + ) + if cur.rowcount == 0: + await self._conn.execute( + "INSERT INTO sqlite_sequence(name, seq) VALUES (?, ?)", + (table, sequence), + ) + + await self._create_binding_scope_indexes() + async with self._conn.execute("PRAGMA foreign_key_check") as cur: + violations = await cur.fetchall() + if violations: + raise RuntimeError(f"v24 foreign key check failed: {violations!r}") + await self._conn.execute(f"PRAGMA user_version = {SCHEMA_VERSION}") + await self._conn.commit() + except BaseException: + await self._conn.rollback() + raise + finally: + await self._conn.execute("PRAGMA foreign_keys = ON") + + async def _create_binding_scope_indexes(self) -> None: + for statement in _BINDING_SCOPE_INDEX_STATEMENTS: + await self._conn.execute(statement) async def _ensure_deliveries_table(self) -> None: await self._conn.executescript(_DELIVERIES_SCHEMA) @@ -1183,11 +1366,20 @@ async def add_lane( cwd: str | None = None, status: LaneStatus = "unknown", pinned: bool = False, + provider: str = "codex", + binding_id: str = DEFAULT_CODEX_BINDING_ID, + provider_session_id: str | None = None, ) -> Lane: now = self._now() - ref, ref_source, ref_payload, ref_mixer = await self._allocate_ref_parts(id) + native_id = _initial_provider_session_id(id, provider, binding_id, provider_session_id) + ref, ref_source, ref_payload, ref_mixer = await self._allocate_ref_parts( + id, provider=provider, binding_id=binding_id, provider_session_id=native_id + ) lane = Lane( id=id, + provider=provider, + binding_id=binding_id, + provider_session_id=native_id, ref=ref, ref_source=ref_source, ref_payload=ref_payload, @@ -1220,13 +1412,22 @@ async def add_lane_with_sync( pinned: bool = False, audit_op: str | None = None, audit_detail: str | None = None, + provider: str = "codex", + binding_id: str = DEFAULT_CODEX_BINDING_ID, + provider_session_id: str | None = None, ) -> tuple[Lane, LaneSync]: if sync.lane != id: raise ValueError(f"sync lane {sync.lane!r} does not match lane id {id!r}") now = self._now() - ref, ref_source, ref_payload, ref_mixer = await self._allocate_ref_parts(id) + native_id = _initial_provider_session_id(id, provider, binding_id, provider_session_id) + ref, ref_source, ref_payload, ref_mixer = await self._allocate_ref_parts( + id, provider=provider, binding_id=binding_id, provider_session_id=native_id + ) lane = Lane( id=id, + provider=provider, + binding_id=binding_id, + provider_session_id=native_id, ref=ref, ref_source=ref_source, ref_payload=ref_payload, @@ -1254,12 +1455,16 @@ async def add_lane_with_sync( async def _insert_lane(self, lane: Lane) -> None: await self._conn.execute( - "INSERT INTO lanes (id, ref, ref_source, ref_payload, ref_mixer, handle, role, cwd, " + "INSERT INTO lanes (id, provider, binding_id, provider_session_id, ref, ref_source, " + "ref_payload, ref_mixer, handle, role, cwd, " "source, status, pinned, active_turn_id, latest_turn_id, latest_turn_status, " "latest_error, latest_error_at, created_at, updated_at, last_event_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ( lane.id, + lane.provider, + lane.binding_id, + lane.provider_session_id, lane.ref, lane.ref_source, lane.ref_payload, @@ -1281,19 +1486,77 @@ async def _insert_lane(self, lane: Lane) -> None: ), ) - async def _allocate_ref_parts(self, thread_id: str) -> tuple[str, str, str, str]: - source = CODEX_REF_SOURCE - payload = codex_ref_payload(thread_id) + async def _allocate_ref_parts( + self, + lane_id: str, + *, + provider: str = "codex", + binding_id: str = DEFAULT_CODEX_BINDING_ID, + provider_session_id: str | None = None, + ) -> tuple[str, str, str, str]: + native_id = ( + lane_id + if provider == "codex" + and binding_id == DEFAULT_CODEX_BINDING_ID + and provider_session_id is None + else provider_session_id + ) + is_default_codex = ( + provider == "codex" and binding_id == DEFAULT_CODEX_BINDING_ID and native_id == lane_id + ) + source = CODEX_REF_SOURCE if is_default_codex else GENERIC_REF_SOURCE + payload = codex_ref_payload(lane_id) if is_default_codex else generic_ref_payload(lane_id) for mixer in BASE58BTC_ALPHABET: candidate = make_ref(source=source, payload=payload, mixer=mixer) existing = await self.find_lane_by_ref(candidate) - if existing is None or existing.id == thread_id: + if existing is None or existing.id == lane_id: return candidate, source, payload, mixer raise RuntimeError( - f"ref mixer alphabet exhausted for Codex thread hash payload {payload!r}; " - "use the full Codex thread id" + f"ref mixer alphabet exhausted for lane payload {payload!r}; use the lane id" ) + @_serialized_access + async def find_lane_by_provider_session( + self, provider: str, binding_id: str, provider_session_id: str + ) -> Lane | None: + async with self._conn.execute( + "SELECT * FROM lanes WHERE provider = ? AND binding_id = ? AND provider_session_id = ?", + (provider, binding_id, provider_session_id), + ) as cur: + row = await cur.fetchone() + return _row_to_lane(row) if row is not None else None + + @_serialized_access + async def update_lane_provider_session( + self, + lane_id: str, + *, + provider: str, + binding_id: str, + provider_session_id: str, + ) -> Lane: + """Record native continuation evidence without permitting a binding retarget.""" + + if ( + provider == "codex" + and binding_id == DEFAULT_CODEX_BINDING_ID + and provider_session_id != lane_id + ): + raise ValidationError( + "default-Codex provider session identity must equal the stable lane id" + ) + async with self._transaction(): + cur = await self._conn.execute( + "UPDATE lanes SET provider_session_id = ?, updated_at = ? " + "WHERE id = ? AND provider = ? AND binding_id = ?", + (provider_session_id, self.now_iso(), lane_id, provider, binding_id), + ) + if cur.rowcount != 1: + raise NotFoundError( + f"no lane {lane_id!r} for provider binding {provider}:{binding_id}" + ) + return await self.get_lane(lane_id) + @_serialized_access async def find_lane(self, lane_id: str) -> Lane | None: async with self._conn.execute("SELECT * FROM lanes WHERE id = ?", (lane_id,)) as cur: @@ -2458,7 +2721,9 @@ async def upsert_provider_threads( saved: list[ProviderThread] = [] for observation in observations: thread = await self.get_provider_thread( - observation.provider, observation.provider_thread_id + observation.provider, + observation.provider_thread_id, + binding_id=observation.binding_id, ) if thread is None: raise RuntimeError("provider thread upsert did not return a row") @@ -2473,12 +2738,12 @@ async def _upsert_provider_thread_row(self, observation: ProviderThreadObservati archived_at = observed_at if lifecycle_state == "archived" else None deleted_at = observed_at if lifecycle_state == "deleted" else None await self._conn.execute( - "INSERT INTO provider_threads (provider, provider_thread_id, session_id, " + "INSERT INTO provider_threads (provider, binding_id, provider_thread_id, session_id, " "parent_thread_id, forked_from_id, source_kind, thread_source, agent_nickname, " "agent_role, agent_depth, lifecycle_state, relationship_source, confidence, " "first_seen_at, last_seen_at, archived_at, deleted_at) VALUES (" - "?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " - "ON CONFLICT(provider, provider_thread_id) DO UPDATE SET " + "?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + "ON CONFLICT(provider, binding_id, provider_thread_id) DO UPDATE SET " "session_id = COALESCE(excluded.session_id, provider_threads.session_id), " "parent_thread_id = COALESCE(excluded.parent_thread_id, " "provider_threads.parent_thread_id), " @@ -2503,6 +2768,7 @@ async def _upsert_provider_thread_row(self, observation: ProviderThreadObservati "ELSE provider_threads.deleted_at END", ( observation.provider, + observation.binding_id, observation.provider_thread_id, observation.session_id, observation.parent_thread_id, @@ -2527,11 +2793,16 @@ async def _upsert_provider_thread_row(self, observation: ProviderThreadObservati @_serialized_access async def get_provider_thread( - self, provider: str, provider_thread_id: str + self, + provider: str, + provider_thread_id: str, + *, + binding_id: str = DEFAULT_CODEX_BINDING_ID, ) -> ProviderThread | None: async with self._conn.execute( - "SELECT * FROM provider_threads WHERE provider = ? AND provider_thread_id = ?", - (provider, provider_thread_id), + "SELECT * FROM provider_threads WHERE provider = ? AND binding_id = ? " + "AND provider_thread_id = ?", + (provider, binding_id, provider_thread_id), ) as cur: row = await cur.fetchone() return _row_to_provider_thread(row) if row is not None else None @@ -2541,6 +2812,7 @@ async def list_provider_threads( self, *, provider: str | None = None, + binding_id: str | None = None, lifecycle_state: ProviderThreadLifecycleState | None = None, ) -> list[ProviderThread]: clauses: list[str] = [] @@ -2548,13 +2820,16 @@ async def list_provider_threads( if provider is not None: clauses.append("provider = ?") params.append(provider) + if binding_id is not None: + clauses.append("binding_id = ?") + params.append(binding_id) if lifecycle_state is not None: clauses.append("lifecycle_state = ?") params.append(lifecycle_state) where = f" WHERE {' AND '.join(clauses)}" if clauses else "" async with self._conn.execute( "SELECT * FROM provider_threads" - f"{where} ORDER BY provider, first_seen_at, provider_thread_id", + f"{where} ORDER BY provider, binding_id, first_seen_at, provider_thread_id", tuple(params), ) as cur: rows = await cur.fetchall() @@ -2567,11 +2842,13 @@ async def mark_provider_thread_state( provider_thread_id: str, lifecycle_state: ProviderThreadLifecycleState, *, + binding_id: str = DEFAULT_CODEX_BINDING_ID, observed_at: str | None = None, ) -> ProviderThread: return await self.upsert_provider_thread( ProviderThreadObservation( provider=provider, + binding_id=binding_id, provider_thread_id=provider_thread_id, lifecycle_state=lifecycle_state, observed_at=observed_at, @@ -2584,6 +2861,7 @@ async def get_provider_thread_topology( provider: str, provider_thread_ids: str | list[str], *, + binding_id: str = DEFAULT_CODEX_BINDING_ID, max_nodes: int = 200, max_depth: int = 16, ) -> ProviderThreadTopology: @@ -2615,7 +2893,7 @@ async def load(thread_ids: list[str]) -> None: truncated = True if not candidates: return - fetched = await self._get_provider_thread_nodes(provider, candidates) + fetched = await self._get_provider_thread_nodes(provider, binding_id, candidates) nodes.update(fetched) missing.update(set(candidates) - set(fetched)) @@ -2624,8 +2902,9 @@ async def related_ids(column: str, thread_id: str) -> list[str]: raise ValueError(f"unsupported provider thread relation {column!r}") async with self._conn.execute( "SELECT provider_thread_id FROM provider_threads " - f"WHERE provider = ? AND {column} = ? ORDER BY provider_thread_id", - (provider, thread_id), + f"WHERE provider = ? AND binding_id = ? AND {column} = ? " + "ORDER BY provider_thread_id", + (provider, binding_id, thread_id), ) as cur: rows = await cur.fetchall() return [str(row["provider_thread_id"]) for row in rows] @@ -2741,6 +3020,7 @@ async def related_ids(column: str, thread_id: str) -> list[str]: complete = not (missing or cycle_detected or truncated) return ProviderThreadTopology( provider=provider, + binding_id=binding_id, requested_thread_ids=requested, nodes=sorted(nodes.values(), key=lambda node: node.thread.provider_thread_id), roots=roots, @@ -2756,7 +3036,7 @@ async def related_ids(column: str, thread_id: str) -> list[str]: ) async def _get_provider_thread_nodes( - self, provider: str, provider_thread_ids: list[str] + self, provider: str, binding_id: str, provider_thread_ids: list[str] ) -> dict[str, ProviderThreadNode]: if not provider_thread_ids: return {} @@ -2764,11 +3044,13 @@ async def _get_provider_thread_nodes( async with self._conn.execute( "SELECT provider_threads.*, lanes.id AS lane_id, lanes.ref, lanes.handle, " "lanes.status AS lane_status FROM provider_threads " - "LEFT JOIN lanes ON lanes.id = provider_threads.provider_thread_id " - "WHERE provider_threads.provider = ? " + "LEFT JOIN lanes ON lanes.provider = provider_threads.provider " + "AND lanes.binding_id = provider_threads.binding_id " + "AND lanes.provider_session_id = provider_threads.provider_thread_id " + "WHERE provider_threads.provider = ? AND provider_threads.binding_id = ? " f"AND provider_threads.provider_thread_id IN ({placeholders}) " "ORDER BY provider_threads.provider_thread_id", - (provider, *provider_thread_ids), + (provider, binding_id, *provider_thread_ids), ) as cur: rows = await cur.fetchall() result: dict[str, ProviderThreadNode] = {} @@ -2872,13 +3154,15 @@ async def record_provider_event(self, event: ProviderEvent) -> ProviderEvent: payload = _json_dump_compact(event.payload) if event.payload is not None else None async with self._write_lock: await self._conn.execute( - "INSERT INTO provider_events (provider, provider_thread_id, lane, event_type, " + "INSERT INTO provider_events (provider, binding_id, provider_thread_id, lane, " + "event_type, " "provider_event_id, provider_turn_id, provider_item_id, correlation_id, " "provider_ts, received_at, summary, payload, raw_retained) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " "ON CONFLICT DO NOTHING", ( event.provider, + event.binding_id, event.provider_thread_id, event.lane, event.event_type, @@ -2904,7 +3188,9 @@ async def record_provider_event(self, event: ProviderEvent) -> ProviderEvent: return _row_to_provider_event(row) if event.provider_event_id is not None: existing = await self.find_provider_event( - event.provider, provider_event_id=event.provider_event_id + event.provider, + binding_id=event.binding_id, + provider_event_id=event.provider_event_id, ) if existing is None: raise RuntimeError("provider event insert did not return a row") @@ -2913,11 +3199,16 @@ async def record_provider_event(self, event: ProviderEvent) -> ProviderEvent: @_serialized_access async def find_provider_event( - self, provider: str, *, provider_event_id: str + self, + provider: str, + *, + binding_id: str = DEFAULT_CODEX_BINDING_ID, + provider_event_id: str, ) -> ProviderEvent | None: async with self._conn.execute( - "SELECT * FROM provider_events WHERE provider = ? AND provider_event_id = ?", - (provider, provider_event_id), + "SELECT * FROM provider_events WHERE provider = ? AND binding_id = ? " + "AND provider_event_id = ?", + (provider, binding_id, provider_event_id), ) as cur: row = await cur.fetchone() return _row_to_provider_event(row) if row is not None else None @@ -2927,6 +3218,8 @@ async def list_provider_events( self, *, lane: str | None = None, + provider: str | None = None, + binding_id: str | None = None, provider_thread_id: str | None = None, limit: int = 50, ) -> list[ProviderEvent]: @@ -2935,7 +3228,17 @@ async def list_provider_events( if lane is not None: clauses.append("lane = ?") params.append(lane) + if provider is not None: + clauses.append("provider = ?") + params.append(provider) + if binding_id is not None: + clauses.append("binding_id = ?") + params.append(binding_id) if provider_thread_id is not None: + if lane is None and (provider is None or binding_id is None): + raise ValueError( + "provider_thread_id requires provider and binding_id when lane is omitted" + ) clauses.append("provider_thread_id = ?") params.append(provider_thread_id) sql = "SELECT * FROM provider_events" @@ -2965,14 +3268,17 @@ async def observe_server_request_once(self, request: ServerRequest) -> ServerReq request_id_json = _json_dump_compact(request.request_id) async with self._write_lock: cur = await self._conn.execute( - "INSERT INTO server_requests (provider, provider_session_id, provider_thread_id, " + "INSERT INTO server_requests (provider, binding_id, provider_session_id, " + "provider_thread_id, " "provider_thread_key, request_id_json, lane, method, category, state, " "received_at, deadline_at, resolved_at, response_summary, error) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " - "ON CONFLICT(provider, provider_session_id, provider_thread_key, request_id_json) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + "ON CONFLICT(provider, binding_id, provider_session_id, provider_thread_key, " + "request_id_json) " "DO NOTHING", ( request.provider, + request.binding_id, request.provider_session_id, request.provider_thread_id, thread_key, @@ -2991,6 +3297,7 @@ async def observe_server_request_once(self, request: ServerRequest) -> ServerReq await self._conn.commit() saved = await self.get_server_request( provider=request.provider, + binding_id=request.binding_id, provider_session_id=request.provider_session_id, provider_thread_id=request.provider_thread_id, request_id=request.request_id, @@ -3004,15 +3311,18 @@ async def get_server_request( self, *, provider: str, + binding_id: str = DEFAULT_CODEX_BINDING_ID, provider_session_id: str, provider_thread_id: str | None, request_id: int | str, ) -> ServerRequest | None: async with self._conn.execute( - "SELECT * FROM server_requests WHERE provider = ? AND provider_session_id = ? " + "SELECT * FROM server_requests WHERE provider = ? AND binding_id = ? " + "AND provider_session_id = ? " "AND provider_thread_key = ? AND request_id_json = ?", ( provider, + binding_id, provider_session_id, _server_request_thread_key(provider_thread_id), _json_dump_compact(request_id), @@ -3062,6 +3372,7 @@ async def list_pending_server_requests( *, lane: str | None = None, provider_session_id: str | None = None, + binding_id: str = DEFAULT_CODEX_BINDING_ID, limit: int = 50, ) -> list[ServerRequest]: if provider_session_id is None: @@ -3073,8 +3384,8 @@ async def list_pending_server_requests( sql += " AND lane = ?" params.append(lane) if provider_session_id is not None: - sql += " AND provider_session_id = ?" - params.append(provider_session_id) + sql += " AND binding_id = ? AND provider_session_id = ?" + params.extend((binding_id, provider_session_id)) sql += " ORDER BY deadline_at, received_at, request_id_json LIMIT ?" params.append(limit) async with self._conn.execute(sql, tuple(params)) as cur: @@ -3086,6 +3397,7 @@ async def claim_server_request( self, *, provider: str, + binding_id: str = DEFAULT_CODEX_BINDING_ID, provider_session_id: str, provider_thread_id: str | None, request_id: int | str, @@ -3097,15 +3409,17 @@ async def claim_server_request( async with self._write_lock: cur = await self._conn.execute( "UPDATE server_requests SET state = 'responding' WHERE provider = ? " + "AND binding_id = ? " "AND provider_session_id = ? AND provider_thread_key = ? " "AND request_id_json = ? AND state = 'pending'", - (provider, provider_session_id, thread_key, request_id_json), + (provider, binding_id, provider_session_id, thread_key, request_id_json), ) await self._conn.commit() if cur.rowcount != 1: return None return await self.get_server_request( provider=provider, + binding_id=binding_id, provider_session_id=provider_session_id, provider_thread_id=provider_thread_id, request_id=request_id, @@ -3131,6 +3445,7 @@ async def finalize_server_request( self, *, provider: str, + binding_id: str = DEFAULT_CODEX_BINDING_ID, provider_session_id: str, provider_thread_id: str | None, request_id: int | str, @@ -3146,7 +3461,7 @@ async def finalize_server_request( async with self._write_lock: cur = await self._conn.execute( "UPDATE server_requests SET state = ?, resolved_at = ?, response_summary = ?, " - "error = ? WHERE provider = ? AND provider_session_id = ? " + "error = ? WHERE provider = ? AND binding_id = ? AND provider_session_id = ? " "AND provider_thread_key = ? AND request_id_json = ? AND state = 'responding'", ( state, @@ -3154,6 +3469,7 @@ async def finalize_server_request( _bound_server_request_text(response_summary), _bound_server_request_text(error), provider, + binding_id, provider_session_id, thread_key, request_id_json, @@ -3164,6 +3480,7 @@ async def finalize_server_request( return None return await self.get_server_request( provider=provider, + binding_id=binding_id, provider_session_id=provider_session_id, provider_thread_id=provider_thread_id, request_id=request_id, @@ -3203,6 +3520,7 @@ async def fail_open_server_requests_except_session( self, current_session_id: str, *, + binding_id: str = DEFAULT_CODEX_BINDING_ID, error: str = "app-server connection replaced before a response was sent", ) -> int: """Terminalize rows that cannot be answered after an App Server reconnect.""" @@ -3210,23 +3528,32 @@ async def fail_open_server_requests_except_session( async with self._write_lock: cur = await self._conn.execute( "UPDATE server_requests SET state = 'failed', resolved_at = ?, " - "response_summary = NULL, error = ? WHERE provider_session_id != ? " + "response_summary = NULL, error = ? WHERE binding_id = ? " + "AND provider_session_id != ? " "AND state IN ('pending', 'responding')", - (self.now_iso(), _bound_server_request_text(error), current_session_id), + ( + self.now_iso(), + _bound_server_request_text(error), + binding_id, + current_session_id, + ), ) await self._conn.commit() return cur.rowcount @_serialized_access async def list_open_server_requests_except_session( - self, current_session_id: str + self, + current_session_id: str, + *, + binding_id: str = DEFAULT_CODEX_BINDING_ID, ) -> list[ServerRequest]: """Return pending/responding rows that a replacement connection cannot answer.""" async with self._conn.execute( - "SELECT * FROM server_requests WHERE provider_session_id != ? " + "SELECT * FROM server_requests WHERE binding_id = ? AND provider_session_id != ? " "AND state IN ('pending', 'responding') ORDER BY id", - (current_session_id,), + (binding_id, current_session_id), ) as cur: rows = await cur.fetchall() return [_row_to_server_request(row) for row in rows] @@ -3235,14 +3562,20 @@ async def list_open_server_requests_except_session( async def upsert_thread_turn(self, turn: ThreadTurn) -> ThreadTurn: async with self._transaction(): await self._upsert_thread_turn_row(turn) - return await self.get_thread_turn(turn.provider, turn.provider_thread_id, turn.turn_id) + return await self.get_thread_turn( + turn.provider, + turn.provider_thread_id, + turn.turn_id, + binding_id=turn.binding_id, + ) async def _upsert_thread_turn_row(self, turn: ThreadTurn) -> None: await self._conn.execute( - "INSERT INTO thread_turns (provider, provider_thread_id, turn_id, lane, status, " + "INSERT INTO thread_turns (provider, binding_id, provider_thread_id, turn_id, lane, " + "status, " "started_at, completed_at, failed_at, error, completion_source, updated_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " - "ON CONFLICT(provider, provider_thread_id, turn_id) DO UPDATE SET " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + "ON CONFLICT(provider, binding_id, provider_thread_id, turn_id) DO UPDATE SET " "lane = COALESCE(excluded.lane, thread_turns.lane), " "status = CASE WHEN excluded.status = 'unknown' " "THEN thread_turns.status ELSE excluded.status END, " @@ -3255,6 +3588,7 @@ async def _upsert_thread_turn_row(self, turn: ThreadTurn) -> None: "updated_at = excluded.updated_at", ( turn.provider, + turn.binding_id, turn.provider_thread_id, turn.turn_id, turn.lane, @@ -3270,12 +3604,18 @@ async def _upsert_thread_turn_row(self, turn: ThreadTurn) -> None: @_serialized_access async def get_thread_turn( - self, provider: str, provider_thread_id: str, turn_id: str + self, + provider: str, + provider_thread_id: str, + turn_id: str, + *, + binding_id: str = DEFAULT_CODEX_BINDING_ID, ) -> ThreadTurn: async with self._conn.execute( - "SELECT * FROM thread_turns WHERE provider = ? AND provider_thread_id = ? " + "SELECT * FROM thread_turns WHERE provider = ? AND binding_id = ? " + "AND provider_thread_id = ? " "AND turn_id = ?", - (provider, provider_thread_id, turn_id), + (provider, binding_id, provider_thread_id, turn_id), ) as cur: row = await cur.fetchone() if row is None: @@ -3299,7 +3639,12 @@ async def upsert_thread_item( await self._upsert_thread_item_row(item) if refs is not None: await self._replace_thread_item_refs(item, refs) - return await self.get_thread_item(item.provider, item.provider_thread_id, item.item_id) + return await self.get_thread_item( + item.provider, + item.provider_thread_id, + item.item_id, + binding_id=item.binding_id, + ) @_serialized_access async def upsert_thread_history_snapshot( @@ -3308,6 +3653,7 @@ async def upsert_thread_history_snapshot( turns: list[ThreadTurn], items: list[tuple[ThreadItem, list[ThreadItemRef]]], provider: str, + binding_id: str = DEFAULT_CODEX_BINDING_ID, provider_thread_id: str, turn_ids: set[str], item_ids: set[str], @@ -3322,6 +3668,7 @@ async def upsert_thread_history_snapshot( if prune_missing: await self._prune_thread_history_snapshot_rows( provider=provider, + binding_id=binding_id, provider_thread_id=provider_thread_id, turn_ids=turn_ids, item_ids=item_ids, @@ -3329,12 +3676,12 @@ async def upsert_thread_history_snapshot( async def _upsert_thread_item_row(self, item: ThreadItem) -> None: await self._conn.execute( - "INSERT INTO thread_items (provider, provider_thread_id, item_id, lane, " + "INSERT INTO thread_items (provider, binding_id, provider_thread_id, item_id, lane, " "turn_id, item_type, role, phase, status, text, tool, server, command, cwd, " "error, duration_ms, arguments, success, agent_nickname, agent_role, created_at, " "position, inserted_at, payload, raw_retained) VALUES (" - "?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " - "ON CONFLICT(provider, provider_thread_id, item_id) DO UPDATE SET " + "?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + "ON CONFLICT(provider, binding_id, provider_thread_id, item_id) DO UPDATE SET " "lane = COALESCE(excluded.lane, thread_items.lane), " "turn_id = COALESCE(excluded.turn_id, thread_items.turn_id), " "item_type = excluded.item_type, " @@ -3362,6 +3709,7 @@ async def _upsert_thread_item_row(self, item: ThreadItem) -> None: "raw_retained = MAX(excluded.raw_retained, thread_items.raw_retained)", ( item.provider, + item.binding_id, item.provider_thread_id, item.item_id, item.lane, @@ -3391,16 +3739,18 @@ async def _upsert_thread_item_row(self, item: ThreadItem) -> None: async def _replace_thread_item_refs(self, item: ThreadItem, refs: list[ThreadItemRef]) -> None: await self._conn.execute( - "DELETE FROM thread_item_refs WHERE provider = ? AND provider_thread_id = ? " + "DELETE FROM thread_item_refs WHERE provider = ? AND binding_id = ? " + "AND provider_thread_id = ? " "AND item_id = ?", - (item.provider, item.provider_thread_id, item.item_id), + (item.provider, item.binding_id, item.provider_thread_id, item.item_id), ) for ref in refs: await self._conn.execute( - "INSERT OR IGNORE INTO thread_item_refs (provider, provider_thread_id, " - "item_id, ref_type, ref_value) VALUES (?, ?, ?, ?, ?)", + "INSERT OR IGNORE INTO thread_item_refs (provider, binding_id, provider_thread_id, " + "item_id, ref_type, ref_value) VALUES (?, ?, ?, ?, ?, ?)", ( ref.provider, + ref.binding_id, ref.provider_thread_id, ref.item_id, ref.ref_type, @@ -3410,21 +3760,34 @@ async def _replace_thread_item_refs(self, item: ThreadItem, refs: list[ThreadIte @_serialized_access async def find_thread_item( - self, provider: str, provider_thread_id: str, item_id: str + self, + provider: str, + provider_thread_id: str, + item_id: str, + *, + binding_id: str = DEFAULT_CODEX_BINDING_ID, ) -> ThreadItem | None: async with self._conn.execute( - "SELECT * FROM thread_items WHERE provider = ? AND provider_thread_id = ? " + "SELECT * FROM thread_items WHERE provider = ? AND binding_id = ? " + "AND provider_thread_id = ? " "AND item_id = ?", - (provider, provider_thread_id, item_id), + (provider, binding_id, provider_thread_id, item_id), ) as cur: row = await cur.fetchone() return _row_to_thread_item(row) if row is not None else None @_serialized_access async def get_thread_item( - self, provider: str, provider_thread_id: str, item_id: str + self, + provider: str, + provider_thread_id: str, + item_id: str, + *, + binding_id: str = DEFAULT_CODEX_BINDING_ID, ) -> ThreadItem: - item = await self.find_thread_item(provider, provider_thread_id, item_id) + item = await self.find_thread_item( + provider, provider_thread_id, item_id, binding_id=binding_id + ) if item is None: raise NotFoundError(f"no thread item {provider}:{provider_thread_id}:{item_id}") return item @@ -3633,6 +3996,7 @@ async def get_thread_history_summary_stats(self, *, lane: str) -> ThreadHistoryS FROM thread_item_refs refs INNER JOIN thread_items items ON items.provider = refs.provider + AND items.binding_id = refs.binding_id AND items.provider_thread_id = refs.provider_thread_id AND items.item_id = refs.item_id WHERE items.lane = ? AND refs.ref_type = 'file' @@ -3649,6 +4013,7 @@ async def get_thread_history_summary_stats(self, *, lane: str) -> ThreadHistoryS FROM thread_item_refs refs INNER JOIN thread_items items ON items.provider = refs.provider + AND items.binding_id = refs.binding_id AND items.provider_thread_id = refs.provider_thread_id AND items.item_id = refs.item_id WHERE items.lane = ? AND refs.ref_type = 'file' @@ -3662,6 +4027,7 @@ async def get_thread_history_summary_stats(self, *, lane: str) -> ThreadHistoryS FROM thread_item_refs refs INNER JOIN thread_items items ON items.provider = refs.provider + AND items.binding_id = refs.binding_id AND items.provider_thread_id = refs.provider_thread_id AND items.item_id = refs.item_id WHERE items.lane = ? AND refs.ref_type = 'child_thread' @@ -3711,9 +4077,10 @@ async def get_thread_history_summary_stats(self, *, lane: str) -> ThreadHistoryS @_serialized_access async def list_thread_item_refs(self, item: ThreadItem) -> list[ThreadItemRef]: async with self._conn.execute( - "SELECT * FROM thread_item_refs WHERE provider = ? AND provider_thread_id = ? " + "SELECT * FROM thread_item_refs WHERE provider = ? AND binding_id = ? " + "AND provider_thread_id = ? " "AND item_id = ? ORDER BY ref_type, ref_value", - (item.provider, item.provider_thread_id, item.item_id), + (item.provider, item.binding_id, item.provider_thread_id, item.item_id), ) as cur: rows = await cur.fetchall() return [ThreadItemRef.model_validate(_row_dict(row)) for row in rows] @@ -3729,8 +4096,12 @@ async def list_thread_item_refs_many( clauses = [] params: list[object] = [] for item in items[chunk_start : chunk_start + 500]: - clauses.append("(provider = ? AND provider_thread_id = ? AND item_id = ?)") - params.extend((item.provider, item.provider_thread_id, item.item_id)) + clauses.append( + "(provider = ? AND binding_id = ? AND provider_thread_id = ? AND item_id = ?)" + ) + params.extend( + (item.provider, item.binding_id, item.provider_thread_id, item.item_id) + ) if not clauses: continue sql = ( @@ -3750,6 +4121,7 @@ async def prune_thread_history_snapshot( self, *, provider: str, + binding_id: str = DEFAULT_CODEX_BINDING_ID, provider_thread_id: str, turn_ids: set[str], item_ids: set[str], @@ -3757,6 +4129,7 @@ async def prune_thread_history_snapshot( async with self._transaction(): await self._prune_thread_history_snapshot_rows( provider=provider, + binding_id=binding_id, provider_thread_id=provider_thread_id, turn_ids=turn_ids, item_ids=item_ids, @@ -3766,6 +4139,7 @@ async def _prune_thread_history_snapshot_rows( self, *, provider: str, + binding_id: str, provider_thread_id: str, turn_ids: set[str], item_ids: set[str], @@ -3773,6 +4147,7 @@ async def _prune_thread_history_snapshot_rows( await self._delete_missing_values( "thread_items", provider=provider, + binding_id=binding_id, provider_thread_id=provider_thread_id, id_column="item_id", keep_ids=item_ids, @@ -3780,6 +4155,7 @@ async def _prune_thread_history_snapshot_rows( await self._delete_missing_values( "thread_turns", provider=provider, + binding_id=binding_id, provider_thread_id=provider_thread_id, id_column="turn_id", keep_ids=turn_ids, @@ -3790,12 +4166,15 @@ async def _delete_missing_values( table: str, *, provider: str, + binding_id: str, provider_thread_id: str, id_column: str, keep_ids: set[str], ) -> None: - params: list[object] = [provider, provider_thread_id] - sql = f"DELETE FROM {table} WHERE provider = ? AND provider_thread_id = ?" + params: list[object] = [provider, binding_id, provider_thread_id] + sql = ( + f"DELETE FROM {table} WHERE provider = ? AND binding_id = ? AND provider_thread_id = ?" + ) if keep_ids: placeholders = ", ".join("?" for _ in keep_ids) sql += f" AND {id_column} NOT IN ({placeholders})" @@ -3806,10 +4185,10 @@ async def _delete_missing_values( async def upsert_message_receipt(self, receipt: MessageReceipt) -> MessageReceipt: async with self._write_lock: await self._conn.execute( - "INSERT INTO message_receipts (id, lane, queued_message_id, provider, " + "INSERT INTO message_receipts (id, lane, queued_message_id, provider, binding_id, " "provider_thread_id, dispatch_message_id, status, turn_id, error, created_at, " "sent_at, accepted_at, completed_at, failed_at, updated_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " "ON CONFLICT(dispatch_message_id) WHERE dispatch_message_id IS NOT NULL " "DO UPDATE SET lane = excluded.lane, " "queued_message_id = excluded.queued_message_id, " @@ -3817,12 +4196,16 @@ async def upsert_message_receipt(self, receipt: MessageReceipt) -> MessageReceip "status = excluded.status, turn_id = excluded.turn_id, error = excluded.error, " "sent_at = excluded.sent_at, accepted_at = excluded.accepted_at, " "completed_at = excluded.completed_at, failed_at = excluded.failed_at, " - "updated_at = excluded.updated_at", + "updated_at = excluded.updated_at " + "WHERE message_receipts.provider = excluded.provider " + "AND message_receipts.binding_id = excluded.binding_id " + "AND message_receipts.provider_thread_id = excluded.provider_thread_id", ( receipt.id, receipt.lane, receipt.queued_message_id, receipt.provider, + receipt.binding_id, receipt.provider_thread_id, receipt.dispatch_message_id, receipt.status, @@ -3847,7 +4230,9 @@ async def upsert_message_receipt(self, receipt: MessageReceipt) -> MessageReceip return MessageReceipt.model_validate(_row_dict(row)) if receipt.dispatch_message_id is not None: got = await self.find_message_receipt( - provider=receipt.provider, dispatch_message_id=receipt.dispatch_message_id + provider=receipt.provider, + dispatch_message_id=receipt.dispatch_message_id, + binding_id=receipt.binding_id, ) if got is None: raise RuntimeError("message receipt upsert did not return a row") @@ -3856,11 +4241,16 @@ async def upsert_message_receipt(self, receipt: MessageReceipt) -> MessageReceip @_serialized_access async def find_message_receipt( - self, *, provider: str, dispatch_message_id: str + self, + *, + provider: str, + binding_id: str = DEFAULT_CODEX_BINDING_ID, + dispatch_message_id: str, ) -> MessageReceipt | None: async with self._conn.execute( - "SELECT * FROM message_receipts WHERE provider = ? AND dispatch_message_id = ?", - (provider, dispatch_message_id), + "SELECT * FROM message_receipts WHERE provider = ? AND binding_id = ? " + "AND dispatch_message_id = ?", + (provider, binding_id, dispatch_message_id), ) as cur: row = await cur.fetchone() return MessageReceipt.model_validate(_row_dict(row)) if row is not None else None @@ -3878,11 +4268,13 @@ async def list_message_receipts(self, *, lane: str, limit: int = 50) -> list[Mes async def upsert_lane_runtime_state(self, state: LaneRuntimeState) -> LaneRuntimeState: async with self._write_lock: await self._conn.execute( - "INSERT INTO lane_runtime_state (lane, provider, provider_thread_id, status, " + "INSERT INTO lane_runtime_state (lane, provider, binding_id, provider_thread_id, " + "status, " "active_turn_id, latest_turn_id, latest_turn_status, needs_attention, " "attention_kind, attention_detail, updated_at, last_event_at) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " "ON CONFLICT(lane) DO UPDATE SET provider = excluded.provider, " + "binding_id = excluded.binding_id, " "provider_thread_id = excluded.provider_thread_id, status = excluded.status, " "active_turn_id = excluded.active_turn_id, " "latest_turn_id = excluded.latest_turn_id, " @@ -3894,6 +4286,7 @@ async def upsert_lane_runtime_state(self, state: LaneRuntimeState) -> LaneRuntim ( state.lane, state.provider, + state.binding_id, state.provider_thread_id, state.status, state.active_turn_id, diff --git a/tests/contracts/test_contracts.py b/tests/contracts/test_contracts.py index 167b3c7..dd78c2f 100644 --- a/tests/contracts/test_contracts.py +++ b/tests/contracts/test_contracts.py @@ -128,7 +128,8 @@ def test_registry_legacy_safe_ops_real_registry_gates_new_plan() -> None: assert "roster" not in safe assert "show" not in safe assert "status" in safe - assert "stop" in safe # write-intent, but schema unchanged since the parent release + assert "archive" in safe # write-intent, with schema unchanged since the parent release + assert "stop" not in safe assert "new" not in safe assert "new-plan" not in safe diff --git a/tests/core/test_provider_binding_identity.py b/tests/core/test_provider_binding_identity.py new file mode 100644 index 0000000..daa6d1e --- /dev/null +++ b/tests/core/test_provider_binding_identity.py @@ -0,0 +1,143 @@ +"""Public compatibility checks for binding-scoped managed identities.""" + +from __future__ import annotations + +import pytest + +from outfitter.dispatch.contracts.errors import ( + CapabilityUnavailableError, + NotFoundError, + ValidationError, +) +from outfitter.dispatch.core import handlers, queue +from outfitter.dispatch.core.models import ( + DiscoverInput, + GoalGetInput, + HistoryInput, + LaneRenameInput, + LaneSyncInput, + LaneTextInput, + RosterInput, + SearchInput, + ShowInput, + ThreadTargetInput, + TranscriptInput, + WatchInput, +) +from outfitter.dispatch.core.selectors import resolve_managed_selector +from outfitter.dispatch.registry.store import DEFAULT_CODEX_BINDING_ID, Registry +from tests.fakes import FakeLaneClient, make_ctx + + +async def test_default_codex_and_non_codex_outputs_preserve_public_aliases() -> None: + store = await Registry.open() + try: + codex = await store.add_lane( + id="codex-native", handle="@codex", source="own", status="idle" + ) + other = await store.add_lane( + id="dsp_other", + handle="@other", + source="own", + status="idle", + provider="claude", + binding_id="profile-a", + provider_session_id="claude-native", + ) + client = FakeLaneClient() + ctx = make_ctx(store, client) + + roster = await handlers.roster(RosterInput(), ctx) + by_id = {lane.id: lane for lane in roster.lanes} + codex_view = by_id[codex.id] + assert codex_view.provider == "codex" + assert codex_view.binding_id == DEFAULT_CODEX_BINDING_ID + assert codex_view.provider_session_id == codex.id + assert codex_view.ref == codex.ref + + other_view = by_id[other.id] + assert other_view.provider == "claude" + assert other_view.binding_id == "profile-a" + assert other_view.provider_session_id == "claude-native" + assert other_view.id == other.id + assert other_view.capabilities.send is False + assert other_view.capabilities.read is False + assert other_view.capabilities.sync is False + assert other_view.capabilities.tail is False + assert other_view.writable is False + + with pytest.raises(NotFoundError, match="no managed thread"): + await resolve_managed_selector(ctx, "claude-native") + with pytest.raises(CapabilityUnavailableError, match="execution is not supported"): + await handlers.send(LaneTextInput(lane=other.ref, text="blocked"), ctx) + assert not client.calls + finally: + await store.close() + + +async def test_non_default_binding_reads_fail_before_codex_client_calls() -> None: + store = await Registry.open() + try: + lane = await store.add_lane( + id="dsp_other", + handle="@other", + source="own", + status="idle", + provider="codex", + binding_id="profile-a", + provider_session_id="native-shared", + ) + client = FakeLaneClient() + ctx = make_ctx(store, client) + + detail = await handlers.show(ShowInput(lane=lane.ref), ctx) + assert detail.capabilities.read is False + assert detail.topology.observed is False + assert not client.calls + + calls = ( + handlers.show(ShowInput(lane=lane.ref, include_transcript=True), ctx), + handlers.show(ShowInput(lane=lane.ref, topology=True), ctx), + handlers.sync_lane(LaneSyncInput(lane=lane.ref), ctx), + handlers.watch(WatchInput(lane=lane.ref, timeout=0), ctx), + handlers.transcript(TranscriptInput(lane=lane.ref), ctx), + handlers.history(HistoryInput(lane=lane.ref, view="summary"), ctx), + handlers.goal_get(GoalGetInput(lane=lane.ref), ctx), + handlers.rename_lane(LaneRenameInput(old=lane.ref, new="renamed"), ctx), + handlers.search(SearchInput(query="needle", lane=lane.ref), ctx), + handlers.roster(RosterInput(parent=lane.ref), ctx), + handlers.discover(DiscoverInput(parent=lane.ref), ctx), + handlers.archive(ThreadTargetInput(target=lane.ref), ctx), + handlers.restore(ThreadTargetInput(target=lane.ref), ctx), + ) + for call in calls: + with pytest.raises(CapabilityUnavailableError): + await call + assert not client.calls + + with pytest.raises(ValidationError, match="current Codex thread"): + await handlers._resolve_self(ctx, lane.id) + queued = await store.enqueue_message(lane=lane.id, text="must not send") + assert await queue.drain_next_queued_message(ctx, lane.id) is False + assert (await store.get_queued_message(queued.id)).status == "pending" + assert not client.calls + finally: + await store.close() + + +async def test_malformed_default_codex_identity_cannot_execute() -> None: + store = await Registry.open() + try: + lane = await store.add_lane(id="codex-stable", handle="@codex", source="own", status="idle") + await store._conn.execute( + "UPDATE lanes SET provider_session_id = 'wrong-native' WHERE id = ?", (lane.id,) + ) + await store._conn.commit() + client = FakeLaneClient() + ctx = make_ctx(store, client) + + with pytest.raises(CapabilityUnavailableError): + await handlers.transcript(TranscriptInput(lane=lane.ref), ctx) + assert not client.calls + finally: + await store.close() diff --git a/tests/core/test_server_requests.py b/tests/core/test_server_requests.py index 6a253d4..ff02ad3 100644 --- a/tests/core/test_server_requests.py +++ b/tests/core/test_server_requests.py @@ -97,6 +97,46 @@ async def test_owned_user_input_becomes_durable_attention(store: Registry) -> No await manager.close() +async def test_codex_request_does_not_capture_colliding_non_codex_lane_key( + store: Registry, +) -> None: + lane = await store.add_lane( + id="dsp_collision", + handle="@other", + source="own", + status="idle", + provider="claude", + binding_id="profile-a", + provider_session_id="native-other", + ) + ctx = make_ctx(store, FakeLaneClient()) + ctx.provider_session_id = "session-1" + manager = ServerRequestManager(ctx) + + request = await manager.handle( + ServerRequestReceived( + method="item/tool/requestUserInput", + request_id="question-collision", + category="user_input", + thread_id=lane.id, + turn_id="T1", + item_id="I1", + raw_params={"questions": []}, + ) + ) + + assert request.lane is None + assert (await store.get_lane(lane.id)).status == "idle" + assert await store.get_lane_runtime_state(lane.id) is None + events = await store.list_provider_events( + provider="codex", + binding_id="codex-default", + provider_thread_id=lane.id, + ) + assert events and all(event.lane is None for event in events) + await manager.close() + + async def test_attached_request_is_denied_by_default(store: Registry) -> None: await store.add_lane(id="L1", handle="@desktop", source="attached", status="busy") client = FakeLaneClient() diff --git a/tests/daemon/test_supervisor.py b/tests/daemon/test_supervisor.py index a722051..81ee1e8 100644 --- a/tests/daemon/test_supervisor.py +++ b/tests/daemon/test_supervisor.py @@ -6,6 +6,7 @@ from collections.abc import AsyncIterator from datetime import UTC, datetime +import pytest import pytest_asyncio from outfitter.dispatch.client.errors import AppServerError @@ -97,6 +98,25 @@ async def make_client() -> FakeSupervisedClient: await asyncio.wait_for(task, timeout=1) +async def test_supervisor_skips_non_default_provider_bindings(store: Registry) -> None: + await store.add_lane( + id="dsp_other", + handle="@other", + source="own", + status="idle", + provider="codex", + binding_id="profile-a", + provider_session_id="native-shared", + ) + ctx = make_ctx(store) + client = FakeSupervisedClient() + supervisor = Supervisor(ctx, lambda: pytest.fail("must not respawn"), lambda: pytest.fail()) + + await supervisor._restore_lanes(client) + + assert not client.calls + + async def test_supervisor_recovers_and_drains_idle_queue_on_start(store: Registry) -> None: await store.add_lane(id="O1", handle="@own", source="own", status="idle") queued = await store.enqueue_message(lane="O1", text="resume queued") diff --git a/tests/fixtures/provider_events/codex_turn_lifecycle.jsonl b/tests/fixtures/provider_events/codex_turn_lifecycle.jsonl index 4a77c16..02d84c7 100644 --- a/tests/fixtures/provider_events/codex_turn_lifecycle.jsonl +++ b/tests/fixtures/provider_events/codex_turn_lifecycle.jsonl @@ -1,2 +1,2 @@ -{"provider":"codex","provider_thread_id":"019f0000-0000-7000-9000-000000000001","lane":"L1","event_type":"turn/started","provider_event_id":"evt-1","provider_turn_id":"turn-1","provider_ts":"2026-06-11T12:00:00Z","received_at":"2026-06-11T12:00:00+00:00","summary":{"status":"started"},"payload":{"method":"turn/started","params":{"threadId":"019f0000-0000-7000-9000-000000000001","turnId":"turn-1"}},"raw_retained":true} -{"provider":"codex","provider_thread_id":"019f0000-0000-7000-9000-000000000001","lane":"L1","event_type":"turn/completed","provider_event_id":"evt-2","provider_turn_id":"turn-1","provider_ts":"2026-06-11T12:00:10Z","received_at":"2026-06-11T12:00:10+00:00","summary":{"status":"completed"},"payload":{"method":"turn/completed","params":{"threadId":"019f0000-0000-7000-9000-000000000001","turnId":"turn-1"}},"raw_retained":true} +{"provider":"codex","binding_id":"codex-default","provider_thread_id":"019f0000-0000-7000-9000-000000000001","lane":"L1","event_type":"turn/started","provider_event_id":"evt-1","provider_turn_id":"turn-1","provider_ts":"2026-06-11T12:00:00Z","received_at":"2026-06-11T12:00:00+00:00","summary":{"status":"started"},"payload":{"method":"turn/started","params":{"threadId":"019f0000-0000-7000-9000-000000000001","turnId":"turn-1"}},"raw_retained":true} +{"provider":"codex","binding_id":"codex-default","provider_thread_id":"019f0000-0000-7000-9000-000000000001","lane":"L1","event_type":"turn/completed","provider_event_id":"evt-2","provider_turn_id":"turn-1","provider_ts":"2026-06-11T12:00:10Z","received_at":"2026-06-11T12:00:10+00:00","summary":{"status":"completed"},"payload":{"method":"turn/completed","params":{"threadId":"019f0000-0000-7000-9000-000000000001","turnId":"turn-1"}},"raw_retained":true} diff --git a/tests/fixtures/registry/builders.py b/tests/fixtures/registry/builders.py index c7b49e6..5b9e521 100644 --- a/tests/fixtures/registry/builders.py +++ b/tests/fixtures/registry/builders.py @@ -117,6 +117,7 @@ def lane_runtime_settings( def provider_event( *, lane: str = "L1", + binding_id: str = "codex-default", provider_thread_id: str = "thread-1", event_type: str = "turn/started", provider_event_id: str | None = "event-1", @@ -125,6 +126,7 @@ def provider_event( ) -> ProviderEvent: return ProviderEvent( provider="codex", + binding_id=binding_id, provider_thread_id=provider_thread_id, lane=lane, event_type=event_type, @@ -141,6 +143,7 @@ def provider_event( def provider_thread_observation( *, provider: str = "codex", + binding_id: str = "codex-default", provider_thread_id: str = "thread-1", parent_thread_id: str | None = None, forked_from_id: str | None = None, @@ -149,6 +152,7 @@ def provider_thread_observation( ) -> ProviderThreadObservation: return ProviderThreadObservation( provider=provider, + binding_id=binding_id, provider_thread_id=provider_thread_id, session_id="session-1", parent_thread_id=parent_thread_id, @@ -221,12 +225,14 @@ def provider_capacity_observation( def server_request( *, provider_session_id: str = "app-server-1", + binding_id: str = "codex-default", provider_thread_id: str | None = "thread-1", lane: str | None = "L1", request_id: int | str = 1, received_at: str | None = None, ) -> ServerRequest: return ServerRequest( + binding_id=binding_id, provider_session_id=provider_session_id, provider_thread_id=provider_thread_id, lane=lane, @@ -240,6 +246,7 @@ def server_request( def thread_turn( *, lane: str = "L1", + binding_id: str = "codex-default", provider_thread_id: str = "thread-1", turn_id: str = "turn-1", status: str = "started", @@ -247,6 +254,7 @@ def thread_turn( ) -> ThreadTurn: return ThreadTurn( provider="codex", + binding_id=binding_id, provider_thread_id=provider_thread_id, turn_id=turn_id, lane=lane, @@ -259,6 +267,7 @@ def thread_turn( def thread_item( *, lane: str = "L1", + binding_id: str = "codex-default", provider_thread_id: str = "thread-1", turn_id: str = "turn-1", item_id: str = "item-1", @@ -278,6 +287,7 @@ def thread_item( ) -> ThreadItem: return ThreadItem( provider="codex", + binding_id=binding_id, provider_thread_id=provider_thread_id, item_id=item_id, lane=lane, @@ -306,12 +316,14 @@ def thread_item( def thread_item_ref( *, provider_thread_id: str = "thread-1", + binding_id: str = "codex-default", item_id: str = "item-1", ref_type: str = "tool", ref_value: str = "bash", ) -> ThreadItemRef: return ThreadItemRef( provider="codex", + binding_id=binding_id, provider_thread_id=provider_thread_id, item_id=item_id, ref_type=ref_type, @@ -322,6 +334,7 @@ def thread_item_ref( def message_receipt( *, lane: str = "L1", + binding_id: str = "codex-default", provider_thread_id: str = "thread-1", dispatch_message_id: str = "dispatch-message-1", status: str = "created", @@ -331,6 +344,7 @@ def message_receipt( return MessageReceipt( lane=lane, provider="codex", + binding_id=binding_id, provider_thread_id=provider_thread_id, dispatch_message_id=dispatch_message_id, status=status, # type: ignore[arg-type] @@ -342,12 +356,14 @@ def message_receipt( def lane_runtime_state( *, lane: str = "L1", + binding_id: str = "codex-default", provider_thread_id: str = "thread-1", updated_at: str | None = None, ) -> LaneRuntimeState: return LaneRuntimeState( lane=lane, provider="codex", + binding_id=binding_id, provider_thread_id=provider_thread_id, status="busy", active_turn_id="turn-1", diff --git a/tests/fixtures/registry/v23_binding_scope.sql b/tests/fixtures/registry/v23_binding_scope.sql new file mode 100644 index 0000000..c0b974a --- /dev/null +++ b/tests/fixtures/registry/v23_binding_scope.sql @@ -0,0 +1,184 @@ +-- Exact binding-related schema from dispatch v0.11.0 / schema v23. + +CREATE TABLE IF NOT EXISTS provider_threads ( + provider TEXT NOT NULL, + provider_thread_id TEXT NOT NULL, + session_id TEXT, + parent_thread_id TEXT, + forked_from_id TEXT, + source_kind TEXT, + thread_source TEXT, + agent_nickname TEXT, + agent_role TEXT, + agent_depth INTEGER, + lifecycle_state TEXT NOT NULL DEFAULT 'unknown' + CHECK(lifecycle_state IN ('active', 'archived', 'deleted', 'unknown')), + relationship_source TEXT, + confidence REAL, + first_seen_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + archived_at TEXT, + deleted_at TEXT, + PRIMARY KEY(provider, provider_thread_id) +); +CREATE INDEX IF NOT EXISTS idx_provider_threads_parent +ON provider_threads(provider, parent_thread_id); +CREATE INDEX IF NOT EXISTS idx_provider_threads_fork +ON provider_threads(provider, forked_from_id); + +CREATE TABLE IF NOT EXISTS provider_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL, + provider_thread_id TEXT NOT NULL, + lane TEXT, + event_type TEXT NOT NULL, + provider_event_id TEXT, + provider_turn_id TEXT, + provider_item_id TEXT, + correlation_id TEXT, + provider_ts TEXT, + received_at TEXT NOT NULL, + summary TEXT NOT NULL DEFAULT '{}', + payload TEXT, + raw_retained INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY(lane) REFERENCES lanes(id) ON DELETE SET NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_provider_events_provider_event_id +ON provider_events(provider, provider_event_id) +WHERE provider_event_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_provider_events_thread_received +ON provider_events(provider, provider_thread_id, received_at); +CREATE INDEX IF NOT EXISTS idx_provider_events_lane_received +ON provider_events(lane, received_at); + +CREATE TABLE IF NOT EXISTS thread_turns ( + provider TEXT NOT NULL, + provider_thread_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + lane TEXT, + status TEXT NOT NULL, + started_at TEXT, + completed_at TEXT, + failed_at TEXT, + error TEXT, + completion_source TEXT, + updated_at TEXT NOT NULL, + PRIMARY KEY(provider, provider_thread_id, turn_id), + FOREIGN KEY(lane) REFERENCES lanes(id) ON DELETE SET NULL +); +CREATE INDEX IF NOT EXISTS idx_thread_turns_lane_updated +ON thread_turns(lane, updated_at); + +CREATE TABLE IF NOT EXISTS thread_items ( + provider TEXT NOT NULL, + provider_thread_id TEXT NOT NULL, + item_id TEXT NOT NULL, + lane TEXT, + turn_id TEXT, + item_type TEXT NOT NULL, + role TEXT, + phase TEXT, + status TEXT, + text TEXT, + tool TEXT, + server TEXT, + command TEXT, + cwd TEXT, + error TEXT, + duration_ms INTEGER, + arguments TEXT, + success INTEGER, + agent_nickname TEXT, + agent_role TEXT, + created_at TEXT, + position INTEGER, + inserted_at TEXT NOT NULL, + payload TEXT, + raw_retained INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY(provider, provider_thread_id, item_id), + FOREIGN KEY(lane) REFERENCES lanes(id) ON DELETE SET NULL +); +CREATE INDEX IF NOT EXISTS idx_thread_items_lane_inserted +ON thread_items(lane, position, inserted_at); +CREATE INDEX IF NOT EXISTS idx_thread_items_turn +ON thread_items(provider, provider_thread_id, turn_id); + +CREATE TABLE IF NOT EXISTS thread_item_refs ( + provider TEXT NOT NULL, + provider_thread_id TEXT NOT NULL, + item_id TEXT NOT NULL, + ref_type TEXT NOT NULL, + ref_value TEXT NOT NULL, + PRIMARY KEY(provider, provider_thread_id, item_id, ref_type, ref_value), + FOREIGN KEY(provider, provider_thread_id, item_id) + REFERENCES thread_items(provider, provider_thread_id, item_id) + ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_thread_item_refs_lookup +ON thread_item_refs(ref_type, ref_value); + +CREATE TABLE IF NOT EXISTS message_receipts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + lane TEXT, + queued_message_id INTEGER, + provider TEXT NOT NULL, + provider_thread_id TEXT NOT NULL, + dispatch_message_id TEXT, + status TEXT NOT NULL, + turn_id TEXT, + error TEXT, + created_at TEXT NOT NULL, + sent_at TEXT, + accepted_at TEXT, + completed_at TEXT, + failed_at TEXT, + updated_at TEXT NOT NULL, + FOREIGN KEY(lane) REFERENCES lanes(id) ON DELETE SET NULL, + FOREIGN KEY(queued_message_id) REFERENCES queued_messages(id) ON DELETE SET NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_message_receipts_dispatch_message_id +ON message_receipts(dispatch_message_id) +WHERE dispatch_message_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_message_receipts_lane_updated +ON message_receipts(lane, updated_at); + +CREATE TABLE IF NOT EXISTS lane_runtime_state ( + lane TEXT PRIMARY KEY, + provider TEXT NOT NULL, + provider_thread_id TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'unknown', + active_turn_id TEXT, + latest_turn_id TEXT, + latest_turn_status TEXT, + needs_attention INTEGER NOT NULL DEFAULT 0, + attention_kind TEXT, + attention_detail TEXT, + updated_at TEXT NOT NULL, + last_event_at TEXT, + FOREIGN KEY(lane) REFERENCES lanes(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS server_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + provider TEXT NOT NULL CHECK (provider = 'codex'), + provider_session_id TEXT NOT NULL, + provider_thread_id TEXT, + provider_thread_key TEXT NOT NULL, + request_id_json TEXT NOT NULL, + lane TEXT, + method TEXT NOT NULL, + category TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'pending' + CHECK (state IN ('pending', 'responding', 'responded', 'denied', 'timed_out', 'failed')), + received_at TEXT NOT NULL, + deadline_at TEXT, + resolved_at TEXT, + response_summary TEXT, + error TEXT, + UNIQUE(provider, provider_session_id, provider_thread_key, request_id_json), + FOREIGN KEY(lane) REFERENCES lanes(id) ON DELETE SET NULL +); +CREATE INDEX IF NOT EXISTS idx_server_requests_pending +ON server_requests(provider, provider_session_id, state, deadline_at, received_at); +CREATE INDEX IF NOT EXISTS idx_server_requests_lane_pending +ON server_requests(lane, provider_session_id, state, received_at); diff --git a/tests/fixtures/test_corpus.py b/tests/fixtures/test_corpus.py index 8a57650..41d9c0f 100644 --- a/tests/fixtures/test_corpus.py +++ b/tests/fixtures/test_corpus.py @@ -251,6 +251,7 @@ def test_provider_event_replay_fixture_validates_storage_shape() -> None: ] assert [event.event_type for event in events] == ["turn/started", "turn/completed"] + assert {event.binding_id for event in events} == {"codex-default"} assert [event.provider_turn_id for event in events] == ["turn-1", "turn-1"] assert events[0].payload is not None assert events[0].payload["method"] == "turn/started" diff --git a/tests/registry/test_binding_identity.py b/tests/registry/test_binding_identity.py new file mode 100644 index 0000000..263a2a9 --- /dev/null +++ b/tests/registry/test_binding_identity.py @@ -0,0 +1,577 @@ +"""Binding-scoped registry identity and v23 migration coverage.""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +import aiosqlite +import pytest + +from outfitter.dispatch.contracts.errors import NotFoundError, ValidationError +from outfitter.dispatch.registry.models import ( + LaneRuntimeState, + MessageReceipt, + ProviderEvent, + ProviderThreadObservation, + ServerRequest, + ThreadItem, + ThreadItemRef, + ThreadTurn, +) +from outfitter.dispatch.registry.store import DEFAULT_CODEX_BINDING_ID, Registry + +_NOW = "2026-09-12T12:00:00+00:00" + + +async def test_same_native_identity_is_isolated_by_binding(tmp_path: Path) -> None: + store = await Registry.open(tmp_path / "registry.db") + try: + lane_a = await store.add_lane( + id="dsp_lane_a", + handle="@a", + source="own", + provider="claude", + binding_id="profile-a", + provider_session_id="native-shared", + ) + lane_b = await store.add_lane( + id="dsp_lane_b", + handle="@b", + source="own", + provider="claude", + binding_id="profile-b", + provider_session_id="native-shared", + ) + assert lane_a.ref_source == lane_b.ref_source == "1" + + for lane in (lane_a, lane_b): + await store.upsert_provider_thread( + ProviderThreadObservation( + provider="claude", + binding_id=lane.binding_id, + provider_thread_id="native-shared", + parent_thread_id="parent-shared", + lifecycle_state="active", + observed_at=_NOW, + ) + ) + await store.upsert_provider_thread( + ProviderThreadObservation( + provider="claude", + binding_id=lane.binding_id, + provider_thread_id="parent-shared", + lifecycle_state="active", + observed_at=_NOW, + ) + ) + await store.record_provider_event( + ProviderEvent( + provider="claude", + binding_id=lane.binding_id, + provider_thread_id="native-shared", + lane=lane.id, + event_type="turn.started", + provider_event_id="event-shared", + received_at=_NOW, + ) + ) + await store.upsert_thread_turn( + ThreadTurn( + provider="claude", + binding_id=lane.binding_id, + provider_thread_id="native-shared", + turn_id="turn-shared", + lane=lane.id, + updated_at=_NOW, + ) + ) + item = ThreadItem( + provider="claude", + binding_id=lane.binding_id, + provider_thread_id="native-shared", + item_id="item-shared", + lane=lane.id, + item_type="message", + text=lane.binding_id, + inserted_at=_NOW, + ) + await store.upsert_thread_item( + item, + refs=[ + ThreadItemRef( + provider="claude", + binding_id=lane.binding_id, + provider_thread_id="native-shared", + item_id="item-shared", + ref_type="binding", + ref_value=lane.binding_id, + ) + ], + ) + + assert ( + await store.get_thread_item( + "claude", "native-shared", "item-shared", binding_id="profile-a" + ) + ).text == "profile-a" + assert ( + await store.get_thread_item( + "claude", "native-shared", "item-shared", binding_id="profile-b" + ) + ).text == "profile-b" + assert ( + len( + await store.list_provider_events( + provider="claude", + binding_id="profile-a", + provider_thread_id="native-shared", + ) + ) + == 1 + ) + topology_a = await store.get_provider_thread_topology( + "claude", "native-shared", binding_id="profile-a" + ) + topology_b = await store.get_provider_thread_topology( + "claude", "native-shared", binding_id="profile-b" + ) + assert topology_a.nodes[0].thread.binding_id == "profile-a" + assert topology_b.nodes[0].thread.binding_id == "profile-b" + assert {node.thread.provider_thread_id: node.managed for node in topology_a.nodes}[ + "native-shared" + ] is True + assert {node.thread.provider_thread_id: node.managed for node in topology_b.nodes}[ + "native-shared" + ] is True + + with pytest.raises(sqlite3.IntegrityError): + await store._conn.execute( + "INSERT INTO thread_item_refs " + "(provider, binding_id, provider_thread_id, item_id, ref_type, ref_value) " + "VALUES (?, ?, ?, ?, ?, ?)", + ("claude", "missing", "native-shared", "item-shared", "tool", "bad"), + ) + finally: + await store.close() + + +async def test_dispatch_message_id_cannot_retarget_binding() -> None: + store = await Registry.open() + try: + original = MessageReceipt( + provider="codex", + binding_id="profile-a", + provider_thread_id="native", + dispatch_message_id="dispatch-global", + created_at=_NOW, + updated_at=_NOW, + ) + await store.upsert_message_receipt(original) + with pytest.raises(RuntimeError, match="did not return a row"): + await store.upsert_message_receipt( + original.model_copy(update={"binding_id": "profile-b"}) + ) + saved = await store.find_message_receipt( + provider="codex", + binding_id="profile-a", + dispatch_message_id="dispatch-global", + ) + assert saved is not None and saved.binding_id == "profile-a" + finally: + await store.close() + + +async def test_provider_session_continuation_cannot_retarget_binding() -> None: + store = await Registry.open() + try: + reserved = await store.add_lane( + id="dsp_reserved", + handle="@reserved", + source="own", + provider="claude", + binding_id="profile-a", + ) + assert reserved.provider_session_id is None + continued = await store.update_lane_provider_session( + reserved.id, + provider="claude", + binding_id="profile-a", + provider_session_id="native-1", + ) + assert continued.provider_session_id == "native-1" + with pytest.raises(NotFoundError, match=r"no lane .* provider binding"): + await store.update_lane_provider_session( + reserved.id, + provider="claude", + binding_id="profile-b", + provider_session_id="native-2", + ) + assert (await store.get_lane(reserved.id)).provider_session_id == "native-1" + finally: + await store.close() + + +async def test_provider_session_collision_rolls_back_and_registry_remains_writable() -> None: + store = await Registry.open() + try: + first = await store.add_lane( + id="dsp_first", + handle="@first", + source="own", + provider="claude", + binding_id="profile-a", + provider_session_id="native-shared", + ) + second = await store.add_lane( + id="dsp_second", + handle="@second", + source="own", + provider="claude", + binding_id="profile-a", + ) + with pytest.raises(sqlite3.IntegrityError): + await store.update_lane_provider_session( + second.id, + provider="claude", + binding_id="profile-a", + provider_session_id="native-shared", + ) + assert store._conn.in_transaction is False + assert (await store.get_lane(first.id)).provider_session_id == "native-shared" + assert (await store.get_lane(second.id)).provider_session_id is None + created = await store.add_lane( + id="dsp_after", + handle="@after", + source="own", + provider="claude", + binding_id="profile-a", + ) + assert created.id == "dsp_after" + finally: + await store.close() + + +async def test_default_codex_provider_session_cannot_diverge_from_stable_id() -> None: + store = await Registry.open() + try: + lane = await store.add_lane(id="codex-native", handle="@codex", source="own", status="idle") + with pytest.raises(ValidationError, match="must equal the stable lane id"): + await store.update_lane_provider_session( + lane.id, + provider="codex", + binding_id=DEFAULT_CODEX_BINDING_ID, + provider_session_id="replacement-native", + ) + assert (await store.get_lane(lane.id)).provider_session_id == lane.id + finally: + await store.close() + + +async def test_new_lane_ids_keep_default_codex_and_other_provider_namespaces_disjoint() -> None: + store = await Registry.open() + try: + with pytest.raises(ValueError, match="default-Codex lane"): + await store.add_lane( + id="codex-key", + handle="@bad-codex", + source="own", + provider_session_id="different-native", + ) + with pytest.raises(ValueError, match="opaque dsp_ Dispatch id"): + await store.add_lane( + id="codex-shaped-native", + handle="@bad-other", + source="own", + provider="claude", + binding_id="profile-a", + ) + finally: + await store.close() + + +async def test_server_request_recovery_is_binding_scoped() -> None: + store = await Registry.open() + try: + for binding in ("profile-a", "profile-b"): + await store.observe_server_request( + ServerRequest( + binding_id=binding, + provider_session_id="generation-old", + provider_thread_id="native-shared", + request_id="request-shared", + method="item/tool/requestUserInput", + category="user_input", + received_at=_NOW, + ) + ) + failed = await store.fail_open_server_requests_except_session( + "generation-new", binding_id="profile-a" + ) + assert failed == 1 + request_a = await store.get_server_request( + provider="codex", + binding_id="profile-a", + provider_session_id="generation-old", + provider_thread_id="native-shared", + request_id="request-shared", + ) + request_b = await store.get_server_request( + provider="codex", + binding_id="profile-b", + provider_session_id="generation-old", + provider_thread_id="native-shared", + request_id="request-shared", + ) + assert request_a is not None and request_a.state == "failed" + assert request_b is not None and request_b.state == "pending" + finally: + await store.close() + + +async def _downgrade_seed_to_v23(path: Path, *, migration_collision: bool = False) -> None: + store = await Registry.open(path) + lane = await store.add_lane(id="codex-native", handle="@legacy", source="own") + await store.upsert_provider_thread( + ProviderThreadObservation(provider_thread_id=lane.id, observed_at=_NOW) + ) + event = await store.record_provider_event( + ProviderEvent( + provider="codex", + provider_thread_id=lane.id, + lane=lane.id, + event_type="turn.started", + provider_event_id="legacy-event", + received_at=_NOW, + ) + ) + await store.upsert_thread_turn( + ThreadTurn( + provider="codex", + provider_thread_id=lane.id, + turn_id="legacy-turn", + lane=lane.id, + updated_at=_NOW, + ) + ) + item = ThreadItem( + provider="codex", + provider_thread_id=lane.id, + item_id="legacy-item", + lane=lane.id, + item_type="message", + inserted_at=_NOW, + ) + await store.upsert_thread_item( + item, + refs=[ + ThreadItemRef( + provider="codex", + provider_thread_id=lane.id, + item_id=item.item_id, + ref_type="tool", + ref_value="legacy", + ) + ], + ) + receipt = await store.upsert_message_receipt( + MessageReceipt( + lane=lane.id, + provider="codex", + provider_thread_id=lane.id, + dispatch_message_id="legacy-message", + created_at=_NOW, + updated_at=_NOW, + ) + ) + await store.upsert_lane_runtime_state( + LaneRuntimeState( + lane=lane.id, + provider="codex", + provider_thread_id=lane.id, + updated_at=_NOW, + ) + ) + request = await store.observe_server_request( + ServerRequest( + provider_session_id="legacy-generation", + provider_thread_id=lane.id, + lane=lane.id, + request_id="legacy-request", + method="item/tool/requestUserInput", + category="user_input", + received_at=_NOW, + ) + ) + await store.close() + + tables = ( + "provider_threads", + "provider_events", + "thread_turns", + "thread_items", + "thread_item_refs", + "message_receipts", + "lane_runtime_state", + "server_requests", + ) + with sqlite3.connect(path) as conn: + conn.row_factory = sqlite3.Row + snapshots = { + table: [dict(row) for row in conn.execute(f"SELECT * FROM {table}").fetchall()] + for table in tables + } + conn.execute("PRAGMA foreign_keys = OFF") + conn.execute("DROP TABLE thread_item_refs") + for table in tables: + if table != "thread_item_refs": + conn.execute(f"DROP TABLE {table}") + schema_path = Path(__file__).parents[1] / "fixtures/registry/v23_binding_scope.sql" + conn.executescript(schema_path.read_text()) + for table in tables: + columns = [str(row[1]) for row in conn.execute(f"PRAGMA table_info({table})")] + placeholders = ", ".join("?" for _ in columns) + for row in snapshots[table]: + conn.execute( + f"INSERT INTO {table} ({', '.join(columns)}) VALUES ({placeholders})", + [row[column] for column in columns], + ) + for table in ("provider_events", "message_receipts", "server_requests"): + conn.execute("UPDATE sqlite_sequence SET seq = 42 WHERE name = ?", (table,)) + conn.execute("DROP INDEX idx_lanes_provider_session") + conn.execute("ALTER TABLE lanes DROP COLUMN provider_session_id") + conn.execute("ALTER TABLE lanes DROP COLUMN binding_id") + conn.execute("ALTER TABLE lanes DROP COLUMN provider") + if migration_collision: + conn.execute("CREATE TABLE provider_events_v24 (sentinel TEXT)") + conn.execute("PRAGMA user_version = 23") + conn.commit() + + assert event.id is not None + assert receipt.id is not None + assert request.id is not None + + +async def test_v23_migration_preserves_keys_ids_and_foreign_keys(tmp_path: Path) -> None: + path = tmp_path / "registry-v23.db" + await _downgrade_seed_to_v23(path) + with sqlite3.connect(path) as legacy: + legacy_identity = legacy.execute( + "SELECT id, ref, ref_source, ref_payload, ref_mixer FROM lanes" + ).fetchone() + + store = await Registry.open(path) + try: + lane = await store.get_lane("codex-native") + assert lane.provider == "codex" + assert lane.binding_id == DEFAULT_CODEX_BINDING_ID + assert lane.provider_session_id == lane.id + assert lane.ref_source == "0" + assert ( + lane.id, + lane.ref, + lane.ref_source, + lane.ref_payload, + lane.ref_mixer, + ) == legacy_identity + events = await store.list_provider_events(lane=lane.id) + receipts = await store.list_message_receipts(lane=lane.id) + requests = await store.list_server_requests(state=None) + assert [(event.id, event.binding_id) for event in events] == [(1, DEFAULT_CODEX_BINDING_ID)] + assert [(receipt.id, receipt.binding_id) for receipt in receipts] == [ + (1, DEFAULT_CODEX_BINDING_ID) + ] + assert [(request.id, request.binding_id) for request in requests] == [ + (1, DEFAULT_CODEX_BINDING_ID) + ] + next_event = await store.record_provider_event( + ProviderEvent( + provider="codex", + provider_thread_id=lane.id, + lane=lane.id, + event_type="turn.completed", + received_at=_NOW, + ) + ) + next_receipt = await store.upsert_message_receipt( + MessageReceipt( + lane=lane.id, + provider="codex", + provider_thread_id=lane.id, + created_at=_NOW, + updated_at=_NOW, + ) + ) + next_request = await store.observe_server_request( + ServerRequest( + provider_session_id="next-generation", + provider_thread_id=lane.id, + lane=lane.id, + request_id="next-request", + method="item/tool/requestUserInput", + category="user_input", + received_at=_NOW, + ) + ) + assert next_event.id == 43 + assert next_receipt.id == 43 + assert next_request.id == 43 + for table in ( + "provider_events", + "thread_turns", + "thread_items", + "message_receipts", + "lane_runtime_state", + "server_requests", + ): + async with store._conn.execute(f"SELECT DISTINCT lane FROM {table}") as cur: + assert {str(row[0]) for row in await cur.fetchall()} == {lane.id} + async with store._conn.execute("PRAGMA foreign_key_check") as cur: + assert await cur.fetchall() == [] + async with store._conn.execute("PRAGMA foreign_key_list(thread_item_refs)") as cur: + foreign_keys = await cur.fetchall() + assert {str(row["table"]) for row in foreign_keys} == {"thread_items"} + assert {str(row["to"]) for row in foreign_keys} == { + "provider", + "binding_id", + "provider_thread_id", + "item_id", + } + async with store._conn.execute("PRAGMA user_version") as cur: + assert int((await cur.fetchone())[0]) == 24 # type: ignore[index] + finally: + await store.close() + + +async def test_v23_migration_failure_rolls_back_schema_and_data(tmp_path: Path) -> None: + path = tmp_path / "registry-v23-invalid.db" + await _downgrade_seed_to_v23(path, migration_collision=True) + conn = await aiosqlite.connect(path) + conn.row_factory = aiosqlite.Row + await conn.execute("PRAGMA foreign_keys = ON") + store = Registry(conn, now=lambda: pytest.fail("clock should not be read")) + with pytest.raises(sqlite3.OperationalError, match="already exists"): + await store._ensure_binding_scope_v24() + try: + async with conn.execute("PRAGMA foreign_keys") as cur: + assert int((await cur.fetchone())[0]) == 1 # type: ignore[index] + async with conn.execute("PRAGMA user_version") as cur: + assert int((await cur.fetchone())[0]) == 23 # type: ignore[index] + async with conn.execute("PRAGMA table_info(provider_events)") as cur: + assert "binding_id" not in {str(row["name"]) for row in await cur.fetchall()} + async with conn.execute( + "SELECT provider_event_id FROM provider_events WHERE id = 1" + ) as cur: + assert str((await cur.fetchone())[0]) == "legacy-event" # type: ignore[index] + async with conn.execute("SELECT sentinel FROM provider_events_v24") as cur: + assert await cur.fetchall() == [] + finally: + await store.close() + + with pytest.raises(sqlite3.OperationalError, match="already exists"): + await Registry.open(path) + with sqlite3.connect(path) as repair: + repair.execute("DROP TABLE provider_events_v24") + repair.commit() + reopened = await Registry.open(path) + await reopened.close() diff --git a/tests/registry/test_deliveries.py b/tests/registry/test_deliveries.py index 57c2c6a..bcad0bc 100644 --- a/tests/registry/test_deliveries.py +++ b/tests/registry/test_deliveries.py @@ -341,7 +341,7 @@ async def test_v21_migration_adds_delivery_ledger(tmp_path: Path) -> None: assert receipt.key == "after:migration" async with migrated._conn.execute("PRAGMA user_version") as cur: row = await cur.fetchone() - assert row is not None and int(row[0]) == 23 + assert row is not None and int(row[0]) == 24 finally: await migrated.close() diff --git a/tests/registry/test_sql_compat.py b/tests/registry/test_sql_compat.py index 4d790e1..a85d17d 100644 --- a/tests/registry/test_sql_compat.py +++ b/tests/registry/test_sql_compat.py @@ -36,12 +36,17 @@ def test_registry_sql_exercise_sets_schema_version_and_rolls_back(tmp_path: Path ("server_requests",), ).fetchone() assert server_requests is not None + server_request_columns = { + str(row[1]) for row in conn.execute("PRAGMA table_info(server_requests)").fetchall() + } + assert "binding_id" in server_request_columns provider_thread_columns = { str(row[1]) for row in conn.execute("PRAGMA table_info(provider_threads)").fetchall() } assert { "provider", + "binding_id", "provider_thread_id", "parent_thread_id", "forked_from_id", @@ -51,13 +56,15 @@ def test_registry_sql_exercise_sets_schema_version_and_rolls_back(tmp_path: Path } <= provider_thread_columns assert conn.execute("PRAGMA foreign_key_list(provider_threads)").fetchall() == [] conn.execute( - "INSERT INTO provider_threads (provider, provider_thread_id, parent_thread_id, " - "lifecycle_state, first_seen_at, last_seen_at) VALUES (?, ?, ?, ?, ?, ?) " - "ON CONFLICT(provider, provider_thread_id) DO UPDATE SET " + "INSERT INTO provider_threads (provider, binding_id, provider_thread_id, " + "parent_thread_id, lifecycle_state, first_seen_at, last_seen_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?) " + "ON CONFLICT(provider, binding_id, provider_thread_id) DO UPDATE SET " "parent_thread_id = COALESCE(excluded.parent_thread_id, " "provider_threads.parent_thread_id), last_seen_at = excluded.last_seen_at", ( "codex", + "codex-default", "thread-topology", "parent-topology", "active", diff --git a/tests/registry/test_store.py b/tests/registry/test_store.py index 17588b1..89ba7fb 100644 --- a/tests/registry/test_store.py +++ b/tests/registry/test_store.py @@ -949,7 +949,7 @@ async def test_thread_history_snapshot_batches_rows_prunes_and_summarizes( assert [found.item_id for found in listed_items] == ["item-1"] assert listed_items[0].inserted_at == item.inserted_at refs_by_item = await store.list_thread_item_refs_many([item]) - key = (item.provider, item.provider_thread_id, item.item_id) + key = (item.provider, item.binding_id, item.provider_thread_id, item.item_id) assert [(ref.ref_type, ref.ref_value) for ref in refs_by_item[key]] == [ ("file", "README.md"), ("tool", "bash"), @@ -990,11 +990,15 @@ async def test_list_thread_item_refs_many_keeps_same_item_ids_separate( refs = await store.list_thread_item_refs_many([first, second]) assert set(refs) == { - ("codex", "thread-1", "shared"), - ("codex", "thread-2", "shared"), + ("codex", "codex-default", "thread-1", "shared"), + ("codex", "codex-default", "thread-2", "shared"), } - assert [ref.ref_value for ref in refs[("codex", "thread-1", "shared")]] == ["bash"] - assert [ref.ref_value for ref in refs[("codex", "thread-2", "shared")]] == ["linear"] + assert [ref.ref_value for ref in refs[("codex", "codex-default", "thread-1", "shared")]] == [ + "bash" + ] + assert [ref.ref_value for ref in refs[("codex", "codex-default", "thread-2", "shared")]] == [ + "linear" + ] async def test_concurrent_lane_sync_writes_are_serialized(store: Registry) -> None: @@ -1820,7 +1824,7 @@ async def test_v17_migration_adds_replace_in_place_provider_capacity_table( async with migrated._conn.execute("PRAGMA user_version") as cur: row = await cur.fetchone() assert row is not None - assert int(row[0]) == SCHEMA_VERSION == 23 + assert int(row[0]) == SCHEMA_VERSION == 24 finally: await migrated.close() diff --git a/tests/surfaces/test_cli_daemon_skew.py b/tests/surfaces/test_cli_daemon_skew.py index 4d475fa..b27382a 100644 --- a/tests/surfaces/test_cli_daemon_skew.py +++ b/tests/surfaces/test_cli_daemon_skew.py @@ -269,8 +269,8 @@ def request(_socket: Path, method: str, _params: dict[str, object]) -> dict[str, monkeypatch.setattr(cli, "_control_request", request) assert _invoke(tmp_path / "dispatchd.sock", "models", {}) == {"ok": True} - assert _invoke(tmp_path / "dispatchd.sock", "stop", {"lane": "@a"}) == {"ok": True} - assert calls == [CONTROL_META_METHOD, "models", CONTROL_META_METHOD, "stop"] + assert _invoke(tmp_path / "dispatchd.sock", "archive", {"target": "T1"}) == {"ok": True} + assert calls == [CONTROL_META_METHOD, "models", "archive"] def test_invoke_daemon_blocks_baseline_ops_on_older_prehandshake_daemon( diff --git a/tests/surfaces/test_control_execution_binding.py b/tests/surfaces/test_control_execution_binding.py index ad0269d..425d287 100644 --- a/tests/surfaces/test_control_execution_binding.py +++ b/tests/surfaces/test_control_execution_binding.py @@ -117,8 +117,10 @@ def serve() -> None: { "protocol_version": 1, "version": PARENT_VERSION, - "supported_ops": ["stop"], - "op_schemas": {"stop": registry_op_schema_hashes(REGISTRY)["stop"]}, + "supported_ops": ["archive"], + "op_schemas": { + "archive": registry_op_schema_hashes(REGISTRY)["archive"] + }, } if request["method"] == CONTROL_META_METHOD else {"ok": True} @@ -130,13 +132,13 @@ def serve() -> None: thread.start() assert ready.wait(timeout=2) - assert _invoke_cli(path, "stop", {"lane": "@a"}) == {"ok": True} + assert _invoke_cli(path, "archive", {"target": "T1"}) == {"ok": True} thread.join(timeout=2) assert not thread.is_alive() assert observed == [ (observed[0][0], CONTROL_META_METHOD), - (observed[0][0], "stop"), + (observed[0][0], "archive"), ] @@ -156,8 +158,10 @@ async def handle(reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> { "protocol_version": 1, "version": PARENT_VERSION, - "supported_ops": ["stop"], - "op_schemas": {"stop": registry_op_schema_hashes(REGISTRY)["stop"]}, + "supported_ops": ["lane-rename"], + "op_schemas": { + "lane-rename": registry_op_schema_hashes(REGISTRY)["lane-rename"] + }, } if method == CONTROL_META_METHOD else {"accepted": True} @@ -180,10 +184,14 @@ async def test_mcp_legacy_metadata_and_raw_op_share_one_socket( ) -> None: path, observed = legacy_mcp_socket - result = await mcp.handle_tool_call(path, "dispatch_thread_write", {"op": "stop", "lane": "@a"}) + result = await mcp.handle_tool_call( + path, + "dispatch_thread_write", + {"op": "rename", "old": "T1", "new": "renamed"}, + ) assert result.isError is False - assert [method for _, method in observed] == [CONTROL_META_METHOD, "stop"] + assert [method for _, method in observed] == [CONTROL_META_METHOD, "lane-rename"] assert observed[0][0] == observed[1][0] diff --git a/tests/surfaces/test_mcp_routing.py b/tests/surfaces/test_mcp_routing.py index 5c868bf..2c83de3 100644 --- a/tests/surfaces/test_mcp_routing.py +++ b/tests/surfaces/test_mcp_routing.py @@ -370,12 +370,14 @@ async def fake_bound( monkeypatch.setattr(mcp, "_call_daemon_bound", fake_bound) - # Parent-version daemon: the baseline proves ``stop`` parses identically. + # Parent-version daemon: the baseline proves ``lane-rename`` parses identically. stopped = await handle_tool_call( - Path("/nonexistent.sock"), "dispatch_thread_write", {"op": "stop", "lane": "@a"} + Path("/nonexistent.sock"), + "dispatch_thread_write", + {"op": "rename", "old": "T1", "new": "renamed"}, ) assert stopped.isError is False - assert forwarded == ["stop"] + assert forwarded == ["lane-rename"] # This read op was added after the parent release, so it is not eligible # for the pre-handshake read allowance even though older daemons would @@ -388,7 +390,7 @@ async def fake_bound( assert new_read.isError is True assert new_read.meta is not None assert new_read.meta["dispatchCode"] == "daemon_stale" - assert forwarded == ["stop"] + assert forwarded == ["lane-rename"] # Older pre-handshake daemon (e.g. v0.8.2's ``send`` had no ``content``): # Baseline writes are blocked with the actionable restart hint; unchanged reads pass. @@ -404,13 +406,13 @@ async def fake_bound( assert isinstance(first, TextContent) assert "version 0.10.0" in first.text assert "dispatch down && dispatch up" in first.text - assert forwarded == ["stop"] + assert forwarded == ["lane-rename"] listed = await handle_tool_call( Path("/nonexistent.sock"), "dispatch_daemon_read", {"op": "models"} ) assert listed.isError is False - assert forwarded == ["stop", "models"] + assert forwarded == ["lane-rename", "models"] # Below the read baseline floor (v0.9.0's ``usage`` output predates the # provider runtime summary): reads are blocked too. @@ -424,7 +426,7 @@ async def fake_bound( first_read = read_blocked.content[0] assert isinstance(first_read, TextContent) assert "version 0.9.0" in first_read.text - assert forwarded == ["stop", "models"] + assert forwarded == ["lane-rename", "models"] async def test_hash_capable_tool_call_uses_checked_execution_envelope( From c6117f5ed19113887cc9b9192200047c42177eb1 Mon Sep 17 00:00:00 2001 From: Matt Galligan Date: Sun, 13 Sep 2026 15:58:42 -0400 Subject: [PATCH 2/3] fix(registry): complete provider identity projections --- .agents/plans/stations-providers/RETRO.md | 2 +- docs/usage/README.md | 17 +-- .../dispatch/contracts/legacy_baseline.py | 4 +- src/outfitter/dispatch/core/backfill.py | 4 +- src/outfitter/dispatch/core/claude_launch.py | 4 +- .../dispatch/core/claude_launch_types.py | 2 +- src/outfitter/dispatch/core/claude_roster.py | 8 +- src/outfitter/dispatch/core/delivery.py | 4 +- .../dispatch/core/delivery_reconciliation.py | 4 +- src/outfitter/dispatch/core/event_index.py | 12 +-- src/outfitter/dispatch/core/handlers.py | 25 +++-- src/outfitter/dispatch/core/history.py | 3 + src/outfitter/dispatch/core/history_index.py | 8 +- src/outfitter/dispatch/core/models.py | 10 +- src/outfitter/dispatch/core/ops.py | 6 +- src/outfitter/dispatch/core/queue.py | 6 +- src/outfitter/dispatch/core/reactor.py | 2 +- .../dispatch/core/server_requests.py | 6 +- src/outfitter/dispatch/core/subscriptions.py | 4 +- src/outfitter/dispatch/core/topology.py | 8 +- src/outfitter/dispatch/daemon/supervisor.py | 4 +- src/outfitter/dispatch/registry/models.py | 2 +- src/outfitter/dispatch/registry/sql_compat.py | 2 +- src/outfitter/dispatch/registry/store.py | 100 +++++++++++------- tests/core/test_claude_launch.py | 10 +- tests/core/test_handlers.py | 29 ++++- tests/core/test_provider_binding_identity.py | 18 +++- tests/core/test_server_requests.py | 2 +- tests/daemon/test_supervisor.py | 2 +- tests/registry/test_binding_identity.py | 90 +++++++++++----- tests/registry/test_deliveries.py | 2 +- tests/registry/test_store.py | 14 ++- 32 files changed, 266 insertions(+), 148 deletions(-) diff --git a/.agents/plans/stations-providers/RETRO.md b/.agents/plans/stations-providers/RETRO.md index fdbdce1..b10200d 100644 --- a/.agents/plans/stations-providers/RETRO.md +++ b/.agents/plans/stations-providers/RETRO.md @@ -35,7 +35,7 @@ DIS-79–82 remain unimplemented. DIS-84's isolated native Hermes API investigat ## September 12, 2026 — provider and binding identity -Matt expanded the assignment through the shared foundation and native Hermes implementation, including direct default-profile research. DIS-79 extends the reviewed `904386c` stack with schema v24. It scopes native sessions, history, topology, events, normalized receipts, runtime state and server requests by provider and binding, while preserving Dispatch lane keys and refs. Default Codex keeps `provider_session_id == id`; other bindings remain non-executable in this migration slice. +Matt expanded the assignment through the shared foundation and native Hermes implementation, including direct default-profile research. DIS-79 extends the reviewed `904386c` stack with schema v24. It scopes native sessions, history, topology, events, normalized receipts, runtime state and server requests by provider and binding, while preserving Dispatch lane keys and refs. Default Codex keeps `provider_thread_id == id`; other bindings remain non-executable in this migration slice. ### Verification and review diff --git a/docs/usage/README.md b/docs/usage/README.md index 7268a85..8f86534 100644 --- a/docs/usage/README.md +++ b/docs/usage/README.md @@ -228,15 +228,16 @@ Common recovery paths: operations. In shared mode, verify the configured Unix socket exists and its daemon is ready; Dispatch will not replace it with a private server. -### Provider identity migration (schema 24) +### Provider identity migration (schemas 24-25) Schema 24 adds a runtime binding namespace to managed threads and normalized -provider history, topology, receipts, runtime state and server requests. Existing -Codex thread IDs, refs and dependent records remain unchanged. Managed-thread -output adds `provider`, `binding_id` and `provider_session_id`; `id` and the -existing `lane` alias still identify the same stable Dispatch thread. Native -session IDs are interpreted inside their binding, so identical IDs in different -profiles cannot share evidence. +provider history, topology, receipts, runtime state and server requests. Schema +25 names a managed thread's native conversation identity `provider_thread_id`. +Existing Codex thread IDs, refs and dependent records remain unchanged. +Managed-thread output adds `provider`, `binding_id` and `provider_thread_id`; +`id` and the existing `lane` alias still identify the same stable Dispatch +thread. Native thread IDs are interpreted inside their binding, so identical IDs +in different profiles cannot share evidence. Use the backed-up `dispatch registry migrate` workflow above before starting the new binary against an existing registry. The migration rebuilds affected tables @@ -342,7 +343,7 @@ Every managed thread gets a dispatch-local `ref`, for example `0k7M4a`. Use refs for day-to-day commands. Its `id` is the stable Dispatch identity. For the default Codex binding, that remains the full native Codex thread ID; existing full-ID selectors retain their behavior. Other bindings keep native identity -in `provider_session_id`. Titles and `@handles` are mutable labels; they are +in `provider_thread_id`. Titles and `@handles` are mutable labels; they are convenient, but not stable identity. Example `.dispatch/config.toml`: diff --git a/src/outfitter/dispatch/contracts/legacy_baseline.py b/src/outfitter/dispatch/contracts/legacy_baseline.py index 9681d2a..f18d106 100644 --- a/src/outfitter/dispatch/contracts/legacy_baseline.py +++ b/src/outfitter/dispatch/contracts/legacy_baseline.py @@ -58,9 +58,11 @@ "goal-clear", "goal-get", "goal-set", + "history", "new", "new-plan", "open", + "query", "rollback", "roster", "send", @@ -82,9 +84,7 @@ PARENT_OP_SCHEMA_HASHES: dict[str, str] = { "lane-rename": "c90f27886bd7bb6a8468f7bc7336716d60525b2361b4e9507d6e9d8b9273cf46", - "history": "43f8d8689b4171d655a8b14895cbb1af6ffe007aaa93ec14d6be76c19d2e1243", "search": "6383eb95772eae4ad689ad90c123244f36434f5b884cbe43cfab15075a891c0e", - "query": "37544d538a1ee55818edcda6b9615db719d35fd8dd4274fc4075fccf5e6b79ba", "models": "246d581df042fe34b36c404ab9d75bdce1e678aee1c01662a16b93bb6f997035", "permissions": "c9601fe3d928338d3f09d66bfddd705672af66fdcef17f12237ad0244989b03f", "usage": "e39b1afb1f34f970cde77645cf62aa9aed23b88c5b7bb1364d2745120640b956", diff --git a/src/outfitter/dispatch/core/backfill.py b/src/outfitter/dispatch/core/backfill.py index b78f5c3..9f73c04 100644 --- a/src/outfitter/dispatch/core/backfill.py +++ b/src/outfitter/dispatch/core/backfill.py @@ -87,12 +87,12 @@ async def backfill_codex_history( if ( lane.provider != "codex" or lane.binding_id != DEFAULT_CODEX_BINDING_ID - or lane.provider_session_id != lane.id + or lane.provider_thread_id != lane.id ): raise CapabilityUnavailableError( f"history sync is unavailable for provider binding {lane.provider}:{lane.binding_id}" ) - native_id = lane.provider_session_id + native_id = lane.provider_thread_id started = monotonic() deadline = started + max_seconds initial_request = ThreadResumeInitialTurnsPageParams( diff --git a/src/outfitter/dispatch/core/claude_launch.py b/src/outfitter/dispatch/core/claude_launch.py index 0872442..df15a18 100644 --- a/src/outfitter/dispatch/core/claude_launch.py +++ b/src/outfitter/dispatch/core/claude_launch.py @@ -208,7 +208,7 @@ async def launch_claude_background( provider="claude", reconciliation="pending", short_id=short_id, - provider_session_id=None, + provider_thread_id=None, launch_cwd=str(envelope.cwd), pending_reason="roster_unavailable", ) @@ -217,7 +217,7 @@ async def launch_claude_background( provider="claude", reconciliation="pending", short_id=short_id, - provider_session_id=None, + provider_thread_id=None, launch_cwd=str(envelope.cwd), pending_reason="roster_unavailable", ) diff --git a/src/outfitter/dispatch/core/claude_launch_types.py b/src/outfitter/dispatch/core/claude_launch_types.py index f4939cd..c8bb48f 100644 --- a/src/outfitter/dispatch/core/claude_launch_types.py +++ b/src/outfitter/dispatch/core/claude_launch_types.py @@ -82,7 +82,7 @@ class ClaudeLaunchObservation: provider: Literal["claude"] reconciliation: Literal["reconciled", "pending"] short_id: str - provider_session_id: str | None + provider_thread_id: str | None launch_cwd: str pending_reason: Literal["roster_absent", "identity_pending", "roster_unavailable"] | None = None observed_cwd: str | None = None diff --git a/src/outfitter/dispatch/core/claude_roster.py b/src/outfitter/dispatch/core/claude_roster.py index 44e6c02..769d3cb 100644 --- a/src/outfitter/dispatch/core/claude_roster.py +++ b/src/outfitter/dispatch/core/claude_roster.py @@ -41,7 +41,7 @@ def reconcile_claude_launch( provider="claude", reconciliation="pending", short_id=short_id, - provider_session_id=None, + provider_thread_id=None, launch_cwd=str(launch_cwd), pending_reason="roster_absent", ) @@ -57,7 +57,7 @@ def reconcile_claude_launch( provider="claude", reconciliation="pending", short_id=short_id, - provider_session_id=None, + provider_thread_id=None, launch_cwd=str(launch_cwd), pending_reason="identity_pending", observed_cwd=_optional_text(row, "cwd"), @@ -69,14 +69,14 @@ def reconcile_claude_launch( if not isinstance(session_id, str): raise ClaudeLaunchOutputError("Claude roster session identity has an incompatible type") try: - provider_session_id = str(uuid.UUID(session_id)) + provider_thread_id = str(uuid.UUID(session_id)) except ValueError as exc: raise ClaudeLaunchOutputError("Claude roster session identity is not a full UUID") from exc return ClaudeLaunchObservation( provider="claude", reconciliation="reconciled", short_id=short_id, - provider_session_id=provider_session_id, + provider_thread_id=provider_thread_id, launch_cwd=str(launch_cwd), observed_cwd=_optional_text(row, "cwd"), observed_name=_optional_text(row, "name"), diff --git a/src/outfitter/dispatch/core/delivery.py b/src/outfitter/dispatch/core/delivery.py index fc0e6b5..d7c7f46 100644 --- a/src/outfitter/dispatch/core/delivery.py +++ b/src/outfitter/dispatch/core/delivery.py @@ -29,10 +29,10 @@ def _native_id(lane: Lane) -> str | None: if ( lane.provider != "codex" or lane.binding_id != DEFAULT_CODEX_BINDING_ID - or lane.provider_session_id != lane.id + or lane.provider_thread_id != lane.id ): return None - return lane.provider_session_id + return lane.provider_thread_id async def get_receipt(inp: DeliveryLookupInput, ctx: Ctx) -> DeliveryView: diff --git a/src/outfitter/dispatch/core/delivery_reconciliation.py b/src/outfitter/dispatch/core/delivery_reconciliation.py index b29bdd3..f9033a1 100644 --- a/src/outfitter/dispatch/core/delivery_reconciliation.py +++ b/src/outfitter/dispatch/core/delivery_reconciliation.py @@ -22,10 +22,10 @@ def _native_id(lane: Lane | None) -> str | None: lane is None or lane.provider != "codex" or lane.binding_id != DEFAULT_CODEX_BINDING_ID - or lane.provider_session_id != lane.id + or lane.provider_thread_id != lane.id ): return None - return lane.provider_session_id + return lane.provider_thread_id async def reconcile_pending(ctx: Ctx) -> None: diff --git a/src/outfitter/dispatch/core/event_index.py b/src/outfitter/dispatch/core/event_index.py index 6ca1e2a..aa27cd6 100644 --- a/src/outfitter/dispatch/core/event_index.py +++ b/src/outfitter/dispatch/core/event_index.py @@ -48,7 +48,7 @@ async def index_codex_lane_event( provider_event = ProviderEvent( provider=lane.provider, binding_id=lane.binding_id, - provider_thread_id=lane.provider_session_id or lane.id, + provider_thread_id=lane.provider_thread_id or lane.id, lane=lane.id, event_type=_event_type(event), provider_event_id=_provider_event_id(event), @@ -65,7 +65,7 @@ async def index_codex_lane_event( if isinstance(event, ItemStarted | ItemCompleted) and event.item is not None: item, refs = normalize_codex_item( event.item, - provider_thread_id=lane.provider_session_id or lane.id, + provider_thread_id=lane.provider_thread_id or lane.id, binding_id=lane.binding_id, lane=lane.id, turn_id=event.turn_id, @@ -261,7 +261,7 @@ def _thread_turn( return ThreadTurn( provider=lane.provider, binding_id=lane.binding_id, - provider_thread_id=lane.provider_session_id or lane.id, + provider_thread_id=lane.provider_thread_id or lane.id, lane=lane.id, turn_id=event.turn_id, status="started", @@ -272,7 +272,7 @@ def _thread_turn( return ThreadTurn( provider=lane.provider, binding_id=lane.binding_id, - provider_thread_id=lane.provider_session_id or lane.id, + provider_thread_id=lane.provider_thread_id or lane.id, lane=lane.id, turn_id=event.turn_id, status="completed", @@ -285,7 +285,7 @@ def _thread_turn( return ThreadTurn( provider=lane.provider, binding_id=lane.binding_id, - provider_thread_id=lane.provider_session_id or lane.id, + provider_thread_id=lane.provider_thread_id or lane.id, lane=lane.id, turn_id=event.turn_id, status=event.execution_status, @@ -356,7 +356,7 @@ def _state( lane=lane.id, provider=lane.provider, binding_id=lane.binding_id, - provider_thread_id=lane.provider_session_id or lane.id, + provider_thread_id=lane.provider_thread_id or lane.id, status=status, # type: ignore[arg-type] active_turn_id=active_turn_id, latest_turn_id=latest_turn_id, diff --git a/src/outfitter/dispatch/core/handlers.py b/src/outfitter/dispatch/core/handlers.py index 7c190b5..354ec00 100644 --- a/src/outfitter/dispatch/core/handlers.py +++ b/src/outfitter/dispatch/core/handlers.py @@ -214,7 +214,7 @@ class _ManagedIdentityPayload(TypedDict): id: str provider: str binding_id: str - provider_session_id: str | None + provider_thread_id: str | None title: str | None handle: str | None managed: bool @@ -253,7 +253,7 @@ def _has_default_codex_binding(lane: Lane) -> bool: return ( lane.provider == "codex" and lane.binding_id == DEFAULT_CODEX_BINDING_ID - and lane.provider_session_id == lane.id + and lane.provider_thread_id == lane.id ) @@ -262,8 +262,8 @@ def _require_default_codex_binding(lane: Lane, operation: str) -> str: raise CapabilityUnavailableError( f"{operation} is unavailable for provider binding {lane.provider}:{lane.binding_id}" ) - assert lane.provider_session_id is not None - return lane.provider_session_id + assert lane.provider_thread_id is not None + return lane.provider_thread_id def _can_write(lane: Lane, ctx: Ctx) -> bool: @@ -309,7 +309,7 @@ def _ref(lane: Lane, ctx: Ctx) -> LaneRef: id=lane.id, provider=lane.provider, binding_id=lane.binding_id, - provider_session_id=lane.provider_session_id, + provider_thread_id=lane.provider_thread_id, handle=lane.handle, source=lane.source, status=lane.status, @@ -346,7 +346,7 @@ def _managed_identity(lane: Lane, ctx: Ctx) -> _ManagedIdentityPayload: "id": lane.id, "provider": lane.provider, "binding_id": lane.binding_id, - "provider_session_id": lane.provider_session_id, + "provider_thread_id": lane.provider_thread_id, "title": lane.handle.removeprefix("@"), "handle": lane.handle, "managed": True, @@ -537,7 +537,7 @@ async def _resolve_self(ctx: Ctx, caller_thread_id: str | None) -> Lane: thread_id = caller_thread_id or os.environ.get("CODEX_THREAD_ID") if not thread_id: raise ValidationError("self requires CODEX_THREAD_ID from the current Codex thread") - lane = await ctx.registry.find_lane_by_provider_session( + lane = await ctx.registry.find_lane_by_provider_thread( "codex", DEFAULT_CODEX_BINDING_ID, thread_id ) if lane is None: @@ -951,6 +951,7 @@ async def attach_lane(inp: AttachInput, ctx: Ctx) -> LaneRef: existing = await ctx.registry.find_lane(inp.thread) if existing is not None: if inp.sync: + _require_default_codex_binding(existing, "sync") await _sync_lane(existing, ctx, full=False) return _ref(existing, ctx) # idempotent re-attach try: @@ -1371,7 +1372,7 @@ async def _record_direct_send_receipt( lane=lane.id, provider=lane.provider, binding_id=lane.binding_id, - provider_thread_id=lane.provider_session_id or lane.id, + provider_thread_id=lane.provider_thread_id or lane.id, status=status, # type: ignore[arg-type] error=error, created_at=now, @@ -1397,7 +1398,7 @@ async def _record_queue_receipt( queued_message_id=queued_message_id, provider=lane.provider, binding_id=lane.binding_id, - provider_thread_id=lane.provider_session_id or lane.id, + provider_thread_id=lane.provider_thread_id or lane.id, dispatch_message_id=f"queue:{queued_message_id}", status=status, # type: ignore[arg-type] error=error, @@ -2224,6 +2225,9 @@ async def _history_summary_from_index(lane: Lane, ctx: Ctx) -> HistoryThreadSumm return HistoryThreadSummary( ref=lane.ref, id=lane.id, + provider=lane.provider, + binding_id=lane.binding_id, + provider_thread_id=lane.provider_thread_id, handle=lane.handle, source=lane.source, status=lane.status, @@ -2420,6 +2424,9 @@ def _query_match( return QueryMatch( ref=lane.ref, id=lane.id, + provider=lane.provider, + binding_id=lane.binding_id, + provider_thread_id=lane.provider_thread_id, handle=lane.handle, source=lane.source, status=lane.status, diff --git a/src/outfitter/dispatch/core/history.py b/src/outfitter/dispatch/core/history.py index 12edde9..d8947d1 100644 --- a/src/outfitter/dispatch/core/history.py +++ b/src/outfitter/dispatch/core/history.py @@ -133,6 +133,9 @@ def summarize_history( summary = HistoryThreadSummary( ref=lane.ref, id=lane.id, + provider=lane.provider, + binding_id=lane.binding_id, + provider_thread_id=lane.provider_thread_id, handle=lane.handle, source=lane.source, status=lane.status, diff --git a/src/outfitter/dispatch/core/history_index.py b/src/outfitter/dispatch/core/history_index.py index 763965b..aefb8e0 100644 --- a/src/outfitter/dispatch/core/history_index.py +++ b/src/outfitter/dispatch/core/history_index.py @@ -40,7 +40,7 @@ async def index_codex_thread_read( registry, lane, [turn for turn in turns if isinstance(turn, dict)], - provider_thread_id=_string(thread.get("id")) or lane.provider_session_id or lane.id, + provider_thread_id=_string(thread.get("id")) or lane.provider_thread_id or lane.id, capture=capture, completion_source="thread-read", ) @@ -58,7 +58,7 @@ async def index_codex_turns_page( registry, lane, [turn.model_dump(by_alias=True, exclude_none=True) for turn in turns], - provider_thread_id=lane.provider_session_id or lane.id, + provider_thread_id=lane.provider_thread_id or lane.id, capture=capture, completion_source="thread-turns-list", ) @@ -86,7 +86,7 @@ async def index_codex_items_page( item_ids.add(item_id) item, refs = normalize_codex_item( raw_item, - provider_thread_id=lane.provider_session_id or lane.id, + provider_thread_id=lane.provider_thread_id or lane.id, binding_id=lane.binding_id, lane=lane.id, turn_id=turn_id, @@ -100,7 +100,7 @@ async def index_codex_items_page( items=indexed_items, provider=lane.provider, binding_id=lane.binding_id, - provider_thread_id=lane.provider_session_id or lane.id, + provider_thread_id=lane.provider_thread_id or lane.id, turn_ids=set(), item_ids=item_ids, prune_missing=False, diff --git a/src/outfitter/dispatch/core/models.py b/src/outfitter/dispatch/core/models.py index e5e7e66..67005a8 100644 --- a/src/outfitter/dispatch/core/models.py +++ b/src/outfitter/dispatch/core/models.py @@ -722,7 +722,7 @@ class LaneRef(BaseModel): id: str provider: str binding_id: str - provider_session_id: str | None = None + provider_thread_id: str | None = None handle: str source: LaneSource status: LaneStatus @@ -756,7 +756,7 @@ class ManagedThreadIdentity(BaseModel): id: str provider: str binding_id: str - provider_session_id: str | None = None + provider_thread_id: str | None = None title: str | None = None handle: str | None = None managed: bool = True @@ -1156,6 +1156,9 @@ class HistoryItem(TranscriptItem): class HistoryThreadSummary(BaseModel): ref: str | None = None id: str + provider: str + binding_id: str + provider_thread_id: str | None = None handle: str | None = None source: LaneSource | None = None status: LaneStatus | None = None @@ -1304,6 +1307,9 @@ class QueryRef(BaseModel): class QueryMatch(BaseModel): ref: str id: str + provider: str + binding_id: str + provider_thread_id: str | None = None handle: str source: LaneSource status: LaneStatus diff --git a/src/outfitter/dispatch/core/ops.py b/src/outfitter/dispatch/core/ops.py index 409fa17..378d215 100644 --- a/src/outfitter/dispatch/core/ops.py +++ b/src/outfitter/dispatch/core/ops.py @@ -102,7 +102,7 @@ "id": "lane-1", "provider": "codex", "binding_id": "codex-default", - "provider_session_id": "lane-1", + "provider_thread_id": "lane-1", "handle": "@alpha", "source": "own", "status": "idle", @@ -153,7 +153,7 @@ "id": "lane-1", "provider": "codex", "binding_id": "codex-default", - "provider_session_id": "lane-1", + "provider_thread_id": "lane-1", "handle": "@[dispatch] alpha", "source": "own", "status": "idle", @@ -333,7 +333,7 @@ "id": "T1", "provider": "codex", "binding_id": "codex-default", - "provider_session_id": "T1", + "provider_thread_id": "T1", "handle": "@T1", "source": "attached", "status": "idle", diff --git a/src/outfitter/dispatch/core/queue.py b/src/outfitter/dispatch/core/queue.py index bc50fa9..f29b245 100644 --- a/src/outfitter/dispatch/core/queue.py +++ b/src/outfitter/dispatch/core/queue.py @@ -36,10 +36,10 @@ async def drain_next_queued_message(ctx: Ctx, lane_id: str) -> bool: if ( lane.provider != "codex" or lane.binding_id != DEFAULT_CODEX_BINDING_ID - or lane.provider_session_id != lane.id + or lane.provider_thread_id != lane.id ): return False - native_id = lane.provider_session_id + native_id = lane.provider_thread_id if await ctx.registry.lane_delivery_held(lane.id): return False message = await ctx.registry.next_pending_message(lane.id) @@ -137,7 +137,7 @@ async def _record_queue_receipt( queued_message_id=message.id, provider=lane.provider, binding_id=lane.binding_id, - provider_thread_id=lane.provider_session_id or lane.id, + provider_thread_id=lane.provider_thread_id or lane.id, dispatch_message_id=f"queue:{message.id}", status=status, # type: ignore[arg-type] error=error, diff --git a/src/outfitter/dispatch/core/reactor.py b/src/outfitter/dispatch/core/reactor.py index 19872bf..6ac52f3 100644 --- a/src/outfitter/dispatch/core/reactor.py +++ b/src/outfitter/dispatch/core/reactor.py @@ -95,7 +95,7 @@ async def handle(self, event: LaneEvent) -> None: await registry.mark_provider_thread_state("codex", event.lane_id, "active") elif isinstance(event, ThreadDeleted): await registry.mark_provider_thread_state("codex", event.lane_id, "deleted") - lane = await registry.find_lane_by_provider_session( + lane = await registry.find_lane_by_provider_thread( "codex", DEFAULT_CODEX_BINDING_ID, event.lane_id ) if lane is None: diff --git a/src/outfitter/dispatch/core/server_requests.py b/src/outfitter/dispatch/core/server_requests.py index 93e250b..c40a2a5 100644 --- a/src/outfitter/dispatch/core/server_requests.py +++ b/src/outfitter/dispatch/core/server_requests.py @@ -88,7 +88,7 @@ async def close(self) -> None: async def handle(self, request: ServerRequestReceived) -> ServerRequest: lane = ( - await self._ctx.registry.find_lane_by_provider_session( + await self._ctx.registry.find_lane_by_provider_thread( "codex", DEFAULT_CODEX_BINDING_ID, request.lane_id ) if request.lane_id @@ -249,7 +249,7 @@ async def _surface_attention( lane=lane.id, provider=lane.provider, binding_id=lane.binding_id, - provider_thread_id=lane.provider_session_id or lane.id, + provider_thread_id=lane.provider_thread_id or lane.id, status=status, active_turn_id=(current.active_turn_id if current else lane.active_turn_id), latest_turn_id=(current.latest_turn_id if current else lane.latest_turn_id), @@ -314,7 +314,7 @@ async def _clear_attention_if_resolved(ctx: Ctx, lane_id: str) -> None: lane=lane_id, provider=lane.provider, binding_id=lane.binding_id, - provider_thread_id=lane.provider_session_id or lane.id, + provider_thread_id=lane.provider_thread_id or lane.id, status=status, active_turn_id=active_turn_id, latest_turn_id=(current.latest_turn_id if current else lane.latest_turn_id), diff --git a/src/outfitter/dispatch/core/subscriptions.py b/src/outfitter/dispatch/core/subscriptions.py index ccc70b1..970df51 100644 --- a/src/outfitter/dispatch/core/subscriptions.py +++ b/src/outfitter/dispatch/core/subscriptions.py @@ -159,11 +159,11 @@ async def _tail_text(ctx: Ctx, lane: Lane, tail: int) -> str | None: if ( lane.provider != "codex" or lane.binding_id != DEFAULT_CODEX_BINDING_ID - or lane.provider_session_id != lane.id + or lane.provider_thread_id != lane.id ): return None try: - result = await ctx.client.thread_read(lane.provider_session_id, include_turns=True) + result = await ctx.client.thread_read(lane.provider_thread_id, include_turns=True) except Exception as exc: ctx.log.warning("subscription.tail_read_failed", lane=lane.id, error=str(exc)) return None diff --git a/src/outfitter/dispatch/core/topology.py b/src/outfitter/dispatch/core/topology.py index f29d89c..84ccfef 100644 --- a/src/outfitter/dispatch/core/topology.py +++ b/src/outfitter/dispatch/core/topology.py @@ -156,19 +156,19 @@ async def lane_topology_views( views: dict[str, ThreadTopologyView] = {} groups: dict[tuple[str, str], list[Lane]] = {} for lane in lanes: - if lane.provider_session_id is None: + if lane.provider_thread_id is None: views[lane.id] = ThreadTopologyView() continue groups.setdefault((lane.provider, lane.binding_id), []).append(lane) for (provider, binding_id), group in groups.items(): native_views = await topology_views( registry, - [lane.provider_session_id for lane in group if lane.provider_session_id is not None], + [lane.provider_thread_id for lane in group if lane.provider_thread_id is not None], max_nodes=max_nodes, provider=provider, binding_id=binding_id, ) for lane in group: - assert lane.provider_session_id is not None - views[lane.id] = native_views[lane.provider_session_id] + assert lane.provider_thread_id is not None + views[lane.id] = native_views[lane.provider_thread_id] return views diff --git a/src/outfitter/dispatch/daemon/supervisor.py b/src/outfitter/dispatch/daemon/supervisor.py index c033333..e6d47da 100644 --- a/src/outfitter/dispatch/daemon/supervisor.py +++ b/src/outfitter/dispatch/daemon/supervisor.py @@ -96,7 +96,7 @@ async def _restore_lanes(self, client: SupervisedClient) -> None: if ( lane.provider != "codex" or lane.binding_id != DEFAULT_CODEX_BINDING_ID - or lane.provider_session_id != lane.id + or lane.provider_thread_id != lane.id ): self._ctx.log.info( "lane.restore_unsupported_binding", @@ -105,7 +105,7 @@ async def _restore_lanes(self, client: SupervisedClient) -> None: binding_id=lane.binding_id, ) continue - native_id = lane.provider_session_id + native_id = lane.provider_thread_id try: sync = await self._ctx.registry.get_lane_sync(lane.id) observed = sync is not None and sync.observation_enabled diff --git a/src/outfitter/dispatch/registry/models.py b/src/outfitter/dispatch/registry/models.py index f64c504..5631532 100644 --- a/src/outfitter/dispatch/registry/models.py +++ b/src/outfitter/dispatch/registry/models.py @@ -449,7 +449,7 @@ class Lane(BaseModel): id: str # stable Dispatch key (native Codex id for the default binding) provider: str = "codex" binding_id: str = "codex-default" - provider_session_id: str | None = None + provider_thread_id: str | None = None ref: str # dispatch-local stable short ref ref_source: str ref_payload: str diff --git a/src/outfitter/dispatch/registry/sql_compat.py b/src/outfitter/dispatch/registry/sql_compat.py index 7b27bea..a8d2208 100644 --- a/src/outfitter/dispatch/registry/sql_compat.py +++ b/src/outfitter/dispatch/registry/sql_compat.py @@ -71,7 +71,7 @@ def exercise_registry_sql(conn: Any) -> None: conn, """ INSERT INTO lanes ( - id, provider, binding_id, provider_session_id, ref, ref_source, ref_payload, + id, provider, binding_id, provider_thread_id, ref, ref_source, ref_payload, ref_mixer, handle, source, status, pinned, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, diff --git a/src/outfitter/dispatch/registry/store.py b/src/outfitter/dispatch/registry/store.py index 5f717b9..e7276f1 100644 --- a/src/outfitter/dispatch/registry/store.py +++ b/src/outfitter/dispatch/registry/store.py @@ -78,7 +78,7 @@ ) Clock = Callable[[], datetime] -SCHEMA_VERSION = 24 +SCHEMA_VERSION = 25 DEFAULT_CODEX_BINDING_ID = "codex-default" @@ -242,16 +242,16 @@ def _extension_suffix(ext: str) -> str: return ext if ext.startswith(".") else f".{ext}" -def _initial_provider_session_id( - lane_id: str, provider: str, binding_id: str, provider_session_id: str | None +def _initial_provider_thread_id( + lane_id: str, provider: str, binding_id: str, provider_thread_id: str | None ) -> str | None: if provider == "codex" and binding_id == DEFAULT_CODEX_BINDING_ID: - if provider_session_id not in (None, lane_id): + if provider_thread_id not in (None, lane_id): raise ValueError("a new default-Codex lane must retain its native id as the lane id") return lane_id if not lane_id.startswith("dsp_"): raise ValueError("non-default provider lanes require an opaque dsp_ Dispatch id") - return provider_session_id + return provider_thread_id _QUEUED_MESSAGES_SCHEMA = """ @@ -513,7 +513,7 @@ def _utcnow() -> datetime: id TEXT PRIMARY KEY, provider TEXT NOT NULL, binding_id TEXT NOT NULL, - provider_session_id TEXT, + provider_thread_id TEXT, ref TEXT NOT NULL UNIQUE, ref_source TEXT NOT NULL, ref_payload TEXT NOT NULL, @@ -710,9 +710,9 @@ def _utcnow() -> datetime: """ _BINDING_SCOPE_INDEX_STATEMENTS = ( - "CREATE UNIQUE INDEX IF NOT EXISTS idx_lanes_provider_session " - "ON lanes(provider, binding_id, provider_session_id) " - "WHERE provider_session_id IS NOT NULL", + "CREATE UNIQUE INDEX IF NOT EXISTS idx_lanes_provider_thread " + "ON lanes(provider, binding_id, provider_thread_id) " + "WHERE provider_thread_id IS NOT NULL", "CREATE INDEX IF NOT EXISTS idx_provider_threads_parent " "ON provider_threads(provider, binding_id, parent_thread_id)", "CREATE INDEX IF NOT EXISTS idx_provider_threads_fork " @@ -870,6 +870,8 @@ async def _migrate(self, user_version: int) -> None: await self._ensure_deliveries_table() if user_version < 24: await self._ensure_binding_scope_v24() + if user_version < 25: + await self._ensure_lane_provider_thread_id_v25() async def _ensure_binding_scope_v24(self) -> None: """Add binding identity without changing stable lane keys or local row ids.""" @@ -897,11 +899,11 @@ async def _ensure_binding_scope_v24(self) -> None: "ALTER TABLE lanes ADD COLUMN binding_id TEXT NOT NULL " f"DEFAULT '{DEFAULT_CODEX_BINDING_ID}'" ) - added_provider_session_id = "provider_session_id" not in lane_columns - if added_provider_session_id: - await self._conn.execute("ALTER TABLE lanes ADD COLUMN provider_session_id TEXT") + added_provider_thread_id = "provider_thread_id" not in lane_columns + if added_provider_thread_id: + await self._conn.execute("ALTER TABLE lanes ADD COLUMN provider_thread_id TEXT") await self._conn.execute( - "UPDATE lanes SET provider = 'codex', binding_id = ?, provider_session_id = id", + "UPDATE lanes SET provider = 'codex', binding_id = ?, provider_thread_id = id", (DEFAULT_CODEX_BINDING_ID,), ) @@ -990,6 +992,22 @@ async def _create_binding_scope_indexes(self) -> None: for statement in _BINDING_SCOPE_INDEX_STATEMENTS: await self._conn.execute(statement) + async def _ensure_lane_provider_thread_id_v25(self) -> None: + """Rename the managed lane's native conversation identity column.""" + + async with self._conn.execute("PRAGMA table_info(lanes)") as cur: + columns = {str(row["name"]) for row in await cur.fetchall()} + if "provider_thread_id" not in columns and "provider_session_id" in columns: + await self._conn.execute("DROP INDEX IF EXISTS idx_lanes_provider_session") + await self._conn.execute( + "ALTER TABLE lanes RENAME COLUMN provider_session_id TO provider_thread_id" + ) + await self._conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_lanes_provider_thread " + "ON lanes(provider, binding_id, provider_thread_id) " + "WHERE provider_thread_id IS NOT NULL" + ) + async def _ensure_deliveries_table(self) -> None: await self._conn.executescript(_DELIVERIES_SCHEMA) @@ -1368,18 +1386,18 @@ async def add_lane( pinned: bool = False, provider: str = "codex", binding_id: str = DEFAULT_CODEX_BINDING_ID, - provider_session_id: str | None = None, + provider_thread_id: str | None = None, ) -> Lane: now = self._now() - native_id = _initial_provider_session_id(id, provider, binding_id, provider_session_id) + native_id = _initial_provider_thread_id(id, provider, binding_id, provider_thread_id) ref, ref_source, ref_payload, ref_mixer = await self._allocate_ref_parts( - id, provider=provider, binding_id=binding_id, provider_session_id=native_id + id, provider=provider, binding_id=binding_id, provider_thread_id=native_id ) lane = Lane( id=id, provider=provider, binding_id=binding_id, - provider_session_id=native_id, + provider_thread_id=native_id, ref=ref, ref_source=ref_source, ref_payload=ref_payload, @@ -1414,20 +1432,20 @@ async def add_lane_with_sync( audit_detail: str | None = None, provider: str = "codex", binding_id: str = DEFAULT_CODEX_BINDING_ID, - provider_session_id: str | None = None, + provider_thread_id: str | None = None, ) -> tuple[Lane, LaneSync]: if sync.lane != id: raise ValueError(f"sync lane {sync.lane!r} does not match lane id {id!r}") now = self._now() - native_id = _initial_provider_session_id(id, provider, binding_id, provider_session_id) + native_id = _initial_provider_thread_id(id, provider, binding_id, provider_thread_id) ref, ref_source, ref_payload, ref_mixer = await self._allocate_ref_parts( - id, provider=provider, binding_id=binding_id, provider_session_id=native_id + id, provider=provider, binding_id=binding_id, provider_thread_id=native_id ) lane = Lane( id=id, provider=provider, binding_id=binding_id, - provider_session_id=native_id, + provider_thread_id=native_id, ref=ref, ref_source=ref_source, ref_payload=ref_payload, @@ -1455,7 +1473,7 @@ async def add_lane_with_sync( async def _insert_lane(self, lane: Lane) -> None: await self._conn.execute( - "INSERT INTO lanes (id, provider, binding_id, provider_session_id, ref, ref_source, " + "INSERT INTO lanes (id, provider, binding_id, provider_thread_id, ref, ref_source, " "ref_payload, ref_mixer, handle, role, cwd, " "source, status, pinned, active_turn_id, latest_turn_id, latest_turn_status, " "latest_error, latest_error_at, created_at, updated_at, last_event_at) " @@ -1464,7 +1482,7 @@ async def _insert_lane(self, lane: Lane) -> None: lane.id, lane.provider, lane.binding_id, - lane.provider_session_id, + lane.provider_thread_id, lane.ref, lane.ref_source, lane.ref_payload, @@ -1492,14 +1510,14 @@ async def _allocate_ref_parts( *, provider: str = "codex", binding_id: str = DEFAULT_CODEX_BINDING_ID, - provider_session_id: str | None = None, + provider_thread_id: str | None = None, ) -> tuple[str, str, str, str]: native_id = ( lane_id if provider == "codex" and binding_id == DEFAULT_CODEX_BINDING_ID - and provider_session_id is None - else provider_session_id + and provider_thread_id is None + else provider_thread_id ) is_default_codex = ( provider == "codex" and binding_id == DEFAULT_CODEX_BINDING_ID and native_id == lane_id @@ -1516,40 +1534,40 @@ async def _allocate_ref_parts( ) @_serialized_access - async def find_lane_by_provider_session( - self, provider: str, binding_id: str, provider_session_id: str + async def find_lane_by_provider_thread( + self, provider: str, binding_id: str, provider_thread_id: str ) -> Lane | None: async with self._conn.execute( - "SELECT * FROM lanes WHERE provider = ? AND binding_id = ? AND provider_session_id = ?", - (provider, binding_id, provider_session_id), + "SELECT * FROM lanes WHERE provider = ? AND binding_id = ? AND provider_thread_id = ?", + (provider, binding_id, provider_thread_id), ) as cur: row = await cur.fetchone() return _row_to_lane(row) if row is not None else None @_serialized_access - async def update_lane_provider_session( + async def update_lane_provider_thread( self, lane_id: str, *, provider: str, binding_id: str, - provider_session_id: str, + provider_thread_id: str, ) -> Lane: """Record native continuation evidence without permitting a binding retarget.""" if ( provider == "codex" and binding_id == DEFAULT_CODEX_BINDING_ID - and provider_session_id != lane_id + and provider_thread_id != lane_id ): raise ValidationError( - "default-Codex provider session identity must equal the stable lane id" + "default-Codex provider thread identity must equal the stable lane id" ) async with self._transaction(): cur = await self._conn.execute( - "UPDATE lanes SET provider_session_id = ?, updated_at = ? " + "UPDATE lanes SET provider_thread_id = ?, updated_at = ? " "WHERE id = ? AND provider = ? AND binding_id = ?", - (provider_session_id, self.now_iso(), lane_id, provider, binding_id), + (provider_thread_id, self.now_iso(), lane_id, provider, binding_id), ) if cur.rowcount != 1: raise NotFoundError( @@ -3046,7 +3064,7 @@ async def _get_provider_thread_nodes( "lanes.status AS lane_status FROM provider_threads " "LEFT JOIN lanes ON lanes.provider = provider_threads.provider " "AND lanes.binding_id = provider_threads.binding_id " - "AND lanes.provider_session_id = provider_threads.provider_thread_id " + "AND lanes.provider_thread_id = provider_threads.provider_thread_id " "WHERE provider_threads.provider = ? AND provider_threads.binding_id = ? " f"AND provider_threads.provider_thread_id IN ({placeholders}) " "ORDER BY provider_threads.provider_thread_id", @@ -3375,7 +3393,7 @@ async def list_pending_server_requests( binding_id: str = DEFAULT_CODEX_BINDING_ID, limit: int = 50, ) -> list[ServerRequest]: - if provider_session_id is None: + if provider_session_id is None and binding_id == DEFAULT_CODEX_BINDING_ID: return await self.list_server_requests(lane=lane, limit=limit) sql = "SELECT * FROM server_requests WHERE state = 'pending'" @@ -3383,9 +3401,11 @@ async def list_pending_server_requests( if lane is not None: sql += " AND lane = ?" params.append(lane) + sql += " AND binding_id = ?" + params.append(binding_id) if provider_session_id is not None: - sql += " AND binding_id = ? AND provider_session_id = ?" - params.extend((binding_id, provider_session_id)) + sql += " AND provider_session_id = ?" + params.append(provider_session_id) sql += " ORDER BY deadline_at, received_at, request_id_json LIMIT ?" params.append(limit) async with self._conn.execute(sql, tuple(params)) as cur: diff --git a/tests/core/test_claude_launch.py b/tests/core/test_claude_launch.py index 6d96232..b530901 100644 --- a/tests/core/test_claude_launch.py +++ b/tests/core/test_claude_launch.py @@ -150,7 +150,7 @@ def test_reconcile_unique_full_uuid(tmp_path: Path) -> None: ) assert observation.reconciliation == "reconciled" - assert observation.provider_session_id == session_id + assert observation.provider_thread_id == session_id assert observation.observed_cwd == "/effective/worktree" assert observation.observed_worktree == "/effective/worktree" @@ -164,7 +164,7 @@ def test_reconcile_absent_or_not_yet_identified_is_pending(tmp_path: Path) -> No ) assert absent.reconciliation == "pending" - assert absent.provider_session_id is None + assert absent.provider_thread_id is None assert absent.pending_reason == "roster_absent" assert provisional.reconciliation == "pending" assert provisional.pending_reason == "identity_pending" @@ -218,7 +218,7 @@ async def run( ("claude", "agents", "--json", "--all"), ] assert all(call[1] == tmp_path for call in calls) - assert result.provider_session_id == session_id + assert result.provider_thread_id == session_id async def test_validation_happens_before_process_invocation(tmp_path: Path) -> None: @@ -268,7 +268,7 @@ async def run( assert observation.reconciliation == "pending" assert observation.pending_reason == "roster_unavailable" assert observation.short_id == "518b912b" - assert observation.provider_session_id is None + assert observation.provider_thread_id is None async def test_post_launch_incompatible_roster_still_fails_closed(tmp_path: Path) -> None: @@ -313,7 +313,7 @@ async def run( ) assert observation.reconciliation == "reconciled" - assert observation.provider_session_id == session_id + assert observation.provider_thread_id == session_id @pytest.mark.parametrize("candidates", [(), ("518b912b", "91abcdef")]) diff --git a/tests/core/test_handlers.py b/tests/core/test_handlers.py index 73fb3e8..87f5ebf 100644 --- a/tests/core/test_handlers.py +++ b/tests/core/test_handlers.py @@ -1372,6 +1372,9 @@ async def test_history_overview_summarizes_managed_threads(store: Registry) -> N assert len(out.threads) == 1 summary = out.threads[0] assert summary.ref == "0BGeK1" + assert summary.provider == "codex" + assert summary.binding_id == "codex-default" + assert summary.provider_thread_id == "lane-1" assert summary.turns == 1 assert summary.items == 3 assert summary.messages == 1 @@ -3048,20 +3051,35 @@ async def test_query_reads_indexed_managed_history_without_app_server_search( (repo / ".git").mkdir() client = FakeLaneClient() ctx = make_ctx(store, client) - lane = await store.add_lane(id="L1", handle="@local", source="own", cwd=str(repo)) + lane = await store.add_lane( + id="dsp_local", + handle="@local", + source="own", + cwd=str(repo), + provider="codex", + binding_id="profile-a", + provider_thread_id="profile-native", + ) await store.upsert_thread_turn( - thread_turn(lane=lane.id, provider_thread_id=lane.id, turn_id="turn-1") + thread_turn( + lane=lane.id, + binding_id="profile-a", + provider_thread_id="profile-native", + turn_id="turn-1", + ) ) await store.upsert_thread_item( thread_item( lane=lane.id, - provider_thread_id=lane.id, + binding_id="profile-a", + provider_thread_id="profile-native", turn_id="turn-1", item_id="item-1", ).model_copy(update={"text": "local needle lives in normalized history"}), refs=[ thread_item_ref( - provider_thread_id=lane.id, + binding_id="profile-a", + provider_thread_id="profile-native", item_id="item-1", ref_type="file", ref_value="src/local.py", @@ -3075,6 +3093,9 @@ async def test_query_reads_indexed_managed_history_without_app_server_search( assert out.scanned == 1 assert [match.ref for match in out.matches] == [lane.ref] assert out.matches[0].handle == "@local" + assert out.matches[0].provider == "codex" + assert out.matches[0].binding_id == "profile-a" + assert out.matches[0].provider_thread_id == "profile-native" assert "local needle lives" in out.matches[0].snippet assert out.matches[0].files == ["src/local.py"] assert not any(name == "thread_search" for name, _ in client.calls) diff --git a/tests/core/test_provider_binding_identity.py b/tests/core/test_provider_binding_identity.py index daa6d1e..6e86863 100644 --- a/tests/core/test_provider_binding_identity.py +++ b/tests/core/test_provider_binding_identity.py @@ -11,6 +11,7 @@ ) from outfitter.dispatch.core import handlers, queue from outfitter.dispatch.core.models import ( + AttachInput, DiscoverInput, GoalGetInput, HistoryInput, @@ -42,7 +43,7 @@ async def test_default_codex_and_non_codex_outputs_preserve_public_aliases() -> status="idle", provider="claude", binding_id="profile-a", - provider_session_id="claude-native", + provider_thread_id="claude-native", ) client = FakeLaneClient() ctx = make_ctx(store, client) @@ -52,13 +53,13 @@ async def test_default_codex_and_non_codex_outputs_preserve_public_aliases() -> codex_view = by_id[codex.id] assert codex_view.provider == "codex" assert codex_view.binding_id == DEFAULT_CODEX_BINDING_ID - assert codex_view.provider_session_id == codex.id + assert codex_view.provider_thread_id == codex.id assert codex_view.ref == codex.ref other_view = by_id[other.id] assert other_view.provider == "claude" assert other_view.binding_id == "profile-a" - assert other_view.provider_session_id == "claude-native" + assert other_view.provider_thread_id == "claude-native" assert other_view.id == other.id assert other_view.capabilities.send is False assert other_view.capabilities.read is False @@ -66,6 +67,12 @@ async def test_default_codex_and_non_codex_outputs_preserve_public_aliases() -> assert other_view.capabilities.tail is False assert other_view.writable is False + overview = await handlers.history(HistoryInput(), ctx) + other_summary = next(summary for summary in overview.threads if summary.id == other.id) + assert other_summary.provider == "claude" + assert other_summary.binding_id == "profile-a" + assert other_summary.provider_thread_id == "claude-native" + with pytest.raises(NotFoundError, match="no managed thread"): await resolve_managed_selector(ctx, "claude-native") with pytest.raises(CapabilityUnavailableError, match="execution is not supported"): @@ -85,7 +92,7 @@ async def test_non_default_binding_reads_fail_before_codex_client_calls() -> Non status="idle", provider="codex", binding_id="profile-a", - provider_session_id="native-shared", + provider_thread_id="native-shared", ) client = FakeLaneClient() ctx = make_ctx(store, client) @@ -96,6 +103,7 @@ async def test_non_default_binding_reads_fail_before_codex_client_calls() -> Non assert not client.calls calls = ( + handlers.attach_lane(AttachInput(thread=lane.id, sync=True), ctx), handlers.show(ShowInput(lane=lane.ref, include_transcript=True), ctx), handlers.show(ShowInput(lane=lane.ref, topology=True), ctx), handlers.sync_lane(LaneSyncInput(lane=lane.ref), ctx), @@ -130,7 +138,7 @@ async def test_malformed_default_codex_identity_cannot_execute() -> None: try: lane = await store.add_lane(id="codex-stable", handle="@codex", source="own", status="idle") await store._conn.execute( - "UPDATE lanes SET provider_session_id = 'wrong-native' WHERE id = ?", (lane.id,) + "UPDATE lanes SET provider_thread_id = 'wrong-native' WHERE id = ?", (lane.id,) ) await store._conn.commit() client = FakeLaneClient() diff --git a/tests/core/test_server_requests.py b/tests/core/test_server_requests.py index ff02ad3..bb7c1b0 100644 --- a/tests/core/test_server_requests.py +++ b/tests/core/test_server_requests.py @@ -107,7 +107,7 @@ async def test_codex_request_does_not_capture_colliding_non_codex_lane_key( status="idle", provider="claude", binding_id="profile-a", - provider_session_id="native-other", + provider_thread_id="native-other", ) ctx = make_ctx(store, FakeLaneClient()) ctx.provider_session_id = "session-1" diff --git a/tests/daemon/test_supervisor.py b/tests/daemon/test_supervisor.py index 81ee1e8..e2c07b1 100644 --- a/tests/daemon/test_supervisor.py +++ b/tests/daemon/test_supervisor.py @@ -106,7 +106,7 @@ async def test_supervisor_skips_non_default_provider_bindings(store: Registry) - status="idle", provider="codex", binding_id="profile-a", - provider_session_id="native-shared", + provider_thread_id="native-shared", ) ctx = make_ctx(store) client = FakeSupervisedClient() diff --git a/tests/registry/test_binding_identity.py b/tests/registry/test_binding_identity.py index 263a2a9..5075372 100644 --- a/tests/registry/test_binding_identity.py +++ b/tests/registry/test_binding_identity.py @@ -33,7 +33,7 @@ async def test_same_native_identity_is_isolated_by_binding(tmp_path: Path) -> No source="own", provider="claude", binding_id="profile-a", - provider_session_id="native-shared", + provider_thread_id="native-shared", ) lane_b = await store.add_lane( id="dsp_lane_b", @@ -41,7 +41,7 @@ async def test_same_native_identity_is_isolated_by_binding(tmp_path: Path) -> No source="own", provider="claude", binding_id="profile-b", - provider_session_id="native-shared", + provider_thread_id="native-shared", ) assert lane_a.ref_source == lane_b.ref_source == "1" @@ -182,7 +182,7 @@ async def test_dispatch_message_id_cannot_retarget_binding() -> None: await store.close() -async def test_provider_session_continuation_cannot_retarget_binding() -> None: +async def test_provider_thread_continuation_cannot_retarget_binding() -> None: store = await Registry.open() try: reserved = await store.add_lane( @@ -192,27 +192,27 @@ async def test_provider_session_continuation_cannot_retarget_binding() -> None: provider="claude", binding_id="profile-a", ) - assert reserved.provider_session_id is None - continued = await store.update_lane_provider_session( + assert reserved.provider_thread_id is None + continued = await store.update_lane_provider_thread( reserved.id, provider="claude", binding_id="profile-a", - provider_session_id="native-1", + provider_thread_id="native-1", ) - assert continued.provider_session_id == "native-1" + assert continued.provider_thread_id == "native-1" with pytest.raises(NotFoundError, match=r"no lane .* provider binding"): - await store.update_lane_provider_session( + await store.update_lane_provider_thread( reserved.id, provider="claude", binding_id="profile-b", - provider_session_id="native-2", + provider_thread_id="native-2", ) - assert (await store.get_lane(reserved.id)).provider_session_id == "native-1" + assert (await store.get_lane(reserved.id)).provider_thread_id == "native-1" finally: await store.close() -async def test_provider_session_collision_rolls_back_and_registry_remains_writable() -> None: +async def test_provider_thread_collision_rolls_back_and_registry_remains_writable() -> None: store = await Registry.open() try: first = await store.add_lane( @@ -221,7 +221,7 @@ async def test_provider_session_collision_rolls_back_and_registry_remains_writab source="own", provider="claude", binding_id="profile-a", - provider_session_id="native-shared", + provider_thread_id="native-shared", ) second = await store.add_lane( id="dsp_second", @@ -231,15 +231,15 @@ async def test_provider_session_collision_rolls_back_and_registry_remains_writab binding_id="profile-a", ) with pytest.raises(sqlite3.IntegrityError): - await store.update_lane_provider_session( + await store.update_lane_provider_thread( second.id, provider="claude", binding_id="profile-a", - provider_session_id="native-shared", + provider_thread_id="native-shared", ) assert store._conn.in_transaction is False - assert (await store.get_lane(first.id)).provider_session_id == "native-shared" - assert (await store.get_lane(second.id)).provider_session_id is None + assert (await store.get_lane(first.id)).provider_thread_id == "native-shared" + assert (await store.get_lane(second.id)).provider_thread_id is None created = await store.add_lane( id="dsp_after", handle="@after", @@ -252,18 +252,18 @@ async def test_provider_session_collision_rolls_back_and_registry_remains_writab await store.close() -async def test_default_codex_provider_session_cannot_diverge_from_stable_id() -> None: +async def test_default_codex_provider_thread_cannot_diverge_from_stable_id() -> None: store = await Registry.open() try: lane = await store.add_lane(id="codex-native", handle="@codex", source="own", status="idle") with pytest.raises(ValidationError, match="must equal the stable lane id"): - await store.update_lane_provider_session( + await store.update_lane_provider_thread( lane.id, provider="codex", binding_id=DEFAULT_CODEX_BINDING_ID, - provider_session_id="replacement-native", + provider_thread_id="replacement-native", ) - assert (await store.get_lane(lane.id)).provider_session_id == lane.id + assert (await store.get_lane(lane.id)).provider_thread_id == lane.id finally: await store.close() @@ -276,7 +276,7 @@ async def test_new_lane_ids_keep_default_codex_and_other_provider_namespaces_dis id="codex-key", handle="@bad-codex", source="own", - provider_session_id="different-native", + provider_thread_id="different-native", ) with pytest.raises(ValueError, match="opaque dsp_ Dispatch id"): await store.add_lane( @@ -438,8 +438,8 @@ async def _downgrade_seed_to_v23(path: Path, *, migration_collision: bool = Fals ) for table in ("provider_events", "message_receipts", "server_requests"): conn.execute("UPDATE sqlite_sequence SET seq = 42 WHERE name = ?", (table,)) - conn.execute("DROP INDEX idx_lanes_provider_session") - conn.execute("ALTER TABLE lanes DROP COLUMN provider_session_id") + conn.execute("DROP INDEX idx_lanes_provider_thread") + conn.execute("ALTER TABLE lanes DROP COLUMN provider_thread_id") conn.execute("ALTER TABLE lanes DROP COLUMN binding_id") conn.execute("ALTER TABLE lanes DROP COLUMN provider") if migration_collision: @@ -465,7 +465,7 @@ async def test_v23_migration_preserves_keys_ids_and_foreign_keys(tmp_path: Path) lane = await store.get_lane("codex-native") assert lane.provider == "codex" assert lane.binding_id == DEFAULT_CODEX_BINDING_ID - assert lane.provider_session_id == lane.id + assert lane.provider_thread_id == lane.id assert lane.ref_source == "0" assert ( lane.id, @@ -538,11 +538,51 @@ async def test_v23_migration_preserves_keys_ids_and_foreign_keys(tmp_path: Path) "item_id", } async with store._conn.execute("PRAGMA user_version") as cur: - assert int((await cur.fetchone())[0]) == 24 # type: ignore[index] + assert int((await cur.fetchone())[0]) == 25 # type: ignore[index] finally: await store.close() +async def test_v24_migration_renames_lane_native_identity(tmp_path: Path) -> None: + path = tmp_path / "registry-v24.db" + store = await Registry.open(path) + lane = await store.add_lane( + id="dsp_claude", + handle="@claude", + source="own", + provider="claude", + binding_id="profile-a", + provider_thread_id="native-1", + ) + await store.close() + + with sqlite3.connect(path) as legacy: + legacy.execute("DROP INDEX idx_lanes_provider_thread") + legacy.execute("ALTER TABLE lanes RENAME COLUMN provider_thread_id TO provider_session_id") + legacy.execute( + "CREATE UNIQUE INDEX idx_lanes_provider_session " + "ON lanes(provider, binding_id, provider_session_id) " + "WHERE provider_session_id IS NOT NULL" + ) + legacy.execute("PRAGMA user_version = 24") + legacy.commit() + + migrated = await Registry.open(path) + try: + restored = await migrated.get_lane(lane.id) + assert restored.provider_thread_id == "native-1" + async with migrated._conn.execute("PRAGMA table_info(lanes)") as cur: + columns = {str(row["name"]) for row in await cur.fetchall()} + assert "provider_thread_id" in columns + assert "provider_session_id" not in columns + async with migrated._conn.execute("PRAGMA index_list(lanes)") as cur: + indexes = {str(row["name"]) for row in await cur.fetchall()} + assert "idx_lanes_provider_thread" in indexes + assert "idx_lanes_provider_session" not in indexes + finally: + await migrated.close() + + async def test_v23_migration_failure_rolls_back_schema_and_data(tmp_path: Path) -> None: path = tmp_path / "registry-v23-invalid.db" await _downgrade_seed_to_v23(path, migration_collision=True) diff --git a/tests/registry/test_deliveries.py b/tests/registry/test_deliveries.py index bcad0bc..c0b5609 100644 --- a/tests/registry/test_deliveries.py +++ b/tests/registry/test_deliveries.py @@ -341,7 +341,7 @@ async def test_v21_migration_adds_delivery_ledger(tmp_path: Path) -> None: assert receipt.key == "after:migration" async with migrated._conn.execute("PRAGMA user_version") as cur: row = await cur.fetchone() - assert row is not None and int(row[0]) == 24 + assert row is not None and int(row[0]) == 25 finally: await migrated.close() diff --git a/tests/registry/test_store.py b/tests/registry/test_store.py index 89ba7fb..37be147 100644 --- a/tests/registry/test_store.py +++ b/tests/registry/test_store.py @@ -743,6 +743,18 @@ async def test_server_request_observation_reports_atomic_insert_winner(store: Re assert {first.inserted, duplicate.inserted} == {True, False} +async def test_pending_server_requests_filter_by_binding_without_session(store: Registry) -> None: + profile_a = await store.observe_server_request( + server_request(binding_id="profile-a", lane=None, request_id="a") + ) + profile_b = await store.observe_server_request( + server_request(binding_id="profile-b", lane=None, request_id="b") + ) + + assert await store.list_pending_server_requests(binding_id="profile-a") == [profile_a] + assert await store.list_pending_server_requests() == [profile_a, profile_b] + + async def test_server_requests_support_threadless_recovery_and_terminal_claims( store: Registry, ) -> None: @@ -1824,7 +1836,7 @@ async def test_v17_migration_adds_replace_in_place_provider_capacity_table( async with migrated._conn.execute("PRAGMA user_version") as cur: row = await cur.fetchone() assert row is not None - assert int(row[0]) == SCHEMA_VERSION == 24 + assert int(row[0]) == SCHEMA_VERSION == 25 finally: await migrated.close() From d12985543bbbf2936b5aeb88f08890adc54723f3 Mon Sep 17 00:00:00 2001 From: Matt Galligan Date: Sun, 13 Sep 2026 18:39:24 -0400 Subject: [PATCH 3/3] fix(core): bound server-request responses and topology budget to the binding --- .../dispatch/core/server_requests.py | 17 +++++++- src/outfitter/dispatch/core/topology.py | 32 ++++++++++++--- tests/core/test_provider_binding_identity.py | 39 +++++++++++++++++++ tests/core/test_server_requests.py | 32 ++++++++++++++- 4 files changed, 111 insertions(+), 9 deletions(-) diff --git a/src/outfitter/dispatch/core/server_requests.py b/src/outfitter/dispatch/core/server_requests.py index c40a2a5..fa5682f 100644 --- a/src/outfitter/dispatch/core/server_requests.py +++ b/src/outfitter/dispatch/core/server_requests.py @@ -11,7 +11,11 @@ from outfitter.dispatch.client.events import ServerRequestReceived from outfitter.dispatch.contracts.context import Ctx -from outfitter.dispatch.contracts.errors import NotFoundError, ValidationError +from outfitter.dispatch.contracts.errors import ( + CapabilityUnavailableError, + NotFoundError, + ValidationError, +) from outfitter.dispatch.registry.models import ( Lane, LaneRuntimeState, @@ -184,8 +188,19 @@ async def respond_to_server_request( return result +def _require_default_binding(request: ServerRequest) -> None: + # The response goes over the single Codex connection; never answer a request + # that another provider binding observed. + if (request.provider, request.binding_id) != ("codex", DEFAULT_CODEX_BINDING_ID): + raise CapabilityUnavailableError( + "interactive request response is unavailable for provider binding " + f"{request.provider}:{request.binding_id}" + ) + + async def _send_response(ctx: Ctx, request: ServerRequest, plan: PlannedResponse) -> bool: local_id = _local_id(request) + _require_default_binding(request) claimed = await ctx.registry.claim_server_request_by_id(local_id) if claimed is None: return False diff --git a/src/outfitter/dispatch/core/topology.py b/src/outfitter/dispatch/core/topology.py index 84ccfef..9c58010 100644 --- a/src/outfitter/dispatch/core/topology.py +++ b/src/outfitter/dispatch/core/topology.py @@ -10,7 +10,11 @@ ProviderThreadNode, ProviderThreadObservation, ) -from outfitter.dispatch.registry.store import DEFAULT_CODEX_BINDING_ID, Registry +from outfitter.dispatch.registry.store import ( + DEFAULT_CODEX_BINDING_ID, + ProviderThreadTopology, + Registry, +) def observation_from_thread( @@ -127,6 +131,12 @@ async def topology_views( max_nodes=max_nodes, max_depth=16, ) + return _views(topology, thread_ids) + + +def _views( + topology: ProviderThreadTopology, thread_ids: list[str] +) -> dict[str, ThreadTopologyView]: indexed = {node.thread.provider_thread_id: node for node in topology.nodes} views: dict[str, ThreadTopologyView] = {} for thread_id in thread_ids: @@ -160,14 +170,24 @@ async def lane_topology_views( views[lane.id] = ThreadTopologyView() continue groups.setdefault((lane.provider, lane.binding_id), []).append(lane) + remaining = max_nodes # one node budget shared by every binding group for (provider, binding_id), group in groups.items(): - native_views = await topology_views( - registry, - [lane.provider_thread_id for lane in group if lane.provider_thread_id is not None], - max_nodes=max_nodes, - provider=provider, + if remaining < 1: + for lane in group: + views[lane.id] = ThreadTopologyView(truncated=True) + continue + thread_ids = [ + lane.provider_thread_id for lane in group if lane.provider_thread_id is not None + ] + topology = await registry.get_provider_thread_topology( + provider, + thread_ids, binding_id=binding_id, + max_nodes=remaining, + max_depth=16, ) + remaining -= len(topology.nodes) + native_views = _views(topology, thread_ids) for lane in group: assert lane.provider_thread_id is not None views[lane.id] = native_views[lane.provider_thread_id] diff --git a/tests/core/test_provider_binding_identity.py b/tests/core/test_provider_binding_identity.py index 6e86863..79359b9 100644 --- a/tests/core/test_provider_binding_identity.py +++ b/tests/core/test_provider_binding_identity.py @@ -26,8 +26,10 @@ WatchInput, ) from outfitter.dispatch.core.selectors import resolve_managed_selector +from outfitter.dispatch.core.topology import lane_topology_views from outfitter.dispatch.registry.store import DEFAULT_CODEX_BINDING_ID, Registry from tests.fakes import FakeLaneClient, make_ctx +from tests.fixtures.registry.builders import provider_thread_observation async def test_default_codex_and_non_codex_outputs_preserve_public_aliases() -> None: @@ -149,3 +151,40 @@ async def test_malformed_default_codex_identity_cannot_execute() -> None: assert not client.calls finally: await store.close() + + +async def test_lane_topology_shares_one_node_budget_across_bindings() -> None: + store = await Registry.open() + try: + lanes = [] + for provider, binding_id in (("codex", DEFAULT_CODEX_BINDING_ID), ("claude", "profile-a")): + for index in range(3): + native_id = f"{binding_id}-thread-{index}" + await store.upsert_provider_thread( + provider_thread_observation( + provider=provider, binding_id=binding_id, provider_thread_id=native_id + ) + ) + lanes.append( + await store.add_lane( + id=native_id if provider == "codex" else f"dsp_{binding_id}_{index}", + handle=f"@{binding_id}-{index}", + source="own", + status="idle", + provider=provider, + binding_id=binding_id, + provider_thread_id=native_id, + ) + ) + foreign = [lane.id for lane in lanes if lane.binding_id == "profile-a"] + + views = await lane_topology_views(store, lanes, max_nodes=4) + assert sum(view.observed for view in views.values()) == 4 + assert all(views[lane_id].truncated for lane_id in foreign) + + exhausted = await lane_topology_views(store, lanes, max_nodes=3) + assert sum(view.observed for view in exhausted.values()) == 3 + assert all(not exhausted[lane_id].observed for lane_id in foreign) + assert all(exhausted[lane_id].truncated for lane_id in foreign) + finally: + await store.close() diff --git a/tests/core/test_server_requests.py b/tests/core/test_server_requests.py index bb7c1b0..de6a1c9 100644 --- a/tests/core/test_server_requests.py +++ b/tests/core/test_server_requests.py @@ -9,7 +9,7 @@ from outfitter.dispatch.client.events import ServerRequestReceived, classify_server_request from outfitter.dispatch.client.models import JsonRpcError, JsonRpcId from outfitter.dispatch.config import RuntimePolicy -from outfitter.dispatch.contracts.errors import ValidationError +from outfitter.dispatch.contracts.errors import CapabilityUnavailableError, ValidationError from outfitter.dispatch.core.server_request_policy import ( automatic_response, expected_response, @@ -19,7 +19,7 @@ ServerRequestManager, respond_to_server_request, ) -from outfitter.dispatch.registry.models import Subscription +from outfitter.dispatch.registry.models import ServerRequest, Subscription from outfitter.dispatch.registry.store import Registry from tests.fakes import FakeLaneClient, make_ctx @@ -256,6 +256,34 @@ async def test_operator_response_is_validated_and_sent_once(store: Registry) -> await manager.close() +async def test_operator_response_rejects_request_from_non_default_binding( + store: Registry, +) -> None: + client = FakeLaneClient() + ctx = make_ctx(store, client) + ctx.provider_session_id = "session-1" + observation = await store.observe_server_request_once( + ServerRequest( + binding_id="profile-a", + provider_session_id="session-1", + provider_thread_id="native-other", + request_id="question-foreign", + method="item/tool/requestUserInput", + category="user_input", + received_at=datetime(2026, 7, 10, tzinfo=UTC).isoformat(), + ) + ) + request_id = observation.request.id or 0 + + with pytest.raises(CapabilityUnavailableError, match="codex:profile-a"): + await respond_to_server_request(ctx, request_id, {"answers": {}}) + + stored = await store.get_server_request_by_id(request_id) + assert stored is not None + assert stored.state == "pending" + assert not any(name == "respond_server_request" for name, _ in client.calls) + + async def test_duplicate_delivery_does_not_duplicate_attention_or_response( store: Registry, ) -> None: