@arbiterforge/ca-pi 0.15.5 → 0.15.7

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.
@@ -23,8 +23,10 @@ import _replylib
23
23
 
24
24
  ID_RE = re.compile(r"[A-Z][A-Z0-9_-]{0,127}")
25
25
  TOKEN_RE = re.compile(r"[A-Za-z0-9_-]{12,128}")
26
- PENDING_DIR = Path(".codearbiter/.markers/reconciliations")
27
- ATTEMPT_DIR = Path(".codearbiter/.markers/reconciliation-attempts")
26
+ PENDING_DIR = Path("pending")
27
+ ATTEMPT_DIR = Path("attempts")
28
+ LEGACY_PENDING_DIR = Path(".codearbiter/.markers/reconciliations")
29
+ LEGACY_ATTEMPT_DIR = Path(".codearbiter/.markers/reconciliation-attempts")
28
30
  SOURCE_DIR = Path(".codearbiter/.artifacts/authority-sources")
29
31
  PROVEN_UNCOMMITTED = frozenset({"REVISION_CONFLICT", "OPERATION_ROLLED_BACK"})
30
32
 
@@ -53,6 +55,65 @@ def _attempt(pending: dict[str, Any]) -> Path:
53
55
  return ATTEMPT_DIR / f"{_operation_id(pending)}.json"
54
56
 
55
57
 
58
+ def _state_root(root: Path) -> Path:
59
+ """Keep operational state outside every repository input root."""
60
+ try:
61
+ spool = _artifactauthoritylib._spool_root(root)
62
+ return _artifactauthoritylib._safe_directory(spool, Path("reconciliations"), create=True)
63
+ except _artifactauthoritylib.AuthorityError as exc:
64
+ raise ReconciliationError("Git admin reconciliation storage is unavailable or unsafe") from exc
65
+
66
+
67
+ def _legacy_pending(root: Path, artifact_id: str) -> Path:
68
+ return _legacy_file(root, LEGACY_PENDING_DIR, f"{artifact_id}.json")
69
+
70
+
71
+ def _legacy_file(root: Path, directory: Path, filename: str) -> Path:
72
+ try:
73
+ parent = _approvallib._safe_directory(root, directory, create=False)
74
+ except FileNotFoundError:
75
+ return root / directory / filename
76
+ except _approvallib.ApprovalError as exc:
77
+ raise ReconciliationError("legacy reconciliation directory is unsafe") from exc
78
+ return parent / filename
79
+
80
+
81
+ def _state_file(root: Path, directory: Path, filename: str) -> Path:
82
+ try:
83
+ parent = _artifactauthoritylib._safe_directory(_state_root(root), directory, create=True)
84
+ except _artifactauthoritylib.AuthorityError as exc:
85
+ raise ReconciliationError("Git admin reconciliation directory is unsafe") from exc
86
+ return parent / filename
87
+
88
+
89
+ def _present(path: Path) -> bool:
90
+ try:
91
+ path.lstat()
92
+ except FileNotFoundError:
93
+ return False
94
+ return True
95
+
96
+
97
+ def _pending_path(root: Path, artifact_id: str) -> Path:
98
+ current = _state_file(root, PENDING_DIR, f"{artifact_id}.json")
99
+ legacy = _legacy_pending(root, artifact_id)
100
+ if _present(current) and _present(legacy):
101
+ raise ReconciliationError("ambiguous reconciliation state in repository and Git admin storage")
102
+ return legacy if _present(legacy) else current
103
+
104
+
105
+ def _attempt_path(root: Path, pending: dict[str, Any]) -> Path:
106
+ filename = f"{_operation_id(pending)}.json"
107
+ legacy = _legacy_file(root, LEGACY_ATTEMPT_DIR, filename)
108
+ current = _state_file(root, ATTEMPT_DIR, filename)
109
+ legacy_pending = _present(_legacy_pending(root, pending["artifact_id"]))
110
+ if (_present(legacy) and _present(current)) or (
111
+ legacy_pending and _present(current)
112
+ ) or (not legacy_pending and _present(legacy)):
113
+ raise ReconciliationError("mixed reconciliation state requires explicit repair")
114
+ return legacy if legacy_pending else current
115
+
116
+
56
117
  def _mutation_request(pending: dict[str, Any], receipt: str) -> dict[str, Any]:
57
118
  identity = pending["identity"]
58
119
  request = {
@@ -72,7 +133,7 @@ def _reconciliation_lock(root: Path, artifact_id: str):
72
133
  """Serialize all transitions for one artifact, including authority revocation."""
73
134
  if ID_RE.fullmatch(artifact_id) is None:
74
135
  raise ReconciliationError("reconciliation ID is invalid")
75
- parent = _approvallib._safe_directory(root, PENDING_DIR, create=True)
136
+ parent = _state_file(root, PENDING_DIR, artifact_id).parent
76
137
  path = parent / f"{artifact_id}.lock"
77
138
  flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0)
78
139
  fd = os.open(path, flags, 0o600)
@@ -147,11 +208,11 @@ def _write_attempt(root: Path, pending: dict[str, Any], request: dict[str, Any])
147
208
  "format": "codearbiter.reconciliation-attempt/0.1.0",
148
209
  "pending_sha256": _digest(_canonical(pending)), "request": request,
149
210
  }
150
- _write_atomic_new(root, _attempt(pending), _canonical(record))
211
+ _write_atomic_new(_state_root(root), _attempt(pending), _canonical(record))
151
212
 
152
213
 
153
214
  def _load_attempt(root: Path, pending: dict[str, Any]) -> dict[str, Any] | None:
154
- path = root / _attempt(pending)
215
+ path = _attempt_path(root, pending)
155
216
  try:
156
217
  info = path.lstat()
157
218
  except FileNotFoundError:
@@ -225,6 +286,8 @@ def _arm_reconciliation_unlocked(
225
286
  token = token or secrets.token_urlsafe(24)
226
287
  if TOKEN_RE.fullmatch(token) is None:
227
288
  raise ReconciliationError("reconciliation token is invalid")
289
+ if _present(_legacy_pending(root, artifact_id)):
290
+ raise ReconciliationError("PENDING_RECONCILIATION: legacy request must be cancelled or recovered")
228
291
  identity, snapshot, record = _current(client, artifact_id, target_id)
229
292
  reply = (
230
293
  f"reconcile-task {artifact_id} {target_id} {target_state} {token}"
@@ -240,10 +303,11 @@ def _arm_reconciliation_unlocked(
240
303
  "prompt_sha256": _digest(reply.encode()),
241
304
  }
242
305
  relative = _pending(artifact_id)
306
+ state = _state_root(root)
243
307
  created = False
244
308
  try:
245
309
  pending_bytes = _canonical(pending)
246
- _write_atomic_new(root, relative, pending_bytes)
310
+ _write_atomic_new(state, relative, pending_bytes)
247
311
  created = True
248
312
  _artifactpromptlib.register(
249
313
  root, "reconciliation", artifact_id, reply,
@@ -254,7 +318,7 @@ def _arm_reconciliation_unlocked(
254
318
  except Exception:
255
319
  if created:
256
320
  try:
257
- (root / relative).unlink()
321
+ (state / relative).unlink()
258
322
  except OSError:
259
323
  pass
260
324
  raise
@@ -267,7 +331,7 @@ def _arm_reconciliation_unlocked(
267
331
 
268
332
  def _load(root: Path, artifact_id: str) -> dict[str, Any]:
269
333
  try:
270
- path = root / _pending(artifact_id)
334
+ path = _pending_path(root, artifact_id)
271
335
  info = path.lstat()
272
336
  reparse = getattr(info, "st_file_attributes", 0) & 0x400
273
337
  if stat.S_ISLNK(info.st_mode) or reparse or not stat.S_ISREG(info.st_mode) or info.st_size > 131072:
@@ -328,7 +392,7 @@ def _cancel_reconciliation_unlocked(root: Path, artifact_id: str, prompt: str) -
328
392
  raise ReconciliationError("reconciliation authority binding is unavailable") from exc
329
393
  if binding != _digest(_canonical(pending)):
330
394
  raise ReconciliationError("pending reconciliation changed after arming")
331
- (root / _pending(artifact_id)).unlink()
395
+ _pending_path(root, artifact_id).unlink()
332
396
  _artifactpromptlib.unregister(root, "reconciliation", artifact_id)
333
397
  return {"artifact_id": artifact_id, "cancelled": True}
334
398
 
@@ -349,7 +413,7 @@ def cancel_orphaned_reconciliation(root: str | Path, artifact_id: str) -> dict[s
349
413
  raise ReconciliationError("reconciliation authority binding is unreadable") from exc
350
414
  if binding == _digest(_canonical(pending)):
351
415
  raise ReconciliationError("reconciliation route is active; cancel with its exact prompt")
352
- (root / _pending(artifact_id)).unlink()
416
+ _pending_path(root, artifact_id).unlink()
353
417
  if binding is not None:
354
418
  _artifactpromptlib.unregister(root, "reconciliation", artifact_id)
355
419
  return {"artifact_id": artifact_id, "cancelled": True, "orphaned": True}
@@ -379,9 +443,10 @@ def recover_reconciliation(root: str | Path, client: Any, artifact_id: str) -> d
379
443
  raise
380
444
  result = None
381
445
  committed = False
382
- (root / _pending(artifact_id)).unlink()
446
+ attempt_path = _attempt_path(root, pending)
447
+ _pending_path(root, artifact_id).unlink()
383
448
  _artifactpromptlib.unregister(root, "reconciliation", artifact_id)
384
- (root / _attempt(pending)).unlink()
449
+ attempt_path.unlink()
385
450
  return {
386
451
  "artifact_id": artifact_id, "committed": committed,
387
452
  "revision": result.get("revision") if committed else None,
@@ -407,6 +472,8 @@ def _consume_reconciliation_unlocked(
407
472
  parts = prompt.split(" ")
408
473
  artifact_id, target_id = parts[1], parts[2]
409
474
  pending = _load(root, artifact_id)
475
+ if _present(_legacy_pending(root, artifact_id)):
476
+ raise ReconciliationError("legacy reconciliation must be cancelled or recovered before consumption")
410
477
  if not isinstance(host, str) or not host or not isinstance(session_id, str) or not session_id:
411
478
  raise ReconciliationError("reconciliation host context is invalid")
412
479
  try:
@@ -456,9 +523,10 @@ def _consume_reconciliation_unlocked(
456
523
  if request is None:
457
524
  raise ReconciliationError("in-flight reconciliation record disappeared")
458
525
  result = client.call(pending["operation"], request)
459
- (root / _pending(artifact_id)).unlink()
526
+ attempt_path = _attempt_path(root, pending)
527
+ _pending_path(root, artifact_id).unlink()
460
528
  _artifactpromptlib.unregister(root, "reconciliation", artifact_id)
461
- (root / _attempt(pending)).unlink()
529
+ attempt_path.unlink()
462
530
  return {"matched": True, "reconciled": True, "artifact_id": artifact_id, "target_id": target_id, "receipt": request["receipt"], "revision": result.get("revision")}
463
531
 
464
532
 
@@ -205,16 +205,76 @@ def model_pill(model, effort=""):
205
205
 
206
206
 
207
207
  def usage_row(label, tin, tout, cost, trail=""):
208
- """One row of the Session/Today mini-table: label │ ↓in ↑out │ $cost. in/out are
209
- fresh (sent) tokens; `cost` is Claude Code's real session cost (cost.total_cost_usd),
210
- not an estimate. `trail` carries extras (session age, burn sparkline)."""
208
+ """One row of the Session/Today mini-table: label | down-in up-out | cost.
209
+ in/out are fresh (sent) tokens. `cost` is a pre-rendered cell from
210
+ cost_cell(), whose label carries the figure's provenance; a bare number is
211
+ still accepted for callers outside the usage block. `trail` carries extras
212
+ (the burn sparkline)."""
213
+ money = cost if isinstance(cost, str) else f"{OK}{usd_fine(cost)}{RESET}"
211
214
  base = (f"{GREY}{label:<7}{RESET} {V0}{_VBAR}{RESET} "
212
215
  f"{V2}{DN}{RESET} {WHITE}{fmt_tok(tin):>6}{RESET} "
213
216
  f"{V2}{UP}{RESET} {WHITE}{fmt_tok(tout):>6}{RESET} {V0}{_VBAR}{RESET} "
214
- f"{OK}{usd_fine(cost)}{RESET}")
217
+ f"{money}")
215
218
  return base + (f" {trail}" if trail else "")
216
219
 
217
220
 
221
+ # Visible columns of usage_row() before the cost cell: label(7) + separators and
222
+ # two right-aligned 6-column token counts.
223
+ USAGE_ROW_FIXED = 30
224
+ # Coverage reasons that mean observed usage could not be priced at all.
225
+ PRICING_REASONS = frozenset({"unknown_model", "unpriced_modifier", "unknown_cache_ttl",
226
+ "unknown_charge", "unknown_iteration", "model_conflict"})
227
+
228
+
229
+ def cost_label(scope, allow_host=False, compact=False):
230
+ """(text, tone) for one Session/Today dollar figure, from the ledger's
231
+ structured coverage (spec AC-01/02, D-19). The renderer never infers
232
+ completeness from a number:
233
+ api≈$N complete reconstruction at pinned list rates
234
+ api≥$N a true lower bound: partial coverage, or still catching up
235
+ api≈? no defensible dollar figure: grey when nothing is reconstructed
236
+ yet (neutral, not a warning), amber when usage exists but
237
+ none of it could be priced
238
+ host≈$N Session only: Claude Code's own estimate, when reconstruction
239
+ is unavailable (never Today, never mixed with api figures)
240
+ A trailing `*` marks a stale snapshot, shown because this render lost the
241
+ ledger lock. `compact` drops the api/host words and keeps the provenance
242
+ symbol, for narrow boxes."""
243
+ scope = scope if isinstance(scope, dict) else {}
244
+ state = scope.get("state") or "unavailable"
245
+ api = "" if compact else "api"
246
+ host = scope.get("host") if allow_host else None
247
+ if state == "unavailable":
248
+ if isinstance(host, (int, float)) and not isinstance(host, bool) and host >= 0:
249
+ text, tone = ("h" if compact else "host") + "≈" + usd_fine(host), "host"
250
+ else:
251
+ text, tone = api + "≈?", "none"
252
+ elif state == "complete":
253
+ text, tone = api + "≈" + usd_fine(scope.get("cost")), "ok"
254
+ else:
255
+ reasons = set(scope.get("reasons") or ())
256
+ used = num(scope.get("in")) + num(scope.get("out")) > 0
257
+ if num(scope.get("pd")) <= 0 and used and reasons & PRICING_REASONS:
258
+ text, tone = api + "≈?", "unknown"
259
+ else:
260
+ text, tone = api + "≥" + usd_fine(scope.get("cost")), "partial"
261
+ if scope.get("stale"):
262
+ text += "*"
263
+ return text, tone
264
+
265
+
266
+ def cost_cell(scope, width, allow_host=False):
267
+ """The colored cost cell: the full label when it fits `width` visible
268
+ columns, else the compact form, so a figure is never clipped mid-number."""
269
+ text, tone = cost_label(scope, allow_host)
270
+ if len(text) > width:
271
+ text, tone = cost_label(scope, allow_host, compact=True)
272
+ # Amber only where coverage genuinely needs attention; "none" (nothing
273
+ # reconstructed) stays neutral, since amber is a status signal.
274
+ color = {"ok": OK, "partial": WARN, "unknown": WARN, "host": GREY, "none": GREY}.get(tone, GREY)
275
+ return f"{color}{text}{RESET}"
276
+
277
+
218
278
  def seg_lines(data):
219
279
  add = get(data, "cost", "total_lines_added")
220
280
  rem = get(data, "cost", "total_lines_removed")
@@ -2,10 +2,12 @@
2
2
  # codeArbiter — statusline subagent-rows helpers (extracted from statusline.py,
3
3
  # architecture-004).
4
4
  #
5
- # Owns resolving the current session's subagents directory, reading its recent
6
- # `.jsonl` transcripts (deduped per requestId, same rule as the Session/Today
7
- # rows), and labeling each subagent from its first user message. Carries no
8
- # rendering concern — the statusline turns the returned dicts into rows.
5
+ # Owns resolving the current session's subagents directory and turning the
6
+ # accounting scanner's per-child presentation summaries (from the ledger record)
7
+ # into rows, plus labeling a subagent from its first user message (sub_label is
8
+ # called BY that scanner). It never parses a child transcript itself: the render
9
+ # path has one JSONL parser (_ledgerlib). No rendering concern — the statusline
10
+ # turns the returned dicts into rows.
9
11
  #
10
12
  # Design principles (mirroring _ledgerlib.py):
11
13
  # - Stdlib only; no third-party imports ever.
@@ -14,11 +16,10 @@
14
16
  #
15
17
  # Public API:
16
18
  # subagent_dir(data, root, sid) -> str|None
17
- # read_subagents(sdir) -> (active, recent, shown, (tot_in, tot_out))
19
+ # read_subagents(sdir, rec=None) -> (active, recent, shown, (tot_in, tot_out))
18
20
  # sub_label(content) -> str
19
21
  # display_model(model_id) -> str
20
22
 
21
- import json
22
23
  import os
23
24
  import re
24
25
  import time
@@ -35,8 +36,7 @@ except Exception: # pragma: no cover — never let an import break the statusli
35
36
  ACTIVE_WINDOW = 150 # secs: a subagent file touched this recently is "active"
36
37
  SHOW_WINDOW = 600 # secs: still display recently-finished subagents
37
38
  MAX_SUB_ROWS = 4
38
- MAX_SUB_FILES = 12 # hot-path bound: parse at most this many files / render
39
- MAX_SUB_LINES = 2500 # per-file line cap
39
+ MAX_SUB_FILES = 12 # rows considered per render (presentation bound, not accounting)
40
40
 
41
41
 
42
42
  def display_model(model_id):
@@ -88,17 +88,24 @@ def subagent_dir(data, root, sid):
88
88
  return None
89
89
 
90
90
 
91
- def read_subagents(sdir):
92
- """Return (active, recent, shown[{label,inp,out,age,active}], (tot_in, tot_out)).
91
+ def read_subagents(sdir, rec=None):
92
+ """Return (active, recent, shown[{label,model,inp,out,age,active}], (tot_in, tot_out)).
93
93
  `active` = files touched within ACTIVE_WINDOW (a liveness proxy); `recent` =
94
94
  all files within SHOW_WINDOW (active + recently finished).
95
95
 
96
+ Rows come from the accounting scanner's per-source PRESENTATION SUMMARIES in
97
+ the ledger record `rec` (spec D-7): label, observed models and owner-attributed
98
+ tokens, with fork-replayed parent requests excluded. This reader only lists
99
+ the directory and stats files for liveness. It never opens or parses a child
100
+ transcript, so the render path has exactly one JSONL parser. A child the
101
+ scanner has not reached yet shows its fallback name and zero tokens until it
102
+ has.
103
+
96
104
  ONE result shape on EVERY path (#413). The element types are stable — int,
97
105
  int, list, (int|float, int|float) — so a caller may unpack four names
98
106
  unconditionally. statusline.py does exactly that, OUTSIDE its safe()
99
- wrapper, so an error branch returning a shorter tuple was a hard ValueError
100
- that erased the entire subagent section on a routine directory race. An
101
- unreadable/absent directory is an EMPTY result, not a different result."""
107
+ wrapper, so an error branch returning a shorter tuple would be a hard
108
+ ValueError. An unreadable/absent directory is an EMPTY result."""
102
109
  empty = (0, 0, [], (0, 0))
103
110
  now = time.time()
104
111
  files = []
@@ -117,61 +124,20 @@ def read_subagents(sdir):
117
124
  return empty
118
125
  files.sort(reverse=True) # most-recently-touched first
119
126
 
127
+ sources = rec.get("src") if isinstance(rec, dict) else None
128
+ sources = sources if isinstance(sources, dict) else {}
120
129
  active = sum(1 for mtime, _, _, _ in files if now - mtime <= ACTIVE_WINDOW)
121
130
  shown, tot_in, tot_out = [], 0, 0
122
- for mtime, size, fp, nm in files[:MAX_SUB_FILES]:
131
+ for mtime, size, _fp, nm in files[:MAX_SUB_FILES]:
123
132
  if size > 16 * 1024 * 1024:
124
133
  continue
125
- reqs = {}
126
- models = []
127
- label = None
128
- try:
129
- with open(fp, encoding="utf-8", errors="replace") as f:
130
- for i, ln in enumerate(f):
131
- if i > MAX_SUB_LINES:
132
- break
133
- ln = ln.strip()
134
- if not ln:
135
- continue
136
- try:
137
- d = json.loads(ln)
138
- except ValueError:
139
- continue
140
- if not isinstance(d, dict):
141
- # A transcript line may be ANY valid JSON value; only an
142
- # object carries an event. `[]`/`null`/`3`/`"s"` used to
143
- # reach .get() below inside a try that caught OSError
144
- # only, so one such line raised AttributeError out of
145
- # read_subagents and blanked every subagent row (#413).
146
- continue
147
- msg = d.get("message")
148
- if not isinstance(msg, dict):
149
- continue
150
- if label is None and msg.get("role") == "user":
151
- label = sub_label(msg.get("content"))
152
- if msg.get("role") == "assistant":
153
- raw_model = msg.get("model")
154
- if isinstance(raw_model, str) and raw_model.strip():
155
- raw_model = raw_model.strip()
156
- if raw_model not in models:
157
- models.append(raw_model)
158
- u = msg.get("usage")
159
- if isinstance(u, dict):
160
- # dedupe by requestId; fresh input only (cache reads excluded)
161
- # — same definition as the Session/Today rows.
162
- reqs[d.get("requestId") or msg.get("id") or i] = (
163
- num(u.get("input_tokens")) + num(u.get("cache_creation_input_tokens")),
164
- num(u.get("output_tokens")))
165
- except Exception: # noqa: BLE001 — #413: the error boundary is PER FILE
166
- # One unreadable or structurally surprising transcript must cost
167
- # only its own row, never the rest of the directory. Widened from
168
- # OSError so a shape the reader has not anticipated degrades the
169
- # same way malformed JSON already does (the module's standing
170
- # "never raise on malformed input" contract) instead of escaping
171
- # into the statusline and blanking every subagent row.
172
- continue
173
- inp = sum(v[0] for v in reqs.values())
174
- out = sum(v[1] for v in reqs.values())
134
+ summary = sources.get("c:" + nm)
135
+ summary = summary if isinstance(summary, dict) else {}
136
+ tok = summary.get("tok")
137
+ inp, out = ((num(tok[0]), num(tok[1])) if isinstance(tok, list) and len(tok) == 2
138
+ else (0, 0))
139
+ models = [m for m in (summary.get("models") or []) if isinstance(m, str)]
140
+ label = summary.get("label") if isinstance(summary.get("label"), str) else None
175
141
  tot_in += inp
176
142
  tot_out += out
177
143
  if len(shown) < MAX_SUB_ROWS: