Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ADR-0015: Enforce owner-routed HTTP event streams for Phase 2 sticky routing

**Status**: Proposed
**Status**: Accepted
**Date**: 2026-04-21

## Context
Expand All @@ -25,6 +25,22 @@ Specifically:

This ADR intentionally keeps approval metadata persistence out of scope. It closes the narrowest, clearest routing gap first: only the current owner may serve the live SSE stream for a session.

## Implementation Status

The sticky-owner first cut is implemented. `GET /sessions/{session_id}/events`
and `/sessions/{session_id}/display-events` authorize ownership before queue
registration, register through the owned queue path, and re-check ownership
inside the stream before delivering queued events or keepalive pings.

Covered regressions live in `tests/ui/test_http_server_failover.py`, including
stale-owner rejection before queue registration, owner-change stream shutdown,
queued-event drop after owner change, disappearing-session races, owner change
after queue registration, the append-to-recheck race window, and current-owner
keepalive behavior.

Brokered cross-instance stream routing, redirect payloads, and queue transfer
remain intentionally out of scope for this ADR.

## Alternatives Rejected

- Keep `/events` unfenced and rely on load-balancer stickiness alone — rejected because it leaves stale instances free to attach live streams even when PostgreSQL ownership already says they are not authoritative.
Expand All @@ -34,17 +50,18 @@ This ADR intentionally keeps approval metadata persistence out of scope. It clos

## Acceptance Criteria

- [ ] `test_get_events_rejects_stale_owner_before_stream_registration`
- [ ] `test_get_events_stops_stream_after_owner_change`
- [ ] `test_get_events_keeps_stream_alive_for_current_owner`
- [ ] `test_get_events_returns_404_before_owner_check_for_missing_session`
- [ ] `uv run pytest tests/ui/test_http_server_failover.py -v`
- [ ] `uv run pytest tests/ui/test_http_server.py tests/ui/test_http_server_failover.py tests/ui/test_session_manager_owner_checks.py -k "events or owner or failover" -v`
- [x] `test_get_events_rejects_stale_owner_before_stream_registration`
- [x] `test_get_events_stops_stream_after_owner_change`
- [x] `test_get_events_keeps_stream_alive_for_current_owner`
- [x] `test_get_events_returns_404_before_owner_check_for_missing_session`
- [x] `uv run pytest tests/ui/test_http_server_failover.py -v`
- [x] `uv run pytest tests/ui/test_http_server.py tests/ui/test_http_server_failover.py tests/ui/test_session_manager_owner_checks.py -k "events or owner or failover" -v`

## References

- `docs/adr/0013-define-phase2-multi-instance-session-ownership-for-pg-http-sessions.md`
- `docs/pg-phase2-multi-instance-checklist.md`
- `src/coding_agent/ui/http_server.py`
- `src/coding_agent/ui/session_manager.py`
- `src/coding_agent/server/http_server.py`
- `src/coding_agent/server/session_manager.py`
- `tests/ui/test_http_server.py`
- `tests/ui/test_http_server_failover.py`
10 changes: 10 additions & 0 deletions coding-agent/docs/adr/0060-native-platform-sandbox-backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,16 @@ The default config value stays `sandbox_mode = "none"` in this change.
Switching the default to `native` is deferred to a separate, final PR after the
native backends are implemented and verified, to keep blast radius minimal.

## Implementation Status

Implemented. The user-visible `sandbox_mode` values are now converged to
`none`, `native`, `podman`, and `docker`; `nsjail` is rejected by validation.
Native sandbox resolution fails closed when the required platform backend is
unavailable, and the default remains `none`.

Verification is captured by `tests/tools/test_shell.py` and the acceptance
criteria below.

## Alternatives Rejected

- Expose `landlock`, `bwrap`, and `seatbelt` as separate user-visible modes —
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ADR-0068: Local SQLite transactional durable fencing

**Status**: Proposed
**Status**: Accepted
**Date**: 2026-06-05

