cdx-manager 0.20.9 → 0.20.11
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 +51 -4
- package/VERSION +1 -1
- package/changelogs/CHANGELOGS_0_20_10.md +71 -0
- package/changelogs/CHANGELOGS_0_20_11.md +37 -0
- package/checksums/release-archives.json +22 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/backup_bundle.py +78 -22
- package/src/claude_usage.py +27 -1
- package/src/cli.py +1 -1
- package/src/cli_args.py +1 -1
- package/src/cli_commands.py +0 -4
- package/src/cli_helpers.py +1 -212
- package/src/commands/backup.py +5 -0
- package/src/commands/launch.py +84 -54
- package/src/commands/runs.py +22 -5
- package/src/context_store.py +22 -3
- package/src/handoff.py +267 -0
- package/src/provider_runtime.py +26 -3
- package/src/session_backup.py +47 -2
- package/src/tray_install.py +86 -14
package/src/cli_helpers.py
CHANGED
|
@@ -12,15 +12,11 @@ import re
|
|
|
12
12
|
from datetime import datetime
|
|
13
13
|
|
|
14
14
|
from .cli_render import _dim, _info, _pad_table, _style, _warn
|
|
15
|
-
from .config import PROVIDER_CODEX
|
|
16
15
|
from .errors import CdxError
|
|
17
16
|
from .fs_utils import atomic_write
|
|
18
|
-
from .provider_runtime import
|
|
19
|
-
from .status_source import _normalize_terminal_transcript
|
|
17
|
+
from .provider_runtime import _run_interactive_provider_command, get_resume_capability
|
|
20
18
|
|
|
21
19
|
API_SCHEMA_VERSION = 1
|
|
22
|
-
HANDOFF_TRANSCRIPT_CHARS = 120000
|
|
23
|
-
HANDOFF_NATIVE_TRANSCRIPT_CANDIDATES = 64
|
|
24
20
|
|
|
25
21
|
|
|
26
22
|
def _local_now_iso():
|
|
@@ -201,213 +197,6 @@ def _make_notify_progress(ctx):
|
|
|
201
197
|
)
|
|
202
198
|
|
|
203
199
|
|
|
204
|
-
def _latest_launch_transcript_path(session):
|
|
205
|
-
paths = _list_launch_transcript_paths(session)
|
|
206
|
-
if not paths:
|
|
207
|
-
return None
|
|
208
|
-
return max(paths, key=lambda path: (os.path.getmtime(path), path))
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
def _get_session_home(session):
|
|
212
|
-
return session.get("authHome") or session.get("sessionRoot") or session.get("codexHome", "")
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
def _safe_stat(path):
|
|
216
|
-
try:
|
|
217
|
-
return os.stat(path)
|
|
218
|
-
except OSError:
|
|
219
|
-
return None
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
def _sort_recent_paths(paths):
|
|
223
|
-
stats = {path: stat for path, stat in ((_path, _safe_stat(_path)) for _path in set(paths)) if stat}
|
|
224
|
-
return sorted(stats, key=lambda path: (stats[path].st_mtime, path), reverse=True)
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
def _collect_native_handoff_transcript_paths(session):
|
|
228
|
-
root = _get_session_home(session)
|
|
229
|
-
if not root:
|
|
230
|
-
return []
|
|
231
|
-
candidates = []
|
|
232
|
-
direct = [
|
|
233
|
-
os.path.join(root, "history.jsonl"),
|
|
234
|
-
os.path.join(root, "session_index.jsonl"),
|
|
235
|
-
]
|
|
236
|
-
candidates.extend(path for path in direct if _safe_stat(path))
|
|
237
|
-
|
|
238
|
-
scan_roots = [
|
|
239
|
-
os.path.join(root, "sessions"),
|
|
240
|
-
os.path.join(root, ".claude", "projects"),
|
|
241
|
-
os.path.join(root, "projects"),
|
|
242
|
-
]
|
|
243
|
-
skip_dirs = {"cache", "plugins", "skills", "memories", "sqlite", "shell_snapshots", "tmp", "__pycache__"}
|
|
244
|
-
for scan_root in scan_roots:
|
|
245
|
-
if not os.path.isdir(scan_root):
|
|
246
|
-
continue
|
|
247
|
-
for dirpath, dirnames, filenames in os.walk(scan_root):
|
|
248
|
-
dirnames[:] = [name for name in dirnames if not name.startswith(".") and name not in skip_dirs]
|
|
249
|
-
for filename in filenames:
|
|
250
|
-
if filename.endswith(".jsonl") or filename.endswith(".log"):
|
|
251
|
-
candidates.append(os.path.join(dirpath, filename))
|
|
252
|
-
return _sort_recent_paths(candidates)[:HANDOFF_NATIVE_TRANSCRIPT_CANDIDATES]
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
def _handoff_transcript_workspace(path):
|
|
256
|
-
if not path.endswith(".jsonl"):
|
|
257
|
-
return None
|
|
258
|
-
try:
|
|
259
|
-
with open(path, encoding="utf-8", errors="replace") as handle:
|
|
260
|
-
record = json.loads(handle.readline() or "{}")
|
|
261
|
-
except (OSError, json.JSONDecodeError):
|
|
262
|
-
return None
|
|
263
|
-
if record.get("type") != "session_meta":
|
|
264
|
-
return None
|
|
265
|
-
payload = record.get("payload") if isinstance(record.get("payload"), dict) else {}
|
|
266
|
-
cwd = payload.get("cwd")
|
|
267
|
-
return os.path.realpath(cwd) if isinstance(cwd, str) and cwd else None
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
def _latest_handoff_transcript_path(session, cwd=None):
|
|
271
|
-
native_paths = _collect_native_handoff_transcript_paths(session)
|
|
272
|
-
if cwd:
|
|
273
|
-
workspace = os.path.realpath(cwd)
|
|
274
|
-
for path in native_paths:
|
|
275
|
-
if _handoff_transcript_workspace(path) == workspace:
|
|
276
|
-
return path
|
|
277
|
-
if native_paths:
|
|
278
|
-
return native_paths[0]
|
|
279
|
-
return _latest_launch_transcript_path(session)
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
def _collect_handoff_text_fragments(value):
|
|
283
|
-
fragments = []
|
|
284
|
-
if isinstance(value, str):
|
|
285
|
-
text = value.strip()
|
|
286
|
-
if text:
|
|
287
|
-
fragments.append(text)
|
|
288
|
-
elif isinstance(value, list):
|
|
289
|
-
for item in value:
|
|
290
|
-
fragments.extend(_collect_handoff_text_fragments(item))
|
|
291
|
-
elif isinstance(value, dict):
|
|
292
|
-
if isinstance(value.get("text"), str):
|
|
293
|
-
fragments.extend(_collect_handoff_text_fragments(value.get("text")))
|
|
294
|
-
if isinstance(value.get("content"), (str, list, dict)):
|
|
295
|
-
fragments.extend(_collect_handoff_text_fragments(value.get("content")))
|
|
296
|
-
if isinstance(value.get("message"), dict):
|
|
297
|
-
fragments.extend(_collect_handoff_text_fragments(value.get("message")))
|
|
298
|
-
if isinstance(value.get("payload"), dict):
|
|
299
|
-
fragments.extend(_collect_handoff_text_fragments(value.get("payload")))
|
|
300
|
-
return fragments
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
def _jsonl_record_to_handoff_text(record):
|
|
304
|
-
if not isinstance(record, dict):
|
|
305
|
-
return None
|
|
306
|
-
if record.get("type") == "response_item":
|
|
307
|
-
record = record.get("payload")
|
|
308
|
-
if not isinstance(record, dict):
|
|
309
|
-
return None
|
|
310
|
-
message = record.get("message") if isinstance(record.get("message"), dict) else {}
|
|
311
|
-
role = (
|
|
312
|
-
message.get("role")
|
|
313
|
-
or record.get("role")
|
|
314
|
-
or record.get("type")
|
|
315
|
-
or record.get("sender")
|
|
316
|
-
or "entry"
|
|
317
|
-
)
|
|
318
|
-
if role not in ("user", "assistant"):
|
|
319
|
-
return None
|
|
320
|
-
text_sources = []
|
|
321
|
-
for key in ("content", "text", "payload"):
|
|
322
|
-
if key in record:
|
|
323
|
-
text_sources.append(record.get(key))
|
|
324
|
-
for key in ("content", "text"):
|
|
325
|
-
if key in message:
|
|
326
|
-
text_sources.append(message.get(key))
|
|
327
|
-
fragments = []
|
|
328
|
-
for value in text_sources:
|
|
329
|
-
fragments.extend(_collect_handoff_text_fragments(value))
|
|
330
|
-
text = "\n".join(fragment for fragment in fragments if fragment).strip()
|
|
331
|
-
if not text:
|
|
332
|
-
return None
|
|
333
|
-
role = re.sub(r"[^A-Za-z0-9_-]+", "-", str(role).strip()).strip("-") or "entry"
|
|
334
|
-
return f"[{role}]\n{text}"
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
def _read_jsonl_handoff_transcript(path):
|
|
338
|
-
entries = []
|
|
339
|
-
with open(path, encoding="utf-8", errors="replace") as handle:
|
|
340
|
-
for line in handle:
|
|
341
|
-
line = line.strip()
|
|
342
|
-
if not line:
|
|
343
|
-
continue
|
|
344
|
-
try:
|
|
345
|
-
text = _jsonl_record_to_handoff_text(json.loads(line))
|
|
346
|
-
except json.JSONDecodeError:
|
|
347
|
-
text = None
|
|
348
|
-
if text:
|
|
349
|
-
entries.append(text)
|
|
350
|
-
if entries:
|
|
351
|
-
return "\n\n".join(entries)
|
|
352
|
-
with open(path, encoding="utf-8", errors="replace") as handle:
|
|
353
|
-
return handle.read()
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
def _read_handoff_transcript(path):
|
|
357
|
-
if path.endswith(".jsonl"):
|
|
358
|
-
content = _read_jsonl_handoff_transcript(path)
|
|
359
|
-
else:
|
|
360
|
-
with open(path, encoding="utf-8", errors="replace") as handle:
|
|
361
|
-
# `script`-captured PTY transcript: strip ANSI/control noise before
|
|
362
|
-
# the char-budget truncation so the tail holds real text, not escapes.
|
|
363
|
-
content = _normalize_terminal_transcript(handle.read())
|
|
364
|
-
if len(content) <= HANDOFF_TRANSCRIPT_CHARS:
|
|
365
|
-
return content, False
|
|
366
|
-
return content[-HANDOFF_TRANSCRIPT_CHARS:], True
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
def _build_handoff_context(source, target, transcript_path, transcript, truncated=False):
|
|
370
|
-
truncation_note = (
|
|
371
|
-
"Only the tail of the previous transcript is included because the log was large."
|
|
372
|
-
if truncated else
|
|
373
|
-
"The previous transcript is included below."
|
|
374
|
-
)
|
|
375
|
-
return "\n".join([
|
|
376
|
-
"# Shared Context",
|
|
377
|
-
"",
|
|
378
|
-
"## Goal",
|
|
379
|
-
f"Resume the work from `{source['name']}` in `{target['name']}` because the source account has no usage left.",
|
|
380
|
-
"",
|
|
381
|
-
"## Current State",
|
|
382
|
-
f"- Source session: `{source['name']}` ({source['provider']})",
|
|
383
|
-
f"- Target session: `{target['name']}` ({target['provider']})",
|
|
384
|
-
f"- Transcript source: `{transcript_path}`",
|
|
385
|
-
f"- {truncation_note}",
|
|
386
|
-
"",
|
|
387
|
-
"## Previous Session Transcript",
|
|
388
|
-
transcript.rstrip(),
|
|
389
|
-
"",
|
|
390
|
-
"## Next Steps",
|
|
391
|
-
"- Read the transcript above.",
|
|
392
|
-
"- Continue the task from the latest actionable state.",
|
|
393
|
-
"- Preserve the target account authentication; do not copy source credentials.",
|
|
394
|
-
])
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
def _handoff_launch_prompt(session, install=None):
|
|
398
|
-
if session.get("provider") == PROVIDER_CODEX:
|
|
399
|
-
context_ref = "$CODEX_HOME/shared-context.md"
|
|
400
|
-
else:
|
|
401
|
-
context_ref = (install or {}).get("target_path") or os.path.join(
|
|
402
|
-
session.get("authHome") or session.get("sessionRoot") or "~",
|
|
403
|
-
"shared-context.md",
|
|
404
|
-
)
|
|
405
|
-
return (
|
|
406
|
-
f"Read {context_ref} first, then resume the previous session "
|
|
407
|
-
"from the latest actionable state. Do not ask me to paste the context again."
|
|
408
|
-
)
|
|
409
|
-
|
|
410
|
-
|
|
411
200
|
def _resolve_confirmation(confirm_fn, name):
|
|
412
201
|
confirmed = (
|
|
413
202
|
asyncio.get_event_loop().run_until_complete(confirm_fn(name))
|
package/src/commands/backup.py
CHANGED
|
@@ -109,4 +109,9 @@ def handle_import(rest, ctx):
|
|
|
109
109
|
_write_json(ctx, payload)
|
|
110
110
|
return 0
|
|
111
111
|
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
112
|
+
retained = result.get("retained_local_credentials") or []
|
|
113
|
+
if retained:
|
|
114
|
+
# Merge keeps local values, so say which sessions kept their own login
|
|
115
|
+
# rather than leaving the operator to guess which credential is live.
|
|
116
|
+
ctx["out"](f"Kept the existing local Claude authentication for: {', '.join(retained)}\n")
|
|
112
117
|
return 0
|
package/src/commands/launch.py
CHANGED
|
@@ -10,11 +10,8 @@ from ..agent_notify import notifications_enabled, provision, supports_agent_aler
|
|
|
10
10
|
from ..cli_args import CAN_RESUME_USAGE, HANDOFF_USAGE, RESUME_USAGE, _parse_json_flag
|
|
11
11
|
from ..cli_helpers import (
|
|
12
12
|
API_SCHEMA_VERSION,
|
|
13
|
-
|
|
14
|
-
_handoff_launch_prompt,
|
|
13
|
+
_json_failure,
|
|
15
14
|
_json_success,
|
|
16
|
-
_latest_handoff_transcript_path,
|
|
17
|
-
_read_handoff_transcript,
|
|
18
15
|
_resume_capability_for_session,
|
|
19
16
|
_update_notice_warnings,
|
|
20
17
|
_warn_if_session_already_running,
|
|
@@ -23,8 +20,8 @@ from ..cli_helpers import (
|
|
|
23
20
|
)
|
|
24
21
|
from ..cli_render import _dim, _info, _success, _warn
|
|
25
22
|
from ..config import PROVIDER_CODEX
|
|
26
|
-
from ..context_store import install_context_for_session, write_context
|
|
27
23
|
from ..errors import CdxError
|
|
24
|
+
from ..handoff import HandoffSourceError, launch_prompt, load_entry, prepare_entry, resolve_source
|
|
28
25
|
from ..interactive_usage import extract_interactive_usage, transcript_predates_run, usage_delta
|
|
29
26
|
from ..provider_runtime import (
|
|
30
27
|
INTERRUPT_EXIT_CODES,
|
|
@@ -397,59 +394,92 @@ def _previous_cumulative(history, transcript_path):
|
|
|
397
394
|
return cumulative
|
|
398
395
|
return None
|
|
399
396
|
|
|
400
|
-
def
|
|
397
|
+
def _parse_handoff_args(rest):
|
|
401
398
|
json_flag, args = _parse_json_flag(rest)
|
|
402
|
-
|
|
399
|
+
names, flags = [], {}
|
|
400
|
+
index = 0
|
|
401
|
+
while index < len(args):
|
|
402
|
+
arg = args[index]
|
|
403
|
+
if arg.startswith("--"):
|
|
404
|
+
key, sep, value = arg.partition("=")
|
|
405
|
+
if key not in ("--source-conversation", "--source-transcript") or key in flags:
|
|
406
|
+
raise CdxError(HANDOFF_USAGE)
|
|
407
|
+
if not sep:
|
|
408
|
+
index += 1
|
|
409
|
+
value = args[index] if index < len(args) else ""
|
|
410
|
+
if not value or value.startswith("--"):
|
|
411
|
+
raise CdxError(HANDOFF_USAGE)
|
|
412
|
+
flags[key] = value
|
|
413
|
+
else:
|
|
414
|
+
names.append(arg)
|
|
415
|
+
index += 1
|
|
416
|
+
if len(names) not in (1, 2) or (flags and len(names) != 2) or len(flags) > 1:
|
|
403
417
|
raise CdxError(HANDOFF_USAGE)
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
418
|
+
return json_flag, names, flags
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _handoff_source(source, workspace, flags, ctx, json_flag):
|
|
422
|
+
try:
|
|
423
|
+
return resolve_source(source, workspace, flags.get("--source-conversation"), flags.get("--source-transcript"))
|
|
424
|
+
except HandoffSourceError as error:
|
|
425
|
+
candidates = error.candidates
|
|
426
|
+
# JSON and non-interactive callers always make an explicit second call.
|
|
427
|
+
if json_flag or not ctx["stdin_is_tty"] or not candidates or flags:
|
|
428
|
+
raise
|
|
429
|
+
ctx["out"](str(error) + "\n" + "".join(
|
|
430
|
+
f" {i + 1}. {c['conversation_id']} | {c['workspace']} | last event {c['last_event_at'] or 'unknown'}\n"
|
|
431
|
+
for i, c in enumerate(candidates)
|
|
432
|
+
))
|
|
433
|
+
ask = ctx["options"].get("input") or input
|
|
434
|
+
try:
|
|
435
|
+
answer = ask("Source conversation number (blank to cancel): ").strip()
|
|
436
|
+
except (KeyboardInterrupt, EOFError):
|
|
437
|
+
raise CdxError("Handoff cancelled.", exit_code=130) from None
|
|
438
|
+
if not answer:
|
|
439
|
+
raise CdxError("Handoff cancelled.", exit_code=130) from None
|
|
440
|
+
if not answer.isdigit() or not 1 <= int(answer) <= len(candidates):
|
|
441
|
+
raise CdxError("Handoff cancelled: choose a listed source.") from None
|
|
442
|
+
return resolve_source(source, workspace, candidates[int(answer) - 1]["conversation_id"])
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def handle_handoff(rest, ctx):
|
|
446
|
+
json_flag, names, flags = _parse_handoff_args(rest)
|
|
447
|
+
target = ctx["service"]["get_session"](names[-1])
|
|
431
448
|
if not target:
|
|
432
|
-
raise CdxError(f"Unknown session: {
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
449
|
+
raise CdxError(f"Unknown session: {names[-1]}")
|
|
450
|
+
if not target.get("enabled", True):
|
|
451
|
+
raise CdxError(f"Session is disabled: {target['name']}")
|
|
452
|
+
workspace = os.path.realpath(ctx.get("cwd") or os.getcwd())
|
|
453
|
+
if not os.path.isdir(workspace):
|
|
454
|
+
raise CdxError(f"Invalid directory: {workspace}")
|
|
455
|
+
base_dir = ctx["service"]["base_dir"]
|
|
456
|
+
source = None
|
|
457
|
+
if len(names) == 2:
|
|
458
|
+
if names[0] == names[1]:
|
|
459
|
+
raise CdxError("Source and target sessions must be different")
|
|
460
|
+
source = ctx["service"]["get_session"](names[0])
|
|
461
|
+
if not source:
|
|
462
|
+
raise CdxError(f"Unknown session: {names[0]}")
|
|
463
|
+
try:
|
|
464
|
+
transcript = _handoff_source(source, workspace, flags, ctx, json_flag)
|
|
465
|
+
except HandoffSourceError as error:
|
|
466
|
+
if not json_flag:
|
|
467
|
+
choices = "\n".join(f" {c['conversation_id']} | {c['workspace']} | {c['last_event_at'] or 'unknown'}" for c in error.candidates)
|
|
468
|
+
raise CdxError(str(error) + ("\nCandidates:\n" + choices if choices else "")) from error
|
|
469
|
+
_write_json(ctx, _json_failure("handoff", "handoff_source_unresolved", str(error), candidates=error.candidates))
|
|
470
|
+
return error.exit_code
|
|
471
|
+
path, entry = prepare_entry(base_dir, target, workspace, source, transcript)
|
|
472
|
+
else:
|
|
473
|
+
prepared = load_entry(base_dir, target, workspace)
|
|
474
|
+
path, entry = prepared if prepared else prepare_entry(base_dir, target, workspace)
|
|
475
|
+
prompt = launch_prompt(path, entry)
|
|
441
476
|
if json_flag:
|
|
442
477
|
_write_json(ctx, _json_success(
|
|
443
|
-
"handoff",
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
target_session=target,
|
|
448
|
-
source_transcript=transcript_path,
|
|
449
|
-
shared_context=write_result,
|
|
450
|
-
launch_prompt=launch_prompt,
|
|
478
|
+
"handoff", f"Prepared {entry['mode']} handoff for {target['name']}",
|
|
479
|
+
context={"target_path": path}, handoff=entry,
|
|
480
|
+
source_session=entry['source'], target_session={"name": target['name'], "provider": target['provider']},
|
|
481
|
+
source_transcript=(entry['transcript'] or {}).get('path'), launch_prompt=prompt,
|
|
451
482
|
))
|
|
452
483
|
return 0
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
return handle_launch(target_name, ctx, initial_prompt=launch_prompt)
|
|
484
|
+
ctx["out"](f"Handoff ready ({entry['mode']}): {path}\n")
|
|
485
|
+
return handle_launch(target['name'], ctx, initial_prompt=prompt, directory=workspace)
|
package/src/commands/runs.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
Split out of cli_commands.py. Moved verbatim; re-exported by cli_commands.
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
+
import importlib.util
|
|
6
7
|
import os
|
|
7
8
|
import subprocess
|
|
8
9
|
import sys
|
|
@@ -147,9 +148,22 @@ def _cdx_self_command():
|
|
|
147
148
|
script has no reliable executable bit on Windows (`os.access(X_OK)` is true
|
|
148
149
|
for any existing file there) and a shebang script handed to CreateProcess
|
|
149
150
|
does not start at all.
|
|
151
|
+
|
|
152
|
+
Named from the top-level package, not this module's own package: `cli` lives
|
|
153
|
+
beside `commands`, so `{__package__}.cli` resolves to a module that does not
|
|
154
|
+
exist. The import root is the parent of every package component, which for
|
|
155
|
+
src/commands/runs.py is the repository root rather than src/.
|
|
150
156
|
"""
|
|
151
|
-
|
|
152
|
-
|
|
157
|
+
package = (__package__ or "src.commands").split(".")[0]
|
|
158
|
+
module = f"{package}.cli"
|
|
159
|
+
package_root = os.path.abspath(__file__)
|
|
160
|
+
for _ in range((__package__ or "src.commands").count(".") + 2):
|
|
161
|
+
package_root = os.path.dirname(package_root)
|
|
162
|
+
if importlib.util.find_spec(module) is None:
|
|
163
|
+
# A wrong module name reports a launched run that never runs, so fail
|
|
164
|
+
# here where the caller still gets the error.
|
|
165
|
+
raise CdxError(f"Cannot launch a detached run: the cdx CLI module {module} was not found.")
|
|
166
|
+
return [sys.executable, "-m", module], package_root
|
|
153
167
|
|
|
154
168
|
def _detached_spawn_options():
|
|
155
169
|
"""Platform flags that keep the child alive after this process exits.
|
|
@@ -751,6 +765,7 @@ def handle_run(rest, ctx):
|
|
|
751
765
|
return error.exit_code or 1
|
|
752
766
|
except CdxError as error:
|
|
753
767
|
run_info = getattr(error, "run_info", None)
|
|
768
|
+
cancelled = bool(getattr(error, "cancelled", False))
|
|
754
769
|
final_payload = run_result_payload(
|
|
755
770
|
API_SCHEMA_VERSION,
|
|
756
771
|
False,
|
|
@@ -758,14 +773,16 @@ def handle_run(rest, ctx):
|
|
|
758
773
|
locals().get("session"),
|
|
759
774
|
run_info=run_info,
|
|
760
775
|
error=error,
|
|
761
|
-
error_source="cdx",
|
|
762
|
-
error_code=run_cdx_error_code(error),
|
|
776
|
+
error_source="provider" if cancelled else "cdx",
|
|
777
|
+
error_code="run_cancelled" if cancelled else run_cdx_error_code(error),
|
|
763
778
|
selection_row=selection_row,
|
|
764
779
|
)
|
|
765
780
|
if registry and run_id:
|
|
766
781
|
registry.finish(
|
|
767
782
|
run_id,
|
|
768
|
-
|
|
783
|
+
# A cancelled run is neither a task failure nor a timeout, and a
|
|
784
|
+
# caller has to be able to tell the three apart.
|
|
785
|
+
status="cancelled" if cancelled else "failed",
|
|
769
786
|
final_payload=final_payload,
|
|
770
787
|
run_info=run_info,
|
|
771
788
|
error=final_payload.get("error"),
|
package/src/context_store.py
CHANGED
|
@@ -2,6 +2,7 @@ import hashlib
|
|
|
2
2
|
import os
|
|
3
3
|
import shlex
|
|
4
4
|
import subprocess
|
|
5
|
+
import threading
|
|
5
6
|
from datetime import datetime
|
|
6
7
|
from urllib.parse import quote, unquote
|
|
7
8
|
|
|
@@ -105,13 +106,31 @@ def append_context(base_dir, note, cwd=None):
|
|
|
105
106
|
return append_context_path(get_context_path(base_dir, cwd), note)
|
|
106
107
|
|
|
107
108
|
|
|
109
|
+
# An append is a read-modify-write, and the write is atomic per attempt, so two
|
|
110
|
+
# concurrent appends that both read the pre-existing text produce one file that
|
|
111
|
+
# is missing a note the caller was told had been accepted. Parallel agents append
|
|
112
|
+
# to the same scope by design, so the sequence is serialized here.
|
|
113
|
+
_APPEND_LOCKS = {}
|
|
114
|
+
_APPEND_LOCKS_GUARD = threading.Lock()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _append_lock(path):
|
|
118
|
+
key = os.path.abspath(path)
|
|
119
|
+
with _APPEND_LOCKS_GUARD:
|
|
120
|
+
lock = _APPEND_LOCKS.get(key)
|
|
121
|
+
if lock is None:
|
|
122
|
+
lock = _APPEND_LOCKS[key] = threading.Lock()
|
|
123
|
+
return lock
|
|
124
|
+
|
|
125
|
+
|
|
108
126
|
def append_context_path(path, note):
|
|
109
127
|
note = str(note or "").strip()
|
|
110
128
|
if not note:
|
|
111
129
|
raise CdxError("Memory append requires text.")
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
130
|
+
with _append_lock(path):
|
|
131
|
+
current = read_context_path(path).rstrip()
|
|
132
|
+
content = f"{current}\n{note}" if current else note
|
|
133
|
+
return write_context_path(path, content)
|
|
115
134
|
|
|
116
135
|
|
|
117
136
|
def init_context(base_dir, cwd=None):
|