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.
Files changed (162) hide show
  1. zikaron/__init__.py +1 -0
  2. zikaron/cli/__init__.py +1 -0
  3. zikaron/cli/main.py +114 -0
  4. zikaron/core/__init__.py +1 -0
  5. zikaron/core/clock.py +78 -0
  6. zikaron/core/config/__init__.py +1 -0
  7. zikaron/core/config/keys.py +395 -0
  8. zikaron/core/config/resolution.py +267 -0
  9. zikaron/core/consolidation/__init__.py +1 -0
  10. zikaron/core/consolidation/authorization.py +316 -0
  11. zikaron/core/consolidation/candidates.py +147 -0
  12. zikaron/core/consolidation/context.py +166 -0
  13. zikaron/core/consolidation/grouping.py +383 -0
  14. zikaron/core/consolidation/groups.py +490 -0
  15. zikaron/core/consolidation/payload.py +246 -0
  16. zikaron/core/consolidation/planning.py +192 -0
  17. zikaron/core/consolidation/rowstate.py +68 -0
  18. zikaron/core/consolidation/runs.py +306 -0
  19. zikaron/core/consolidation/serving.py +462 -0
  20. zikaron/core/consolidation/verbs.py +500 -0
  21. zikaron/core/errors.py +355 -0
  22. zikaron/core/events.py +748 -0
  23. zikaron/core/indexing/__init__.py +1 -0
  24. zikaron/core/indexing/acquisition.py +255 -0
  25. zikaron/core/indexing/chunking.py +368 -0
  26. zikaron/core/indexing/encoder.py +537 -0
  27. zikaron/core/indexing/lexical.py +86 -0
  28. zikaron/core/indexing/model_cache.py +93 -0
  29. zikaron/core/indexing/model_pin.py +89 -0
  30. zikaron/core/indexing/vectors.py +223 -0
  31. zikaron/core/indexing/writes.py +461 -0
  32. zikaron/core/knowledge/__init__.py +5 -0
  33. zikaron/core/knowledge/arms.py +104 -0
  34. zikaron/core/knowledge/builds.py +204 -0
  35. zikaron/core/knowledge/candidates.py +130 -0
  36. zikaron/core/knowledge/changes.py +175 -0
  37. zikaron/core/knowledge/chunking.py +376 -0
  38. zikaron/core/knowledge/counters.py +228 -0
  39. zikaron/core/knowledge/database.py +380 -0
  40. zikaron/core/knowledge/ddl.py +196 -0
  41. zikaron/core/knowledge/disposal.py +213 -0
  42. zikaron/core/knowledge/errors.py +166 -0
  43. zikaron/core/knowledge/files.py +202 -0
  44. zikaron/core/knowledge/git.py +385 -0
  45. zikaron/core/knowledge/groups.py +450 -0
  46. zikaron/core/knowledge/lexical.py +64 -0
  47. zikaron/core/knowledge/lifecycle.py +418 -0
  48. zikaron/core/knowledge/lock.py +277 -0
  49. zikaron/core/knowledge/meta.py +393 -0
  50. zikaron/core/knowledge/paths.py +55 -0
  51. zikaron/core/knowledge/pending.py +59 -0
  52. zikaron/core/knowledge/registry.py +264 -0
  53. zikaron/core/knowledge/repair.py +152 -0
  54. zikaron/core/knowledge/reporting.py +436 -0
  55. zikaron/core/knowledge/roots.py +91 -0
  56. zikaron/core/knowledge/scan.py +429 -0
  57. zikaron/core/knowledge/search.py +346 -0
  58. zikaron/core/knowledge/state.py +174 -0
  59. zikaron/core/knowledge/text.py +166 -0
  60. zikaron/core/knowledge/vectors.py +102 -0
  61. zikaron/core/knowledge/walk.py +264 -0
  62. zikaron/core/knowledge/writes.py +127 -0
  63. zikaron/core/records/__init__.py +1 -0
  64. zikaron/core/records/memory.py +961 -0
  65. zikaron/core/records/receipts.py +161 -0
  66. zikaron/core/records/supersession.py +221 -0
  67. zikaron/core/retrieval/__init__.py +1 -0
  68. zikaron/core/retrieval/arms.py +318 -0
  69. zikaron/core/retrieval/block.py +107 -0
  70. zikaron/core/retrieval/eligibility.py +164 -0
  71. zikaron/core/retrieval/query.py +327 -0
  72. zikaron/core/retrieval/ranking.py +260 -0
  73. zikaron/core/retrieval/reads.py +294 -0
  74. zikaron/core/retrieval/retrieve.py +158 -0
  75. zikaron/core/retrieval/similarity.py +87 -0
  76. zikaron/core/signals/__init__.py +34 -0
  77. zikaron/core/signals/contention.py +106 -0
  78. zikaron/core/signals/dedup.py +201 -0
  79. zikaron/core/signals/horizon.py +47 -0
  80. zikaron/core/signals/repair.py +161 -0
  81. zikaron/core/signals/retirement.py +83 -0
  82. zikaron/core/signals/sessions.py +105 -0
  83. zikaron/core/signals/writes.py +200 -0
  84. zikaron/core/store/__init__.py +1 -0
  85. zikaron/core/store/connection.py +202 -0
  86. zikaron/core/store/ddl.py +215 -0
  87. zikaron/core/store/embedder.py +45 -0
  88. zikaron/core/store/meta.py +152 -0
  89. zikaron/core/store/permissions.py +160 -0
  90. zikaron/core/store/store.py +408 -0
  91. zikaron/core/store/transactions.py +181 -0
  92. zikaron/core/write/__init__.py +33 -0
  93. zikaron/core/write/dedup.py +145 -0
  94. zikaron/core/write/tools.py +290 -0
  95. zikaron/doctor/__init__.py +1 -0
  96. zikaron/doctor/checks.py +220 -0
  97. zikaron/doctor/main.py +64 -0
  98. zikaron/harness/__init__.py +1 -0
  99. zikaron/harness/detect.py +92 -0
  100. zikaron/harness/spec.py +320 -0
  101. zikaron/hook/__init__.py +1 -0
  102. zikaron/hook/connect.py +379 -0
  103. zikaron/hook/envelope.py +106 -0
  104. zikaron/hook/failure.py +104 -0
  105. zikaron/hook/limits.py +61 -0
  106. zikaron/hook/main.py +118 -0
  107. zikaron/hook/push.py +183 -0
  108. zikaron/hook/rpc.py +85 -0
  109. zikaron/hook/spawn_warm.py +81 -0
  110. zikaron/hook/subagent_policy.py +57 -0
  111. zikaron/hook/tripwire.py +54 -0
  112. zikaron/hook/warm_helper.py +137 -0
  113. zikaron/hook/write_policy.py +319 -0
  114. zikaron/install/__init__.py +4 -0
  115. zikaron/install/__main__.py +18 -0
  116. zikaron/install/assets.py +394 -0
  117. zikaron/install/entries.py +370 -0
  118. zikaron/install/harness.py +185 -0
  119. zikaron/install/main.py +375 -0
  120. zikaron/install/targets.py +789 -0
  121. zikaron/install/writer.py +973 -0
  122. zikaron/knowledge/__init__.py +1 -0
  123. zikaron/knowledge/__main__.py +17 -0
  124. zikaron/knowledge/indexer/__init__.py +1 -0
  125. zikaron/knowledge/indexer/__main__.py +17 -0
  126. zikaron/knowledge/indexer/detach.py +83 -0
  127. zikaron/knowledge/indexer/main.py +187 -0
  128. zikaron/knowledge/main.py +466 -0
  129. zikaron/knowledge/scope.py +133 -0
  130. zikaron/mcp/__init__.py +6 -0
  131. zikaron/mcp/connection.py +583 -0
  132. zikaron/mcp/consolidator.py +316 -0
  133. zikaron/mcp/errors.py +73 -0
  134. zikaron/mcp/main.py +66 -0
  135. zikaron/mcp/primary.py +420 -0
  136. zikaron/mcp/server.py +96 -0
  137. zikaron/mcp/spill.py +328 -0
  138. zikaron/mcp/tool_names.py +67 -0
  139. zikaron/py.typed +0 -0
  140. zikaron/service/__init__.py +1 -0
  141. zikaron/service/asyncio_compat.py +126 -0
  142. zikaron/service/context.py +251 -0
  143. zikaron/service/dispatch.py +332 -0
  144. zikaron/service/dispatch_consolidation.py +397 -0
  145. zikaron/service/dispatch_knowledge.py +469 -0
  146. zikaron/service/envelope.py +166 -0
  147. zikaron/service/lifecycle.py +467 -0
  148. zikaron/service/log.py +96 -0
  149. zikaron/service/main.py +531 -0
  150. zikaron/service/params.py +168 -0
  151. zikaron/service/paths.py +181 -0
  152. zikaron/service/rpc.py +176 -0
  153. zikaron/service/security.py +156 -0
  154. zikaron/service/serialize.py +204 -0
  155. zikaron/service/serialize_knowledge.py +238 -0
  156. zikaron/service/server.py +416 -0
  157. zikaron-0.1.0.dist-info/METADATA +770 -0
  158. zikaron-0.1.0.dist-info/RECORD +162 -0
  159. zikaron-0.1.0.dist-info/WHEEL +5 -0
  160. zikaron-0.1.0.dist-info/entry_points.txt +4 -0
  161. zikaron-0.1.0.dist-info/licenses/LICENSE +21 -0
  162. 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
+ )