@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.
- package/README.md +6 -0
- package/npm/cli.cjs +29 -3
- package/package.json +1 -1
- package/server/VERSION +1 -1
- package/server/agent_server.py +230 -79
- package/server/claude_goals.py +16 -4
- package/server/codex_app_server.py +4 -0
- package/server/codex_auth.py +31 -0
- package/server/codex_history_repair.py +92 -32
- package/server/codex_provider.py +12 -2
- package/server/execution_activation.py +1 -1
- package/server/execution_control.py +4 -0
- package/server/execution_install.py +48 -7
- package/server/execution_manage.py +304 -0
- package/server/execution_recovery.py +1 -1
- package/server/execution_uninstall.py +19 -0
- package/server/execution_update_status.py +3 -10
- package/server/opencode_agent_client.py +5 -0
- package/server/provider_connections.py +21 -11
- package/server/server_instances.py +211 -4
- package/server/update_handoff.py +6 -11
package/server/agent_server.py
CHANGED
|
@@ -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: {
|
|
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"),
|
|
46816
|
-
sess.get("
|
|
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
|
|
54115
|
-
|
|
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
|
-
|
|
54169
|
-
|
|
54170
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
54206
|
-
|
|
54207
|
-
|
|
54208
|
-
|
|
54209
|
-
|
|
54210
|
-
|
|
54211
|
-
|
|
54212
|
-
|
|
54213
|
-
|
|
54214
|
-
|
|
54215
|
-
|
|
54216
|
-
|
|
54217
|
-
|
|
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()
|
|
54273
|
-
|
|
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(
|
|
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
|
|
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
|
|
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, "
|
|
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, "
|
|
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
|
-
-
|
|
67448
|
-
|
|
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
|
-
#
|
|
68358
|
-
#
|
|
68359
|
-
#
|
|
68360
|
-
|
|
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
|
-
"""
|
|
73614
|
-
|
|
73615
|
-
|
|
73616
|
-
|
|
73617
|
-
|
|
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
|
-
|
|
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
|
|
77296
|
+
if managed_server_force_update_is_pending(startup_update_status)
|
|
77216
77297
|
else None
|
|
77217
77298
|
)
|
|
77218
|
-
#
|
|
77219
|
-
#
|
|
77220
|
-
#
|
|
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
|
|
80654
|
-
|
|
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
|
|
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
|
-
|
|
82189
|
-
|
|
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
|
-
|
|
82195
|
-
|
|
82196
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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"
|
|
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
|
):
|