@agentsdock/server 1.0.10 → 1.0.11-beta.2
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/NOTICE +7 -0
- package/package.json +1 -1
- package/server/NOTICE +7 -0
- package/server/VERSION +1 -1
- package/server/agent_server.py +1605 -493
- package/server/agentsdock_jobs.py +2 -2
- package/server/claude_history_repair.py +46 -61
- package/server/codex_auth.py +76 -7
- package/server/codex_history_repair.py +203 -38
- package/server/cursor_process_guard.py +67 -4
- package/server/cursor_provider_mcp.py +2 -0
- package/server/execution_durability.py +87 -11
- package/server/execution_install.py +5 -1
- package/server/gemini_acp_client.py +432 -0
- package/server/gemini_api.py +128 -0
- package/server/gemini_content.py +102 -0
- package/server/gemini_history.py +293 -0
- package/server/gemini_import.py +126 -0
- package/server/gemini_permissions.py +47 -0
- package/server/gemini_runner.py +493 -0
- package/server/gemini_runtime.py +322 -0
- package/server/install.sh +11 -2
- package/server/interactive_chat_controls.py +9 -3
- package/server/interactive_chat_native.py +1 -1
- package/server/interactive_chat_share_routes.py +1 -1
- package/server/interactive_chat_share_web.py +2 -2
- package/server/local_session_ownership.py +1 -0
- package/server/provider_commands.py +65 -0
- package/server/provider_connections.py +39 -10
- package/server/server_instances.py +217 -15
- package/server/title_generation.py +60 -1
- package/server/transcript_reader.py +334 -0
- package/server/update_runner.py +4 -2
package/server/agent_server.py
CHANGED
|
@@ -26,6 +26,7 @@ import importlib.util
|
|
|
26
26
|
import inspect
|
|
27
27
|
import io
|
|
28
28
|
import json
|
|
29
|
+
from transcript_reader import jsonl_records, scan_check, SCAN_SECONDS
|
|
29
30
|
import logging
|
|
30
31
|
import logging.handlers
|
|
31
32
|
import math
|
|
@@ -125,6 +126,7 @@ from provider_commands import (
|
|
|
125
126
|
claude_provider_command_inventory,
|
|
126
127
|
codex_provider_command_inventory,
|
|
127
128
|
empty_provider_command_inventory,
|
|
129
|
+
gemini_provider_skill_inventory,
|
|
128
130
|
opencode_provider_skill_inventory,
|
|
129
131
|
validate_opencode_provider_skill_record,
|
|
130
132
|
)
|
|
@@ -207,7 +209,8 @@ BACKEND_CLAUDE = "claude"
|
|
|
207
209
|
BACKEND_CODEX = "codex"
|
|
208
210
|
BACKEND_CURSOR = "cursor"
|
|
209
211
|
BACKEND_OPENCODE = "opencode"
|
|
210
|
-
|
|
212
|
+
BACKEND_GEMINI = "gemini"
|
|
213
|
+
VALID_BACKENDS = {BACKEND_CLAUDE, BACKEND_CODEX, BACKEND_CURSOR, BACKEND_OPENCODE, BACKEND_GEMINI}
|
|
211
214
|
CODEX_EFFORTS = {"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"}
|
|
212
215
|
CODEX_EFFORT_ALIASES = {
|
|
213
216
|
"extra high": "xhigh",
|
|
@@ -471,39 +474,17 @@ def boolean_setting(suffix: str, default: bool) -> bool:
|
|
|
471
474
|
return default
|
|
472
475
|
|
|
473
476
|
|
|
474
|
-
CODEX_GOALS_DEFAULT_ENABLED =
|
|
477
|
+
CODEX_GOALS_DEFAULT_ENABLED = True
|
|
475
478
|
|
|
476
479
|
|
|
477
480
|
def read_codex_goals_enabled(path: Path | None = None) -> bool:
|
|
478
|
-
"""
|
|
481
|
+
"""Goals are available without a server-wide opt-in.
|
|
479
482
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
+
Ignore the retired environment/config switch, including persisted False
|
|
484
|
+
from older clients. Do not rewrite shared settings or resume paused goals.
|
|
485
|
+
Keep the optional path argument for compatibility with existing callers.
|
|
483
486
|
"""
|
|
484
|
-
|
|
485
|
-
settings_path = path or CODEX_SETTINGS_FILE
|
|
486
|
-
try:
|
|
487
|
-
value = json.loads(settings_path.read_text())
|
|
488
|
-
except FileNotFoundError:
|
|
489
|
-
return CODEX_GOALS_DEFAULT_ENABLED
|
|
490
|
-
except (json.JSONDecodeError, OSError) as exc:
|
|
491
|
-
logger.warning(
|
|
492
|
-
"could not read Codex server settings from %s: %s",
|
|
493
|
-
settings_path,
|
|
494
|
-
exc,
|
|
495
|
-
)
|
|
496
|
-
return CODEX_GOALS_DEFAULT_ENABLED
|
|
497
|
-
if not isinstance(value, dict) or not isinstance(
|
|
498
|
-
value.get("goals_enabled"),
|
|
499
|
-
bool,
|
|
500
|
-
):
|
|
501
|
-
logger.warning(
|
|
502
|
-
"ignoring malformed Codex server settings at %s",
|
|
503
|
-
settings_path,
|
|
504
|
-
)
|
|
505
|
-
return CODEX_GOALS_DEFAULT_ENABLED
|
|
506
|
-
return bool(value["goals_enabled"])
|
|
487
|
+
return True
|
|
507
488
|
|
|
508
489
|
|
|
509
490
|
# Per-thread Codex config overrides (codex-cli 0.149.1: thread/start,
|
|
@@ -683,6 +664,7 @@ def codex_effective_thread_config(sess: dict[str, Any]) -> dict[str, Any]:
|
|
|
683
664
|
CODEX_GOALS_ENABLED = read_codex_goals_enabled()
|
|
684
665
|
|
|
685
666
|
CODEX_FALLBACK_MODELS = (
|
|
667
|
+
("gpt-6-luna", "GPT-6-Luna"),
|
|
686
668
|
("gpt-5.6-sol", "GPT-5.6-Sol"),
|
|
687
669
|
("gpt-5.6-terra", "GPT-5.6-Terra"),
|
|
688
670
|
("gpt-5.6-luna", "GPT-5.6-Luna"),
|
|
@@ -693,6 +675,7 @@ CODEX_FALLBACK_MODELS = (
|
|
|
693
675
|
CODEX_FALLBACK_EFFORTS = ("low", "medium", "high", "xhigh", "max", "ultra")
|
|
694
676
|
CODEX_EFFORT_ORDER = ("none", "minimal", *CODEX_FALLBACK_EFFORTS)
|
|
695
677
|
CODEX_FALLBACK_MODEL_EFFORTS = {
|
|
678
|
+
"gpt-6-luna": ("low", "medium", "high", "xhigh", "max"),
|
|
696
679
|
"gpt-5.6-sol": ("low", "medium", "high", "xhigh", "max", "ultra"),
|
|
697
680
|
"gpt-5.6-terra": ("low", "medium", "high", "xhigh", "max", "ultra"),
|
|
698
681
|
"gpt-5.6-luna": ("low", "medium", "high", "xhigh", "max"),
|
|
@@ -1319,8 +1302,10 @@ HISTORY_SYNC_EVENT_SCAN_LIMIT = int(
|
|
|
1319
1302
|
MAX_IMPORTED_TEXT_CHARS = int(agentsdock_setting("HISTORY_IMPORT_TEXT_CHARS", "12000"))
|
|
1320
1303
|
MAX_LOCAL_SESSION_LIST_ITEMS = 500
|
|
1321
1304
|
MAX_LOCAL_SESSION_SCAN_FILES = 10_000
|
|
1322
|
-
|
|
1323
|
-
|
|
1305
|
+
# Retained text/metadata budget. Opaque native media/tool bytes are streamed.
|
|
1306
|
+
# Private timeline/index readers still use this as their raw record bound.
|
|
1307
|
+
MAX_LOCAL_TRANSCRIPT_LINE_BYTES = 32 * 1024 * 1024
|
|
1308
|
+
HISTORY_SYNC_BATCH_BYTES = 16 * 1024 * 1024
|
|
1324
1309
|
MAX_LOCAL_TRANSCRIPT_SCAN_LINES = 250_000
|
|
1325
1310
|
MAX_CODEX_SESSION_INDEX_BYTES = 16 * 1024 * 1024
|
|
1326
1311
|
MAX_CODEX_SESSION_INDEX_LINES = 100_000
|
|
@@ -6337,6 +6322,7 @@ class CreateSessionRequest(BaseModel):
|
|
|
6337
6322
|
# Same three names as Cursor, but "default" means OpenCode's own
|
|
6338
6323
|
# permissions, which do allow shell.
|
|
6339
6324
|
opencode_permission_mode: Literal["default", "full_access", "plan"] | None = None
|
|
6325
|
+
gemini_permission_mode: Literal["default", "autoEdit", "yolo", "plan"] | None = None
|
|
6340
6326
|
|
|
6341
6327
|
|
|
6342
6328
|
MAX_BULK_IMPORT_ITEMS = 25
|
|
@@ -6348,9 +6334,10 @@ PROVIDER_SESSION_IDENTIFIER_RE = re.compile(
|
|
|
6348
6334
|
|
|
6349
6335
|
class BulkImportSessionItem(BaseModel):
|
|
6350
6336
|
provider_session_id: str = Field(min_length=1, max_length=256)
|
|
6351
|
-
backend: Literal["claude", "codex", "cursor"]
|
|
6337
|
+
backend: Literal["claude", "codex", "cursor", "gemini"]
|
|
6352
6338
|
cwd: str | None = Field(default=None, max_length=MAX_WORKSPACE_PATH_CHARS)
|
|
6353
6339
|
title: str | None = Field(default=None, max_length=MAX_LOCAL_SESSION_LABEL_CHARS)
|
|
6340
|
+
provider_connection: Literal["custom"] | None = None
|
|
6354
6341
|
|
|
6355
6342
|
@field_validator("provider_session_id")
|
|
6356
6343
|
@classmethod
|
|
@@ -6374,6 +6361,7 @@ class BulkImportSessionsRequest(BaseModel):
|
|
|
6374
6361
|
|
|
6375
6362
|
|
|
6376
6363
|
class UpdateSessionRequest(BaseModel):
|
|
6364
|
+
gemini_connect_api: bool | None = Field(default=None, strict=True)
|
|
6377
6365
|
title: str | None = None
|
|
6378
6366
|
auto_title_enabled: bool | None = None
|
|
6379
6367
|
folder: str | None = None
|
|
@@ -6402,6 +6390,7 @@ class UpdateSessionRequest(BaseModel):
|
|
|
6402
6390
|
provider_jobs_access: Literal["full", "read_only", "blocked"] | None = None
|
|
6403
6391
|
cursor_permission_mode: Literal["default", "full_access", "plan"] | None = None
|
|
6404
6392
|
opencode_permission_mode: Literal["default", "full_access", "plan"] | None = None
|
|
6393
|
+
gemini_permission_mode: Literal["default", "autoEdit", "yolo", "plan"] | None = None
|
|
6405
6394
|
|
|
6406
6395
|
|
|
6407
6396
|
SESSION_LIFECYCLE_UPDATE_FIELDS = frozenset({
|
|
@@ -6420,6 +6409,7 @@ SESSION_LIFECYCLE_UPDATE_FIELDS = frozenset({
|
|
|
6420
6409
|
"provider_jobs_access",
|
|
6421
6410
|
"cursor_permission_mode",
|
|
6422
6411
|
"opencode_permission_mode",
|
|
6412
|
+
"gemini_permission_mode",
|
|
6423
6413
|
"archived",
|
|
6424
6414
|
})
|
|
6425
6415
|
|
|
@@ -6569,12 +6559,25 @@ class EmergencyAlertRequest(BaseModel):
|
|
|
6569
6559
|
class AcknowledgeEmergencyRequest(BaseModel):
|
|
6570
6560
|
model_config = {"extra": "forbid"}
|
|
6571
6561
|
|
|
6572
|
-
expected_alert_id: str = Field(
|
|
6562
|
+
expected_alert_id: str | None = Field(
|
|
6563
|
+
default=None,
|
|
6564
|
+
min_length=8,
|
|
6565
|
+
max_length=80,
|
|
6566
|
+
pattern=r"^emergency_[0-9a-f]{32}$",
|
|
6567
|
+
)
|
|
6568
|
+
through_alert_id: str | None = Field(
|
|
6569
|
+
default=None,
|
|
6573
6570
|
min_length=8,
|
|
6574
6571
|
max_length=80,
|
|
6575
6572
|
pattern=r"^emergency_[0-9a-f]{32}$",
|
|
6576
6573
|
)
|
|
6577
6574
|
|
|
6575
|
+
@model_validator(mode="after")
|
|
6576
|
+
def exactly_one_alert_boundary(self):
|
|
6577
|
+
if (self.expected_alert_id is None) == (self.through_alert_id is None):
|
|
6578
|
+
raise ValueError("provide exactly one of expected_alert_id or through_alert_id")
|
|
6579
|
+
return self
|
|
6580
|
+
|
|
6578
6581
|
|
|
6579
6582
|
class ChatReference(BaseModel):
|
|
6580
6583
|
model_config = {"extra": "forbid"}
|
|
@@ -6743,6 +6746,7 @@ class ProviderCommandSelectionUnavailable(HTTPException):
|
|
|
6743
6746
|
|
|
6744
6747
|
class TurnRequest(BaseModel):
|
|
6745
6748
|
prompt: str
|
|
6749
|
+
client_request_id: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_-]{8,128}$")
|
|
6746
6750
|
# Server-derived attribution only; the guest router accepts neither field
|
|
6747
6751
|
# from its JSON body and never accepts arbitrary TurnRequest controls.
|
|
6748
6752
|
shared_chat_id: str | None = Field(default=None, pattern=r"^interactive_[a-f0-9]{32}$")
|
|
@@ -7055,6 +7059,10 @@ def normalize_runtime_effort(backend: str, effort: Any, *, strict: bool = False)
|
|
|
7055
7059
|
clean = str(effort or "").strip().lower()
|
|
7056
7060
|
if not clean:
|
|
7057
7061
|
return None
|
|
7062
|
+
if backend == BACKEND_GEMINI:
|
|
7063
|
+
if strict:
|
|
7064
|
+
raise HTTPException(400, "Gemini reasoning settings are managed by its native model.")
|
|
7065
|
+
return None
|
|
7058
7066
|
if str(backend or "").strip().lower() != BACKEND_CODEX:
|
|
7059
7067
|
return clean
|
|
7060
7068
|
|
|
@@ -7200,7 +7208,7 @@ def preview_session_runtime_update(
|
|
|
7200
7208
|
|
|
7201
7209
|
current_connection = sess.get("provider_connection") or "default"
|
|
7202
7210
|
connection = (patch.get("provider_connection") or "default") if "provider_connection" in patch else "default" if backend_changed else current_connection
|
|
7203
|
-
if connection not in {"default", "custom"} or connection == "custom" and prospective_backend not in {BACKEND_CLAUDE, BACKEND_OPENCODE, BACKEND_CURSOR}:
|
|
7211
|
+
if connection not in {"default", "custom"} or connection == "custom" and prospective_backend not in {BACKEND_CLAUDE, BACKEND_OPENCODE, BACKEND_CURSOR, BACKEND_GEMINI}:
|
|
7204
7212
|
raise HTTPException(400, "This backend does not support that API connection.")
|
|
7205
7213
|
connection_changed = current_connection != connection
|
|
7206
7214
|
if connection_changed and session_backend_locked(sess):
|
|
@@ -7212,11 +7220,21 @@ def preview_session_runtime_update(
|
|
|
7212
7220
|
if "model" in patch
|
|
7213
7221
|
else None if backend_changed or provider_changed else sess.get("model")
|
|
7214
7222
|
)
|
|
7223
|
+
previous_model = prospective_model
|
|
7224
|
+
if prospective_backend == BACKEND_CODEX and prospective_provider == "default" and prospective_model:
|
|
7225
|
+
prospective_model = codex_available_model(str(prospective_model))
|
|
7215
7226
|
prospective_effort = (
|
|
7216
7227
|
patch.get("effort")
|
|
7217
7228
|
if "effort" in patch
|
|
7218
7229
|
else None if backend_changed or provider_changed else sess.get("effort")
|
|
7219
7230
|
)
|
|
7231
|
+
if prospective_model != previous_model and prospective_effort:
|
|
7232
|
+
# Saved/queued settings may contain a level unsupported by the new
|
|
7233
|
+
# model. Validate the vocabulary, then preserve the nearest level.
|
|
7234
|
+
prospective_effort = clamp_codex_runtime_effort(
|
|
7235
|
+
str(prospective_model),
|
|
7236
|
+
normalize_runtime_effort(BACKEND_CODEX, prospective_effort, strict="effort" in patch),
|
|
7237
|
+
)
|
|
7220
7238
|
normalized_effort = (
|
|
7221
7239
|
normalize_runtime_effort(prospective_backend, prospective_effort, strict="effort" in patch)
|
|
7222
7240
|
if prospective_provider == "custom" else
|
|
@@ -7235,7 +7253,7 @@ def preview_session_runtime_update(
|
|
|
7235
7253
|
preview["model"] = prospective_model
|
|
7236
7254
|
preview["effort"] = normalized_effort
|
|
7237
7255
|
preview["provider_connection"] = connection
|
|
7238
|
-
if connection_changed or connection == "default":
|
|
7256
|
+
if backend_changed or connection_changed or connection == "default":
|
|
7239
7257
|
preview.pop("provider_connection_revision", None)
|
|
7240
7258
|
if connection == "custom":
|
|
7241
7259
|
selected_connection = PROVIDER_CONNECTION_STORE.bind(preview)
|
|
@@ -7257,6 +7275,7 @@ def preview_session_runtime_update(
|
|
|
7257
7275
|
BACKEND_CODEX: "codex_thread_id",
|
|
7258
7276
|
BACKEND_CURSOR: "cursor_session_id",
|
|
7259
7277
|
BACKEND_OPENCODE: "opencode_session_id",
|
|
7278
|
+
BACKEND_GEMINI: "gemini_session_id",
|
|
7260
7279
|
}[prospective_backend]
|
|
7261
7280
|
preview["session_id"] = sess.get(provider_id_field)
|
|
7262
7281
|
return preview
|
|
@@ -9648,35 +9667,66 @@ def normalized_emergency_alert(value: Any) -> dict[str, Any] | None:
|
|
|
9648
9667
|
}
|
|
9649
9668
|
|
|
9650
9669
|
|
|
9651
|
-
def reversed_jsonl_events(
|
|
9652
|
-
|
|
9670
|
+
def reversed_jsonl_events(
|
|
9671
|
+
path: Path, *, retained_fields: frozenset[str] | None = None,
|
|
9672
|
+
) -> Iterator[dict[str, Any]]:
|
|
9673
|
+
"""Read newest-first in linear work, without accumulating a giant line.
|
|
9674
|
+
|
|
9675
|
+
Find offsets in fixed blocks. Emergency readers only retain lifecycle
|
|
9676
|
+
fields; unrelated text/media is validated and skipped in bounded chunks.
|
|
9677
|
+
Other consumers retain the full event contract.
|
|
9678
|
+
"""
|
|
9653
9679
|
|
|
9654
9680
|
if not path.exists():
|
|
9655
9681
|
return
|
|
9656
9682
|
chunk_size = 256 * 1024
|
|
9657
9683
|
with path.open("rb") as source:
|
|
9658
9684
|
source.seek(0, os.SEEK_END)
|
|
9659
|
-
position = source.tell()
|
|
9660
|
-
|
|
9685
|
+
position = end = source.tell()
|
|
9686
|
+
|
|
9687
|
+
def read_event(start: int, stop: int) -> dict[str, Any] | None:
|
|
9688
|
+
if stop <= start:
|
|
9689
|
+
return None
|
|
9690
|
+
source.seek(start)
|
|
9691
|
+
if retained_fields is not None:
|
|
9692
|
+
for event, _, _ in jsonl_records(
|
|
9693
|
+
source, end=stop, project=False, require_newline=False,
|
|
9694
|
+
retained_fields=retained_fields, deadline=float("inf"),
|
|
9695
|
+
# This is a server-owned journal, not a native import.
|
|
9696
|
+
# A tool payload can legitimately contain many values.
|
|
9697
|
+
# Keep retained metadata bounded; scan omitted values in
|
|
9698
|
+
# linear time without the importer's value-count ceiling.
|
|
9699
|
+
max_nodes=None, max_depth=512,
|
|
9700
|
+
):
|
|
9701
|
+
return event
|
|
9702
|
+
return None
|
|
9703
|
+
with suppress(ValueError, UnicodeError):
|
|
9704
|
+
event = json.loads(source.read(stop - start).decode("utf-8", "replace"))
|
|
9705
|
+
if isinstance(event, dict):
|
|
9706
|
+
return event
|
|
9707
|
+
return None
|
|
9708
|
+
|
|
9661
9709
|
while position > 0:
|
|
9662
9710
|
read_size = min(chunk_size, position)
|
|
9663
9711
|
position -= read_size
|
|
9664
9712
|
source.seek(position)
|
|
9665
|
-
block = source.read(read_size)
|
|
9666
|
-
|
|
9667
|
-
|
|
9668
|
-
|
|
9669
|
-
|
|
9670
|
-
|
|
9671
|
-
with suppress(Exception):
|
|
9672
|
-
event = json.loads(raw_line.decode("utf-8", "replace"))
|
|
9673
|
-
if isinstance(event, dict):
|
|
9674
|
-
yield event
|
|
9675
|
-
if remainder.strip():
|
|
9676
|
-
with suppress(Exception):
|
|
9677
|
-
event = json.loads(remainder.decode("utf-8", "replace"))
|
|
9678
|
-
if isinstance(event, dict):
|
|
9713
|
+
block = source.read(read_size)
|
|
9714
|
+
offset = len(block)
|
|
9715
|
+
while (offset := block.rfind(b"\n", 0, offset)) >= 0:
|
|
9716
|
+
start = position + offset + 1
|
|
9717
|
+
event = read_event(start, end)
|
|
9718
|
+
if event is not None:
|
|
9679
9719
|
yield event
|
|
9720
|
+
end = start - 1
|
|
9721
|
+
event = read_event(0, end)
|
|
9722
|
+
if event is not None:
|
|
9723
|
+
yield event
|
|
9724
|
+
|
|
9725
|
+
|
|
9726
|
+
EMERGENCY_EVENT_FIELDS = frozenset({
|
|
9727
|
+
"id", "seq", "type", "ts", "session_id", "run_id",
|
|
9728
|
+
"emergency_alert", "emergency_alert_id", "emergency_request_id", "emergency_request_digest",
|
|
9729
|
+
})
|
|
9680
9730
|
|
|
9681
9731
|
|
|
9682
9732
|
def emergency_lifecycle_events_after(
|
|
@@ -9687,7 +9737,9 @@ def emergency_lifecycle_events_after(
|
|
|
9687
9737
|
|
|
9688
9738
|
newest_seq = max(0, int(after_seq or 0))
|
|
9689
9739
|
events: list[dict[str, Any]] = []
|
|
9690
|
-
for event in reversed_jsonl_events(
|
|
9740
|
+
for event in reversed_jsonl_events(
|
|
9741
|
+
events_path(session_id), retained_fields=EMERGENCY_EVENT_FIELDS,
|
|
9742
|
+
):
|
|
9691
9743
|
seq = int(event.get("seq") or 0)
|
|
9692
9744
|
newest_seq = max(newest_seq, seq)
|
|
9693
9745
|
if seq <= after_seq:
|
|
@@ -10178,6 +10230,9 @@ class SessionStore:
|
|
|
10178
10230
|
if reconcile_session_emergency_alerts(session_id, sess):
|
|
10179
10231
|
runtime_changed = True
|
|
10180
10232
|
backend = str(sess.get("backend") or DEFAULT_BACKEND).strip().lower()
|
|
10233
|
+
if not sess.get("gemini_permission_mode"):
|
|
10234
|
+
sess["gemini_permission_mode"] = "default"
|
|
10235
|
+
runtime_changed = True
|
|
10181
10236
|
claude_permission_mode = effective_claude_permission_mode(sess)
|
|
10182
10237
|
if sess.get("claude_permission_mode") != claude_permission_mode:
|
|
10183
10238
|
sess["claude_permission_mode"] = claude_permission_mode
|
|
@@ -10542,13 +10597,25 @@ class SessionStore:
|
|
|
10542
10597
|
if initializing_fork and parent_id and req.provider_connection == "custom":
|
|
10543
10598
|
parent = self.sessions.get(parent_id) or {}
|
|
10544
10599
|
runtime_source.update({name: parent.get(name) for name in ("provider_connection", "provider_connection_revision")})
|
|
10545
|
-
if req.provider_connection == "custom" and not initializing_fork and any((req.provider_session_id, req.session_id, req.claude_session_id, req.opencode_session_id)):
|
|
10600
|
+
if backend != BACKEND_GEMINI and req.provider_connection == "custom" and not initializing_fork and any((req.provider_session_id, req.session_id, req.claude_session_id, req.opencode_session_id)):
|
|
10546
10601
|
raise HTTPException(409, "Import native conversations with their native login. Create a new chat for a custom API.")
|
|
10547
10602
|
runtime = preview_session_runtime_update(runtime_source, {
|
|
10548
10603
|
"codex_provider": req.codex_provider, "provider_connection": req.provider_connection, "model": req.model, "effort": req.effort,
|
|
10549
10604
|
})
|
|
10550
10605
|
model, effort = runtime["model"], runtime["effort"]
|
|
10551
10606
|
session_cwd = req.cwd or DEFAULT_CWD
|
|
10607
|
+
if backend == BACKEND_GEMINI:
|
|
10608
|
+
if req.provider_session_id or req.session_id or req.import_history:
|
|
10609
|
+
from gemini_import import initial_history
|
|
10610
|
+
from gemini_acp_client import GeminiProtocolError
|
|
10611
|
+
try:
|
|
10612
|
+
await asyncio.to_thread(initial_history, sys.modules[__name__], runner_env(),
|
|
10613
|
+
req.provider_session_id or req.session_id or "", str(Path(session_cwd).expanduser().resolve()))
|
|
10614
|
+
except (GeminiProtocolError, OSError, ValueError):
|
|
10615
|
+
raise HTTPException(400, "Gemini history does not uniquely match this session ID and workspace.") from None
|
|
10616
|
+
if not Path(session_cwd).expanduser().is_dir():
|
|
10617
|
+
raise HTTPException(400, "Gemini requires an existing workspace directory.")
|
|
10618
|
+
session_cwd = str(Path(session_cwd).expanduser().resolve())
|
|
10552
10619
|
if backend == BACKEND_OPENCODE:
|
|
10553
10620
|
session_cwd = validated_opencode_cwd(
|
|
10554
10621
|
session_cwd,
|
|
@@ -10585,6 +10652,7 @@ class SessionStore:
|
|
|
10585
10652
|
claude_session_id if backend == BACKEND_CLAUDE
|
|
10586
10653
|
else codex_thread_id if backend == BACKEND_CODEX
|
|
10587
10654
|
else cursor_session_id if backend == BACKEND_CURSOR
|
|
10655
|
+
else provider_id if backend == BACKEND_GEMINI
|
|
10588
10656
|
else opencode_session_id
|
|
10589
10657
|
)
|
|
10590
10658
|
# A Claude chat can park an inactive Codex identity for later backend
|
|
@@ -10651,6 +10719,9 @@ class SessionStore:
|
|
|
10651
10719
|
"claude_session_id": claude_session_id,
|
|
10652
10720
|
"codex_thread_id": codex_thread_id,
|
|
10653
10721
|
"cursor_session_id": cursor_session_id,
|
|
10722
|
+
"gemini_session_id": provider_id if backend == BACKEND_GEMINI else None,
|
|
10723
|
+
"gemini_native_history": bool(backend == BACKEND_GEMINI and provider_id),
|
|
10724
|
+
"gemini_session_cwd": session_cwd if backend == BACKEND_GEMINI and provider_id else None,
|
|
10654
10725
|
"opencode_session_id": opencode_session_id,
|
|
10655
10726
|
"opencode_session_cwd": (
|
|
10656
10727
|
session_cwd
|
|
@@ -10670,6 +10741,7 @@ class SessionStore:
|
|
|
10670
10741
|
"opencode_permission_mode": (
|
|
10671
10742
|
req.opencode_permission_mode or OPENCODE_DEFAULT_PERMISSION_MODE
|
|
10672
10743
|
),
|
|
10744
|
+
"gemini_permission_mode": req.gemini_permission_mode or "default",
|
|
10673
10745
|
"codex_approval_policy": (
|
|
10674
10746
|
req.codex_approval_policy or CODEX_DEFAULT_APPROVAL_POLICY
|
|
10675
10747
|
),
|
|
@@ -10706,6 +10778,8 @@ class SessionStore:
|
|
|
10706
10778
|
"created_at": now,
|
|
10707
10779
|
"updated_at": now,
|
|
10708
10780
|
}
|
|
10781
|
+
if not active_provider_id and not parent_id and sess["title"] == "New chat":
|
|
10782
|
+
sess["_empty_chat_cleanup"] = {"version": 1, "dismissed_by": {}}
|
|
10709
10783
|
if req.subagent_limit is not None:
|
|
10710
10784
|
sess["subagent_limit"] = req.subagent_limit
|
|
10711
10785
|
if initializing_fork and parent_id:
|
|
@@ -10864,6 +10938,7 @@ class SessionStore:
|
|
|
10864
10938
|
if patch.get("auto_title_enabled") is not None:
|
|
10865
10939
|
sess["auto_title_enabled"] = bool(patch["auto_title_enabled"])
|
|
10866
10940
|
for key, default, allowed in (
|
|
10941
|
+
("gemini_permission_mode", "default", {"default", "autoEdit", "yolo", "plan"}),
|
|
10867
10942
|
(
|
|
10868
10943
|
"claude_permission_mode",
|
|
10869
10944
|
CLAUDE_DEFAULT_PERMISSION_MODE,
|
|
@@ -10939,14 +11014,15 @@ class SessionStore:
|
|
|
10939
11014
|
sess["subagent_limit"] = patch["subagent_limit"]
|
|
10940
11015
|
if "system_prompt" in patch:
|
|
10941
11016
|
sess["system_prompt"] = clean_session_system_prompt(patch["system_prompt"])
|
|
11017
|
+
if {"cwd", "backend", "provider_connection"}.intersection(patch):
|
|
11018
|
+
sess.pop("gemini_permission_modes", None)
|
|
10942
11019
|
for key in ("model", "effort"):
|
|
10943
11020
|
if key in patch:
|
|
10944
11021
|
value = patch[key]
|
|
10945
11022
|
if key == "effort":
|
|
10946
11023
|
sess[key] = normalized_prospective_effort
|
|
10947
11024
|
else:
|
|
10948
|
-
|
|
10949
|
-
sess[key] = clean or None
|
|
11025
|
+
sess[key] = runtime_preview["model"]
|
|
10950
11026
|
if reset_implicit_incompatible_effort:
|
|
10951
11027
|
# Older clients update the model and effort in separate
|
|
10952
11028
|
# requests. Let that model change succeed while clearing a
|
|
@@ -11066,7 +11142,8 @@ class SessionStore:
|
|
|
11066
11142
|
}
|
|
11067
11143
|
sess.update(title=title, _title_source=source, _title_auto_value=title)
|
|
11068
11144
|
if source == "prompt" and isinstance(prompt_seed, str):
|
|
11069
|
-
sess["_title_seed"] = prompt_seed
|
|
11145
|
+
sess["_title_seed"] = (title_generation.gemini_title_seed(prompt_seed)
|
|
11146
|
+
if sess.get("backend") == BACKEND_GEMINI else prompt_seed[:1600])
|
|
11070
11147
|
elif source in {"provider", "generated"}:
|
|
11071
11148
|
sess.pop("_title_seed", None)
|
|
11072
11149
|
sess["updated_at"] = now_iso()
|
|
@@ -11203,9 +11280,18 @@ class SessionStore:
|
|
|
11203
11280
|
sid: str,
|
|
11204
11281
|
event: dict[str, Any],
|
|
11205
11282
|
) -> dict[str, Any]:
|
|
11283
|
+
# Disk replay must not hold the global sessions lock: other chats and
|
|
11284
|
+
# health/recovery controls must remain usable during a large scan.
|
|
11285
|
+
snapshot = self.sessions.get(sid)
|
|
11286
|
+
if not snapshot:
|
|
11287
|
+
raise HTTPException(status_code=404, detail="session not found")
|
|
11288
|
+
scan_after = max(0, int(snapshot.get("_emergency_reconciled_through_seq") or 0))
|
|
11289
|
+
lifecycle_events, reconciled_through = await asyncio.to_thread(
|
|
11290
|
+
emergency_lifecycle_events_after, sid, scan_after,
|
|
11291
|
+
)
|
|
11206
11292
|
async with self._lock:
|
|
11207
11293
|
sess = self.sessions.get(sid)
|
|
11208
|
-
if not sess:
|
|
11294
|
+
if not sess or sess is not snapshot:
|
|
11209
11295
|
raise HTTPException(status_code=404, detail="session not found")
|
|
11210
11296
|
previous_present = "_emergency_alerts" in sess
|
|
11211
11297
|
previous = sess.get("_emergency_alerts")
|
|
@@ -11215,11 +11301,10 @@ class SessionStore:
|
|
|
11215
11301
|
0,
|
|
11216
11302
|
int(sess.get("_emergency_reconciled_through_seq") or 0),
|
|
11217
11303
|
)
|
|
11218
|
-
|
|
11219
|
-
|
|
11220
|
-
|
|
11221
|
-
|
|
11222
|
-
)
|
|
11304
|
+
# A concurrent projection may have advanced while we read. Never
|
|
11305
|
+
# replay an older raise over a newer acknowledgement.
|
|
11306
|
+
lifecycle_events = [candidate for candidate in lifecycle_events
|
|
11307
|
+
if int(candidate.get("seq") or 0) > previous_reconciled_through]
|
|
11223
11308
|
event_seq = int(event.get("seq") or 0)
|
|
11224
11309
|
if (
|
|
11225
11310
|
event_seq > previous_reconciled_through
|
|
@@ -11390,6 +11475,9 @@ class SessionStore:
|
|
|
11390
11475
|
sess.pop("cursor_instruction_version", None)
|
|
11391
11476
|
elif backend == BACKEND_OPENCODE:
|
|
11392
11477
|
sess["opencode_session_id"] = provider_id
|
|
11478
|
+
elif backend == BACKEND_GEMINI:
|
|
11479
|
+
sess["gemini_session_id"] = provider_id
|
|
11480
|
+
sess["gemini_session_cwd"] = cwd
|
|
11393
11481
|
if backend == BACKEND_CLAUDE and cwd:
|
|
11394
11482
|
sess["claude_session_cwd"] = cwd
|
|
11395
11483
|
if backend == BACKEND_OPENCODE and cwd:
|
|
@@ -13082,6 +13170,9 @@ class JobStore:
|
|
|
13082
13170
|
client_capabilities=[
|
|
13083
13171
|
ASYNC_ROUTE_V1_CLIENT_CAPABILITY,
|
|
13084
13172
|
*([CROSS_CHAT_HANDOFFS_V2_CLIENT_CAPABILITY] if chat_references else []),
|
|
13173
|
+
*([GEMINI_CLIENT_CAPABILITY] if (
|
|
13174
|
+
job.get("backend") or (parent_session or {}).get("backend")
|
|
13175
|
+
) == BACKEND_GEMINI else []),
|
|
13085
13176
|
],
|
|
13086
13177
|
chat_references=chat_references,
|
|
13087
13178
|
team_references=team_references,
|
|
@@ -14101,6 +14192,8 @@ RUN_NOW_TURNS: dict[str, dict[str, Any]] = {}
|
|
|
14101
14192
|
# that the scan has not discovered yet. A completed failed task intentionally
|
|
14102
14193
|
# remains installed and keeps admission fail-closed.
|
|
14103
14194
|
QUEUE_RECOVERY_TASK: asyncio.Task[tuple[int, int]] | None = None
|
|
14195
|
+
QUEUE_RECOVERY_READY: set[str] = set()
|
|
14196
|
+
QUEUE_CHECKPOINT_DIRTY: set[str] = set()
|
|
14104
14197
|
QUEUE_RECOVERY_ADMISSION_WAIT_SECONDS = 2.0
|
|
14105
14198
|
STEERING_SESSIONS: set[str] = set()
|
|
14106
14199
|
# A non-native Force Send keeps the steering fence while the interrupted
|
|
@@ -16561,6 +16654,7 @@ HANDOFF_DIGEST_FINALIZING: set[str] = set()
|
|
|
16561
16654
|
RUNTIME_DIAGNOSTICS: dict[str, dict[str, Any]] = {}
|
|
16562
16655
|
RUNTIME_DIAGNOSTICS_LOCK = threading.RLock()
|
|
16563
16656
|
RUNTIME_DIAGNOSTIC_GENERATIONS: dict[str, int] = {}
|
|
16657
|
+
CODEX_MODEL_MIGRATION_TARGET = "gpt-6-luna"
|
|
16564
16658
|
ABANDONED_FORK_PROVIDER_THREADS: set[str] = set()
|
|
16565
16659
|
ABANDONED_FORK_PROVIDER_THREADS_LOCK = asyncio.Lock()
|
|
16566
16660
|
|
|
@@ -17365,6 +17459,7 @@ def should_bump_session_updated_at(event_type: str, event: dict[str, Any]) -> bo
|
|
|
17365
17459
|
|
|
17366
17460
|
|
|
17367
17461
|
async def update_session_event_metadata(session_id: str, event: dict[str, Any]) -> None:
|
|
17462
|
+
QUEUE_CHECKPOINT_DIRTY.add(session_id)
|
|
17368
17463
|
sess = STORE.sessions.get(session_id)
|
|
17369
17464
|
if not sess:
|
|
17370
17465
|
return
|
|
@@ -17492,7 +17587,22 @@ async def update_session_event_metadata(session_id: str, event: dict[str, Any])
|
|
|
17492
17587
|
await STORE.save(flush=False)
|
|
17493
17588
|
|
|
17494
17589
|
|
|
17495
|
-
|
|
17590
|
+
def queue_recovery_readiness() -> dict[str, Any]:
|
|
17591
|
+
recovery = QUEUE_RECOVERY_TASK
|
|
17592
|
+
if recovery is None or (recovery.done() and not recovery.cancelled() and recovery.exception() is None):
|
|
17593
|
+
return {"ready": True, "state": "ready", "message": None}
|
|
17594
|
+
if not recovery.done():
|
|
17595
|
+
return {
|
|
17596
|
+
"ready": False, "state": "recovering",
|
|
17597
|
+
"message": "Queued messages are being restored after restart. Retry shortly; do not resend accepted messages.",
|
|
17598
|
+
}
|
|
17599
|
+
return {
|
|
17600
|
+
"ready": False, "state": "failed",
|
|
17601
|
+
"message": "Queued message recovery failed. Chat admission remains closed to preserve saved messages. Restart the managed server; if it repeats, inspect server logs.",
|
|
17602
|
+
}
|
|
17603
|
+
|
|
17604
|
+
|
|
17605
|
+
async def wait_for_queue_recovery_admission(session_id: str | None = None) -> None:
|
|
17496
17606
|
"""Wait briefly for the one startup queue scan, then fail retryably.
|
|
17497
17607
|
|
|
17498
17608
|
The shared recovery task is shielded because disconnecting one request
|
|
@@ -17501,42 +17611,34 @@ async def wait_for_queue_recovery_admission() -> None:
|
|
|
17501
17611
|
ordering hole with only a partial in-memory queue.
|
|
17502
17612
|
"""
|
|
17503
17613
|
|
|
17614
|
+
if session_id is not None and session_id in QUEUE_RECOVERY_READY:
|
|
17615
|
+
return
|
|
17504
17616
|
recovery = QUEUE_RECOVERY_TASK
|
|
17505
17617
|
if recovery is None or recovery is asyncio.current_task():
|
|
17506
17618
|
return
|
|
17507
|
-
|
|
17508
|
-
|
|
17509
|
-
|
|
17510
|
-
|
|
17511
|
-
|
|
17512
|
-
|
|
17619
|
+
# asyncio.wait does not propagate the task's own TimeoutError and never
|
|
17620
|
+
# cancels the shared recovery when this request times out/disconnects.
|
|
17621
|
+
done, _ = await asyncio.wait(
|
|
17622
|
+
{recovery}, timeout=max(0.0, QUEUE_RECOVERY_ADMISSION_WAIT_SECONDS),
|
|
17623
|
+
)
|
|
17624
|
+
if not done:
|
|
17513
17625
|
raise TransientAdmissionWait(
|
|
17514
17626
|
status_code=503,
|
|
17515
|
-
detail=
|
|
17516
|
-
|
|
17517
|
-
|
|
17518
|
-
|
|
17519
|
-
) from exc
|
|
17627
|
+
detail="queued turn recovery is still in progress; retry this request shortly",
|
|
17628
|
+
)
|
|
17629
|
+
try:
|
|
17630
|
+
recovery.result()
|
|
17520
17631
|
except asyncio.CancelledError:
|
|
17521
|
-
|
|
17522
|
-
|
|
17523
|
-
|
|
17524
|
-
|
|
17525
|
-
|
|
17526
|
-
|
|
17527
|
-
detail=(
|
|
17528
|
-
"queued turn recovery did not complete; restart or retry "
|
|
17529
|
-
"the managed server"
|
|
17530
|
-
),
|
|
17531
|
-
) from None
|
|
17532
|
-
raise
|
|
17632
|
+
if SERVER_SHUTTING_DOWN:
|
|
17633
|
+
raise
|
|
17634
|
+
raise TransientAdmissionWait(
|
|
17635
|
+
status_code=503,
|
|
17636
|
+
detail="queued turn recovery did not complete; restart the managed server; if it repeats, inspect server logs",
|
|
17637
|
+
) from None
|
|
17533
17638
|
except Exception as exc:
|
|
17534
17639
|
raise TransientAdmissionWait(
|
|
17535
17640
|
status_code=503,
|
|
17536
|
-
detail=
|
|
17537
|
-
"queued turn recovery did not complete; restart or retry the "
|
|
17538
|
-
"managed server"
|
|
17539
|
-
),
|
|
17641
|
+
detail="queued turn recovery did not complete; restart the managed server; if it repeats, inspect server logs",
|
|
17540
17642
|
) from exc
|
|
17541
17643
|
|
|
17542
17644
|
|
|
@@ -17549,7 +17651,7 @@ async def enqueue_turn(
|
|
|
17549
17651
|
secure_peer_route_snapshots: list[dict[str, Any]] | None = None,
|
|
17550
17652
|
reciprocal_route_grant: dict[str, Any] | None = None,
|
|
17551
17653
|
) -> dict[str, Any]:
|
|
17552
|
-
await wait_for_queue_recovery_admission()
|
|
17654
|
+
await wait_for_queue_recovery_admission(session_id)
|
|
17553
17655
|
if not routed_references_match_visible_prompt(
|
|
17554
17656
|
req.prompt,
|
|
17555
17657
|
req.display_prompt,
|
|
@@ -17821,6 +17923,7 @@ async def enqueue_turn(
|
|
|
17821
17923
|
),
|
|
17822
17924
|
"prompt": display_prompt,
|
|
17823
17925
|
"request_prompt": req.prompt,
|
|
17926
|
+
"client_request_id": getattr(req, "client_request_id", None),
|
|
17824
17927
|
"display_prompt": req.display_prompt,
|
|
17825
17928
|
"file_ids": list(req.file_ids),
|
|
17826
17929
|
"position": position,
|
|
@@ -18086,7 +18189,7 @@ async def enqueue_turn(
|
|
|
18086
18189
|
|
|
18087
18190
|
|
|
18088
18191
|
async def unqueue_turn(session_id: str, queued_id: str) -> dict[str, Any]:
|
|
18089
|
-
await wait_for_queue_recovery_admission()
|
|
18192
|
+
await wait_for_queue_recovery_admission(session_id)
|
|
18090
18193
|
if session_id not in STORE.sessions:
|
|
18091
18194
|
raise HTTPException(status_code=404, detail="session not found")
|
|
18092
18195
|
|
|
@@ -20750,7 +20853,7 @@ def provider_tool_active_matches(
|
|
|
20750
20853
|
expected_transport = (
|
|
20751
20854
|
CODEX_TRANSPORT_APP_SERVER
|
|
20752
20855
|
if backend == BACKEND_CODEX
|
|
20753
|
-
else "exec" if backend in {BACKEND_CURSOR, BACKEND_OPENCODE}
|
|
20856
|
+
else "exec" if backend in {BACKEND_CURSOR, BACKEND_OPENCODE, BACKEND_GEMINI}
|
|
20754
20857
|
else CLAUDE_TRANSPORT_AGENT_SDK
|
|
20755
20858
|
)
|
|
20756
20859
|
matches = bool(
|
|
@@ -20775,7 +20878,7 @@ def provider_tool_active_matches(
|
|
|
20775
20878
|
and str(active.get("provider_turn_id") or "") == provider_turn_id
|
|
20776
20879
|
and active.get("provider_turn_ready") is True
|
|
20777
20880
|
)
|
|
20778
|
-
elif backend in {BACKEND_CURSOR, BACKEND_OPENCODE}:
|
|
20881
|
+
elif backend in {BACKEND_CURSOR, BACKEND_OPENCODE, BACKEND_GEMINI}:
|
|
20779
20882
|
proc = active.get("proc")
|
|
20780
20883
|
matches = bool(
|
|
20781
20884
|
matches and cursor_owner_token
|
|
@@ -20848,7 +20951,7 @@ async def provider_tool_capability_snapshot(
|
|
|
20848
20951
|
and codex_native_mailbox_owner_matches(session_id, run_id, provider_thread_id, provider_turn_id))
|
|
20849
20952
|
):
|
|
20850
20953
|
raise ProviderToolError("provider tool turn is stale")
|
|
20851
|
-
elif backend not in {BACKEND_CURSOR, BACKEND_OPENCODE}:
|
|
20954
|
+
elif backend not in {BACKEND_CURSOR, BACKEND_OPENCODE, BACKEND_GEMINI}:
|
|
20852
20955
|
raise ProviderToolError("provider tool backend is unsupported")
|
|
20853
20956
|
|
|
20854
20957
|
async with ACTIVE_LOCK:
|
|
@@ -22512,7 +22615,7 @@ def public_queued_turn(
|
|
|
22512
22615
|
|
|
22513
22616
|
|
|
22514
22617
|
async def queued_turns_snapshot(session_id: str) -> list[dict[str, Any]]:
|
|
22515
|
-
await wait_for_queue_recovery_admission()
|
|
22618
|
+
await wait_for_queue_recovery_admission(session_id)
|
|
22516
22619
|
async with QUEUE_LOCK:
|
|
22517
22620
|
queue = list(QUEUED_TURNS.get(session_id) or [])
|
|
22518
22621
|
run_now = RUN_NOW_TURNS.get(session_id)
|
|
@@ -22572,7 +22675,7 @@ def reject_promoted_queue_mutation(session_id: str, queued_id: str) -> None:
|
|
|
22572
22675
|
|
|
22573
22676
|
|
|
22574
22677
|
async def update_queued_turn(session_id: str, queued_id: str, req: UpdateQueuedTurnRequest) -> dict[str, Any]:
|
|
22575
|
-
await wait_for_queue_recovery_admission()
|
|
22678
|
+
await wait_for_queue_recovery_admission(session_id)
|
|
22576
22679
|
if session_id not in STORE.sessions:
|
|
22577
22680
|
raise HTTPException(status_code=404, detail="session not found")
|
|
22578
22681
|
async_result = await update_async_queued_message(session_id, queued_id, req)
|
|
@@ -23019,7 +23122,7 @@ async def update_queued_turn(session_id: str, queued_id: str, req: UpdateQueuedT
|
|
|
23019
23122
|
|
|
23020
23123
|
|
|
23021
23124
|
async def move_queued_turn(session_id: str, queued_id: str, req: MoveQueuedTurnRequest) -> dict[str, Any]:
|
|
23022
|
-
await wait_for_queue_recovery_admission()
|
|
23125
|
+
await wait_for_queue_recovery_admission(session_id)
|
|
23023
23126
|
if session_id not in STORE.sessions:
|
|
23024
23127
|
raise HTTPException(status_code=404, detail="session not found")
|
|
23025
23128
|
direction = req.direction.strip().lower()
|
|
@@ -23534,7 +23637,7 @@ async def reconcile_idle_queued_turns(*, reason: str) -> int:
|
|
|
23534
23637
|
|
|
23535
23638
|
async def run_queued_turn_now(session_id: str, queued_id: str) -> dict[str, Any]:
|
|
23536
23639
|
"""Coalesce duplicate Force Send requests without replaying a steer."""
|
|
23537
|
-
await wait_for_queue_recovery_admission()
|
|
23640
|
+
await wait_for_queue_recovery_admission(session_id)
|
|
23538
23641
|
ensure_session_not_deleting(session_id)
|
|
23539
23642
|
await reconcile_idle_queue_session(
|
|
23540
23643
|
session_id,
|
|
@@ -25179,7 +25282,7 @@ def schedule_steered_turn_slot_waiter(
|
|
|
25179
25282
|
|
|
25180
25283
|
|
|
25181
25284
|
async def start_next_queued_turn(session_id: str) -> None:
|
|
25182
|
-
await wait_for_queue_recovery_admission()
|
|
25285
|
+
await wait_for_queue_recovery_admission(session_id)
|
|
25183
25286
|
await reconcile_idle_queue_session(
|
|
25184
25287
|
session_id,
|
|
25185
25288
|
schedule=False,
|
|
@@ -25821,14 +25924,78 @@ def queued_turn_from_event(event: dict[str, Any], sess: dict[str, Any], position
|
|
|
25821
25924
|
}
|
|
25822
25925
|
|
|
25823
25926
|
|
|
25824
|
-
def queued_event_lines(path: Path, *, include_envelopes: bool = False
|
|
25927
|
+
def queued_event_lines(path: Path, *, include_envelopes: bool = False,
|
|
25928
|
+
scan: dict[str, Any] | None = None) -> Iterator[bytes]:
|
|
25825
25929
|
with path.open("rb") as source:
|
|
25826
|
-
|
|
25930
|
+
if scan is not None:
|
|
25931
|
+
source.seek(scan["offset"])
|
|
25932
|
+
while True:
|
|
25933
|
+
remaining = scan["end"] - source.tell() if scan is not None else -1
|
|
25934
|
+
if remaining == 0:
|
|
25935
|
+
break
|
|
25936
|
+
raw_line = source.readline(remaining)
|
|
25937
|
+
if not raw_line:
|
|
25938
|
+
break
|
|
25939
|
+
if scan is not None:
|
|
25940
|
+
if not raw_line.endswith(b"\n"):
|
|
25941
|
+
break
|
|
25942
|
+
scan["offset"] = source.tell()
|
|
25827
25943
|
if (b'"queued_id"' in raw_line or b'"turn_queue_paused"' in raw_line
|
|
25828
25944
|
or (include_envelopes and b'"cross_chat_envelope_id"' in raw_line)):
|
|
25829
25945
|
yield raw_line
|
|
25830
25946
|
|
|
25831
25947
|
|
|
25948
|
+
def queue_checkpoint_anchor(path: Path, offset: int) -> str:
|
|
25949
|
+
with path.open("rb") as stream:
|
|
25950
|
+
stream.seek(max(0, offset - 4096))
|
|
25951
|
+
return hashlib.sha256(stream.read(min(offset, 4096))).hexdigest()
|
|
25952
|
+
|
|
25953
|
+
|
|
25954
|
+
def queue_checkpoint_seed(path: Path, stamp, *, enabled: bool) -> tuple[dict, list]:
|
|
25955
|
+
if enabled:
|
|
25956
|
+
try:
|
|
25957
|
+
checkpoint_path = path.with_name("queue-recovery.json")
|
|
25958
|
+
if checkpoint_path.stat().st_size > 16 * 1024 * 1024:
|
|
25959
|
+
raise ValueError("oversized queue checkpoint")
|
|
25960
|
+
value = json.loads(checkpoint_path.read_text())
|
|
25961
|
+
offset = value["offset"]
|
|
25962
|
+
rows = value["pending"]
|
|
25963
|
+
if (value["version"] == 1 and value["dev"] == stamp.st_dev
|
|
25964
|
+
and value["ino"] == stamp.st_ino and isinstance(offset, int)
|
|
25965
|
+
and 0 <= offset <= stamp.st_size
|
|
25966
|
+
and (offset != stamp.st_size or value["mtime_ns"] == stamp.st_mtime_ns)
|
|
25967
|
+
and value["anchor"] == queue_checkpoint_anchor(path, offset)
|
|
25968
|
+
and isinstance(rows, list)
|
|
25969
|
+
and all(isinstance(row, dict) and isinstance(row.get("queued_id"), str) for row in rows)):
|
|
25970
|
+
return {"offset": offset, "end": stamp.st_size}, rows
|
|
25971
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
25972
|
+
pass
|
|
25973
|
+
return {"offset": 0, "end": stamp.st_size}, []
|
|
25974
|
+
|
|
25975
|
+
|
|
25976
|
+
def save_queue_checkpoint(path: Path, stamp, offset: int, rows: list) -> None:
|
|
25977
|
+
# These logs are server-owned append-only journals. Replacement/truncation
|
|
25978
|
+
# invalidates the checkpoint; a bounded boundary anchor detects regrowth.
|
|
25979
|
+
current = path.stat()
|
|
25980
|
+
if (current.st_dev, current.st_ino) != (stamp.st_dev, stamp.st_ino) or current.st_size < offset:
|
|
25981
|
+
return
|
|
25982
|
+
if current.st_size == stamp.st_size and current.st_mtime_ns != stamp.st_mtime_ns:
|
|
25983
|
+
return
|
|
25984
|
+
value = {"version": 1, "dev": stamp.st_dev, "ino": stamp.st_ino,
|
|
25985
|
+
"mtime_ns": stamp.st_mtime_ns, "offset": offset,
|
|
25986
|
+
"anchor": queue_checkpoint_anchor(path, offset), "pending": rows}
|
|
25987
|
+
target = path.with_name("queue-recovery.json")
|
|
25988
|
+
temporary = target.with_suffix(f".{uuid.uuid4().hex}.tmp")
|
|
25989
|
+
try:
|
|
25990
|
+
with temporary.open("x") as stream:
|
|
25991
|
+
json.dump(value, stream, separators=(",", ":"))
|
|
25992
|
+
stream.flush()
|
|
25993
|
+
os.fsync(stream.fileno())
|
|
25994
|
+
temporary.replace(target)
|
|
25995
|
+
finally:
|
|
25996
|
+
temporary.unlink(missing_ok=True)
|
|
25997
|
+
|
|
25998
|
+
|
|
25832
25999
|
def record_mailbox_migration_evidence(evidence: dict[str, dict[str, Any]], event: dict[str, Any]) -> None:
|
|
25833
26000
|
item = evidence.get(str(event.get("cross_chat_envelope_id") or ""))
|
|
25834
26001
|
if item is None:
|
|
@@ -25862,11 +26029,13 @@ def scan_queued_turns_from_events(
|
|
|
25862
26029
|
value["complete"] = False
|
|
25863
26030
|
if not path.exists():
|
|
25864
26031
|
continue
|
|
25865
|
-
|
|
26032
|
+
scan_stamp = path.stat()
|
|
26033
|
+
proof_stamp = scan_stamp if evidence else None
|
|
25866
26034
|
proof_complete = True
|
|
25867
|
-
|
|
25868
|
-
|
|
25869
|
-
|
|
26035
|
+
scan, prior_rows = queue_checkpoint_seed(path, scan_stamp, enabled=not evidence)
|
|
26036
|
+
pending: dict[str, dict[str, Any]] = {row["queued_id"]: row for row in prior_rows}
|
|
26037
|
+
order: list[str] = [row["queued_id"] for row in prior_rows]
|
|
26038
|
+
for raw_line in queued_event_lines(path, include_envelopes=bool(evidence), scan=scan):
|
|
25870
26039
|
try:
|
|
25871
26040
|
event = json.loads(raw_line.decode("utf-8", "replace"))
|
|
25872
26041
|
except Exception:
|
|
@@ -26026,7 +26195,9 @@ def scan_queued_turns_from_events(
|
|
|
26026
26195
|
unchanged = ((proof_stamp.st_dev, proof_stamp.st_ino, proof_stamp.st_size, proof_stamp.st_mtime_ns)
|
|
26027
26196
|
== (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns))
|
|
26028
26197
|
for value in evidence.values():
|
|
26029
|
-
|
|
26198
|
+
# A snapshot ending in a partial record was not fully scanned.
|
|
26199
|
+
# It may hide a terminal/admission event, so never migrate it.
|
|
26200
|
+
value["complete"] = proof_complete and unchanged and scan["offset"] == scan["end"]
|
|
26030
26201
|
items = [
|
|
26031
26202
|
pending[qid]
|
|
26032
26203
|
for qid in order
|
|
@@ -26037,6 +26208,9 @@ def scan_queued_turns_from_events(
|
|
|
26037
26208
|
]
|
|
26038
26209
|
if items:
|
|
26039
26210
|
recovered[session_id] = items
|
|
26211
|
+
if proof_complete:
|
|
26212
|
+
with suppress(OSError):
|
|
26213
|
+
save_queue_checkpoint(path, scan_stamp, scan["offset"], [pending[qid] for qid in order if qid in pending])
|
|
26040
26214
|
return recovered
|
|
26041
26215
|
|
|
26042
26216
|
|
|
@@ -26200,13 +26374,48 @@ async def migrate_unstarted_chat_mailbox_backlog(
|
|
|
26200
26374
|
return migrated
|
|
26201
26375
|
|
|
26202
26376
|
|
|
26377
|
+
async def queue_checkpoint_loop() -> None:
|
|
26378
|
+
while True:
|
|
26379
|
+
await asyncio.sleep(60)
|
|
26380
|
+
if QUEUE_RECOVERY_TASK is not None:
|
|
26381
|
+
continue
|
|
26382
|
+
for session_id in tuple(QUEUE_CHECKPOINT_DIRTY):
|
|
26383
|
+
sess = STORE.sessions.get(session_id)
|
|
26384
|
+
QUEUE_CHECKPOINT_DIRTY.discard(session_id)
|
|
26385
|
+
if not sess or session_id in DELETING_SESSIONS:
|
|
26386
|
+
continue
|
|
26387
|
+
try:
|
|
26388
|
+
await asyncio.to_thread(scan_queued_turns_from_events, [(session_id, dict(sess))])
|
|
26389
|
+
except Exception as exc:
|
|
26390
|
+
QUEUE_CHECKPOINT_DIRTY.add(session_id)
|
|
26391
|
+
logger.warning("queue checkpoint deferred session=%s error=%s", session_id, type(exc).__name__)
|
|
26392
|
+
|
|
26393
|
+
|
|
26203
26394
|
async def recover_queued_turns_after_start() -> tuple[int, int]:
|
|
26395
|
+
QUEUE_RECOVERY_READY.clear()
|
|
26396
|
+
candidates = await CROSS_CHAT.pending_mailbox_migration_candidates()
|
|
26397
|
+
sessions = [(sid, dict(sess)) for sid, sess in STORE.sessions.items()]
|
|
26398
|
+
# Recover small/checkpointed chats first. A legacy giant only fences itself.
|
|
26399
|
+
def recovery_cost(item):
|
|
26400
|
+
path = events_path(item[0])
|
|
26401
|
+
try:
|
|
26402
|
+
checkpoint = json.loads(path.with_name("queue-recovery.json").read_text())
|
|
26403
|
+
return max(0, path.stat().st_size - int(checkpoint.get("offset", 0)))
|
|
26404
|
+
except (OSError, ValueError, TypeError):
|
|
26405
|
+
return path.stat().st_size if path.exists() else 0
|
|
26406
|
+
sessions = await asyncio.to_thread(lambda: sorted(sessions, key=recovery_cost))
|
|
26407
|
+
rebuilt = scheduled = 0
|
|
26408
|
+
for sid, sess in sessions:
|
|
26409
|
+
relevant = [row for row in candidates if row.get("target_session_id") == sid]
|
|
26410
|
+
count, started = await _recover_queued_turns_for_sessions([(sid, sess)], relevant)
|
|
26411
|
+
rebuilt += count
|
|
26412
|
+
scheduled += started
|
|
26413
|
+
QUEUE_RECOVERY_READY.add(sid)
|
|
26414
|
+
return rebuilt, scheduled
|
|
26415
|
+
|
|
26416
|
+
|
|
26417
|
+
async def _recover_queued_turns_for_sessions(session_items, migration_candidates) -> tuple[int, int]:
|
|
26204
26418
|
started = time.monotonic()
|
|
26205
|
-
session_items = [
|
|
26206
|
-
(session_id, dict(sess))
|
|
26207
|
-
for session_id, sess in STORE.sessions.items()
|
|
26208
|
-
]
|
|
26209
|
-
migration_candidates = await CROSS_CHAT.pending_mailbox_migration_candidates()
|
|
26210
26419
|
mailbox_evidence = {str(record["id"]): {"target_session_id": record["target_session_id"]}
|
|
26211
26420
|
for record in migration_candidates if is_async_route_message(record)}
|
|
26212
26421
|
recovered = await asyncio.to_thread(scan_queued_turns_from_events, session_items,
|
|
@@ -27595,6 +27804,40 @@ def linux_process_still_exists(
|
|
|
27595
27804
|
return True
|
|
27596
27805
|
|
|
27597
27806
|
|
|
27807
|
+
def systemd_worker_preserves_descendants(service_cgroup: str) -> bool:
|
|
27808
|
+
"""Prove the effective stop policy of this exact native worker, read-only.
|
|
27809
|
+
|
|
27810
|
+
Never infer policy from an on-disk unit: runtime drop-ins and an unloaded
|
|
27811
|
+
replacement can differ. Unknown/malformed ownership retains the existing
|
|
27812
|
+
requirement that the service cgroup contain no other processes.
|
|
27813
|
+
"""
|
|
27814
|
+
|
|
27815
|
+
if not sys.platform.startswith("linux"):
|
|
27816
|
+
return False
|
|
27817
|
+
properties = {"LoadState", "ActiveState", "MainPID", "ControlGroup", "KillMode", "SendSIGKILL"}
|
|
27818
|
+
try:
|
|
27819
|
+
result = subprocess.run(
|
|
27820
|
+
["systemctl", "--user", "show", AGENTS_SERVER_SYSTEMD_UNIT, "--no-pager",
|
|
27821
|
+
"--property=" + ",".join(sorted(properties))],
|
|
27822
|
+
stdin=subprocess.DEVNULL, capture_output=True, text=True,
|
|
27823
|
+
timeout=2.0, check=False,
|
|
27824
|
+
)
|
|
27825
|
+
except (OSError, subprocess.SubprocessError):
|
|
27826
|
+
return False
|
|
27827
|
+
if result.returncode != 0 or len(result.stdout) > 16_384:
|
|
27828
|
+
return False
|
|
27829
|
+
values: dict[str, str] = {}
|
|
27830
|
+
for line in result.stdout.splitlines():
|
|
27831
|
+
key, separator, value = line.partition("=")
|
|
27832
|
+
if not separator or key in values or key not in properties:
|
|
27833
|
+
return False
|
|
27834
|
+
values[key] = value
|
|
27835
|
+
return values == {
|
|
27836
|
+
"LoadState": "loaded", "ActiveState": "active", "MainPID": str(os.getpid()),
|
|
27837
|
+
"ControlGroup": service_cgroup, "KillMode": "process", "SendSIGKILL": "no",
|
|
27838
|
+
} and agents_server_systemd_cgroup() == service_cgroup
|
|
27839
|
+
|
|
27840
|
+
|
|
27598
27841
|
def managed_update_service_cgroup_state(
|
|
27599
27842
|
*,
|
|
27600
27843
|
service_cgroup: str | None = None,
|
|
@@ -27666,10 +27909,11 @@ def managed_update_service_cgroup_state(
|
|
|
27666
27909
|
})
|
|
27667
27910
|
return state
|
|
27668
27911
|
unknown = tuple(sorted(service_pids - {os.getpid()}))
|
|
27912
|
+
preserved = bool(unknown) and systemd_worker_preserves_descendants(selected_cgroup)
|
|
27669
27913
|
state.update({
|
|
27670
|
-
"safe": not unknown,
|
|
27914
|
+
"safe": not unknown or preserved,
|
|
27671
27915
|
"unknown_descendant_count": len(unknown),
|
|
27672
|
-
"inspection": "verified",
|
|
27916
|
+
"inspection": "verified-preserved" if preserved else "verified",
|
|
27673
27917
|
"_unknown_descendant_pids": unknown,
|
|
27674
27918
|
})
|
|
27675
27919
|
return state
|
|
@@ -29981,6 +30225,14 @@ async def reconcile_provider_task_exit(
|
|
|
29981
30225
|
prevents delayed cleanup from touching a replacement turn.
|
|
29982
30226
|
"""
|
|
29983
30227
|
|
|
30228
|
+
if backend == BACKEND_GEMINI:
|
|
30229
|
+
async with ACTIVE_LOCK:
|
|
30230
|
+
active = ACTIVE.get(session_id) or {}
|
|
30231
|
+
if active.get("run_id") == run_id and active.get("gemini_cleanup_failed"):
|
|
30232
|
+
# Keep the exact owner fenced. A dead consumer is not proof
|
|
30233
|
+
# that its editing tool processes stopped; Stop/Delete retry
|
|
30234
|
+
# the owned cleanup before releasing this slot.
|
|
30235
|
+
return False
|
|
29984
30236
|
persisted_active = (STORE.sessions.get(session_id) or {}).get("active_run")
|
|
29985
30237
|
terminal_missing = bool(
|
|
29986
30238
|
isinstance(persisted_active, dict)
|
|
@@ -32391,14 +32643,14 @@ CODEX_GOAL_HISTORY_REPAIR_CACHE = CodexGoalHistoryRepairCache()
|
|
|
32391
32643
|
CODEX_NATIVE_HISTORY_REPAIR_CACHE = CodexNativeHistoryRepairCache()
|
|
32392
32644
|
|
|
32393
32645
|
|
|
32394
|
-
def prepare_provider_history_metadata_repair(session_id: str) -> None:
|
|
32395
|
-
"""Prepare bounded source proofs
|
|
32646
|
+
def prepare_provider_history_metadata_repair(session_id: str) -> bool | None:
|
|
32647
|
+
"""Prepare bounded source proofs in background history maintenance."""
|
|
32396
32648
|
prepare_claude_history_metadata_repair(session_id)
|
|
32397
32649
|
prepare_codex_goal_history_repair(session_id)
|
|
32398
|
-
prepare_codex_native_history_repair(session_id)
|
|
32650
|
+
return prepare_codex_native_history_repair(session_id)
|
|
32399
32651
|
|
|
32400
32652
|
|
|
32401
|
-
def prepare_codex_native_history_repair(session_id: str) -> None:
|
|
32653
|
+
def prepare_codex_native_history_repair(session_id: str) -> bool | None:
|
|
32402
32654
|
session = STORE.sessions.get(session_id)
|
|
32403
32655
|
if not isinstance(session, dict) or session.get("backend") != BACKEND_CODEX or session.get("_deleting") is True:
|
|
32404
32656
|
CODEX_NATIVE_HISTORY_REPAIR_CACHE.forget(session_id)
|
|
@@ -32426,11 +32678,13 @@ def prepare_codex_native_history_repair(session_id: str) -> None:
|
|
|
32426
32678
|
session_id, provider_id, events_path(session_id), source, CODEX_SESSIONS_ROOT,
|
|
32427
32679
|
lambda event: codex_history_event_item(event, expected_session_id=session_id),
|
|
32428
32680
|
)
|
|
32429
|
-
except CodexNativeHistoryProofUnavailable:
|
|
32681
|
+
except CodexNativeHistoryProofUnavailable as exc:
|
|
32682
|
+
if getattr(exc, "deterministic", False):
|
|
32683
|
+
raise
|
|
32430
32684
|
# Incomplete evidence is retryable at a later explicit read boundary,
|
|
32431
32685
|
# not a successful empty proof and not a reason to break chat opening.
|
|
32432
32686
|
logger.debug("native history repair deferred session=%s", session_id)
|
|
32433
|
-
return
|
|
32687
|
+
return False
|
|
32434
32688
|
if changed:
|
|
32435
32689
|
HISTORY_SEARCH_REPAIR_DIRTY.add(session_id)
|
|
32436
32690
|
HISTORY_SEARCH_DIRTY.add(session_id)
|
|
@@ -32993,7 +33247,6 @@ def build_timeline_index(session_id: str) -> dict[str, Any]:
|
|
|
32993
33247
|
|
|
32994
33248
|
|
|
32995
33249
|
def _build_timeline_index_locked(session_id: str) -> dict[str, Any]:
|
|
32996
|
-
prepare_provider_history_metadata_repair(session_id)
|
|
32997
33250
|
claude_metadata_signature = CLAUDE_METADATA_REPAIR_CACHE.signature(session_id)
|
|
32998
33251
|
codex_goal_history_signature = (
|
|
32999
33252
|
CODEX_GOAL_HISTORY_REPAIR_CACHE.signature(session_id),
|
|
@@ -33081,6 +33334,15 @@ def _build_timeline_index_locked(session_id: str) -> dict[str, Any]:
|
|
|
33081
33334
|
if can_append
|
|
33082
33335
|
else []
|
|
33083
33336
|
)
|
|
33337
|
+
# Keep provisional turn lookup linear in the journal size. Scheduled-job
|
|
33338
|
+
# streams can have hundreds of thousands of events with no ordinary turn;
|
|
33339
|
+
# scanning every preceding landmark for each one makes cold opens quadratic.
|
|
33340
|
+
turn_record_keys_by_run: dict[str, list[str]] = {}
|
|
33341
|
+
for existing in records:
|
|
33342
|
+
existing_key = str(existing.get("key") or "")
|
|
33343
|
+
if existing_key.startswith("turn:"):
|
|
33344
|
+
existing_run = existing_key[5:].split(":start-", 1)[0]
|
|
33345
|
+
turn_record_keys_by_run.setdefault(existing_run, []).append(existing_key)
|
|
33084
33346
|
dirty_record_keys: set[str] = set()
|
|
33085
33347
|
active_turn_key: str | None = cached.get("active_turn_key") if can_append else None
|
|
33086
33348
|
current_turn_by_run: dict[str, str] = (
|
|
@@ -33401,6 +33663,8 @@ def _build_timeline_index_locked(session_id: str) -> dict[str, Any]:
|
|
|
33401
33663
|
}
|
|
33402
33664
|
by_key[key] = record
|
|
33403
33665
|
records.append(record)
|
|
33666
|
+
if key.startswith("turn:"):
|
|
33667
|
+
turn_record_keys_by_run.setdefault(key[5:].split(":start-", 1)[0], []).append(key)
|
|
33404
33668
|
if advance_timeline:
|
|
33405
33669
|
latest_timeline_landmark_key = key
|
|
33406
33670
|
record["start_seq"] = min(int(record["start_seq"]), seq)
|
|
@@ -33867,16 +34131,10 @@ def _build_timeline_index_locked(session_id: str) -> dict[str, Any]:
|
|
|
33867
34131
|
job_by_run[run_id] = job_id
|
|
33868
34132
|
prior_key = current_turn_by_run.pop(run_id, None) if run_id else None
|
|
33869
34133
|
if not prior_key and run_id:
|
|
33870
|
-
|
|
33871
|
-
|
|
33872
|
-
(
|
|
33873
|
-
|
|
33874
|
-
for candidate in reversed(records)
|
|
33875
|
-
if str(candidate.get("key") or "") == base_turn_key
|
|
33876
|
-
or str(candidate.get("key") or "").startswith(f"{base_turn_key}:start-")
|
|
33877
|
-
),
|
|
33878
|
-
None,
|
|
33879
|
-
)
|
|
34134
|
+
prior_keys = turn_record_keys_by_run.get(run_id, [])
|
|
34135
|
+
while prior_keys and prior_keys[-1] not in by_key:
|
|
34136
|
+
prior_keys.pop()
|
|
34137
|
+
prior_key = prior_keys[-1] if prior_keys else None
|
|
33880
34138
|
prior = by_key.get(prior_key or "")
|
|
33881
34139
|
group_key, group_created = assign_job_timeline_group(
|
|
33882
34140
|
event,
|
|
@@ -38401,7 +38659,7 @@ def cross_chat_supported_target_backends() -> list[str]:
|
|
|
38401
38659
|
# Only cached diagnostics/private metadata are read here; health must not
|
|
38402
38660
|
# launch CLIs or authenticate remotely. Each target's actual turn admission
|
|
38403
38661
|
# still checks its own binding, CLI compatibility and selected login mode.
|
|
38404
|
-
for backend in (BACKEND_CURSOR, BACKEND_OPENCODE):
|
|
38662
|
+
for backend in (BACKEND_CURSOR, BACKEND_OPENCODE, BACKEND_GEMINI):
|
|
38405
38663
|
with RUNTIME_DIAGNOSTICS_LOCK:
|
|
38406
38664
|
diagnostic = dict(RUNTIME_DIAGNOSTICS.get(backend) or {})
|
|
38407
38665
|
ready = diagnostic.get("status") == "ready"
|
|
@@ -38527,15 +38785,21 @@ def cross_chat_handoffs_capability() -> dict[str, Any]:
|
|
|
38527
38785
|
BACKEND_CLAUDE: CLAUDE_TRANSPORT_AGENT_SDK,
|
|
38528
38786
|
BACKEND_CURSOR: "headless-stream-json",
|
|
38529
38787
|
BACKEND_OPENCODE: "headless-stream-json",
|
|
38788
|
+
BACKEND_GEMINI: "acp",
|
|
38530
38789
|
},
|
|
38531
38790
|
}
|
|
38532
38791
|
|
|
38533
38792
|
|
|
38534
38793
|
OPENCODE_CROSS_CHAT_CLIENT_CAPABILITY = "opencode_cross_chat_v1"
|
|
38794
|
+
GEMINI_CLIENT_CAPABILITY = "gemini_acp_v1"
|
|
38535
38795
|
|
|
38536
38796
|
|
|
38537
38797
|
def cross_chat_delivery_client_capabilities(target: dict[str, Any]) -> list[str]:
|
|
38538
38798
|
backend = str(target.get("backend") or DEFAULT_BACKEND).strip().lower()
|
|
38799
|
+
if backend == BACKEND_GEMINI:
|
|
38800
|
+
if not cross_chat_target_backend_supported(backend):
|
|
38801
|
+
raise HTTPException(409, "The target Gemini CLI is unavailable.")
|
|
38802
|
+
return [GEMINI_CLIENT_CAPABILITY]
|
|
38539
38803
|
if backend == BACKEND_CODEX:
|
|
38540
38804
|
if not cross_chat_target_backend_supported(backend):
|
|
38541
38805
|
raise HTTPException(
|
|
@@ -38578,6 +38842,7 @@ CROSS_CHAT_DELIVERY_CAPABILITIES_BY_BACKEND: dict[str, frozenset[str]] = {
|
|
|
38578
38842
|
BACKEND_CLAUDE: frozenset({CLAUDE_SDK_INTERACTIVE_CLIENT_CAPABILITY}),
|
|
38579
38843
|
BACKEND_CURSOR: frozenset(),
|
|
38580
38844
|
BACKEND_OPENCODE: frozenset({OPENCODE_CROSS_CHAT_CLIENT_CAPABILITY}),
|
|
38845
|
+
BACKEND_GEMINI: frozenset({GEMINI_CLIENT_CAPABILITY}),
|
|
38581
38846
|
}
|
|
38582
38847
|
|
|
38583
38848
|
|
|
@@ -43311,7 +43576,9 @@ def find_emergency_request_event(
|
|
|
43311
43576
|
request_id: str,
|
|
43312
43577
|
source_run_id: str,
|
|
43313
43578
|
) -> dict[str, Any] | None:
|
|
43314
|
-
for event in reversed_jsonl_events(
|
|
43579
|
+
for event in reversed_jsonl_events(
|
|
43580
|
+
events_path(session_id), retained_fields=EMERGENCY_EVENT_FIELDS,
|
|
43581
|
+
):
|
|
43315
43582
|
if (
|
|
43316
43583
|
event.get("type") == "emergency_alert_raised"
|
|
43317
43584
|
and event.get("emergency_request_id") == request_id
|
|
@@ -43326,7 +43593,9 @@ def emergency_alert_was_acknowledged(
|
|
|
43326
43593
|
alert_id: str,
|
|
43327
43594
|
raised_seq: int,
|
|
43328
43595
|
) -> bool:
|
|
43329
|
-
for event in reversed_jsonl_events(
|
|
43596
|
+
for event in reversed_jsonl_events(
|
|
43597
|
+
events_path(session_id), retained_fields=EMERGENCY_EVENT_FIELDS,
|
|
43598
|
+
):
|
|
43330
43599
|
seq = int(event.get("seq") or 0)
|
|
43331
43600
|
if seq <= raised_seq:
|
|
43332
43601
|
return False
|
|
@@ -43342,7 +43611,9 @@ def find_emergency_acknowledgement_event(
|
|
|
43342
43611
|
session_id: str,
|
|
43343
43612
|
alert_id: str,
|
|
43344
43613
|
) -> dict[str, Any] | None:
|
|
43345
|
-
for event in reversed_jsonl_events(
|
|
43614
|
+
for event in reversed_jsonl_events(
|
|
43615
|
+
events_path(session_id), retained_fields=EMERGENCY_EVENT_FIELDS,
|
|
43616
|
+
):
|
|
43346
43617
|
if (
|
|
43347
43618
|
event.get("type") == "emergency_alert_acknowledged"
|
|
43348
43619
|
and event.get("emergency_alert_id") == alert_id
|
|
@@ -43351,6 +43622,41 @@ def find_emergency_acknowledgement_event(
|
|
|
43351
43622
|
return None
|
|
43352
43623
|
|
|
43353
43624
|
|
|
43625
|
+
def emergency_alert_batch_through(
|
|
43626
|
+
session_id: str,
|
|
43627
|
+
through_alert_id: str,
|
|
43628
|
+
) -> tuple[dict[str, Any] | None, list[str]]:
|
|
43629
|
+
"""Freeze active IDs at an exact durable raise boundary, including retries.
|
|
43630
|
+
|
|
43631
|
+
Read lifecycle facts rather than a potentially interrupted summary save.
|
|
43632
|
+
Reverse order lets each alert's newest fact determine whether it is active.
|
|
43633
|
+
A previously acknowledged boundary remains a valid, immutable cut-off.
|
|
43634
|
+
"""
|
|
43635
|
+
settled: set[str] = set()
|
|
43636
|
+
active: list[tuple[int, str]] = []
|
|
43637
|
+
boundary = None
|
|
43638
|
+
for event in reversed_jsonl_events(
|
|
43639
|
+
events_path(session_id), retained_fields=EMERGENCY_EVENT_FIELDS,
|
|
43640
|
+
):
|
|
43641
|
+
if event.get("type") == "emergency_alert_acknowledged":
|
|
43642
|
+
settled.add(str(event.get("emergency_alert_id") or ""))
|
|
43643
|
+
elif event.get("type") == "emergency_alert_raised":
|
|
43644
|
+
alert = normalized_emergency_alert(event.get("emergency_alert"))
|
|
43645
|
+
seq = int(event.get("seq") or 0)
|
|
43646
|
+
if alert is None or seq <= 0:
|
|
43647
|
+
continue
|
|
43648
|
+
alert_id = alert["id"]
|
|
43649
|
+
if alert_id == through_alert_id:
|
|
43650
|
+
boundary = event
|
|
43651
|
+
if alert_id not in settled:
|
|
43652
|
+
active.append((seq, alert_id))
|
|
43653
|
+
settled.add(alert_id)
|
|
43654
|
+
if boundary is None:
|
|
43655
|
+
return None, []
|
|
43656
|
+
boundary_seq = int(boundary["seq"])
|
|
43657
|
+
return boundary, [alert_id for seq, alert_id in reversed(active) if seq <= boundary_seq]
|
|
43658
|
+
|
|
43659
|
+
|
|
43354
43660
|
def emergency_alert_response(
|
|
43355
43661
|
session_id: str,
|
|
43356
43662
|
event: dict[str, Any],
|
|
@@ -44941,98 +45247,46 @@ def bounded_jsonl_paths(
|
|
|
44941
45247
|
continue
|
|
44942
45248
|
|
|
44943
45249
|
|
|
44944
|
-
def bounded_jsonl_events(path: Path, *, preserve_invalid: bool = False) -> Iterator[dict[str, Any] | None]:
|
|
44945
|
-
"""Parse a transcript with hard byte, line-size, and line-count bounds."""
|
|
44946
45250
|
|
|
44947
|
-
|
|
44948
|
-
|
|
44949
|
-
|
|
44950
|
-
|
|
44951
|
-
|
|
44952
|
-
consumed = 0
|
|
45251
|
+
|
|
45252
|
+
def bounded_jsonl_events(path: Path, *, preserve_invalid: bool = False) -> Iterator[dict[str, Any] | None]:
|
|
45253
|
+
"""Project native records with bounded memory, not a source-byte cap."""
|
|
45254
|
+
initial = path.stat()
|
|
45255
|
+
expected = {field: int(getattr(initial, field)) for field in ("st_dev", "st_ino", "st_size", "st_mtime_ns")}
|
|
44953
45256
|
with path.open("rb") as stream:
|
|
44954
|
-
|
|
44955
|
-
|
|
44956
|
-
|
|
44957
|
-
|
|
44958
|
-
|
|
44959
|
-
|
|
44960
|
-
|
|
44961
|
-
if len(raw_line) > MAX_LOCAL_TRANSCRIPT_LINE_BYTES:
|
|
44962
|
-
raise ValueError(f"transcript line {line_number} exceeds the import limit")
|
|
44963
|
-
if not raw_line.strip():
|
|
44964
|
-
continue
|
|
44965
|
-
try:
|
|
44966
|
-
event = json.loads(raw_line)
|
|
44967
|
-
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
44968
|
-
if preserve_invalid:
|
|
44969
|
-
yield None
|
|
44970
|
-
continue
|
|
44971
|
-
if isinstance(event, dict):
|
|
45257
|
+
opened = os.fstat(stream.fileno())
|
|
45258
|
+
if any(int(getattr(opened, field)) != expected[field] for field in expected):
|
|
45259
|
+
raise ValueError("transcript changed before parsing")
|
|
45260
|
+
for event, _end, raw in jsonl_records(stream, end=initial.st_size,
|
|
45261
|
+
require_newline=False, max_records=MAX_LOCAL_TRANSCRIPT_SCAN_LINES,
|
|
45262
|
+
max_retained_bytes=MAX_LOCAL_TRANSCRIPT_LINE_BYTES):
|
|
45263
|
+
if not raw.blank and (event is not None or preserve_invalid):
|
|
44972
45264
|
yield event
|
|
44973
|
-
|
|
44974
|
-
|
|
44975
|
-
|
|
44976
|
-
raise ValueError(
|
|
44977
|
-
f"transcript exceeds the {MAX_LOCAL_TRANSCRIPT_SCAN_LINES}-line import limit"
|
|
44978
|
-
)
|
|
45265
|
+
finished = os.fstat(stream.fileno())
|
|
45266
|
+
if any(int(getattr(finished, field)) != expected[field] for field in expected):
|
|
45267
|
+
raise ValueError("transcript changed during parsing")
|
|
44979
45268
|
|
|
44980
45269
|
|
|
44981
45270
|
def bounded_jsonl_records_range(
|
|
44982
|
-
path: Path,
|
|
44983
|
-
|
|
44984
|
-
end: int,
|
|
44985
|
-
*,
|
|
44986
|
-
expected_stat: dict[str, int],
|
|
45271
|
+
path: Path, start: int, end: int, *, expected_stat: dict[str, int],
|
|
45272
|
+
require_newline: bool = True, allow_partial: bool = False,
|
|
44987
45273
|
) -> Iterator[tuple[dict[str, Any] | None, int]]:
|
|
44988
45274
|
"""Read every record and ending offset in one immutable JSONL range."""
|
|
44989
|
-
|
|
44990
|
-
if start < 0 or end < start or end > MAX_LOCAL_TRANSCRIPT_BYTES:
|
|
45275
|
+
if start < 0 or end < start or end > expected_stat["st_size"]:
|
|
44991
45276
|
raise ValueError("transcript cursor range is invalid")
|
|
44992
|
-
remaining = end - start
|
|
44993
|
-
if remaining == 0:
|
|
44994
|
-
return
|
|
44995
45277
|
with path.open("rb") as stream:
|
|
44996
45278
|
before = os.fstat(stream.fileno())
|
|
44997
|
-
if any(
|
|
44998
|
-
int(getattr(before, field)) != int(expected_stat[field])
|
|
44999
|
-
for field in ("st_dev", "st_ino", "st_size", "st_mtime_ns")
|
|
45000
|
-
):
|
|
45279
|
+
if any(int(getattr(before, field)) != int(expected_stat[field]) for field in ("st_dev", "st_ino", "st_size", "st_mtime_ns")):
|
|
45001
45280
|
raise ValueError("transcript changed before cursor parsing")
|
|
45002
45281
|
stream.seek(start)
|
|
45003
|
-
for
|
|
45004
|
-
|
|
45282
|
+
for count, (event, offset, _raw) in enumerate(jsonl_records(stream, end=end,
|
|
45283
|
+
require_newline=require_newline, max_records=MAX_LOCAL_TRANSCRIPT_SCAN_LINES,
|
|
45284
|
+
max_retained_bytes=MAX_LOCAL_TRANSCRIPT_LINE_BYTES), 1):
|
|
45285
|
+
yield event, offset
|
|
45286
|
+
if allow_partial and count >= MAX_LOCAL_TRANSCRIPT_SCAN_LINES:
|
|
45005
45287
|
break
|
|
45006
|
-
raw_line = stream.readline(
|
|
45007
|
-
min(MAX_LOCAL_TRANSCRIPT_LINE_BYTES + 1, remaining)
|
|
45008
|
-
)
|
|
45009
|
-
if not raw_line:
|
|
45010
|
-
raise ValueError("transcript was truncated during cursor parsing")
|
|
45011
|
-
remaining -= len(raw_line)
|
|
45012
|
-
if len(raw_line) > MAX_LOCAL_TRANSCRIPT_LINE_BYTES:
|
|
45013
|
-
raise ValueError(
|
|
45014
|
-
f"transcript cursor line {line_number} exceeds the import limit"
|
|
45015
|
-
)
|
|
45016
|
-
if not raw_line.endswith(b"\n"):
|
|
45017
|
-
raise ValueError("transcript cursor does not end on a complete line")
|
|
45018
|
-
event: dict[str, Any] | None = None
|
|
45019
|
-
if raw_line.strip():
|
|
45020
|
-
try:
|
|
45021
|
-
loaded = json.loads(raw_line)
|
|
45022
|
-
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
45023
|
-
loaded = None
|
|
45024
|
-
if isinstance(loaded, dict):
|
|
45025
|
-
event = loaded
|
|
45026
|
-
yield event, int(stream.tell())
|
|
45027
|
-
if remaining > 0:
|
|
45028
|
-
raise ValueError(
|
|
45029
|
-
f"transcript cursor range exceeds the {MAX_LOCAL_TRANSCRIPT_SCAN_LINES}-line import limit"
|
|
45030
|
-
)
|
|
45031
45288
|
after = os.fstat(stream.fileno())
|
|
45032
|
-
if any(
|
|
45033
|
-
int(getattr(after, field)) != int(expected_stat[field])
|
|
45034
|
-
for field in ("st_dev", "st_ino", "st_size", "st_mtime_ns")
|
|
45035
|
-
):
|
|
45289
|
+
if any(int(getattr(after, field)) != int(expected_stat[field]) for field in ("st_dev", "st_ino", "st_size", "st_mtime_ns")):
|
|
45036
45290
|
raise ValueError("transcript changed during cursor parsing")
|
|
45037
45291
|
|
|
45038
45292
|
|
|
@@ -45324,20 +45578,7 @@ def find_codex_history(provider_id: str) -> Path | None:
|
|
|
45324
45578
|
clean_provider_id = provider_session_identifier(provider_id)
|
|
45325
45579
|
if clean_provider_id is None:
|
|
45326
45580
|
return None
|
|
45327
|
-
|
|
45328
|
-
return None
|
|
45329
|
-
selected: Path | None = None
|
|
45330
|
-
selected_mtime = -1.0
|
|
45331
|
-
for path in bounded_jsonl_paths(CODEX_SESSIONS_ROOT):
|
|
45332
|
-
transcript_id, _cwd = codex_transcript_meta(path)
|
|
45333
|
-
if transcript_id != clean_provider_id:
|
|
45334
|
-
continue
|
|
45335
|
-
with suppress(OSError):
|
|
45336
|
-
mtime = path.stat().st_mtime
|
|
45337
|
-
if mtime > selected_mtime:
|
|
45338
|
-
selected = path
|
|
45339
|
-
selected_mtime = mtime
|
|
45340
|
-
return selected
|
|
45581
|
+
return codex_rollout_path(clean_provider_id)
|
|
45341
45582
|
|
|
45342
45583
|
|
|
45343
45584
|
def codex_compaction_count(sess: dict[str, Any]) -> int:
|
|
@@ -45379,7 +45620,7 @@ def codex_rollout_path(thread_id: str) -> Path | None:
|
|
|
45379
45620
|
"""Locate ``rollout-*-<thread_id>.jsonl`` under CODEX_SESSIONS_ROOT.
|
|
45380
45621
|
|
|
45381
45622
|
Blocking filesystem work; call from a worker thread. Hits are cached per
|
|
45382
|
-
thread id and re-validated with
|
|
45623
|
+
thread id and re-validated with a bounded metadata read. Misses are cached briefly so a
|
|
45383
45624
|
thread whose rollout has not been flushed yet does not trigger a directory
|
|
45384
45625
|
walk on every runtime snapshot.
|
|
45385
45626
|
"""
|
|
@@ -45389,7 +45630,7 @@ def codex_rollout_path(thread_id: str) -> Path | None:
|
|
|
45389
45630
|
return None
|
|
45390
45631
|
cached = CODEX_ROLLOUT_PATH_CACHE.get(clean_id)
|
|
45391
45632
|
if cached is not None:
|
|
45392
|
-
if cached
|
|
45633
|
+
if contained_jsonl_path(cached, CODEX_SESSIONS_ROOT) is not None and codex_transcript_meta(cached)[0] == clean_id:
|
|
45393
45634
|
return cached
|
|
45394
45635
|
CODEX_ROLLOUT_PATH_CACHE.pop(clean_id, None)
|
|
45395
45636
|
missed_at = CODEX_ROLLOUT_PATH_MISSES.get(clean_id)
|
|
@@ -45402,17 +45643,29 @@ def codex_rollout_path(thread_id: str) -> Path | None:
|
|
|
45402
45643
|
suffix = f"-{clean_id}.jsonl"
|
|
45403
45644
|
selected: Path | None = None
|
|
45404
45645
|
selected_mtime = -1.0
|
|
45646
|
+
legacy_paths: list[Path] = []
|
|
45405
45647
|
for path in bounded_jsonl_paths(CODEX_SESSIONS_ROOT):
|
|
45406
45648
|
name = path.name
|
|
45407
45649
|
if not (name.startswith("rollout-") and name.endswith(suffix)):
|
|
45650
|
+
legacy_paths.append(path)
|
|
45408
45651
|
continue
|
|
45409
|
-
if
|
|
45652
|
+
if codex_transcript_meta(path)[0] != clean_id:
|
|
45410
45653
|
continue
|
|
45411
45654
|
with suppress(OSError):
|
|
45412
45655
|
mtime = path.stat().st_mtime
|
|
45413
45656
|
if mtime > selected_mtime:
|
|
45414
45657
|
selected = path
|
|
45415
45658
|
selected_mtime = mtime
|
|
45659
|
+
if selected is None:
|
|
45660
|
+
# Preserve discovery of renamed/legacy transcripts without reading
|
|
45661
|
+
# unrelated headers on the ordinary filename lookup path.
|
|
45662
|
+
for path in legacy_paths:
|
|
45663
|
+
if codex_transcript_meta(path)[0] != clean_id:
|
|
45664
|
+
continue
|
|
45665
|
+
with suppress(OSError):
|
|
45666
|
+
mtime = path.stat().st_mtime
|
|
45667
|
+
if mtime > selected_mtime:
|
|
45668
|
+
selected, selected_mtime = path, mtime
|
|
45416
45669
|
if selected is None:
|
|
45417
45670
|
CODEX_ROLLOUT_PATH_MISSES[clean_id] = now
|
|
45418
45671
|
if len(CODEX_ROLLOUT_PATH_MISSES) > 1024:
|
|
@@ -46603,6 +46856,8 @@ def session_provider_id(sess: dict[str, Any]) -> str | None:
|
|
|
46603
46856
|
return sess.get("cursor_session_id") or sess.get("session_id")
|
|
46604
46857
|
if backend == BACKEND_OPENCODE:
|
|
46605
46858
|
return sess.get("opencode_session_id") or sess.get("session_id")
|
|
46859
|
+
if backend == BACKEND_GEMINI:
|
|
46860
|
+
return sess.get("gemini_session_id") or sess.get("session_id")
|
|
46606
46861
|
return sess.get("session_id")
|
|
46607
46862
|
|
|
46608
46863
|
|
|
@@ -46742,6 +46997,12 @@ def read_native_session_title(sess: dict[str, Any]) -> str | None:
|
|
|
46742
46997
|
return native_session_title(codex_session_index_thread_names().get(provider_id))
|
|
46743
46998
|
if backend == BACKEND_CURSOR:
|
|
46744
46999
|
return cursor_native_session_title(provider_id, sess.get("cwd"))
|
|
47000
|
+
if backend == BACKEND_GEMINI:
|
|
47001
|
+
from gemini_history import native_snapshot
|
|
47002
|
+
from gemini_runtime import history_environment
|
|
47003
|
+
env = history_environment(runner_env(), sess, PROVIDER_CONNECTION_STORE.for_session(sess), PROVIDER_CONNECTION_STORE.root)
|
|
47004
|
+
record = native_snapshot(env, str(sess.get("cwd") or ""), provider_id)
|
|
47005
|
+
return native_session_title(record.get("summary"))
|
|
46745
47006
|
if backend == "opencode":
|
|
46746
47007
|
# Current native storage. A schema change, missing store, or busy DB
|
|
46747
47008
|
# fails closed. mode=ro must never create or migrate a provider DB.
|
|
@@ -46826,7 +47087,8 @@ def generated_title_eligible(sess: dict[str, Any], *, allow_attempted: bool = Fa
|
|
|
46826
47087
|
return bool(
|
|
46827
47088
|
os.environ.get("AGENTSDOCK_AUTO_TITLES", "1").strip().lower() not in {"0", "false", "off"}
|
|
46828
47089
|
and not SERVER_SHUTTING_DOWN
|
|
46829
|
-
and sess.get("backend") in {BACKEND_CODEX, BACKEND_CURSOR}
|
|
47090
|
+
and (sess.get("backend") in {BACKEND_CODEX, BACKEND_CURSOR}
|
|
47091
|
+
or (sess.get("backend") == BACKEND_GEMINI and sess.get("provider_connection") == "custom"))
|
|
46830
47092
|
and sess.get("auto_title_enabled", True) is not False
|
|
46831
47093
|
and not sess.get("archived") and not sess.get("parent_id") and not sess.get("fork_from")
|
|
46832
47094
|
and sess.get("id") not in DELETING_SESSIONS
|
|
@@ -46853,6 +47115,9 @@ async def close_generated_session_titles() -> None:
|
|
|
46853
47115
|
|
|
46854
47116
|
async def generate_session_title(session_id: str, snapshot: dict, reply: str) -> None:
|
|
46855
47117
|
async with GENERATED_TITLE_SLOTS:
|
|
47118
|
+
if snapshot["backend"] == BACKEND_GEMINI:
|
|
47119
|
+
# A native summary may arrive while this optional task waits.
|
|
47120
|
+
await refresh_native_session_title(session_id)
|
|
46856
47121
|
# Persist the claim before spending any provider usage. A restart does
|
|
46857
47122
|
# not retry an ambiguous request. This is one optional attempt per chat.
|
|
46858
47123
|
async with STORE._lock:
|
|
@@ -46879,6 +47144,27 @@ async def generate_session_title(session_id: str, snapshot: dict, reply: str) ->
|
|
|
46879
47144
|
options["model"] = codex_runtime_settings(snapshot)[0]
|
|
46880
47145
|
options["executable"] = CODEX_BIN
|
|
46881
47146
|
options["provider_selection"] = CODEX_PROVIDER_STORE.for_session(snapshot, include_key=True)
|
|
47147
|
+
elif snapshot["backend"] == BACKEND_GEMINI:
|
|
47148
|
+
from gemini_api import default_model
|
|
47149
|
+
from gemini_history import native_snapshot
|
|
47150
|
+
connection = PROVIDER_CONNECTION_STORE.for_session(snapshot)
|
|
47151
|
+
model = default_model(connection)
|
|
47152
|
+
if model == "auto":
|
|
47153
|
+
from gemini_runtime import history_environment
|
|
47154
|
+
env = history_environment(runner_env(), snapshot, connection, PROVIDER_CONNECTION_STORE.root)
|
|
47155
|
+
native = await asyncio.wait_for(asyncio.to_thread(native_snapshot, env,
|
|
47156
|
+
str(snapshot.get("cwd") or ""), session_provider_id(snapshot)), timeout=1.0)
|
|
47157
|
+
model = next((row.get("model") for row in reversed(native["messages"])
|
|
47158
|
+
if row.get("type") == "gemini" and row.get("model") and row.get("model") != "auto"), None)
|
|
47159
|
+
# Recheck revocation after the awaited metadata read and before
|
|
47160
|
+
# sending anything. Never take credentials from the current default.
|
|
47161
|
+
connection = PROVIDER_CONNECTION_STORE.for_session(snapshot)
|
|
47162
|
+
current = STORE.sessions.get(session_id)
|
|
47163
|
+
if (not current or not generated_title_eligible(current, allow_attempted=True)
|
|
47164
|
+
or title_runtime_key(current) != title_runtime_key(snapshot)
|
|
47165
|
+
or current.get("title") != snapshot.get("title")):
|
|
47166
|
+
return
|
|
47167
|
+
options = {"model": model, "provider_selection": connection}
|
|
46882
47168
|
else:
|
|
46883
47169
|
options["executable"] = resolve_cursor_executable()
|
|
46884
47170
|
if not options["executable"]:
|
|
@@ -46890,6 +47176,9 @@ async def generate_session_title(session_id: str, snapshot: dict, reply: str) ->
|
|
|
46890
47176
|
snapshot["backend"], snapshot["_title_seed"], reply, **options,
|
|
46891
47177
|
)
|
|
46892
47178
|
if result:
|
|
47179
|
+
if snapshot["backend"] == BACKEND_GEMINI:
|
|
47180
|
+
await refresh_native_session_title(session_id)
|
|
47181
|
+
PROVIDER_CONNECTION_STORE.for_session(snapshot) # Forget also fences a pending result.
|
|
46893
47182
|
changed = await STORE.adopt_auto_title(
|
|
46894
47183
|
session_id, result, source="generated", backend=snapshot["backend"],
|
|
46895
47184
|
provider_id=session_provider_id(snapshot), expected_title=snapshot.get("title"),
|
|
@@ -47040,7 +47329,7 @@ def codex_transcript_meta(
|
|
|
47040
47329
|
) -> tuple[str | None, str | None]:
|
|
47041
47330
|
"""Return (session_id, cwd) from a Codex transcript's session_meta record."""
|
|
47042
47331
|
with suppress(OSError, ValueError):
|
|
47043
|
-
for index, event in enumerate(
|
|
47332
|
+
for index, event in enumerate(codex_transcript_metadata_records(path)):
|
|
47044
47333
|
if index >= CODEX_TRANSCRIPT_SCAN_LINES:
|
|
47045
47334
|
break
|
|
47046
47335
|
if event.get("type") != "session_meta":
|
|
@@ -47056,6 +47345,23 @@ def codex_transcript_meta(
|
|
|
47056
47345
|
return None, None
|
|
47057
47346
|
|
|
47058
47347
|
|
|
47348
|
+
def codex_transcript_metadata_records(path: Path) -> Iterator[dict[str, Any]]:
|
|
47349
|
+
"""Discovery reads a bounded header, independent of total rollout size."""
|
|
47350
|
+
remaining = 1024 * 1024
|
|
47351
|
+
with path.open("rb") as stream:
|
|
47352
|
+
for _ in range(CODEX_TRANSCRIPT_SCAN_LINES):
|
|
47353
|
+
line = stream.readline(min(remaining, MAX_LOCAL_TRANSCRIPT_LINE_BYTES) + 1)
|
|
47354
|
+
if not line or len(line) > remaining or not line.endswith(b"\n"):
|
|
47355
|
+
return
|
|
47356
|
+
remaining -= len(line)
|
|
47357
|
+
try:
|
|
47358
|
+
record = json.loads(line)
|
|
47359
|
+
except (ValueError, UnicodeDecodeError):
|
|
47360
|
+
continue
|
|
47361
|
+
if isinstance(record, dict):
|
|
47362
|
+
yield record
|
|
47363
|
+
|
|
47364
|
+
|
|
47059
47365
|
def codex_transcript_preview(path: Path) -> str | None:
|
|
47060
47366
|
"""Return a short snippet of the first user message, for a local-session label."""
|
|
47061
47367
|
with suppress(OSError, ValueError):
|
|
@@ -47288,6 +47594,7 @@ def local_session_candidates(
|
|
|
47288
47594
|
known_provider_keys: set[tuple[str, str]] | None = None,
|
|
47289
47595
|
*,
|
|
47290
47596
|
include_cursor: bool = False,
|
|
47597
|
+
include_gemini: bool = False,
|
|
47291
47598
|
) -> list[dict[str, Any]]:
|
|
47292
47599
|
"""Enumerate sessions not already used by this or another local instance."""
|
|
47293
47600
|
|
|
@@ -47316,6 +47623,11 @@ def local_session_candidates(
|
|
|
47316
47623
|
candidates.extend(local_cursor_session_candidates({
|
|
47317
47624
|
provider_id for backend, provider_id in known_provider_keys if backend == BACKEND_CURSOR
|
|
47318
47625
|
}))
|
|
47626
|
+
if include_gemini:
|
|
47627
|
+
from gemini_import import candidates as gemini_candidates
|
|
47628
|
+
candidates.extend(gemini_candidates(sys.modules[__name__], runner_env(), {
|
|
47629
|
+
provider_id for backend, provider_id in known_provider_keys if backend == BACKEND_GEMINI
|
|
47630
|
+
}))
|
|
47319
47631
|
candidates.sort(key=lambda c: c["updated_at"], reverse=True)
|
|
47320
47632
|
return candidates[:max(1, min(limit, MAX_LOCAL_SESSION_LIST_ITEMS))]
|
|
47321
47633
|
|
|
@@ -47363,6 +47675,8 @@ def standalone_provider_session(sess: dict[str, Any]) -> dict[str, Any]:
|
|
|
47363
47675
|
isolated["codex_thread_id"] = None
|
|
47364
47676
|
isolated["cursor_session_id"] = None
|
|
47365
47677
|
isolated["opencode_session_id"] = None
|
|
47678
|
+
isolated["gemini_session_id"] = None
|
|
47679
|
+
isolated.pop("gemini_session_cwd", None)
|
|
47366
47680
|
isolated["backend_locked"] = False
|
|
47367
47681
|
isolated.pop("cursor_instruction_hash", None)
|
|
47368
47682
|
isolated.pop("cursor_instruction_version", None)
|
|
@@ -47402,6 +47716,12 @@ def provider_history_path(sess: dict[str, Any]) -> Path | None:
|
|
|
47402
47716
|
if backend == BACKEND_CLAUDE:
|
|
47403
47717
|
return find_claude_history(provider_id)
|
|
47404
47718
|
if backend == BACKEND_CODEX:
|
|
47719
|
+
cursor = normalized_history_sync_cursor(sess)
|
|
47720
|
+
if cursor and cursor.get("provider_session_id") == provider_id:
|
|
47721
|
+
path = contained_jsonl_path(Path(cursor["source_path"]), CODEX_SESSIONS_ROOT)
|
|
47722
|
+
if path is not None and codex_transcript_meta(path)[0] == provider_id:
|
|
47723
|
+
CODEX_ROLLOUT_PATH_CACHE[provider_id] = path
|
|
47724
|
+
return path
|
|
47405
47725
|
return find_codex_history(provider_id)
|
|
47406
47726
|
return None
|
|
47407
47727
|
|
|
@@ -47494,7 +47814,6 @@ def normalized_history_sync_cursor(
|
|
|
47494
47814
|
or int(raw.get(field, 0)) < 0
|
|
47495
47815
|
for field in optional_integer_fields
|
|
47496
47816
|
)
|
|
47497
|
-
or int(raw["source_size"]) > MAX_LOCAL_TRANSCRIPT_BYTES
|
|
47498
47817
|
or int(raw["source_offset"]) > int(raw["source_size"])
|
|
47499
47818
|
or (
|
|
47500
47819
|
timeline_pending_active
|
|
@@ -47525,6 +47844,8 @@ def normalized_history_sync_cursor(
|
|
|
47525
47844
|
raw.get("timeline_pending_through_seq") or 0
|
|
47526
47845
|
),
|
|
47527
47846
|
"timeline_pending_active": timeline_pending_active,
|
|
47847
|
+
**({"bootstrap_timeline_seq": raw["bootstrap_timeline_seq"]}
|
|
47848
|
+
if type(raw.get("bootstrap_timeline_seq")) is int and raw["bootstrap_timeline_seq"] >= 0 else {}),
|
|
47528
47849
|
"checkpoint_seq": int(raw.get("checkpoint_seq", raw["timeline_seq"])),
|
|
47529
47850
|
}
|
|
47530
47851
|
# Distinguish a pre-feature cursor from an initialized conservative empty
|
|
@@ -47675,10 +47996,6 @@ def provider_history_source_snapshot(
|
|
|
47675
47996
|
"""Hash a stable transcript through its last complete JSONL record."""
|
|
47676
47997
|
|
|
47677
47998
|
initial = path.stat()
|
|
47678
|
-
if initial.st_size > MAX_LOCAL_TRANSCRIPT_BYTES:
|
|
47679
|
-
raise ValueError(
|
|
47680
|
-
f"transcript exceeds the {MAX_LOCAL_TRANSCRIPT_BYTES}-byte import limit"
|
|
47681
|
-
)
|
|
47682
47999
|
# Provider CLIs may rotate a JSONL file with atomic replacement or move it
|
|
47683
48000
|
# between dated directories. Backend/provider identity is validated before
|
|
47684
48001
|
# this helper; byte-for-byte prefix continuity is authoritative across a
|
|
@@ -47693,7 +48010,8 @@ def provider_history_source_snapshot(
|
|
|
47693
48010
|
prefix_digest = hashlib.sha256() if verify_offset is not None else None
|
|
47694
48011
|
prefix_hashed = 0
|
|
47695
48012
|
complete_size = 0
|
|
47696
|
-
|
|
48013
|
+
running_digest = hashlib.sha256()
|
|
48014
|
+
deadline = time.monotonic() + SCAN_SECONDS
|
|
47697
48015
|
with path.open("rb") as stream:
|
|
47698
48016
|
opened = os.fstat(stream.fileno())
|
|
47699
48017
|
expected_identity = (
|
|
@@ -47709,7 +48027,12 @@ def provider_history_source_snapshot(
|
|
|
47709
48027
|
int(opened.st_mtime_ns),
|
|
47710
48028
|
) != expected_identity:
|
|
47711
48029
|
raise ValueError("transcript changed before cursor snapshot")
|
|
47712
|
-
|
|
48030
|
+
# Verify the existing prefix, then prepare at most one bounded suffix.
|
|
48031
|
+
# Total file size is not an import limit. Prefix verification remains
|
|
48032
|
+
# necessary: provider transcripts can be replaced or rewritten.
|
|
48033
|
+
snapshot_limit = min(int(initial.st_size), (verify_offset or 0) + HISTORY_SYNC_BATCH_BYTES)
|
|
48034
|
+
remaining = snapshot_limit
|
|
48035
|
+
consumed = 0
|
|
47713
48036
|
while remaining > 0:
|
|
47714
48037
|
chunk = stream.read(min(1024 * 1024, remaining))
|
|
47715
48038
|
if not chunk:
|
|
@@ -47719,15 +48042,20 @@ def provider_history_source_snapshot(
|
|
|
47719
48042
|
take = min(len(chunk), int(verify_offset) - prefix_hashed)
|
|
47720
48043
|
prefix_digest.update(chunk[:take])
|
|
47721
48044
|
prefix_hashed += take
|
|
47722
|
-
|
|
47723
|
-
newline =
|
|
48045
|
+
scan_check(deadline, budget_seconds=SCAN_SECONDS)
|
|
48046
|
+
newline = chunk.rfind(b"\n")
|
|
47724
48047
|
if newline >= 0:
|
|
47725
|
-
|
|
47726
|
-
complete_digest.
|
|
47727
|
-
complete_size
|
|
47728
|
-
|
|
47729
|
-
|
|
47730
|
-
|
|
48048
|
+
running_digest.update(chunk[:newline + 1])
|
|
48049
|
+
complete_digest = running_digest.copy()
|
|
48050
|
+
complete_size = consumed + newline + 1
|
|
48051
|
+
running_digest.update(chunk[newline + 1:])
|
|
48052
|
+
else:
|
|
48053
|
+
running_digest.update(chunk)
|
|
48054
|
+
consumed += len(chunk)
|
|
48055
|
+
# A single streamed record may exceed a batch. Extend only until
|
|
48056
|
+
# at least one new complete record exists, under the scan deadline.
|
|
48057
|
+
if remaining == 0 and complete_size <= (verify_offset or 0) and consumed < int(initial.st_size):
|
|
48058
|
+
remaining = min(1024 * 1024, int(initial.st_size) - consumed)
|
|
47731
48059
|
finished = os.fstat(stream.fileno())
|
|
47732
48060
|
if (
|
|
47733
48061
|
int(finished.st_dev),
|
|
@@ -47880,6 +48208,7 @@ def parse_provider_history_delta(
|
|
|
47880
48208
|
start,
|
|
47881
48209
|
end,
|
|
47882
48210
|
expected_stat=expected_stat,
|
|
48211
|
+
allow_partial=True,
|
|
47883
48212
|
):
|
|
47884
48213
|
if blocked_on_unseen_message:
|
|
47885
48214
|
# Exhaust the fixed range so its final fstat validation still
|
|
@@ -48010,51 +48339,27 @@ def load_provider_history_with_cursor(
|
|
|
48010
48339
|
if "claude_interruption_context" in previous
|
|
48011
48340
|
else seed_claude_interruption_context(path, start, expected_stat=snapshot["expected_stat"], provider_session_id=provider_id)
|
|
48012
48341
|
)
|
|
48013
|
-
|
|
48014
|
-
|
|
48015
|
-
|
|
48016
|
-
|
|
48017
|
-
|
|
48018
|
-
|
|
48019
|
-
|
|
48020
|
-
|
|
48021
|
-
|
|
48022
|
-
|
|
48023
|
-
|
|
48024
|
-
|
|
48025
|
-
|
|
48026
|
-
|
|
48027
|
-
|
|
48028
|
-
|
|
48029
|
-
|
|
48030
|
-
|
|
48031
|
-
|
|
48032
|
-
|
|
48033
|
-
|
|
48034
|
-
caught_up = not delta_overflow and cursor_offset == end
|
|
48035
|
-
else:
|
|
48036
|
-
events = bounded_jsonl_events_range(
|
|
48037
|
-
path,
|
|
48038
|
-
0,
|
|
48039
|
-
end,
|
|
48040
|
-
expected_stat=snapshot["expected_stat"],
|
|
48041
|
-
preserve_invalid=backend == BACKEND_CLAUDE,
|
|
48042
|
-
)
|
|
48043
|
-
if backend == BACKEND_CLAUDE:
|
|
48044
|
-
items = parse_claude_history_events(events, limit, expected_session_id=str(sess.get("id") or "") or None, interruption_context=interruption_context)
|
|
48045
|
-
elif backend == BACKEND_CODEX:
|
|
48046
|
-
items = parse_codex_history_events(events, limit, expected_session_id=str(sess.get("id") or "") or None)
|
|
48047
|
-
else:
|
|
48048
|
-
return None, [], None, False
|
|
48049
|
-
cursor_offset = end
|
|
48050
|
-
last_item_digest = (
|
|
48051
|
-
history_item_cursor_digest(items[-1]) if items else ""
|
|
48052
|
-
)
|
|
48053
|
-
if items and items[-1].get("source_text_sha256"):
|
|
48054
|
-
source_text_context["sha256"] = items[-1]["source_text_sha256"]
|
|
48055
|
-
if backend == BACKEND_CODEX and items and items[-1].get("phase") in ("commentary", "final_answer"):
|
|
48056
|
-
codex_phase_context["phase"] = items[-1]["phase"]
|
|
48057
|
-
caught_up = True
|
|
48342
|
+
(
|
|
48343
|
+
items,
|
|
48344
|
+
cursor_offset,
|
|
48345
|
+
last_item_digest,
|
|
48346
|
+
delta_overflow,
|
|
48347
|
+
) = parse_provider_history_delta(
|
|
48348
|
+
path,
|
|
48349
|
+
backend,
|
|
48350
|
+
start,
|
|
48351
|
+
end,
|
|
48352
|
+
limit=limit,
|
|
48353
|
+
expected_stat=snapshot["expected_stat"],
|
|
48354
|
+
previous_last_item_digest=str(
|
|
48355
|
+
(previous or {}).get("last_item_digest") or ""
|
|
48356
|
+
),
|
|
48357
|
+
expected_session_id=str(sess.get("id") or "") or None,
|
|
48358
|
+
interruption_context=interruption_context,
|
|
48359
|
+
codex_phase_context=codex_phase_context,
|
|
48360
|
+
source_text_context=source_text_context,
|
|
48361
|
+
)
|
|
48362
|
+
caught_up = not delta_overflow and cursor_offset == end and end == int(snapshot["source_size"])
|
|
48058
48363
|
source_digest = (
|
|
48059
48364
|
str(snapshot["source_digest"])
|
|
48060
48365
|
if cursor_offset == end
|
|
@@ -48417,6 +48722,7 @@ def reconcile_cursor_history_items(
|
|
|
48417
48722
|
*,
|
|
48418
48723
|
timeline_after_seq: int,
|
|
48419
48724
|
timeline_through_seq: int,
|
|
48725
|
+
bootstrap: bool = False,
|
|
48420
48726
|
) -> tuple[list[dict[str, str]], int]:
|
|
48421
48727
|
"""Subtract timeline-owned occurrences from one exact provider delta.
|
|
48422
48728
|
|
|
@@ -48440,14 +48746,15 @@ def reconcile_cursor_history_items(
|
|
|
48440
48746
|
timeline_after_seq=after_seq,
|
|
48441
48747
|
timeline_through_seq=through_seq,
|
|
48442
48748
|
tail=False,
|
|
48443
|
-
include_imported=
|
|
48749
|
+
include_imported=bootstrap,
|
|
48444
48750
|
message_details=message_details,
|
|
48445
48751
|
)
|
|
48446
48752
|
|
|
48447
48753
|
fresh: list[dict[str, str]] = []
|
|
48448
48754
|
timeline_index = 0
|
|
48449
48755
|
consumed_seq = after_seq
|
|
48450
|
-
|
|
48756
|
+
last_match_index = -1
|
|
48757
|
+
for item_index, item in enumerate(items):
|
|
48451
48758
|
item_details = history_message_match_details(item.get("kind", ""), item.get("text", ""), item, provider_item=True)
|
|
48452
48759
|
if (
|
|
48453
48760
|
timeline_index < len(timeline_messages)
|
|
@@ -48457,6 +48764,7 @@ def reconcile_cursor_history_items(
|
|
|
48457
48764
|
):
|
|
48458
48765
|
consumed_seq = timeline_messages[timeline_index][0]
|
|
48459
48766
|
timeline_index += 1
|
|
48767
|
+
last_match_index = item_index
|
|
48460
48768
|
else:
|
|
48461
48769
|
fresh.append(item)
|
|
48462
48770
|
|
|
@@ -48471,6 +48779,11 @@ def reconcile_cursor_history_items(
|
|
|
48471
48779
|
if timeline_window_truncated and timeline_messages
|
|
48472
48780
|
else through_seq
|
|
48473
48781
|
)
|
|
48782
|
+
if bootstrap:
|
|
48783
|
+
# Initial backfill uses the same conservative tail semantics as a
|
|
48784
|
+
# single full import. Old imported rows are credits too; keep only
|
|
48785
|
+
# source items after the entire captured preexisting timeline.
|
|
48786
|
+
fresh = items[last_match_index + 1:] if consumed_seq == through_seq and not timeline_window_truncated else []
|
|
48474
48787
|
return fresh, consumed_seq
|
|
48475
48788
|
|
|
48476
48789
|
|
|
@@ -48592,10 +48905,48 @@ def unsynced_history_items(
|
|
|
48592
48905
|
return items[last_matched + 1:]
|
|
48593
48906
|
|
|
48594
48907
|
|
|
48908
|
+
class HistoryPreparationStale(RuntimeError):
|
|
48909
|
+
"""The read-only plan lost its ownership or immutable input generation."""
|
|
48910
|
+
|
|
48911
|
+
|
|
48912
|
+
def history_file_stamp(path: Path) -> tuple[int, int, int, int] | None:
|
|
48913
|
+
try:
|
|
48914
|
+
value = path.stat()
|
|
48915
|
+
return value.st_dev, value.st_ino, value.st_size, value.st_mtime_ns
|
|
48916
|
+
except FileNotFoundError:
|
|
48917
|
+
return None
|
|
48918
|
+
|
|
48919
|
+
|
|
48920
|
+
@asynccontextmanager
|
|
48921
|
+
async def history_commit_guard(sess, cursor, timeline_stamp, source_stamp, enabled):
|
|
48922
|
+
if not enabled:
|
|
48923
|
+
yield
|
|
48924
|
+
return
|
|
48925
|
+
session_id = str(sess["id"])
|
|
48926
|
+
async with session_lifecycle_lock(session_id):
|
|
48927
|
+
current = STORE.sessions.get(session_id)
|
|
48928
|
+
if (not current or session_id in DELETING_SESSIONS
|
|
48929
|
+
or session_id in DELETED_SESSION_TOMBSTONES
|
|
48930
|
+
or session_id in BUSY_SESSIONS or ACTIVE.get(session_id) is not None
|
|
48931
|
+
or session_id in SERVER_MAINTENANCE_SESSIONS
|
|
48932
|
+
or any(not task.done() for task in SESSION_TURN_TASKS.get(session_id, ()))
|
|
48933
|
+
or current.get("backend") != sess.get("backend")
|
|
48934
|
+
or session_provider_id(current) != session_provider_id(sess)
|
|
48935
|
+
or normalized_history_sync_cursor(current) != cursor
|
|
48936
|
+
or history_file_stamp(events_path(session_id)) != timeline_stamp):
|
|
48937
|
+
raise HistoryPreparationStale("chat changed during history preparation")
|
|
48938
|
+
if source_stamp is not None:
|
|
48939
|
+
path, size, mtime, dev, ino = source_stamp
|
|
48940
|
+
if history_file_stamp(Path(path)) != (dev, ino, size, mtime):
|
|
48941
|
+
raise HistoryPreparationStale("provider transcript changed during history preparation")
|
|
48942
|
+
yield
|
|
48943
|
+
|
|
48944
|
+
|
|
48595
48945
|
async def sync_provider_history(
|
|
48596
48946
|
sess: dict[str, Any],
|
|
48597
48947
|
*,
|
|
48598
48948
|
limit: int | None = None,
|
|
48949
|
+
background: bool = False,
|
|
48599
48950
|
) -> dict[str, Any]:
|
|
48600
48951
|
"""Append provider messages added outside this chat since the last sync.
|
|
48601
48952
|
|
|
@@ -48616,6 +48967,7 @@ async def sync_provider_history(
|
|
|
48616
48967
|
}
|
|
48617
48968
|
live_session = STORE.sessions.get(session_id) or {}
|
|
48618
48969
|
previous_cursor = normalized_history_sync_cursor(live_session)
|
|
48970
|
+
timeline_stamp = history_file_stamp(events_path(session_id))
|
|
48619
48971
|
# A durable cursor makes the unchanged fast path survive process restart;
|
|
48620
48972
|
# all five stat/identity fields must still describe the fully-consumed
|
|
48621
48973
|
# source before parsing can be skipped.
|
|
@@ -48638,6 +48990,10 @@ async def sync_provider_history(
|
|
|
48638
48990
|
"source_path": None,
|
|
48639
48991
|
"message": "No provider transcript found.",
|
|
48640
48992
|
}
|
|
48993
|
+
if (previous_cursor is not None and next_cursor is not None
|
|
48994
|
+
and next_cursor["source_offset"] == previous_cursor["source_offset"]
|
|
48995
|
+
and not next_cursor.get("source_caught_up", True)):
|
|
48996
|
+
return {"imported": 0, "deferred": True, "reason": "incomplete_source_record"}
|
|
48641
48997
|
if next_cursor is not None and previous_cursor is not None:
|
|
48642
48998
|
recovered = await asyncio.to_thread(
|
|
48643
48999
|
committed_history_sync_checkpoint,
|
|
@@ -48646,11 +49002,11 @@ async def sync_provider_history(
|
|
|
48646
49002
|
)
|
|
48647
49003
|
if recovered is not None:
|
|
48648
49004
|
recovered_cursor, _terminal_seq = recovered
|
|
48649
|
-
|
|
48650
|
-
|
|
48651
|
-
|
|
48652
|
-
|
|
48653
|
-
|
|
49005
|
+
async with history_commit_guard(sess, previous_cursor, timeline_stamp, stamp, background):
|
|
49006
|
+
await persist_history_sync_cursor(
|
|
49007
|
+
sess, recovered_cursor,
|
|
49008
|
+
timeline_seq=int(recovered_cursor.get("timeline_seq") or 0),
|
|
49009
|
+
)
|
|
48654
49010
|
return {
|
|
48655
49011
|
"imported": 0,
|
|
48656
49012
|
"source_path": str(source_path),
|
|
@@ -48660,14 +49016,18 @@ async def sync_provider_history(
|
|
|
48660
49016
|
last_event_seq_from_file,
|
|
48661
49017
|
events_path(session_id),
|
|
48662
49018
|
)
|
|
48663
|
-
|
|
48664
|
-
|
|
49019
|
+
bootstrap_boundary = (previous_cursor or {}).get("bootstrap_timeline_seq") if continued else None
|
|
49020
|
+
if not continued and next_cursor is not None and not next_cursor.get("source_caught_up", True):
|
|
49021
|
+
bootstrap_boundary = timeline_latest_seq
|
|
49022
|
+
if (continued and previous_cursor is not None) or (next_cursor is not None and not next_cursor.get("source_caught_up", True)):
|
|
49023
|
+
timeline_scan_through_seq = timeline_latest_seq if bootstrap_boundary is None else bootstrap_boundary
|
|
48665
49024
|
fresh, consumed_timeline_seq = await asyncio.to_thread(
|
|
48666
49025
|
reconcile_cursor_history_items,
|
|
48667
49026
|
session_id,
|
|
48668
49027
|
items,
|
|
48669
|
-
timeline_after_seq=int(previous_cursor.get("timeline_seq") or 0),
|
|
49028
|
+
timeline_after_seq=int((previous_cursor or {}).get("timeline_seq") or 0) if continued else 0,
|
|
48670
49029
|
timeline_through_seq=timeline_scan_through_seq,
|
|
49030
|
+
**({"bootstrap": True} if bootstrap_boundary is not None else {}),
|
|
48671
49031
|
)
|
|
48672
49032
|
else:
|
|
48673
49033
|
# First sync or invalidated/rotated source: retain the conservative
|
|
@@ -48688,11 +49048,11 @@ async def sync_provider_history(
|
|
|
48688
49048
|
)
|
|
48689
49049
|
if recovered is not None:
|
|
48690
49050
|
recovered_cursor, _terminal_seq = recovered
|
|
48691
|
-
|
|
48692
|
-
|
|
48693
|
-
|
|
48694
|
-
|
|
48695
|
-
|
|
49051
|
+
async with history_commit_guard(sess, previous_cursor, timeline_stamp, stamp, background):
|
|
49052
|
+
await persist_history_sync_cursor(
|
|
49053
|
+
sess, recovered_cursor,
|
|
49054
|
+
timeline_seq=int(recovered_cursor.get("timeline_seq") or 0),
|
|
49055
|
+
)
|
|
48696
49056
|
return {
|
|
48697
49057
|
"imported": 0,
|
|
48698
49058
|
"source_path": str(source_path),
|
|
@@ -48703,6 +49063,8 @@ async def sync_provider_history(
|
|
|
48703
49063
|
timeline_seq = timeline_latest_seq
|
|
48704
49064
|
if next_cursor is not None:
|
|
48705
49065
|
caught_up = bool(next_cursor.pop("source_caught_up", True))
|
|
49066
|
+
if bootstrap_boundary is not None and not caught_up:
|
|
49067
|
+
next_cursor["bootstrap_timeline_seq"] = bootstrap_boundary
|
|
48706
49068
|
next_cursor["timeline_pending_through_seq"] = 0
|
|
48707
49069
|
next_cursor["timeline_pending_active"] = False
|
|
48708
49070
|
if not caught_up:
|
|
@@ -48718,50 +49080,59 @@ async def sync_provider_history(
|
|
|
48718
49080
|
caught_up=caught_up,
|
|
48719
49081
|
)
|
|
48720
49082
|
|
|
48721
|
-
|
|
48722
|
-
if not fresh:
|
|
48723
|
-
result = {
|
|
48724
|
-
"imported": 0,
|
|
48725
|
-
"source_path": str(source_path),
|
|
48726
|
-
"message": "Already up to date with the provider transcript.",
|
|
48727
|
-
}
|
|
48728
|
-
else:
|
|
48729
|
-
# The imported batch is fsynced atomically together with its exact next
|
|
48730
|
-
# cursor. A cursor moves only after that authoritative commit; if the
|
|
48731
|
-
# registry save then fails, retry recovers the embedded checkpoint
|
|
48732
|
-
# without relying on ambiguous content alignment.
|
|
49083
|
+
if background and fresh:
|
|
48733
49084
|
try:
|
|
48734
|
-
|
|
48735
|
-
sess,
|
|
48736
|
-
source_path,
|
|
48737
|
-
fresh,
|
|
48738
|
-
sync_checkpoint=checkpoint,
|
|
48739
|
-
)
|
|
49085
|
+
fresh = await prepare_imported_history_items(sess, source_path, fresh, checkpoint)
|
|
48740
49086
|
except CodexNativeHistoryProofUnavailable:
|
|
48741
|
-
|
|
48742
|
-
|
|
48743
|
-
|
|
48744
|
-
|
|
48745
|
-
|
|
48746
|
-
"
|
|
49087
|
+
return {"imported": 0, "deferred": True, "reason": "native_history_proof_unavailable"}
|
|
49088
|
+
async with history_commit_guard(sess, previous_cursor, timeline_stamp, stamp, background):
|
|
49089
|
+
result: dict[str, Any]
|
|
49090
|
+
if not fresh:
|
|
49091
|
+
result = {
|
|
49092
|
+
"imported": 0,
|
|
49093
|
+
"source_path": str(source_path),
|
|
49094
|
+
"message": "Already up to date with the provider transcript.",
|
|
48747
49095
|
}
|
|
48748
|
-
|
|
48749
|
-
|
|
48750
|
-
|
|
49096
|
+
else:
|
|
49097
|
+
# The imported batch is fsynced atomically together with its exact next
|
|
49098
|
+
# cursor. A cursor moves only after that authoritative commit; if the
|
|
49099
|
+
# registry save then fails, retry recovers the embedded checkpoint
|
|
49100
|
+
# without relying on ambiguous content alignment.
|
|
49101
|
+
try:
|
|
49102
|
+
result = await append_imported_history(
|
|
49103
|
+
sess,
|
|
49104
|
+
source_path,
|
|
49105
|
+
fresh,
|
|
49106
|
+
sync_checkpoint=checkpoint,
|
|
49107
|
+
_prepared=background,
|
|
49108
|
+
_expected_timeline_stamp=timeline_stamp,
|
|
49109
|
+
)
|
|
49110
|
+
except CodexNativeHistoryProofUnavailable:
|
|
49111
|
+
# Do not persist next_cursor: these source bytes have not yet been
|
|
49112
|
+
# proven against native history. A later explicit sync can retry.
|
|
49113
|
+
return {
|
|
49114
|
+
"imported": 0, "source_path": str(source_path), "deferred": True,
|
|
49115
|
+
"reason": "native_history_proof_unavailable",
|
|
49116
|
+
"message": "History reconciliation deferred; no messages were imported.",
|
|
49117
|
+
}
|
|
49118
|
+
if next_cursor is not None:
|
|
49119
|
+
if caught_up:
|
|
49120
|
+
timeline_seq = max(
|
|
49121
|
+
timeline_latest_seq,
|
|
49122
|
+
int(result.get("timeline_seq") or 0),
|
|
49123
|
+
)
|
|
49124
|
+
next_cursor["checkpoint_seq"] = max(
|
|
49125
|
+
int(next_cursor.get("checkpoint_seq") or 0),
|
|
48751
49126
|
timeline_latest_seq,
|
|
48752
49127
|
int(result.get("timeline_seq") or 0),
|
|
48753
49128
|
)
|
|
48754
|
-
|
|
48755
|
-
|
|
48756
|
-
|
|
48757
|
-
|
|
48758
|
-
|
|
48759
|
-
|
|
48760
|
-
|
|
48761
|
-
next_cursor,
|
|
48762
|
-
timeline_seq=timeline_seq,
|
|
48763
|
-
)
|
|
48764
|
-
return result
|
|
49129
|
+
await persist_history_sync_cursor(
|
|
49130
|
+
sess,
|
|
49131
|
+
next_cursor,
|
|
49132
|
+
timeline_seq=timeline_seq,
|
|
49133
|
+
)
|
|
49134
|
+
result["pending"] = not caught_up
|
|
49135
|
+
return result
|
|
48765
49136
|
|
|
48766
49137
|
|
|
48767
49138
|
def provider_history_source_stamp(sess: dict[str, Any]) -> list[Any] | None:
|
|
@@ -48843,73 +49214,115 @@ async def persist_history_sync_cursor(
|
|
|
48843
49214
|
return True
|
|
48844
49215
|
|
|
48845
49216
|
|
|
48846
|
-
|
|
48847
|
-
|
|
49217
|
+
HISTORY_MAINTENANCE_SLOTS = asyncio.Semaphore(2)
|
|
49218
|
+
HISTORY_MAINTENANCE_STATE: dict[str, dict[str, Any]] = {}
|
|
49219
|
+
HISTORY_MAINTENANCE_TASKS: dict[str, asyncio.Task] = {}
|
|
49220
|
+
HISTORY_PROJECTION_REVISIONS: dict[str, int] = {}
|
|
49221
|
+
|
|
49222
|
+
|
|
49223
|
+
def history_projection_notice(session_id: str) -> dict[str, Any]:
|
|
49224
|
+
return {"type": "history_projection_changed", "session_id": session_id,
|
|
49225
|
+
"revision": f"{SERVER_INSTANCE_ID}:{HISTORY_PROJECTION_REVISIONS.get(session_id, 0)}"}
|
|
48848
49226
|
|
|
49227
|
+
|
|
49228
|
+
def history_maintenance_key(sess: dict[str, Any]) -> list[Any]:
|
|
49229
|
+
provider_id = session_provider_id(sess)
|
|
49230
|
+
cursor = normalized_history_sync_cursor(sess)
|
|
49231
|
+
source = (Path(cursor["source_path"]) if cursor else CODEX_ROLLOUT_PATH_CACHE.get(provider_id))
|
|
49232
|
+
return [2, sess.get("backend"), provider_id,
|
|
49233
|
+
list(history_file_stamp(events_path(str(sess["id"]))) or ()),
|
|
49234
|
+
str(source) if source else None,
|
|
49235
|
+
list(history_file_stamp(source) or ()) if source else None]
|
|
49236
|
+
|
|
49237
|
+
|
|
49238
|
+
def history_maintenance_state(session_id: str) -> dict[str, Any]:
|
|
49239
|
+
if session_id not in HISTORY_MAINTENANCE_STATE:
|
|
49240
|
+
try:
|
|
49241
|
+
raw = json.loads((events_path(session_id).parent / "history-maintenance.json").read_text())
|
|
49242
|
+
HISTORY_MAINTENANCE_STATE[session_id] = raw if isinstance(raw, dict) else {}
|
|
49243
|
+
except (OSError, ValueError):
|
|
49244
|
+
HISTORY_MAINTENANCE_STATE[session_id] = {}
|
|
49245
|
+
return HISTORY_MAINTENANCE_STATE[session_id]
|
|
49246
|
+
|
|
49247
|
+
|
|
49248
|
+
def save_history_maintenance_state(session_id: str, value: dict[str, Any]) -> None:
|
|
49249
|
+
path = events_path(session_id).parent / "history-maintenance.json"
|
|
49250
|
+
if not path.parent.is_dir():
|
|
49251
|
+
return
|
|
49252
|
+
temporary = path.with_suffix(".tmp")
|
|
49253
|
+
temporary.write_text(json.dumps(value, separators=(",", ":")))
|
|
49254
|
+
temporary.replace(path)
|
|
49255
|
+
|
|
49256
|
+
|
|
49257
|
+
async def run_provider_history_sync(session_id: str) -> None:
|
|
49258
|
+
"""Prepare in the background; hold admission only for validated commit."""
|
|
49259
|
+
state = history_maintenance_state(session_id)
|
|
49260
|
+
key = None
|
|
49261
|
+
pending = False
|
|
48849
49262
|
try:
|
|
48850
|
-
|
|
48851
|
-
# controls use this same lifecycle lock. Revalidate after acquiring it
|
|
48852
|
-
# so a sync scheduled while idle cannot import a transcript that a new
|
|
48853
|
-
# live turn is concurrently extending.
|
|
48854
|
-
async with session_lifecycle_lock(session_id):
|
|
49263
|
+
async with HISTORY_MAINTENANCE_SLOTS:
|
|
48855
49264
|
sess = STORE.sessions.get(session_id)
|
|
48856
|
-
if (
|
|
48857
|
-
|
|
48858
|
-
|
|
48859
|
-
|
|
48860
|
-
):
|
|
49265
|
+
if (not sess or session_id in DELETING_SESSIONS
|
|
49266
|
+
or session_id in DELETED_SESSION_TOMBSTONES
|
|
49267
|
+
or session_id in BUSY_SESSIONS or ACTIVE.get(session_id) is not None
|
|
49268
|
+
or session_id in SERVER_MAINTENANCE_SESSIONS):
|
|
48861
49269
|
return
|
|
48862
|
-
|
|
48863
|
-
|
|
48864
|
-
session_id in BUSY_SESSIONS
|
|
48865
|
-
or ACTIVE.get(session_id) is not None
|
|
48866
|
-
or session_id in SERVER_MAINTENANCE_SESSIONS
|
|
48867
|
-
or any(
|
|
48868
|
-
task is not asyncio.current_task() and not task.done()
|
|
48869
|
-
for task in tuple(SESSION_TURN_TASKS.get(session_id) or ())
|
|
48870
|
-
)
|
|
48871
|
-
):
|
|
48872
|
-
return
|
|
49270
|
+
key = history_maintenance_key(sess)
|
|
49271
|
+
state.update(status="running", reason=None)
|
|
48873
49272
|
await refresh_native_session_title(session_id)
|
|
48874
|
-
|
|
48875
|
-
|
|
48876
|
-
|
|
48877
|
-
|
|
48878
|
-
|
|
48879
|
-
|
|
48880
|
-
|
|
49273
|
+
repair_caches = (CLAUDE_METADATA_REPAIR_CACHE, CODEX_GOAL_HISTORY_REPAIR_CACHE,
|
|
49274
|
+
CODEX_NATIVE_HISTORY_REPAIR_CACHE)
|
|
49275
|
+
before_repair = tuple(cache.signature(session_id) for cache in repair_caches)
|
|
49276
|
+
result = await sync_provider_history(dict(sess), background=True)
|
|
49277
|
+
pending = bool(result.get("pending")) and not result.get("deferred")
|
|
49278
|
+
# Forward imports have already proved their own batch. Repair of
|
|
49279
|
+
# older displayed rows must not starve the remaining source suffix.
|
|
49280
|
+
repaired = None if pending else await asyncio.to_thread(prepare_provider_history_metadata_repair, session_id)
|
|
49281
|
+
if before_repair != tuple(cache.signature(session_id) for cache in repair_caches):
|
|
49282
|
+
HISTORY_PROJECTION_REVISIONS[session_id] = HISTORY_PROJECTION_REVISIONS.get(session_id, 0) + 1
|
|
49283
|
+
await HUB.broadcast(session_id, history_projection_notice(session_id))
|
|
49284
|
+
deferred = result.get("deferred") or repaired is False
|
|
49285
|
+
state.update(status="deferred" if deferred else "pending" if pending else "idle", deterministic=False,
|
|
49286
|
+
reason=result.get("reason") or ("native_history_proof_unavailable" if repaired is False else None))
|
|
49287
|
+
if result.get("imported"):
|
|
49288
|
+
logger.info("synced %d provider message(s) added outside session=%s", result["imported"], session_id)
|
|
48881
49289
|
except asyncio.CancelledError:
|
|
49290
|
+
state.update(status="deferred", reason="cancelled")
|
|
48882
49291
|
raise
|
|
48883
49292
|
except Exception as exc:
|
|
48884
|
-
|
|
48885
|
-
logger.warning(
|
|
48886
|
-
"provider history sync failed session=%s error=%s",
|
|
48887
|
-
session_id,
|
|
48888
|
-
type(exc).__name__,
|
|
48889
|
-
)
|
|
49293
|
+
state.update(status="deferred", reason=type(exc).__name__, deterministic=isinstance(exc, ValueError))
|
|
49294
|
+
logger.warning("provider history sync deferred session=%s error=%s", session_id, type(exc).__name__)
|
|
48890
49295
|
finally:
|
|
49296
|
+
if key is not None:
|
|
49297
|
+
# A changing input retries on its next open; unchanged failures
|
|
49298
|
+
# survive reconnects and process restarts without a scan storm.
|
|
49299
|
+
current = STORE.sessions.get(session_id)
|
|
49300
|
+
state.update(key=history_maintenance_key(current) if current else key,
|
|
49301
|
+
retry_at=time.time() + (60 if state.get("status") == "deferred" else 1 if pending else 15))
|
|
49302
|
+
with suppress(OSError):
|
|
49303
|
+
await asyncio.to_thread(save_history_maintenance_state, session_id, dict(state))
|
|
48891
49304
|
HISTORY_SYNC_SCHEDULED.discard(session_id)
|
|
49305
|
+
HISTORY_MAINTENANCE_TASKS.pop(session_id, None)
|
|
49306
|
+
if pending and state.get("status") == "pending" and not SERVER_SHUTTING_DOWN:
|
|
49307
|
+
def continue_history():
|
|
49308
|
+
current = STORE.sessions.get(session_id)
|
|
49309
|
+
if current is not None:
|
|
49310
|
+
schedule_provider_history_sync(current)
|
|
49311
|
+
asyncio.get_running_loop().call_later(1.1, continue_history)
|
|
48892
49312
|
|
|
48893
49313
|
|
|
48894
49314
|
def schedule_provider_history_sync(sess: dict[str, Any]) -> None:
|
|
48895
|
-
"""Catch one chat's transcript up when opened, without blocking the open.
|
|
48896
|
-
|
|
48897
|
-
Opening a chat must stay fast, and the client is already subscribed to
|
|
48898
|
-
the event stream, so recovered messages arrive live instead of delaying
|
|
48899
|
-
the response.
|
|
48900
|
-
"""
|
|
48901
|
-
|
|
48902
49315
|
session_id = str(sess.get("id") or "")
|
|
48903
|
-
if not session_id or session_id in HISTORY_SYNC_SCHEDULED:
|
|
49316
|
+
if not session_id or session_id in HISTORY_SYNC_SCHEDULED or not session_provider_id(sess):
|
|
48904
49317
|
return
|
|
48905
|
-
if not session_provider_id(sess):
|
|
48906
|
-
return
|
|
48907
|
-
# A turn in flight is already streaming the provider's own output and its
|
|
48908
|
-
# transcript writes are still landing.
|
|
48909
49318
|
if session_id in BUSY_SESSIONS or ACTIVE.get(session_id) is not None:
|
|
48910
49319
|
return
|
|
49320
|
+
state = history_maintenance_state(session_id)
|
|
49321
|
+
if state.get("key") == history_maintenance_key(sess) and (
|
|
49322
|
+
state.get("deterministic") or time.time() < state.get("retry_at", 0)):
|
|
49323
|
+
return
|
|
48911
49324
|
HISTORY_SYNC_SCHEDULED.add(session_id)
|
|
48912
|
-
asyncio.create_task(run_provider_history_sync(session_id))
|
|
49325
|
+
HISTORY_MAINTENANCE_TASKS[session_id] = asyncio.create_task(run_provider_history_sync(session_id))
|
|
48913
49326
|
|
|
48914
49327
|
|
|
48915
49328
|
async def filter_codex_history_for_import(
|
|
@@ -48937,13 +49350,10 @@ async def filter_codex_history_for_import(
|
|
|
48937
49350
|
raise
|
|
48938
49351
|
|
|
48939
49352
|
|
|
48940
|
-
async def
|
|
48941
|
-
sess: dict[str, Any],
|
|
48942
|
-
|
|
48943
|
-
|
|
48944
|
-
*,
|
|
48945
|
-
sync_checkpoint: dict[str, Any] | None = None,
|
|
48946
|
-
) -> dict[str, Any]:
|
|
49353
|
+
async def prepare_imported_history_items(
|
|
49354
|
+
sess: dict[str, Any], source_path: Path, items: list[dict[str, Any]],
|
|
49355
|
+
sync_checkpoint: dict[str, Any] | None,
|
|
49356
|
+
) -> list[dict[str, Any]]:
|
|
48947
49357
|
session_id = str(sess["id"])
|
|
48948
49358
|
provider_id = str(session_provider_id(sess) or "")
|
|
48949
49359
|
backend = str(sess.get("backend") or DEFAULT_BACKEND).lower()
|
|
@@ -48970,6 +49380,23 @@ async def append_imported_history(
|
|
|
48970
49380
|
normalize_user=normalize_wake_user, normalize_full_user=normalize_full_wake_user,
|
|
48971
49381
|
normalize_assistant=clean_assistant_text,
|
|
48972
49382
|
)
|
|
49383
|
+
return items
|
|
49384
|
+
|
|
49385
|
+
|
|
49386
|
+
async def append_imported_history(
|
|
49387
|
+
sess: dict[str, Any],
|
|
49388
|
+
source_path: Path,
|
|
49389
|
+
items: list[dict[str, Any]],
|
|
49390
|
+
*,
|
|
49391
|
+
sync_checkpoint: dict[str, Any] | None = None,
|
|
49392
|
+
_prepared: bool = False,
|
|
49393
|
+
_expected_timeline_stamp: tuple | None = None,
|
|
49394
|
+
) -> dict[str, Any]:
|
|
49395
|
+
session_id = str(sess["id"])
|
|
49396
|
+
provider_id = str(session_provider_id(sess) or "")
|
|
49397
|
+
backend = str(sess.get("backend") or DEFAULT_BACKEND).lower()
|
|
49398
|
+
if not _prepared:
|
|
49399
|
+
items = await prepare_imported_history_items(sess, source_path, items, sync_checkpoint)
|
|
48973
49400
|
metadata_only = bool(items) and all(item.get("kind") == "interruption" or (
|
|
48974
49401
|
item.get("provider_history_repair") in {"source_proven_import", "source_proven_native_replay", "source_proven_assistant_replay"}
|
|
48975
49402
|
and item.get("text") == ""
|
|
@@ -49059,7 +49486,13 @@ async def append_imported_history(
|
|
|
49059
49486
|
imported_events.append(imported_history_terminal_event(
|
|
49060
49487
|
run_id, backend, metadata_only=metadata_only, source_timestamp=last_source_timestamp,
|
|
49061
49488
|
))
|
|
49062
|
-
|
|
49489
|
+
if _prepared:
|
|
49490
|
+
async with event_delivery_lock(session_id):
|
|
49491
|
+
if history_file_stamp(events_path(session_id)) != _expected_timeline_stamp:
|
|
49492
|
+
raise HistoryPreparationStale("timeline changed before history publication")
|
|
49493
|
+
committed = await append_durable_event_batch_locked(session_id, imported_events)
|
|
49494
|
+
else:
|
|
49495
|
+
committed = await append_durable_event_batch(session_id, imported_events)
|
|
49063
49496
|
if len(committed) != len(imported_events):
|
|
49064
49497
|
raise RuntimeError("history event batch was not fully persisted")
|
|
49065
49498
|
if backend == BACKEND_CODEX:
|
|
@@ -49197,6 +49630,12 @@ async def import_session_history(sess: dict[str, Any], *, force: bool = False, l
|
|
|
49197
49630
|
backend = (sess.get("backend") or DEFAULT_BACKEND).lower()
|
|
49198
49631
|
if not provider_id:
|
|
49199
49632
|
return {"imported": 0, "source_path": None, "message": "No provider session ID set."}
|
|
49633
|
+
if backend == BACKEND_GEMINI:
|
|
49634
|
+
if await asyncio.to_thread(lambda: any(event.get("type") == "history_imported" for event in iter_session_events(session_id))):
|
|
49635
|
+
return {"imported": 0, "source_path": None, "message": "Gemini initial text history already imported; automatic reconciliation is not supported."}
|
|
49636
|
+
from gemini_import import initial_history
|
|
49637
|
+
source, items = await asyncio.to_thread(initial_history, sys.modules[__name__], runner_env(), provider_id, str(sess.get("cwd") or ""), limit)
|
|
49638
|
+
return await append_imported_history(sess, source, items)
|
|
49200
49639
|
if backend == BACKEND_CURSOR:
|
|
49201
49640
|
# Initial public-text snapshot only. The private native store, not these
|
|
49202
49641
|
# display messages, supplies resume context. Cursor exports are rewritable
|
|
@@ -50234,9 +50673,10 @@ def public_session(sess: dict[str, Any], *, summary: bool = False) -> dict[str,
|
|
|
50234
50673
|
detail_fields = () if summary else (
|
|
50235
50674
|
"auto_title_enabled",
|
|
50236
50675
|
"system_prompt", "session_id", "claude_session_id", "codex_thread_id",
|
|
50237
|
-
"cursor_session_id", "opencode_session_id",
|
|
50676
|
+
"cursor_session_id", "opencode_session_id", "gemini_session_id",
|
|
50238
50677
|
"claude_permission_mode", "cursor_permission_mode",
|
|
50239
50678
|
"opencode_permission_mode",
|
|
50679
|
+
"gemini_permission_mode", "gemini_permission_modes",
|
|
50240
50680
|
"codex_approval_policy", "codex_sandbox_mode",
|
|
50241
50681
|
"codex_permission_profile", "codex_approvals_reviewer",
|
|
50242
50682
|
"provider_jobs_access",
|
|
@@ -50257,6 +50697,11 @@ def public_session(sess: dict[str, Any], *, summary: bool = False) -> dict[str,
|
|
|
50257
50697
|
"last_read_agent_event_seq", "last_read_agent_event_at", "manual_unread",
|
|
50258
50698
|
)
|
|
50259
50699
|
}
|
|
50700
|
+
maintenance = HISTORY_MAINTENANCE_STATE.get(str(sess.get("id") or ""), {})
|
|
50701
|
+
# Never-started maintenance adds no useful information to high-volume lists.
|
|
50702
|
+
# Once observed, retain idle/cleared fields so caches can retire old errors.
|
|
50703
|
+
if not summary or maintenance:
|
|
50704
|
+
public["history_maintenance"] = {key: maintenance.get(key) for key in ("status", "reason", "retry_at")}
|
|
50260
50705
|
# Omit only never-configured defaults from high-volume lists. A stored null
|
|
50261
50706
|
# is a clear tombstone and must survive summary/cache merges.
|
|
50262
50707
|
if not summary or "subagent_limit" in sess:
|
|
@@ -50280,6 +50725,9 @@ def public_session(sess: dict[str, Any], *, summary: bool = False) -> dict[str,
|
|
|
50280
50725
|
# Provider ids are intentionally omitted from summary responses, but the
|
|
50281
50726
|
# UI still needs the authoritative first-turn backend fence.
|
|
50282
50727
|
public["backend_locked"] = session_backend_locked(sess)
|
|
50728
|
+
# Capability, not proof of emptiness: dismiss-empty repeats the full proof.
|
|
50729
|
+
if sess.get("title") == "New chat" and not public["backend_locked"]:
|
|
50730
|
+
public["empty_chat_cleanup"] = "client-v1"
|
|
50283
50731
|
public["codex_provider"] = codex_provider.session_choice(sess.get("codex_provider"))
|
|
50284
50732
|
connection = sess.get("provider_connection") or "default"
|
|
50285
50733
|
# Keep legacy native summaries sparse, but retain a stored default as the
|
|
@@ -54177,26 +54625,29 @@ async def refresh_codex_app_server_binary(*, force: bool = False) -> None:
|
|
|
54177
54625
|
schedule_codex_manager_drain()
|
|
54178
54626
|
|
|
54179
54627
|
|
|
54180
|
-
async def refresh_codex_app_server_login(*, request_handoff: bool = False) ->
|
|
54628
|
+
async def refresh_codex_app_server_login(*, request_handoff: bool = False, source: str = "manager_lookup") -> bool:
|
|
54181
54629
|
"""Change normal-Codex admission; never kill a credential-owning process.
|
|
54182
54630
|
|
|
54183
|
-
|
|
54184
|
-
|
|
54185
|
-
|
|
54631
|
+
Ordinary lookups/catalog refreshes only observe native file revisions.
|
|
54632
|
+
Explicit native-admin login reload also covers opaque credential stores.
|
|
54633
|
+
This is not a token refresh or login operation.
|
|
54186
54634
|
"""
|
|
54187
54635
|
global CODEX_APP_SERVER_MANAGER, CODEX_LOGIN_REVISION
|
|
54188
54636
|
if CODEX_TRANSPORT == CODEX_TRANSPORT_EXEC or CODEX_AUTH_LOCK.locked() or CODEX_MANAGER_CLOSING:
|
|
54189
|
-
return
|
|
54637
|
+
return False
|
|
54638
|
+
# Sources are server-owned labels, never request text, account IDs or keys.
|
|
54639
|
+
if source not in {"manager_lookup", "turn_admission", "account_status", "catalog_refresh", "manual_login_reload"}:
|
|
54640
|
+
source = "manager_lookup"
|
|
54190
54641
|
async with CODEX_LOGIN_PROBE_LOCK:
|
|
54191
54642
|
if not await asyncio.to_thread(codex_auth.native_login_handoff_supported,
|
|
54192
54643
|
codex_app_server_env(), cwd=existing_cwd(DEFAULT_CWD)):
|
|
54193
|
-
return
|
|
54644
|
+
return False
|
|
54194
54645
|
revision = await asyncio.to_thread(codex_auth.native_login_revision,
|
|
54195
54646
|
codex_app_server_env(), cwd=existing_cwd(DEFAULT_CWD))
|
|
54196
54647
|
async with CODEX_GOALS_CONFIG_LOCK:
|
|
54197
54648
|
async with CODEX_APP_SERVER_MANAGER_LOCK:
|
|
54198
54649
|
if CODEX_APP_SERVER_MANAGER_CLEANUP_EPOCH is not None or CODEX_AUTH_LOCK.locked() or CODEX_MANAGER_CLOSING:
|
|
54199
|
-
return
|
|
54650
|
+
return False
|
|
54200
54651
|
observed_change = revision is not None and revision != CODEX_LOGIN_REVISION
|
|
54201
54652
|
CODEX_LOGIN_REVISION = revision
|
|
54202
54653
|
if observed_change or request_handoff:
|
|
@@ -54212,12 +54663,17 @@ async def refresh_codex_app_server_login(*, request_handoff: bool = False) -> No
|
|
|
54212
54663
|
if revision is not None and revision == previous:
|
|
54213
54664
|
manager._agentsdock_login_superseded = False
|
|
54214
54665
|
if changed or (request_handoff and revision is None):
|
|
54666
|
+
if getattr(manager, "_agentsdock_login_superseded", False) is not True:
|
|
54667
|
+
reason = "login_revision_changed" if changed else "explicit_reload_opaque_login"
|
|
54668
|
+
manager._agentsdock_login_handoff_source = source
|
|
54669
|
+
logger.info("Codex login handoff requested source=%s reason=%s manager=%x", source, reason, id(manager))
|
|
54215
54670
|
manager._agentsdock_login_superseded = True
|
|
54216
54671
|
if not any(manager is item for item in CODEX_RETIRED_APP_SERVER_MANAGERS):
|
|
54217
54672
|
CODEX_RETIRED_APP_SERVER_MANAGERS.append(manager)
|
|
54218
54673
|
if CODEX_APP_SERVER_MANAGER is manager:
|
|
54219
54674
|
CODEX_APP_SERVER_MANAGER = None
|
|
54220
54675
|
schedule_codex_manager_drain()
|
|
54676
|
+
return True
|
|
54221
54677
|
|
|
54222
54678
|
|
|
54223
54679
|
def codex_manager_session_blocker(manager: CodexAppServerManager, session_id: str, *, ignore_task=None, ignore_maintenance=False) -> str | None:
|
|
@@ -54320,6 +54776,17 @@ def watch_codex_provider_handoff_blockers(
|
|
|
54320
54776
|
|
|
54321
54777
|
async def release_idle_codex_manager_session(manager: CodexAppServerManager, session_id: str, *, ignore_task=None) -> bool:
|
|
54322
54778
|
"""Called under the session lifecycle lock; retain ownership on any doubt."""
|
|
54779
|
+
def retained(reason: str) -> bool:
|
|
54780
|
+
if getattr(manager, "_agentsdock_login_superseded", False) is True:
|
|
54781
|
+
reasons = getattr(manager, "_agentsdock_login_handoff_blockers", None)
|
|
54782
|
+
if reasons is None:
|
|
54783
|
+
reasons = manager._agentsdock_login_handoff_blockers = {}
|
|
54784
|
+
if reasons.get(session_id) != reason:
|
|
54785
|
+
reasons[session_id] = reason
|
|
54786
|
+
logger.info("Codex login handoff waiting source=%s manager=%x session=%s reason=%s",
|
|
54787
|
+
getattr(manager, "_agentsdock_login_handoff_source", "unknown"), id(manager), session_id, reason)
|
|
54788
|
+
return False
|
|
54789
|
+
|
|
54323
54790
|
def blocked(*, own_maintenance=False):
|
|
54324
54791
|
return (codex_manager_session_busy(manager, session_id, ignore_task=ignore_task,
|
|
54325
54792
|
ignore_maintenance=own_maintenance)
|
|
@@ -54360,7 +54827,7 @@ async def release_idle_codex_manager_session(manager: CodexAppServerManager, ses
|
|
|
54360
54827
|
sum(not task.done() for task in manager.client._server_request_tasks.values()),
|
|
54361
54828
|
error_type,
|
|
54362
54829
|
)
|
|
54363
|
-
return
|
|
54830
|
+
return retained(reasons[session_id])
|
|
54364
54831
|
|
|
54365
54832
|
if blocked():
|
|
54366
54833
|
return await defer("runtime_borrowers")
|
|
@@ -54440,6 +54907,10 @@ async def release_idle_codex_manager_session(manager: CodexAppServerManager, ses
|
|
|
54440
54907
|
if CODEX_SESSION_APP_SERVER_MANAGERS.get(session_id) is manager:
|
|
54441
54908
|
CODEX_SESSION_APP_SERVER_MANAGERS.pop(session_id, None)
|
|
54442
54909
|
CODEX_GOAL_SYNC_GENERATIONS.pop(session_id, None)
|
|
54910
|
+
if getattr(manager, "_agentsdock_login_superseded", False) is True:
|
|
54911
|
+
getattr(manager, "_agentsdock_login_handoff_blockers", {}).pop(session_id, None)
|
|
54912
|
+
logger.info("Codex login handoff released source=%s manager=%x session=%s",
|
|
54913
|
+
getattr(manager, "_agentsdock_login_handoff_source", "unknown"), id(manager), session_id)
|
|
54443
54914
|
return True
|
|
54444
54915
|
except Exception as exc:
|
|
54445
54916
|
logger.debug("Codex handoff retained an unverified idle owner", exc_info=True)
|
|
@@ -54459,7 +54930,7 @@ async def prepare_codex_login_turn(session: dict[str, Any]) -> None:
|
|
|
54459
54930
|
session.pop("_codex_provider_pending", None)
|
|
54460
54931
|
if codex_provider.session_choice(session.get("codex_provider")) == "custom":
|
|
54461
54932
|
return
|
|
54462
|
-
await refresh_codex_app_server_login()
|
|
54933
|
+
await refresh_codex_app_server_login(source="turn_admission")
|
|
54463
54934
|
session_id = str(session.get("id") or "")
|
|
54464
54935
|
manager = existing_codex_app_server_manager(session)
|
|
54465
54936
|
if getattr(manager, "_agentsdock_login_superseded", False) is not True:
|
|
@@ -54618,9 +55089,8 @@ async def codex_app_server_manager(sess: dict[str, Any] | None = None, *, allow_
|
|
|
54618
55089
|
selected = CODEX_PROVIDER_STORE.for_session(sess, include_key=True) if sess is not None else None
|
|
54619
55090
|
revision = selected["credential_id"] if selected else None
|
|
54620
55091
|
session_id = str((sess or {}).get("id") or "")
|
|
54621
|
-
#
|
|
54622
|
-
#
|
|
54623
|
-
# no caller can retain/create the old generation halfway through a toggle.
|
|
55092
|
+
# Manager lookup shares the provider reconfiguration barrier, including the
|
|
55093
|
+
# fast path, so a sign-in reload cannot race a retained/created generation.
|
|
54624
55094
|
async with CODEX_GOALS_CONFIG_LOCK:
|
|
54625
55095
|
ensure_provider_manager_factory_admission(codex=True)
|
|
54626
55096
|
manager = existing_codex_app_server_manager(sess) if sess is not None else None
|
|
@@ -57216,6 +57686,9 @@ def resolve_cursor_executable(*, require_compatible: bool = True) -> str | None:
|
|
|
57216
57686
|
|
|
57217
57687
|
|
|
57218
57688
|
def runtime_executable(backend: str) -> str:
|
|
57689
|
+
if backend == BACKEND_GEMINI:
|
|
57690
|
+
from gemini_runtime import resolve_executable
|
|
57691
|
+
return resolve_executable(runner_env()) or "gemini"
|
|
57219
57692
|
if backend == BACKEND_CLAUDE:
|
|
57220
57693
|
return CLAUDE_BIN
|
|
57221
57694
|
if backend == BACKEND_OPENCODE:
|
|
@@ -57234,6 +57707,8 @@ def runtime_executable(backend: str) -> str:
|
|
|
57234
57707
|
|
|
57235
57708
|
|
|
57236
57709
|
def runtime_display_name(backend: str) -> str:
|
|
57710
|
+
if backend == BACKEND_GEMINI:
|
|
57711
|
+
return "Gemini CLI"
|
|
57237
57712
|
if backend == BACKEND_CLAUDE:
|
|
57238
57713
|
return "Claude Code"
|
|
57239
57714
|
if backend == BACKEND_CURSOR:
|
|
@@ -57463,6 +57938,9 @@ def cursor_auth_failure_confirmed(executable: str) -> bool:
|
|
|
57463
57938
|
|
|
57464
57939
|
|
|
57465
57940
|
def probe_runtime(backend: str) -> dict[str, Any]:
|
|
57941
|
+
if backend == BACKEND_GEMINI:
|
|
57942
|
+
from gemini_runtime import probe
|
|
57943
|
+
return probe(runner_env(), timeout=runtime_probe_timeout(8.0))
|
|
57466
57944
|
if backend == BACKEND_OPENCODE:
|
|
57467
57945
|
(
|
|
57468
57946
|
resolved,
|
|
@@ -57686,7 +58164,14 @@ def probe_runtime(backend: str) -> dict[str, Any]:
|
|
|
57686
58164
|
|
|
57687
58165
|
combined = f"{auth_result.stdout}\n{auth_result.stderr}"
|
|
57688
58166
|
if auth_result.returncode == 0:
|
|
57689
|
-
|
|
58167
|
+
result = runtime_diagnostic_payload(backend, "ready", installed=True, authenticated=True, version=version)
|
|
58168
|
+
if backend == BACKEND_CODEX:
|
|
58169
|
+
# Retain only the kind, never login-status output (which may show a
|
|
58170
|
+
# masked API key). No additional auth/refresh subprocess is needed.
|
|
58171
|
+
text = combined.lower()
|
|
58172
|
+
result["_codex_auth_mode"] = "apiKey" if "api key" in text else "chatgpt" if "chatgpt" in text else "unknown"
|
|
58173
|
+
result["_codex_auth_mode_checked_at_epoch"] = result["checked_at_epoch"]
|
|
58174
|
+
return result
|
|
57690
58175
|
if auth_failure_text(combined):
|
|
57691
58176
|
return runtime_diagnostic_payload(backend, "unauthenticated", installed=True, authenticated=False, version=version)
|
|
57692
58177
|
return runtime_diagnostic_payload(backend, "error", installed=True, authenticated=None, version=version)
|
|
@@ -57709,10 +58194,14 @@ def store_runtime_diagnostic(diagnostic: dict[str, Any], *, preserve_last_error:
|
|
|
57709
58194
|
|
|
57710
58195
|
def expire_codex_login_diagnostic() -> None:
|
|
57711
58196
|
"""A login hint requires new native evidence, never reuse a cached denial."""
|
|
58197
|
+
global CODEX_MODEL_MIGRATION_TARGET
|
|
57712
58198
|
with RUNTIME_DIAGNOSTICS_LOCK:
|
|
58199
|
+
CODEX_MODEL_MIGRATION_TARGET = "gpt-6-luna"
|
|
57713
58200
|
current = dict(RUNTIME_DIAGNOSTICS.get(BACKEND_CODEX) or runtime_diagnostic_payload(
|
|
57714
58201
|
BACKEND_CODEX, "unknown", installed=None, authenticated=None))
|
|
57715
58202
|
current["checked_at_epoch"] = 0.0
|
|
58203
|
+
current.pop("_codex_auth_mode", None)
|
|
58204
|
+
current.pop("_codex_auth_mode_checked_at_epoch", None)
|
|
57716
58205
|
store_runtime_diagnostic(current)
|
|
57717
58206
|
|
|
57718
58207
|
|
|
@@ -57721,6 +58210,8 @@ def runtime_diagnostic(backend: str, *, force: bool = False) -> dict[str, Any]:
|
|
|
57721
58210
|
cached = dict(RUNTIME_DIAGNOSTICS.get(backend) or {})
|
|
57722
58211
|
generation = RUNTIME_DIAGNOSTIC_GENERATIONS.get(backend, 0)
|
|
57723
58212
|
checked_at = cached.get("_installation_checked_at_epoch", cached.get("checked_at_epoch"))
|
|
58213
|
+
if backend == BACKEND_CODEX and cached.get("_codex_auth_mode_checked_at_epoch") is not None:
|
|
58214
|
+
checked_at = min(checked_at or 0, cached["_codex_auth_mode_checked_at_epoch"])
|
|
57724
58215
|
if not force and isinstance(checked_at, (int, float)) and time.time() - checked_at < RUNTIME_DIAGNOSTIC_TTL_SECONDS:
|
|
57725
58216
|
return cached
|
|
57726
58217
|
try:
|
|
@@ -57865,14 +58356,34 @@ def record_runtime_success(backend: str) -> None:
|
|
|
57865
58356
|
version=previous.get("version"),
|
|
57866
58357
|
executable=(
|
|
57867
58358
|
str(previous.get("_executable") or "") or None
|
|
57868
|
-
if backend in (BACKEND_CURSOR, BACKEND_OPENCODE)
|
|
58359
|
+
if backend in (BACKEND_CURSOR, BACKEND_OPENCODE, BACKEND_GEMINI)
|
|
57869
58360
|
else None
|
|
57870
58361
|
),
|
|
57871
58362
|
)
|
|
58363
|
+
if backend == BACKEND_CODEX and previous.get("_codex_auth_mode"):
|
|
58364
|
+
# A successful turn does not establish which login kind was used.
|
|
58365
|
+
# Preserve the existing observation without extending its lifetime.
|
|
58366
|
+
current["_codex_auth_mode"] = previous["_codex_auth_mode"]
|
|
58367
|
+
current["_codex_auth_mode_checked_at_epoch"] = previous.get(
|
|
58368
|
+
"_codex_auth_mode_checked_at_epoch", previous.get("checked_at_epoch", 0),
|
|
58369
|
+
)
|
|
57872
58370
|
store_runtime_diagnostic(current, preserve_last_error=False)
|
|
57873
58371
|
|
|
57874
58372
|
|
|
57875
58373
|
async def ensure_runtime_available(backend: str, *, session: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
58374
|
+
if backend == BACKEND_GEMINI:
|
|
58375
|
+
from gemini_runtime import probe, API_ONLY_MESSAGE
|
|
58376
|
+
if (session or {}).get("provider_connection") != "custom":
|
|
58377
|
+
raise HTTPException(409, API_ONLY_MESSAGE)
|
|
58378
|
+
diagnostic = await asyncio.to_thread(probe, runner_env())
|
|
58379
|
+
store_runtime_diagnostic(diagnostic)
|
|
58380
|
+
if diagnostic.get("status") != "ready":
|
|
58381
|
+
raise HTTPException(503, diagnostic.get("message"))
|
|
58382
|
+
if (session or {}).get("provider_connection") == "custom":
|
|
58383
|
+
from gemini_api import default_model
|
|
58384
|
+
selected = PROVIDER_CONNECTION_STORE.for_session(session)
|
|
58385
|
+
provider_connections.require_model({**selected, "model": default_model(selected)})
|
|
58386
|
+
return diagnostic
|
|
57876
58387
|
diagnostic = await asyncio.to_thread(runtime_diagnostic, backend)
|
|
57877
58388
|
if (session or {}).get("provider_connection") == "custom":
|
|
57878
58389
|
selected = PROVIDER_CONNECTION_STORE.for_session(session)
|
|
@@ -58037,6 +58548,26 @@ def codex_default_service_tier(model: str) -> str:
|
|
|
58037
58548
|
return CODEX_FALLBACK_SERVICE_TIERS.get(str(model or "").strip(), "")
|
|
58038
58549
|
|
|
58039
58550
|
|
|
58551
|
+
def codex_retired_model_replacements() -> dict[str, str]:
|
|
58552
|
+
"""Scope retirement to native ChatGPT auth, never API/custom endpoints."""
|
|
58553
|
+
with RUNTIME_DIAGNOSTICS_LOCK:
|
|
58554
|
+
diagnostic = RUNTIME_DIAGNOSTICS.get(BACKEND_CODEX) or {}
|
|
58555
|
+
checked_at = diagnostic.get("_codex_auth_mode_checked_at_epoch", diagnostic.get("checked_at_epoch")) or 0
|
|
58556
|
+
observed = (str(diagnostic.get("_codex_auth_mode") or "")
|
|
58557
|
+
if time.time() - checked_at < RUNTIME_DIAGNOSTIC_TTL_SECONDS else "")
|
|
58558
|
+
replacement = CODEX_MODEL_MIGRATION_TARGET
|
|
58559
|
+
mode = codex_auth.native_model_auth_mode(
|
|
58560
|
+
codex_app_server_env(), cwd=existing_cwd(DEFAULT_CWD), observed_mode=observed,
|
|
58561
|
+
)
|
|
58562
|
+
if mode != "chatgpt":
|
|
58563
|
+
return {}
|
|
58564
|
+
return {model: replacement for model in sorted(codex_auth.CHATGPT_RETIRED_MODELS)}
|
|
58565
|
+
|
|
58566
|
+
|
|
58567
|
+
def codex_available_model(model: str) -> str:
|
|
58568
|
+
return codex_retired_model_replacements().get(model, model)
|
|
58569
|
+
|
|
58570
|
+
|
|
58040
58571
|
# The Codex app-server's thread/start and turn-override JSON-RPC params only
|
|
58041
58572
|
# accept a narrower serviceTier enum ("fast"/"flex") than the canonical
|
|
58042
58573
|
# service_tier values used for `codex exec` CLI config overrides (see
|
|
@@ -58053,6 +58584,7 @@ def codex_app_server_service_tier(service_tier: str) -> str:
|
|
|
58053
58584
|
|
|
58054
58585
|
|
|
58055
58586
|
def discover_codex_catalog() -> dict[str, Any]:
|
|
58587
|
+
global CODEX_MODEL_MIGRATION_TARGET
|
|
58056
58588
|
models: list[dict[str, Any]] = []
|
|
58057
58589
|
model_options: list[dict[str, Any]] = []
|
|
58058
58590
|
effort_options: list[dict[str, Any]] = []
|
|
@@ -58075,16 +58607,29 @@ def discover_codex_catalog() -> dict[str, Any]:
|
|
|
58075
58607
|
model_source = f"{model_source} failed"
|
|
58076
58608
|
effort_source = f"{effort_source} failed"
|
|
58077
58609
|
|
|
58610
|
+
replacements = codex_retired_model_replacements()
|
|
58078
58611
|
visible_models = [
|
|
58079
58612
|
model for model in models
|
|
58080
58613
|
if str(model.get("visibility") or "list") == "list" and model.get("supported_in_api", True) is not False
|
|
58614
|
+
and str(model.get("slug") or model.get("id") or "").strip() not in replacements
|
|
58081
58615
|
]
|
|
58082
58616
|
visible_models.sort(key=runtime_priority)
|
|
58617
|
+
if replacements and visible_models:
|
|
58618
|
+
# Use an available configured default, then the native default/priority.
|
|
58619
|
+
# The fallback is only needed when the CLI cannot return a catalog.
|
|
58620
|
+
slugs = [str(entry.get("slug") or entry.get("id") or "").strip() for entry in visible_models]
|
|
58621
|
+
preferred = next((slug for slug in (configured_model, CODEX_DEFAULT_MODEL) if slug in slugs), None)
|
|
58622
|
+
if preferred is None:
|
|
58623
|
+
preferred = next((str(entry.get("slug") or entry.get("id")) for entry in visible_models if entry.get("is_default") is True or entry.get("isDefault") is True), slugs[0])
|
|
58624
|
+
if preferred:
|
|
58625
|
+
with RUNTIME_DIAGNOSTICS_LOCK:
|
|
58626
|
+
CODEX_MODEL_MIGRATION_TARGET = preferred
|
|
58627
|
+
replacements = {model: preferred for model in replacements}
|
|
58083
58628
|
# The selectable "Server default" must describe the exact settings that
|
|
58084
58629
|
# codex_runtime_settings() launches for a chat with no explicit override.
|
|
58085
58630
|
# The CLI's discovered list can omit a configured/custom default (or a
|
|
58086
58631
|
# legacy fallback), so the first discovered model is not a safe proxy.
|
|
58087
|
-
default_model = configured_model or CODEX_DEFAULT_MODEL
|
|
58632
|
+
default_model = replacements.get(configured_model or CODEX_DEFAULT_MODEL, configured_model or CODEX_DEFAULT_MODEL)
|
|
58088
58633
|
default_effort = clamp_codex_runtime_effort(
|
|
58089
58634
|
default_model,
|
|
58090
58635
|
configured_effort or CODEX_DEFAULT_EFFORT,
|
|
@@ -58131,6 +58676,8 @@ def discover_codex_catalog() -> dict[str, Any]:
|
|
|
58131
58676
|
|
|
58132
58677
|
if not model_options:
|
|
58133
58678
|
for slug, label in CODEX_FALLBACK_MODELS:
|
|
58679
|
+
if slug in replacements:
|
|
58680
|
+
continue
|
|
58134
58681
|
fallback_efforts = [
|
|
58135
58682
|
runtime_option(effort, title_effort_label(effort))
|
|
58136
58683
|
for effort in CODEX_FALLBACK_MODEL_EFFORTS.get(slug, CODEX_FALLBACK_EFFORTS)
|
|
@@ -58140,7 +58687,7 @@ def discover_codex_catalog() -> dict[str, Any]:
|
|
|
58140
58687
|
model_options.append(runtime_option(slug, label, efforts=fallback_efforts, service_tier=service_tier or None))
|
|
58141
58688
|
if not effort_options:
|
|
58142
58689
|
effort_options.extend(runtime_option(effort, title_effort_label(effort)) for effort in CODEX_FALLBACK_EFFORTS)
|
|
58143
|
-
if configured_model and not any(option.get("value") == configured_model for option in model_options):
|
|
58690
|
+
if configured_model and configured_model not in replacements and not any(option.get("value") == configured_model for option in model_options):
|
|
58144
58691
|
model_options.insert(0, runtime_option(configured_model, title_model_label(configured_model)))
|
|
58145
58692
|
if configured_effort and not any(option.get("value") == configured_effort for option in effort_options):
|
|
58146
58693
|
effort_options.append(runtime_option(configured_effort, title_effort_label(configured_effort)))
|
|
@@ -58163,6 +58710,7 @@ def discover_codex_catalog() -> dict[str, Any]:
|
|
|
58163
58710
|
"default_model": default_model or None,
|
|
58164
58711
|
"default_effort": default_effort or None,
|
|
58165
58712
|
"default_service_tier": default_service_tier or None,
|
|
58713
|
+
"model_replacements": replacements,
|
|
58166
58714
|
}
|
|
58167
58715
|
|
|
58168
58716
|
|
|
@@ -58658,6 +59206,13 @@ def discover_runtime_backend_catalog(backend: str, *, force_runtime_probe: bool
|
|
|
58658
59206
|
diagnostic = runtime_diagnostic(backend, force=force_runtime_probe)
|
|
58659
59207
|
ready = diagnostic.get("status") == "ready"
|
|
58660
59208
|
executable = str(diagnostic.get("_executable") or "") or None
|
|
59209
|
+
if backend == BACKEND_GEMINI:
|
|
59210
|
+
return {"models": [], "default_model": None,
|
|
59211
|
+
"efforts": [], "default_effort": None, "effort_source": "none",
|
|
59212
|
+
"model_source": "Configure a Gemini API connection in My Agents",
|
|
59213
|
+
"available": False, "diagnostic": public_runtime_diagnostic(diagnostic),
|
|
59214
|
+
"native_credentials_present": False,
|
|
59215
|
+
"custom_provider": PROVIDER_CONNECTION_STORE.catalog(backend, installed=ready)}
|
|
58661
59216
|
if backend == BACKEND_CLAUDE:
|
|
58662
59217
|
catalog = parse_claude_help_catalog()
|
|
58663
59218
|
elif backend == BACKEND_CODEX:
|
|
@@ -58750,7 +59305,7 @@ def codex_runtime_settings(sess: dict[str, Any]) -> tuple[str, str, str]:
|
|
|
58750
59305
|
catalog = CODEX_PROVIDER_STORE.cached_catalog(selected)
|
|
58751
59306
|
return model, codex_provider.runtime_effort(selected, catalog, sess.get("effort")), ""
|
|
58752
59307
|
configured_model, configured_effort, configured_service_tier = codex_user_config_defaults()
|
|
58753
|
-
model = str(sess.get("model") or configured_model or CODEX_DEFAULT_MODEL).strip()
|
|
59308
|
+
model = codex_available_model(str(sess.get("model") or configured_model or CODEX_DEFAULT_MODEL).strip())
|
|
58754
59309
|
effort = clamp_codex_runtime_effort(
|
|
58755
59310
|
model,
|
|
58756
59311
|
sess.get("effort") or configured_effort or CODEX_DEFAULT_EFFORT,
|
|
@@ -58759,6 +59314,39 @@ def codex_runtime_settings(sess: dict[str, Any]) -> tuple[str, str, str]:
|
|
|
58759
59314
|
return model, effort, service_tier
|
|
58760
59315
|
|
|
58761
59316
|
|
|
59317
|
+
async def migrate_retired_codex_selection(
|
|
59318
|
+
session_id: str, sess: dict[str, Any], *, standalone: bool = False,
|
|
59319
|
+
previous_model: str | None = None,
|
|
59320
|
+
) -> dict[str, Any]:
|
|
59321
|
+
"""Migrate future work under the existing admission lock; retain history.
|
|
59322
|
+
|
|
59323
|
+
No native transcript edits, forks, auth changes, or retries. Native
|
|
59324
|
+
compaction/recovery remains Codex-owned and can still fail independently.
|
|
59325
|
+
"""
|
|
59326
|
+
if sess.get("backend") != BACKEND_CODEX or sess.get("codex_provider") == "custom":
|
|
59327
|
+
return sess
|
|
59328
|
+
configured_model, _effort, _tier = codex_user_config_defaults()
|
|
59329
|
+
previous = str(previous_model or sess.get("model") or configured_model or CODEX_DEFAULT_MODEL).strip()
|
|
59330
|
+
replacement = codex_available_model(previous)
|
|
59331
|
+
if previous == replacement:
|
|
59332
|
+
return sess
|
|
59333
|
+
patch = {"model": replacement, "effort": clamp_codex_runtime_effort(replacement, sess.get("effort") or _effort or CODEX_DEFAULT_EFFORT)}
|
|
59334
|
+
if standalone:
|
|
59335
|
+
migrated = {**sess, **patch}
|
|
59336
|
+
elif all(sess.get(key) == value for key, value in patch.items()):
|
|
59337
|
+
migrated = sess
|
|
59338
|
+
else:
|
|
59339
|
+
migrated = await STORE.update(session_id, patch)
|
|
59340
|
+
await append_event(session_id, "runtime_model_migrated", {
|
|
59341
|
+
"backend": BACKEND_CODEX, "previous_model": previous, "model": replacement,
|
|
59342
|
+
"standalone": standalone,
|
|
59343
|
+
"message": (f"{previous} is no longer available with ChatGPT sign-in. "
|
|
59344
|
+
f"This {'scheduled run' if standalone else 'chat'} will use {replacement}. "
|
|
59345
|
+
"Conversation history is unchanged."),
|
|
59346
|
+
})
|
|
59347
|
+
return migrated
|
|
59348
|
+
|
|
59349
|
+
|
|
58762
59350
|
def codex_goal_time_budget_remaining(sess: dict[str, Any]) -> float | None:
|
|
58763
59351
|
goal = sess.get("codex_goal")
|
|
58764
59352
|
limit = sess.get("codex_goal_time_budget_seconds")
|
|
@@ -60026,7 +60614,7 @@ async def discover_session_provider_commands(
|
|
|
60026
60614
|
|
|
60027
60615
|
backend = str(session.get("backend") or DEFAULT_BACKEND).strip().lower()
|
|
60028
60616
|
requested_cwd = str(session.get("cwd") or DEFAULT_CWD)
|
|
60029
|
-
if backend
|
|
60617
|
+
if backend in {BACKEND_OPENCODE, BACKEND_GEMINI}:
|
|
60030
60618
|
try:
|
|
60031
60619
|
cwd = validated_opencode_cwd(requested_cwd)
|
|
60032
60620
|
except HTTPException:
|
|
@@ -60040,7 +60628,7 @@ async def discover_session_provider_commands(
|
|
|
60040
60628
|
support = provider_command_support(
|
|
60041
60629
|
False,
|
|
60042
60630
|
"unavailable",
|
|
60043
|
-
"OpenCode local skills require the chat working directory to be available.",
|
|
60631
|
+
f"{'Gemini' if backend == BACKEND_GEMINI else 'OpenCode'} local skills require the chat working directory to be available.",
|
|
60044
60632
|
)
|
|
60045
60633
|
return provider_command_snapshot(fallback_empty, support), fallback_empty
|
|
60046
60634
|
else:
|
|
@@ -60080,7 +60668,7 @@ async def discover_session_provider_commands(
|
|
|
60080
60668
|
provider_command_snapshot(fallback_empty, support),
|
|
60081
60669
|
fallback_empty,
|
|
60082
60670
|
)
|
|
60083
|
-
elif backend
|
|
60671
|
+
elif backend in {BACKEND_OPENCODE, BACKEND_GEMINI}:
|
|
60084
60672
|
pass
|
|
60085
60673
|
else:
|
|
60086
60674
|
support = provider_command_support(
|
|
@@ -60174,6 +60762,26 @@ async def discover_session_provider_commands(
|
|
|
60174
60762
|
f"Showing the first {MAX_PROVIDER_COMMANDS} provider commands."
|
|
60175
60763
|
)
|
|
60176
60764
|
return provider_command_snapshot(inventory, support), inventory
|
|
60765
|
+
if backend == BACKEND_GEMINI:
|
|
60766
|
+
from gemini_runtime import build_environment, list_native_skills, resolve_executable, GeminiConfigurationError
|
|
60767
|
+
def discover_gemini():
|
|
60768
|
+
connection = PROVIDER_CONNECTION_STORE.for_session(session)
|
|
60769
|
+
profile = PROVIDER_CONNECTION_STORE.root / ("gemini-runtime-" + connection["credential_id"]) if connection else None
|
|
60770
|
+
env = build_environment(runner_env(), cwd=cwd, api_home=profile,
|
|
60771
|
+
api_key=connection.get("api_key") if connection else None,
|
|
60772
|
+
base_url=connection.get("base_url") if connection else None,
|
|
60773
|
+
native_history=bool(session.get("gemini_native_history")))
|
|
60774
|
+
executable = resolve_executable(env)
|
|
60775
|
+
if not executable:
|
|
60776
|
+
raise GeminiConfigurationError("Install Gemini CLI to discover its skills.")
|
|
60777
|
+
return gemini_provider_skill_inventory(list_native_skills(executable, env, cwd),
|
|
60778
|
+
cwd=cwd, selector_secret=identity, binding_context=session_id)
|
|
60779
|
+
try:
|
|
60780
|
+
inventory = await asyncio.to_thread(discover_gemini)
|
|
60781
|
+
except (GeminiConfigurationError, ProviderCommandDiscoveryError, HTTPException, OSError, ValueError):
|
|
60782
|
+
return provider_command_snapshot(empty, provider_command_support(False, "unavailable",
|
|
60783
|
+
"Gemini skills could not be read. Check gemini skills list --all in this workspace and refresh.")), empty
|
|
60784
|
+
return provider_command_snapshot(inventory, provider_command_support(True, "native_activation_request")), inventory
|
|
60177
60785
|
if backend == BACKEND_OPENCODE:
|
|
60178
60786
|
try:
|
|
60179
60787
|
env = runner_env()
|
|
@@ -60251,6 +60859,8 @@ async def resolve_provider_command_selection(
|
|
|
60251
60859
|
if backend == BACKEND_CLAUDE
|
|
60252
60860
|
else OPENCODE_PROVIDER_COMMANDS_CLIENT_CAPABILITY
|
|
60253
60861
|
if backend == BACKEND_OPENCODE
|
|
60862
|
+
else GEMINI_CLIENT_CAPABILITY
|
|
60863
|
+
if backend == BACKEND_GEMINI
|
|
60254
60864
|
else None
|
|
60255
60865
|
)
|
|
60256
60866
|
if required_capability is None:
|
|
@@ -60688,7 +61298,9 @@ async def update_reasoning_summary_stream(
|
|
|
60688
61298
|
phase: str = "summary",
|
|
60689
61299
|
) -> None:
|
|
60690
61300
|
"""Keep provider-exposed summary and plaintext reasoning channels distinct."""
|
|
60691
|
-
if phase not in {"summary", "reasoning"}:
|
|
61301
|
+
if phase not in {"summary", "reasoning", "assistant"}:
|
|
61302
|
+
return
|
|
61303
|
+
if phase == "assistant" and (metadata or {}).get("backend") != "gemini":
|
|
60692
61304
|
return
|
|
60693
61305
|
index = params.get("contentIndex" if phase == "reasoning" else "summaryIndex", 0)
|
|
60694
61306
|
delta = params.get("delta", "")
|
|
@@ -60708,7 +61320,7 @@ async def update_reasoning_summary_stream(
|
|
|
60708
61320
|
after_seq = await asyncio.to_thread(last_event_seq_from_file, events_path(session_id))
|
|
60709
61321
|
state = {"parts": {}, "item": {
|
|
60710
61322
|
**dict(metadata or {}), "run_id": run_id, "item_id": item_id,
|
|
60711
|
-
"backend": BACKEND_CODEX, "phase": phase, "text": "",
|
|
61323
|
+
"backend": (metadata or {}).get("backend", BACKEND_CODEX), "phase": phase, "text": "",
|
|
60712
61324
|
"ts": now_iso(), "after_seq": after_seq,
|
|
60713
61325
|
}}
|
|
60714
61326
|
streams[(run_id, item_id, phase)] = state
|
|
@@ -68907,6 +69519,35 @@ async def retain_codex_goal_run_owner(
|
|
|
68907
69519
|
return True
|
|
68908
69520
|
|
|
68909
69521
|
|
|
69522
|
+
def codex_native_archive_rejection(exc: BaseException, thread_id: str) -> bool:
|
|
69523
|
+
"""Only the owning native resume RPC can report this state, not chat text."""
|
|
69524
|
+
return bool(
|
|
69525
|
+
thread_id and isinstance(exc, CodexAppServerRequestError)
|
|
69526
|
+
and exc.method == "thread/resume" and isinstance(exc.error, dict)
|
|
69527
|
+
and exc.error.get("message") == (
|
|
69528
|
+
f"session {thread_id} is archived. Run `codex unarchive {thread_id}` to unarchive it first."
|
|
69529
|
+
)
|
|
69530
|
+
)
|
|
69531
|
+
|
|
69532
|
+
|
|
69533
|
+
async def archive_rejected_codex_session(session_id: str, thread_id: str, run_id: str) -> None:
|
|
69534
|
+
"""Mirror a confirmed native archive through the normal local archive path.
|
|
69535
|
+
|
|
69536
|
+
This never archives/unarchives/deletes the native thread. It retires local
|
|
69537
|
+
routes and jobs exactly like the user's Archive action, preserving history.
|
|
69538
|
+
Identity is stable while this run owns the chat; ignore stale/deleted runs.
|
|
69539
|
+
"""
|
|
69540
|
+
current = STORE.sessions.get(session_id)
|
|
69541
|
+
active = ACTIVE.get(session_id) or {}
|
|
69542
|
+
if (not current or current.get("archived") or session_id in DELETING_SESSIONS
|
|
69543
|
+
or current.get("backend") != BACKEND_CODEX
|
|
69544
|
+
or str(session_provider_id(current) or "") != thread_id
|
|
69545
|
+
or active.get("run_id") != run_id):
|
|
69546
|
+
return
|
|
69547
|
+
await _update_session(session_id, UpdateSessionRequest(archived=True),
|
|
69548
|
+
native_archive_owner=(thread_id, run_id))
|
|
69549
|
+
|
|
69550
|
+
|
|
68910
69551
|
async def run_codex_app_server(
|
|
68911
69552
|
session_id: str,
|
|
68912
69553
|
run_id: str,
|
|
@@ -71330,6 +71971,7 @@ async def run_codex_app_server(
|
|
|
71330
71971
|
thread_pinned = False
|
|
71331
71972
|
can_fallback = (
|
|
71332
71973
|
allow_exec_fallback
|
|
71974
|
+
and not codex_native_archive_rejection(exc, str(session_provider_id(sess) or ""))
|
|
71333
71975
|
and codex_provider.session_choice(sess.get("codex_provider")) == "default"
|
|
71334
71976
|
and provider_command is None
|
|
71335
71977
|
and not stop_requested
|
|
@@ -71394,6 +72036,15 @@ async def run_codex_app_server(
|
|
|
71394
72036
|
**current_metadata(),
|
|
71395
72037
|
})
|
|
71396
72038
|
error_emitted = True
|
|
72039
|
+
if codex_native_archive_rejection(exc, str(session_provider_id(sess) or "")):
|
|
72040
|
+
try:
|
|
72041
|
+
await archive_rejected_codex_session(
|
|
72042
|
+
session_id, str(session_provider_id(sess)), current_run_id,
|
|
72043
|
+
)
|
|
72044
|
+
except Exception:
|
|
72045
|
+
# Preserve the actual provider error if local retirement
|
|
72046
|
+
# fails. Never pretend the native thread was unarchived.
|
|
72047
|
+
logger.exception("Could not mirror native Codex archive session=%s", session_id)
|
|
71397
72048
|
finally:
|
|
71398
72049
|
async def settle_transport_finalizer() -> None:
|
|
71399
72050
|
"""Retire every owner before cancellation can escape the runner."""
|
|
@@ -71789,7 +72440,7 @@ async def start_turn(
|
|
|
71789
72440
|
scheduled_job_revision: str | None = None,
|
|
71790
72441
|
scheduled_job_manual_run: bool = False,
|
|
71791
72442
|
) -> dict[str, Any]:
|
|
71792
|
-
await wait_for_queue_recovery_admission()
|
|
72443
|
+
await wait_for_queue_recovery_admission(session_id)
|
|
71793
72444
|
if queue_if_busy and provider_context_mode == "chat":
|
|
71794
72445
|
await reconcile_idle_queue_session(
|
|
71795
72446
|
session_id,
|
|
@@ -71862,7 +72513,7 @@ async def _start_turn_locked(
|
|
|
71862
72513
|
# Internal delivery paths can enter with the lifecycle lock already held
|
|
71863
72514
|
# and intentionally bypass ``start_turn``. They still must not start or
|
|
71864
72515
|
# queue ahead of an undiscovered durable startup row.
|
|
71865
|
-
await wait_for_queue_recovery_admission()
|
|
72516
|
+
await wait_for_queue_recovery_admission(session_id)
|
|
71866
72517
|
if (
|
|
71867
72518
|
req.purpose == SECURE_PEER_DELIVERY_PURPOSE
|
|
71868
72519
|
and not SECURE_PEER_AGENT_RELAY_ENABLED
|
|
@@ -71884,6 +72535,8 @@ async def _start_turn_locked(
|
|
|
71884
72535
|
raise HTTPException(status_code=404, detail="session not found")
|
|
71885
72536
|
if sess.get("archived"):
|
|
71886
72537
|
raise HTTPException(status_code=409, detail="archived chats cannot start turns")
|
|
72538
|
+
if sess.get("gemini_cleanup_pending_run_id"):
|
|
72539
|
+
raise HTTPException(status_code=409, detail="Gemini runtime cleanup is unconfirmed. Retry Stop; after a server restart, server-side runtime verification is required before this chat can continue.")
|
|
71887
72540
|
if req.purpose == "chat_mailbox_wake" or mailbox_wake_claim is not None:
|
|
71888
72541
|
if (req.purpose != "chat_mailbox_wake" or mailbox_wake_claim is None
|
|
71889
72542
|
or mailbox_wake_claim.get("target_session_id") != session_id
|
|
@@ -72173,6 +72826,9 @@ async def _start_turn_locked(
|
|
|
72173
72826
|
),
|
|
72174
72827
|
runtime_validation_patch,
|
|
72175
72828
|
)
|
|
72829
|
+
if runtime_preview_session.get("backend") == BACKEND_GEMINI:
|
|
72830
|
+
if GEMINI_CLIENT_CAPABILITY not in req.client_capabilities:
|
|
72831
|
+
raise HTTPException(409, "Update AgentsDock to a Gemini-capable client before sending messages.")
|
|
72176
72832
|
if str(runtime_preview_session.get("backend") or DEFAULT_BACKEND).strip().lower() == BACKEND_CODEX:
|
|
72177
72833
|
await prepare_codex_login_turn(runtime_preview_session)
|
|
72178
72834
|
opencode_cwd: str | None = None
|
|
@@ -72219,6 +72875,10 @@ async def _start_turn_locked(
|
|
|
72219
72875
|
session_id,
|
|
72220
72876
|
list(item.get("file_ids") or []),
|
|
72221
72877
|
)
|
|
72878
|
+
if runtime_preview_session.get("backend") == BACKEND_GEMINI:
|
|
72879
|
+
from gemini_content import attachments as gemini_attachments
|
|
72880
|
+
await asyncio.to_thread(gemini_attachments, sys.modules[__name__], session_id,
|
|
72881
|
+
list(normalized_lineage[-1].get("file_ids") or []) if len(normalized_lineage) >= 2 else list(req.file_ids))
|
|
72222
72882
|
# OpenCode's native --file path has no provider-side size ceiling and can
|
|
72223
72883
|
# eagerly ingest local data. Resolve and bound the exact current message
|
|
72224
72884
|
# before either reserving a slot or durably accepting it into the queue.
|
|
@@ -72435,6 +73095,9 @@ async def _start_turn_locked(
|
|
|
72435
73095
|
if team_mail_grant_mutation:
|
|
72436
73096
|
team_mail_route_snapshot = team_mail_grants.admission_snapshot(team_mail_grant_mutation, sess)
|
|
72437
73097
|
fields_set = runtime_fields_set
|
|
73098
|
+
migration_previous_model = str(
|
|
73099
|
+
req.model if "model" in fields_set and req.model else sess.get("model") or ""
|
|
73100
|
+
).strip() or None
|
|
72438
73101
|
runtime_patch: dict[str, Any] = {}
|
|
72439
73102
|
if req.backend:
|
|
72440
73103
|
runtime_patch["backend"] = req.backend
|
|
@@ -72463,14 +73126,20 @@ async def _start_turn_locked(
|
|
|
72463
73126
|
|
|
72464
73127
|
backend = sess.get("backend") or DEFAULT_BACKEND
|
|
72465
73128
|
runtime_status = await ensure_runtime_available(backend, session=sess)
|
|
73129
|
+
if backend == BACKEND_CODEX:
|
|
73130
|
+
sess = await migrate_retired_codex_selection(
|
|
73131
|
+
session_id, sess, standalone=provider_context_mode == "standalone",
|
|
73132
|
+
previous_model=migration_previous_model,
|
|
73133
|
+
)
|
|
72466
73134
|
if provider_context_mode == "chat":
|
|
72467
73135
|
sess = await STORE.mark_backend_started(session_id, str(backend))
|
|
72468
|
-
if backend in (BACKEND_CURSOR, BACKEND_OPENCODE):
|
|
73136
|
+
if backend in (BACKEND_CURSOR, BACKEND_OPENCODE, BACKEND_GEMINI):
|
|
72469
73137
|
# Keep the exact compatibility-probed executable pinned through
|
|
72470
73138
|
# admission; mark_backend_started returns a fresh durable snapshot.
|
|
72471
73139
|
sess = dict(sess)
|
|
72472
73140
|
executable_key = (
|
|
72473
|
-
"
|
|
73141
|
+
"_gemini_executable" if backend == BACKEND_GEMINI
|
|
73142
|
+
else "_opencode_executable" if backend == BACKEND_OPENCODE
|
|
72474
73143
|
else "_cursor_executable"
|
|
72475
73144
|
)
|
|
72476
73145
|
sess[executable_key] = runtime_status.get("_executable")
|
|
@@ -72784,7 +73453,7 @@ async def _start_turn_locked(
|
|
|
72784
73453
|
else None
|
|
72785
73454
|
),
|
|
72786
73455
|
)
|
|
72787
|
-
if turn_obligation_ids and backend in (BACKEND_CURSOR, BACKEND_OPENCODE):
|
|
73456
|
+
if turn_obligation_ids and backend in (BACKEND_CURSOR, BACKEND_OPENCODE, BACKEND_GEMINI):
|
|
72788
73457
|
provider_authority_context += (
|
|
72789
73458
|
"\n\n[AgentsDock final-result handoff]\n"
|
|
72790
73459
|
"Your successful non-empty final answer will be delivered once to the explicitly referenced chat. "
|
|
@@ -72814,7 +73483,7 @@ async def _start_turn_locked(
|
|
|
72814
73483
|
prompt,
|
|
72815
73484
|
)
|
|
72816
73485
|
provider_prompt = provider_turn_payload.user_prompt
|
|
72817
|
-
if backend
|
|
73486
|
+
if backend in {BACKEND_CURSOR, BACKEND_GEMINI} or (
|
|
72818
73487
|
backend == BACKEND_OPENCODE and resolved_provider_command is None
|
|
72819
73488
|
):
|
|
72820
73489
|
# These print-mode CLIs have no separate system/application-context
|
|
@@ -72884,6 +73553,8 @@ async def _start_turn_locked(
|
|
|
72884
73553
|
else:
|
|
72885
73554
|
started_payload["model"] = sess.get("model")
|
|
72886
73555
|
started_payload["effort"] = sess.get("effort")
|
|
73556
|
+
if getattr(req, "client_request_id", None):
|
|
73557
|
+
started_payload["client_request_id"] = req.client_request_id
|
|
72887
73558
|
if queued_id:
|
|
72888
73559
|
started_payload["queued_id"] = queued_id
|
|
72889
73560
|
run_metadata = {
|
|
@@ -72963,6 +73634,7 @@ async def _start_turn_locked(
|
|
|
72963
73634
|
or mailbox_wake_claim is not None
|
|
72964
73635
|
or getattr(req, "shared_chat_id", None) is not None
|
|
72965
73636
|
or queued_id is not None
|
|
73637
|
+
or getattr(req, "client_request_id", None) is not None
|
|
72966
73638
|
)
|
|
72967
73639
|
else append_event(session_id, "turn_started", started_payload)
|
|
72968
73640
|
)
|
|
@@ -73136,6 +73808,21 @@ async def _start_turn_locked(
|
|
|
73136
73808
|
else {}
|
|
73137
73809
|
),
|
|
73138
73810
|
)
|
|
73811
|
+
elif backend == BACKEND_GEMINI:
|
|
73812
|
+
from gemini_runner import run as run_gemini
|
|
73813
|
+
task = run_gemini(sys.modules[__name__], session_id, run_id, provider_prompt,
|
|
73814
|
+
dict(sess), manifest_path,
|
|
73815
|
+
file_ids=prompt_file_ids,
|
|
73816
|
+
communication_required=bool(
|
|
73817
|
+
any(reference.action in {"route", "direct_message", "instruction", "request_reply"}
|
|
73818
|
+
for reference in req.chat_references)
|
|
73819
|
+
or req.team_references or team_mail_command
|
|
73820
|
+
or mailbox_wake_claim is not None
|
|
73821
|
+
or req.purpose in {"cross_chat_handoff_delivery", "secure_peer_handoff_delivery"}
|
|
73822
|
+
),
|
|
73823
|
+
provider_command=resolved_provider_command,
|
|
73824
|
+
provider_runtime_env=provider_turn_payload.runtime_env,
|
|
73825
|
+
standalone_provider_context=provider_context_mode == "standalone")
|
|
73139
73826
|
elif backend == BACKEND_OPENCODE:
|
|
73140
73827
|
task = run_opencode(
|
|
73141
73828
|
session_id,
|
|
@@ -76325,7 +77012,7 @@ def ensure_managed_update_service_cgroup_clear(
|
|
|
76325
77012
|
*,
|
|
76326
77013
|
service_cgroup: str | None = None,
|
|
76327
77014
|
) -> dict[str, Any]:
|
|
76328
|
-
"""
|
|
77015
|
+
"""Require no children, or prove this worker's stop policy preserves them."""
|
|
76329
77016
|
|
|
76330
77017
|
state = managed_update_service_cgroup_state(
|
|
76331
77018
|
service_cgroup=service_cgroup,
|
|
@@ -77426,6 +78113,7 @@ async def lifespan(app: FastAPI):
|
|
|
77426
78113
|
cleanup_abandoned_fork_provider_threads(startup_abandoned_fork_threads)
|
|
77427
78114
|
)
|
|
77428
78115
|
host_monitor_task = asyncio.create_task(host_monitor_loop())
|
|
78116
|
+
queue_checkpoint_task = asyncio.create_task(queue_checkpoint_loop())
|
|
77429
78117
|
history_search_task = asyncio.create_task(history_search_index_loop())
|
|
77430
78118
|
runtime_probe_task = asyncio.create_task(asyncio.to_thread(refresh_runtime_diagnostics, force=True))
|
|
77431
78119
|
if CONFIG_ENV_APPLIED:
|
|
@@ -77503,6 +78191,10 @@ async def lifespan(app: FastAPI):
|
|
|
77503
78191
|
cross_chat_expiry_task.cancel()
|
|
77504
78192
|
digest_recovery_task.cancel()
|
|
77505
78193
|
queue_recovery_task.cancel()
|
|
78194
|
+
queue_checkpoint_task.cancel()
|
|
78195
|
+
history_tasks = (*HISTORY_MAINTENANCE_TASKS.values(), *(item[1] for item in TURN_RECEIPT_TASKS.values()))
|
|
78196
|
+
for task in history_tasks:
|
|
78197
|
+
task.cancel()
|
|
77506
78198
|
server_update_pending_waiter_task.cancel()
|
|
77507
78199
|
await bounded_shutdown_phase(
|
|
77508
78200
|
"startup-reconciliation",
|
|
@@ -77511,6 +78203,8 @@ async def lifespan(app: FastAPI):
|
|
|
77511
78203
|
cross_chat_expiry_task,
|
|
77512
78204
|
digest_recovery_task,
|
|
77513
78205
|
queue_recovery_task,
|
|
78206
|
+
queue_checkpoint_task,
|
|
78207
|
+
*history_tasks,
|
|
77514
78208
|
server_update_pending_waiter_task,
|
|
77515
78209
|
*(
|
|
77516
78210
|
(health_queue_reconcile_task,)
|
|
@@ -78295,6 +78989,7 @@ async def require_agent_token(request: Request, call_next):
|
|
|
78295
78989
|
codex_goals_admin_route = request.url.path in {
|
|
78296
78990
|
"/api/admin/codex/goals", "/api/admin/codex/subagents",
|
|
78297
78991
|
"/api/admin/codex/auth", "/api/admin/codex/auth/api-key",
|
|
78992
|
+
"/api/admin/codex/auth/reload",
|
|
78298
78993
|
"/api/admin/codex/provider", "/api/admin/codex/provider/test",
|
|
78299
78994
|
"/api/admin/codex/provider/models",
|
|
78300
78995
|
"/api/admin/provider-connections/claude", "/api/admin/provider-connections/claude/check",
|
|
@@ -78483,7 +79178,7 @@ async def require_agent_token(request: Request, call_next):
|
|
|
78483
79178
|
if body_error is not None:
|
|
78484
79179
|
status_code, detail = body_error
|
|
78485
79180
|
return JSONResponse({"detail": detail}, status_code=status_code)
|
|
78486
|
-
elif request.url.path
|
|
79181
|
+
elif request.url.path in {"/api/admin/codex/auth/api-key", "/api/admin/codex/auth/reload"} and request.method.upper() == "POST":
|
|
78487
79182
|
declared_size, transport_error = privileged_native_json_transport(
|
|
78488
79183
|
request, max_body_bytes=codex_auth.MAX_BODY_BYTES,
|
|
78489
79184
|
label="Codex authentication", require_content_length=True,
|
|
@@ -80829,6 +81524,7 @@ async def health() -> dict[str, Any]:
|
|
|
80829
81524
|
team_hub_capability = await team_hub_capability_for_health()
|
|
80830
81525
|
return {
|
|
80831
81526
|
"ok": True,
|
|
81527
|
+
"chat_readiness": queue_recovery_readiness(),
|
|
80832
81528
|
"server_version": SERVER_VERSION,
|
|
80833
81529
|
"api_contract_version": API_CONTRACT_VERSION,
|
|
80834
81530
|
"server_identity": server_identity(),
|
|
@@ -80850,7 +81546,7 @@ async def health() -> dict[str, Any]:
|
|
|
80850
81546
|
"side_questions": side_questions.capability(),
|
|
80851
81547
|
"codex_auth_v1": codex_auth.capability(available=bool(AGENT_TOKEN) and CODEX_TRANSPORT != CODEX_TRANSPORT_EXEC),
|
|
80852
81548
|
"codex_provider_v1": {"available": bool(AGENT_TOKEN) and CODEX_TRANSPORT != CODEX_TRANSPORT_EXEC, "wire_api": "responses", "per_chat": True, "per_chat_models": True, "model_discovery": True, "model_compatibility": True},
|
|
80853
|
-
"provider_connections_v1": {"available": bool(AGENT_TOKEN), "per_chat": True, "backends": [BACKEND_CLAUDE, BACKEND_OPENCODE, BACKEND_CURSOR]},
|
|
81549
|
+
"provider_connections_v1": {"available": bool(AGENT_TOKEN), "per_chat": True, "backends": [BACKEND_CLAUDE, BACKEND_OPENCODE, BACKEND_CURSOR, BACKEND_GEMINI]},
|
|
80854
81550
|
"team_mail_hints_v1": SECURE_PEER_RUNTIME.team_mail_hint_capability(),
|
|
80855
81551
|
"team_mail_hints_v2": SECURE_PEER_RUNTIME.team_notification_hint_capability(),
|
|
80856
81552
|
"websocket_auth_v1": {
|
|
@@ -80865,6 +81561,7 @@ async def health() -> dict[str, Any]:
|
|
|
80865
81561
|
"event_protocol": EVENTS_WEBSOCKET_PROTOCOL,
|
|
80866
81562
|
"terminal_protocol": TERMINAL_WEBSOCKET_PROTOCOL,
|
|
80867
81563
|
},
|
|
81564
|
+
"event_stream_liveness_v1": {"available": True, "version": 1},
|
|
80868
81565
|
"team_hub_v1": team_hub_capability,
|
|
80869
81566
|
"team_hub_host_control_v1": team_hub_host_control_capability(),
|
|
80870
81567
|
"agent_team_mail_routes_v1": {
|
|
@@ -80971,16 +81668,19 @@ async def health() -> dict[str, Any]:
|
|
|
80971
81668
|
BACKEND_CODEX,
|
|
80972
81669
|
BACKEND_CLAUDE,
|
|
80973
81670
|
BACKEND_OPENCODE,
|
|
81671
|
+
BACKEND_GEMINI,
|
|
80974
81672
|
],
|
|
80975
81673
|
"client_capability_by_backend": {
|
|
80976
81674
|
BACKEND_CODEX: CODEX_INTERACTIVE_CLIENT_CAPABILITY,
|
|
80977
81675
|
BACKEND_CLAUDE: CLAUDE_SDK_INTERACTIVE_CLIENT_CAPABILITY,
|
|
80978
81676
|
BACKEND_OPENCODE: OPENCODE_PROVIDER_COMMANDS_CLIENT_CAPABILITY,
|
|
81677
|
+
BACKEND_GEMINI: GEMINI_CLIENT_CAPABILITY,
|
|
80979
81678
|
},
|
|
80980
81679
|
"supported_kinds_by_backend": {
|
|
80981
81680
|
BACKEND_CODEX: ["skill"],
|
|
80982
81681
|
BACKEND_CLAUDE: ["command"],
|
|
80983
81682
|
BACKEND_OPENCODE: ["skill"],
|
|
81683
|
+
BACKEND_GEMINI: ["skill"],
|
|
80984
81684
|
},
|
|
80985
81685
|
"opencode_scope": {
|
|
80986
81686
|
"discovery": "bounded_documented_default_skill_roots",
|
|
@@ -81051,6 +81751,25 @@ async def health() -> dict[str, Any]:
|
|
|
81051
81751
|
"max_client_frame_bytes": PORT_TUNNEL_MAX_CLIENT_FRAME_BYTES,
|
|
81052
81752
|
**port_tunnel_status,
|
|
81053
81753
|
},
|
|
81754
|
+
"gemini_backend": {
|
|
81755
|
+
# Server support is always advertised; CLI installation and
|
|
81756
|
+
# API connection readiness are checked separately at runtime.
|
|
81757
|
+
"available": True, "required": False, "version": 1, "transport": "acp",
|
|
81758
|
+
"permission_modes": ["default", "autoEdit", "yolo"],
|
|
81759
|
+
"default_permission_mode": "default",
|
|
81760
|
+
"message": "Gemini requires a compatible Gemini CLI and a configured API connection in My Agents.", "action": None,
|
|
81761
|
+
"client_capability": GEMINI_CLIENT_CAPABILITY,
|
|
81762
|
+
"history_mode": "initial_text_snapshot",
|
|
81763
|
+
"features": {"resume": True, "external_import": True, "fork": False,
|
|
81764
|
+
"native_import_api": True,
|
|
81765
|
+
"history_reconciliation": False,
|
|
81766
|
+
"approvals": True, "skills_picker": True, "custom_api": True,
|
|
81767
|
+
"attachments": True, "live_text": True, "token_usage": True,
|
|
81768
|
+
"scheduled_jobs": True, "interactive_shares": True,
|
|
81769
|
+
"native_goals": False, "native_side_questions": False,
|
|
81770
|
+
"managed_subagents": False, "native_compaction_control": False,
|
|
81771
|
+
"permission_mode_control": True},
|
|
81772
|
+
},
|
|
81054
81773
|
"opencode_backend": {
|
|
81055
81774
|
# ``available`` advertises the server contract. Runtime
|
|
81056
81775
|
# readiness is reported independently by
|
|
@@ -81206,6 +81925,7 @@ async def health() -> dict[str, Any]:
|
|
|
81206
81925
|
"max_active_alerts": EMERGENCY_ACTIVE_ALERT_LIMIT,
|
|
81207
81926
|
"stream_path": "/api/emergency-alerts/events",
|
|
81208
81927
|
"stream_protocol": EMERGENCY_WEBSOCKET_PROTOCOL,
|
|
81928
|
+
"acknowledge_through_alert_id": True,
|
|
81209
81929
|
},
|
|
81210
81930
|
"cross_chat_handoffs_v1": cross_chat_handoffs_capability(),
|
|
81211
81931
|
"codex_controls": {
|
|
@@ -81302,15 +82022,11 @@ def codex_goals_admin_status() -> dict[str, Any]:
|
|
|
81302
82022
|
enabled = bool(CODEX_GOALS_ENABLED)
|
|
81303
82023
|
return {
|
|
81304
82024
|
"enabled": enabled,
|
|
81305
|
-
"configurable":
|
|
82025
|
+
"configurable": False,
|
|
81306
82026
|
"default_enabled": bool(CODEX_GOALS_DEFAULT_ENABLED),
|
|
81307
82027
|
"scope": "server",
|
|
81308
82028
|
"reconfiguring": bool(CODEX_GOALS_RECONFIGURING),
|
|
81309
|
-
"message":
|
|
81310
|
-
"Persistent Codex goals are enabled for this server."
|
|
81311
|
-
if enabled
|
|
81312
|
-
else "Persistent Codex goals are disabled for this server."
|
|
81313
|
-
),
|
|
82029
|
+
"message": "Persistent Codex goals are always available. Manage each goal in its chat.",
|
|
81314
82030
|
}
|
|
81315
82031
|
|
|
81316
82032
|
|
|
@@ -82134,36 +82850,14 @@ async def get_codex_goals_admin() -> dict[str, Any]:
|
|
|
82134
82850
|
async def put_codex_goals_admin(
|
|
82135
82851
|
req: CodexGoalsAdminRequest,
|
|
82136
82852
|
) -> dict[str, Any]:
|
|
82137
|
-
|
|
82138
|
-
|
|
82139
|
-
|
|
82140
|
-
|
|
82141
|
-
|
|
82142
|
-
|
|
82143
|
-
|
|
82144
|
-
|
|
82145
|
-
"paused_goal_count": 0,
|
|
82146
|
-
"fenced_goal_count": 0,
|
|
82147
|
-
}
|
|
82148
|
-
try:
|
|
82149
|
-
if not requested:
|
|
82150
|
-
transition = await pause_idle_codex_goals_before_disable()
|
|
82151
|
-
# The feature flag is a process-level Codex setting. Replacing the
|
|
82152
|
-
# shared, idle manager is the only way to make it authoritative for
|
|
82153
|
-
# every loaded and future thread.
|
|
82154
|
-
await close_codex_app_server_manager()
|
|
82155
|
-
atomic_update_json(
|
|
82156
|
-
CODEX_SETTINGS_FILE,
|
|
82157
|
-
{
|
|
82158
|
-
**settings,
|
|
82159
|
-
"goals_enabled": requested,
|
|
82160
|
-
"updated_at": update_utc_now(),
|
|
82161
|
-
},
|
|
82162
|
-
)
|
|
82163
|
-
CODEX_GOALS_ENABLED = requested
|
|
82164
|
-
finally:
|
|
82165
|
-
await release_codex_goals_reconfiguration()
|
|
82166
|
-
return {**codex_goals_admin_status(), **transition}
|
|
82853
|
+
# Preserve the authenticated legacy route without allowing an older client
|
|
82854
|
+
# to disable goals for every chat or restart the shared provider manager.
|
|
82855
|
+
if not req.enabled:
|
|
82856
|
+
raise HTTPException(
|
|
82857
|
+
status_code=409,
|
|
82858
|
+
detail="Persistent Codex goals cannot be disabled server-wide. Pause individual goals in their chats.",
|
|
82859
|
+
)
|
|
82860
|
+
return codex_goals_admin_status()
|
|
82167
82861
|
|
|
82168
82862
|
|
|
82169
82863
|
def require_native_admin_control(request: Request) -> None:
|
|
@@ -82187,7 +82881,7 @@ def require_native_admin_control(request: Request) -> None:
|
|
|
82187
82881
|
async def codex_auth_operation(*, mutate: bool, existing_only: bool = False):
|
|
82188
82882
|
"""Fence native authentication changes with the existing Codex admission barrier."""
|
|
82189
82883
|
if not mutate:
|
|
82190
|
-
await refresh_codex_app_server_login()
|
|
82884
|
+
await refresh_codex_app_server_login(source="account_status")
|
|
82191
82885
|
async with CODEX_AUTH_LOCK:
|
|
82192
82886
|
reserved = False
|
|
82193
82887
|
try:
|
|
@@ -82224,10 +82918,20 @@ async def codex_auth_operation(*, mutate: bool, existing_only: bool = False):
|
|
|
82224
82918
|
await release_codex_goals_reconfiguration()
|
|
82225
82919
|
|
|
82226
82920
|
|
|
82921
|
+
async def reload_codex_login() -> bool:
|
|
82922
|
+
logger.info("Codex login reload requested source=manual_login_reload")
|
|
82923
|
+
if not await refresh_codex_app_server_login(request_handoff=True, source="manual_login_reload"):
|
|
82924
|
+
logger.info("Codex login reload deferred source=manual_login_reload reason=unavailable_or_busy")
|
|
82925
|
+
raise HTTPException(409, "Codex sign-in cannot be reloaded now. Its credential store is unsupported or maintenance is active.")
|
|
82926
|
+
return any(getattr(manager, "_agentsdock_login_superseded", False) is True
|
|
82927
|
+
for manager in codex_app_server_managers())
|
|
82928
|
+
|
|
82929
|
+
|
|
82227
82930
|
app.include_router(codex_auth.create_router(
|
|
82228
82931
|
authorize=require_native_admin_control,
|
|
82229
82932
|
operation=codex_auth_operation,
|
|
82230
82933
|
available=lambda: CODEX_TRANSPORT != CODEX_TRANSPORT_EXEC,
|
|
82934
|
+
reload_login=reload_codex_login,
|
|
82231
82935
|
))
|
|
82232
82936
|
|
|
82233
82937
|
|
|
@@ -82509,6 +83213,9 @@ app.include_router(provider_connections.create_router(
|
|
|
82509
83213
|
),
|
|
82510
83214
|
))
|
|
82511
83215
|
|
|
83216
|
+
from gemini_runner import router as gemini_router
|
|
83217
|
+
app.include_router(gemini_router(sys.modules[__name__]))
|
|
83218
|
+
|
|
82512
83219
|
app.include_router(codex_provider.create_router(
|
|
82513
83220
|
authorize=require_native_admin_control,
|
|
82514
83221
|
store=CODEX_PROVIDER_STORE,
|
|
@@ -82929,6 +83636,7 @@ async def submit_interactive_chat_prompt(
|
|
|
82929
83636
|
result = await post_turn(session_id, TurnRequest(
|
|
82930
83637
|
prompt=prompt, file_ids=file_ids, shared_chat_id=share_id,
|
|
82931
83638
|
shared_chat_request_id=request_id,
|
|
83639
|
+
client_capabilities=(["gemini_acp_v1"] if (STORE.sessions.get(session_id) or {}).get("backend") == "gemini" else []),
|
|
82932
83640
|
))
|
|
82933
83641
|
if not isinstance(result, dict) or type(result.get("queued")) is not bool:
|
|
82934
83642
|
raise HTTPException(503, "Message acceptance could not be confirmed")
|
|
@@ -83035,13 +83743,21 @@ async def interactive_chat_native_snapshot(session_id: str) -> dict[str, Any]:
|
|
|
83035
83743
|
projected[key] = strip_agentsdock_generated_user_text(projected[key], expected_session_id=session_id)
|
|
83036
83744
|
queue.append(projected)
|
|
83037
83745
|
model, effort = str(session.get("model") or ""), str(session.get("effort") or "")
|
|
83746
|
+
gemini_fields = {}
|
|
83747
|
+
if backend == "gemini":
|
|
83748
|
+
from gemini_runner import PERMISSIONS, owns
|
|
83749
|
+
gemini_fields["gemini_runtime"] = {"available": True, "pending_interactions": [
|
|
83750
|
+
shared_native_value(row["public"]) for row in PERMISSIONS.values()
|
|
83751
|
+
if row["public"]["session_id"] == session_id and owns(sys.modules[__name__], session_id, row["run_id"])]}
|
|
83038
83752
|
snapshot = shared_native_value({
|
|
83039
83753
|
"session": public, "events": page["events"], "queue": queue, "active": active,
|
|
83040
83754
|
"hasMoreEvents": page["has_more"], "nextTimelineBefore": page.get("next_before"),
|
|
83041
83755
|
"eventsTotal": page.get("semantic_total"), "goal": goal,
|
|
83042
83756
|
"jobs": (await list_session_jobs(session_id))["jobs"],
|
|
83043
83757
|
"codex_runtime": codex_runtime, "claude_runtime": claude_runtime,
|
|
83758
|
+
**gemini_fields,
|
|
83044
83759
|
"health": {"capabilities": {
|
|
83760
|
+
**({"gemini_backend": {"available": True, "version": 1}} if backend == "gemini" else {}),
|
|
83045
83761
|
"codex_controls": {"available": codex_available},
|
|
83046
83762
|
"claude_controls": {"available": claude_available,
|
|
83047
83763
|
"interactive_client_capability": CLAUDE_SDK_INTERACTIVE_CLIENT_CAPABILITY},
|
|
@@ -83072,6 +83788,7 @@ async def interactive_chat_native_snapshot(session_id: str) -> dict[str, Any]:
|
|
|
83072
83788
|
async def steer_interactive_chat_prompt(session_id: str, prompt: str, *, share_id: str, request_id: str) -> dict[str, Any]:
|
|
83073
83789
|
receipt = await post_turn(session_id, TurnRequest(
|
|
83074
83790
|
prompt=prompt, shared_chat_id=share_id, shared_chat_request_id=request_id,
|
|
83791
|
+
client_capabilities=(["gemini_acp_v1"] if (STORE.sessions.get(session_id) or {}).get("backend") == "gemini" else []),
|
|
83075
83792
|
))
|
|
83076
83793
|
if not isinstance(receipt, dict) or type(receipt.get("queued")) is not bool:
|
|
83077
83794
|
raise HTTPException(503, "Message acceptance could not be confirmed")
|
|
@@ -83168,6 +83885,9 @@ async def control_interactive_chat(session_id: str, action: str, payload: dict[s
|
|
|
83168
83885
|
INTERACTIVE_CHAT_CATALOG = (time.monotonic(), shared_native_value(await runtime_catalog()))
|
|
83169
83886
|
value = INTERACTIVE_CHAT_CATALOG[1]
|
|
83170
83887
|
return shared_native_value(value)
|
|
83888
|
+
async def shared_gemini_approval(sid, interaction_id, response):
|
|
83889
|
+
from gemini_runner import resolve_permission
|
|
83890
|
+
return await resolve_permission(sys.modules[__name__], sid, interaction_id, response)
|
|
83171
83891
|
callbacks = {
|
|
83172
83892
|
"turn.stop": stop_turn_endpoint, "turn.steer": steer_interactive_chat_prompt,
|
|
83173
83893
|
"queue.run_now": post_run_queued_turn_now, "queue.edit": patch_queued_turn,
|
|
@@ -83177,6 +83897,7 @@ async def control_interactive_chat(session_id: str, action: str, payload: dict[s
|
|
|
83177
83897
|
"job.create": create_session_job, "job.update": update_session_job,
|
|
83178
83898
|
"job.toggle": update_session_job, "job.delete": delete_session_job, "job.run": run_interactive_chat_job,
|
|
83179
83899
|
"approval.codex": post_codex_interaction_response, "approval.claude": post_claude_interaction_response,
|
|
83900
|
+
"approval.gemini": shared_gemini_approval,
|
|
83180
83901
|
}
|
|
83181
83902
|
models = {model.__name__: model for model in (
|
|
83182
83903
|
UpdateSessionRequest, CodexGoalRequest, CreateScopedJobRequest, UpdateJobRequest,
|
|
@@ -85787,7 +86508,7 @@ async def host_diagnostics(limit: int = 40) -> dict[str, Any]:
|
|
|
85787
86508
|
async def runtime_catalog(refresh: bool = False) -> dict[str, Any]:
|
|
85788
86509
|
if refresh:
|
|
85789
86510
|
await refresh_codex_app_server_binary(force=True)
|
|
85790
|
-
await refresh_codex_app_server_login()
|
|
86511
|
+
await refresh_codex_app_server_login(source="catalog_refresh")
|
|
85791
86512
|
return await asyncio.to_thread(discover_runtime_catalog, force_runtime_probe=refresh)
|
|
85792
86513
|
|
|
85793
86514
|
|
|
@@ -85908,12 +86629,13 @@ async def tail_session_process_log(session_id: str, path: str, lines: int = 200)
|
|
|
85908
86629
|
|
|
85909
86630
|
|
|
85910
86631
|
@app.get("/api/sessions")
|
|
85911
|
-
async def list_sessions(summary: bool = False) -> dict[str, Any]:
|
|
86632
|
+
async def list_sessions(summary: bool = False, placeholder_client_id: str = "") -> dict[str, Any]:
|
|
85912
86633
|
await STORE.ensure_sort_orders()
|
|
85913
86634
|
sessions = [
|
|
85914
86635
|
public_session(session, summary=summary)
|
|
85915
86636
|
for session in sorted_sessions(list(STORE.sessions.values()))
|
|
85916
86637
|
if not session.get("_fork_initializing")
|
|
86638
|
+
and not empty_chat_dismissed_for(session, placeholder_client_id)
|
|
85917
86639
|
]
|
|
85918
86640
|
return {"sessions": sessions}
|
|
85919
86641
|
|
|
@@ -85968,6 +86690,7 @@ async def create_session_with_history(req: CreateSessionRequest) -> dict[str, An
|
|
|
85968
86690
|
async def get_local_sessions(
|
|
85969
86691
|
limit: int = Query(default=200, ge=1, le=MAX_LOCAL_SESSION_LIST_ITEMS),
|
|
85970
86692
|
include_cursor: bool = False,
|
|
86693
|
+
include_gemini: bool = False,
|
|
85971
86694
|
) -> dict[str, Any]:
|
|
85972
86695
|
"""List main conversations unused by any installed same-user local instance."""
|
|
85973
86696
|
|
|
@@ -85978,6 +86701,8 @@ async def get_local_sessions(
|
|
|
85978
86701
|
for key in local_session_ownership.provider_session_keys(session, DEFAULT_BACKEND)
|
|
85979
86702
|
}
|
|
85980
86703
|
options = {"include_cursor": True} if include_cursor else {}
|
|
86704
|
+
if include_gemini:
|
|
86705
|
+
options["include_gemini"] = True
|
|
85981
86706
|
sessions = await asyncio.to_thread(local_session_candidates, limit, known_provider_keys, **options)
|
|
85982
86707
|
return {"sessions": sessions}
|
|
85983
86708
|
|
|
@@ -86051,6 +86776,7 @@ async def bulk_import_sessions_guarded(
|
|
|
86051
86776
|
MAX_LOCAL_SESSION_LIST_ITEMS,
|
|
86052
86777
|
set(),
|
|
86053
86778
|
**({"include_cursor": True} if any(item.backend == BACKEND_CURSOR for item in req.items) else {}),
|
|
86779
|
+
**({"include_gemini": True} if any(item.backend == BACKEND_GEMINI for item in req.items) else {}),
|
|
86054
86780
|
)
|
|
86055
86781
|
candidate_by_key = {
|
|
86056
86782
|
(str(candidate["backend"]), str(candidate["provider_session_id"])): candidate
|
|
@@ -86060,6 +86786,10 @@ async def bulk_import_sessions_guarded(
|
|
|
86060
86786
|
results: list[dict[str, Any]] = []
|
|
86061
86787
|
for item in req.items:
|
|
86062
86788
|
key = (item.backend, item.provider_session_id)
|
|
86789
|
+
if item.provider_connection and item.backend != BACKEND_GEMINI:
|
|
86790
|
+
results.append(bulk_import_result(item, ok=False, code="invalid_connection",
|
|
86791
|
+
error="API binding on import is only supported for Gemini."))
|
|
86792
|
+
continue
|
|
86063
86793
|
if key in requested_keys:
|
|
86064
86794
|
results.append(bulk_import_result(
|
|
86065
86795
|
item,
|
|
@@ -86100,7 +86830,11 @@ async def bulk_import_sessions_guarded(
|
|
|
86100
86830
|
|
|
86101
86831
|
staged_session: dict[str, Any] | None = None
|
|
86102
86832
|
try:
|
|
86103
|
-
if item.backend ==
|
|
86833
|
+
if item.backend == BACKEND_GEMINI:
|
|
86834
|
+
from gemini_import import initial_history
|
|
86835
|
+
source_path, history_items = await asyncio.to_thread(initial_history,
|
|
86836
|
+
sys.modules[__name__], runner_env(), item.provider_session_id, candidate_cwd or "")
|
|
86837
|
+
elif item.backend == BACKEND_CURSOR:
|
|
86104
86838
|
source_path, history_items = await asyncio.to_thread(
|
|
86105
86839
|
cursor_initial_history, item.provider_session_id, candidate_cwd or "",
|
|
86106
86840
|
)
|
|
@@ -86142,6 +86876,7 @@ async def bulk_import_sessions_guarded(
|
|
|
86142
86876
|
staged_session = await STORE.create(
|
|
86143
86877
|
CreateSessionRequest(
|
|
86144
86878
|
backend=item.backend,
|
|
86879
|
+
provider_connection=item.provider_connection,
|
|
86145
86880
|
provider_session_id=item.provider_session_id,
|
|
86146
86881
|
cwd=candidate_cwd or DEFAULT_CWD,
|
|
86147
86882
|
title=(item.title or str(candidate.get("label") or "")).strip()
|
|
@@ -86418,8 +87153,6 @@ async def get_session(
|
|
|
86418
87153
|
normalized_page_mode = str(page_mode or "").strip().lower()
|
|
86419
87154
|
if normalized_page_mode not in {"", "semantic"}:
|
|
86420
87155
|
raise HTTPException(status_code=400, detail="page_mode must be semantic")
|
|
86421
|
-
if normalized_page_mode != "semantic":
|
|
86422
|
-
await asyncio.to_thread(prepare_provider_history_metadata_repair, session_id)
|
|
86423
87156
|
page_tail = tail and after <= 0
|
|
86424
87157
|
semantic_page: dict[str, Any] | None = None
|
|
86425
87158
|
if normalized_page_mode == "semantic":
|
|
@@ -86485,6 +87218,23 @@ async def get_session(
|
|
|
86485
87218
|
return response
|
|
86486
87219
|
|
|
86487
87220
|
|
|
87221
|
+
@app.post("/api/sessions/{session_id}/history/maintenance", status_code=202)
|
|
87222
|
+
async def retry_history_maintenance(session_id: str) -> dict[str, Any]:
|
|
87223
|
+
"""Explicit retry clears backoff, never provenance or lifecycle guards."""
|
|
87224
|
+
ensure_session_not_initializing(session_id)
|
|
87225
|
+
ensure_session_not_deleting(session_id)
|
|
87226
|
+
sess = STORE.sessions.get(session_id)
|
|
87227
|
+
if not sess:
|
|
87228
|
+
raise HTTPException(status_code=404, detail="session not found")
|
|
87229
|
+
if session_id in BUSY_SESSIONS or ACTIVE.get(session_id) is not None:
|
|
87230
|
+
raise HTTPException(status_code=409, detail="history maintenance waits for this chat to become idle")
|
|
87231
|
+
if session_id not in HISTORY_SYNC_SCHEDULED:
|
|
87232
|
+
history_maintenance_state(session_id).clear()
|
|
87233
|
+
schedule_provider_history_sync(sess)
|
|
87234
|
+
return {"scheduled": session_id in HISTORY_SYNC_SCHEDULED,
|
|
87235
|
+
"history_maintenance": dict(history_maintenance_state(session_id))}
|
|
87236
|
+
|
|
87237
|
+
|
|
86488
87238
|
@app.post("/api/sessions/{session_id}/import-history")
|
|
86489
87239
|
async def import_history(session_id: str, req: ImportHistoryRequest) -> dict[str, Any]:
|
|
86490
87240
|
async with session_lifecycle_lock(session_id):
|
|
@@ -86780,6 +87530,68 @@ async def ensure_backend_update_allowed(
|
|
|
86780
87530
|
|
|
86781
87531
|
@app.patch("/api/sessions/{session_id}")
|
|
86782
87532
|
async def update_session(session_id: str, req: UpdateSessionRequest) -> dict[str, Any]:
|
|
87533
|
+
if req.gemini_connect_api:
|
|
87534
|
+
if set(req.model_dump(exclude_unset=True)) != {"gemini_connect_api"}:
|
|
87535
|
+
raise HTTPException(400, "Connect the Gemini API separately from other chat edits.")
|
|
87536
|
+
return await connect_gemini_import_api(session_id)
|
|
87537
|
+
return await _update_session(session_id, req)
|
|
87538
|
+
|
|
87539
|
+
|
|
87540
|
+
async def connect_gemini_import_api(session_id: str) -> dict[str, Any]:
|
|
87541
|
+
"""Explicit one-way binding of a native import, not general account switching."""
|
|
87542
|
+
from gemini_import import initial_history
|
|
87543
|
+
from gemini_history import native_snapshot, snapshot_replay
|
|
87544
|
+
from gemini_acp_client import GeminiProtocolError
|
|
87545
|
+
ensure_session_not_initializing(session_id)
|
|
87546
|
+
async with session_lifecycle_lock(session_id):
|
|
87547
|
+
current = STORE.sessions.get(session_id)
|
|
87548
|
+
if not current:
|
|
87549
|
+
raise HTTPException(404, "session not found")
|
|
87550
|
+
if current.get("backend") != BACKEND_GEMINI or not session_provider_id(current):
|
|
87551
|
+
raise HTTPException(400, "Only an imported Gemini conversation can use this action.")
|
|
87552
|
+
if current.get("archived") or session_id in DELETING_SESSIONS:
|
|
87553
|
+
raise HTTPException(409, "Unarchive this chat before connecting its API.")
|
|
87554
|
+
if (session_id in BUSY_SESSIONS or session_id in ACTIVE or QUEUED_TURNS.get(session_id)
|
|
87555
|
+
or current.get("gemini_cleanup_pending_run_id")
|
|
87556
|
+
or any(not task.done() for task in SESSION_TURN_TASKS.get(session_id, ()))):
|
|
87557
|
+
raise HTTPException(409, "Stop active work and clear queued messages before connecting this API.")
|
|
87558
|
+
if current.get("provider_connection") == "custom":
|
|
87559
|
+
PROVIDER_CONNECTION_STORE.for_session(current) # No silent rebind on Forget.
|
|
87560
|
+
return {"session": public_session(current)}
|
|
87561
|
+
provider_id, cwd = session_provider_id(current), str(current.get("cwd") or "")
|
|
87562
|
+
def validate():
|
|
87563
|
+
initial_history(sys.modules[__name__], runner_env(), provider_id, cwd)
|
|
87564
|
+
snapshot_replay(native_snapshot(runner_env(), cwd, provider_id))
|
|
87565
|
+
try:
|
|
87566
|
+
await asyncio.to_thread(validate)
|
|
87567
|
+
except (GeminiProtocolError, OSError, ValueError):
|
|
87568
|
+
raise HTTPException(409, "The original Gemini history is unavailable or changed; refresh Import Chat.") from None
|
|
87569
|
+
async with STORE._lock:
|
|
87570
|
+
if (STORE.sessions.get(session_id) is not current or session_id in DELETING_SESSIONS
|
|
87571
|
+
or current.get("archived") or session_id in BUSY_SESSIONS or session_id in ACTIVE
|
|
87572
|
+
or QUEUED_TURNS.get(session_id) or current.get("gemini_cleanup_pending_run_id")
|
|
87573
|
+
or any(not task.done() for task in SESSION_TURN_TASKS.get(session_id, ()))):
|
|
87574
|
+
raise HTTPException(409, "This chat changed while connecting; wait for active work to finish and retry.")
|
|
87575
|
+
previous = dict(current)
|
|
87576
|
+
selected = PROVIDER_CONNECTION_STORE.bind({"backend": BACKEND_GEMINI, "provider_connection": "custom"})
|
|
87577
|
+
current.update(provider_connection="custom", provider_connection_revision=selected["credential_id"],
|
|
87578
|
+
gemini_native_history=True, gemini_session_cwd=cwd, model=selected.get("model"), updated_at=now_iso())
|
|
87579
|
+
try:
|
|
87580
|
+
await STORE.save(durable=True)
|
|
87581
|
+
except BaseException:
|
|
87582
|
+
current.clear(); current.update(previous)
|
|
87583
|
+
raise
|
|
87584
|
+
return {"session": public_session(current)}
|
|
87585
|
+
|
|
87586
|
+
|
|
87587
|
+
async def _update_session(
|
|
87588
|
+
session_id: str, req: UpdateSessionRequest, *,
|
|
87589
|
+
native_archive_owner: tuple[str, str] | None = None,
|
|
87590
|
+
) -> dict[str, Any]:
|
|
87591
|
+
# Internal provenance is not an HTTP parameter. Recheck after acquiring
|
|
87592
|
+
# the same lock as Stop, replacement and new-turn admission.
|
|
87593
|
+
if native_archive_owner is not None and req.archived is not True:
|
|
87594
|
+
raise ValueError("native archive ownership only applies to archive")
|
|
86783
87595
|
ensure_session_not_initializing(session_id)
|
|
86784
87596
|
patch = req.model_dump(exclude_unset=True)
|
|
86785
87597
|
if SESSION_LIFECYCLE_UPDATE_FIELDS.intersection(patch):
|
|
@@ -86787,6 +87599,12 @@ async def update_session(session_id: str, req: UpdateSessionRequest) -> dict[str
|
|
|
86787
87599
|
current = STORE.sessions.get(session_id)
|
|
86788
87600
|
if not current:
|
|
86789
87601
|
raise HTTPException(status_code=404, detail="session not found")
|
|
87602
|
+
if native_archive_owner is not None:
|
|
87603
|
+
thread_id, run_id = native_archive_owner
|
|
87604
|
+
if (session_id in DELETING_SESSIONS or current.get("backend") != BACKEND_CODEX
|
|
87605
|
+
or str(session_provider_id(current) or "") != thread_id
|
|
87606
|
+
or (ACTIVE.get(session_id) or {}).get("run_id") != run_id):
|
|
87607
|
+
return {"session": public_session(current)}
|
|
86790
87608
|
if req.archived is False and current.get("archived"):
|
|
86791
87609
|
try:
|
|
86792
87610
|
# An earlier archive may have failed to save jobs.json
|
|
@@ -88648,7 +89466,7 @@ async def _put_codex_goal_locked(
|
|
|
88648
89466
|
if req.status == "active":
|
|
88649
89467
|
if (STORE.sessions.get(session_id) or {}).get("archived"):
|
|
88650
89468
|
raise HTTPException(status_code=409, detail="archived chats cannot resume goals")
|
|
88651
|
-
await wait_for_queue_recovery_admission()
|
|
89469
|
+
await wait_for_queue_recovery_admission(session_id)
|
|
88652
89470
|
manager, thread_id, control_session = await acquire_codex_control_thread(
|
|
88653
89471
|
session_id,
|
|
88654
89472
|
reserve_session=req.status == "active",
|
|
@@ -89435,6 +90253,26 @@ async def acknowledge_session_emergency(
|
|
|
89435
90253
|
sess = STORE.sessions.get(session_id)
|
|
89436
90254
|
if not sess:
|
|
89437
90255
|
raise HTTPException(status_code=404, detail="session not found")
|
|
90256
|
+
if req.through_alert_id is not None:
|
|
90257
|
+
boundary, alert_ids = await asyncio.to_thread(
|
|
90258
|
+
emergency_alert_batch_through, session_id, req.through_alert_id,
|
|
90259
|
+
)
|
|
90260
|
+
if boundary is None:
|
|
90261
|
+
raise HTTPException(status_code=409, detail="that emergency alert boundary was not found")
|
|
90262
|
+
if alert_ids:
|
|
90263
|
+
events = await append_durable_event_batch(session_id, [
|
|
90264
|
+
("emergency_alert_acknowledged", {
|
|
90265
|
+
"emergency_alert_id": alert_id,
|
|
90266
|
+
"message": "Emergency acknowledged by the user.",
|
|
90267
|
+
})
|
|
90268
|
+
for alert_id in alert_ids
|
|
90269
|
+
])
|
|
90270
|
+
committed = events[-1]
|
|
90271
|
+
else:
|
|
90272
|
+
committed = boundary
|
|
90273
|
+
sess = await project_committed_emergency_event(session_id, committed)
|
|
90274
|
+
return {"session": public_session(sess), "acknowledged": bool(alert_ids),
|
|
90275
|
+
"acknowledged_count": len(alert_ids)}
|
|
89438
90276
|
alert_id = req.expected_alert_id
|
|
89439
90277
|
acknowledged_event = await asyncio.to_thread(
|
|
89440
90278
|
find_emergency_acknowledgement_event,
|
|
@@ -89520,6 +90358,74 @@ async def ensure_discardable_empty_session(session_id: str, expected_updated_at:
|
|
|
89520
90358
|
raise HTTPException(status_code=409, detail="Chat has content or resources; it was preserved.")
|
|
89521
90359
|
|
|
89522
90360
|
|
|
90361
|
+
def empty_chat_revision(session: dict[str, Any]) -> str:
|
|
90362
|
+
# Use durable content/settings, not public runtime projections, read cursors
|
|
90363
|
+
# or sort order. Reconnecting, reading or normalizing runtime metadata on
|
|
90364
|
+
# startup must not resurrect an unchanged blank row.
|
|
90365
|
+
revision = {key: session.get(key) for key in (
|
|
90366
|
+
"created_at", "updated_at", "title", "folder", "cwd", "backend",
|
|
90367
|
+
"model", "effort", "system_prompt", "_title_source",
|
|
90368
|
+
"codex_provider", "provider_connection", "parent_id",
|
|
90369
|
+
"codex_goal", "claude_goal",
|
|
90370
|
+
)}
|
|
90371
|
+
revision["latest_event_seq"] = int(session.get("latest_event_seq") or 0)
|
|
90372
|
+
for key in ("backend_locked", "pinned", "archived", "manual_unread"):
|
|
90373
|
+
revision[key] = bool(session.get(key))
|
|
90374
|
+
return hashlib.sha256(json.dumps(revision, sort_keys=True,
|
|
90375
|
+
separators=(",", ":")).encode()).hexdigest()
|
|
90376
|
+
|
|
90377
|
+
|
|
90378
|
+
def empty_chat_dismissed_for(session: dict[str, Any], client_id: str) -> bool:
|
|
90379
|
+
if not client_id or session_backend_locked(session):
|
|
90380
|
+
return False
|
|
90381
|
+
state = session.get("_empty_chat_cleanup") or {}
|
|
90382
|
+
return (isinstance(state, dict) and state.get("version") == 1
|
|
90383
|
+
and isinstance(state.get("dismissed_by"), dict)
|
|
90384
|
+
and state["dismissed_by"].get(client_id) == empty_chat_revision(session))
|
|
90385
|
+
|
|
90386
|
+
|
|
90387
|
+
@app.post("/api/sessions/{session_id}/dismiss-empty")
|
|
90388
|
+
async def dismiss_empty_session(session_id: str, expected_updated_at: str,
|
|
90389
|
+
placeholder_client_id: str) -> dict[str, Any]:
|
|
90390
|
+
"""Remove an unused row from one desktop's list without deleting its identity.
|
|
90391
|
+
|
|
90392
|
+
Another/offline desktop may own an unsent draft. Its list is unaffected;
|
|
90393
|
+
subsequent real changes also invalidate this desktop's dismissal. No draft
|
|
90394
|
+
content leaves a desktop, and no native history/process is touched.
|
|
90395
|
+
"""
|
|
90396
|
+
if not re.fullmatch(r"[a-zA-Z0-9_-]{16,80}", placeholder_client_id):
|
|
90397
|
+
raise HTTPException(400, "Invalid placeholder client identity")
|
|
90398
|
+
await wait_for_queue_recovery_admission()
|
|
90399
|
+
async with session_lifecycle_lock(session_id):
|
|
90400
|
+
ensure_session_not_initializing(session_id)
|
|
90401
|
+
if session_id in DELETING_SESSIONS or session_id in DELETED_SESSION_TOMBSTONES:
|
|
90402
|
+
raise HTTPException(409, "Chat deletion is already in progress")
|
|
90403
|
+
# Existing admission, uploads, jobs and terminal routes use this fence.
|
|
90404
|
+
DELETING_SESSIONS.add(session_id)
|
|
90405
|
+
try:
|
|
90406
|
+
await ensure_discardable_empty_session(session_id, expected_updated_at)
|
|
90407
|
+
async with STORE._lock:
|
|
90408
|
+
session = STORE.sessions[session_id]
|
|
90409
|
+
previous = session.get("_empty_chat_cleanup")
|
|
90410
|
+
state = previous if isinstance(previous, dict) else {}
|
|
90411
|
+
dismissed = dict(state.get("dismissed_by") or {})
|
|
90412
|
+
if placeholder_client_id not in dismissed and len(dismissed) >= 256:
|
|
90413
|
+
raise HTTPException(409, "Too many desktop cleanup identities; chat preserved")
|
|
90414
|
+
dismissed[placeholder_client_id] = empty_chat_revision(session)
|
|
90415
|
+
session["_empty_chat_cleanup"] = {"version": 1, "dismissed_by": dismissed}
|
|
90416
|
+
try:
|
|
90417
|
+
await STORE.save()
|
|
90418
|
+
except BaseException:
|
|
90419
|
+
if previous is None:
|
|
90420
|
+
session.pop("_empty_chat_cleanup", None)
|
|
90421
|
+
else:
|
|
90422
|
+
session["_empty_chat_cleanup"] = previous
|
|
90423
|
+
raise
|
|
90424
|
+
return {"ok": True, "dismissed": True}
|
|
90425
|
+
finally:
|
|
90426
|
+
DELETING_SESSIONS.discard(session_id)
|
|
90427
|
+
|
|
90428
|
+
|
|
89523
90429
|
@app.post("/api/sessions/{session_id}/discard-empty")
|
|
89524
90430
|
async def discard_empty_session(session_id: str, expected_updated_at: str) -> dict[str, Any]:
|
|
89525
90431
|
# A separate route makes old servers fail safely with 404, rather than
|
|
@@ -89532,6 +90438,13 @@ async def discard_empty_session(session_id: str, expected_updated_at: str) -> di
|
|
|
89532
90438
|
async def delete_session(session_id: str, discard_updated_at: str | None = None) -> dict[str, Any]:
|
|
89533
90439
|
async with session_lifecycle_lock(session_id):
|
|
89534
90440
|
ensure_session_not_initializing(session_id)
|
|
90441
|
+
pending_gemini_cleanup = (STORE.sessions.get(session_id) or {}).get("gemini_cleanup_pending_run_id")
|
|
90442
|
+
live_gemini_owner = ACTIVE.get(session_id) or {}
|
|
90443
|
+
if pending_gemini_cleanup and (
|
|
90444
|
+
live_gemini_owner.get("run_id") != pending_gemini_cleanup
|
|
90445
|
+
or not callable(live_gemini_owner.get("gemini_cleanup"))
|
|
90446
|
+
):
|
|
90447
|
+
raise HTTPException(409, "Gemini runtime cleanup requires server-side verification after restart; the chat and native history were not deleted.")
|
|
89535
90448
|
if session_id in DELETED_SESSION_TOMBSTONES:
|
|
89536
90449
|
# A previous delete can have committed before its request was
|
|
89537
90450
|
# canceled. Retry any tracked bridge retirement on idempotent
|
|
@@ -89699,6 +90612,16 @@ async def delete_session(session_id: str, discard_updated_at: str | None = None)
|
|
|
89699
90612
|
"shortly."
|
|
89700
90613
|
),
|
|
89701
90614
|
)
|
|
90615
|
+
elif active.get("backend") == BACKEND_GEMINI:
|
|
90616
|
+
cleanup = active.get("gemini_cleanup")
|
|
90617
|
+
try:
|
|
90618
|
+
if not callable(cleanup):
|
|
90619
|
+
raise RuntimeError("Missing Gemini cleanup owner")
|
|
90620
|
+
await cleanup()
|
|
90621
|
+
except Exception:
|
|
90622
|
+
raise HTTPException(status_code=409, detail=(
|
|
90623
|
+
"Gemini runtime cleanup could not be verified; the chat was not deleted. Retry Stop."
|
|
90624
|
+
)) from None
|
|
89702
90625
|
elif proc:
|
|
89703
90626
|
await terminate_process_tree(proc)
|
|
89704
90627
|
|
|
@@ -89797,6 +90720,17 @@ async def delete_session(session_id: str, discard_updated_at: str | None = None)
|
|
|
89797
90720
|
# initial session record but before its task acknowledges stop.
|
|
89798
90721
|
# The task registry is now drained, so this reread is stable.
|
|
89799
90722
|
late_session = STORE.sessions.get(session_id) or {}
|
|
90723
|
+
gemini_active = ACTIVE.get(session_id) or {}
|
|
90724
|
+
if gemini_active.get("backend") == BACKEND_GEMINI and gemini_active.get("gemini_cleanup_failed"):
|
|
90725
|
+
# A completed consumer task does not prove its owned runtime
|
|
90726
|
+
# was reaped. Retry only this chat's cleanup, before deletion.
|
|
90727
|
+
try:
|
|
90728
|
+
await gemini_active["gemini_cleanup"]()
|
|
90729
|
+
from gemini_runner import clear_cleanup_fence
|
|
90730
|
+
await clear_cleanup_fence(sys.modules[__name__], session_id, str(gemini_active.get("run_id") or ""))
|
|
90731
|
+
except Exception:
|
|
90732
|
+
raise HTTPException(409, "Gemini runtime cleanup is incomplete; the chat was not deleted. Retry after stopping its runtime.") from None
|
|
90733
|
+
gemini_active["gemini_cleanup_failed"] = False
|
|
89800
90734
|
late_provider_thread_id = str(
|
|
89801
90735
|
session_provider_id(late_session) or ""
|
|
89802
90736
|
)
|
|
@@ -90024,19 +90958,20 @@ def claude_imported_fork_boundary(
|
|
|
90024
90958
|
digest = hashlib.sha256()
|
|
90025
90959
|
last_message: dict[str, Any] = {}
|
|
90026
90960
|
with path.open("rb") as stream:
|
|
90027
|
-
|
|
90028
|
-
|
|
90029
|
-
|
|
90030
|
-
raw
|
|
90031
|
-
|
|
90032
|
-
|
|
90033
|
-
remaining -= len(raw)
|
|
90034
|
-
digest.update(raw)
|
|
90035
|
-
if not raw.strip():
|
|
90961
|
+
original = os.fstat(stream.fileno())
|
|
90962
|
+
for record, offset, raw in jsonl_records(stream, end=remaining,
|
|
90963
|
+
max_records=MAX_LOCAL_TRANSCRIPT_SCAN_LINES):
|
|
90964
|
+
raw.update_hash(digest)
|
|
90965
|
+
remaining = int(cursor["source_offset"]) - offset
|
|
90966
|
+
if raw.blank:
|
|
90036
90967
|
continue
|
|
90037
|
-
record
|
|
90038
|
-
|
|
90968
|
+
if record is None:
|
|
90969
|
+
raise ValueError("imported history checkpoint has a malformed transcript record")
|
|
90970
|
+
if record.get("type") in {"user", "assistant"} and not record.get("isSidechain"):
|
|
90039
90971
|
last_message = record
|
|
90972
|
+
current = os.fstat(stream.fileno())
|
|
90973
|
+
if (original.st_dev, original.st_ino, original.st_size, original.st_mtime_ns) != (current.st_dev, current.st_ino, current.st_size, current.st_mtime_ns):
|
|
90974
|
+
raise ValueError("imported history checkpoint changed during reading")
|
|
90040
90975
|
if remaining or not hmac.compare_digest(digest.hexdigest(), cursor["source_digest"]):
|
|
90041
90976
|
raise ValueError("imported history checkpoint no longer matches its native prefix")
|
|
90042
90977
|
message = last_message.get("message")
|
|
@@ -90145,6 +91080,8 @@ async def fork_session(session_id: str, req: ForkSessionRequest) -> dict[str, An
|
|
|
90145
91080
|
async with session_lifecycle_lock(session_id):
|
|
90146
91081
|
try:
|
|
90147
91082
|
ensure_session_not_deleting(session_id)
|
|
91083
|
+
if (STORE.sessions.get(session_id) or {}).get("backend") == BACKEND_GEMINI:
|
|
91084
|
+
raise HTTPException(409, "Gemini native fork is not available; create a new chat instead.")
|
|
90148
91085
|
if (STORE.sessions.get(session_id) or {}).get("backend") == BACKEND_OPENCODE:
|
|
90149
91086
|
raise HTTPException(status_code=409, detail="OpenCode native chat fork is unavailable; no transcript-copy fallback is enabled.")
|
|
90150
91087
|
async with ACTIVE_LOCK:
|
|
@@ -90557,8 +91494,123 @@ async def _fork_session_locked(
|
|
|
90557
91494
|
return {"session": public_session(child), "sessions": [public_session(sess) for sess in ordered_sessions]}
|
|
90558
91495
|
|
|
90559
91496
|
|
|
91497
|
+
TURN_RECEIPT_TASKS: dict[tuple[str, str], tuple[str, asyncio.Task]] = {}
|
|
91498
|
+
|
|
91499
|
+
|
|
91500
|
+
def turn_receipt_path(session_id: str, request_id: str) -> Path:
|
|
91501
|
+
return events_path(session_id).parent / "turn-receipts" / f"{request_id}.json"
|
|
91502
|
+
|
|
91503
|
+
|
|
91504
|
+
def write_turn_receipt(path: Path, value: dict) -> None:
|
|
91505
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
91506
|
+
temporary = path.with_suffix(".tmp")
|
|
91507
|
+
with temporary.open("w") as stream:
|
|
91508
|
+
json.dump(value, stream, separators=(",", ":"))
|
|
91509
|
+
stream.flush()
|
|
91510
|
+
os.fsync(stream.fileno())
|
|
91511
|
+
temporary.replace(path)
|
|
91512
|
+
descriptor = os.open(path.parent, os.O_RDONLY)
|
|
91513
|
+
try:
|
|
91514
|
+
os.fsync(descriptor)
|
|
91515
|
+
finally:
|
|
91516
|
+
os.close(descriptor)
|
|
91517
|
+
|
|
91518
|
+
|
|
91519
|
+
def recover_turn_receipt(session_id: str, value: dict) -> dict | None:
|
|
91520
|
+
path = events_path(session_id)
|
|
91521
|
+
stamp = history_file_stamp(path)
|
|
91522
|
+
before = value.get("journal")
|
|
91523
|
+
offset = before[2] if before else 0
|
|
91524
|
+
if stamp is None:
|
|
91525
|
+
if before is not None:
|
|
91526
|
+
raise ValueError("send receipt journal disappeared")
|
|
91527
|
+
return None
|
|
91528
|
+
if before and (stamp[:2] != tuple(before[:2]) or stamp[2] < offset):
|
|
91529
|
+
raise ValueError("send receipt journal was replaced")
|
|
91530
|
+
# An ambiguous old receipt must not cause an unbounded scan or a resend.
|
|
91531
|
+
if stamp[2] - offset > 32 * 1024 * 1024:
|
|
91532
|
+
raise ValueError("send receipt recovery requires a larger journal scan")
|
|
91533
|
+
with path.open("rb") as stream:
|
|
91534
|
+
stream.seek(offset)
|
|
91535
|
+
while stream.tell() < stamp[2]:
|
|
91536
|
+
line = stream.readline(stamp[2] - stream.tell())
|
|
91537
|
+
if not line.endswith(b"\n"):
|
|
91538
|
+
raise ValueError("send receipt journal has an incomplete record")
|
|
91539
|
+
if b'"client_request_id"' not in line:
|
|
91540
|
+
continue
|
|
91541
|
+
event = json.loads(line)
|
|
91542
|
+
if event.get("client_request_id") != value["request_id"]:
|
|
91543
|
+
continue
|
|
91544
|
+
if event.get("type") == "turn_started":
|
|
91545
|
+
return {"run_id": event["run_id"], "queued": False}
|
|
91546
|
+
if event.get("type") == "turn_queued":
|
|
91547
|
+
return {"queued_id": event["queued_id"], "queued": True,
|
|
91548
|
+
"position": event.get("position", 1)}
|
|
91549
|
+
return None
|
|
91550
|
+
|
|
91551
|
+
|
|
91552
|
+
async def execute_receipted_turn(session_id: str, req: TurnRequest, fingerprint: str) -> dict:
|
|
91553
|
+
path = turn_receipt_path(session_id, req.client_request_id)
|
|
91554
|
+
try:
|
|
91555
|
+
value = await asyncio.to_thread(lambda: json.loads(path.read_text()))
|
|
91556
|
+
except FileNotFoundError:
|
|
91557
|
+
value = {"request_id": req.client_request_id, "fingerprint": fingerprint,
|
|
91558
|
+
"journal": history_file_stamp(events_path(session_id)), "status": "pending"}
|
|
91559
|
+
await asyncio.to_thread(write_turn_receipt, path, value)
|
|
91560
|
+
else:
|
|
91561
|
+
if value.get("fingerprint") != fingerprint:
|
|
91562
|
+
raise HTTPException(status_code=409, detail="request ID was already used for a different message")
|
|
91563
|
+
if value.get("status") == "accepted":
|
|
91564
|
+
return {**value["response"], "session": public_session(STORE.sessions[session_id])}
|
|
91565
|
+
try:
|
|
91566
|
+
recovered = await asyncio.to_thread(recover_turn_receipt, session_id, value)
|
|
91567
|
+
except (ValueError, OSError) as exc:
|
|
91568
|
+
raise HTTPException(status_code=409, detail="message acceptance is uncertain; retain this request ID") from exc
|
|
91569
|
+
if recovered is not None:
|
|
91570
|
+
recovered["session"] = public_session(STORE.sessions[session_id])
|
|
91571
|
+
value.update(status="accepted", response=recovered)
|
|
91572
|
+
await asyncio.to_thread(write_turn_receipt, path, value)
|
|
91573
|
+
return recovered
|
|
91574
|
+
response = await post_turn_without_receipt(session_id, req)
|
|
91575
|
+
value.update(status="accepted", response=response)
|
|
91576
|
+
await asyncio.to_thread(write_turn_receipt, path, value)
|
|
91577
|
+
return response
|
|
91578
|
+
|
|
91579
|
+
|
|
91580
|
+
async def post_turn_with_receipt(session_id: str, req: TurnRequest) -> Any:
|
|
91581
|
+
if session_id not in STORE.sessions or session_id in DELETED_SESSION_TOMBSTONES:
|
|
91582
|
+
raise HTTPException(status_code=404, detail="session not found")
|
|
91583
|
+
fingerprint = hashlib.sha256(json.dumps(req.model_dump(mode="json"), sort_keys=True,
|
|
91584
|
+
separators=(",", ":")).encode()).hexdigest()
|
|
91585
|
+
key = (session_id, req.client_request_id)
|
|
91586
|
+
existing = TURN_RECEIPT_TASKS.get(key)
|
|
91587
|
+
if existing is not None and existing[0] != fingerprint:
|
|
91588
|
+
raise HTTPException(status_code=409, detail="request ID was already used for a different message")
|
|
91589
|
+
if existing is None:
|
|
91590
|
+
task = asyncio.create_task(execute_receipted_turn(session_id, req, fingerprint))
|
|
91591
|
+
TURN_RECEIPT_TASKS[key] = (fingerprint, task)
|
|
91592
|
+
def completed(done):
|
|
91593
|
+
TURN_RECEIPT_TASKS.pop(key, None)
|
|
91594
|
+
if not done.cancelled():
|
|
91595
|
+
done.exception()
|
|
91596
|
+
task.add_done_callback(completed)
|
|
91597
|
+
else:
|
|
91598
|
+
task = existing[1]
|
|
91599
|
+
try:
|
|
91600
|
+
return await asyncio.wait_for(asyncio.shield(task), timeout=10)
|
|
91601
|
+
except asyncio.TimeoutError:
|
|
91602
|
+
return JSONResponse(status_code=202, content={"request_id": req.client_request_id,
|
|
91603
|
+
"acceptance": "pending", "retry_same_request": True})
|
|
91604
|
+
|
|
91605
|
+
|
|
90560
91606
|
@app.post("/api/sessions/{session_id}/turns")
|
|
90561
|
-
async def post_turn(session_id: str, req: TurnRequest) ->
|
|
91607
|
+
async def post_turn(session_id: str, req: TurnRequest) -> Any:
|
|
91608
|
+
if req.client_request_id is not None:
|
|
91609
|
+
return await post_turn_with_receipt(session_id, req)
|
|
91610
|
+
return await post_turn_without_receipt(session_id, req)
|
|
91611
|
+
|
|
91612
|
+
|
|
91613
|
+
async def post_turn_without_receipt(session_id: str, req: TurnRequest) -> dict[str, Any]:
|
|
90562
91614
|
while True:
|
|
90563
91615
|
try:
|
|
90564
91616
|
return await start_turn(session_id, req)
|
|
@@ -94028,6 +95080,12 @@ async def stop_turn(
|
|
|
94028
95080
|
"superseded": True,
|
|
94029
95081
|
"message": "The requested run no longer owns this chat.",
|
|
94030
95082
|
}
|
|
95083
|
+
if ((STORE.sessions.get(session_id) or {}).get("gemini_cleanup_pending_run_id")
|
|
95084
|
+
and (not active or not callable(active.get("gemini_cleanup")))):
|
|
95085
|
+
if _admission_ready is not None:
|
|
95086
|
+
_admission_ready.set()
|
|
95087
|
+
return {"ok": False, "stopped": False, "pending": True,
|
|
95088
|
+
"message": "Gemini runtime cleanup requires server-side verification after restart; this chat remains fenced."}
|
|
94031
95089
|
if preserve_active_goal and codex_goal_followup_requires_native(
|
|
94032
95090
|
STORE.sessions.get(session_id) or {}, active or {}, current_turn,
|
|
94033
95091
|
):
|
|
@@ -94750,7 +95808,20 @@ async def stop_turn(
|
|
|
94750
95808
|
# The app-server process may serve multiple Codex chats and must never be
|
|
94751
95809
|
# terminated as a per-chat Stop action.
|
|
94752
95810
|
if proc and active.get("transport") != CODEX_TRANSPORT_APP_SERVER:
|
|
94753
|
-
|
|
95811
|
+
if active.get("backend") == BACKEND_GEMINI:
|
|
95812
|
+
# Gemini owns a verified-cleanup guard. Generic tree termination
|
|
95813
|
+
# can kill it before startup or during verification, destroying the
|
|
95814
|
+
# cleanup evidence and leaving the chat permanently fenced.
|
|
95815
|
+
cleanup = active.get("gemini_cleanup")
|
|
95816
|
+
try:
|
|
95817
|
+
if not callable(cleanup):
|
|
95818
|
+
raise RuntimeError("Missing Gemini cleanup owner")
|
|
95819
|
+
await cleanup()
|
|
95820
|
+
except Exception:
|
|
95821
|
+
return {"ok": False, "stopped": False, "pending": True,
|
|
95822
|
+
"message": "Gemini runtime cleanup is incomplete. Retry Stop; this chat remains fenced."}
|
|
95823
|
+
else:
|
|
95824
|
+
await terminate_process_tree(proc)
|
|
94754
95825
|
if transition_ready is not None:
|
|
94755
95826
|
try:
|
|
94756
95827
|
await asyncio.wait_for(
|
|
@@ -94818,6 +95889,18 @@ async def stop_turn(
|
|
|
94818
95889
|
and not owner_task.done()
|
|
94819
95890
|
):
|
|
94820
95891
|
dynamic_tasks.add(owner_task)
|
|
95892
|
+
if (current is active and active.get("backend") == BACKEND_GEMINI
|
|
95893
|
+
and active.get("gemini_cleanup_failed") and not dynamic_tasks):
|
|
95894
|
+
try:
|
|
95895
|
+
await active["gemini_cleanup"]()
|
|
95896
|
+
from gemini_runner import clear_cleanup_fence
|
|
95897
|
+
await clear_cleanup_fence(sys.modules[__name__], session_id, str(active.get("run_id") or ""))
|
|
95898
|
+
except Exception:
|
|
95899
|
+
return {"ok": False, "stopped": False, "pending": True,
|
|
95900
|
+
"message": "Gemini runtime cleanup is incomplete. Retry Stop; this chat remains fenced."}
|
|
95901
|
+
active["gemini_cleanup_failed"] = False
|
|
95902
|
+
await reconcile_provider_task_exit(session_id, str(active.get("run_id") or ""), BACKEND_GEMINI)
|
|
95903
|
+
return {"ok": True, "stopped": True, "pending": False}
|
|
94821
95904
|
if current is not active:
|
|
94822
95905
|
if not goal_pause_ok:
|
|
94823
95906
|
return await terminal_goal_pause_failure_result(native_interrupt)
|
|
@@ -95663,6 +96746,17 @@ async def session_terminal(
|
|
|
95663
96746
|
await ws.close()
|
|
95664
96747
|
|
|
95665
96748
|
|
|
96749
|
+
EVENT_STREAM_HEARTBEAT_SECONDS = 15.0
|
|
96750
|
+
|
|
96751
|
+
|
|
96752
|
+
async def send_event_stream_status(ws: WebSocket, session_id: str, phase: str) -> None:
|
|
96753
|
+
# Ephemeral, opt-in control frames: never persisted or assigned a seq.
|
|
96754
|
+
await asyncio.wait_for(ws.send_json({
|
|
96755
|
+
"type": "event_stream_status", "version": 1,
|
|
96756
|
+
"session_id": session_id, "phase": phase,
|
|
96757
|
+
}), timeout=WEBSOCKET_SEND_TIMEOUT_SECONDS)
|
|
96758
|
+
|
|
96759
|
+
|
|
95666
96760
|
@app.websocket("/api/sessions/{session_id}/events")
|
|
95667
96761
|
async def session_events(
|
|
95668
96762
|
session_id: str,
|
|
@@ -95671,6 +96765,8 @@ async def session_events(
|
|
|
95671
96765
|
visible: bool | None = None,
|
|
95672
96766
|
reasoning_stream: bool = False,
|
|
95673
96767
|
reasoning_text: bool = False,
|
|
96768
|
+
liveness: bool = False,
|
|
96769
|
+
history_projection: bool = False,
|
|
95674
96770
|
) -> None:
|
|
95675
96771
|
selected_subprotocol = websocket_endpoint_subprotocol(
|
|
95676
96772
|
ws,
|
|
@@ -95685,6 +96781,7 @@ async def session_events(
|
|
|
95685
96781
|
await ws.close(code=4404)
|
|
95686
96782
|
return
|
|
95687
96783
|
await ws.accept(subprotocol=selected_subprotocol)
|
|
96784
|
+
schedule_provider_history_sync(STORE.sessions[session_id])
|
|
95688
96785
|
try:
|
|
95689
96786
|
if not await HUB.reserve(session_id, ws):
|
|
95690
96787
|
await close_event_websocket_over_capacity(ws)
|
|
@@ -95700,7 +96797,6 @@ async def session_events(
|
|
|
95700
96797
|
# is ready. Reading its fsynced rows earlier can replay raw scheduled
|
|
95701
96798
|
# inputs before this socket is registered for the repaired broadcast.
|
|
95702
96799
|
async with event_delivery_lock(session_id):
|
|
95703
|
-
await asyncio.to_thread(prepare_provider_history_metadata_repair, session_id)
|
|
95704
96800
|
boundary = await asyncio.to_thread(
|
|
95705
96801
|
last_event_seq_from_file,
|
|
95706
96802
|
events_path(session_id),
|
|
@@ -95751,12 +96847,28 @@ async def session_events(
|
|
|
95751
96847
|
return
|
|
95752
96848
|
if reasoning_snapshot is not None:
|
|
95753
96849
|
await asyncio.wait_for(ws.send_json(reasoning_snapshot), timeout=WEBSOCKET_SEND_TIMEOUT_SECONDS)
|
|
96850
|
+
if liveness:
|
|
96851
|
+
await send_event_stream_status(ws, session_id, "ready")
|
|
96852
|
+
if history_projection:
|
|
96853
|
+
# A proof may finish between the HTTP page and subscription.
|
|
96854
|
+
# The revision closes that gap without replaying old rows.
|
|
96855
|
+
await asyncio.wait_for(ws.send_json(history_projection_notice(session_id)), timeout=WEBSOCKET_SEND_TIMEOUT_SECONDS)
|
|
95754
96856
|
break
|
|
95755
96857
|
boundary = gap_boundary
|
|
95756
96858
|
while True:
|
|
95757
|
-
|
|
96859
|
+
if not liveness:
|
|
96860
|
+
await ws.receive_text()
|
|
96861
|
+
continue
|
|
96862
|
+
try:
|
|
96863
|
+
await asyncio.wait_for(ws.receive_text(), timeout=EVENT_STREAM_HEARTBEAT_SECONDS)
|
|
96864
|
+
except asyncio.TimeoutError:
|
|
96865
|
+
# No history scan, provider call, or sequence acknowledgement.
|
|
96866
|
+
# This confirms that the application event path is still alive.
|
|
96867
|
+
await send_event_stream_status(ws, session_id, "heartbeat")
|
|
95758
96868
|
except WebSocketDisconnect:
|
|
95759
96869
|
pass
|
|
96870
|
+
except (asyncio.TimeoutError, ConnectionError):
|
|
96871
|
+
await close_event_websocket_after_delivery_failure(ws)
|
|
95760
96872
|
finally:
|
|
95761
96873
|
await HUB.unsubscribe(session_id, ws)
|
|
95762
96874
|
|