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,306 @@
|
|
|
1
|
+
"""The `consolidation_run` aggregate: one effectively-active run per store, and its lease.
|
|
2
|
+
|
|
3
|
+
`schema.md` invariants 15 and 17 and `architecture.md` §"Consolidation lifecycle" are normative. Two
|
|
4
|
+
rules run through everything here:
|
|
5
|
+
|
|
6
|
+
**"Effectively active" is the only run test anywhere in the system** — `status='active' AND
|
|
7
|
+
expires_at ≥ now`. A stored `'active'` row past its lease constrains nobody, *including the session
|
|
8
|
+
that owns it*, and that second half is not a nicety: `next_group` replans only when the caller has
|
|
9
|
+
no active run, so an owner whose lease lapsed would otherwise find its own run, be served a group
|
|
10
|
+
from it, and be rejected `group_expired` forever — with no way out, since `plan_groups` is a service
|
|
11
|
+
RPC and not one of the consolidator's four tools.
|
|
12
|
+
|
|
13
|
+
**Expiry is derived on the read side and stored only at plan time.** Every reader computes it from
|
|
14
|
+
`expires_at`; only `plan_groups` writes `status='expired'`. The alternative — having whichever call
|
|
15
|
+
noticed a lapsed lease perform the transition — would make a *rejected* call write
|
|
16
|
+
`consolidation_run.status`, which `architecture.md` §"What a rejected call does and does not change"
|
|
17
|
+
forbids. The observable consequence is that a store can hold an `active` row whose lease has passed;
|
|
18
|
+
that is a lazily-collected tombstone, not drift, because every reader already computes the same
|
|
19
|
+
answer from the column beside it.
|
|
20
|
+
|
|
21
|
+
**The lease arithmetic lives here, with the lease.** It **parses** rather than comparing strings —
|
|
22
|
+
not because string order is untrustworthy (`core.clock` states and pins the opposite: lexicographic
|
|
23
|
+
order on these strings agrees with temporal order) but because a lease is a start plus a duration,
|
|
24
|
+
and adding seconds is not something ordering can do for you. Parsing then makes the comparison that
|
|
25
|
+
follows independent of the format as well, which is the cheaper half of a step taken for another
|
|
26
|
+
reason. Every timestamp this module stores is either a `core.clock.timestamp` reading or
|
|
27
|
+
`lease_expiry`'s parse-add-`isoformat()` of one, and that derivation preserves the format — so
|
|
28
|
+
there is still one.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from collections.abc import Sequence
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from datetime import datetime, timedelta
|
|
34
|
+
from enum import StrEnum
|
|
35
|
+
from typing import Final
|
|
36
|
+
from uuid import uuid4
|
|
37
|
+
|
|
38
|
+
import aiosqlite
|
|
39
|
+
|
|
40
|
+
from zikaron.core.clock import timestamp
|
|
41
|
+
from zikaron.core.consolidation import groups
|
|
42
|
+
from zikaron.core.consolidation.context import RunOwner
|
|
43
|
+
from zikaron.core.consolidation.groups import RunCounts
|
|
44
|
+
from zikaron.core.events import ConsolidateRunDetail, RunPhase
|
|
45
|
+
from zikaron.core.records.memory import CallParams, log_event
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RunStatus(StrEnum):
|
|
49
|
+
"""`consolidation_run.status`'s five values, exactly as `schema.md`'s `CHECK` states them.
|
|
50
|
+
|
|
51
|
+
`ABANDONED` and `TAKEN_OVER` are one producer discriminated by one test — an explicit
|
|
52
|
+
`plan_groups` closing an unexpired run, called by its owner or by somebody else. Both are
|
|
53
|
+
recorded because a takeover is a supported operation with a real cost (the displaced worker's
|
|
54
|
+
work in progress), and because in the likeliest case nothing else distinguishes them: a user
|
|
55
|
+
retrying the skill in one kiro session presents the same `session_id` and only a different pid.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
ACTIVE = "active"
|
|
59
|
+
COMPLETE = "complete"
|
|
60
|
+
EXPIRED = "expired"
|
|
61
|
+
ABANDONED = "abandoned"
|
|
62
|
+
TAKEN_OVER = "taken_over"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
#: `RunStatus` → the `consolidate_run` phase recording the transition into it. `ACTIVE` is absent
|
|
66
|
+
#: because its phase is named differently — a run is `planned`, and then it *is* active — the one
|
|
67
|
+
#: place the two vocabularies do not coincide, and so the one place a mapping is needed at all.
|
|
68
|
+
_CLOSING_PHASE: Final[dict[RunStatus, RunPhase]] = {
|
|
69
|
+
RunStatus.COMPLETE: RunPhase.COMPLETE,
|
|
70
|
+
RunStatus.EXPIRED: RunPhase.EXPIRED,
|
|
71
|
+
RunStatus.ABANDONED: RunPhase.ABANDONED,
|
|
72
|
+
RunStatus.TAKEN_OVER: RunPhase.TAKEN_OVER,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
_RUN_COLUMNS: Final = "run_id, session_id, pid, started_at, expires_at, status"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, slots=True)
|
|
79
|
+
class Run:
|
|
80
|
+
"""One `consolidation_run` row, exactly as stored.
|
|
81
|
+
|
|
82
|
+
`effective_status` is computed rather than stored, which is the whole content of the
|
|
83
|
+
derived-expiry rule: two readers of one row must reach the same conclusion about a lapsed lease
|
|
84
|
+
without either of them writing anything.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
run_id: str
|
|
88
|
+
owner: RunOwner
|
|
89
|
+
started_at: str
|
|
90
|
+
expires_at: str
|
|
91
|
+
status: RunStatus
|
|
92
|
+
|
|
93
|
+
def has_lapsed(self, *, at: str) -> bool:
|
|
94
|
+
"""Whether this run's lease has passed as of `at` — `expires_at < now`."""
|
|
95
|
+
return _parse(self.expires_at) < _parse(at)
|
|
96
|
+
|
|
97
|
+
def is_effectively_active(self, *, at: str) -> bool:
|
|
98
|
+
"""`status='active' AND expires_at ≥ now`: the only run test the design defines."""
|
|
99
|
+
return self.status is RunStatus.ACTIVE and not self.has_lapsed(at=at)
|
|
100
|
+
|
|
101
|
+
def effective_status(self, *, at: str) -> RunStatus:
|
|
102
|
+
"""The status a reader should act on: `EXPIRED` for a lapsed `ACTIVE` row, else the stored
|
|
103
|
+
one. Reported in `group_expired`'s payload beside the stored value, so a caller can tell a
|
|
104
|
+
lease that ran out from a run somebody replanned."""
|
|
105
|
+
if self.status is RunStatus.ACTIVE and self.has_lapsed(at=at):
|
|
106
|
+
return RunStatus.EXPIRED
|
|
107
|
+
return self.status
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _parse(stamp: str) -> datetime:
|
|
111
|
+
return datetime.fromisoformat(stamp)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def lease_expiry(started_at: str, *, seconds: int) -> str:
|
|
115
|
+
"""`started_at` plus `seconds`, in the store's own timestamp format.
|
|
116
|
+
|
|
117
|
+
Derived from the stored `started_at` rather than from a second clock read, so that
|
|
118
|
+
`expires_at` minus `started_at` is exactly `run_lease` on every row — which is what lets a test
|
|
119
|
+
assert the lease length rather than assert it approximately.
|
|
120
|
+
"""
|
|
121
|
+
return (_parse(started_at) + timedelta(seconds=seconds)).isoformat()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _to_run(row: Sequence[object]) -> Run:
|
|
125
|
+
run_id, session_id, pid, started_at, expires_at, status = row
|
|
126
|
+
return Run(
|
|
127
|
+
run_id=str(run_id),
|
|
128
|
+
owner=RunOwner(session_id=str(session_id), pid=int(str(pid))),
|
|
129
|
+
started_at=str(started_at),
|
|
130
|
+
expires_at=str(expires_at),
|
|
131
|
+
status=RunStatus(str(status)),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
async def load(db: aiosqlite.Connection, run_id: str) -> Run | None:
|
|
136
|
+
"""One run by id, or `None` if the store holds no such row."""
|
|
137
|
+
rows = await db.execute_fetchall(
|
|
138
|
+
f"SELECT {_RUN_COLUMNS} FROM consolidation_run WHERE run_id = ?", # noqa: S608 — a source-level column list; the id is bound.
|
|
139
|
+
(run_id,),
|
|
140
|
+
)
|
|
141
|
+
found = list(rows)
|
|
142
|
+
return None if not found else _to_run(tuple(found[0]))
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def stored_active(db: aiosqlite.Connection) -> Run | None:
|
|
146
|
+
"""The store's one `status='active'` run, whoever owns it and whether or not its lease holds.
|
|
147
|
+
|
|
148
|
+
Returns the **stored** row, deliberately, because two callers want different things from it: a
|
|
149
|
+
write verb needs to know whether the lease has lapsed in order to answer `group_expired`, and
|
|
150
|
+
`next_group` needs to know in order to replan. Deciding here would force one of them to
|
|
151
|
+
reconstruct what it was not told.
|
|
152
|
+
|
|
153
|
+
Raises:
|
|
154
|
+
ValueError: the store holds more than one `active` run, which invariant 15 forbids and only
|
|
155
|
+
a bug in this module can produce, since `plan_groups` closes every one of them before
|
|
156
|
+
creating another. Refused rather than resolved by picking the earliest: an
|
|
157
|
+
implementation that quietly served one of two active runs would keep a broken store
|
|
158
|
+
working while two consolidators mutated one journal, which is the precise failure the
|
|
159
|
+
invariant exists to prevent.
|
|
160
|
+
"""
|
|
161
|
+
rows = await db.execute_fetchall(
|
|
162
|
+
f"SELECT {_RUN_COLUMNS} FROM consolidation_run WHERE status = 'active'" # noqa: S608 — a source-level column list; no request value is interpolated.
|
|
163
|
+
)
|
|
164
|
+
found = list(rows)
|
|
165
|
+
if len(found) > 1:
|
|
166
|
+
ids = sorted(str(row[0]) for row in found)
|
|
167
|
+
raise ValueError(f"invariant 15: {len(found)} active consolidation runs at once: {ids}")
|
|
168
|
+
return None if not found else _to_run(tuple(found[0]))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async def log_phase(
|
|
172
|
+
db: aiosqlite.Connection,
|
|
173
|
+
*,
|
|
174
|
+
run_id: str,
|
|
175
|
+
phase: RunPhase,
|
|
176
|
+
run_counts: RunCounts,
|
|
177
|
+
ctx: CallParams,
|
|
178
|
+
) -> None:
|
|
179
|
+
"""Emit the `consolidate_run` event for one transition, in the caller's own transaction."""
|
|
180
|
+
await log_event(
|
|
181
|
+
db,
|
|
182
|
+
ctx=ctx,
|
|
183
|
+
detail=ConsolidateRunDetail(
|
|
184
|
+
run_id=run_id,
|
|
185
|
+
phase=phase,
|
|
186
|
+
n_groups=run_counts.n_groups,
|
|
187
|
+
n_members=run_counts.n_members,
|
|
188
|
+
n_deferred=run_counts.n_deferred,
|
|
189
|
+
),
|
|
190
|
+
memory_uuid=None,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
async def create(
|
|
195
|
+
db: aiosqlite.Connection, *, owner: RunOwner, started_at: str, lease_seconds: int
|
|
196
|
+
) -> Run:
|
|
197
|
+
"""Insert a fresh `active` run owned by `owner`, with its lease set from `started_at`.
|
|
198
|
+
|
|
199
|
+
Emits no event: the `planned` phase's counts describe the groups this run holds, which do not
|
|
200
|
+
exist until the planner has written them, so the event belongs to the planner rather than here.
|
|
201
|
+
Assumes the caller's open transaction, and assumes the caller has already closed any
|
|
202
|
+
pre-existing `active` run — invariant 15 is enforced by that ordering, and this function
|
|
203
|
+
deliberately does not do it silently, since which status the old run gets is the planner's
|
|
204
|
+
decision to make and to record.
|
|
205
|
+
"""
|
|
206
|
+
run = Run(
|
|
207
|
+
run_id=str(uuid4()),
|
|
208
|
+
owner=owner,
|
|
209
|
+
started_at=started_at,
|
|
210
|
+
expires_at=lease_expiry(started_at, seconds=lease_seconds),
|
|
211
|
+
status=RunStatus.ACTIVE,
|
|
212
|
+
)
|
|
213
|
+
await db.execute(
|
|
214
|
+
"INSERT INTO consolidation_run "
|
|
215
|
+
"(run_id, session_id, pid, started_at, expires_at, status) VALUES (?, ?, ?, ?, ?, ?)",
|
|
216
|
+
(
|
|
217
|
+
run.run_id,
|
|
218
|
+
run.owner.session_id,
|
|
219
|
+
run.owner.pid,
|
|
220
|
+
run.started_at,
|
|
221
|
+
run.expires_at,
|
|
222
|
+
run.status.value,
|
|
223
|
+
),
|
|
224
|
+
)
|
|
225
|
+
return run
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
async def close(
|
|
229
|
+
db: aiosqlite.Connection, *, run_id: str, status: RunStatus, ctx: CallParams
|
|
230
|
+
) -> bool:
|
|
231
|
+
"""Transition one `active` run to a terminal status, emitting its phase event if it moved.
|
|
232
|
+
|
|
233
|
+
A **guarded** update — `WHERE status='active'` — so it is idempotent under a retry and so two
|
|
234
|
+
paths that both notice a run is finished cannot emit two `complete` events for it. The event is
|
|
235
|
+
emitted only when the update actually changed a row, which is what makes the guard load-bearing
|
|
236
|
+
rather than decorative: `next_group`'s loop and the write verb that dispositions the last member
|
|
237
|
+
can both reach the same conclusion in the same transaction.
|
|
238
|
+
|
|
239
|
+
Counts are read **after** the transition, so a `complete` event's `n_deferred` reflects every
|
|
240
|
+
group this run ended up deferring.
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
Whether this call performed the transition.
|
|
244
|
+
|
|
245
|
+
Raises:
|
|
246
|
+
KeyError: `status` is `ACTIVE`, which is not a close. Raised rather than silently accepted
|
|
247
|
+
because the guarded update would then be a no-op that looks like an already-closed run.
|
|
248
|
+
"""
|
|
249
|
+
phase = _CLOSING_PHASE[status]
|
|
250
|
+
cursor = await db.execute(
|
|
251
|
+
"UPDATE consolidation_run SET status = ? WHERE run_id = ? AND status = 'active'",
|
|
252
|
+
(status.value, run_id),
|
|
253
|
+
)
|
|
254
|
+
if cursor.rowcount == 0:
|
|
255
|
+
return False
|
|
256
|
+
await log_phase(
|
|
257
|
+
db, run_id=run_id, phase=phase, run_counts=await groups.run_counts(db, run_id), ctx=ctx
|
|
258
|
+
)
|
|
259
|
+
return True
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
async def refresh_lease(
|
|
263
|
+
db: aiosqlite.Connection, *, run_id: str, at: str, lease_seconds: int
|
|
264
|
+
) -> None:
|
|
265
|
+
"""Push one `active` run's `expires_at` out to `at` plus the lease.
|
|
266
|
+
|
|
267
|
+
Guarded on `status='active'`, so a run closed under the caller cannot be revived by a
|
|
268
|
+
refresh. Called only on a path that made progress — a serve that delivered a group, or a write
|
|
269
|
+
verb that dispositioned a member — never on a rejection and never on a `{conflict: true}`
|
|
270
|
+
response, which mutates nothing and so must not buy the lease more time.
|
|
271
|
+
"""
|
|
272
|
+
await db.execute(
|
|
273
|
+
"UPDATE consolidation_run SET expires_at = ? WHERE run_id = ? AND status = 'active'",
|
|
274
|
+
(lease_expiry(at, seconds=lease_seconds), run_id),
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def now() -> str:
|
|
279
|
+
"""The current instant in the store's own timestamp format.
|
|
280
|
+
|
|
281
|
+
Re-exported from the shared clock so every module in this package reads one clock, and reads it
|
|
282
|
+
by a name that does not invite a second implementation. One call per transaction is the intent:
|
|
283
|
+
a serve's `served_at`, its members' `disposed_at` and its lease refresh should all name the same
|
|
284
|
+
instant, because they describe one event.
|
|
285
|
+
"""
|
|
286
|
+
return timestamp()
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
async def close_if_finished(db: aiosqlite.Connection, *, run_id: str, ctx: CallParams) -> bool:
|
|
290
|
+
"""Close one run `complete` if no group of it is `pending` or `served` any more.
|
|
291
|
+
|
|
292
|
+
Invariant 17 gives the run's `active → complete` transition exactly one condition and several
|
|
293
|
+
transactions that can observe it: the write verb that dispositions the last member of the last
|
|
294
|
+
open group, and the serve transaction, either when re-validation vacates a group empty or when
|
|
295
|
+
the loop finds nothing servable left. So this is a question every one of them asks, and asking
|
|
296
|
+
it through one function is what keeps them from disagreeing about whether `deferred` counts as
|
|
297
|
+
open — it does not, because a deferred group is terminal for the run and its members simply
|
|
298
|
+
return to the next plan.
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
Whether this call performed the transition. `close`'s guarded update is what makes the
|
|
302
|
+
answer trustworthy when two of those observers land in one transaction.
|
|
303
|
+
"""
|
|
304
|
+
if await groups.has_open_groups(db, run_id):
|
|
305
|
+
return False
|
|
306
|
+
return await close(db, run_id=run_id, status=RunStatus.COMPLETE, ctx=ctx)
|