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,462 @@
|
|
|
1
|
+
"""`next_group`: one transaction that re-validates, closes, defers or delivers — and iterates.
|
|
2
|
+
|
|
3
|
+
`architecture.md` §"Consolidation lifecycle" §Serving is normative, and the ordering inside is part
|
|
4
|
+
of the contract because it is observable in which status a group lands in:
|
|
5
|
+
|
|
6
|
+
1. **Re-validate the members.** A member no longer `tier='journal' AND active=1` is marked `vacated`
|
|
7
|
+
and not delivered; a planned anchor no longer `tier='long_term' AND active=1` is dropped. This
|
|
8
|
+
runs first because everything after it depends on how many members are left.
|
|
9
|
+
2. **Zero undispositioned members left → close the group `complete`** and continue the loop. Nothing
|
|
10
|
+
was delivered, so `serve_count` does not move and no `group_served` event is emitted.
|
|
11
|
+
3. **Otherwise, at `max_group_serves` already → mark the group `deferred`** and continue. Completion
|
|
12
|
+
is tested *first* on purpose: a group whose last member vacated is finished, and deferring it
|
|
13
|
+
instead would leave a group with nothing left to decide looking abandoned for the rest of the run
|
|
14
|
+
and inflating `n_deferred`.
|
|
15
|
+
4. **Otherwise serve it.**
|
|
16
|
+
|
|
17
|
+
**Why the loop exists rather than returning the emptied group:** serve-time vacating can
|
|
18
|
+
disposition a group's *last* open member, which leaves zero undispositioned rows with no write verb
|
|
19
|
+
having run — so nothing transitioned the group, it was handed back empty, and it was re-served
|
|
20
|
+
until the budget ran out and it went `deferred`. That contradicted invariant 16's own completion
|
|
21
|
+
condition, spent the re-serve budget on a group with nothing in it, and delayed `{done: true}`
|
|
22
|
+
behind groups that were already finished.
|
|
23
|
+
|
|
24
|
+
**The served set is named once and is the only member-shaped thing in the payload:** the group's
|
|
25
|
+
members with `disposition IS NULL` at the end of step 1. `journal_entries` carries it in group
|
|
26
|
+
order; the candidate query concatenates *its* gists, so `n_gists_used` counts from it; one
|
|
27
|
+
`group_served` event with `role:'member'` is emitted per element; receipts are minted for its rows
|
|
28
|
+
only. Two implementations reading "the group members" differently would authorize different
|
|
29
|
+
`absorb` sets on the same store, which is why it is one set with one name.
|
|
30
|
+
|
|
31
|
+
**The embedding happens inside the transaction, and that is forced rather than chosen.** The
|
|
32
|
+
candidate query's text is the served set's gists, and the served set is not known until step 1 has
|
|
33
|
+
run and written its vacatings — so there is no text to embed before the transaction opens, the way
|
|
34
|
+
the read path and the write path both manage. Splitting the call in two was the alternative and it
|
|
35
|
+
is worse: the served set would be free to move between the decision and the delivery, which is
|
|
36
|
+
exactly what "runs in one transaction" exists to prevent. The cost is that SQLite's write lock is
|
|
37
|
+
held across one embedding call; consolidation is manually invoked, rare, and off every
|
|
38
|
+
latency-critical path, and the call runs through `asyncio.to_thread` so at least the event loop is
|
|
39
|
+
not also blocked.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from collections.abc import Mapping, Sequence
|
|
43
|
+
from dataclasses import dataclass
|
|
44
|
+
from typing import Final
|
|
45
|
+
|
|
46
|
+
import aiosqlite
|
|
47
|
+
|
|
48
|
+
from zikaron.core.consolidation import candidates, groups, planning, rowstate, runs
|
|
49
|
+
from zikaron.core.consolidation.context import ConsolidationCall
|
|
50
|
+
from zikaron.core.consolidation.groups import (
|
|
51
|
+
AuthorizedRecord,
|
|
52
|
+
CandidateRole,
|
|
53
|
+
Disposition,
|
|
54
|
+
Group,
|
|
55
|
+
GroupStatus,
|
|
56
|
+
)
|
|
57
|
+
from zikaron.core.consolidation.payload import (
|
|
58
|
+
Busy,
|
|
59
|
+
GroupRecord,
|
|
60
|
+
NextGroupOutcome,
|
|
61
|
+
RankedRecord,
|
|
62
|
+
RunDone,
|
|
63
|
+
ServedGroup,
|
|
64
|
+
)
|
|
65
|
+
from zikaron.core.consolidation.runs import Run, RunStatus
|
|
66
|
+
from zikaron.core.errors import ErrorCode, IndexStage, ZikaronError
|
|
67
|
+
from zikaron.core.events import GroupRef, GroupServedDetail, ServeRole
|
|
68
|
+
from zikaron.core.records import memory as records
|
|
69
|
+
from zikaron.core.records import receipts
|
|
70
|
+
from zikaron.core.records.memory import log_event
|
|
71
|
+
from zikaron.core.records.receipts import ReceiptKey, ReceiptSource
|
|
72
|
+
from zikaron.core.store import transactions
|
|
73
|
+
|
|
74
|
+
#: The anchor's `rank` in the persisted authorization set. 0 rather than 1 so the anchor is
|
|
75
|
+
#: distinguishable from the first candidate by rank alone, which is what the schema's own column
|
|
76
|
+
#: comment states ("0 for the anchor; 1..N for candidates").
|
|
77
|
+
_ANCHOR_RANK: Final = 0
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True, slots=True)
|
|
81
|
+
class _Revalidated:
|
|
82
|
+
"""Step 1's output: what this serve would deliver, and what the anchor turned out to be.
|
|
83
|
+
|
|
84
|
+
`all_member_uuids` is the frozen plan-time universe, carried out of step 1 because step 4 needs
|
|
85
|
+
it to exclude every member from the candidate results and re-reading the member table for it
|
|
86
|
+
would be a second read of a set this pass has already loaded.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
served: tuple[GroupRecord, ...]
|
|
90
|
+
anchor: GroupRecord | None
|
|
91
|
+
anchor_vacated: bool
|
|
92
|
+
all_member_uuids: frozenset[str]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True, slots=True)
|
|
96
|
+
class _Serve:
|
|
97
|
+
"""What one delivery is about: the run, the group, and the single instant it happened at.
|
|
98
|
+
|
|
99
|
+
One value rather than three parameters threaded through five functions, and the instant belongs
|
|
100
|
+
with them rather than beside them: a serve's `served_at`, its members' `disposed_at` and its
|
|
101
|
+
lease refresh all describe one event, so reading the clock once and passing it as part of the
|
|
102
|
+
subject is what keeps them from drifting apart by a microsecond nobody would notice.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
run: Run
|
|
106
|
+
group: Group
|
|
107
|
+
at: str
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def reference(self) -> GroupRef:
|
|
111
|
+
"""This delivery's `(group_id, run_id)` pair, as every event of it carries them."""
|
|
112
|
+
return GroupRef(group_id=self.group.group_id, run_id=self.run.run_id)
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def serve_count(self) -> int:
|
|
116
|
+
"""This delivery's own count, **including** itself.
|
|
117
|
+
|
|
118
|
+
A `pending` group's first delivery is therefore 1, and at the default `max_group_serves =
|
|
119
|
+
3` a group is delivered at most three times per run. The other reading — first delivery
|
|
120
|
+
leaves the counter at 0 — would permit four from the same configuration.
|
|
121
|
+
"""
|
|
122
|
+
return self.group.serve_count + 1
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass(frozen=True, slots=True)
|
|
126
|
+
class _Delivered:
|
|
127
|
+
"""The rows one serve delivers, in payload order: the anchor, the members, the candidates.
|
|
128
|
+
|
|
129
|
+
Exists so that "what was delivered" is computed once and then used by all four things that need
|
|
130
|
+
the same answer — the receipts, the events, the authorization set and the payload. Three of
|
|
131
|
+
those four had a different reason to build the list, which is three chances for one of them to
|
|
132
|
+
include a row the others did not.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
revalidated: _Revalidated
|
|
136
|
+
candidates: tuple[RankedRecord, ...]
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def roles(self) -> tuple[tuple[ServeRole, GroupRecord], ...]:
|
|
140
|
+
"""Every delivered row with the role it played, in payload order."""
|
|
141
|
+
anchor = self.revalidated.anchor
|
|
142
|
+
return (
|
|
143
|
+
*(() if anchor is None else ((ServeRole.ANCHOR, anchor),)),
|
|
144
|
+
*((ServeRole.MEMBER, record) for record in self.revalidated.served),
|
|
145
|
+
*((ServeRole.CANDIDATE, ranked.record) for ranked in self.candidates),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def records(self) -> tuple[GroupRecord, ...]:
|
|
150
|
+
"""Every delivered row, in payload order, without its role."""
|
|
151
|
+
return tuple(record for _, record in self.roles)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
async def _revalidate(db: aiosqlite.Connection, *, group: Group, at: str) -> _Revalidated:
|
|
155
|
+
"""Re-read every open member and the anchor, marking whatever has left as vacated or dropped.
|
|
156
|
+
|
|
157
|
+
Writes the `vacated` dispositions, because that is where vacating is recorded: durably, in
|
|
158
|
+
`consolidation_group_member`, and deliberately **not** in the event log — a vacated member
|
|
159
|
+
emits no `group_served`, which is precisely what lets that event's `version_served` be non-null
|
|
160
|
+
and mean what it says.
|
|
161
|
+
|
|
162
|
+
The anchor is dropped from the payload but its `anchor_uuid` is left alone: that column is the
|
|
163
|
+
plan's own record, `anchor_vacated` is a fact about *this* serve, and the authorization set is
|
|
164
|
+
rewritten every serve anyway, so a dropped anchor simply stops being authorized.
|
|
165
|
+
"""
|
|
166
|
+
vacated: list[str] = []
|
|
167
|
+
served: list[GroupRecord] = []
|
|
168
|
+
all_members = await groups.members(db, group.group_id)
|
|
169
|
+
for member in all_members:
|
|
170
|
+
if member.disposition is not None:
|
|
171
|
+
continue
|
|
172
|
+
row = await records.load(db, member.memory_uuid)
|
|
173
|
+
if row is None or not await rowstate.is_deliverable_member(db, member.memory_uuid):
|
|
174
|
+
vacated.append(member.memory_uuid)
|
|
175
|
+
continue
|
|
176
|
+
served.append(GroupRecord.of(row))
|
|
177
|
+
await groups.disposition_members(
|
|
178
|
+
db,
|
|
179
|
+
group_id=group.group_id,
|
|
180
|
+
memory_uuids=vacated,
|
|
181
|
+
disposition=Disposition.VACATED,
|
|
182
|
+
at=at,
|
|
183
|
+
)
|
|
184
|
+
return _Revalidated(
|
|
185
|
+
served=tuple(served),
|
|
186
|
+
anchor=await _anchor_record(db, anchor_uuid=group.anchor_uuid),
|
|
187
|
+
anchor_vacated=await _anchor_vacated(db, anchor_uuid=group.anchor_uuid),
|
|
188
|
+
all_member_uuids=frozenset(member.memory_uuid for member in all_members),
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
async def _anchor_record(
|
|
193
|
+
db: aiosqlite.Connection, *, anchor_uuid: str | None
|
|
194
|
+
) -> GroupRecord | None:
|
|
195
|
+
"""The planned anchor as the payload carries it, or `None` if there is none to deliver.
|
|
196
|
+
|
|
197
|
+
`None` covers two situations the payload distinguishes by `anchor_vacated`: an orphan group,
|
|
198
|
+
which never had an anchor, and a planned anchor that stopped being targetable before this serve.
|
|
199
|
+
"""
|
|
200
|
+
if anchor_uuid is None or not await rowstate.is_targetable(db, anchor_uuid):
|
|
201
|
+
return None
|
|
202
|
+
row = await records.load(db, anchor_uuid)
|
|
203
|
+
return None if row is None else GroupRecord.of(row)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
async def _anchor_vacated(db: aiosqlite.Connection, *, anchor_uuid: str | None) -> bool:
|
|
207
|
+
"""Whether a group that *was* planned with an anchor is being served without it."""
|
|
208
|
+
return anchor_uuid is not None and not await rowstate.is_targetable(db, anchor_uuid)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
async def _mint_receipts(
|
|
212
|
+
db: aiosqlite.Connection, *, delivered: _Delivered, call: ConsolidationCall, at: str
|
|
213
|
+
) -> None:
|
|
214
|
+
"""Mint one `group` receipt per delivered row, at the version whose prose was delivered.
|
|
215
|
+
|
|
216
|
+
This is how the consolidator satisfies D26 without being given `fetch`: the payload delivers
|
|
217
|
+
full content, so the payload is what mints the licence to write it. Minting is an idempotent
|
|
218
|
+
upsert, which is required rather than cosmetic — a re-served group delivers the same rows at
|
|
219
|
+
the same versions to the same session, and a plain insert would violate the primary key.
|
|
220
|
+
"""
|
|
221
|
+
for record in delivered.records:
|
|
222
|
+
await receipts.mint(
|
|
223
|
+
db,
|
|
224
|
+
key=ReceiptKey(
|
|
225
|
+
session_id=call.ctx.session_id,
|
|
226
|
+
client_kind=call.ctx.client_kind,
|
|
227
|
+
memory_uuid=record.uuid,
|
|
228
|
+
version=record.expected_version,
|
|
229
|
+
),
|
|
230
|
+
at=at,
|
|
231
|
+
source=ReceiptSource.GROUP,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
async def _log_delivery(
|
|
236
|
+
db: aiosqlite.Connection, *, serve: _Serve, delivered: _Delivered, call: ConsolidationCall
|
|
237
|
+
) -> None:
|
|
238
|
+
"""One `group_served` event per row whose prose this serve actually delivered.
|
|
239
|
+
|
|
240
|
+
In payload order — anchor, then members, then candidates — so the log reads in the same order
|
|
241
|
+
the consolidator saw. A serve-time-vacated member emits none, and a vacated anchor emits none,
|
|
242
|
+
because neither had any prose delivered; that is what keeps `version_served` non-null on every
|
|
243
|
+
row of this kind.
|
|
244
|
+
"""
|
|
245
|
+
for role, record in delivered.roles:
|
|
246
|
+
await log_event(
|
|
247
|
+
db,
|
|
248
|
+
ctx=call.ctx,
|
|
249
|
+
detail=GroupServedDetail(
|
|
250
|
+
group=serve.reference,
|
|
251
|
+
role=role,
|
|
252
|
+
version_served=record.expected_version,
|
|
253
|
+
serve_count=serve.serve_count,
|
|
254
|
+
),
|
|
255
|
+
memory_uuid=record.uuid,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _authorization(delivered: _Delivered) -> tuple[AuthorizedRecord, ...]:
|
|
260
|
+
"""The exact set a later `merge` may target, as rows for `consolidation_group_candidate`."""
|
|
261
|
+
anchor = delivered.revalidated.anchor
|
|
262
|
+
return (
|
|
263
|
+
*(
|
|
264
|
+
()
|
|
265
|
+
if anchor is None
|
|
266
|
+
else (
|
|
267
|
+
AuthorizedRecord(
|
|
268
|
+
memory_uuid=anchor.uuid,
|
|
269
|
+
role=CandidateRole.ANCHOR,
|
|
270
|
+
version_served=anchor.expected_version,
|
|
271
|
+
rank=_ANCHOR_RANK,
|
|
272
|
+
),
|
|
273
|
+
)
|
|
274
|
+
),
|
|
275
|
+
*(
|
|
276
|
+
AuthorizedRecord(
|
|
277
|
+
memory_uuid=ranked.record.uuid,
|
|
278
|
+
role=CandidateRole.CANDIDATE,
|
|
279
|
+
version_served=ranked.record.expected_version,
|
|
280
|
+
rank=ranked.rank,
|
|
281
|
+
)
|
|
282
|
+
for ranked in delivered.candidates
|
|
283
|
+
),
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _versions_served(delivered: Sequence[GroupRecord]) -> Mapping[str, int]:
|
|
288
|
+
return {record.uuid: record.expected_version for record in delivered}
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
async def _serve(
|
|
292
|
+
db: aiosqlite.Connection,
|
|
293
|
+
*,
|
|
294
|
+
serve: _Serve,
|
|
295
|
+
revalidated: _Revalidated,
|
|
296
|
+
call: ConsolidationCall,
|
|
297
|
+
) -> ServedGroup:
|
|
298
|
+
"""Deliver one group: recompute candidates, persist authorization, mint receipts, log, count.
|
|
299
|
+
|
|
300
|
+
The lease is refreshed here because a delivery is progress. It is deliberately not refreshed on
|
|
301
|
+
the vacating or the deferral path, since neither delivers anything.
|
|
302
|
+
"""
|
|
303
|
+
group_query = await candidates.build_group_query(revalidated.served, call=call)
|
|
304
|
+
anchor = revalidated.anchor
|
|
305
|
+
excluded = revalidated.all_member_uuids | (
|
|
306
|
+
frozenset() if anchor is None else frozenset({anchor.uuid})
|
|
307
|
+
)
|
|
308
|
+
delivered = _Delivered(
|
|
309
|
+
revalidated=revalidated,
|
|
310
|
+
candidates=await candidates.select(
|
|
311
|
+
db, group_query=group_query, excluded=excluded, call=call
|
|
312
|
+
),
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
await groups.record_delivery(
|
|
316
|
+
db, group_id=serve.group.group_id, serve_count=serve.serve_count, served_at=serve.at
|
|
317
|
+
)
|
|
318
|
+
await groups.record_versions_served(
|
|
319
|
+
db, group_id=serve.group.group_id, versions=_versions_served(revalidated.served)
|
|
320
|
+
)
|
|
321
|
+
await groups.replace_authorization(
|
|
322
|
+
db, group_id=serve.group.group_id, records=_authorization(delivered)
|
|
323
|
+
)
|
|
324
|
+
await _mint_receipts(db, delivered=delivered, call=call, at=serve.at)
|
|
325
|
+
await _log_delivery(db, serve=serve, delivered=delivered, call=call)
|
|
326
|
+
await runs.refresh_lease(
|
|
327
|
+
db, run_id=serve.run.run_id, at=serve.at, lease_seconds=call.settings.run_lease_seconds
|
|
328
|
+
)
|
|
329
|
+
return ServedGroup(
|
|
330
|
+
group_id=serve.group.group_id,
|
|
331
|
+
run_id=serve.run.run_id,
|
|
332
|
+
anchor=anchor,
|
|
333
|
+
anchor_vacated=revalidated.anchor_vacated,
|
|
334
|
+
journal_entries=revalidated.served,
|
|
335
|
+
candidates=delivered.candidates,
|
|
336
|
+
shard=serve.group.shard,
|
|
337
|
+
serve_count=serve.serve_count,
|
|
338
|
+
n_gists_used=group_query.n_gists_used,
|
|
339
|
+
remaining_groups=await groups.open_group_count(
|
|
340
|
+
db, serve.run.run_id, excluding=serve.group.group_id
|
|
341
|
+
),
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
async def _run_for(db: aiosqlite.Connection, *, call: ConsolidationCall, at: str) -> Run | Busy:
|
|
346
|
+
"""The run this call will serve from — replanning, or refusing, exactly as the design requires.
|
|
347
|
+
|
|
348
|
+
An **effectively-expired** run is treated as absent for every caller *including its owner*,
|
|
349
|
+
which is the fix for a real dead end: `next_group` replans only when the caller has no active
|
|
350
|
+
run — so without this rule an owner whose lease lapsed would find its own run, be served a group
|
|
351
|
+
from it, and be rejected `group_expired` with no way out, since `plan_groups` is a service RPC
|
|
352
|
+
and not one of the four consolidator tools. So crash takeover by a stranger and lease recovery
|
|
353
|
+
by the owner are the same code path.
|
|
354
|
+
|
|
355
|
+
Only an `active` **and unexpired** run belonging to a different `(session_id, pid)` owner yields
|
|
356
|
+
`Busy`. The pair, never the session alone: two consolidators of one kiro session share a
|
|
357
|
+
`session_id` and a `client_kind`, so a session-only test would let a second worker serve a group
|
|
358
|
+
the first still holds.
|
|
359
|
+
|
|
360
|
+
**This path never displaces a live worker, and that asymmetry with `plan_groups` is the point.**
|
|
361
|
+
`next_group` is the verb a *model* drives, and a model must not be able to decide that another
|
|
362
|
+
worker has stopped. An explicit `plan_groups` may take an unexpired run over, because the
|
|
363
|
+
evidence for doing so is a human invoking the skill again and D32 keeps that RPC out of both
|
|
364
|
+
tool sets.
|
|
365
|
+
"""
|
|
366
|
+
existing = await runs.stored_active(db)
|
|
367
|
+
if existing is None or existing.has_lapsed(at=at):
|
|
368
|
+
return await planning.plan_within_transaction(db, call=call, at=at)
|
|
369
|
+
if existing.owner != call.owner:
|
|
370
|
+
return Busy(
|
|
371
|
+
holder_session=existing.owner.session_id,
|
|
372
|
+
holder_pid=existing.owner.pid,
|
|
373
|
+
expires_at=existing.expires_at,
|
|
374
|
+
)
|
|
375
|
+
return existing
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
async def next_group_within_transaction(
|
|
379
|
+
db: aiosqlite.Connection, *, call: ConsolidationCall
|
|
380
|
+
) -> NextGroupOutcome:
|
|
381
|
+
"""`next_group`'s work, assuming the caller already holds an open transaction.
|
|
382
|
+
|
|
383
|
+
The loop is bounded by the run's own group count, which is fixed at plan time: every iteration
|
|
384
|
+
either returns a served group or transitions its candidate out of `pending`/`served`, so after
|
|
385
|
+
that many iterations there is nothing left to consider and falling through is the correct
|
|
386
|
+
conclusion rather than a give-up.
|
|
387
|
+
"""
|
|
388
|
+
at = runs.now()
|
|
389
|
+
resolved = await _run_for(db, call=call, at=at)
|
|
390
|
+
if isinstance(resolved, Busy):
|
|
391
|
+
return resolved
|
|
392
|
+
run = resolved
|
|
393
|
+
for _ in range((await groups.run_counts(db, run.run_id)).n_groups):
|
|
394
|
+
candidate = await groups.next_candidate(db, run.run_id)
|
|
395
|
+
if candidate is None:
|
|
396
|
+
break
|
|
397
|
+
revalidated = await _revalidate(db, group=candidate, at=at)
|
|
398
|
+
if not revalidated.served:
|
|
399
|
+
await groups.set_status(
|
|
400
|
+
db,
|
|
401
|
+
group_id=candidate.group_id,
|
|
402
|
+
status=GroupStatus.COMPLETE,
|
|
403
|
+
expected=candidate.status,
|
|
404
|
+
)
|
|
405
|
+
await runs.close_if_finished(db, run_id=run.run_id, ctx=call.ctx)
|
|
406
|
+
continue
|
|
407
|
+
if candidate.serve_count >= call.settings.max_group_serves:
|
|
408
|
+
await groups.set_status(
|
|
409
|
+
db,
|
|
410
|
+
group_id=candidate.group_id,
|
|
411
|
+
status=GroupStatus.DEFERRED,
|
|
412
|
+
expected=candidate.status,
|
|
413
|
+
)
|
|
414
|
+
continue
|
|
415
|
+
return await _serve(
|
|
416
|
+
db,
|
|
417
|
+
serve=_Serve(run=run, group=candidate, at=at),
|
|
418
|
+
revalidated=revalidated,
|
|
419
|
+
call=call,
|
|
420
|
+
)
|
|
421
|
+
await runs.close(db, run_id=run.run_id, status=RunStatus.COMPLETE, ctx=call.ctx)
|
|
422
|
+
return RunDone()
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
async def next_group(db: aiosqlite.Connection, *, call: ConsolidationCall) -> NextGroupOutcome:
|
|
426
|
+
"""Deliver the next consolidation group, planning a run implicitly if the store has none.
|
|
427
|
+
|
|
428
|
+
One transaction covering re-validation, every status transition it causes, the candidate
|
|
429
|
+
recomputation and its embedding, the authorization set, the receipts and the events. The
|
|
430
|
+
instrumentation and the state it describes therefore commit together or not at all, which is
|
|
431
|
+
invariant 10 for this verb.
|
|
432
|
+
|
|
433
|
+
Returns:
|
|
434
|
+
A `ServedGroup`, or `RunDone` when this run has nothing left to serve, or `Busy` when an
|
|
435
|
+
effectively-active run belongs to a different worker. All three are answers; none is an
|
|
436
|
+
error.
|
|
437
|
+
|
|
438
|
+
Raises:
|
|
439
|
+
ZikaronError: `STORE_BUSY` if the store was locked, which the caller may retry, or
|
|
440
|
+
`INDEX_FAILED` for any other driver failure. Nothing is left behind in either case — a
|
|
441
|
+
failed serve delivers no group and records no delivery, so the next call reconsiders the
|
|
442
|
+
same group from the same state.
|
|
443
|
+
"""
|
|
444
|
+
|
|
445
|
+
async def work(connection: aiosqlite.Connection) -> NextGroupOutcome:
|
|
446
|
+
return await next_group_within_transaction(connection, call=call)
|
|
447
|
+
|
|
448
|
+
return await transactions.in_one_transaction(db, work, failure=_failure_map("next_group"))
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _failure_map(verb: str) -> transactions.FailureMap:
|
|
452
|
+
"""Contention is `store_busy`, everything else `index_failed` — a serve is a write.
|
|
453
|
+
|
|
454
|
+
It writes dispositions, statuses, an authorization set, receipts and events, and it fails the
|
|
455
|
+
way a write fails, so `architecture.md` §Errors gives it a write's two codes rather than a
|
|
456
|
+
read's one.
|
|
457
|
+
"""
|
|
458
|
+
return lambda error: (
|
|
459
|
+
ZikaronError(ErrorCode.STORE_BUSY, verb=verb)
|
|
460
|
+
if transactions.is_contention(error)
|
|
461
|
+
else ZikaronError(ErrorCode.INDEX_FAILED, stage=IndexStage.INDEX_WRITE)
|
|
462
|
+
)
|