cdx-manager 0.9.13 → 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.
@@ -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
 
@@ -14,7 +14,7 @@ from .claude_usage import _decode_jwt_claims
14
14
  from .codex_usage import fetch_codex_rate_limits
15
15
  from .config import PROVIDER_ANTIGRAVITY, PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDERS, get_cdx_home
16
16
  from .errors import CdxError
17
- from .fs_utils import remove_tree
17
+ from .fs_utils import atomic_write, remove_tree
18
18
  from .session_store import create_session_store
19
19
  from .status_source import find_latest_status_artifact
20
20
 
@@ -266,6 +266,7 @@ def _normalize_status_payload(payload=None):
266
266
  "updated_at": _to_local_iso(payload.get("updated_at") or payload.get("captured_at") or now),
267
267
  "raw_status_text": payload.get("raw_status_text"),
268
268
  "source_ref": payload.get("source_ref"),
269
+ "structured": bool(payload.get("structured")),
269
270
  }
270
271
 
271
272
 
@@ -366,6 +367,12 @@ def _merge_status_payload(current, candidate):
366
367
  if merged.get(field) is None and candidate.get(field) is not None:
367
368
  merged[field] = candidate[field]
368
369
 
370
+ # The structured marker qualifies the source_ref; keep them in sync when
371
+ # the merge adopts the candidate's ref, else a structured rollout ref
372
+ # would read as low-confidence scraped text.
373
+ if merged.get("source_ref") is not None and merged["source_ref"] == candidate.get("source_ref"):
374
+ merged["structured"] = bool(candidate.get("structured"))
375
+
369
376
  merged["updated_at"] = candidate.get("updated_at") or current.get("updated_at")
370
377
  return merged
371
378
 
@@ -386,6 +393,10 @@ def _compute_available_pct(status):
386
393
  def _is_low_confidence_status_source(status):
387
394
  if not status:
388
395
  return False
396
+ if status.get("structured"):
397
+ # Exact rate_limits API data: trustworthy even when it comes from a
398
+ # sessions/rollout path, which otherwise flags scraped-text noise.
399
+ return False
389
400
  source_ref = str(status.get("source_ref") or "").replace(os.sep, "/")
390
401
  return "/sessions/" in source_ref and "/rollout" in source_ref
391
402
 
@@ -425,9 +436,7 @@ def _ensure_claude_attribution_disabled(auth_home):
425
436
  settings = {}
426
437
  settings["includeCoAuthoredBy"] = False
427
438
  try:
428
- with open(settings_path, "w", encoding="utf-8") as handle:
429
- json.dump(settings, handle, indent=2, sort_keys=True)
430
- handle.write("\n")
439
+ atomic_write(settings_path, json.dumps(settings, indent=2, sort_keys=True) + "\n", mode=0o644)
431
440
  except OSError:
432
441
  return False
433
442
  return True
@@ -478,23 +487,29 @@ def create_session_service(options=None):
478
487
  )
479
488
 
480
489
  def _session_runtime(name):
481
- state = store["read_session_state"](name)
482
- if not state:
490
+ found = {}
491
+
492
+ def updater(state):
493
+ if not state:
494
+ return None
495
+ runtime = state.get("runtime")
496
+ if _runtime_is_active(runtime):
497
+ found["runtime"] = runtime
498
+ return None
499
+ if isinstance(runtime, dict) and runtime.get("status") == "running":
500
+ return {
501
+ **state,
502
+ "status": "ready",
503
+ "runtime": {
504
+ **runtime,
505
+ "status": "stale",
506
+ "endedAt": _local_now_iso(),
507
+ },
508
+ }
483
509
  return None
484
- runtime = state.get("runtime")
485
- if _runtime_is_active(runtime):
486
- return runtime
487
- if isinstance(runtime, dict) and runtime.get("status") == "running":
488
- store["write_session_state"](name, {
489
- **state,
490
- "status": "ready",
491
- "runtime": {
492
- **runtime,
493
- "status": "stale",
494
- "endedAt": _local_now_iso(),
495
- },
496
- })
497
- return None
510
+
511
+ store["update_session_state"](name, updater)
512
+ return found.get("runtime")
498
513
 
499
514
  def _validate_new_session_name(name):
500
515
  if not name:
@@ -755,11 +770,14 @@ def create_session_service(options=None):
755
770
  raise CdxError(f"Unknown session: {name}")
