@arbiterforge/ca-pi 0.6.3 → 0.10.0
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.
- package/README.md +41 -98
- package/package.json +1 -1
- package/plugins/ca-pi/CHANGELOG.md +145 -0
- package/plugins/ca-pi/COMMANDS.md +138 -68
- package/plugins/ca-pi/SKILLS.md +137 -30
- package/plugins/ca-pi/agents/INDEX.md +3 -2
- package/plugins/ca-pi/agents/checkpoint-aggregator.md +8 -7
- package/plugins/ca-pi/agents/design-quality-reviewer.md +1 -1
- package/plugins/ca-pi/agents/finding-triage.md +31 -14
- package/plugins/ca-pi/agents/verdict-aggregator.md +64 -0
- package/plugins/ca-pi/{ORCHESTRATOR.md → arbiter.md} +37 -36
- package/plugins/ca-pi/extensions/codearbiter.js +844 -19
- package/plugins/ca-pi/generated/command-catalog.json +386 -196
- package/plugins/ca-pi/generated/roles.json +9 -0
- package/plugins/ca-pi/hooks/_arbiterstatelib.py +59 -11
- package/plugins/ca-pi/hooks/_bashguardlib.py +30 -12
- package/plugins/ca-pi/hooks/_gitexec.py +23 -0
- package/plugins/ca-pi/hooks/_githooks.py +50 -23
- package/plugins/ca-pi/hooks/_hooklib.py +148 -20
- package/plugins/ca-pi/hooks/_host.py +9 -1
- package/plugins/ca-pi/hooks/_metricslib.py +20 -0
- package/plugins/ca-pi/hooks/_modelib.py +762 -0
- package/plugins/ca-pi/hooks/_protectedlib.py +13 -4
- package/plugins/ca-pi/hooks/_prunelib.py +51 -12
- package/plugins/ca-pi/hooks/_prunepolicy.py +33 -7
- package/plugins/ca-pi/hooks/_readinjectlib.py +10 -4
- package/plugins/ca-pi/hooks/_releaselib.py +278 -48
- package/plugins/ca-pi/hooks/_updatelib.py +230 -50
- package/plugins/ca-pi/hooks/doctor.py +58 -9
- package/plugins/ca-pi/hooks/git-enforce.py +10 -3
- package/plugins/ca-pi/hooks/hostapi.py +220 -22
- package/plugins/ca-pi/hooks/pi-bridge.py +10 -4
- package/plugins/ca-pi/hooks/prompt-submit.py +486 -0
- package/plugins/ca-pi/hooks/prune-transcript.py +23 -3
- package/plugins/ca-pi/hooks/session-start.py +529 -435
- package/plugins/ca-pi/hooks/statusline.py +28 -10
- package/plugins/ca-pi/hooks/wire-statusline.py +13 -8
- package/plugins/ca-pi/includes/anti-slop-design/INDEX.md +1 -1
- package/plugins/ca-pi/includes/command-compatibility.md +16 -0
- package/plugins/ca-pi/includes/dangerous-mode.md +57 -0
- package/plugins/ca-pi/includes/ops-mode.md +96 -0
- package/plugins/ca-pi/includes/pi-host-notes.md +10 -1
- package/plugins/ca-pi/includes/redirect.md +12 -1
- package/plugins/ca-pi/includes/routing-table.md +14 -5
- package/plugins/ca-pi/includes/safety-core.md +86 -0
- package/plugins/ca-pi/includes/smarts/core.md +1 -1
- package/plugins/ca-pi/routines/INDEX.md +1 -1
- package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +55 -3
- package/plugins/ca-pi/routines/decision-lifecycle/references/adr-template.md +9 -1
- package/plugins/ca-pi/routines/decompose/SKILL.md +1 -1
- package/plugins/ca-pi/routines/dispatching-parallel-agents/SKILL.md +4 -4
- package/plugins/ca-pi/routines/release/SKILL.md +1 -1
- package/plugins/ca-pi/skills/ca-checkpoint/SKILL.md +5 -4
- package/plugins/ca-pi/skills/ca-cleanup/SKILL.md +6 -0
- package/plugins/ca-pi/skills/ca-context-check/SKILL.md +6 -0
- package/plugins/ca-pi/skills/ca-create-context/SKILL.md +6 -0
- package/plugins/ca-pi/skills/ca-decompose/SKILL.md +6 -0
- package/plugins/ca-pi/skills/ca-doctor/SKILL.md +4 -0
- package/plugins/ca-pi/skills/ca-init/SKILL.md +18 -1
- package/plugins/ca-pi/skills/ca-pr/SKILL.md +17 -1
- package/plugins/ca-pi/skills/ca-review/SKILL.md +3 -4
- package/plugins/ca-pi/skills/ca-spike/SKILL.md +15 -8
- package/plugins/ca-pi/skills/ca-status/SKILL.md +13 -1
- package/plugins/ca-pi/skills/ca-watch/SKILL.md +6 -0
- package/plugins/ca-pi/includes/dev-mode.md +0 -30
- package/plugins/ca-pi/skills/ca-arbiter/SKILL.md +0 -36
- package/plugins/ca-pi/skills/ca-dev/SKILL.md +0 -42
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# codeArbiter — mode plane: the three-value runtime posture (arbiter/dangerous/
|
|
3
|
+
# ops), its deterministic token flip, and the write-ahead audit-close ledger
|
|
4
|
+
# that backs it (#437, mode-plane-deterministic-flip).
|
|
5
|
+
#
|
|
6
|
+
# T-06 (pure refactor, no behavior change): the write-ahead ledger machinery —
|
|
7
|
+
# `_settle_dev_close` and its pending-close record — moved here verbatim from
|
|
8
|
+
# `core/pysrc/session-start.py` (formerly ~lines 551-810). `session-start.py`
|
|
9
|
+
# now imports `_settle_dev_close` (and the `_DEV_PENDING_CLOSE_MAX` constant,
|
|
10
|
+
# which a pre-existing test reads off the session-start module) from here;
|
|
11
|
+
# `clear_dev_marker` itself stays in session-start.py — this module owns the
|
|
12
|
+
# ledger MECHANISM, not the SessionStart-specific policy of when to invoke it.
|
|
13
|
+
#
|
|
14
|
+
# The proof this introduced no behavior change: the pre-existing
|
|
15
|
+
# `TestDevExitRetryablePendingClose` cases in
|
|
16
|
+
# `plugins/ca/hooks/tests/test_session_start.py` pass UNMODIFIED against the
|
|
17
|
+
# regenerated (sync-core.py) vendored copy, which now imports from this file.
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import datetime
|
|
22
|
+
import hashlib
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
|
|
27
|
+
from _activationlib import marker_root
|
|
28
|
+
from _hooklib import write_text_atomic
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# T-16 — PERSONA_SENTINEL: a single stable literal embedded in every composed
|
|
33
|
+
# persona injection (T-31, Lane B), so a later transcript-pruning pass
|
|
34
|
+
# (`_prunelib`/`_prunepolicy`, T-49/T-50, R-5) can recognize an injected-
|
|
35
|
+
# persona line and mark it `pinned=True` — protected from folding,
|
|
36
|
+
# condensing, and eviction at EVERY tier, including aggressive (AC-26).
|
|
37
|
+
#
|
|
38
|
+
# Deliberately shaped as an HTML comment (renders invisibly in the persona
|
|
39
|
+
# markdown) and deliberately distinct from `_prunepolicy.MARKER_PREFIX`
|
|
40
|
+
# ("[ca-condensed ") — the two must never collide: one marks "this content
|
|
41
|
+
# was ELIDED by a prior prune pass", the other marks "this content must
|
|
42
|
+
# NEVER be elided". Exported here, not in `_prunelib`/`_prunepolicy`,
|
|
43
|
+
# because the INJECTOR (this module's consumers) is the single source that
|
|
44
|
+
# must emit it — a value redefined in two places is a value that can drift.
|
|
45
|
+
PERSONA_SENTINEL = "<!-- codearbiter:persona-sentinel -->"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# T-07 — the mode plane: three-value posture, resolved through marker_root
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
# `MODES` is the ONLY legal-value tuple; `dev` is retired (superseded by
|
|
52
|
+
# `dangerous`, R-3/ADR-0022 supersession). Index 0 is deliberately the safe
|
|
53
|
+
# default every anomaly falls back to.
|
|
54
|
+
MODES = ("arbiter", "dangerous", "ops")
|
|
55
|
+
|
|
56
|
+
# [[never-fold-unreadable-into-absent]] — the house rule this constant set
|
|
57
|
+
# exists to satisfy: a marker file that genuinely does not exist and one that
|
|
58
|
+
# exists but could not be read/parsed are DIFFERENT failure classes and must
|
|
59
|
+
# never collapse onto one diagnostic string. Every non-None diagnostic below
|
|
60
|
+
# still resolves the mode to MODES[0] ("arbiter") — these strings distinguish
|
|
61
|
+
# WHY, not WHAT the fallback is.
|
|
62
|
+
MODE_DIAG_ABSENT = "mode-marker-absent"
|
|
63
|
+
MODE_DIAG_UNREADABLE = "mode-marker-unreadable"
|
|
64
|
+
MODE_DIAG_UNRECOGNIZED = "mode-marker-unrecognized"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def mode_marker_path(root=None, payload=None):
|
|
68
|
+
"""Absolute path of the mode marker: `<root>/.codearbiter/.markers/mode`.
|
|
69
|
+
|
|
70
|
+
`root` defaults to `_activationlib.marker_root(payload)` — deliberately
|
|
71
|
+
NOT `project_root(payload)`: `marker_root` exists precisely because
|
|
72
|
+
`project_root` splits marker state across linked worktrees (#604), and
|
|
73
|
+
every other `.codearbiter/.markers/` writer (security-pass.py,
|
|
74
|
+
migration-pass.py, the H-09b/H-10b/H-14 guards) already resolves through
|
|
75
|
+
it. An explicit `root` is accepted as a test-only escape hatch for
|
|
76
|
+
fixture isolation — production callers pass neither and let this resolve
|
|
77
|
+
via the host seam."""
|
|
78
|
+
if root is None:
|
|
79
|
+
root = marker_root(payload)
|
|
80
|
+
return os.path.join(root, ".codearbiter", ".markers", "mode")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def mode_entry_dir(root=None, payload=None):
|
|
84
|
+
"""Absolute path of the per-session entry directory:
|
|
85
|
+
`<root>/.codearbiter/.markers/mode.d`.
|
|
86
|
+
|
|
87
|
+
One file per session, never a shared cell. The single-file
|
|
88
|
+
`{session_id: mode}` map this replaces made every flip a read-modify-write
|
|
89
|
+
over state other sessions also own, and `write_text_atomic` serializes the
|
|
90
|
+
replace, not the PAIR. A session holding a map it read moments earlier
|
|
91
|
+
could therefore reinstate a `dangerous` entry another session had
|
|
92
|
+
explicitly left — with that session's own earlier `enter` row still
|
|
93
|
+
backing it in `ledger_backs`, so nothing downstream noticed. Verifying the
|
|
94
|
+
write did not close it: each writer only ever confirmed its OWN key, and
|
|
95
|
+
the victim had already returned success before the clobber landed.
|
|
96
|
+
|
|
97
|
+
Splitting the state removes the shared cell rather than serializing access
|
|
98
|
+
to it, so the interleave has nowhere left to occur. That also matches what
|
|
99
|
+
the plane already is — ADR-0030 position 6 makes it transient and
|
|
100
|
+
session-scoped — and is the direction ADR-0012 named ("session-scoped
|
|
101
|
+
markers") when it deferred this hardening as out of scope for the Codex
|
|
102
|
+
campaign.
|
|
103
|
+
|
|
104
|
+
Locking the map with `_hooklib.acquire_lock` was the real alternative, and
|
|
105
|
+
a close one: the primitive already exists, is OS-owned (process death
|
|
106
|
+
releases it, so there is no stale lock to steal), and would have confined
|
|
107
|
+
the change to this function. It loses on the prompt seam. `acquire_lock`
|
|
108
|
+
fail-softs to None after a bounded contention spin, and the only safe
|
|
109
|
+
reading of None here is failure — so under contention a user's flip would
|
|
110
|
+
stop working rather than serialize, which is the cost taskwrite.py accepts
|
|
111
|
+
for a board write and this path should not. Removing the shared cell has no
|
|
112
|
+
contention state at all.
|
|
113
|
+
|
|
114
|
+
Resolves through `marker_root` exactly as `mode_marker_path` does."""
|
|
115
|
+
if root is None:
|
|
116
|
+
root = marker_root(payload)
|
|
117
|
+
return os.path.join(root, ".codearbiter", ".markers", "mode.d")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _mode_entry_name(session_id):
|
|
121
|
+
"""The entry filename for `session_id`: its SHA-256 hex digest.
|
|
122
|
+
|
|
123
|
+
Hashed rather than sanitized because a session id arrives from host-
|
|
124
|
+
supplied hook input and is therefore untrusted for path construction. A
|
|
125
|
+
substitution sanitizer has to be injective to be correct, and on this
|
|
126
|
+
Windows-primary project it cannot be: NTFS is case-insensitive, so ids
|
|
127
|
+
differing only in case collapse onto one file — reintroducing the shared
|
|
128
|
+
cell this design exists to remove, on the platform most sessions run on. A
|
|
129
|
+
lowercase hex digest is fixed-length, path-safe, case-stable, cannot
|
|
130
|
+
traverse, and cannot collide with a reserved device name.
|
|
131
|
+
|
|
132
|
+
The cost is a directory of opaque names, paid back by the record itself:
|
|
133
|
+
each entry stores the session id it belongs to, so the mapping is
|
|
134
|
+
verifiable by reading one file rather than trusted."""
|
|
135
|
+
return hashlib.sha256(str(session_id).encode("utf-8")).hexdigest()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def mode_entry_path(session_id, root=None, payload=None):
|
|
139
|
+
"""Absolute path of `session_id`'s own mode entry."""
|
|
140
|
+
return os.path.join(mode_entry_dir(root, payload), _mode_entry_name(session_id))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _read_session_entry(session_id, root=None, payload=None):
|
|
144
|
+
"""(value, diagnostic) off `session_id`'s OWN entry file.
|
|
145
|
+
|
|
146
|
+
`value` is the raw recorded mode, or None whenever a diagnostic is set —
|
|
147
|
+
validating it against MODES is `current_mode`'s job, as it was for the map.
|
|
148
|
+
Diagnostics carry the same three-way distinction the map reader draws
|
|
149
|
+
([[never-fold-unreadable-into-absent]]), and `os.path.exists` gates the
|
|
150
|
+
absent check for the same reason: a directory at that path must report
|
|
151
|
+
UNREADABLE, not ABSENT.
|
|
152
|
+
|
|
153
|
+
An entry whose recorded `session` is not the one asked for is UNRECOGNIZED,
|
|
154
|
+
never silently honoured. That makes the hash mapping checked rather than
|
|
155
|
+
assumed — a hand-edited file, a restored backup, or (astronomically) a
|
|
156
|
+
digest collision resolves toward `arbiter` with a diagnostic instead of
|
|
157
|
+
handing one session another's posture."""
|
|
158
|
+
path = mode_entry_path(session_id, root, payload)
|
|
159
|
+
if not os.path.exists(path):
|
|
160
|
+
return None, MODE_DIAG_ABSENT
|
|
161
|
+
try:
|
|
162
|
+
with open(path, encoding="utf-8") as f:
|
|
163
|
+
text = f.read()
|
|
164
|
+
except Exception: # noqa: BLE001 — exists but could not be read
|
|
165
|
+
return None, MODE_DIAG_UNREADABLE
|
|
166
|
+
text = text.strip()
|
|
167
|
+
if not text:
|
|
168
|
+
return None, MODE_DIAG_UNRECOGNIZED
|
|
169
|
+
try:
|
|
170
|
+
data = json.loads(text)
|
|
171
|
+
except Exception: # noqa: BLE001 — not valid JSON
|
|
172
|
+
return None, MODE_DIAG_UNRECOGNIZED
|
|
173
|
+
if not isinstance(data, dict) or data.get("session") != str(session_id):
|
|
174
|
+
return None, MODE_DIAG_UNRECOGNIZED
|
|
175
|
+
return data.get("mode"), None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def session_has_entry(session_id, root=None, payload=None):
|
|
179
|
+
"""(has_entry, diagnostic) — whether `session_id` has recorded ANY mode-
|
|
180
|
+
plane opinion yet, including a deliberate `arbiter`.
|
|
181
|
+
|
|
182
|
+
The distinction `current_mode` cannot express: it answers `arbiter` both
|
|
183
|
+
for a session that never flipped and for one that flipped back, and the
|
|
184
|
+
SessionStart legacy conversion (T-47) may only migrate over the first.
|
|
185
|
+
Absence is the only clean "nothing to convert over" — so an unreadable
|
|
186
|
+
entry reports its diagnostic and `False`, and the caller must treat that
|
|
187
|
+
as "could not tell", never as absence."""
|
|
188
|
+
_value, diag = _read_session_entry(session_id, root=root, payload=payload)
|
|
189
|
+
if diag is None:
|
|
190
|
+
return True, None
|
|
191
|
+
if diag != MODE_DIAG_ABSENT:
|
|
192
|
+
return False, diag
|
|
193
|
+
state, legacy_diag = _read_mode_state(root, payload)
|
|
194
|
+
if legacy_diag is not None and legacy_diag != MODE_DIAG_ABSENT:
|
|
195
|
+
return False, legacy_diag
|
|
196
|
+
return str(session_id) in {str(k) for k in state}, None
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _read_mode_state(root=None, payload=None):
|
|
200
|
+
"""(state, diagnostic) off the LEGACY single-file mode marker.
|
|
201
|
+
|
|
202
|
+
Read-only since #681 split the plane into per-session entries
|
|
203
|
+
(`mode_entry_dir`); nothing writes this file any more. It is still read so
|
|
204
|
+
a session live across the upgrade keeps the posture it chose, and — more
|
|
205
|
+
importantly — so `session_has_entry` keeps seeing an explicit `arbiter`
|
|
206
|
+
that predates the split. Dropping the read would fail SAFE for the mode
|
|
207
|
+
itself (absent resolves to `arbiter`) but not for that second question: the
|
|
208
|
+
entry would read as "never flipped", re-arming the legacy `dev-active`
|
|
209
|
+
conversion to turn gates off under a session that had already chosen.
|
|
210
|
+
|
|
211
|
+
`state` is the RAW dict mapping session_id -> whatever value was on disk
|
|
212
|
+
for it (validation of an individual session's value is `current_mode`'s
|
|
213
|
+
job, not this function's — a per-session bad value must still be visible
|
|
214
|
+
to the caller so it can be reported, not silently dropped). Always a
|
|
215
|
+
dict, never None, so callers never need a None-check. A file that is
|
|
216
|
+
itself absent, unreadable, empty, or not a JSON object returns `{}` plus
|
|
217
|
+
a diagnostic.
|
|
218
|
+
|
|
219
|
+
`diagnostic` is None on a clean read (the file parses as a JSON object —
|
|
220
|
+
individual bad entries inside it do not taint this diagnostic). Otherwise
|
|
221
|
+
exactly one of MODE_DIAG_ABSENT / MODE_DIAG_UNREADABLE /
|
|
222
|
+
MODE_DIAG_UNRECOGNIZED — `os.path.exists` (not `os.path.isfile`) gates the
|
|
223
|
+
absent check, so a path that exists but cannot be opened as a normal file
|
|
224
|
+
(a directory sitting at that path, or a real permissions error) falls
|
|
225
|
+
through to the `open()` call and is correctly reported UNREADABLE rather
|
|
226
|
+
than ABSENT. This is the portable, no-chmod-needed shape of the
|
|
227
|
+
distinction: a directory path always fails `open()` (IsADirectoryError on
|
|
228
|
+
POSIX, PermissionError on Windows — both are OSError) without depending
|
|
229
|
+
on host-specific permission semantics."""
|
|
230
|
+
path = mode_marker_path(root, payload)
|
|
231
|
+
if not os.path.exists(path):
|
|
232
|
+
return {}, MODE_DIAG_ABSENT
|
|
233
|
+
try:
|
|
234
|
+
with open(path, encoding="utf-8") as f:
|
|
235
|
+
text = f.read()
|
|
236
|
+
except Exception: # noqa: BLE001 — exists but could not be read
|
|
237
|
+
return {}, MODE_DIAG_UNREADABLE
|
|
238
|
+
text = text.strip()
|
|
239
|
+
if not text:
|
|
240
|
+
return {}, MODE_DIAG_UNRECOGNIZED
|
|
241
|
+
try:
|
|
242
|
+
data = json.loads(text)
|
|
243
|
+
except Exception: # noqa: BLE001 — not valid JSON
|
|
244
|
+
return {}, MODE_DIAG_UNRECOGNIZED
|
|
245
|
+
if not isinstance(data, dict):
|
|
246
|
+
return {}, MODE_DIAG_UNRECOGNIZED
|
|
247
|
+
return data, None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def current_mode(session_id, root=None, payload=None):
|
|
251
|
+
"""(mode, diagnostic) for `session_id`.
|
|
252
|
+
|
|
253
|
+
Resolves MODES[0] ("arbiter") whenever the marker file is absent, empty,
|
|
254
|
+
unreadable, or unrecognized (AC-2) — WITH a diagnostic distinguishing
|
|
255
|
+
which — and also when the file is clean but simply has no entry yet for
|
|
256
|
+
this session (a fresh session legitimately starts arbiter; that is not an
|
|
257
|
+
anomaly, so diagnostic is None). When the file is clean but THIS
|
|
258
|
+
session's own recorded value is not a legal mode, that is reported the
|
|
259
|
+
same as a file-level unrecognized value (MODE_DIAG_UNRECOGNIZED) — a
|
|
260
|
+
garbage per-session entry is exactly as much an anomaly as a garbage
|
|
261
|
+
file, and must not be swallowed silently.
|
|
262
|
+
|
|
263
|
+
Reads `session_id`'s own entry first and falls back to the legacy map ONLY
|
|
264
|
+
when that entry is absent (never when it is unreadable — an entry we could
|
|
265
|
+
not read is not evidence that the legacy value is current). Once a session
|
|
266
|
+
has written once, its own entry is authoritative and the legacy file can
|
|
267
|
+
never revive a superseded posture."""
|
|
268
|
+
value, diag = _read_session_entry(session_id, root=root, payload=payload)
|
|
269
|
+
if diag is None:
|
|
270
|
+
if value not in MODES:
|
|
271
|
+
return MODES[0], MODE_DIAG_UNRECOGNIZED
|
|
272
|
+
return value, None
|
|
273
|
+
if diag != MODE_DIAG_ABSENT:
|
|
274
|
+
return MODES[0], diag
|
|
275
|
+
|
|
276
|
+
state, diag = _read_mode_state(root, payload)
|
|
277
|
+
if diag is not None:
|
|
278
|
+
return MODES[0], diag
|
|
279
|
+
if session_id not in state:
|
|
280
|
+
return MODES[0], None
|
|
281
|
+
value = state.get(session_id)
|
|
282
|
+
if value not in MODES:
|
|
283
|
+
return MODES[0], MODE_DIAG_UNRECOGNIZED
|
|
284
|
+
return value, None
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def write_mode(session_id, mode, root=None, payload=None):
|
|
288
|
+
"""Persist `mode` for `session_id` (T-08, AC-1) in that session's OWN entry.
|
|
289
|
+
|
|
290
|
+
The write is delegated ENTIRELY to `write_text_atomic` — this function does
|
|
291
|
+
no `open()`/`write()` of its own — so an interrupted write can only ever
|
|
292
|
+
land in write_text_atomic's own guarantee: a sibling temp file, then
|
|
293
|
+
`os.replace()`; on any failure the temp is removed and `path` is left
|
|
294
|
+
exactly as it was (untouched if it existed, absent if it did not). Returns
|
|
295
|
+
True on a confirmed write, False on failure. Never raises — the caller
|
|
296
|
+
(`flip`, T-11/T-14) decides what failure means.
|
|
297
|
+
|
|
298
|
+
There is no read-modify-write and therefore no retry loop. Writing one
|
|
299
|
+
session's posture no longer requires reading state other sessions own, so
|
|
300
|
+
the interleave that made the old loop necessary — and that the loop could
|
|
301
|
+
not actually close, because each writer verified only its OWN key while the
|
|
302
|
+
victim had already returned success — has nowhere to occur. See
|
|
303
|
+
`mode_entry_dir` for why the shared cell was removed rather than locked.
|
|
304
|
+
|
|
305
|
+
VERIFIED, not merely attempted: the entry is re-read and must come back as
|
|
306
|
+
this session's, carrying this value. ADR-0030 position 5 requires the
|
|
307
|
+
return path out of `dangerous` to be "a verified write" that "must surface
|
|
308
|
+
its failure"; a write that cannot be confirmed returns False rather than
|
|
309
|
+
reporting a success it cannot demonstrate."""
|
|
310
|
+
path = mode_entry_path(session_id, root, payload)
|
|
311
|
+
record = json.dumps({"session": str(session_id), "mode": mode})
|
|
312
|
+
try:
|
|
313
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
314
|
+
write_text_atomic(path, record, newline="\n")
|
|
315
|
+
except OSError:
|
|
316
|
+
return False
|
|
317
|
+
observed, diag = _read_session_entry(session_id, root=root, payload=payload)
|
|
318
|
+
return diag is None and observed == mode
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# ---------------------------------------------------------------------------
|
|
322
|
+
# T-11 — flip(): the deterministic token-flip primitive every host caller
|
|
323
|
+
# (prompt-submit.py, pi-bridge.py) drives through. Three distinct sentinels,
|
|
324
|
+
# never a bare bool — a caller has to tell "already there" from "the write
|
|
325
|
+
# failed" apart to report either correctly to the user.
|
|
326
|
+
# ---------------------------------------------------------------------------
|
|
327
|
+
FLIP_FLIPPED = "flipped"
|
|
328
|
+
FLIP_NOOP = "noop"
|
|
329
|
+
FLIP_FAILED = "failed"
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _mode_audit_line(verb, mode, host_name=None, now=None, session_id=None):
|
|
333
|
+
"""One `MODE: <name> enter|exit` audit row (Decided parameters: Audit
|
|
334
|
+
verb). `now` (epoch seconds) is injectable for deterministic tests;
|
|
335
|
+
defaults to the real current time.
|
|
336
|
+
|
|
337
|
+
Carries `SESSION:` because the row is an AUTHORIZATION, not just a record:
|
|
338
|
+
`ledger_backs` reads it to decide whether a gates-off marker is allowed to
|
|
339
|
+
take effect. Without the field that check is repo-wide, so one session's
|
|
340
|
+
`enter` row authorizes ANOTHER session's marker — the mode plane is keyed
|
|
341
|
+
per session everywhere else, and an unkeyed authorization defeats that
|
|
342
|
+
isolation (AC-3)."""
|
|
343
|
+
ts = (datetime.datetime.fromtimestamp(now, tz=datetime.timezone.utc)
|
|
344
|
+
if now is not None
|
|
345
|
+
else datetime.datetime.now(datetime.timezone.utc))
|
|
346
|
+
ts_str = ts.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
347
|
+
return (f"[{ts_str}] | BY: session-mode | HOST: {host_name or 'unknown'} "
|
|
348
|
+
f"| SESSION: {session_id or 'unknown'} "
|
|
349
|
+
f"| MODE: {mode} {verb} | NOTE: —\n")
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def flip(session_id, mode, root=None, payload=None, host_name=None, now=None):
|
|
353
|
+
"""Attempt to set `session_id`'s mode to `mode`. Returns one of
|
|
354
|
+
FLIP_FLIPPED / FLIP_NOOP / FLIP_FAILED. Never raises.
|
|
355
|
+
|
|
356
|
+
AC-6: a flip TO THE ALREADY-ACTIVE mode is a no-op — no write is even
|
|
357
|
+
attempted and no audit row is appended, so `overrides.log` is left
|
|
358
|
+
byte-identical. This is also the mechanism behind T-14's fail-direction
|
|
359
|
+
asymmetry: once a failed flip has left the session's resolved mode
|
|
360
|
+
unchanged, any LATER flip back to that same resolved mode is a no-op
|
|
361
|
+
under ANY filesystem state — including a markers directory that cannot
|
|
362
|
+
be written to at all — because a no-op never touches disk.
|
|
363
|
+
|
|
364
|
+
On a genuine transition the order is deliberate: write first, audit row
|
|
365
|
+
ONLY on a confirmed write. AC-11's `ledger_backs` exists to catch exactly
|
|
366
|
+
the opposite ordering — a ledger row minted for a flip that never
|
|
367
|
+
actually landed, which would let an unbacked marker masquerade as an
|
|
368
|
+
audited one."""
|
|
369
|
+
root = root if root is not None else marker_root(payload)
|
|
370
|
+
current, _diag = current_mode(session_id, root=root, payload=payload)
|
|
371
|
+
if current == mode:
|
|
372
|
+
return FLIP_NOOP
|
|
373
|
+
if not write_mode(session_id, mode, root=root, payload=payload):
|
|
374
|
+
return FLIP_FAILED
|
|
375
|
+
# ADR-0030 position 4: EVERY transition row is staged through the #396
|
|
376
|
+
# write-ahead ledger, never a bare append. The exit half already complied;
|
|
377
|
+
# this one did not, so an unwritable overrides.log dropped the `MODE: …
|
|
378
|
+
# enter` row with no replay while `flip` still reported success — an
|
|
379
|
+
# unaudited entry into a gates-off posture, which is the one transition
|
|
380
|
+
# that must never be silent.
|
|
381
|
+
#
|
|
382
|
+
# Reporting FLIP_FAILED when the row is not confirmed is consistent rather
|
|
383
|
+
# than pessimistic: `ledger_backs` (AC-11) already refuses to compose a
|
|
384
|
+
# body whose mode has no matching `enter` row, so an unaudited marker is
|
|
385
|
+
# not in effect anyway. Saying so out loud beats leaving the user believing
|
|
386
|
+
# a flip took that the injector will ignore.
|
|
387
|
+
#
|
|
388
|
+
# Confirmed by looking for the row itself, not by the settle count: a
|
|
389
|
+
# settle can append an OLDER owed line and stall on this one, which would
|
|
390
|
+
# read as success from the count alone.
|
|
391
|
+
line = _mode_audit_line("enter", mode, host_name=host_name, now=now,
|
|
392
|
+
session_id=session_id)
|
|
393
|
+
_settle_dev_close(root, new_line=line, host_name=host_name)
|
|
394
|
+
if not _overrides_has_line(root, line):
|
|
395
|
+
return FLIP_FAILED
|
|
396
|
+
return FLIP_FLIPPED
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# ---------------------------------------------------------------------------
|
|
400
|
+
# T-12 — the token table: `mode --arbiter|--dangerous|--ops`, matched
|
|
401
|
+
# WHOLE-PROMPT, never substring (Decided parameters: Token). Pure text logic,
|
|
402
|
+
# no I/O — every host's prompt-seam interceptor (Claude/Codex/Pi) imports
|
|
403
|
+
# this so the matching rule can never drift between hosts.
|
|
404
|
+
# ---------------------------------------------------------------------------
|
|
405
|
+
MODE_TOKEN_REPORT = "report" # bare `mode`: report current + legal values, write nothing
|
|
406
|
+
|
|
407
|
+
_MODE_TOKEN_RE = re.compile(r"mode(?:\s+--(arbiter|dangerous|ops))?", re.I)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def match_mode_token(prompt):
|
|
411
|
+
"""Classify `prompt` against the mode control-token table.
|
|
412
|
+
|
|
413
|
+
Returns one of MODES (a flip request), MODE_TOKEN_REPORT (bare `mode`),
|
|
414
|
+
or None (not a control token at all — the prompt reaches the model
|
|
415
|
+
unmodified).
|
|
416
|
+
|
|
417
|
+
Whole-prompt only: `re.fullmatch` against the prompt after stripping
|
|
418
|
+
SURROUNDING whitespace (never internal) means a token embedded anywhere
|
|
419
|
+
in a longer prompt — before, after, or on another line — cannot match,
|
|
420
|
+
because fullmatch requires the ENTIRE stripped string to be consumed by
|
|
421
|
+
the pattern and the pattern contains no `\\n`. Case-insensitive (`re.I`);
|
|
422
|
+
surrounding whitespace of any kind (spaces, tabs, newlines) is
|
|
423
|
+
insensitive because it is stripped before matching."""
|
|
424
|
+
if not isinstance(prompt, str):
|
|
425
|
+
return None
|
|
426
|
+
stripped = prompt.strip()
|
|
427
|
+
if not stripped:
|
|
428
|
+
return None
|
|
429
|
+
m = _MODE_TOKEN_RE.fullmatch(stripped)
|
|
430
|
+
if not m:
|
|
431
|
+
return None
|
|
432
|
+
name = m.group(1)
|
|
433
|
+
if name is None:
|
|
434
|
+
return MODE_TOKEN_REPORT
|
|
435
|
+
return name.lower()
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
# ---------------------------------------------------------------------------
|
|
439
|
+
# T-13 — ledger_backs(): the AC-11 compensating control. The deterministic
|
|
440
|
+
# flip removes ADR-0022's tier-2 confirmation for dangerous-mode entry (its
|
|
441
|
+
# supersession, per the spec's ADR conflict note); this is the load-bearing
|
|
442
|
+
# replacement — the injector refuses to compose a non-arbiter body the audit
|
|
443
|
+
# trail does not back.
|
|
444
|
+
# ---------------------------------------------------------------------------
|
|
445
|
+
_LEGACY_DEV_ENTER_RE = re.compile(r"\|\s*DEV:\s*enter\s*(?:\||$)", re.M)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def ledger_backs(root, mode, session_id=None):
|
|
449
|
+
"""True iff the audit trail (at `root`) holds a matching
|
|
450
|
+
`MODE: <mode> enter` row FOR `session_id`.
|
|
451
|
+
|
|
452
|
+
Session-scoped, because this row is an authorization rather than a
|
|
453
|
+
record: it decides whether a gates-off marker takes effect. A repo-wide
|
|
454
|
+
match let one session's `enter` row authorize a DIFFERENT session's marker
|
|
455
|
+
— every other part of the mode plane is keyed per session, and an unkeyed
|
|
456
|
+
authorization defeats that isolation (AC-3). Pass `session_id` at every
|
|
457
|
+
production call site; omitting it keeps the older repo-wide question,
|
|
458
|
+
which is only ever the right one for a caller that has no session.
|
|
459
|
+
|
|
460
|
+
A row written before this field existed carries no session and therefore
|
|
461
|
+
backs NO session-scoped query. That fails toward `arbiter` — gates ON, one
|
|
462
|
+
re-flip — which is the direction ADR-0030 requires; the alternative would
|
|
463
|
+
reopen the hole for exactly the history that cannot be checked.
|
|
464
|
+
|
|
465
|
+
A legacy `DEV: enter` row backs `mode == "dangerous"` ONLY — dev was
|
|
466
|
+
retired INTO dangerous (T-47 converts a live `dev-active` marker to
|
|
467
|
+
`dangerous` exactly once), so a pre-mode-plane audit trail's DEV: enter
|
|
468
|
+
rows must continue to authorize it. A legacy row must NEVER back `ops`:
|
|
469
|
+
`ops` did not exist when any DEV: row could have been written, so
|
|
470
|
+
accepting one there would be a fail-OPEN into a mode the operator never
|
|
471
|
+
actually requested — the exact failure this function exists to prevent,
|
|
472
|
+
just relocated to a different mode.
|
|
473
|
+
|
|
474
|
+
Read-only and tolerant: an absent or unreadable log answers False,
|
|
475
|
+
never raises — consistent with this module's fail-toward-arbiter
|
|
476
|
+
convention (a missing ledger can never AUTHORIZE anything)."""
|
|
477
|
+
try:
|
|
478
|
+
with open(_overrides_log_path(root), encoding="utf-8", errors="replace") as f:
|
|
479
|
+
text = f.read()
|
|
480
|
+
except Exception: # noqa: BLE001 — absent/unreadable log -> nothing backs it
|
|
481
|
+
return False
|
|
482
|
+
enter_re = re.compile(r"\|\s*MODE:\s*" + re.escape(mode) + r"\s+enter\s*(?:\||$)", re.M)
|
|
483
|
+
if session_id is None:
|
|
484
|
+
if enter_re.search(text):
|
|
485
|
+
return True
|
|
486
|
+
else:
|
|
487
|
+
session_re = re.compile(r"\|\s*SESSION:\s*" + re.escape(str(session_id)) + r"\s*\|")
|
|
488
|
+
for line in text.splitlines():
|
|
489
|
+
if session_re.search(line) and enter_re.search(line):
|
|
490
|
+
return True
|
|
491
|
+
# The legacy exception, deliberately session-blind: `dev` was retired INTO
|
|
492
|
+
# `dangerous` (T-47 converts a live `dev-active` marker exactly once), and a
|
|
493
|
+
# pre-mode-plane `DEV: enter` row predates session attribution entirely, so
|
|
494
|
+
# requiring one would break the migration it exists to serve. Bounded to
|
|
495
|
+
# `dangerous` on a repo that already has DEV history — `ops` never gets it,
|
|
496
|
+
# since accepting a legacy row there would authorize a mode the operator
|
|
497
|
+
# could not have requested when that row was written.
|
|
498
|
+
if mode == "dangerous" and _LEGACY_DEV_ENTER_RE.search(text):
|
|
499
|
+
return True
|
|
500
|
+
return False
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
# --- #396: a durable, retryable DEV: exit -----------------------------------
|
|
504
|
+
# The synthetic close line is the ONLY thing that keeps the append-only audit
|
|
505
|
+
# trail's DEV: enter/exit pairs matched after an abandoned maintainer session.
|
|
506
|
+
# It used to be written best-effort ("except OSError: pass") and the marker was
|
|
507
|
+
# then removed regardless — so a locked file, a full disk, or a permission blip
|
|
508
|
+
# permanently erased the obligation and left an orphaned DEV: enter that no
|
|
509
|
+
# later session could know about.
|
|
510
|
+
#
|
|
511
|
+
# The fix is a small write-ahead record: the owed line is staged on disk BEFORE
|
|
512
|
+
# the append is attempted, and the record is deleted only once BOTH the append
|
|
513
|
+
# is confirmed AND the marker it settles is gone. That single record therefore
|
|
514
|
+
# carries three facts at once:
|
|
515
|
+
#
|
|
516
|
+
# "lines" — close lines still owed to overrides.log. Emptied one at a
|
|
517
|
+
# time as each append is confirmed.
|
|
518
|
+
# "marker_mtime" — the identity of the dev-active marker this close belongs
|
|
519
|
+
# to. While the record still names a LIVE marker, the
|
|
520
|
+
# force-close path knows that marker has already been
|
|
521
|
+
# closed in the audit trail and refuses to mint a second
|
|
522
|
+
# row for it — which is what makes a failed `os.remove`
|
|
523
|
+
# idempotent rather than duplicating the close. It is
|
|
524
|
+
# cleared the moment that marker is gone: an mtime only
|
|
525
|
+
# identifies a file that still EXISTS, and a stale one is
|
|
526
|
+
# free to collide with an unrelated future marker (2s
|
|
527
|
+
# granularity on FAT32/exFAT/SMB/WSL mounts makes that a
|
|
528
|
+
# real event, not a theoretical one) and suppress a close
|
|
529
|
+
# that is genuinely owed.
|
|
530
|
+
# "dropped" — how many owed close lines the bound below has discarded.
|
|
531
|
+
# The cap keeps the record small, but the loss must not be
|
|
532
|
+
# silent: the count is written to the trail as one
|
|
533
|
+
# attributable note the moment overrides.log accepts writes.
|
|
534
|
+
#
|
|
535
|
+
# Replayed lines carry the timestamp they were MINTED with, not the time they
|
|
536
|
+
# land, so a delayed replay leaves overrides.log non-chronological. Enter/exit
|
|
537
|
+
# pairing is by timestamp, so that is correct — but an audit reader must not
|
|
538
|
+
# assume file order is time order.
|
|
539
|
+
#
|
|
540
|
+
# Every boundary is covered:
|
|
541
|
+
# crash before the append -> record present, line owed -> replayed
|
|
542
|
+
# crash after the append -> record present, line owed -> the bounded
|
|
543
|
+
# tail scan sees the line already landed and
|
|
544
|
+
# drops it instead of appending a duplicate
|
|
545
|
+
# marker removal fails -> record present, no line owed -> the next
|
|
546
|
+
# session only retries the removal
|
|
547
|
+
#
|
|
548
|
+
# That tail scan is applied ONLY to lines read back off the record — the ones
|
|
549
|
+
# that might have landed before a crash. A line minted in THIS process cannot
|
|
550
|
+
# already be on the trail, and must never be dedupe-checked: close rows are
|
|
551
|
+
# timestamped to the second, so two distinct closes minted in the same second
|
|
552
|
+
# are byte-identical, and checking the fresh one against an owed copy of itself
|
|
553
|
+
# would silently swallow a close that is genuinely owed.
|
|
554
|
+
#
|
|
555
|
+
# Everything here is best-effort by the module's standing convention: session
|
|
556
|
+
# startup must never be bricked by audit bookkeeping, so nothing raises.
|
|
557
|
+
_DEV_PENDING_CLOSE_MAX = 8 # bounded: never accumulate owed lines forever
|
|
558
|
+
_DEV_PENDING_SCAN_BYTES = 64 * 1024 # bounded tail scan for the dedupe check
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _dev_pending_close_path(root):
|
|
562
|
+
return os.path.join(root, ".codearbiter", ".markers", "dev-close-pending.json")
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _overrides_log_path(root):
|
|
566
|
+
return os.path.join(root, ".codearbiter", "overrides.log")
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _read_dev_pending_close(root):
|
|
570
|
+
"""The pending-close record as
|
|
571
|
+
{"lines": [...], "marker_mtime": float|None, "dropped": int}, or None when
|
|
572
|
+
there is nothing usable on disk. A record that exists but carries no
|
|
573
|
+
replayable line, no marker identity and no unreported drop is reported as
|
|
574
|
+
None so the caller discards it — a corrupt record must never wedge the
|
|
575
|
+
mechanism shut. Never raises."""
|
|
576
|
+
try:
|
|
577
|
+
with open(_dev_pending_close_path(root), encoding="utf-8") as f:
|
|
578
|
+
data = json.load(f)
|
|
579
|
+
if not isinstance(data, dict):
|
|
580
|
+
return None
|
|
581
|
+
lines = [ln for ln in (data.get("lines") or [])
|
|
582
|
+
if isinstance(ln, str) and ln.strip()][:_DEV_PENDING_CLOSE_MAX]
|
|
583
|
+
mtime = data.get("marker_mtime")
|
|
584
|
+
mtime = float(mtime) if isinstance(mtime, (int, float)) else None
|
|
585
|
+
dropped = data.get("dropped")
|
|
586
|
+
# `isinstance(True, int)` is True, so booleans are excluded explicitly.
|
|
587
|
+
dropped = (int(dropped) if isinstance(dropped, int)
|
|
588
|
+
and not isinstance(dropped, bool) and dropped > 0 else 0)
|
|
589
|
+
if not lines and mtime is None and not dropped:
|
|
590
|
+
return None
|
|
591
|
+
return {"lines": lines, "marker_mtime": mtime, "dropped": dropped}
|
|
592
|
+
except Exception: # noqa: BLE001 — absent/corrupt record -> no signal
|
|
593
|
+
return None
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _write_dev_pending_close(root, rec):
|
|
597
|
+
"""Atomically persist the pending-close record. Never raises — a write
|
|
598
|
+
failure only costs the retry signal this call was trying to create, which
|
|
599
|
+
is exactly the pre-#396 behavior and still must not brick startup."""
|
|
600
|
+
try:
|
|
601
|
+
path = _dev_pending_close_path(root)
|
|
602
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
603
|
+
write_text_atomic(path, json.dumps(rec), newline="\n")
|
|
604
|
+
except Exception: # noqa: BLE001 — must never brick session startup
|
|
605
|
+
pass
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _discard_dev_pending_close(root):
|
|
609
|
+
try:
|
|
610
|
+
os.remove(_dev_pending_close_path(root))
|
|
611
|
+
except OSError:
|
|
612
|
+
pass
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _overrides_has_line(root, line):
|
|
616
|
+
"""True iff `line` already appears in the tail of overrides.log. Bounded to
|
|
617
|
+
the last _DEV_PENDING_SCAN_BYTES — a replay always happens on the very next
|
|
618
|
+
SessionStart, so the line it is looking for is at (or near) the end. An
|
|
619
|
+
unreadable log answers False: re-appending a close row is a far smaller
|
|
620
|
+
harm than silently dropping one.
|
|
621
|
+
|
|
622
|
+
Read in BINARY and decoded here on purpose: a byte offset is only
|
|
623
|
+
meaningful to seek() on a binary stream, and the comparison is made on the
|
|
624
|
+
stripped line so the platform EOL the append produced never matters."""
|
|
625
|
+
needle = line.strip()
|
|
626
|
+
if not needle:
|
|
627
|
+
return False
|
|
628
|
+
try:
|
|
629
|
+
path = _overrides_log_path(root)
|
|
630
|
+
size = os.path.getsize(path)
|
|
631
|
+
with open(path, "rb") as f:
|
|
632
|
+
if size > _DEV_PENDING_SCAN_BYTES:
|
|
633
|
+
f.seek(size - _DEV_PENDING_SCAN_BYTES)
|
|
634
|
+
tail = f.read().decode("utf-8", "replace")
|
|
635
|
+
return needle in tail
|
|
636
|
+
except Exception: # noqa: BLE001 — cannot confirm -> assume not present
|
|
637
|
+
return False
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _append_override_line(root, line):
|
|
641
|
+
"""Append one audit line to overrides.log. True on a confirmed write."""
|
|
642
|
+
try:
|
|
643
|
+
with open(_overrides_log_path(root), "a", encoding="utf-8") as f:
|
|
644
|
+
f.write(line)
|
|
645
|
+
return True
|
|
646
|
+
except OSError:
|
|
647
|
+
return False
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _dev_dropped_close_note(count, host_name=None):
|
|
651
|
+
"""One audit line accounting for close rows the pending-close cap had to
|
|
652
|
+
discard. Deliberately NOT a `DEV: exit` row — it closes nothing; it records
|
|
653
|
+
that N closes can never be written, so a reader of the append-only trail
|
|
654
|
+
can attribute the unmatched entries instead of finding an unexplained gap.
|
|
655
|
+
"""
|
|
656
|
+
ts = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
657
|
+
return (f"[{ts}] | BY: session-cleanup | HOST: {host_name or 'unknown'} "
|
|
658
|
+
f"| DEV: close-dropped | NOTE: {count} owed close row(s) discarded - the "
|
|
659
|
+
f"pending-close cap ({_DEV_PENDING_CLOSE_MAX}) was reached while "
|
|
660
|
+
f"overrides.log was unwritable; that many maintainer sessions have "
|
|
661
|
+
f"no matching close row\n")
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _settle_dev_close(root, marker=None, new_line=None, host_name=None):
|
|
665
|
+
"""Drive the pending-close record to settlement; the single place the owed
|
|
666
|
+
DEV: exit is appended and the retry state is cleared.
|
|
667
|
+
|
|
668
|
+
`marker` is the dev-active path when one is live (its mtime becomes the
|
|
669
|
+
close identity), None when there is no marker to settle. `new_line` is a
|
|
670
|
+
freshly minted close line to take on, or None when this is a pure replay of
|
|
671
|
+
whatever is already owed. `host_name` only attributes the cap-overflow note
|
|
672
|
+
below; the close lines themselves already carry their own HOST field.
|
|
673
|
+
Returns the number of close lines appended by THIS call. Never raises."""
|
|
674
|
+
if (marker is None and new_line is None
|
|
675
|
+
and not os.path.isfile(_dev_pending_close_path(root))):
|
|
676
|
+
return 0 # nothing owed, nothing to settle — the overwhelming case
|
|
677
|
+
rec = _read_dev_pending_close(root)
|
|
678
|
+
owed = list(rec["lines"]) if rec else []
|
|
679
|
+
prev_mtime = rec["marker_mtime"] if rec else None
|
|
680
|
+
dropped = rec["dropped"] if rec else 0
|
|
681
|
+
|
|
682
|
+
marker_mtime = None
|
|
683
|
+
if marker:
|
|
684
|
+
try:
|
|
685
|
+
marker_mtime = os.path.getmtime(marker)
|
|
686
|
+
except OSError:
|
|
687
|
+
marker_mtime = None
|
|
688
|
+
|
|
689
|
+
# Everything already in `owed` came off disk, so it MAY have reached the
|
|
690
|
+
# trail before a crash and has to be dedupe-checked. Anything appended
|
|
691
|
+
# below is minted in this process and cannot possibly be there yet.
|
|
692
|
+
replays = len(owed)
|
|
693
|
+
|
|
694
|
+
if new_line is not None:
|
|
695
|
+
# Already closed THIS marker (the append landed, only the removal
|
|
696
|
+
# failed) -> do not mint a second row for it; just retry the cleanup.
|
|
697
|
+
already_closed = (rec is not None and prev_mtime is not None
|
|
698
|
+
and marker_mtime is not None
|
|
699
|
+
and prev_mtime == marker_mtime)
|
|
700
|
+
if not already_closed:
|
|
701
|
+
owed.append(new_line)
|
|
702
|
+
if len(owed) > _DEV_PENDING_CLOSE_MAX:
|
|
703
|
+
# Bounded, but never SILENT. A permanently-unwritable overrides.log
|
|
704
|
+
# would otherwise accumulate owed lines forever, so the oldest are
|
|
705
|
+
# discarded — and counted, so the loss is itself auditable rather than
|
|
706
|
+
# reintroducing exactly the unmatched `DEV: enter` this record exists
|
|
707
|
+
# to prevent.
|
|
708
|
+
overflow = len(owed) - _DEV_PENDING_CLOSE_MAX
|
|
709
|
+
dropped += overflow
|
|
710
|
+
owed = owed[-_DEV_PENDING_CLOSE_MAX:]
|
|
711
|
+
replays = max(0, replays - overflow) # the discards come off the front
|
|
712
|
+
|
|
713
|
+
if owed or dropped or marker_mtime is not None:
|
|
714
|
+
# Write-ahead: the obligation is durable BEFORE the append is tried.
|
|
715
|
+
_write_dev_pending_close(root, {"lines": owed,
|
|
716
|
+
"marker_mtime": marker_mtime,
|
|
717
|
+
"dropped": dropped})
|
|
718
|
+
|
|
719
|
+
# The overflow note goes in FIRST — the rows it accounts for are older than
|
|
720
|
+
# everything still owed. It is minted fresh each attempt, so it is not
|
|
721
|
+
# deduped by the tail scan; a crash between this append and the write-back
|
|
722
|
+
# below can repeat it once, which is the same "a duplicate beats a loss"
|
|
723
|
+
# trade the close rows themselves make.
|
|
724
|
+
if dropped and _append_override_line(root, _dev_dropped_close_note(dropped, host_name)):
|
|
725
|
+
dropped = 0
|
|
726
|
+
|
|
727
|
+
appended = 0
|
|
728
|
+
remaining = []
|
|
729
|
+
stalled = False
|
|
730
|
+
for idx, line in enumerate(owed):
|
|
731
|
+
if stalled:
|
|
732
|
+
remaining.append(line) # the log is failing — everything after
|
|
733
|
+
continue # the first failure is still owed
|
|
734
|
+
if idx < replays and _overrides_has_line(root, line):
|
|
735
|
+
continue # crash-after-append: already in the trail
|
|
736
|
+
if not _append_override_line(root, line):
|
|
737
|
+
stalled = True
|
|
738
|
+
remaining.append(line) # still owed — replay on the next session
|
|
739
|
+
continue
|
|
740
|
+
appended += 1
|
|
741
|
+
|
|
742
|
+
marker_gone = True
|
|
743
|
+
if marker:
|
|
744
|
+
try:
|
|
745
|
+
os.remove(marker)
|
|
746
|
+
except OSError:
|
|
747
|
+
marker_gone = not os.path.isfile(marker)
|
|
748
|
+
|
|
749
|
+
# Keep the record ONLY while it still carries information: a line still
|
|
750
|
+
# owed, an unreported cap overflow, or the identity of a marker that
|
|
751
|
+
# survived its own removal (the tombstone that stops the next session
|
|
752
|
+
# minting a second close for it). A marker that IS gone takes its tombstone
|
|
753
|
+
# with it — a dead marker's mtime identifies nothing, and leaving it behind
|
|
754
|
+
# lets an unrelated future marker collide with it and lose a real close.
|
|
755
|
+
if remaining or dropped or (not marker_gone and marker_mtime is not None):
|
|
756
|
+
_write_dev_pending_close(root, {"lines": remaining,
|
|
757
|
+
"marker_mtime": (None if marker_gone
|
|
758
|
+
else marker_mtime),
|
|
759
|
+
"dropped": dropped})
|
|
760
|
+
else:
|
|
761
|
+
_discard_dev_pending_close(root)
|
|
762
|
+
return appended
|