Supersedes ADR-0067. ADR-0067 remains the record of the first ACP SQLite
Expand Down Expand Up @@ -208,6 +208,22 @@ PR 4 adds PG/cloud parity. It is not required to block the local SQLite
implementation, but the PR 3 interfaces must already support a PG transaction
implementation.

## Implementation Status

Accepted and implemented for the local SQLite and PostgreSQL durable fencing
paths.

Implementation references:

- `7911839` — `feat(storage): default local runtime to sqlite bundle (#559)`
- `3c5a2f6` — `feat(storage): add sqlite durable fencing`
- `40a3110` — `feat(storage): add pg durable fencing`
- `4d8cafe` — `fix(storage): restore sqlite checkpoint snapshots`

Operational follow-up: startup diagnostics now reject mixed local SQLite
backend/path configurations instead of silently disabling durable fencing, and
warn when custom in-process stores intentionally disable local or PG fencing.

## Alternatives Rejected

- Keep ADR-0067 as the target architecture — rejected because filesystem
Expand All @@ -234,28 +250,28 @@ implementation.

## Acceptance Criteria

- [ ] `test_adr_0068_supersedes_adr_0067_without_conflicting_target_architecture`
- [ ] `test_adr_0068_defines_single_db_epoch_and_transactional_fencing_contract`
- [ ] `test_adr_0068_rejects_attach_as_the_durable_fencing_foundation`
- [ ] `test_adr_0068_requires_same_session_target_ownership_for_protected_writes`
- [ ] `test_adr_0068_covers_worker_maintenance_and_cleanup_authority`
- [ ] `test_adr_0068_records_full_write_path_inventory_and_future_pr_sequence`
- [ ] Future PR 3: owner of session A cannot mutate run, checkpoint, tape,
- [x] `test_adr_0068_supersedes_adr_0067_without_conflicting_target_architecture`
- [x] `test_adr_0068_defines_single_db_epoch_and_transactional_fencing_contract`
- [x] `test_adr_0068_rejects_attach_as_the_durable_fencing_foundation`
- [x] `test_adr_0068_requires_same_session_target_ownership_for_protected_writes`
- [x] `test_adr_0068_covers_worker_maintenance_and_cleanup_authority`
- [x] `test_adr_0068_records_full_write_path_inventory_and_future_pr_sequence`
- [x] Future PR 3: owner of session A cannot mutate run, checkpoint, tape,
interaction, snapshot, or event target of session B.
- [ ] Future PR 3: A acquires epoch N, A writes own session target, and the
- [x] Future PR 3: A acquires epoch N, A writes own session target, and the
write succeeds.
- [ ] Future PR 3: A lease expires, B takes over with epoch N+1, A write fails,
- [x] Future PR 3: A lease expires, B takes over with epoch N+1, A write fails,
and B write succeeds.
- [ ] Future PR 3: A renews and keeps epoch N, and A write succeeds.
- [ ] Future PR 3: expired same owner without live lease cannot write until the
- [x] Future PR 3: A renews and keeps epoch N, and A write succeeds.
- [x] Future PR 3: expired same owner without live lease cannot write until the
reacquire/takeover policy grants a live epoch.
- [ ] Future PR 3: stale owner cancellation cleanup cannot persist final
- [x] Future PR 3: stale owner cancellation cleanup cannot persist final
status, result, or session metadata through the normal write path.
- [ ] Future PR 3: worker claim, heartbeat, and finalize cannot bypass session
- [x] Future PR 3: worker claim, heartbeat, and finalize cannot bypass session
takeover or target ownership rules.
- [ ] Future PR 3: checkpoint restore is atomic or has an explicit recovery
- [x] Future PR 3: checkpoint restore is atomic or has an explicit recovery
contract.
- [ ] `uv run pytest tests/coding_agent/test_durable_fencing_adr_contract.py -q`
- [x] `uv run pytest tests/coding_agent/test_durable_fencing_adr_contract.py -q`

## References

Expand Down
60 changes: 46 additions & 14 deletions coding-agent/src/coding_agent/local_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@


LOCAL_SQLITE_FILENAME = "local.sqlite3"
DURABLE_STORAGE_BACKEND_PATH_KEYS = (
("http_session_backend", "http_session_path"),
("tape_backend", "tape_path"),
("checkpoint_backend", "checkpoint_path"),
("runtime_backend", "runtime_path"),
)
DURABLE_STORAGE_BACKEND_KEYS = tuple(
backend_key for backend_key, _ in DURABLE_STORAGE_BACKEND_PATH_KEYS
)
DURABLE_STORAGE_PATH_KEYS = tuple(
path_key for _, path_key in DURABLE_STORAGE_BACKEND_PATH_KEYS
)


def normalize_storage_path(path: str) -> Path:
Expand Down Expand Up @@ -47,13 +59,7 @@ def local_sqlite_path_from_storage_config(
if isinstance(local_path, str) and local_path.strip():
return Path(local_path)
explicit_paths = [
storage_config.get(path_key)
for path_key in (
"http_session_path",
"tape_path",
"checkpoint_path",
"runtime_path",
)
storage_config.get(path_key) for path_key in DURABLE_STORAGE_PATH_KEYS
]
if all(isinstance(path, str) and path.strip() for path in explicit_paths):
resolved_paths = [normalize_storage_path(str(path)) for path in explicit_paths]
Expand All @@ -63,19 +69,45 @@ def local_sqlite_path_from_storage_config(
return local_sqlite_path(data_dir)


def durable_storage_backend_values(
storage_config: dict[str, Any],
) -> dict[str, str]:
return {
key: str(storage_config.get(key, "")).strip().lower()
for key in DURABLE_STORAGE_BACKEND_KEYS
}


def storage_has_any_sqlite_backend(storage_config: dict[str, Any]) -> bool:
return any(
value == "sqlite"
for value in durable_storage_backend_values(storage_config).values()
)


def storage_uses_local_sqlite_bundle(
storage_config: dict[str, Any],
data_dir: Path | str | None = None,
) -> bool:
backend_values = durable_storage_backend_values(storage_config)
if any(value != "sqlite" for value in backend_values.values()):
return False
local_path = normalize_storage_path(
str(local_sqlite_path_from_storage_config(storage_config, data_dir))
)
return all(
normalize_storage_path(str(storage_config.get(key, ""))) == local_path
for key in DURABLE_STORAGE_PATH_KEYS
)


def with_local_sqlite_bundle_paths(
storage_config: dict[str, Any],
data_dir: Path | str | None = None,
) -> dict[str, Any]:
config = dict(storage_config)
local_path = str(local_sqlite_path_from_storage_config(config, data_dir))
backend_path_pairs = (
("http_session_backend", "http_session_path"),
("tape_backend", "tape_path"),
("checkpoint_backend", "checkpoint_path"),
("runtime_backend", "runtime_path"),
)
for backend_key, path_key in backend_path_pairs:
for backend_key, path_key in DURABLE_STORAGE_BACKEND_PATH_KEYS:
backend = config.get(backend_key)
if isinstance(backend, str) and backend.strip().lower() == "sqlite":
configured_path = config.get(path_key)
Expand Down
62 changes: 20 additions & 42 deletions coding-agent/src/coding_agent/server/http_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,8 @@
RuntimeEventRecord,
)
from coding_agent.local_storage import local_sqlite_path_from_storage_config
from coding_agent.local_storage import normalize_storage_path
from coding_agent.local_storage import local_sqlite_storage_config
from coding_agent.local_storage import storage_has_any_sqlite_backend
from coding_agent.events import DisplayEvent, DisplayEventStreamProjector
from coding_agent.scheduled_runs import (
PGScheduledRunStore,
Expand Down Expand Up @@ -209,7 +210,6 @@
ResumeSessionRequest,
RuntimeConfigUpdateRequest,
RuntimeConfigUpdateResponse,
ThinkingConfigSchema,
RuntimeEventResponse,
RuntimeEventsResponse,
RuntimeInteractionListResponse,
Expand Down Expand Up @@ -729,33 +729,6 @@ def _storage_uses_pg_http_sessions(storage_config: dict[str, Any]) -> bool:
return tape_backend == "pg"


def _storage_uses_local_sqlite_bundle(storage_config: dict[str, Any]) -> bool:
backend_keys = (
"http_session_backend",
"tape_backend",
"checkpoint_backend",
"runtime_backend",
)
if any(
str(storage_config.get(key, "")).strip().lower() != "sqlite"
for key in backend_keys
):
return False
local_path = normalize_storage_path(
str(local_sqlite_path_from_storage_config(storage_config))
)
path_keys = (
"http_session_path",
"tape_path",
"checkpoint_path",
"runtime_path",
)
return all(
normalize_storage_path(str(storage_config.get(key, ""))) == local_path
for key in path_keys
)


def _configured_owner_id(storage_config: dict[str, Any]) -> str:
owner_id = storage_config.get("owner_id")
if isinstance(owner_id, str) and owner_id.strip():
Expand Down Expand Up @@ -783,6 +756,7 @@ def _configured_owner_lease_seconds(storage_config: dict[str, Any]) -> float:

def _build_session_manager() -> SessionManager:
storage_config = _load_storage_config()
effective_storage_config = storage_config or local_sqlite_storage_config()
remote_retention_config = _load_remote_retention_config()
try:
_validate_production_config(
Expand All @@ -794,23 +768,25 @@ def _build_session_manager() -> SessionManager:
except Exception:
logger.exception("Production config validation failed")
raise
uses_local_sqlite_bundle = _storage_uses_local_sqlite_bundle(storage_config)
has_local_sqlite_intent = storage_has_any_sqlite_backend(effective_storage_config)
manager = SessionManager(
storage_config=storage_config,
storage_config=effective_storage_config,
owner_store=(
SQLiteSessionOwnerStore(
local_sqlite_path_from_storage_config(storage_config)
local_sqlite_path_from_storage_config(effective_storage_config)
)
if uses_local_sqlite_bundle
if has_local_sqlite_intent
else None
),
owner_id=(
_configured_owner_id(storage_config) if uses_local_sqlite_bundle else None
_configured_owner_id(effective_storage_config)
if has_local_sqlite_intent
else None
),
fencing_token=1 if uses_local_sqlite_bundle else None,
fencing_token=1 if has_local_sqlite_intent else None,
owner_lease_seconds=(
_configured_owner_lease_seconds(storage_config)
if uses_local_sqlite_bundle
_configured_owner_lease_seconds(effective_storage_config)
if has_local_sqlite_intent
else 30.0
),
cloud_workspace_client_factory=(
Expand All @@ -824,14 +800,14 @@ def _build_session_manager() -> SessionManager:
manager.configure_workspace_metadata_store(
PGWorkspaceMetadataStore(pool=manager.pg_pool)
)
if not _storage_uses_pg_http_sessions(storage_config):
if not _storage_uses_pg_http_sessions(effective_storage_config):
return manager
owner_store = SessionOwnerStore(pg_pool=manager.pg_pool)
manager.configure_owner_leases(
owner_store=owner_store,
owner_id=_configured_owner_id(storage_config),
fencing_token=_configured_fencing_token(storage_config),
owner_lease_seconds=_configured_owner_lease_seconds(storage_config),
owner_id=_configured_owner_id(effective_storage_config),
fencing_token=_configured_fencing_token(effective_storage_config),
owner_lease_seconds=_configured_owner_lease_seconds(effective_storage_config),
)
return manager

Expand Down Expand Up @@ -2478,7 +2454,9 @@ async def create_session(
return SessionResponse(session_id=session_id)


@app.patch("/sessions/{session_id}/runtime-config", response_model=RuntimeConfigUpdateResponse)
@app.patch(
"/sessions/{session_id}/runtime-config", response_model=RuntimeConfigUpdateResponse
)
@limiter.limit(RateLimits.CREATE_SESSION)
async def update_runtime_config(
request: Request,
Expand Down
Loading