756
771
  if session.get("enabled", True) is False:
757
772
  raise CdxError(f"Session is disabled: {name}")
758
- state = store["read_session_state"](name)
759
- if not state:
760
- raise CdxError(f"Session state missing for {name}. Reconnect required.")
761
773
  now = _local_now_iso()
762
- store["write_session_state"](name, {**state, "rehydratedAt": now})
774
+
775
+ def state_updater(state):
776
+ if not state:
777
+ raise CdxError(f"Session state missing for {name}. Reconnect required.")
778
+ return {**state, "rehydratedAt": now}
779
+
780
+ store["update_session_state"](name, state_updater)
763
781
  return store["update_session"](name, lambda s: {
764
782
  **s, "updatedAt": now, "lastLaunchedAt": now
765
783
  })
@@ -768,9 +786,6 @@ def create_session_service(options=None):
768
786
  session = store["get_session"](name)
769
787
  if not session:
770
788
  raise CdxError(f"Unknown session: {name}")
771
- state = store["read_session_state"](name)
772
- if not state:
773
- raise CdxError(f"Session state missing for {name}. Reconnect required.")
774
789
  payload = dict(payload or {})
775
790
  now = _local_now_iso()
776
791
  runtime = {
@@ -782,52 +797,70 @@ def create_session_service(options=None):
782
797
  "label": payload.get("label"),
783
798
  "transcriptPath": payload.get("transcript_path") or payload.get("transcriptPath"),
784
799
  }
785
- store["write_session_state"](name, {
786
- **state,
787
- "status": "running",
788
- "runtime": runtime,
789
- })
800
+
801
+ def updater(state):
802
+ if not state:
803
+ raise CdxError(f"Session state missing for {name}. Reconnect required.")
804
+ return {
805
+ **state,
806
+ "status": "running",
807
+ "runtime": runtime,
808
+ }
809
+
810
+ store["update_session_state"](name, updater)
790
811
  return runtime
791
812
 
792
813
  def finish_session_runtime(name, run_id=None, payload=None):
793
814
  session = store["get_session"](name)
794
815
  if not session:
795
816
  raise CdxError(f"Unknown session: {name}")
796
- state = store["read_session_state"](name)
797
- if not state:
798
- return None
799
- runtime = state.get("runtime") or {}
800
- if run_id and runtime.get("runId") != run_id:
801
- return runtime
802
817
  payload = dict(payload or {})
803
- updated_runtime = {
804
- **runtime,
805
- "status": payload.get("status") or "stopped",
806
- "endedAt": payload.get("endedAt") or _local_now_iso(),
807
- }
808
- if "returncode" in payload:
809
- updated_runtime["returncode"] = payload["returncode"]
810
- store["write_session_state"](name, {
811
- **state,
812
- "status": "ready",
813
- "runtime": updated_runtime,
814
- })
815
- return updated_runtime
818
+ outcome = {}
819
+
820
+ def updater(state):
821
+ if not state:
822
+ return None
823
+ runtime = state.get("runtime") or {}
824
+ if run_id and runtime.get("runId") != run_id:
825
+ outcome["runtime"] = runtime
826
+ return None
827
+ updated_runtime = {
828
+ **runtime,
829
+ "status": payload.get("status") or "stopped",
830
+ "endedAt": payload.get("endedAt") or _local_now_iso(),
831
+ }
832
+ if "returncode" in payload:
833
+ updated_runtime["returncode"] = payload["returncode"]
834
+ outcome["runtime"] = updated_runtime
835
+ return {
836
+ **state,
837
+ "status": "ready",
838
+ "runtime": updated_runtime,
839
+ }
840
+
841
+ store["update_session_state"](name, updater)
842
+ return outcome.get("runtime")
816
843
 
817
844
  def ensure_session_state(name):
818
845
  session = store["get_session"](name)
819
846
  if not session:
820
847
  raise CdxError(f"Unknown session: {name}")
821
- state = store["read_session_state"](name)
822
- if state:
823
- return state
824
- repaired = {
825
- "provider": session["provider"],
826
- "status": "ready",
827
- "rehydratedAt": None,
828
- }
829
- store["write_session_state"](name, repaired)
830
- return repaired
848
+ result = {}
849
+
850
+ def updater(state):
851
+ if state:
852
+ result["state"] = state
853
+ return None
854
+ repaired = {
855
+ "provider": session["provider"],
856
+ "status": "ready",
857
+ "rehydratedAt": None,
858
+ }
859
+ result["state"] = repaired
860
+ return repaired
861
+
862
+ store["update_session_state"](name, updater)
863
+ return result["state"]
831
864
 
