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
@@ -0,0 +1,115 @@
# Task Packet

packet_id: tp-2026-09-03-adr-0085-phase-g1-restore-point
packet_revision: 1
role: implementer
baseline_ref: origin/main
baseline_sha: 1ebe91f9efc2e777c731ab3abf8d179a3b775
branch: feat/adr-0085-phase-g-checkpoint

## Goal

Implement ADR-0083/0085 Phase G1: new-runtime checkpoint capture and restore as a RestorePoint over `OperationStateVersion` / `commit_ref` / committed `EventRecord` facts / effect+mailbox / projection epoch. Re-enable capture/restore for matching `agentkit-1` sessions only. Keep Option B rejection for cross-version and for Tape/`plugin_states` inputs on new-runtime.

## Scope

### Phase boundary

- Legacy sessions keep ADR-0001/0005/0006/0010 Tape + `plugin_states` capture/restore unchanged.
- New-runtime sessions (`runtime_version=agentkit-1`) capture and restore only the new RestorePoint format.
- Cross-version capture/restore still reject before mutation (`legacy` checkpoint onto `agentkit-1` session and the reverse).
- Do not flip the production activation flag.
- Do not start Phase H (legacy pipeline deletion).
- Do not remove the idempotent-commit epoch promotion SQL in this packet (G2). Capture/restore must still open a new projection epoch on restore, matching current restore behavior.

### RestorePoint format

New-runtime `CheckpointSnapshot` must satisfy:

- `checkpoint_format` (in `extra` or an equivalent persisted field) = `agentkit-1`
- `tape_entries` empty
- `plugin_states` empty
- RestorePoint payload includes:
- `operation_state_version`
- `commit_ref` (or equivalent durable commit identity already stored)
- `projection_epoch` at capture
- mailbox / effect-ledger pointers needed to resume
- Capture of non-empty `tape_entries` or `plugin_states` for a new-runtime session rejects before mutation.

### Capture

- `save_checkpoint` for `agentkit-1` sessions is allowed when the snapshot is a RestorePoint.
- HTTP `POST /sessions/{id}/checkpoints` succeeds for new-runtime sessions and returns checkpoint metadata.
- Daemon still rejects before mutation when the session/checkpoint versions do not match.

### Restore

- `restore_checkpoint_state` for `agentkit-1` rebuilds from RestorePoint + committed `EventRecord` facts + host immutable inputs.
- Restore remains serialized with active turns (ADR-0010 per-session turn lock).
- Hot provider reuse only when `provider_name`, `model_name`, and `base_url` all match persisted session metadata.
- Restore marks superseded runs without deleting them (ADR-0075). Resume still creates a new linked run (ADR-0055); do not invent a second lineage scheme.
- After restore, a same-epoch lost-ack retry must not rewrite committed facts; a retry after restore opened a new epoch fails the state-version CAS without writing.

### Activation / errors

- Replace the Phase F blanket `NewRuntimeCheckpointRejectedError` for matching new-runtime RestorePoints.
- Keep the same error type (or a dedicated cross-version error) for mismatched runtime/format.
- Error strings for remaining rejections stay stable enough that existing cross-version tests can be updated in place.

## Out of scope

- Phase G2: remove `_PROMOTE_SESSION_EVENT_EPOCH_SQL` / SQLite epoch promotion update; make `projection_epoch` immutable on committed `EventRecord`.
- Phase H package split / legacy pipeline deletion.
- Production `new_sessions_enabled` flag flip.
- AgentKit frozen request/proposal/result/outcome contracts.
- Independent child workers.
- CLI `daemon runtime-activation` operator command.

## Context

