@agentsdock/server 1.0.10-beta.1 → 1.0.10-beta.6

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.
@@ -10765,6 +10765,7 @@ class SessionStore:
10765
10765
  if "subagent_limit" in patch:
10766
10766
  validate_session_subagent_limit({**sess, "backend": patch.get("backend") or sess.get("backend")}, patch["subagent_limit"])
10767
10767
  previous_provider_runtime = dict(sess) if {"codex_provider", "provider_connection", "subagent_limit", "model", "effort"}.intersection(patch) or sess.get("_codex_provider_pending") else None
10768
+ previous_title_runtime = title_runtime_key(sess)
10768
10769
  missing_policy = object()
10769
10770
  previous_provider_jobs_access = sess.get(
10770
10771
  "provider_jobs_access",
@@ -10862,9 +10863,6 @@ class SessionStore:
10862
10863
  sess.pop("_title_seed", None)
10863
10864
  if patch.get("auto_title_enabled") is not None:
10864
10865
  sess["auto_title_enabled"] = bool(patch["auto_title_enabled"])
10865
- if (any(key in patch for key in ("title", "model", "backend", "codex_provider"))
10866
- or patch.get("auto_title_enabled") is False or patch.get("archived") is True):
10867
- cancel_generated_session_title(sid)
10868
10866
  for key, default, allowed in (
10869
10867
  (
10870
10868
  "claude_permission_mode",
@@ -10991,6 +10989,13 @@ class SessionStore:
10991
10989
  sess["_codex_provider_pending"] = desired
10992
10990
  else:
10993
10991
  sess.pop("_codex_provider_pending", None)
10992
+ # Ordinary/queued turns re-save their backend and may repeat model
10993
+ # or provider selections. Only an actual runtime change invalidates
10994
+ # the in-flight title; cancelling a no-op consumes its sole attempt.
10995
+ # An explicit title PATCH is still manual ownership, even if equal.
10996
+ if ("title" in patch or title_runtime_key(sess) != previous_title_runtime
10997
+ or patch.get("auto_title_enabled") is False or patch.get("archived") is True):
10998
+ cancel_generated_session_title(sid)
10994
10999
  sess["updated_at"] = now_iso()
10995
11000
  try:
10996
11001
  await self.save()
@@ -25588,7 +25593,7 @@ async def _start_next_queued_turn_locked(
25588
25593
  if e.status_code in (409, 503):
25589
25594
  already_notified = bool(item.get("_turn_deferred_notified"))
25590
25595
  item["_turn_deferred_notified"] = True
25591
- deferred_detail = str(e.detail or "")
25596
+ deferred_detail = str(e.detail.get("message", "") if isinstance(e.detail, dict) else e.detail or "")
25592
25597
  item["_last_deferred_detail"] = deferred_detail
25593
25598
  # Pure wait states (capacity, memory, goals or maintenance in
25594
25599
  # progress) are not admission failures of this row.
@@ -25602,7 +25607,7 @@ async def _start_next_queued_turn_locked(
25602
25607
  if not already_notified:
25603
25608
  await append_event(session_id, "turn_deferred", {
25604
25609
  "queued_id": item.get("queued_id"),
25605
- "message": f"Queued turn deferred: {e.detail}",
25610
+ "message": f"Queued turn deferred: {deferred_detail}",
25606
25611
  })
25607
25612
  schedule_queued_turn_retry(session_id)
25608
25613
  return
@@ -41583,7 +41588,7 @@ def cross_chat_exchange_submission_defer_reason(
41583
41588
  if isinstance(error, asyncio.CancelledError):
41584
41589
  return "AgentsServer stopped while admitting the delivery"
41585
41590
  if isinstance(error, TransientAdmissionWait):
41586
- return str(error.detail or "").strip() or "target admission deferred"
41591
+ return str(error.detail.get("message", "") if isinstance(error.detail, dict) else error.detail or "").strip() or "target admission deferred"
41587
41592
  if isinstance(error, HTTPException) and error.status_code == 503:
41588
41593
  detail = error.detail
41589
41594
  if isinstance(detail, dict):
@@ -46812,8 +46817,9 @@ def active_generated_title_work_labels() -> list[str]:
46812
46817
 
46813
46818
  def title_runtime_key(sess: dict[str, Any]) -> tuple:
46814
46819
  return (sess.get("backend") or DEFAULT_BACKEND, session_provider_id(sess),
46815
- sess.get("model"), sess.get("codex_provider"), sess.get("codex_provider_revision"),
46816
- sess.get("provider_connection"), sess.get("provider_connection_revision"))
46820
+ sess.get("model"), codex_provider.session_choice(sess.get("codex_provider")),
46821
+ sess.get("codex_provider_revision"), sess.get("provider_connection") or "default",
46822
+ sess.get("provider_connection_revision"))
46817
46823
 
46818
46824
 
46819
46825
  def generated_title_eligible(sess: dict[str, Any], *, allow_attempted: bool = False) -> bool:
@@ -54111,8 +54117,17 @@ def codex_manager_has_callers(manager: CodexAppServerManager, session_id: str |
54111
54117
  for task, owner in tuple(getattr(manager, "_agentsdock_callers", {}).items()))
54112
54118
 
54113
54119
 
54114
- def codex_manager_has_callbacks(manager: CodexAppServerManager) -> bool:
54115
- tasks = [task for task in manager.client._callback_tasks if not task.done()]
54120
+ def codex_manager_work_belongs_to_session(work: Any, session_id: str) -> bool:
54121
+ thread_id = getattr(work, "_codex_thread_id", "")
54122
+ owner = codex_session_id_for_thread(thread_id) if thread_id else None
54123
+ # Unknown ownership stays process-wide. Work for a known different chat
54124
+ # must not prevent an idle chat from releasing its own subscription.
54125
+ return owner is None or owner == session_id
54126
+
54127
+
54128
+ def codex_manager_has_callbacks(manager: CodexAppServerManager, session_id: str | None = None) -> bool:
54129
+ tasks = [task for task in manager.client._callback_tasks if not task.done()
54130
+ and (session_id is None or codex_manager_work_belongs_to_session(task, session_id))]
54116
54131
  for task in tasks:
54117
54132
  if not getattr(task, "_agentsdock_drain_watched", False):
54118
54133
  task._agentsdock_drain_watched = True
@@ -54165,9 +54180,9 @@ async def refresh_codex_app_server_binary(*, force: bool = False) -> None:
54165
54180
  async def refresh_codex_app_server_login(*, request_handoff: bool = False) -> None:
54166
54181
  """Change normal-Codex admission; never kill a credential-owning process.
54167
54182
 
54168
- Ordinary lookups use a conservative native file revision. Recheck CLIs is
54169
- an explicit handoff request, also covering keyring and same-account logins
54170
- without an OIDC auth_time. This is not a token refresh or login operation.
54183
+ Catalog refresh only observes the native login revision. Only an explicit
54184
+ provider reload requests handoff for opaque credential stores. Neither
54185
+ operation logs in or rotates native credentials.
54171
54186
  """
54172
54187
  global CODEX_APP_SERVER_MANAGER, CODEX_LOGIN_REVISION
54173
54188
  if CODEX_TRANSPORT == CODEX_TRANSPORT_EXEC or CODEX_AUTH_LOCK.locked() or CODEX_MANAGER_CLOSING:
@@ -54191,7 +54206,12 @@ async def refresh_codex_app_server_login(*, request_handoff: bool = False) -> No
54191
54206
  continue
54192
54207
  previous = getattr(manager, "_agentsdock_login_revision", None)
54193
54208
  changed = revision is not None and revision != previous
54194
- if changed or request_handoff:
54209
+ # Recheck CLIs is observational when the native sign-in
54210
+ # revision is unchanged. Retiring a healthy writer here
54211
+ # strands its chats behind an unnecessary login handoff.
54212
+ if revision is not None and revision == previous:
54213
+ manager._agentsdock_login_superseded = False
54214
+ if changed or (request_handoff and revision is None):
54195
54215
  manager._agentsdock_login_superseded = True
54196
54216
  if not any(manager is item for item in CODEX_RETIRED_APP_SERVER_MANAGERS):
54197
54217
  CODEX_RETIRED_APP_SERVER_MANAGERS.append(manager)
@@ -54200,22 +54220,59 @@ async def refresh_codex_app_server_login(*, request_handoff: bool = False) -> No
54200
54220
  schedule_codex_manager_drain()
54201
54221
 
54202
54222
 
54203
- def codex_manager_session_busy(manager: CodexAppServerManager, session_id: str, *, ignore_task=None, ignore_maintenance=False) -> bool:
54223
+ def codex_manager_session_blocker(manager: CodexAppServerManager, session_id: str, *, ignore_task=None, ignore_maintenance=False) -> str | None:
54204
54224
  session = STORE.sessions.get(session_id) or {}
54205
- return bool(
54206
- session_id in BUSY_SESSIONS or ACTIVE.get(session_id) is not None
54207
- or (not ignore_maintenance and session_id in SERVER_MAINTENANCE_SESSIONS)
54208
- or codex_manager_has_callers(manager, session_id, ignore_task=ignore_task)
54209
- or codex_manager_has_callbacks(manager)
54210
- or any(task is not ignore_task and not task.done()
54211
- for registry in (SESSION_TURN_TASKS, CODEX_NATIVE_ACTION_TASKS, CODEX_INTERACTION_HANDLER_TASKS)
54212
- for task in registry.get(session_id, ()))
54213
- or any(item.get("session_id") == session_id for item in CODEX_PENDING_INTERACTIONS.values())
54214
- or session_id in SIDE_QUESTIONS.active_session_ids()
54215
- or (isinstance(session.get("codex_goal"), dict)
54216
- and session["codex_goal"].get("status") == "active")
54217
- or codex_session_has_live_subagents(session_id)
54218
- )
54225
+ if session_id in BUSY_SESSIONS or ACTIVE.get(session_id) is not None:
54226
+ return "active_turn"
54227
+ if not ignore_maintenance and session_id in SERVER_MAINTENANCE_SESSIONS:
54228
+ return "maintenance"
54229
+ if any(item.get("session_id") == session_id for item in CODEX_PENDING_INTERACTIONS.values()):
54230
+ return "approval"
54231
+ if session_id in SIDE_QUESTIONS.active_session_ids():
54232
+ return "side_chat"
54233
+ if (session.get("codex_goal") or {}).get("status") == "active":
54234
+ return "active_goal"
54235
+ if codex_session_has_live_subagents(session_id):
54236
+ return "subagents"
54237
+ if codex_manager_has_callers(manager, session_id, ignore_task=ignore_task):
54238
+ return "runtime_request"
54239
+ if codex_manager_has_callbacks(manager, session_id):
54240
+ return "callback"
54241
+ if any(task is not ignore_task and not task.done()
54242
+ for registry in (SESSION_TURN_TASKS, CODEX_INTERACTION_HANDLER_TASKS)
54243
+ for task in registry.get(session_id, ())):
54244
+ return "runtime_request"
54245
+ if any(sid == session_id and task is not ignore_task and not task.done()
54246
+ for (sid, _), task in CODEX_NATIVE_ACTION_TASKS.items()):
54247
+ return "runtime_request"
54248
+ return None
54249
+
54250
+
54251
+ def codex_manager_session_busy(manager: CodexAppServerManager, session_id: str, *, ignore_task=None, ignore_maintenance=False) -> bool:
54252
+ return codex_manager_session_blocker(manager, session_id, ignore_task=ignore_task,
54253
+ ignore_maintenance=ignore_maintenance) is not None
54254
+
54255
+
54256
+ def codex_login_handoff_wait(manager: CodexAppServerManager, session_id: str) -> HTTPException:
54257
+ reason = (getattr(manager, "_agentsdock_handoff_reasons", {}).get(session_id)
54258
+ or codex_manager_session_blocker(manager, session_id, ignore_task=asyncio.current_task())
54259
+ or "runtime_request")
54260
+ return TransientAdmissionWait(409, codex_auth.handoff_detail(reason))
54261
+
54262
+
54263
+ def watch_codex_login_handoff_blockers(manager: CodexAppServerManager, session_id: str, *, ignore_task=None) -> None:
54264
+ if not getattr(manager, "_agentsdock_login_superseded", False):
54265
+ return
54266
+ work = set(manager.client._callback_tasks) | set(manager.client._server_request_tasks.values())
54267
+ work.update(value[1] for value in manager.client._pending.values())
54268
+ work.update(task for task, owner in tuple(getattr(manager, "_agentsdock_callers", {}).items())
54269
+ if not owner or owner == session_id)
54270
+ for task in work:
54271
+ if task is ignore_task or task.done() or not codex_manager_work_belongs_to_session(task, session_id):
54272
+ continue
54273
+ if not getattr(task, "_agentsdock_login_handoff_watched", False):
54274
+ task._agentsdock_login_handoff_watched = True
54275
+ task.add_done_callback(lambda _: schedule_codex_manager_drain())
54219
54276
 
54220
54277
 
54221
54278
  def watch_codex_provider_handoff_blockers(
@@ -54269,11 +54326,20 @@ async def release_idle_codex_manager_session(manager: CodexAppServerManager, ses
54269
54326
  # The response Future can finish before its RPC coroutine
54270
54327
  # removes the registry entry. Treat that settled response as
54271
54328
  # complete so its wakeup cannot get lost during unwinding.
54272
- or any(not value[1].done() for value in manager.client._pending.values())
54273
- or any(not task.done() for task in manager.client._server_request_tasks.values()))
54329
+ or any(not value[1].done() and codex_manager_work_belongs_to_session(value[1], session_id)
54330
+ for value in manager.client._pending.values())
54331
+ or any(not task.done() and codex_manager_work_belongs_to_session(task, session_id)
54332
+ for task in manager.client._server_request_tasks.values()))
54274
54333
 
54275
54334
  async def defer(reason: str, *, error_type: str = "") -> bool:
54276
54335
  session = STORE.sessions.get(session_id) or {}
54336
+ reasons = getattr(manager, "_agentsdock_handoff_reasons", None)
54337
+ if reasons is None:
54338
+ reasons = manager._agentsdock_handoff_reasons = {}
54339
+ reasons[session_id] = reason
54340
+ if reason in {"runtime_borrowers", "runtime_borrowers_after_metadata", "runtime_borrowers_after_unsubscribe"}:
54341
+ reasons[session_id] = codex_manager_session_blocker(manager, session_id, ignore_task=ignore_task, ignore_maintenance=True) or "runtime_request"
54342
+ watch_codex_login_handoff_blockers(manager, session_id, ignore_task=ignore_task)
54277
54343
  if isinstance(session.get("_codex_provider_pending"), dict):
54278
54344
  # A failed ownership/goal request can itself emit callbacks.
54279
54345
  # Only verified transient borrowers own a completion retry; an
@@ -54299,6 +54365,7 @@ async def release_idle_codex_manager_session(manager: CodexAppServerManager, ses
54299
54365
  if blocked():
54300
54366
  return await defer("runtime_borrowers")
54301
54367
  deadline = time.monotonic() + 8.0
54368
+ failure_reason = "thread_list_unavailable"
54302
54369
  SERVER_MAINTENANCE_SESSIONS.add(session_id)
54303
54370
  try:
54304
54371
  threads = [thread for thread in tuple(manager.client._loaded_threads)
@@ -54334,6 +54401,7 @@ async def release_idle_codex_manager_session(manager: CodexAppServerManager, ses
54334
54401
  if (thread in CODEX_APP_SERVER_PINNED_THREADS or thread in CODEX_INTERACTIVE_CONTROL_THREADS
54335
54402
  or manager.active_turn(thread) is not None):
54336
54403
  return await defer("native_thread_in_use")
54404
+ failure_reason = "goal_state_unavailable"
54337
54405
  try:
54338
54406
  goal = await asyncio.wait_for(manager.get_thread_goal(thread), timeout=min(3.0, deadline - time.monotonic()))
54339
54407
  except CodexAppServerRequestError as exc:
@@ -54342,6 +54410,7 @@ async def release_idle_codex_manager_session(manager: CodexAppServerManager, ses
54342
54410
  goal = None
54343
54411
  if isinstance(goal, dict) and goal.get("status") == "active":
54344
54412
  return await defer("native_goal_active")
54413
+ failure_reason = "terminal_state_unavailable"
54345
54414
  try:
54346
54415
  terminals = await asyncio.wait_for(manager.list_background_terminals(thread), timeout=min(3.0, deadline - time.monotonic()))
54347
54416
  except CodexAppServerRequestError as exc:
@@ -54354,6 +54423,7 @@ async def release_idle_codex_manager_session(manager: CodexAppServerManager, ses
54354
54423
  return await defer("background_terminals")
54355
54424
  if blocked(own_maintenance=True):
54356
54425
  return await defer("runtime_borrowers_after_metadata")
54426
+ failure_reason = "native_unsubscribe_incomplete"
54357
54427
  if manager.is_thread_loaded(thread):
54358
54428
  evicted = await asyncio.wait_for(evict_codex_app_server_thread(manager, thread, reinsert_on_failure=True),
54359
54429
  timeout=max(0.0, deadline - time.monotonic()))
@@ -54364,14 +54434,16 @@ async def release_idle_codex_manager_session(manager: CodexAppServerManager, ses
54364
54434
  # Unsubscribe removes the client subscription, but native Codex keeps
54365
54435
  # the native writer alive. Release it before another manager can own
54366
54436
  # this chat; unrelated active chats keep their current process.
54437
+ failure_reason = "writer_release_failed"
54367
54438
  await release_codex_provider_writers(manager, session_id, threads)
54439
+ getattr(manager, "_agentsdock_handoff_reasons", {}).pop(session_id, None)
54368
54440
  if CODEX_SESSION_APP_SERVER_MANAGERS.get(session_id) is manager:
54369
54441
  CODEX_SESSION_APP_SERVER_MANAGERS.pop(session_id, None)
54370
54442
  CODEX_GOAL_SYNC_GENERATIONS.pop(session_id, None)
54371
54443
  return True
54372
54444
  except Exception as exc:
54373
54445
  logger.debug("Codex handoff retained an unverified idle owner", exc_info=True)
54374
- return await defer("ownership_unverified", error_type=type(exc).__name__)
54446
+ return await defer(failure_reason, error_type=type(exc).__name__)
54375
54447
  finally:
54376
54448
  SERVER_MAINTENANCE_SESSIONS.discard(session_id)
54377
54449
 
@@ -54397,7 +54469,7 @@ async def prepare_codex_login_turn(session: dict[str, Any]) -> None:
54397
54469
  if session_id in BUSY_SESSIONS or ACTIVE.get(session_id) is not None:
54398
54470
  return
54399
54471
  if not await release_idle_codex_manager_session(manager, session_id, ignore_task=asyncio.current_task()):
54400
- raise TransientAdmissionWait(409, codex_auth.HANDOFF_MESSAGE)
54472
+ raise codex_login_handoff_wait(manager, session_id)
54401
54473
  schedule_codex_manager_drain()
54402
54474
 
54403
54475
 
@@ -54414,6 +54486,11 @@ async def drain_retired_codex_managers() -> None:
54414
54486
  for session_id, owner in tuple(CODEX_SESSION_APP_SERVER_MANAGERS.items()):
54415
54487
  if owner is not manager or codex_manager_session_busy(manager, session_id):
54416
54488
  continue
54489
+ # Failed metadata RPCs can emit status callbacks themselves.
54490
+ # Those callbacks must not trigger an endless self-retry.
54491
+ # A user's next send always retries the proof in preflight.
54492
+ if getattr(manager, "_agentsdock_handoff_reasons", {}).get(session_id) in {"thread_list_unavailable", "goal_state_unavailable", "terminal_state_unavailable", "ownership_deadline"}:
54493
+ continue
54417
54494
  lock = session_lifecycle_lock(session_id)
54418
54495
  if lock.locked():
54419
54496
  continue # Never invert the session -> manager lock order.
@@ -54556,7 +54633,7 @@ async def codex_app_server_manager(sess: dict[str, Any] | None = None, *, allow_
54556
54633
  if (getattr(manager, "_agentsdock_login_superseded", False) is True and not allow_retired_login
54557
54634
  and getattr(manager, "_agentsdock_callers", {}).get(asyncio.current_task()) != session_id):
54558
54635
  schedule_codex_manager_drain()
54559
- raise TransientAdmissionWait(409, codex_auth.HANDOFF_MESSAGE)
54636
+ raise codex_login_handoff_wait(manager, session_id)
54560
54637
  if session_id:
54561
54638
  CODEX_SESSION_APP_SERVER_MANAGERS[session_id] = manager
54562
54639
  retain_codex_manager_caller(manager, session_id)
@@ -62620,10 +62697,9 @@ async def run_claude_print(
62620
62697
  if btype == "text" and block.get("text"):
62621
62698
  text = clean_assistant_text(block["text"])
62622
62699
  if text:
62623
- await append_event(session_id, "reasoning_summary", {
62700
+ await append_event(session_id, "assistant_text", {
62624
62701
  "run_id": run_id,
62625
62702
  "text": text,
62626
- "phase": "commentary",
62627
62703
  "backend": BACKEND_CLAUDE,
62628
62704
  **run_event_metadata(run_id),
62629
62705
  })
@@ -63063,10 +63139,9 @@ async def project_claude_sdk_message(
63063
63139
  )
63064
63140
  if text:
63065
63141
  text_parts.append(text)
63066
- await append_event(session_id, "reasoning_summary", {
63142
+ await append_event(session_id, "assistant_text", {
63067
63143
  "run_id": run_id,
63068
63144
  "text": text,
63069
- "phase": "commentary",
63070
63145
  "backend": BACKEND_CLAUDE,
63071
63146
  **({"provider_message_id": message_uuid}
63072
63147
  if (message_uuid := claude_sdk_field(message, "uuid")) else {}),
@@ -67444,8 +67519,8 @@ async def run_opencode_process(
67444
67519
  - The workspace is explicitly passed as ``--dir`` as well as cwd.
67445
67520
  - There is no init event. Every event already carries the sessionID, so
67446
67521
  the session is considered started at the first event of any kind.
67447
- - The turn ends at a ``step_finish`` whose reason is not ``tool-calls``;
67448
- there is no separate terminal result event.
67522
+ - An explicit completion ``step_finish`` ends the turn; ``tool-calls``
67523
+ continues it and invalid/unknown finishes fail closed.
67449
67524
  - Usage is reported per step and must be accumulated, not overwritten.
67450
67525
  """
67451
67526
  from opencode_agent_client import (
@@ -68354,10 +68429,24 @@ async def run_opencode_process(
68354
68429
  opencode_usage,
68355
68430
  normalized.get("usage") or {},
68356
68431
  )
68357
- # A turn is a sequence of steps. "tool-calls" means another
68358
- # step follows; anything else ends the turn, and there is no
68359
- # separate terminal result event to wait for.
68360
- if str(normalized.get("reason") or "") != "tool-calls":
68432
+ # Only explicit completion reasons end a turn. In particular,
68433
+ # HTML-200/empty streams can yield "unknown" while the CLI
68434
+ # immediately requests another step; waiting for exit lets it
68435
+ # loop and hides the invalid response behind a cleanup error.
68436
+ finish_reason = str(normalized.get("reason") or "")
68437
+ if finish_reason not in {"tool-calls", "stop", "length", "content-filter"}:
68438
+ stream_error = (
68439
+ "OpenCode did not receive a valid completion from the "
68440
+ "model API. Check the endpoint URL, selected protocol "
68441
+ "and streaming support. The run was stopped to prevent "
68442
+ "repeated requests."
68443
+ )
68444
+ await terminate_process_tree(
68445
+ proc,
68446
+ grace=OPENCODE_GUARD_TEARDOWN_GRACE_SECONDS,
68447
+ )
68448
+ break
68449
+ if finish_reason != "tool-calls":
68361
68450
  await flush_opencode_reasoning()
68362
68451
  terminal_event_seen = True
68363
68452
  try:
@@ -73472,7 +73561,7 @@ def scheduled_job_lifecycle_admission_defer_reason(
73472
73561
  """
73473
73562
 
73474
73563
  if isinstance(error, TransientAdmissionWait):
73475
- return str(error.detail or "").strip() or "turn admission deferred"
73564
+ return str(error.detail.get("message", "") if isinstance(error.detail, dict) else error.detail or "").strip() or "turn admission deferred"
73476
73565
  if not isinstance(error, HTTPException) or error.status_code != 503:
73477
73566
  return None
73478
73567
  reason = str(error.detail or "").strip()
@@ -73610,22 +73699,14 @@ def managed_server_update_scheduled_job_blocker(
73610
73699
  *,
73611
73700
  manual: bool = False,
73612
73701
  ) -> str | None:
73613
- """Fence autonomous job admission while an idle update is reserved.
73614
-
73615
- A pending reservation deliberately stays invisible to ordinary user turns,
73616
- Force Send, provider controls, and manual Run Now. Automatic job
73617
- dispatches are durable and retryable, so deferring only that autonomous
73618
- admission path prevents recurring jobs from starving the updater without
73619
- reviving the global pending fence that locked operators out on
73620
- 2026-09-04.
73702
+ """Apply the same update admission policy to scheduled and manual work.
73703
+
73704
+ Waiting for idle must not manufacture idle by suspending recurring jobs.
73705
+ Only an actual replacement/restart (or an explicitly confirmed force
73706
+ update) fences new work, just as it does for ordinary user turns.
73621
73707
  """
73622
73708
 
73623
- blocker = managed_server_update_blocker()
73624
- if blocker:
73625
- return blocker
73626
- if not manual and managed_server_update_is_pending():
73627
- return MANAGED_SERVER_UPDATE_PENDING_DETAIL
73628
- return None
73709
+ return managed_server_update_admission_blocker()
73629
73710
 
73630
73711
 
73631
73712
  def live_unsafe_http_mutation_ids_locked() -> list[str]:
@@ -77212,13 +77293,12 @@ async def lifespan(app: FastAPI):
77212
77293
  startup_update_status = read_server_update_status()
77213
77294
  active_update_schedule_id = (
77214
77295
  str(startup_update_status.get("schedule_id") or "").strip()
77215
- if managed_server_update_is_pending(startup_update_status)
77296
+ if managed_server_force_update_is_pending(startup_update_status)
77216
77297
  else None
77217
77298
  )
77218
- # A completed/failed update, an offline cancellation, or replacement by a
77219
- # new reservation may leave exact automatic occurrences parked in the
77220
- # durable job registry. Rearm untouched revisions except those owned by
77221
- # the one reservation that is still live.
77299
+ # Older releases parked automatic jobs while merely waiting for idle.
77300
+ # Restore those unchanged occurrences even if that reservation still exists.
77301
+ # Preserve only an explicitly confirmed force update's admission fence.
77222
77302
  await JOBS.resume_update_parked(
77223
77303
  active_schedule_id=active_update_schedule_id,
77224
77304
  )
@@ -80644,14 +80724,21 @@ async def codex_provider_mcp(request: Request) -> Response:
80644
80724
  client_meta = turn_meta
80645
80725
  run_id = str(client_meta.get("agentsdock_run_id") or "")
80646
80726
  proof = str(client_meta.get("agentsdock_run_proof") or "")
80727
+ # Codex's own goal turns need not inherit turn/start client metadata.
80728
+ # Their authenticated native thread/turn identity still has an exact
80729
+ # server-owned supervisor. Resolve that owner, never a caller's chat ID
80730
+ # or a previously completed run. Partially supplied proofs stay invalid.
80731
+ native_goal_metadata = not run_id and not proof
80647
80732
  core_call_id = str(meta.get("callId") or "")
80648
80733
  if (
80649
80734
  not thread_id
80650
80735
  or len(thread_id) > 256
80651
80736
  or not turn_id
80652
80737
  or len(turn_id) > 256
80653
- or re.fullmatch(r"run_[A-Za-z0-9_-]{1,128}", run_id) is None
80654
- or re.fullmatch(r"[0-9a-f]{64}", proof) is None
80738
+ or (not native_goal_metadata and (
80739
+ re.fullmatch(r"run_[A-Za-z0-9_-]{1,128}", run_id) is None
80740
+ or re.fullmatch(r"[0-9a-f]{64}", proof) is None
80741
+ ))
80655
80742
  or not core_call_id
80656
80743
  ):
80657
80744
  return codex_provider_mcp_jsonrpc_error(request_id, -32602, "Incomplete turn metadata")
@@ -80666,11 +80753,18 @@ async def codex_provider_mcp(request: Request) -> Response:
80666
80753
  active.get("backend") == BACKEND_CODEX
80667
80754
  and active.get("transport") == CODEX_TRANSPORT_APP_SERVER
80668
80755
  and str(active.get("provider_thread_id") or "") == thread_id
80669
- and str(active.get("run_id") or "") == run_id
80756
+ and (
80757
+ codex_native_mailbox_owner_matches(
80758
+ session_id, str(active.get("run_id") or ""), thread_id, turn_id,
80759
+ ) if native_goal_metadata
80760
+ else str(active.get("run_id") or "") == run_id
80761
+ )
80670
80762
  and session_id in BUSY_SESSIONS
80671
80763
  )
80672
80764
  ]
80673
80765
  if len(owners) == 1:
80766
+ if native_goal_metadata:
80767
+ run_id = str(ACTIVE[owners[0]].get("run_id") or "")
80674
80768
  standalone_active_owner = bool(
80675
80769
  (ACTIVE.get(owners[0]) or {}).get(
80676
80770
  "standalone_provider_context"
@@ -80693,7 +80787,7 @@ async def codex_provider_mcp(request: Request) -> Response:
80693
80787
  ):
80694
80788
  return codex_provider_mcp_jsonrpc_error(request_id, -32602, "Ambiguous thread owner")
80695
80789
  expected_proof = codex_provider_mcp_run_proof(session_id, thread_id, run_id)
80696
- if not hmac.compare_digest(proof, expected_proof):
80790
+ if not native_goal_metadata and not hmac.compare_digest(proof, expected_proof):
80697
80791
  return codex_provider_mcp_jsonrpc_error(request_id, -32602, "Invalid turn proof")
80698
80792
  try:
80699
80793
  text, is_error = await execute_provider_tool_once(
@@ -82185,15 +82279,16 @@ async def release_codex_provider_writers(manager: CodexAppServerManager, session
82185
82279
  try:
82186
82280
  await manager.request("thread/unarchive", {"threadId": thread_id})
82187
82281
  except CodexAppServerRequestError:
82188
- if thread_id == parent_id:
82189
- raise
82282
+ # A lost restore response can leave the parent marker after the
82283
+ # native rollout is already restored. Recover it idempotently too.
82190
82284
  # Older versions archived the parent, then tried to archive each
82191
82285
  # already-archived child. Their finally block restored the child
82192
82286
  # but left its marker. Require native relationship, terminal turn
82193
82287
  # and existing unarchived rollout proof before clearing it.
82194
- descendants = await manager.list_descendant_threads(parent_id)
82195
- if not any(isinstance(child, dict) and child.get("id") == thread_id for child in descendants):
82196
- raise
82288
+ if thread_id != parent_id:
82289
+ descendants = await manager.list_descendant_threads(parent_id)
82290
+ if not any(isinstance(child, dict) and child.get("id") == thread_id for child in descendants):
82291
+ raise
82197
82292
  child = await manager.read_thread(thread_id)
82198
82293
  turns = await manager.list_turns(thread_id, limit=1, items_view="summary", sort_direction="desc")
82199
82294
  path = Path(str(child.get("path") or ""))
@@ -84198,7 +84293,7 @@ async def server_update_status(
84198
84293
  # second update attempt before reconnecting.
84199
84294
  public_status = public_server_update_status(status)
84200
84295
  await TERMINAL_ATTACHMENTS.reopen_if_update_inactive(public_status)
84201
- if managed_server_update_is_pending(status):
84296
+ if managed_server_force_update_is_pending(status):
84202
84297
  await JOBS.resume_update_parked(
84203
84298
  active_schedule_id=str(status.get("schedule_id") or "").strip(),
84204
84299
  )
@@ -84756,7 +84851,14 @@ async def _start_server_update(
84756
84851
  active_session_ids = (
84757
84852
  server_update_active_session_ids_locked()
84758
84853
  )
84759
- queued_turn_count = update_blocking_queued_turn_count_locked()
84854
+ # An idle update waits behind queued work too; persistence
84855
+ # across restart is not permission to jump ahead of it.
84856
+ queued_turn_count = (
84857
+ sum(len(queue) for queue in QUEUED_TURNS.values())
84858
+ + len(RUN_NOW_TURNS)
84859
+ if body.when_idle and not managed_server_force_update_is_pending(status)
84860
+ else update_blocking_queued_turn_count_locked()
84861
+ )
84760
84862
  mutation_count = unsafe_http_mutation_count_locked()
84761
84863
  duplicate_provider_labels = {
84762
84864
  *(f"active chat {session_id}" for session_id in BUSY_SESSIONS),
@@ -85685,7 +85787,7 @@ async def host_diagnostics(limit: int = 40) -> dict[str, Any]:
85685
85787
  async def runtime_catalog(refresh: bool = False) -> dict[str, Any]:
85686
85788
  if refresh:
85687
85789
  await refresh_codex_app_server_binary(force=True)
85688
- await refresh_codex_app_server_login(request_handoff=True)
85790
+ await refresh_codex_app_server_login()
85689
85791
  return await asyncio.to_thread(discover_runtime_catalog, force_runtime_probe=refresh)
85690
85792
 
85691
85793
 
@@ -86109,7 +86211,9 @@ async def get_session_subagents(
86109
86211
  session = STORE.sessions[session_id]
86110
86212
  if str(session.get("backend") or "") == BACKEND_CODEX and session_provider_id(session):
86111
86213
  try:
86112
- manager = await codex_app_server_manager(session)
86214
+ # Inspect the process which still owns the children during login
86215
+ # handoff. This read must not be rejected as a new model turn.
86216
+ manager = await codex_app_server_manager(session, allow_retired_login=True)
86113
86217
  await reconcile_codex_subagents(session_id, manager)
86114
86218
  except Exception as exc:
86115
86219
  logger.warning(
@@ -86120,6 +86224,29 @@ async def get_session_subagents(
86120
86224
  return await asyncio.to_thread(build_subagent_snapshot, session_id, limit)
86121
86225
 
86122
86226
 
86227
+ def find_timeline_event_anchor(session_id: str, event_id: str) -> dict[str, Any] | None:
86228
+ """Resolve a persisted pin by identity, independent of FTS text/index lag."""
86229
+ for event in iter_session_events(session_id):
86230
+ if event.get("id") != event_id:
86231
+ continue
86232
+ if str(event.get("session_id") or session_id) != session_id:
86233
+ return None
86234
+ seq = event.get("seq")
86235
+ if type(seq) is not int or seq <= 0:
86236
+ return None
86237
+ return {"session_id": session_id, "event_id": event_id, "seq": seq,
86238
+ "ts": event.get("ts", ""), "role": "system", "snippet": ""}
86239
+ return None
86240
+
86241
+
86242
+ @app.get("/api/sessions/{session_id}/timeline-event/{event_id}")
86243
+ async def get_timeline_event_anchor(session_id: str, event_id: str) -> dict[str, Any]:
86244
+ if session_id not in STORE.sessions:
86245
+ raise HTTPException(status_code=404, detail="session not found")
86246
+ anchor = await asyncio.to_thread(find_timeline_event_anchor, session_id, event_id)
86247
+ return {"event": anchor}
86248
+
86249
+
86123
86250
  @app.get("/api/sessions/{session_id}/search")
86124
86251
  async def search_session_timeline(
86125
86252
  session_id: str,
@@ -87251,6 +87378,8 @@ async def reload_session_provider(session_id: str) -> dict[str, Any]:
87251
87378
  ),
87252
87379
  )
87253
87380
  await release_codex_provider_writers(manager, session_id, [provider_id])
87381
+ if codex_provider.session_choice(session.get("codex_provider")) != "custom":
87382
+ await refresh_codex_app_server_login(request_handoff=True)
87254
87383
  runtime = await codex_runtime_snapshot(session_id)
87255
87384
 
87256
87385
  latest_session = STORE.sessions.get(session_id) or session
@@ -88560,7 +88689,7 @@ async def _put_codex_goal_locked(
88560
88689
  blocker = await turn_start_blocker(ignore_session_id=session_id)
88561
88690
  if blocker:
88562
88691
  raise HTTPException(status_code=503, detail=f"agent launch deferred: {blocker}")
88563
- operation_id = f"codexgoal_{uuid.uuid4().hex[:16]}"
88692
+ operation_id = f"run_goal_{uuid.uuid4().hex[:16]}"
88564
88693
  subscription = manager.subscribe_thread(thread_id)
88565
88694
  provider_model, provider_effort, provider_service_tier = codex_runtime_settings(stored_session)
88566
88695
  async with ACTIVE_LOCK:
@@ -88577,6 +88706,21 @@ async def _put_codex_goal_locked(
88577
88706
  if current_turn is not None:
88578
88707
  current_turn["run_id"] = operation_id
88579
88708
  current_turn["purpose"] = "codex_goal_resume"
88709
+ # goal/set starts native work without passing through turn/start.
88710
+ # Issue this new logical run's helper authority before native work
88711
+ # can call Publish/Jobs/Mail. Reuse only existing durable routes;
88712
+ # resuming a goal must not mint grants from its text or history.
88713
+ resume_routes = provider_cross_chat_route_snapshot_for_authority(
88714
+ provider_cross_chat_routes(stored_session), [], source_session_id=session_id,
88715
+ )
88716
+ authority_path = await issue_cross_chat_capability(
88717
+ session_id, operation_id, [],
88718
+ actions={"publish", "jobs", "emergency", "agent_cross_chat_routes"},
88719
+ provider_route_snapshot=resume_routes,
88720
+ async_route_v1=True,
88721
+ )
88722
+ if authority_path is None:
88723
+ raise ProviderToolError("Could not prepare the resumed goal's tools")
88580
88724
  await append_event(session_id, "turn_started", {
88581
88725
  "run_id": operation_id,
88582
88726
  "backend": BACKEND_CODEX,
@@ -88701,6 +88845,8 @@ async def _put_codex_goal_locked(
88701
88845
  finally:
88702
88846
  if not consumer_owns_reservation:
88703
88847
  try:
88848
+ if operation_id:
88849
+ await revoke_cross_chat_capability(operation_id)
88704
88850
  if goal_activation_attempted:
88705
88851
  await stop_codex_goal_resume(
88706
88852
  session_id, manager, thread_id, reservation_id,
@@ -95908,7 +96054,12 @@ async def active_artifact_publication_run(
95908
96054
  or not isinstance(active, dict)
95909
96055
  or not run_id
95910
96056
  or str(active.get("run_id") or "").strip() != run_id
95911
- or active.get("codex_native_operation") is True
96057
+ or (active.get("codex_native_operation") is True
96058
+ and not codex_native_mailbox_owner_matches(
96059
+ session_id, run_id,
96060
+ str(active.get("provider_thread_id") or ""),
96061
+ str(active.get("provider_turn_id") or ""),
96062
+ ))
95912
96063
  or active.get("stop_requested") is True
95913
96064
  or run_id in STOPPED_RUNS
95914
96065
  ):