ccx-native 0.0.1 → 0.2.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.
Files changed (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +240 -4
  3. package/bin/ccx +55 -2
  4. package/ccx/__init__.py +167 -0
  5. package/ccx/__main__.py +28 -0
  6. package/ccx/claude/__init__.py +0 -0
  7. package/ccx/claude/analytics.py +466 -0
  8. package/ccx/claude/attach.py +807 -0
  9. package/ccx/claude/context.py +465 -0
  10. package/ccx/claude/delivery.py +806 -0
  11. package/ccx/claude/external.py +253 -0
  12. package/ccx/claude/host/__init__.py +0 -0
  13. package/ccx/claude/host/events.py +297 -0
  14. package/ccx/claude/host/hooks.py +438 -0
  15. package/ccx/claude/host/pty.py +269 -0
  16. package/ccx/claude/host/session.py +5244 -0
  17. package/ccx/claude/host/subagents.py +169 -0
  18. package/ccx/claude/host/successor.py +267 -0
  19. package/ccx/claude/managed_turns.py +240 -0
  20. package/ccx/claude/oauth.py +176 -0
  21. package/ccx/claude/settings.py +339 -0
  22. package/ccx/claude/spawn.py +557 -0
  23. package/ccx/claude/tasks.py +72 -0
  24. package/ccx/claude/telemetry.py +282 -0
  25. package/ccx/claude/transcript.py +2535 -0
  26. package/ccx/claude/usage.py +796 -0
  27. package/ccx/claude/usage_control.py +204 -0
  28. package/ccx/cli/__init__.py +0 -0
  29. package/ccx/cli/codex.py +1009 -0
  30. package/ccx/cli/commands/__init__.py +0 -0
  31. package/ccx/cli/commands/_common.py +407 -0
  32. package/ccx/cli/commands/control.py +161 -0
  33. package/ccx/cli/commands/create.py +669 -0
  34. package/ccx/cli/commands/models.py +53 -0
  35. package/ccx/cli/commands/read.py +1104 -0
  36. package/ccx/cli/commands/turns.py +1081 -0
  37. package/ccx/cli/controller.py +757 -0
  38. package/ccx/cli/main.py +302 -0
  39. package/ccx/cli/parser.py +1439 -0
  40. package/ccx/cli/skills.py +215 -0
  41. package/ccx/cli/statusline.py +160 -0
  42. package/ccx/client.py +1904 -0
  43. package/ccx/codex/__init__.py +0 -0
  44. package/ccx/codex/attach_tty.py +267 -0
  45. package/ccx/codex/client.py +738 -0
  46. package/ccx/codex/completion.py +121 -0
  47. package/ccx/codex/control.py +288 -0
  48. package/ccx/codex/daemon/__init__.py +0 -0
  49. package/ccx/codex/daemon/__main__.py +4 -0
  50. package/ccx/codex/daemon/daemon.py +1558 -0
  51. package/ccx/codex/daemon/models.py +140 -0
  52. package/ccx/codex/daemon/native.py +1590 -0
  53. package/ccx/codex/daemon/release.py +367 -0
  54. package/ccx/codex/daemon/worker.py +2424 -0
  55. package/ccx/codex/delivery.py +758 -0
  56. package/ccx/codex/delivery_recovery.py +232 -0
  57. package/ccx/codex/evidence.py +827 -0
  58. package/ccx/codex/history.py +158 -0
  59. package/ccx/codex/history_store.py +131 -0
  60. package/ccx/codex/notify_history.py +140 -0
  61. package/ccx/codex/ops.py +666 -0
  62. package/ccx/codex/paths.py +70 -0
  63. package/ccx/codex/protocol.py +805 -0
  64. package/ccx/codex/provenance.py +105 -0
  65. package/ccx/codex/read.py +29 -0
  66. package/ccx/codex/records.py +51 -0
  67. package/ccx/codex/reports.py +177 -0
  68. package/ccx/codex/runtime.py +1454 -0
  69. package/ccx/codex/settle.py +363 -0
  70. package/ccx/codex/telemetry.py +792 -0
  71. package/ccx/codex/types.py +659 -0
  72. package/ccx/codex/worker_environment.py +25 -0
  73. package/ccx/constants.py +288 -0
  74. package/ccx/controllers/__init__.py +0 -0
  75. package/ccx/controllers/controller.py +1449 -0
  76. package/ccx/controllers/live_vars.py +1706 -0
  77. package/ccx/controllers/live_vars_runner.py +216 -0
  78. package/ccx/controllers/notify.py +213 -0
  79. package/ccx/controllers/orchestrator.py +193 -0
  80. package/ccx/controllers/policy.py +57 -0
  81. package/ccx/controllers/store.py +681 -0
  82. package/ccx/errors.py +634 -0
  83. package/ccx/models.py +225 -0
  84. package/ccx/notify/__init__.py +0 -0
  85. package/ccx/notify/__main__.py +5 -0
  86. package/ccx/notify/client.py +116 -0
  87. package/ccx/notify/daemon.py +480 -0
  88. package/ccx/notify/deliver.py +304 -0
  89. package/ccx/notify/paths.py +51 -0
  90. package/ccx/notify/registry.py +649 -0
  91. package/ccx/notify/select.py +750 -0
  92. package/ccx/notify/settle.py +684 -0
  93. package/ccx/ops/__init__.py +0 -0
  94. package/ccx/ops/background.py +115 -0
  95. package/ccx/ops/cancel.py +296 -0
  96. package/ccx/ops/codex_import.py +96 -0
  97. package/ccx/ops/common.py +397 -0
  98. package/ccx/ops/compact.py +337 -0
  99. package/ccx/ops/compact_ops.py +550 -0
  100. package/ccx/ops/context_events.py +48 -0
  101. package/ccx/ops/conversation.py +1593 -0
  102. package/ccx/ops/conversation_paging.py +1339 -0
  103. package/ccx/ops/create.py +56 -0
  104. package/ccx/ops/creation.py +952 -0
  105. package/ccx/ops/creation_steps.py +575 -0
  106. package/ccx/ops/event_feed.py +710 -0
  107. package/ccx/ops/forking.py +711 -0
  108. package/ccx/ops/history.py +1041 -0
  109. package/ccx/ops/input.py +556 -0
  110. package/ccx/ops/maintenance.py +295 -0
  111. package/ccx/ops/report.py +773 -0
  112. package/ccx/ops/semantics.py +490 -0
  113. package/ccx/ops/send.py +961 -0
  114. package/ccx/ops/steer_ops.py +867 -0
  115. package/ccx/ops/terminate.py +383 -0
  116. package/ccx/ops/turn_reports.py +348 -0
  117. package/ccx/ops/wait.py +762 -0
  118. package/ccx/ops/worker_events.py +640 -0
  119. package/ccx/rpc/__init__.py +0 -0
  120. package/ccx/rpc/local_rpc.py +1602 -0
  121. package/ccx/rpc/rpc.py +559 -0
  122. package/ccx/rpc/serve.py +374 -0
  123. package/ccx/rpc/transport.py +409 -0
  124. package/ccx/rpc/view.py +125 -0
  125. package/ccx/store/__init__.py +0 -0
  126. package/ccx/store/discovery.py +170 -0
  127. package/ccx/store/identity.py +105 -0
  128. package/ccx/store/journal_io.py +79 -0
  129. package/ccx/store/liveness.py +194 -0
  130. package/ccx/store/paths.py +250 -0
  131. package/ccx/store/record_cache.py +40 -0
  132. package/ccx/store/records.py +309 -0
  133. package/ccx/store/root.py +789 -0
  134. package/ccx/store/runtime_presence.py +137 -0
  135. package/ccx/store/store.py +2332 -0
  136. package/ccx/store/worker_state.py +86 -0
  137. package/ccx/system/__init__.py +0 -0
  138. package/ccx/system/bounded.py +296 -0
  139. package/ccx/system/cwd_paths.py +25 -0
  140. package/ccx/system/engine_binary.py +59 -0
  141. package/ccx/system/executable.py +50 -0
  142. package/ccx/system/oslayer.py +667 -0
  143. package/ccx/system/pipes.py +46 -0
  144. package/ccx/system/private_json.py +54 -0
  145. package/ccx/system/sanitize.py +43 -0
  146. package/ccx/types.py +809 -0
  147. package/ccx/version.py +29 -0
  148. package/ccx/workers.py +2093 -0
  149. package/ccx.py +9 -0
  150. package/package.json +39 -7
  151. package/pyproject.toml +87 -0
  152. package/skills/ccx/SKILL.md +166 -0
  153. package/skills/ccx/references/controllers.md +244 -0
  154. package/skills/ccx/references/history.md +50 -0
  155. package/skills/ccx/references/troubleshooting.md +94 -0
  156. package/skills/ccx/references/variables.md +62 -0
@@ -0,0 +1,550 @@
1
+ """Durable operation journal for managed Claude ``/compact`` requests.
2
+
3
+ Compaction is unusual among CCX operations: Claude records its result in the
4
+ transcript, while the host also has to release the PTY input surface. A client
5
+ must never infer failure merely because the host stopped saying "compacting";
6
+ the result may have landed between two observations. This module gives the
7
+ host and all clients one durable, monotonic operation record to coordinate on.
8
+
9
+ The journal is a bounded sidecar rather than a field in the session record.
10
+ Session records have several independent writers (analytics, lifecycle,
11
+ dashboard projections); keeping operations separate prevents a stale
12
+ read-modify-write from erasing a terminal compact result.
13
+
14
+ The journal itself has writers in more than one process too: the host, and a
15
+ CLI/Python caller that reconciles from the transcript when the host does not
16
+ answer in time. Every mutation therefore holds a bounded exclusive ``flock``
17
+ on ``<short>.compact.lock`` across its read -> mutate -> replace cycle, the
18
+ same discipline as the steering journal. Reads stay lock-free because
19
+ publication is one atomic ``os.replace``.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import os
25
+ import pathlib
26
+ import time
27
+ import uuid
28
+ from typing import Any
29
+
30
+ from ccx.store.journal_io import journal_lock as _journal_lock, write_json
31
+ from ccx.store.paths import compact_operations_path
32
+ from ccx.claude.transcript import PinnedNativeSource, api_error_reason, compact_result_since
33
+
34
+ JOURNAL_VERSION = 1
35
+ JOURNAL_KEEP = 32
36
+
37
+ PENDING_STATUSES = frozenset({"accepted", "pasted", "submitted"})
38
+ TERMINAL_STATUSES = frozenset({"succeeded", "noop", "failed", "unknown"})
39
+ ALL_STATUSES = PENDING_STATUSES | TERMINAL_STATUSES
40
+
41
+ def compact_intent_digest(
42
+ worker_id: str, *, generation: str, native_session_id: str,
43
+ operation_id: str,
44
+ ) -> str:
45
+ """Immutable original Claude /compact intent shared by caller and host.
46
+
47
+ Observation wait and host execution budgets are deliberately not part of
48
+ native effect identity: changing either cannot authorize a second paste.
49
+ """
50
+ from ccx.store.store import _digest
51
+
52
+ return _digest({
53
+ "workerId": worker_id,
54
+ "engine": "claude",
55
+ "generation": generation,
56
+ "nativeSessionId": native_session_id,
57
+ "operationId": operation_id,
58
+ "action": "compact",
59
+ })
60
+
61
+
62
+ _ALLOWED_TRANSITIONS = {
63
+ "accepted": frozenset({"accepted", "pasted", "submitted", "failed", "unknown",
64
+ "succeeded", "noop"}),
65
+ "pasted": frozenset({"pasted", "submitted", "failed", "unknown",
66
+ "succeeded", "noop"}),
67
+ # A submitted compact may reach `failed` only on positive transcript
68
+ # evidence — Claude's own stderr verdict — never on a bare timeout. Absence
69
+ # of a result stays `unknown`, because not hearing back is not proof of
70
+ # failure; that distinction is the whole point of this journal.
71
+ "submitted": frozenset({"submitted", "unknown", "succeeded", "noop", "failed"}),
72
+ "succeeded": frozenset({"succeeded"}),
73
+ "noop": frozenset({"noop"}),
74
+ "failed": frozenset({"failed"}),
75
+ # A late transcript boundary is stronger evidence than a prior inability to
76
+ # decide, so unknown alone is allowed to resolve to success/no-op — or to a
77
+ # failure verdict that only appeared in the transcript after we gave up.
78
+ "unknown": frozenset({"unknown", "succeeded", "noop", "failed"}),
79
+ }
80
+
81
+
82
+ class CompactJournalError(RuntimeError):
83
+ """The durable compact journal exists but cannot be trusted."""
84
+
85
+
86
+ class CompactJournalBusy(CompactJournalError):
87
+ """Another writer holds the journal lock; nothing was written."""
88
+
89
+
90
+ #: The host's select loop is single-threaded: it must never park behind a
91
+ #: wedged peer. Out-of-host callers may wait longer, never forever.
92
+ HOST_LOCK_TIMEOUT_S = 2.0
93
+ CLIENT_LOCK_TIMEOUT_S = 15.0
94
+
95
+
96
+ def lock_path(short: str) -> pathlib.Path:
97
+ return compact_operations_path(short).with_suffix(".lock")
98
+
99
+
100
+ def journal_lock(short: str, *, timeout: float | None = None):
101
+ return _journal_lock(
102
+ lock_path(short),
103
+ timeout=CLIENT_LOCK_TIMEOUT_S if timeout is None else timeout,
104
+ busy_error=CompactJournalBusy, label=f"compact journal for {short}",
105
+ )
106
+
107
+
108
+ def now_ms() -> int:
109
+ return int(time.time() * 1000)
110
+
111
+
112
+ def new_operation_id() -> str:
113
+ return str(uuid.uuid4())
114
+
115
+
116
+ def transcript_offset(path: str | pathlib.Path | PinnedNativeSource) -> int:
117
+ try:
118
+ return (path.size() if isinstance(path, PinnedNativeSource)
119
+ else pathlib.Path(path).stat().st_size)
120
+ except OSError:
121
+ return 0
122
+
123
+
124
+ def transcript_identity(
125
+ path: str | pathlib.Path | PinnedNativeSource,
126
+ ) -> tuple[int | None, int | None]:
127
+ try:
128
+ stat = (os.fstat(path.fd) if isinstance(path, PinnedNativeSource)
129
+ else pathlib.Path(path).stat())
130
+ except OSError:
131
+ return None, None
132
+ return stat.st_dev, stat.st_ino
133
+
134
+
135
+ def empty_journal() -> dict[str, Any]:
136
+ return {"version": JOURNAL_VERSION, "operations": []}
137
+
138
+
139
+ def read_journal(short: str) -> dict[str, Any]:
140
+ """Read the exact generation-owned compact sidecar through verified dir fds."""
141
+ from ccx import constants
142
+ from ccx.store.root import require_ccx_active
143
+ store = require_ccx_active(constants.CCX_ROOT)
144
+ value = store.read_worker_sidecar(short, "compact.json")
145
+ if value is None:
146
+ return empty_journal()
147
+ if not isinstance(value, dict) or not isinstance(value.get("operations"), list):
148
+ raise CompactJournalError("compact journal has an invalid shape")
149
+ if value.get("version") != JOURNAL_VERSION or any(
150
+ not isinstance(op, dict) or op.get("status") not in ALL_STATUSES
151
+ or not isinstance(op.get("operation_id"), str) or not op["operation_id"]
152
+ for op in value["operations"]
153
+ ):
154
+ raise CompactJournalError("compact journal has invalid operation evidence")
155
+ return value
156
+
157
+
158
+ def write_journal(short: str, journal: dict[str, Any]) -> None:
159
+ path = compact_operations_path(short)
160
+ path.parent.mkdir(parents=True, exist_ok=True)
161
+ operations = list(journal.get("operations") or [])
162
+ # Never evict pending operations: each retains unresolved native evidence.
163
+ pending = [op for op in operations if op.get("status") in PENDING_STATUSES]
164
+ terminal = [op for op in operations if op.get("status") not in PENDING_STATUSES]
165
+ kept = terminal[-max(0, JOURNAL_KEEP - len(pending)):] + pending
166
+ value = {
167
+ "version": JOURNAL_VERSION,
168
+ "updated_at": now_ms(),
169
+ "operations": kept,
170
+ }
171
+ write_json(path, value)
172
+
173
+
174
+ def get_operation(short: str, operation_id: str | None) -> dict | None:
175
+ if not operation_id:
176
+ return None
177
+ for operation in reversed(read_journal(short).get("operations") or []):
178
+ if operation.get("operation_id") == operation_id:
179
+ return dict(operation)
180
+ return None
181
+
182
+
183
+ def latest_operation(short: str) -> dict | None:
184
+ operations = read_journal(short).get("operations") or []
185
+ return dict(operations[-1]) if operations else None
186
+
187
+
188
+ def active_operation(short: str) -> dict | None:
189
+ for operation in reversed(read_journal(short).get("operations") or []):
190
+ if operation.get("status") in PENDING_STATUSES:
191
+ return dict(operation)
192
+ return None
193
+
194
+
195
+ def begin_operation(
196
+ short: str,
197
+ *,
198
+ operation_id: str,
199
+ transcript: str,
200
+ timeout_s: float,
201
+ host_epoch: str,
202
+ lock_timeout: float | None = None,
203
+ ) -> tuple[dict, bool]:
204
+ """Create an accepted operation, or return the idempotent prior operation.
205
+
206
+ A different pending operation is a conflict.
207
+ """
208
+ with journal_lock(short, timeout=lock_timeout):
209
+ return _begin_operation_locked(
210
+ short, operation_id=operation_id, transcript=transcript,
211
+ timeout_s=timeout_s, host_epoch=host_epoch)
212
+
213
+
214
+ def _begin_operation_locked(
215
+ short: str,
216
+ *,
217
+ operation_id: str,
218
+ transcript: str,
219
+ timeout_s: float,
220
+ host_epoch: str,
221
+ ) -> tuple[dict, bool]:
222
+ journal = read_journal(short)
223
+ operations = journal.get("operations") or []
224
+ for operation in operations:
225
+ if operation.get("operation_id") == operation_id:
226
+ return dict(operation), False
227
+ for operation in operations:
228
+ if operation.get("status") in PENDING_STATUSES:
229
+ raise RuntimeError(
230
+ f"compact operation already active: {operation.get('operation_id')}"
231
+ )
232
+ accepted_at = now_ms()
233
+ fence_dev, fence_inode = transcript_identity(transcript)
234
+ operation = {
235
+ "operation_id": operation_id,
236
+ "status": "accepted",
237
+ "accepted_at": accepted_at,
238
+ "updated_at": accepted_at,
239
+ "timeout_s": float(timeout_s),
240
+ "host_epoch": host_epoch,
241
+ "transcript": transcript,
242
+ "fence_offset": transcript_offset(transcript),
243
+ "fence_dev": fence_dev,
244
+ "fence_inode": fence_inode,
245
+ "submitted": False,
246
+ "result": None,
247
+ "reason": None,
248
+ "evidence_uuid": None,
249
+ }
250
+ operations.append(operation)
251
+ journal["operations"] = operations
252
+ write_journal(short, journal)
253
+ return dict(operation), True
254
+
255
+
256
+ def transition_operation(
257
+ short: str,
258
+ operation_id: str,
259
+ status: str,
260
+ *,
261
+ lock_timeout: float | None = None,
262
+ **patch: Any,
263
+ ) -> dict:
264
+ """Move one operation forward, under the journal lock.
265
+
266
+ Raises ``ValueError`` for a non-monotonic move (another writer already
267
+ advanced it), ``KeyError`` for an unknown id, and
268
+ :class:`CompactJournalBusy` when the lock is not acquired in time.
269
+ """
270
+ if status not in ALL_STATUSES:
271
+ raise ValueError(f"invalid compact operation status: {status}")
272
+ with journal_lock(short, timeout=lock_timeout):
273
+ return _transition_operation_locked(short, operation_id, status, **patch)
274
+
275
+
276
+ def _transition_operation_locked(
277
+ short: str,
278
+ operation_id: str,
279
+ status: str,
280
+ **patch: Any,
281
+ ) -> dict:
282
+ journal = read_journal(short)
283
+ operations = journal.get("operations") or []
284
+ for index, operation in enumerate(operations):
285
+ if operation.get("operation_id") != operation_id:
286
+ continue
287
+ previous = operation.get("status")
288
+ if status not in _ALLOWED_TRANSITIONS.get(previous, frozenset()):
289
+ raise ValueError(
290
+ f"non-monotonic compact transition {previous!r} -> {status!r}"
291
+ )
292
+ updated = {**operation, **patch, "status": status, "updated_at": now_ms()}
293
+ if status == "submitted":
294
+ updated["submitted"] = True
295
+ operations[index] = updated
296
+ journal["operations"] = operations
297
+ write_journal(short, journal)
298
+ return dict(updated)
299
+ raise KeyError(f"unknown compact operation: {operation_id}")
300
+
301
+
302
+ def result_status(result: dict) -> str:
303
+ """Map a transcript verdict onto a terminal journal status.
304
+
305
+ ``ok=False`` must come first: a failure is also ``compacted=False``, so
306
+ testing only that would file Claude's explicit failure as a benign no-op.
307
+ """
308
+ if result.get("ok") is False:
309
+ return "failed"
310
+ return "succeeded" if result.get("compacted") else "noop"
311
+
312
+
313
+ def failure_reason(result: dict) -> str | None:
314
+ """The stored ``reason`` for a failed verdict, or ``None`` when it succeeded.
315
+
316
+ An API failure is described by its classification; everything else (a
317
+ conversation that cannot be reduced, unstrippable media) is already a
318
+ complete sentence from Claude, so it is passed through untouched.
319
+ """
320
+ if result.get("ok") is not False:
321
+ return None
322
+ api_error = result.get("api_error")
323
+ if isinstance(api_error, dict):
324
+ return api_error_reason(api_error)
325
+ return result.get("message") or "compaction failed"
326
+
327
+
328
+ def evaluate_operation(
329
+ short: str,
330
+ operation_id: str,
331
+ *,
332
+ transcript: str | None = None,
333
+ journal: dict | None = None,
334
+ before_offset: int | None = None,
335
+ ) -> dict | None:
336
+ """Bounded projection of one original compact operation's native evidence."""
337
+ journal = read_journal(short) if journal is None else journal
338
+ operations = journal.get("operations") or []
339
+ operation = next((dict(op) for op in reversed(operations)
340
+ if op.get("operation_id") == operation_id), None)
341
+ if operation is None:
342
+ return None
343
+ if operation.get("status") in {"succeeded", "noop", "failed"}:
344
+ return operation
345
+ transcript_path = transcript or operation.get("transcript")
346
+ if not transcript_path:
347
+ return operation
348
+ current_dev, current_inode = transcript_identity(transcript_path)
349
+ fence_dev = operation.get("fence_dev")
350
+ fence_inode = operation.get("fence_inode")
351
+ if fence_dev is not None and fence_inode is not None and (
352
+ current_dev != fence_dev or current_inode != fence_inode
353
+ ):
354
+ # The append cursor belongs to a different file generation. Scanning
355
+ # the replacement from zero could turn an unrelated old boundary into
356
+ # false success, so preserve the operation's current evidence state.
357
+ return operation
358
+ offset = int(operation.get("fence_offset") or 0)
359
+ if transcript_offset(transcript_path) < offset:
360
+ return operation
361
+ # A later journal operation is an upper bound, even when its native echo
362
+ # has not arrived. Never borrow its boundary to resolve this operation.
363
+ index = next(i for i, op in enumerate(operations)
364
+ if op.get("operation_id") == operation_id)
365
+ upper = [int(op.get("fence_offset") or 0) for op in operations[index + 1:]
366
+ if op.get("transcript") == operation.get("transcript")
367
+ and op.get("fence_dev") == fence_dev
368
+ and op.get("fence_inode") == fence_inode]
369
+ if before_offset is not None:
370
+ upper.append(before_offset)
371
+ result = compact_result_since(
372
+ transcript_path,
373
+ after_offset=offset,
374
+ before_offset=min(upper) if upper else None,
375
+ bounded_manual=True,
376
+ require_command=not bool(operation.get("submitted")),
377
+ )
378
+ if result is None:
379
+ return operation
380
+ return {**operation, "status": result_status(result), "result": result,
381
+ "reason": failure_reason(result), "evidence_uuid": result.get("event_uuid")}
382
+
383
+
384
+ def reconcile_operation(
385
+ short: str, operation_id: str, *, transcript: str | None = None,
386
+ before_offset: int | None = None,
387
+ lock_timeout: float | None = None,
388
+ ) -> dict | None:
389
+ """Publish a stronger verdict using the same evaluator as strict reads.
390
+
391
+ The transcript scan runs holding no lock; the write re-validates against
392
+ the live journal. If another writer advanced the operation meanwhile, its
393
+ current state is returned rather than raising.
394
+ """
395
+ original = get_operation(short, operation_id)
396
+ evaluated = evaluate_operation(
397
+ short, operation_id, transcript=transcript, before_offset=before_offset,
398
+ )
399
+ if evaluated is None or original == evaluated:
400
+ return evaluated
401
+ try:
402
+ return transition_operation(
403
+ short, operation_id, evaluated["status"], result=evaluated.get("result"),
404
+ reason=evaluated.get("reason"), evidence_uuid=evaluated.get("evidence_uuid"),
405
+ lock_timeout=lock_timeout,
406
+ )
407
+ except ValueError:
408
+ return get_operation(short, operation_id)
409
+
410
+
411
+ def observe_original_operation(store, owner, rec: dict, operation_id: str,
412
+ *, publish: bool = True) -> dict | None:
413
+ """Evaluate original Claude evidence; optionally publish a proven verdict.
414
+
415
+ A missing/unverifiable native source cannot prove native success, failure,
416
+ or noop, even if a cached journal claims terminality. The journal remains
417
+ unchanged in that case; a later verified source can still prove the event.
418
+ """
419
+ from ccx.claude.transcript import open_original_claude_source
420
+ from ccx.store.root import StorageIntegrityError
421
+
422
+ store.revalidate()
423
+ operation = get_operation(owner.worker, operation_id)
424
+ if operation is None:
425
+ return None
426
+ try:
427
+ with open_original_claude_source(store, owner, rec) as source:
428
+ if source is None:
429
+ raise FileNotFoundError("original Claude native JSONL absent")
430
+ pinned, stat, end_offset, original_path, _dir_fd = source
431
+ fence = operation.get("fence_offset")
432
+ if (operation.get("transcript") != str(original_path)
433
+ or type(operation.get("fence_dev")) is not int
434
+ or type(operation.get("fence_inode")) is not int
435
+ or (operation["fence_dev"], operation["fence_inode"])
436
+ != (stat.st_dev, stat.st_ino)
437
+ or type(fence) is not int or not 0 <= fence <= end_offset):
438
+ raise StorageIntegrityError("original compact transcript fence differs")
439
+ # The V1 observer uses the same bounded native evaluator but
440
+ # never publishes. Python status retains the original journal
441
+ # reconciliation/lock under this one verified source fd.
442
+ store.revalidate()
443
+ if publish:
444
+ result = reconcile_operation(
445
+ owner.worker_id, operation_id, transcript=pinned,
446
+ before_offset=end_offset,
447
+ )
448
+ else:
449
+ result = evaluate_operation(
450
+ owner.worker_id, operation_id, transcript=pinned,
451
+ before_offset=end_offset,
452
+ )
453
+ store.revalidate()
454
+ return result
455
+ except (FileNotFoundError, OSError, StorageIntegrityError) as exc:
456
+ return {
457
+ **operation, "status": "unknown", "result": None,
458
+ "evidence_uuid": None,
459
+ "reason": f"original Claude compact source unavailable: {exc}",
460
+ }
461
+
462
+
463
+ def reconcile_stale_operations(
464
+ short: str,
465
+ *,
466
+ transcript: str,
467
+ current_host_epoch: str,
468
+ lock_timeout: float | None = None,
469
+ ) -> list[dict]:
470
+ """Reconcile operations inherited from a previous host process.
471
+
472
+ If the transcript proves success, preserve it. Otherwise the old host and
473
+ PTY are gone, so the truthful terminal state is unknown—not failed.
474
+ """
475
+ resolved: list[dict] = []
476
+ journal = read_journal(short)
477
+ for operation in list(journal.get("operations") or []):
478
+ if operation.get("status") not in PENDING_STATUSES:
479
+ continue
480
+ if operation.get("host_epoch") == current_host_epoch:
481
+ continue
482
+ current = reconcile_operation(
483
+ short, operation.get("operation_id"), transcript=transcript,
484
+ lock_timeout=lock_timeout,
485
+ )
486
+ if current and current.get("status") in PENDING_STATUSES:
487
+ try:
488
+ current = transition_operation(
489
+ short,
490
+ current["operation_id"],
491
+ "unknown",
492
+ reason="host restarted before terminal compact evidence",
493
+ lock_timeout=lock_timeout,
494
+ )
495
+ except ValueError:
496
+ current = get_operation(short, current["operation_id"])
497
+ if current:
498
+ resolved.append(current)
499
+ return resolved
500
+
501
+
502
+ def public_operation(operation: dict | None) -> dict | None:
503
+ """Bounded RPC-safe projection of a journal operation."""
504
+ if operation is None:
505
+ return None
506
+ result = operation.get("result") if isinstance(operation.get("result"), dict) else None
507
+ return {
508
+ "operation_id": operation.get("operation_id"),
509
+ "status": operation.get("status"),
510
+ "accepted_at": operation.get("accepted_at"),
511
+ "updated_at": operation.get("updated_at"),
512
+ "submitted": bool(operation.get("submitted")),
513
+ "reason": operation.get("reason"),
514
+ "evidence_uuid": operation.get("evidence_uuid"),
515
+ "result": dict(result) if result else None,
516
+ }
517
+
518
+
519
+ def observe_operation(
520
+ short: str,
521
+ operation_id: str,
522
+ *,
523
+ transcript: str | None,
524
+ status_reader=None,
525
+ ) -> tuple[dict | None, str | None]:
526
+ """Read one reconciled operation snapshot for CLI and Python callers.
527
+
528
+ ``status_reader`` is an optional zero-argument callback returning the live
529
+ host's RPC envelope. Any unavailable/bad host reply falls back to the
530
+ durable journal and transcript. Journal corruption deliberately propagates
531
+ so public surfaces can report an indeterminate protocol condition rather
532
+ than silently treating the operation as absent.
533
+ """
534
+ current = None
535
+ native_state = None
536
+ if status_reader is not None:
537
+ try:
538
+ reply = status_reader() or {}
539
+ except Exception:
540
+ reply = {}
541
+ native_state = reply.get("state")
542
+ if reply.get("ok"):
543
+ current = reply.get("operation")
544
+ if current is None:
545
+ current = get_operation(short, operation_id)
546
+ if current is not None:
547
+ current = reconcile_operation(
548
+ short, operation_id, transcript=transcript
549
+ ) or current
550
+ return current, native_state
@@ -0,0 +1,48 @@
1
+ """Bound material carried-context changes for the existing worker event feed."""
2
+ from __future__ import annotations
3
+
4
+ import time
5
+
6
+ FIELDS = ("tokens_used", "window_size", "percent_used", "as_of_turn_ref",
7
+ "as_of_ms", "reason")
8
+ INTERVAL_S = 5.0
9
+
10
+
11
+ def snapshot(value: dict | None) -> dict:
12
+ value = value if isinstance(value, dict) else {}
13
+ return {name: value.get(name) for name in FIELDS}
14
+
15
+
16
+ def _material(value: dict) -> tuple:
17
+ return tuple(value.get(key) for key in FIELDS if key != "as_of_ms")
18
+
19
+
20
+ class ContextChanges:
21
+ """One owner's small dedupe/throttle gate. Force bypasses the interval."""
22
+
23
+ def __init__(self):
24
+ self.last: dict | None = None
25
+ self.pending: dict | None = None
26
+ self.last_at: float | None = None
27
+
28
+ def observe(self, value: dict | None, *, force: bool = False,
29
+ now: float | None = None) -> dict | None:
30
+ current = snapshot(value)
31
+ if current["reason"] == "no-signal" and self.last is None:
32
+ return None
33
+ if self.last is not None and _material(current) == _material(self.last):
34
+ self.pending = None
35
+ return None
36
+ now = time.monotonic() if now is None else now
37
+ if not force and self.last_at is not None and now - self.last_at < INTERVAL_S:
38
+ self.pending = current
39
+ return None
40
+ self.last = current
41
+ self.pending = None
42
+ self.last_at = now
43
+ return current
44
+
45
+ def flush(self, *, now: float | None = None) -> dict | None:
46
+ if self.pending is None:
47
+ return None
48
+ return self.observe(self.pending, now=now)