- ADRs:
- `docs/adr/0085-restage-durable-runtime-activation-through-phase-f.md`
- `docs/adr/0083-host-coordinated-durable-agentkit-runtime.md` (Phase G)
- `docs/adr/0075-checkpoint-restore-active-run-timeline.md`
- `docs/adr/0055-and-fork-tape-memory-semantic-index.md` (resume lineage; reuse, do not replace)
- `docs/adr/0010-synchronize-checkpoint-restore-with-active-turns.md` (if present; otherwise the synchronize-restore ADR retained by 0083)
- Relevant files:
- `src/coding_agent/runtime_activation.py`
- `src/coding_agent/stores/local_durable/checkpoint.py`
- `src/coding_agent/stores/pg_durable/checkpoint.py`
- `src/coding_agent/runs/checkpoint_capture.py`
- `src/coding_agent/runs/checkpoint_restore.py`
- `src/coding_agent/runs/checkpoint_runtime.py`
- `src/coding_agent/runs/runtime_checkpoint_restore.py`
- `src/coding_agent/harness/restore.py`
- `src/coding_agent/server/session/restore.py`
- `src/coding_agent/server/http/routes/checkpoints.py`
- `src/agentkit/checkpoint/models.py`
- `tests/coding_agent/test_phase_f_checkpoint_rejection.py`

## Target tests

- `uv run pytest tests/coding_agent/test_phase_f_checkpoint_rejection.py -v`
- `uv run pytest tests/coding_agent/test_checkpoint_restore_service.py tests/coding_agent/test_checkpoint_runtime_builder.py tests/coding_agent/test_runtime_checkpoint_restore.py -v`
- New G1 tests (names may land in `tests/coding_agent/test_phase_g_restore_point.py`):
- `test_new_runtime_restore_point_capture_persists_on_sqlite_and_pg`
- `test_new_runtime_restore_point_restore_rebuilds_session_without_plugin_states`
- `test_new_runtime_rejects_tape_or_plugin_state_snapshot`
- `test_cross_version_checkpoint_still_rejects_before_mutation`
- `test_legacy_checkpoint_capture_and_restore_unchanged`
- `uv run pytest tests/coding_agent/test_phase_f_checkpoint_rejection.py tests/coding_agent/test_phase_g_restore_point.py tests/coding_agent/test_checkpoint_restore_service.py tests/coding_agent/test_checkpoint_runtime_builder.py tests/coding_agent/test_runtime_checkpoint_restore.py -v`

## Loop policy

- Engineer implements the smallest correct change and runs the target tests.
- Reviewer reviews only the resulting diff and affected tests.
- Reviewer reports only P1/P2 findings.
- Engineer fixes only accepted P1/P2 findings and reruns the same target tests.
- Verifier reruns the exact target tests and reports pass/fail only.

## Stop conditions

