Skip to content

Commit af192bd

Browse files
committed
fix(runtime): migrate Dream bindings and replay approvals
1 parent 0fc2e03 commit af192bd

5 files changed

Lines changed: 165 additions & 2 deletions

File tree

‎src/powercontext/builtin/persistence/processing_migration.py‎

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@
8888
"experience-incubation": "experience",
8989
"profile-source-window": "profile",
9090
}
91+
_COMPATIBLE_DREAM_BINDING_ADDITIONS = {
92+
"handoff.dream.v1": "handoff",
93+
"prompt.dream.v1": "prompt",
94+
}
95+
_COMPATIBLE_DREAM_CAPABILITY_ADDITIONS = {"handoff", "prompt"}
9196
_STATE_COLUMNS = {
9297
"last_schedule_checkpoint_at": "DATETIME NULL",
9398
"scan_generation": "BIGINT NOT NULL DEFAULT 0",
@@ -164,6 +169,7 @@ async def bootstrap_processing_schema(
164169

165170
marker = await _marker(connection)
166171
if marker is not None:
172+
await _upgrade_compatible_dream_manifest(connection, config_manifest)
167173
await assert_processing_schema_ready(connection, config_manifest)
168174
return
169175
columns = await _columns(connection, STATES.name)
@@ -234,6 +240,82 @@ async def assert_processing_schema_ready(
234240
raise ProcessingSchemaNotReadyError(reason="; ".join(problems))
235241

236242

243+
async def _upgrade_compatible_dream_manifest(
244+
connection: AsyncConnection,
245+
config_manifest: Mapping[str, Any] | str | None,
246+
) -> None:
247+
"""Record the additive Handoff and Prompt bindings on existing deployments."""
248+
249+
if config_manifest is None:
250+
return
251+
marker = await _marker(connection, for_update=True)
252+
if marker is None or marker["schema_version"] != PROCESSING_SCHEMA_VERSION or marker["phase"] != "complete":
253+
return
254+
previous_manifest = _manifest(str(marker["config_manifest"]))
255+
current_manifest = _manifest(config_manifest)
256+
if _deployment_manifest(previous_manifest) == _deployment_manifest(current_manifest):
257+
return
258+
previous = json.loads(previous_manifest)
259+
current = json.loads(current_manifest)
260+
if not _is_compatible_dream_manifest_extension(previous, current):
261+
return
262+
upgraded = dict(previous)
263+
upgraded["bindings"] = {**previous["bindings"], **_manifest_binding_additions(previous, current)}
264+
upgraded["capabilities"] = current["capabilities"]
265+
upgraded_manifest = _manifest(upgraded)
266+
if _deployment_manifest(upgraded_manifest) != _deployment_manifest(current_manifest):
267+
return
268+
await connection.execute(
269+
update(SCHEMA)
270+
.where(SCHEMA.c.singleton == 1, SCHEMA.c.config_manifest == marker["config_manifest"])
271+
.values(config_manifest=upgraded_manifest)
272+
)
273+
274+
275+
def _is_compatible_dream_manifest_extension(previous: Mapping[str, Any], current: Mapping[str, Any]) -> bool:
276+
if set(previous) != set(current) or previous.get("mode") != current.get("mode"):
277+
return False
278+
previous_bindings = previous.get("bindings")
279+
current_bindings = current.get("bindings")
280+
previous_capabilities = previous.get("capabilities")
281+
current_capabilities = current.get("capabilities")
282+
if (
283+
not isinstance(previous_bindings, dict)
284+
or not isinstance(current_bindings, dict)
285+
or not isinstance(previous_capabilities, list)
286+
or not isinstance(current_capabilities, list)
287+
or any(not isinstance(item, str) for item in (*previous_capabilities, *current_capabilities))
288+
):
289+
return False
290+
if any(current_bindings.get(name) != family for name, family in previous_bindings.items()):
291+
return False
292+
additions = _manifest_binding_additions(previous, current)
293+
if not additions or any(
294+
_COMPATIBLE_DREAM_BINDING_ADDITIONS.get(name) != family for name, family in additions.items()
295+
):
296+
return False
297+
previous_capability_set = set(previous_capabilities)
298+
current_capability_set = set(current_capabilities)
299+
added_capabilities = current_capability_set - previous_capability_set
300+
return (
301+
previous_capability_set <= current_capability_set
302+
and added_capabilities <= _COMPATIBLE_DREAM_CAPABILITY_ADDITIONS
303+
and added_capabilities <= set(additions.values())
304+
)
305+
306+
307+
def _manifest_binding_additions(previous: Mapping[str, Any], current: Mapping[str, Any]) -> dict[str, str]:
308+
previous_bindings = previous.get("bindings")
309+
current_bindings = current.get("bindings")
310+
if not isinstance(previous_bindings, dict) or not isinstance(current_bindings, dict):
311+
return {}
312+
return {
313+
name: family
314+
for name, family in current_bindings.items()
315+
if isinstance(name, str) and isinstance(family, str) and name not in previous_bindings
316+
}
317+
318+
237319
async def apply_processing_migration( # noqa: C901 - explicit durable maintenance phases
238320
connection: AsyncConnection,
239321
*,

‎src/powercontext/builtin/review/service.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,6 +592,10 @@ async def approve( # noqa: C901 - each Family keeps its own transactional publi
592592
async with self._connection() as connection:
593593
current = await self._candidates.get(connection, self._scope_id, candidate_id)
594594
await self._authorize_decision("approve", current)
595+
if current.status is CandidateStatus.APPROVED:
596+
if current.version != expected_version:
597+
raise CandidateConflictError(candidate_id, expected_version, current.version)
598+
return _reviewed_candidate(current)
595599
if current.family == Memory.family:
596600
return await self._approve_memory_dream(candidate_id, expected_version)
597601
if current.family == TopicMemory.family:

‎tests/builtin/runtime/test_processing_composition.py‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from __future__ import annotations
1616

1717
import asyncio
18+
import json
1819
from contextlib import asynccontextmanager
1920
from pathlib import Path
2021
from types import SimpleNamespace
@@ -29,6 +30,7 @@
2930
from powercontext.builtin.persistence.processing_migration import (
3031
ProcessingSchemaNotReadyError,
3132
apply_processing_migration,
33+
bootstrap_processing_schema,
3234
)
3335
from powercontext.builtin.persistence.sqlite import SQLiteConfig, SQLiteProfile
3436
from powercontext.builtin.persistence.supervision import ArtifactProcessingFence
@@ -56,6 +58,18 @@
5658

5759
_FAMILIES = {"memory", "topic-memory", "experience", "profile"}
5860
_BINDING = "topic-memory-source-window"
61+
_PRE_HANDOFF_PROMPT_MANIFEST = {
62+
"mode": "global",
63+
"capabilities": [],
64+
"bindings": {
65+
"memory-source-window": "memory",
66+
"topic-memory-source-window": "topic-memory",
67+
"experience-incubation": "experience",
68+
"profile-source-window": "profile",
69+
"skill.dream.v1": "skill",
70+
},
71+
"legacy_automatic_bindings": ["topic-memory-source-window"],
72+
}
5973
_OCEANBASE = OceanBaseConfig(
6074
url=SecretStr("mysql+aoceanbase://root%40test@127.0.0.1:2881/composition_unused?charset=utf8mb4")
6175
)
@@ -103,6 +117,37 @@ async def scenario() -> None:
103117
asyncio.run(scenario())
104118

105119

120+
def test_startup_upgrades_manifest_written_by_pre_handoff_prompt_runtime(tmp_path: Path) -> None:
121+
async def scenario() -> None:
122+
database = _sqlite(tmp_path / "pre-dream-bindings.db")
123+
config = BuiltinConfig(database=database, runtime=RuntimeConfig(dream_enabled=False))
124+
# This is the completed manifest emitted by the pre-Handoff/Prompt runtime,
125+
# bootstrapped through the same SQLite schema path used by that version.
126+
async with (
127+
SQLiteProfile.open(database, tables=SHARED_TABLES) as profile,
128+
profile.database.transaction() as connection,
129+
):
130+
await bootstrap_processing_schema(connection, _PRE_HANDOFF_PROMPT_MANIFEST)
131+
132+
async with open_builtin_contexts(config):
133+
pass
134+
135+
async with (
136+
SQLiteProfile.open(database, tables=SHARED_TABLES) as profile,
137+
profile.database.transaction() as connection,
138+
):
139+
marker = (await connection.execute(select(ARTIFACT_PROCESSING_SCHEMA_TABLE))).mappings().one()
140+
stored = json.loads(marker["config_manifest"])
141+
current = canonical_processing_manifest(config)
142+
assert marker["phase"] == "complete"
143+
assert marker["migration_id"] == "fresh"
144+
assert stored["bindings"] == current["bindings"]
145+
assert stored["capabilities"] == current["capabilities"]
146+
assert stored["legacy_automatic_bindings"] == _PRE_HANDOFF_PROMPT_MANIFEST["legacy_automatic_bindings"]
147+
148+
asyncio.run(scenario())
149+
150+
106151
def test_nonempty_legacy_sqlite_is_rejected_without_importing_or_discarding_work(tmp_path: Path) -> None:
107152
async def scenario() -> None:
108153
database = _sqlite(tmp_path / "legacy.db")

‎tests/e2e/test_artifact_dreaming.py‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -666,7 +666,7 @@ async def scenario() -> None:
666666
completed.error,
667667
)
668668
assert (await handoffs.latest()).revision == 1
669-
from powercontext.builtin.review.errors import InvalidCandidateError
669+
from powercontext.builtin.review.errors import CandidateConflictError, InvalidCandidateError
670670

671671
candidate = await runtime.review.for_scope(scope_id).get(
672672
GetArtifactCandidateRequest(candidate_id=completed.candidate.candidate_id)
@@ -690,6 +690,20 @@ async def scenario() -> None:
690690
)
691691
)
692692
assert approved.result_artifact is not None
693+
retry = await runtime.review.for_scope(scope_id).approve(
694+
ApproveArtifactCandidateRequest(
695+
candidate_id=completed.candidate.candidate_id,
696+
expected_version=completed.candidate.version,
697+
)
698+
)
699+
assert retry == approved
700+
with pytest.raises(CandidateConflictError):
701+
await runtime.review.for_scope(scope_id).approve(
702+
ApproveArtifactCandidateRequest(
703+
candidate_id=completed.candidate.candidate_id,
704+
expected_version=completed.candidate.version + 1,
705+
)
706+
)
693707
updated = await handoffs.latest()
694708
assert updated is not None and updated.revision == 2
695709
assert updated.content.generation is None

‎tests/e2e/test_dream_skill_prompt.py‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,11 @@
3838
from powercontext.builtin.inference.models import GenerationResult, InferenceUsage
3939
from powercontext.builtin.persistence.artifacts import RepositoryArtifactDraft
4040
from powercontext.builtin.records import ArtifactWrite
41-
from powercontext.builtin.review.errors import ArtifactTargetConflictError, InvalidCandidateError
41+
from powercontext.builtin.review.errors import (
42+
ArtifactTargetConflictError,
43+
CandidateConflictError,
44+
InvalidCandidateError,
45+
)
4246
from powercontext.builtin.runtime import (
4347
ApproveArtifactCandidateRequest,
4448
BuiltinConfig,
@@ -187,6 +191,20 @@ async def scenario():
187191
)
188192
)
189193
assert approved.result_artifact.revision == 2
194+
assert (
195+
await review.approve(
196+
ApproveArtifactCandidateRequest(
197+
candidate_id=candidate.candidate_id, expected_version=candidate.version
198+
)
199+
)
200+
== approved
201+
)
202+
with pytest.raises(CandidateConflictError):
203+
await review.approve(
204+
ApproveArtifactCandidateRequest(
205+
candidate_id=candidate.candidate_id, expected_version=candidate.version + 1
206+
)
207+
)
190208
assert current_prompt("memory.extract") == frozen
191209
assert frozen.artifact == target
192210
fresh = await prompts.resolve(scope.scope_id, "memory.extract")

0 commit comments

Comments
 (0)