zikaron 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- zikaron/__init__.py +1 -0
- zikaron/cli/__init__.py +1 -0
- zikaron/cli/main.py +114 -0
- zikaron/core/__init__.py +1 -0
- zikaron/core/clock.py +78 -0
- zikaron/core/config/__init__.py +1 -0
- zikaron/core/config/keys.py +395 -0
- zikaron/core/config/resolution.py +267 -0
- zikaron/core/consolidation/__init__.py +1 -0
- zikaron/core/consolidation/authorization.py +316 -0
- zikaron/core/consolidation/candidates.py +147 -0
- zikaron/core/consolidation/context.py +166 -0
- zikaron/core/consolidation/grouping.py +383 -0
- zikaron/core/consolidation/groups.py +490 -0
- zikaron/core/consolidation/payload.py +246 -0
- zikaron/core/consolidation/planning.py +192 -0
- zikaron/core/consolidation/rowstate.py +68 -0
- zikaron/core/consolidation/runs.py +306 -0
- zikaron/core/consolidation/serving.py +462 -0
- zikaron/core/consolidation/verbs.py +500 -0
- zikaron/core/errors.py +355 -0
- zikaron/core/events.py +748 -0
- zikaron/core/indexing/__init__.py +1 -0
- zikaron/core/indexing/acquisition.py +255 -0
- zikaron/core/indexing/chunking.py +368 -0
- zikaron/core/indexing/encoder.py +537 -0
- zikaron/core/indexing/lexical.py +86 -0
- zikaron/core/indexing/model_cache.py +93 -0
- zikaron/core/indexing/model_pin.py +89 -0
- zikaron/core/indexing/vectors.py +223 -0
- zikaron/core/indexing/writes.py +461 -0
- zikaron/core/knowledge/__init__.py +5 -0
- zikaron/core/knowledge/arms.py +104 -0
- zikaron/core/knowledge/builds.py +204 -0
- zikaron/core/knowledge/candidates.py +130 -0
- zikaron/core/knowledge/changes.py +175 -0
- zikaron/core/knowledge/chunking.py +376 -0
- zikaron/core/knowledge/counters.py +228 -0
- zikaron/core/knowledge/database.py +380 -0
- zikaron/core/knowledge/ddl.py +196 -0
- zikaron/core/knowledge/disposal.py +213 -0
- zikaron/core/knowledge/errors.py +166 -0
- zikaron/core/knowledge/files.py +202 -0
- zikaron/core/knowledge/git.py +385 -0
- zikaron/core/knowledge/groups.py +450 -0
- zikaron/core/knowledge/lexical.py +64 -0
- zikaron/core/knowledge/lifecycle.py +418 -0
- zikaron/core/knowledge/lock.py +277 -0
- zikaron/core/knowledge/meta.py +393 -0
- zikaron/core/knowledge/paths.py +55 -0
- zikaron/core/knowledge/pending.py +59 -0
- zikaron/core/knowledge/registry.py +264 -0
- zikaron/core/knowledge/repair.py +152 -0
- zikaron/core/knowledge/reporting.py +436 -0
- zikaron/core/knowledge/roots.py +91 -0
- zikaron/core/knowledge/scan.py +429 -0
- zikaron/core/knowledge/search.py +346 -0
- zikaron/core/knowledge/state.py +174 -0
- zikaron/core/knowledge/text.py +166 -0
- zikaron/core/knowledge/vectors.py +102 -0
- zikaron/core/knowledge/walk.py +264 -0
- zikaron/core/knowledge/writes.py +127 -0
- zikaron/core/records/__init__.py +1 -0
- zikaron/core/records/memory.py +961 -0
- zikaron/core/records/receipts.py +161 -0
- zikaron/core/records/supersession.py +221 -0
- zikaron/core/retrieval/__init__.py +1 -0
- zikaron/core/retrieval/arms.py +318 -0
- zikaron/core/retrieval/block.py +107 -0
- zikaron/core/retrieval/eligibility.py +164 -0
- zikaron/core/retrieval/query.py +327 -0
- zikaron/core/retrieval/ranking.py +260 -0
- zikaron/core/retrieval/reads.py +294 -0
- zikaron/core/retrieval/retrieve.py +158 -0
- zikaron/core/retrieval/similarity.py +87 -0
- zikaron/core/signals/__init__.py +34 -0
- zikaron/core/signals/contention.py +106 -0
- zikaron/core/signals/dedup.py +201 -0
- zikaron/core/signals/horizon.py +47 -0
- zikaron/core/signals/repair.py +161 -0
- zikaron/core/signals/retirement.py +83 -0
- zikaron/core/signals/sessions.py +105 -0
- zikaron/core/signals/writes.py +200 -0
- zikaron/core/store/__init__.py +1 -0
- zikaron/core/store/connection.py +202 -0
- zikaron/core/store/ddl.py +215 -0
- zikaron/core/store/embedder.py +45 -0
- zikaron/core/store/meta.py +152 -0
- zikaron/core/store/permissions.py +160 -0
- zikaron/core/store/store.py +408 -0
- zikaron/core/store/transactions.py +181 -0
- zikaron/core/write/__init__.py +33 -0
- zikaron/core/write/dedup.py +145 -0
- zikaron/core/write/tools.py +290 -0
- zikaron/doctor/__init__.py +1 -0
- zikaron/doctor/checks.py +220 -0
- zikaron/doctor/main.py +64 -0
- zikaron/harness/__init__.py +1 -0
- zikaron/harness/detect.py +92 -0
- zikaron/harness/spec.py +320 -0
- zikaron/hook/__init__.py +1 -0
- zikaron/hook/connect.py +379 -0
- zikaron/hook/envelope.py +106 -0
- zikaron/hook/failure.py +104 -0
- zikaron/hook/limits.py +61 -0
- zikaron/hook/main.py +118 -0
- zikaron/hook/push.py +183 -0
- zikaron/hook/rpc.py +85 -0
- zikaron/hook/spawn_warm.py +81 -0
- zikaron/hook/subagent_policy.py +57 -0
- zikaron/hook/tripwire.py +54 -0
- zikaron/hook/warm_helper.py +137 -0
- zikaron/hook/write_policy.py +319 -0
- zikaron/install/__init__.py +4 -0
- zikaron/install/__main__.py +18 -0
- zikaron/install/assets.py +394 -0
- zikaron/install/entries.py +370 -0
- zikaron/install/harness.py +185 -0
- zikaron/install/main.py +375 -0
- zikaron/install/targets.py +789 -0
- zikaron/install/writer.py +973 -0
- zikaron/knowledge/__init__.py +1 -0
- zikaron/knowledge/__main__.py +17 -0
- zikaron/knowledge/indexer/__init__.py +1 -0
- zikaron/knowledge/indexer/__main__.py +17 -0
- zikaron/knowledge/indexer/detach.py +83 -0
- zikaron/knowledge/indexer/main.py +187 -0
- zikaron/knowledge/main.py +466 -0
- zikaron/knowledge/scope.py +133 -0
- zikaron/mcp/__init__.py +6 -0
- zikaron/mcp/connection.py +583 -0
- zikaron/mcp/consolidator.py +316 -0
- zikaron/mcp/errors.py +73 -0
- zikaron/mcp/main.py +66 -0
- zikaron/mcp/primary.py +420 -0
- zikaron/mcp/server.py +96 -0
- zikaron/mcp/spill.py +328 -0
- zikaron/mcp/tool_names.py +67 -0
- zikaron/py.typed +0 -0
- zikaron/service/__init__.py +1 -0
- zikaron/service/asyncio_compat.py +126 -0
- zikaron/service/context.py +251 -0
- zikaron/service/dispatch.py +332 -0
- zikaron/service/dispatch_consolidation.py +397 -0
- zikaron/service/dispatch_knowledge.py +469 -0
- zikaron/service/envelope.py +166 -0
- zikaron/service/lifecycle.py +467 -0
- zikaron/service/log.py +96 -0
- zikaron/service/main.py +531 -0
- zikaron/service/params.py +168 -0
- zikaron/service/paths.py +181 -0
- zikaron/service/rpc.py +176 -0
- zikaron/service/security.py +156 -0
- zikaron/service/serialize.py +204 -0
- zikaron/service/serialize_knowledge.py +238 -0
- zikaron/service/server.py +416 -0
- zikaron-0.1.0.dist-info/METADATA +770 -0
- zikaron-0.1.0.dist-info/RECORD +162 -0
- zikaron-0.1.0.dist-info/WHEEL +5 -0
- zikaron-0.1.0.dist-info/entry_points.txt +4 -0
- zikaron-0.1.0.dist-info/licenses/LICENSE +21 -0
- zikaron-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""The shapes `zikaron_memory_next_group` returns, as three types a caller pattern-matches on.
|
|
2
|
+
|
|
3
|
+
`architecture.md` §"Consolidator tool surface" is normative for every field here. These are `core`'s
|
|
4
|
+
values, not wire objects: the literal `{done: true}` and `{busy: true}` booleans belong to whichever
|
|
5
|
+
transport serializes them, because `isinstance(outcome, RunDone)` already *is* that fact at the
|
|
6
|
+
Python level and a boolean field repeating it would carry no information the type does not.
|
|
7
|
+
|
|
8
|
+
Kept apart from the serve loop because the two change for different reasons — a payload field moves
|
|
9
|
+
when the tool surface does, while the loop moves when the lifecycle does — and because the ladder
|
|
10
|
+
that authorizes the write verbs needs the record shape without needing the loop.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
from zikaron.core.consolidation.groups import Shard
|
|
16
|
+
from zikaron.core.errors import ErrorCode, ZikaronError
|
|
17
|
+
from zikaron.core.records.memory import ConflictRecord, Memory, Rewrite
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class GroupRecord:
|
|
22
|
+
"""One row a serve delivers: `architecture.md`'s `{uuid, expected_version, gist, content,
|
|
23
|
+
created_at}` exactly, for a member, the anchor and a candidate alike.
|
|
24
|
+
|
|
25
|
+
`expected_version` is the version whose prose is in **this** payload, re-read at serve time —
|
|
26
|
+
not the version at plan time. The two are different fields for a reason: `version_seen` is
|
|
27
|
+
change detection, while this is the version the receipt was minted at, and delivering current
|
|
28
|
+
prose under a stale version would break D26 while delivering historical prose is impossible,
|
|
29
|
+
since no history is stored.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
uuid: str
|
|
33
|
+
expected_version: int
|
|
34
|
+
gist: str
|
|
35
|
+
content: str
|
|
36
|
+
created_at: str
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def of(cls, row: Memory) -> "GroupRecord":
|
|
40
|
+
"""This row as a payload record, at its current version."""
|
|
41
|
+
return cls(
|
|
42
|
+
uuid=row.uuid,
|
|
43
|
+
expected_version=row.version,
|
|
44
|
+
gist=row.gist,
|
|
45
|
+
content=row.content,
|
|
46
|
+
created_at=row.created_at,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class RankedRecord:
|
|
52
|
+
"""One candidate and its rank in `retrieval.md`'s total order.
|
|
53
|
+
|
|
54
|
+
The rank is delivered so a run is reproducible from the payload alone, and persisted alongside
|
|
55
|
+
it so the authorization set survives a service restart.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
record: GroupRecord
|
|
59
|
+
rank: int
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class ServedGroup:
|
|
64
|
+
"""What one delivery hands the consolidator: `zikaron_memory_next_group`'s success shape.
|
|
65
|
+
|
|
66
|
+
`anchor` is `None` for an orphan group, and also `None` with `anchor_vacated=True` when a
|
|
67
|
+
planned anchor stopped being targetable before the group was served — two different situations
|
|
68
|
+
that the payload deliberately distinguishes, because the second means "there was a record here
|
|
69
|
+
and it is gone", which is information a model deciding between `merge` and `promote` can use.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
group_id: str
|
|
73
|
+
run_id: str
|
|
74
|
+
anchor: GroupRecord | None
|
|
75
|
+
anchor_vacated: bool
|
|
76
|
+
journal_entries: tuple[GroupRecord, ...]
|
|
77
|
+
candidates: tuple[RankedRecord, ...]
|
|
78
|
+
shard: Shard
|
|
79
|
+
serve_count: int
|
|
80
|
+
n_gists_used: int
|
|
81
|
+
remaining_groups: int
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True, slots=True)
|
|
85
|
+
class RunDone:
|
|
86
|
+
"""`{done: true}`: this run has nothing left to serve.
|
|
87
|
+
|
|
88
|
+
A value rather than `None`, so that "the run is finished" and "something went wrong" cannot be
|
|
89
|
+
the same return. Carries no fields: the run is closed, and every count about it is in its
|
|
90
|
+
`consolidate_run` event.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True, slots=True)
|
|
95
|
+
class Busy:
|
|
96
|
+
"""`{busy: true, ...}`: an effectively-active run belongs to a different `(session_id, pid)`.
|
|
97
|
+
|
|
98
|
+
A defined, deterministic answer rather than an error and rather than a second plan — the skill
|
|
99
|
+
reports it and stops. `holder_pid` is returned so same-session contention is diagnosable rather
|
|
100
|
+
than silent, which matters now that two consolidators launched from one kiro session share a
|
|
101
|
+
`session_id` *and* a `client_kind`.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
holder_session: str
|
|
105
|
+
holder_pid: int
|
|
106
|
+
expires_at: str
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
#: `ServedGroup | RunDone | Busy`: `next_group`'s three response shapes as one return type a caller
|
|
110
|
+
#: pattern-matches on, rather than three optional fields two of which are always meaningless.
|
|
111
|
+
type NextGroupOutcome = ServedGroup | RunDone | Busy
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True, slots=True)
|
|
115
|
+
class NamedRow:
|
|
116
|
+
"""One `{uuid, expected_version}` pair — how every consolidator verb names a row it touches.
|
|
117
|
+
|
|
118
|
+
Targets, absorbed rows and discarded rows alike, never the merge target only. Otherwise a
|
|
119
|
+
primary agent that amends an absorbed journal row after it was planned into a group would have
|
|
120
|
+
its repair silently retired by the consolidator, which is exactly the lost update D26 exists to
|
|
121
|
+
prevent.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
uuid: str
|
|
125
|
+
expected_version: int
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True, slots=True)
|
|
129
|
+
class NamedRows:
|
|
130
|
+
"""Every row one call names: its `absorb` list, and a merge target where the verb has one.
|
|
131
|
+
|
|
132
|
+
Self-validating on the one bound that is independent of store state, which is what makes it rung
|
|
133
|
+
1: **a uuid may appear at most once in `absorb`**. A repeat is malformed on its own terms — the
|
|
134
|
+
list names the rows one call dispositions, so a uuid twice would bump one row's `version` twice
|
|
135
|
+
for one logical action and report an `n_absorbed` that counts it twice. The other half of the
|
|
136
|
+
stated bound, `≤ the group's member count`, needs the store and so is rung 2's, where it follows
|
|
137
|
+
from every element having to be a member of that group. The **minimum** of one row is rung 2's
|
|
138
|
+
`not_in_group` rather than `bounds`, which the error table states directly.
|
|
139
|
+
|
|
140
|
+
Raises:
|
|
141
|
+
ZikaronError: `BOUNDS` naming `absorb` if a uuid appears twice.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
absorb: tuple[NamedRow, ...]
|
|
145
|
+
target: NamedRow | None = None
|
|
146
|
+
|
|
147
|
+
def __post_init__(self) -> None:
|
|
148
|
+
seen: set[str] = set()
|
|
149
|
+
for row in self.absorb:
|
|
150
|
+
if row.uuid in seen:
|
|
151
|
+
raise ZikaronError(
|
|
152
|
+
ErrorCode.BOUNDS,
|
|
153
|
+
field="absorb",
|
|
154
|
+
limit="each uuid at most once",
|
|
155
|
+
actual=row.uuid,
|
|
156
|
+
)
|
|
157
|
+
seen.add(row.uuid)
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def every(self) -> tuple[NamedRow, ...]:
|
|
161
|
+
"""Every named row, the target first where there is one.
|
|
162
|
+
|
|
163
|
+
One order, used by the existence, version and receipt rungs alike, so that a call naming
|
|
164
|
+
several offending rows reports them in a defined sequence rather than in whichever order a
|
|
165
|
+
dict happened to iterate.
|
|
166
|
+
"""
|
|
167
|
+
return (*(() if self.target is None else (self.target,)), *self.absorb)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass(frozen=True, slots=True)
|
|
171
|
+
class MergeTarget:
|
|
172
|
+
"""The long-term record a `merge` rewrites: which row, at which version, with what prose.
|
|
173
|
+
|
|
174
|
+
One value rather than three parameters, because the three are one instruction — and because the
|
|
175
|
+
row and the prose must not be able to arrive from different places, which is what a call site
|
|
176
|
+
threading `target`, `gist` and `content` separately makes possible.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
row: NamedRow
|
|
180
|
+
rewrite: Rewrite
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass(frozen=True, slots=True)
|
|
184
|
+
class GroupConflict:
|
|
185
|
+
"""`{conflict: true, current: [...], remaining_uuids: [...]}` — the three verbs' shared
|
|
186
|
+
rejection.
|
|
187
|
+
|
|
188
|
+
A **list** of records, unlike the primary-agent verbs' single object: a consolidator call names
|
|
189
|
+
several rows, all versions are validated before anything mutates, and one mismatch returns the
|
|
190
|
+
current record for **every** conflicting uuid so the model can re-decide in one round trip.
|
|
191
|
+
|
|
192
|
+
Carries `remaining_uuids` because the caller learns the same remaining state whether its call
|
|
193
|
+
landed or raced. A call rejected by an *error* carries no member list at all — it changed
|
|
194
|
+
nothing, so the list the caller already holds still stands.
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
current: tuple[ConflictRecord, ...]
|
|
198
|
+
remaining_uuids: tuple[str, ...]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass(frozen=True, slots=True)
|
|
202
|
+
class Merged:
|
|
203
|
+
"""`zikaron_memory_merge`'s success shape:
|
|
204
|
+
`{uuid, version, remaining_uuids, group_complete}`."""
|
|
205
|
+
|
|
206
|
+
uuid: str
|
|
207
|
+
version: int
|
|
208
|
+
remaining_uuids: tuple[str, ...]
|
|
209
|
+
group_complete: bool
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@dataclass(frozen=True, slots=True)
|
|
213
|
+
class Promoted:
|
|
214
|
+
"""`zikaron_memory_promote`'s success shape — identical in shape to `Merged`, and a separate
|
|
215
|
+
type.
|
|
216
|
+
|
|
217
|
+
Separate for the reason `Amended` and `Retired` are: the two verbs are not interchangeable at
|
|
218
|
+
any call site that matches on the outcome's own type, and a promotion's `uuid` may be a row
|
|
219
|
+
that did not exist before the call while a merge's never is.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
uuid: str
|
|
223
|
+
version: int
|
|
224
|
+
remaining_uuids: tuple[str, ...]
|
|
225
|
+
group_complete: bool
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@dataclass(frozen=True, slots=True)
|
|
229
|
+
class Discarded:
|
|
230
|
+
"""`zikaron_memory_discard`'s success shape: `{retired, remaining_uuids, group_complete}`.
|
|
231
|
+
|
|
232
|
+
`retired` is how many member rows this call actually moved, read from the disposition write
|
|
233
|
+
rather than from the length of the argument, so a number the store did not produce cannot be
|
|
234
|
+
reported.
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
retired: int
|
|
238
|
+
remaining_uuids: tuple[str, ...]
|
|
239
|
+
group_complete: bool
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
#: The three verbs' return types. Each is its own success shape or the shared conflict, as a union a
|
|
243
|
+
#: caller pattern-matches rather than a boolean flag beside fields that are sometimes meaningless.
|
|
244
|
+
type MergeOutcome = Merged | GroupConflict
|
|
245
|
+
type PromoteOutcome = Promoted | GroupConflict
|
|
246
|
+
type DiscardOutcome = Discarded | GroupConflict
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""`plan_groups`: write one run's whole partition, and close whatever run preceded it.
|
|
2
|
+
|
|
3
|
+
`architecture.md` §"Consolidation lifecycle" §Planning is normative. Four things happen here, in
|
|
4
|
+
this order, and the order is what enforces invariant 15:
|
|
5
|
+
|
|
6
|
+
1. **Close any pre-existing `active` run**, with the status two tests imply — `expired` if the lease
|
|
7
|
+
has passed, else `abandoned` for its own owner and `taken_over` for anybody else. This function
|
|
8
|
+
is the only producer of all three, which is what makes them one decision rather than several
|
|
9
|
+
rules whose conditions could all be true of one run. **An explicit call takes the run over**: no
|
|
10
|
+
evidence inside the store separates a dead consolidator from a slow one, so a human invoking the
|
|
11
|
+
skill again is the evidence, and `next_group` — the path a *model* uses — still refuses a
|
|
12
|
+
stranger.
|
|
13
|
+
2. **Create the new run**, owned by `(session_id, pid)` with a fresh lease. It is only after step 1
|
|
14
|
+
that this can be done without there being two effectively-active runs for an instant.
|
|
15
|
+
3. **Plan and persist the groups.** Membership is frozen here. Planning is a pure function of the
|
|
16
|
+
store plus the effective config, so a run is reproducible from its inputs.
|
|
17
|
+
4. **Emit `planned`**, and — when the journal was empty — close the run `complete` immediately,
|
|
18
|
+
since a run with no groups has nothing left to serve.
|
|
19
|
+
|
|
20
|
+
**Replanning is never incremental.** A new run always plans from scratch, which is safe precisely
|
|
21
|
+
because undispositioned members stay `tier='journal' AND active=1`: nothing was lost by the run
|
|
22
|
+
being abandoned, so nothing needs carrying forward. That is D29's never-lose guard doing its work —
|
|
23
|
+
the store's own state is the record of what happened, and no cooperation from the model is required.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import aiosqlite
|
|
27
|
+
|
|
28
|
+
from zikaron.core.consolidation import grouping, groups, runs
|
|
29
|
+
from zikaron.core.consolidation.context import ConsolidationCall
|
|
30
|
+
from zikaron.core.consolidation.groups import Group, GroupStatus
|
|
31
|
+
from zikaron.core.consolidation.runs import Run, RunStatus
|
|
32
|
+
from zikaron.core.errors import ErrorCode, IndexStage, ZikaronError
|
|
33
|
+
from zikaron.core.events import RunPhase
|
|
34
|
+
from zikaron.core.store import transactions
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _closing_status(previous: Run, *, call: ConsolidationCall, at: str) -> RunStatus:
|
|
38
|
+
"""Which terminal status a pre-existing `active` run gets: two tests, three answers.
|
|
39
|
+
|
|
40
|
+
A lapsed lease is `expired`, whoever asks. An unexpired one is `abandoned` when the caller
|
|
41
|
+
**is** its owner — a worker deliberately restarting its own run — and `taken_over` when it is
|
|
42
|
+
not. The third value exists so those last two stay distinguishable: they have different costs,
|
|
43
|
+
and in the likeliest case nothing else on the row separates them, since a user retrying the
|
|
44
|
+
skill in one kiro session presents the same `session_id` and only a different pid.
|
|
45
|
+
"""
|
|
46
|
+
if previous.has_lapsed(at=at):
|
|
47
|
+
return RunStatus.EXPIRED
|
|
48
|
+
return RunStatus.ABANDONED if previous.owner == call.owner else RunStatus.TAKEN_OVER
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def _close_previous(
|
|
52
|
+
db: aiosqlite.Connection, *, call: ConsolidationCall, at: str
|
|
53
|
+
) -> Run | None:
|
|
54
|
+
"""Close the store's one `active` run, if it has one, with the status the two tests above imply.
|
|
55
|
+
|
|
56
|
+
Returns the run that was closed, so a caller can tell "there was nothing to close" from "there
|
|
57
|
+
was, and it is closed now" — which is the difference between a first run and a takeover.
|
|
58
|
+
"""
|
|
59
|
+
previous = await runs.stored_active(db)
|
|
60
|
+
if previous is None:
|
|
61
|
+
return None
|
|
62
|
+
status = _closing_status(previous, call=call, at=at)
|
|
63
|
+
await runs.close(db, run_id=previous.run_id, status=status, ctx=call.ctx)
|
|
64
|
+
return previous
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def plan_within_transaction(
|
|
68
|
+
db: aiosqlite.Connection, *, call: ConsolidationCall, at: str
|
|
69
|
+
) -> Run:
|
|
70
|
+
"""`plan_groups`' work, assuming the caller already holds an open transaction.
|
|
71
|
+
|
|
72
|
+
Neither commits nor rolls back, so `next_group` can call this inside its own serve transaction —
|
|
73
|
+
which it must, because an implicit replan and the serve that follows it have to be one atomic
|
|
74
|
+
step or a second caller could serve from a run this one is still writing.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
at: the single instant this transaction is about — the closing lease comparison, the new
|
|
78
|
+
run's `started_at`, and the lease derived from it. Passed in rather than read here so a
|
|
79
|
+
serve that replans implicitly stamps the replan and the delivery identically.
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
The new run, `active` unless the journal was empty, in which case it is already `complete`
|
|
83
|
+
in the store and this value's own `status` is the pre-close one. Callers that need the
|
|
84
|
+
current status re-read it; the serve loop instead asks the group table what is servable,
|
|
85
|
+
which is the same question asked of the authoritative side.
|
|
86
|
+
"""
|
|
87
|
+
await _close_previous(db, call=call, at=at)
|
|
88
|
+
run = await runs.create(
|
|
89
|
+
db,
|
|
90
|
+
owner=call.owner,
|
|
91
|
+
started_at=at,
|
|
92
|
+
lease_seconds=call.settings.run_lease_seconds,
|
|
93
|
+
)
|
|
94
|
+
planned = await grouping.plan(db, call=call)
|
|
95
|
+
for group in planned:
|
|
96
|
+
group_id = groups.new_group_id()
|
|
97
|
+
await groups.insert(
|
|
98
|
+
db,
|
|
99
|
+
Group(
|
|
100
|
+
group_id=group_id,
|
|
101
|
+
run_id=run.run_id,
|
|
102
|
+
anchor_uuid=group.anchor_uuid,
|
|
103
|
+
order_key=group.order_key,
|
|
104
|
+
shard=group.shard,
|
|
105
|
+
status=GroupStatus.PENDING,
|
|
106
|
+
serve_count=0,
|
|
107
|
+
served_at=None,
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
for member in group.members:
|
|
111
|
+
await groups.insert_member(
|
|
112
|
+
db,
|
|
113
|
+
group_id=group_id,
|
|
114
|
+
memory_uuid=member.uuid,
|
|
115
|
+
version_seen=member.version,
|
|
116
|
+
)
|
|
117
|
+
await runs.log_phase(
|
|
118
|
+
db,
|
|
119
|
+
run_id=run.run_id,
|
|
120
|
+
phase=RunPhase.PLANNED,
|
|
121
|
+
run_counts=await groups.run_counts(db, run.run_id),
|
|
122
|
+
ctx=call.ctx,
|
|
123
|
+
)
|
|
124
|
+
if not planned:
|
|
125
|
+
# A run with zero groups has nothing to serve, so it is complete the moment it is planned.
|
|
126
|
+
# Invariant 17's `active → complete` cause is "whichever transaction first observes that no
|
|
127
|
+
# group of the run is pending or served", and this transaction is the first to observe it.
|
|
128
|
+
await runs.close(db, run_id=run.run_id, status=RunStatus.COMPLETE, ctx=call.ctx)
|
|
129
|
+
return run
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
async def plan_groups(db: aiosqlite.Connection, *, call: ConsolidationCall) -> Run:
|
|
133
|
+
"""Plan a fresh consolidation run over the whole journal, in one transaction — taking over if
|
|
134
|
+
held.
|
|
135
|
+
|
|
136
|
+
The service RPC, and it is the **one** entry point that may displace a live worker. Whoever
|
|
137
|
+
calls it wins: an unexpired run of another owner is closed `taken_over`, its own unexpired run
|
|
138
|
+
is closed `abandoned`, and a lapsed one is closed `expired`. `next_group` answers `{busy: true}`
|
|
139
|
+
to a stranger instead, and that asymmetry is what D32 buys: a model confined to a configured
|
|
140
|
+
tool surface cannot *request* a takeover, because this RPC is in neither tool set. It is not a
|
|
141
|
+
claim that nothing calls it — the consolidator's own MCP client does, immediately before the
|
|
142
|
+
first `next_group` it forwards and at most once *successfully* per process — nor a capability
|
|
143
|
+
boundary, since the socket is unauthenticated to any same-uid process.
|
|
144
|
+
|
|
145
|
+
**Why takeover rather than refusal, since the refusal is the safer-looking choice.** Nothing
|
|
146
|
+
reachable from inside the store distinguishes a dead consolidator from one that is merely slow.
|
|
147
|
+
A lease is a timer, so it cannot. A pid-liveness check is not useless — the holder's `pid` names
|
|
148
|
+
an MCP client process that normally exits with its own agent — but it cannot carry the decision:
|
|
149
|
+
it answers *is this process alive*, never *will this worker make progress*, pid reuse can return
|
|
150
|
+
a false alive and prolong the very lockout being diagnosed, and the service and the client are
|
|
151
|
+
not guaranteed a shared pid namespace. The evidence that settles it is outside the store
|
|
152
|
+
altogether: a human invoking the consolidation skill a second time. Refusing would pin the store
|
|
153
|
+
for up to `run_lease` on a worker that has stopped.
|
|
154
|
+
|
|
155
|
+
**Nothing the displaced worker does can land, and that is the ladder's property rather than a
|
|
156
|
+
hope.** Rung 2 requires a group's run to be owned by the caller *and* effectively active, so the
|
|
157
|
+
displaced worker's next `merge`/`promote`/`discard` is refused `group_expired` before touching a
|
|
158
|
+
row, and its next `next_group` finds the new run foreign and stops. It loses its reasoning in
|
|
159
|
+
progress — what the human chose to discard — and no journal row, since undispositioned members
|
|
160
|
+
stay `tier='journal' AND active=1` and are replanned.
|
|
161
|
+
|
|
162
|
+
`next_group` does not come through here: it calls `plan_within_transaction` inside its own serve
|
|
163
|
+
transaction, having already established that no effectively-active run exists.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
The new run, `active` unless the journal was empty.
|
|
167
|
+
|
|
168
|
+
Raises:
|
|
169
|
+
ZikaronError: `STORE_BUSY` if the store was locked, which the caller may retry, or
|
|
170
|
+
`INDEX_FAILED` at the `index_write` stage for any other driver failure — the plan is a
|
|
171
|
+
write, so `architecture.md` §Errors gives it a write's two codes and nothing is left
|
|
172
|
+
behind in either case.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
async def work(connection: aiosqlite.Connection) -> Run:
|
|
176
|
+
return await plan_within_transaction(connection, call=call, at=runs.now())
|
|
177
|
+
|
|
178
|
+
return await transactions.in_one_transaction(db, work, failure=_failure_map("plan_groups"))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _failure_map(verb: str) -> transactions.FailureMap:
|
|
182
|
+
"""Name contention `store_busy` and everything else `index_failed`, per `architecture.md`.
|
|
183
|
+
|
|
184
|
+
This layer's own decision for its own transactions, deliberately not a shared utility: what
|
|
185
|
+
varies between callers is exactly how a driver failure is named, and a consolidation transaction
|
|
186
|
+
writes rows, chunks and vectors, so it is a write by that section's own test.
|
|
187
|
+
"""
|
|
188
|
+
return lambda error: (
|
|
189
|
+
ZikaronError(ErrorCode.STORE_BUSY, verb=verb)
|
|
190
|
+
if transactions.is_contention(error)
|
|
191
|
+
else ZikaronError(ErrorCode.INDEX_FAILED, stage=IndexStage.INDEX_WRITE)
|
|
192
|
+
)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""The two row-state re-checks a serve and the ladder both run, evaluated by SQLite's own clause.
|
|
2
|
+
|
|
3
|
+
`architecture.md` names them as one condition each, and names them **twice** on purpose: a member
|
|
4
|
+
that fails the first at serve time is recorded `disposition='vacated'`, and a member that fails it
|
|
5
|
+
at call time is *vacated in fact and not yet recorded*, which is why rung 6 answers `not_in_group`
|
|
6
|
+
rather than inventing a code. Those two sites must apply the identical predicate or the second
|
|
7
|
+
would reject rows the first would have delivered.
|
|
8
|
+
|
|
9
|
+
**So the predicate is not re-expressed here.** The clause comes from
|
|
10
|
+
`eligibility.CONSUMER_FILTERS` — `tier='journal' AND active=1` for a member, `tier='long_term' AND
|
|
11
|
+
active=1` for a merge target — and is evaluated by SQLite against the row. A Python mirror
|
|
12
|
+
(`row.tier is Tier.JOURNAL and row.active`) would read better and would be a second statement of a
|
|
13
|
+
rule the design gives exactly one home, silently surviving a change to that table. This is the same
|
|
14
|
+
choice the query constructor makes when it hands quoted terms to FTS5's own tokenizer instead of
|
|
15
|
+
reimplementing `unicode61`.
|
|
16
|
+
|
|
17
|
+
The cost is one indexed `EXISTS` per row on a manually-invoked, rare path, inside a transaction the
|
|
18
|
+
caller already holds.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from typing import Final
|
|
22
|
+
|
|
23
|
+
import aiosqlite
|
|
24
|
+
|
|
25
|
+
from zikaron.core.retrieval.eligibility import MEMORY_ALIAS, Consumer, narrowing
|
|
26
|
+
|
|
27
|
+
#: The two consumers whose filters double as these state conditions. Named so the mapping from
|
|
28
|
+
#: "what is being checked" to "whose filter says so" is written once rather than at each call site.
|
|
29
|
+
_MEMBER: Final = Consumer.ORPHAN
|
|
30
|
+
_TARGET: Final = Consumer.CONSOLIDATION
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def _satisfies(db: aiosqlite.Connection, *, uuid: str, consumer: Consumer) -> bool:
|
|
34
|
+
"""Whether the row named by `uuid` satisfies `consumer`'s own narrowing filter, right now.
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
KeyError: the consumer states no narrowing filter, so there is nothing to check and a caller
|
|
38
|
+
asking has confused "no filter" with "admits nothing". Only `surface` and `search` are
|
|
39
|
+
in that position and neither has a row-state condition to re-check.
|
|
40
|
+
"""
|
|
41
|
+
clause = narrowing(consumer)
|
|
42
|
+
if clause is None:
|
|
43
|
+
raise KeyError(f"{consumer} states no narrowing filter, so there is nothing to re-check")
|
|
44
|
+
sql = (
|
|
45
|
+
"SELECT EXISTS (SELECT 1 FROM memory " # noqa: S608 — the clause is a source-level constant from the filter table; the uuid is bound.
|
|
46
|
+
f"{MEMORY_ALIAS} WHERE {MEMORY_ALIAS}.uuid = ? AND {clause})"
|
|
47
|
+
)
|
|
48
|
+
rows = await db.execute_fetchall(sql, (uuid,))
|
|
49
|
+
return bool(next(iter(rows))[0])
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def is_deliverable_member(db: aiosqlite.Connection, uuid: str) -> bool:
|
|
53
|
+
"""Whether this row is still an unconsolidated journal row, i.e. still a deliverable member.
|
|
54
|
+
|
|
55
|
+
A row that fails this has left the journal by another path between plan and call — a primary
|
|
56
|
+
agent retired it, or promoted it. Nothing was lost and it blocks no completion; it is simply not
|
|
57
|
+
something a consolidator may act on.
|
|
58
|
+
"""
|
|
59
|
+
return await _satisfies(db, uuid=uuid, consumer=_MEMBER)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def is_targetable(db: aiosqlite.Connection, uuid: str) -> bool:
|
|
63
|
+
"""Whether this row is still an active long-term record, i.e. still a legal `merge` target.
|
|
64
|
+
|
|
65
|
+
A merge never rewrites a historical row: `superseded_by` is immutable once set, so a retired row
|
|
66
|
+
given fresh prose would be neither the current record nor a faithful historical one.
|
|
67
|
+
"""
|
|
68
|
+
return await _satisfies(db, uuid=uuid, consumer=_TARGET)
|