- At most one review/fix/retest cycle.
- Escalate if restore requires mutating AgentKit Tape as a physical session log, or if capture cannot be expressed without `plugin_states`.
- Escalate if G1 cannot land without the G2 immutable-epoch SQL change.
- Ignore non-blocking optimization suggestions.
30 changes: 30 additions & 0 deletions coding-agent/src/agentkit/checkpoint/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,36 @@ async def capture(
await self._store.save(snapshot)
return meta

async def capture_restore_point(
self,
*,
tape_id: str,
session_id: str,
label: str | None = None,
extra: dict[str, Any] | None = None,
) -> CheckpointMeta:
payload = extra or {}
validate_json_safe(payload, name="extra")
if payload.get("checkpoint_format") != "agentkit-1":
raise ValueError("RestorePoint extra must set checkpoint_format=agentkit-1")
meta = CheckpointMeta(
checkpoint_id=uuid4().hex,
tape_id=tape_id,
session_id=session_id,
entry_count=0,
window_start=0,
created_at=datetime.now(UTC),
label=label,
)
snapshot = CheckpointSnapshot(
meta=meta,
tape_entries=(),
plugin_states={},
extra=payload,
)
await self._store.save(snapshot)
return meta

async def restore(self, checkpoint_id: str) -> CheckpointSnapshot:
snapshot = await self._store.load(checkpoint_id)
if snapshot is None:
Expand Down
76 changes: 71 additions & 5 deletions coding-agent/src/coding_agent/runs/checkpoint_capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@
from typing import Any, Protocol

from agentkit.checkpoint.models import CheckpointMeta
from agentkit.runtime.contracts import OperationStateVersion

from coding_agent.runtime_activation import (
CHECKPOINT_FORMAT_KEY,
OPERATION_STATE_VERSION_KEY,
RUNTIME_VERSION_NEW,
)
from coding_agent.stores.runtime_store import _plain_json

from .checkpoint_restore import (
CHECKPOINT_SESSION_CONFIG_KEY,
Expand Down Expand Up @@ -44,13 +52,39 @@ class RuntimeCheckpointCaptureContext(Protocol):
[RuntimeCheckpointCaptureSession],
Awaitable[None],
]
RestorePointStateLoader = Callable[
[str, str],
Awaitable[OperationStateVersion | None],
]


def serialize_operation_state_version(
state: OperationStateVersion | None,
) -> dict[str, Any] | None:
if state is None:
return None
value = _plain_json(state.value)
if not isinstance(value, dict):
raise TypeError("operation state value must be an object")
return {
"run_id": state.run_id,
"revision": state.revision,
"projection_epoch": state.projection_epoch,
"commit_ref": {
"transition_id": state.commit_ref.transition_id,
"fact_seq_start": state.commit_ref.fact_seq_start,
"fact_seq_end": state.commit_ref.fact_seq_end,
},
"value": value,
}


@dataclass(frozen=True)
class RuntimeCheckpointCaptureService:
checkpoint_service: RuntimeCheckpointCaptureBackendProvider
ensure_runtime: RuntimeCheckpointRuntimeEnsurer
persist_session: RuntimeCheckpointCapturePersister
load_operation_state: RestorePointStateLoader | None = None

async def capture(
self,
Expand All @@ -65,25 +99,57 @@ async def capture(
raise ValueError(
f"'{CHECKPOINT_SESSION_CONFIG_KEY}' is a reserved checkpoint metadata key and cannot be provided via extra"
)
if OPERATION_STATE_VERSION_KEY in payload:
raise ValueError(
f"'{OPERATION_STATE_VERSION_KEY}' is a reserved checkpoint metadata key and cannot be provided via extra"
)
payload[CHECKPOINT_SESSION_CONFIG_KEY] = serialize_checkpoint_session_config(
session
)
checkpoint = await self.checkpoint_service().capture(
ctx,
label=label,
extra=payload,
)
backend = self.checkpoint_service()
if getattr(session, "runtime_version", None) == RUNTIME_VERSION_NEW:
payload[CHECKPOINT_FORMAT_KEY] = RUNTIME_VERSION_NEW
run_id = getattr(session, "current_turn_id", None)
state = None
if (
isinstance(run_id, str)
and run_id
and self.load_operation_state is not None
):
state = await self.load_operation_state(session.id, run_id)
payload[OPERATION_STATE_VERSION_KEY] = serialize_operation_state_version(
state
)
capture_restore_point = getattr(backend, "capture_restore_point", None)
if capture_restore_point is None:
raise TypeError(
"checkpoint backend missing capture_restore_point for new-runtime"
)
checkpoint = await capture_restore_point(
tape_id=ctx.tape.tape_id,
session_id=session.id,
label=label,
extra=payload,
)
else:
checkpoint = await backend.capture(
ctx,
label=label,
extra=payload,
)
session.tape_id = ctx.tape.tape_id
await self.persist_session(session)
return checkpoint


__all__ = [
"RestorePointStateLoader",
"RuntimeCheckpointCaptureBackend",
"RuntimeCheckpointCaptureBackendProvider",
"RuntimeCheckpointCaptureContext",
"RuntimeCheckpointCapturePersister",
"RuntimeCheckpointCaptureService",
"RuntimeCheckpointCaptureSession",
"RuntimeCheckpointRuntimeEnsurer",
"serialize_operation_state_version",
]
5 changes: 4 additions & 1 deletion coding-agent/src/coding_agent/runs/serving_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -608,11 +608,12 @@ def initial_operation_state(
run_id: str,
*,
system_prompt: str,
projection_epoch: int = 0,
) -> OperationStateVersion:
return OperationStateVersion(
run_id=run_id,
revision=0,
projection_epoch=0,
projection_epoch=projection_epoch,
commit_ref=CommitRef(transition_id=f"{run_id}:admission"),
value={
"context": {
Expand All @@ -633,6 +634,7 @@ def build_new_runtime_turn_adapter(
model_adapter: object | None = None,
effect_executor: object | None = None,
resolve_blocked: ServingBlockedResolver | None = None,
projection_epoch: int = 0,
) -> DurableSegmentTurnAdapter:
session_state: Mapping[str, object]
to_store_data = getattr(session, "to_store_data", None)
Expand Down Expand Up @@ -668,6 +670,7 @@ def build_new_runtime_turn_adapter(
resolved_state = state_version or initial_operation_state(
run_id,
system_prompt=system_prompt,
projection_epoch=projection_epoch,
)
return DurableSegmentTurnAdapter(
runner=runner,
Expand Down
68 changes: 64 additions & 4 deletions coding-agent/src/coding_agent/runtime_activation.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,20 @@

from collections.abc import Mapping
from dataclasses import dataclass
from typing import Literal
from typing import TYPE_CHECKING, Literal


RUNTIME_VERSION_LEGACY = "legacy"
RUNTIME_VERSION_NEW = "agentkit-1"
KNOWN_RUNTIME_VERSIONS = frozenset({RUNTIME_VERSION_LEGACY, RUNTIME_VERSION_NEW})
RuntimePath = Literal["legacy", "new"]

if TYPE_CHECKING:
from agentkit.checkpoint.models import CheckpointSnapshot

CHECKPOINT_FORMAT_KEY = "checkpoint_format"
OPERATION_STATE_VERSION_KEY = "operation_state_version"


class UnknownRuntimeVersionError(ValueError):
"""Raised when a session carries an unknown runtime version."""
Expand Down Expand Up @@ -91,10 +97,64 @@ def stamp_session_payload_for_save(
return {**dict(incoming), "runtime_version": stored_version}


def assert_checkpoint_allowed(payload: Mapping[str, object]) -> None:
if parse_runtime_version(payload) == RUNTIME_VERSION_NEW:
def is_new_runtime_restore_point(snapshot: CheckpointSnapshot) -> bool:
extra = snapshot.extra or {}
return (
extra.get(CHECKPOINT_FORMAT_KEY) == RUNTIME_VERSION_NEW
and OPERATION_STATE_VERSION_KEY in extra
and not snapshot.tape_entries
and not snapshot.plugin_states
)


def restore_point_stamp(
extra: Mapping[str, object],
) -> Mapping[str, object] | None:
if OPERATION_STATE_VERSION_KEY not in extra:
return None
stamp = extra[OPERATION_STATE_VERSION_KEY]
if stamp is None:
return None
if not isinstance(stamp, Mapping):
raise ValueError("operation_state_version must be an object or null")
return stamp


def restore_point_cas_matches(
stamp: Mapping[str, object],
*,
run_id: str,
revision: int,
projection_epoch: int,
transition_id: str,
) -> bool:
commit_ref = stamp.get("commit_ref")
stored_transition = (
commit_ref.get("transition_id") if isinstance(commit_ref, Mapping) else None
)
return (
stamp.get("run_id") == run_id
and stamp.get("revision") == revision
and stamp.get("projection_epoch") == projection_epoch
and stored_transition == transition_id
)


def assert_checkpoint_allowed(
payload: Mapping[str, object],
snapshot: CheckpointSnapshot | None = None,
) -> None:
version = parse_runtime_version(payload)
restore_point = snapshot is not None and is_new_runtime_restore_point(snapshot)
if version == RUNTIME_VERSION_NEW:
if restore_point:
return
raise NewRuntimeCheckpointRejectedError(
"new-runtime checkpoint capture and restore require RestorePoint format"
)
if restore_point:
raise NewRuntimeCheckpointRejectedError(
"new-runtime checkpoint capture and restore are rejected until Phase G"
"legacy sessions cannot capture or restore agentkit-1 RestorePoints"
)


Expand Down
Loading
Loading