832
865
  def list_sessions():
833
866
  return store["list_sessions"]()
@@ -853,22 +886,28 @@ def create_session_service(options=None):
853
886
  updates = _normalize_launch_settings(settings)
854
887
  if not updates:
855
888
  raise CdxError("At least one launch setting is required.")
856
- current = _normalize_launch_settings(session.get("launch") or {}, mark_fast_service_tier=False)
857
- launch = {**current, **updates}
858
- explicit_power = "power" in updates or "reasoning_effort" in updates
859
- if explicit_power and "fast" not in updates and launch.get("fastMode") != "service_tier":
860
- launch["fast"] = False
861
- launch.pop("fastMode", None)
862
- if updates.get("fast") is False:
863
- launch.pop("fastMode", None)
864
- if not any(key in launch for key in ("power", "reasoning_effort", "reasoningEffort")):
865
- launch["power"] = DEFAULT_LAUNCH_SETTINGS["power"]
866
889
  now = _local_now_iso()
867
- return store["update_session"](name, lambda s: {
868
- **s,
869
- "launch": launch,
870
- "updatedAt": now,
871
- })
890
+
891
+ def updater(s):
892
+ # Build the new launch dict from the fresh record inside the store
893
+ # lock; a pre-lock snapshot loses a concurrent setting change.
894
+ current = _normalize_launch_settings(s.get("launch") or {}, mark_fast_service_tier=False)
895
+ launch = {**current, **updates}
896
+ explicit_power = "power" in updates or "reasoning_effort" in updates
897
+ if explicit_power and "fast" not in updates and launch.get("fastMode") != "service_tier":
898
+ launch["fast"] = False
899
+ launch.pop("fastMode", None)
900
+ if updates.get("fast") is False:
901
+ launch.pop("fastMode", None)
902
+ if not any(key in launch for key in ("power", "reasoning_effort", "reasoningEffort")):
903
+ launch["power"] = DEFAULT_LAUNCH_SETTINGS["power"]
904
+ return {
905
+ **s,
906
+ "launch": launch,
907
+ "updatedAt": now,
908
+ }
909
+
910
+ return store["update_session"](name, updater)
872
911
 
873
912
  def unset_launch_settings(name, keys):
874
913
  session = store["get_session"](name)
@@ -880,12 +919,12 @@ def create_session_service(options=None):
880
919
  unknown = [key for key in keys if key not in allowed]
881
920
  if unknown:
882
921
  raise CdxError(f"Unsupported launch setting: {', '.join(unknown)}")
883
- current = dict(session.get("launch") or {})
884
- for key in keys:
885
- current.pop(key, None)
886
922
  now = _local_now_iso()
887
923
 
888
924
  def updater(s):
925
+ current = dict(s.get("launch") or {})
926
+ for key in keys:
927
+ current.pop(key, None)
889
928
  updated = {**s, "updatedAt": now}
890
929
  if current:
891
930
  updated["launch"] = current
@@ -972,6 +1011,9 @@ def create_session_service(options=None):
972
1011
  global_root,
973
1012
  session["provider"],
974
1013
  expected_account_email=expected_account_email,
1014
+ # The shared codex home can hold other accounts' rollouts;
1015
+ # structured payloads there cannot be attributed to ours.
1016
+ trust_unattributed_structured=False,
975
1017
  )
976
1018
  if not artifact:
977
1019
  if _is_low_confidence_status_source(current_status):
@@ -988,6 +1030,7 @@ def create_session_service(options=None):
988
1030
  "updated_at": artifact.get("updated_at"),
989
1031
  "raw_status_text": artifact.get("raw_status_text"),
990
1032
  "source_ref": artifact.get("source_ref"),
1033
+ "structured": artifact.get("structured"),
991
1034
  })
992
1035
  if _is_low_confidence_status_source(current_status) and not _is_low_confidence_status_source(resolved):
993
1036
  record_status(session["name"], resolved)
@@ -1269,14 +1312,10 @@ def create_session_service(options=None):
1269
1312
  bundle_bytes = encode_bundle(payload, include_auth=include_auth, passphrase=passphrase)
