ccx-native 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +240 -4
- package/bin/ccx +55 -2
- package/ccx/__init__.py +167 -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 +807 -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 +269 -0
- package/ccx/claude/host/session.py +5244 -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 +2535 -0
- package/ccx/claude/usage.py +796 -0
- package/ccx/claude/usage_control.py +204 -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/models.py +53 -0
- package/ccx/cli/commands/read.py +1104 -0
- package/ccx/cli/commands/turns.py +1081 -0
- package/ccx/cli/controller.py +757 -0
- package/ccx/cli/main.py +302 -0
- package/ccx/cli/parser.py +1439 -0
- package/ccx/cli/skills.py +215 -0
- package/ccx/cli/statusline.py +160 -0
- package/ccx/client.py +1904 -0
- package/ccx/codex/__init__.py +0 -0
- package/ccx/codex/attach_tty.py +267 -0
- package/ccx/codex/client.py +738 -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 +1558 -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 +2424 -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/history_store.py +131 -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 +1454 -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/models.py +225 -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 +296 -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 +1593 -0
- package/ccx/ops/conversation_paging.py +1339 -0
- package/ccx/ops/create.py +56 -0
- package/ccx/ops/creation.py +952 -0
- package/ccx/ops/creation_steps.py +575 -0
- package/ccx/ops/event_feed.py +710 -0
- package/ccx/ops/forking.py +711 -0
- package/ccx/ops/history.py +1041 -0
- package/ccx/ops/input.py +556 -0
- package/ccx/ops/maintenance.py +295 -0
- package/ccx/ops/report.py +773 -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 +348 -0
- package/ccx/ops/wait.py +762 -0
- package/ccx/ops/worker_events.py +640 -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 +250 -0
- package/ccx/store/record_cache.py +40 -0
- package/ccx/store/records.py +309 -0
- package/ccx/store/root.py +789 -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/private_json.py +54 -0
- package/ccx/system/sanitize.py +43 -0
- package/ccx/types.py +809 -0
- package/ccx/version.py +29 -0
- package/ccx/workers.py +2093 -0
- package/ccx.py +9 -0
- package/package.json +39 -7
- package/pyproject.toml +87 -0
- package/skills/ccx/SKILL.md +166 -0
- package/skills/ccx/references/controllers.md +244 -0
- package/skills/ccx/references/history.md +50 -0
- package/skills/ccx/references/troubleshooting.md +94 -0
- package/skills/ccx/references/variables.md +62 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""Execution helpers for CCX worker variables.
|
|
2
|
+
|
|
3
|
+
This module intentionally depends only on the Python standard library. The
|
|
4
|
+
standalone durable path executes this file with the worker-selected Python,
|
|
5
|
+
which may not have the ccx package importable.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import linecache
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import runpy
|
|
16
|
+
import sys
|
|
17
|
+
import traceback
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
SCHEMA_VERSION = 1
|
|
22
|
+
STATE_FILE_LIMIT = 4 * 1024 * 1024
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def strict_json(value: Any) -> str:
|
|
26
|
+
return json.dumps(
|
|
27
|
+
value,
|
|
28
|
+
separators=(",", ":"),
|
|
29
|
+
ensure_ascii=False,
|
|
30
|
+
allow_nan=False,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _system_exit_code(error: SystemExit) -> int:
|
|
35
|
+
if error.code is None:
|
|
36
|
+
return 0
|
|
37
|
+
if isinstance(error.code, int):
|
|
38
|
+
return error.code
|
|
39
|
+
if error.code:
|
|
40
|
+
print(error.code, file=sys.stderr)
|
|
41
|
+
return 1
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def execute_target(args: list[str], globals_seed: dict[str, Any]) -> tuple[int, dict[str, Any]]:
|
|
45
|
+
"""Execute one supported Python invocation against a fresh globals mapping."""
|
|
46
|
+
ns = dict(globals_seed)
|
|
47
|
+
ns.setdefault("__name__", "__main__")
|
|
48
|
+
try:
|
|
49
|
+
if not args or args[0] == "-":
|
|
50
|
+
if sys.path:
|
|
51
|
+
sys.path[0] = ""
|
|
52
|
+
sys.argv = ["-", *args[1:]]
|
|
53
|
+
ns["__file__"] = "<stdin>"
|
|
54
|
+
source = sys.stdin.read()
|
|
55
|
+
exec(compile(source, "<stdin>", "exec"), ns, ns)
|
|
56
|
+
return 0, ns
|
|
57
|
+
|
|
58
|
+
if args[0] == "-c":
|
|
59
|
+
if sys.path:
|
|
60
|
+
sys.path[0] = ""
|
|
61
|
+
if len(args) < 2:
|
|
62
|
+
print("argument expected for -c", file=sys.stderr)
|
|
63
|
+
return 2, ns
|
|
64
|
+
sys.argv = ["-c", *args[2:]]
|
|
65
|
+
ns["__file__"] = "<string>"
|
|
66
|
+
# The bootstrap itself ran as -c; give traceback the user's source.
|
|
67
|
+
linecache.cache["<string>"] = (
|
|
68
|
+
len(args[1]), None, args[1].splitlines(keepends=True), "<string>"
|
|
69
|
+
)
|
|
70
|
+
exec(compile(args[1], "<string>", "exec"), ns, ns)
|
|
71
|
+
return 0, ns
|
|
72
|
+
|
|
73
|
+
if args[0] == "-m":
|
|
74
|
+
if sys.path:
|
|
75
|
+
sys.path[0] = ""
|
|
76
|
+
if len(args) < 2:
|
|
77
|
+
print("argument expected for -m", file=sys.stderr)
|
|
78
|
+
return 2, ns
|
|
79
|
+
sys.argv = [args[1], *args[2:]]
|
|
80
|
+
# Resolve packages through runpy, but retain the executing globals
|
|
81
|
+
# even when the module exits or raises before runpy can return them.
|
|
82
|
+
_, spec, code = runpy._get_module_details(args[1])
|
|
83
|
+
with runpy._TempModule("__main__") as temporary, runpy._ModifiedArgv0(spec.origin):
|
|
84
|
+
ns = temporary.module.__dict__
|
|
85
|
+
ns.update(globals_seed)
|
|
86
|
+
runpy._run_code(code, ns, mod_name="__main__", mod_spec=spec)
|
|
87
|
+
return 0, ns
|
|
88
|
+
|
|
89
|
+
if args[0].startswith("-"):
|
|
90
|
+
print(
|
|
91
|
+
f"ccx shared python: unsupported interpreter option {args[0]!r}; "
|
|
92
|
+
"use an explicit interpreter path for native Python",
|
|
93
|
+
file=sys.stderr,
|
|
94
|
+
)
|
|
95
|
+
return 2, ns
|
|
96
|
+
|
|
97
|
+
script = Path(args[0]).resolve()
|
|
98
|
+
if sys.path:
|
|
99
|
+
sys.path[0] = str(script.parent)
|
|
100
|
+
sys.argv = [str(script), *args[1:]]
|
|
101
|
+
ns["__file__"] = str(script)
|
|
102
|
+
source = script.read_bytes()
|
|
103
|
+
exec(compile(source, str(script), "exec"), ns, ns)
|
|
104
|
+
return 0, ns
|
|
105
|
+
except SystemExit as error:
|
|
106
|
+
return _system_exit_code(error), ns
|
|
107
|
+
except KeyboardInterrupt as error:
|
|
108
|
+
tb = error.__traceback__
|
|
109
|
+
traceback.print_exception(type(error), error, tb.tb_next if tb else None)
|
|
110
|
+
return 130, ns
|
|
111
|
+
except BaseException as error:
|
|
112
|
+
tb = error.__traceback__
|
|
113
|
+
traceback.print_exception(type(error), error, tb.tb_next if tb else None)
|
|
114
|
+
return 1, ns
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _atomic_write(path: Path, text: str) -> None:
|
|
118
|
+
tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
119
|
+
tmp.write_text(text, encoding="utf-8")
|
|
120
|
+
try:
|
|
121
|
+
os.chmod(tmp, 0o600)
|
|
122
|
+
except OSError:
|
|
123
|
+
pass
|
|
124
|
+
os.replace(tmp, path)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _parse_runner_args(argv: list[str] | None = None):
|
|
128
|
+
parser = argparse.ArgumentParser(add_help=False)
|
|
129
|
+
parser.add_argument("--state", required=True)
|
|
130
|
+
parser.add_argument("--lock", required=True)
|
|
131
|
+
parser.add_argument("--cwd", required=True)
|
|
132
|
+
parser.add_argument("--real-python", required=True)
|
|
133
|
+
parser.add_argument("target_args", nargs=argparse.REMAINDER)
|
|
134
|
+
value = parser.parse_args(argv)
|
|
135
|
+
if value.target_args and value.target_args[0] == "--":
|
|
136
|
+
value.target_args = value.target_args[1:]
|
|
137
|
+
return value
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def durable_main(argv: list[str] | None = None) -> int:
|
|
141
|
+
"""Run one durable invocation under a whole-invocation file lock."""
|
|
142
|
+
import fcntl
|
|
143
|
+
|
|
144
|
+
args = _parse_runner_args(argv)
|
|
145
|
+
state_path = Path(args.state)
|
|
146
|
+
lock_path = Path(args.lock)
|
|
147
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
with lock_path.open("a+") as lock_file:
|
|
149
|
+
fcntl.flock(lock_file, fcntl.LOCK_EX)
|
|
150
|
+
try:
|
|
151
|
+
document = json.loads(state_path.read_text(encoding="utf-8"))
|
|
152
|
+
except FileNotFoundError:
|
|
153
|
+
print("ccx: worker variable state is no longer available", file=sys.stderr)
|
|
154
|
+
return 69
|
|
155
|
+
|
|
156
|
+
if not isinstance(document.get("stateId"), str):
|
|
157
|
+
print("ccx: durable variable state is malformed", file=sys.stderr)
|
|
158
|
+
return 69
|
|
159
|
+
|
|
160
|
+
names = {
|
|
161
|
+
str(name)
|
|
162
|
+
for name in document.get("names", [])
|
|
163
|
+
if isinstance(name, str)
|
|
164
|
+
}
|
|
165
|
+
variables = document.get("variables")
|
|
166
|
+
if not isinstance(variables, dict):
|
|
167
|
+
variables = {}
|
|
168
|
+
|
|
169
|
+
seed = {name: variables[name] for name in names if name in variables}
|
|
170
|
+
old_cwd = os.getcwd()
|
|
171
|
+
old_argv = sys.argv[:]
|
|
172
|
+
try:
|
|
173
|
+
os.chdir(args.cwd)
|
|
174
|
+
exit_code, final_ns = execute_target(args.target_args, seed)
|
|
175
|
+
finally:
|
|
176
|
+
sys.argv = old_argv
|
|
177
|
+
os.chdir(old_cwd)
|
|
178
|
+
|
|
179
|
+
if exit_code != 0:
|
|
180
|
+
return exit_code
|
|
181
|
+
|
|
182
|
+
final_names = sorted(name for name in names if name in final_ns)
|
|
183
|
+
final_variables = {name: final_ns[name] for name in final_names}
|
|
184
|
+
try:
|
|
185
|
+
encoded_variables = strict_json(final_variables)
|
|
186
|
+
except (TypeError, ValueError) as error:
|
|
187
|
+
bad_name = "<unknown>"
|
|
188
|
+
for name, value in final_variables.items():
|
|
189
|
+
try:
|
|
190
|
+
strict_json(value)
|
|
191
|
+
except (TypeError, ValueError):
|
|
192
|
+
bad_name = name
|
|
193
|
+
break
|
|
194
|
+
print(
|
|
195
|
+
f"CcxStateValueUnsupported: variable {bad_name!r} can't be saved: "
|
|
196
|
+
f"{error}; use JSON dicts, lists, strings, numbers, booleans or null",
|
|
197
|
+
file=sys.stderr,
|
|
198
|
+
)
|
|
199
|
+
return 1
|
|
200
|
+
|
|
201
|
+
updated = dict(document)
|
|
202
|
+
updated["schemaVersion"] = SCHEMA_VERSION
|
|
203
|
+
updated["names"] = final_names
|
|
204
|
+
updated["variables"] = json.loads(encoded_variables)
|
|
205
|
+
text = strict_json(updated)
|
|
206
|
+
if len(text.encode("utf-8")) > STATE_FILE_LIMIT:
|
|
207
|
+
print("CcxStateTooLarge: worker variables exceed the 4 MiB state limit; "
|
|
208
|
+
"shrink a bound variable and retry", file=sys.stderr)
|
|
209
|
+
return 1
|
|
210
|
+
_atomic_write(state_path, text)
|
|
211
|
+
return 0
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
if __name__ == "__main__":
|
|
216
|
+
raise SystemExit(durable_main())
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Controller-attempt observations for the shared parent notification daemon."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ccx.controllers import store as store
|
|
9
|
+
from ccx.errors import ControllerError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _attempt_for_registration(
|
|
13
|
+
record: dict[str, Any], reg: dict[str, Any]
|
|
14
|
+
) -> dict[str, Any] | None:
|
|
15
|
+
operation = reg.get("operation") if isinstance(reg.get("operation"), dict) else {}
|
|
16
|
+
number = operation.get("attempt")
|
|
17
|
+
token = operation.get("attemptToken")
|
|
18
|
+
if not isinstance(token, str) or not token:
|
|
19
|
+
return None
|
|
20
|
+
for attempt in record.get("attempts") or []:
|
|
21
|
+
if not isinstance(attempt, dict) or attempt.get("number") != number:
|
|
22
|
+
continue
|
|
23
|
+
if attempt.get("attemptToken") != token:
|
|
24
|
+
continue
|
|
25
|
+
return attempt
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _startup_deadline(
|
|
30
|
+
reg: dict[str, Any],
|
|
31
|
+
attempt: dict[str, Any] | None = None,
|
|
32
|
+
) -> int:
|
|
33
|
+
operation = (
|
|
34
|
+
reg.get("operation")
|
|
35
|
+
if isinstance(reg.get("operation"), dict)
|
|
36
|
+
else {}
|
|
37
|
+
)
|
|
38
|
+
deadline = int(operation.get("startupDeadlineMs") or 0)
|
|
39
|
+
if isinstance(attempt, dict):
|
|
40
|
+
deadline = max(
|
|
41
|
+
deadline,
|
|
42
|
+
int(attempt.get("startedAt") or 0) + 45_000,
|
|
43
|
+
)
|
|
44
|
+
return deadline
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _record(reg: dict[str, Any]) -> dict[str, Any] | None:
|
|
48
|
+
run_id = reg["worker"]["workerId"]
|
|
49
|
+
try:
|
|
50
|
+
bootstrap = store.bootstrap_root() / run_id
|
|
51
|
+
run_dir = (bootstrap if bootstrap.is_dir() else next(
|
|
52
|
+
(path for path in store._all_run_dirs() if path.name == run_id), None
|
|
53
|
+
))
|
|
54
|
+
if run_dir is None:
|
|
55
|
+
raise ControllerError("original controller run is unavailable")
|
|
56
|
+
record = store.read_json(store.run_record_path(run_dir))
|
|
57
|
+
if record.get("runId") != run_id:
|
|
58
|
+
raise ControllerError("controller run identity differs from notification")
|
|
59
|
+
attempt = _attempt_for_registration(record, reg)
|
|
60
|
+
if (
|
|
61
|
+
isinstance(attempt, dict)
|
|
62
|
+
and attempt.get("lifecycle") in store.TERMINAL_LIFECYCLES
|
|
63
|
+
):
|
|
64
|
+
return record
|
|
65
|
+
|
|
66
|
+
# A registration can deliberately precede attempt publication. While
|
|
67
|
+
# that bounded startup window is open, missing attempt state is pending.
|
|
68
|
+
# After the window, settlement/progress will turn a missing or token-
|
|
69
|
+
# mismatched attempt into a structural unavailable result. Never inspect
|
|
70
|
+
# a later attempt's liveness on behalf of this registration.
|
|
71
|
+
if attempt is None:
|
|
72
|
+
return record
|
|
73
|
+
|
|
74
|
+
# An earlier unfinished attempt must never sample a newer host's PID.
|
|
75
|
+
if (record.get("attempts") or [])[-1] is not attempt:
|
|
76
|
+
return record
|
|
77
|
+
|
|
78
|
+
lifecycle = record.get("lifecycle") or {}
|
|
79
|
+
deadline = _startup_deadline(reg, attempt)
|
|
80
|
+
if (
|
|
81
|
+
lifecycle.get("state") == "starting"
|
|
82
|
+
and not lifecycle.get("pid")
|
|
83
|
+
and store.now_ms() < deadline
|
|
84
|
+
):
|
|
85
|
+
return record
|
|
86
|
+
return store.refresh_liveness(run_dir)
|
|
87
|
+
except (ControllerError, OSError):
|
|
88
|
+
# Bootstrap publication can temporarily hide the directory. After the
|
|
89
|
+
# already recorded deadline, keep this event visible as unavailable.
|
|
90
|
+
if store.now_ms() < _startup_deadline(reg):
|
|
91
|
+
return None
|
|
92
|
+
return {"runId": run_id, "controller": reg["worker"].get("name"),
|
|
93
|
+
"attempts": [], "reports": []}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def settlement(reg: dict[str, Any]) -> dict[str, Any] | None:
|
|
97
|
+
record = _record(reg)
|
|
98
|
+
if record is None:
|
|
99
|
+
return None
|
|
100
|
+
number = reg["operation"]["attempt"]
|
|
101
|
+
store.snapshot_attempt_outcome(record)
|
|
102
|
+
attempt = _attempt_for_registration(record, reg)
|
|
103
|
+
if attempt is None:
|
|
104
|
+
if store.now_ms() < _startup_deadline(reg):
|
|
105
|
+
return None
|
|
106
|
+
token = reg.get("operation", {}).get("attemptToken")
|
|
107
|
+
return {
|
|
108
|
+
"status": "unavailable",
|
|
109
|
+
"workerState": "unavailable",
|
|
110
|
+
"operationType": "controller-attempt",
|
|
111
|
+
"operationId": f"{record['runId']}:{number}",
|
|
112
|
+
"controller": record.get("controller"),
|
|
113
|
+
"runId": record["runId"],
|
|
114
|
+
"attempt": number,
|
|
115
|
+
"controllerStatus": None,
|
|
116
|
+
"checkpoint": None,
|
|
117
|
+
"summary": None,
|
|
118
|
+
"report": None,
|
|
119
|
+
"reportTruncated": False,
|
|
120
|
+
"reportPath": None,
|
|
121
|
+
"error": {
|
|
122
|
+
"type": "ControllerAttemptUnavailable",
|
|
123
|
+
"message": (
|
|
124
|
+
"controller attempt was not durably published"
|
|
125
|
+
if token
|
|
126
|
+
else "controller attempt is unavailable"
|
|
127
|
+
),
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
if attempt.get("lifecycle") not in store.TERMINAL_LIFECYCLES:
|
|
131
|
+
return None
|
|
132
|
+
outcome = attempt.get("outcome") or {}
|
|
133
|
+
metadata = outcome.get("report") or {}
|
|
134
|
+
report = None
|
|
135
|
+
path = metadata.get("path")
|
|
136
|
+
error = outcome.get("error")
|
|
137
|
+
if path:
|
|
138
|
+
try:
|
|
139
|
+
report = Path(path).read_text(encoding="utf-8")
|
|
140
|
+
except OSError as exc:
|
|
141
|
+
error = error or {"type": "ControllerReportUnavailable", "message": str(exc)}
|
|
142
|
+
from ccx.notify.settle import _truncate_report
|
|
143
|
+
report, truncated = _truncate_report(report)
|
|
144
|
+
return {
|
|
145
|
+
"status": attempt["lifecycle"],
|
|
146
|
+
"workerState": attempt["lifecycle"],
|
|
147
|
+
"operationType": "controller-attempt",
|
|
148
|
+
"operationId": f"{record['runId']}:{number}",
|
|
149
|
+
"controller": record.get("controller"),
|
|
150
|
+
"runId": record["runId"],
|
|
151
|
+
"attempt": number,
|
|
152
|
+
"controllerStatus": outcome.get("status"),
|
|
153
|
+
"checkpoint": outcome.get("checkpoint"),
|
|
154
|
+
"summary": outcome.get("summary"),
|
|
155
|
+
"report": report,
|
|
156
|
+
"reportTruncated": truncated,
|
|
157
|
+
"reportPath": path,
|
|
158
|
+
"error": error,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def progress(reg: dict[str, Any]) -> dict[str, Any]:
|
|
163
|
+
record = _record(reg)
|
|
164
|
+
result: dict[str, Any] = {
|
|
165
|
+
"elapsedSeconds": max(0, int((store.now_ms() - reg["createdAtMs"]) / 1000)),
|
|
166
|
+
"attempt": reg["operation"]["attempt"],
|
|
167
|
+
}
|
|
168
|
+
if record is None:
|
|
169
|
+
return {**result, "workerState": "unknown", "health": "record-unavailable"}
|
|
170
|
+
store.snapshot_attempt_outcome(record)
|
|
171
|
+
attempt = _attempt_for_registration(record, reg)
|
|
172
|
+
if attempt is None:
|
|
173
|
+
if store.now_ms() >= _startup_deadline(reg):
|
|
174
|
+
return {
|
|
175
|
+
**result,
|
|
176
|
+
"workerState": "unavailable",
|
|
177
|
+
"health": "attempt-unavailable",
|
|
178
|
+
"controller": record.get("controller"),
|
|
179
|
+
"runId": record["runId"],
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
**result,
|
|
183
|
+
"workerState": "starting",
|
|
184
|
+
"health": "attempt-pending-publication",
|
|
185
|
+
"controller": record.get("controller"),
|
|
186
|
+
"runId": record["runId"],
|
|
187
|
+
}
|
|
188
|
+
if attempt.get("outcome") is not None:
|
|
189
|
+
outcome = attempt["outcome"]
|
|
190
|
+
return {
|
|
191
|
+
**result, "workerState": attempt["lifecycle"],
|
|
192
|
+
"controller": record.get("controller"), "runId": record["runId"],
|
|
193
|
+
"controllerStatus": outcome.get("status"),
|
|
194
|
+
"checkpoint": outcome.get("checkpoint"),
|
|
195
|
+
}
|
|
196
|
+
if (record.get("attempts") or [])[-1] is not attempt:
|
|
197
|
+
return {
|
|
198
|
+
**result, "workerState": attempt.get("lifecycle") or "unavailable",
|
|
199
|
+
"controller": record.get("controller"), "runId": record["runId"],
|
|
200
|
+
}
|
|
201
|
+
reports = (record.get("reports") or [])[int(attempt.get("reportStartIndex", 0)):]
|
|
202
|
+
latest = reports[-1] if reports else {}
|
|
203
|
+
tasks = list((record.get("tasks") or {}).values())
|
|
204
|
+
return {
|
|
205
|
+
**result,
|
|
206
|
+
"workerState": (record.get("lifecycle") or {}).get("state"),
|
|
207
|
+
"controller": record.get("controller"),
|
|
208
|
+
"runId": record["runId"],
|
|
209
|
+
"controllerStatus": latest.get("status"),
|
|
210
|
+
"checkpoint": latest.get("checkpoint"),
|
|
211
|
+
"taskCount": len(tasks),
|
|
212
|
+
"activeTasks": [t.get("name") or t.get("id") for t in tasks if t.get("state") == "running"][:4],
|
|
213
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Identify the harness currently driving the CCX CLI.
|
|
2
|
+
|
|
3
|
+
This is deliberately about the *caller*, not the worker engine. A Codex turn can
|
|
4
|
+
launch either a Claude or Codex worker, and the continuation policy follows the
|
|
5
|
+
parent harness that invoked CCX.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
import os
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from ccx.errors import InvalidRequest
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _external_codex_unavailable(thread_id: str) -> str | None:
|
|
18
|
+
"""Check the *owning* app-server, not merely a thread saved on disk.
|
|
19
|
+
|
|
20
|
+
A plain Codex TUI has its own in-process app-server. A separately running
|
|
21
|
+
daemon can read that TUI's rollout, but cannot deliver into the TUI turn.
|
|
22
|
+
Only server-local loaded-thread membership permits an external return arm.
|
|
23
|
+
"""
|
|
24
|
+
from ccx.codex import control as codex_control
|
|
25
|
+
|
|
26
|
+
path = codex_control.control_socket_path()
|
|
27
|
+
try:
|
|
28
|
+
is_socket = path.is_socket()
|
|
29
|
+
except OSError as error: # e.g. a sandbox denying access to ~/.codex
|
|
30
|
+
return f"Codex app-server control socket at {path} is not accessible: {error}"
|
|
31
|
+
if not is_socket:
|
|
32
|
+
return f"no Codex app-server control socket at {path} (plain Codex TUI runs in-process)"
|
|
33
|
+
client = None
|
|
34
|
+
try:
|
|
35
|
+
client = codex_control.CodexControlClient(handshake_timeout=2)
|
|
36
|
+
client.initialize(timeout=2)
|
|
37
|
+
result = client.request("thread/loaded/list", {}, timeout=2)
|
|
38
|
+
except Exception as error:
|
|
39
|
+
return f"Codex app-server control socket at {path} is unreachable: {error}"
|
|
40
|
+
finally:
|
|
41
|
+
if client is not None:
|
|
42
|
+
client.close()
|
|
43
|
+
loaded = result.get("data")
|
|
44
|
+
if not isinstance(loaded, list) or not all(isinstance(item, str) for item in loaded):
|
|
45
|
+
return f"Codex app-server at {path} did not confirm its loaded threads"
|
|
46
|
+
if thread_id not in loaded:
|
|
47
|
+
return (f"Codex parent thread {thread_id} is not loaded in the app-server at {path}; "
|
|
48
|
+
"a separate TUI process cannot receive its notifications there")
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _owned_codex_thread(store, thread_id: str):
|
|
53
|
+
"""Complete read-only native return-address census, never a worker alias."""
|
|
54
|
+
from ccx.errors import SessionUnavailable
|
|
55
|
+
|
|
56
|
+
if not isinstance(thread_id, str) or not thread_id:
|
|
57
|
+
raise InvalidRequest("Notification needs a Codex recipient; call it from Codex")
|
|
58
|
+
matched = None
|
|
59
|
+
for row in store.list_workers(engine="codex", limit=None):
|
|
60
|
+
native = store.record(row["worker"]).get("native")
|
|
61
|
+
if not isinstance(native, dict):
|
|
62
|
+
raise SessionUnavailable("Codex worker list is unavailable; check CCX_ROOT")
|
|
63
|
+
if native.get("threadId") == thread_id:
|
|
64
|
+
if matched is not None:
|
|
65
|
+
raise SessionUnavailable("Cannot identify the Codex caller; use a worker handle")
|
|
66
|
+
matched = row["worker"]
|
|
67
|
+
store.revalidate()
|
|
68
|
+
return store.resolve(matched) if matched is not None else None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class CallerContext:
|
|
73
|
+
"""Full-UUID owned caller or explicitly unverified native origin."""
|
|
74
|
+
kind: str # codex | claude | unknown
|
|
75
|
+
native_id: str | None = None
|
|
76
|
+
worker_id: str | None = None
|
|
77
|
+
managed: bool = False
|
|
78
|
+
|
|
79
|
+
def parent_notify_unavailable(self) -> str | None:
|
|
80
|
+
"""The single capability rule for wait policy and notification arms."""
|
|
81
|
+
if (self.kind != "codex" or not self.native_id
|
|
82
|
+
or (not self.worker_id if self.managed else self.worker_id is not None)):
|
|
83
|
+
return ("parent notification needs a managed Codex caller or its own "
|
|
84
|
+
"CODEX_THREAD_ID; Claude and mixed origins are not supported")
|
|
85
|
+
if self.managed:
|
|
86
|
+
return None # CCX-owned parents keep their original codexd path.
|
|
87
|
+
return _external_codex_unavailable(self.native_id)
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def supports_parent_notify(self) -> bool:
|
|
91
|
+
return self.parent_notify_unavailable() is None
|
|
92
|
+
|
|
93
|
+
def parent_ref(self) -> dict[str, Any]:
|
|
94
|
+
unavailable = self.parent_notify_unavailable()
|
|
95
|
+
if unavailable is not None:
|
|
96
|
+
raise InvalidRequest(f"{unavailable}; use --wait / ccx wait TARGET instead")
|
|
97
|
+
from ccx.constants import CCX_ROOT
|
|
98
|
+
from ccx.errors import SessionUnavailable
|
|
99
|
+
from ccx.store.root import GenerationError, require_ccx_active
|
|
100
|
+
from ccx.store.identity import IdentityError
|
|
101
|
+
|
|
102
|
+
if not self.managed:
|
|
103
|
+
# The caller's own CODEX_THREAD_ID is a return address, not a
|
|
104
|
+
# worker target. A missing managed stamp must never cause the
|
|
105
|
+
# interactive app-server to dual-subscribe a CCX-owned rollout.
|
|
106
|
+
try:
|
|
107
|
+
store = require_ccx_active(CCX_ROOT)
|
|
108
|
+
if _owned_codex_thread(store, self.native_id) is not None:
|
|
109
|
+
raise InvalidRequest(
|
|
110
|
+
"Notification recipient is already a CCX worker; use that handle"
|
|
111
|
+
)
|
|
112
|
+
except (GenerationError, IdentityError, OSError) as exc:
|
|
113
|
+
raise SessionUnavailable(
|
|
114
|
+
f"Cannot list Codex workers; check CCX_ROOT: {exc}"
|
|
115
|
+
) from exc
|
|
116
|
+
return {"kind": "codex-native-thread", "threadId": self.native_id}
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
store = require_ccx_active(CCX_ROOT)
|
|
120
|
+
owner = store.resolve(f"codex:{self.worker_id}")
|
|
121
|
+
store.authorize_native_action(owner.worker, "send")
|
|
122
|
+
native = store.record(owner.worker).get("native")
|
|
123
|
+
if not isinstance(native, dict):
|
|
124
|
+
raise SessionUnavailable("Codex worker record is missing; inspect its handle")
|
|
125
|
+
if (native.get("threadId") != self.native_id
|
|
126
|
+
or native.get("threadSource")
|
|
127
|
+
!= store.codex_source(owner.worker)):
|
|
128
|
+
raise SessionUnavailable(
|
|
129
|
+
"Codex caller does not match the worker; verify the handle"
|
|
130
|
+
)
|
|
131
|
+
store.revalidate()
|
|
132
|
+
except (GenerationError, IdentityError, OSError) as exc:
|
|
133
|
+
raise SessionUnavailable(
|
|
134
|
+
f"Cannot verify the Codex parent worker; inspect status: {exc}"
|
|
135
|
+
) from exc
|
|
136
|
+
return {"kind": "ccx-codex", "workerId": owner.worker_id,
|
|
137
|
+
"threadId": native["threadId"], "generation": store.generation}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def current_caller() -> CallerContext:
|
|
141
|
+
"""Classify owner with full-ID engine stamp; never infer from old short.
|
|
142
|
+
|
|
143
|
+
The managed full-ID stamp is independently checked against current
|
|
144
|
+
ACTIVE global owner record and provider-native thread/session identity.
|
|
145
|
+
A native-only stamp is NOT a CCX worker association.
|
|
146
|
+
"""
|
|
147
|
+
from ccx.store.records import spawned_by_from_env
|
|
148
|
+
from ccx.constants import CCX_ROOT
|
|
149
|
+
from ccx.store.root import GenerationError, require_ccx_active
|
|
150
|
+
from ccx.errors import SessionUnavailable
|
|
151
|
+
|
|
152
|
+
origin = spawned_by_from_env()
|
|
153
|
+
if origin is None:
|
|
154
|
+
return CallerContext("unknown")
|
|
155
|
+
if origin.get("kind") == "ccx-worker":
|
|
156
|
+
wid, engine = origin["workerId"], origin["engine"]
|
|
157
|
+
try:
|
|
158
|
+
rec = require_ccx_active(CCX_ROOT).record(f"{engine}:{wid}")
|
|
159
|
+
except (GenerationError, OSError) as exc:
|
|
160
|
+
raise SessionUnavailable(
|
|
161
|
+
f"Caller worker changed; retry the notification: {exc}"
|
|
162
|
+
) from exc
|
|
163
|
+
native = rec.get("native") if isinstance(rec.get("native"), dict) else {}
|
|
164
|
+
key = "sessionId" if engine == "claude" else "threadId"
|
|
165
|
+
env_key = ("CLAUDE_CODE_SESSION_ID" if engine == "claude"
|
|
166
|
+
else "CODEX_THREAD_ID")
|
|
167
|
+
supplied = os.environ.get(env_key, "").strip()
|
|
168
|
+
if supplied and supplied != native.get(key):
|
|
169
|
+
raise SessionUnavailable(
|
|
170
|
+
"Codex caller does not match this worker; verify the handle"
|
|
171
|
+
)
|
|
172
|
+
return CallerContext(
|
|
173
|
+
engine, native_id=supplied or native.get(key),
|
|
174
|
+
worker_id=wid, managed=True,
|
|
175
|
+
)
|
|
176
|
+
if origin.get("kind") == "provider-native-unknown":
|
|
177
|
+
engine = origin.get("engine")
|
|
178
|
+
if engine in ("codex", "claude"):
|
|
179
|
+
return CallerContext(engine, native_id=origin.get("nativeId"))
|
|
180
|
+
return CallerContext("unknown")
|
|
181
|
+
raise SessionUnavailable("Notification caller could not be identified; use a CCX Codex worker")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def require_notify_parent() -> dict[str, Any]:
|
|
185
|
+
"""Return the machine-continuation parent reference or raise before effects."""
|
|
186
|
+
return current_caller().parent_ref()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def codex_blocking_override_enabled() -> bool:
|
|
190
|
+
"""Hidden developer escape hatch; intentionally absent from the public CLI."""
|
|
191
|
+
return os.environ.get("CCX_ALLOW_CODEX_BLOCKING_WAIT", "").strip().lower() in {
|
|
192
|
+
"1", "true", "yes", "on"
|
|
193
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Harness-specific CLI policy for blocking versus continuation modes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from ccx.constants import EXIT_ERROR
|
|
8
|
+
from ccx.controllers.orchestrator import codex_blocking_override_enabled, current_caller
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def blocking_turn_family(argv: list[str]) -> str | None:
|
|
12
|
+
"""Classify calls whose blocking completion has a durable continuation sibling.
|
|
13
|
+
|
|
14
|
+
This includes model turns and native compaction. Operational waits such as
|
|
15
|
+
kill confirmation and report reads stay synchronous because notification is
|
|
16
|
+
not a semantic replacement for them.
|
|
17
|
+
"""
|
|
18
|
+
if not argv or "--help" in argv or "-h" in argv:
|
|
19
|
+
return None
|
|
20
|
+
command, flags = argv[0], argv[1:]
|
|
21
|
+
if command in {"new", "fork"} and "--wait" in flags:
|
|
22
|
+
return command
|
|
23
|
+
# Every managed root send is a strict new turn and waits by default,
|
|
24
|
+
# regardless of the child engine. No target/native probe is needed here.
|
|
25
|
+
if command == "send" and "--no-wait" not in flags and "--notify-parent" not in flags:
|
|
26
|
+
return "send"
|
|
27
|
+
if command == "wait":
|
|
28
|
+
return "wait.next-turn" if "--next-turn" in flags else "wait"
|
|
29
|
+
if command in {"answer", "approve", "reject", "respond"} and "--wait" in flags:
|
|
30
|
+
return command
|
|
31
|
+
if command == "compact" and "--no-wait" not in flags and "--notify-parent" not in flags:
|
|
32
|
+
return "compact"
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _guidance(family: str) -> str:
|
|
37
|
+
if family == "wait.next-turn":
|
|
38
|
+
return "use `ccx notify TARGET --next-turn` to arm a durable future-turn continuation"
|
|
39
|
+
if family in {"wait"}:
|
|
40
|
+
return "use `ccx notify TARGET` to arm a durable parent continuation"
|
|
41
|
+
if family == "compact":
|
|
42
|
+
return "use `--notify-parent` so Codex is resumed when the exact compact operation settles"
|
|
43
|
+
return "use `--notify-parent` instead of blocking for the worker turn"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def reject_codex_blocking_call(argv: list[str]) -> None:
|
|
47
|
+
family = blocking_turn_family(argv)
|
|
48
|
+
if family is None or codex_blocking_override_enabled():
|
|
49
|
+
return
|
|
50
|
+
if not current_caller().supports_parent_notify:
|
|
51
|
+
return
|
|
52
|
+
sys.stderr.write(
|
|
53
|
+
"[ccx] blocking CCX waits are disabled for Codex orchestrators when a durable continuation exists; "
|
|
54
|
+
+ _guidance(family)
|
|
55
|
+
+ ". The notification returns immediately and resumes Codex when the awaited operation settles.\n"
|
|
56
|
+
)
|
|
57
|
+
raise SystemExit(EXIT_ERROR)
|