cdx-manager 0.12.4 → 0.14.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/README.md +132 -3
- package/changelogs/CHANGELOGS_0_14_0.md +146 -0
- package/package.json +3 -3
- package/pyproject.toml +1 -1
- package/src/cli.py +64 -4
- package/src/cli_args.py +274 -33
- package/src/cli_commands.py +251 -3049
- package/src/cli_helpers.py +177 -2
- package/src/commands/__init__.py +6 -0
- package/src/commands/auth.py +224 -0
- package/src/commands/backup.py +112 -0
- package/src/commands/context_memory.py +341 -0
- package/src/commands/launch.py +218 -0
- package/src/commands/lifecycle.py +154 -0
- package/src/commands/maintenance.py +723 -0
- package/src/commands/runs.py +562 -0
- package/src/commands/settings.py +107 -0
- package/src/commands/status.py +681 -0
- package/src/config.py +41 -0
- package/src/errors.py +17 -0
- package/src/health.py +151 -3
- package/src/notify.py +11 -5
- package/src/provider_runtime.py +46 -11
- package/src/run_command.py +112 -4
- package/src/run_registry.py +53 -2
- package/src/session_backup.py +343 -0
- package/src/session_helpers.py +179 -0
- package/src/session_ranking.py +259 -0
- package/src/session_service.py +574 -1426
- package/src/session_status.py +534 -0
- package/src/status_view.py +15 -34
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"""The single rule for ordering sessions by desirability.
|
|
2
|
+
|
|
3
|
+
cdx used to answer "which session should this work go to" twice, with two
|
|
4
|
+
algorithms that disagreed: one behind `cdx select` and `cdx run --provider`,
|
|
5
|
+
another behind `cdx next`, the `cdx status` recommendation, and `cdx ready`.
|
|
6
|
+
Each knew something the other did not — the first honored the user's
|
|
7
|
+
`--priority` and filtered by provider, the second understood credits and reset
|
|
8
|
+
scheduling — so the answer depended on which command you asked.
|
|
9
|
+
|
|
10
|
+
This module is the merge. Every selector calls `rank_sessions`; the callers
|
|
11
|
+
differ only in the options they pass and what they do with the result.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .config import REASONING_EFFORT_VALUES
|
|
15
|
+
|
|
16
|
+
# Below this, a session has nothing useful left in its window.
|
|
17
|
+
USABLE_AVAILABLE_THRESHOLD = 5
|
|
18
|
+
|
|
19
|
+
# Usability classes, best first.
|
|
20
|
+
USABLE_TIER = 3
|
|
21
|
+
BLOCKED_WITH_RESET_TIER = 2
|
|
22
|
+
RESET_KNOWN_TIER = 1
|
|
23
|
+
UNKNOWN_TIER = 0
|
|
24
|
+
|
|
25
|
+
# Compared in order, best first. `cdx select` publishes this as its
|
|
26
|
+
# `selection_policy`, built from these tuples, so the published policy cannot
|
|
27
|
+
# describe an order the code does not apply.
|
|
28
|
+
#
|
|
29
|
+
# The order after the first two factors depends on whether the session can be
|
|
30
|
+
# used right now, which the recommendation ranking already did and which is
|
|
31
|
+
# worth keeping: for a session you can use, what matters is cost and headroom;
|
|
32
|
+
# for one you cannot, what matters is when it comes back.
|
|
33
|
+
LEADING_FACTORS = ("usability", "priority")
|
|
34
|
+
USABLE_FACTORS = ("credits", "availability", "reset")
|
|
35
|
+
BLOCKED_FACTORS = ("reset", "credits", "availability")
|
|
36
|
+
TRAILING_FACTORS = ("reasoning_effort", "name")
|
|
37
|
+
|
|
38
|
+
RANKING_FACTORS = LEADING_FACTORS + USABLE_FACTORS + TRAILING_FACTORS
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def factor_order(usable):
|
|
42
|
+
"""The factors, in the order they are compared, for one usability class."""
|
|
43
|
+
return LEADING_FACTORS + (USABLE_FACTORS if usable else BLOCKED_FACTORS) + TRAILING_FACTORS
|
|
44
|
+
|
|
45
|
+
FACTOR_DESCRIPTIONS = {
|
|
46
|
+
"usability": "usable now, then blocked with a known reset, then reset known, then unknown",
|
|
47
|
+
"priority": "higher configured --priority first",
|
|
48
|
+
"credits": "sessions without credits first, to spend included quota before paid credits",
|
|
49
|
+
"availability": "higher remaining availability first",
|
|
50
|
+
"reset": "earlier known reset first",
|
|
51
|
+
"reasoning_effort": "lower configured reasoning effort first, to leave stronger sessions free",
|
|
52
|
+
"name": "session name, so the order is deterministic",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# Candidate filters, which exclude rather than order. Kept separate because
|
|
56
|
+
# describing them as ranking stages is what made the old published policy wrong.
|
|
57
|
+
FILTER_DESCRIPTIONS = {
|
|
58
|
+
"enabled": "disabled sessions are never candidates",
|
|
59
|
+
"authenticated": "logged-out sessions are never candidates; they cannot serve work",
|
|
60
|
+
"provider": "only sessions for the requested provider (when a provider is given)",
|
|
61
|
+
"min_reasoning_effort": "only sessions configured at or above the requested effort (when given)",
|
|
62
|
+
"require_ready": "only sessions confirmed authenticated with availability left (when readiness is required)",
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def reasoning_rank(value):
|
|
67
|
+
try:
|
|
68
|
+
return REASONING_EFFORT_VALUES.index(str(value or "low").lower())
|
|
69
|
+
except ValueError:
|
|
70
|
+
return -1
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _is_usable_now(row):
|
|
74
|
+
available = row.get("available_pct")
|
|
75
|
+
return available is not None and available > USABLE_AVAILABLE_THRESHOLD
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _usability_tier(row, now_ts, reset_timestamp_fn):
|
|
79
|
+
"""Higher is better.
|
|
80
|
+
|
|
81
|
+
Preserved from the recommendation ranking, which distinguished "blocked but
|
|
82
|
+
coming back at a known time" from "blocked with no information". The
|
|
83
|
+
headless ranking flattened both into "unavailable", losing the distinction
|
|
84
|
+
that makes `cdx next` useful.
|
|
85
|
+
"""
|
|
86
|
+
reset_ts = reset_timestamp_fn(row)
|
|
87
|
+
if _is_usable_now(row):
|
|
88
|
+
return USABLE_TIER
|
|
89
|
+
if reset_ts is not None and reset_ts >= now_ts:
|
|
90
|
+
return BLOCKED_WITH_RESET_TIER
|
|
91
|
+
if reset_ts is not None:
|
|
92
|
+
return RESET_KNOWN_TIER
|
|
93
|
+
return UNKNOWN_TIER
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _factor_values(row, now_ts, reset_timestamp_fn):
|
|
97
|
+
"""This row's value for each factor, already oriented so higher is better."""
|
|
98
|
+
reset_ts = reset_timestamp_fn(row)
|
|
99
|
+
available = row.get("available_pct")
|
|
100
|
+
try:
|
|
101
|
+
priority = int(row.get("priority") or 0)
|
|
102
|
+
except (TypeError, ValueError):
|
|
103
|
+
priority = 0
|
|
104
|
+
return {
|
|
105
|
+
"usability": _usability_tier(row, now_ts, reset_timestamp_fn),
|
|
106
|
+
# Ranked inside a usability tier, never across one: a session that
|
|
107
|
+
# cannot serve the request does not become the right choice by being
|
|
108
|
+
# preferred. Priority expresses which usable session to favour.
|
|
109
|
+
"priority": priority,
|
|
110
|
+
# Sessions *without* credits first: spend the included quota before
|
|
111
|
+
# burning paid credits. Inverting this is an easy mistake — the factor
|
|
112
|
+
# is named for what it looks at, not for what it prefers.
|
|
113
|
+
"credits": 0 if row.get("credits") is not None else 1,
|
|
114
|
+
"availability": float(available) if available is not None else -1.0,
|
|
115
|
+
"reset": -reset_ts if reset_ts is not None else float("-inf"),
|
|
116
|
+
# Negated: the weakest configured session that still clears the floor
|
|
117
|
+
# wins, so a stronger one stays free for work that needs it. This was
|
|
118
|
+
# previously implied by an un-negated term in a sort tuple; it is a
|
|
119
|
+
# deliberate cost preference and is stated here rather than inferred.
|
|
120
|
+
"reasoning_effort": -reasoning_rank(row.get("reasoning_effort")),
|
|
121
|
+
"name": row.get("session_name") or "",
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _sort_key(values):
|
|
126
|
+
# Sessions are bucketed by usability first, so every candidate compared
|
|
127
|
+
# beyond that point shares a usability class and therefore a factor order.
|
|
128
|
+
key = []
|
|
129
|
+
for factor in factor_order(values["usability"] == USABLE_TIER):
|
|
130
|
+
value = values[factor]
|
|
131
|
+
# Names sort ascending; every other factor is "higher is better", so
|
|
132
|
+
# they are negated to sort descending under one ascending sort.
|
|
133
|
+
key.append(value if factor == "name" else _negate(value))
|
|
134
|
+
return tuple(key)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _negate(value):
|
|
138
|
+
return -value if isinstance(value, (int, float)) else value
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def deciding_factor(winner_values, runner_up_values):
|
|
142
|
+
"""The first factor that separated the winner from the runner-up.
|
|
143
|
+
|
|
144
|
+
Returns None when there was nobody to compare against, so a caller can say
|
|
145
|
+
"only candidate" instead of naming a factor that decided nothing.
|
|
146
|
+
"""
|
|
147
|
+
if runner_up_values is None:
|
|
148
|
+
return None
|
|
149
|
+
for factor in factor_order(winner_values["usability"] == USABLE_TIER):
|
|
150
|
+
if winner_values[factor] != runner_up_values[factor]:
|
|
151
|
+
return factor
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def candidate_rows(rows, provider=None, require_ready=False, min_reasoning_effort=None):
|
|
156
|
+
"""Rows eligible for selection, applying the filters described above.
|
|
157
|
+
|
|
158
|
+
Logged-out sessions are excluded unconditionally. The headless path used to
|
|
159
|
+
admit them unless `--require-ready` was passed, so `cdx select` could return
|
|
160
|
+
a session that could not possibly run anything.
|
|
161
|
+
"""
|
|
162
|
+
minimum = reasoning_rank(min_reasoning_effort) if min_reasoning_effort else None
|
|
163
|
+
candidates = []
|
|
164
|
+
for row in rows:
|
|
165
|
+
if row.get("enabled", True) is False:
|
|
166
|
+
continue
|
|
167
|
+
if provider is not None and row.get("provider") != provider:
|
|
168
|
+
continue
|
|
169
|
+
if _row_is_logged_out(row):
|
|
170
|
+
continue
|
|
171
|
+
if minimum is not None and reasoning_rank(row.get("reasoning_effort")) < minimum:
|
|
172
|
+
continue
|
|
173
|
+
if require_ready and not _row_is_ready(row):
|
|
174
|
+
continue
|
|
175
|
+
candidates.append(row)
|
|
176
|
+
return candidates
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _row_is_logged_out(row):
|
|
180
|
+
if row.get("provider") in ("antigravity", "ollama"):
|
|
181
|
+
return False
|
|
182
|
+
return str(row.get("auth_status") or "").strip().lower() == "logged_out"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _row_is_ready(row):
|
|
186
|
+
"""Ready to receive a run right now.
|
|
187
|
+
|
|
188
|
+
Stricter than "not logged out": an unknown auth state is not readiness.
|
|
189
|
+
`cdx run --provider` asks for readiness precisely so it does not hand work
|
|
190
|
+
to a session that will fail at launch, and the headless ranking required a
|
|
191
|
+
confirmed `authenticated` here before the two rankings were merged.
|
|
192
|
+
Sessions that are only recommended, rather than selected to run, do not
|
|
193
|
+
pass `require_ready` and so are still surfaced with an unknown state.
|
|
194
|
+
"""
|
|
195
|
+
if row.get("provider") not in ("antigravity", "ollama"):
|
|
196
|
+
if str(row.get("auth_status") or "").strip().lower() != "authenticated":
|
|
197
|
+
return False
|
|
198
|
+
return _row_has_availability(row)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _row_has_availability(row):
|
|
202
|
+
available = row.get("available_pct")
|
|
203
|
+
if available is None:
|
|
204
|
+
# Unknown is not the same as empty. Excluding it here would make a
|
|
205
|
+
# never-probed session permanently unselectable; the caller warns
|
|
206
|
+
# instead, so the decision is visible rather than silent.
|
|
207
|
+
return True
|
|
208
|
+
try:
|
|
209
|
+
return float(available) > 0
|
|
210
|
+
except (TypeError, ValueError):
|
|
211
|
+
return False
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def rank_sessions(rows, now_ts, reset_timestamp_fn, provider=None, require_ready=False,
|
|
215
|
+
min_reasoning_effort=None):
|
|
216
|
+
"""Candidate rows ordered best-first, with why the winner won.
|
|
217
|
+
|
|
218
|
+
Returns (ordered_rows, decision) where decision names the deciding factor,
|
|
219
|
+
or None when there was only one candidate.
|
|
220
|
+
"""
|
|
221
|
+
candidates = candidate_rows(
|
|
222
|
+
rows,
|
|
223
|
+
provider=provider,
|
|
224
|
+
require_ready=require_ready,
|
|
225
|
+
min_reasoning_effort=min_reasoning_effort,
|
|
226
|
+
)
|
|
227
|
+
scored = [
|
|
228
|
+
(row, _factor_values(row, now_ts, reset_timestamp_fn))
|
|
229
|
+
for row in candidates
|
|
230
|
+
]
|
|
231
|
+
scored.sort(key=lambda item: _sort_key(item[1]))
|
|
232
|
+
ordered = [row for row, _values in scored]
|
|
233
|
+
if not scored:
|
|
234
|
+
return ordered, None
|
|
235
|
+
runner_up = scored[1][1] if len(scored) > 1 else None
|
|
236
|
+
return ordered, deciding_factor(scored[0][1], runner_up)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def selection_policy():
|
|
240
|
+
"""The published description of the ranking, built from the ranking itself.
|
|
241
|
+
|
|
242
|
+
Derived rather than written, so a change to `RANKING_FACTORS` changes what
|
|
243
|
+
`cdx select` reports without anyone remembering to edit a string. The
|
|
244
|
+
previous hand-written policy omitted the reasoning-effort tie-break and
|
|
245
|
+
described a candidate filter as a sort stage.
|
|
246
|
+
"""
|
|
247
|
+
return {
|
|
248
|
+
"factors": [
|
|
249
|
+
{"name": factor, "description": FACTOR_DESCRIPTIONS[factor]}
|
|
250
|
+
for factor in RANKING_FACTORS
|
|
251
|
+
],
|
|
252
|
+
"filters": [
|
|
253
|
+
{"name": name, "description": description}
|
|
254
|
+
for name, description in FILTER_DESCRIPTIONS.items()
|
|
255
|
+
],
|
|
256
|
+
"usable_order": list(factor_order(True)),
|
|
257
|
+
"blocked_order": list(factor_order(False)),
|
|
258
|
+
"summary": "_then_".join(factor_order(True)),
|
|
259
|
+
}
|