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,867 @@
|
|
|
1
|
+
'Durable delivery journal for managed Claude steering (``ccx steer``).\n\nSteering a BUSY worker is the one CCX write whose delivery cannot be proven\nsynchronously: the prompt is pasted into Ink, and Claude decides — later, and\nin its own journal — whether it joins the running turn or the next one. The\nprompt must not be re-pasted merely because native evidence is delayed.\n\nThis module makes the prompt durable *before* it is ever written to the PTY,\nand makes every later decision evidence-driven:\n\n* the full pending prompt and its transcript byte fence are journaled\n atomically before the host acknowledges ownership;\n* retrying the same ``delivery_id`` returns the original entry and never\n produces a second paste;\n* placement is resolved from native Claude evidence fenced by transcript byte\n offset (see :func:`ccx.claude.transcript.steer_evidence_events`), never from\n timestamps or whole-file text containment;\n* when evidence is inconclusive the entry stays honestly pending, and a\n delivery that was pasted with no native trace at all eventually becomes\n ``unknown`` rather than being re-sent.\n\nThe journal is a bounded per-session sidecar (``<short>.deliveries.json``) for\nthe same reason the compact journal is: the session record has several\nindependent writers and a stale read-modify-write must never erase a terminal\ndelivery result. Those writers live in different processes, so every mutation\nholds an exclusive ``flock`` on ``<short>.deliveries.lock`` across its whole\nread -> mutate -> atomic-replace cycle (see :func:`journal_lock`). Reads stay\nlock-free: publication is one ``os.replace``, so a reader never sees a torn\njournal.\n'
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import pathlib
|
|
8
|
+
import time
|
|
9
|
+
import uuid
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ccx.store.journal_io import journal_lock as _journal_lock, write_json
|
|
13
|
+
|
|
14
|
+
from ccx.constants import EXIT_REJECTED, STEER_EVIDENCE_GRACE_S
|
|
15
|
+
from ccx.errors import CcxError
|
|
16
|
+
from ccx.store.paths import steer_deliveries_lock_path, steer_deliveries_path
|
|
17
|
+
from ccx.claude.transcript import STEER_EVIDENCE_RANK, steer_evidence_events
|
|
18
|
+
|
|
19
|
+
JOURNAL_VERSION = 1
|
|
20
|
+
JOURNAL_KEEP = 32
|
|
21
|
+
PREVIEW_LIMIT = 200
|
|
22
|
+
MAX_DELIVERY_ID = 128
|
|
23
|
+
|
|
24
|
+
#: Journaled but not yet proven to be placed in a turn.
|
|
25
|
+
PENDING_STATUSES = frozenset({"accepted", "pasted", "enqueued"})
|
|
26
|
+
#: Resolved: ``consumed`` (current turn), ``started`` (next turn),
|
|
27
|
+
#: ``unknown`` (accepted but unprovable), ``failed`` (proven never written).
|
|
28
|
+
TERMINAL_STATUSES = frozenset({"consumed", "started", "unknown", "failed",
|
|
29
|
+
"delivered", "abandoned"})
|
|
30
|
+
ALL_STATUSES = PENDING_STATUSES | TERMINAL_STATUSES
|
|
31
|
+
|
|
32
|
+
#: Statuses that prove Claude itself owns the prompt.
|
|
33
|
+
NATIVE_ACCEPTED_STATUSES = frozenset({"enqueued", "consumed", "started"})
|
|
34
|
+
|
|
35
|
+
# --- public placement vocabulary ---------------------------------------------
|
|
36
|
+
#
|
|
37
|
+
# Internal statuses record how far a delivery got through CCX's own machinery.
|
|
38
|
+
# Public surfaces (CLI, RPC, Python results) must speak only about placement,
|
|
39
|
+
# because that is the only thing a caller can act on:
|
|
40
|
+
#
|
|
41
|
+
# accepted-pending CCX durably owns the prompt; Claude has not visibly
|
|
42
|
+
# placed it in a turn yet (internally accepted/pasted/
|
|
43
|
+
# enqueued). Never a reason to resend.
|
|
44
|
+
# consumed-current-turn the running turn saw it (queued_command attachment).
|
|
45
|
+
# started-next-turn it runs as the next turn (user event / dequeue).
|
|
46
|
+
# unknown genuinely indeterminate transport or evidence.
|
|
47
|
+
PUBLIC_ACCEPTED_PENDING = "accepted-pending"
|
|
48
|
+
PUBLIC_CONSUMED = "consumed-current-turn"
|
|
49
|
+
PUBLIC_STARTED = "started-next-turn"
|
|
50
|
+
PUBLIC_UNKNOWN = "unknown"
|
|
51
|
+
|
|
52
|
+
PUBLIC_STATUS = {
|
|
53
|
+
"accepted": PUBLIC_ACCEPTED_PENDING,
|
|
54
|
+
"pasted": PUBLIC_ACCEPTED_PENDING,
|
|
55
|
+
"enqueued": PUBLIC_ACCEPTED_PENDING,
|
|
56
|
+
"consumed": PUBLIC_CONSUMED,
|
|
57
|
+
"started": PUBLIC_STARTED,
|
|
58
|
+
"unknown": PUBLIC_UNKNOWN,
|
|
59
|
+
# No code path produces `failed` any more: once a prompt may have reached
|
|
60
|
+
# Ink, "proven undelivered" is not a claim CCX can make. Kept mapped so an
|
|
61
|
+
# old journal never leaks a non-vocabulary status.
|
|
62
|
+
"failed": PUBLIC_UNKNOWN,
|
|
63
|
+
"delivered": PUBLIC_UNKNOWN, # placement from exact evidence below
|
|
64
|
+
"abandoned": PUBLIC_UNKNOWN,
|
|
65
|
+
}
|
|
66
|
+
PUBLIC_TERMINAL_STATUSES = frozenset({PUBLIC_CONSUMED, PUBLIC_STARTED, PUBLIC_UNKNOWN})
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def public_status(status: str | None) -> str | None:
|
|
70
|
+
"""Map an internal status onto the approved placement vocabulary."""
|
|
71
|
+
if status is None:
|
|
72
|
+
return None
|
|
73
|
+
return PUBLIC_STATUS.get(status, PUBLIC_UNKNOWN)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
#: Statuses whose placement may still be decided by later native evidence.
|
|
77
|
+
#: ``unknown`` is included deliberately: it records missing evidence, not a
|
|
78
|
+
#: disproven delivery, so its prompt text is retained for correlation.
|
|
79
|
+
RECONCILABLE_STATUSES = PENDING_STATUSES | {"unknown"}
|
|
80
|
+
|
|
81
|
+
_ALLOWED_TRANSITIONS = {
|
|
82
|
+
"accepted": frozenset({"accepted", "pasted", "enqueued", "consumed",
|
|
83
|
+
"started", "failed", "unknown", "delivered", "abandoned"}),
|
|
84
|
+
# Once written to the PTY we can no longer claim it was never delivered,
|
|
85
|
+
# so `failed` is unreachable from here — only evidence or `unknown`.
|
|
86
|
+
"pasted": frozenset({"pasted", "enqueued", "consumed", "started", "unknown",
|
|
87
|
+
"delivered", "abandoned"}),
|
|
88
|
+
"enqueued": frozenset({"enqueued", "consumed", "started", "unknown",
|
|
89
|
+
"delivered", "abandoned"}),
|
|
90
|
+
"consumed": frozenset({"consumed"}),
|
|
91
|
+
"started": frozenset({"started"}),
|
|
92
|
+
"failed": frozenset({"failed"}),
|
|
93
|
+
# `unknown` means evidence was missing, not that placement was disproven;
|
|
94
|
+
# a late native trace is stronger and may still resolve it.
|
|
95
|
+
"unknown": frozenset({"unknown", "consumed", "started",
|
|
96
|
+
"delivered", "abandoned"}),
|
|
97
|
+
"delivered": frozenset({"delivered"}),
|
|
98
|
+
"abandoned": frozenset({"abandoned"}),
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
_EVIDENCE_STATUS = {"enqueued": "enqueued", "started": "started",
|
|
102
|
+
"consumed": "consumed"}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class SteerJournalError(RuntimeError):
|
|
106
|
+
"""The durable steering journal exists but cannot be trusted."""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class SteerJournalBusy(CcxError, SteerJournalError):
|
|
110
|
+
"""Another process holds the journal's mutation lock right now.
|
|
111
|
+
|
|
112
|
+
Distinct from corruption: the journal is fine, this caller simply refused
|
|
113
|
+
to wait any longer. Callers must treat it as "nothing was written" — a
|
|
114
|
+
delivery is never journaled, marked, or transitioned on this path — which
|
|
115
|
+
is what keeps a lock timeout from turning into a second paste. A typed
|
|
116
|
+
refusal (CLI exit 5): retrying the same delivery is safe.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
exit_code = EXIT_REJECTED
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def now_ms() -> int:
|
|
123
|
+
return int(time.time() * 1000)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def new_delivery_id() -> str:
|
|
127
|
+
return str(uuid.uuid4())
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def valid_delivery_id(delivery_id) -> bool:
|
|
131
|
+
return (isinstance(delivery_id, str) and bool(delivery_id)
|
|
132
|
+
and len(delivery_id) <= MAX_DELIVERY_ID)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def preview(text: str | None, limit: int = PREVIEW_LIMIT) -> str | None:
|
|
136
|
+
if not isinstance(text, str) or not text:
|
|
137
|
+
return None
|
|
138
|
+
return text if len(text) <= limit else text[:limit] + "…"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def transcript_offset(path: str | pathlib.Path | None) -> int:
|
|
142
|
+
try:
|
|
143
|
+
return pathlib.Path(path).stat().st_size
|
|
144
|
+
except (OSError, TypeError):
|
|
145
|
+
return 0
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def transcript_identity(path: str | pathlib.Path | None) -> tuple[int | None, int | None]:
|
|
149
|
+
try:
|
|
150
|
+
stat = pathlib.Path(path).stat()
|
|
151
|
+
except (OSError, TypeError):
|
|
152
|
+
return None, None
|
|
153
|
+
return stat.st_dev, stat.st_ino
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def empty_journal() -> dict[str, Any]:
|
|
157
|
+
return {"version": JOURNAL_VERSION, "deliveries": []}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def read_journal(short: str) -> dict[str, Any]:
|
|
161
|
+
path = steer_deliveries_path(short)
|
|
162
|
+
try:
|
|
163
|
+
value = json.loads(path.read_text())
|
|
164
|
+
except FileNotFoundError:
|
|
165
|
+
return empty_journal()
|
|
166
|
+
except OSError as exc:
|
|
167
|
+
raise SteerJournalError(f"cannot read steering journal: {exc}") from exc
|
|
168
|
+
except ValueError as exc:
|
|
169
|
+
raise SteerJournalError("steering journal is not valid JSON") from exc
|
|
170
|
+
if not isinstance(value, dict) or not isinstance(value.get("deliveries"), list):
|
|
171
|
+
raise SteerJournalError("steering journal has an invalid shape")
|
|
172
|
+
return value
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def write_journal(short: str, journal: dict[str, Any]) -> None:
|
|
176
|
+
path = steer_deliveries_path(short)
|
|
177
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
178
|
+
deliveries = list(journal.get("deliveries") or [])
|
|
179
|
+
# Never evict a pending delivery: its full prompt is the only copy of work
|
|
180
|
+
# the caller already owns.
|
|
181
|
+
pending = [d for d in deliveries if d.get("status") in PENDING_STATUSES]
|
|
182
|
+
terminal = [d for d in deliveries if d.get("status") not in PENDING_STATUSES]
|
|
183
|
+
# Keep the newest terminals that still fit beside the pending entries. A
|
|
184
|
+
# bare negative slice would turn "no room left" into `terminal[-0:]`, which
|
|
185
|
+
# keeps every terminal — an unbounded journal exactly when it is fullest.
|
|
186
|
+
room = max(0, JOURNAL_KEEP - len(pending))
|
|
187
|
+
kept = (terminal[-room:] if room else []) + pending
|
|
188
|
+
# Resolved entries keep identity and evidence, not the full prompt body.
|
|
189
|
+
trimmed = []
|
|
190
|
+
for entry in kept:
|
|
191
|
+
if entry.get("status") in RECONCILABLE_STATUSES or not entry.get("text"):
|
|
192
|
+
trimmed.append(entry)
|
|
193
|
+
else:
|
|
194
|
+
trimmed.append({**entry, "text": None})
|
|
195
|
+
value = {
|
|
196
|
+
"version": JOURNAL_VERSION,
|
|
197
|
+
"updated_at": now_ms(),
|
|
198
|
+
"deliveries": trimmed,
|
|
199
|
+
}
|
|
200
|
+
write_json(path, value)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# Hold the lock across read-modify-replace so concurrent writers cannot erase
|
|
204
|
+
# an acknowledged delivery. Atomic publication lets readers stay lock-free.
|
|
205
|
+
|
|
206
|
+
#: Bound for the Claude host, whose select loop is single-threaded: it must
|
|
207
|
+
#: never wait behind a SIGSTOPed or wedged client holding the sidecar lock.
|
|
208
|
+
#: Short enough that a stalled peer costs one skipped reconcile pass rather
|
|
209
|
+
#: than a frozen worker.
|
|
210
|
+
HOST_LOCK_TIMEOUT_S = 2.0
|
|
211
|
+
#: Bound for out-of-host callers (CLI/Python client reconciliation). They may
|
|
212
|
+
#: wait longer than the host — nothing else is blocked behind them — but never
|
|
213
|
+
#: indefinitely.
|
|
214
|
+
CLIENT_LOCK_TIMEOUT_S = 15.0
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def journal_lock(short: str, *, timeout: float | None = None):
|
|
218
|
+
return _journal_lock(
|
|
219
|
+
steer_deliveries_lock_path(short),
|
|
220
|
+
timeout=CLIENT_LOCK_TIMEOUT_S if timeout is None else timeout,
|
|
221
|
+
busy_error=SteerJournalBusy, label=f"steering journal for {short}",
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def get_delivery(short: str, delivery_id: str | None) -> dict | None:
|
|
226
|
+
if not delivery_id:
|
|
227
|
+
return None
|
|
228
|
+
for entry in reversed(read_journal(short).get("deliveries") or []):
|
|
229
|
+
if entry.get("delivery_id") == delivery_id:
|
|
230
|
+
return dict(entry)
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def pending_deliveries(short: str) -> list[dict]:
|
|
235
|
+
return [dict(d) for d in (read_journal(short).get("deliveries") or [])
|
|
236
|
+
if d.get("status") in PENDING_STATUSES]
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def unproven_deliveries(short: str) -> list[dict]:
|
|
240
|
+
"""Deliveries whose placement inside Ink is still unproven.
|
|
241
|
+
|
|
242
|
+
A pending entry is obviously unproven. So is a terminal ``unknown`` that
|
|
243
|
+
carries no evidence: it means ccx wrote prompt text into the editor and
|
|
244
|
+
never learned where that text went. Claude may still be holding it as a
|
|
245
|
+
queued prompt, so the input surface is not provably free — the text can
|
|
246
|
+
surface as a turn at any later moment (observed in the wild ~33 minutes
|
|
247
|
+
after the delivery gave up). Anything that seizes the input surface for a
|
|
248
|
+
local slash command must refuse while such an entry exists; the host's
|
|
249
|
+
reconciliation pump clears it as soon as native evidence appears.
|
|
250
|
+
"""
|
|
251
|
+
return [dict(d) for d in (read_journal(short).get("deliveries") or [])
|
|
252
|
+
if d.get("status") in RECONCILABLE_STATUSES and not d.get("evidence")]
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def begin_delivery(
|
|
256
|
+
short: str,
|
|
257
|
+
*,
|
|
258
|
+
delivery_id: str,
|
|
259
|
+
text: str,
|
|
260
|
+
transcript: str | None,
|
|
261
|
+
host_epoch: str,
|
|
262
|
+
state: str | None = None,
|
|
263
|
+
cap: dict | None = None,
|
|
264
|
+
requested_mode: str | None = None,
|
|
265
|
+
route: str | None = None,
|
|
266
|
+
target_turn_ref: str | None = None,
|
|
267
|
+
timeout: float | None = None,
|
|
268
|
+
) -> tuple[dict, bool]:
|
|
269
|
+
"""Journal a steer durably, or return the idempotent prior delivery.
|
|
270
|
+
|
|
271
|
+
The second element is False when this ``delivery_id`` was already owned —
|
|
272
|
+
the caller must then not queue anything: the original entry is the single
|
|
273
|
+
source of truth for that delivery, whatever state it reached.
|
|
274
|
+
|
|
275
|
+
Raises :class:`SteerJournalBusy` if the journal lock cannot be taken within
|
|
276
|
+
``timeout``. Nothing is journaled then, so no ownership is acknowledged and
|
|
277
|
+
the caller may retry with the same ``delivery_id``.
|
|
278
|
+
"""
|
|
279
|
+
with journal_lock(short, timeout=timeout):
|
|
280
|
+
return _begin_delivery_locked(
|
|
281
|
+
short, delivery_id=delivery_id, text=text, transcript=transcript,
|
|
282
|
+
host_epoch=host_epoch, state=state, cap=cap,
|
|
283
|
+
requested_mode=requested_mode, route=route,
|
|
284
|
+
target_turn_ref=target_turn_ref,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _begin_delivery_locked(
|
|
289
|
+
short: str,
|
|
290
|
+
*,
|
|
291
|
+
delivery_id: str,
|
|
292
|
+
text: str,
|
|
293
|
+
transcript: str | None,
|
|
294
|
+
host_epoch: str,
|
|
295
|
+
state: str | None = None,
|
|
296
|
+
cap: dict | None = None,
|
|
297
|
+
requested_mode: str | None = None,
|
|
298
|
+
route: str | None = None,
|
|
299
|
+
target_turn_ref: str | None = None,
|
|
300
|
+
) -> tuple[dict, bool]:
|
|
301
|
+
journal = read_journal(short)
|
|
302
|
+
deliveries = journal.get("deliveries") or []
|
|
303
|
+
for entry in deliveries:
|
|
304
|
+
if entry.get("delivery_id") == delivery_id:
|
|
305
|
+
return dict(entry), False
|
|
306
|
+
accepted_at = now_ms()
|
|
307
|
+
fence_dev, fence_inode = transcript_identity(transcript)
|
|
308
|
+
entry = {
|
|
309
|
+
"delivery_id": delivery_id,
|
|
310
|
+
"status": "accepted",
|
|
311
|
+
"accepted_at": accepted_at,
|
|
312
|
+
"updated_at": accepted_at,
|
|
313
|
+
"host_epoch": host_epoch,
|
|
314
|
+
"accepted_state": state,
|
|
315
|
+
"transcript": str(transcript) if transcript else None,
|
|
316
|
+
"fence_offset": transcript_offset(transcript),
|
|
317
|
+
"fence_dev": fence_dev,
|
|
318
|
+
"fence_inode": fence_inode,
|
|
319
|
+
"text": text,
|
|
320
|
+
"text_len": len(text or ""),
|
|
321
|
+
"preview": preview(text),
|
|
322
|
+
"cap": cap or None,
|
|
323
|
+
"requested_mode": requested_mode,
|
|
324
|
+
"route": route,
|
|
325
|
+
"target_turn_ref": target_turn_ref,
|
|
326
|
+
"attempts": 0,
|
|
327
|
+
"pasted_at": None,
|
|
328
|
+
"evidence": None,
|
|
329
|
+
"evidence_offset": None,
|
|
330
|
+
"evidence_uuid": None,
|
|
331
|
+
"reason": None,
|
|
332
|
+
}
|
|
333
|
+
deliveries.append(entry)
|
|
334
|
+
journal["deliveries"] = deliveries
|
|
335
|
+
write_journal(short, journal)
|
|
336
|
+
return dict(entry), True
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def transition_delivery(short: str, delivery_id: str, status: str,
|
|
340
|
+
*, timeout: float | None = None, **patch: Any) -> dict:
|
|
341
|
+
if status not in ALL_STATUSES:
|
|
342
|
+
raise ValueError(f"invalid steering delivery status: {status}")
|
|
343
|
+
with journal_lock(short, timeout=timeout):
|
|
344
|
+
return _transition_delivery_locked(short, delivery_id, status, **patch)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _transition_delivery_locked(short: str, delivery_id: str, status: str,
|
|
348
|
+
**patch: Any) -> dict:
|
|
349
|
+
journal = read_journal(short)
|
|
350
|
+
deliveries = journal.get("deliveries") or []
|
|
351
|
+
for index, entry in enumerate(deliveries):
|
|
352
|
+
if entry.get("delivery_id") != delivery_id:
|
|
353
|
+
continue
|
|
354
|
+
previous = entry.get("status")
|
|
355
|
+
if status not in _ALLOWED_TRANSITIONS.get(previous, frozenset()):
|
|
356
|
+
raise ValueError(
|
|
357
|
+
f"non-monotonic steering transition {previous!r} -> {status!r}"
|
|
358
|
+
)
|
|
359
|
+
updated = {**entry, **patch, "status": status, "updated_at": now_ms()}
|
|
360
|
+
deliveries[index] = updated
|
|
361
|
+
journal["deliveries"] = deliveries
|
|
362
|
+
write_journal(short, journal)
|
|
363
|
+
return dict(updated)
|
|
364
|
+
raise KeyError(f"unknown steering delivery: {delivery_id}")
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def mark_pasted(short: str, delivery_id: str, *, offset: int | None = None,
|
|
368
|
+
timeout: float | None = None) -> dict:
|
|
369
|
+
"""Record a PTY write attempt before it happens.
|
|
370
|
+
|
|
371
|
+
Journaling the attempt first is what makes a crash mid-write safe: the
|
|
372
|
+
recovery path can see that the text may already be in Ink and must not
|
|
373
|
+
write it again.
|
|
374
|
+
|
|
375
|
+
One delivery is written at a time; its own text and transcript byte fence
|
|
376
|
+
distinguish it from any other delivery with identical text.
|
|
377
|
+
"""
|
|
378
|
+
with journal_lock(short, timeout=timeout):
|
|
379
|
+
# Read and increment inside one hold: the attempt counter is itself a
|
|
380
|
+
# read-modify-write, and losing an attempt is how a recovered host
|
|
381
|
+
# concludes a written prompt was never written and pastes it twice.
|
|
382
|
+
entry = get_delivery(short, delivery_id)
|
|
383
|
+
attempts = int((entry or {}).get("attempts") or 0) + 1
|
|
384
|
+
return transition_delivery(
|
|
385
|
+
short, delivery_id, "pasted",
|
|
386
|
+
attempts=attempts,
|
|
387
|
+
pasted_at=now_ms(),
|
|
388
|
+
paste_offset=offset,
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _fence_valid(entry: dict, transcript: str | None) -> bool:
|
|
393
|
+
path = transcript or entry.get("transcript")
|
|
394
|
+
if not path:
|
|
395
|
+
return False
|
|
396
|
+
fence_dev = entry.get("fence_dev")
|
|
397
|
+
fence_inode = entry.get("fence_inode")
|
|
398
|
+
if fence_dev is None or fence_inode is None:
|
|
399
|
+
return True
|
|
400
|
+
current_dev, current_inode = transcript_identity(path)
|
|
401
|
+
return current_dev == fence_dev and current_inode == fence_inode
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _scan_from(entry: dict) -> int:
|
|
405
|
+
"""Byte fence for one entry: never earlier than its own paste attempt."""
|
|
406
|
+
return max(int(entry.get("fence_offset") or 0),
|
|
407
|
+
int(entry.get("paste_offset") or 0))
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def reconcile_pending(
|
|
411
|
+
short: str,
|
|
412
|
+
*,
|
|
413
|
+
transcript: str | None = None,
|
|
414
|
+
now: float | None = None,
|
|
415
|
+
grace_s: float = STEER_EVIDENCE_GRACE_S,
|
|
416
|
+
timeout: float | None = None,
|
|
417
|
+
) -> list[dict]:
|
|
418
|
+
"""Resolve pending deliveries from native transcript evidence.
|
|
419
|
+
|
|
420
|
+
Original deliveries are considered in acceptance order. One native
|
|
421
|
+
evidence event belongs to at most one delivery, even with identical text.
|
|
422
|
+
|
|
423
|
+
``now``/``grace_s`` are injectable so tests never need a real wall clock.
|
|
424
|
+
"""
|
|
425
|
+
plan = _reconcile_plan(short, transcript=transcript, now=now,
|
|
426
|
+
grace_s=grace_s)
|
|
427
|
+
if not plan:
|
|
428
|
+
return []
|
|
429
|
+
return _apply_reconcile_plan(short, plan, timeout=timeout)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _evidence_claim(offset, evidence_uuid):
|
|
433
|
+
"""Identity of one native evidence event, or None when it has neither.
|
|
434
|
+
|
|
435
|
+
An offset of ``None`` is not an identity: putting it in the claimed set
|
|
436
|
+
would make the first offset-less event block every later one, so fall back
|
|
437
|
+
to the event's own uuid.
|
|
438
|
+
"""
|
|
439
|
+
if offset is not None:
|
|
440
|
+
return ("offset", offset)
|
|
441
|
+
if evidence_uuid is not None:
|
|
442
|
+
return ("uuid", evidence_uuid)
|
|
443
|
+
return None
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _reconcile_plan(
|
|
447
|
+
short: str,
|
|
448
|
+
*,
|
|
449
|
+
transcript: str | None = None,
|
|
450
|
+
now: float | None = None,
|
|
451
|
+
grace_s: float = STEER_EVIDENCE_GRACE_S,
|
|
452
|
+
) -> list[tuple[str, str, dict]]:
|
|
453
|
+
"""Decide what the evidence implies, holding no lock.
|
|
454
|
+
|
|
455
|
+
Reading the journal and scanning the transcript are the slow parts of
|
|
456
|
+
reconciliation, and doing them under the mutation lock would hold it for as
|
|
457
|
+
long as a transcript takes to walk — while the single-threaded host may be
|
|
458
|
+
waiting for it. They are pure reads over an atomically published journal,
|
|
459
|
+
so they are safe here; :func:`_apply_reconcile_plan` re-validates every
|
|
460
|
+
decision against the live journal before writing.
|
|
461
|
+
"""
|
|
462
|
+
journal = read_journal(short)
|
|
463
|
+
deliveries = journal.get("deliveries") or []
|
|
464
|
+
pending = [d for d in deliveries if d.get("status") in RECONCILABLE_STATUSES]
|
|
465
|
+
if not pending:
|
|
466
|
+
return []
|
|
467
|
+
claimed: dict[tuple, str] = {}
|
|
468
|
+
for d in deliveries:
|
|
469
|
+
claim = _evidence_claim(d.get("evidence_offset"), d.get("evidence_uuid"))
|
|
470
|
+
if claim is not None:
|
|
471
|
+
claimed.setdefault(claim, d["delivery_id"])
|
|
472
|
+
now = time.time() if now is None else now
|
|
473
|
+
plan: list[tuple[str, str, dict]] = []
|
|
474
|
+
for entry in sorted(pending, key=lambda d: d.get("accepted_at") or 0):
|
|
475
|
+
delivery_id = entry["delivery_id"]
|
|
476
|
+
path = transcript or entry.get("transcript")
|
|
477
|
+
# Only stronger evidence advances the original delivery.
|
|
478
|
+
floor = STEER_EVIDENCE_RANK.get(entry.get("status"), 0)
|
|
479
|
+
best = None
|
|
480
|
+
if path and _fence_valid(entry, transcript):
|
|
481
|
+
for evidence in steer_evidence_events(
|
|
482
|
+
path, entry.get("text") or "", after_offset=_scan_from(entry),
|
|
483
|
+
):
|
|
484
|
+
claim = _evidence_claim(evidence.get("offset"),
|
|
485
|
+
evidence.get("uuid"))
|
|
486
|
+
if (claim is not None and claim in claimed
|
|
487
|
+
and claimed[claim] != delivery_id):
|
|
488
|
+
continue
|
|
489
|
+
if evidence["rank"] <= floor:
|
|
490
|
+
continue
|
|
491
|
+
if best is None or evidence["rank"] > best["rank"]:
|
|
492
|
+
best = evidence
|
|
493
|
+
if best is not None:
|
|
494
|
+
best_claim = _evidence_claim(best.get("offset"), best.get("uuid"))
|
|
495
|
+
if best_claim is not None:
|
|
496
|
+
claimed[best_claim] = delivery_id
|
|
497
|
+
plan.append((delivery_id, _EVIDENCE_STATUS[best["kind"]], {
|
|
498
|
+
"evidence": best["kind"],
|
|
499
|
+
"evidence_offset": best.get("offset"),
|
|
500
|
+
"evidence_uuid": best.get("uuid"),
|
|
501
|
+
"reason": None,
|
|
502
|
+
}))
|
|
503
|
+
continue
|
|
504
|
+
# A possibly written input without native evidence stays unknown, never
|
|
505
|
+
# eligible for a second paste.
|
|
506
|
+
if entry.get("status") == "pasted":
|
|
507
|
+
pasted_at = (entry.get("pasted_at") or entry.get("accepted_at") or 0) / 1000.0
|
|
508
|
+
if grace_s is not None and now - pasted_at >= grace_s:
|
|
509
|
+
plan.append((delivery_id, "unknown", {
|
|
510
|
+
"reason": "no native evidence within the delivery "
|
|
511
|
+
"grace window",
|
|
512
|
+
}))
|
|
513
|
+
return plan
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _apply_reconcile_plan(short: str, plan: list[tuple[str, str, dict]], *,
|
|
517
|
+
timeout: float | None = None) -> list[dict]:
|
|
518
|
+
"""Write a plan under one short hold, re-validating against the live journal.
|
|
519
|
+
|
|
520
|
+
The plan was computed lock-free, so the journal may have moved on: another
|
|
521
|
+
process may have resolved an entry, or claimed the very evidence event this
|
|
522
|
+
plan wanted to use. Both are re-checked here, and a stale decision is
|
|
523
|
+
dropped rather than written — a delivery only ever moves forward, and one
|
|
524
|
+
native event still resolves at most one original delivery.
|
|
525
|
+
"""
|
|
526
|
+
resolved: list[dict] = []
|
|
527
|
+
with journal_lock(short, timeout=timeout):
|
|
528
|
+
current = {d.get("delivery_id"): d
|
|
529
|
+
for d in (read_journal(short).get("deliveries") or [])}
|
|
530
|
+
# Include both already settled and still planned deliveries: a claim
|
|
531
|
+
# can be reused only by its own original delivery.
|
|
532
|
+
claimed: dict[tuple, str] = {}
|
|
533
|
+
for delivery_id, entry in current.items():
|
|
534
|
+
claim = _evidence_claim(entry.get("evidence_offset"),
|
|
535
|
+
entry.get("evidence_uuid"))
|
|
536
|
+
if claim is not None:
|
|
537
|
+
claimed.setdefault(claim, delivery_id)
|
|
538
|
+
for delivery_id, status, patch in plan:
|
|
539
|
+
entry = current.get(delivery_id)
|
|
540
|
+
if entry is None:
|
|
541
|
+
continue
|
|
542
|
+
if status not in _ALLOWED_TRANSITIONS.get(entry.get("status"),
|
|
543
|
+
frozenset()):
|
|
544
|
+
continue
|
|
545
|
+
if STEER_EVIDENCE_RANK.get(status, 0) <= \
|
|
546
|
+
STEER_EVIDENCE_RANK.get(entry.get("status"), 0) \
|
|
547
|
+
and status != "unknown":
|
|
548
|
+
continue
|
|
549
|
+
claim = _evidence_claim(patch.get("evidence_offset"),
|
|
550
|
+
patch.get("evidence_uuid"))
|
|
551
|
+
if (claim is not None and claim in claimed
|
|
552
|
+
and claimed[claim] != delivery_id):
|
|
553
|
+
continue
|
|
554
|
+
updated = transition_delivery(short, delivery_id, status, **patch)
|
|
555
|
+
if claim is not None:
|
|
556
|
+
claimed[claim] = delivery_id
|
|
557
|
+
current[delivery_id] = updated
|
|
558
|
+
resolved.append(updated)
|
|
559
|
+
return resolved
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def reset_semantic_delivery_for_retry(
|
|
563
|
+
short: str,
|
|
564
|
+
delivery_id: str,
|
|
565
|
+
*,
|
|
566
|
+
timeout: float | None = None,
|
|
567
|
+
) -> dict:
|
|
568
|
+
"""Reset one semantic hot entry after positive non-submission proof.
|
|
569
|
+
|
|
570
|
+
Only a proven unsubmitted idle paste may be reset after clearing Ink.
|
|
571
|
+
A possibly placed native steer is never eligible for another paste.
|
|
572
|
+
"""
|
|
573
|
+
with journal_lock(short, timeout=timeout):
|
|
574
|
+
journal = read_journal(short)
|
|
575
|
+
deliveries = journal.get("deliveries") or []
|
|
576
|
+
for index, entry in enumerate(deliveries):
|
|
577
|
+
if entry.get("delivery_id") != delivery_id:
|
|
578
|
+
continue
|
|
579
|
+
if entry.get("status") != "pasted":
|
|
580
|
+
raise SteerJournalError(
|
|
581
|
+
f"semantic retry reset requires pasted status, got {entry.get('status')!r}"
|
|
582
|
+
)
|
|
583
|
+
updated = {
|
|
584
|
+
**entry,
|
|
585
|
+
"status": "accepted",
|
|
586
|
+
"maybe_written": False,
|
|
587
|
+
"cleared_at": now_ms(),
|
|
588
|
+
"reason": "prior paste cleared after positive non-submission proof",
|
|
589
|
+
"updated_at": now_ms(),
|
|
590
|
+
}
|
|
591
|
+
deliveries[index] = updated
|
|
592
|
+
journal["deliveries"] = deliveries
|
|
593
|
+
write_journal(short, journal)
|
|
594
|
+
return dict(updated)
|
|
595
|
+
raise KeyError(f"unknown steering delivery: {delivery_id}")
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def fail_semantic_delivery_unwritten(
|
|
599
|
+
short: str,
|
|
600
|
+
delivery_id: str,
|
|
601
|
+
*,
|
|
602
|
+
reason: str,
|
|
603
|
+
timeout: float | None = None,
|
|
604
|
+
) -> dict:
|
|
605
|
+
"""Terminalize a semantic hot entry after proven non-delivery + clear."""
|
|
606
|
+
with journal_lock(short, timeout=timeout):
|
|
607
|
+
journal = read_journal(short)
|
|
608
|
+
deliveries = journal.get("deliveries") or []
|
|
609
|
+
for index, entry in enumerate(deliveries):
|
|
610
|
+
if entry.get("delivery_id") != delivery_id:
|
|
611
|
+
continue
|
|
612
|
+
if entry.get("status") in {"consumed", "started", "enqueued"}:
|
|
613
|
+
raise SteerJournalError(
|
|
614
|
+
f"cannot fail natively accepted semantic delivery ({entry.get('status')})"
|
|
615
|
+
)
|
|
616
|
+
updated = {
|
|
617
|
+
**entry,
|
|
618
|
+
"status": "failed",
|
|
619
|
+
"maybe_written": False,
|
|
620
|
+
"reason": reason,
|
|
621
|
+
"updated_at": now_ms(),
|
|
622
|
+
}
|
|
623
|
+
deliveries[index] = updated
|
|
624
|
+
journal["deliveries"] = deliveries
|
|
625
|
+
write_journal(short, journal)
|
|
626
|
+
return dict(updated)
|
|
627
|
+
raise KeyError(f"unknown steering delivery: {delivery_id}")
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def reconcile_delivery(short: str, delivery_id: str, *,
|
|
631
|
+
transcript: str | None = None,
|
|
632
|
+
now: float | None = None,
|
|
633
|
+
timeout: float | None = None) -> dict | None:
|
|
634
|
+
"""Reconcile the whole journal, then return one delivery's snapshot.
|
|
635
|
+
|
|
636
|
+
Not held under one lock end to end: reconciliation re-validates its own
|
|
637
|
+
decisions, and the snapshot read afterwards is atomic on its own. That
|
|
638
|
+
keeps the lock hold to the write itself.
|
|
639
|
+
"""
|
|
640
|
+
entry = get_delivery(short, delivery_id)
|
|
641
|
+
if entry is None:
|
|
642
|
+
return None
|
|
643
|
+
if entry.get("status") in PENDING_STATUSES or entry.get("status") == "unknown":
|
|
644
|
+
try:
|
|
645
|
+
reconcile_pending(short, transcript=transcript, now=now,
|
|
646
|
+
timeout=timeout)
|
|
647
|
+
except SteerJournalError:
|
|
648
|
+
raise
|
|
649
|
+
except Exception:
|
|
650
|
+
pass
|
|
651
|
+
entry = get_delivery(short, delivery_id) or entry
|
|
652
|
+
return entry
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def public_delivery(entry: dict | None) -> dict | None:
|
|
656
|
+
"""Bounded RPC/CLI-safe projection.
|
|
657
|
+
|
|
658
|
+
Identity, placement, and evidence only — never the full prompt, and never
|
|
659
|
+
an internal status name: ``status`` is always one of the four approved
|
|
660
|
+
placement values (see :data:`PUBLIC_STATUS`).
|
|
661
|
+
"""
|
|
662
|
+
if entry is None:
|
|
663
|
+
return None
|
|
664
|
+
return {
|
|
665
|
+
"delivery_id": entry.get("delivery_id"),
|
|
666
|
+
"status": (PUBLIC_CONSUMED if entry.get("status") == "delivered"
|
|
667
|
+
and entry.get("evidence") == "consumed" else
|
|
668
|
+
PUBLIC_STARTED if entry.get("status") == "delivered"
|
|
669
|
+
and entry.get("evidence") == "started" else
|
|
670
|
+
public_status(entry.get("status"))),
|
|
671
|
+
"accepted_at": entry.get("accepted_at"),
|
|
672
|
+
"updated_at": entry.get("updated_at"),
|
|
673
|
+
"attempts": int(entry.get("attempts") or 0),
|
|
674
|
+
"text_len": entry.get("text_len"),
|
|
675
|
+
"preview": entry.get("preview"),
|
|
676
|
+
"evidence": entry.get("evidence"),
|
|
677
|
+
"evidence_uuid": entry.get("evidence_uuid"),
|
|
678
|
+
"reason": entry.get("reason"),
|
|
679
|
+
"pending": public_status(entry.get("status")) == PUBLIC_ACCEPTED_PENDING,
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def observe_delivery(
|
|
684
|
+
short: str,
|
|
685
|
+
delivery_id: str,
|
|
686
|
+
*,
|
|
687
|
+
transcript: str | None,
|
|
688
|
+
status_reader=None,
|
|
689
|
+
timeout: float | None = None,
|
|
690
|
+
) -> tuple[dict | None, str | None]:
|
|
691
|
+
"""Read one reconciled delivery snapshot for CLI and Python callers.
|
|
692
|
+
|
|
693
|
+
``status_reader`` is an optional zero-argument callback returning the live
|
|
694
|
+
host's RPC envelope; any unavailable/bad reply falls back to the durable
|
|
695
|
+
journal plus transcript. Journal corruption deliberately propagates so
|
|
696
|
+
public surfaces can report an indeterminate delivery instead of silently
|
|
697
|
+
treating it as absent (and retrying it).
|
|
698
|
+
"""
|
|
699
|
+
current = None
|
|
700
|
+
native_state = None
|
|
701
|
+
if status_reader is not None:
|
|
702
|
+
try:
|
|
703
|
+
reply = status_reader() or {}
|
|
704
|
+
except Exception:
|
|
705
|
+
reply = {}
|
|
706
|
+
native_state = reply.get("state")
|
|
707
|
+
if reply.get("ok"):
|
|
708
|
+
current = reply.get("delivery")
|
|
709
|
+
if current is None:
|
|
710
|
+
current = get_delivery(short, delivery_id)
|
|
711
|
+
if current is not None:
|
|
712
|
+
current = public_delivery(
|
|
713
|
+
reconcile_delivery(short, delivery_id, transcript=transcript,
|
|
714
|
+
timeout=timeout)
|
|
715
|
+
or current
|
|
716
|
+
)
|
|
717
|
+
return current, native_state
|
|
718
|
+
|
|
719
|
+
def finalize_dead_claude_steers(
|
|
720
|
+
store, target: str, *, child_pid: int | None, child_start: str | None,
|
|
721
|
+
) -> list[str]:
|
|
722
|
+
"""Once the previous child is positively dead, settle fenced native steers.
|
|
723
|
+
|
|
724
|
+
One owner-pinned fd and one bounded scan per unresolved delivery. An
|
|
725
|
+
unreadable/replaced source, unproved child death, absent fence or malformed
|
|
726
|
+
JSONL leaves the receipt unresolved. Hot journal first, then semantic:
|
|
727
|
+
a crash between writes is repaired from the hot terminal on next kill/revive.
|
|
728
|
+
No text is ever sent or replayed.
|
|
729
|
+
"""
|
|
730
|
+
from ccx.claude.delivery import (read_receipt, finalize_dead_steer)
|
|
731
|
+
from ccx.store.liveness import owned_process
|
|
732
|
+
from ccx.claude.transcript import (
|
|
733
|
+
_block_text, _has_queued_command_marker, _iter_transcript,
|
|
734
|
+
normalize_semantic_prompt_text, open_original_claude_source, prompt_matches,
|
|
735
|
+
)
|
|
736
|
+
from ccx.store.root import StorageIntegrityError
|
|
737
|
+
|
|
738
|
+
owner = store.resolve(target)
|
|
739
|
+
if owner.engine != "claude":
|
|
740
|
+
return []
|
|
741
|
+
if (not isinstance(child_pid, int) or child_pid <= 0
|
|
742
|
+
or not child_start or owned_process(child_pid, child_start) != "dead"):
|
|
743
|
+
return []
|
|
744
|
+
entries = [d for d in read_journal(owner.worker_id).get("deliveries", [])
|
|
745
|
+
if d.get("status") in
|
|
746
|
+
(RECONCILABLE_STATUSES | {"delivered", "abandoned", "consumed", "started"})]
|
|
747
|
+
if not entries:
|
|
748
|
+
return []
|
|
749
|
+
resolved: list[str] = []
|
|
750
|
+
record = store.record(owner.worker)
|
|
751
|
+
# The native owner/source is checked with descriptor-pinned origin binding.
|
|
752
|
+
with open_original_claude_source(store, owner, record) as source:
|
|
753
|
+
if source is None:
|
|
754
|
+
return []
|
|
755
|
+
pinned, stat, eof, path, _parent = source
|
|
756
|
+
claimed: dict[tuple, str] = {}
|
|
757
|
+
for row in read_journal(owner.worker_id).get("deliveries", []):
|
|
758
|
+
claim = _evidence_claim(row.get("evidence_offset"), row.get("evidence_uuid"))
|
|
759
|
+
if claim is not None:
|
|
760
|
+
claimed.setdefault(claim, row.get("delivery_id"))
|
|
761
|
+
for item in entries:
|
|
762
|
+
token = item.get("delivery_id")
|
|
763
|
+
if not isinstance(token, str) or not token:
|
|
764
|
+
continue
|
|
765
|
+
receipt = read_receipt(owner.worker, token)
|
|
766
|
+
route = (receipt or {}).get("route")
|
|
767
|
+
if (route not in {"steer", "new_turn"}
|
|
768
|
+
or item.get("route") != route):
|
|
769
|
+
continue # no hot-only authority or contradictory routing
|
|
770
|
+
if (route == "new_turn" and receipt.get("state") not in
|
|
771
|
+
{"owned", "delivered", "abandoned"}):
|
|
772
|
+
continue # an already accepted original turn needs no repair
|
|
773
|
+
if (item.get("transcript") != str(path)
|
|
774
|
+
or item.get("fence_dev") != stat.st_dev
|
|
775
|
+
or item.get("fence_inode") != stat.st_ino
|
|
776
|
+
or type(item.get("fence_offset")) is not int
|
|
777
|
+
or not 0 <= _scan_from(item) <= eof):
|
|
778
|
+
continue
|
|
779
|
+
status = item.get("status")
|
|
780
|
+
if status in {"delivered", "abandoned", "consumed", "started"}:
|
|
781
|
+
fate = ("abandoned" if status == "abandoned" else "delivered")
|
|
782
|
+
evidence = ({"kind": item.get("evidence"),
|
|
783
|
+
"offset": item.get("evidence_offset"),
|
|
784
|
+
"uuid": item.get("evidence_uuid")}
|
|
785
|
+
if fate == "delivered" else None)
|
|
786
|
+
if (route == "steer" and fate == "delivered" and (
|
|
787
|
+
evidence["kind"] not in {"enqueued", "started", "consumed"}
|
|
788
|
+
or type(evidence["offset"]) is not int
|
|
789
|
+
)):
|
|
790
|
+
continue
|
|
791
|
+
# For owned new-turns, generic hot started/consumed is already
|
|
792
|
+
# accepted delivery evidence, even without a native offset.
|
|
793
|
+
# A proven-dead child must not strand the semantic receipt.
|
|
794
|
+
else:
|
|
795
|
+
prompt = item.get("text")
|
|
796
|
+
if not isinstance(prompt, str) or not prompt.strip():
|
|
797
|
+
continue
|
|
798
|
+
stats = {}
|
|
799
|
+
best = None
|
|
800
|
+
try:
|
|
801
|
+
if route == "new_turn":
|
|
802
|
+
# Unlike steering, new-turn acceptance is only a
|
|
803
|
+
# non-meta, non-queued ordinary USER row with exactly
|
|
804
|
+
# matching normalized text after the original fence.
|
|
805
|
+
import hashlib
|
|
806
|
+
if hashlib.sha256(prompt.encode("utf-8")).hexdigest() != receipt.get(
|
|
807
|
+
"promptSha256"
|
|
808
|
+
):
|
|
809
|
+
continue
|
|
810
|
+
expected = normalize_semantic_prompt_text(prompt).strip()
|
|
811
|
+
for event in _iter_transcript(
|
|
812
|
+
pinned, stats=stats, after_offset=_scan_from(item),
|
|
813
|
+
before_offset=eof,
|
|
814
|
+
):
|
|
815
|
+
if (event.get("type") != "user"
|
|
816
|
+
or event.get("isMeta")
|
|
817
|
+
or _has_queued_command_marker(event)):
|
|
818
|
+
continue
|
|
819
|
+
content = (event.get("message") or {}).get("content")
|
|
820
|
+
if not prompt_matches(expected, _block_text(content)):
|
|
821
|
+
continue
|
|
822
|
+
evidence = {"kind": "started",
|
|
823
|
+
"offset": event.get("_ccx_end_offset"),
|
|
824
|
+
"uuid": event.get("uuid")}
|
|
825
|
+
claim = _evidence_claim(evidence["offset"], evidence["uuid"])
|
|
826
|
+
if claim is None or claim not in claimed or claimed[claim] == token:
|
|
827
|
+
best = evidence
|
|
828
|
+
break
|
|
829
|
+
else:
|
|
830
|
+
for evidence in steer_evidence_events(
|
|
831
|
+
pinned, prompt, after_offset=_scan_from(item),
|
|
832
|
+
before_offset=eof, stats=stats,
|
|
833
|
+
):
|
|
834
|
+
claim = _evidence_claim(evidence.get("offset"),
|
|
835
|
+
evidence.get("uuid"))
|
|
836
|
+
if (claim is not None and claim in claimed
|
|
837
|
+
and claimed[claim] != token):
|
|
838
|
+
continue
|
|
839
|
+
if best is None or evidence["rank"] > best["rank"]:
|
|
840
|
+
best = evidence
|
|
841
|
+
except (OSError, ValueError, StorageIntegrityError):
|
|
842
|
+
continue
|
|
843
|
+
if stats.get("malformedLines"):
|
|
844
|
+
continue
|
|
845
|
+
fate = "delivered" if best else "abandoned"
|
|
846
|
+
evidence = best
|
|
847
|
+
if status in RECONCILABLE_STATUSES:
|
|
848
|
+
patch = {
|
|
849
|
+
"evidence": evidence.get("kind") if evidence else None,
|
|
850
|
+
"evidence_offset": evidence.get("offset") if evidence else None,
|
|
851
|
+
"evidence_uuid": evidence.get("uuid") if evidence else None,
|
|
852
|
+
"reason": ("previous native Claude child exited; no fenced "
|
|
853
|
+
f"native {route} evidence" if fate == "abandoned" else None),
|
|
854
|
+
}
|
|
855
|
+
try:
|
|
856
|
+
transition_delivery(owner.worker_id, token, fate, **patch)
|
|
857
|
+
except ValueError:
|
|
858
|
+
# Concurrent evidence reconciliation won. Do not overwrite.
|
|
859
|
+
continue
|
|
860
|
+
finalize_dead_steer(owner.worker, token, fate=fate, evidence=evidence)
|
|
861
|
+
if evidence:
|
|
862
|
+
claim = _evidence_claim(evidence.get("offset"), evidence.get("uuid"))
|
|
863
|
+
if claim:
|
|
864
|
+
claimed[claim] = token
|
|
865
|
+
resolved.append(token)
|
|
866
|
+
store.revalidate()
|
|
867
|
+
return resolved
|