@agentsdock/server 1.0.10 → 1.0.11-beta.12
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 +1658 -498
- 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 +26 -2
- package/server/interactive_chat_controls.py +9 -3
- package/server/interactive_chat_native.py +1 -1
- package/server/interactive_chat_share_routes.py +2 -2
- package/server/interactive_chat_share_web.py +2 -2
- package/server/local_session_ownership.py +1 -0
- package/server/npm/agentsdock/QR-NOTICES.txt +56 -0
- package/server/npm/agentsdock/pair.cjs +123 -0
- package/server/npm/agentsdock/qr-renderer.cjs +15 -0
- package/server/provider_commands.py +65 -0
- package/server/provider_connections.py +39 -10
- package/server/public_chat_share_routes.py +8 -6
- package/server/public_chat_shares.py +17 -7
- package/server/server_instances.py +217 -15
- package/server/shared_chat_analytics_web.py +5 -0
- 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)
|
|
@@ -32112,6 +32364,9 @@ SEMANTIC_TIMELINE_EVENT_BUDGET_PER_ITEM = 4
|
|
|
32112
32364
|
SEMANTIC_TIMELINE_ESSENTIAL_LIMIT_PER_ITEM = 32
|
|
32113
32365
|
SEMANTIC_TIMELINE_ESSENTIAL_PAGE_OVERFLOW_LIMIT = 128
|
|
32114
32366
|
SEMANTIC_TIMELINE_TRACE_ANCHOR_PAGE_OVERFLOW_LIMIT = 128
|
|
32367
|
+
# Same-ID history corrections clear previously cached replay bubbles. They
|
|
32368
|
+
# need their own bounded allowance, not the slots reserved for chat content.
|
|
32369
|
+
SEMANTIC_TIMELINE_REPAIR_PAGE_OVERFLOW_LIMIT = 256
|
|
32115
32370
|
JOB_RUN_HISTORY_TEXT_LIMIT = 64 * 1024
|
|
32116
32371
|
SEMANTIC_TIMELINE_ESSENTIAL_DETAIL_TYPES = {
|
|
32117
32372
|
"artifact_created",
|
|
@@ -32391,14 +32646,14 @@ CODEX_GOAL_HISTORY_REPAIR_CACHE = CodexGoalHistoryRepairCache()
|
|
|
32391
32646
|
CODEX_NATIVE_HISTORY_REPAIR_CACHE = CodexNativeHistoryRepairCache()
|
|
32392
32647
|
|
|
32393
32648
|
|
|
32394
|
-
def prepare_provider_history_metadata_repair(session_id: str) -> None:
|
|
32395
|
-
"""Prepare bounded source proofs
|
|
32649
|
+
def prepare_provider_history_metadata_repair(session_id: str) -> bool | None:
|
|
32650
|
+
"""Prepare bounded source proofs in background history maintenance."""
|
|
32396
32651
|
prepare_claude_history_metadata_repair(session_id)
|
|
32397
32652
|
prepare_codex_goal_history_repair(session_id)
|
|
32398
|
-
prepare_codex_native_history_repair(session_id)
|
|
32653
|
+
return prepare_codex_native_history_repair(session_id)
|
|
32399
32654
|
|
|
32400
32655
|
|
|
32401
|
-
def prepare_codex_native_history_repair(session_id: str) -> None:
|
|
32656
|
+
def prepare_codex_native_history_repair(session_id: str) -> bool | None:
|
|
32402
32657
|
session = STORE.sessions.get(session_id)
|
|
32403
32658
|
if not isinstance(session, dict) or session.get("backend") != BACKEND_CODEX or session.get("_deleting") is True:
|
|
32404
32659
|
CODEX_NATIVE_HISTORY_REPAIR_CACHE.forget(session_id)
|
|
@@ -32426,11 +32681,13 @@ def prepare_codex_native_history_repair(session_id: str) -> None:
|
|
|
32426
32681
|
session_id, provider_id, events_path(session_id), source, CODEX_SESSIONS_ROOT,
|
|
32427
32682
|
lambda event: codex_history_event_item(event, expected_session_id=session_id),
|
|
32428
32683
|
)
|
|
32429
|
-
except CodexNativeHistoryProofUnavailable:
|
|
32684
|
+
except CodexNativeHistoryProofUnavailable as exc:
|
|
32685
|
+
if getattr(exc, "deterministic", False):
|
|
32686
|
+
raise
|
|
32430
32687
|
# Incomplete evidence is retryable at a later explicit read boundary,
|
|
32431
32688
|
# not a successful empty proof and not a reason to break chat opening.
|
|
32432
32689
|
logger.debug("native history repair deferred session=%s", session_id)
|
|
32433
|
-
return
|
|
32690
|
+
return False
|
|
32434
32691
|
if changed:
|
|
32435
32692
|
HISTORY_SEARCH_REPAIR_DIRTY.add(session_id)
|
|
32436
32693
|
HISTORY_SEARCH_DIRTY.add(session_id)
|
|
@@ -32993,7 +33250,6 @@ def build_timeline_index(session_id: str) -> dict[str, Any]:
|
|
|
32993
33250
|
|
|
32994
33251
|
|
|
32995
33252
|
def _build_timeline_index_locked(session_id: str) -> dict[str, Any]:
|
|
32996
|
-
prepare_provider_history_metadata_repair(session_id)
|
|
32997
33253
|
claude_metadata_signature = CLAUDE_METADATA_REPAIR_CACHE.signature(session_id)
|
|
32998
33254
|
codex_goal_history_signature = (
|
|
32999
33255
|
CODEX_GOAL_HISTORY_REPAIR_CACHE.signature(session_id),
|
|
@@ -33081,6 +33337,15 @@ def _build_timeline_index_locked(session_id: str) -> dict[str, Any]:
|
|
|
33081
33337
|
if can_append
|
|
33082
33338
|
else []
|
|
33083
33339
|
)
|
|
33340
|
+
# Keep provisional turn lookup linear in the journal size. Scheduled-job
|
|
33341
|
+
# streams can have hundreds of thousands of events with no ordinary turn;
|
|
33342
|
+
# scanning every preceding landmark for each one makes cold opens quadratic.
|
|
33343
|
+
turn_record_keys_by_run: dict[str, list[str]] = {}
|
|
33344
|
+
for existing in records:
|
|
33345
|
+
existing_key = str(existing.get("key") or "")
|
|
33346
|
+
if existing_key.startswith("turn:"):
|
|
33347
|
+
existing_run = existing_key[5:].split(":start-", 1)[0]
|
|
33348
|
+
turn_record_keys_by_run.setdefault(existing_run, []).append(existing_key)
|
|
33084
33349
|
dirty_record_keys: set[str] = set()
|
|
33085
33350
|
active_turn_key: str | None = cached.get("active_turn_key") if can_append else None
|
|
33086
33351
|
current_turn_by_run: dict[str, str] = (
|
|
@@ -33401,6 +33666,8 @@ def _build_timeline_index_locked(session_id: str) -> dict[str, Any]:
|
|
|
33401
33666
|
}
|
|
33402
33667
|
by_key[key] = record
|
|
33403
33668
|
records.append(record)
|
|
33669
|
+
if key.startswith("turn:"):
|
|
33670
|
+
turn_record_keys_by_run.setdefault(key[5:].split(":start-", 1)[0], []).append(key)
|
|
33404
33671
|
if advance_timeline:
|
|
33405
33672
|
latest_timeline_landmark_key = key
|
|
33406
33673
|
record["start_seq"] = min(int(record["start_seq"]), seq)
|
|
@@ -33867,16 +34134,10 @@ def _build_timeline_index_locked(session_id: str) -> dict[str, Any]:
|
|
|
33867
34134
|
job_by_run[run_id] = job_id
|
|
33868
34135
|
prior_key = current_turn_by_run.pop(run_id, None) if run_id else None
|
|
33869
34136
|
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
|
-
)
|
|
34137
|
+
prior_keys = turn_record_keys_by_run.get(run_id, [])
|
|
34138
|
+
while prior_keys and prior_keys[-1] not in by_key:
|
|
34139
|
+
prior_keys.pop()
|
|
34140
|
+
prior_key = prior_keys[-1] if prior_keys else None
|
|
33880
34141
|
prior = by_key.get(prior_key or "")
|
|
33881
34142
|
group_key, group_created = assign_job_timeline_group(
|
|
33882
34143
|
event,
|
|
@@ -35502,9 +35763,6 @@ def collect_semantic_timeline_events(
|
|
|
35502
35763
|
deduplicated[identity] = event
|
|
35503
35764
|
return True
|
|
35504
35765
|
|
|
35505
|
-
for event in page_repairs:
|
|
35506
|
-
append_with_limit(event, event_limit)
|
|
35507
|
-
|
|
35508
35766
|
# Every selected semantic item gets one representative before any item
|
|
35509
35767
|
# receives detail. The page-size clamp in read_semantic_timeline_page()
|
|
35510
35768
|
# guarantees that this baseline always fits.
|
|
@@ -35519,6 +35777,22 @@ def collect_semantic_timeline_events(
|
|
|
35519
35777
|
for event in secondary:
|
|
35520
35778
|
append_with_limit(event, event_limit)
|
|
35521
35779
|
|
|
35780
|
+
# Reserve both sides of ordinary turns before emitting repair receipts.
|
|
35781
|
+
# A single imported run can contain hundreds of corrected boundaries:
|
|
35782
|
+
# charging them to the ordinary budget used to leave only trace anchors
|
|
35783
|
+
# for recent turns. Extend that budget only by receipts actually added,
|
|
35784
|
+
# retaining its remaining detail capacity and the absolute response cap.
|
|
35785
|
+
repair_allowance = min(
|
|
35786
|
+
SEMANTIC_TIMELINE_REPAIR_PAGE_OVERFLOW_LIMIT,
|
|
35787
|
+
MAX_EVENT_RESPONSE_LIMIT - event_limit,
|
|
35788
|
+
)
|
|
35789
|
+
repair_limit = len(deduplicated) + repair_allowance
|
|
35790
|
+
repair_count = 0
|
|
35791
|
+
for event in page_repairs:
|
|
35792
|
+
if append_with_limit(event, repair_limit):
|
|
35793
|
+
repair_count += 1
|
|
35794
|
+
event_limit += repair_count
|
|
35795
|
+
|
|
35522
35796
|
# Files and reviewable diffs are semantic output, not optional trace
|
|
35523
35797
|
# detail. Give each selected item a generous but bounded overflow and also
|
|
35524
35798
|
# cap total page overflow. This preserves normal multi-video workflows
|
|
@@ -38401,7 +38675,7 @@ def cross_chat_supported_target_backends() -> list[str]:
|
|
|
38401
38675
|
# Only cached diagnostics/private metadata are read here; health must not
|
|
38402
38676
|
# launch CLIs or authenticate remotely. Each target's actual turn admission
|
|
38403
38677
|
# still checks its own binding, CLI compatibility and selected login mode.
|
|
38404
|
-
for backend in (BACKEND_CURSOR, BACKEND_OPENCODE):
|
|
38678
|
+
for backend in (BACKEND_CURSOR, BACKEND_OPENCODE, BACKEND_GEMINI):
|
|
38405
38679
|
with RUNTIME_DIAGNOSTICS_LOCK:
|
|
38406
38680
|
diagnostic = dict(RUNTIME_DIAGNOSTICS.get(backend) or {})
|
|
38407
38681
|
ready = diagnostic.get("status") == "ready"
|
|
@@ -38527,15 +38801,21 @@ def cross_chat_handoffs_capability() -> dict[str, Any]:
|
|
|
38527
38801
|
BACKEND_CLAUDE: CLAUDE_TRANSPORT_AGENT_SDK,
|
|
38528
38802
|
BACKEND_CURSOR: "headless-stream-json",
|
|
38529
38803
|
BACKEND_OPENCODE: "headless-stream-json",
|
|
38804
|
+
BACKEND_GEMINI: "acp",
|
|
38530
38805
|
},
|
|
38531
38806
|
}
|
|
38532
38807
|
|
|
38533
38808
|
|
|
38534
38809
|
OPENCODE_CROSS_CHAT_CLIENT_CAPABILITY = "opencode_cross_chat_v1"
|
|
38810
|
+
GEMINI_CLIENT_CAPABILITY = "gemini_acp_v1"
|
|
38535
38811
|
|
|
38536
38812
|
|
|
38537
38813
|
def cross_chat_delivery_client_capabilities(target: dict[str, Any]) -> list[str]:
|
|
38538
38814
|
backend = str(target.get("backend") or DEFAULT_BACKEND).strip().lower()
|
|
38815
|
+
if backend == BACKEND_GEMINI:
|
|
38816
|
+
if not cross_chat_target_backend_supported(backend):
|
|
38817
|
+
raise HTTPException(409, "The target Gemini CLI is unavailable.")
|
|
38818
|
+
return [GEMINI_CLIENT_CAPABILITY]
|
|
38539
38819
|
if backend == BACKEND_CODEX:
|
|
38540
38820
|
if not cross_chat_target_backend_supported(backend):
|
|
38541
38821
|
raise HTTPException(
|
|
@@ -38578,6 +38858,7 @@ CROSS_CHAT_DELIVERY_CAPABILITIES_BY_BACKEND: dict[str, frozenset[str]] = {
|
|
|
38578
38858
|
BACKEND_CLAUDE: frozenset({CLAUDE_SDK_INTERACTIVE_CLIENT_CAPABILITY}),
|
|
38579
38859
|
BACKEND_CURSOR: frozenset(),
|
|
38580
38860
|
BACKEND_OPENCODE: frozenset({OPENCODE_CROSS_CHAT_CLIENT_CAPABILITY}),
|
|
38861
|
+
BACKEND_GEMINI: frozenset({GEMINI_CLIENT_CAPABILITY}),
|
|
38581
38862
|
}
|
|
38582
38863
|
|
|
38583
38864
|
|
|
@@ -43311,7 +43592,9 @@ def find_emergency_request_event(
|
|
|
43311
43592
|
request_id: str,
|
|
43312
43593
|
source_run_id: str,
|
|
43313
43594
|
) -> dict[str, Any] | None:
|
|
43314
|
-
for event in reversed_jsonl_events(
|
|
43595
|
+
for event in reversed_jsonl_events(
|
|
43596
|
+
events_path(session_id), retained_fields=EMERGENCY_EVENT_FIELDS,
|
|
43597
|
+
):
|
|
43315
43598
|
if (
|
|
43316
43599
|
event.get("type") == "emergency_alert_raised"
|
|
43317
43600
|
and event.get("emergency_request_id") == request_id
|
|
@@ -43326,7 +43609,9 @@ def emergency_alert_was_acknowledged(
|
|
|
43326
43609
|
alert_id: str,
|
|
43327
43610
|
raised_seq: int,
|
|
43328
43611
|
) -> bool:
|
|
43329
|
-
for event in reversed_jsonl_events(
|
|
43612
|
+
for event in reversed_jsonl_events(
|
|
43613
|
+
events_path(session_id), retained_fields=EMERGENCY_EVENT_FIELDS,
|
|
43614
|
+
):
|
|
43330
43615
|
seq = int(event.get("seq") or 0)
|
|
43331
43616
|
if seq <= raised_seq:
|
|
43332
43617
|
return False
|
|
@@ -43342,7 +43627,9 @@ def find_emergency_acknowledgement_event(
|
|
|
43342
43627
|
session_id: str,
|
|
43343
43628
|
alert_id: str,
|
|
43344
43629
|
) -> dict[str, Any] | None:
|
|
43345
|
-
for event in reversed_jsonl_events(
|
|
43630
|
+
for event in reversed_jsonl_events(
|
|
43631
|
+
events_path(session_id), retained_fields=EMERGENCY_EVENT_FIELDS,
|
|
43632
|
+
):
|
|
43346
43633
|
if (
|
|
43347
43634
|
event.get("type") == "emergency_alert_acknowledged"
|
|
43348
43635
|
and event.get("emergency_alert_id") == alert_id
|
|
@@ -43351,6 +43638,41 @@ def find_emergency_acknowledgement_event(
|
|
|
43351
43638
|
return None
|
|
43352
43639
|
|
|
43353
43640
|
|
|
43641
|
+
def emergency_alert_batch_through(
|
|
43642
|
+
session_id: str,
|
|
43643
|
+
through_alert_id: str,
|
|
43644
|
+
) -> tuple[dict[str, Any] | None, list[str]]:
|
|
43645
|
+
"""Freeze active IDs at an exact durable raise boundary, including retries.
|
|
43646
|
+
|
|
43647
|
+
Read lifecycle facts rather than a potentially interrupted summary save.
|
|
43648
|
+
Reverse order lets each alert's newest fact determine whether it is active.
|
|
43649
|
+
A previously acknowledged boundary remains a valid, immutable cut-off.
|
|
43650
|
+
"""
|
|
43651
|
+
settled: set[str] = set()
|
|
43652
|
+
active: list[tuple[int, str]] = []
|
|
43653
|
+
boundary = None
|
|
43654
|
+
for event in reversed_jsonl_events(
|
|
43655
|
+
events_path(session_id), retained_fields=EMERGENCY_EVENT_FIELDS,
|
|
43656
|
+
):
|
|
43657
|
+
if event.get("type") == "emergency_alert_acknowledged":
|
|
43658
|
+
settled.add(str(event.get("emergency_alert_id") or ""))
|
|
43659
|
+
elif event.get("type") == "emergency_alert_raised":
|
|
43660
|
+
alert = normalized_emergency_alert(event.get("emergency_alert"))
|
|
43661
|
+
seq = int(event.get("seq") or 0)
|
|
43662
|
+
if alert is None or seq <= 0:
|
|
43663
|
+
continue
|
|
43664
|
+
alert_id = alert["id"]
|
|
43665
|
+
if alert_id == through_alert_id:
|
|
43666
|
+
boundary = event
|
|
43667
|
+
if alert_id not in settled:
|
|
43668
|
+
active.append((seq, alert_id))
|
|
43669
|
+
settled.add(alert_id)
|
|
43670
|
+
if boundary is None:
|
|
43671
|
+
return None, []
|
|
43672
|
+
boundary_seq = int(boundary["seq"])
|
|
43673
|
+
return boundary, [alert_id for seq, alert_id in reversed(active) if seq <= boundary_seq]
|
|
43674
|
+
|
|
43675
|
+
|
|
43354
43676
|
def emergency_alert_response(
|
|
43355
43677
|
session_id: str,
|
|
43356
43678
|
event: dict[str, Any],
|
|
@@ -44941,98 +45263,46 @@ def bounded_jsonl_paths(
|
|
|
44941
45263
|
continue
|
|
44942
45264
|
|
|
44943
45265
|
|
|
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
45266
|
|
|
44947
|
-
|
|
44948
|
-
|
|
44949
|
-
|
|
44950
|
-
|
|
44951
|
-
|
|
44952
|
-
consumed = 0
|
|
45267
|
+
|
|
45268
|
+
def bounded_jsonl_events(path: Path, *, preserve_invalid: bool = False) -> Iterator[dict[str, Any] | None]:
|
|
45269
|
+
"""Project native records with bounded memory, not a source-byte cap."""
|
|
45270
|
+
initial = path.stat()
|
|
45271
|
+
expected = {field: int(getattr(initial, field)) for field in ("st_dev", "st_ino", "st_size", "st_mtime_ns")}
|
|
44953
45272
|
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):
|
|
45273
|
+
opened = os.fstat(stream.fileno())
|
|
45274
|
+
if any(int(getattr(opened, field)) != expected[field] for field in expected):
|
|
45275
|
+
raise ValueError("transcript changed before parsing")
|
|
45276
|
+
for event, _end, raw in jsonl_records(stream, end=initial.st_size,
|
|
45277
|
+
require_newline=False, max_records=MAX_LOCAL_TRANSCRIPT_SCAN_LINES,
|
|
45278
|
+
max_retained_bytes=MAX_LOCAL_TRANSCRIPT_LINE_BYTES):
|
|
45279
|
+
if not raw.blank and (event is not None or preserve_invalid):
|
|
44972
45280
|
yield event
|
|
44973
|
-
|
|
44974
|
-
|
|
44975
|
-
|
|
44976
|
-
raise ValueError(
|
|
44977
|
-
f"transcript exceeds the {MAX_LOCAL_TRANSCRIPT_SCAN_LINES}-line import limit"
|
|
44978
|
-
)
|
|
45281
|
+
finished = os.fstat(stream.fileno())
|
|
45282
|
+
if any(int(getattr(finished, field)) != expected[field] for field in expected):
|
|
45283
|
+
raise ValueError("transcript changed during parsing")
|
|
44979
45284
|
|
|
44980
45285
|
|
|
44981
45286
|
def bounded_jsonl_records_range(
|
|
44982
|
-
path: Path,
|
|
44983
|
-
|
|
44984
|
-
end: int,
|
|
44985
|
-
*,
|
|
44986
|
-
expected_stat: dict[str, int],
|
|
45287
|
+
path: Path, start: int, end: int, *, expected_stat: dict[str, int],
|
|
45288
|
+
require_newline: bool = True, allow_partial: bool = False,
|
|
44987
45289
|
) -> Iterator[tuple[dict[str, Any] | None, int]]:
|
|
44988
45290
|
"""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:
|
|
45291
|
+
if start < 0 or end < start or end > expected_stat["st_size"]:
|
|
44991
45292
|
raise ValueError("transcript cursor range is invalid")
|
|
44992
|
-
remaining = end - start
|
|
44993
|
-
if remaining == 0:
|
|
44994
|
-
return
|
|
44995
45293
|
with path.open("rb") as stream:
|
|
44996
45294
|
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
|
-
):
|
|
45295
|
+
if any(int(getattr(before, field)) != int(expected_stat[field]) for field in ("st_dev", "st_ino", "st_size", "st_mtime_ns")):
|
|
45001
45296
|
raise ValueError("transcript changed before cursor parsing")
|
|
45002
45297
|
stream.seek(start)
|
|
45003
|
-
for
|
|
45004
|
-
|
|
45298
|
+
for count, (event, offset, _raw) in enumerate(jsonl_records(stream, end=end,
|
|
45299
|
+
require_newline=require_newline, max_records=MAX_LOCAL_TRANSCRIPT_SCAN_LINES,
|
|
45300
|
+
max_retained_bytes=MAX_LOCAL_TRANSCRIPT_LINE_BYTES), 1):
|
|
45301
|
+
yield event, offset
|
|
45302
|
+
if allow_partial and count >= MAX_LOCAL_TRANSCRIPT_SCAN_LINES:
|
|
45005
45303
|
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
45304
|
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
|
-
):
|
|
45305
|
+
if any(int(getattr(after, field)) != int(expected_stat[field]) for field in ("st_dev", "st_ino", "st_size", "st_mtime_ns")):
|
|
45036
45306
|
raise ValueError("transcript changed during cursor parsing")
|
|
45037
45307
|
|
|
45038
45308
|
|
|
@@ -45324,20 +45594,7 @@ def find_codex_history(provider_id: str) -> Path | None:
|
|
|
45324
45594
|
clean_provider_id = provider_session_identifier(provider_id)
|
|
45325
45595
|
if clean_provider_id is None:
|
|
45326
45596
|
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
|
|
45597
|
+
return codex_rollout_path(clean_provider_id)
|
|
45341
45598
|
|
|
45342
45599
|
|
|
45343
45600
|
def codex_compaction_count(sess: dict[str, Any]) -> int:
|
|
@@ -45379,7 +45636,7 @@ def codex_rollout_path(thread_id: str) -> Path | None:
|
|
|
45379
45636
|
"""Locate ``rollout-*-<thread_id>.jsonl`` under CODEX_SESSIONS_ROOT.
|
|
45380
45637
|
|
|
45381
45638
|
Blocking filesystem work; call from a worker thread. Hits are cached per
|
|
45382
|
-
thread id and re-validated with
|
|
45639
|
+
thread id and re-validated with a bounded metadata read. Misses are cached briefly so a
|
|
45383
45640
|
thread whose rollout has not been flushed yet does not trigger a directory
|
|
45384
45641
|
walk on every runtime snapshot.
|
|
45385
45642
|
"""
|
|
@@ -45389,7 +45646,7 @@ def codex_rollout_path(thread_id: str) -> Path | None:
|
|
|
45389
45646
|
return None
|
|
45390
45647
|
cached = CODEX_ROLLOUT_PATH_CACHE.get(clean_id)
|
|
45391
45648
|
if cached is not None:
|
|
45392
|
-
if cached
|
|
45649
|
+
if contained_jsonl_path(cached, CODEX_SESSIONS_ROOT) is not None and codex_transcript_meta(cached)[0] == clean_id:
|
|
45393
45650
|
return cached
|
|
45394
45651
|
CODEX_ROLLOUT_PATH_CACHE.pop(clean_id, None)
|
|
45395
45652
|
missed_at = CODEX_ROLLOUT_PATH_MISSES.get(clean_id)
|
|
@@ -45402,17 +45659,29 @@ def codex_rollout_path(thread_id: str) -> Path | None:
|
|
|
45402
45659
|
suffix = f"-{clean_id}.jsonl"
|
|
45403
45660
|
selected: Path | None = None
|
|
45404
45661
|
selected_mtime = -1.0
|
|
45662
|
+
legacy_paths: list[Path] = []
|
|
45405
45663
|
for path in bounded_jsonl_paths(CODEX_SESSIONS_ROOT):
|
|
45406
45664
|
name = path.name
|
|
45407
45665
|
if not (name.startswith("rollout-") and name.endswith(suffix)):
|
|
45666
|
+
legacy_paths.append(path)
|
|
45408
45667
|
continue
|
|
45409
|
-
if
|
|
45668
|
+
if codex_transcript_meta(path)[0] != clean_id:
|
|
45410
45669
|
continue
|
|
45411
45670
|
with suppress(OSError):
|
|
45412
45671
|
mtime = path.stat().st_mtime
|
|
45413
45672
|
if mtime > selected_mtime:
|
|
45414
45673
|
selected = path
|
|
45415
45674
|
selected_mtime = mtime
|
|
45675
|
+
if selected is None:
|
|
45676
|
+
# Preserve discovery of renamed/legacy transcripts without reading
|
|
45677
|
+
# unrelated headers on the ordinary filename lookup path.
|
|
45678
|
+
for path in legacy_paths:
|
|
45679
|
+
if codex_transcript_meta(path)[0] != clean_id:
|
|
45680
|
+
continue
|
|
45681
|
+
with suppress(OSError):
|
|
45682
|
+
mtime = path.stat().st_mtime
|
|
45683
|
+
if mtime > selected_mtime:
|
|
45684
|
+
selected, selected_mtime = path, mtime
|
|
45416
45685
|
if selected is None:
|
|
45417
45686
|
CODEX_ROLLOUT_PATH_MISSES[clean_id] = now
|
|
45418
45687
|
if len(CODEX_ROLLOUT_PATH_MISSES) > 1024:
|
|
@@ -46603,6 +46872,8 @@ def session_provider_id(sess: dict[str, Any]) -> str | None:
|
|
|
46603
46872
|
return sess.get("cursor_session_id") or sess.get("session_id")
|
|
46604
46873
|
if backend == BACKEND_OPENCODE:
|
|
46605
46874
|
return sess.get("opencode_session_id") or sess.get("session_id")
|
|
46875
|
+
if backend == BACKEND_GEMINI:
|
|
46876
|
+
return sess.get("gemini_session_id") or sess.get("session_id")
|
|
46606
46877
|
return sess.get("session_id")
|
|
46607
46878
|
|
|
46608
46879
|
|
|
@@ -46742,6 +47013,12 @@ def read_native_session_title(sess: dict[str, Any]) -> str | None:
|
|
|
46742
47013
|
return native_session_title(codex_session_index_thread_names().get(provider_id))
|
|
46743
47014
|
if backend == BACKEND_CURSOR:
|
|
46744
47015
|
return cursor_native_session_title(provider_id, sess.get("cwd"))
|
|
47016
|
+
if backend == BACKEND_GEMINI:
|
|
47017
|
+
from gemini_history import native_snapshot
|
|
47018
|
+
from gemini_runtime import history_environment
|
|
47019
|
+
env = history_environment(runner_env(), sess, PROVIDER_CONNECTION_STORE.for_session(sess), PROVIDER_CONNECTION_STORE.root)
|
|
47020
|
+
record = native_snapshot(env, str(sess.get("cwd") or ""), provider_id)
|
|
47021
|
+
return native_session_title(record.get("summary"))
|
|
46745
47022
|
if backend == "opencode":
|
|
46746
47023
|
# Current native storage. A schema change, missing store, or busy DB
|
|
46747
47024
|
# fails closed. mode=ro must never create or migrate a provider DB.
|
|
@@ -46826,7 +47103,8 @@ def generated_title_eligible(sess: dict[str, Any], *, allow_attempted: bool = Fa
|
|
|
46826
47103
|
return bool(
|
|
46827
47104
|
os.environ.get("AGENTSDOCK_AUTO_TITLES", "1").strip().lower() not in {"0", "false", "off"}
|
|
46828
47105
|
and not SERVER_SHUTTING_DOWN
|
|
46829
|
-
and sess.get("backend") in {BACKEND_CODEX, BACKEND_CURSOR}
|
|
47106
|
+
and (sess.get("backend") in {BACKEND_CODEX, BACKEND_CURSOR}
|
|
47107
|
+
or (sess.get("backend") == BACKEND_GEMINI and sess.get("provider_connection") == "custom"))
|
|
46830
47108
|
and sess.get("auto_title_enabled", True) is not False
|
|
46831
47109
|
and not sess.get("archived") and not sess.get("parent_id") and not sess.get("fork_from")
|
|
46832
47110
|
and sess.get("id") not in DELETING_SESSIONS
|
|
@@ -46853,6 +47131,9 @@ async def close_generated_session_titles() -> None:
|
|
|
46853
47131
|
|
|
46854
47132
|
async def generate_session_title(session_id: str, snapshot: dict, reply: str) -> None:
|
|
46855
47133
|
async with GENERATED_TITLE_SLOTS:
|
|
47134
|
+
if snapshot["backend"] == BACKEND_GEMINI:
|
|
47135
|
+
# A native summary may arrive while this optional task waits.
|
|
47136
|
+
await refresh_native_session_title(session_id)
|
|
46856
47137
|
# Persist the claim before spending any provider usage. A restart does
|
|
46857
47138
|
# not retry an ambiguous request. This is one optional attempt per chat.
|
|
46858
47139
|
async with STORE._lock:
|
|
@@ -46879,6 +47160,27 @@ async def generate_session_title(session_id: str, snapshot: dict, reply: str) ->
|
|
|
46879
47160
|
options["model"] = codex_runtime_settings(snapshot)[0]
|
|
46880
47161
|
options["executable"] = CODEX_BIN
|
|
46881
47162
|
options["provider_selection"] = CODEX_PROVIDER_STORE.for_session(snapshot, include_key=True)
|
|
47163
|
+
elif snapshot["backend"] == BACKEND_GEMINI:
|
|
47164
|
+
from gemini_api import default_model
|
|
47165
|
+
from gemini_history import native_snapshot
|
|
47166
|
+
connection = PROVIDER_CONNECTION_STORE.for_session(snapshot)
|
|
47167
|
+
model = default_model(connection)
|
|
47168
|
+
if model == "auto":
|
|
47169
|
+
from gemini_runtime import history_environment
|
|
47170
|
+
env = history_environment(runner_env(), snapshot, connection, PROVIDER_CONNECTION_STORE.root)
|
|
47171
|
+
native = await asyncio.wait_for(asyncio.to_thread(native_snapshot, env,
|
|
47172
|
+
str(snapshot.get("cwd") or ""), session_provider_id(snapshot)), timeout=1.0)
|
|
47173
|
+
model = next((row.get("model") for row in reversed(native["messages"])
|
|
47174
|
+
if row.get("type") == "gemini" and row.get("model") and row.get("model") != "auto"), None)
|
|
47175
|
+
# Recheck revocation after the awaited metadata read and before
|
|
47176
|
+
# sending anything. Never take credentials from the current default.
|
|
47177
|
+
connection = PROVIDER_CONNECTION_STORE.for_session(snapshot)
|
|
47178
|
+
current = STORE.sessions.get(session_id)
|
|
47179
|
+
if (not current or not generated_title_eligible(current, allow_attempted=True)
|
|
47180
|
+
or title_runtime_key(current) != title_runtime_key(snapshot)
|
|
47181
|
+
or current.get("title") != snapshot.get("title")):
|
|
47182
|
+
return
|
|
47183
|
+
options = {"model": model, "provider_selection": connection}
|
|
46882
47184
|
else:
|
|
46883
47185
|
options["executable"] = resolve_cursor_executable()
|
|
46884
47186
|
if not options["executable"]:
|
|
@@ -46890,6 +47192,9 @@ async def generate_session_title(session_id: str, snapshot: dict, reply: str) ->
|
|
|
46890
47192
|
snapshot["backend"], snapshot["_title_seed"], reply, **options,
|
|
46891
47193
|
)
|
|
46892
47194
|
if result:
|
|
47195
|
+
if snapshot["backend"] == BACKEND_GEMINI:
|
|
47196
|
+
await refresh_native_session_title(session_id)
|
|
47197
|
+
PROVIDER_CONNECTION_STORE.for_session(snapshot) # Forget also fences a pending result.
|
|
46893
47198
|
changed = await STORE.adopt_auto_title(
|
|
46894
47199
|
session_id, result, source="generated", backend=snapshot["backend"],
|
|
46895
47200
|
provider_id=session_provider_id(snapshot), expected_title=snapshot.get("title"),
|
|
@@ -47040,7 +47345,7 @@ def codex_transcript_meta(
|
|
|
47040
47345
|
) -> tuple[str | None, str | None]:
|
|
47041
47346
|
"""Return (session_id, cwd) from a Codex transcript's session_meta record."""
|
|
47042
47347
|
with suppress(OSError, ValueError):
|
|
47043
|
-
for index, event in enumerate(
|
|
47348
|
+
for index, event in enumerate(codex_transcript_metadata_records(path)):
|
|
47044
47349
|
if index >= CODEX_TRANSCRIPT_SCAN_LINES:
|
|
47045
47350
|
break
|
|
47046
47351
|
if event.get("type") != "session_meta":
|
|
@@ -47056,6 +47361,23 @@ def codex_transcript_meta(
|
|
|
47056
47361
|
return None, None
|
|
47057
47362
|
|
|
47058
47363
|
|
|
47364
|
+
def codex_transcript_metadata_records(path: Path) -> Iterator[dict[str, Any]]:
|
|
47365
|
+
"""Discovery reads a bounded header, independent of total rollout size."""
|
|
47366
|
+
remaining = 1024 * 1024
|
|
47367
|
+
with path.open("rb") as stream:
|
|
47368
|
+
for _ in range(CODEX_TRANSCRIPT_SCAN_LINES):
|
|
47369
|
+
line = stream.readline(min(remaining, MAX_LOCAL_TRANSCRIPT_LINE_BYTES) + 1)
|
|
47370
|
+
if not line or len(line) > remaining or not line.endswith(b"\n"):
|
|
47371
|
+
return
|
|
47372
|
+
remaining -= len(line)
|
|
47373
|
+
try:
|
|
47374
|
+
record = json.loads(line)
|
|
47375
|
+
except (ValueError, UnicodeDecodeError):
|
|
47376
|
+
continue
|
|
47377
|
+
if isinstance(record, dict):
|
|
47378
|
+
yield record
|
|
47379
|
+
|
|
47380
|
+
|
|
47059
47381
|
def codex_transcript_preview(path: Path) -> str | None:
|
|
47060
47382
|
"""Return a short snippet of the first user message, for a local-session label."""
|
|
47061
47383
|
with suppress(OSError, ValueError):
|
|
@@ -47288,6 +47610,7 @@ def local_session_candidates(
|
|
|
47288
47610
|
known_provider_keys: set[tuple[str, str]] | None = None,
|
|
47289
47611
|
*,
|
|
47290
47612
|
include_cursor: bool = False,
|
|
47613
|
+
include_gemini: bool = False,
|
|
47291
47614
|
) -> list[dict[str, Any]]:
|
|
47292
47615
|
"""Enumerate sessions not already used by this or another local instance."""
|
|
47293
47616
|
|
|
@@ -47316,6 +47639,11 @@ def local_session_candidates(
|
|
|
47316
47639
|
candidates.extend(local_cursor_session_candidates({
|
|
47317
47640
|
provider_id for backend, provider_id in known_provider_keys if backend == BACKEND_CURSOR
|
|
47318
47641
|
}))
|
|
47642
|
+
if include_gemini:
|
|
47643
|
+
from gemini_import import candidates as gemini_candidates
|
|
47644
|
+
candidates.extend(gemini_candidates(sys.modules[__name__], runner_env(), {
|
|
47645
|
+
provider_id for backend, provider_id in known_provider_keys if backend == BACKEND_GEMINI
|
|
47646
|
+
}))
|
|
47319
47647
|
candidates.sort(key=lambda c: c["updated_at"], reverse=True)
|
|
47320
47648
|
return candidates[:max(1, min(limit, MAX_LOCAL_SESSION_LIST_ITEMS))]
|
|
47321
47649
|
|
|
@@ -47363,6 +47691,8 @@ def standalone_provider_session(sess: dict[str, Any]) -> dict[str, Any]:
|
|
|
47363
47691
|
isolated["codex_thread_id"] = None
|
|
47364
47692
|
isolated["cursor_session_id"] = None
|
|
47365
47693
|
isolated["opencode_session_id"] = None
|
|
47694
|
+
isolated["gemini_session_id"] = None
|
|
47695
|
+
isolated.pop("gemini_session_cwd", None)
|
|
47366
47696
|
isolated["backend_locked"] = False
|
|
47367
47697
|
isolated.pop("cursor_instruction_hash", None)
|
|
47368
47698
|
isolated.pop("cursor_instruction_version", None)
|
|
@@ -47402,6 +47732,12 @@ def provider_history_path(sess: dict[str, Any]) -> Path | None:
|
|
|
47402
47732
|
if backend == BACKEND_CLAUDE:
|
|
47403
47733
|
return find_claude_history(provider_id)
|
|
47404
47734
|
if backend == BACKEND_CODEX:
|
|
47735
|
+
cursor = normalized_history_sync_cursor(sess)
|
|
47736
|
+
if cursor and cursor.get("provider_session_id") == provider_id:
|
|
47737
|
+
path = contained_jsonl_path(Path(cursor["source_path"]), CODEX_SESSIONS_ROOT)
|
|
47738
|
+
if path is not None and codex_transcript_meta(path)[0] == provider_id:
|
|
47739
|
+
CODEX_ROLLOUT_PATH_CACHE[provider_id] = path
|
|
47740
|
+
return path
|
|
47405
47741
|
return find_codex_history(provider_id)
|
|
47406
47742
|
return None
|
|
47407
47743
|
|
|
@@ -47494,7 +47830,6 @@ def normalized_history_sync_cursor(
|
|
|
47494
47830
|
or int(raw.get(field, 0)) < 0
|
|
47495
47831
|
for field in optional_integer_fields
|
|
47496
47832
|
)
|
|
47497
|
-
or int(raw["source_size"]) > MAX_LOCAL_TRANSCRIPT_BYTES
|
|
47498
47833
|
or int(raw["source_offset"]) > int(raw["source_size"])
|
|
47499
47834
|
or (
|
|
47500
47835
|
timeline_pending_active
|
|
@@ -47525,6 +47860,8 @@ def normalized_history_sync_cursor(
|
|
|
47525
47860
|
raw.get("timeline_pending_through_seq") or 0
|
|
47526
47861
|
),
|
|
47527
47862
|
"timeline_pending_active": timeline_pending_active,
|
|
47863
|
+
**({"bootstrap_timeline_seq": raw["bootstrap_timeline_seq"]}
|
|
47864
|
+
if type(raw.get("bootstrap_timeline_seq")) is int and raw["bootstrap_timeline_seq"] >= 0 else {}),
|
|
47528
47865
|
"checkpoint_seq": int(raw.get("checkpoint_seq", raw["timeline_seq"])),
|
|
47529
47866
|
}
|
|
47530
47867
|
# Distinguish a pre-feature cursor from an initialized conservative empty
|
|
@@ -47675,10 +48012,6 @@ def provider_history_source_snapshot(
|
|
|
47675
48012
|
"""Hash a stable transcript through its last complete JSONL record."""
|
|
47676
48013
|
|
|
47677
48014
|
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
48015
|
# Provider CLIs may rotate a JSONL file with atomic replacement or move it
|
|
47683
48016
|
# between dated directories. Backend/provider identity is validated before
|
|
47684
48017
|
# this helper; byte-for-byte prefix continuity is authoritative across a
|
|
@@ -47693,7 +48026,8 @@ def provider_history_source_snapshot(
|
|
|
47693
48026
|
prefix_digest = hashlib.sha256() if verify_offset is not None else None
|
|
47694
48027
|
prefix_hashed = 0
|
|
47695
48028
|
complete_size = 0
|
|
47696
|
-
|
|
48029
|
+
running_digest = hashlib.sha256()
|
|
48030
|
+
deadline = time.monotonic() + SCAN_SECONDS
|
|
47697
48031
|
with path.open("rb") as stream:
|
|
47698
48032
|
opened = os.fstat(stream.fileno())
|
|
47699
48033
|
expected_identity = (
|
|
@@ -47709,7 +48043,12 @@ def provider_history_source_snapshot(
|
|
|
47709
48043
|
int(opened.st_mtime_ns),
|
|
47710
48044
|
) != expected_identity:
|
|
47711
48045
|
raise ValueError("transcript changed before cursor snapshot")
|
|
47712
|
-
|
|
48046
|
+
# Verify the existing prefix, then prepare at most one bounded suffix.
|
|
48047
|
+
# Total file size is not an import limit. Prefix verification remains
|
|
48048
|
+
# necessary: provider transcripts can be replaced or rewritten.
|
|
48049
|
+
snapshot_limit = min(int(initial.st_size), (verify_offset or 0) + HISTORY_SYNC_BATCH_BYTES)
|
|
48050
|
+
remaining = snapshot_limit
|
|
48051
|
+
consumed = 0
|
|
47713
48052
|
while remaining > 0:
|
|
47714
48053
|
chunk = stream.read(min(1024 * 1024, remaining))
|
|
47715
48054
|
if not chunk:
|
|
@@ -47719,15 +48058,20 @@ def provider_history_source_snapshot(
|
|
|
47719
48058
|
take = min(len(chunk), int(verify_offset) - prefix_hashed)
|
|
47720
48059
|
prefix_digest.update(chunk[:take])
|
|
47721
48060
|
prefix_hashed += take
|
|
47722
|
-
|
|
47723
|
-
newline =
|
|
48061
|
+
scan_check(deadline, budget_seconds=SCAN_SECONDS)
|
|
48062
|
+
newline = chunk.rfind(b"\n")
|
|
47724
48063
|
if newline >= 0:
|
|
47725
|
-
|
|
47726
|
-
complete_digest.
|
|
47727
|
-
complete_size
|
|
47728
|
-
|
|
47729
|
-
|
|
47730
|
-
|
|
48064
|
+
running_digest.update(chunk[:newline + 1])
|
|
48065
|
+
complete_digest = running_digest.copy()
|
|
48066
|
+
complete_size = consumed + newline + 1
|
|
48067
|
+
running_digest.update(chunk[newline + 1:])
|
|
48068
|
+
else:
|
|
48069
|
+
running_digest.update(chunk)
|
|
48070
|
+
consumed += len(chunk)
|
|
48071
|
+
# A single streamed record may exceed a batch. Extend only until
|
|
48072
|
+
# at least one new complete record exists, under the scan deadline.
|
|
48073
|
+
if remaining == 0 and complete_size <= (verify_offset or 0) and consumed < int(initial.st_size):
|
|
48074
|
+
remaining = min(1024 * 1024, int(initial.st_size) - consumed)
|
|
47731
48075
|
finished = os.fstat(stream.fileno())
|
|
47732
48076
|
if (
|
|
47733
48077
|
int(finished.st_dev),
|
|
@@ -47880,6 +48224,7 @@ def parse_provider_history_delta(
|
|
|
47880
48224
|
start,
|
|
47881
48225
|
end,
|
|
47882
48226
|
expected_stat=expected_stat,
|
|
48227
|
+
allow_partial=True,
|
|
47883
48228
|
):
|
|
47884
48229
|
if blocked_on_unseen_message:
|
|
47885
48230
|
# Exhaust the fixed range so its final fstat validation still
|
|
@@ -48010,51 +48355,27 @@ def load_provider_history_with_cursor(
|
|
|
48010
48355
|
if "claude_interruption_context" in previous
|
|
48011
48356
|
else seed_claude_interruption_context(path, start, expected_stat=snapshot["expected_stat"], provider_session_id=provider_id)
|
|
48012
48357
|
)
|
|
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
|
|
48358
|
+
(
|
|
48359
|
+
items,
|
|
48360
|
+
cursor_offset,
|
|
48361
|
+
last_item_digest,
|
|
48362
|
+
delta_overflow,
|
|
48363
|
+
) = parse_provider_history_delta(
|
|
48364
|
+
path,
|
|
48365
|
+
backend,
|
|
48366
|
+
start,
|
|
48367
|
+
end,
|
|
48368
|
+
limit=limit,
|
|
48369
|
+
expected_stat=snapshot["expected_stat"],
|
|
48370
|
+
previous_last_item_digest=str(
|
|
48371
|
+
(previous or {}).get("last_item_digest") or ""
|
|
48372
|
+
),
|
|
48373
|
+
expected_session_id=str(sess.get("id") or "") or None,
|
|
48374
|
+
interruption_context=interruption_context,
|
|
48375
|
+
codex_phase_context=codex_phase_context,
|
|
48376
|
+
source_text_context=source_text_context,
|
|
48377
|
+
)
|
|
48378
|
+
caught_up = not delta_overflow and cursor_offset == end and end == int(snapshot["source_size"])
|
|
48058
48379
|
source_digest = (
|
|
48059
48380
|
str(snapshot["source_digest"])
|
|
48060
48381
|
if cursor_offset == end
|
|
@@ -48417,6 +48738,7 @@ def reconcile_cursor_history_items(
|
|
|
48417
48738
|
*,
|
|
48418
48739
|
timeline_after_seq: int,
|
|
48419
48740
|
timeline_through_seq: int,
|
|
48741
|
+
bootstrap: bool = False,
|
|
48420
48742
|
) -> tuple[list[dict[str, str]], int]:
|
|
48421
48743
|
"""Subtract timeline-owned occurrences from one exact provider delta.
|
|
48422
48744
|
|
|
@@ -48440,14 +48762,15 @@ def reconcile_cursor_history_items(
|
|
|
48440
48762
|
timeline_after_seq=after_seq,
|
|
48441
48763
|
timeline_through_seq=through_seq,
|
|
48442
48764
|
tail=False,
|
|
48443
|
-
include_imported=
|
|
48765
|
+
include_imported=bootstrap,
|
|
48444
48766
|
message_details=message_details,
|
|
48445
48767
|
)
|
|
48446
48768
|
|
|
48447
48769
|
fresh: list[dict[str, str]] = []
|
|
48448
48770
|
timeline_index = 0
|
|
48449
48771
|
consumed_seq = after_seq
|
|
48450
|
-
|
|
48772
|
+
last_match_index = -1
|
|
48773
|
+
for item_index, item in enumerate(items):
|
|
48451
48774
|
item_details = history_message_match_details(item.get("kind", ""), item.get("text", ""), item, provider_item=True)
|
|
48452
48775
|
if (
|
|
48453
48776
|
timeline_index < len(timeline_messages)
|
|
@@ -48457,6 +48780,7 @@ def reconcile_cursor_history_items(
|
|
|
48457
48780
|
):
|
|
48458
48781
|
consumed_seq = timeline_messages[timeline_index][0]
|
|
48459
48782
|
timeline_index += 1
|
|
48783
|
+
last_match_index = item_index
|
|
48460
48784
|
else:
|
|
48461
48785
|
fresh.append(item)
|
|
48462
48786
|
|
|
@@ -48471,6 +48795,11 @@ def reconcile_cursor_history_items(
|
|
|
48471
48795
|
if timeline_window_truncated and timeline_messages
|
|
48472
48796
|
else through_seq
|
|
48473
48797
|
)
|
|
48798
|
+
if bootstrap:
|
|
48799
|
+
# Initial backfill uses the same conservative tail semantics as a
|
|
48800
|
+
# single full import. Old imported rows are credits too; keep only
|
|
48801
|
+
# source items after the entire captured preexisting timeline.
|
|
48802
|
+
fresh = items[last_match_index + 1:] if consumed_seq == through_seq and not timeline_window_truncated else []
|
|
48474
48803
|
return fresh, consumed_seq
|
|
48475
48804
|
|
|
48476
48805
|
|
|
@@ -48592,10 +48921,48 @@ def unsynced_history_items(
|
|
|
48592
48921
|
return items[last_matched + 1:]
|
|
48593
48922
|
|
|
48594
48923
|
|
|
48924
|
+
class HistoryPreparationStale(RuntimeError):
|
|
48925
|
+
"""The read-only plan lost its ownership or immutable input generation."""
|
|
48926
|
+
|
|
48927
|
+
|
|
48928
|
+
def history_file_stamp(path: Path) -> tuple[int, int, int, int] | None:
|
|
48929
|
+
try:
|
|
48930
|
+
value = path.stat()
|
|
48931
|
+
return value.st_dev, value.st_ino, value.st_size, value.st_mtime_ns
|
|
48932
|
+
except FileNotFoundError:
|
|
48933
|
+
return None
|
|
48934
|
+
|
|
48935
|
+
|
|
48936
|
+
@asynccontextmanager
|
|
48937
|
+
async def history_commit_guard(sess, cursor, timeline_stamp, source_stamp, enabled):
|
|
48938
|
+
if not enabled:
|
|
48939
|
+
yield
|
|
48940
|
+
return
|
|
48941
|
+
session_id = str(sess["id"])
|
|
48942
|
+
async with session_lifecycle_lock(session_id):
|
|
48943
|
+
current = STORE.sessions.get(session_id)
|
|
48944
|
+
if (not current or session_id in DELETING_SESSIONS
|
|
48945
|
+
or session_id in DELETED_SESSION_TOMBSTONES
|
|
48946
|
+
or session_id in BUSY_SESSIONS or ACTIVE.get(session_id) is not None
|
|
48947
|
+
or session_id in SERVER_MAINTENANCE_SESSIONS
|
|
48948
|
+
or any(not task.done() for task in SESSION_TURN_TASKS.get(session_id, ()))
|
|
48949
|
+
or current.get("backend") != sess.get("backend")
|
|
48950
|
+
or session_provider_id(current) != session_provider_id(sess)
|
|
48951
|
+
or normalized_history_sync_cursor(current) != cursor
|
|
48952
|
+
or history_file_stamp(events_path(session_id)) != timeline_stamp):
|
|
48953
|
+
raise HistoryPreparationStale("chat changed during history preparation")
|
|
48954
|
+
if source_stamp is not None:
|
|
48955
|
+
path, size, mtime, dev, ino = source_stamp
|
|
48956
|
+
if history_file_stamp(Path(path)) != (dev, ino, size, mtime):
|
|
48957
|
+
raise HistoryPreparationStale("provider transcript changed during history preparation")
|
|
48958
|
+
yield
|
|
48959
|
+
|
|
48960
|
+
|
|
48595
48961
|
async def sync_provider_history(
|
|
48596
48962
|
sess: dict[str, Any],
|
|
48597
48963
|
*,
|
|
48598
48964
|
limit: int | None = None,
|
|
48965
|
+
background: bool = False,
|
|
48599
48966
|
) -> dict[str, Any]:
|
|
48600
48967
|
"""Append provider messages added outside this chat since the last sync.
|
|
48601
48968
|
|
|
@@ -48616,6 +48983,7 @@ async def sync_provider_history(
|
|
|
48616
48983
|
}
|
|
48617
48984
|
live_session = STORE.sessions.get(session_id) or {}
|
|
48618
48985
|
previous_cursor = normalized_history_sync_cursor(live_session)
|
|
48986
|
+
timeline_stamp = history_file_stamp(events_path(session_id))
|
|
48619
48987
|
# A durable cursor makes the unchanged fast path survive process restart;
|
|
48620
48988
|
# all five stat/identity fields must still describe the fully-consumed
|
|
48621
48989
|
# source before parsing can be skipped.
|
|
@@ -48638,6 +49006,10 @@ async def sync_provider_history(
|
|
|
48638
49006
|
"source_path": None,
|
|
48639
49007
|
"message": "No provider transcript found.",
|
|
48640
49008
|
}
|
|
49009
|
+
if (previous_cursor is not None and next_cursor is not None
|
|
49010
|
+
and next_cursor["source_offset"] == previous_cursor["source_offset"]
|
|
49011
|
+
and not next_cursor.get("source_caught_up", True)):
|
|
49012
|
+
return {"imported": 0, "deferred": True, "reason": "incomplete_source_record"}
|
|
48641
49013
|
if next_cursor is not None and previous_cursor is not None:
|
|
48642
49014
|
recovered = await asyncio.to_thread(
|
|
48643
49015
|
committed_history_sync_checkpoint,
|
|
@@ -48646,11 +49018,11 @@ async def sync_provider_history(
|
|
|
48646
49018
|
)
|
|
48647
49019
|
if recovered is not None:
|
|
48648
49020
|
recovered_cursor, _terminal_seq = recovered
|
|
48649
|
-
|
|
48650
|
-
|
|
48651
|
-
|
|
48652
|
-
|
|
48653
|
-
|
|
49021
|
+
async with history_commit_guard(sess, previous_cursor, timeline_stamp, stamp, background):
|
|
49022
|
+
await persist_history_sync_cursor(
|
|
49023
|
+
sess, recovered_cursor,
|
|
49024
|
+
timeline_seq=int(recovered_cursor.get("timeline_seq") or 0),
|
|
49025
|
+
)
|
|
48654
49026
|
return {
|
|
48655
49027
|
"imported": 0,
|
|
48656
49028
|
"source_path": str(source_path),
|
|
@@ -48660,14 +49032,18 @@ async def sync_provider_history(
|
|
|
48660
49032
|
last_event_seq_from_file,
|
|
48661
49033
|
events_path(session_id),
|
|
48662
49034
|
)
|
|
48663
|
-
|
|
48664
|
-
|
|
49035
|
+
bootstrap_boundary = (previous_cursor or {}).get("bootstrap_timeline_seq") if continued else None
|
|
49036
|
+
if not continued and next_cursor is not None and not next_cursor.get("source_caught_up", True):
|
|
49037
|
+
bootstrap_boundary = timeline_latest_seq
|
|
49038
|
+
if (continued and previous_cursor is not None) or (next_cursor is not None and not next_cursor.get("source_caught_up", True)):
|
|
49039
|
+
timeline_scan_through_seq = timeline_latest_seq if bootstrap_boundary is None else bootstrap_boundary
|
|
48665
49040
|
fresh, consumed_timeline_seq = await asyncio.to_thread(
|
|
48666
49041
|
reconcile_cursor_history_items,
|
|
48667
49042
|
session_id,
|
|
48668
49043
|
items,
|
|
48669
|
-
timeline_after_seq=int(previous_cursor.get("timeline_seq") or 0),
|
|
49044
|
+
timeline_after_seq=int((previous_cursor or {}).get("timeline_seq") or 0) if continued else 0,
|
|
48670
49045
|
timeline_through_seq=timeline_scan_through_seq,
|
|
49046
|
+
**({"bootstrap": True} if bootstrap_boundary is not None else {}),
|
|
48671
49047
|
)
|
|
48672
49048
|
else:
|
|
48673
49049
|
# First sync or invalidated/rotated source: retain the conservative
|
|
@@ -48688,11 +49064,11 @@ async def sync_provider_history(
|
|
|
48688
49064
|
)
|
|
48689
49065
|
if recovered is not None:
|
|
48690
49066
|
recovered_cursor, _terminal_seq = recovered
|
|
48691
|
-
|
|
48692
|
-
|
|
48693
|
-
|
|
48694
|
-
|
|
48695
|
-
|
|
49067
|
+
async with history_commit_guard(sess, previous_cursor, timeline_stamp, stamp, background):
|
|
49068
|
+
await persist_history_sync_cursor(
|
|
49069
|
+
sess, recovered_cursor,
|
|
49070
|
+
timeline_seq=int(recovered_cursor.get("timeline_seq") or 0),
|
|
49071
|
+
)
|
|
48696
49072
|
return {
|
|
48697
49073
|
"imported": 0,
|
|
48698
49074
|
"source_path": str(source_path),
|
|
@@ -48703,6 +49079,8 @@ async def sync_provider_history(
|
|
|
48703
49079
|
timeline_seq = timeline_latest_seq
|
|
48704
49080
|
if next_cursor is not None:
|
|
48705
49081
|
caught_up = bool(next_cursor.pop("source_caught_up", True))
|
|
49082
|
+
if bootstrap_boundary is not None and not caught_up:
|
|
49083
|
+
next_cursor["bootstrap_timeline_seq"] = bootstrap_boundary
|
|
48706
49084
|
next_cursor["timeline_pending_through_seq"] = 0
|
|
48707
49085
|
next_cursor["timeline_pending_active"] = False
|
|
48708
49086
|
if not caught_up:
|
|
@@ -48718,50 +49096,59 @@ async def sync_provider_history(
|
|
|
48718
49096
|
caught_up=caught_up,
|
|
48719
49097
|
)
|
|
48720
49098
|
|
|
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.
|
|
49099
|
+
if background and fresh:
|
|
48733
49100
|
try:
|
|
48734
|
-
|
|
48735
|
-
sess,
|
|
48736
|
-
source_path,
|
|
48737
|
-
fresh,
|
|
48738
|
-
sync_checkpoint=checkpoint,
|
|
48739
|
-
)
|
|
49101
|
+
fresh = await prepare_imported_history_items(sess, source_path, fresh, checkpoint)
|
|
48740
49102
|
except CodexNativeHistoryProofUnavailable:
|
|
48741
|
-
|
|
48742
|
-
|
|
48743
|
-
|
|
48744
|
-
|
|
48745
|
-
|
|
48746
|
-
"
|
|
49103
|
+
return {"imported": 0, "deferred": True, "reason": "native_history_proof_unavailable"}
|
|
49104
|
+
async with history_commit_guard(sess, previous_cursor, timeline_stamp, stamp, background):
|
|
49105
|
+
result: dict[str, Any]
|
|
49106
|
+
if not fresh:
|
|
49107
|
+
result = {
|
|
49108
|
+
"imported": 0,
|
|
49109
|
+
"source_path": str(source_path),
|
|
49110
|
+
"message": "Already up to date with the provider transcript.",
|
|
48747
49111
|
}
|
|
48748
|
-
|
|
48749
|
-
|
|
48750
|
-
|
|
49112
|
+
else:
|
|
49113
|
+
# The imported batch is fsynced atomically together with its exact next
|
|
49114
|
+
# cursor. A cursor moves only after that authoritative commit; if the
|
|
49115
|
+
# registry save then fails, retry recovers the embedded checkpoint
|
|
49116
|
+
# without relying on ambiguous content alignment.
|
|
49117
|
+
try:
|
|
49118
|
+
result = await append_imported_history(
|
|
49119
|
+
sess,
|
|
49120
|
+
source_path,
|
|
49121
|
+
fresh,
|
|
49122
|
+
sync_checkpoint=checkpoint,
|
|
49123
|
+
_prepared=background,
|
|
49124
|
+
_expected_timeline_stamp=timeline_stamp,
|
|
49125
|
+
)
|
|
49126
|
+
except CodexNativeHistoryProofUnavailable:
|
|
49127
|
+
# Do not persist next_cursor: these source bytes have not yet been
|
|
49128
|
+
# proven against native history. A later explicit sync can retry.
|
|
49129
|
+
return {
|
|
49130
|
+
"imported": 0, "source_path": str(source_path), "deferred": True,
|
|
49131
|
+
"reason": "native_history_proof_unavailable",
|
|
49132
|
+
"message": "History reconciliation deferred; no messages were imported.",
|
|
49133
|
+
}
|
|
49134
|
+
if next_cursor is not None:
|
|
49135
|
+
if caught_up:
|
|
49136
|
+
timeline_seq = max(
|
|
49137
|
+
timeline_latest_seq,
|
|
49138
|
+
int(result.get("timeline_seq") or 0),
|
|
49139
|
+
)
|
|
49140
|
+
next_cursor["checkpoint_seq"] = max(
|
|
49141
|
+
int(next_cursor.get("checkpoint_seq") or 0),
|
|
48751
49142
|
timeline_latest_seq,
|
|
48752
49143
|
int(result.get("timeline_seq") or 0),
|
|
48753
49144
|
)
|
|
48754
|
-
|
|
48755
|
-
|
|
48756
|
-
|
|
48757
|
-
|
|
48758
|
-
|
|
48759
|
-
|
|
48760
|
-
|
|
48761
|
-
next_cursor,
|
|
48762
|
-
timeline_seq=timeline_seq,
|
|
48763
|
-
)
|
|
48764
|
-
return result
|
|
49145
|
+
await persist_history_sync_cursor(
|
|
49146
|
+
sess,
|
|
49147
|
+
next_cursor,
|
|
49148
|
+
timeline_seq=timeline_seq,
|
|
49149
|
+
)
|
|
49150
|
+
result["pending"] = not caught_up
|
|
49151
|
+
return result
|
|
48765
49152
|
|
|
48766
49153
|
|
|
48767
49154
|
def provider_history_source_stamp(sess: dict[str, Any]) -> list[Any] | None:
|
|
@@ -48843,73 +49230,115 @@ async def persist_history_sync_cursor(
|
|
|
48843
49230
|
return True
|
|
48844
49231
|
|
|
48845
49232
|
|
|
48846
|
-
|
|
48847
|
-
|
|
49233
|
+
HISTORY_MAINTENANCE_SLOTS = asyncio.Semaphore(2)
|
|
49234
|
+
HISTORY_MAINTENANCE_STATE: dict[str, dict[str, Any]] = {}
|
|
49235
|
+
HISTORY_MAINTENANCE_TASKS: dict[str, asyncio.Task] = {}
|
|
49236
|
+
HISTORY_PROJECTION_REVISIONS: dict[str, int] = {}
|
|
49237
|
+
|
|
49238
|
+
|
|
49239
|
+
def history_projection_notice(session_id: str) -> dict[str, Any]:
|
|
49240
|
+
return {"type": "history_projection_changed", "session_id": session_id,
|
|
49241
|
+
"revision": f"{SERVER_INSTANCE_ID}:{HISTORY_PROJECTION_REVISIONS.get(session_id, 0)}"}
|
|
49242
|
+
|
|
49243
|
+
|
|
49244
|
+
def history_maintenance_key(sess: dict[str, Any]) -> list[Any]:
|
|
49245
|
+
provider_id = session_provider_id(sess)
|
|
49246
|
+
cursor = normalized_history_sync_cursor(sess)
|
|
49247
|
+
source = (Path(cursor["source_path"]) if cursor else CODEX_ROLLOUT_PATH_CACHE.get(provider_id))
|
|
49248
|
+
return [2, sess.get("backend"), provider_id,
|
|
49249
|
+
list(history_file_stamp(events_path(str(sess["id"]))) or ()),
|
|
49250
|
+
str(source) if source else None,
|
|
49251
|
+
list(history_file_stamp(source) or ()) if source else None]
|
|
49252
|
+
|
|
49253
|
+
|
|
49254
|
+
def history_maintenance_state(session_id: str) -> dict[str, Any]:
|
|
49255
|
+
if session_id not in HISTORY_MAINTENANCE_STATE:
|
|
49256
|
+
try:
|
|
49257
|
+
raw = json.loads((events_path(session_id).parent / "history-maintenance.json").read_text())
|
|
49258
|
+
HISTORY_MAINTENANCE_STATE[session_id] = raw if isinstance(raw, dict) else {}
|
|
49259
|
+
except (OSError, ValueError):
|
|
49260
|
+
HISTORY_MAINTENANCE_STATE[session_id] = {}
|
|
49261
|
+
return HISTORY_MAINTENANCE_STATE[session_id]
|
|
48848
49262
|
|
|
49263
|
+
|
|
49264
|
+
def save_history_maintenance_state(session_id: str, value: dict[str, Any]) -> None:
|
|
49265
|
+
path = events_path(session_id).parent / "history-maintenance.json"
|
|
49266
|
+
if not path.parent.is_dir():
|
|
49267
|
+
return
|
|
49268
|
+
temporary = path.with_suffix(".tmp")
|
|
49269
|
+
temporary.write_text(json.dumps(value, separators=(",", ":")))
|
|
49270
|
+
temporary.replace(path)
|
|
49271
|
+
|
|
49272
|
+
|
|
49273
|
+
async def run_provider_history_sync(session_id: str) -> None:
|
|
49274
|
+
"""Prepare in the background; hold admission only for validated commit."""
|
|
49275
|
+
state = history_maintenance_state(session_id)
|
|
49276
|
+
key = None
|
|
49277
|
+
pending = False
|
|
48849
49278
|
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):
|
|
49279
|
+
async with HISTORY_MAINTENANCE_SLOTS:
|
|
48855
49280
|
sess = STORE.sessions.get(session_id)
|
|
48856
|
-
if (
|
|
48857
|
-
|
|
48858
|
-
|
|
48859
|
-
|
|
48860
|
-
):
|
|
49281
|
+
if (not sess or session_id in DELETING_SESSIONS
|
|
49282
|
+
or session_id in DELETED_SESSION_TOMBSTONES
|
|
49283
|
+
or session_id in BUSY_SESSIONS or ACTIVE.get(session_id) is not None
|
|
49284
|
+
or session_id in SERVER_MAINTENANCE_SESSIONS):
|
|
48861
49285
|
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
|
|
49286
|
+
key = history_maintenance_key(sess)
|
|
49287
|
+
state.update(status="running", reason=None)
|
|
48873
49288
|
await refresh_native_session_title(session_id)
|
|
48874
|
-
|
|
48875
|
-
|
|
48876
|
-
|
|
48877
|
-
|
|
48878
|
-
|
|
48879
|
-
|
|
48880
|
-
|
|
49289
|
+
repair_caches = (CLAUDE_METADATA_REPAIR_CACHE, CODEX_GOAL_HISTORY_REPAIR_CACHE,
|
|
49290
|
+
CODEX_NATIVE_HISTORY_REPAIR_CACHE)
|
|
49291
|
+
before_repair = tuple(cache.signature(session_id) for cache in repair_caches)
|
|
49292
|
+
result = await sync_provider_history(dict(sess), background=True)
|
|
49293
|
+
pending = bool(result.get("pending")) and not result.get("deferred")
|
|
49294
|
+
# Forward imports have already proved their own batch. Repair of
|
|
49295
|
+
# older displayed rows must not starve the remaining source suffix.
|
|
49296
|
+
repaired = None if pending else await asyncio.to_thread(prepare_provider_history_metadata_repair, session_id)
|
|
49297
|
+
if before_repair != tuple(cache.signature(session_id) for cache in repair_caches):
|
|
49298
|
+
HISTORY_PROJECTION_REVISIONS[session_id] = HISTORY_PROJECTION_REVISIONS.get(session_id, 0) + 1
|
|
49299
|
+
await HUB.broadcast(session_id, history_projection_notice(session_id))
|
|
49300
|
+
deferred = result.get("deferred") or repaired is False
|
|
49301
|
+
state.update(status="deferred" if deferred else "pending" if pending else "idle", deterministic=False,
|
|
49302
|
+
reason=result.get("reason") or ("native_history_proof_unavailable" if repaired is False else None))
|
|
49303
|
+
if result.get("imported"):
|
|
49304
|
+
logger.info("synced %d provider message(s) added outside session=%s", result["imported"], session_id)
|
|
48881
49305
|
except asyncio.CancelledError:
|
|
49306
|
+
state.update(status="deferred", reason="cancelled")
|
|
48882
49307
|
raise
|
|
48883
49308
|
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
|
-
)
|
|
49309
|
+
state.update(status="deferred", reason=type(exc).__name__, deterministic=isinstance(exc, ValueError))
|
|
49310
|
+
logger.warning("provider history sync deferred session=%s error=%s", session_id, type(exc).__name__)
|
|
48890
49311
|
finally:
|
|
49312
|
+
if key is not None:
|
|
49313
|
+
# A changing input retries on its next open; unchanged failures
|
|
49314
|
+
# survive reconnects and process restarts without a scan storm.
|
|
49315
|
+
current = STORE.sessions.get(session_id)
|
|
49316
|
+
state.update(key=history_maintenance_key(current) if current else key,
|
|
49317
|
+
retry_at=time.time() + (60 if state.get("status") == "deferred" else 1 if pending else 15))
|
|
49318
|
+
with suppress(OSError):
|
|
49319
|
+
await asyncio.to_thread(save_history_maintenance_state, session_id, dict(state))
|
|
48891
49320
|
HISTORY_SYNC_SCHEDULED.discard(session_id)
|
|
49321
|
+
HISTORY_MAINTENANCE_TASKS.pop(session_id, None)
|
|
49322
|
+
if pending and state.get("status") == "pending" and not SERVER_SHUTTING_DOWN:
|
|
49323
|
+
def continue_history():
|
|
49324
|
+
current = STORE.sessions.get(session_id)
|
|
49325
|
+
if current is not None:
|
|
49326
|
+
schedule_provider_history_sync(current)
|
|
49327
|
+
asyncio.get_running_loop().call_later(1.1, continue_history)
|
|
48892
49328
|
|
|
48893
49329
|
|
|
48894
49330
|
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
49331
|
session_id = str(sess.get("id") or "")
|
|
48903
|
-
if not session_id or session_id in HISTORY_SYNC_SCHEDULED:
|
|
49332
|
+
if not session_id or session_id in HISTORY_SYNC_SCHEDULED or not session_provider_id(sess):
|
|
48904
49333
|
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
49334
|
if session_id in BUSY_SESSIONS or ACTIVE.get(session_id) is not None:
|
|
48910
49335
|
return
|
|
49336
|
+
state = history_maintenance_state(session_id)
|
|
49337
|
+
if state.get("key") == history_maintenance_key(sess) and (
|
|
49338
|
+
state.get("deterministic") or time.time() < state.get("retry_at", 0)):
|
|
49339
|
+
return
|
|
48911
49340
|
HISTORY_SYNC_SCHEDULED.add(session_id)
|
|
48912
|
-
asyncio.create_task(run_provider_history_sync(session_id))
|
|
49341
|
+
HISTORY_MAINTENANCE_TASKS[session_id] = asyncio.create_task(run_provider_history_sync(session_id))
|
|
48913
49342
|
|
|
48914
49343
|
|
|
48915
49344
|
async def filter_codex_history_for_import(
|
|
@@ -48937,13 +49366,10 @@ async def filter_codex_history_for_import(
|
|
|
48937
49366
|
raise
|
|
48938
49367
|
|
|
48939
49368
|
|
|
48940
|
-
async def
|
|
48941
|
-
sess: dict[str, Any],
|
|
48942
|
-
|
|
48943
|
-
|
|
48944
|
-
*,
|
|
48945
|
-
sync_checkpoint: dict[str, Any] | None = None,
|
|
48946
|
-
) -> dict[str, Any]:
|
|
49369
|
+
async def prepare_imported_history_items(
|
|
49370
|
+
sess: dict[str, Any], source_path: Path, items: list[dict[str, Any]],
|
|
49371
|
+
sync_checkpoint: dict[str, Any] | None,
|
|
49372
|
+
) -> list[dict[str, Any]]:
|
|
48947
49373
|
session_id = str(sess["id"])
|
|
48948
49374
|
provider_id = str(session_provider_id(sess) or "")
|
|
48949
49375
|
backend = str(sess.get("backend") or DEFAULT_BACKEND).lower()
|
|
@@ -48970,6 +49396,23 @@ async def append_imported_history(
|
|
|
48970
49396
|
normalize_user=normalize_wake_user, normalize_full_user=normalize_full_wake_user,
|
|
48971
49397
|
normalize_assistant=clean_assistant_text,
|
|
48972
49398
|
)
|
|
49399
|
+
return items
|
|
49400
|
+
|
|
49401
|
+
|
|
49402
|
+
async def append_imported_history(
|
|
49403
|
+
sess: dict[str, Any],
|
|
49404
|
+
source_path: Path,
|
|
49405
|
+
items: list[dict[str, Any]],
|
|
49406
|
+
*,
|
|
49407
|
+
sync_checkpoint: dict[str, Any] | None = None,
|
|
49408
|
+
_prepared: bool = False,
|
|
49409
|
+
_expected_timeline_stamp: tuple | None = None,
|
|
49410
|
+
) -> dict[str, Any]:
|
|
49411
|
+
session_id = str(sess["id"])
|
|
49412
|
+
provider_id = str(session_provider_id(sess) or "")
|
|
49413
|
+
backend = str(sess.get("backend") or DEFAULT_BACKEND).lower()
|
|
49414
|
+
if not _prepared:
|
|
49415
|
+
items = await prepare_imported_history_items(sess, source_path, items, sync_checkpoint)
|
|
48973
49416
|
metadata_only = bool(items) and all(item.get("kind") == "interruption" or (
|
|
48974
49417
|
item.get("provider_history_repair") in {"source_proven_import", "source_proven_native_replay", "source_proven_assistant_replay"}
|
|
48975
49418
|
and item.get("text") == ""
|
|
@@ -49059,7 +49502,13 @@ async def append_imported_history(
|
|
|
49059
49502
|
imported_events.append(imported_history_terminal_event(
|
|
49060
49503
|
run_id, backend, metadata_only=metadata_only, source_timestamp=last_source_timestamp,
|
|
49061
49504
|
))
|
|
49062
|
-
|
|
49505
|
+
if _prepared:
|
|
49506
|
+
async with event_delivery_lock(session_id):
|
|
49507
|
+
if history_file_stamp(events_path(session_id)) != _expected_timeline_stamp:
|
|
49508
|
+
raise HistoryPreparationStale("timeline changed before history publication")
|
|
49509
|
+
committed = await append_durable_event_batch_locked(session_id, imported_events)
|
|
49510
|
+
else:
|
|
49511
|
+
committed = await append_durable_event_batch(session_id, imported_events)
|
|
49063
49512
|
if len(committed) != len(imported_events):
|
|
49064
49513
|
raise RuntimeError("history event batch was not fully persisted")
|
|
49065
49514
|
if backend == BACKEND_CODEX:
|
|
@@ -49197,6 +49646,12 @@ async def import_session_history(sess: dict[str, Any], *, force: bool = False, l
|
|
|
49197
49646
|
backend = (sess.get("backend") or DEFAULT_BACKEND).lower()
|
|
49198
49647
|
if not provider_id:
|
|
49199
49648
|
return {"imported": 0, "source_path": None, "message": "No provider session ID set."}
|
|
49649
|
+
if backend == BACKEND_GEMINI:
|
|
49650
|
+
if await asyncio.to_thread(lambda: any(event.get("type") == "history_imported" for event in iter_session_events(session_id))):
|
|
49651
|
+
return {"imported": 0, "source_path": None, "message": "Gemini initial text history already imported; automatic reconciliation is not supported."}
|
|
49652
|
+
from gemini_import import initial_history
|
|
49653
|
+
source, items = await asyncio.to_thread(initial_history, sys.modules[__name__], runner_env(), provider_id, str(sess.get("cwd") or ""), limit)
|
|
49654
|
+
return await append_imported_history(sess, source, items)
|
|
49200
49655
|
if backend == BACKEND_CURSOR:
|
|
49201
49656
|
# Initial public-text snapshot only. The private native store, not these
|
|
49202
49657
|
# display messages, supplies resume context. Cursor exports are rewritable
|
|
@@ -50234,9 +50689,10 @@ def public_session(sess: dict[str, Any], *, summary: bool = False) -> dict[str,
|
|
|
50234
50689
|
detail_fields = () if summary else (
|
|
50235
50690
|
"auto_title_enabled",
|
|
50236
50691
|
"system_prompt", "session_id", "claude_session_id", "codex_thread_id",
|
|
50237
|
-
"cursor_session_id", "opencode_session_id",
|
|
50692
|
+
"cursor_session_id", "opencode_session_id", "gemini_session_id",
|
|
50238
50693
|
"claude_permission_mode", "cursor_permission_mode",
|
|
50239
50694
|
"opencode_permission_mode",
|
|
50695
|
+
"gemini_permission_mode", "gemini_permission_modes",
|
|
50240
50696
|
"codex_approval_policy", "codex_sandbox_mode",
|
|
50241
50697
|
"codex_permission_profile", "codex_approvals_reviewer",
|
|
50242
50698
|
"provider_jobs_access",
|
|
@@ -50257,6 +50713,11 @@ def public_session(sess: dict[str, Any], *, summary: bool = False) -> dict[str,
|
|
|
50257
50713
|
"last_read_agent_event_seq", "last_read_agent_event_at", "manual_unread",
|
|
50258
50714
|
)
|
|
50259
50715
|
}
|
|
50716
|
+
maintenance = HISTORY_MAINTENANCE_STATE.get(str(sess.get("id") or ""), {})
|
|
50717
|
+
# Never-started maintenance adds no useful information to high-volume lists.
|
|
50718
|
+
# Once observed, retain idle/cleared fields so caches can retire old errors.
|
|
50719
|
+
if not summary or maintenance:
|
|
50720
|
+
public["history_maintenance"] = {key: maintenance.get(key) for key in ("status", "reason", "retry_at")}
|
|
50260
50721
|
# Omit only never-configured defaults from high-volume lists. A stored null
|
|
50261
50722
|
# is a clear tombstone and must survive summary/cache merges.
|
|
50262
50723
|
if not summary or "subagent_limit" in sess:
|
|
@@ -50280,7 +50741,14 @@ def public_session(sess: dict[str, Any], *, summary: bool = False) -> dict[str,
|
|
|
50280
50741
|
# Provider ids are intentionally omitted from summary responses, but the
|
|
50281
50742
|
# UI still needs the authoritative first-turn backend fence.
|
|
50282
50743
|
public["backend_locked"] = session_backend_locked(sess)
|
|
50744
|
+
# Capability, not proof of emptiness: dismiss-empty repeats the full proof.
|
|
50745
|
+
if sess.get("title") == "New chat" and not public["backend_locked"]:
|
|
50746
|
+
public["empty_chat_cleanup"] = "client-v1"
|
|
50283
50747
|
public["codex_provider"] = codex_provider.session_choice(sess.get("codex_provider"))
|
|
50748
|
+
if summary and "codex_goal" in sess:
|
|
50749
|
+
goal = sess.get("codex_goal")
|
|
50750
|
+
public["codex_goal_status"] = goal.get("status") if isinstance(goal, dict) else None
|
|
50751
|
+
public["codex_goal_updated_at"] = goal.get("updatedAt") if isinstance(goal, dict) else None
|
|
50284
50752
|
connection = sess.get("provider_connection") or "default"
|
|
50285
50753
|
# Keep legacy native summaries sparse, but retain a stored default as the
|
|
50286
50754
|
# tombstone that clears a previously selected custom API in client caches.
|
|
@@ -54177,26 +54645,29 @@ async def refresh_codex_app_server_binary(*, force: bool = False) -> None:
|
|
|
54177
54645
|
schedule_codex_manager_drain()
|
|
54178
54646
|
|
|
54179
54647
|
|
|
54180
|
-
async def refresh_codex_app_server_login(*, request_handoff: bool = False) ->
|
|
54648
|
+
async def refresh_codex_app_server_login(*, request_handoff: bool = False, source: str = "manager_lookup") -> bool:
|
|
54181
54649
|
"""Change normal-Codex admission; never kill a credential-owning process.
|
|
54182
54650
|
|
|
54183
|
-
|
|
54184
|
-
|
|
54185
|
-
|
|
54651
|
+
Ordinary lookups/catalog refreshes only observe native file revisions.
|
|
54652
|
+
Explicit native-admin login reload also covers opaque credential stores.
|
|
54653
|
+
This is not a token refresh or login operation.
|
|
54186
54654
|
"""
|
|
54187
54655
|
global CODEX_APP_SERVER_MANAGER, CODEX_LOGIN_REVISION
|
|
54188
54656
|
if CODEX_TRANSPORT == CODEX_TRANSPORT_EXEC or CODEX_AUTH_LOCK.locked() or CODEX_MANAGER_CLOSING:
|
|
54189
|
-
return
|
|
54657
|
+
return False
|
|
54658
|
+
# Sources are server-owned labels, never request text, account IDs or keys.
|
|
54659
|
+
if source not in {"manager_lookup", "turn_admission", "account_status", "catalog_refresh", "manual_login_reload"}:
|
|
54660
|
+
source = "manager_lookup"
|
|
54190
54661
|
async with CODEX_LOGIN_PROBE_LOCK:
|
|
54191
54662
|
if not await asyncio.to_thread(codex_auth.native_login_handoff_supported,
|
|
54192
54663
|
codex_app_server_env(), cwd=existing_cwd(DEFAULT_CWD)):
|
|
54193
|
-
return
|
|
54664
|
+
return False
|
|
54194
54665
|
revision = await asyncio.to_thread(codex_auth.native_login_revision,
|
|
54195
54666
|
codex_app_server_env(), cwd=existing_cwd(DEFAULT_CWD))
|
|
54196
54667
|
async with CODEX_GOALS_CONFIG_LOCK:
|
|
54197
54668
|
async with CODEX_APP_SERVER_MANAGER_LOCK:
|
|
54198
54669
|
if CODEX_APP_SERVER_MANAGER_CLEANUP_EPOCH is not None or CODEX_AUTH_LOCK.locked() or CODEX_MANAGER_CLOSING:
|
|
54199
|
-
return
|
|
54670
|
+
return False
|
|
54200
54671
|
observed_change = revision is not None and revision != CODEX_LOGIN_REVISION
|
|
54201
54672
|
CODEX_LOGIN_REVISION = revision
|
|
54202
54673
|
if observed_change or request_handoff:
|
|
@@ -54212,12 +54683,17 @@ async def refresh_codex_app_server_login(*, request_handoff: bool = False) -> No
|
|
|
54212
54683
|
if revision is not None and revision == previous:
|
|
54213
54684
|
manager._agentsdock_login_superseded = False
|
|
54214
54685
|
if changed or (request_handoff and revision is None):
|
|
54686
|
+
if getattr(manager, "_agentsdock_login_superseded", False) is not True:
|
|
54687
|
+
reason = "login_revision_changed" if changed else "explicit_reload_opaque_login"
|
|
54688
|
+
manager._agentsdock_login_handoff_source = source
|
|
54689
|
+
logger.info("Codex login handoff requested source=%s reason=%s manager=%x", source, reason, id(manager))
|
|
54215
54690
|
manager._agentsdock_login_superseded = True
|
|
54216
54691
|
if not any(manager is item for item in CODEX_RETIRED_APP_SERVER_MANAGERS):
|
|
54217
54692
|
CODEX_RETIRED_APP_SERVER_MANAGERS.append(manager)
|
|
54218
54693
|
if CODEX_APP_SERVER_MANAGER is manager:
|
|
54219
54694
|
CODEX_APP_SERVER_MANAGER = None
|
|
54220
54695
|
schedule_codex_manager_drain()
|
|
54696
|
+
return True
|
|
54221
54697
|
|
|
54222
54698
|
|
|
54223
54699
|
def codex_manager_session_blocker(manager: CodexAppServerManager, session_id: str, *, ignore_task=None, ignore_maintenance=False) -> str | None:
|
|
@@ -54320,6 +54796,17 @@ def watch_codex_provider_handoff_blockers(
|
|
|
54320
54796
|
|
|
54321
54797
|
async def release_idle_codex_manager_session(manager: CodexAppServerManager, session_id: str, *, ignore_task=None) -> bool:
|
|
54322
54798
|
"""Called under the session lifecycle lock; retain ownership on any doubt."""
|
|
54799
|
+
def retained(reason: str) -> bool:
|
|
54800
|
+
if getattr(manager, "_agentsdock_login_superseded", False) is True:
|
|
54801
|
+
reasons = getattr(manager, "_agentsdock_login_handoff_blockers", None)
|
|
54802
|
+
if reasons is None:
|
|
54803
|
+
reasons = manager._agentsdock_login_handoff_blockers = {}
|
|
54804
|
+
if reasons.get(session_id) != reason:
|
|
54805
|
+
reasons[session_id] = reason
|
|
54806
|
+
logger.info("Codex login handoff waiting source=%s manager=%x session=%s reason=%s",
|
|
54807
|
+
getattr(manager, "_agentsdock_login_handoff_source", "unknown"), id(manager), session_id, reason)
|
|
54808
|
+
return False
|
|
54809
|
+
|
|
54323
54810
|
def blocked(*, own_maintenance=False):
|
|
54324
54811
|
return (codex_manager_session_busy(manager, session_id, ignore_task=ignore_task,
|
|
54325
54812
|
ignore_maintenance=own_maintenance)
|
|
@@ -54360,7 +54847,7 @@ async def release_idle_codex_manager_session(manager: CodexAppServerManager, ses
|
|
|
54360
54847
|
sum(not task.done() for task in manager.client._server_request_tasks.values()),
|
|
54361
54848
|
error_type,
|
|
54362
54849
|
)
|
|
54363
|
-
return
|
|
54850
|
+
return retained(reasons[session_id])
|
|
54364
54851
|
|
|
54365
54852
|
if blocked():
|
|
54366
54853
|
return await defer("runtime_borrowers")
|
|
@@ -54440,6 +54927,10 @@ async def release_idle_codex_manager_session(manager: CodexAppServerManager, ses
|
|
|
54440
54927
|
if CODEX_SESSION_APP_SERVER_MANAGERS.get(session_id) is manager:
|
|
54441
54928
|
CODEX_SESSION_APP_SERVER_MANAGERS.pop(session_id, None)
|
|
54442
54929
|
CODEX_GOAL_SYNC_GENERATIONS.pop(session_id, None)
|
|
54930
|
+
if getattr(manager, "_agentsdock_login_superseded", False) is True:
|
|
54931
|
+
getattr(manager, "_agentsdock_login_handoff_blockers", {}).pop(session_id, None)
|
|
54932
|
+
logger.info("Codex login handoff released source=%s manager=%x session=%s",
|
|
54933
|
+
getattr(manager, "_agentsdock_login_handoff_source", "unknown"), id(manager), session_id)
|
|
54443
54934
|
return True
|
|
54444
54935
|
except Exception as exc:
|
|
54445
54936
|
logger.debug("Codex handoff retained an unverified idle owner", exc_info=True)
|
|
@@ -54459,7 +54950,7 @@ async def prepare_codex_login_turn(session: dict[str, Any]) -> None:
|
|
|
54459
54950
|
session.pop("_codex_provider_pending", None)
|
|
54460
54951
|
if codex_provider.session_choice(session.get("codex_provider")) == "custom":
|
|
54461
54952
|
return
|
|
54462
|
-
await refresh_codex_app_server_login()
|
|
54953
|
+
await refresh_codex_app_server_login(source="turn_admission")
|
|
54463
54954
|
session_id = str(session.get("id") or "")
|
|
54464
54955
|
manager = existing_codex_app_server_manager(session)
|
|
54465
54956
|
if getattr(manager, "_agentsdock_login_superseded", False) is not True:
|
|
@@ -54618,9 +55109,8 @@ async def codex_app_server_manager(sess: dict[str, Any] | None = None, *, allow_
|
|
|
54618
55109
|
selected = CODEX_PROVIDER_STORE.for_session(sess, include_key=True) if sess is not None else None
|
|
54619
55110
|
revision = selected["credential_id"] if selected else None
|
|
54620
55111
|
session_id = str((sess or {}).get("id") or "")
|
|
54621
|
-
#
|
|
54622
|
-
#
|
|
54623
|
-
# no caller can retain/create the old generation halfway through a toggle.
|
|
55112
|
+
# Manager lookup shares the provider reconfiguration barrier, including the
|
|
55113
|
+
# fast path, so a sign-in reload cannot race a retained/created generation.
|
|
54624
55114
|
async with CODEX_GOALS_CONFIG_LOCK:
|
|
54625
55115
|
ensure_provider_manager_factory_admission(codex=True)
|
|
54626
55116
|
manager = existing_codex_app_server_manager(sess) if sess is not None else None
|
|
@@ -57216,6 +57706,9 @@ def resolve_cursor_executable(*, require_compatible: bool = True) -> str | None:
|
|
|
57216
57706
|
|
|
57217
57707
|
|
|
57218
57708
|
def runtime_executable(backend: str) -> str:
|
|
57709
|
+
if backend == BACKEND_GEMINI:
|
|
57710
|
+
from gemini_runtime import resolve_executable
|
|
57711
|
+
return resolve_executable(runner_env()) or "gemini"
|
|
57219
57712
|
if backend == BACKEND_CLAUDE:
|
|
57220
57713
|
return CLAUDE_BIN
|
|
57221
57714
|
if backend == BACKEND_OPENCODE:
|
|
@@ -57234,6 +57727,8 @@ def runtime_executable(backend: str) -> str:
|
|
|
57234
57727
|
|
|
57235
57728
|
|
|
57236
57729
|
def runtime_display_name(backend: str) -> str:
|
|
57730
|
+
if backend == BACKEND_GEMINI:
|
|
57731
|
+
return "Gemini CLI"
|
|
57237
57732
|
if backend == BACKEND_CLAUDE:
|
|
57238
57733
|
return "Claude Code"
|
|
57239
57734
|
if backend == BACKEND_CURSOR:
|
|
@@ -57463,6 +57958,9 @@ def cursor_auth_failure_confirmed(executable: str) -> bool:
|
|
|
57463
57958
|
|
|
57464
57959
|
|
|
57465
57960
|
def probe_runtime(backend: str) -> dict[str, Any]:
|
|
57961
|
+
if backend == BACKEND_GEMINI:
|
|
57962
|
+
from gemini_runtime import probe
|
|
57963
|
+
return probe(runner_env(), timeout=runtime_probe_timeout(8.0))
|
|
57466
57964
|
if backend == BACKEND_OPENCODE:
|
|
57467
57965
|
(
|
|
57468
57966
|
resolved,
|
|
@@ -57686,7 +58184,14 @@ def probe_runtime(backend: str) -> dict[str, Any]:
|
|
|
57686
58184
|
|
|
57687
58185
|
combined = f"{auth_result.stdout}\n{auth_result.stderr}"
|
|
57688
58186
|
if auth_result.returncode == 0:
|
|
57689
|
-
|
|
58187
|
+
result = runtime_diagnostic_payload(backend, "ready", installed=True, authenticated=True, version=version)
|
|
58188
|
+
if backend == BACKEND_CODEX:
|
|
58189
|
+
# Retain only the kind, never login-status output (which may show a
|
|
58190
|
+
# masked API key). No additional auth/refresh subprocess is needed.
|
|
58191
|
+
text = combined.lower()
|
|
58192
|
+
result["_codex_auth_mode"] = "apiKey" if "api key" in text else "chatgpt" if "chatgpt" in text else "unknown"
|
|
58193
|
+
result["_codex_auth_mode_checked_at_epoch"] = result["checked_at_epoch"]
|
|
58194
|
+
return result
|
|
57690
58195
|
if auth_failure_text(combined):
|
|
57691
58196
|
return runtime_diagnostic_payload(backend, "unauthenticated", installed=True, authenticated=False, version=version)
|
|
57692
58197
|
return runtime_diagnostic_payload(backend, "error", installed=True, authenticated=None, version=version)
|
|
@@ -57709,10 +58214,14 @@ def store_runtime_diagnostic(diagnostic: dict[str, Any], *, preserve_last_error:
|
|
|
57709
58214
|
|
|
57710
58215
|
def expire_codex_login_diagnostic() -> None:
|
|
57711
58216
|
"""A login hint requires new native evidence, never reuse a cached denial."""
|
|
58217
|
+
global CODEX_MODEL_MIGRATION_TARGET
|
|
57712
58218
|
with RUNTIME_DIAGNOSTICS_LOCK:
|
|
58219
|
+
CODEX_MODEL_MIGRATION_TARGET = "gpt-6-luna"
|
|
57713
58220
|
current = dict(RUNTIME_DIAGNOSTICS.get(BACKEND_CODEX) or runtime_diagnostic_payload(
|
|
57714
58221
|
BACKEND_CODEX, "unknown", installed=None, authenticated=None))
|
|
57715
58222
|
current["checked_at_epoch"] = 0.0
|
|
58223
|
+
current.pop("_codex_auth_mode", None)
|
|
58224
|
+
current.pop("_codex_auth_mode_checked_at_epoch", None)
|
|
57716
58225
|
store_runtime_diagnostic(current)
|
|
57717
58226
|
|
|
57718
58227
|
|
|
@@ -57721,6 +58230,8 @@ def runtime_diagnostic(backend: str, *, force: bool = False) -> dict[str, Any]:
|
|
|
57721
58230
|
cached = dict(RUNTIME_DIAGNOSTICS.get(backend) or {})
|
|
57722
58231
|
generation = RUNTIME_DIAGNOSTIC_GENERATIONS.get(backend, 0)
|
|
57723
58232
|
checked_at = cached.get("_installation_checked_at_epoch", cached.get("checked_at_epoch"))
|
|
58233
|
+
if backend == BACKEND_CODEX and cached.get("_codex_auth_mode_checked_at_epoch") is not None:
|
|
58234
|
+
checked_at = min(checked_at or 0, cached["_codex_auth_mode_checked_at_epoch"])
|
|
57724
58235
|
if not force and isinstance(checked_at, (int, float)) and time.time() - checked_at < RUNTIME_DIAGNOSTIC_TTL_SECONDS:
|
|
57725
58236
|
return cached
|
|
57726
58237
|
try:
|
|
@@ -57865,14 +58376,34 @@ def record_runtime_success(backend: str) -> None:
|
|
|
57865
58376
|
version=previous.get("version"),
|
|
57866
58377
|
executable=(
|
|
57867
58378
|
str(previous.get("_executable") or "") or None
|
|
57868
|
-
if backend in (BACKEND_CURSOR, BACKEND_OPENCODE)
|
|
58379
|
+
if backend in (BACKEND_CURSOR, BACKEND_OPENCODE, BACKEND_GEMINI)
|
|
57869
58380
|
else None
|
|
57870
58381
|
),
|
|
57871
58382
|
)
|
|
58383
|
+
if backend == BACKEND_CODEX and previous.get("_codex_auth_mode"):
|
|
58384
|
+
# A successful turn does not establish which login kind was used.
|
|
58385
|
+
# Preserve the existing observation without extending its lifetime.
|
|
58386
|
+
current["_codex_auth_mode"] = previous["_codex_auth_mode"]
|
|
58387
|
+
current["_codex_auth_mode_checked_at_epoch"] = previous.get(
|
|
58388
|
+
"_codex_auth_mode_checked_at_epoch", previous.get("checked_at_epoch", 0),
|
|
58389
|
+
)
|
|
57872
58390
|
store_runtime_diagnostic(current, preserve_last_error=False)
|
|
57873
58391
|
|
|
57874
58392
|
|
|
57875
58393
|
async def ensure_runtime_available(backend: str, *, session: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
58394
|
+
if backend == BACKEND_GEMINI:
|
|
58395
|
+
from gemini_runtime import probe, API_ONLY_MESSAGE
|
|
58396
|
+
if (session or {}).get("provider_connection") != "custom":
|
|
58397
|
+
raise HTTPException(409, API_ONLY_MESSAGE)
|
|
58398
|
+
diagnostic = await asyncio.to_thread(probe, runner_env())
|
|
58399
|
+
store_runtime_diagnostic(diagnostic)
|
|
58400
|
+
if diagnostic.get("status") != "ready":
|
|
58401
|
+
raise HTTPException(503, diagnostic.get("message"))
|
|
58402
|
+
if (session or {}).get("provider_connection") == "custom":
|
|
58403
|
+
from gemini_api import default_model
|
|
58404
|
+
selected = PROVIDER_CONNECTION_STORE.for_session(session)
|
|
58405
|
+
provider_connections.require_model({**selected, "model": default_model(selected)})
|
|
58406
|
+
return diagnostic
|
|
57876
58407
|
diagnostic = await asyncio.to_thread(runtime_diagnostic, backend)
|
|
57877
58408
|
if (session or {}).get("provider_connection") == "custom":
|
|
57878
58409
|
selected = PROVIDER_CONNECTION_STORE.for_session(session)
|
|
@@ -58037,6 +58568,26 @@ def codex_default_service_tier(model: str) -> str:
|
|
|
58037
58568
|
return CODEX_FALLBACK_SERVICE_TIERS.get(str(model or "").strip(), "")
|
|
58038
58569
|
|
|
58039
58570
|
|
|
58571
|
+
def codex_retired_model_replacements() -> dict[str, str]:
|
|
58572
|
+
"""Scope retirement to native ChatGPT auth, never API/custom endpoints."""
|
|
58573
|
+
with RUNTIME_DIAGNOSTICS_LOCK:
|
|
58574
|
+
diagnostic = RUNTIME_DIAGNOSTICS.get(BACKEND_CODEX) or {}
|
|
58575
|
+
checked_at = diagnostic.get("_codex_auth_mode_checked_at_epoch", diagnostic.get("checked_at_epoch")) or 0
|
|
58576
|
+
observed = (str(diagnostic.get("_codex_auth_mode") or "")
|
|
58577
|
+
if time.time() - checked_at < RUNTIME_DIAGNOSTIC_TTL_SECONDS else "")
|
|
58578
|
+
replacement = CODEX_MODEL_MIGRATION_TARGET
|
|
58579
|
+
mode = codex_auth.native_model_auth_mode(
|
|
58580
|
+
codex_app_server_env(), cwd=existing_cwd(DEFAULT_CWD), observed_mode=observed,
|
|
58581
|
+
)
|
|
58582
|
+
if mode != "chatgpt":
|
|
58583
|
+
return {}
|
|
58584
|
+
return {model: replacement for model in sorted(codex_auth.CHATGPT_RETIRED_MODELS)}
|
|
58585
|
+
|
|
58586
|
+
|
|
58587
|
+
def codex_available_model(model: str) -> str:
|
|
58588
|
+
return codex_retired_model_replacements().get(model, model)
|
|
58589
|
+
|
|
58590
|
+
|
|
58040
58591
|
# The Codex app-server's thread/start and turn-override JSON-RPC params only
|
|
58041
58592
|
# accept a narrower serviceTier enum ("fast"/"flex") than the canonical
|
|
58042
58593
|
# service_tier values used for `codex exec` CLI config overrides (see
|
|
@@ -58053,6 +58604,7 @@ def codex_app_server_service_tier(service_tier: str) -> str:
|
|
|
58053
58604
|
|
|
58054
58605
|
|
|
58055
58606
|
def discover_codex_catalog() -> dict[str, Any]:
|
|
58607
|
+
global CODEX_MODEL_MIGRATION_TARGET
|
|
58056
58608
|
models: list[dict[str, Any]] = []
|
|
58057
58609
|
model_options: list[dict[str, Any]] = []
|
|
58058
58610
|
effort_options: list[dict[str, Any]] = []
|
|
@@ -58075,16 +58627,29 @@ def discover_codex_catalog() -> dict[str, Any]:
|
|
|
58075
58627
|
model_source = f"{model_source} failed"
|
|
58076
58628
|
effort_source = f"{effort_source} failed"
|
|
58077
58629
|
|
|
58630
|
+
replacements = codex_retired_model_replacements()
|
|
58078
58631
|
visible_models = [
|
|
58079
58632
|
model for model in models
|
|
58080
58633
|
if str(model.get("visibility") or "list") == "list" and model.get("supported_in_api", True) is not False
|
|
58634
|
+
and str(model.get("slug") or model.get("id") or "").strip() not in replacements
|
|
58081
58635
|
]
|
|
58082
58636
|
visible_models.sort(key=runtime_priority)
|
|
58637
|
+
if replacements and visible_models:
|
|
58638
|
+
# Use an available configured default, then the native default/priority.
|
|
58639
|
+
# The fallback is only needed when the CLI cannot return a catalog.
|
|
58640
|
+
slugs = [str(entry.get("slug") or entry.get("id") or "").strip() for entry in visible_models]
|
|
58641
|
+
preferred = next((slug for slug in (configured_model, CODEX_DEFAULT_MODEL) if slug in slugs), None)
|
|
58642
|
+
if preferred is None:
|
|
58643
|
+
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])
|
|
58644
|
+
if preferred:
|
|
58645
|
+
with RUNTIME_DIAGNOSTICS_LOCK:
|
|
58646
|
+
CODEX_MODEL_MIGRATION_TARGET = preferred
|
|
58647
|
+
replacements = {model: preferred for model in replacements}
|
|
58083
58648
|
# The selectable "Server default" must describe the exact settings that
|
|
58084
58649
|
# codex_runtime_settings() launches for a chat with no explicit override.
|
|
58085
58650
|
# The CLI's discovered list can omit a configured/custom default (or a
|
|
58086
58651
|
# legacy fallback), so the first discovered model is not a safe proxy.
|
|
58087
|
-
default_model = configured_model or CODEX_DEFAULT_MODEL
|
|
58652
|
+
default_model = replacements.get(configured_model or CODEX_DEFAULT_MODEL, configured_model or CODEX_DEFAULT_MODEL)
|
|
58088
58653
|
default_effort = clamp_codex_runtime_effort(
|
|
58089
58654
|
default_model,
|
|
58090
58655
|
configured_effort or CODEX_DEFAULT_EFFORT,
|
|
@@ -58131,6 +58696,8 @@ def discover_codex_catalog() -> dict[str, Any]:
|
|
|
58131
58696
|
|
|
58132
58697
|
if not model_options:
|
|
58133
58698
|
for slug, label in CODEX_FALLBACK_MODELS:
|
|
58699
|
+
if slug in replacements:
|
|
58700
|
+
continue
|
|
58134
58701
|
fallback_efforts = [
|
|
58135
58702
|
runtime_option(effort, title_effort_label(effort))
|
|
58136
58703
|
for effort in CODEX_FALLBACK_MODEL_EFFORTS.get(slug, CODEX_FALLBACK_EFFORTS)
|
|
@@ -58140,7 +58707,7 @@ def discover_codex_catalog() -> dict[str, Any]:
|
|
|
58140
58707
|
model_options.append(runtime_option(slug, label, efforts=fallback_efforts, service_tier=service_tier or None))
|
|
58141
58708
|
if not effort_options:
|
|
58142
58709
|
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):
|
|
58710
|
+
if configured_model and configured_model not in replacements and not any(option.get("value") == configured_model for option in model_options):
|
|
58144
58711
|
model_options.insert(0, runtime_option(configured_model, title_model_label(configured_model)))
|
|
58145
58712
|
if configured_effort and not any(option.get("value") == configured_effort for option in effort_options):
|
|
58146
58713
|
effort_options.append(runtime_option(configured_effort, title_effort_label(configured_effort)))
|
|
@@ -58163,6 +58730,7 @@ def discover_codex_catalog() -> dict[str, Any]:
|
|
|
58163
58730
|
"default_model": default_model or None,
|
|
58164
58731
|
"default_effort": default_effort or None,
|
|
58165
58732
|
"default_service_tier": default_service_tier or None,
|
|
58733
|
+
"model_replacements": replacements,
|
|
58166
58734
|
}
|
|
58167
58735
|
|
|
58168
58736
|
|
|
@@ -58658,6 +59226,13 @@ def discover_runtime_backend_catalog(backend: str, *, force_runtime_probe: bool
|
|
|
58658
59226
|
diagnostic = runtime_diagnostic(backend, force=force_runtime_probe)
|
|
58659
59227
|
ready = diagnostic.get("status") == "ready"
|
|
58660
59228
|
executable = str(diagnostic.get("_executable") or "") or None
|
|
59229
|
+
if backend == BACKEND_GEMINI:
|
|
59230
|
+
return {"models": [], "default_model": None,
|
|
59231
|
+
"efforts": [], "default_effort": None, "effort_source": "none",
|
|
59232
|
+
"model_source": "Configure a Gemini API connection in My Agents",
|
|
59233
|
+
"available": False, "diagnostic": public_runtime_diagnostic(diagnostic),
|
|
59234
|
+
"native_credentials_present": False,
|
|
59235
|
+
"custom_provider": PROVIDER_CONNECTION_STORE.catalog(backend, installed=ready)}
|
|
58661
59236
|
if backend == BACKEND_CLAUDE:
|
|
58662
59237
|
catalog = parse_claude_help_catalog()
|
|
58663
59238
|
elif backend == BACKEND_CODEX:
|
|
@@ -58750,7 +59325,7 @@ def codex_runtime_settings(sess: dict[str, Any]) -> tuple[str, str, str]:
|
|
|
58750
59325
|
catalog = CODEX_PROVIDER_STORE.cached_catalog(selected)
|
|
58751
59326
|
return model, codex_provider.runtime_effort(selected, catalog, sess.get("effort")), ""
|
|
58752
59327
|
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()
|
|
59328
|
+
model = codex_available_model(str(sess.get("model") or configured_model or CODEX_DEFAULT_MODEL).strip())
|
|
58754
59329
|
effort = clamp_codex_runtime_effort(
|
|
58755
59330
|
model,
|
|
58756
59331
|
sess.get("effort") or configured_effort or CODEX_DEFAULT_EFFORT,
|
|
@@ -58759,6 +59334,39 @@ def codex_runtime_settings(sess: dict[str, Any]) -> tuple[str, str, str]:
|
|
|
58759
59334
|
return model, effort, service_tier
|
|
58760
59335
|
|
|
58761
59336
|
|
|
59337
|
+
async def migrate_retired_codex_selection(
|
|
59338
|
+
session_id: str, sess: dict[str, Any], *, standalone: bool = False,
|
|
59339
|
+
previous_model: str | None = None,
|
|
59340
|
+
) -> dict[str, Any]:
|
|
59341
|
+
"""Migrate future work under the existing admission lock; retain history.
|
|
59342
|
+
|
|
59343
|
+
No native transcript edits, forks, auth changes, or retries. Native
|
|
59344
|
+
compaction/recovery remains Codex-owned and can still fail independently.
|
|
59345
|
+
"""
|
|
59346
|
+
if sess.get("backend") != BACKEND_CODEX or sess.get("codex_provider") == "custom":
|
|
59347
|
+
return sess
|
|
59348
|
+
configured_model, _effort, _tier = codex_user_config_defaults()
|
|
59349
|
+
previous = str(previous_model or sess.get("model") or configured_model or CODEX_DEFAULT_MODEL).strip()
|
|
59350
|
+
replacement = codex_available_model(previous)
|
|
59351
|
+
if previous == replacement:
|
|
59352
|
+
return sess
|
|
59353
|
+
patch = {"model": replacement, "effort": clamp_codex_runtime_effort(replacement, sess.get("effort") or _effort or CODEX_DEFAULT_EFFORT)}
|
|
59354
|
+
if standalone:
|
|
59355
|
+
migrated = {**sess, **patch}
|
|
59356
|
+
elif all(sess.get(key) == value for key, value in patch.items()):
|
|
59357
|
+
migrated = sess
|
|
59358
|
+
else:
|
|
59359
|
+
migrated = await STORE.update(session_id, patch)
|
|
59360
|
+
await append_event(session_id, "runtime_model_migrated", {
|
|
59361
|
+
"backend": BACKEND_CODEX, "previous_model": previous, "model": replacement,
|
|
59362
|
+
"standalone": standalone,
|
|
59363
|
+
"message": (f"{previous} is no longer available with ChatGPT sign-in. "
|
|
59364
|
+
f"This {'scheduled run' if standalone else 'chat'} will use {replacement}. "
|
|
59365
|
+
"Conversation history is unchanged."),
|
|
59366
|
+
})
|
|
59367
|
+
return migrated
|
|
59368
|
+
|
|
59369
|
+
|
|
58762
59370
|
def codex_goal_time_budget_remaining(sess: dict[str, Any]) -> float | None:
|
|
58763
59371
|
goal = sess.get("codex_goal")
|
|
58764
59372
|
limit = sess.get("codex_goal_time_budget_seconds")
|
|
@@ -60026,7 +60634,7 @@ async def discover_session_provider_commands(
|
|
|
60026
60634
|
|
|
60027
60635
|
backend = str(session.get("backend") or DEFAULT_BACKEND).strip().lower()
|
|
60028
60636
|
requested_cwd = str(session.get("cwd") or DEFAULT_CWD)
|
|
60029
|
-
if backend
|
|
60637
|
+
if backend in {BACKEND_OPENCODE, BACKEND_GEMINI}:
|
|
60030
60638
|
try:
|
|
60031
60639
|
cwd = validated_opencode_cwd(requested_cwd)
|
|
60032
60640
|
except HTTPException:
|
|
@@ -60040,7 +60648,7 @@ async def discover_session_provider_commands(
|
|
|
60040
60648
|
support = provider_command_support(
|
|
60041
60649
|
False,
|
|
60042
60650
|
"unavailable",
|
|
60043
|
-
"OpenCode local skills require the chat working directory to be available.",
|
|
60651
|
+
f"{'Gemini' if backend == BACKEND_GEMINI else 'OpenCode'} local skills require the chat working directory to be available.",
|
|
60044
60652
|
)
|
|
60045
60653
|
return provider_command_snapshot(fallback_empty, support), fallback_empty
|
|
60046
60654
|
else:
|
|
@@ -60080,7 +60688,7 @@ async def discover_session_provider_commands(
|
|
|
60080
60688
|
provider_command_snapshot(fallback_empty, support),
|
|
60081
60689
|
fallback_empty,
|
|
60082
60690
|
)
|
|
60083
|
-
elif backend
|
|
60691
|
+
elif backend in {BACKEND_OPENCODE, BACKEND_GEMINI}:
|
|
60084
60692
|
pass
|
|
60085
60693
|
else:
|
|
60086
60694
|
support = provider_command_support(
|
|
@@ -60174,6 +60782,26 @@ async def discover_session_provider_commands(
|
|
|
60174
60782
|
f"Showing the first {MAX_PROVIDER_COMMANDS} provider commands."
|
|
60175
60783
|
)
|
|
60176
60784
|
return provider_command_snapshot(inventory, support), inventory
|
|
60785
|
+
if backend == BACKEND_GEMINI:
|
|
60786
|
+
from gemini_runtime import build_environment, list_native_skills, resolve_executable, GeminiConfigurationError
|
|
60787
|
+
def discover_gemini():
|
|
60788
|
+
connection = PROVIDER_CONNECTION_STORE.for_session(session)
|
|
60789
|
+
profile = PROVIDER_CONNECTION_STORE.root / ("gemini-runtime-" + connection["credential_id"]) if connection else None
|
|
60790
|
+
env = build_environment(runner_env(), cwd=cwd, api_home=profile,
|
|
60791
|
+
api_key=connection.get("api_key") if connection else None,
|
|
60792
|
+
base_url=connection.get("base_url") if connection else None,
|
|
60793
|
+
native_history=bool(session.get("gemini_native_history")))
|
|
60794
|
+
executable = resolve_executable(env)
|
|
60795
|
+
if not executable:
|
|
60796
|
+
raise GeminiConfigurationError("Install Gemini CLI to discover its skills.")
|
|
60797
|
+
return gemini_provider_skill_inventory(list_native_skills(executable, env, cwd),
|
|
60798
|
+
cwd=cwd, selector_secret=identity, binding_context=session_id)
|
|
60799
|
+
try:
|
|
60800
|
+
inventory = await asyncio.to_thread(discover_gemini)
|
|
60801
|
+
except (GeminiConfigurationError, ProviderCommandDiscoveryError, HTTPException, OSError, ValueError):
|
|
60802
|
+
return provider_command_snapshot(empty, provider_command_support(False, "unavailable",
|
|
60803
|
+
"Gemini skills could not be read. Check gemini skills list --all in this workspace and refresh.")), empty
|
|
60804
|
+
return provider_command_snapshot(inventory, provider_command_support(True, "native_activation_request")), inventory
|
|
60177
60805
|
if backend == BACKEND_OPENCODE:
|
|
60178
60806
|
try:
|
|
60179
60807
|
env = runner_env()
|
|
@@ -60251,6 +60879,8 @@ async def resolve_provider_command_selection(
|
|
|
60251
60879
|
if backend == BACKEND_CLAUDE
|
|
60252
60880
|
else OPENCODE_PROVIDER_COMMANDS_CLIENT_CAPABILITY
|
|
60253
60881
|
if backend == BACKEND_OPENCODE
|
|
60882
|
+
else GEMINI_CLIENT_CAPABILITY
|
|
60883
|
+
if backend == BACKEND_GEMINI
|
|
60254
60884
|
else None
|
|
60255
60885
|
)
|
|
60256
60886
|
if required_capability is None:
|
|
@@ -60688,7 +61318,9 @@ async def update_reasoning_summary_stream(
|
|
|
60688
61318
|
phase: str = "summary",
|
|
60689
61319
|
) -> None:
|
|
60690
61320
|
"""Keep provider-exposed summary and plaintext reasoning channels distinct."""
|
|
60691
|
-
if phase not in {"summary", "reasoning"}:
|
|
61321
|
+
if phase not in {"summary", "reasoning", "assistant"}:
|
|
61322
|
+
return
|
|
61323
|
+
if phase == "assistant" and (metadata or {}).get("backend") != "gemini":
|
|
60692
61324
|
return
|
|
60693
61325
|
index = params.get("contentIndex" if phase == "reasoning" else "summaryIndex", 0)
|
|
60694
61326
|
delta = params.get("delta", "")
|
|
@@ -60708,7 +61340,7 @@ async def update_reasoning_summary_stream(
|
|
|
60708
61340
|
after_seq = await asyncio.to_thread(last_event_seq_from_file, events_path(session_id))
|
|
60709
61341
|
state = {"parts": {}, "item": {
|
|
60710
61342
|
**dict(metadata or {}), "run_id": run_id, "item_id": item_id,
|
|
60711
|
-
"backend": BACKEND_CODEX, "phase": phase, "text": "",
|
|
61343
|
+
"backend": (metadata or {}).get("backend", BACKEND_CODEX), "phase": phase, "text": "",
|
|
60712
61344
|
"ts": now_iso(), "after_seq": after_seq,
|
|
60713
61345
|
}}
|
|
60714
61346
|
streams[(run_id, item_id, phase)] = state
|
|
@@ -68907,6 +69539,35 @@ async def retain_codex_goal_run_owner(
|
|
|
68907
69539
|
return True
|
|
68908
69540
|
|
|
68909
69541
|
|
|
69542
|
+
def codex_native_archive_rejection(exc: BaseException, thread_id: str) -> bool:
|
|
69543
|
+
"""Only the owning native resume RPC can report this state, not chat text."""
|
|
69544
|
+
return bool(
|
|
69545
|
+
thread_id and isinstance(exc, CodexAppServerRequestError)
|
|
69546
|
+
and exc.method == "thread/resume" and isinstance(exc.error, dict)
|
|
69547
|
+
and exc.error.get("message") == (
|
|
69548
|
+
f"session {thread_id} is archived. Run `codex unarchive {thread_id}` to unarchive it first."
|
|
69549
|
+
)
|
|
69550
|
+
)
|
|
69551
|
+
|
|
69552
|
+
|
|
69553
|
+
async def archive_rejected_codex_session(session_id: str, thread_id: str, run_id: str) -> None:
|
|
69554
|
+
"""Mirror a confirmed native archive through the normal local archive path.
|
|
69555
|
+
|
|
69556
|
+
This never archives/unarchives/deletes the native thread. It retires local
|
|
69557
|
+
routes and jobs exactly like the user's Archive action, preserving history.
|
|
69558
|
+
Identity is stable while this run owns the chat; ignore stale/deleted runs.
|
|
69559
|
+
"""
|
|
69560
|
+
current = STORE.sessions.get(session_id)
|
|
69561
|
+
active = ACTIVE.get(session_id) or {}
|
|
69562
|
+
if (not current or current.get("archived") or session_id in DELETING_SESSIONS
|
|
69563
|
+
or current.get("backend") != BACKEND_CODEX
|
|
69564
|
+
or str(session_provider_id(current) or "") != thread_id
|
|
69565
|
+
or active.get("run_id") != run_id):
|
|
69566
|
+
return
|
|
69567
|
+
await _update_session(session_id, UpdateSessionRequest(archived=True),
|
|
69568
|
+
native_archive_owner=(thread_id, run_id))
|
|
69569
|
+
|
|
69570
|
+
|
|
68910
69571
|
async def run_codex_app_server(
|
|
68911
69572
|
session_id: str,
|
|
68912
69573
|
run_id: str,
|
|
@@ -71330,6 +71991,7 @@ async def run_codex_app_server(
|
|
|
71330
71991
|
thread_pinned = False
|
|
71331
71992
|
can_fallback = (
|
|
71332
71993
|
allow_exec_fallback
|
|
71994
|
+
and not codex_native_archive_rejection(exc, str(session_provider_id(sess) or ""))
|
|
71333
71995
|
and codex_provider.session_choice(sess.get("codex_provider")) == "default"
|
|
71334
71996
|
and provider_command is None
|
|
71335
71997
|
and not stop_requested
|
|
@@ -71394,6 +72056,15 @@ async def run_codex_app_server(
|
|
|
71394
72056
|
**current_metadata(),
|
|
71395
72057
|
})
|
|
71396
72058
|
error_emitted = True
|
|
72059
|
+
if codex_native_archive_rejection(exc, str(session_provider_id(sess) or "")):
|
|
72060
|
+
try:
|
|
72061
|
+
await archive_rejected_codex_session(
|
|
72062
|
+
session_id, str(session_provider_id(sess)), current_run_id,
|
|
72063
|
+
)
|
|
72064
|
+
except Exception:
|
|
72065
|
+
# Preserve the actual provider error if local retirement
|
|
72066
|
+
# fails. Never pretend the native thread was unarchived.
|
|
72067
|
+
logger.exception("Could not mirror native Codex archive session=%s", session_id)
|
|
71397
72068
|
finally:
|
|
71398
72069
|
async def settle_transport_finalizer() -> None:
|
|
71399
72070
|
"""Retire every owner before cancellation can escape the runner."""
|
|
@@ -71789,7 +72460,7 @@ async def start_turn(
|
|
|
71789
72460
|
scheduled_job_revision: str | None = None,
|
|
71790
72461
|
scheduled_job_manual_run: bool = False,
|
|
71791
72462
|
) -> dict[str, Any]:
|
|
71792
|
-
await wait_for_queue_recovery_admission()
|
|
72463
|
+
await wait_for_queue_recovery_admission(session_id)
|
|
71793
72464
|
if queue_if_busy and provider_context_mode == "chat":
|
|
71794
72465
|
await reconcile_idle_queue_session(
|
|
71795
72466
|
session_id,
|
|
@@ -71862,7 +72533,7 @@ async def _start_turn_locked(
|
|
|
71862
72533
|
# Internal delivery paths can enter with the lifecycle lock already held
|
|
71863
72534
|
# and intentionally bypass ``start_turn``. They still must not start or
|
|
71864
72535
|
# queue ahead of an undiscovered durable startup row.
|
|
71865
|
-
await wait_for_queue_recovery_admission()
|
|
72536
|
+
await wait_for_queue_recovery_admission(session_id)
|
|
71866
72537
|
if (
|
|
71867
72538
|
req.purpose == SECURE_PEER_DELIVERY_PURPOSE
|
|
71868
72539
|
and not SECURE_PEER_AGENT_RELAY_ENABLED
|
|
@@ -71884,6 +72555,8 @@ async def _start_turn_locked(
|
|
|
71884
72555
|
raise HTTPException(status_code=404, detail="session not found")
|
|
71885
72556
|
if sess.get("archived"):
|
|
71886
72557
|
raise HTTPException(status_code=409, detail="archived chats cannot start turns")
|
|
72558
|
+
if sess.get("gemini_cleanup_pending_run_id"):
|
|
72559
|
+
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
72560
|
if req.purpose == "chat_mailbox_wake" or mailbox_wake_claim is not None:
|
|
71888
72561
|
if (req.purpose != "chat_mailbox_wake" or mailbox_wake_claim is None
|
|
71889
72562
|
or mailbox_wake_claim.get("target_session_id") != session_id
|
|
@@ -72173,6 +72846,9 @@ async def _start_turn_locked(
|
|
|
72173
72846
|
),
|
|
72174
72847
|
runtime_validation_patch,
|
|
72175
72848
|
)
|
|
72849
|
+
if runtime_preview_session.get("backend") == BACKEND_GEMINI:
|
|
72850
|
+
if GEMINI_CLIENT_CAPABILITY not in req.client_capabilities:
|
|
72851
|
+
raise HTTPException(409, "Update AgentsDock to a Gemini-capable client before sending messages.")
|
|
72176
72852
|
if str(runtime_preview_session.get("backend") or DEFAULT_BACKEND).strip().lower() == BACKEND_CODEX:
|
|
72177
72853
|
await prepare_codex_login_turn(runtime_preview_session)
|
|
72178
72854
|
opencode_cwd: str | None = None
|
|
@@ -72219,6 +72895,10 @@ async def _start_turn_locked(
|
|
|
72219
72895
|
session_id,
|
|
72220
72896
|
list(item.get("file_ids") or []),
|
|
72221
72897
|
)
|
|
72898
|
+
if runtime_preview_session.get("backend") == BACKEND_GEMINI:
|
|
72899
|
+
from gemini_content import attachments as gemini_attachments
|
|
72900
|
+
await asyncio.to_thread(gemini_attachments, sys.modules[__name__], session_id,
|
|
72901
|
+
list(normalized_lineage[-1].get("file_ids") or []) if len(normalized_lineage) >= 2 else list(req.file_ids))
|
|
72222
72902
|
# OpenCode's native --file path has no provider-side size ceiling and can
|
|
72223
72903
|
# eagerly ingest local data. Resolve and bound the exact current message
|
|
72224
72904
|
# before either reserving a slot or durably accepting it into the queue.
|
|
@@ -72435,6 +73115,9 @@ async def _start_turn_locked(
|
|
|
72435
73115
|
if team_mail_grant_mutation:
|
|
72436
73116
|
team_mail_route_snapshot = team_mail_grants.admission_snapshot(team_mail_grant_mutation, sess)
|
|
72437
73117
|
fields_set = runtime_fields_set
|
|
73118
|
+
migration_previous_model = str(
|
|
73119
|
+
req.model if "model" in fields_set and req.model else sess.get("model") or ""
|
|
73120
|
+
).strip() or None
|
|
72438
73121
|
runtime_patch: dict[str, Any] = {}
|
|
72439
73122
|
if req.backend:
|
|
72440
73123
|
runtime_patch["backend"] = req.backend
|
|
@@ -72463,14 +73146,20 @@ async def _start_turn_locked(
|
|
|
72463
73146
|
|
|
72464
73147
|
backend = sess.get("backend") or DEFAULT_BACKEND
|
|
72465
73148
|
runtime_status = await ensure_runtime_available(backend, session=sess)
|
|
73149
|
+
if backend == BACKEND_CODEX:
|
|
73150
|
+
sess = await migrate_retired_codex_selection(
|
|
73151
|
+
session_id, sess, standalone=provider_context_mode == "standalone",
|
|
73152
|
+
previous_model=migration_previous_model,
|
|
73153
|
+
)
|
|
72466
73154
|
if provider_context_mode == "chat":
|
|
72467
73155
|
sess = await STORE.mark_backend_started(session_id, str(backend))
|
|
72468
|
-
if backend in (BACKEND_CURSOR, BACKEND_OPENCODE):
|
|
73156
|
+
if backend in (BACKEND_CURSOR, BACKEND_OPENCODE, BACKEND_GEMINI):
|
|
72469
73157
|
# Keep the exact compatibility-probed executable pinned through
|
|
72470
73158
|
# admission; mark_backend_started returns a fresh durable snapshot.
|
|
72471
73159
|
sess = dict(sess)
|
|
72472
73160
|
executable_key = (
|
|
72473
|
-
"
|
|
73161
|
+
"_gemini_executable" if backend == BACKEND_GEMINI
|
|
73162
|
+
else "_opencode_executable" if backend == BACKEND_OPENCODE
|
|
72474
73163
|
else "_cursor_executable"
|
|
72475
73164
|
)
|
|
72476
73165
|
sess[executable_key] = runtime_status.get("_executable")
|
|
@@ -72784,7 +73473,7 @@ async def _start_turn_locked(
|
|
|
72784
73473
|
else None
|
|
72785
73474
|
),
|
|
72786
73475
|
)
|
|
72787
|
-
if turn_obligation_ids and backend in (BACKEND_CURSOR, BACKEND_OPENCODE):
|
|
73476
|
+
if turn_obligation_ids and backend in (BACKEND_CURSOR, BACKEND_OPENCODE, BACKEND_GEMINI):
|
|
72788
73477
|
provider_authority_context += (
|
|
72789
73478
|
"\n\n[AgentsDock final-result handoff]\n"
|
|
72790
73479
|
"Your successful non-empty final answer will be delivered once to the explicitly referenced chat. "
|
|
@@ -72814,7 +73503,7 @@ async def _start_turn_locked(
|
|
|
72814
73503
|
prompt,
|
|
72815
73504
|
)
|
|
72816
73505
|
provider_prompt = provider_turn_payload.user_prompt
|
|
72817
|
-
if backend
|
|
73506
|
+
if backend in {BACKEND_CURSOR, BACKEND_GEMINI} or (
|
|
72818
73507
|
backend == BACKEND_OPENCODE and resolved_provider_command is None
|
|
72819
73508
|
):
|
|
72820
73509
|
# These print-mode CLIs have no separate system/application-context
|
|
@@ -72884,6 +73573,8 @@ async def _start_turn_locked(
|
|
|
72884
73573
|
else:
|
|
72885
73574
|
started_payload["model"] = sess.get("model")
|
|
72886
73575
|
started_payload["effort"] = sess.get("effort")
|
|
73576
|
+
if getattr(req, "client_request_id", None):
|
|
73577
|
+
started_payload["client_request_id"] = req.client_request_id
|
|
72887
73578
|
if queued_id:
|
|
72888
73579
|
started_payload["queued_id"] = queued_id
|
|
72889
73580
|
run_metadata = {
|
|
@@ -72963,6 +73654,7 @@ async def _start_turn_locked(
|
|
|
72963
73654
|
or mailbox_wake_claim is not None
|
|
72964
73655
|
or getattr(req, "shared_chat_id", None) is not None
|
|
72965
73656
|
or queued_id is not None
|
|
73657
|
+
or getattr(req, "client_request_id", None) is not None
|
|
72966
73658
|
)
|
|
72967
73659
|
else append_event(session_id, "turn_started", started_payload)
|
|
72968
73660
|
)
|
|
@@ -73136,6 +73828,21 @@ async def _start_turn_locked(
|
|
|
73136
73828
|
else {}
|
|
73137
73829
|
),
|
|
73138
73830
|
)
|
|
73831
|
+
elif backend == BACKEND_GEMINI:
|
|
73832
|
+
from gemini_runner import run as run_gemini
|
|
73833
|
+
task = run_gemini(sys.modules[__name__], session_id, run_id, provider_prompt,
|
|
73834
|
+
dict(sess), manifest_path,
|
|
73835
|
+
file_ids=prompt_file_ids,
|
|
73836
|
+
communication_required=bool(
|
|
73837
|
+
any(reference.action in {"route", "direct_message", "instruction", "request_reply"}
|
|
73838
|
+
for reference in req.chat_references)
|
|
73839
|
+
or req.team_references or team_mail_command
|
|
73840
|
+
or mailbox_wake_claim is not None
|
|
73841
|
+
or req.purpose in {"cross_chat_handoff_delivery", "secure_peer_handoff_delivery"}
|
|
73842
|
+
),
|
|
73843
|
+
provider_command=resolved_provider_command,
|
|
73844
|
+
provider_runtime_env=provider_turn_payload.runtime_env,
|
|
73845
|
+
standalone_provider_context=provider_context_mode == "standalone")
|
|
73139
73846
|
elif backend == BACKEND_OPENCODE:
|
|
73140
73847
|
task = run_opencode(
|
|
73141
73848
|
session_id,
|
|
@@ -76325,7 +77032,7 @@ def ensure_managed_update_service_cgroup_clear(
|
|
|
76325
77032
|
*,
|
|
76326
77033
|
service_cgroup: str | None = None,
|
|
76327
77034
|
) -> dict[str, Any]:
|
|
76328
|
-
"""
|
|
77035
|
+
"""Require no children, or prove this worker's stop policy preserves them."""
|
|
76329
77036
|
|
|
76330
77037
|
state = managed_update_service_cgroup_state(
|
|
76331
77038
|
service_cgroup=service_cgroup,
|
|
@@ -77426,6 +78133,7 @@ async def lifespan(app: FastAPI):
|
|
|
77426
78133
|
cleanup_abandoned_fork_provider_threads(startup_abandoned_fork_threads)
|
|
77427
78134
|
)
|
|
77428
78135
|
host_monitor_task = asyncio.create_task(host_monitor_loop())
|
|
78136
|
+
queue_checkpoint_task = asyncio.create_task(queue_checkpoint_loop())
|
|
77429
78137
|
history_search_task = asyncio.create_task(history_search_index_loop())
|
|
77430
78138
|
runtime_probe_task = asyncio.create_task(asyncio.to_thread(refresh_runtime_diagnostics, force=True))
|
|
77431
78139
|
if CONFIG_ENV_APPLIED:
|
|
@@ -77503,6 +78211,10 @@ async def lifespan(app: FastAPI):
|
|
|
77503
78211
|
cross_chat_expiry_task.cancel()
|
|
77504
78212
|
digest_recovery_task.cancel()
|
|
77505
78213
|
queue_recovery_task.cancel()
|
|
78214
|
+
queue_checkpoint_task.cancel()
|
|
78215
|
+
history_tasks = (*HISTORY_MAINTENANCE_TASKS.values(), *(item[1] for item in TURN_RECEIPT_TASKS.values()))
|
|
78216
|
+
for task in history_tasks:
|
|
78217
|
+
task.cancel()
|
|
77506
78218
|
server_update_pending_waiter_task.cancel()
|
|
77507
78219
|
await bounded_shutdown_phase(
|
|
77508
78220
|
"startup-reconciliation",
|
|
@@ -77511,6 +78223,8 @@ async def lifespan(app: FastAPI):
|
|
|
77511
78223
|
cross_chat_expiry_task,
|
|
77512
78224
|
digest_recovery_task,
|
|
77513
78225
|
queue_recovery_task,
|
|
78226
|
+
queue_checkpoint_task,
|
|
78227
|
+
*history_tasks,
|
|
77514
78228
|
server_update_pending_waiter_task,
|
|
77515
78229
|
*(
|
|
77516
78230
|
(health_queue_reconcile_task,)
|
|
@@ -78295,6 +79009,7 @@ async def require_agent_token(request: Request, call_next):
|
|
|
78295
79009
|
codex_goals_admin_route = request.url.path in {
|
|
78296
79010
|
"/api/admin/codex/goals", "/api/admin/codex/subagents",
|
|
78297
79011
|
"/api/admin/codex/auth", "/api/admin/codex/auth/api-key",
|
|
79012
|
+
"/api/admin/codex/auth/reload",
|
|
78298
79013
|
"/api/admin/codex/provider", "/api/admin/codex/provider/test",
|
|
78299
79014
|
"/api/admin/codex/provider/models",
|
|
78300
79015
|
"/api/admin/provider-connections/claude", "/api/admin/provider-connections/claude/check",
|
|
@@ -78483,7 +79198,7 @@ async def require_agent_token(request: Request, call_next):
|
|
|
78483
79198
|
if body_error is not None:
|
|
78484
79199
|
status_code, detail = body_error
|
|
78485
79200
|
return JSONResponse({"detail": detail}, status_code=status_code)
|
|
78486
|
-
elif request.url.path
|
|
79201
|
+
elif request.url.path in {"/api/admin/codex/auth/api-key", "/api/admin/codex/auth/reload"} and request.method.upper() == "POST":
|
|
78487
79202
|
declared_size, transport_error = privileged_native_json_transport(
|
|
78488
79203
|
request, max_body_bytes=codex_auth.MAX_BODY_BYTES,
|
|
78489
79204
|
label="Codex authentication", require_content_length=True,
|
|
@@ -80811,6 +81526,22 @@ async def codex_provider_mcp(request: Request) -> Response:
|
|
|
80811
81526
|
})
|
|
80812
81527
|
|
|
80813
81528
|
|
|
81529
|
+
@app.get("/api/connection-addresses")
|
|
81530
|
+
async def connection_addresses(request: Request) -> dict[str, Any]:
|
|
81531
|
+
require_native_admin_control(request)
|
|
81532
|
+
# Discover only on explicit QR sharing, never on the frequently polled health path.
|
|
81533
|
+
from server_instances import candidate_addresses, connection_choices, tailscale_status
|
|
81534
|
+
|
|
81535
|
+
def discover() -> list[str]:
|
|
81536
|
+
choices = connection_choices(
|
|
81537
|
+
SERVER_BIND_ADDRESS, SERVER_PORT,
|
|
81538
|
+
candidate_addresses(SERVER_BIND_ADDRESS, SERVER_PORT), tailscale_status(),
|
|
81539
|
+
)
|
|
81540
|
+
return list(dict.fromkeys(filter(None, [choices["tailscale"], *choices["lan"]])))[:6]
|
|
81541
|
+
|
|
81542
|
+
return {"version": 1, "server_identity": server_identity(), "urls": await asyncio.to_thread(discover)}
|
|
81543
|
+
|
|
81544
|
+
|
|
80814
81545
|
@app.get("/api/health")
|
|
80815
81546
|
async def health() -> dict[str, Any]:
|
|
80816
81547
|
await reconcile_idle_queued_turns_from_health_poll()
|
|
@@ -80829,6 +81560,7 @@ async def health() -> dict[str, Any]:
|
|
|
80829
81560
|
team_hub_capability = await team_hub_capability_for_health()
|
|
80830
81561
|
return {
|
|
80831
81562
|
"ok": True,
|
|
81563
|
+
"chat_readiness": queue_recovery_readiness(),
|
|
80832
81564
|
"server_version": SERVER_VERSION,
|
|
80833
81565
|
"api_contract_version": API_CONTRACT_VERSION,
|
|
80834
81566
|
"server_identity": server_identity(),
|
|
@@ -80850,7 +81582,7 @@ async def health() -> dict[str, Any]:
|
|
|
80850
81582
|
"side_questions": side_questions.capability(),
|
|
80851
81583
|
"codex_auth_v1": codex_auth.capability(available=bool(AGENT_TOKEN) and CODEX_TRANSPORT != CODEX_TRANSPORT_EXEC),
|
|
80852
81584
|
"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]},
|
|
81585
|
+
"provider_connections_v1": {"available": bool(AGENT_TOKEN), "per_chat": True, "backends": [BACKEND_CLAUDE, BACKEND_OPENCODE, BACKEND_CURSOR, BACKEND_GEMINI]},
|
|
80854
81586
|
"team_mail_hints_v1": SECURE_PEER_RUNTIME.team_mail_hint_capability(),
|
|
80855
81587
|
"team_mail_hints_v2": SECURE_PEER_RUNTIME.team_notification_hint_capability(),
|
|
80856
81588
|
"websocket_auth_v1": {
|
|
@@ -80865,6 +81597,7 @@ async def health() -> dict[str, Any]:
|
|
|
80865
81597
|
"event_protocol": EVENTS_WEBSOCKET_PROTOCOL,
|
|
80866
81598
|
"terminal_protocol": TERMINAL_WEBSOCKET_PROTOCOL,
|
|
80867
81599
|
},
|
|
81600
|
+
"event_stream_liveness_v1": {"available": True, "version": 1},
|
|
80868
81601
|
"team_hub_v1": team_hub_capability,
|
|
80869
81602
|
"team_hub_host_control_v1": team_hub_host_control_capability(),
|
|
80870
81603
|
"agent_team_mail_routes_v1": {
|
|
@@ -80971,16 +81704,19 @@ async def health() -> dict[str, Any]:
|
|
|
80971
81704
|
BACKEND_CODEX,
|
|
80972
81705
|
BACKEND_CLAUDE,
|
|
80973
81706
|
BACKEND_OPENCODE,
|
|
81707
|
+
BACKEND_GEMINI,
|
|
80974
81708
|
],
|
|
80975
81709
|
"client_capability_by_backend": {
|
|
80976
81710
|
BACKEND_CODEX: CODEX_INTERACTIVE_CLIENT_CAPABILITY,
|
|
80977
81711
|
BACKEND_CLAUDE: CLAUDE_SDK_INTERACTIVE_CLIENT_CAPABILITY,
|
|
80978
81712
|
BACKEND_OPENCODE: OPENCODE_PROVIDER_COMMANDS_CLIENT_CAPABILITY,
|
|
81713
|
+
BACKEND_GEMINI: GEMINI_CLIENT_CAPABILITY,
|
|
80979
81714
|
},
|
|
80980
81715
|
"supported_kinds_by_backend": {
|
|
80981
81716
|
BACKEND_CODEX: ["skill"],
|
|
80982
81717
|
BACKEND_CLAUDE: ["command"],
|
|
80983
81718
|
BACKEND_OPENCODE: ["skill"],
|
|
81719
|
+
BACKEND_GEMINI: ["skill"],
|
|
80984
81720
|
},
|
|
80985
81721
|
"opencode_scope": {
|
|
80986
81722
|
"discovery": "bounded_documented_default_skill_roots",
|
|
@@ -81051,6 +81787,25 @@ async def health() -> dict[str, Any]:
|
|
|
81051
81787
|
"max_client_frame_bytes": PORT_TUNNEL_MAX_CLIENT_FRAME_BYTES,
|
|
81052
81788
|
**port_tunnel_status,
|
|
81053
81789
|
},
|
|
81790
|
+
"gemini_backend": {
|
|
81791
|
+
# Server support is always advertised; CLI installation and
|
|
81792
|
+
# API connection readiness are checked separately at runtime.
|
|
81793
|
+
"available": True, "required": False, "version": 1, "transport": "acp",
|
|
81794
|
+
"permission_modes": ["default", "autoEdit", "yolo"],
|
|
81795
|
+
"default_permission_mode": "default",
|
|
81796
|
+
"message": "Gemini requires a compatible Gemini CLI and a configured API connection in My Agents.", "action": None,
|
|
81797
|
+
"client_capability": GEMINI_CLIENT_CAPABILITY,
|
|
81798
|
+
"history_mode": "initial_text_snapshot",
|
|
81799
|
+
"features": {"resume": True, "external_import": True, "fork": False,
|
|
81800
|
+
"native_import_api": True,
|
|
81801
|
+
"history_reconciliation": False,
|
|
81802
|
+
"approvals": True, "skills_picker": True, "custom_api": True,
|
|
81803
|
+
"attachments": True, "live_text": True, "token_usage": True,
|
|
81804
|
+
"scheduled_jobs": True, "interactive_shares": True,
|
|
81805
|
+
"native_goals": False, "native_side_questions": False,
|
|
81806
|
+
"managed_subagents": False, "native_compaction_control": False,
|
|
81807
|
+
"permission_mode_control": True},
|
|
81808
|
+
},
|
|
81054
81809
|
"opencode_backend": {
|
|
81055
81810
|
# ``available`` advertises the server contract. Runtime
|
|
81056
81811
|
# readiness is reported independently by
|
|
@@ -81206,6 +81961,7 @@ async def health() -> dict[str, Any]:
|
|
|
81206
81961
|
"max_active_alerts": EMERGENCY_ACTIVE_ALERT_LIMIT,
|
|
81207
81962
|
"stream_path": "/api/emergency-alerts/events",
|
|
81208
81963
|
"stream_protocol": EMERGENCY_WEBSOCKET_PROTOCOL,
|
|
81964
|
+
"acknowledge_through_alert_id": True,
|
|
81209
81965
|
},
|
|
81210
81966
|
"cross_chat_handoffs_v1": cross_chat_handoffs_capability(),
|
|
81211
81967
|
"codex_controls": {
|
|
@@ -81302,15 +82058,11 @@ def codex_goals_admin_status() -> dict[str, Any]:
|
|
|
81302
82058
|
enabled = bool(CODEX_GOALS_ENABLED)
|
|
81303
82059
|
return {
|
|
81304
82060
|
"enabled": enabled,
|
|
81305
|
-
"configurable":
|
|
82061
|
+
"configurable": False,
|
|
81306
82062
|
"default_enabled": bool(CODEX_GOALS_DEFAULT_ENABLED),
|
|
81307
82063
|
"scope": "server",
|
|
81308
82064
|
"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
|
-
),
|
|
82065
|
+
"message": "Persistent Codex goals are always available. Manage each goal in its chat.",
|
|
81314
82066
|
}
|
|
81315
82067
|
|
|
81316
82068
|
|
|
@@ -82134,36 +82886,14 @@ async def get_codex_goals_admin() -> dict[str, Any]:
|
|
|
82134
82886
|
async def put_codex_goals_admin(
|
|
82135
82887
|
req: CodexGoalsAdminRequest,
|
|
82136
82888
|
) -> 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}
|
|
82889
|
+
# Preserve the authenticated legacy route without allowing an older client
|
|
82890
|
+
# to disable goals for every chat or restart the shared provider manager.
|
|
82891
|
+
if not req.enabled:
|
|
82892
|
+
raise HTTPException(
|
|
82893
|
+
status_code=409,
|
|
82894
|
+
detail="Persistent Codex goals cannot be disabled server-wide. Pause individual goals in their chats.",
|
|
82895
|
+
)
|
|
82896
|
+
return codex_goals_admin_status()
|
|
82167
82897
|
|
|
82168
82898
|
|
|
82169
82899
|
def require_native_admin_control(request: Request) -> None:
|
|
@@ -82187,7 +82917,7 @@ def require_native_admin_control(request: Request) -> None:
|
|
|
82187
82917
|
async def codex_auth_operation(*, mutate: bool, existing_only: bool = False):
|
|
82188
82918
|
"""Fence native authentication changes with the existing Codex admission barrier."""
|
|
82189
82919
|
if not mutate:
|
|
82190
|
-
await refresh_codex_app_server_login()
|
|
82920
|
+
await refresh_codex_app_server_login(source="account_status")
|
|
82191
82921
|
async with CODEX_AUTH_LOCK:
|
|
82192
82922
|
reserved = False
|
|
82193
82923
|
try:
|
|
@@ -82224,10 +82954,20 @@ async def codex_auth_operation(*, mutate: bool, existing_only: bool = False):
|
|
|
82224
82954
|
await release_codex_goals_reconfiguration()
|
|
82225
82955
|
|
|
82226
82956
|
|
|
82957
|
+
async def reload_codex_login() -> bool:
|
|
82958
|
+
logger.info("Codex login reload requested source=manual_login_reload")
|
|
82959
|
+
if not await refresh_codex_app_server_login(request_handoff=True, source="manual_login_reload"):
|
|
82960
|
+
logger.info("Codex login reload deferred source=manual_login_reload reason=unavailable_or_busy")
|
|
82961
|
+
raise HTTPException(409, "Codex sign-in cannot be reloaded now. Its credential store is unsupported or maintenance is active.")
|
|
82962
|
+
return any(getattr(manager, "_agentsdock_login_superseded", False) is True
|
|
82963
|
+
for manager in codex_app_server_managers())
|
|
82964
|
+
|
|
82965
|
+
|
|
82227
82966
|
app.include_router(codex_auth.create_router(
|
|
82228
82967
|
authorize=require_native_admin_control,
|
|
82229
82968
|
operation=codex_auth_operation,
|
|
82230
82969
|
available=lambda: CODEX_TRANSPORT != CODEX_TRANSPORT_EXEC,
|
|
82970
|
+
reload_login=reload_codex_login,
|
|
82231
82971
|
))
|
|
82232
82972
|
|
|
82233
82973
|
|
|
@@ -82509,6 +83249,9 @@ app.include_router(provider_connections.create_router(
|
|
|
82509
83249
|
),
|
|
82510
83250
|
))
|
|
82511
83251
|
|
|
83252
|
+
from gemini_runner import router as gemini_router
|
|
83253
|
+
app.include_router(gemini_router(sys.modules[__name__]))
|
|
83254
|
+
|
|
82512
83255
|
app.include_router(codex_provider.create_router(
|
|
82513
83256
|
authorize=require_native_admin_control,
|
|
82514
83257
|
store=CODEX_PROVIDER_STORE,
|
|
@@ -82929,6 +83672,7 @@ async def submit_interactive_chat_prompt(
|
|
|
82929
83672
|
result = await post_turn(session_id, TurnRequest(
|
|
82930
83673
|
prompt=prompt, file_ids=file_ids, shared_chat_id=share_id,
|
|
82931
83674
|
shared_chat_request_id=request_id,
|
|
83675
|
+
client_capabilities=(["gemini_acp_v1"] if (STORE.sessions.get(session_id) or {}).get("backend") == "gemini" else []),
|
|
82932
83676
|
))
|
|
82933
83677
|
if not isinstance(result, dict) or type(result.get("queued")) is not bool:
|
|
82934
83678
|
raise HTTPException(503, "Message acceptance could not be confirmed")
|
|
@@ -83035,13 +83779,21 @@ async def interactive_chat_native_snapshot(session_id: str) -> dict[str, Any]:
|
|
|
83035
83779
|
projected[key] = strip_agentsdock_generated_user_text(projected[key], expected_session_id=session_id)
|
|
83036
83780
|
queue.append(projected)
|
|
83037
83781
|
model, effort = str(session.get("model") or ""), str(session.get("effort") or "")
|
|
83782
|
+
gemini_fields = {}
|
|
83783
|
+
if backend == "gemini":
|
|
83784
|
+
from gemini_runner import PERMISSIONS, owns
|
|
83785
|
+
gemini_fields["gemini_runtime"] = {"available": True, "pending_interactions": [
|
|
83786
|
+
shared_native_value(row["public"]) for row in PERMISSIONS.values()
|
|
83787
|
+
if row["public"]["session_id"] == session_id and owns(sys.modules[__name__], session_id, row["run_id"])]}
|
|
83038
83788
|
snapshot = shared_native_value({
|
|
83039
83789
|
"session": public, "events": page["events"], "queue": queue, "active": active,
|
|
83040
83790
|
"hasMoreEvents": page["has_more"], "nextTimelineBefore": page.get("next_before"),
|
|
83041
83791
|
"eventsTotal": page.get("semantic_total"), "goal": goal,
|
|
83042
83792
|
"jobs": (await list_session_jobs(session_id))["jobs"],
|
|
83043
83793
|
"codex_runtime": codex_runtime, "claude_runtime": claude_runtime,
|
|
83794
|
+
**gemini_fields,
|
|
83044
83795
|
"health": {"capabilities": {
|
|
83796
|
+
**({"gemini_backend": {"available": True, "version": 1}} if backend == "gemini" else {}),
|
|
83045
83797
|
"codex_controls": {"available": codex_available},
|
|
83046
83798
|
"claude_controls": {"available": claude_available,
|
|
83047
83799
|
"interactive_client_capability": CLAUDE_SDK_INTERACTIVE_CLIENT_CAPABILITY},
|
|
@@ -83072,6 +83824,7 @@ async def interactive_chat_native_snapshot(session_id: str) -> dict[str, Any]:
|
|
|
83072
83824
|
async def steer_interactive_chat_prompt(session_id: str, prompt: str, *, share_id: str, request_id: str) -> dict[str, Any]:
|
|
83073
83825
|
receipt = await post_turn(session_id, TurnRequest(
|
|
83074
83826
|
prompt=prompt, shared_chat_id=share_id, shared_chat_request_id=request_id,
|
|
83827
|
+
client_capabilities=(["gemini_acp_v1"] if (STORE.sessions.get(session_id) or {}).get("backend") == "gemini" else []),
|
|
83075
83828
|
))
|
|
83076
83829
|
if not isinstance(receipt, dict) or type(receipt.get("queued")) is not bool:
|
|
83077
83830
|
raise HTTPException(503, "Message acceptance could not be confirmed")
|
|
@@ -83168,6 +83921,9 @@ async def control_interactive_chat(session_id: str, action: str, payload: dict[s
|
|
|
83168
83921
|
INTERACTIVE_CHAT_CATALOG = (time.monotonic(), shared_native_value(await runtime_catalog()))
|
|
83169
83922
|
value = INTERACTIVE_CHAT_CATALOG[1]
|
|
83170
83923
|
return shared_native_value(value)
|
|
83924
|
+
async def shared_gemini_approval(sid, interaction_id, response):
|
|
83925
|
+
from gemini_runner import resolve_permission
|
|
83926
|
+
return await resolve_permission(sys.modules[__name__], sid, interaction_id, response)
|
|
83171
83927
|
callbacks = {
|
|
83172
83928
|
"turn.stop": stop_turn_endpoint, "turn.steer": steer_interactive_chat_prompt,
|
|
83173
83929
|
"queue.run_now": post_run_queued_turn_now, "queue.edit": patch_queued_turn,
|
|
@@ -83177,6 +83933,7 @@ async def control_interactive_chat(session_id: str, action: str, payload: dict[s
|
|
|
83177
83933
|
"job.create": create_session_job, "job.update": update_session_job,
|
|
83178
83934
|
"job.toggle": update_session_job, "job.delete": delete_session_job, "job.run": run_interactive_chat_job,
|
|
83179
83935
|
"approval.codex": post_codex_interaction_response, "approval.claude": post_claude_interaction_response,
|
|
83936
|
+
"approval.gemini": shared_gemini_approval,
|
|
83180
83937
|
}
|
|
83181
83938
|
models = {model.__name__: model for model in (
|
|
83182
83939
|
UpdateSessionRequest, CodexGoalRequest, CreateScopedJobRequest, UpdateJobRequest,
|
|
@@ -85787,7 +86544,7 @@ async def host_diagnostics(limit: int = 40) -> dict[str, Any]:
|
|
|
85787
86544
|
async def runtime_catalog(refresh: bool = False) -> dict[str, Any]:
|
|
85788
86545
|
if refresh:
|
|
85789
86546
|
await refresh_codex_app_server_binary(force=True)
|
|
85790
|
-
await refresh_codex_app_server_login()
|
|
86547
|
+
await refresh_codex_app_server_login(source="catalog_refresh")
|
|
85791
86548
|
return await asyncio.to_thread(discover_runtime_catalog, force_runtime_probe=refresh)
|
|
85792
86549
|
|
|
85793
86550
|
|
|
@@ -85908,12 +86665,13 @@ async def tail_session_process_log(session_id: str, path: str, lines: int = 200)
|
|
|
85908
86665
|
|
|
85909
86666
|
|
|
85910
86667
|
@app.get("/api/sessions")
|
|
85911
|
-
async def list_sessions(summary: bool = False) -> dict[str, Any]:
|
|
86668
|
+
async def list_sessions(summary: bool = False, placeholder_client_id: str = "") -> dict[str, Any]:
|
|
85912
86669
|
await STORE.ensure_sort_orders()
|
|
85913
86670
|
sessions = [
|
|
85914
86671
|
public_session(session, summary=summary)
|
|
85915
86672
|
for session in sorted_sessions(list(STORE.sessions.values()))
|
|
85916
86673
|
if not session.get("_fork_initializing")
|
|
86674
|
+
and not empty_chat_dismissed_for(session, placeholder_client_id)
|
|
85917
86675
|
]
|
|
85918
86676
|
return {"sessions": sessions}
|
|
85919
86677
|
|
|
@@ -85968,6 +86726,7 @@ async def create_session_with_history(req: CreateSessionRequest) -> dict[str, An
|
|
|
85968
86726
|
async def get_local_sessions(
|
|
85969
86727
|
limit: int = Query(default=200, ge=1, le=MAX_LOCAL_SESSION_LIST_ITEMS),
|
|
85970
86728
|
include_cursor: bool = False,
|
|
86729
|
+
include_gemini: bool = False,
|
|
85971
86730
|
) -> dict[str, Any]:
|
|
85972
86731
|
"""List main conversations unused by any installed same-user local instance."""
|
|
85973
86732
|
|
|
@@ -85978,6 +86737,8 @@ async def get_local_sessions(
|
|
|
85978
86737
|
for key in local_session_ownership.provider_session_keys(session, DEFAULT_BACKEND)
|
|
85979
86738
|
}
|
|
85980
86739
|
options = {"include_cursor": True} if include_cursor else {}
|
|
86740
|
+
if include_gemini:
|
|
86741
|
+
options["include_gemini"] = True
|
|
85981
86742
|
sessions = await asyncio.to_thread(local_session_candidates, limit, known_provider_keys, **options)
|
|
85982
86743
|
return {"sessions": sessions}
|
|
85983
86744
|
|
|
@@ -86051,6 +86812,7 @@ async def bulk_import_sessions_guarded(
|
|
|
86051
86812
|
MAX_LOCAL_SESSION_LIST_ITEMS,
|
|
86052
86813
|
set(),
|
|
86053
86814
|
**({"include_cursor": True} if any(item.backend == BACKEND_CURSOR for item in req.items) else {}),
|
|
86815
|
+
**({"include_gemini": True} if any(item.backend == BACKEND_GEMINI for item in req.items) else {}),
|
|
86054
86816
|
)
|
|
86055
86817
|
candidate_by_key = {
|
|
86056
86818
|
(str(candidate["backend"]), str(candidate["provider_session_id"])): candidate
|
|
@@ -86060,6 +86822,10 @@ async def bulk_import_sessions_guarded(
|
|
|
86060
86822
|
results: list[dict[str, Any]] = []
|
|
86061
86823
|
for item in req.items:
|
|
86062
86824
|
key = (item.backend, item.provider_session_id)
|
|
86825
|
+
if item.provider_connection and item.backend != BACKEND_GEMINI:
|
|
86826
|
+
results.append(bulk_import_result(item, ok=False, code="invalid_connection",
|
|
86827
|
+
error="API binding on import is only supported for Gemini."))
|
|
86828
|
+
continue
|
|
86063
86829
|
if key in requested_keys:
|
|
86064
86830
|
results.append(bulk_import_result(
|
|
86065
86831
|
item,
|
|
@@ -86100,7 +86866,11 @@ async def bulk_import_sessions_guarded(
|
|
|
86100
86866
|
|
|
86101
86867
|
staged_session: dict[str, Any] | None = None
|
|
86102
86868
|
try:
|
|
86103
|
-
if item.backend ==
|
|
86869
|
+
if item.backend == BACKEND_GEMINI:
|
|
86870
|
+
from gemini_import import initial_history
|
|
86871
|
+
source_path, history_items = await asyncio.to_thread(initial_history,
|
|
86872
|
+
sys.modules[__name__], runner_env(), item.provider_session_id, candidate_cwd or "")
|
|
86873
|
+
elif item.backend == BACKEND_CURSOR:
|
|
86104
86874
|
source_path, history_items = await asyncio.to_thread(
|
|
86105
86875
|
cursor_initial_history, item.provider_session_id, candidate_cwd or "",
|
|
86106
86876
|
)
|
|
@@ -86142,6 +86912,7 @@ async def bulk_import_sessions_guarded(
|
|
|
86142
86912
|
staged_session = await STORE.create(
|
|
86143
86913
|
CreateSessionRequest(
|
|
86144
86914
|
backend=item.backend,
|
|
86915
|
+
provider_connection=item.provider_connection,
|
|
86145
86916
|
provider_session_id=item.provider_session_id,
|
|
86146
86917
|
cwd=candidate_cwd or DEFAULT_CWD,
|
|
86147
86918
|
title=(item.title or str(candidate.get("label") or "")).strip()
|
|
@@ -86418,8 +87189,6 @@ async def get_session(
|
|
|
86418
87189
|
normalized_page_mode = str(page_mode or "").strip().lower()
|
|
86419
87190
|
if normalized_page_mode not in {"", "semantic"}:
|
|
86420
87191
|
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
87192
|
page_tail = tail and after <= 0
|
|
86424
87193
|
semantic_page: dict[str, Any] | None = None
|
|
86425
87194
|
if normalized_page_mode == "semantic":
|
|
@@ -86485,6 +87254,23 @@ async def get_session(
|
|
|
86485
87254
|
return response
|
|
86486
87255
|
|
|
86487
87256
|
|
|
87257
|
+
@app.post("/api/sessions/{session_id}/history/maintenance", status_code=202)
|
|
87258
|
+
async def retry_history_maintenance(session_id: str) -> dict[str, Any]:
|
|
87259
|
+
"""Explicit retry clears backoff, never provenance or lifecycle guards."""
|
|
87260
|
+
ensure_session_not_initializing(session_id)
|
|
87261
|
+
ensure_session_not_deleting(session_id)
|
|
87262
|
+
sess = STORE.sessions.get(session_id)
|
|
87263
|
+
if not sess:
|
|
87264
|
+
raise HTTPException(status_code=404, detail="session not found")
|
|
87265
|
+
if session_id in BUSY_SESSIONS or ACTIVE.get(session_id) is not None:
|
|
87266
|
+
raise HTTPException(status_code=409, detail="history maintenance waits for this chat to become idle")
|
|
87267
|
+
if session_id not in HISTORY_SYNC_SCHEDULED:
|
|
87268
|
+
history_maintenance_state(session_id).clear()
|
|
87269
|
+
schedule_provider_history_sync(sess)
|
|
87270
|
+
return {"scheduled": session_id in HISTORY_SYNC_SCHEDULED,
|
|
87271
|
+
"history_maintenance": dict(history_maintenance_state(session_id))}
|
|
87272
|
+
|
|
87273
|
+
|
|
86488
87274
|
@app.post("/api/sessions/{session_id}/import-history")
|
|
86489
87275
|
async def import_history(session_id: str, req: ImportHistoryRequest) -> dict[str, Any]:
|
|
86490
87276
|
async with session_lifecycle_lock(session_id):
|
|
@@ -86780,6 +87566,68 @@ async def ensure_backend_update_allowed(
|
|
|
86780
87566
|
|
|
86781
87567
|
@app.patch("/api/sessions/{session_id}")
|
|
86782
87568
|
async def update_session(session_id: str, req: UpdateSessionRequest) -> dict[str, Any]:
|
|
87569
|
+
if req.gemini_connect_api:
|
|
87570
|
+
if set(req.model_dump(exclude_unset=True)) != {"gemini_connect_api"}:
|
|
87571
|
+
raise HTTPException(400, "Connect the Gemini API separately from other chat edits.")
|
|
87572
|
+
return await connect_gemini_import_api(session_id)
|
|
87573
|
+
return await _update_session(session_id, req)
|
|
87574
|
+
|
|
87575
|
+
|
|
87576
|
+
async def connect_gemini_import_api(session_id: str) -> dict[str, Any]:
|
|
87577
|
+
"""Explicit one-way binding of a native import, not general account switching."""
|
|
87578
|
+
from gemini_import import initial_history
|
|
87579
|
+
from gemini_history import native_snapshot, snapshot_replay
|
|
87580
|
+
from gemini_acp_client import GeminiProtocolError
|
|
87581
|
+
ensure_session_not_initializing(session_id)
|
|
87582
|
+
async with session_lifecycle_lock(session_id):
|
|
87583
|
+
current = STORE.sessions.get(session_id)
|
|
87584
|
+
if not current:
|
|
87585
|
+
raise HTTPException(404, "session not found")
|
|
87586
|
+
if current.get("backend") != BACKEND_GEMINI or not session_provider_id(current):
|
|
87587
|
+
raise HTTPException(400, "Only an imported Gemini conversation can use this action.")
|
|
87588
|
+
if current.get("archived") or session_id in DELETING_SESSIONS:
|
|
87589
|
+
raise HTTPException(409, "Unarchive this chat before connecting its API.")
|
|
87590
|
+
if (session_id in BUSY_SESSIONS or session_id in ACTIVE or QUEUED_TURNS.get(session_id)
|
|
87591
|
+
or current.get("gemini_cleanup_pending_run_id")
|
|
87592
|
+
or any(not task.done() for task in SESSION_TURN_TASKS.get(session_id, ()))):
|
|
87593
|
+
raise HTTPException(409, "Stop active work and clear queued messages before connecting this API.")
|
|
87594
|
+
if current.get("provider_connection") == "custom":
|
|
87595
|
+
PROVIDER_CONNECTION_STORE.for_session(current) # No silent rebind on Forget.
|
|
87596
|
+
return {"session": public_session(current)}
|
|
87597
|
+
provider_id, cwd = session_provider_id(current), str(current.get("cwd") or "")
|
|
87598
|
+
def validate():
|
|
87599
|
+
initial_history(sys.modules[__name__], runner_env(), provider_id, cwd)
|
|
87600
|
+
snapshot_replay(native_snapshot(runner_env(), cwd, provider_id))
|
|
87601
|
+
try:
|
|
87602
|
+
await asyncio.to_thread(validate)
|
|
87603
|
+
except (GeminiProtocolError, OSError, ValueError):
|
|
87604
|
+
raise HTTPException(409, "The original Gemini history is unavailable or changed; refresh Import Chat.") from None
|
|
87605
|
+
async with STORE._lock:
|
|
87606
|
+
if (STORE.sessions.get(session_id) is not current or session_id in DELETING_SESSIONS
|
|
87607
|
+
or current.get("archived") or session_id in BUSY_SESSIONS or session_id in ACTIVE
|
|
87608
|
+
or QUEUED_TURNS.get(session_id) or current.get("gemini_cleanup_pending_run_id")
|
|
87609
|
+
or any(not task.done() for task in SESSION_TURN_TASKS.get(session_id, ()))):
|
|
87610
|
+
raise HTTPException(409, "This chat changed while connecting; wait for active work to finish and retry.")
|
|
87611
|
+
previous = dict(current)
|
|
87612
|
+
selected = PROVIDER_CONNECTION_STORE.bind({"backend": BACKEND_GEMINI, "provider_connection": "custom"})
|
|
87613
|
+
current.update(provider_connection="custom", provider_connection_revision=selected["credential_id"],
|
|
87614
|
+
gemini_native_history=True, gemini_session_cwd=cwd, model=selected.get("model"), updated_at=now_iso())
|
|
87615
|
+
try:
|
|
87616
|
+
await STORE.save(durable=True)
|
|
87617
|
+
except BaseException:
|
|
87618
|
+
current.clear(); current.update(previous)
|
|
87619
|
+
raise
|
|
87620
|
+
return {"session": public_session(current)}
|
|
87621
|
+
|
|
87622
|
+
|
|
87623
|
+
async def _update_session(
|
|
87624
|
+
session_id: str, req: UpdateSessionRequest, *,
|
|
87625
|
+
native_archive_owner: tuple[str, str] | None = None,
|
|
87626
|
+
) -> dict[str, Any]:
|
|
87627
|
+
# Internal provenance is not an HTTP parameter. Recheck after acquiring
|
|
87628
|
+
# the same lock as Stop, replacement and new-turn admission.
|
|
87629
|
+
if native_archive_owner is not None and req.archived is not True:
|
|
87630
|
+
raise ValueError("native archive ownership only applies to archive")
|
|
86783
87631
|
ensure_session_not_initializing(session_id)
|
|
86784
87632
|
patch = req.model_dump(exclude_unset=True)
|
|
86785
87633
|
if SESSION_LIFECYCLE_UPDATE_FIELDS.intersection(patch):
|
|
@@ -86787,6 +87635,12 @@ async def update_session(session_id: str, req: UpdateSessionRequest) -> dict[str
|
|
|
86787
87635
|
current = STORE.sessions.get(session_id)
|
|
86788
87636
|
if not current:
|
|
86789
87637
|
raise HTTPException(status_code=404, detail="session not found")
|
|
87638
|
+
if native_archive_owner is not None:
|
|
87639
|
+
thread_id, run_id = native_archive_owner
|
|
87640
|
+
if (session_id in DELETING_SESSIONS or current.get("backend") != BACKEND_CODEX
|
|
87641
|
+
or str(session_provider_id(current) or "") != thread_id
|
|
87642
|
+
or (ACTIVE.get(session_id) or {}).get("run_id") != run_id):
|
|
87643
|
+
return {"session": public_session(current)}
|
|
86790
87644
|
if req.archived is False and current.get("archived"):
|
|
86791
87645
|
try:
|
|
86792
87646
|
# An earlier archive may have failed to save jobs.json
|
|
@@ -88648,7 +89502,7 @@ async def _put_codex_goal_locked(
|
|
|
88648
89502
|
if req.status == "active":
|
|
88649
89503
|
if (STORE.sessions.get(session_id) or {}).get("archived"):
|
|
88650
89504
|
raise HTTPException(status_code=409, detail="archived chats cannot resume goals")
|
|
88651
|
-
await wait_for_queue_recovery_admission()
|
|
89505
|
+
await wait_for_queue_recovery_admission(session_id)
|
|
88652
89506
|
manager, thread_id, control_session = await acquire_codex_control_thread(
|
|
88653
89507
|
session_id,
|
|
88654
89508
|
reserve_session=req.status == "active",
|
|
@@ -89435,6 +90289,26 @@ async def acknowledge_session_emergency(
|
|
|
89435
90289
|
sess = STORE.sessions.get(session_id)
|
|
89436
90290
|
if not sess:
|
|
89437
90291
|
raise HTTPException(status_code=404, detail="session not found")
|
|
90292
|
+
if req.through_alert_id is not None:
|
|
90293
|
+
boundary, alert_ids = await asyncio.to_thread(
|
|
90294
|
+
emergency_alert_batch_through, session_id, req.through_alert_id,
|
|
90295
|
+
)
|
|
90296
|
+
if boundary is None:
|
|
90297
|
+
raise HTTPException(status_code=409, detail="that emergency alert boundary was not found")
|
|
90298
|
+
if alert_ids:
|
|
90299
|
+
events = await append_durable_event_batch(session_id, [
|
|
90300
|
+
("emergency_alert_acknowledged", {
|
|
90301
|
+
"emergency_alert_id": alert_id,
|
|
90302
|
+
"message": "Emergency acknowledged by the user.",
|
|
90303
|
+
})
|
|
90304
|
+
for alert_id in alert_ids
|
|
90305
|
+
])
|
|
90306
|
+
committed = events[-1]
|
|
90307
|
+
else:
|
|
90308
|
+
committed = boundary
|
|
90309
|
+
sess = await project_committed_emergency_event(session_id, committed)
|
|
90310
|
+
return {"session": public_session(sess), "acknowledged": bool(alert_ids),
|
|
90311
|
+
"acknowledged_count": len(alert_ids)}
|
|
89438
90312
|
alert_id = req.expected_alert_id
|
|
89439
90313
|
acknowledged_event = await asyncio.to_thread(
|
|
89440
90314
|
find_emergency_acknowledgement_event,
|
|
@@ -89520,6 +90394,74 @@ async def ensure_discardable_empty_session(session_id: str, expected_updated_at:
|
|
|
89520
90394
|
raise HTTPException(status_code=409, detail="Chat has content or resources; it was preserved.")
|
|
89521
90395
|
|
|
89522
90396
|
|
|
90397
|
+
def empty_chat_revision(session: dict[str, Any]) -> str:
|
|
90398
|
+
# Use durable content/settings, not public runtime projections, read cursors
|
|
90399
|
+
# or sort order. Reconnecting, reading or normalizing runtime metadata on
|
|
90400
|
+
# startup must not resurrect an unchanged blank row.
|
|
90401
|
+
revision = {key: session.get(key) for key in (
|
|
90402
|
+
"created_at", "updated_at", "title", "folder", "cwd", "backend",
|
|
90403
|
+
"model", "effort", "system_prompt", "_title_source",
|
|
90404
|
+
"codex_provider", "provider_connection", "parent_id",
|
|
90405
|
+
"codex_goal", "claude_goal",
|
|
90406
|
+
)}
|
|
90407
|
+
revision["latest_event_seq"] = int(session.get("latest_event_seq") or 0)
|
|
90408
|
+
for key in ("backend_locked", "pinned", "archived", "manual_unread"):
|
|
90409
|
+
revision[key] = bool(session.get(key))
|
|
90410
|
+
return hashlib.sha256(json.dumps(revision, sort_keys=True,
|
|
90411
|
+
separators=(",", ":")).encode()).hexdigest()
|
|
90412
|
+
|
|
90413
|
+
|
|
90414
|
+
def empty_chat_dismissed_for(session: dict[str, Any], client_id: str) -> bool:
|
|
90415
|
+
if not client_id or session_backend_locked(session):
|
|
90416
|
+
return False
|
|
90417
|
+
state = session.get("_empty_chat_cleanup") or {}
|
|
90418
|
+
return (isinstance(state, dict) and state.get("version") == 1
|
|
90419
|
+
and isinstance(state.get("dismissed_by"), dict)
|
|
90420
|
+
and state["dismissed_by"].get(client_id) == empty_chat_revision(session))
|
|
90421
|
+
|
|
90422
|
+
|
|
90423
|
+
@app.post("/api/sessions/{session_id}/dismiss-empty")
|
|
90424
|
+
async def dismiss_empty_session(session_id: str, expected_updated_at: str,
|
|
90425
|
+
placeholder_client_id: str) -> dict[str, Any]:
|
|
90426
|
+
"""Remove an unused row from one desktop's list without deleting its identity.
|
|
90427
|
+
|
|
90428
|
+
Another/offline desktop may own an unsent draft. Its list is unaffected;
|
|
90429
|
+
subsequent real changes also invalidate this desktop's dismissal. No draft
|
|
90430
|
+
content leaves a desktop, and no native history/process is touched.
|
|
90431
|
+
"""
|
|
90432
|
+
if not re.fullmatch(r"[a-zA-Z0-9_-]{16,80}", placeholder_client_id):
|
|
90433
|
+
raise HTTPException(400, "Invalid placeholder client identity")
|
|
90434
|
+
await wait_for_queue_recovery_admission()
|
|
90435
|
+
async with session_lifecycle_lock(session_id):
|
|
90436
|
+
ensure_session_not_initializing(session_id)
|
|
90437
|
+
if session_id in DELETING_SESSIONS or session_id in DELETED_SESSION_TOMBSTONES:
|
|
90438
|
+
raise HTTPException(409, "Chat deletion is already in progress")
|
|
90439
|
+
# Existing admission, uploads, jobs and terminal routes use this fence.
|
|
90440
|
+
DELETING_SESSIONS.add(session_id)
|
|
90441
|
+
try:
|
|
90442
|
+
await ensure_discardable_empty_session(session_id, expected_updated_at)
|
|
90443
|
+
async with STORE._lock:
|
|
90444
|
+
session = STORE.sessions[session_id]
|
|
90445
|
+
previous = session.get("_empty_chat_cleanup")
|
|
90446
|
+
state = previous if isinstance(previous, dict) else {}
|
|
90447
|
+
dismissed = dict(state.get("dismissed_by") or {})
|
|
90448
|
+
if placeholder_client_id not in dismissed and len(dismissed) >= 256:
|
|
90449
|
+
raise HTTPException(409, "Too many desktop cleanup identities; chat preserved")
|
|
90450
|
+
dismissed[placeholder_client_id] = empty_chat_revision(session)
|
|
90451
|
+
session["_empty_chat_cleanup"] = {"version": 1, "dismissed_by": dismissed}
|
|
90452
|
+
try:
|
|
90453
|
+
await STORE.save()
|
|
90454
|
+
except BaseException:
|
|
90455
|
+
if previous is None:
|
|
90456
|
+
session.pop("_empty_chat_cleanup", None)
|
|
90457
|
+
else:
|
|
90458
|
+
session["_empty_chat_cleanup"] = previous
|
|
90459
|
+
raise
|
|
90460
|
+
return {"ok": True, "dismissed": True}
|
|
90461
|
+
finally:
|
|
90462
|
+
DELETING_SESSIONS.discard(session_id)
|
|
90463
|
+
|
|
90464
|
+
|
|
89523
90465
|
@app.post("/api/sessions/{session_id}/discard-empty")
|
|
89524
90466
|
async def discard_empty_session(session_id: str, expected_updated_at: str) -> dict[str, Any]:
|
|
89525
90467
|
# A separate route makes old servers fail safely with 404, rather than
|
|
@@ -89532,6 +90474,13 @@ async def discard_empty_session(session_id: str, expected_updated_at: str) -> di
|
|
|
89532
90474
|
async def delete_session(session_id: str, discard_updated_at: str | None = None) -> dict[str, Any]:
|
|
89533
90475
|
async with session_lifecycle_lock(session_id):
|
|
89534
90476
|
ensure_session_not_initializing(session_id)
|
|
90477
|
+
pending_gemini_cleanup = (STORE.sessions.get(session_id) or {}).get("gemini_cleanup_pending_run_id")
|
|
90478
|
+
live_gemini_owner = ACTIVE.get(session_id) or {}
|
|
90479
|
+
if pending_gemini_cleanup and (
|
|
90480
|
+
live_gemini_owner.get("run_id") != pending_gemini_cleanup
|
|
90481
|
+
or not callable(live_gemini_owner.get("gemini_cleanup"))
|
|
90482
|
+
):
|
|
90483
|
+
raise HTTPException(409, "Gemini runtime cleanup requires server-side verification after restart; the chat and native history were not deleted.")
|
|
89535
90484
|
if session_id in DELETED_SESSION_TOMBSTONES:
|
|
89536
90485
|
# A previous delete can have committed before its request was
|
|
89537
90486
|
# canceled. Retry any tracked bridge retirement on idempotent
|
|
@@ -89699,6 +90648,16 @@ async def delete_session(session_id: str, discard_updated_at: str | None = None)
|
|
|
89699
90648
|
"shortly."
|
|
89700
90649
|
),
|
|
89701
90650
|
)
|
|
90651
|
+
elif active.get("backend") == BACKEND_GEMINI:
|
|
90652
|
+
cleanup = active.get("gemini_cleanup")
|
|
90653
|
+
try:
|
|
90654
|
+
if not callable(cleanup):
|
|
90655
|
+
raise RuntimeError("Missing Gemini cleanup owner")
|
|
90656
|
+
await cleanup()
|
|
90657
|
+
except Exception:
|
|
90658
|
+
raise HTTPException(status_code=409, detail=(
|
|
90659
|
+
"Gemini runtime cleanup could not be verified; the chat was not deleted. Retry Stop."
|
|
90660
|
+
)) from None
|
|
89702
90661
|
elif proc:
|
|
89703
90662
|
await terminate_process_tree(proc)
|
|
89704
90663
|
|
|
@@ -89797,6 +90756,17 @@ async def delete_session(session_id: str, discard_updated_at: str | None = None)
|
|
|
89797
90756
|
# initial session record but before its task acknowledges stop.
|
|
89798
90757
|
# The task registry is now drained, so this reread is stable.
|
|
89799
90758
|
late_session = STORE.sessions.get(session_id) or {}
|
|
90759
|
+
gemini_active = ACTIVE.get(session_id) or {}
|
|
90760
|
+
if gemini_active.get("backend") == BACKEND_GEMINI and gemini_active.get("gemini_cleanup_failed"):
|
|
90761
|
+
# A completed consumer task does not prove its owned runtime
|
|
90762
|
+
# was reaped. Retry only this chat's cleanup, before deletion.
|
|
90763
|
+
try:
|
|
90764
|
+
await gemini_active["gemini_cleanup"]()
|
|
90765
|
+
from gemini_runner import clear_cleanup_fence
|
|
90766
|
+
await clear_cleanup_fence(sys.modules[__name__], session_id, str(gemini_active.get("run_id") or ""))
|
|
90767
|
+
except Exception:
|
|
90768
|
+
raise HTTPException(409, "Gemini runtime cleanup is incomplete; the chat was not deleted. Retry after stopping its runtime.") from None
|
|
90769
|
+
gemini_active["gemini_cleanup_failed"] = False
|
|
89800
90770
|
late_provider_thread_id = str(
|
|
89801
90771
|
session_provider_id(late_session) or ""
|
|
89802
90772
|
)
|
|
@@ -90024,19 +90994,20 @@ def claude_imported_fork_boundary(
|
|
|
90024
90994
|
digest = hashlib.sha256()
|
|
90025
90995
|
last_message: dict[str, Any] = {}
|
|
90026
90996
|
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():
|
|
90997
|
+
original = os.fstat(stream.fileno())
|
|
90998
|
+
for record, offset, raw in jsonl_records(stream, end=remaining,
|
|
90999
|
+
max_records=MAX_LOCAL_TRANSCRIPT_SCAN_LINES):
|
|
91000
|
+
raw.update_hash(digest)
|
|
91001
|
+
remaining = int(cursor["source_offset"]) - offset
|
|
91002
|
+
if raw.blank:
|
|
90036
91003
|
continue
|
|
90037
|
-
record
|
|
90038
|
-
|
|
91004
|
+
if record is None:
|
|
91005
|
+
raise ValueError("imported history checkpoint has a malformed transcript record")
|
|
91006
|
+
if record.get("type") in {"user", "assistant"} and not record.get("isSidechain"):
|
|
90039
91007
|
last_message = record
|
|
91008
|
+
current = os.fstat(stream.fileno())
|
|
91009
|
+
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):
|
|
91010
|
+
raise ValueError("imported history checkpoint changed during reading")
|
|
90040
91011
|
if remaining or not hmac.compare_digest(digest.hexdigest(), cursor["source_digest"]):
|
|
90041
91012
|
raise ValueError("imported history checkpoint no longer matches its native prefix")
|
|
90042
91013
|
message = last_message.get("message")
|
|
@@ -90091,7 +91062,15 @@ def claude_completed_fork_boundary(
|
|
|
90091
91062
|
# final projected message can identify the inclusive native cutoff.
|
|
90092
91063
|
expected_text = projected_text if projected_text and final_text.endswith(projected_text) else final_text
|
|
90093
91064
|
recorded_uuid = str(final_projection.get("provider_message_id") or "") if expected_text == projected_text else ""
|
|
90094
|
-
|
|
91065
|
+
cutoff_ts = str(terminal.get("ts") or "")
|
|
91066
|
+
cutoff_time = parse_job_timestamp(cutoff_ts)
|
|
91067
|
+
# now_iso() truncates server events to whole seconds; native Claude
|
|
91068
|
+
# records retain milliseconds. Only an exact completed-message UUID can
|
|
91069
|
+
# disambiguate another turn in that same second. Keep legacy text-only
|
|
91070
|
+
# matching and explicitly fractional timestamps on the strict cutoff.
|
|
91071
|
+
cutoff_second_is_truncated = bool(recorded_uuid and re.fullmatch(
|
|
91072
|
+
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", cutoff_ts,
|
|
91073
|
+
))
|
|
90095
91074
|
started = next((
|
|
90096
91075
|
event for event in reversed(events[:-1])
|
|
90097
91076
|
if event.get("type") == "turn_started" and str(event.get("run_id") or "") == run_id
|
|
@@ -90112,7 +91091,11 @@ def claude_completed_fork_boundary(
|
|
|
90112
91091
|
if not record_uuid or (recorded_uuid and record_uuid != recorded_uuid):
|
|
90113
91092
|
continue
|
|
90114
91093
|
record_time = parse_job_timestamp(str(record.get("timestamp") or ""))
|
|
90115
|
-
if cutoff_time is None or record_time is None
|
|
91094
|
+
if cutoff_time is None or record_time is None:
|
|
91095
|
+
continue
|
|
91096
|
+
if record_time > cutoff_time and not (
|
|
91097
|
+
cutoff_second_is_truncated and record_time < cutoff_time + 1
|
|
91098
|
+
):
|
|
90116
91099
|
continue
|
|
90117
91100
|
if not recorded_uuid and (start_time is None or record_time < start_time):
|
|
90118
91101
|
continue
|
|
@@ -90145,6 +91128,8 @@ async def fork_session(session_id: str, req: ForkSessionRequest) -> dict[str, An
|
|
|
90145
91128
|
async with session_lifecycle_lock(session_id):
|
|
90146
91129
|
try:
|
|
90147
91130
|
ensure_session_not_deleting(session_id)
|
|
91131
|
+
if (STORE.sessions.get(session_id) or {}).get("backend") == BACKEND_GEMINI:
|
|
91132
|
+
raise HTTPException(409, "Gemini native fork is not available; create a new chat instead.")
|
|
90148
91133
|
if (STORE.sessions.get(session_id) or {}).get("backend") == BACKEND_OPENCODE:
|
|
90149
91134
|
raise HTTPException(status_code=409, detail="OpenCode native chat fork is unavailable; no transcript-copy fallback is enabled.")
|
|
90150
91135
|
async with ACTIVE_LOCK:
|
|
@@ -90557,8 +91542,123 @@ async def _fork_session_locked(
|
|
|
90557
91542
|
return {"session": public_session(child), "sessions": [public_session(sess) for sess in ordered_sessions]}
|
|
90558
91543
|
|
|
90559
91544
|
|
|
91545
|
+
TURN_RECEIPT_TASKS: dict[tuple[str, str], tuple[str, asyncio.Task]] = {}
|
|
91546
|
+
|
|
91547
|
+
|
|
91548
|
+
def turn_receipt_path(session_id: str, request_id: str) -> Path:
|
|
91549
|
+
return events_path(session_id).parent / "turn-receipts" / f"{request_id}.json"
|
|
91550
|
+
|
|
91551
|
+
|
|
91552
|
+
def write_turn_receipt(path: Path, value: dict) -> None:
|
|
91553
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
91554
|
+
temporary = path.with_suffix(".tmp")
|
|
91555
|
+
with temporary.open("w") as stream:
|
|
91556
|
+
json.dump(value, stream, separators=(",", ":"))
|
|
91557
|
+
stream.flush()
|
|
91558
|
+
os.fsync(stream.fileno())
|
|
91559
|
+
temporary.replace(path)
|
|
91560
|
+
descriptor = os.open(path.parent, os.O_RDONLY)
|
|
91561
|
+
try:
|
|
91562
|
+
os.fsync(descriptor)
|
|
91563
|
+
finally:
|
|
91564
|
+
os.close(descriptor)
|
|
91565
|
+
|
|
91566
|
+
|
|
91567
|
+
def recover_turn_receipt(session_id: str, value: dict) -> dict | None:
|
|
91568
|
+
path = events_path(session_id)
|
|
91569
|
+
stamp = history_file_stamp(path)
|
|
91570
|
+
before = value.get("journal")
|
|
91571
|
+
offset = before[2] if before else 0
|
|
91572
|
+
if stamp is None:
|
|
91573
|
+
if before is not None:
|
|
91574
|
+
raise ValueError("send receipt journal disappeared")
|
|
91575
|
+
return None
|
|
91576
|
+
if before and (stamp[:2] != tuple(before[:2]) or stamp[2] < offset):
|
|
91577
|
+
raise ValueError("send receipt journal was replaced")
|
|
91578
|
+
# An ambiguous old receipt must not cause an unbounded scan or a resend.
|
|
91579
|
+
if stamp[2] - offset > 32 * 1024 * 1024:
|
|
91580
|
+
raise ValueError("send receipt recovery requires a larger journal scan")
|
|
91581
|
+
with path.open("rb") as stream:
|
|
91582
|
+
stream.seek(offset)
|
|
91583
|
+
while stream.tell() < stamp[2]:
|
|
91584
|
+
line = stream.readline(stamp[2] - stream.tell())
|
|
91585
|
+
if not line.endswith(b"\n"):
|
|
91586
|
+
raise ValueError("send receipt journal has an incomplete record")
|
|
91587
|
+
if b'"client_request_id"' not in line:
|
|
91588
|
+
continue
|
|
91589
|
+
event = json.loads(line)
|
|
91590
|
+
if event.get("client_request_id") != value["request_id"]:
|
|
91591
|
+
continue
|
|
91592
|
+
if event.get("type") == "turn_started":
|
|
91593
|
+
return {"run_id": event["run_id"], "queued": False}
|
|
91594
|
+
if event.get("type") == "turn_queued":
|
|
91595
|
+
return {"queued_id": event["queued_id"], "queued": True,
|
|
91596
|
+
"position": event.get("position", 1)}
|
|
91597
|
+
return None
|
|
91598
|
+
|
|
91599
|
+
|
|
91600
|
+
async def execute_receipted_turn(session_id: str, req: TurnRequest, fingerprint: str) -> dict:
|
|
91601
|
+
path = turn_receipt_path(session_id, req.client_request_id)
|
|
91602
|
+
try:
|
|
91603
|
+
value = await asyncio.to_thread(lambda: json.loads(path.read_text()))
|
|
91604
|
+
except FileNotFoundError:
|
|
91605
|
+
value = {"request_id": req.client_request_id, "fingerprint": fingerprint,
|
|
91606
|
+
"journal": history_file_stamp(events_path(session_id)), "status": "pending"}
|
|
91607
|
+
await asyncio.to_thread(write_turn_receipt, path, value)
|
|
91608
|
+
else:
|
|
91609
|
+
if value.get("fingerprint") != fingerprint:
|
|
91610
|
+
raise HTTPException(status_code=409, detail="request ID was already used for a different message")
|
|
91611
|
+
if value.get("status") == "accepted":
|
|
91612
|
+
return {**value["response"], "session": public_session(STORE.sessions[session_id])}
|
|
91613
|
+
try:
|
|
91614
|
+
recovered = await asyncio.to_thread(recover_turn_receipt, session_id, value)
|
|
91615
|
+
except (ValueError, OSError) as exc:
|
|
91616
|
+
raise HTTPException(status_code=409, detail="message acceptance is uncertain; retain this request ID") from exc
|
|
91617
|
+
if recovered is not None:
|
|
91618
|
+
recovered["session"] = public_session(STORE.sessions[session_id])
|
|
91619
|
+
value.update(status="accepted", response=recovered)
|
|
91620
|
+
await asyncio.to_thread(write_turn_receipt, path, value)
|
|
91621
|
+
return recovered
|
|
91622
|
+
response = await post_turn_without_receipt(session_id, req)
|
|
91623
|
+
value.update(status="accepted", response=response)
|
|
91624
|
+
await asyncio.to_thread(write_turn_receipt, path, value)
|
|
91625
|
+
return response
|
|
91626
|
+
|
|
91627
|
+
|
|
91628
|
+
async def post_turn_with_receipt(session_id: str, req: TurnRequest) -> Any:
|
|
91629
|
+
if session_id not in STORE.sessions or session_id in DELETED_SESSION_TOMBSTONES:
|
|
91630
|
+
raise HTTPException(status_code=404, detail="session not found")
|
|
91631
|
+
fingerprint = hashlib.sha256(json.dumps(req.model_dump(mode="json"), sort_keys=True,
|
|
91632
|
+
separators=(",", ":")).encode()).hexdigest()
|
|
91633
|
+
key = (session_id, req.client_request_id)
|
|
91634
|
+
existing = TURN_RECEIPT_TASKS.get(key)
|
|
91635
|
+
if existing is not None and existing[0] != fingerprint:
|
|
91636
|
+
raise HTTPException(status_code=409, detail="request ID was already used for a different message")
|
|
91637
|
+
if existing is None:
|
|
91638
|
+
task = asyncio.create_task(execute_receipted_turn(session_id, req, fingerprint))
|
|
91639
|
+
TURN_RECEIPT_TASKS[key] = (fingerprint, task)
|
|
91640
|
+
def completed(done):
|
|
91641
|
+
TURN_RECEIPT_TASKS.pop(key, None)
|
|
91642
|
+
if not done.cancelled():
|
|
91643
|
+
done.exception()
|
|
91644
|
+
task.add_done_callback(completed)
|
|
91645
|
+
else:
|
|
91646
|
+
task = existing[1]
|
|
91647
|
+
try:
|
|
91648
|
+
return await asyncio.wait_for(asyncio.shield(task), timeout=10)
|
|
91649
|
+
except asyncio.TimeoutError:
|
|
91650
|
+
return JSONResponse(status_code=202, content={"request_id": req.client_request_id,
|
|
91651
|
+
"acceptance": "pending", "retry_same_request": True})
|
|
91652
|
+
|
|
91653
|
+
|
|
90560
91654
|
@app.post("/api/sessions/{session_id}/turns")
|
|
90561
|
-
async def post_turn(session_id: str, req: TurnRequest) ->
|
|
91655
|
+
async def post_turn(session_id: str, req: TurnRequest) -> Any:
|
|
91656
|
+
if req.client_request_id is not None:
|
|
91657
|
+
return await post_turn_with_receipt(session_id, req)
|
|
91658
|
+
return await post_turn_without_receipt(session_id, req)
|
|
91659
|
+
|
|
91660
|
+
|
|
91661
|
+
async def post_turn_without_receipt(session_id: str, req: TurnRequest) -> dict[str, Any]:
|
|
90562
91662
|
while True:
|
|
90563
91663
|
try:
|
|
90564
91664
|
return await start_turn(session_id, req)
|
|
@@ -94028,6 +95128,12 @@ async def stop_turn(
|
|
|
94028
95128
|
"superseded": True,
|
|
94029
95129
|
"message": "The requested run no longer owns this chat.",
|
|
94030
95130
|
}
|
|
95131
|
+
if ((STORE.sessions.get(session_id) or {}).get("gemini_cleanup_pending_run_id")
|
|
95132
|
+
and (not active or not callable(active.get("gemini_cleanup")))):
|
|
95133
|
+
if _admission_ready is not None:
|
|
95134
|
+
_admission_ready.set()
|
|
95135
|
+
return {"ok": False, "stopped": False, "pending": True,
|
|
95136
|
+
"message": "Gemini runtime cleanup requires server-side verification after restart; this chat remains fenced."}
|
|
94031
95137
|
if preserve_active_goal and codex_goal_followup_requires_native(
|
|
94032
95138
|
STORE.sessions.get(session_id) or {}, active or {}, current_turn,
|
|
94033
95139
|
):
|
|
@@ -94750,7 +95856,20 @@ async def stop_turn(
|
|
|
94750
95856
|
# The app-server process may serve multiple Codex chats and must never be
|
|
94751
95857
|
# terminated as a per-chat Stop action.
|
|
94752
95858
|
if proc and active.get("transport") != CODEX_TRANSPORT_APP_SERVER:
|
|
94753
|
-
|
|
95859
|
+
if active.get("backend") == BACKEND_GEMINI:
|
|
95860
|
+
# Gemini owns a verified-cleanup guard. Generic tree termination
|
|
95861
|
+
# can kill it before startup or during verification, destroying the
|
|
95862
|
+
# cleanup evidence and leaving the chat permanently fenced.
|
|
95863
|
+
cleanup = active.get("gemini_cleanup")
|
|
95864
|
+
try:
|
|
95865
|
+
if not callable(cleanup):
|
|
95866
|
+
raise RuntimeError("Missing Gemini cleanup owner")
|
|
95867
|
+
await cleanup()
|
|
95868
|
+
except Exception:
|
|
95869
|
+
return {"ok": False, "stopped": False, "pending": True,
|
|
95870
|
+
"message": "Gemini runtime cleanup is incomplete. Retry Stop; this chat remains fenced."}
|
|
95871
|
+
else:
|
|
95872
|
+
await terminate_process_tree(proc)
|
|
94754
95873
|
if transition_ready is not None:
|
|
94755
95874
|
try:
|
|
94756
95875
|
await asyncio.wait_for(
|
|
@@ -94818,6 +95937,18 @@ async def stop_turn(
|
|
|
94818
95937
|
and not owner_task.done()
|
|
94819
95938
|
):
|
|
94820
95939
|
dynamic_tasks.add(owner_task)
|
|
95940
|
+
if (current is active and active.get("backend") == BACKEND_GEMINI
|
|
95941
|
+
and active.get("gemini_cleanup_failed") and not dynamic_tasks):
|
|
95942
|
+
try:
|
|
95943
|
+
await active["gemini_cleanup"]()
|
|
95944
|
+
from gemini_runner import clear_cleanup_fence
|
|
95945
|
+
await clear_cleanup_fence(sys.modules[__name__], session_id, str(active.get("run_id") or ""))
|
|
95946
|
+
except Exception:
|
|
95947
|
+
return {"ok": False, "stopped": False, "pending": True,
|
|
95948
|
+
"message": "Gemini runtime cleanup is incomplete. Retry Stop; this chat remains fenced."}
|
|
95949
|
+
active["gemini_cleanup_failed"] = False
|
|
95950
|
+
await reconcile_provider_task_exit(session_id, str(active.get("run_id") or ""), BACKEND_GEMINI)
|
|
95951
|
+
return {"ok": True, "stopped": True, "pending": False}
|
|
94821
95952
|
if current is not active:
|
|
94822
95953
|
if not goal_pause_ok:
|
|
94823
95954
|
return await terminal_goal_pause_failure_result(native_interrupt)
|
|
@@ -95663,6 +96794,17 @@ async def session_terminal(
|
|
|
95663
96794
|
await ws.close()
|
|
95664
96795
|
|
|
95665
96796
|
|
|
96797
|
+
EVENT_STREAM_HEARTBEAT_SECONDS = 15.0
|
|
96798
|
+
|
|
96799
|
+
|
|
96800
|
+
async def send_event_stream_status(ws: WebSocket, session_id: str, phase: str) -> None:
|
|
96801
|
+
# Ephemeral, opt-in control frames: never persisted or assigned a seq.
|
|
96802
|
+
await asyncio.wait_for(ws.send_json({
|
|
96803
|
+
"type": "event_stream_status", "version": 1,
|
|
96804
|
+
"session_id": session_id, "phase": phase,
|
|
96805
|
+
}), timeout=WEBSOCKET_SEND_TIMEOUT_SECONDS)
|
|
96806
|
+
|
|
96807
|
+
|
|
95666
96808
|
@app.websocket("/api/sessions/{session_id}/events")
|
|
95667
96809
|
async def session_events(
|
|
95668
96810
|
session_id: str,
|
|
@@ -95671,6 +96813,8 @@ async def session_events(
|
|
|
95671
96813
|
visible: bool | None = None,
|
|
95672
96814
|
reasoning_stream: bool = False,
|
|
95673
96815
|
reasoning_text: bool = False,
|
|
96816
|
+
liveness: bool = False,
|
|
96817
|
+
history_projection: bool = False,
|
|
95674
96818
|
) -> None:
|
|
95675
96819
|
selected_subprotocol = websocket_endpoint_subprotocol(
|
|
95676
96820
|
ws,
|
|
@@ -95685,6 +96829,7 @@ async def session_events(
|
|
|
95685
96829
|
await ws.close(code=4404)
|
|
95686
96830
|
return
|
|
95687
96831
|
await ws.accept(subprotocol=selected_subprotocol)
|
|
96832
|
+
schedule_provider_history_sync(STORE.sessions[session_id])
|
|
95688
96833
|
try:
|
|
95689
96834
|
if not await HUB.reserve(session_id, ws):
|
|
95690
96835
|
await close_event_websocket_over_capacity(ws)
|
|
@@ -95700,7 +96845,6 @@ async def session_events(
|
|
|
95700
96845
|
# is ready. Reading its fsynced rows earlier can replay raw scheduled
|
|
95701
96846
|
# inputs before this socket is registered for the repaired broadcast.
|
|
95702
96847
|
async with event_delivery_lock(session_id):
|
|
95703
|
-
await asyncio.to_thread(prepare_provider_history_metadata_repair, session_id)
|
|
95704
96848
|
boundary = await asyncio.to_thread(
|
|
95705
96849
|
last_event_seq_from_file,
|
|
95706
96850
|
events_path(session_id),
|
|
@@ -95751,12 +96895,28 @@ async def session_events(
|
|
|
95751
96895
|
return
|
|
95752
96896
|
if reasoning_snapshot is not None:
|
|
95753
96897
|
await asyncio.wait_for(ws.send_json(reasoning_snapshot), timeout=WEBSOCKET_SEND_TIMEOUT_SECONDS)
|
|
96898
|
+
if liveness:
|
|
96899
|
+
await send_event_stream_status(ws, session_id, "ready")
|
|
96900
|
+
if history_projection:
|
|
96901
|
+
# A proof may finish between the HTTP page and subscription.
|
|
96902
|
+
# The revision closes that gap without replaying old rows.
|
|
96903
|
+
await asyncio.wait_for(ws.send_json(history_projection_notice(session_id)), timeout=WEBSOCKET_SEND_TIMEOUT_SECONDS)
|
|
95754
96904
|
break
|
|
95755
96905
|
boundary = gap_boundary
|
|
95756
96906
|
while True:
|
|
95757
|
-
|
|
96907
|
+
if not liveness:
|
|
96908
|
+
await ws.receive_text()
|
|
96909
|
+
continue
|
|
96910
|
+
try:
|
|
96911
|
+
await asyncio.wait_for(ws.receive_text(), timeout=EVENT_STREAM_HEARTBEAT_SECONDS)
|
|
96912
|
+
except asyncio.TimeoutError:
|
|
96913
|
+
# No history scan, provider call, or sequence acknowledgement.
|
|
96914
|
+
# This confirms that the application event path is still alive.
|
|
96915
|
+
await send_event_stream_status(ws, session_id, "heartbeat")
|
|
95758
96916
|
except WebSocketDisconnect:
|
|
95759
96917
|
pass
|
|
96918
|
+
except (asyncio.TimeoutError, ConnectionError):
|
|
96919
|
+
await close_event_websocket_after_delivery_failure(ws)
|
|
95760
96920
|
finally:
|
|
95761
96921
|
await HUB.unsubscribe(session_id, ws)
|
|
95762
96922
|
|