1270
1313
  if progress_callback:
1271
1314
  progress_callback({"event": "writing_started", "path": file_path, "bundle_size_bytes": len(bundle_bytes)})
1272
- os.makedirs(os.path.dirname(os.path.abspath(file_path)) or ".", exist_ok=True)
1273
- with open(file_path, "wb") as handle:
1274
- handle.write(bundle_bytes)
1275
- if sys.platform != "win32":
1276
- try:
1277
- os.chmod(file_path, 0o600)
1278
- except OSError:
1279
- pass
1315
+ # Atomic + 0o600 from the start: the export path may live in a
1316
+ # world-traversable directory, and a crash must not clobber a
1317
+ # previous good bundle with a truncated one.
1318
+ atomic_write(os.path.abspath(file_path), bundle_bytes, mode=0o600)
1280
1319
  return {
1281
1320
  "path": file_path,
1282
1321
  "include_auth": include_auth,
@@ -1327,8 +1366,12 @@ def create_session_service(options=None):
1327
1366
  _ensure_private_dir(session_root)
1328
1367
  _ensure_private_dir(auth_home)
1329
1368
 
1369
+ existing_state_before = None
1330
1370
  if is_existing and merge:
1331
1371
  existing_record = store["get_session"](name) or {}
1372
+ # replace_session resets the state file to defaults, so the
1373
+ # local state must be captured before it runs.
1374
+ existing_state_before = store["read_session_state"](name) or {}
1332
1375
  bundle_record = {
1333
1376
  **session_payload,
1334
1377
  "provider": provider,
@@ -1352,13 +1395,12 @@ def create_session_service(options=None):
1352
1395
  store["replace_session"](name, session_record)
1353
1396
 
1354
1397
  state = (payload.get("states") or {}).get(name)
1355
- if state is not None:
1356
- if is_existing and merge:
1357
- existing_state = store["read_session_state"](name) or {}
1358
- merged_state = {**state, **{k: v for k, v in existing_state.items() if v is not None}}
1398
+ if is_existing and merge:
1399
+ merged_state = {**(state or {}), **{k: v for k, v in (existing_state_before or {}).items() if v is not None}}
1400
+ if merged_state:
1359
1401
  store["write_session_state"](name, merged_state)
1360
- else:
1361
- store["write_session_state"](name, state)
1402
+ elif state is not None:
1403
+ store["write_session_state"](name, state)
1362
1404
 
1363
1405
  for item in (payload.get("profiles") or {}).get(name, []):
1364
1406
  rel_path = _safe_relpath(item.get("path"))
@@ -1371,13 +1413,8 @@ def create_session_service(options=None):
1371
1413
  if is_existing and merge and os.path.exists(dest_path):
1372
1414
  continue
1373
1415
  _ensure_private_dir(os.path.dirname(dest_path))
1374
- with open(dest_path, "wb") as handle:
1375
- handle.write(content)
1376
- if sys.platform != "win32":
1377
- try:
1378
- os.chmod(dest_path, 0o600)
1379
- except OSError:
1380
- pass
1416
+ # Decrypted credentials: 0o600 from creation, no umask window.
1417
+ atomic_write(dest_path, content, mode=0o600)
1381
1418
 
1382
1419
  return {
1383
1420
  "path": file_path,
@@ -260,6 +260,16 @@ def create_session_store(base_dir):
260
260
  with _file_lock(lock_file):
261
261
  _write_session_state_unlocked(name, state)
262
262
 
263
+ def update_session_state(name, updater):
264
+ # Read-modify-write under one lock; separate read/write calls let a
265
+ # concurrent writer's update be clobbered between the two.
266
+ with _file_lock(lock_file):
267
+ state = _read_session_state_unlocked(name)
268
+ updated = updater(state)
269
+ if updated is not None:
270
+ _write_session_state_unlocked(name, updated)
271
+ return updated
272
+
263
273
  def append_launch_history(entry):
264
274
  with _file_lock(lock_file):
265
275
  _ensure_dir(state_dir)
@@ -303,6 +313,7 @@ def create_session_store(base_dir):
303
313
  "replace_session": replace_session,
304
314
  "read_session_state": read_session_state,
305
315
  "write_session_state": write_session_state,
316
+ "update_session_state": update_session_state,
306
317
  "append_launch_history": append_launch_history,
307
318
  "list_launch_history": list_launch_history,
308
319
  }