ccx-native 0.0.1 → 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 -4
- package/bin/ccx +55 -2
- 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 +39 -7
- 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,827 @@
|
|
|
1
|
+
"""Sparse durable evidence for what a native Codex turn actually did.
|
|
2
|
+
|
|
3
|
+
``thread/read`` reconstructs a thread's content. It does not carry the moments
|
|
4
|
+
CCX observed while the turn ran: when an item started, when it completed, the
|
|
5
|
+
exact native millisecond timestamps on those notifications, or a structured
|
|
6
|
+
``error`` the turn recovered from. Those exist only as live notifications, and
|
|
7
|
+
once they are reduced into ``lastTurn``/``lastReport``/``lifecycle`` the detail
|
|
8
|
+
is gone. This journal is where the authoritative event survives the reduction.
|
|
9
|
+
|
|
10
|
+
It is deliberately sparse. Only lifecycle evidence is written —
|
|
11
|
+
``turn/started``, ``item/started``, ``item/completed``, ``error``,
|
|
12
|
+
``turn/completed`` — never the token, output, or reasoning delta streams, which
|
|
13
|
+
would turn a small durable record into a firehose that costs a worker its
|
|
14
|
+
throughput. One file per worker, owner-only, outside the lifecycle record so
|
|
15
|
+
that record stays compact.
|
|
16
|
+
|
|
17
|
+
Ordering: ``sequence`` is CCX's own per-worker observation order, and nothing
|
|
18
|
+
else. It is not the content order of the normalized document (that is
|
|
19
|
+
``ordinal``), and it is not native time (that is ``nativeTimestampMs``, present
|
|
20
|
+
only where the native notification actually carried one).
|
|
21
|
+
|
|
22
|
+
Reading is one simple pass over the file, and :func:`build_index` materializes
|
|
23
|
+
the folded result — the same memory tradeoff the conversation document already
|
|
24
|
+
accepts, and the reason the journal stays sparse rather than becoming a delta
|
|
25
|
+
log nobody can afford to read back.
|
|
26
|
+
|
|
27
|
+
Durability: every selected event is flushed to the OS before ``record``
|
|
28
|
+
returns, so a CCX crash loses nothing, and turn settlement additionally
|
|
29
|
+
``fsync``s — the barrier that bounds what a machine crash can take to the tail
|
|
30
|
+
of one unsettled turn. Nothing here waits on the network or the model.
|
|
31
|
+
|
|
32
|
+
Integrity is stated, never repaired. ``sequence`` is part of it: a gap, a
|
|
33
|
+
duplicate, a backwards number, a malformed line, and a line that parses but is
|
|
34
|
+
not an envelope all mean evidence was lost, and each is reported. A partial
|
|
35
|
+
tail's number is reserved rather than reused, so the gap it leaves is the
|
|
36
|
+
durable record that one event may be missing. And degradation is sticky: a
|
|
37
|
+
writer rehydrates what the worker's record already knew, a reader merges the
|
|
38
|
+
file's integrity with that record, and no later success anywhere presents a
|
|
39
|
+
worker's evidence as complete once CCX has recorded that it is not.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
import hashlib
|
|
45
|
+
import json
|
|
46
|
+
import os
|
|
47
|
+
import pathlib
|
|
48
|
+
import threading
|
|
49
|
+
import uuid
|
|
50
|
+
from typing import Any
|
|
51
|
+
|
|
52
|
+
from ccx.codex.paths import ensure_dirs, evidence_path
|
|
53
|
+
from ccx.codex.provenance import parse_client_message_id
|
|
54
|
+
from ccx.codex.records import now_ms, read_record
|
|
55
|
+
from ccx.system.sanitize import safe_native
|
|
56
|
+
|
|
57
|
+
SCHEMA_VERSION = 1
|
|
58
|
+
|
|
59
|
+
# The header CCX writes when it begins journaling. It is what separates "no
|
|
60
|
+
# such event ever happened" from "CCX was not yet watching", which a consumer
|
|
61
|
+
# otherwise cannot tell apart from an empty stretch of journal.
|
|
62
|
+
HEADER_METHOD = "ccx/evidence-started"
|
|
63
|
+
|
|
64
|
+
# Authoritative lifecycle evidence only. Deltas are excluded by construction:
|
|
65
|
+
# adding one here is how this file stops being sparse.
|
|
66
|
+
TOKEN_USAGE_METHOD = "thread/tokenUsage/updated"
|
|
67
|
+
COMPACTION_CONTEXT_METHOD = "ccx/compaction/context"
|
|
68
|
+
|
|
69
|
+
# CCX's own lifecycle facts, which no native notification carries: the
|
|
70
|
+
# worker's state transitions and its approval requests coming and going. They
|
|
71
|
+
# exist for the event feed (`ccx/event_feed.py`) and are deliberately kept out
|
|
72
|
+
# of turn and item evidence: their params never carry a top-level `turnId`, and
|
|
73
|
+
# `build_index` skips them.
|
|
74
|
+
LIFECYCLE_METHOD = "ccx/lifecycle"
|
|
75
|
+
INPUT_REQUESTED_METHOD = "ccx/input/requested"
|
|
76
|
+
INPUT_RESOLVED_METHOD = "ccx/input/resolved"
|
|
77
|
+
COMPACT_STARTED_METHOD = "ccx/compact/started"
|
|
78
|
+
COMPACT_ENDED_METHOD = "ccx/compact/ended"
|
|
79
|
+
# A content-free wake, journaled only after a delivery receipt became
|
|
80
|
+
# accepted. It is never evidence: readers reconcile the delivery id.
|
|
81
|
+
DELIVERY_CHANGED_METHOD = "ccx/delivery/changed"
|
|
82
|
+
BACKGROUND_CHANGED_METHOD = "ccx/background/changed"
|
|
83
|
+
CONTEXT_CHANGED_METHOD = "ccx/context/changed"
|
|
84
|
+
FEED_METHODS = frozenset({
|
|
85
|
+
DELIVERY_CHANGED_METHOD,
|
|
86
|
+
BACKGROUND_CHANGED_METHOD,
|
|
87
|
+
CONTEXT_CHANGED_METHOD,
|
|
88
|
+
LIFECYCLE_METHOD,
|
|
89
|
+
INPUT_REQUESTED_METHOD,
|
|
90
|
+
INPUT_RESOLVED_METHOD,
|
|
91
|
+
COMPACT_STARTED_METHOD,
|
|
92
|
+
COMPACT_ENDED_METHOD,
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
JOURNALED_METHODS = frozenset({
|
|
96
|
+
"turn/started",
|
|
97
|
+
"item/started",
|
|
98
|
+
"item/completed",
|
|
99
|
+
"error",
|
|
100
|
+
"turn/completed",
|
|
101
|
+
TOKEN_USAGE_METHOD,
|
|
102
|
+
COMPACTION_CONTEXT_METHOD,
|
|
103
|
+
}) | FEED_METHODS
|
|
104
|
+
|
|
105
|
+
# Settlement and bounded telemetry enrichments get a real durability barrier.
|
|
106
|
+
_DURABLE_METHODS = frozenset({
|
|
107
|
+
"turn/completed",
|
|
108
|
+
COMPACTION_CONTEXT_METHOD,
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
_ITEM_TIMESTAMP = {
|
|
112
|
+
"item/started": "startedAtMs",
|
|
113
|
+
"item/completed": "completedAtMs",
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def journal_path(short: str) -> pathlib.Path:
|
|
118
|
+
return evidence_path(short)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _owned_evidence_fd(target: str, flags: int, *, store=None) -> int:
|
|
122
|
+
"""Pinned nofollow actual journal inode, never a pathname authority.
|
|
123
|
+
|
|
124
|
+
The worker record's full global UUID is the only owner index. A symlink,
|
|
125
|
+
replaced file inode, wrong uid or too-open file is refused, not chmodded
|
|
126
|
+
into trust. The ACTIVE lease bounds opening, not native turn completion.
|
|
127
|
+
"""
|
|
128
|
+
from ccx import constants
|
|
129
|
+
from ccx.store.root import require_ccx_active
|
|
130
|
+
from ccx.store.identity import EngineMismatch
|
|
131
|
+
import stat
|
|
132
|
+
ctx = store if store is not None else require_ccx_active(constants.CCX_ROOT)
|
|
133
|
+
owner = ctx.resolve(target)
|
|
134
|
+
if owner.engine != "codex":
|
|
135
|
+
raise EngineMismatch("Codex journal requires Codex full UUID")
|
|
136
|
+
with ctx.lease():
|
|
137
|
+
with ctx._gen("workers", "codex", owner.worker_id) as directory:
|
|
138
|
+
fd = os.open(
|
|
139
|
+
"evidence.jsonl",
|
|
140
|
+
flags | os.O_NOFOLLOW | os.O_CLOEXEC,
|
|
141
|
+
0o600, dir_fd=directory,
|
|
142
|
+
)
|
|
143
|
+
try:
|
|
144
|
+
actual = os.fstat(fd)
|
|
145
|
+
named = os.stat(
|
|
146
|
+
"evidence.jsonl", dir_fd=directory,
|
|
147
|
+
follow_symlinks=False,
|
|
148
|
+
)
|
|
149
|
+
if (not stat.S_ISREG(actual.st_mode)
|
|
150
|
+
or actual.st_uid != os.getuid()
|
|
151
|
+
or actual.st_mode & 0o077
|
|
152
|
+
or (actual.st_dev, actual.st_ino)
|
|
153
|
+
!= (named.st_dev, named.st_ino)):
|
|
154
|
+
raise PermissionError(
|
|
155
|
+
"Codex full-UUID journal inode not owned/private"
|
|
156
|
+
)
|
|
157
|
+
except BaseException:
|
|
158
|
+
os.close(fd)
|
|
159
|
+
raise
|
|
160
|
+
return fd
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _int_or_none(value: Any) -> int | None:
|
|
164
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
165
|
+
return None
|
|
166
|
+
return value
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _text_or_none(value: Any) -> str | None:
|
|
170
|
+
return value if isinstance(value, str) and value else None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _is_envelope(value: Any) -> bool:
|
|
174
|
+
"""Is this parsed line actually one of our journal envelopes?
|
|
175
|
+
|
|
176
|
+
Deliberately the minimum this schema needs and nothing more — an integer
|
|
177
|
+
``sequence`` alone does not make a JSON object evidence, and a line that
|
|
178
|
+
parses but cannot be interpreted is lost evidence, not a valid record. This
|
|
179
|
+
is not, and must not grow into, a general schema validator.
|
|
180
|
+
"""
|
|
181
|
+
if not isinstance(value, dict):
|
|
182
|
+
return False
|
|
183
|
+
if _int_or_none(value.get("schemaVersion")) is None:
|
|
184
|
+
return False
|
|
185
|
+
if _int_or_none(value.get("sequence")) is None:
|
|
186
|
+
return False
|
|
187
|
+
if _int_or_none(value.get("observedAtMs")) is None:
|
|
188
|
+
return False
|
|
189
|
+
if value.get("method") not in JOURNALED_METHODS | {HEADER_METHOD}:
|
|
190
|
+
return False
|
|
191
|
+
return isinstance(value.get("params"), dict)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def envelope(
|
|
195
|
+
method: str,
|
|
196
|
+
params: dict[str, Any],
|
|
197
|
+
*,
|
|
198
|
+
sequence: int,
|
|
199
|
+
thread_id: str | None = None,
|
|
200
|
+
) -> dict[str, Any]:
|
|
201
|
+
"""One journal line: sanitized native params plus CCX's own observation.
|
|
202
|
+
|
|
203
|
+
The convenience ids are read out of ``params`` rather than supplied
|
|
204
|
+
alongside it, so an envelope cannot disagree with the event it carries.
|
|
205
|
+
"""
|
|
206
|
+
safe_params = safe_native(params)
|
|
207
|
+
if not isinstance(safe_params, dict):
|
|
208
|
+
safe_params = {"value": safe_params}
|
|
209
|
+
turn = safe_params.get("turn")
|
|
210
|
+
turn = turn if isinstance(turn, dict) else {}
|
|
211
|
+
item = safe_params.get("item")
|
|
212
|
+
item = item if isinstance(item, dict) else {}
|
|
213
|
+
return {
|
|
214
|
+
"schemaVersion": SCHEMA_VERSION,
|
|
215
|
+
"sequence": sequence,
|
|
216
|
+
# CCX receipt time. Never presented as native time.
|
|
217
|
+
"observedAtMs": now_ms(),
|
|
218
|
+
"method": method,
|
|
219
|
+
"threadId": _text_or_none(safe_params.get("threadId")) or thread_id,
|
|
220
|
+
"turnId": (
|
|
221
|
+
_text_or_none(safe_params.get("turnId"))
|
|
222
|
+
or _text_or_none(turn.get("id"))
|
|
223
|
+
),
|
|
224
|
+
"itemId": _text_or_none(item.get("id")),
|
|
225
|
+
"itemType": _text_or_none(item.get("type")),
|
|
226
|
+
# Exactly the native timestamp the notification carried, or nothing.
|
|
227
|
+
"nativeTimestampMs": _int_or_none(
|
|
228
|
+
safe_params.get(_ITEM_TIMESTAMP.get(method, ""))
|
|
229
|
+
),
|
|
230
|
+
"params": safe_params,
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class JournalRead:
|
|
235
|
+
"""The result of one pass over a worker's journal."""
|
|
236
|
+
|
|
237
|
+
__slots__ = (
|
|
238
|
+
"short", "path", "exists", "events", "last_sequence",
|
|
239
|
+
"partial_tail", "malformed_lines", "sequence_issues",
|
|
240
|
+
"journal_instance_id",
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
def __init__(
|
|
244
|
+
self,
|
|
245
|
+
short: str,
|
|
246
|
+
path: pathlib.Path,
|
|
247
|
+
*,
|
|
248
|
+
exists: bool,
|
|
249
|
+
events: list[dict[str, Any]],
|
|
250
|
+
last_sequence: int,
|
|
251
|
+
partial_tail: bool,
|
|
252
|
+
malformed_lines: list[int],
|
|
253
|
+
sequence_issues: list[str] | None = None,
|
|
254
|
+
journal_instance_id: str | None = None,
|
|
255
|
+
):
|
|
256
|
+
self.short = short
|
|
257
|
+
self.path = path
|
|
258
|
+
self.exists = exists
|
|
259
|
+
self.events = events
|
|
260
|
+
self.last_sequence = last_sequence
|
|
261
|
+
self.partial_tail = partial_tail
|
|
262
|
+
self.malformed_lines = malformed_lines
|
|
263
|
+
self.sequence_issues = sequence_issues or []
|
|
264
|
+
self.journal_instance_id = journal_instance_id
|
|
265
|
+
|
|
266
|
+
@property
|
|
267
|
+
def complete(self) -> bool:
|
|
268
|
+
"""Is every line this journal holds intact?
|
|
269
|
+
|
|
270
|
+
A partial tail (a write interrupted mid-line), a malformed complete
|
|
271
|
+
line, and a break in the sequence chain all mean evidence was lost.
|
|
272
|
+
None of them is skipped quietly, and none of them is repaired.
|
|
273
|
+
"""
|
|
274
|
+
return not (
|
|
275
|
+
self.partial_tail or self.malformed_lines or self.sequence_issues
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
@property
|
|
279
|
+
def warnings(self) -> list[str]:
|
|
280
|
+
notes = []
|
|
281
|
+
if self.partial_tail:
|
|
282
|
+
notes.append(
|
|
283
|
+
"the evidence journal ends in a partial line: a write was "
|
|
284
|
+
"interrupted, so its last event is incomplete"
|
|
285
|
+
)
|
|
286
|
+
if self.malformed_lines:
|
|
287
|
+
shown = ", ".join(str(n) for n in self.malformed_lines[:10])
|
|
288
|
+
notes.append(
|
|
289
|
+
f"{len(self.malformed_lines)} malformed evidence line(s) "
|
|
290
|
+
f"(line {shown})"
|
|
291
|
+
)
|
|
292
|
+
notes.extend(self.sequence_issues[:10])
|
|
293
|
+
if len(self.sequence_issues) > 10:
|
|
294
|
+
notes.append(
|
|
295
|
+
f"{len(self.sequence_issues) - 10} further sequence problem(s)"
|
|
296
|
+
)
|
|
297
|
+
return notes
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _explicit_instance_id(header: dict[str, Any]) -> str | None:
|
|
301
|
+
params = header.get("params")
|
|
302
|
+
params = params if isinstance(params, dict) else {}
|
|
303
|
+
return _text_or_none(params.get("journalInstanceId"))
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def header_instance_id(header: dict[str, Any]) -> str:
|
|
307
|
+
"""The journal instance a valid evidence-start envelope names.
|
|
308
|
+
|
|
309
|
+
Pre-instance-ID journals still need a stable epoch: it is derived from
|
|
310
|
+
their first valid header, and the writer persists the same value on its
|
|
311
|
+
next open.
|
|
312
|
+
"""
|
|
313
|
+
explicit = _explicit_instance_id(header)
|
|
314
|
+
if explicit is not None:
|
|
315
|
+
return explicit
|
|
316
|
+
seed = json.dumps(
|
|
317
|
+
header,
|
|
318
|
+
sort_keys=True,
|
|
319
|
+
separators=(",", ":"),
|
|
320
|
+
).encode("utf-8")
|
|
321
|
+
return "legacy-" + hashlib.sha256(seed).hexdigest()[:32]
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def read_journal(short: str, *, events: bool = True) -> JournalRead:
|
|
325
|
+
"""Scan a worker's journal once, for its events and/or its sequence.
|
|
326
|
+
|
|
327
|
+
Recovery reads the file itself rather than trusting a counter in the
|
|
328
|
+
lifecycle record: the record and the journal are written separately, and
|
|
329
|
+
only the journal knows what actually reached the disk.
|
|
330
|
+
"""
|
|
331
|
+
path = journal_path(short)
|
|
332
|
+
collected: list[dict[str, Any]] = []
|
|
333
|
+
last_sequence = 0
|
|
334
|
+
previous = 0
|
|
335
|
+
partial_tail = False
|
|
336
|
+
malformed: list[int] = []
|
|
337
|
+
issues: list[str] = []
|
|
338
|
+
journal_instance_id: str | None = None
|
|
339
|
+
try:
|
|
340
|
+
with os.fdopen(_owned_evidence_fd(short, os.O_RDONLY), "rb") as handle:
|
|
341
|
+
for number, raw in enumerate(handle, 1):
|
|
342
|
+
if not raw.endswith(b"\n"):
|
|
343
|
+
# An unterminated fragment is not an event. It is never
|
|
344
|
+
# parsed and its sequence is never claimed.
|
|
345
|
+
partial_tail = True
|
|
346
|
+
break
|
|
347
|
+
line = raw.strip()
|
|
348
|
+
if not line:
|
|
349
|
+
continue
|
|
350
|
+
try:
|
|
351
|
+
value = json.loads(line)
|
|
352
|
+
except ValueError:
|
|
353
|
+
malformed.append(number)
|
|
354
|
+
continue
|
|
355
|
+
# A parseable line can still be unusable. Its sequence is read
|
|
356
|
+
# anyway when it is safe to: recovery must never hand the same
|
|
357
|
+
# number to a future event just because this line was damaged.
|
|
358
|
+
sequence = (
|
|
359
|
+
_int_or_none(value.get("sequence"))
|
|
360
|
+
if isinstance(value, dict) else None
|
|
361
|
+
)
|
|
362
|
+
if not _is_envelope(value):
|
|
363
|
+
malformed.append(number)
|
|
364
|
+
else:
|
|
365
|
+
if value.get("method") == HEADER_METHOD:
|
|
366
|
+
explicit = _explicit_instance_id(value)
|
|
367
|
+
if explicit is not None:
|
|
368
|
+
if (
|
|
369
|
+
journal_instance_id is not None
|
|
370
|
+
and journal_instance_id != explicit
|
|
371
|
+
):
|
|
372
|
+
issues.append(
|
|
373
|
+
f"line {number}: evidence journal instance changed "
|
|
374
|
+
f"from {journal_instance_id} to {explicit}"
|
|
375
|
+
)
|
|
376
|
+
else:
|
|
377
|
+
journal_instance_id = explicit
|
|
378
|
+
elif journal_instance_id is None:
|
|
379
|
+
journal_instance_id = header_instance_id(value)
|
|
380
|
+
if events:
|
|
381
|
+
collected.append(value)
|
|
382
|
+
if sequence is None:
|
|
383
|
+
continue
|
|
384
|
+
if sequence == previous:
|
|
385
|
+
issues.append(
|
|
386
|
+
f"line {number}: duplicate evidence sequence {sequence}"
|
|
387
|
+
)
|
|
388
|
+
elif sequence < previous:
|
|
389
|
+
issues.append(
|
|
390
|
+
f"line {number}: evidence sequence {sequence} goes "
|
|
391
|
+
f"backwards after {previous}"
|
|
392
|
+
)
|
|
393
|
+
elif sequence > previous + 1:
|
|
394
|
+
# The writer reserves a number before it writes, so a gap
|
|
395
|
+
# is positive evidence that an event was lost.
|
|
396
|
+
missing = (
|
|
397
|
+
f"{previous + 1}" if sequence == previous + 2
|
|
398
|
+
else f"{previous + 1}-{sequence - 1}"
|
|
399
|
+
)
|
|
400
|
+
issues.append(
|
|
401
|
+
f"line {number}: evidence sequence gap ({missing} "
|
|
402
|
+
f"never landed)"
|
|
403
|
+
)
|
|
404
|
+
previous = max(previous, sequence)
|
|
405
|
+
last_sequence = max(last_sequence, sequence)
|
|
406
|
+
except FileNotFoundError:
|
|
407
|
+
return JournalRead(
|
|
408
|
+
short, path, exists=False, events=[], last_sequence=0,
|
|
409
|
+
partial_tail=False, malformed_lines=[], journal_instance_id=None,
|
|
410
|
+
)
|
|
411
|
+
return JournalRead(
|
|
412
|
+
short, path, exists=True, events=collected,
|
|
413
|
+
last_sequence=last_sequence, partial_tail=partial_tail,
|
|
414
|
+
malformed_lines=malformed, sequence_issues=issues,
|
|
415
|
+
journal_instance_id=journal_instance_id,
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
class EvidenceJournal:
|
|
420
|
+
"""The append-only writer for one worker's journal.
|
|
421
|
+
|
|
422
|
+
Opened lazily, so a worker that never produces evidence never creates a
|
|
423
|
+
file — which is itself the honest answer to "was CCX journaling?".
|
|
424
|
+
"""
|
|
425
|
+
|
|
426
|
+
def __init__(
|
|
427
|
+
self,
|
|
428
|
+
short: str,
|
|
429
|
+
*,
|
|
430
|
+
codex_version: str | None = None,
|
|
431
|
+
health: dict[str, Any] | None = None,
|
|
432
|
+
):
|
|
433
|
+
self.short = short
|
|
434
|
+
self.path = journal_path(short)
|
|
435
|
+
self.codex_version = codex_version
|
|
436
|
+
self._lock = threading.Lock()
|
|
437
|
+
self._handle = None
|
|
438
|
+
self._closed = False
|
|
439
|
+
self._sequence = 0
|
|
440
|
+
self._partial_tail = False
|
|
441
|
+
self._journal_instance_id: str | None = None
|
|
442
|
+
self._degraded: dict[str, Any] | None = None
|
|
443
|
+
# A worker outlives the process that watched it. Rehydrating the
|
|
444
|
+
# durable health block is what makes degradation survive a daemon
|
|
445
|
+
# restart: once CCX knows an event was lost, no later successful append
|
|
446
|
+
# may present that worker's evidence as complete again. Only an
|
|
447
|
+
# explicit repair could ever clear it, and none exists.
|
|
448
|
+
if isinstance(health, dict) and health.get("status") == "degraded":
|
|
449
|
+
self._degraded = {
|
|
450
|
+
"reason": str(
|
|
451
|
+
health.get("degradedReason")
|
|
452
|
+
or "evidence was already recorded as degraded"
|
|
453
|
+
)[:300],
|
|
454
|
+
"atMs": _int_or_none(health.get("degradedAtMs")) or now_ms(),
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
def state(self) -> dict[str, Any]:
|
|
458
|
+
"""The smallest durable description of this journal's health."""
|
|
459
|
+
with self._lock:
|
|
460
|
+
return self._state_locked()
|
|
461
|
+
|
|
462
|
+
def _state_locked(self) -> dict[str, Any]:
|
|
463
|
+
degraded = self._degraded
|
|
464
|
+
return {
|
|
465
|
+
"schemaVersion": SCHEMA_VERSION,
|
|
466
|
+
"status": "degraded" if degraded else "complete",
|
|
467
|
+
"sequence": self._sequence,
|
|
468
|
+
"journalInstanceId": self._journal_instance_id,
|
|
469
|
+
"degradedReason": degraded["reason"] if degraded else None,
|
|
470
|
+
"degradedAtMs": degraded["atMs"] if degraded else None,
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
@property
|
|
474
|
+
def degraded(self) -> dict[str, Any] | None:
|
|
475
|
+
return self._degraded
|
|
476
|
+
|
|
477
|
+
@property
|
|
478
|
+
def closed(self) -> bool:
|
|
479
|
+
"""Has a teardown released this journal? A later write would reopen
|
|
480
|
+
it with a fresh header, so CCX's own lifecycle lines stop here."""
|
|
481
|
+
return self._closed
|
|
482
|
+
|
|
483
|
+
@property
|
|
484
|
+
def observed(self) -> bool:
|
|
485
|
+
"""Has this worker any evidence story to persist at all?
|
|
486
|
+
|
|
487
|
+
False for a worker that never journaled anything, so a pre-journal
|
|
488
|
+
worker keeps a null evidence block rather than being credited with
|
|
489
|
+
complete evidence it never produced.
|
|
490
|
+
"""
|
|
491
|
+
return self._sequence > 0 or self._degraded is not None
|
|
492
|
+
|
|
493
|
+
def _degrade(self, reason: str) -> None:
|
|
494
|
+
# Once evidence is known to be incomplete it stays that way: a later
|
|
495
|
+
# successful append does not make the lost event reappear.
|
|
496
|
+
if self._degraded is None:
|
|
497
|
+
self._degraded = {"reason": reason[:300], "atMs": now_ms()}
|
|
498
|
+
|
|
499
|
+
def _open_locked(self) -> None:
|
|
500
|
+
if self._handle is not None:
|
|
501
|
+
return
|
|
502
|
+
ensure_dirs()
|
|
503
|
+
scan = read_journal(self.short, events=False)
|
|
504
|
+
self._sequence = scan.last_sequence
|
|
505
|
+
self._partial_tail = scan.partial_tail
|
|
506
|
+
self._journal_instance_id = (
|
|
507
|
+
scan.journal_instance_id or str(uuid.uuid4())
|
|
508
|
+
)
|
|
509
|
+
if scan.partial_tail:
|
|
510
|
+
# The interrupted line was mid-write, so it had already been given
|
|
511
|
+
# the next number. Reserve it rather than handing it to a new
|
|
512
|
+
# event: the resulting gap is the durable record that one event may
|
|
513
|
+
# have been lost here.
|
|
514
|
+
self._sequence += 1
|
|
515
|
+
if not scan.complete:
|
|
516
|
+
self._degrade(
|
|
517
|
+
"the existing journal is incomplete: "
|
|
518
|
+
+ "; ".join(scan.warnings)
|
|
519
|
+
)
|
|
520
|
+
descriptor = _owned_evidence_fd(
|
|
521
|
+
self.short, os.O_WRONLY | os.O_CREAT | os.O_APPEND,
|
|
522
|
+
)
|
|
523
|
+
self._handle = os.fdopen(descriptor, "a", encoding="utf-8")
|
|
524
|
+
if scan.partial_tail:
|
|
525
|
+
# Close the interrupted fragment on its own line. Appending
|
|
526
|
+
# straight onto it would fuse a broken record and a good one into
|
|
527
|
+
# a single unreadable line, losing the new event as well.
|
|
528
|
+
self._handle.write("\n")
|
|
529
|
+
self._write_locked(HEADER_METHOD, {
|
|
530
|
+
"schemaVersion": SCHEMA_VERSION,
|
|
531
|
+
"short": self.short,
|
|
532
|
+
"journalInstanceId": self._journal_instance_id,
|
|
533
|
+
"codexVersion": self.codex_version,
|
|
534
|
+
"resumedFromSequence": scan.last_sequence,
|
|
535
|
+
"partialTail": scan.partial_tail,
|
|
536
|
+
}, durable=True)
|
|
537
|
+
|
|
538
|
+
def _write_locked(
|
|
539
|
+
self,
|
|
540
|
+
method: str,
|
|
541
|
+
params: dict[str, Any],
|
|
542
|
+
*,
|
|
543
|
+
thread_id: str | None = None,
|
|
544
|
+
durable: bool = False,
|
|
545
|
+
) -> int:
|
|
546
|
+
# A failed write still consumes its number rather than handing it to
|
|
547
|
+
# the next event: sequence is strictly increasing, and a gap is honest
|
|
548
|
+
# evidence that something was lost.
|
|
549
|
+
self._sequence += 1
|
|
550
|
+
line = envelope(
|
|
551
|
+
method, params, sequence=self._sequence, thread_id=thread_id
|
|
552
|
+
)
|
|
553
|
+
self._handle.write(json.dumps(line, separators=(",", ":")) + "\n")
|
|
554
|
+
# Always past CCX's own buffers, so a CCX crash cannot lose an event.
|
|
555
|
+
self._handle.flush()
|
|
556
|
+
if durable:
|
|
557
|
+
os.fsync(self._handle.fileno())
|
|
558
|
+
return self._sequence
|
|
559
|
+
|
|
560
|
+
def record(
|
|
561
|
+
self,
|
|
562
|
+
method: str,
|
|
563
|
+
params: dict[str, Any],
|
|
564
|
+
*,
|
|
565
|
+
thread_id: str | None = None,
|
|
566
|
+
durable: bool | None = None,
|
|
567
|
+
) -> int | None:
|
|
568
|
+
"""Journal one selected event. Never raises.
|
|
569
|
+
|
|
570
|
+
A journal that cannot be written is a degraded observation, not a
|
|
571
|
+
reason to lose a running native turn — so the failure is latched into
|
|
572
|
+
this journal's state (which the caller persists) instead of
|
|
573
|
+
propagating into the notification path that drives worker health.
|
|
574
|
+
"""
|
|
575
|
+
if method not in JOURNALED_METHODS:
|
|
576
|
+
return None
|
|
577
|
+
try:
|
|
578
|
+
with self._lock:
|
|
579
|
+
self._open_locked()
|
|
580
|
+
return self._write_locked(
|
|
581
|
+
method,
|
|
582
|
+
params,
|
|
583
|
+
thread_id=thread_id,
|
|
584
|
+
durable=(
|
|
585
|
+
method in _DURABLE_METHODS
|
|
586
|
+
if durable is None else bool(durable)
|
|
587
|
+
),
|
|
588
|
+
)
|
|
589
|
+
except Exception as error: # noqa: BLE001 - see docstring
|
|
590
|
+
with self._lock:
|
|
591
|
+
self._degrade(f"{error.__class__.__name__}: {error}")
|
|
592
|
+
return None
|
|
593
|
+
|
|
594
|
+
def close(self) -> None:
|
|
595
|
+
"""Flush and release the journal. Never raises.
|
|
596
|
+
|
|
597
|
+
The final durability barrier failing is exactly the case where the tail
|
|
598
|
+
of the evidence may not be on disk, so it degrades the journal's health
|
|
599
|
+
rather than vanishing — but it still must not take down the worker
|
|
600
|
+
teardown that called it.
|
|
601
|
+
"""
|
|
602
|
+
with self._lock:
|
|
603
|
+
self._closed = True
|
|
604
|
+
handle, self._handle = self._handle, None
|
|
605
|
+
if handle is None:
|
|
606
|
+
return
|
|
607
|
+
try:
|
|
608
|
+
handle.flush()
|
|
609
|
+
os.fsync(handle.fileno())
|
|
610
|
+
except Exception as error: # noqa: BLE001 - see docstring
|
|
611
|
+
self._degrade(
|
|
612
|
+
f"the journal's final flush failed: "
|
|
613
|
+
f"{error.__class__.__name__}: {error}"
|
|
614
|
+
)
|
|
615
|
+
try:
|
|
616
|
+
handle.close()
|
|
617
|
+
except Exception as error: # noqa: BLE001 - see docstring
|
|
618
|
+
self._degrade(
|
|
619
|
+
f"the journal could not be closed cleanly: "
|
|
620
|
+
f"{error.__class__.__name__}: {error}"
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
# One CCX client id seen on two live items names neither of them.
|
|
625
|
+
_UNSET = object()
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _merged_health(
|
|
629
|
+
read: JournalRead, health: dict[str, Any] | None
|
|
630
|
+
) -> tuple[str, list[str]]:
|
|
631
|
+
"""Fold the file's own integrity together with the durable record health.
|
|
632
|
+
|
|
633
|
+
The two can disagree in both directions, and only one of those directions
|
|
634
|
+
is evidence loss. A file ahead of the record is ordinary: the journal is
|
|
635
|
+
flushed before the record is written, so a crash in between leaves exactly
|
|
636
|
+
that. A record ahead of the file, or a record that remembers evidence where
|
|
637
|
+
no file remains, means something the worker durably knew about is gone.
|
|
638
|
+
"""
|
|
639
|
+
warnings = list(read.warnings)
|
|
640
|
+
recorded_sequence = 0
|
|
641
|
+
if isinstance(health, dict):
|
|
642
|
+
recorded_sequence = _int_or_none(health.get("sequence")) or 0
|
|
643
|
+
if health.get("status") == "degraded":
|
|
644
|
+
reason = _text_or_none(health.get("degradedReason"))
|
|
645
|
+
warnings.append(
|
|
646
|
+
"the worker recorded its evidence as degraded"
|
|
647
|
+
+ (f": {reason}" if reason else "")
|
|
648
|
+
)
|
|
649
|
+
if not read.exists and recorded_sequence > 0:
|
|
650
|
+
warnings.append(
|
|
651
|
+
f"the worker recorded {recorded_sequence} evidence event(s) but "
|
|
652
|
+
f"the journal file is gone"
|
|
653
|
+
)
|
|
654
|
+
elif read.exists and recorded_sequence > read.last_sequence:
|
|
655
|
+
warnings.append(
|
|
656
|
+
f"the worker recorded evidence through sequence "
|
|
657
|
+
f"{recorded_sequence} but the journal ends at "
|
|
658
|
+
f"{read.last_sequence}"
|
|
659
|
+
)
|
|
660
|
+
if warnings:
|
|
661
|
+
return "degraded", warnings
|
|
662
|
+
return ("complete" if read.exists else "absent"), warnings
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def build_index(
|
|
666
|
+
read: JournalRead, *, health: dict[str, Any] | None = None
|
|
667
|
+
) -> dict[str, Any]:
|
|
668
|
+
"""Fold a journal into the evidence a normalized document can use.
|
|
669
|
+
|
|
670
|
+
Identity is exact and native: an item is only ever keyed by the
|
|
671
|
+
``(turnId, itemId)`` the event stream itself carried. Live and
|
|
672
|
+
reconstructed ``thread/read`` item ids may legitimately differ (Scope 02),
|
|
673
|
+
so an unmatched historical item stays unmatched — nothing here joins two
|
|
674
|
+
source-native ids because they look similar.
|
|
675
|
+
|
|
676
|
+
The one exception is not a heuristic at all: a native ``userMessage``
|
|
677
|
+
carries the ``clientId`` CCX itself minted, on both surfaces. That is a
|
|
678
|
+
real shared identity for the semantic message, so it is indexed separately
|
|
679
|
+
— and only when the id parses as one CCX owns and names exactly one live
|
|
680
|
+
item in its turn.
|
|
681
|
+
|
|
682
|
+
``health`` is the worker's durable evidence block. Degradation recorded
|
|
683
|
+
there outranks a file whose surviving lines happen to parse cleanly.
|
|
684
|
+
"""
|
|
685
|
+
turns: dict[str, dict[str, Any]] = {}
|
|
686
|
+
items: dict[str, dict[str, dict[str, Any]]] = {}
|
|
687
|
+
clients: dict[str, dict[str, str | None]] = {}
|
|
688
|
+
began_at: int | None = None
|
|
689
|
+
codex_version: str | None = None
|
|
690
|
+
counted = 0
|
|
691
|
+
|
|
692
|
+
for event in read.events:
|
|
693
|
+
method = event.get("method")
|
|
694
|
+
sequence = _int_or_none(event.get("sequence"))
|
|
695
|
+
observed = _int_or_none(event.get("observedAtMs"))
|
|
696
|
+
if method == HEADER_METHOD:
|
|
697
|
+
if began_at is None:
|
|
698
|
+
began_at = observed
|
|
699
|
+
params = event.get("params")
|
|
700
|
+
if isinstance(params, dict):
|
|
701
|
+
codex_version = _text_or_none(params.get("codexVersion"))
|
|
702
|
+
continue
|
|
703
|
+
if method not in JOURNALED_METHODS or method in FEED_METHODS:
|
|
704
|
+
continue
|
|
705
|
+
counted += 1
|
|
706
|
+
turn_id = _text_or_none(event.get("turnId"))
|
|
707
|
+
if turn_id is None:
|
|
708
|
+
continue
|
|
709
|
+
turn = turns.setdefault(turn_id, {
|
|
710
|
+
"startedAtMs": None,
|
|
711
|
+
"completedAtMs": None,
|
|
712
|
+
"sequences": [],
|
|
713
|
+
"errorCount": 0,
|
|
714
|
+
# Turn-level coverage: whether CCX watched this turn from its start
|
|
715
|
+
# through its settlement, and how many distinct item lifecycles it
|
|
716
|
+
# saw while it did.
|
|
717
|
+
"sawTurnStarted": False,
|
|
718
|
+
"sawTurnCompleted": False,
|
|
719
|
+
"distinctItemCount": 0,
|
|
720
|
+
})
|
|
721
|
+
if sequence is not None:
|
|
722
|
+
turn["sequences"].append(sequence)
|
|
723
|
+
if method == "turn/started":
|
|
724
|
+
turn["sawTurnStarted"] = True
|
|
725
|
+
turn["startedAtMs"] = turn["startedAtMs"] or observed
|
|
726
|
+
elif method == "turn/completed":
|
|
727
|
+
turn["sawTurnCompleted"] = True
|
|
728
|
+
turn["completedAtMs"] = observed
|
|
729
|
+
elif method == "error":
|
|
730
|
+
turn["errorCount"] += 1
|
|
731
|
+
|
|
732
|
+
item_id = _text_or_none(event.get("itemId"))
|
|
733
|
+
if item_id is None:
|
|
734
|
+
continue
|
|
735
|
+
turn_items = items.setdefault(turn_id, {})
|
|
736
|
+
new_item = item_id not in turn_items
|
|
737
|
+
entry = turn_items.setdefault(item_id, {
|
|
738
|
+
"itemType": None,
|
|
739
|
+
"clientId": None,
|
|
740
|
+
"startedAtMs": None,
|
|
741
|
+
"completedAtMs": None,
|
|
742
|
+
"sequences": [],
|
|
743
|
+
"completed": None,
|
|
744
|
+
})
|
|
745
|
+
if new_item:
|
|
746
|
+
turn["distinctItemCount"] += 1
|
|
747
|
+
entry["itemType"] = entry["itemType"] or _text_or_none(
|
|
748
|
+
event.get("itemType")
|
|
749
|
+
)
|
|
750
|
+
if sequence is not None:
|
|
751
|
+
entry["sequences"].append(sequence)
|
|
752
|
+
params = event.get("params")
|
|
753
|
+
item = params.get("item") if isinstance(params, dict) else None
|
|
754
|
+
if isinstance(item, dict):
|
|
755
|
+
client_id = _text_or_none(item.get("clientId"))
|
|
756
|
+
if client_id and parse_client_message_id(client_id):
|
|
757
|
+
entry["clientId"] = entry["clientId"] or client_id
|
|
758
|
+
turn_clients = clients.setdefault(turn_id, {})
|
|
759
|
+
known = turn_clients.get(client_id, _UNSET)
|
|
760
|
+
if known is _UNSET:
|
|
761
|
+
turn_clients[client_id] = item_id
|
|
762
|
+
elif known != item_id:
|
|
763
|
+
# The same CCX id on two different live items is not an
|
|
764
|
+
# identity any more. Refuse it rather than pick one.
|
|
765
|
+
turn_clients[client_id] = None
|
|
766
|
+
native = _int_or_none(event.get("nativeTimestampMs"))
|
|
767
|
+
if method == "item/started":
|
|
768
|
+
entry["startedAtMs"] = entry["startedAtMs"] or native
|
|
769
|
+
elif method == "item/completed":
|
|
770
|
+
entry["completedAtMs"] = native
|
|
771
|
+
if isinstance(item, dict):
|
|
772
|
+
entry["completed"] = item
|
|
773
|
+
|
|
774
|
+
status, warnings = _merged_health(read, health)
|
|
775
|
+
return {
|
|
776
|
+
"available": read.exists,
|
|
777
|
+
# File integrity alone; `status` is the answer that also accounts for
|
|
778
|
+
# what the worker durably recorded.
|
|
779
|
+
"complete": status == "complete",
|
|
780
|
+
"status": status,
|
|
781
|
+
"beganAtMs": began_at,
|
|
782
|
+
"codexVersion": codex_version,
|
|
783
|
+
"eventCount": counted,
|
|
784
|
+
"turns": turns,
|
|
785
|
+
"items": items,
|
|
786
|
+
"clients": clients,
|
|
787
|
+
"warnings": warnings,
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def read_index(short: str) -> dict[str, Any]:
|
|
792
|
+
"""One-call read + fold for a worker's journal, health included."""
|
|
793
|
+
record = read_record(short) or {}
|
|
794
|
+
health = record.get("evidence")
|
|
795
|
+
return build_index(
|
|
796
|
+
read_journal(short),
|
|
797
|
+
health=health if isinstance(health, dict) else None,
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def export_artifacts(short: str) -> list[tuple[str, str, str]]:
|
|
802
|
+
"""``(kind, filename, source path)`` for whatever evidence exists.
|
|
803
|
+
|
|
804
|
+
Empty when a worker has no journal, so an export never advertises evidence
|
|
805
|
+
it does not have.
|
|
806
|
+
"""
|
|
807
|
+
path = journal_path(short)
|
|
808
|
+
if not path.exists():
|
|
809
|
+
return []
|
|
810
|
+
return [("codex-evidence-jsonl", "codex-evidence.jsonl", str(path))]
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
__all__ = [
|
|
814
|
+
"COMPACTION_CONTEXT_METHOD",
|
|
815
|
+
"HEADER_METHOD",
|
|
816
|
+
"JOURNALED_METHODS",
|
|
817
|
+
"TOKEN_USAGE_METHOD",
|
|
818
|
+
"SCHEMA_VERSION",
|
|
819
|
+
"EvidenceJournal",
|
|
820
|
+
"JournalRead",
|
|
821
|
+
"build_index",
|
|
822
|
+
"envelope",
|
|
823
|
+
"export_artifacts",
|
|
824
|
+
"journal_path",
|
|
825
|
+
"read_index",
|
|
826
|
+
"read_journal",
|
|
827
|
+
]
|