@agentsdock/server 1.0.10-beta.1 → 1.0.10-beta.4
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/npm/cli.cjs +6 -2
- package/package.json +1 -1
- package/server/VERSION +1 -1
- package/server/agent_server.py +210 -73
- 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/server_instances.py +166 -2
package/npm/cli.cjs
CHANGED
|
@@ -66,7 +66,8 @@ function ensureFreshInstall(context) {
|
|
|
66
66
|
if (context.uid === 0) throw new Error('Run as the user who will own the server, without sudo.')
|
|
67
67
|
if (!['darwin', 'linux'].includes(context.platform)) throw new Error('Server installation requires Linux or Apple silicon macOS.')
|
|
68
68
|
for (const name of ROOT_SELECTORS) if (context.env[name]) throw new Error(`Custom installation selector ${name} is unsupported by the fresh-install CLI. Use the existing server's managed updater.`)
|
|
69
|
-
const existingInstall = (
|
|
69
|
+
const existingInstall = (message = 'An existing server installation or state was found. Use AgentsDock or agentsdock-server update; no installer was started.') =>
|
|
70
|
+
Object.assign(new Error(message), { code: 'AGENTSDOCK_EXISTING_INSTALLATION' })
|
|
70
71
|
const safeDirectory = info => info.isDirectory() && !info.isSymbolicLink() && info.uid === context.uid && (info.mode & 0o022) === 0 && (info.mode & 0o500) === 0o500
|
|
71
72
|
const allowEmptyScaffold = (relative, child) => {
|
|
72
73
|
const root = path.join(context.home, relative)
|
|
@@ -100,11 +101,14 @@ function ensureFreshInstall(context) {
|
|
|
100
101
|
}
|
|
101
102
|
if (context.platform === 'darwin') {
|
|
102
103
|
const check = context.spawn('/bin/launchctl', ['print', `gui/${context.uid}/com.agentsdock.server`], { encoding: 'utf8', timeout: 10000 })
|
|
103
|
-
if (check.status === 0) throw
|
|
104
|
+
if (check.status === 0) throw existingInstall('An AgentsServer service already exists. Use its managed updater.')
|
|
104
105
|
if (check.error || !/could not find service|service.*not found/i.test(`${check.stdout || ''} ${check.stderr || ''}`)) throw new Error('Could not verify the user service is absent. No installer was started.')
|
|
105
106
|
} else {
|
|
106
107
|
for (const unit of ['agents-server.service', 'zenithbot-agent.service']) {
|
|
107
108
|
const check = context.spawn('systemctl', ['--user', 'show', unit, '--property=LoadState', '--value'], { encoding: 'utf8', timeout: 10000 })
|
|
109
|
+
if (!check.error && check.status === 0 && String(check.stdout).trim() && String(check.stdout).trim() !== 'not-found') {
|
|
110
|
+
throw existingInstall('An existing user service was found. Use its managed updater.')
|
|
111
|
+
}
|
|
108
112
|
if (check.error || String(check.stdout).trim() !== 'not-found') throw new Error('An existing user service was found or its absence could not be verified. Use its managed updater.')
|
|
109
113
|
}
|
|
110
114
|
}
|
package/package.json
CHANGED
package/server/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.10-beta.
|
|
1
|
+
1.0.10-beta.4
|
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 {}),
|
|
@@ -73472,7 +73547,7 @@ def scheduled_job_lifecycle_admission_defer_reason(
|
|
|
73472
73547
|
"""
|
|
73473
73548
|
|
|
73474
73549
|
if isinstance(error, TransientAdmissionWait):
|
|
73475
|
-
return str(error.detail or "").strip() or "turn admission deferred"
|
|
73550
|
+
return str(error.detail.get("message", "") if isinstance(error.detail, dict) else error.detail or "").strip() or "turn admission deferred"
|
|
73476
73551
|
if not isinstance(error, HTTPException) or error.status_code != 503:
|
|
73477
73552
|
return None
|
|
73478
73553
|
reason = str(error.detail or "").strip()
|
|
@@ -73610,22 +73685,14 @@ def managed_server_update_scheduled_job_blocker(
|
|
|
73610
73685
|
*,
|
|
73611
73686
|
manual: bool = False,
|
|
73612
73687
|
) -> 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.
|
|
73688
|
+
"""Apply the same update admission policy to scheduled and manual work.
|
|
73689
|
+
|
|
73690
|
+
Waiting for idle must not manufacture idle by suspending recurring jobs.
|
|
73691
|
+
Only an actual replacement/restart (or an explicitly confirmed force
|
|
73692
|
+
update) fences new work, just as it does for ordinary user turns.
|
|
73621
73693
|
"""
|
|
73622
73694
|
|
|
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
|
|
73695
|
+
return managed_server_update_admission_blocker()
|
|
73629
73696
|
|
|
73630
73697
|
|
|
73631
73698
|
def live_unsafe_http_mutation_ids_locked() -> list[str]:
|
|
@@ -77212,13 +77279,12 @@ async def lifespan(app: FastAPI):
|
|
|
77212
77279
|
startup_update_status = read_server_update_status()
|
|
77213
77280
|
active_update_schedule_id = (
|
|
77214
77281
|
str(startup_update_status.get("schedule_id") or "").strip()
|
|
77215
|
-
if
|
|
77282
|
+
if managed_server_force_update_is_pending(startup_update_status)
|
|
77216
77283
|
else None
|
|
77217
77284
|
)
|
|
77218
|
-
#
|
|
77219
|
-
#
|
|
77220
|
-
#
|
|
77221
|
-
# the one reservation that is still live.
|
|
77285
|
+
# Older releases parked automatic jobs while merely waiting for idle.
|
|
77286
|
+
# Restore those unchanged occurrences even if that reservation still exists.
|
|
77287
|
+
# Preserve only an explicitly confirmed force update's admission fence.
|
|
77222
77288
|
await JOBS.resume_update_parked(
|
|
77223
77289
|
active_schedule_id=active_update_schedule_id,
|
|
77224
77290
|
)
|
|
@@ -80644,14 +80710,21 @@ async def codex_provider_mcp(request: Request) -> Response:
|
|
|
80644
80710
|
client_meta = turn_meta
|
|
80645
80711
|
run_id = str(client_meta.get("agentsdock_run_id") or "")
|
|
80646
80712
|
proof = str(client_meta.get("agentsdock_run_proof") or "")
|
|
80713
|
+
# Codex's own goal turns need not inherit turn/start client metadata.
|
|
80714
|
+
# Their authenticated native thread/turn identity still has an exact
|
|
80715
|
+
# server-owned supervisor. Resolve that owner, never a caller's chat ID
|
|
80716
|
+
# or a previously completed run. Partially supplied proofs stay invalid.
|
|
80717
|
+
native_goal_metadata = not run_id and not proof
|
|
80647
80718
|
core_call_id = str(meta.get("callId") or "")
|
|
80648
80719
|
if (
|
|
80649
80720
|
not thread_id
|
|
80650
80721
|
or len(thread_id) > 256
|
|
80651
80722
|
or not turn_id
|
|
80652
80723
|
or len(turn_id) > 256
|
|
80653
|
-
or
|
|
80654
|
-
|
|
80724
|
+
or (not native_goal_metadata and (
|
|
80725
|
+
re.fullmatch(r"run_[A-Za-z0-9_-]{1,128}", run_id) is None
|
|
80726
|
+
or re.fullmatch(r"[0-9a-f]{64}", proof) is None
|
|
80727
|
+
))
|
|
80655
80728
|
or not core_call_id
|
|
80656
80729
|
):
|
|
80657
80730
|
return codex_provider_mcp_jsonrpc_error(request_id, -32602, "Incomplete turn metadata")
|
|
@@ -80666,11 +80739,18 @@ async def codex_provider_mcp(request: Request) -> Response:
|
|
|
80666
80739
|
active.get("backend") == BACKEND_CODEX
|
|
80667
80740
|
and active.get("transport") == CODEX_TRANSPORT_APP_SERVER
|
|
80668
80741
|
and str(active.get("provider_thread_id") or "") == thread_id
|
|
80669
|
-
and
|
|
80742
|
+
and (
|
|
80743
|
+
codex_native_mailbox_owner_matches(
|
|
80744
|
+
session_id, str(active.get("run_id") or ""), thread_id, turn_id,
|
|
80745
|
+
) if native_goal_metadata
|
|
80746
|
+
else str(active.get("run_id") or "") == run_id
|
|
80747
|
+
)
|
|
80670
80748
|
and session_id in BUSY_SESSIONS
|
|
80671
80749
|
)
|
|
80672
80750
|
]
|
|
80673
80751
|
if len(owners) == 1:
|
|
80752
|
+
if native_goal_metadata:
|
|
80753
|
+
run_id = str(ACTIVE[owners[0]].get("run_id") or "")
|
|
80674
80754
|
standalone_active_owner = bool(
|
|
80675
80755
|
(ACTIVE.get(owners[0]) or {}).get(
|
|
80676
80756
|
"standalone_provider_context"
|
|
@@ -80693,7 +80773,7 @@ async def codex_provider_mcp(request: Request) -> Response:
|
|
|
80693
80773
|
):
|
|
80694
80774
|
return codex_provider_mcp_jsonrpc_error(request_id, -32602, "Ambiguous thread owner")
|
|
80695
80775
|
expected_proof = codex_provider_mcp_run_proof(session_id, thread_id, run_id)
|
|
80696
|
-
if not hmac.compare_digest(proof, expected_proof):
|
|
80776
|
+
if not native_goal_metadata and not hmac.compare_digest(proof, expected_proof):
|
|
80697
80777
|
return codex_provider_mcp_jsonrpc_error(request_id, -32602, "Invalid turn proof")
|
|
80698
80778
|
try:
|
|
80699
80779
|
text, is_error = await execute_provider_tool_once(
|
|
@@ -82185,15 +82265,16 @@ async def release_codex_provider_writers(manager: CodexAppServerManager, session
|
|
|
82185
82265
|
try:
|
|
82186
82266
|
await manager.request("thread/unarchive", {"threadId": thread_id})
|
|
82187
82267
|
except CodexAppServerRequestError:
|
|
82188
|
-
|
|
82189
|
-
|
|
82268
|
+
# A lost restore response can leave the parent marker after the
|
|
82269
|
+
# native rollout is already restored. Recover it idempotently too.
|
|
82190
82270
|
# Older versions archived the parent, then tried to archive each
|
|
82191
82271
|
# already-archived child. Their finally block restored the child
|
|
82192
82272
|
# but left its marker. Require native relationship, terminal turn
|
|
82193
82273
|
# and existing unarchived rollout proof before clearing it.
|
|
82194
|
-
|
|
82195
|
-
|
|
82196
|
-
|
|
82274
|
+
if thread_id != parent_id:
|
|
82275
|
+
descendants = await manager.list_descendant_threads(parent_id)
|
|
82276
|
+
if not any(isinstance(child, dict) and child.get("id") == thread_id for child in descendants):
|
|
82277
|
+
raise
|
|
82197
82278
|
child = await manager.read_thread(thread_id)
|
|
82198
82279
|
turns = await manager.list_turns(thread_id, limit=1, items_view="summary", sort_direction="desc")
|
|
82199
82280
|
path = Path(str(child.get("path") or ""))
|
|
@@ -84198,7 +84279,7 @@ async def server_update_status(
|
|
|
84198
84279
|
# second update attempt before reconnecting.
|
|
84199
84280
|
public_status = public_server_update_status(status)
|
|
84200
84281
|
await TERMINAL_ATTACHMENTS.reopen_if_update_inactive(public_status)
|
|
84201
|
-
if
|
|
84282
|
+
if managed_server_force_update_is_pending(status):
|
|
84202
84283
|
await JOBS.resume_update_parked(
|
|
84203
84284
|
active_schedule_id=str(status.get("schedule_id") or "").strip(),
|
|
84204
84285
|
)
|
|
@@ -84756,7 +84837,14 @@ async def _start_server_update(
|
|
|
84756
84837
|
active_session_ids = (
|
|
84757
84838
|
server_update_active_session_ids_locked()
|
|
84758
84839
|
)
|
|
84759
|
-
|
|
84840
|
+
# An idle update waits behind queued work too; persistence
|
|
84841
|
+
# across restart is not permission to jump ahead of it.
|
|
84842
|
+
queued_turn_count = (
|
|
84843
|
+
sum(len(queue) for queue in QUEUED_TURNS.values())
|
|
84844
|
+
+ len(RUN_NOW_TURNS)
|
|
84845
|
+
if body.when_idle and not managed_server_force_update_is_pending(status)
|
|
84846
|
+
else update_blocking_queued_turn_count_locked()
|
|
84847
|
+
)
|
|
84760
84848
|
mutation_count = unsafe_http_mutation_count_locked()
|
|
84761
84849
|
duplicate_provider_labels = {
|
|
84762
84850
|
*(f"active chat {session_id}" for session_id in BUSY_SESSIONS),
|
|
@@ -85685,7 +85773,7 @@ async def host_diagnostics(limit: int = 40) -> dict[str, Any]:
|
|
|
85685
85773
|
async def runtime_catalog(refresh: bool = False) -> dict[str, Any]:
|
|
85686
85774
|
if refresh:
|
|
85687
85775
|
await refresh_codex_app_server_binary(force=True)
|
|
85688
|
-
await refresh_codex_app_server_login(
|
|
85776
|
+
await refresh_codex_app_server_login()
|
|
85689
85777
|
return await asyncio.to_thread(discover_runtime_catalog, force_runtime_probe=refresh)
|
|
85690
85778
|
|
|
85691
85779
|
|
|
@@ -86109,7 +86197,9 @@ async def get_session_subagents(
|
|
|
86109
86197
|
session = STORE.sessions[session_id]
|
|
86110
86198
|
if str(session.get("backend") or "") == BACKEND_CODEX and session_provider_id(session):
|
|
86111
86199
|
try:
|
|
86112
|
-
|
|
86200
|
+
# Inspect the process which still owns the children during login
|
|
86201
|
+
# handoff. This read must not be rejected as a new model turn.
|
|
86202
|
+
manager = await codex_app_server_manager(session, allow_retired_login=True)
|
|
86113
86203
|
await reconcile_codex_subagents(session_id, manager)
|
|
86114
86204
|
except Exception as exc:
|
|
86115
86205
|
logger.warning(
|
|
@@ -86120,6 +86210,29 @@ async def get_session_subagents(
|
|
|
86120
86210
|
return await asyncio.to_thread(build_subagent_snapshot, session_id, limit)
|
|
86121
86211
|
|
|
86122
86212
|
|
|
86213
|
+
def find_timeline_event_anchor(session_id: str, event_id: str) -> dict[str, Any] | None:
|
|
86214
|
+
"""Resolve a persisted pin by identity, independent of FTS text/index lag."""
|
|
86215
|
+
for event in iter_session_events(session_id):
|
|
86216
|
+
if event.get("id") != event_id:
|
|
86217
|
+
continue
|
|
86218
|
+
if str(event.get("session_id") or session_id) != session_id:
|
|
86219
|
+
return None
|
|
86220
|
+
seq = event.get("seq")
|
|
86221
|
+
if type(seq) is not int or seq <= 0:
|
|
86222
|
+
return None
|
|
86223
|
+
return {"session_id": session_id, "event_id": event_id, "seq": seq,
|
|
86224
|
+
"ts": event.get("ts", ""), "role": "system", "snippet": ""}
|
|
86225
|
+
return None
|
|
86226
|
+
|
|
86227
|
+
|
|
86228
|
+
@app.get("/api/sessions/{session_id}/timeline-event/{event_id}")
|
|
86229
|
+
async def get_timeline_event_anchor(session_id: str, event_id: str) -> dict[str, Any]:
|
|
86230
|
+
if session_id not in STORE.sessions:
|
|
86231
|
+
raise HTTPException(status_code=404, detail="session not found")
|
|
86232
|
+
anchor = await asyncio.to_thread(find_timeline_event_anchor, session_id, event_id)
|
|
86233
|
+
return {"event": anchor}
|
|
86234
|
+
|
|
86235
|
+
|
|
86123
86236
|
@app.get("/api/sessions/{session_id}/search")
|
|
86124
86237
|
async def search_session_timeline(
|
|
86125
86238
|
session_id: str,
|
|
@@ -87251,6 +87364,8 @@ async def reload_session_provider(session_id: str) -> dict[str, Any]:
|
|
|
87251
87364
|
),
|
|
87252
87365
|
)
|
|
87253
87366
|
await release_codex_provider_writers(manager, session_id, [provider_id])
|
|
87367
|
+
if codex_provider.session_choice(session.get("codex_provider")) != "custom":
|
|
87368
|
+
await refresh_codex_app_server_login(request_handoff=True)
|
|
87254
87369
|
runtime = await codex_runtime_snapshot(session_id)
|
|
87255
87370
|
|
|
87256
87371
|
latest_session = STORE.sessions.get(session_id) or session
|
|
@@ -88560,7 +88675,7 @@ async def _put_codex_goal_locked(
|
|
|
88560
88675
|
blocker = await turn_start_blocker(ignore_session_id=session_id)
|
|
88561
88676
|
if blocker:
|
|
88562
88677
|
raise HTTPException(status_code=503, detail=f"agent launch deferred: {blocker}")
|
|
88563
|
-
operation_id = f"
|
|
88678
|
+
operation_id = f"run_goal_{uuid.uuid4().hex[:16]}"
|
|
88564
88679
|
subscription = manager.subscribe_thread(thread_id)
|
|
88565
88680
|
provider_model, provider_effort, provider_service_tier = codex_runtime_settings(stored_session)
|
|
88566
88681
|
async with ACTIVE_LOCK:
|
|
@@ -88577,6 +88692,21 @@ async def _put_codex_goal_locked(
|
|
|
88577
88692
|
if current_turn is not None:
|
|
88578
88693
|
current_turn["run_id"] = operation_id
|
|
88579
88694
|
current_turn["purpose"] = "codex_goal_resume"
|
|
88695
|
+
# goal/set starts native work without passing through turn/start.
|
|
88696
|
+
# Issue this new logical run's helper authority before native work
|
|
88697
|
+
# can call Publish/Jobs/Mail. Reuse only existing durable routes;
|
|
88698
|
+
# resuming a goal must not mint grants from its text or history.
|
|
88699
|
+
resume_routes = provider_cross_chat_route_snapshot_for_authority(
|
|
88700
|
+
provider_cross_chat_routes(stored_session), [], source_session_id=session_id,
|
|
88701
|
+
)
|
|
88702
|
+
authority_path = await issue_cross_chat_capability(
|
|
88703
|
+
session_id, operation_id, [],
|
|
88704
|
+
actions={"publish", "jobs", "emergency", "agent_cross_chat_routes"},
|
|
88705
|
+
provider_route_snapshot=resume_routes,
|
|
88706
|
+
async_route_v1=True,
|
|
88707
|
+
)
|
|
88708
|
+
if authority_path is None:
|
|
88709
|
+
raise ProviderToolError("Could not prepare the resumed goal's tools")
|
|
88580
88710
|
await append_event(session_id, "turn_started", {
|
|
88581
88711
|
"run_id": operation_id,
|
|
88582
88712
|
"backend": BACKEND_CODEX,
|
|
@@ -88701,6 +88831,8 @@ async def _put_codex_goal_locked(
|
|
|
88701
88831
|
finally:
|
|
88702
88832
|
if not consumer_owns_reservation:
|
|
88703
88833
|
try:
|
|
88834
|
+
if operation_id:
|
|
88835
|
+
await revoke_cross_chat_capability(operation_id)
|
|
88704
88836
|
if goal_activation_attempted:
|
|
88705
88837
|
await stop_codex_goal_resume(
|
|
88706
88838
|
session_id, manager, thread_id, reservation_id,
|
|
@@ -95908,7 +96040,12 @@ async def active_artifact_publication_run(
|
|
|
95908
96040
|
or not isinstance(active, dict)
|
|
95909
96041
|
or not run_id
|
|
95910
96042
|
or str(active.get("run_id") or "").strip() != run_id
|
|
95911
|
-
or active.get("codex_native_operation") is True
|
|
96043
|
+
or (active.get("codex_native_operation") is True
|
|
96044
|
+
and not codex_native_mailbox_owner_matches(
|
|
96045
|
+
session_id, run_id,
|
|
96046
|
+
str(active.get("provider_thread_id") or ""),
|
|
96047
|
+
str(active.get("provider_turn_id") or ""),
|
|
96048
|
+
))
|
|
95912
96049
|
or active.get("stop_requested") is True
|
|
95913
96050
|
or run_id in STOPPED_RUNS
|
|
95914
96051
|
):
|
package/server/claude_goals.py
CHANGED
|
@@ -138,13 +138,19 @@ class ClaudeGoalProjection:
|
|
|
138
138
|
def consume(self, event: Any) -> bool:
|
|
139
139
|
"""Fold one native JSONL record; return whether visible goal changed."""
|
|
140
140
|
if not isinstance(event, dict) or (
|
|
141
|
-
event.get("type")
|
|
141
|
+
event.get("type") not in {"attachment", "system"}
|
|
142
142
|
or event.get("sessionId") != self.provider_session_id
|
|
143
143
|
or event.get("isSidechain") is not False
|
|
144
144
|
):
|
|
145
145
|
return False
|
|
146
146
|
attachment = event.get("attachment")
|
|
147
|
-
|
|
147
|
+
content = event.get("content")
|
|
148
|
+
stopped = (event.get("type") == "system" and event.get("subtype") == "informational"
|
|
149
|
+
and event.get("level") == "warning" and isinstance(content, str)
|
|
150
|
+
and re.match(r"\AA hook blocked the turn from ending [0-9]+ consecutive times [—-] overriding and ending turn\.", content))
|
|
151
|
+
if stopped:
|
|
152
|
+
attachment = {"type": "goal_status", "condition": (self.goal or {}).get("condition"), "met": False}
|
|
153
|
+
elif not isinstance(attachment, dict) or attachment.get("type") != "goal_status":
|
|
148
154
|
return False
|
|
149
155
|
event_id = event.get("uuid")
|
|
150
156
|
if not isinstance(event_id, str) or len(event_id) != 36:
|
|
@@ -175,10 +181,16 @@ class ClaudeGoalProjection:
|
|
|
175
181
|
return False
|
|
176
182
|
goal = dict(previous)
|
|
177
183
|
goal["status"] = "cleared" if sentinel else "achieved" if met else "active"
|
|
184
|
+
if stopped:
|
|
185
|
+
goal["execution_stop_reason"] = "continuation_limit"
|
|
186
|
+
goal["execution_stopped_at"] = timestamp
|
|
187
|
+
else:
|
|
188
|
+
goal.pop("execution_stop_reason", None)
|
|
189
|
+
goal.pop("execution_stopped_at", None)
|
|
178
190
|
if sentinel:
|
|
179
191
|
for field in ("iterations", "duration_ms", "tokens"):
|
|
180
192
|
goal.pop(field, None)
|
|
181
|
-
if not sentinel:
|
|
193
|
+
if not sentinel and not stopped:
|
|
182
194
|
reason = attachment.get("reason")
|
|
183
195
|
if isinstance(reason, str) and reason:
|
|
184
196
|
goal["last_reason"] = reason[:8192]
|
|
@@ -233,7 +245,7 @@ class ClaudeGoalProjection:
|
|
|
233
245
|
if self._discard_line:
|
|
234
246
|
self._discard_line = False
|
|
235
247
|
continue
|
|
236
|
-
if len(line) > MAX_GOAL_RECORD_BYTES or b'"goal_status"'
|
|
248
|
+
if len(line) > MAX_GOAL_RECORD_BYTES or not (b'"goal_status"' in line or b"A hook blocked the turn from ending" in line):
|
|
237
249
|
continue
|
|
238
250
|
try:
|
|
239
251
|
self.consume(json.loads(line))
|
|
@@ -1088,6 +1088,8 @@ class CodexAppServerClient:
|
|
|
1088
1088
|
self._next_id += 1
|
|
1089
1089
|
request_id = self._next_id
|
|
1090
1090
|
future: asyncio.Future[Any] = loop.create_future()
|
|
1091
|
+
# Preserve request ownership for per-chat process handoff.
|
|
1092
|
+
future._codex_thread_id = str((params or {}).get("threadId") or (params or {}).get("ancestorThreadId") or "")
|
|
1091
1093
|
self._pending[request_id] = (
|
|
1092
1094
|
method,
|
|
1093
1095
|
future,
|
|
@@ -1557,6 +1559,7 @@ class CodexAppServerClient:
|
|
|
1557
1559
|
previous,
|
|
1558
1560
|
)
|
|
1559
1561
|
)
|
|
1562
|
+
task._codex_thread_id = thread_id
|
|
1560
1563
|
self._callback_tails[owner] = task
|
|
1561
1564
|
self._callback_tasks.add(task)
|
|
1562
1565
|
task.add_done_callback(
|
|
@@ -1619,6 +1622,7 @@ class CodexAppServerClient:
|
|
|
1619
1622
|
self._handle_server_request(request_id, method, params),
|
|
1620
1623
|
name=f"codex-app-server-request-{request_id}",
|
|
1621
1624
|
)
|
|
1625
|
+
task._codex_thread_id = str(params.get("threadId") or "")
|
|
1622
1626
|
self._server_request_tasks[request_id] = task
|
|
1623
1627
|
task.add_done_callback(
|
|
1624
1628
|
lambda done, rid=request_id: self._finish_server_request(rid, done)
|
package/server/codex_auth.py
CHANGED
|
@@ -28,6 +28,37 @@ HANDOFF_MESSAGE = (
|
|
|
28
28
|
)
|
|
29
29
|
_REVISION_KEY = secrets.token_bytes(32)
|
|
30
30
|
_MAX_AUTH_BYTES = 128 * 1024
|
|
31
|
+
HANDOFF_PROBE_FAILURES = frozenset({"thread_list_unavailable", "goal_state_unavailable",
|
|
32
|
+
"terminal_state_unavailable", "ownership_deadline", "native_unsubscribe_incomplete", "writer_release_failed"})
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def handoff_detail(reason: str) -> dict:
|
|
36
|
+
"""Content-free admission status, distinct from a native auth rejection."""
|
|
37
|
+
reasons = {
|
|
38
|
+
"active_turn": "this chat still has a running turn",
|
|
39
|
+
"maintenance": "this chat is finishing a provider operation",
|
|
40
|
+
"approval": "this chat has an unanswered approval or question",
|
|
41
|
+
"side_chat": "this chat has a running Side chat",
|
|
42
|
+
"active_goal": "this chat has an active goal",
|
|
43
|
+
"native_goal_active": "Codex reports an active goal for this chat",
|
|
44
|
+
"subagents": "this chat has running subagents",
|
|
45
|
+
"callback": "this chat is finishing a provider notification",
|
|
46
|
+
"runtime_request": "a provider request for this chat is still finishing",
|
|
47
|
+
"native_thread_in_use": "Codex still has work using this thread",
|
|
48
|
+
"background_terminals": "this chat has a running background terminal",
|
|
49
|
+
"thread_list_unavailable": "Codex could not report which threads it owns",
|
|
50
|
+
"goal_state_unavailable": "Codex could not report this chat's goal state",
|
|
51
|
+
"terminal_state_unavailable": "Codex could not report this chat's background terminals",
|
|
52
|
+
"ownership_deadline": "Codex took too long to report this chat's state",
|
|
53
|
+
"native_unsubscribe_incomplete": "Codex has not released this thread yet",
|
|
54
|
+
"writer_release_failed": "Codex could not release the old connection to this thread",
|
|
55
|
+
}
|
|
56
|
+
reason = reason if reason in reasons else "runtime_request"
|
|
57
|
+
action = ("Retry the message to check again." if reason in HANDOFF_PROBE_FAILURES
|
|
58
|
+
else "Retry after that operation finishes.")
|
|
59
|
+
return {"code": "codex_login_handoff", "reason": reason, "retryable": True,
|
|
60
|
+
"message": f"Codex sign-in handoff is waiting: {reasons[reason]}. "
|
|
61
|
+
f"Your message has not been sent. {action}"}
|
|
31
62
|
|
|
32
63
|
|
|
33
64
|
@dataclass(frozen=True)
|
|
@@ -202,10 +202,26 @@ def _native_records(path: Path, expected: tuple[int, int, int, int], budget: _Na
|
|
|
202
202
|
|
|
203
203
|
def _native_event_identity(event: dict) -> dict:
|
|
204
204
|
"""Keep bounded public identity, never retain tool bodies or message text."""
|
|
205
|
-
return {key: event[key] for key in ("id", "seq", "type", "phase", "item_id")
|
|
205
|
+
return {key: event[key] for key in ("id", "seq", "type", "phase", "item_id", "provider_thread_id", "provider_turn_id")
|
|
206
206
|
if key in event and (type(event[key]) is int or isinstance(event[key], str) and len(event[key]) <= 256)}
|
|
207
207
|
|
|
208
208
|
|
|
209
|
+
def _native_goal_steer_turn(event: dict) -> str | None:
|
|
210
|
+
"""Accepted goal follow-ups already have a user bubble in this chat.
|
|
211
|
+
|
|
212
|
+
Keep their exact native turn: one goal run can span many provider turns,
|
|
213
|
+
including later genuine messages with identical text.
|
|
214
|
+
"""
|
|
215
|
+
turn = event.get("provider_turn_id")
|
|
216
|
+
if (event.get("type") == "turn_steered" and event.get("backend") == "codex"
|
|
217
|
+
and event.get("purpose") == "codex_goal_resume"
|
|
218
|
+
and event.get("native_goal_steer") is True and event.get("native_steer") is True
|
|
219
|
+
and event.get("provider_user_authored") is True
|
|
220
|
+
and isinstance(turn, str) and 0 < len(turn) <= 256):
|
|
221
|
+
return turn
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
|
|
209
225
|
def _native_import_batch(event: dict) -> dict:
|
|
210
226
|
result = {key: event.get(key) for key in ("seq", "provider_session_id", "source_path")}
|
|
211
227
|
checkpoint = event.get("_history_sync_checkpoint")
|
|
@@ -242,10 +258,13 @@ def _native_owned_turn_ids(event: dict) -> set[str]:
|
|
|
242
258
|
if isinstance(value := event.get(field), str) and 0 < len(value) <= 256}
|
|
243
259
|
|
|
244
260
|
|
|
245
|
-
def _native_assistant_item_owners(assistant_items: dict, completed_runs: set[str]) -> dict:
|
|
261
|
+
def _native_assistant_item_owners(assistant_items: dict, completed_runs: set[str], provider_id: str) -> dict:
|
|
246
262
|
owners: dict = {}
|
|
247
|
-
for run, item_id, body_key in assistant_items:
|
|
248
|
-
|
|
263
|
+
for (run, item_id, body_key), event in assistant_items.items():
|
|
264
|
+
# A restart or Stop can omit the completion receipt. An exact public
|
|
265
|
+
# item already delivered by this provider thread still identifies its
|
|
266
|
+
# native run; source reconciliation below supplies the matching turn.
|
|
267
|
+
if run in completed_runs or event.get("provider_thread_id") == provider_id:
|
|
249
268
|
owners.setdefault((item_id, body_key), set()).add(run)
|
|
250
269
|
return owners
|
|
251
270
|
|
|
@@ -856,7 +875,7 @@ def _prove_native_replays(session_id: str, provider_id: str, events: Path, sourc
|
|
|
856
875
|
owned = owners.setdefault((thread, turn), set())
|
|
857
876
|
retained += run not in owned
|
|
858
877
|
owned.add(run)
|
|
859
|
-
kind = "user" if event.get("type") == "turn_started" else "assistant" if event.get("type") in ("assistant_text", "reasoning_summary", "turn_finished") else None
|
|
878
|
+
kind = "user" if event.get("type") == "turn_started" or _native_goal_steer_turn(event) else "assistant" if event.get("type") in ("assistant_text", "reasoning_summary", "turn_finished") else None
|
|
860
879
|
body = event.get("prompt") if kind == "user" else event.get("result_text") if event.get("type") == "turn_finished" else event.get("text")
|
|
861
880
|
if kind and isinstance(body, str) and body and len(body) <= 4 * 1024 * 1024:
|
|
862
881
|
native.setdefault((run, kind, _text_key(body)), []).append(_native_event_identity(event))
|
|
@@ -935,14 +954,14 @@ def _prove_native_source(thread: str, source: Path, root: Path, batches: dict, c
|
|
|
935
954
|
assistant_native_keys = {}
|
|
936
955
|
compactions, candidate_assistants = {}, set()
|
|
937
956
|
compaction_tracker = CodexCompactionSummaryTracker(thread)
|
|
938
|
-
delivery_bodies, delivery_source_ids = {}, {}
|
|
957
|
+
delivery_bodies, delivery_source_ids, full_user_keys = {}, {}, {}
|
|
939
958
|
completed_runs = {run for (owner_thread, _turn), runs in owners.items()
|
|
940
959
|
if owner_thread == thread for run in runs}
|
|
941
960
|
assistant_item_owners = _native_assistant_item_owners({
|
|
942
961
|
(run, item_id, body_key): event
|
|
943
962
|
for (run, kind, body_key), events in native.items() if kind == "assistant"
|
|
944
963
|
for event in events if (item_id := _public_assistant_item_id(event)) is not None
|
|
945
|
-
}, completed_runs)
|
|
964
|
+
}, completed_runs, thread)
|
|
946
965
|
assistant_owner_evidence: dict = {}
|
|
947
966
|
assistant_source_turns: dict = {}
|
|
948
967
|
allowed_header_owners, header_parents = {thread}, {}
|
|
@@ -1016,6 +1035,21 @@ def _prove_native_source(thread: str, source: Path, root: Path, batches: dict, c
|
|
|
1016
1035
|
raise _Unproven()
|
|
1017
1036
|
if (item["kind"], key[2], key[3]) not in candidate_keys:
|
|
1018
1037
|
continue
|
|
1038
|
+
if item["kind"] == "user" and key[3] is not None:
|
|
1039
|
+
# A display-trimmed input still has its complete source in this
|
|
1040
|
+
# checkpoint-verified record. Compare those bytes to the original
|
|
1041
|
+
# native input, never a shared preview or an unverified hash.
|
|
1042
|
+
full_text = None
|
|
1043
|
+
if record.get("type") == "event_msg" and payload.get("type") == "user_message":
|
|
1044
|
+
full_text = payload.get("message")
|
|
1045
|
+
elif record.get("type") == "response_item" and payload.get("role") == "user":
|
|
1046
|
+
blocks = payload.get("content")
|
|
1047
|
+
if (isinstance(blocks, list) and len(blocks) == 1 and isinstance(blocks[0], dict)
|
|
1048
|
+
and blocks[0].get("type") == "input_text"):
|
|
1049
|
+
full_text = blocks[0].get("text")
|
|
1050
|
+
if (isinstance(full_text, str) and full_text
|
|
1051
|
+
and _text_key(" ".join(full_text.split())) == key[3]):
|
|
1052
|
+
full_user_keys[key] = _text_key(full_text)
|
|
1019
1053
|
if item["kind"] == "user" and (body := _async_delivery_body(item["text"])):
|
|
1020
1054
|
delivery_bodies[key] = body
|
|
1021
1055
|
if item["kind"] == "assistant":
|
|
@@ -1073,9 +1107,7 @@ def _prove_native_source(thread: str, source: Path, root: Path, batches: dict, c
|
|
|
1073
1107
|
if len(proofs) > MAX_TARGETS:
|
|
1074
1108
|
raise _Unproven()
|
|
1075
1109
|
continue
|
|
1076
|
-
if len(source_ids) != 1 or source_hash is not None:
|
|
1077
|
-
# Truncated source hashes need a separately retained native full-body
|
|
1078
|
-
# hash. Until one exists, the bounded preview cannot prove equality.
|
|
1110
|
+
if len(source_ids) != 1 or source_hash is not None and key not in full_user_keys:
|
|
1079
1111
|
continue
|
|
1080
1112
|
source_origin = next(iter(source_ids.values()))
|
|
1081
1113
|
if source_origin.get("kind") in ("subagent_notification", "turn_aborted", "provider_notice"):
|
|
@@ -1087,8 +1119,10 @@ def _prove_native_source(thread: str, source: Path, root: Path, batches: dict, c
|
|
|
1087
1119
|
if len(owned_runs) != 1:
|
|
1088
1120
|
continue
|
|
1089
1121
|
native_run = next(iter(owned_runs))
|
|
1090
|
-
|
|
1091
|
-
|
|
1122
|
+
native_body_key = full_user_keys.get(key, body_key)
|
|
1123
|
+
native_matches = [event for event in native.get((native_run, kind, native_body_key), [])
|
|
1124
|
+
if event.get("type") != "turn_steered" or event.get("provider_turn_id") == key[0]]
|
|
1125
|
+
if not native_matches and kind == "user" and source_hash is None:
|
|
1092
1126
|
delivery = deliveries.match(session_id, thread, native_run, source_origin,
|
|
1093
1127
|
delivery_bodies.get(key), delivery_source_ids.get(key[0], set()), body_key)
|
|
1094
1128
|
if delivery is not None:
|
|
@@ -1105,7 +1139,7 @@ def _prove_native_source(thread: str, source: Path, root: Path, batches: dict, c
|
|
|
1105
1139
|
if not native_matches:
|
|
1106
1140
|
continue
|
|
1107
1141
|
representative = min(native_matches, key=lambda event: event["seq"])
|
|
1108
|
-
proofs[target] = {**next(iter(source_ids.values())), "native_event_id": representative["id"], "source_text_sha256":
|
|
1142
|
+
proofs[target] = {**next(iter(source_ids.values())), "native_event_id": representative["id"], "source_text_sha256": native_body_key}
|
|
1109
1143
|
if len(proofs) > MAX_TARGETS:
|
|
1110
1144
|
raise _Unproven()
|
|
1111
1145
|
budget.check()
|
|
@@ -1183,10 +1217,10 @@ class CodexNativeHistoryRepairCache(CodexGoalHistoryRepairCache):
|
|
|
1183
1217
|
**({"_agentsdock_imported_prompt_hidden": True} if event["type"] == "turn_started" else {})}
|
|
1184
1218
|
|
|
1185
1219
|
|
|
1186
|
-
def
|
|
1220
|
+
def _prove_pending_native_inputs(session_id: str, provider_id: str, items: list[dict],
|
|
1187
1221
|
source: Path, root: Path, checkpoint: dict, parse_item: Callable,
|
|
1188
1222
|
previous_seq: int, owners: dict, deliveries: _AsyncDeliveryIndex,
|
|
1189
|
-
budget: _NativeReadBudget) -> dict:
|
|
1223
|
+
budget: _NativeReadBudget, native_inputs: dict | None = None) -> dict:
|
|
1190
1224
|
"""Reuse pinned-prefix proof before broadcast; the synthetic batch is never stored."""
|
|
1191
1225
|
cursor = checkpoint.get("cursor")
|
|
1192
1226
|
if (checkpoint.get("version") != 1 or not isinstance(cursor, dict) or cursor.get("version") != 1
|
|
@@ -1195,16 +1229,21 @@ def _prove_pending_async_deliveries(session_id: str, provider_id: str, items: li
|
|
|
1195
1229
|
raise _Unproven()
|
|
1196
1230
|
run = "import_pending_native_delivery_proof"
|
|
1197
1231
|
candidates, indexes = [], {}
|
|
1232
|
+
steer_keys = {key[2] for key, events in (native_inputs or {}).items()
|
|
1233
|
+
if key[1] == "user" and any(event.get("type") == "turn_steered" for event in events)}
|
|
1198
1234
|
for index, item in enumerate(items):
|
|
1199
1235
|
budget.check()
|
|
1200
1236
|
origin = item.get("provider_origin")
|
|
1201
1237
|
if (item.get("kind") != "user" or not isinstance(item.get("text"), str)
|
|
1202
1238
|
or not isinstance(origin, dict) or origin.get("provider") != "codex" or origin.get("kind") != "user"
|
|
1203
|
-
or origin.get("session_id", provider_id) != provider_id
|
|
1204
|
-
or not (
|
|
1239
|
+
or origin.get("session_id", provider_id) != provider_id
|
|
1240
|
+
or not (item.get("source_text_sha256") is not None
|
|
1241
|
+
or _async_delivery_body(item["text"]) or _text_key(item["text"]) in deliveries.status_body_keys
|
|
1242
|
+
or _text_key(item["text"]) in steer_keys)):
|
|
1205
1243
|
continue
|
|
1206
1244
|
candidate = _replay_target({"seq": previous_seq + index + 2, "id": f"pending:{index}", "run_id": run,
|
|
1207
1245
|
"type": "turn_started", "backend": "codex", "imported": True, "prompt": item["text"],
|
|
1246
|
+
"source_text_sha256": item.get("source_text_sha256"),
|
|
1208
1247
|
"ts": origin.get("timestamp"), "provider_user_authored": _runtime_human_provenance(item)})
|
|
1209
1248
|
if candidate is not None:
|
|
1210
1249
|
candidates.append(candidate)
|
|
@@ -1214,7 +1253,7 @@ def _prove_pending_async_deliveries(session_id: str, provider_id: str, items: li
|
|
|
1214
1253
|
if not candidates:
|
|
1215
1254
|
return {}
|
|
1216
1255
|
proof = _prove_native_source(provider_id, source, root,
|
|
1217
|
-
{run: (previous_seq + 1, previous_seq + len(items) + 2, checkpoint)}, candidates, {},
|
|
1256
|
+
{run: (previous_seq + 1, previous_seq + len(items) + 2, checkpoint)}, candidates, native_inputs or {},
|
|
1218
1257
|
{(provider_id, turn): runs for turn, runs in owners.items()}, parse_item, budget,
|
|
1219
1258
|
session_id=session_id, deliveries=deliveries, require_verified_checkpoint=True)
|
|
1220
1259
|
result = {}
|
|
@@ -1234,10 +1273,10 @@ def filter_native_codex_history_items(session_id: str, provider_id: str, events:
|
|
|
1234
1273
|
parse_item: Callable[[dict], dict | None] | None = None) -> list[dict]:
|
|
1235
1274
|
"""An existing verified import boundary can omit exact completed native copies.
|
|
1236
1275
|
|
|
1237
|
-
Exact native bodies need only the ledger. Legacy async wrappers
|
|
1238
|
-
require the caller's source/checkpoint/parser and
|
|
1239
|
-
|
|
1240
|
-
Missing source context leaves those
|
|
1276
|
+
Exact native bodies need only the ledger. Legacy async wrappers and trimmed
|
|
1277
|
+
user inputs additionally require the caller's source/checkpoint/parser and
|
|
1278
|
+
one verified source-prefix scan. A preview alone never proves equality.
|
|
1279
|
+
Missing source context leaves those inputs visible for later read repair.
|
|
1241
1280
|
Incomplete proof raises; the caller must defer import and its cursor commit.
|
|
1242
1281
|
"""
|
|
1243
1282
|
if not items:
|
|
@@ -1248,6 +1287,7 @@ def filter_native_codex_history_items(session_id: str, provider_id: str, events:
|
|
|
1248
1287
|
budget = _NativeReadBudget(cancelled, deadline)
|
|
1249
1288
|
stamp = _native_stamp(events)
|
|
1250
1289
|
owners, native, assistant_items, wake_keys, native_count = {}, {}, {}, set(), 0
|
|
1290
|
+
native_inputs, steered_inputs = {}, {}
|
|
1251
1291
|
deliveries = _AsyncDeliveryIndex()
|
|
1252
1292
|
prove_deliveries = (source_path is not None and root is not None
|
|
1253
1293
|
and isinstance(sync_checkpoint, dict) and callable(parse_item)
|
|
@@ -1273,24 +1313,31 @@ def filter_native_codex_history_items(session_id: str, provider_id: str, events:
|
|
|
1273
1313
|
owned = owners.setdefault(turn_id, set())
|
|
1274
1314
|
owner_count += run not in owned
|
|
1275
1315
|
owned.add(run)
|
|
1276
|
-
|
|
1316
|
+
steer_turn = _native_goal_steer_turn(event)
|
|
1317
|
+
kind = "user" if event.get("type") == "turn_started" or steer_turn else "assistant" if event.get("type") in ("assistant_text", "reasoning_summary", "turn_finished") else None
|
|
1277
1318
|
body = event.get("prompt") if kind == "user" else event.get("result_text") if event.get("type") == "turn_finished" else event.get("text")
|
|
1278
1319
|
wake_hash = _native_mailbox_wake_hash(event) if kind == "user" else None
|
|
1279
1320
|
if kind and (isinstance(body, str) and body or wake_hash):
|
|
1280
|
-
keys = native.setdefault(run, {})
|
|
1321
|
+
keys = steered_inputs.setdefault((run, steer_turn), {}) if steer_turn else native.setdefault(run, {})
|
|
1281
1322
|
key = (kind, wake_hash or _text_key(body))
|
|
1282
1323
|
native_count += key not in keys
|
|
1283
1324
|
if isinstance(event.get("id"), str) and 0 < len(event["id"]) <= 256:
|
|
1284
1325
|
keys.setdefault(key, event["id"])
|
|
1326
|
+
if prove_deliveries and kind == "user" and isinstance(body, str) and body:
|
|
1327
|
+
inputs = native_inputs.setdefault((run, kind, key[1]), [])
|
|
1328
|
+
if not any(prior.get("type") == event.get("type")
|
|
1329
|
+
and prior.get("provider_turn_id") == event.get("provider_turn_id") for prior in inputs):
|
|
1330
|
+
inputs.append(_native_event_identity(event))
|
|
1331
|
+
native_count += 1
|
|
1285
1332
|
if wake_hash:
|
|
1286
1333
|
wake_keys.add((run, wake_hash))
|
|
1287
1334
|
item_id = _public_assistant_item_id(event)
|
|
1288
1335
|
if item_id is not None:
|
|
1289
|
-
assistant_items[(run, item_id, key[1])] = event
|
|
1336
|
+
assistant_items[(run, item_id, key[1])] = _native_event_identity(event)
|
|
1290
1337
|
if native_count + owner_count + len(assistant_items) + deliveries.count > MAX_KEYS:
|
|
1291
1338
|
raise _Unproven()
|
|
1292
1339
|
assistant_item_owners = _native_assistant_item_owners(
|
|
1293
|
-
assistant_items, {run for runs in owners.values() for run in runs},
|
|
1340
|
+
assistant_items, {run for runs in owners.values() for run in runs}, provider_id,
|
|
1294
1341
|
)
|
|
1295
1342
|
assistant_source_turns: dict = {}
|
|
1296
1343
|
for item in items:
|
|
@@ -1308,10 +1355,17 @@ def filter_native_codex_history_items(session_id: str, provider_id: str, events:
|
|
|
1308
1355
|
for item_key, turns in assistant_source_turns.items():
|
|
1309
1356
|
if len(turns) == 1:
|
|
1310
1357
|
owners.setdefault(next(iter(turns)), set()).update(assistant_item_owners[item_key])
|
|
1311
|
-
|
|
1358
|
+
if prove_deliveries and steered_inputs:
|
|
1359
|
+
# The unmatched import can contain only follow-ups: its assistant
|
|
1360
|
+
# messages were already reconciled. Preserve public item identities
|
|
1361
|
+
# for the source scan to recover earlier turns of a long goal run.
|
|
1362
|
+
for (run, _item_id, body_key), event in assistant_items.items():
|
|
1363
|
+
native_inputs.setdefault((run, "assistant", body_key), []).append(event)
|
|
1364
|
+
delivery_proofs = _prove_pending_native_inputs(
|
|
1312
1365
|
session_id, provider_id, items, source_path, root, sync_checkpoint, parse_item,
|
|
1313
|
-
previous_seq, owners, deliveries, budget,
|
|
1314
|
-
) if prove_deliveries and deliveries.starts
|
|
1366
|
+
previous_seq, owners, deliveries, budget, native_inputs,
|
|
1367
|
+
) if prove_deliveries and (deliveries.starts or steered_inputs or any(
|
|
1368
|
+
item.get("kind") == "user" and item.get("source_text_sha256") is not None for item in items)) else {}
|
|
1315
1369
|
# A hidden wake has no public prompt body. Require a single exact source
|
|
1316
1370
|
# item in this verified import range, in addition to native turn ownership.
|
|
1317
1371
|
wake_source_ids = {}
|
|
@@ -1332,9 +1386,15 @@ def filter_native_codex_history_items(session_id: str, provider_id: str, events:
|
|
|
1332
1386
|
and origin.get("session_id", provider_id) == provider_id and len(runs) == 1
|
|
1333
1387
|
and item.get("source_text_sha256") is None and isinstance(item.get("text"), str))
|
|
1334
1388
|
known = owned and (item["kind"], _text_key(item["text"])) in native.get(next(iter(runs)), {})
|
|
1335
|
-
|
|
1389
|
+
# Steers never use delta-local text equality as proof. An equal
|
|
1390
|
+
# source item can precede this delta's checkpoint in the same
|
|
1391
|
+
# native turn. Only the full verified prefix can establish that
|
|
1392
|
+
# this source occurrence is the already-shown follow-up.
|
|
1393
|
+
# This proof already established exact source bytes, native owner,
|
|
1394
|
+
# original event and checkpoint even when the display is trimmed.
|
|
1395
|
+
delivery = delivery_proofs.get(index)
|
|
1336
1396
|
known = known or delivery is not None
|
|
1337
|
-
if known and (next(iter(runs)), _text_key(item["text"])) in wake_keys:
|
|
1397
|
+
if known and runs and (next(iter(runs)), _text_key(item["text"])) in wake_keys:
|
|
1338
1398
|
known = (bool(origin.get("event_id")) and bool(origin.get("timestamp"))
|
|
1339
1399
|
and len(wake_source_ids.get((origin.get("turn_id"), _text_key(item["text"])), set())) == 1)
|
|
1340
1400
|
if owned and not known and item["kind"] == "assistant":
|
|
@@ -1350,7 +1410,7 @@ def filter_native_codex_history_items(session_id: str, provider_id: str, events:
|
|
|
1350
1410
|
"provider_history_repair": "source_proven_native_replay",
|
|
1351
1411
|
"provider_origin": {**origin, "session_id": provider_id,
|
|
1352
1412
|
"native_event_id": delivery["native_event_id"] if delivery else native[next(iter(runs))][key],
|
|
1353
|
-
"source_text_sha256": key[1]}})
|
|
1413
|
+
"source_text_sha256": delivery["source_text_sha256"] if delivery else key[1]}})
|
|
1354
1414
|
budget.check()
|
|
1355
1415
|
return result
|
|
1356
1416
|
except (OSError, ValueError, TypeError, KeyError, RuntimeError) as exc:
|
|
@@ -17,6 +17,7 @@ import os
|
|
|
17
17
|
from pathlib import Path
|
|
18
18
|
import plistlib
|
|
19
19
|
import re
|
|
20
|
+
import select
|
|
20
21
|
import shlex
|
|
21
22
|
import shutil
|
|
22
23
|
import socket
|
|
@@ -24,7 +25,9 @@ import stat
|
|
|
24
25
|
import subprocess
|
|
25
26
|
import sys
|
|
26
27
|
import tempfile
|
|
28
|
+
import termios
|
|
27
29
|
import time
|
|
30
|
+
import tty
|
|
28
31
|
from urllib.parse import urlsplit
|
|
29
32
|
|
|
30
33
|
INSTANCE_PROTOCOL = 1
|
|
@@ -611,6 +614,147 @@ def show(instance: Instance, *, network: dict[str, str] | None = None):
|
|
|
611
614
|
print()
|
|
612
615
|
|
|
613
616
|
|
|
617
|
+
def show_status(instance: Instance, *, network: dict[str, str]) -> None:
|
|
618
|
+
"""Read-only per-instance summary; never print configuration or tokens."""
|
|
619
|
+
item = describe(instance)
|
|
620
|
+
print(terminal_color(f"Server ({item['name']}):", "34"))
|
|
621
|
+
print(f" Status: {item['status']}")
|
|
622
|
+
addresses = []
|
|
623
|
+
if item["port"]:
|
|
624
|
+
choices = connection_choices(read_config(instance).get("AGENTSDOCK_AGENT_BIND", "0.0.0.0"),
|
|
625
|
+
item["port"], item["addresses"], network)
|
|
626
|
+
if choices["tailscale"]:
|
|
627
|
+
addresses.append(f"{choices['tailscale']} (Tailscale / other networks)")
|
|
628
|
+
for key, label in (("lan", "Same Wi-Fi / LAN"), ("local", "This machine only"),
|
|
629
|
+
("other", "Other / unverified")):
|
|
630
|
+
addresses.extend(f"{url} ({label})" for url in choices[key])
|
|
631
|
+
print(" Address: " + (addresses[0] if addresses else "Not configured"))
|
|
632
|
+
for address in addresses[1:]:
|
|
633
|
+
print(" " + address)
|
|
634
|
+
print(f" Version: {item['version'] or 'unknown'}")
|
|
635
|
+
print(f" Port: {item['port'] or 'Not configured'}")
|
|
636
|
+
print()
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def read_token_menu_key(fd: int) -> str:
|
|
640
|
+
key = os.read(fd, 1)
|
|
641
|
+
if key in (b"", b"\x03", b"\x04"):
|
|
642
|
+
return "cancel"
|
|
643
|
+
if key in (b"\r", b"\n"):
|
|
644
|
+
return "enter"
|
|
645
|
+
if key != b"\x1b":
|
|
646
|
+
return "other"
|
|
647
|
+
sequence = b""
|
|
648
|
+
while len(sequence) < 16 and select.select([fd], [], [], 0.15)[0]:
|
|
649
|
+
part = os.read(fd, 1)
|
|
650
|
+
if not part:
|
|
651
|
+
return "cancel"
|
|
652
|
+
sequence += part
|
|
653
|
+
if sequence in (b"[A", b"OA"):
|
|
654
|
+
return "up"
|
|
655
|
+
if sequence in (b"[B", b"OB"):
|
|
656
|
+
return "down"
|
|
657
|
+
if sequence[:1] not in (b"[", b"O") or (len(sequence) > 1 and 0x40 <= part[0] <= 0x7e):
|
|
658
|
+
return "other"
|
|
659
|
+
return "cancel" if not sequence else "other"
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def arrow_token_choice(rows: list[str], fd: int, settings: list) -> int | None:
|
|
663
|
+
"""Render a bounded terminal menu and restore input mode before token output."""
|
|
664
|
+
size = shutil.get_terminal_size(fallback=(80, 24))
|
|
665
|
+
visible = min(len(rows), max(1, size.lines - 6), 10)
|
|
666
|
+
width = max(1, size.columns - 1)
|
|
667
|
+
selected, start = 0, 0
|
|
668
|
+
hint = "Use ↑/↓ to choose; Enter to select; Esc to cancel."
|
|
669
|
+
|
|
670
|
+
def render(redraw=False):
|
|
671
|
+
if redraw:
|
|
672
|
+
sys.stdout.write(f"\x1b[{visible + 1}A")
|
|
673
|
+
for index in range(start, start + visible):
|
|
674
|
+
row = (" > " if index == selected else " ") + rows[index]
|
|
675
|
+
row = row[:width]
|
|
676
|
+
if index == selected:
|
|
677
|
+
row = terminal_color(row, "1;36")
|
|
678
|
+
sys.stdout.write("\r\x1b[2K" + row + "\n")
|
|
679
|
+
sys.stdout.write("\r\x1b[2K" + hint[:width] + "\n")
|
|
680
|
+
sys.stdout.flush()
|
|
681
|
+
|
|
682
|
+
try:
|
|
683
|
+
tty.setcbreak(fd, termios.TCSANOW)
|
|
684
|
+
sys.stdout.write("\x1b[?25l")
|
|
685
|
+
render()
|
|
686
|
+
while True:
|
|
687
|
+
key = read_token_menu_key(fd)
|
|
688
|
+
if key == "cancel":
|
|
689
|
+
return None
|
|
690
|
+
if key == "enter":
|
|
691
|
+
return selected
|
|
692
|
+
if key not in ("up", "down"):
|
|
693
|
+
continue
|
|
694
|
+
selected = (selected + (1 if key == "down" else -1)) % len(rows)
|
|
695
|
+
start = min(start, selected) if selected < start else max(start, selected - visible + 1)
|
|
696
|
+
render(redraw=True)
|
|
697
|
+
except KeyboardInterrupt:
|
|
698
|
+
return None
|
|
699
|
+
finally:
|
|
700
|
+
termios.tcsetattr(fd, termios.TCSANOW, settings)
|
|
701
|
+
sys.stdout.write("\x1b[?25h\n")
|
|
702
|
+
sys.stdout.flush()
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def choose_token_instance(registry: Registry) -> Instance | None:
|
|
706
|
+
"""Select by stable instance name; never default or expose unselected tokens."""
|
|
707
|
+
items = registry.instances()
|
|
708
|
+
if not items:
|
|
709
|
+
print("No installations found. Run agentsdock setup to create your first server.")
|
|
710
|
+
return None
|
|
711
|
+
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
|
712
|
+
raise ValueError("Choose a server in an interactive terminal, or run agentsdock token NAME "
|
|
713
|
+
"(for example: agentsdock token default). No token was shown.")
|
|
714
|
+
print("\nChoose a server to view its access token:\n")
|
|
715
|
+
rows = []
|
|
716
|
+
for index, instance in enumerate(items, 1):
|
|
717
|
+
config = read_config(instance)
|
|
718
|
+
port = config.get("AGENTSDOCK_AGENT_PORT") or "not configured"
|
|
719
|
+
rows.append(f"{instance.name:<20} {service_status(instance):<10} Port: {port}")
|
|
720
|
+
if os.environ.get("TERM", "dumb") not in ("", "dumb"):
|
|
721
|
+
try:
|
|
722
|
+
fd = sys.stdin.fileno()
|
|
723
|
+
settings = termios.tcgetattr(fd)
|
|
724
|
+
except (OSError, ValueError, termios.error):
|
|
725
|
+
pass # Basic terminals retain the explicit numbered/name prompt.
|
|
726
|
+
else:
|
|
727
|
+
index = arrow_token_choice(rows, fd, settings)
|
|
728
|
+
if index is None:
|
|
729
|
+
print("Cancelled; no token was shown.")
|
|
730
|
+
return None
|
|
731
|
+
selected = items[index]
|
|
732
|
+
if selected.name not in {item.name for item in registry.instances()}:
|
|
733
|
+
raise ValueError("That server is no longer available. Run agentsdock token again. No token was shown.")
|
|
734
|
+
return selected
|
|
735
|
+
for index, row in enumerate(rows, 1):
|
|
736
|
+
print(f" {index}. {row}")
|
|
737
|
+
print()
|
|
738
|
+
names = {item.name: item for item in items}
|
|
739
|
+
while True:
|
|
740
|
+
try:
|
|
741
|
+
answer = input("Enter a number or server name (Enter to cancel): ").strip()
|
|
742
|
+
except (EOFError, KeyboardInterrupt):
|
|
743
|
+
print()
|
|
744
|
+
answer = ""
|
|
745
|
+
if not answer:
|
|
746
|
+
print("Cancelled; no token was shown.")
|
|
747
|
+
return None
|
|
748
|
+
selected = names.get(answer)
|
|
749
|
+
if re.fullmatch(r"[1-9][0-9]{0,5}", answer) and int(answer) <= len(items):
|
|
750
|
+
selected = items[int(answer) - 1]
|
|
751
|
+
if selected is not None:
|
|
752
|
+
if selected.name not in {item.name for item in registry.instances()}:
|
|
753
|
+
raise ValueError("That server is no longer available. Run agentsdock token again. No token was shown.")
|
|
754
|
+
return selected
|
|
755
|
+
print(f"Please enter a number from 1 to {len(items)} or a listed server name (Enter cancels).")
|
|
756
|
+
|
|
757
|
+
|
|
614
758
|
def terminal_color(text: str, code: str) -> str:
|
|
615
759
|
color = sys.stdout.isatty() and os.environ.get("TERM") != "dumb" and "NO_COLOR" not in os.environ
|
|
616
760
|
return f"\033[{code}m{text}\033[0m" if color else text
|
|
@@ -747,8 +891,11 @@ def parser() -> argparse.ArgumentParser:
|
|
|
747
891
|
result = argparse.ArgumentParser(description="Independent AgentsServer instances for the current OS user. Defaults to list.")
|
|
748
892
|
commands = result.add_subparsers(dest="command")
|
|
749
893
|
commands.add_parser("list")
|
|
894
|
+
commands.add_parser("token", help="Choose an existing server and display only its access token")
|
|
750
895
|
info = commands.add_parser("info")
|
|
751
896
|
info.add_argument("name", type=instance_name)
|
|
897
|
+
status = commands.add_parser("status", help="Show readable details for all instances or one name")
|
|
898
|
+
status.add_argument("name", nargs="?", type=instance_name)
|
|
752
899
|
new = commands.add_parser("new")
|
|
753
900
|
new.add_argument("--name", type=instance_name)
|
|
754
901
|
new.add_argument("--port", type=int)
|
|
@@ -785,13 +932,30 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
785
932
|
validate_binding(instance)
|
|
786
933
|
print(instance.shell_bindings())
|
|
787
934
|
return 0
|
|
788
|
-
if args.command
|
|
935
|
+
if args.command == "token":
|
|
936
|
+
selected = choose_token_instance(registry)
|
|
937
|
+
if selected is None:
|
|
938
|
+
return 0
|
|
939
|
+
try:
|
|
940
|
+
run(["/bin/bash", str(ROOT / "install.sh"), "--instance", selected.name, "--show-token"],
|
|
941
|
+
env=clean_environment(selected), cwd=ROOT)
|
|
942
|
+
return 0
|
|
943
|
+
except subprocess.CalledProcessError as exc:
|
|
944
|
+
return exc.returncode if exc.returncode > 0 else 1
|
|
945
|
+
if args.command in {None, "list", "info", "status"}:
|
|
789
946
|
items = registry.instances()
|
|
790
|
-
if args.command
|
|
947
|
+
if args.command in {"info", "status"} and args.name:
|
|
791
948
|
items = [item for item in items if item.name == args.name]
|
|
792
949
|
if not items:
|
|
793
950
|
raise ValueError("Unknown instance.")
|
|
951
|
+
if args.command == "info":
|
|
794
952
|
print(json.dumps(describe(items[0]), indent=2))
|
|
953
|
+
elif args.command == "status":
|
|
954
|
+
network = tailscale_status() if items else None
|
|
955
|
+
for item in items:
|
|
956
|
+
show_status(item, network=network)
|
|
957
|
+
if not items:
|
|
958
|
+
print("No installations found. Run agentsdock setup to create your first server.")
|
|
795
959
|
else:
|
|
796
960
|
network = tailscale_status() if items else None
|
|
797
961
|
print("NAME STATUS PORT")
|