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,367 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any
|
|
6
|
+
from ccx.codex.records import now_ms
|
|
7
|
+
from ccx.codex.daemon.models import ReleasePending, _ThreadRelease, _retire_runtime_markers
|
|
8
|
+
import ccx.codex.daemon.models as daemon_models
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from ccx.codex.daemon.worker import Worker
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
_RELEASE_FENCE_WAIT_S = 5.0
|
|
15
|
+
# A stopped worker adopted moments ago (never loaded) is about to be resumed,
|
|
16
|
+
# killed or sent to; its caller takes load_lock right after adoption. Leave it
|
|
17
|
+
# alone briefly so the janitor's cleanup doesn't unload it first.
|
|
18
|
+
_STOPPED_UNLOAD_GRACE_S = 5.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _abandon_killed_load(daemon, worker: Worker) -> None:
|
|
22
|
+
"""Finish a kill the loader was still racing, durably.
|
|
23
|
+
|
|
24
|
+
This is the completion path for a kill that could not confirm — an
|
|
25
|
+
unresolved `turn/start` — as well as the tidy-up for one that could.
|
|
26
|
+
Deregistering alone is not enough: leaving a nonterminal record behind
|
|
27
|
+
would show a killed worker as `busy` forever. So the record is written
|
|
28
|
+
terminal and its transient runtime fields cleared here, exactly
|
|
29
|
+
as a confirmed kill does, before the worker leaves the registries.
|
|
30
|
+
|
|
31
|
+
Idempotent, and it never touches another worker's bindings.
|
|
32
|
+
"""
|
|
33
|
+
worker.loaded = False
|
|
34
|
+
# `_state` then `_unload` is the same durable order `kill` uses:
|
|
35
|
+
# terminal state first, then turn/approval fields cleared and saved.
|
|
36
|
+
worker._state("dead")
|
|
37
|
+
daemon._unload(worker, owns_lifecycle=True, terminal_state="dead")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _unload(
|
|
41
|
+
daemon,
|
|
42
|
+
worker: Worker,
|
|
43
|
+
*,
|
|
44
|
+
terminal_state: str,
|
|
45
|
+
owns_lifecycle: bool = False,
|
|
46
|
+
) -> None:
|
|
47
|
+
"""Take one Worker object out of the daemon and release its threads.
|
|
48
|
+
|
|
49
|
+
Exactly one teardown runs per object. Only the registered object is
|
|
50
|
+
torn down: the janitor's snapshot can outlive a kill and a reload, and
|
|
51
|
+
a stale object must not save its record over the fresh worker's, drop
|
|
52
|
+
the fresh worker's bindings or release the thread the fresh worker
|
|
53
|
+
resumed. A second unload of an object already being torn down returns
|
|
54
|
+
at once. The exception is the loader (`owns_lifecycle`): it holds this
|
|
55
|
+
short's lifecycle lock, so no fresh object can exist, and a kill that
|
|
56
|
+
raced its resume released the thread before that resume subscribed
|
|
57
|
+
it, so the loader must release it again.
|
|
58
|
+
|
|
59
|
+
The release never waits for Codex. Each thread gets a fence that a
|
|
60
|
+
load of that thread waits on, and that only Codex's answer (or the
|
|
61
|
+
loss of the connection) clears; see `_release_native_threads`.
|
|
62
|
+
"""
|
|
63
|
+
# Sequential, never nested inside daemon.lock: lock order is
|
|
64
|
+
# daemon.lock -> worker.lock everywhere else.
|
|
65
|
+
with daemon.lock:
|
|
66
|
+
current = daemon.workers.get(worker.short)
|
|
67
|
+
if owns_lifecycle:
|
|
68
|
+
if current is not None and current is not worker:
|
|
69
|
+
return
|
|
70
|
+
elif current is not worker or worker.teardown_claimed:
|
|
71
|
+
return
|
|
72
|
+
# Lock order is daemon.lock -> worker.lock. A native callback must
|
|
73
|
+
# not change the active-command set between the idle-only check
|
|
74
|
+
# and the final teardown claim.
|
|
75
|
+
with worker.lock:
|
|
76
|
+
if terminal_state == "idle-killed" and not owns_lifecycle:
|
|
77
|
+
if worker.attach_count or worker._bg_open:
|
|
78
|
+
return
|
|
79
|
+
worker.teardown_claimed = True
|
|
80
|
+
releases = [
|
|
81
|
+
_fence_release_locked(daemon, thread_id)
|
|
82
|
+
for thread_id in sorted(worker.native_threads)
|
|
83
|
+
if daemon.by_thread.get(thread_id, worker.short) == worker.short
|
|
84
|
+
]
|
|
85
|
+
# Unrouted before the final record write, so no notification or
|
|
86
|
+
# server request looked up from now on can reach this worker; one
|
|
87
|
+
# routed earlier sees `teardown_claimed` under `worker.lock` and
|
|
88
|
+
# changes nothing (see `on_notification`).
|
|
89
|
+
daemon._drop_thread_bindings_locked(worker.short)
|
|
90
|
+
# Once claimed, the teardown always finishes: even if the record write
|
|
91
|
+
# fails, the worker leaves the registry and every fence is sent or
|
|
92
|
+
# settled, since no later unload may claim it again.
|
|
93
|
+
try:
|
|
94
|
+
# The journal is closed next: an unloaded worker observes nothing
|
|
95
|
+
# more, and the close is where its last events reach the disk — so
|
|
96
|
+
# its outcome, including a failed final flush, belongs in the
|
|
97
|
+
# record write below rather than being discovered by nobody.
|
|
98
|
+
with worker.lock:
|
|
99
|
+
# Its last lifecycle facts go in before the journal closes;
|
|
100
|
+
# the save below then finds nothing new to journal.
|
|
101
|
+
worker._journal_lifecycle_locked(
|
|
102
|
+
state=terminal_state, approvals=[]
|
|
103
|
+
)
|
|
104
|
+
worker.evidence.close()
|
|
105
|
+
with worker.lock:
|
|
106
|
+
lifecycle = worker.record["lifecycle"]
|
|
107
|
+
# A notification handled after kill wrote its terminal state
|
|
108
|
+
# but before the claim may have moved the record; the teardown
|
|
109
|
+
# owns it.
|
|
110
|
+
if lifecycle["state"] != terminal_state:
|
|
111
|
+
lifecycle["since"] = now_ms()
|
|
112
|
+
lifecycle["state"] = terminal_state
|
|
113
|
+
lifecycle["activeTurnId"] = None
|
|
114
|
+
worker.record["activeTurnError"] = None
|
|
115
|
+
# A server request cannot survive its JSON-RPC connection scope
|
|
116
|
+
# once the worker leaves the daemon.
|
|
117
|
+
worker.record["pendingApprovals"] = []
|
|
118
|
+
if worker.evidence.observed:
|
|
119
|
+
worker.record["evidence"] = worker.evidence.state()
|
|
120
|
+
worker._save()
|
|
121
|
+
finally:
|
|
122
|
+
with daemon.lock:
|
|
123
|
+
if daemon.workers.get(worker.short) is worker:
|
|
124
|
+
del daemon.workers[worker.short]
|
|
125
|
+
if not daemon.workers:
|
|
126
|
+
daemon.empty_since = time.monotonic()
|
|
127
|
+
daemon._release_native_threads(worker, releases)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _retire_workers(daemon) -> None:
|
|
131
|
+
"""Write every still-registered worker's record for a daemon exit."""
|
|
132
|
+
with daemon.lock:
|
|
133
|
+
remaining = list(daemon.workers.values())
|
|
134
|
+
daemon.workers.clear()
|
|
135
|
+
daemon.by_thread.clear()
|
|
136
|
+
for worker in remaining:
|
|
137
|
+
with worker.lock:
|
|
138
|
+
lifecycle = worker.record["lifecycle"]
|
|
139
|
+
lifecycle["activeTurnId"] = None
|
|
140
|
+
state = lifecycle["state"]
|
|
141
|
+
if state in {"booting", "resuming", "busy", "waiting"}:
|
|
142
|
+
# Work was cut, not finished: the same verdict the next
|
|
143
|
+
# daemon's orphan repair gives a record left this way.
|
|
144
|
+
lifecycle["state"] = "crashed"
|
|
145
|
+
lifecycle["lastError"] = (
|
|
146
|
+
"codexd stopped while the worker was active")
|
|
147
|
+
lifecycle["since"] = now_ms()
|
|
148
|
+
elif state == "idle":
|
|
149
|
+
# A daemon exit merely unloads an idle worker; `dead` is
|
|
150
|
+
# reserved for an explicit kill.
|
|
151
|
+
lifecycle["state"] = "idle-killed"
|
|
152
|
+
lifecycle["since"] = now_ms()
|
|
153
|
+
elif state not in {"idle-killed", "crashed", "dead", "budget-stopped"}:
|
|
154
|
+
lifecycle["state"] = "dead"
|
|
155
|
+
lifecycle["since"] = now_ms()
|
|
156
|
+
_retire_runtime_markers(
|
|
157
|
+
worker.record,
|
|
158
|
+
"codexd stopped before the compaction was observed")
|
|
159
|
+
# This native app-server is gone. Neither its open command IDs
|
|
160
|
+
# nor foreground candidates can pin the next runtime's janitor.
|
|
161
|
+
worker._lose_native_background_locked()
|
|
162
|
+
# A server request cannot survive the process that owns its
|
|
163
|
+
# JSON-RPC connection.
|
|
164
|
+
worker.record["pendingApprovals"] = []
|
|
165
|
+
worker._save()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _fence_release_locked(daemon, thread_id: str) -> "_ThreadRelease":
|
|
169
|
+
release = _ThreadRelease(thread_id)
|
|
170
|
+
daemon.releases.setdefault(thread_id, []).append(release)
|
|
171
|
+
return release
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _settle_release(daemon, release: "_ThreadRelease") -> None:
|
|
175
|
+
with daemon.lock:
|
|
176
|
+
fences = daemon.releases.get(release.thread_id) or []
|
|
177
|
+
if release in fences:
|
|
178
|
+
fences.remove(release)
|
|
179
|
+
if not fences:
|
|
180
|
+
daemon.releases.pop(release.thread_id, None)
|
|
181
|
+
release.settled.set()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _release_native_threads(
|
|
185
|
+
daemon, worker: Worker, releases: list["_ThreadRelease"]
|
|
186
|
+
) -> None:
|
|
187
|
+
"""Send `thread/unsubscribe` for each fenced thread; never wait for it.
|
|
188
|
+
|
|
189
|
+
Forgetting a worker locally is not enough: Codex keeps a thread, and
|
|
190
|
+
any stdio MCP servers it started, loaded for as long as this connection
|
|
191
|
+
stays subscribed, which on a long-lived daemon means forever. Once the
|
|
192
|
+
last subscriber leaves, Codex unloads an idle thread after its own
|
|
193
|
+
`thread_unload_delay_secs`; a later resume reloads it from its rollout.
|
|
194
|
+
|
|
195
|
+
Each fence stays until Codex answers or the connection is lost, never
|
|
196
|
+
on a local timeout: a load of the thread waits on it, so a late
|
|
197
|
+
unsubscribe can never land after that load's resume and leave a live
|
|
198
|
+
worker deaf. A refusal only costs memory until the app-server exits,
|
|
199
|
+
so it is logged.
|
|
200
|
+
|
|
201
|
+
Sending still writes to the app-server's stdin under the client's
|
|
202
|
+
write lock, so it can wait briefly behind another request's write. It
|
|
203
|
+
blocks for long only if Codex stops reading its input, and then every
|
|
204
|
+
other request, including kill's own `turn/interrupt`, hangs the same
|
|
205
|
+
way, so the send is not moved to another thread.
|
|
206
|
+
"""
|
|
207
|
+
client = daemon.client
|
|
208
|
+
for release in releases:
|
|
209
|
+
if client is None:
|
|
210
|
+
_settle_release(daemon, release)
|
|
211
|
+
continue
|
|
212
|
+
try:
|
|
213
|
+
pending = client.begin_request(
|
|
214
|
+
"thread/unsubscribe", {"threadId": release.thread_id}
|
|
215
|
+
)
|
|
216
|
+
except Exception as error: # noqa: BLE001 - must not block unload
|
|
217
|
+
_log_release_failure(daemon, worker, release.thread_id, error)
|
|
218
|
+
_settle_release(daemon, release)
|
|
219
|
+
continue
|
|
220
|
+
pending.add_done_callback(
|
|
221
|
+
lambda answered, release=release: _on_release_answered(daemon, worker, release, answered
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _on_release_answered(
|
|
227
|
+
daemon, worker: Worker, release: "_ThreadRelease", answered: Any
|
|
228
|
+
) -> None:
|
|
229
|
+
# Runs on the reader thread: no app-server request may be made here.
|
|
230
|
+
try:
|
|
231
|
+
answered.result()
|
|
232
|
+
except Exception as error: # noqa: BLE001
|
|
233
|
+
_log_release_failure(daemon, worker, release.thread_id, error)
|
|
234
|
+
finally:
|
|
235
|
+
_settle_release(daemon, release)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _log_release_failure(daemon, worker: Worker, thread_id: str, error: Any) -> None:
|
|
239
|
+
print(
|
|
240
|
+
f"ccx codexd: could not release Codex thread {thread_id} "
|
|
241
|
+
f"of worker {worker.short}: {error}",
|
|
242
|
+
file=sys.stderr, flush=True,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _await_thread_release(daemon, thread_id: str | None) -> None:
|
|
247
|
+
"""Wait briefly for Codex to answer an earlier release of `thread_id`.
|
|
248
|
+
|
|
249
|
+
Raises `ReleasePending` rather than let a resume of the thread race an
|
|
250
|
+
unsubscribe Codex has not answered yet.
|
|
251
|
+
"""
|
|
252
|
+
if not thread_id:
|
|
253
|
+
return
|
|
254
|
+
deadline = time.monotonic() + _RELEASE_FENCE_WAIT_S
|
|
255
|
+
while True:
|
|
256
|
+
with daemon.lock:
|
|
257
|
+
fences = list(daemon.releases.get(thread_id) or [])
|
|
258
|
+
if not fences:
|
|
259
|
+
return
|
|
260
|
+
for fence in fences:
|
|
261
|
+
if not fence.settled.wait(max(0.0, deadline - time.monotonic())):
|
|
262
|
+
raise ReleasePending(
|
|
263
|
+
f"Codex has not yet confirmed releasing thread "
|
|
264
|
+
f"{thread_id} from an earlier unload; retry shortly"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _janitor(daemon) -> None:
|
|
269
|
+
while not daemon.stop.wait(1):
|
|
270
|
+
# The janitor is the only thing that idle-kills workers and ends
|
|
271
|
+
# the linger. An exception escaping it would end both silently and
|
|
272
|
+
# keep codexd and its app-server alive forever, so every step is
|
|
273
|
+
# isolated and logged.
|
|
274
|
+
try:
|
|
275
|
+
with daemon.lock:
|
|
276
|
+
snapshot = list(daemon.workers.values())
|
|
277
|
+
for worker in snapshot:
|
|
278
|
+
try:
|
|
279
|
+
daemon._janitor_worker(worker)
|
|
280
|
+
except Exception as error: # noqa: BLE001
|
|
281
|
+
_log_janitor_failure(daemon, f"worker {worker.short}", error)
|
|
282
|
+
daemon._janitor_native_start_pass()
|
|
283
|
+
if daemon._janitor_daemon_pass():
|
|
284
|
+
return
|
|
285
|
+
except Exception as error: # noqa: BLE001
|
|
286
|
+
_log_janitor_failure(daemon, "pass", error)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _log_janitor_failure(daemon, what: str, error: BaseException) -> None:
|
|
290
|
+
print(f"ccx codexd: janitor {what} failed: {error!r}",
|
|
291
|
+
file=sys.stderr, flush=True)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _janitor_worker(daemon, worker: Worker) -> None:
|
|
295
|
+
if worker.load_lock.locked():
|
|
296
|
+
return
|
|
297
|
+
worker.flush_context()
|
|
298
|
+
with worker.lock:
|
|
299
|
+
state = worker.record["lifecycle"]["state"]
|
|
300
|
+
timeout = int(
|
|
301
|
+
worker.record["createArgs"].get("idleTimeout", 1800)
|
|
302
|
+
)
|
|
303
|
+
expired = (
|
|
304
|
+
state == "idle"
|
|
305
|
+
and timeout > 0
|
|
306
|
+
and worker.attach_count == 0
|
|
307
|
+
and not worker._bg_open
|
|
308
|
+
and time.monotonic() - worker.last_activity >= timeout
|
|
309
|
+
)
|
|
310
|
+
if expired:
|
|
311
|
+
if worker.kill_intent.is_set() or worker.killed.is_set():
|
|
312
|
+
# An explicit kill owns this worker's terminal record;
|
|
313
|
+
# idle-kill must not write `idle-killed` over it.
|
|
314
|
+
return
|
|
315
|
+
if not worker.operation_lock.acquire(blocking=False):
|
|
316
|
+
return
|
|
317
|
+
try:
|
|
318
|
+
if (worker.kill_intent.is_set() or worker.killed.is_set()
|
|
319
|
+
or not daemon._still_registered(worker)):
|
|
320
|
+
return
|
|
321
|
+
with worker.lock:
|
|
322
|
+
state = worker.record["lifecycle"]["state"]
|
|
323
|
+
timeout = int(
|
|
324
|
+
worker.record["createArgs"].get(
|
|
325
|
+
"idleTimeout", 1800
|
|
326
|
+
)
|
|
327
|
+
)
|
|
328
|
+
expired = (
|
|
329
|
+
state == "idle"
|
|
330
|
+
and timeout > 0
|
|
331
|
+
and worker.attach_count == 0
|
|
332
|
+
and not worker._bg_open
|
|
333
|
+
and time.monotonic() - worker.last_activity >= timeout
|
|
334
|
+
)
|
|
335
|
+
if expired:
|
|
336
|
+
daemon._idle_kill(worker)
|
|
337
|
+
return
|
|
338
|
+
finally:
|
|
339
|
+
worker.operation_lock.release()
|
|
340
|
+
if (state in {"idle-killed", "crashed", "dead"}
|
|
341
|
+
and (worker.loaded
|
|
342
|
+
or time.monotonic() - worker.last_activity >= _STOPPED_UNLOAD_GRACE_S)):
|
|
343
|
+
daemon._unload(worker, terminal_state=state)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _janitor_daemon_pass(daemon) -> bool:
|
|
347
|
+
"""Linger; True once the daemon should stop."""
|
|
348
|
+
with daemon.lock:
|
|
349
|
+
with daemon._native_start_hold_lock:
|
|
350
|
+
# In-flight originals and safely held positive replies keep
|
|
351
|
+
# the daemon alive until a definitive provider result or fsync.
|
|
352
|
+
# Unsafe diagnostic history does not pin the singleton.
|
|
353
|
+
native_pending = bool(daemon._native_start_held)
|
|
354
|
+
if daemon.workers or native_pending:
|
|
355
|
+
daemon.empty_since = None
|
|
356
|
+
expired = False
|
|
357
|
+
else:
|
|
358
|
+
if daemon.empty_since is None:
|
|
359
|
+
daemon.empty_since = time.monotonic()
|
|
360
|
+
expired = (
|
|
361
|
+
time.monotonic() - daemon.empty_since >= daemon_models.linger_seconds()
|
|
362
|
+
)
|
|
363
|
+
if expired:
|
|
364
|
+
daemon.stopping = True
|
|
365
|
+
if expired:
|
|
366
|
+
daemon.stop.set()
|
|
367
|
+
return expired
|