cdx-manager 0.9.14 → 0.9.15

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # CDX Manager
2
2
 
3
- [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.9.14-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
3
+ [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.9.15-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
4
4
 
5
5
  **Run multiple Codex, Claude, Antigravity, and Ollama sessions from one terminal. Switch between accounts instantly.**
6
6
 
@@ -7,12 +7,6 @@ const { spawnSync } = require("node:child_process");
7
7
 
8
8
  const PYTHON_VERSION_CHECK = "import sys; sys.exit(0 if sys.version_info[0] == 3 else 1)";
9
9
  const PYTHON_CACHE_PREFIX = path.join(os.tmpdir(), "cdx-manager-pycache");
10
- const SIGNAL_EXIT_CODES = {
11
- SIGHUP: 129,
12
- SIGINT: 130,
13
- SIGTERM: 143,
14
- };
15
-
16
10
  const WINDOWS_CANDIDATES = [
17
11
  { command: "py", args: ["-3"], label: "py -3" },
18
12
  { command: "python", args: [], label: "python" },
@@ -148,7 +142,10 @@ function runPython(args, options = {}) {
148
142
  }
149
143
 
150
144
  if (result.signal) {
151
- return SIGNAL_EXIT_CODES[result.signal] || 128;
145
+ // Conventional 128+signum so supervisors can tell SIGKILL (137) from
146
+ // SIGSEGV (139) instead of a flat 128.
147
+ const signum = os.constants.signals[result.signal];
148
+ return signum ? 128 + signum : 128;
152
149
  }
153
150
 
154
151
  return typeof result.status === "number" ? result.status : 1;
@@ -0,0 +1,39 @@
1
+ # CDX Manager 0.9.15
2
+
3
+ ## Highlights
4
+
5
+ - Hardened concurrent session, run, and file updates against lost or partial writes.
6
+ - Improved status accuracy when multiple Codex and Claude data sources disagree.
7
+ - Tightened credential handling, headless process cleanup, CLI parsing, and release gating.
8
+
9
+ ## Changes
10
+
11
+ ### Reliable state and status
12
+
13
+ Session and run read-modify-write operations now stay under a single lock, and context, settings, and bundle exports use atomic replacement. Merge imports preserve live local runtime state, malformed JSONL usage records no longer discard an entire run, and refresh tolerates late or failed per-session writes.
14
+
15
+ Status selection now ranks structured, attributed, and fresh records consistently, preserves compatible fields across equally trusted candidates, handles year-wrapped reset timestamps, and treats a zero credit balance as no remaining credit.
16
+
17
+ ### Safer auth and process handling
18
+
19
+ Credential files and directories are private from creation, and temporary Claude setup-token transcripts are removed on both success and failure.
20
+
21
+ Headless timeouts terminate the complete process group, while signal exits consistently return `128 + signum`.
22
+
23
+ ### CLI, updates, and release automation
24
+
25
+ Argument parsing now rejects conflicting target selectors and flags used as values, while subcommand help remains scoped to the subcommand. Headless selection no longer imposes an implicit reasoning-effort floor.
26
+
27
+ Failed Logics Manager version checks are no longer cached, missing updater return codes fail safely, non-epoch Claude reset headers no longer abort refresh, and publication now gates on the latest CI run for the release commit.
28
+
29
+ ## Validation
30
+
31
+ - `npm run release:validate`
32
+ - `npm run lint`
33
+ - `npm test`
34
+ - `logics-manager lint --require-status`
35
+ - `logics-manager audit`
36
+ - `git diff --check`
37
+ - `npm --cache /private/tmp/cdx-npm-cache pack --dry-run`
38
+ - `python -m build`
39
+ - `python -m twine check dist/*`
@@ -120,6 +120,10 @@
120
120
  "v0.9.13": {
121
121
  "github_tarball_sha256": "4541dcecbfc12705f8e50e61ab1cb1f53dd6a209cc4a6d33ec3a4e533a3da3eb",
122
122
  "github_zip_sha256": "a8df11be1910a0562e0f97f69e9f35ee2fdf90ef50cb40689c85bf5ee5937027"
123
+ },
124
+ "v0.9.14": {
125
+ "github_tarball_sha256": "5469be5448eee56b5f0834d0c42d9ed3ca088158f08052bd8addc552635fe33d",
126
+ "github_zip_sha256": "7eb5650cb348e3ef2a082cd7fb39d7f4c170d4a095fe88719a7ebbfb7601db48"
123
127
  }
124
128
  }
125
129
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.9.14",
3
+ "version": "0.9.15",
4
4
  "description": "Terminal session manager for Codex and Claude accounts.",
5
5
  "license": "MIT",
6
6
  "author": "Alexandre Agostini",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cdx-manager"
7
- version = "0.9.14"
7
+ version = "0.9.15"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -40,6 +40,8 @@ def read_bundle_meta(data):
40
40
  except (UnicodeDecodeError, json.JSONDecodeError) as error:
41
41
  raise CdxError("Invalid bundle format.") from error
42
42
 
43
+ if not isinstance(wrapper, dict):
44
+ raise CdxError("Invalid bundle format.")
43
45
  if wrapper.get("schema_version") != BUNDLE_SCHEMA_VERSION:
44
46
  raise CdxError("Unsupported bundle schema version.")
45
47
  return wrapper
@@ -83,9 +83,16 @@ def _refresh_claude_sessions(service, refresh_fn=None, target_names=None, force=
83
83
  for t in threads:
84
84
  t.join(timeout=10)
85
85
 
86
+ # A worker that outlived its join can still insert into results/errors;
87
+ # snapshot both so late writes can't mutate what we iterate.
88
+ results = dict(results)
89
+ errors = list(errors)
90
+
91
+ recorded = []
86
92
  for name, usage in results.items():
87
93
  try:
88
94
  service["record_status"](name, usage)
89
- except CdxError:
95
+ recorded.append(name)
96
+ except (CdxError, OSError):
90
97
  errors.append({"session": name, "error": CdxError(f"Failed to record Claude status for {name}")})
91
- return {"refreshed": sorted(results), "errors": errors}
98
+ return {"refreshed": sorted(recorded), "errors": errors}
@@ -103,10 +103,41 @@ def _refresh_claude_cli_credentials(auth_home, runner=None, env=None):
103
103
 
104
104
 
105
105
  def _format_reset_date(unix_seconds):
106
- dt = datetime.fromtimestamp(unix_seconds, tz=timezone.utc).astimezone()
106
+ try:
107
+ dt = datetime.fromtimestamp(unix_seconds, tz=timezone.utc).astimezone()
108
+ except (TypeError, ValueError, OSError):
109
+ return None
107
110
  return f"{MONTH_ABBR[dt.month - 1]} {dt.day} {str(dt.hour).zfill(2)}:{str(dt.minute).zfill(2)}"
108
111
 
109
112
 
113
+ def _parse_reset_epoch(value):
114
+ # The reset headers are unix seconds today, but an RFC3339 value must
115
+ # not abort the whole refresh.
116
+ if value in (None, ""):
117
+ return None
118
+ text = str(value).strip()
119
+ try:
120
+ return float(text)
121
+ except ValueError:
122
+ pass
123
+ try:
124
+ parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
125
+ except ValueError:
126
+ return None
127
+ if parsed.tzinfo is None:
128
+ parsed = parsed.replace(tzinfo=timezone.utc)
129
+ return parsed.timestamp()
130
+
131
+
132
+ def _safe_float(value):
133
+ if value is None:
134
+ return None
135
+ try:
136
+ return float(value)
137
+ except (TypeError, ValueError):
138
+ return None
139
+
140
+
110
141
  def _remaining_from_utilization(value):
111
142
  if value is None:
112
143
  return None
@@ -184,11 +215,11 @@ def fetch_claude_rate_limit_headers(access_token):
184
215
  if util_5h is None and util_7d is None:
185
216
  return None
186
217
 
187
- utilization_5h = float(util_5h) if util_5h is not None else None
188
- utilization_7d = float(util_7d) if util_7d is not None else None
218
+ utilization_5h = _safe_float(util_5h)
219
+ utilization_7d = _safe_float(util_7d)
189
220
 
190
- reset_5h_at = _format_reset_date(int(reset_5h)) if reset_5h else None
191
- reset_week_at = _format_reset_date(int(reset_7d)) if reset_7d else None
221
+ reset_5h_at = _format_reset_date(_parse_reset_epoch(reset_5h)) if reset_5h else None
222
+ reset_week_at = _format_reset_date(_parse_reset_epoch(reset_7d)) if reset_7d else None
192
223
  reset_at = reset_week_at or reset_5h_at
193
224
 
194
225
  return {
package/src/cli.py CHANGED
@@ -66,7 +66,7 @@ from .status_view import (
66
66
  )
67
67
  from .update_check import check_for_update, check_logics_manager_for_update
68
68
 
69
- VERSION = "0.9.14"
69
+ VERSION = "0.9.15"
70
70
 
71
71
  # Public surface: this module is a facade. Names below are imported above
72
72
  # purely to be re-exported (consumed by tests and external callers); listing
@@ -324,14 +324,15 @@ def main(argv, options=None):
324
324
  def err(text):
325
325
  stderr.write(text)
326
326
 
327
- # Flags
328
- if "--help" in argv or "-h" in argv:
327
+ # Flags — only when leading, so 'cdx history -h' reaches the subcommand
328
+ # instead of being hijacked as a malformed top-level help call.
329
+ if argv[:1] in (["--help"], ["-h"]):
329
330
  if len(argv) != 1:
330
331
  raise CdxError("Usage: cdx --help")
331
332
  out(f"{_print_help(use_color=use_color)}\n")
332
333
  return 0
333
334
 
334
- if "--version" in argv or "-v" in argv:
335
+ if argv[:1] in (["--version"], ["-v"]):
335
336
  if len(argv) != 1:
336
337
  raise CdxError("Usage: cdx --version")
337
338
  out(f"{_print_version()}\n")
package/src/cli_args.py CHANGED
@@ -55,7 +55,7 @@ def _parse_flag_args(args, schema, usage, positionals_key=None, max_positionals=
55
55
  parsed[spec["key"]] = True
56
56
  index += 1
57
57
  continue
58
- value, index = _read_option_value(args, index, usage)
58
+ value, index = _read_option_value(args, index, usage, schema=schema)
59
59
  parsed[spec["key"]] = spec.get("transform", lambda item: item)(value)
60
60
  continue
61
61
  if arg.startswith("--") and "=" in arg:
@@ -162,6 +162,8 @@ def _parse_set_args(args):
162
162
  raise CdxError(SET_USAGE)
163
163
  if parsed["names"] and parsed["provider"]:
164
164
  raise CdxError(SET_USAGE)
165
+ if parsed["sessions"] and parsed["provider"]:
166
+ raise CdxError(SET_USAGE)
165
167
  if not parsed["names"] and not parsed["sessions"] and not parsed["provider"]:
166
168
  raise CdxError(SET_USAGE)
167
169
  settings = {
@@ -200,6 +202,8 @@ def _parse_unset_args(args):
200
202
  raise CdxError(UNSET_USAGE)
201
203
  if parsed["names"] and parsed["provider"]:
202
204
  raise CdxError(UNSET_USAGE)
205
+ if parsed["sessions"] and parsed["provider"]:
206
+ raise CdxError(UNSET_USAGE)
203
207
  if not parsed["names"] and not parsed["sessions"] and not parsed["provider"]:
204
208
  raise CdxError(UNSET_USAGE)
205
209
  keys = ["power", "permission", "fast", "rtk", "logics", "model", "priority"] if parsed["all"] else [
@@ -501,10 +505,16 @@ def _parse_stats_args(args, now=None):
501
505
  }
502
506
 
503
507
 
504
- def _read_option_value(args, index, usage):
508
+ def _read_option_value(args, index, usage, schema=None):
505
509
  if index + 1 >= len(args):
506
510
  raise CdxError(usage)
507
- return args[index + 1], index + 2
511
+ value = args[index + 1]
512
+ # A known flag is never a value: '--model --json' is a missing value,
513
+ # not model='--json'. Literal flag-like values remain expressible as
514
+ # '--model=--json'.
515
+ if schema and value in schema:
516
+ raise CdxError(usage)
517
+ return value, index + 2
508
518
 
509
519
 
510
520
  def _parse_session_names(value):
@@ -79,6 +79,7 @@ from .context_store import (
79
79
  write_context,
80
80
  )
81
81
  from .errors import CdxError
82
+ from .fs_utils import atomic_write
82
83
  from .health import collect_health_report, format_health_report
83
84
  from .notify import (
84
85
  format_notify_event,
@@ -170,20 +171,19 @@ def _extract_claude_oauth_token(text):
170
171
 
171
172
  def _write_claude_oauth_token(auth_home, token):
172
173
  cred_dir = os.path.join(auth_home, "credentials")
173
- os.makedirs(cred_dir, exist_ok=True)
174
+ os.makedirs(cred_dir, mode=0o700, exist_ok=True)
175
+ try:
176
+ os.chmod(cred_dir, 0o700)
177
+ except OSError:
178
+ pass
174
179
  cred_path = os.path.join(cred_dir, "default.json")
175
180
  payload = {
176
181
  "version": "1.0",
177
182
  "type": "oauth_token",
178
183
  "access_token": token,
179
184
  }
180
- with open(cred_path, "w", encoding="utf-8") as handle:
181
- json.dump(payload, handle, indent=2)
182
- handle.write("\n")
183
- try:
184
- os.chmod(cred_path, 0o600)
185
- except OSError:
186
- pass
185
+ # atomic_write keeps the token 0o600 from creation — no umask window.
186
+ atomic_write(cred_path, json.dumps(payload, indent=2) + "\n", mode=0o600)
187
187
  return cred_path
188
188
 
189
189
 
@@ -204,18 +204,22 @@ def _bootstrap_claude_setup_token(session, ctx):
204
204
  "Claude setup-token completed, but cdx could not capture the token. "
205
205
  "Run claude setup-token and save the token under credentials/default.json."
206
206
  )
207
- with open(transcript_path, encoding="utf-8", errors="replace") as handle:
208
- transcript = handle.read()
207
+ try:
208
+ with open(transcript_path, encoding="utf-8", errors="replace") as handle:
209
+ transcript = handle.read()
210
+ finally:
211
+ # The transcript holds the ~1-year OAuth token in cleartext; it must
212
+ # not outlive the extraction, whether it succeeds or not.
213
+ try:
214
+ os.remove(transcript_path)
215
+ except OSError:
216
+ pass
209
217
  token = _extract_claude_oauth_token(transcript)
210
218
  if not token:
211
219
  raise CdxError(
212
220
  "Claude setup-token completed, but cdx could not find CLAUDE_CODE_OAUTH_TOKEN in the output. "
213
- f"Transcript kept at: {transcript_path}"
221
+ "Run claude setup-token manually and save the token under credentials/default.json."
214
222
  )
215
- try:
216
- os.remove(transcript_path)
217
- except OSError:
218
- pass
219
223
  auth_home = session.get("authHome") or ""
220
224
  cred_path = _write_claude_oauth_token(auth_home, token)
221
225
  # ponytail: drop short-lived claude login creds so the ~1yr token wins at launch (provider_runtime _read_claude_launch_oauth_token)
@@ -819,11 +823,12 @@ def _select_headless_session(ctx, provider, min_reasoning_effort=None, require_r
819
823
  session for session in ctx["service"]["list_sessions"]()
820
824
  if session.get("provider") == provider and session.get("enabled", True) is not False
821
825
  ]
822
- minimum = _reasoning_rank(min_reasoning_effort)
823
- sessions = [
824
- session for session in sessions
825
- if _reasoning_rank(_session_reasoning_effort(session)) >= minimum
826
- ]
826
+ if min_reasoning_effort:
827
+ minimum = _reasoning_rank(min_reasoning_effort)
828
+ sessions = [
829
+ session for session in sessions
830
+ if _reasoning_rank(_session_reasoning_effort(session)) >= minimum
831
+ ]
827
832
  rows = ctx["service"]["get_status_rows"](force_refresh=force_refresh)
828
833
  row_by_name = {row.get("session_name"): row for row in rows}
829
834
  candidates = []
@@ -1971,7 +1976,7 @@ def handle_update(rest, ctx):
1971
1976
  base_prefix=ctx["options"].get("basePrefix"),
1972
1977
  )
1973
1978
  results = run_update_plan(plan, runner=ctx["options"].get("runUpdate"), env=ctx.get("env"))
1974
- failed = any((result.get("returncode") not in (0, None)) for result in results)
1979
+ failed = any((result.get("returncode") != 0) for result in results)
1975
1980
  if failed:
1976
1981
  raise CdxError(format_update_failure(results))
1977
1982
 
package/src/cli_render.py CHANGED
@@ -10,7 +10,7 @@ def _format_relative_age(iso_value):
10
10
  if not iso_value:
11
11
  return "-"
12
12
  try:
13
- ts = datetime.fromisoformat(iso_value.replace("Z", "+00:00"))
13
+ ts = datetime.fromisoformat(str(iso_value).replace("Z", "+00:00"))
14
14
  delta_s = (datetime.now(timezone.utc) - ts).total_seconds()
15
15
  except (ValueError, TypeError):
16
16
  return "-"
@@ -6,6 +6,8 @@ import subprocess
6
6
  import threading
7
7
  from datetime import datetime, timezone
8
8
 
9
+ from .status_source import _is_zero_credit_balance
10
+
9
11
  try:
10
12
  import fcntl
11
13
  except ImportError: # pragma: no cover - non-POSIX platforms
@@ -92,7 +94,7 @@ def normalize_codex_rate_limit_snapshot(snapshot):
92
94
  credit_balance = None
93
95
  if isinstance(credits, dict):
94
96
  credit_balance = credits.get("balance")
95
- if not credits.get("hasCredits") and not credits.get("unlimited") and str(credit_balance or "0") == "0":
97
+ if not credits.get("hasCredits") and not credits.get("unlimited") and _is_zero_credit_balance(credit_balance):
96
98
  credit_balance = None
97
99
  elif credits is not None:
98
100
  credit_balance = credits
@@ -5,6 +5,7 @@ import subprocess
5
5
  from datetime import datetime
6
6
 
7
7
  from .errors import CdxError
8
+ from .fs_utils import atomic_write
8
9
  from .session_store import _ensure_dir
9
10
 
10
11
  DEFAULT_CONTEXT_TEMPLATE = """# Shared Context
@@ -57,8 +58,7 @@ def read_context(base_dir, cwd=None):
57
58
  def write_context(base_dir, content, cwd=None):
58
59
  path = get_context_path(base_dir, cwd)
59
60
  _ensure_dir(os.path.dirname(path))
60
- with open(path, "w", encoding="utf-8") as handle:
61
- handle.write(content.rstrip() + "\n")
61
+ atomic_write(path, content.rstrip() + "\n", mode=0o644)
62
62
  return {
63
63
  "path": path,
64
64
  "updated_at": _local_now_iso(),
@@ -117,8 +117,7 @@ def install_context_for_session(base_dir, session, cwd=None):
117
117
  raise CdxError(f"Session auth home missing for {session['name']}")
118
118
  target_path = os.path.join(auth_home, "shared-context.md")
119
119
  _ensure_dir(os.path.dirname(target_path))
120
- with open(target_path, "w", encoding="utf-8") as handle:
121
- handle.write(content.rstrip() + "\n")
120
+ atomic_write(target_path, content.rstrip() + "\n", mode=0o644)
122
121
  return {
123
122
  "source_path": get_context_path(base_dir, cwd),
124
123
  "target_path": target_path,
package/src/fs_utils.py CHANGED
@@ -2,6 +2,38 @@ import inspect
2
2
  import os
3
3
  import shutil
4
4
  import stat
5
+ import tempfile
6
+
7
+
8
+ def atomic_write(path, data, mode=None):
9
+ """Write str/bytes via temp file + rename so a crash can't leave a torn file.
10
+
11
+ mkstemp creates the temp file 0o600, so the content is never readable by
12
+ other users mid-write; pass mode to set the final permissions before the
13
+ rename makes the file visible.
14
+ """
15
+ directory = os.path.dirname(os.path.abspath(path))
16
+ os.makedirs(directory, exist_ok=True)
17
+ binary = isinstance(data, (bytes, bytearray))
18
+ fd, temp_path = tempfile.mkstemp(prefix=f".{os.path.basename(path)}.", suffix=".tmp", dir=directory)
19
+ try:
20
+ if binary:
21
+ handle = os.fdopen(fd, "wb")
22
+ else:
23
+ handle = os.fdopen(fd, "w", encoding="utf-8")
24
+ with handle:
25
+ handle.write(data)
26
+ handle.flush()
27
+ os.fsync(handle.fileno())
28
+ if mode is not None and os.name != "nt":
29
+ os.chmod(temp_path, mode)
30
+ os.replace(temp_path, path)
31
+ except Exception:
32
+ try:
33
+ os.unlink(temp_path)
34
+ except FileNotFoundError:
35
+ pass
36
+ raise
5
37
 
6
38
 
7
39
  def _make_user_writable(path):
@@ -695,6 +695,45 @@ def _redact_sensitive_args(spec):
695
695
  return [REDACTED_PROMPT_ARG if arg in sensitive else arg for arg in args]
696
696
 
697
697
 
698
+ def _signal_child_group(child, sig):
699
+ # Providers spawn helpers of their own; signal the whole group so
700
+ # grandchildren die with the child. Falls back to False when the child
701
+ # shares our group (test doubles spawned without start_new_session).
702
+ pid = getattr(child, "pid", None)
703
+ if not pid or not hasattr(os, "getpgid"):
704
+ return False
705
+ try:
706
+ pgid = os.getpgid(pid)
707
+ if pgid == os.getpgid(0):
708
+ return False
709
+ os.killpg(pgid, sig)
710
+ return True
711
+ except (OSError, TypeError):
712
+ return False
713
+
714
+
715
+ def _terminate_child_tree(child):
716
+ if not _signal_child_group(child, signal.SIGTERM):
717
+ try:
718
+ child.terminate()
719
+ except Exception:
720
+ pass
721
+ try:
722
+ child.wait(timeout=5)
723
+ return
724
+ except Exception:
725
+ pass
726
+ if not _signal_child_group(child, signal.SIGKILL):
727
+ try:
728
+ child.kill()
729
+ except Exception:
730
+ pass
731
+ try:
732
+ child.wait()
733
+ except Exception:
734
+ pass
735
+
736
+
698
737
  def _run_headless_provider_command(session, cwd=None, env_override=None, initial_prompt=None,
699
738
  timeout_seconds=None, spawn=None, run_id=None):
700
739
  spawn = spawn or subprocess.Popen
@@ -714,12 +753,16 @@ def _run_headless_provider_command(session, cwd=None, env_override=None, initial
714
753
  timed_out = False
715
754
  with open(paths["stdout_path"], "w", encoding="utf-8", errors="replace") as stdout_file, \
716
755
  open(paths["stderr_path"], "w", encoding="utf-8", errors="replace") as stderr_file:
756
+ options = {k: v for k, v in spec.get("options", {}).items() if k not in ("stdio", "stdout", "stderr")}
757
+ if spawn is subprocess.Popen:
758
+ # Own process group so a timeout can kill grandchildren too.
759
+ options["start_new_session"] = True
717
760
  try:
718
761
  child = spawn(
719
762
  [command] + spec["args"],
720
763
  stdout=stdout_file,
721
764
  stderr=stderr_file,
722
- **{k: v for k, v in spec.get("options", {}).items() if k not in ("stdio", "stdout", "stderr")},
765
+ **options,
723
766
  )
724
767
  except FileNotFoundError as error:
725
768
  _combine_headless_transcript(paths)
@@ -740,18 +783,7 @@ def _run_headless_provider_command(session, cwd=None, env_override=None, initial
740
783
  child.wait()
741
784
  except subprocess.TimeoutExpired:
742
785
  timed_out = True
743
- try:
744
- child.terminate()
745
- child.wait(timeout=5)
746
- except Exception:
747
- try:
748
- child.kill()
749
- except Exception:
750
- pass
751
- try:
752
- child.wait()
753
- except Exception:
754
- pass
786
+ _terminate_child_tree(child)
755
787
 
756
788
  _combine_headless_transcript(paths)
757
789
  end_time = datetime.now(timezone.utc)
@@ -1,6 +1,8 @@
1
1
  import json
2
2
  import os
3
+ import sys
3
4
  import tempfile
5
+ from contextlib import contextmanager
4
6
  from datetime import datetime, timezone
5
7
 
6
8
 
@@ -12,6 +14,28 @@ def registry_path(base_dir):
12
14
  return os.path.join(base_dir, "runs.json")
13
15
 
14
16
 
17
+ @contextmanager
18
+ def _registry_lock(path):
19
+ os.makedirs(os.path.dirname(path), exist_ok=True)
20
+ with open(path + ".lock", "a") as handle:
21
+ if sys.platform == "win32":
22
+ import msvcrt
23
+ handle.seek(0)
24
+ msvcrt.locking(handle.fileno(), msvcrt.LK_LOCK, 1)
25
+ try:
26
+ yield
27
+ finally:
28
+ handle.seek(0)
29
+ msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
30
+ else:
31
+ import fcntl
32
+ fcntl.flock(handle, fcntl.LOCK_EX)
33
+ try:
34
+ yield
35
+ finally:
36
+ fcntl.flock(handle, fcntl.LOCK_UN)
37
+
38
+
15
39
  def _read_registry(path):
16
40
  try:
17
41
  with open(path, encoding="utf-8") as handle:
@@ -76,7 +100,9 @@ def _base_record(run_id, *, kind, session, provider, model, cwd, artifacts=None)
76
100
  "provider": provider,
77
101
  "model": model,
78
102
  "cwd": os.path.abspath(cwd),
79
- "pid": None,
103
+ # The manager process drives the run synchronously, so its own pid is
104
+ # the liveness proxy until finish() records the provider child's pid.
105
+ "pid": os.getpid(),
80
106
  "started_at": utc_now_iso(),
81
107
  "ended_at": None,
82
108
  "duration_seconds": None,
@@ -94,14 +120,19 @@ class RunRegistry:
94
120
  self.path = registry_path(base_dir)
95
121
 
96
122
  def start(self, run_id, *, kind, session, provider, model, cwd, artifacts=None):
97
- data = _read_registry(self.path)
98
- data["runs"] = [run for run in data["runs"] if run.get("run_id") != run_id]
99
- record = _base_record(run_id, kind=kind, session=session, provider=provider, model=model, cwd=cwd, artifacts=artifacts)
100
- data["runs"].insert(0, record)
101
- _write_registry(self.path, data)
123
+ with _registry_lock(self.path):
124
+ data = _read_registry(self.path)
125
+ data["runs"] = [run for run in data["runs"] if run.get("run_id") != run_id]
126
+ record = _base_record(run_id, kind=kind, session=session, provider=provider, model=model, cwd=cwd, artifacts=artifacts)
127
+ data["runs"].insert(0, record)
128
+ _write_registry(self.path, data)
102
129
  return record
103
130
 
104
131
  def finish(self, run_id, *, status, final_payload=None, run_info=None, error=None, task_report=None):
132
+ with _registry_lock(self.path):
133
+ return self._finish_locked(run_id, status=status, final_payload=final_payload, run_info=run_info, error=error, task_report=task_report)
134
+
135
+ def _finish_locked(self, run_id, *, status, final_payload=None, run_info=None, error=None, task_report=None):
105
136
  data = _read_registry(self.path)
106
137
  now = utc_now_iso()
107
138
  for run in data["runs"]:
@@ -137,17 +168,19 @@ class RunRegistry:
137
168
  return None
138
169
 
139
170
  def list(self, limit=20):
140
- data = _read_registry(self.path)
141
- changed = _refresh_stale_runs(data)
142
- if changed:
143
- _write_registry(self.path, data)
171
+ with _registry_lock(self.path):
172
+ data = _read_registry(self.path)
173
+ changed = _refresh_stale_runs(data)
174
+ if changed:
175
+ _write_registry(self.path, data)
144
176
  return data["runs"][: max(0, int(limit or 20))]
145
177
 
146
178
  def get(self, run_id):
147
- data = _read_registry(self.path)
148
- changed = _refresh_stale_runs(data)
149
- if changed:
150
- _write_registry(self.path, data)
179
+ with _registry_lock(self.path):
180
+ data = _read_registry(self.path)
181
+ changed = _refresh_stale_runs(data)
182
+ if changed:
183
+ _write_registry(self.path, data)
151
184
  for run in data["runs"]:
152
185
  if run.get("run_id") == run_id:
153
186
  return run
package/src/run_usage.py CHANGED
@@ -46,7 +46,9 @@ def _parse_json_records(text):
46
46
  try:
47
47
  records.append(json.loads(line))
48
48
  except json.JSONDecodeError:
49
- return []
49
+ # A single truncated/noisy line must not discard the usage
50
+ # carried by every other valid JSONL record in the stream.
51
+ continue
50
52
  return records
51
53
 
52
54