ccx-native 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +241 -2
- package/bin/ccx +56 -0
- package/ccx/__init__.py +163 -0
- package/ccx/__main__.py +28 -0
- package/ccx/claude/__init__.py +0 -0
- package/ccx/claude/analytics.py +466 -0
- package/ccx/claude/attach.py +762 -0
- package/ccx/claude/context.py +465 -0
- package/ccx/claude/delivery.py +806 -0
- package/ccx/claude/external.py +253 -0
- package/ccx/claude/host/__init__.py +0 -0
- package/ccx/claude/host/events.py +297 -0
- package/ccx/claude/host/hooks.py +438 -0
- package/ccx/claude/host/pty.py +250 -0
- package/ccx/claude/host/session.py +5184 -0
- package/ccx/claude/host/subagents.py +169 -0
- package/ccx/claude/host/successor.py +267 -0
- package/ccx/claude/managed_turns.py +240 -0
- package/ccx/claude/oauth.py +176 -0
- package/ccx/claude/settings.py +339 -0
- package/ccx/claude/spawn.py +557 -0
- package/ccx/claude/tasks.py +72 -0
- package/ccx/claude/telemetry.py +282 -0
- package/ccx/claude/transcript.py +2281 -0
- package/ccx/claude/usage.py +837 -0
- package/ccx/claude/usage_control.py +152 -0
- package/ccx/cli/__init__.py +0 -0
- package/ccx/cli/codex.py +1009 -0
- package/ccx/cli/commands/__init__.py +0 -0
- package/ccx/cli/commands/_common.py +407 -0
- package/ccx/cli/commands/control.py +161 -0
- package/ccx/cli/commands/create.py +669 -0
- package/ccx/cli/commands/read.py +1020 -0
- package/ccx/cli/commands/turns.py +1081 -0
- package/ccx/cli/controller.py +757 -0
- package/ccx/cli/main.py +301 -0
- package/ccx/cli/parser.py +1324 -0
- package/ccx/cli/skills.py +215 -0
- package/ccx/cli/statusline.py +160 -0
- package/ccx/client.py +1900 -0
- package/ccx/codex/__init__.py +0 -0
- package/ccx/codex/attach_tty.py +267 -0
- package/ccx/codex/client.py +732 -0
- package/ccx/codex/completion.py +121 -0
- package/ccx/codex/control.py +288 -0
- package/ccx/codex/daemon/__init__.py +0 -0
- package/ccx/codex/daemon/__main__.py +4 -0
- package/ccx/codex/daemon/daemon.py +1556 -0
- package/ccx/codex/daemon/models.py +140 -0
- package/ccx/codex/daemon/native.py +1590 -0
- package/ccx/codex/daemon/release.py +367 -0
- package/ccx/codex/daemon/worker.py +2396 -0
- package/ccx/codex/delivery.py +758 -0
- package/ccx/codex/delivery_recovery.py +232 -0
- package/ccx/codex/evidence.py +827 -0
- package/ccx/codex/history.py +158 -0
- package/ccx/codex/notify_history.py +140 -0
- package/ccx/codex/ops.py +666 -0
- package/ccx/codex/paths.py +70 -0
- package/ccx/codex/protocol.py +805 -0
- package/ccx/codex/provenance.py +105 -0
- package/ccx/codex/read.py +29 -0
- package/ccx/codex/records.py +51 -0
- package/ccx/codex/reports.py +177 -0
- package/ccx/codex/runtime.py +1412 -0
- package/ccx/codex/settle.py +363 -0
- package/ccx/codex/telemetry.py +792 -0
- package/ccx/codex/types.py +659 -0
- package/ccx/codex/worker_environment.py +25 -0
- package/ccx/constants.py +288 -0
- package/ccx/controllers/__init__.py +0 -0
- package/ccx/controllers/controller.py +1449 -0
- package/ccx/controllers/live_vars.py +1706 -0
- package/ccx/controllers/live_vars_runner.py +216 -0
- package/ccx/controllers/notify.py +213 -0
- package/ccx/controllers/orchestrator.py +193 -0
- package/ccx/controllers/policy.py +57 -0
- package/ccx/controllers/store.py +681 -0
- package/ccx/errors.py +634 -0
- package/ccx/notify/__init__.py +0 -0
- package/ccx/notify/__main__.py +5 -0
- package/ccx/notify/client.py +116 -0
- package/ccx/notify/daemon.py +480 -0
- package/ccx/notify/deliver.py +304 -0
- package/ccx/notify/paths.py +51 -0
- package/ccx/notify/registry.py +649 -0
- package/ccx/notify/select.py +750 -0
- package/ccx/notify/settle.py +684 -0
- package/ccx/ops/__init__.py +0 -0
- package/ccx/ops/background.py +115 -0
- package/ccx/ops/cancel.py +289 -0
- package/ccx/ops/codex_import.py +96 -0
- package/ccx/ops/common.py +397 -0
- package/ccx/ops/compact.py +337 -0
- package/ccx/ops/compact_ops.py +550 -0
- package/ccx/ops/context_events.py +48 -0
- package/ccx/ops/conversation.py +1540 -0
- package/ccx/ops/conversation_paging.py +1338 -0
- package/ccx/ops/create.py +56 -0
- package/ccx/ops/creation.py +952 -0
- package/ccx/ops/creation_steps.py +579 -0
- package/ccx/ops/event_feed.py +710 -0
- package/ccx/ops/forking.py +711 -0
- package/ccx/ops/input.py +556 -0
- package/ccx/ops/maintenance.py +295 -0
- package/ccx/ops/report.py +770 -0
- package/ccx/ops/semantics.py +490 -0
- package/ccx/ops/send.py +961 -0
- package/ccx/ops/steer_ops.py +867 -0
- package/ccx/ops/terminate.py +383 -0
- package/ccx/ops/turn_reports.py +306 -0
- package/ccx/ops/wait.py +762 -0
- package/ccx/ops/worker_events.py +636 -0
- package/ccx/rpc/__init__.py +0 -0
- package/ccx/rpc/local_rpc.py +1602 -0
- package/ccx/rpc/rpc.py +559 -0
- package/ccx/rpc/serve.py +374 -0
- package/ccx/rpc/transport.py +409 -0
- package/ccx/rpc/view.py +125 -0
- package/ccx/store/__init__.py +0 -0
- package/ccx/store/discovery.py +170 -0
- package/ccx/store/identity.py +105 -0
- package/ccx/store/journal_io.py +79 -0
- package/ccx/store/liveness.py +194 -0
- package/ccx/store/paths.py +254 -0
- package/ccx/store/record_cache.py +40 -0
- package/ccx/store/records.py +309 -0
- package/ccx/store/root.py +804 -0
- package/ccx/store/runtime_presence.py +137 -0
- package/ccx/store/store.py +2332 -0
- package/ccx/store/worker_state.py +86 -0
- package/ccx/system/__init__.py +0 -0
- package/ccx/system/bounded.py +296 -0
- package/ccx/system/cwd_paths.py +25 -0
- package/ccx/system/engine_binary.py +59 -0
- package/ccx/system/executable.py +50 -0
- package/ccx/system/oslayer.py +667 -0
- package/ccx/system/pipes.py +46 -0
- package/ccx/system/sanitize.py +43 -0
- package/ccx/types.py +705 -0
- package/ccx/version.py +29 -0
- package/ccx/workers.py +2081 -0
- package/ccx.py +9 -0
- package/package.json +40 -4
- package/pyproject.toml +87 -0
- package/skills/ccx/SKILL.md +155 -0
- package/skills/ccx/references/controllers.md +244 -0
- package/skills/ccx/references/troubleshooting.md +94 -0
- 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)
|